diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index f4247281300..e23ce8a3ce6 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -24,6 +24,7 @@ "../src/main/codex/codex-app-server-grant-bridge.ts", "../src/main/codex/codex-app-server-grant-envelope.ts", "../src/main/codex/codex-app-server-session.ts", + "../src/main/codex/codex-config-auth-store.ts", "../src/main/codex/codex-config-mirror.ts", "../src/main/codex/codex-config-path-reference-rewrite.ts", "../src/main/codex/codex-config-settings-preservation.ts", @@ -41,6 +42,8 @@ "../src/main/codex/codex-trust-grant-ledger.ts", "../src/main/codex/codex-user-hook-trust-rebase-client.ts", "../src/main/codex/codex-user-hook-trust-rebase.ts", + "../src/main/codex/codex-profile-config-overlay-active-publish.ts", + "../src/main/codex/codex-profile-config-overlay-mirror.ts", "../src/main/codex/codex-wsl-hook-install-plan.ts", "../src/main/codex/config-settings-baseline.ts", "../src/main/codex/config-settings-conflict-resolution.ts", diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 693ac8db7cf..51e88c00a55 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -1350,7 +1350,9 @@ describe('CodexRuntimeHomeService', () => { const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml') writeFileSync(wslSystemConfigPath, 'model = "outside-edit"\n', 'utf-8') service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "outside-edit"\n') + expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe( + 'cli_auth_credentials_store = "file"\nmodel = "outside-edit"\n' + ) expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"outside-edit\\""') // Codex now persists a /model change inside Orca's reconciled runtime. @@ -3616,7 +3618,7 @@ describe('CodexRuntimeHomeService', () => { service.prepareForCodexLaunch() expect(readFileSync(join(getRuntimeCodexHomePath(), 'config.toml'), 'utf-8')).toBe( - 'model = "second"\n' + 'cli_auth_credentials_store = "file"\nmodel = "second"\n' ) }) @@ -3638,7 +3640,7 @@ describe('CodexRuntimeHomeService', () => { expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) expect(readFileSync(join(getRuntimeCodexHomePath(), 'config.toml'), 'utf-8')).toBe( - canonicalConfig + `cli_auth_credentials_store = "file"\n${canonicalConfig}` ) expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) expect(readFileSync(canonicalConfigPath, 'utf-8')).toBe(canonicalConfig) diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 175a42e6dbd..494fd6cd3b4 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -20,6 +20,7 @@ import { buildCodexResetCreditExpectedScope } from '../../shared/codex-reset-cre import type { CodexResetCreditAttemptLedger } from '../../shared/codex-reset-credit-attempt-ledger' import { buildWslCodexAvailabilityArgs, buildWslCodexLoginArgs } from './wsl-codex-command' import type { readHookTrustEntries as ReadHookTrustEntries } from '../codex/config-toml-trust' +import { forceFileAuthCredentialsStore } from '../codex/codex-config-auth-store' const testState = { userDataDir: '', @@ -414,12 +415,55 @@ describe('CodexAccountService config sync', () => { const { CodexAccountService } = await import('./service') new CodexAccountService(store as never, rateLimits as never, runtimeHome as never) - expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe(canonicalConfig) + expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe( + forceFileAuthCredentialsStore(canonicalConfig) + ) expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe( '{"account":"managed"}\n' ) }) + it('overrides a canonical keyring preference in managed homes', async () => { + const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') + writeFileSync( + canonicalConfigPath, + 'approval_policy = "never"\ncli_auth_credentials_store = "keyring"\n', + 'utf-8' + ) + const managedHomePath = createManagedHome( + testState.userDataDir, + 'account-1', + 'approval_policy = "on-request"\n', + '{"account":"managed"}\n' + ) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + + const { CodexAccountService } = await import('./service') + new CodexAccountService( + createStore(settings) as never, + createRateLimits() as never, + createRuntimeHome() as never + ) + + expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe( + 'approval_policy = "never"\ncli_auth_credentials_store = "file"\n' + ) + }) + it('does not seed source-home hook trust into a self-contained account home', async () => { const fixture = await createCanonicalHookTrustFixture() const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') @@ -509,7 +553,11 @@ describe('CodexAccountService config sync', () => { createRuntimeHome() as never ) - expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe(fixture.config) + // Why: managed homes force file-backed auth so account switch stays deterministic; + // the source ~/.codex stays untouched. + expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe( + forceFileAuthCredentialsStore(fixture.config) + ) expect(readFileSync(canonicalConfigPath, 'utf-8')).toBe(fixture.config) }) @@ -568,10 +616,12 @@ describe('CodexAccountService config sync', () => { '' ].join('\n') writeFileSync(canonicalConfigPath, canonicalConfig, 'utf-8') + // Pre-seed the forced file-auth form so the sync path's write is a true no-op. + const managedConfig = forceFileAuthCredentialsStore(canonicalConfig) const managedHomePath = createManagedHome( testState.userDataDir, 'account-1', - canonicalConfig, + managedConfig, '{"account":"managed"}\n' ) const managedConfigPath = join(managedHomePath, 'config.toml') @@ -696,7 +746,9 @@ describe('CodexAccountService config sync', () => { await service.selectAccount('account-1') - expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe(canonicalConfig) + expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe( + forceFileAuthCredentialsStore(canonicalConfig) + ) expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledTimes(1) expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(1) }) @@ -760,7 +812,9 @@ describe('CodexAccountService config sync', () => { const loginHome = options.env.CODEX_HOME expect(loginHome).toBeTruthy() - expect(readFileSync(join(loginHome!, 'config.toml'), 'utf-8')).toBe(canonicalConfig) + expect(readFileSync(join(loginHome!, 'config.toml'), 'utf-8')).toBe( + forceFileAuthCredentialsStore(canonicalConfig) + ) const payload = Buffer.from(JSON.stringify({ email: 'user@example.com' })).toString( 'base64url' @@ -932,7 +986,9 @@ describe('CodexAccountService config sync', () => { const loginHome = options.env.CODEX_HOME expect(loginHome).toBeTruthy() expect(readFileSync(join(loginHome!, '.orca-managed-home'), 'utf-8')).toBe('account-1\n') - expect(readFileSync(join(loginHome!, 'config.toml'), 'utf-8')).toBe(canonicalConfig) + expect(readFileSync(join(loginHome!, 'config.toml'), 'utf-8')).toBe( + forceFileAuthCredentialsStore(canonicalConfig) + ) const child = new EventEmitter() as EventEmitter & { stdout: PassThrough @@ -1310,10 +1366,13 @@ describe('CodexAccountService config sync', () => { expect(command).toBe('wsl.exe') expect(args).toEqual(buildWslCodexLoginArgs('Debian', wslLinuxHomePath)) // Why: codex login runs inside WSL, so the rewritten path must be the - // Linux-side ~/.codex, not a Windows UNC path. + // Linux-side ~/.codex, not a Windows UNC path. Managed homes also force + // file-backed auth so credentials stay inside the selected CODEX_HOME. expect(readFileSync(join(wslManagedHomePath, 'config.toml'), 'utf-8')).toBe( - 'sandbox_mode = "danger-full-access"\n' + - "model_instructions_file = '/home/alice/.codex/instructions.md'\n" + forceFileAuthCredentialsStore( + 'sandbox_mode = "danger-full-access"\n' + + "model_instructions_file = '/home/alice/.codex/instructions.md'\n" + ) ) const child = new EventEmitter() as EventEmitter & { stdout: PassThrough diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 3618f138144..e50ed78931f 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -28,6 +28,7 @@ import type { } from '../../shared/codex-reset-credit-attempt-ledger' import type { CodexRuntimeHomeService } from './runtime-home-service' import { writeFileAtomically } from './fs-utils' +import { forceFileAuthCredentialsStore } from '../codex/codex-config-auth-store' import { rewriteRelativePathConfigValues } from '../codex/codex-config-path-reference-rewrite' import { stripCodexManagedHookTrustEntriesFromConfig } from '../codex/codex-managed-trust-reconciliation' import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' @@ -1354,15 +1355,16 @@ export class CodexAccountService { } private writeManagedConfig(managedHomePath: string, contents: string): void { + const managedContents = forceFileAuthCredentialsStore(contents) const configPath = join(managedHomePath, 'config.toml') try { - if (existsSync(configPath) && readFileSync(configPath, 'utf-8') === contents) { + if (existsSync(configPath) && readFileSync(configPath, 'utf-8') === managedContents) { return } } catch { // Why: a read error must not make a stale config look current; atomic write owns ACL repair and error surfacing. } - writeFileAtomically(configPath, contents) + writeFileAtomically(configPath, managedContents) } private getManagedAccountsRoot(): string { diff --git a/src/main/codex/codex-config-auth-store.ts b/src/main/codex/codex-config-auth-store.ts new file mode 100644 index 00000000000..7f7b1f8bd6c --- /dev/null +++ b/src/main/codex/codex-config-auth-store.ts @@ -0,0 +1,47 @@ +import { + createTomlLineScanState, + getTomlTableHeader, + isTomlStructuralLine, + updateTomlLineScanState +} from './config-toml-line-scan' + +// Why: managed Codex homes keep account credentials in auth.json; keyring/auto +// would store them outside the selected home and break deterministic switching. +const FILE_AUTH_CREDENTIALS_STORE_LINE = 'cli_auth_credentials_store = "file"' +const AUTH_CREDENTIALS_STORE_KEY_RE = + /^[ \t]*(?:"cli_auth_credentials_store"|'cli_auth_credentials_store'|cli_auth_credentials_store)[ \t]*=/ +const FILE_AUTH_CREDENTIALS_STORE_RE = + /^[ \t]*(?:"cli_auth_credentials_store"|'cli_auth_credentials_store'|cli_auth_credentials_store)[ \t]*=[ \t]*(?:"file"|'file')[ \t\r]*(?:#.*)?$/ + +export function forceFileAuthCredentialsStore(config: string): string { + const hasBom = config.charCodeAt(0) === 0xfeff + const content = hasBom ? config.slice(1) : config + const restoreBom = (value: string): string => (hasBom ? `\uFEFF${value}` : value) + const lines = content.split('\n') + let scanState = createTomlLineScanState() + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? '' + if (isTomlStructuralLine(scanState)) { + if (getTomlTableHeader(line)) { + break + } + if (AUTH_CREDENTIALS_STORE_KEY_RE.test(line)) { + if (FILE_AUTH_CREDENTIALS_STORE_RE.test(line)) { + return config + } + const indent = /^[ \t]*/.exec(line)?.[0] ?? '' + const lineEnding = line.endsWith('\r') ? '\r' : '' + lines[index] = `${indent}${FILE_AUTH_CREDENTIALS_STORE_LINE}${lineEnding}` + return restoreBom(lines.join('\n')) + } + } + scanState = updateTomlLineScanState(scanState, line) + } + + return restoreBom( + content.length === 0 + ? `${FILE_AUTH_CREDENTIALS_STORE_LINE}\n` + : `${FILE_AUTH_CREDENTIALS_STORE_LINE}\n${content}` + ) +} diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts index 7fcd1754992..135a760c40b 100644 --- a/src/main/codex/codex-config-mirror.test.ts +++ b/src/main/codex/codex-config-mirror.test.ts @@ -1,5 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + existsSync, + lstatSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import type * as NodeOs from 'node:os' import { join } from 'node:path' @@ -29,6 +37,7 @@ import { syncSystemConfigIntoLegacySharedCodexHome, syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' +import { forceFileAuthCredentialsStore } from './codex-config-auth-store' let fakeHomeDir: string let userDataDir: string @@ -172,12 +181,18 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { writeFileSync( getSystemConfigPath(), [ + 'js_repl_node_path = "bin/node"', + '', '[profiles.fast]', 'model_catalog_json = "catalogs/fast.json"', + 'js_repl_node_path = "bin/profile-node"', '', '[debug.config_lockfile]', 'load_path = "locks/config.lock.toml"', 'export_dir = "locks"', + '', + '[otel.exporter.tls]', + 'ca-certificate = "certs/ca.pem"', '' ].join('\n'), 'utf-8' @@ -186,13 +201,75 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { syncSystemConfigIntoManagedCodexHome() const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain( + `js_repl_node_path = '${join(getSystemCodexHomePath(), 'bin', 'node')}'` + ) expect(runtimeConfig).toContain( `model_catalog_json = '${join(getSystemCodexHomePath(), 'catalogs', 'fast.json')}'` ) + expect(runtimeConfig).toContain( + `js_repl_node_path = '${join(getSystemCodexHomePath(), 'bin', 'profile-node')}'` + ) expect(runtimeConfig).toContain( `load_path = '${join(getSystemCodexHomePath(), 'locks', 'config.lock.toml')}'` ) expect(runtimeConfig).toContain(`export_dir = '${join(getSystemCodexHomePath(), 'locks')}'`) + expect(runtimeConfig).toContain( + `ca-certificate = '${join(getSystemCodexHomePath(), 'certs', 'ca.pem')}'` + ) + }) + + it('mirrors free-standing profile-v2 config overlays into the runtime home', () => { + writeFileSync(getSystemConfigPath(), 'model = "system-model"\n', 'utf-8') + writeFileSync( + join(getSystemCodexHomePath(), 'work.config.toml'), + 'model = "work-profile"\n', + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeOverlayPath = join(userDataDir, 'codex-runtime-home', 'home', 'work.config.toml') + expect(existsSync(runtimeOverlayPath)).toBe(true) + expect(lstatSync(runtimeOverlayPath).isSymbolicLink()).toBe(false) + const runtimeOverlay = readFileSync(runtimeOverlayPath, 'utf-8') + expect(runtimeOverlay).toMatch(/^# orca-managed-profile-overlay:v1 sha256=[a-f0-9]{64}\n/) + expect(runtimeOverlay).toContain('cli_auth_credentials_store = "file"') + expect(runtimeOverlay).toContain('model = "work-profile"') + }) + + it('rewrites relative overlay paths against the system Codex home', () => { + writeFileSync(getSystemConfigPath(), 'model = "system-model"\n', 'utf-8') + writeFileSync( + join(getSystemCodexHomePath(), 'work.config.toml'), + 'log_dir = "logs"\nmodel = "work-profile"\n', + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeOverlayPath = join(userDataDir, 'codex-runtime-home', 'home', 'work.config.toml') + const runtimeOverlay = readFileSync(runtimeOverlayPath, 'utf-8') + expect(runtimeOverlay).toContain('cli_auth_credentials_store = "file"') + expect(runtimeOverlay).toContain(`log_dir = '${join(getSystemCodexHomePath(), 'logs')}'`) + }) + + it('forces file-backed auth in BOM-prefixed profile config overlays', () => { + writeFileSync(getSystemConfigPath(), 'model = "system-model"\n', 'utf-8') + writeFileSync( + join(getSystemCodexHomePath(), 'work.config.toml'), + '\uFEFFcli_auth_credentials_store = "keyring"\nmodel = "work-profile"\n', + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeOverlayPath = join(userDataDir, 'codex-runtime-home', 'home', 'work.config.toml') + const contents = readFileSync(runtimeOverlayPath, 'utf-8') + expect(contents[0]).toBe('\uFEFF') + expect(contents.slice(1)).toMatch(/^# orca-managed-profile-overlay:v1 sha256=/) + expect(contents).toContain('cli_auth_credentials_store = "file"') + expect(contents).toContain('model = "work-profile"') }) it('does not treat lines inside multiline arrays as headers or path keys', () => { @@ -809,3 +886,44 @@ describe('prepareSystemConfigForFreshRuntimeMirror', () => { expect(prepared).not.toContain('[hooks.state."system-hooks:stop:0:0"]') }) }) + +describe('forceFileAuthCredentialsStore', () => { + it('inserts the file store setting when missing', () => { + expect(forceFileAuthCredentialsStore('model = "gpt"\n')).toBe( + 'cli_auth_credentials_store = "file"\nmodel = "gpt"\n' + ) + }) + + it('keeps a UTF-8 BOM at the start when inserting the setting', () => { + expect(forceFileAuthCredentialsStore('\uFEFFmodel = "gpt"\n')).toBe( + '\uFEFFcli_auth_credentials_store = "file"\nmodel = "gpt"\n' + ) + }) + + it('overrides quoted root keys without touching nested tables', () => { + const input = [ + '"cli_auth_credentials_store" = "keyring"', + 'model = "gpt"', + '', + '[features]', + 'cli_auth_credentials_store = "auto"', + '' + ].join('\n') + + expect(forceFileAuthCredentialsStore(input)).toBe( + [ + 'cli_auth_credentials_store = "file"', + 'model = "gpt"', + '', + '[features]', + 'cli_auth_credentials_store = "auto"', + '' + ].join('\n') + ) + }) + + it('is idempotent when the file store is already set', () => { + const input = 'cli_auth_credentials_store = "file"\nmodel = "gpt"\n' + expect(forceFileAuthCredentialsStore(input)).toBe(input) + }) +}) diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 10ede4d9cca..ce80667c534 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -6,9 +6,11 @@ import { writeFileAtomically, writeFileAtomicallyIfUnchanged } from '../codex-accounts/fs-utils' +import { forceFileAuthCredentialsStore } from './codex-config-auth-store' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' import { rewriteRelativePathConfigValues } from './codex-config-path-reference-rewrite' import { normalizeDeprecatedCodexHookFeatureFlag } from './config-toml-deprecated-hook-flag' +import { syncCodexProfileConfigOverlaysIntoManagedHome } from './codex-profile-config-overlay-mirror' import { parseWslUncPath } from '../../shared/wsl-paths' import { promoteCodexRuntimeSettingsToSystem, @@ -145,6 +147,14 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe( const runtimeConfigPath = join(runtimeHomePath, 'config.toml') const systemConfigExists = existsSync(systemConfigPath) const runtimeConfigExists = existsSync(runtimeConfigPath) + const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(systemHomePath) + // Why: profile overlays live beside config.toml; keep managed copies in sync + // even when the primary config is blank / missing so auth-store profiles land. + syncCodexProfileConfigOverlaysIntoManagedHome({ + runtimeHomePath, + sourceConfigDir, + systemHomePath + }) const rawSystemConfig = systemConfigExists ? readAgentStateFileSync(systemConfigPath) : '' // Why: a missing or blank source is not an authoritative empty config. Merging // it would erase every ordinary setting from an existing managed runtime, and @@ -155,7 +165,6 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe( : { status: 'mirrored', preservedConflictKeys: new Set() } } - const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(systemHomePath) if (!runtimeConfigExists) { writeFileAtomically( runtimeConfigPath, @@ -181,9 +190,11 @@ export function resolveCodexConfigMirrorSourceDirectory(systemHomePath: string): } function prepareSystemConfigForRuntimeMirror(config: string, systemConfigDir: string): string { - return rewriteRelativePathConfigValues( - normalizeDeprecatedCodexHookFeatureFlag(config), - systemConfigDir + return forceFileAuthCredentialsStore( + rewriteRelativePathConfigValues( + normalizeDeprecatedCodexHookFeatureFlag(config), + systemConfigDir + ) ) } diff --git a/src/main/codex/codex-config-path-reference-rewrite.ts b/src/main/codex/codex-config-path-reference-rewrite.ts index 542ce64a824..1b3132c61d0 100644 --- a/src/main/codex/codex-config-path-reference-rewrite.ts +++ b/src/main/codex/codex-config-path-reference-rewrite.ts @@ -10,12 +10,13 @@ import { // Why: codex-rs types these settings AbsolutePathBuf and resolves relative // values against the defining config.toml's directory (= CODEX_HOME for the // user config). experimental_instructions_file only exists in older Codex -// releases; keeping it is harmless since Codex ignores unknown keys. +// releases, while js_repl_node_path remains a typed deprecated path. const EXACT_PATH_CONFIG_KEYS = new Set([ 'debug.config_lockfile.export_dir', 'debug.config_lockfile.load_path', 'experimental_compact_prompt_file', 'experimental_instructions_file', + 'js_repl_node_path', 'log_dir', 'model_catalog_json', 'model_instructions_file', @@ -87,9 +88,11 @@ function isPathConfigKey(tablePath: string, key: string): boolean { return ( /^agents\..+\.config_file$/.test(fullPath) || /^model_providers\..+\.auth\.cwd$/.test(fullPath) || + // Why: Codex models OTEL TLS material as paths with kebab-case TOML keys. + /^otel\..+\.(?:ca-certificate|client-certificate|client-private-key)$/.test(fullPath) || // Why: profiles mirror the top-level file settings that Codex reads (and // can abort on) during config load. - /^profiles\..+\.(?:experimental_compact_prompt_file|model_catalog_json|model_instructions_file)$/.test( + /^profiles\..+\.(?:experimental_compact_prompt_file|js_repl_node_path|model_catalog_json|model_instructions_file)$/.test( fullPath ) ) diff --git a/src/main/codex/codex-profile-config-overlay-active-publish.ts b/src/main/codex/codex-profile-config-overlay-active-publish.ts new file mode 100644 index 00000000000..086a3b5632f --- /dev/null +++ b/src/main/codex/codex-profile-config-overlay-active-publish.ts @@ -0,0 +1,223 @@ +import { randomUUID } from 'node:crypto' +import { linkSync, lstatSync, renameSync, unlinkSync, type Stats } from 'node:fs' +import { basename, dirname, join } from 'node:path' +import { writeFileAtomically } from '../codex-accounts/fs-utils' + +export function publishActiveManagedOverlay({ + fileName, + managedContents, + replaceExisting, + targetPath +}: { + fileName: string + managedContents: string + replaceExisting: boolean + targetPath: string +}): void { + const stagePath = uniqueOverlaySiblingPath(targetPath, 'stage', 'tmp') + try { + // The existing writer prepares a complete same-directory file; a hard + // link then publishes it without replacing a concurrent target. + writeFileAtomically(stagePath, managedContents) + // Verify hard-link support before moving the old target; otherwise a + // persistent filesystem/ACL failure would strand it in quarantine. + if (replaceExisting && !canPublishProfileOverlayByHardLink(stagePath, targetPath, fileName)) { + return + } + const quarantinePath = replaceExisting + ? quarantineProfileOverlayTarget(targetPath, fileName) + : null + if (quarantinePath === undefined) { + return + } + try { + linkSync(stagePath, targetPath) + } catch (error) { + if (quarantinePath && !isAlreadyExistsError(error)) { + restoreRegularProfileOverlayQuarantine(quarantinePath, targetPath, fileName) + } + const reason = isAlreadyExistsError(error) + ? 'Skipped profile config overlay publish to preserve a concurrent target:' + : 'Failed to publish profile config overlay:' + console.warn('[codex-config]', reason, fileName, error) + return + } + if (quarantinePath) { + removeProfileOverlayQuarantine(quarantinePath, fileName) + } + } finally { + removeActiveOverlayStage(stagePath, fileName) + } +} + +function canPublishProfileOverlayByHardLink( + stagePath: string, + targetPath: string, + fileName: string +): boolean { + const probePath = profileOverlayProbePath(targetPath) + try { + linkSync(stagePath, probePath) + } catch (error) { + console.warn( + '[codex-config] Profile config overlay hard-link preflight failed:', + fileName, + error + ) + return false + } + try { + unlinkSync(probePath) + } catch (error) { + console.warn( + '[codex-config] Failed to remove profile config overlay hard-link probe:', + fileName, + probePath, + error + ) + removeActiveOverlayProbe(probePath, fileName) + return false + } + return true +} + +export function quarantineProfileOverlayTarget( + targetPath: string, + fileName: string +): string | null | undefined { + const quarantinePath = uniqueOverlaySiblingPath(targetPath, 'quarantine', 'hold') + try { + renameSync(targetPath, quarantinePath) + } catch (error) { + if (isNotFoundError(error)) { + return null + } + console.warn('[codex-config] Failed to quarantine profile config overlay:', fileName, error) + return undefined + } + + let metadata: Stats + try { + metadata = lstatSync(quarantinePath) + } catch (error) { + console.warn( + '[codex-config] Failed to inspect quarantined profile config overlay:', + fileName, + error + ) + return undefined + } + if (!metadata.isFile()) { + // The target can change type after the initial lstat. Retaining it avoids + // cross-platform symlink dereference or directory reconstruction. + warnRetainedQuarantine( + fileName, + quarantinePath, + new Error('Quarantined profile overlay is not a regular file') + ) + return undefined + } + return quarantinePath +} + +export function restoreRegularProfileOverlayQuarantine( + quarantinePath: string, + targetPath: string, + fileName: string +): void { + try { + // EEXIST leaves both a concurrent target and the quarantine untouched. + linkSync(quarantinePath, targetPath) + } catch (error) { + warnRetainedQuarantine(fileName, quarantinePath, error) + return + } + removeProfileOverlayQuarantine(quarantinePath, fileName) +} + +export function removeProfileOverlayQuarantine(quarantinePath: string, fileName: string): void { + try { + unlinkSync(quarantinePath) + } catch (error) { + // If restore already linked the target, both names still reference the + // same file, so retaining the quarantine remains recoverable. + warnRetainedQuarantine(fileName, quarantinePath, error) + } +} + +export function lstatProfileOverlayIfExists(filePath: string): Stats | null { + try { + return lstatSync(filePath) + } catch (error) { + if (isNotFoundError(error)) { + return null + } + throw error + } +} + +function removeActiveOverlayStage(stagePath: string, fileName: string): void { + try { + unlinkSync(stagePath) + } catch (error) { + if (!isNotFoundError(error)) { + console.warn( + '[codex-config] Failed to remove profile overlay stage:', + fileName, + stagePath, + error + ) + } + } +} + +function removeActiveOverlayProbe(probePath: string, fileName: string): void { + try { + unlinkSync(probePath) + } catch (error) { + if (!isNotFoundError(error)) { + console.warn('[codex-config] Retained profile overlay hard-link probe:', fileName, probePath) + } + } +} + +function warnRetainedQuarantine(fileName: string, quarantinePath: string, reason: unknown): void { + console.warn( + '[codex-config] Retained profile overlay quarantine for manual recovery:', + fileName, + quarantinePath, + reason + ) +} + +function profileOverlayProbePath(targetPath: string): string { + // Why: persistent unlink denial must cap retained probes at one per target. + return join(dirname(targetPath), `.orca-profile-overlay-probe-${basename(targetPath)}.tmp`) +} + +function uniqueOverlaySiblingPath( + targetPath: string, + role: 'quarantine' | 'stage', + extension: string +): string { + return join( + dirname(targetPath), + `.orca-profile-overlay-${role}-${process.pid}-${randomUUID()}.${extension}` + ) +} + +function isAlreadyExistsError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ) +} + +function isNotFoundError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ) +} diff --git a/src/main/codex/codex-profile-config-overlay-mirror.test.ts b/src/main/codex/codex-profile-config-overlay-mirror.test.ts new file mode 100644 index 00000000000..ee764c7fdba --- /dev/null +++ b/src/main/codex/codex-profile-config-overlay-mirror.test.ts @@ -0,0 +1,583 @@ +import { createHash } from 'node:crypto' +import { + existsSync, + lstatSync, + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import type * as NodeFs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { fsFault } = vi.hoisted(() => ({ + fsFault: { + activePublishFinalLinkError: null as { code: string; targetPath: string } | null, + activePublishHardlinkError: null as { code: string } | null, + activePublishProbeUnlinkFailures: 0, + activeTargetRace: null as { contents: string; targetPath: string } | null, + quarantineTargetRace: null as { contents: string; targetPath: string } | null, + readdirPath: null as string | null, + replaceWithSymlinkBeforeQuarantine: null as { + referentPath: string + targetPath: string + } | null, + restoreBeforeLink: null as { contents: string; targetPath: string } | null + } +})) + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + linkSync: ( + existingPath: Parameters[0], + newPath: Parameters[1] + ) => { + const isActivePublishLink = + typeof existingPath === 'string' && existingPath.includes('.orca-profile-overlay-stage-') + const hardlinkError = fsFault.activePublishHardlinkError + if (isActivePublishLink && hardlinkError) { + throw Object.assign(new Error('injected active publish hard-link failure'), { + code: hardlinkError.code + }) + } + const finalLinkError = fsFault.activePublishFinalLinkError + if ( + isActivePublishLink && + finalLinkError && + typeof newPath === 'string' && + newPath === finalLinkError.targetPath + ) { + fsFault.activePublishFinalLinkError = null + throw Object.assign(new Error('injected final active publish link failure'), { + code: finalLinkError.code + }) + } + const activeRace = fsFault.activeTargetRace + if ( + activeRace && + typeof existingPath === 'string' && + existingPath.includes('.orca-profile-overlay-stage-') && + typeof newPath === 'string' && + newPath === activeRace.targetPath + ) { + fsFault.activeTargetRace = null + actual.writeFileSync(activeRace.targetPath, activeRace.contents, 'utf-8') + } + const race = fsFault.restoreBeforeLink + if ( + race && + typeof existingPath === 'string' && + existingPath.includes('.orca-profile-overlay-quarantine-') && + typeof newPath === 'string' && + newPath === race.targetPath + ) { + fsFault.restoreBeforeLink = null + actual.writeFileSync(race.targetPath, race.contents, 'utf-8') + } + actual.linkSync(existingPath, newPath) + }, + unlinkSync: (path: Parameters[0]) => { + if ( + typeof path === 'string' && + path.includes('.orca-profile-overlay-probe-') && + fsFault.activePublishProbeUnlinkFailures > 0 + ) { + fsFault.activePublishProbeUnlinkFailures -= 1 + throw Object.assign(new Error('injected probe unlink failure'), { code: 'EACCES' }) + } + actual.unlinkSync(path) + }, + renameSync: ( + oldPath: Parameters[0], + newPath: Parameters[1] + ) => { + const race = fsFault.replaceWithSymlinkBeforeQuarantine + if ( + race && + typeof oldPath === 'string' && + oldPath === race.targetPath && + typeof newPath === 'string' && + newPath.includes('.orca-profile-overlay-quarantine-') + ) { + fsFault.replaceWithSymlinkBeforeQuarantine = null + actual.rmSync(race.targetPath) + actual.symlinkSync(race.referentPath, race.targetPath, 'file') + } + actual.renameSync(oldPath, newPath) + }, + readdirSync: ( + path: Parameters[0], + options?: Parameters[1] + ) => { + if (typeof path === 'string' && fsFault.readdirPath === path) { + fsFault.readdirPath = null + throw Object.assign(new Error('injected readdir failure'), { code: 'EACCES' }) + } + return options === undefined + ? actual.readdirSync(path) + : actual.readdirSync(path, options as never) + }, + readFileSync: ( + path: Parameters[0], + options?: Parameters[1] + ) => { + const contents = + options === undefined ? actual.readFileSync(path) : actual.readFileSync(path, options) + const race = fsFault.quarantineTargetRace + if (race && typeof path === 'string' && path.includes('.orca-profile-overlay-quarantine-')) { + fsFault.quarantineTargetRace = null + actual.writeFileSync(race.targetPath, race.contents, 'utf-8') + } + return contents + } + } +}) + +import { syncCodexProfileConfigOverlaysIntoManagedHome } from './codex-profile-config-overlay-mirror' + +let rootPath: string +let runtimeHomePath: string +let systemHomePath: string + +beforeEach(() => { + fsFault.activePublishFinalLinkError = null + fsFault.activePublishHardlinkError = null + fsFault.activePublishProbeUnlinkFailures = 0 + fsFault.activeTargetRace = null + fsFault.quarantineTargetRace = null + fsFault.readdirPath = null + fsFault.replaceWithSymlinkBeforeQuarantine = null + fsFault.restoreBeforeLink = null + rootPath = mkdtempSync(join(tmpdir(), 'orca-profile-overlay-mirror-')) + runtimeHomePath = join(rootPath, 'runtime') + systemHomePath = join(rootPath, 'system') + mkdirSync(systemHomePath, { recursive: true }) +}) + +afterEach(() => { + fsFault.activePublishFinalLinkError = null + fsFault.activePublishHardlinkError = null + fsFault.activePublishProbeUnlinkFailures = 0 + fsFault.activeTargetRace = null + fsFault.quarantineTargetRace = null + fsFault.readdirPath = null + fsFault.replaceWithSymlinkBeforeQuarantine = null + fsFault.restoreBeforeLink = null + rmSync(rootPath, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +function syncOverlays(): void { + syncCodexProfileConfigOverlaysIntoManagedHome({ + runtimeHomePath, + sourceConfigDir: systemHomePath, + systemHomePath + }) +} + +function sourceOverlayPath(fileName = 'work.config.toml'): string { + return join(systemHomePath, fileName) +} + +function runtimeOverlayPath(fileName = 'work.config.toml'): string { + return join(runtimeHomePath, fileName) +} + +function sha256(contents: string): string { + return createHash('sha256').update(contents, 'utf-8').digest('hex') +} + +function readManagedOverlay(fileName = 'work.config.toml'): { + body: string + hasBom: boolean + markerHash: string +} { + const contents = readFileSync(runtimeOverlayPath(fileName), 'utf-8') + const hasBom = contents.startsWith('\uFEFF') + const withoutBom = hasBom ? contents.slice(1) : contents + const marker = withoutBom.match(/^# orca-managed-profile-overlay:v1 sha256=([a-f0-9]{64})\n/) + if (!marker) { + throw new Error('managed profile overlay marker is missing') + } + return { + body: withoutBom.slice(marker[0].length), + hasBom, + markerHash: marker[1]! + } +} + +function listOverlayQuarantines(): string[] { + return readdirSync(runtimeHomePath).filter((name) => name.includes('overlay-quarantine')) +} + +describe('syncCodexProfileConfigOverlaysIntoManagedHome', () => { + it('binds a versioned ownership marker to the final path and auth rewritten body', () => { + writeFileSync( + sourceOverlayPath(), + 'cli_auth_credentials_store = "keyring"\nlog_dir = "logs"\n', + 'utf-8' + ) + + syncOverlays() + + const managed = readManagedOverlay() + expect(managed.hasBom).toBe(false) + expect(managed.body).toContain('cli_auth_credentials_store = "file"') + expect(managed.body).toContain(`log_dir = '${join(systemHomePath, 'logs')}'`) + expect(managed.markerHash).toBe(sha256(managed.body)) + expect(readdirSync(runtimeHomePath)).not.toContain( + '.orca-profile-config-overlay-ownership.json' + ) + }) + + it('keeps a BOM at char zero with the ownership marker immediately after it', () => { + writeFileSync( + sourceOverlayPath(), + '\uFEFFcli_auth_credentials_store = "keyring"\nmodel = "work"\n', + 'utf-8' + ) + + syncOverlays() + + const contents = readFileSync(runtimeOverlayPath(), 'utf-8') + expect(contents[0]).toBe('\uFEFF') + expect(contents.slice(1)).toMatch(/^# orca-managed-profile-overlay:v1 sha256=/) + const managed = readManagedOverlay() + expect(managed.hasBom).toBe(true) + expect(managed.body).toContain('cli_auth_credentials_store = "file"') + expect(managed.markerHash).toBe(sha256(managed.body)) + }) + + it('removes marked overlays after their source is renamed or deleted', () => { + const workSourcePath = sourceOverlayPath() + const focusSourcePath = sourceOverlayPath('focus.config.toml') + writeFileSync(workSourcePath, 'model = "work"\n', 'utf-8') + syncOverlays() + + renameSync(workSourcePath, focusSourcePath) + syncOverlays() + expect(existsSync(runtimeOverlayPath())).toBe(false) + expect(readManagedOverlay('focus.config.toml').body).toContain('model = "work"') + + rmSync(focusSourcePath) + syncOverlays() + expect(existsSync(runtimeOverlayPath('focus.config.toml'))).toBe(false) + }) + + it('restores an unmarked stale regular overlay after quarantine inspection', () => { + mkdirSync(runtimeHomePath, { recursive: true }) + const userContents = 'model = "user-owned"\n' + writeFileSync(runtimeOverlayPath(), userContents, 'utf-8') + + syncOverlays() + + expect(readFileSync(runtimeOverlayPath(), 'utf-8')).toBe(userContents) + expect(listOverlayQuarantines()).toEqual([]) + }) + + it('restores a modified stale overlay whose body no longer matches its marker hash', () => { + writeFileSync(sourceOverlayPath(), 'model = "managed"\n', 'utf-8') + syncOverlays() + const modified = readFileSync(runtimeOverlayPath(), 'utf-8').replace( + 'model = "managed"', + 'model = "user-edit"' + ) + writeFileSync(runtimeOverlayPath(), modified, 'utf-8') + rmSync(sourceOverlayPath()) + + syncOverlays() + + expect(readFileSync(runtimeOverlayPath(), 'utf-8')).toBe(modified) + expect(listOverlayQuarantines()).toEqual([]) + }) + + it('replaces an active regular target using the managed atomic writer', () => { + writeFileSync(sourceOverlayPath(), 'model = "managed"\n', 'utf-8') + mkdirSync(runtimeHomePath, { recursive: true }) + writeFileSync(runtimeOverlayPath(), 'model = "old-runtime-copy"\n', 'utf-8') + + syncOverlays() + + const managed = readManagedOverlay() + expect(managed.body).toContain('model = "managed"') + expect(managed.body).not.toContain('old-runtime-copy') + }) + + it('keeps the active target when hard links are unavailable before quarantine', () => { + writeFileSync(sourceOverlayPath(), 'model = "managed"\n', 'utf-8') + mkdirSync(runtimeHomePath, { recursive: true }) + const oldContents = 'model = "old-runtime-copy"\n' + writeFileSync(runtimeOverlayPath(), oldContents, 'utf-8') + fsFault.activePublishHardlinkError = { code: 'EPERM' } + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + syncOverlays() + + expect(readFileSync(runtimeOverlayPath(), 'utf-8')).toBe(oldContents) + expect(listOverlayQuarantines()).toEqual([]) + expect( + readdirSync(runtimeHomePath).filter( + (name) => name.includes('overlay-stage') || name.includes('overlay-probe') + ) + ).toEqual([]) + expect(warn).toHaveBeenCalled() + }) + + it('retries cleanup after a one-shot hard-link probe unlink failure', () => { + writeFileSync(sourceOverlayPath(), 'model = "managed"\n', 'utf-8') + mkdirSync(runtimeHomePath, { recursive: true }) + const oldContents = 'model = "old-runtime-copy"\n' + writeFileSync(runtimeOverlayPath(), oldContents, 'utf-8') + fsFault.activePublishProbeUnlinkFailures = 1 + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + syncOverlays() + + expect(readFileSync(runtimeOverlayPath(), 'utf-8')).toBe(oldContents) + expect( + readdirSync(runtimeHomePath).filter( + (name) => name.includes('overlay-stage') || name.includes('overlay-probe') + ) + ).toEqual([]) + expect(warn).toHaveBeenCalled() + }) + + it('does not accumulate hard-link probes after persistent unlink failures', () => { + writeFileSync(sourceOverlayPath(), 'model = "managed"\n', 'utf-8') + mkdirSync(runtimeHomePath, { recursive: true }) + const oldContents = 'model = "old-runtime-copy"\n' + writeFileSync(runtimeOverlayPath(), oldContents, 'utf-8') + fsFault.activePublishProbeUnlinkFailures = 4 + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + syncOverlays() + syncOverlays() + + expect(readFileSync(runtimeOverlayPath(), 'utf-8')).toBe(oldContents) + expect( + readdirSync(runtimeHomePath).filter((name) => name.includes('overlay-probe')) + ).toHaveLength(1) + expect(readdirSync(runtimeHomePath).some((name) => name.includes('overlay-stage'))).toBe(false) + }) + + it('restores the active target after a one-shot final publish failure', () => { + writeFileSync(sourceOverlayPath(), 'model = "managed"\n', 'utf-8') + mkdirSync(runtimeHomePath, { recursive: true }) + const oldContents = 'model = "old-runtime-copy"\n' + writeFileSync(runtimeOverlayPath(), oldContents, 'utf-8') + fsFault.activePublishFinalLinkError = { + code: 'EIO', + targetPath: runtimeOverlayPath() + } + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + syncOverlays() + + expect(fsFault.activePublishFinalLinkError).toBeNull() + expect(readFileSync(runtimeOverlayPath(), 'utf-8')).toBe(oldContents) + expect(listOverlayQuarantines()).toEqual([]) + expect( + readdirSync(runtimeHomePath).filter( + (name) => name.includes('overlay-stage') || name.includes('overlay-probe') + ) + ).toEqual([]) + expect(warn).toHaveBeenCalled() + }) + + it('does not overwrite a regular target created immediately before active publish', () => { + writeFileSync(sourceOverlayPath(), 'model = "managed"\n', 'utf-8') + mkdirSync(runtimeHomePath, { recursive: true }) + const oldContents = 'model = "old-runtime-copy"\n' + const concurrentContents = 'model = "concurrent-owner"\n' + writeFileSync(runtimeOverlayPath(), oldContents, 'utf-8') + fsFault.activeTargetRace = { + contents: concurrentContents, + targetPath: runtimeOverlayPath() + } + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + syncOverlays() + + expect(fsFault.activeTargetRace).toBeNull() + expect(readFileSync(runtimeOverlayPath(), 'utf-8')).toBe(concurrentContents) + const quarantines = listOverlayQuarantines() + expect(quarantines).toHaveLength(1) + expect(readFileSync(join(runtimeHomePath, quarantines[0]!), 'utf-8')).toBe(oldContents) + expect(readdirSync(runtimeHomePath).some((name) => name.includes('overlay-stage'))).toBe(false) + expect(warn).toHaveBeenCalled() + }) + + it('does not replace an active symlink swapped in immediately before quarantine', () => { + writeFileSync(sourceOverlayPath(), 'model = "managed"\n', 'utf-8') + mkdirSync(runtimeHomePath, { recursive: true }) + writeFileSync(runtimeOverlayPath(), 'model = "initial-regular"\n', 'utf-8') + const referentPath = join(rootPath, 'active-concurrent-owner.config.toml') + const referentContents = 'model = "concurrent-owner"\n' + writeFileSync(referentPath, referentContents, 'utf-8') + fsFault.replaceWithSymlinkBeforeQuarantine = { + referentPath, + targetPath: runtimeOverlayPath() + } + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + syncOverlays() + + expect(fsFault.replaceWithSymlinkBeforeQuarantine).toBeNull() + expect(existsSync(runtimeOverlayPath())).toBe(false) + const quarantines = listOverlayQuarantines() + expect(quarantines).toHaveLength(1) + const quarantinePath = join(runtimeHomePath, quarantines[0]!) + expect(lstatSync(quarantinePath).isSymbolicLink()).toBe(true) + expect(readFileSync(quarantinePath, 'utf-8')).toBe(referentContents) + expect(readFileSync(referentPath, 'utf-8')).toBe(referentContents) + expect(warn).toHaveBeenCalled() + }) + + it('warns and skips an active symlink target', () => { + writeFileSync(sourceOverlayPath(), 'model = "managed"\n', 'utf-8') + mkdirSync(runtimeHomePath, { recursive: true }) + const userFilePath = join(rootPath, 'user-owned.config.toml') + writeFileSync(userFilePath, 'model = "user-owned"\n', 'utf-8') + symlinkSync(userFilePath, runtimeOverlayPath(), 'file') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + syncOverlays() + + expect(lstatSync(runtimeOverlayPath()).isSymbolicLink()).toBe(true) + expect(readFileSync(userFilePath, 'utf-8')).toBe('model = "user-owned"\n') + expect(warn).toHaveBeenCalled() + }) + + it('warns and skips an active directory target', () => { + writeFileSync(sourceOverlayPath(), 'model = "managed"\n', 'utf-8') + mkdirSync(runtimeOverlayPath(), { recursive: true }) + writeFileSync(join(runtimeOverlayPath(), 'keep.txt'), 'keep', 'utf-8') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + syncOverlays() + + expect(readFileSync(join(runtimeOverlayPath(), 'keep.txt'), 'utf-8')).toBe('keep') + expect(warn).toHaveBeenCalled() + }) + + it('ignores stale non-regular overlays and unrelated regular files', () => { + mkdirSync(runtimeHomePath, { recursive: true }) + const userFilePath = join(rootPath, 'user-owned.config.toml') + const linkPath = runtimeOverlayPath('link.config.toml') + const directoryPath = runtimeOverlayPath('folder.config.toml') + const unrelatedPath = join(runtimeHomePath, 'notes.toml') + writeFileSync(userFilePath, 'model = "user-owned"\n', 'utf-8') + symlinkSync(userFilePath, linkPath, 'file') + mkdirSync(directoryPath) + writeFileSync(join(directoryPath, 'keep.txt'), 'keep', 'utf-8') + writeFileSync(unrelatedPath, 'keep', 'utf-8') + + syncOverlays() + + expect(lstatSync(linkPath).isSymbolicLink()).toBe(true) + expect(readFileSync(join(directoryPath, 'keep.txt'), 'utf-8')).toBe('keep') + expect(readFileSync(unrelatedPath, 'utf-8')).toBe('keep') + }) + + it('does not clean stale overlays when the system home cannot be listed', () => { + writeFileSync(sourceOverlayPath(), 'model = "managed"\n', 'utf-8') + syncOverlays() + rmSync(sourceOverlayPath()) + fsFault.readdirPath = systemHomePath + + syncOverlays() + + expect(fsFault.readdirPath).toBeNull() + expect(readManagedOverlay().body).toContain('model = "managed"') + }) + + it('retains quarantine without overwriting a concurrent target', () => { + mkdirSync(runtimeHomePath, { recursive: true }) + const originalContents = 'model = "user-owned"\n' + const concurrentContents = 'model = "concurrent-owner"\n' + writeFileSync(runtimeOverlayPath(), originalContents, 'utf-8') + fsFault.quarantineTargetRace = { + contents: concurrentContents, + targetPath: runtimeOverlayPath() + } + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + syncOverlays() + + expect(fsFault.quarantineTargetRace).toBeNull() + expect(readFileSync(runtimeOverlayPath(), 'utf-8')).toBe(concurrentContents) + const quarantines = listOverlayQuarantines() + expect(quarantines).toHaveLength(1) + expect(readFileSync(join(runtimeHomePath, quarantines[0]!), 'utf-8')).toBe(originalContents) + expect(warn).toHaveBeenCalled() + }) + + it('retains a symlink swapped in immediately before quarantine', () => { + mkdirSync(runtimeHomePath, { recursive: true }) + writeFileSync(runtimeOverlayPath(), 'model = "initial-regular"\n', 'utf-8') + const referentPath = join(rootPath, 'concurrent-owner.config.toml') + const referentContents = 'model = "concurrent-owner"\n' + writeFileSync(referentPath, referentContents, 'utf-8') + fsFault.replaceWithSymlinkBeforeQuarantine = { + referentPath, + targetPath: runtimeOverlayPath() + } + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + syncOverlays() + + expect(fsFault.replaceWithSymlinkBeforeQuarantine).toBeNull() + expect(existsSync(runtimeOverlayPath())).toBe(false) + const quarantines = listOverlayQuarantines() + expect(quarantines).toHaveLength(1) + const quarantinePath = join(runtimeHomePath, quarantines[0]!) + expect(lstatSync(quarantinePath).isSymbolicLink()).toBe(true) + expect(readFileSync(quarantinePath, 'utf-8')).toBe(referentContents) + expect(readFileSync(referentPath, 'utf-8')).toBe(referentContents) + expect(warn).toHaveBeenCalled() + }) + + it('does not overwrite a target created immediately before atomic restore', () => { + mkdirSync(runtimeHomePath, { recursive: true }) + const originalContents = 'model = "user-owned"\n' + const concurrentContents = 'model = "late-concurrent-owner"\n' + writeFileSync(runtimeOverlayPath(), originalContents, 'utf-8') + fsFault.restoreBeforeLink = { + contents: concurrentContents, + targetPath: runtimeOverlayPath() + } + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + syncOverlays() + + expect(fsFault.restoreBeforeLink).toBeNull() + expect(readFileSync(runtimeOverlayPath(), 'utf-8')).toBe(concurrentContents) + const quarantines = listOverlayQuarantines() + expect(quarantines).toHaveLength(1) + expect(readFileSync(join(runtimeHomePath, quarantines[0]!), 'utf-8')).toBe(originalContents) + expect(warn).toHaveBeenCalled() + }) + + it('matches overlay filenames case-insensitively on Windows', () => { + const lowerSourcePath = sourceOverlayPath() + writeFileSync(lowerSourcePath, 'model = "lower"\n', 'utf-8') + syncOverlays() + rmSync(lowerSourcePath) + writeFileSync(sourceOverlayPath('WORK.CONFIG.TOML'), 'model = "upper"\n', 'utf-8') + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + + syncOverlays() + + expect(existsSync(runtimeOverlayPath())).toBe(true) + expect(listOverlayQuarantines()).toEqual([]) + platform.mockRestore() + }) +}) diff --git a/src/main/codex/codex-profile-config-overlay-mirror.ts b/src/main/codex/codex-profile-config-overlay-mirror.ts new file mode 100644 index 00000000000..664fa981b6f --- /dev/null +++ b/src/main/codex/codex-profile-config-overlay-mirror.ts @@ -0,0 +1,179 @@ +import { createHash } from 'node:crypto' +import { mkdirSync, readFileSync, readdirSync, type Stats } from 'node:fs' +import { basename, join } from 'node:path' +import { forceFileAuthCredentialsStore } from './codex-config-auth-store' +import { rewriteRelativePathConfigValues } from './codex-config-path-reference-rewrite' +import { + lstatProfileOverlayIfExists, + publishActiveManagedOverlay, + quarantineProfileOverlayTarget, + removeProfileOverlayQuarantine, + restoreRegularProfileOverlayQuarantine +} from './codex-profile-config-overlay-active-publish' + +type CodexProfileConfigOverlayHomes = { + runtimeHomePath: string + sourceConfigDir: string + systemHomePath: string +} + +const PROFILE_OVERLAY_MARKER_PREFIX = '# orca-managed-profile-overlay:v1 sha256=' +const PROFILE_OVERLAY_MARKER_PATTERN = /^# orca-managed-profile-overlay:v1 sha256=([a-f0-9]{64})\n/ +const UTF8_BOM = '\uFEFF' + +function getOverlayNameKey(fileName: string): string { + return process.platform === 'win32' ? fileName.toLowerCase() : fileName +} + +function isProfileConfigOverlayName(fileName: string): boolean { + const key = getOverlayNameKey(fileName) + return basename(fileName) === fileName && key !== 'config.toml' && key.endsWith('.config.toml') +} + +// Why: profile-v2 resolves sibling `.config.toml` files from CODEX_HOME; +// a regular rewritten copy keeps relative assets anchored to the system home. +export function syncCodexProfileConfigOverlaysIntoManagedHome({ + runtimeHomePath, + sourceConfigDir, + systemHomePath +}: CodexProfileConfigOverlayHomes): void { + let systemFileNames: string[] + try { + systemFileNames = readdirSync(systemHomePath) + } catch { + // The source inventory is authoritative; without it, no target is stale. + return + } + + const activeOverlayNames = systemFileNames.filter(isProfileConfigOverlayName) + const activeOverlayKeys = new Set(activeOverlayNames.map(getOverlayNameKey)) + for (const fileName of activeOverlayNames) { + mirrorCodexProfileConfigOverlay({ + fileName, + runtimeHomePath, + sourceConfigDir, + systemHomePath + }) + } + removeStaleManagedOverlays(runtimeHomePath, activeOverlayKeys) +} + +function mirrorCodexProfileConfigOverlay({ + fileName, + runtimeHomePath, + sourceConfigDir, + systemHomePath +}: CodexProfileConfigOverlayHomes & { fileName: string }): void { + const sourcePath = join(systemHomePath, fileName) + const targetPath = join(runtimeHomePath, fileName) + try { + const rewritten = forceFileAuthCredentialsStore( + rewriteRelativePathConfigValues(readFileSync(sourcePath, 'utf-8'), sourceConfigDir) + ) + const managedContents = addProfileOverlayOwnershipMarker(rewritten) + mkdirSync(runtimeHomePath, { recursive: true }) + const targetMetadata = lstatProfileOverlayIfExists(targetPath) + if (targetMetadata && !targetMetadata.isFile()) { + console.warn('[codex-config] Skipped non-regular profile config overlay target:', fileName) + return + } + if (targetMetadata) { + try { + if (readFileSync(targetPath, 'utf-8') === managedContents) { + return + } + } catch { + // The staged publisher owns safe replacement and Windows ACL repair. + } + } + publishActiveManagedOverlay({ + fileName, + managedContents, + replaceExisting: targetMetadata !== null, + targetPath + }) + } catch (error) { + console.warn('[codex-config] Failed to mirror profile config overlay:', fileName, error) + } +} + +function addProfileOverlayOwnershipMarker(rewritten: string): string { + const hasBom = rewritten.startsWith(UTF8_BOM) + const body = hasBom ? rewritten.slice(1) : rewritten + const marker = `${PROFILE_OVERLAY_MARKER_PREFIX}${sha256(body)}\n` + return `${hasBom ? UTF8_BOM : ''}${marker}${body}` +} + +function removeStaleManagedOverlays( + runtimeHomePath: string, + activeOverlayKeys: ReadonlySet +): void { + let runtimeFileNames: string[] + try { + runtimeFileNames = readdirSync(runtimeHomePath) + } catch { + return + } + + for (const fileName of runtimeFileNames) { + if ( + !isProfileConfigOverlayName(fileName) || + activeOverlayKeys.has(getOverlayNameKey(fileName)) + ) { + continue + } + removeStaleManagedOverlay(runtimeHomePath, fileName) + } +} + +function removeStaleManagedOverlay(runtimeHomePath: string, fileName: string): void { + const targetPath = join(runtimeHomePath, fileName) + let targetMetadata: Stats | null + try { + targetMetadata = lstatProfileOverlayIfExists(targetPath) + } catch (error) { + warnStaleOverlayFailure('inspect', fileName, error) + return + } + if (!targetMetadata?.isFile()) { + return + } + + // Same-directory rename isolates one path before ownership is inspected. + const quarantinePath = quarantineProfileOverlayTarget(targetPath, fileName) + if (!quarantinePath) { + return + } + + let isManaged = false + try { + isManaged = hasValidProfileOverlayOwnershipMarker(readFileSync(quarantinePath, 'utf-8')) + } catch { + restoreRegularProfileOverlayQuarantine(quarantinePath, targetPath, fileName) + return + } + + if (!isManaged) { + restoreRegularProfileOverlayQuarantine(quarantinePath, targetPath, fileName) + return + } + removeProfileOverlayQuarantine(quarantinePath, fileName) +} + +function hasValidProfileOverlayOwnershipMarker(contents: string): boolean { + const withoutBom = contents.startsWith(UTF8_BOM) ? contents.slice(1) : contents + const marker = withoutBom.match(PROFILE_OVERLAY_MARKER_PATTERN) + if (!marker) { + return false + } + const body = withoutBom.slice(marker[0].length) + return sha256(body) === marker[1] +} + +function warnStaleOverlayFailure(action: string, fileName: string, reason: unknown): void { + console.warn(`[codex-config] Failed to ${action} stale profile config overlay:`, fileName, reason) +} + +function sha256(contents: string): string { + return createHash('sha256').update(contents, 'utf-8').digest('hex') +} diff --git a/src/main/codex/config-settings-promotion.test.ts b/src/main/codex/config-settings-promotion.test.ts index 5e0e5f10b78..4c4335ca2e2 100644 --- a/src/main/codex/config-settings-promotion.test.ts +++ b/src/main/codex/config-settings-promotion.test.ts @@ -473,7 +473,7 @@ describe('codex settings write-back promotion', () => { syncSystemConfigIntoManagedCodexHome() expect(readSystemConfig()).toBe('model = "gpt-5"\n') - expect(readRuntimeConfig()).toBe('model = "o4"\n') + expect(readRuntimeConfig()).toBe('cli_auth_credentials_store = "file"\nmodel = "o4"\n') expect(readFileSync(baselinePath(), 'utf-8')).toBe(baselineBeforeFailure) promotionTestState.failAtomicWrite = false diff --git a/src/main/rate-limits/codex-backend-base-url.test.ts b/src/main/rate-limits/codex-backend-base-url.test.ts new file mode 100644 index 00000000000..f1a7a50f64c --- /dev/null +++ b/src/main/rate-limits/codex-backend-base-url.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { readFileMock } = vi.hoisted(() => ({ readFileMock: vi.fn() })) + +vi.mock('node:fs/promises', () => ({ readFile: readFileMock })) +vi.mock('node-pty', () => ({ spawn: vi.fn() })) + +import { + buildCodexRateLimitResetCreditsConsumeUrl, + buildCodexRateLimitResetCreditsUrl, + normalizeCodexBackendBaseUrl, + resolveCodexBackendBaseUrl +} from './codex-backend-base-url' +import { consumeCodexRateLimitResetCredit } from './codex-fetcher' + +describe('Codex backend base URL', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('normalizes official ChatGPT hosts and keeps custom backend paths', () => { + expect(normalizeCodexBackendBaseUrl(null)).toBe('https://chatgpt.com/backend-api') + expect(normalizeCodexBackendBaseUrl('https://ChatGPT.com/')).toBe( + 'https://chatgpt.com/backend-api' + ) + expect(normalizeCodexBackendBaseUrl('https://api.example.com/v1/')).toBe( + 'https://api.example.com/v1' + ) + expect(buildCodexRateLimitResetCreditsUrl('https://api.example.com')).toBe( + 'https://api.example.com/api/codex/rate-limit-reset-credits' + ) + expect(buildCodexRateLimitResetCreditsUrl('https://api.example.com/backend-api')).toBe( + 'https://api.example.com/backend-api/api/codex/rate-limit-reset-credits' + ) + }) + + it('preserves query and fragment components while appending backend paths', () => { + expect(normalizeCodexBackendBaseUrl('https://chatgpt.com/?token=official#account')).toBe( + 'https://chatgpt.com/backend-api?token=official#account' + ) + expect(normalizeCodexBackendBaseUrl('https://api.example.com/v1/?token=custom#account')).toBe( + 'https://api.example.com/v1?token=custom#account' + ) + expect( + buildCodexRateLimitResetCreditsUrl('https://api.example.com/v1/?token=custom#account') + ).toBe('https://api.example.com/v1/api/codex/rate-limit-reset-credits?token=custom#account') + expect( + buildCodexRateLimitResetCreditsConsumeUrl('https://api.example.com/v1/?token=custom#account') + ).toBe( + 'https://api.example.com/v1/api/codex/rate-limit-reset-credits/consume?token=custom#account' + ) + }) + + it('reads only a top-level chatgpt_base_url from the selected Codex home', async () => { + readFileMock.mockResolvedValue( + [ + '"chatgpt_base_url" = \'https://api.example.com/v1\'', + '', + '[profile.work]', + 'chatgpt_base_url = "https://ignored.example.com"', + '' + ].join('\n') + ) + + await expect(resolveCodexBackendBaseUrl('/managed/codex-home')).resolves.toBe( + 'https://api.example.com/v1' + ) + expect(readFileMock).toHaveBeenCalledWith('/managed/codex-home/config.toml', 'utf8') + }) + + it('reads a top-level chatgpt_base_url after a UTF-8 BOM', async () => { + readFileMock.mockResolvedValue('\uFEFFchatgpt_base_url = "https://api.example.com/v2"\n') + + await expect(resolveCodexBackendBaseUrl('/managed/codex-home')).resolves.toBe( + 'https://api.example.com/v2' + ) + }) + + it('routes reset-credit consumption through the selected custom backend', async () => { + readFileMock.mockImplementation(async (path: string) => + path.endsWith('config.toml') + ? 'chatgpt_base_url = "https://api.example.com/v1"\n' + : JSON.stringify({ tokens: { access_token: 'access-token' } }) + ) + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ code: 'reset' }) + } as Response) + + await expect( + consumeCodexRateLimitResetCredit({ + codexHomePath: '/managed/codex-home', + idempotencyKey: 'redeem-1' + }) + ).resolves.toBe('reset') + + expect(fetch).toHaveBeenCalledWith( + 'https://api.example.com/v1/api/codex/rate-limit-reset-credits/consume', + expect.anything() + ) + }) +}) diff --git a/src/main/rate-limits/codex-backend-base-url.ts b/src/main/rate-limits/codex-backend-base-url.ts new file mode 100644 index 00000000000..e476bf4beb8 --- /dev/null +++ b/src/main/rate-limits/codex-backend-base-url.ts @@ -0,0 +1,114 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + createTomlLineScanState, + getTomlTableHeader, + isTomlStructuralLine, + updateTomlLineScanState +} from '../codex/config-toml-line-scan' + +const DEFAULT_CHATGPT_BACKEND_BASE_URL = 'https://chatgpt.com/backend-api' +const OFFICIAL_CHATGPT_HOSTS = new Set([ + 'chatgpt.com', + 'www.chatgpt.com', + 'chat.openai.com', + 'www.chat.openai.com' +]) + +function isOfficialChatGptHost(hostname: string): boolean { + return OFFICIAL_CHATGPT_HOSTS.has(hostname.toLowerCase()) +} + +function hasBackendApiSuffix(pathname: string): boolean { + const normalized = pathname.replace(/\/+$/, '') || '/' + return normalized === '/backend-api' || normalized.endsWith('/backend-api') +} + +// Why: official ChatGPT backends expose WHAM below /backend-api, while custom +// Codex backends retain their configured path and use /api/codex routes. +export function normalizeCodexBackendBaseUrl(raw: string | null | undefined): string { + const trimmed = (raw?.trim() || DEFAULT_CHATGPT_BACKEND_BASE_URL).replace(/\/+$/, '') + try { + const url = new URL(trimmed) + const path = url.pathname.replace(/\/+$/, '') + url.pathname = + isOfficialChatGptHost(url.hostname) && !hasBackendApiSuffix(url.pathname) + ? `${path}/backend-api` + : path || '/' + const result = url.toString() + return result.endsWith('/') && url.pathname === '/' && !url.search && !url.hash + ? result.slice(0, -1) + : result + } catch { + return trimmed + } +} + +function isOfficialChatGptBackend(baseUrl: string): boolean { + try { + const url = new URL(baseUrl) + return isOfficialChatGptHost(url.hostname) && hasBackendApiSuffix(url.pathname) + } catch { + return false + } +} + +export function buildCodexRateLimitResetCreditsUrl(baseUrl: string): string { + const normalized = normalizeCodexBackendBaseUrl(baseUrl) + const suffix = isOfficialChatGptBackend(normalized) + ? '/wham/rate-limit-reset-credits' + : '/api/codex/rate-limit-reset-credits' + return appendCodexBackendPath(normalized, suffix) +} + +export function buildCodexRateLimitResetCreditsConsumeUrl(baseUrl: string): string { + return appendCodexBackendPath(buildCodexRateLimitResetCreditsUrl(baseUrl), '/consume') +} + +function appendCodexBackendPath(baseUrl: string, suffix: string): string { + try { + const url = new URL(baseUrl) + // Why: append to pathname so configured query/fragment data remains at the URL tail. + url.pathname = `${url.pathname.replace(/\/+$/, '')}${suffix}` + return url.toString() + } catch { + return `${baseUrl}${suffix}` + } +} + +function parseTopLevelTomlString(config: string, key: string): string | null { + const content = config.charCodeAt(0) === 0xfeff ? config.slice(1) : config + const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const keyPattern = new RegExp( + `^[ \\t]*(?:"${escapedKey}"|'${escapedKey}'|${escapedKey})[ \\t]*=[ \\t]*(?:"([^"]*)"|'([^']*)')` + ) + let scanState = createTomlLineScanState() + for (const line of content.split('\n')) { + if (isTomlStructuralLine(scanState)) { + if (getTomlTableHeader(line)) { + break + } + const match = keyPattern.exec(line) + if (match) { + return match[1] ?? match[2] ?? null + } + } + scanState = updateTomlLineScanState(scanState, line) + } + return null +} + +export async function resolveCodexBackendBaseUrl( + codexHomePath: string, + signal?: AbortSignal +): Promise { + try { + const config = await readFile( + join(codexHomePath, 'config.toml'), + signal ? { encoding: 'utf8', signal } : 'utf8' + ) + return normalizeCodexBackendBaseUrl(parseTopLevelTomlString(config, 'chatgpt_base_url')) + } catch { + return DEFAULT_CHATGPT_BACKEND_BASE_URL + } +} diff --git a/src/main/rate-limits/codex-fetcher-backend.test.ts b/src/main/rate-limits/codex-fetcher-backend.test.ts index 3fefe5e3a2f..b157810ab3e 100644 --- a/src/main/rate-limits/codex-fetcher-backend.test.ts +++ b/src/main/rate-limits/codex-fetcher-backend.test.ts @@ -137,6 +137,38 @@ describe('Codex backend rate-limit requests', () => { expect(ptySpawnMock).not.toHaveBeenCalled() }) + it('uses the selected custom backend for the reset-credit follow-up', async () => { + readFileMock.mockImplementation(async (path: string) => + path.endsWith('config.toml') + ? 'chatgpt_base_url = "https://api.example.com/v1"\n' + : JSON.stringify({ tokens: { access_token: 'access-token' } }) + ) + vi.mocked(fetch) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ plan_type: 'plus', rate_limit: null }) + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ available_count: 2 }) + } as Response) + + await expect( + fetchCodexRateLimits({ + codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex' + }) + ).resolves.toMatchObject({ + rateLimitResetCredits: { availableCount: 2 }, + status: 'ok' + }) + + expect(fetch).toHaveBeenNthCalledWith( + 2, + 'https://api.example.com/v1/api/codex/rate-limit-reset-credits', + expect.anything() + ) + }) + it('aborts callers while sharing one stalled backend auth read', async () => { let resolveRead!: (content: string) => void readFileMock.mockImplementation( diff --git a/src/main/rate-limits/codex-fetcher-buckets.test.ts b/src/main/rate-limits/codex-fetcher-buckets.test.ts new file mode 100644 index 00000000000..50987c424b1 --- /dev/null +++ b/src/main/rate-limits/codex-fetcher-buckets.test.ts @@ -0,0 +1,125 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { childSpawnMock, readFileMock, resolveCodexCommandMock, ptySpawnMock } = vi.hoisted(() => ({ + childSpawnMock: vi.fn(), + readFileMock: vi.fn(), + resolveCodexCommandMock: vi.fn(), + ptySpawnMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ spawn: childSpawnMock })) +vi.mock('node:fs/promises', () => ({ readFile: readFileMock })) +vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock })) +vi.mock('node-pty', () => ({ spawn: ptySpawnMock })) +vi.mock('./codex-auth-presence', () => ({ + probeCodexAuthPresence: vi.fn(async () => 'present') +})) + +import { fetchCodexRateLimits } from './codex-fetcher' + +function makeRpcChild() { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter + stderr: EventEmitter + stdin: EventEmitter & { write: ReturnType; end: ReturnType } + kill: ReturnType + exitCode: number | null + } + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + // Why: graceful shutdown path resolves only once the child reports exit. + const exitNow = (): void => { + child.exitCode = 0 + child.emit('exit', 0, null) + child.emit('close', 0, null) + } + child.stdin = Object.assign(new EventEmitter(), { write: vi.fn(), end: vi.fn(exitNow) }) + child.exitCode = null + child.kill = vi.fn(() => { + exitNow() + return true + }) + return child +} + +describe('fetchCodexRateLimits multi-meter windows', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + resolveCodexCommandMock.mockReturnValue('codex') + readFileMock.mockRejectedValue(new Error('no auth fixture')) + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('keeps the preferred meter in session/weekly and surfaces inferred extra buckets', async () => { + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + rpcChild.stdin.write.mockImplementation((line: string) => { + const message = JSON.parse(line) as { id?: number; method?: string } + if (message.method === 'initialize') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, result: {} })}\n`) + ) + }, 0) + } + if (message.method === 'account/rateLimits/read') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from( + `${JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + result: { + rateLimits: { + limitId: 'codex', + primary: { usedPercent: 10, windowDurationMins: 299 }, + secondary: { usedPercent: 20, windowDurationMins: 10079 } + }, + rateLimitsByLimitId: { + short_meter: { + limitId: 'short_meter', + limitName: 'Short', + primary: { usedPercent: 40, windowDurationMins: 50 }, + secondary: { usedPercent: 10, windowDurationMins: 1400 } + }, + codex: { + limitId: 'codex', + primary: { usedPercent: 99 }, + secondary: { usedPercent: 98 } + } + } + } + })}\n` + ) + ) + }, 0) + } + }) + + const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(1) + const result = await resultPromise + + expect(result).toMatchObject({ + // Why: preferred session/weekly stay classification-normalized (upstream #10136); + // extra meters still surface via buckets with reported durations. + session: { usedPercent: 10, windowMinutes: 300 }, + weekly: { usedPercent: 20, windowMinutes: 10080 }, + status: 'ok' + }) + expect(result.buckets).toEqual([ + expect.objectContaining({ name: 'Short', usedPercent: 40, windowMinutes: 50 }), + expect.objectContaining({ name: 'Short weekly', usedPercent: 10, windowMinutes: 1400 }) + ]) + }) +}) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 50c97ff5da7..4a17de0d3b6 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -41,6 +41,12 @@ import { resolveCodexHomeProcessLockKey, withCodexHomeProcessLock } from '../codex-cli/codex-home-process-lock' +import { + buildCodexRateLimitResetCreditsConsumeUrl, + buildCodexRateLimitResetCreditsUrl, + resolveCodexBackendBaseUrl +} from './codex-backend-base-url' +import { mapCodexRpcRateLimitBuckets } from './codex-rpc-rate-limit-mapping' const RPC_TIMEOUT_MS = 10_000 const WSL_RPC_TIMEOUT_MS = 25_000 @@ -389,7 +395,9 @@ async function fetchBackendRateLimitResetCredits( return null } // Why: Codex 0.140's app-server strips the reset-credit metadata this backend endpoint still returns. - const response = await fetch('https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', { + // Why: custom chatgpt_base_url backends need the matching WHAM path; resolve from CODEX_HOME config. + const baseUrl = await resolveCodexBackendBaseUrl(getCodexHomePath(options?.codexHomePath), signal) + const response = await fetch(buildCodexRateLimitResetCreditsUrl(baseUrl), { ...auth, signal }) @@ -448,18 +456,16 @@ export async function consumeCodexRateLimitResetCredit(options: { if (!auth) { throw new Error('Codex not signed in') } - const response = await fetch( - 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume', - { - method: 'POST', - headers: { - ...auth.headers, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ redeem_request_id: options.idempotencyKey }), - signal - } - ) + const baseUrl = await resolveCodexBackendBaseUrl(getCodexHomePath(options.codexHomePath), signal) + const response = await fetch(buildCodexRateLimitResetCreditsConsumeUrl(baseUrl), { + method: 'POST', + headers: { + ...auth.headers, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ redeem_request_id: options.idempotencyKey }), + signal + }) if (!response.ok) { await cancelUnreadResponseBody(response) throw new Error(`Codex reset failed: HTTP ${response.status}`) @@ -804,6 +810,11 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise[0], + mapRpcWindow + ) const rateLimitResetCredits = mapRpcRateLimitResetCredits( wrapper?.rateLimitResetCredits ) @@ -813,6 +824,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise | null +} + +type WindowMapper = ( + raw: CodexRateWindowSnapshot | undefined, + windowMinutes: number +) => RateLimitWindow | null + +function getWindowMinutes(reportedWindowMinutes: number | undefined, fallback: number): number { + if ( + typeof reportedWindowMinutes !== 'number' || + !Number.isFinite(reportedWindowMinutes) || + reportedWindowMinutes <= 0 + ) { + return fallback + } + return reportedWindowMinutes +} + +function getPreferredSnapshotId(payload: CodexRpcRateLimitsPayload | undefined): string | null { + // Why: `rateLimits` is the app-server's declared preferred meter; its id + // (or the 'codex' default) must not reappear as an extra bucket. + if (payload?.rateLimits) { + return payload.rateLimits.limitId?.trim() || 'codex' + } + return null +} + +function getSnapshotName(id: string, snapshot: CodexRpcRateLimitSnapshot): string { + const name = snapshot.limitName?.trim() || snapshot.limitId?.trim() || id + return name === 'codex' ? 'Session' : name +} + +export function mapCodexRpcRateLimitBuckets( + payload: CodexRpcRateLimitsPayload | undefined, + mapWindow: WindowMapper +): RateLimitBucket[] | undefined { + const byId = payload?.rateLimitsByLimitId + if (!byId) { + return undefined + } + const preferredId = getPreferredSnapshotId(payload) ?? 'codex' + const buckets: RateLimitBucket[] = [] + for (const [id, snapshot] of Object.entries(byId)) { + if (!snapshot || id === preferredId) { + continue + } + const name = getSnapshotName(id, snapshot) + // Why: classification picks which slot is session vs weekly; the reported + // duration stays the bucket's windowMinutes (it needn't be 300/10080). + const { session, weekly } = classifyCodexRateLimitWindows(snapshot) + const sessionWindow = mapWindow( + session ?? undefined, + getWindowMinutes( + typeof session?.windowDurationMins === 'number' ? session.windowDurationMins : undefined, + CODEX_SESSION_WINDOW_MINUTES + ) + ) + if (sessionWindow) { + buckets.push({ name, ...sessionWindow }) + } + const weeklyWindow = mapWindow( + weekly ?? undefined, + getWindowMinutes( + typeof weekly?.windowDurationMins === 'number' ? weekly.windowDurationMins : undefined, + CODEX_WEEKLY_WINDOW_MINUTES + ) + ) + if (weeklyWindow) { + buckets.push({ name: `${name} weekly`, ...weeklyWindow }) + } + } + return buckets.length > 0 ? buckets : undefined +} diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 9d7534e8c3b..91fa6a8a9b2 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -1176,7 +1176,9 @@ function VerboseProviderUsage({ p: ProviderRateLimits display: UsagePercentageDisplay }): React.JSX.Element { - if (p.buckets && p.buckets.length > 0) { + // Why: Codex buckets are additional meters; preferred session/weekly remain + // authoritative in the compact/verbose bar. Gemini buckets replace them. + if (p.provider !== 'codex' && p.buckets && p.buckets.length > 0) { const visibleBuckets = p.buckets.filter((bucket) => STATUS_BAR_BUCKET_NAMES.has(bucket.name)) return ( <> diff --git a/src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx b/src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx index a72761322a5..9dc8efc8ba6 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx +++ b/src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx @@ -29,16 +29,21 @@ vi.mock('../../store', () => ({ }) })) -vi.mock('./tooltip', () => ({ - ProviderIcon: function ProviderIcon(props: Record) { - return { type: 'ProviderIcon', props } - }, - ProviderPanel: function ProviderPanel(props: Record) { - return { type: 'ProviderPanel', props } - }, - barColor: () => 'bg-green-500', - clampUsedPercent: (n: number) => Math.max(0, Math.min(100, Math.round(n))) -})) +vi.mock('./tooltip', async (importOriginal) => { + const actual = await importOriginal() // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + ProviderIcon: function ProviderIcon(props: Record) { + return { type: 'ProviderIcon', props } + }, + ProviderPanel: function ProviderPanel(props: Record) { + return { type: 'ProviderPanel', props } + }, + barColor: () => 'bg-green-500', + clampUsedPercent: (n: number) => Math.max(0, Math.min(100, Math.round(n))), + getProviderUsageStatusLabel: () => 'Codex' + } +}) vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenu: function DropdownMenu(props: Record) { @@ -84,6 +89,31 @@ function findChildByType(node: unknown, typeName: string): ReactElementLike { throw new Error(`Could not find ${typeName}`) } +function findChildrenByType(node: unknown, typeName: string): ReactElementLike[] { + const matches: ReactElementLike[] = [] + const stack = [node] + while (stack.length > 0) { + const current = stack.pop() + if (current == null || typeof current === 'string' || typeof current === 'number') { + continue + } + if (Array.isArray(current)) { + stack.push(...current) + continue + } + const element = current as ReactElementLike + const type = element.type as { name?: string } | string | undefined + const matchedName = typeof type === 'string' ? type : type?.name + if (matchedName === typeName) { + matches.push(element) + } + if (element.props && 'children' in element.props) { + stack.push(element.props.children) + } + } + return matches +} + async function renderProviderDetailsMenu(): Promise { const { ProviderDetailsMenu } = await import('./StatusBar') return ProviderDetailsMenu({ @@ -139,4 +169,50 @@ describe('ProviderDetailsMenu focus handoff', () => { }) expect(preventDefault).not.toHaveBeenCalled() }) + + it('keeps Codex session and weekly windows when dynamic buckets are present', async () => { + const { ProviderDetailsMenu } = await import('./StatusBar') + const menu = ProviderDetailsMenu({ + provider: { + provider: 'codex', + status: 'ok', + error: null, + updatedAt: Date.now(), + session: { + usedPercent: 10, + resetsAt: null, + resetDescription: null, + windowMinutes: 300 + }, + weekly: { + usedPercent: 20, + resetsAt: null, + resetDescription: null, + windowMinutes: 10_080 + }, + buckets: [ + { + name: 'Short', + usedPercent: 40, + resetsAt: null, + resetDescription: null, + windowMinutes: 50 + } + ] + }, + compact: false, + iconOnly: false, + ariaLabel: 'Open Codex usage details' + }) + const segment = findChildByType(menu, 'ProviderSegment') + const rendered = (segment.type as (props: Record) => unknown)(segment.props) + const usage = findChildByType(rendered, 'VerboseProviderUsage') + const usageRendered = (usage.type as (props: Record) => unknown)(usage.props) + + expect( + findChildrenByType(usageRendered, 'WindowLabel') + .map((element) => element.props.label) + .sort() + ).toEqual(['5h', 'wk']) + }) }) diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index 2dc23cf100a..25a65c5ef69 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -326,6 +326,32 @@ describe('getWindowSections', () => { ]) }) + it('keeps the preferred Codex session beside additional meter buckets', () => { + const p: ProviderRateLimits = { + provider: 'codex', + session: { usedPercent: 10, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: { usedPercent: 20, windowMinutes: 10080, resetsAt: null, resetDescription: null }, + buckets: [ + { + name: 'Short', + usedPercent: 40, + windowMinutes: 60, + resetsAt: null, + resetDescription: null + } + ], + updatedAt: Date.now(), + error: null, + status: 'ok' + } + + expect(getWindowSections(p)).toEqual([ + { label: 'Session', window: p.session }, + { label: 'Short', window: p.buckets![0] }, + { label: 'Weekly', window: p.weekly } + ]) + }) + it('returns session and weekly when buckets are absent', () => { const p: ProviderRateLimits = { provider: 'claude', diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index db585ab64f7..daaeed3f2d8 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -143,7 +143,19 @@ export function getWindowSections( ): { label: string; window: RateLimitWindow | null }[] { if (p.buckets?.length) { const bucketSections = p.buckets.map((b) => ({ label: b.name, window: b as RateLimitWindow })) + // Why: Codex buckets contain only additional limit IDs; its preferred + // primary meter remains in session, unlike Gemini's bucket-only model. + const preferredCodexSection = + p.provider === 'codex' + ? [ + { + label: translate('auto.components.status.bar.tooltip.94038ad2fa', 'Session'), + window: p.session + } + ] + : [] return [ + ...preferredCodexSection, ...bucketSections, { label: translate('auto.components.status.bar.tooltip.252c096536', 'Weekly'), diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 83210fba2cc..73257f3bc70 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -63,7 +63,7 @@ export type ProviderRateLimits = { fableWeekly?: RateLimitWindow | null /** 30-day monthly window (OpenCode Go, Grok unified billing), null if not available. */ monthly?: RateLimitWindow | null - /** Named per-model buckets (Gemini only). */ + /** Named extra windows (Gemini models and Codex multi-limit meters). */ buckets?: RateLimitBucket[] /** Available earned Codex rate-limit reset credits, if reported. */ rateLimitResetCredits?: {