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:
Brennan Benson 2026-07-02 17:01:41 -07:00 committed by GitHub
parent 5da41d4ed6
commit 99bc693cd5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 614 additions and 206 deletions

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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(),

View File

@ -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
}

View File

@ -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 ''
}

View File

@ -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
}

View File

@ -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