Fix Codex config paths in managed runtime home (#7157)
* Fix mirrored Codex relative config paths Orca mirrors ~/.codex/config.toml into a managed CODEX_HOME before launching Codex. Relative path-valued Codex settings were then resolved from the runtime home instead of the user's real Codex home, which made config loading fail in Orca while the same CLI worked in a normal terminal. Rewrite known relative path settings to absolute paths rooted at the system Codex home while preserving runtime-owned trust sections. * Dedupe Codex TOML line scanner and include path rewrite in CLI tsconfig * Harden Codex config path rewrite and cover managed account homes - Track multiline arrays in the shared TOML line scanner so array lines are never mistaken for table headers or path keys - Escape control characters and reject lone-surrogate unicode escapes so the rewritten runtime config always stays valid TOML - Extend the rewrite allowlist with profiles.* file settings and debug.config_lockfile.* (both can abort Codex config loading) - Rewrite relative paths when mirroring the canonical config into managed account homes (codex login CODEX_HOMEs), anchoring WSL accounts to the Linux-side ~/.codex with posix join semantics --------- Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
parent
5da41d4ed6
commit
99bc693cd5
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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<string>()
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ''
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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."<key>"]. 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<string, CodexHookT
|
|||
// Why: walk line-by-line so `[hooks.state."..."]` inside a `"""..."""` or
|
||||
// `'''...'''` multi-line string isn't mistaken for a real header.
|
||||
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 key = isInsideTomlMultilineString(multilineState) ? null : parseHookStateHeaderKey(line)
|
||||
const key = isTomlStructuralLine(scanState) ? parseHookStateHeaderKey(line) : null
|
||||
if (key !== null) {
|
||||
// Why: block ends at the next *real* header (multi-line aware).
|
||||
const after = content.slice(nextCursor)
|
||||
|
|
@ -863,7 +780,7 @@ export function readHookTrustEntries(configPath: string): Map<string, CodexHookT
|
|||
cursor = nextCursor
|
||||
continue
|
||||
}
|
||||
multilineState = updateTomlMultilineState(multilineState, line)
|
||||
scanState = updateTomlLineScanState(scanState, line)
|
||||
cursor = nextCursor
|
||||
}
|
||||
return result
|
||||
|
|
|
|||
Loading…
Reference in New Issue