Skip to content
117 changes: 88 additions & 29 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ import {
globalSettingsPath,
loadLocalSettingsResult,
type SettingsLoadDiagnostic,
loadSettings,
localSettingsPath,
loadSettingsRecoveringClobberedOAuthSelection,
resolveLocalSettingsPath,
normalizeOpenAICompatibleBaseURL,
resolveProvider,
type MCPServerSettingsEntry,
Expand All @@ -80,6 +80,51 @@ export const SOURCE_MAX_TOKENS = 16384;
// keyless servers ignore it entirely.
export const KEYLESS_API_KEY = "keyless";

function applyPersistedOAuthDefaults(
settings: Settings | null,
projected: Record<string, ProviderSettings>,
): Record<string, ProviderSettings> {
const merged: Record<string, ProviderSettings> = {};
for (const [name, provider] of Object.entries(projected)) {
const defaultModel = settings?.providers[name]?.defaultModel;
merged[name] =
defaultModel !== undefined && defaultModel.length > 0
? {
...provider,
models: provider.models.includes(defaultModel)
? provider.models
: [defaultModel, ...provider.models],
defaultModel,
}
: provider;
}
return merged;
}

// OAuth entries in settings.json carry no credentials; they are only usable
// while a matching auth-store profile exists. Drop orphans in memory so a
// removed profile does not pin resolution to an unauthenticatable provider.
function dropOrphanedOAuthEntries(
settings: Settings | null,
projected: Record<string, ProviderSettings>,
): Settings | null {
if (settings === null) return null;
const providers = Object.fromEntries(
Object.entries(settings.providers).filter(
([name]) =>
(!isCodexProviderName(name) && !isXaiProviderName(name)) || projected[name] !== undefined,
),
);
const { defaultProvider, ...rest } = settings;
return {
...rest,
providers,
...(defaultProvider !== undefined && providers[defaultProvider] !== undefined
? { defaultProvider }
: {}),
};
}

function hasExaEntry(servers: MCPServerSettingsEntry[] | undefined): boolean {
return servers?.some((server) => server.name === EXA_MCP_SERVER_NAME) === true;
}
Expand Down Expand Up @@ -655,21 +700,10 @@ export async function loadConfig(

await bootstrapPricingMetadata({ cachePath: pricingCachePath, ...options.pricing });

const settings =
configPath !== undefined
? await loadSettings(configPath).then((s) => {
if (s === null) throw new Error(`--config file not found or empty: ${configPath}`);
return s;
})
: await loadSettings(options.globalSettingsPath ?? globalSettingsPath());

// Track whether the effective value came from the persisted global default
// rather than this invocation's --dangerously-skip-permissions flag, so the
// TUI/exec entry points can surface a startup notice for the silent case.
const skipPermissionsFromSettings =
!dangerouslySkipPermissions && settings?.dangerouslySkipPermissions === true;
dangerouslySkipPermissions =
dangerouslySkipPermissions || settings?.dangerouslySkipPermissions === true;
// Resolve both settings targets from the same effective global path. The
// local schema must never be read from or written to that global target.
const effectiveSettingsPath = configPath ?? options.globalSettingsPath ?? globalSettingsPath();
const localSettingsFile = resolveLocalSettingsPath(cwd, effectiveSettingsPath);

// OAuth profiles live in home-level auth stores (~/.corbits/codex-auth.json,
// xai-auth.json), entirely separate from settings.json. --config only
Expand All @@ -683,22 +717,51 @@ export async function loadConfig(
const [codexProfiles, xaiProfiles]: [CodexProfile[], XaiProfile[]] = useOAuthProfiles
? await Promise.all([listCodexProfiles(), listXaiProfiles()])
: [[], []];
const codexProviderSettings = codexProvidersAsSettings(codexProfiles);
const xaiProviderSettings = xaiProvidersAsSettings(xaiProfiles);
const oauthProviderSettings = { ...codexProviderSettings, ...xaiProviderSettings };
let projectedOAuthProviders = {
...codexProvidersAsSettings(codexProfiles),
...xaiProvidersAsSettings(xaiProfiles),
};
const settings =
configPath !== undefined
? await loadSettingsRecoveringClobberedOAuthSelection(configPath, projectedOAuthProviders, {
persist: false,
}).then((s) => {
if (s === null) throw new Error(`--config file not found or empty: ${configPath}`);
return s;
})
: await loadSettingsRecoveringClobberedOAuthSelection(
effectiveSettingsPath,
projectedOAuthProviders,
{ persist: true },
);

// Track whether the effective value came from the persisted global default
// rather than this invocation's --dangerously-skip-permissions flag, so the
// TUI/exec entry points can surface a startup notice for the silent case.
const skipPermissionsFromSettings =
!dangerouslySkipPermissions && settings?.dangerouslySkipPermissions === true;
dangerouslySkipPermissions =
dangerouslySkipPermissions || settings?.dangerouslySkipPermissions === true;
projectedOAuthProviders = applyPersistedOAuthDefaults(settings, projectedOAuthProviders);
const liveSettings = useOAuthProfiles
? dropOrphanedOAuthEntries(settings, projectedOAuthProviders)
: settings;
const settingsForResolution: Settings | null =
Object.keys(oauthProviderSettings).length > 0
Object.keys(projectedOAuthProviders).length > 0
? {
...(settings ?? { providers: {} }),
providers: { ...(settings?.providers ?? {}), ...oauthProviderSettings },
...(liveSettings ?? { providers: {} }),
providers: { ...(liveSettings?.providers ?? {}), ...projectedOAuthProviders },
}
: settings;
: liveSettings;

// The per-repo selection file still applies on top of a --config source: that
// file supplies provider definitions, while .corbits/settings.json supplies
// the provider/model selection. CLI --provider/--model override both.
// Fail open on unknown/invalid local keys — never crash startup.
const localResult = await loadLocalSettingsResult(localSettingsPath(cwd));
const localResult =
localSettingsFile === null
? { settings: null, diagnostics: [] }
: await loadLocalSettingsResult(localSettingsFile);
const local = localResult.settings;
const settingsDiagnostics = localResult.diagnostics;

Expand All @@ -717,10 +780,6 @@ export async function loadConfig(
if (provider !== undefined) cli.provider = provider;
if (model !== undefined) cli.model = model;

// When --config is given, onboarding must write to and reload from that
// same file, not the global default. Prefer configPath, then the caller
// override, then the real global default.
const effectiveSettingsPath = configPath ?? options.globalSettingsPath ?? globalSettingsPath();
const task = positional.join(" ").trim();

let resolved: ResolvedProvider;
Expand Down
150 changes: 133 additions & 17 deletions src/config/settings.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { randomUUID } from "node:crypto";
import { realpathSync } from "node:fs";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { basename, dirname, join, resolve } from "node:path";

import { type } from "arktype";

Expand Down Expand Up @@ -212,20 +213,44 @@ export function toggleFavoriteModel(settings: Settings, ref: ModelRef): Settings
};
}

export function setDefaultModel(settings: Settings, ref: ModelRef): Settings {
function providerSelectionMetadata(provider: ProviderSettings, model: string): ProviderSettings {
return {
...(provider.name !== undefined ? { name: provider.name } : {}),
baseURL: provider.baseURL,
models: [model],
defaultModel: model,
...(provider.keyless === true ? { keyless: true } : {}),
...(provider.free === true ? { free: true } : {}),
...(provider.contextWindow !== undefined ? { contextWindow: provider.contextWindow } : {}),
...(provider.bifrostVirtualKey === true ? { bifrostVirtualKey: true } : {}),
...(provider.anthropic === true ? { anthropic: true } : {}),
...(provider.opencodeGo === true ? { opencodeGo: true } : {}),
...(provider.verified !== undefined ? { verified: provider.verified } : {}),
};
}

export function setDefaultModel(
settings: Settings,
ref: ModelRef,
projectedProvider?: ProviderSettings,
): Settings {
const next: ModelRef = { provider: ref.provider, model: ref.model };
const existing = settings.providers[next.provider];
const provider = existing ?? projectedProvider;
if (provider === undefined) {
return { ...settings, defaultProvider: next.provider };
}
const persistedProvider =
existing !== undefined
? { ...existing, defaultModel: next.model }
: providerSelectionMetadata(provider, next.model);
return {
...settings,
defaultProvider: next.provider,
...(existing !== undefined
? {
providers: {
...settings.providers,
[next.provider]: { ...existing, defaultModel: next.model },
},
}
: {}),
providers: {
...settings.providers,
[next.provider]: persistedProvider,
},
};
}

Expand Down Expand Up @@ -372,6 +397,40 @@ export function localSettingsPath(cwd: string): string {
return join(cwd, SETTINGS_DIR_NAME, "settings.json");
}

function physicalPathIdentity(path: string): string {
let candidate = resolve(path);
const missingSegments: string[] = [];

while (true) {
try {
return join(realpathSync.native(candidate), ...missingSegments.reverse());
} catch (err) {
// Anything but a missing segment (ENOTDIR, EACCES, ...) is not aliasable;
// fall back to the lexical path so the fail-open loader sees it.
if (!isENOENT(err)) return resolve(path);
const parent = dirname(candidate);
if (parent === candidate) return resolve(path);
missingSegments.push(basename(candidate));
candidate = parent;
}
}
}

export function resolveLocalSettingsPath(cwd: string, globalPath: string): string | null {
const localPath = localSettingsPath(cwd);
return physicalPathIdentity(localPath) === physicalPathIdentity(globalPath) ? null : localPath;
}

// True when `settingsPath` is a distinct settings file from the default home
// path. Symlink and lexical aliases of the default path are not overrides —
// treating them as such would suppress OAuth profile projection after setup.
export function isProgrammaticSettingsOverride(
settingsPath: string,
defaultGlobalPath: string = globalSettingsPath(),
): boolean {
return physicalPathIdentity(settingsPath) !== physicalPathIdentity(defaultGlobalPath);
}

function isENOENT(err: unknown): boolean {
return (
typeof err === "object" &&
Expand Down Expand Up @@ -688,24 +747,56 @@ export function healOpenCodeGoProviders(settings: Settings): string[] {
return healed;
}

export async function loadSettings(path: string): Promise<Settings | null> {
const ClobberedLocalSelectionSchema = type({
provider: "string>0",
model: "string>0",
"+": "reject",
});

function isClobberedLocalSelection(value: unknown): value is { provider: string; model: string } {
return ClobberedLocalSelectionSchema.allows(value);
}

function recoverClobberedOAuthSelection(
selection: { provider: string; model: string },
projected: Record<string, ProviderSettings>,
): Settings | undefined {
const provider = projected[selection.provider];
// Auth-profile presence is enough: the selected model may be outside the
// projected fallback catalog (CODEX_DEFAULT_MODELS / xAI equivalents).
if (provider === undefined) return undefined;
return {
defaultProvider: selection.provider,
providers: {
[selection.provider]: providerSelectionMetadata(provider, selection.model),
},
};
}

async function loadSettingsJSON(path: string): Promise<unknown | null> {
let raw: string;
try {
raw = await readFile(path, "utf8");
} catch (err) {
if (isENOENT(err)) return null;
throw err;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
return JSON.parse(raw);
} catch {
throw new Error(`Invalid JSON in settings file: ${path}`);
}
}

function settingsSchemaError(path: string): Error {
return new Error(
`Invalid settings schema in ${path}: expected { providers: { <name>: { baseURL, apiKey, models: [...] } } }`,
);
}

function normalizeParsedSettings(path: string, parsed: unknown): Settings {
if (!isSettings(parsed)) {
throw new Error(
`Invalid settings schema in ${path}: expected { providers: { <name>: { baseURL, apiKey, models: [...] } } }`,
);
throw settingsSchemaError(path);
}
const s = parsed as unknown as Record<string, unknown>;
// These keys were removed when plugins moved to discovery; they are now
Expand Down Expand Up @@ -759,10 +850,14 @@ export async function loadSettings(path: string): Promise<Settings | null> {
? Boolean(s.dangerouslySkipPermissions)
: undefined,
};
const settings: Settings = {
return {
providers: s.providers as Settings["providers"],
...pickDefined(optional),
};
}

async function loadStrictSettings(path: string, parsed: unknown): Promise<Settings> {
const settings = normalizeParsedSettings(path, parsed);
// Hard cutover: pin Go flag + canonical baseURL on disk when any Go signal matches.
// Only rewrite disk when heal actually mutates (no write-on-read for no-op reloads).
// Fail open on save: keep the in-memory heal so startup is not bricked by a
Expand All @@ -783,6 +878,27 @@ export async function loadSettings(path: string): Promise<Settings | null> {
return settings;
}

export async function loadSettings(path: string): Promise<Settings | null> {
const parsed = await loadSettingsJSON(path);
return parsed === null ? null : await loadStrictSettings(path, parsed);
}

export async function loadSettingsRecoveringClobberedOAuthSelection(
path: string,
recoverableOAuthProviders: Record<string, ProviderSettings>,
options: { persist: boolean },
): Promise<Settings | null> {
const parsed = await loadSettingsJSON(path);
if (parsed === null) return null;
if (isClobberedLocalSelection(parsed)) {
const recovered = recoverClobberedOAuthSelection(parsed, recoverableOAuthProviders);
if (recovered === undefined) throw settingsSchemaError(path);
if (options.persist) await saveGlobalSettings(path, recovered);
return recovered;
}
return loadStrictSettings(path, parsed);
}

/** Diagnostic produced when settings fail open instead of crashing startup. */
export interface SettingsLoadDiagnostic {
path: string;
Expand Down
Loading
Loading