diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 5c1ec10b7b..25299640ff 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -10,10 +10,13 @@ "../src/main/antigravity/hook-service.ts", "../src/main/claude/hook-settings.ts", "../src/main/claude/hook-service.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-home-paths.ts", "../src/main/codex/codex-hook-identity.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-promotion.ts", "../src/main/codex/config-toml-line-scan.ts", diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index d3c39b0b06..a63625c8b4 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -577,7 +577,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. @@ -1319,7 +1321,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' ) }) diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 8c4779573f..b4b4ba81a0 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -42,6 +42,10 @@ function decodeEncodedWslBashCommand(command: string): string { return encoded ? Buffer.from(encoded, 'base64').toString('utf8') : command } +function withFileAuthStore(config: string): string { + return `cli_auth_credentials_store = "file"\n${config}` +} + function createSettings(overrides: Partial = {}): GlobalSettings { const appFontFamily = overrides.appFontFamily ?? 'Geist' const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true @@ -273,12 +277,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( + withFileAuthStore(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('rewrites relative path config values when syncing into managed homes', async () => { const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') writeFileSync( @@ -329,7 +376,7 @@ describe('CodexAccountService config sync', () => { const managedHomePath = createManagedHome( testState.userDataDir, 'account-1', - canonicalConfig, + withFileAuthStore(canonicalConfig), '{"account":"managed"}\n' ) const managedConfigPath = join(managedHomePath, 'config.toml') @@ -454,7 +501,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( + withFileAuthStore(canonicalConfig) + ) expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledTimes(1) expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(1) }) @@ -517,7 +566,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( + withFileAuthStore(canonicalConfig) + ) const payload = Buffer.from(JSON.stringify({ email: 'user@example.com' })).toString( 'base64url' @@ -576,7 +627,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( + withFileAuthStore(canonicalConfig) + ) const child = new EventEmitter() as EventEmitter & { stdout: PassThrough @@ -783,8 +836,10 @@ describe('CodexAccountService config sync', () => { // Why: codex login runs inside WSL, so the rewritten path must be the // Linux-side ~/.codex, not a Windows UNC path. expect(readFileSync(join(wslManagedHomePath, 'config.toml'), 'utf-8')).toBe( - 'sandbox_mode = "danger-full-access"\n' + - "model_instructions_file = '/home/alice/.codex/instructions.md'\n" + withFileAuthStore( + '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 2a0fb54bdc..64371e71cb 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -15,6 +15,7 @@ import type { } from '../../shared/types' 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 { resolveCodexCommand } from '../codex-cli/command' import type { Store } from '../persistence' @@ -517,16 +518,17 @@ 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: read errors should not make a stale config look current; the // atomic write path owns Windows ACL repair and persistent 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 0000000000..7f7b1f8bd6 --- /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 1ef1f61dd0..68ec99fc19 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' @@ -28,6 +36,7 @@ import { resolveCodexConfigMirrorSourceDirectory, syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' +import { forceFileAuthCredentialsStore } from './codex-config-auth-store' let fakeHomeDir: string let userDataDir: string @@ -171,12 +180,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' @@ -185,13 +200,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', () => { @@ -674,3 +751,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 1949a703d4..3dd3814027 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -1,8 +1,10 @@ import { existsSync, readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { writeFileAtomically } 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 { syncCodexProfileConfigOverlaysIntoManagedHome } from './codex-profile-config-overlay-mirror' import { parseWslUncPath } from '../../shared/wsl-paths' import { promoteCodexRuntimeSettingsToSystem, @@ -53,12 +55,17 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe({ const runtimeConfigPath = join(runtimeHomePath, 'config.toml') const systemConfigExists = existsSync(systemConfigPath) const runtimeConfigExists = existsSync(runtimeConfigPath) + const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(systemHomePath) + syncCodexProfileConfigOverlaysIntoManagedHome({ + runtimeHomePath, + sourceConfigDir, + systemHomePath + }) if (!systemConfigExists && !runtimeConfigExists) { return } const rawSystemConfig = systemConfigExists ? readFileSync(systemConfigPath, 'utf-8') : '' - const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(systemHomePath) if (!runtimeConfigExists) { writeFileAtomically( runtimeConfigPath, @@ -80,9 +87,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 94e29db3d8..3148809324 100644 --- a/src/main/codex/codex-config-path-reference-rewrite.ts +++ b/src/main/codex/codex-config-path-reference-rewrite.ts @@ -9,12 +9,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', @@ -92,9 +93,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 0000000000..5ed3f71c98 --- /dev/null +++ b/src/main/codex/codex-profile-config-overlay-active-publish.ts @@ -0,0 +1,207 @@ +import { randomUUID } from 'node:crypto' +import { linkSync, lstatSync, renameSync, unlinkSync, type Stats } from 'node:fs' +import { 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 = uniqueOverlaySiblingPath(targetPath, 'probe', 'tmp') + 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 + ) + 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 warnRetainedQuarantine(fileName: string, quarantinePath: string, reason: unknown): void { + console.warn( + '[codex-config] Retained profile overlay quarantine for manual recovery:', + fileName, + quarantinePath, + reason + ) +} + +function uniqueOverlaySiblingPath( + targetPath: string, + role: 'probe' | '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 0000000000..58f908b639 --- /dev/null +++ b/src/main/codex/codex-profile-config-overlay-mirror.test.ts @@ -0,0 +1,532 @@ +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, + 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) + }, + 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.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.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('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 0000000000..664fa981b6 --- /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 f6ceabc362..61bb070955 100644 --- a/src/main/codex/config-settings-promotion.test.ts +++ b/src/main/codex/config-settings-promotion.test.ts @@ -393,7 +393,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 0000000000..a228dbaa3e --- /dev/null +++ b/src/main/rate-limits/codex-backend-base-url.test.ts @@ -0,0 +1,89 @@ +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 { + 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('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 0000000000..7b214334a3 --- /dev/null +++ b/src/main/rate-limits/codex-backend-base-url.ts @@ -0,0 +1,98 @@ +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(/\/+$/, '') + if (isOfficialChatGptHost(url.hostname) && !hasBackendApiSuffix(url.pathname)) { + return `${url.origin}${path === '' || path === '/' ? '' : path}/backend-api` + } + return `${url.origin}${path === '/' ? '' : path}` + } 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) + return isOfficialChatGptBackend(normalized) + ? `${normalized}/wham/rate-limit-reset-credits` + : `${normalized}/api/codex/rate-limit-reset-credits` +} + +export function buildCodexRateLimitResetCreditsConsumeUrl(baseUrl: string): string { + return `${buildCodexRateLimitResetCreditsUrl(baseUrl)}/consume` +} + +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 3028bee551..f2c19a2524 100644 --- a/src/main/rate-limits/codex-fetcher-backend.test.ts +++ b/src/main/rate-limits/codex-fetcher-backend.test.ts @@ -95,6 +95,38 @@ describe('Codex backend rate-limit requests', () => { ) }) + 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 0000000000..2829e8a08a --- /dev/null +++ b/src/main/rate-limits/codex-fetcher-buckets.test.ts @@ -0,0 +1,112 @@ +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: { write: ReturnType } + kill: ReturnType + } + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.stdin = { write: vi.fn() } + child.kill = vi.fn() + 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({ + session: { usedPercent: 10, windowMinutes: 299 }, + weekly: { usedPercent: 20, windowMinutes: 10079 }, + 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.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index 571229188c..44691d66ad 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -325,7 +325,7 @@ describe('fetchCodexRateLimits', () => { expect(ptySpawnMock).not.toHaveBeenCalled() }) - it('normalizes Codex RPC remaining-minute windows to fixed display durations', async () => { + it('preserves Codex RPC reported quota window durations', async () => { const rpcChild = makeRpcChild() childSpawnMock.mockReturnValue(rpcChild) rpcChild.stdin.write.mockImplementation((line: string) => { @@ -364,8 +364,8 @@ describe('fetchCodexRateLimits', () => { await vi.advanceTimersByTimeAsync(1) const result = await resultPromise - expect(result.session?.windowMinutes).toBe(300) - expect(result.weekly?.windowMinutes).toBe(10080) + expect(result.session?.windowMinutes).toBe(299) + expect(result.weekly?.windowMinutes).toBe(10079) }) it('fills reset-credit count from the backend when the installed app-server omits it', async () => { diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index d606a12e85..34827d560a 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -29,6 +29,16 @@ import { createAuthFilesystemOperation, type SharedAuthFilesystemOperation } from './auth-filesystem-operation' +import { + buildCodexRateLimitResetCreditsConsumeUrl, + buildCodexRateLimitResetCreditsUrl, + resolveCodexBackendBaseUrl +} from './codex-backend-base-url' +import { + mapCodexRpcRateLimitsPayload, + type CodexRpcRateLimitsPayload, + type CodexRpcRateWindow +} from './codex-rpc-rate-limit-mapping' const RPC_TIMEOUT_MS = 10_000 const WSL_RPC_TIMEOUT_MS = 25_000 @@ -55,11 +65,7 @@ type RpcResponse = { error?: { code: number; message: string } } -type RpcRateWindow = { - usedPercent?: number - windowDurationMins?: number - resetsAt?: number // Unix seconds -} +type RpcRateWindow = CodexRpcRateWindow type RateLimitResetCredits = { availableCount: number @@ -72,15 +78,9 @@ type RateLimitResetCredits = { }[] } -type RpcRateLimitsResult = { - primary?: RpcRateWindow - secondary?: RpcRateWindow -} - // Why: the Codex app-server wraps rate limit data inside a `rateLimits` key. // The actual response shape is `{ rateLimits: { primary, secondary, ... } }`. -type RpcRateLimitsResponse = { - rateLimits?: RpcRateLimitsResult +type RpcRateLimitsResponse = CodexRpcRateLimitsPayload & { rateLimitResetCredits?: { availableCount?: number totalEarnedCount?: number @@ -376,7 +376,8 @@ async function fetchBackendRateLimitResetCredits( } // Why: published Codex 0.140 can read windows through app-server but strips // reset-credit metadata that the backend already returns. - const response = await fetch('https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', { + const baseUrl = await resolveCodexBackendBaseUrl(getCodexHomePath(options?.codexHomePath), signal) + const response = await fetch(buildCodexRateLimitResetCreditsUrl(baseUrl), { ...auth, signal }) @@ -435,18 +436,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) { throw new Error(`Codex reset failed: HTTP ${response.status}`) } @@ -456,7 +455,7 @@ export async function consumeCodexRateLimitResetCredit(options: { function mapRpcWindow( raw: RpcRateWindow | undefined, - expectedWindowMinutes: number + windowMinutes: number ): RateLimitWindow | null { if (!raw || typeof raw.usedPercent !== 'number' || !Number.isFinite(raw.usedPercent)) { return null @@ -483,9 +482,7 @@ function mapRpcWindow( return { usedPercent: Math.min(100, Math.max(0, raw.usedPercent)), - // Why: Codex currently reports remaining minutes in `windowDurationMins`. - // Orca's UI needs the fixed bucket duration so labels stay "5h" / "wk". - windowMinutes: expectedWindowMinutes, + windowMinutes, resetsAt, resetDescription } @@ -723,9 +720,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise | null +} + +type WindowMapper = ( + raw: CodexRpcRateWindow | 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 getPreferredSnapshot(payload: CodexRpcRateLimitsPayload | undefined): { + id: string | null + snapshot: CodexRpcRateLimitSnapshot | undefined +} { + // Why: `rateLimits` is the app-server's declared preferred meter; choosing + // the first by-id entry can replace compact session/weekly with another plan. + if (payload?.rateLimits) { + return { + id: payload.rateLimits.limitId?.trim() || 'codex', + snapshot: payload.rateLimits + } + } + const byId = payload?.rateLimitsByLimitId + if (byId?.codex) { + return { id: 'codex', snapshot: byId.codex } + } + if (byId) { + for (const [id, snapshot] of Object.entries(byId)) { + if (snapshot) { + return { id, snapshot } + } + } + } + return { id: null, snapshot: undefined } +} + +function getSnapshotName(id: string, snapshot: CodexRpcRateLimitSnapshot): string { + const name = snapshot.limitName?.trim() || snapshot.limitId?.trim() || id + return name === 'codex' ? 'Session' : name +} + +export function mapCodexRpcRateLimitsPayload( + payload: CodexRpcRateLimitsPayload | undefined, + mapWindow: WindowMapper +): { + session: RateLimitWindow | null + weekly: RateLimitWindow | null + buckets?: RateLimitBucket[] +} { + const preferred = getPreferredSnapshot(payload) + const session = mapWindow( + preferred.snapshot?.primary, + getWindowMinutes(preferred.snapshot?.primary?.windowDurationMins, 300) + ) + const weekly = mapWindow( + preferred.snapshot?.secondary, + getWindowMinutes(preferred.snapshot?.secondary?.windowDurationMins, 10080) + ) + const byId = payload?.rateLimitsByLimitId + if (!byId) { + return { session, weekly } + } + + const buckets: RateLimitBucket[] = [] + for (const [id, snapshot] of Object.entries(byId)) { + if (!snapshot || id === (preferred.id ?? 'codex')) { + continue + } + const name = getSnapshotName(id, snapshot) + const primary = mapWindow( + snapshot.primary, + getWindowMinutes(snapshot.primary?.windowDurationMins, 300) + ) + if (primary) { + buckets.push({ name, ...primary }) + } + const secondary = mapWindow( + snapshot.secondary, + getWindowMinutes(snapshot.secondary?.windowDurationMins, 10080) + ) + if (secondary) { + buckets.push({ name: `${name} weekly`, ...secondary }) + } + } + + return { session, weekly, ...(buckets.length > 0 ? { buckets } : {}) } +} diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index b3cc8a922c..d4e3735606 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -1169,7 +1169,9 @@ function ProviderSegment({ // Has data (ok, fetching with stale data, or error with stale data) const isStale = p.status === 'error' - if (p.buckets && p.buckets.length > 0) { + // Why: Codex buckets are additional meters; its preferred session/weekly + // remain authoritative in the compact bar. Gemini buckets replace them. + if (p.provider !== 'codex' && p.buckets && p.buckets.length > 0) { const visibleBuckets = p.buckets.filter((b) => STATUS_BAR_BUCKET_NAMES.has(b.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 a72761322a..1c429c474f 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 @@ -37,7 +37,8 @@ vi.mock('./tooltip', () => ({ return { type: 'ProviderPanel', props } }, barColor: () => 'bg-green-500', - clampUsedPercent: (n: number) => Math.max(0, Math.min(100, Math.round(n))) + clampUsedPercent: (n: number) => Math.max(0, Math.min(100, Math.round(n))), + getProviderUsageStatusLabel: () => 'Codex' })) vi.mock('@/components/ui/dropdown-menu', () => ({ @@ -84,6 +85,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 +165,48 @@ 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) + + expect( + findChildrenByType(rendered, '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 c553f68ddb..a39bf6eb24 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -279,6 +279,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 7890529bde..3a29a9b44d 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -151,7 +151,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 65c09aaffa..0fb00f34f3 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -61,7 +61,7 @@ export type ProviderRateLimits = { fableWeekly?: RateLimitWindow | null /** 30-day monthly window (OpenCode Go only), 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?: {