diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 955567a21..a7c4761a0 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -11,7 +11,9 @@ "../src/main/claude/hook-settings.ts", "../src/main/claude/hook-service.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/config-toml-line-scan.ts", "../src/main/codex/config-toml-trust.ts", "../src/main/codex/hook-service.ts", "../src/main/codex-accounts/fs-utils.ts", diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 807d502f9..13b39b6b0 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -277,6 +277,49 @@ describe('CodexAccountService config sync', () => { ) }) + it('rewrites relative path config values when syncing into managed homes', async () => { + const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') + writeFileSync( + canonicalConfigPath, + 'model_instructions_file = "instructions.md"\nsandbox_mode = "danger-full-access"\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 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + + const { CodexAccountService } = await import('./service') + new CodexAccountService(store as never, rateLimits as never, runtimeHome as never) + + const managedConfig = readFileSync(join(managedHomePath, 'config.toml'), 'utf-8') + expect(managedConfig).toContain( + `model_instructions_file = '${join(testState.fakeHomeDir, '.codex', 'instructions.md')}'` + ) + expect(managedConfig).toContain('sandbox_mode = "danger-full-access"') + }) + it('does not rewrite managed configs that already match canonical config', async () => { const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') const canonicalConfig = 'approval_policy = "never"\nsandbox_mode = "danger-full-access"\n' @@ -706,7 +749,11 @@ describe('CodexAccountService config sync', () => { const wslManagedHomePath = join(testState.userDataDir, 'wsl-managed-home') const wslConfigPath = join(testState.userDataDir, 'wsl-config.toml') const wslLinuxHomePath = '/home/alice/.local/share/orca/codex-accounts/account-id-for-test/home' - writeFileSync(wslConfigPath, 'sandbox_mode = "danger-full-access"\n', 'utf-8') + writeFileSync( + wslConfigPath, + 'sandbox_mode = "danger-full-access"\nmodel_instructions_file = "instructions.md"\n', + 'utf-8' + ) const execFileSyncMock = vi.fn((_command: string, args: string[]) => { const script = decodeEncodedWslBashCommand(String(args.at(-1))) @@ -731,8 +778,11 @@ describe('CodexAccountService config sync', () => { '-ic', `export CODEX_HOME='${wslLinuxHomePath}'; exec codex login` ]) + // 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' + '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 ef28b584a..c97972563 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 { rewriteRelativePathConfigValues } from '../codex/codex-config-path-reference-rewrite' import { resolveCodexCommand } from '../codex-cli/command' import type { Store } from '../persistence' import type { RateLimitService } from '../rate-limits/service' @@ -47,6 +48,13 @@ type ResolvedCodexIdentity = { workspaceAccountId: string | null } +type CanonicalCodexConfig = { + contents: string + /** Home the config was read from, in the path style Codex sees at runtime + * (Linux-side for WSL); relative path-valued settings resolve against it. */ + sourceHomePath: string +} + export type CodexAccountAddTarget = { runtime?: 'host' | 'wsl' wslDistro?: string | null @@ -452,25 +460,31 @@ export class CodexAccountService { // Why: Orca account switching is meant to swap Codex credentials and quota // identity, not silently fork the user's sandbox/config defaults. Syncing // one canonical config into every managed home keeps auth isolated per - // account while preserving consistent Codex behavior. - this.writeManagedConfig(trustedManagedHomePath, canonicalConfig) + // account while preserving consistent Codex behavior. Managed homes are + // real CODEX_HOMEs for `codex login`, so relative path-valued settings + // must keep resolving against the home the config was read from. + this.writeManagedConfig( + trustedManagedHomePath, + rewriteRelativePathConfigValues(canonicalConfig.contents, canonicalConfig.sourceHomePath) + ) } - private readCanonicalConfig(): string | null { - const primaryConfigPath = join(homedir(), '.codex', 'config.toml') + private readCanonicalConfig(): CanonicalCodexConfig | null { + const sourceHomePath = join(homedir(), '.codex') + const primaryConfigPath = join(sourceHomePath, 'config.toml') if (!existsSync(primaryConfigPath)) { return null } try { - return readFileSync(primaryConfigPath, 'utf-8') + return { contents: readFileSync(primaryConfigPath, 'utf-8'), sourceHomePath } } catch (error) { console.warn('[codex-accounts] Failed to read canonical config:', error) return null } } - private readCanonicalConfigForManagedHome(managedHomePath: string): string | null { + private readCanonicalConfigForManagedHome(managedHomePath: string): CanonicalCodexConfig | null { const wslInfo = parseWslUncPath(managedHomePath) if (!wslInfo) { return this.readCanonicalConfig() @@ -488,7 +502,9 @@ export class CodexAccountService { } try { - return readFileSync(configPath, 'utf-8') + // Why: the config is read over UNC but consumed by Codex inside WSL, so + // path rewrites must anchor to the Linux-side ~/.codex, not the UNC path. + return { contents: readFileSync(configPath, 'utf-8'), sourceHomePath: `${wslHome}/.codex` } } catch (error) { console.warn('[codex-accounts] Failed to read WSL canonical config:', error) return null diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts index 17865439a..ff3ad9ba3 100644 --- a/src/main/codex/codex-config-mirror.test.ts +++ b/src/main/codex/codex-config-mirror.test.ts @@ -108,6 +108,141 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { expect(readFileSync(getSystemConfigPath(), 'utf-8')).toContain('codex_hooks = true') }) + it('preserves system-home relative path references in the runtime config copy', () => { + writeFileSync( + getSystemConfigPath(), + [ + 'model_instructions_file = "instructions.md"', + "model_catalog_json = 'catalogs/models.json'", + 'experimental_compact_prompt_file = "prompts/compact.md"', + 'experimental_instructions_file = "legacy-instructions.md"', + 'log_dir = "logs"', + 'sqlite_home = "state"', + '', + '[agents.reviewer]', + 'config_file = "agents/reviewer.toml"', + '', + '[model_providers.qwen.auth]', + 'cwd = "auth"', + '', + '[[skills.config]]', + 'path = "skills/local"', + '' + ].join('\r\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain( + `model_instructions_file = '${join(getSystemCodexHomePath(), 'instructions.md')}'` + ) + expect(runtimeConfig).toContain( + `model_catalog_json = '${join(getSystemCodexHomePath(), 'catalogs', 'models.json')}'` + ) + expect(runtimeConfig).toContain( + `experimental_compact_prompt_file = '${join( + getSystemCodexHomePath(), + 'prompts', + 'compact.md' + )}'` + ) + expect(runtimeConfig).toContain( + `experimental_instructions_file = '${join( + getSystemCodexHomePath(), + 'legacy-instructions.md' + )}'` + ) + expect(runtimeConfig).toContain(`log_dir = '${join(getSystemCodexHomePath(), 'logs')}'`) + expect(runtimeConfig).toContain(`sqlite_home = '${join(getSystemCodexHomePath(), 'state')}'`) + expect(runtimeConfig).toContain( + `config_file = '${join(getSystemCodexHomePath(), 'agents', 'reviewer.toml')}'` + ) + expect(runtimeConfig).toContain(`cwd = '${join(getSystemCodexHomePath(), 'auth')}'`) + expect(runtimeConfig).toContain(`path = '${join(getSystemCodexHomePath(), 'skills', 'local')}'`) + }) + + it('rewrites profile and debug lockfile path references', () => { + writeFileSync( + getSystemConfigPath(), + [ + '[profiles.fast]', + 'model_catalog_json = "catalogs/fast.json"', + '', + '[debug.config_lockfile]', + 'load_path = "locks/config.lock.toml"', + 'export_dir = "locks"', + '' + ].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain( + `model_catalog_json = '${join(getSystemCodexHomePath(), 'catalogs', 'fast.json')}'` + ) + expect(runtimeConfig).toContain( + `load_path = '${join(getSystemCodexHomePath(), 'locks', 'config.lock.toml')}'` + ) + expect(runtimeConfig).toContain(`export_dir = '${join(getSystemCodexHomePath(), 'locks')}'`) + }) + + it('does not treat lines inside multiline arrays as headers or path keys', () => { + writeFileSync( + getSystemConfigPath(), + ['notify = [', ' ["custom", 1]', ']', 'log_dir = "logs"', ''].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain(' ["custom", 1]') + expect(runtimeConfig).toContain(`log_dir = '${join(getSystemCodexHomePath(), 'logs')}'`) + }) + + it('escapes control characters instead of emitting them raw in rewritten paths', () => { + writeFileSync(getSystemConfigPath(), 'log_dir = "logs\\bdir"\n', 'utf-8') + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('log_dir = "') + expect(runtimeConfig).toContain('\\u0008') + expect(runtimeConfig).not.toContain('\b') + }) + + it('leaves values with lone-surrogate unicode escapes untouched', () => { + writeFileSync(getSystemConfigPath(), 'log_dir = "logs\\uD800dir"\n', 'utf-8') + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('log_dir = "logs\\uD800dir"') + }) + + it('leaves absolute, home-prefixed, env-shaped, and URL path references untouched', () => { + const passthroughLines = [ + 'model_instructions_file = "~/notes/instructions.md"', + 'model_catalog_json = "$CODEX_ASSETS/models.json"', + 'experimental_instructions_file = "%USERPROFILE%\\\\instructions.md"', + 'log_dir = "/var/log/codex"', + "sqlite_home = 'C:\\Users\\example\\state'", + 'experimental_compact_prompt_file = "file://server/prompts/compact.md"' + ] + writeFileSync(getSystemConfigPath(), `${passthroughLines.join('\n')}\n`, 'utf-8') + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + for (const line of passthroughLines) { + expect(runtimeConfig).toContain(line) + } + }) + it('drops deprecated codex_hooks when the new hooks flag already exists', () => { writeFileSync( getSystemConfigPath(), diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index a78b876c1..ff9f72c55 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -1,7 +1,14 @@ import { existsSync, readFileSync } from 'node:fs' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' +import { rewriteRelativePathConfigValues } from './codex-config-path-reference-rewrite' +import { + createTomlLineScanState, + getTomlTableHeader, + isTomlStructuralLine, + updateTomlLineScanState +} from './config-toml-line-scan' function getRuntimeCodexConfigTomlPath(): string { return join(getOrcaManagedCodexHomePath(), 'config.toml') @@ -28,8 +35,9 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(): void { return } - const systemConfig = normalizeDeprecatedCodexHookFeatureFlag( - systemConfigExists ? readFileSync(systemConfigPath, 'utf-8') : '' + const systemConfig = prepareSystemConfigForRuntimeMirror( + systemConfigExists ? readFileSync(systemConfigPath, 'utf-8') : '', + dirname(systemConfigPath) ) if (!runtimeConfigExists) { // Why: trust blocks reference a hooks.json path, so system-home hook trust @@ -45,6 +53,13 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(): void { } } +function prepareSystemConfigForRuntimeMirror(config: string, systemConfigDir: string): string { + return rewriteRelativePathConfigValues( + normalizeDeprecatedCodexHookFeatureFlag(config), + systemConfigDir + ) +} + function normalizeDeprecatedCodexHookFeatureFlag(config: string): string { if (!config.includes('codex_hooks')) { return config @@ -145,13 +160,6 @@ type TomlSection = { start: number } -type TomlMultilineState = { - basic: boolean - literal: boolean -} - -type TomlMultilineMode = 'basic' | 'literal' | null - function stripRuntimeOwnedTomlSections( config: string, runtimeProjectHeaders = new Set() @@ -179,14 +187,12 @@ function getTomlSections(config: string): TomlSection[] { const sections: TomlSection[] = [] let sectionStart = -1 let sectionHeader: string | null = null - let multilineState: TomlMultilineState = { basic: false, literal: false } + let scanState = createTomlLineScanState() for (let index = 0; index < lines.length; index += 1) { - const header = isInsideTomlMultilineString(multilineState) - ? null - : getTomlTableHeader(lines[index] ?? '') + const header = isTomlStructuralLine(scanState) ? getTomlTableHeader(lines[index] ?? '') : null if (!header) { - multilineState = updateTomlMultilineState(multilineState, lines[index] ?? '') + scanState = updateTomlLineScanState(scanState, lines[index] ?? '') continue } @@ -199,7 +205,7 @@ function getTomlSections(config: string): TomlSection[] { } sectionStart = index sectionHeader = header - multilineState = updateTomlMultilineState(multilineState, lines[index] ?? '') + scanState = updateTomlLineScanState(scanState, lines[index] ?? '') } if (sectionStart !== -1) { @@ -241,87 +247,3 @@ function joinTomlBlocks(blocks: string[]): string { const normalizedBlocks = blocks.map((block) => block.trim()).filter((block) => block.length > 0) return normalizedBlocks.length === 0 ? '' : `${normalizedBlocks.join('\n\n')}\n` } - -function getTomlTableHeader(line: string): string | null { - const match = /^(\s*\[\[?.+\]\]?\s*)(?:#.*)?$/.exec(line) - return match?.[1] ?? null -} - -function isInsideTomlMultilineString(state: TomlMultilineState): boolean { - return state.basic || state.literal -} - -function updateTomlMultilineState(state: TomlMultilineState, line: string): TomlMultilineState { - let mode: TomlMultilineMode = state.basic ? 'basic' : state.literal ? 'literal' : null - let index = 0 - while (index < line.length) { - if (mode === 'basic') { - if (line[index] === '\\') { - index += 2 - continue - } - if (line.startsWith('"""', index)) { - mode = null - index += 3 - continue - } - index += 1 - continue - } - if (mode === 'literal') { - if (line.startsWith("'''", index)) { - mode = null - index += 3 - continue - } - index += 1 - continue - } - - const char = line[index] - if (char === '#') { - break - } - if (line.startsWith('"""', index)) { - mode = 'basic' - index += 3 - continue - } - if (line.startsWith("'''", index)) { - mode = 'literal' - index += 3 - continue - } - if (char === '"') { - index = skipTomlBasicString(line, index + 1) - continue - } - if (char === "'") { - index = skipTomlLiteralString(line, index + 1) - continue - } - index += 1 - } - return { basic: mode === 'basic', literal: mode === 'literal' } -} - -function skipTomlBasicString(line: string, startIndex: number): number { - let index = startIndex - while (index < line.length) { - const char = line[index] - if (char === '\\') { - index += 2 - continue - } - if (char === '"') { - return index + 1 - } - index += 1 - } - return index -} - -function skipTomlLiteralString(line: string, startIndex: number): number { - const endIndex = line.indexOf("'", startIndex) - return endIndex === -1 ? line.length : endIndex + 1 -} diff --git a/src/main/codex/codex-config-path-reference-rewrite.ts b/src/main/codex/codex-config-path-reference-rewrite.ts new file mode 100644 index 000000000..94e29db3d --- /dev/null +++ b/src/main/codex/codex-config-path-reference-rewrite.ts @@ -0,0 +1,251 @@ +import { posix as pathPosix, win32 as pathWin32 } from 'node:path' +import { + createTomlLineScanState, + getTomlTableHeader, + isTomlStructuralLine, + updateTomlLineScanState +} from './config-toml-line-scan' + +// 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. +const EXACT_PATH_CONFIG_KEYS = new Set([ + 'debug.config_lockfile.export_dir', + 'debug.config_lockfile.load_path', + 'experimental_compact_prompt_file', + 'experimental_instructions_file', + 'log_dir', + 'model_catalog_json', + 'model_instructions_file', + 'skills.config.path', + 'sqlite_home' +]) + +type ParsedTomlString = { + value: string + start: number + end: number +} + +// Why: Orca mirrors config.toml into a managed CODEX_HOME, but Codex resolves +// path-valued config settings from the file it read. Keep user-owned assets in +// ~/.codex reachable after the mirror moves the TOML. Best-effort by design: +// values spelled as inline tables, quoted keys, or triple-quoted strings pass +// through unchanged rather than risk corrupting them. +export function rewriteRelativePathConfigValues(config: string, sourceConfigDir: string): string { + const lines = config.split('\n') + let tablePath = '' + let scanState = createTomlLineScanState() + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? '' + if (isTomlStructuralLine(scanState)) { + const header = getTomlTableHeader(line) + if (header) { + tablePath = getTomlHeaderPath(header) + } else { + lines[index] = rewriteRelativePathConfigLine(line, tablePath, sourceConfigDir) + } + } + scanState = updateTomlLineScanState(scanState, line) + } + + return lines.join('\n') +} + +function rewriteRelativePathConfigLine( + line: string, + tablePath: string, + sourceConfigDir: string +): string { + const equalsIndex = line.indexOf('=') + if (equalsIndex === -1) { + return line + } + + const key = line.slice(0, equalsIndex).trim() + if (!isPathConfigKey(tablePath, key)) { + return line + } + + const parsed = parseTomlSingleLineStringValue(line, equalsIndex + 1) + if (!parsed || !shouldRewriteRelativePath(parsed.value)) { + return line + } + + // Why: WSL configs are read over UNC paths but consumed inside Linux, so + // join semantics must follow the source home's path style, not the host's. + const path = sourceConfigDir.startsWith('/') ? pathPosix : pathWin32 + const absolutePath = path.join(sourceConfigDir, parsed.value) + return `${line.slice(0, parsed.start)}${quoteTomlPath(absolutePath)}${line.slice(parsed.end)}` +} + +function isPathConfigKey(tablePath: string, key: string): boolean { + const normalizedKey = normalizeTomlPathExpression(key) + const fullPath = tablePath + ? `${normalizeTomlPathExpression(tablePath)}.${normalizedKey}` + : normalizedKey + if (EXACT_PATH_CONFIG_KEYS.has(fullPath)) { + return true + } + return ( + /^agents\..+\.config_file$/.test(fullPath) || + /^model_providers\..+\.auth\.cwd$/.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( + fullPath + ) + ) +} + +function normalizeTomlPathExpression(value: string): string { + return value.replace(/\s+/g, '') +} + +function shouldRewriteRelativePath(value: string): boolean { + const trimmed = value.trim() + if (!trimmed || trimmed.startsWith('~') || trimmed.startsWith('$') || trimmed.startsWith('%')) { + return false + } + if (pathWin32.isAbsolute(trimmed) || pathPosix.isAbsolute(trimmed)) { + return false + } + return !/^[A-Za-z][A-Za-z0-9+.-]*:/.test(trimmed) +} + +function quoteTomlPath(value: string): string { + return canUseTomlLiteralString(value) ? `'${value}'` : quoteTomlBasicString(value) +} + +// Why: TOML literal strings allow tab but no other control chars and no +// single quote; anything else must go through an escaped basic string. +function canUseTomlLiteralString(value: string): boolean { + for (const char of value) { + if (char === "'") { + return false + } + const codePoint = char.codePointAt(0) ?? 0 + if ((codePoint < 0x20 && codePoint !== 0x09) || codePoint === 0x7f) { + return false + } + } + return true +} + +function quoteTomlBasicString(value: string): string { + let quoted = '"' + for (const char of value) { + if (char === '"' || char === '\\') { + quoted += `\\${char}` + continue + } + const codePoint = char.codePointAt(0) ?? 0 + if (codePoint < 0x20 || codePoint === 0x7f) { + quoted += `\\u${codePoint.toString(16).toUpperCase().padStart(4, '0')}` + continue + } + quoted += char + } + return `${quoted}"` +} + +function parseTomlSingleLineStringValue(line: string, offset: number): ParsedTomlString | null { + let index = offset + while (line[index] === ' ' || line[index] === '\t') { + index += 1 + } + + if (line.startsWith('"""', index) || line.startsWith("'''", index)) { + return null + } + + const quote = line[index] + if (quote !== '"' && quote !== "'") { + return null + } + + const start = index + index += 1 + let value = '' + while (index < line.length) { + const char = line[index] + if (char === quote) { + return { value, start, end: index + 1 } + } + if (quote === '"' && char === '\\') { + const escaped = parseTomlBasicStringEscape(line, index) + if (!escaped) { + return null + } + value += escaped.value + index = escaped.nextIndex + continue + } + value += char + index += 1 + } + return null +} + +function parseTomlBasicStringEscape( + line: string, + slashIndex: number +): { value: string; nextIndex: number } | null { + const escaped = line[slashIndex + 1] + switch (escaped) { + case 'b': + return { value: '\b', nextIndex: slashIndex + 2 } + case 't': + return { value: '\t', nextIndex: slashIndex + 2 } + case 'n': + return { value: '\n', nextIndex: slashIndex + 2 } + case 'f': + return { value: '\f', nextIndex: slashIndex + 2 } + case 'r': + return { value: '\r', nextIndex: slashIndex + 2 } + case '"': + case '\\': + return { value: escaped, nextIndex: slashIndex + 2 } + case 'u': + return parseTomlUnicodeEscape(line, slashIndex + 2, 4) + case 'U': + return parseTomlUnicodeEscape(line, slashIndex + 2, 8) + default: + return null + } +} + +function parseTomlUnicodeEscape( + line: string, + start: number, + length: number +): { value: string; nextIndex: number } | null { + const raw = line.slice(start, start + length) + if (!new RegExp(`^[0-9a-fA-F]{${length}}$`).test(raw)) { + return null + } + const codePoint = Number.parseInt(raw, 16) + // Why: TOML escapes must be Unicode scalar values; String.fromCodePoint + // accepts lone surrogates, which would round-trip into invalid TOML. + if (codePoint >= 0xd800 && codePoint <= 0xdfff) { + return null + } + try { + return { value: String.fromCodePoint(codePoint), nextIndex: start + length } + } catch { + return null + } +} + +function getTomlHeaderPath(header: string): string { + const trimmed = header.trim() + if (trimmed.startsWith('[[') && trimmed.endsWith(']]')) { + return trimmed.slice(2, -2).trim() + } + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + return trimmed.slice(1, -1).trim() + } + return '' +} diff --git a/src/main/codex/config-toml-line-scan.ts b/src/main/codex/config-toml-line-scan.ts new file mode 100644 index 000000000..c0136be9e --- /dev/null +++ b/src/main/codex/config-toml-line-scan.ts @@ -0,0 +1,115 @@ +// Why: Orca edits Codex config.toml byte-preservingly (no TOML dependency), so +// every editor must agree on which lines sit inside multiline strings or +// arrays vs real TOML structure. Keep the line-scanner in one place to avoid +// drift. + +export type TomlLineScanState = { + basic: boolean + literal: boolean + arrayDepth: number +} + +type TomlMultilineMode = 'basic' | 'literal' | null + +export function createTomlLineScanState(): TomlLineScanState { + return { basic: false, literal: false, arrayDepth: 0 } +} + +// Why: lines inside multiline strings or unclosed arrays can look exactly like +// `[section]` headers or `key = value` pairs but are data, not structure. +export function isTomlStructuralLine(state: TomlLineScanState): boolean { + return !state.basic && !state.literal && state.arrayDepth === 0 +} + +export function updateTomlLineScanState(state: TomlLineScanState, line: string): TomlLineScanState { + let mode: TomlMultilineMode = state.basic ? 'basic' : state.literal ? 'literal' : null + let arrayDepth = state.arrayDepth + let index = 0 + while (index < line.length) { + if (mode === 'basic') { + if (line[index] === '\\') { + index += 2 + continue + } + if (line.startsWith('"""', index)) { + mode = null + index += 3 + continue + } + index += 1 + continue + } + if (mode === 'literal') { + if (line.startsWith("'''", index)) { + mode = null + index += 3 + continue + } + index += 1 + continue + } + + const char = line[index] + if (char === '#') { + break + } + if (line.startsWith('"""', index)) { + mode = 'basic' + index += 3 + continue + } + if (line.startsWith("'''", index)) { + mode = 'literal' + index += 3 + continue + } + if (char === '"') { + index = skipTomlBasicString(line, index + 1) + continue + } + if (char === "'") { + index = skipTomlLiteralString(line, index + 1) + continue + } + // Why: table-header brackets balance within their line, so a depth that + // stays positive across lines means a multiline array is still open. + if (char === '[') { + arrayDepth += 1 + index += 1 + continue + } + if (char === ']') { + arrayDepth = Math.max(0, arrayDepth - 1) + index += 1 + continue + } + index += 1 + } + return { basic: mode === 'basic', literal: mode === 'literal', arrayDepth } +} + +export function getTomlTableHeader(line: string): string | null { + const match = /^(\s*\[\[?.+\]\]?\s*)(?:#.*)?$/.exec(line) + return match?.[1] ?? null +} + +function skipTomlBasicString(line: string, startIndex: number): number { + let index = startIndex + while (index < line.length) { + const char = line[index] + if (char === '\\') { + index += 2 + continue + } + if (char === '"') { + return index + 1 + } + index += 1 + } + return index +} + +function skipTomlLiteralString(line: string, startIndex: number): number { + const endIndex = line.indexOf("'", startIndex) + return endIndex === -1 ? line.length : endIndex + 1 +} diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index afc9c0d36..d587f044d 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -11,6 +11,11 @@ import { dirname, join } from 'node:path' import { createHash, randomUUID } from 'node:crypto' import { escapeRegex } from '../../shared/string-utils' import { copyFileWithWindowsRetry, renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' +import { + createTomlLineScanState, + isTomlStructuralLine, + updateTomlLineScanState +} from './config-toml-line-scan' // Why: Codex 0.129+ gates each hook on a `trusted_hash` entry in // ~/.codex/config.toml under [hooks.state.""]. Without it the hook is in @@ -476,16 +481,14 @@ export function normalizeHookTrustKeyForLookup(key: string): string { function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] { const ranges: TrustBlockRange[] = [] let cursor = 0 - let multilineState: TomlMultilineState = { basic: false, literal: false } + let scanState = createTomlLineScanState() while (cursor < content.length) { const newlineIdx = content.indexOf('\n', cursor) const lineEnd = newlineIdx === -1 ? content.length : newlineIdx const rawLine = content.slice(cursor, lineEnd) const line = rawLine.replace(/\r$/, '') const nextCursor = newlineIdx === -1 ? content.length : newlineIdx + 1 - const headerKey = isInsideTomlMultilineString(multilineState) - ? null - : parseHookStateHeaderKey(line) + const headerKey = isTomlStructuralLine(scanState) ? parseHookStateHeaderKey(line) : null if ( headerKey !== null && normalizeHookTrustKeyForLookup(headerKey) === normalizeHookTrustKeyForLookup(key) @@ -498,7 +501,7 @@ function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] { cursor = Math.max(blockEnd, nextCursor) continue } - multilineState = updateTomlMultilineState(multilineState, line) + scanState = updateTomlLineScanState(scanState, line) cursor = nextCursor } return ranges @@ -596,13 +599,13 @@ function skipTomlInlineWhitespace(line: string, startIndex: number): number { // a flat regex misclassifies both cases. function findNextTableHeader(text: string): number { let cursor = 0 - let multilineState: TomlMultilineState = { basic: false, literal: false } + let scanState = createTomlLineScanState() while (cursor < text.length) { const newlineIdx = text.indexOf('\n', cursor) const lineEnd = newlineIdx === -1 ? text.length : newlineIdx const rawLine = text.slice(cursor, lineEnd) const line = rawLine.replace(/\r$/, '') - if (!isInsideTomlMultilineString(multilineState)) { + if (isTomlStructuralLine(scanState)) { const trimmed = line.trimStart() // Why: stop at both `[table]` and `[[array.of.tables]]` — both end our // block. Skipping `[[ ]]` here would let our slice consume past array @@ -611,7 +614,7 @@ function findNextTableHeader(text: string): number { return cursor } } - multilineState = updateTomlMultilineState(multilineState, line) + scanState = updateTomlLineScanState(scanState, line) if (newlineIdx === -1) { return -1 } @@ -678,92 +681,6 @@ function isCompleteTableHeader(line: string): boolean { return false } -type TomlMultilineState = { - basic: boolean - literal: boolean -} - -type TomlMultilineMode = 'basic' | 'literal' | null - -function isInsideTomlMultilineString(state: TomlMultilineState): boolean { - return state.basic || state.literal -} - -function updateTomlMultilineState(state: TomlMultilineState, line: string): TomlMultilineState { - let mode: TomlMultilineMode = state.basic ? 'basic' : state.literal ? 'literal' : null - let index = 0 - while (index < line.length) { - if (mode === 'basic') { - if (line[index] === '\\') { - index += 2 - continue - } - if (line.startsWith('"""', index)) { - mode = null - index += 3 - continue - } - index++ - continue - } - if (mode === 'literal') { - if (line.startsWith("'''", index)) { - mode = null - index += 3 - continue - } - index++ - continue - } - - const char = line[index] - if (char === '#') { - break - } - if (line.startsWith('"""', index)) { - mode = 'basic' - index += 3 - continue - } - if (line.startsWith("'''", index)) { - mode = 'literal' - index += 3 - continue - } - if (char === '"') { - index = skipTomlBasicString(line, index + 1) - continue - } - if (char === "'") { - index = skipTomlLiteralString(line, index + 1) - continue - } - index++ - } - return { basic: mode === 'basic', literal: mode === 'literal' } -} - -function skipTomlBasicString(line: string, startIndex: number): number { - let index = startIndex - while (index < line.length) { - const char = line[index] - if (char === '\\') { - index += 2 - continue - } - if (char === '"') { - return index + 1 - } - index++ - } - return index -} - -function skipTomlLiteralString(line: string, startIndex: number): number { - const endIndex = line.indexOf("'", startIndex) - return endIndex === -1 ? line.length : endIndex + 1 -} - // Why: same atomic-rename + .bak rotation pattern as writeHooksJson — a // half-written config.toml can brick a user's Codex install, so write to // tmp and rename. Random-suffix tmp name avoids cross-process races on @@ -830,14 +747,14 @@ export function readHookTrustEntries(configPath: string): Map