From 31e974d01c582c4aca1c02026c4e361dd682f037 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:24:40 -0700 Subject: [PATCH 01/25] chore(deps): add smol-toml as a direct dependency for checked Codex config edits --- package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/package.json b/package.json index 0b41ed3980ac..a7adbfab7023 100644 --- a/package.json +++ b/package.json @@ -193,6 +193,7 @@ "react-i18next": "17.0.15", "serve-sim": "0.1.40", "sherpa-onnx": "1.12.37", + "smol-toml": "^1.8.0", "ssh2": "^1.17.0", "tldts": "7.4.14", "tweetnacl": "^1.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b5c4b890d44..998f7807032c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,6 +181,9 @@ importers: sherpa-onnx: specifier: 1.12.37 version: 1.12.37 + smol-toml: + specifier: ^1.8.0 + version: 1.8.0 ssh2: specifier: ^1.17.0 version: 1.17.0 From 4faaadc4bcc65a73d3213fab0d17a36eacccb307 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:46:48 -0700 Subject: [PATCH 02/25] fix(codex): edit Codex config.toml through one parser-checked writer Fixes #22592. After Orca started a Codex agent, every `codex` command (including the Codex app and Codex outside Orca) failed with "duplicate key" in ~/.codex/config.toml. Codex had written the project as `["projects".""]`; Orca's text matchers only knew the bare `[projects.""]` spelling, so Orca appended a second table, added a second `trust_level` beside a quoted `"trust_level"`, and its mirror into Orca-managed Codex homes kept both spellings (and a second `[hooks.state]`). TOML forbids all of these. Now every Orca edit of a Codex config.toml finds tables and keys by their decoded key path (quoted, bare, literal, spaced, dotted or inline), decides with a real TOML parse (smol-toml, 64-bit integers included), and keeps its text-level edit so the user's comments and formatting survive. Each result is checked before it is written: it must parse, hold the intended value, and leave every other value unchanged. A file that does not parse is repaired only where the damage is the exact shape earlier Orca builds wrote (never a table under an array of tables, and never if a removed value would be lost); otherwise the edit is refused with the file and line, and the launch continues. The same check covers the local atomic writer, the managed-home mirror, settings promotion, the legacy profile cleanup, the SSH relay trust write and the SFTP hook-trust write. While ~/.codex does not parse, a managed Codex home whose config parses is left untouched (logged once and recorded), so state only it holds survives an invalid save; a fresh or equally unreadable managed home gets an unchanged copy so Orca-launched Codex shows Codex's own error, like `codex` typed by hand. Settings are never promoted from, or merged out of, a managed config that does not parse, and one that is reseeded is kept as `.bak`. smol-toml is loaded with createRequire (its declarations are ESM-only, and the CLI builds as CommonJS) and shipped as a packaged runtime dependency so the packaged app and CLI can find it. --- config/packaged-runtime-node-modules.cjs | 2 + config/tsconfig.cli.json | 8 + .../codex-config-legacy-shared-home-mirror.ts | 107 ++++++ ...dex-config-mirror-duplicate-repair.test.ts | 321 ++++++++++++++++++ src/main/codex/codex-config-mirror-merge.ts | 86 +++++ src/main/codex/codex-config-mirror-source.ts | 40 +++ src/main/codex/codex-config-mirror.test.ts | 9 +- src/main/codex/codex-config-mirror.ts | 251 +++++--------- .../codex-config-toml-checked-edit.test.ts | 312 +++++++++++++++++ .../codex/codex-config-toml-checked-edit.ts | 191 +++++++++++ src/main/codex/codex-config-toml-document.ts | 134 ++++++++ src/main/codex/codex-config-toml-repair.ts | 218 ++++++++++++ src/main/codex/codex-config-toml-structure.ts | 160 +++++++++ src/main/codex/codex-hook-legacy-cleanup.ts | 2 +- src/main/codex/codex-hook-user-mirroring.ts | 51 +-- .../codex/codex-managed-config-validity.ts | 210 ++++++++++++ src/main/codex/config-settings-promotion.ts | 58 +++- .../codex/config-toml-hook-trust-blocks.ts | 49 ++- src/main/codex/config-toml-hook-trust-edit.ts | 143 ++++++-- src/main/codex/config-toml-hook-trust-read.ts | 75 ++-- src/main/codex/config-toml-project-trust.ts | 177 +++++++--- .../config-toml-runtime-owned-sections.ts | 52 ++- src/main/codex/config-toml-syntax.ts | 83 +---- .../config-toml-trust-stale-writes.test.ts | 17 +- src/main/codex/config-toml-trust.ts | 64 +++- src/main/remote-agent-trust-presets.test.ts | 35 ++ src/main/remote-agent-trust-presets.ts | 17 +- 27 files changed, 2438 insertions(+), 434 deletions(-) create mode 100644 src/main/codex/codex-config-legacy-shared-home-mirror.ts create mode 100644 src/main/codex/codex-config-mirror-duplicate-repair.test.ts create mode 100644 src/main/codex/codex-config-mirror-merge.ts create mode 100644 src/main/codex/codex-config-mirror-source.ts create mode 100644 src/main/codex/codex-config-toml-checked-edit.test.ts create mode 100644 src/main/codex/codex-config-toml-checked-edit.ts create mode 100644 src/main/codex/codex-config-toml-document.ts create mode 100644 src/main/codex/codex-config-toml-repair.ts create mode 100644 src/main/codex/codex-config-toml-structure.ts create mode 100644 src/main/codex/codex-managed-config-validity.ts diff --git a/config/packaged-runtime-node-modules.cjs b/config/packaged-runtime-node-modules.cjs index c88d70350eb0..206b5e29afd3 100644 --- a/config/packaged-runtime-node-modules.cjs +++ b/config/packaged-runtime-node-modules.cjs @@ -28,6 +28,8 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [ // serve-sim (for CLI JS entry + closure + state/middleware + to make packaged require('serve-sim') + its internal relatives work; mirrors other runtime JS like ws/yaml/zod. Natives/dylibs still via extraResources + the node_modules/serve-sim copy in resources from builder. Client if added too. 'serve-sim', 'qrcode', + // smol-toml: Codex config.toml reads/validation in main and the CLI (loaded via createRequire). + 'smol-toml', 'ssh2', 'tweetnacl', 'ws', diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 85f3529b2dcb..a7f697f304e9 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -116,6 +116,14 @@ "../src/main/codex/codex-app-server-record-reader.ts", "../src/main/codex/codex-app-server-session.ts", "../src/main/codex/codex-config-mirror.ts", + "../src/main/codex/codex-config-legacy-shared-home-mirror.ts", + "../src/main/codex/codex-config-mirror-merge.ts", + "../src/main/codex/codex-config-mirror-source.ts", + "../src/main/codex/codex-config-toml-checked-edit.ts", + "../src/main/codex/codex-config-toml-document.ts", + "../src/main/codex/codex-config-toml-repair.ts", + "../src/main/codex/codex-config-toml-structure.ts", + "../src/main/codex/codex-managed-config-validity.ts", "../src/main/codex/codex-config-path-reference-rewrite.ts", "../src/main/codex/codex-config-settings-preservation.ts", "../src/main/codex/codex-config-settings-removal.ts", diff --git a/src/main/codex/codex-config-legacy-shared-home-mirror.ts b/src/main/codex/codex-config-legacy-shared-home-mirror.ts new file mode 100644 index 000000000000..a71cdeaa7445 --- /dev/null +++ b/src/main/codex/codex-config-legacy-shared-home-mirror.ts @@ -0,0 +1,107 @@ +import { join } from 'node:path' +import { + recoverInterruptedGuardedFileOperation, + writeFileAtomicallyIfUnchanged +} from '../codex-accounts/fs-utils' +import { observeAgentStateFile } from './codex-path-observation' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' +import type { CodexSettingsPromotionHomes } from './config-settings-promotion' +import { applyCodexDaemonSocketGuard } from './codex-daemon-socket-path-guard' +import { + applyUnparseableCodexSourceRule, + backUpDiscardedManagedConfig, + findUnparseableManagedCodexConfig, + refuseUnparseableManagedConfig +} from './codex-managed-config-validity' +import { mergeSystemCodexConfigIntoRuntime } from './codex-config-mirror-merge' +import { + prepareSystemConfigForFreshRuntimeMirror, + prepareSystemConfigForRuntimeMirror, + resolveCodexConfigMirrorSourceDirectory +} from './codex-config-mirror-source' + +/** + * Refreshes the retired shared home for PTYs that survived real-home rollout. + * + * This is deliberately one-way: a retained PTY may hold pre-rollout settings, + * so treating that home as a promotion source could overwrite the live config. + */ +export function syncSystemConfigIntoLegacySharedCodexHome( + homes: CodexSettingsPromotionHomes = { + runtimeHomePath: getOrcaManagedCodexHomePath(), + systemHomePath: getSystemCodexHomePath() + } +): void { + const systemConfigPath = join(homes.systemHomePath, 'config.toml') + const runtimeConfigPath = join(homes.runtimeHomePath, 'config.toml') + recoverInterruptedGuardedFileOperation(runtimeConfigPath) + const systemConfigObservation = observeAgentStateFile(systemConfigPath) + if (systemConfigObservation.kind === 'indeterminate') { + throw systemConfigObservation.error + } + const rawSystemConfig = + systemConfigObservation.kind === 'present' ? systemConfigObservation.value : '' + const runtimeConfigObservation = observeAgentStateFile(runtimeConfigPath) + if (runtimeConfigObservation.kind === 'indeterminate') { + throw runtimeConfigObservation.error + } + const runtimeConfigBeforeMirror = + runtimeConfigObservation.kind === 'present' ? runtimeConfigObservation.value : null + if ( + applyUnparseableCodexSourceRule({ + sourcePath: systemConfigPath, + runtimeConfigPath, + source: rawSystemConfig, + runtime: runtimeConfigBeforeMirror, + writeVerbatimCopy: () => + writeFileAtomicallyIfUnchanged( + runtimeConfigPath, + runtimeConfigBeforeMirror, + rawSystemConfig + ) + }) + ) { + return + } + const runtimeParses = + runtimeConfigBeforeMirror !== null && + findUnparseableManagedCodexConfig(runtimeConfigBeforeMirror) === null + // Why: a missing cloud-synced source is not proof the user cleared config. + let mirroredRuntimeConfig = runtimeConfigBeforeMirror ?? '' + if (rawSystemConfig.trim() !== '') { + const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(homes.systemHomePath) + // The retired home has no ownership baseline; its entire MCP root stays canonical. + // Why: sections are never carried out of a managed config Codex cannot parse. + mirroredRuntimeConfig = + runtimeConfigBeforeMirror !== null && runtimeParses + ? mergeSystemCodexConfigIntoRuntime( + runtimeConfigBeforeMirror, + prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir), + new Set(), + true + ) + : prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir) + } + // Why: retained pre-rollout panes still use this home, so a refresh must keep the daemon guard. + const nextRuntimeConfig = applyCodexDaemonSocketGuard( + mirroredRuntimeConfig, + homes.runtimeHomePath + ) + if ( + (runtimeConfigBeforeMirror ?? '') === nextRuntimeConfig || + refuseUnparseableManagedConfig(runtimeConfigPath, nextRuntimeConfig) + ) { + return + } + if (rawSystemConfig.trim() !== '') { + backUpDiscardedManagedConfig({ + sourcePath: systemConfigPath, + runtimeConfigPath, + source: rawSystemConfig, + discarded: runtimeConfigBeforeMirror + }) + } + // Why: stage first, then compare immediately before replace so a retained + // Codex trust write during mirror preparation wins. + writeFileAtomicallyIfUnchanged(runtimeConfigPath, runtimeConfigBeforeMirror, nextRuntimeConfig) +} diff --git a/src/main/codex/codex-config-mirror-duplicate-repair.test.ts b/src/main/codex/codex-config-mirror-duplicate-repair.test.ts new file mode 100644 index 000000000000..04f46a6e3aee --- /dev/null +++ b/src/main/codex/codex-config-mirror-duplicate-repair.test.ts @@ -0,0 +1,321 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import type * as NodeOs from 'node:os' +import { join } from 'node:path' +import { parse } from 'smol-toml' + +// Why: temp homes exceed sun_path on macOS but not on Linux; keep asserted config bytes host-independent. +vi.mock('./codex-daemon-socket-path-guard', async (importOriginal) => ({ + ...(await importOriginal()), + applyCodexDaemonSocketGuard: (config: string) => config +})) + +const { getPathMock, homedirMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { + ...actual, + homedir: homedirMock + } +}) + +import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' +import { getRecentCodexMirrorRefusal } from './codex-managed-config-validity' + +let fakeHomeDir: string +let userDataDir: string +let previousUserDataPath: string | undefined + +function getSystemCodexHomePath(): string { + return join(fakeHomeDir, '.codex') +} + +function getSystemConfigPath(): string { + return join(getSystemCodexHomePath(), 'config.toml') +} + +function getRuntimeConfigPath(): string { + return join(userDataDir, 'codex-runtime-home', 'home', 'config.toml') +} + +beforeEach(() => { + fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-config-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-config-user-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(fakeHomeDir) + getPathMock.mockImplementation((name: string) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + }) + mkdirSync(getSystemCodexHomePath(), { recursive: true }) +}) + +afterEach(() => { + rmSync(fakeHomeDir, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +describe('managed-home mirror never writes a config Codex cannot read (#22592)', () => { + it('collapses both project spellings and a doubled [hooks.state] in an existing managed home (#22592)', () => { + writeFileSync( + getSystemConfigPath(), + 'model = "m"\n\n["projects"."/repo"]\ntrust_level = "trusted"\n', + 'utf-8' + ) + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + [ + 'model = "m"', + '', + '["projects"."/repo"]', + 'trust_level = "trusted"', + '', + '[projects."/repo"]', + 'trust_level = "trusted"', + '', + '[hooks.state]', + '', + '[hooks.state]', + '', + '[hooks.state."/rt/hooks.json:stop:0:0"]', + 'enabled = true', + 'trusted_hash = "sha256:x"', + '' + ].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(parse(runtimeConfig)).toEqual({ + model: 'm', + projects: { '/repo': { trust_level: 'trusted' } }, + hooks: { state: { '/rt/hooks.json:stop:0:0': { enabled: true, trusted_hash: 'sha256:x' } } } + }) + }) + + it('drops a runtime project table that ~/.codex defines inline', () => { + writeFileSync( + getSystemConfigPath(), + '[projects]\n"/repo" = { trust_level = "untrusted" }\n', + 'utf-8' + ) + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync(getRuntimeConfigPath(), '[projects."/repo"]\ntrust_level = "trusted"\n', 'utf-8') + + syncSystemConfigIntoManagedCodexHome() + + expect(parse(readFileSync(getRuntimeConfigPath(), 'utf-8'))).toEqual({ + projects: { '/repo': { trust_level: 'untrusted' } } + }) + }) + + it('copies an unparseable ~/.codex verbatim so Orca-launched Codex fails like hand-typed codex', () => { + const broken = '[mcp_servers.a]\ncommand = "x"\n[mcp_servers.a]\ncommand = "y"\n' + writeFileSync(getSystemConfigPath(), broken, 'utf-8') + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync(getRuntimeConfigPath(), 'model = "stale"\n[a\n', 'utf-8') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + syncSystemConfigIntoManagedCodexHome() + syncSystemConfigIntoManagedCodexHome() + + expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe(broken) + const copies = warn.mock.calls + .flat() + .filter((line) => String(line).includes('copied it unchanged')) + expect(copies).toHaveLength(1) + warn.mockRestore() + }) + + it('collapses Orca\u2019s own duplicate hook tables instead of reseeding, so managed-only trust survives', () => { + writeFileSync(getSystemConfigPath(), 'model = "m"\n', 'utf-8') + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + const key = '/rt/hooks.json:stop:0:0' + writeFileSync( + getRuntimeConfigPath(), + [ + 'model = "m"', + '', + '[projects."/answered-in-orca"]', + 'trust_level = "trusted"', + '', + `[hooks.state."${key}"]`, + 'trusted_hash = "sha256:a"', + '', + `[hooks.state."${key}"]`, + 'trusted_hash = "sha256:b"', + '' + ].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + expect(parse(readFileSync(getRuntimeConfigPath(), 'utf-8'))).toEqual({ + model: 'm', + projects: { '/answered-in-orca': { trust_level: 'trusted' } }, + hooks: { state: { [key]: { trusted_hash: 'sha256:a' } } } + }) + expect(existsSync(`${getRuntimeConfigPath()}.bak`)).toBe(false) + }) + + it('keeps a discarded unparseable managed config as .bak when it differs from ~/.codex', () => { + writeFileSync(getSystemConfigPath(), 'model = "A"\n', 'utf-8') + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + const discarded = 'model = "mine"\n[a\n' + writeFileSync(getRuntimeConfigPath(), discarded, 'utf-8') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + syncSystemConfigIntoManagedCodexHome() + + expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "A"\n') + expect(readFileSync(`${getRuntimeConfigPath()}.bak`, 'utf-8')).toBe(discarded) + expect(warn.mock.calls.flat().join(' ')).toContain('.bak') + warn.mockRestore() + }) + + it('keeps no .bak when the managed config it replaces still parses', () => { + writeFileSync(getSystemConfigPath(), 'model = "B"\n', 'utf-8') + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync(getRuntimeConfigPath(), 'model = "A"\n', 'utf-8') + + syncSystemConfigIntoManagedCodexHome() + + expect(existsSync(`${getRuntimeConfigPath()}.bak`)).toBe(false) + }) + + it('leaves a managed config that parses untouched while ~/.codex is broken, and records why', () => { + writeFileSync(getSystemConfigPath(), 'model = "A"\nbroken = \n', 'utf-8') + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync(getRuntimeConfigPath(), 'model = "A"\n', 'utf-8') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + syncSystemConfigIntoManagedCodexHome() + syncSystemConfigIntoManagedCodexHome() + + expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "A"\n') + expect(getRecentCodexMirrorRefusal(getRuntimeConfigPath())).toContain('left') + expect(warn.mock.calls.flat().filter((line) => String(line).includes('left '))).toHaveLength(1) + warn.mockRestore() + }) + + it('seeds a fresh managed home with the same verbatim copy', () => { + writeFileSync(getSystemConfigPath(), 'log_dir = "logs\\uD800dir"\n', 'utf-8') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + syncSystemConfigIntoManagedCodexHome() + + expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('log_dir = "logs\\uD800dir"\n') + warn.mockRestore() + }) +}) + +// Why: an invalid save of ~/.codex followed by a fix must end exactly where the +// same edits without the invalid save end; nothing managed-only may be lost and +// nothing stale may be promoted into ~/.codex. +describe('break-then-fix of ~/.codex keeps managed-home state', () => { + function makeHomes(): { + homes: { systemHomePath: string; runtimeHomePath: string } + root: string + } { + const root = mkdtempSync(join(tmpdir(), 'orca-break-fix-')) + const homes = { systemHomePath: join(root, 'sys'), runtimeHomePath: join(root, 'rt') } + mkdirSync(homes.systemHomePath, { recursive: true }) + mkdirSync(homes.runtimeHomePath, { recursive: true }) + return { homes, root } + } + + function runInCodexChangesThenFix(breakFirst: boolean): { system: string; runtime: string } { + const { homes, root } = makeHomes() + const sysPath = join(homes.systemHomePath, 'config.toml') + const rtPath = join(homes.runtimeHomePath, 'config.toml') + const good = 'model = "A"\n\n[mcp_servers.keep]\ncommand = "k"\n' + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + writeFileSync(sysPath, good) + syncSystemConfigIntoManagedCodexHome(homes) + // Codex inside Orca: /model B, an MCP server added there, a trust answer. + writeFileSync( + rtPath, + `${readFileSync(rtPath, 'utf8').replace('model = "A"', 'model = "B"')}\n[mcp_servers.mine]\ncommand = "m"\n\n[projects."/sub"]\ntrust_level = "trusted"\n` + ) + if (breakFirst) { + writeFileSync(sysPath, `${good}broken = \n`) + syncSystemConfigIntoManagedCodexHome(homes) + } + writeFileSync(sysPath, good) + syncSystemConfigIntoManagedCodexHome(homes) + syncSystemConfigIntoManagedCodexHome(homes) + return { system: readFileSync(sysPath, 'utf8'), runtime: readFileSync(rtPath, 'utf8') } + } finally { + warn.mockRestore() + rmSync(root, { recursive: true, force: true }) + } + } + + it('ends where the same edits without the invalid save end', () => { + const withBreak = runInCodexChangesThenFix(true) + expect(withBreak).toEqual(runInCodexChangesThenFix(false)) + expect(withBreak.runtime).toContain('[mcp_servers.mine]') + expect(withBreak.runtime).toContain('[projects."/sub"]') + }) + + it('never promotes a value from the broken save into ~/.codex', () => { + const { homes, root } = makeHomes() + const sysPath = join(homes.systemHomePath, 'config.toml') + const rtPath = join(homes.runtimeHomePath, 'config.toml') + const good = + 'model = "A"\nsandbox_mode = "workspace-write"\n\n[mcp_servers.keep]\ncommand = "k"\n' + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + writeFileSync(sysPath, good) + syncSystemConfigIntoManagedCodexHome(homes) + writeFileSync( + rtPath, + `${readFileSync(rtPath, 'utf8')}\n[projects."/w"]\ntrust_level = "trusted"\n` + ) + syncSystemConfigIntoManagedCodexHome(homes) + writeFileSync( + sysPath, + 'model = "A"\nsandbox_mode = "danger-full-access"\nbroken = \n\n[mcp_servers.keep]\ncommand = "k"\n\n[mcp_servers.tmp]\ncommand = "t"\n' + ) + syncSystemConfigIntoManagedCodexHome(homes) + writeFileSync(sysPath, good) + syncSystemConfigIntoManagedCodexHome(homes) + syncSystemConfigIntoManagedCodexHome(homes) + + expect(readFileSync(sysPath, 'utf8')).toBe(good) + const runtime = readFileSync(rtPath, 'utf8') + expect(runtime).toContain('sandbox_mode = "workspace-write"') + expect(runtime).toContain('[projects."/w"]') + expect(runtime).not.toContain('mcp_servers.tmp') + expect(runtime).not.toContain('danger-full-access') + } finally { + warn.mockRestore() + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/codex/codex-config-mirror-merge.ts b/src/main/codex/codex-config-mirror-merge.ts new file mode 100644 index 000000000000..afc0680e05ff --- /dev/null +++ b/src/main/codex/codex-config-mirror-merge.ts @@ -0,0 +1,86 @@ +import { readMcpServerTomlOwnership } from './config-toml-mcp-servers' +import { + getProjectsDefinedOutsideTables, + repairUnparseableCodexConfig +} from './codex-managed-config-validity' +import { + deduplicateProjectTomlSections, + getMcpServerTomlSectionName, + getProjectTrustLevel, + getRevocationTomlSectionHeaderKey, + getTomlSectionHeaderKey, + getTomlSections, + isRuntimePreservedTomlSection, + isRuntimeProjectTomlSection, + joinTomlBlocks, + stripRuntimeOwnedTomlSections +} from './config-toml-runtime-owned-sections' + +/** Ordinary settings from ~/.codex plus the trust and MCP tables the managed home owns. */ +export function mergeSystemCodexConfigIntoRuntime( + runtimeConfig: string, + systemConfig: string, + mirroredMcpServerNames: ReadonlySet = new Set(), + mirroredMcpServerRoot = false +): string { + const systemInlineProjects = getProjectsDefinedOutsideTables(systemConfig) + const runtimeSections = deduplicateProjectTomlSections( + getTomlSections(repairUnparseableCodexConfig(runtimeConfig)) + ).filter( + // Why: ~/.codex defines this project inline or with dotted keys, so a + // runtime `[projects."…"]` table beside it would redefine it. + (section) => + !isRuntimeProjectTomlSection(section.header) || + !systemInlineProjects.has(getTomlSectionHeaderKey(section.header)) + ) + const runtimeProjectHeaders = new Set( + runtimeSections + .filter((section) => isRuntimeProjectTomlSection(section.header)) + .map((section) => getTomlSectionHeaderKey(section.header)) + ) + const systemProjectSections = deduplicateProjectTomlSections( + getTomlSections(systemConfig) + ).filter((section) => isRuntimeProjectTomlSection(section.header)) + const systemUntrustedProjectHeaders = new Set( + systemProjectSections + .filter((section) => getProjectTrustLevel(section.block) === 'untrusted') + .map((section) => getRevocationTomlSectionHeaderKey(section.header)) + ) + // Why: an exact-cased trusted entry in ~/.codex is the user's latest explicit + // decision for that exact project; a loosely-matched (case-drifted) revocation + // must not override it, or re-granting trust would be reverted every mirror. + const systemTrustedProjectHeaders = new Set( + systemProjectSections + .filter((section) => getProjectTrustLevel(section.block) === 'trusted') + .map((section) => getTomlSectionHeaderKey(section.header)) + ) + const systemMcpServers = readMcpServerTomlOwnership(systemConfig) + // Why: ordinary Codex settings should mirror ~/.codex exactly; runtime hook + // trust and project trust are written under Orca's managed CODEX_HOME and + // must survive the copy unless the user explicitly revoked project trust in + // the system config. + return joinTomlBlocks([ + stripRuntimeOwnedTomlSections(systemConfig, runtimeProjectHeaders), + ...runtimeSections + .filter((section) => { + if (isRuntimePreservedTomlSection(section.header)) { + return true + } + const mcpServerName = getMcpServerTomlSectionName(section.header) + return ( + mcpServerName !== null && + !systemMcpServers.ownsRoot && + !mirroredMcpServerRoot && + !systemMcpServers.names.has(mcpServerName) && + !mirroredMcpServerNames.has(mcpServerName) + ) + }) + .filter( + (section) => + !isRuntimeProjectTomlSection(section.header) || + !systemUntrustedProjectHeaders.has(getRevocationTomlSectionHeaderKey(section.header)) || + systemTrustedProjectHeaders.has(getTomlSectionHeaderKey(section.header)) + ) + .map((section) => section.block) + ]) +} diff --git a/src/main/codex/codex-config-mirror-source.ts b/src/main/codex/codex-config-mirror-source.ts new file mode 100644 index 000000000000..b5e2ad86f32b --- /dev/null +++ b/src/main/codex/codex-config-mirror-source.ts @@ -0,0 +1,40 @@ +import { dirname, join } from 'node:path' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { rewriteRelativePathConfigValues } from './codex-config-path-reference-rewrite' +import { normalizeDeprecatedCodexHookFeatureFlag } from './config-toml-deprecated-hook-flag' +import { repairUnparseableCodexConfig } from './codex-managed-config-validity' +import { stripRuntimeOwnedTomlSections } from './config-toml-runtime-owned-sections' + +// Why: how ~/.codex is prepared before it is mirrored into an Orca-owned Codex home. + +export function resolveCodexConfigMirrorSourceDirectory( + systemHomePath: string, + systemConfigDir?: string +): string { + return ( + systemConfigDir ?? + parseWslUncPath(systemHomePath)?.linuxPath ?? + dirname(join(systemHomePath, 'config.toml')) + ) +} + +export function prepareSystemConfigForRuntimeMirror( + config: string, + systemConfigDir: string +): string { + return rewriteRelativePathConfigValues( + normalizeDeprecatedCodexHookFeatureFlag(repairUnparseableCodexConfig(config)), + systemConfigDir + ) +} + +// Why: trust blocks reference a hooks.json path, so system-home hook trust +// entries are not valid in a fresh runtime CODEX_HOME until install remaps +// them. Also seeds WSL runtime homes, where systemConfigDir must be the +// Linux-side ~/.codex the config resolves against inside the distro. +export function prepareSystemConfigForFreshRuntimeMirror( + config: string, + systemConfigDir: string +): string { + return stripRuntimeOwnedTomlSections(prepareSystemConfigForRuntimeMirror(config, systemConfigDir)) +} diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts index 74db1cd9c05b..356e4eddee6e 100644 --- a/src/main/codex/codex-config-mirror.test.ts +++ b/src/main/codex/codex-config-mirror.test.ts @@ -231,12 +231,9 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { }) 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"') + expect( + prepareSystemConfigForFreshRuntimeMirror('log_dir = "logs\\uD800dir"\n', '/home/me/.codex') + ).toContain('log_dir = "logs\\uD800dir"') }) it('leaves absolute, home-prefixed, env-shaped, and URL path references untouched', () => { diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 275e350328a2..85c00edb27a9 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -1,15 +1,8 @@ import { readMcpServerTomlOwnership } from './config-toml-mcp-servers' -import { dirname, join } from 'node:path' +import { join } from 'node:path' import { observeAgentStateFile } from './codex-path-observation' -import { - recoverInterruptedGuardedFileOperation, - writeFileAtomically, - writeFileAtomicallyIfUnchanged -} from '../codex-accounts/fs-utils' +import { writeFileAtomically, writeFileAtomicallyIfUnchanged } from '../codex-accounts/fs-utils' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' -import { rewriteRelativePathConfigValues } from './codex-config-path-reference-rewrite' -import { normalizeDeprecatedCodexHookFeatureFlag } from './config-toml-deprecated-hook-flag' -import { parseWslUncPath } from '../../shared/wsl-paths' import { promoteCodexRuntimeSettingsToSystem, snapshotCodexRuntimeSettingsBaseline, @@ -21,17 +14,29 @@ import { getCodexConfigSyncStatus, reportCodexConfigSyncOutcome } from './config import { preserveRuntimeConflictValues } from './codex-config-settings-preservation' import { applyCodexDaemonSocketGuard } from './codex-daemon-socket-path-guard' import { - deduplicateProjectTomlSections, - getMcpServerTomlSectionName, - getProjectTrustLevel, - getRevocationTomlSectionHeaderKey, - getTomlSectionHeaderKey, - getTomlSections, - isRuntimePreservedTomlSection, - isRuntimeProjectTomlSection, - joinTomlBlocks, - stripRuntimeOwnedTomlSections -} from './config-toml-runtime-owned-sections' + clearCodexConfigTomlEditRefusalReport, + type CodexConfigTomlEditRefusedError, + reportCodexConfigTomlEditRefusal +} from './codex-config-toml-checked-edit' +import { + findManagedConfigRefusal, + applyUnparseableCodexSourceRule, + backUpDiscardedManagedConfig, + findUnparseableManagedCodexConfig, + refuseUnparseableManagedConfig +} from './codex-managed-config-validity' +import { mergeSystemCodexConfigIntoRuntime } from './codex-config-mirror-merge' +import { + prepareSystemConfigForFreshRuntimeMirror, + prepareSystemConfigForRuntimeMirror, + resolveCodexConfigMirrorSourceDirectory +} from './codex-config-mirror-source' + +export { syncSystemConfigIntoLegacySharedCodexHome } from './codex-config-legacy-shared-home-mirror' +export { + prepareSystemConfigForFreshRuntimeMirror, + resolveCodexConfigMirrorSourceDirectory +} from './codex-config-mirror-source' export function syncSystemConfigIntoManagedCodexHome( homes: CodexSettingsPromotionHomes = { @@ -78,6 +83,23 @@ function mirrorSystemConfigIntoManagedCodexHome(homes: CodexSettingsPromotionHom reportCodexConfigSyncOutcome(homes.runtimeHomePath, getCodexConfigSyncStatus(homes), error) return false } + if (mirrorResult.status === 'refused-invalid') { + // Why: writing a config Codex cannot parse breaks every managed-home launch; + // keep the last good copy and advance nothing, like an unreadable source. + reportCodexConfigTomlEditRefusal( + mirrorResult.error, + 'Skipped mirroring the Codex config into a managed home' + ) + return false + } + if (mirrorResult.status === 'refused-unparseable-source') { + // Why: the managed home keeps its own state; nothing mirrored, so nothing advances. + return false + } + if (mirrorResult.status === 'copied-unparseable') { + // Why: a mirror ran (the verbatim copy), so no daemon guard may edit it and no baseline advances from it. + return true + } if (mirrorResult.status === 'refused-indeterminate') { // Why: no mirror ran, so this must behave exactly like the throwing path // above — surface the reason and advance nothing. Advancing the baseline @@ -136,68 +158,20 @@ export function ensureCodexDaemonSocketGuard(runtimeHomePath: string): void { function writeCodexDaemonSocketGuard(runtimeHomePath: string, runtimeConfig: string | null): void { const guarded = applyCodexDaemonSocketGuard(runtimeConfig ?? '', runtimeHomePath) if (guarded !== (runtimeConfig ?? '')) { - writeFileAtomicallyIfUnchanged(join(runtimeHomePath, 'config.toml'), runtimeConfig, guarded) - } -} - -/** - * Refreshes the retired shared home for PTYs that survived real-home rollout. - * - * This is deliberately one-way: a retained PTY may hold pre-rollout settings, - * so treating that home as a promotion source could overwrite the live config. - */ -export function syncSystemConfigIntoLegacySharedCodexHome( - homes: CodexSettingsPromotionHomes = { - runtimeHomePath: getOrcaManagedCodexHomePath(), - systemHomePath: getSystemCodexHomePath() - } -): void { - const systemConfigPath = join(homes.systemHomePath, 'config.toml') - const runtimeConfigPath = join(homes.runtimeHomePath, 'config.toml') - recoverInterruptedGuardedFileOperation(runtimeConfigPath) - const systemConfigObservation = observeAgentStateFile(systemConfigPath) - if (systemConfigObservation.kind === 'indeterminate') { - throw systemConfigObservation.error - } - const rawSystemConfig = - systemConfigObservation.kind === 'present' ? systemConfigObservation.value : '' - const runtimeConfigObservation = observeAgentStateFile(runtimeConfigPath) - if (runtimeConfigObservation.kind === 'indeterminate') { - throw runtimeConfigObservation.error - } - const runtimeConfigBeforeMirror = - runtimeConfigObservation.kind === 'present' ? runtimeConfigObservation.value : null - // Why: a missing cloud-synced source is not proof the user cleared config. - let mirroredRuntimeConfig = runtimeConfigBeforeMirror ?? '' - if (rawSystemConfig.trim() !== '') { - const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(homes.systemHomePath) - // The retired home has no ownership baseline; its entire MCP root stays canonical. - mirroredRuntimeConfig = - runtimeConfigBeforeMirror !== null - ? mergeSystemCodexConfigIntoRuntime( - runtimeConfigBeforeMirror, - prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir), - new Set(), - true - ) - : prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir) - } - // Why: retained pre-rollout panes still use this home, so a refresh must keep the daemon guard. - const nextRuntimeConfig = applyCodexDaemonSocketGuard( - mirroredRuntimeConfig, - homes.runtimeHomePath - ) - if ((runtimeConfigBeforeMirror ?? '') === nextRuntimeConfig) { - return + const runtimeConfigPath = join(runtimeHomePath, 'config.toml') + if (refuseUnparseableManagedConfig(runtimeConfigPath, guarded)) { + return + } + writeFileAtomicallyIfUnchanged(runtimeConfigPath, runtimeConfig, guarded) } - // Why: stage first, then compare immediately before replace so a retained - // Codex trust write during mirror preparation wins. - writeFileAtomicallyIfUnchanged(runtimeConfigPath, runtimeConfigBeforeMirror, nextRuntimeConfig) } type CodexConfigMirrorResult = | { status: 'skipped-missing-source' } + | { status: 'copied-unparseable' } + | { status: 'refused-unparseable-source' } | { status: 'refused-indeterminate'; error: unknown } + | { status: 'refused-invalid'; error: CodexConfigTomlEditRefusedError } | { status: 'mirrored' preservedConflictKeys: ReadonlySet @@ -245,13 +219,41 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe( } } + const existingRuntimeConfig = + runtimeConfigObservation.kind === 'present' ? runtimeConfigObservation.value : null + const unparseableSource = applyUnparseableCodexSourceRule({ + sourcePath: systemConfigPath, + runtimeConfigPath, + source: rawSystemConfig, + runtime: existingRuntimeConfig, + writeVerbatimCopy: () => writeFileAtomically(runtimeConfigPath, rawSystemConfig) + }) + if (unparseableSource) { + return { + status: unparseableSource === 'copied' ? 'copied-unparseable' : 'refused-unparseable-source' + } + } + const runtimeParses = + existingRuntimeConfig !== null && + findUnparseableManagedCodexConfig(existingRuntimeConfig) === null const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(systemHomePath, systemConfigDir) - if (!runtimeConfigExists) { + // Why: a managed config Codex cannot parse (the verbatim copy of a broken ~/.codex) holds nothing to keep; reseed it. + if (!runtimeParses || existingRuntimeConfig === null) { const freshRuntimeConfig = applyCodexDaemonSocketGuard( prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir), runtimeHomePath ) + const refusal = findManagedConfigRefusal(runtimeConfigPath, freshRuntimeConfig) + if (refusal) { + return { status: 'refused-invalid', error: refusal } + } const ownership = readMcpServerTomlOwnership(freshRuntimeConfig) + backUpDiscardedManagedConfig({ + sourcePath: systemConfigPath, + runtimeConfigPath, + source: rawSystemConfig, + discarded: existingRuntimeConfig + }) writeFileAtomically(runtimeConfigPath, freshRuntimeConfig) return { status: 'mirrored', @@ -266,7 +268,7 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe( readMcpServerTomlOwnership(systemConfig) // Why: reuse the bytes already observed above rather than re-reading. A second // read could succeed where the first failed and re-open the gap this closes. - const runtimeConfig = runtimeConfigObservation.value + const runtimeConfig = existingRuntimeConfig const preserved = preserveRuntimeConflictValues( mergeSystemCodexConfigIntoRuntime( runtimeConfig, @@ -278,8 +280,13 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe( ) const nextRuntimeConfig = applyCodexDaemonSocketGuard(preserved.content, runtimeHomePath) if (nextRuntimeConfig !== runtimeConfig) { + const refusal = findManagedConfigRefusal(runtimeConfigPath, nextRuntimeConfig) + if (refusal) { + return { status: 'refused-invalid', error: refusal } + } writeFileAtomically(runtimeConfigPath, nextRuntimeConfig) } + clearCodexConfigTomlEditRefusalReport(runtimeConfigPath) return { status: 'mirrored', preservedConflictKeys: preserved.keys, @@ -287,91 +294,3 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe( mirroredMcpServerRoot } } - -export function resolveCodexConfigMirrorSourceDirectory( - systemHomePath: string, - systemConfigDir?: string -): string { - return ( - systemConfigDir ?? - parseWslUncPath(systemHomePath)?.linuxPath ?? - dirname(join(systemHomePath, 'config.toml')) - ) -} - -function prepareSystemConfigForRuntimeMirror(config: string, systemConfigDir: string): string { - return rewriteRelativePathConfigValues( - normalizeDeprecatedCodexHookFeatureFlag(config), - systemConfigDir - ) -} - -// Why: trust blocks reference a hooks.json path, so system-home hook trust -// entries are not valid in a fresh runtime CODEX_HOME until install remaps -// them. Also seeds WSL runtime homes, where systemConfigDir must be the -// Linux-side ~/.codex the config resolves against inside the distro. -export function prepareSystemConfigForFreshRuntimeMirror( - config: string, - systemConfigDir: string -): string { - return stripRuntimeOwnedTomlSections(prepareSystemConfigForRuntimeMirror(config, systemConfigDir)) -} - -function mergeSystemCodexConfigIntoRuntime( - runtimeConfig: string, - systemConfig: string, - mirroredMcpServerNames: ReadonlySet = new Set(), - mirroredMcpServerRoot = false -): string { - const runtimeSections = deduplicateProjectTomlSections(getTomlSections(runtimeConfig)) - const runtimeProjectHeaders = new Set( - runtimeSections - .filter((section) => isRuntimeProjectTomlSection(section.header)) - .map((section) => getTomlSectionHeaderKey(section.header)) - ) - const systemProjectSections = deduplicateProjectTomlSections( - getTomlSections(systemConfig) - ).filter((section) => isRuntimeProjectTomlSection(section.header)) - const systemUntrustedProjectHeaders = new Set( - systemProjectSections - .filter((section) => getProjectTrustLevel(section.block) === 'untrusted') - .map((section) => getRevocationTomlSectionHeaderKey(section.header)) - ) - // Why: an exact-cased trusted entry in ~/.codex is the user's latest explicit - // decision for that exact project; a loosely-matched (case-drifted) revocation - // must not override it, or re-granting trust would be reverted every mirror. - const systemTrustedProjectHeaders = new Set( - systemProjectSections - .filter((section) => getProjectTrustLevel(section.block) === 'trusted') - .map((section) => getTomlSectionHeaderKey(section.header)) - ) - const systemMcpServers = readMcpServerTomlOwnership(systemConfig) - // Why: ordinary Codex settings should mirror ~/.codex exactly; runtime hook - // trust and project trust are written under Orca's managed CODEX_HOME and - // must survive the copy unless the user explicitly revoked project trust in - // the system config. - return joinTomlBlocks([ - stripRuntimeOwnedTomlSections(systemConfig, runtimeProjectHeaders), - ...runtimeSections - .filter((section) => { - if (isRuntimePreservedTomlSection(section.header)) { - return true - } - const mcpServerName = getMcpServerTomlSectionName(section.header) - return ( - mcpServerName !== null && - !systemMcpServers.ownsRoot && - !mirroredMcpServerRoot && - !systemMcpServers.names.has(mcpServerName) && - !mirroredMcpServerNames.has(mcpServerName) - ) - }) - .filter( - (section) => - !isRuntimeProjectTomlSection(section.header) || - !systemUntrustedProjectHeaders.has(getRevocationTomlSectionHeaderKey(section.header)) || - systemTrustedProjectHeaders.has(getTomlSectionHeaderKey(section.header)) - ) - .map((section) => section.block) - ]) -} diff --git a/src/main/codex/codex-config-toml-checked-edit.test.ts b/src/main/codex/codex-config-toml-checked-edit.test.ts new file mode 100644 index 000000000000..f4b990e400ff --- /dev/null +++ b/src/main/codex/codex-config-toml-checked-edit.test.ts @@ -0,0 +1,312 @@ +import { describe, expect, it } from 'vitest' +import { parse } from 'smol-toml' +import { + readHookTrustEntriesFromContent, + removeHookTrustEntriesFromContent, + upsertHookTrustEntriesInContent, + upsertProjectTrustLevelInContent, + type CodexTrustEntry +} from './config-toml-trust' +import { CodexConfigTomlEditRefusedError } from './codex-config-toml-checked-edit' +import { getTomlTable, readTomlValueAtPath } from './codex-config-toml-document' +import { setHookTrustEnabledContent } from './config-toml-hook-trust-edit' +import { repairOrcaCodexConfigDuplicates } from './codex-config-toml-repair' +import { prepareSystemConfigForFreshRuntimeMirror } from './codex-config-mirror' +import { getProjectTrustLevel, getTomlSections } from './config-toml-runtime-owned-sections' + +const trust = (content: string, path = '/work/repo', level: 'trusted' | 'untrusted' = 'trusted') => + upsertProjectTrustLevelInContent(content, path, level, { alreadyCanonical: true }) + +function projectsOf(content: string): Record { + const parsed = parse(content, { integersAsBigInt: 'asNeeded' }) + return getTomlTable(parsed.projects) ?? {} +} + +describe('project trust edits find the existing table in any spelling (#22592)', () => { + it.each([ + ['bare projects, basic path', '[projects."/work/repo"]'], + ['quoted projects (Codex desktop form)', '["projects"."/work/repo"]'], + ['literal-quoted segments', "['projects'.'/work/repo']"], + ['spaced dots', '[ projects . "/work/repo" ]'], + ['trailing comment', '[projects."/work/repo"] # added by codex'] + ])('%s', (_name, header) => { + const existing = `model = "gpt-5"\n\n${header}\ntrust_level = "untrusted"\nsandbox = "workspace-write"\n` + const updated = trust(existing) + + expect(updated).toBe(existing.replace('"untrusted"', '"trusted"')) + expect(projectsOf(updated)).toEqual({ + '/work/repo': { trust_level: 'trusted', sandbox: 'workspace-write' } + }) + }) + + it('edits a quoted "trust_level" key in place instead of adding a second one', () => { + const existing = '[projects."/work/repo"]\n"trust_level" = "untrusted" # mine\n' + expect(trust(existing)).toBe('[projects."/work/repo"]\n"trust_level" = "trusted" # mine\n') + }) + + it('finds an escaped-quote path', () => { + const existing = '["projects"."/work/a \\"b\\""]\ntrust_level = "untrusted"\n' + const updated = trust(existing, '/work/a "b"') + expect(Object.keys(projectsOf(updated))).toEqual(['/work/a "b"']) + }) + + it('leaves an inline projects table that already trusts the folder untouched', () => { + const existing = '[projects]\n"/work/repo" = { trust_level = "trusted" }\n' + expect(trust(existing)).toBe(existing) + }) + + it('refuses to append a second definition beside an inline project table', () => { + const existing = '[projects]\n"/work/repo" = { trust_level = "untrusted" }\n' + expect(() => trust(existing)).toThrow(CodexConfigTomlEditRefusedError) + }) + + it('edits a dotted trust_level key where it is', () => { + const existing = '# top\nprojects."/work/repo".trust_level = "untrusted"\nmodel = "o3"\n' + expect(trust(existing)).toBe( + '# top\nprojects."/work/repo".trust_level = "trusted"\nmodel = "o3"\n' + ) + }) + + it('adds trust as one more dotted key to a project defined by dotted keys', () => { + const existing = '[projects]\n"/work/repo".sandbox = "read-only"\n' + const updated = trust(existing) + expect(updated).toBe( + '[projects]\n"/work/repo".sandbox = "read-only"\n"/work/repo".trust_level = "trusted"\n' + ) + }) + + it('keeps CRLF, drops only the BOM, and preserves comments', () => { + const existing = '# keep me\r\n["projects"."/work/repo"]\r\n# and me\r\nmodel = "o3"\r\n' + const updated = trust(existing) + expect(updated).toBe( + '# keep me\r\n["projects"."/work/repo"]\r\ntrust_level = "trusted"\r\n# and me\r\nmodel = "o3"\r\n' + ) + }) + + it('refuses to edit a file that is invalid for reasons Orca did not cause', () => { + const existing = '[mcp_servers.a]\ncommand = "x"\n[mcp_servers.a]\ncommand = "y"\n' + const error = (() => { + try { + trust(existing) + } catch (caught) { + return caught + } + return null + })() + expect(error).toBeInstanceOf(CodexConfigTomlEditRefusedError) + expect(error).toMatchObject({ reason: 'input-invalid', line: 3 }) + }) + + it('refuses an edit whose result would not parse', () => { + // An inline root `projects` table cannot be extended with a header. + const existing = 'projects = { "/other" = { trust_level = "trusted" } }\n' + expect(() => trust(existing)).toThrow(/would have produced invalid TOML/) + }) +}) + +describe('repair of duplicates earlier Orca builds wrote (#22592)', () => { + it('drops Orca’s appended bare table after Codex’s quoted one', () => { + const broken = [ + '["projects"."/work/repo"]', + 'trust_level = "trusted"', + '', + '[projects."/work/repo"]', + 'trust_level = "trusted"', + '' + ].join('\n') + expect(repairOrcaCodexConfigDuplicates(broken)).toBe( + '["projects"."/work/repo"]\ntrust_level = "trusted"\n\n' + ) + expect(trust(broken)).toBe('["projects"."/work/repo"]\ntrust_level = "trusted"\n\n') + }) + + it('keeps the user’s first answer when Orca appended a different one', () => { + const broken = + '["projects"."/w"]\ntrust_level = "untrusted"\n[projects."/w"]\ntrust_level = "trusted"\n' + expect(projectsOf(repairOrcaCodexConfigDuplicates(broken))).toEqual({ + '/w': { trust_level: 'untrusted' } + }) + }) + + it('moves Orca’s trust line into the user’s table when that table had none', () => { + const broken = '["projects"."/w"]\nmodel = "o3"\n\n[projects."/w"]\ntrust_level = "trusted"\n' + expect(projectsOf(repairOrcaCodexConfigDuplicates(broken))).toEqual({ + '/w': { trust_level: 'trusted', model: 'o3' } + }) + }) + + it('drops the bare trust_level Orca inserted beside a quoted one', () => { + const broken = '[projects."/w"]\ntrust_level = "trusted"\n"trust_level" = "untrusted"\n' + expect(repairOrcaCodexConfigDuplicates(broken)).toBe( + '[projects."/w"]\n"trust_level" = "untrusted"\n' + ) + }) + + it('drops a second, empty [hooks.state] header but keeps its comment', () => { + const broken = + '[hooks.state]\n\n[hooks.state."k:stop:0:0"]\nenabled = true\n[hooks.state]\n# note\n' + const repaired = repairOrcaCodexConfigDuplicates(broken) + expect(repaired).toBe('[hooks.state]\n\n[hooks.state."k:stop:0:0"]\nenabled = true\n# note\n') + expect(() => parse(repaired)).not.toThrow() + }) + + it('handles the wrong-order sed workaround (two identical bare headers)', () => { + const broken = + '[projects."/w"]\ntrust_level = "trusted"\nmodel = "o3"\n\n[projects."/w"]\ntrust_level = "trusted"\n' + expect(projectsOf(repairOrcaCodexConfigDuplicates(broken))).toEqual({ + '/w': { trust_level: 'trusted', model: 'o3' } + }) + }) + + it('never deletes a duplicate table that carries the user’s own values', () => { + const broken = '[projects."/w"]\ntrust_level = "trusted"\n[projects."/w"]\nmodel = "o3"\n' + expect(repairOrcaCodexConfigDuplicates(broken)).toBe(broken) + expect(() => trust(broken, '/w')).toThrow(CodexConfigTomlEditRefusedError) + }) +}) + +const hookEntry = (overrides: Partial = {}): CodexTrustEntry => ({ + sourcePath: '/home/u/.codex/hooks.json', + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 0, + command: '/bin/sh /home/u/.orca/agent-hooks/codex-hook.sh', + trustedHash: 'sha256:new', + ...overrides +}) + +describe('hooks.state edits and reads by decoded key (#22592)', () => { + const key = '/home/u/.codex/hooks.json:stop:0:0' + + it('replaces a quoted-segment hooks.state table instead of appending a duplicate', () => { + const existing = `model = "o3"\n\n["hooks"."state"."${key}"]\ntrusted_hash = "sha256:old"\n` + const updated = upsertHookTrustEntriesInContent(existing, [hookEntry()]) + const state = readTomlValueAtPath(parse(updated), ['hooks', 'state']) + expect(state).toEqual({ [key]: { enabled: true, trusted_hash: 'sha256:new' } }) + expect(updated.startsWith('model = "o3"\n')).toBe(true) + }) + + it('reads a literal-quoted enabled key and an inline hooks.state entry', () => { + const content = `[hooks.state]\n"${key}" = { trusted_hash = "sha256:h", 'enabled' = false }\n` + expect(readHookTrustEntriesFromContent(content).get(key)).toEqual({ + trustedHash: 'sha256:h', + enabled: false + }) + }) + + it('removes a quoted-segment hooks.state table', () => { + const existing = `["hooks"."state"."${key}"]\ntrusted_hash = "sha256:old"\n\n[tui]\ntheme = "dark"\n` + expect(parse(removeHookTrustEntriesFromContent(existing, [key]))).toEqual({ + tui: { theme: 'dark' } + }) + }) +}) + +describe('the managed-home mirror sees one identity per table (#22592 layer 3)', () => { + it('keeps one project table when ~/.codex has both spellings', () => { + const system = [ + '["projects"."/w"]', + 'trust_level = "trusted"', + '', + '[projects."/w"]', + 'trust_level = "trusted"', + '', + '[hooks.state]', + '[hooks.state]', + '' + ].join('\n') + const mirrored = prepareSystemConfigForFreshRuntimeMirror(system, '/home/u/.codex') + expect(() => parse(mirrored)).not.toThrow() + }) + + it('reads a quoted trust_level from a section block', () => { + const [section] = getTomlSections('["projects"."/w"]\n"trust_level" = \'untrusted\'\n') + expect(getProjectTrustLevel(section!.block)).toBe('untrusted') + }) +}) + +describe('review follow-ups for the checked writer', () => { + it('never treats the same sub-table under two array-of-tables elements as a duplicate', () => { + const content = [ + '["projects"."/w"]', + 'trust_level = "trusted"', + '[projects."/w"]', + 'trust_level = "trusted"', + '[[s]]', + '[s.o]', + 'x = 1', + '[[s]]', + '[s.o]', + 'x = 1', + '' + ].join('\n') + const repaired = repairOrcaCodexConfigDuplicates(content) + expect(parse(repaired)).toEqual({ + projects: { '/w': { trust_level: 'trusted' } }, + s: [{ o: { x: 1 } }, { o: { x: 1 } }] + }) + }) + + it('keeps Orca’s trust line when the user’s empty table sits directly above Orca’s', () => { + const broken = '["projects"."/w"]\n[projects."/w"]\ntrust_level = "trusted"\n' + expect(repairOrcaCodexConfigDuplicates(broken)).toBe( + '["projects"."/w"]\ntrust_level = "trusted"\n' + ) + }) + + it('edits a file holding integers past 2^53, which Codex reads as 64-bit', () => { + const existing = 'max_bytes = 9007199254740993\n' + const updated = trust(existing) + expect(updated.startsWith(existing)).toBe(true) + expect(projectsOf(updated)).toEqual({ '/work/repo': { trust_level: 'trusted' } }) + }) + + it('keeps a comment that sits above the next table when replacing or removing a hook block', () => { + const key = '/home/u/.codex/hooks.json:stop:0:0' + const existing = `[hooks.state."${key}"]\ntrusted_hash = "sha256:old"\n\n# my servers\n[mcp_servers.z]\ncommand = "z"\n` + expect(upsertHookTrustEntriesInContent(existing, [hookEntry()])).toContain( + '# my servers\n[mcp_servers.z]' + ) + expect(removeHookTrustEntriesFromContent(existing, [key])).toBe( + '# my servers\n[mcp_servers.z]\ncommand = "z"\n' + ) + }) + + it('inserts `enabled = false` with the file\u2019s own CRLF line ending', () => { + const key = '/home/u/.codex/hooks.json:stop:0:0' + const existing = `model = "o3"\r\n[hooks.state."${key}"]\r\ntrusted_hash = "h"\r\n` + expect(setHookTrustEnabledContent(existing, [{ key, enabled: false }])).toBe( + `model = "o3"\r\n[hooks.state."${key}"]\r\nenabled = false\r\ntrusted_hash = "h"\r\n` + ) + }) + + it('checks repair removals against the file without Orca\u2019s blocks when the repair alone does not parse', () => { + const key = '/home/u/.codex/hooks.json:stop:0:0' + const block = (hash: string) => `[hooks.state."${key}"]\ntrusted_hash = "${hash}"\n` + const broken = `model = "o3"\n${block('a')}${block('a')}${block('b')}` + expect(parse(upsertHookTrustEntriesInContent(broken, [hookEntry()]))).toEqual({ + model: 'o3', + hooks: { state: { [key]: { enabled: true, trusted_hash: 'sha256:new' } } } + }) + }) + + it('refuses to collapse owned duplicates when something else in the file is also broken', () => { + const key = '/home/u/.codex/hooks.json:stop:0:0' + const broken = [ + `[hooks.state."${key}"]`, + 'trusted_hash = "a"', + `[hooks.state."${key}"]`, + 'trusted_hash = "b"', + '[mcp_servers.a]', + 'command = "x"', + '[mcp_servers.a]', + 'command = "y"', + '' + ].join('\n') + expect(() => upsertHookTrustEntriesInContent(broken, [hookEntry()])).toThrow( + CodexConfigTomlEditRefusedError + ) + const ownedOnly = broken.split('[mcp_servers.a]')[0]! + expect(() => parse(upsertHookTrustEntriesInContent(ownedOnly, [hookEntry()]))).not.toThrow() + }) +}) diff --git a/src/main/codex/codex-config-toml-checked-edit.ts b/src/main/codex/codex-config-toml-checked-edit.ts new file mode 100644 index 000000000000..c56047812caa --- /dev/null +++ b/src/main/codex/codex-config-toml-checked-edit.ts @@ -0,0 +1,191 @@ +import { + omitTomlPaths, + parseCodexConfigToml, + readTomlValueAtPath, + tomlValuesEqual, + type TomlKeyPath, + type TomlTable +} from './codex-config-toml-document' +import { + removedValuesSurviveIn, + repairOrcaCodexConfigDuplicatesWithRemovals +} from './codex-config-toml-repair' +import type { TomlAssignmentLine } from './codex-config-toml-structure' + +export type CodexConfigTomlEditRefusalReason = + | 'input-invalid' + | 'result-invalid' + | 'unrelated-change' + | 'intended-value-missing' + | 'unsupported-form' + +/** Orca declined to write a Codex config.toml because the edit could not be proven safe. */ +export class CodexConfigTomlEditRefusedError extends Error { + readonly reason: CodexConfigTomlEditRefusalReason + readonly detail: string + readonly line: number | null + readonly configPath: string | null + + constructor(args: { + reason: CodexConfigTomlEditRefusalReason + detail: string + line?: number | null + configPath?: string | null + }) { + const where = args.configPath ?? 'Codex config.toml' + const at = args.line ? ` (line ${args.line})` : '' + super(`Orca left ${where} unchanged${at}: ${args.detail}`) + this.name = 'CodexConfigTomlEditRefusedError' + this.reason = args.reason + this.detail = args.detail + this.line = args.line ?? null + this.configPath = args.configPath ?? null + } + + forConfigPath(configPath: string): CodexConfigTomlEditRefusedError { + return new CodexConfigTomlEditRefusedError({ + reason: this.reason, + detail: this.detail, + line: this.line, + configPath + }) + } +} + +export type CodexConfigTomlEditResult = { + content: string + /** Paths the edit owns; every other parsed value must be unchanged. */ + ownedPaths: readonly TomlKeyPath[] + /** Values the result must hold afterwards; `undefined` means the path must be absent. */ + expected?: readonly { path: TomlKeyPath; value: unknown }[] +} + +export type CodexConfigTomlEdit = ( + content: string, + table: TomlTable | null +) => CodexConfigTomlEditResult + +export type CheckedCodexConfigTomlEditOptions = { + /** + * The edit replaces every definition of its owned paths, so it may run on a + * file that is invalid only because of duplicates it collapses. Returns the + * content with those definitions removed, which must parse and is what the + * "changes nothing else" check compares against. + */ + collapsesOwnedDuplicates?: (content: string) => string +} + +/** + * Runs a text-level edit and returns its result only if Codex could read it: + * the result parses, holds the intended values, and leaves every other value + * as it was. A file Orca broke earlier is repaired first; anything else is refused. + */ +export function applyCheckedCodexConfigTomlEdit( + existingContent: string, + edit: CodexConfigTomlEdit, + options: CheckedCodexConfigTomlEditOptions = {} +): string { + const before = parseCodexConfigToml(existingContent) + let input = existingContent + let inputTable: TomlTable | null = before.ok ? before.table : null + let comparisonTable = inputTable + let unverifiedRemovals: readonly TomlAssignmentLine[] = [] + if (!before.ok) { + const repair = repairOrcaCodexConfigDuplicatesWithRemovals(existingContent) + input = repair.content + const repaired = parseCodexConfigToml(input) + const withoutOwned = + !repaired.ok && options.collapsesOwnedDuplicates + ? parseCodexConfigToml(options.collapsesOwnedDuplicates(input)) + : null + if (repaired.ok) { + inputTable = repaired.table + comparisonTable = repaired.table + } else if (withoutOwned?.ok) { + comparisonTable = withoutOwned.table + unverifiedRemovals = repair.unverifiedRemovals + } else { + throw new CodexConfigTomlEditRefusedError({ + reason: 'input-invalid', + detail: `the file is not valid TOML (${before.message}). Fix it so Codex can read it; Orca never edits a config Codex cannot parse.`, + line: before.line + }) + } + } + const result = edit(input, inputTable) + if (result.content === existingContent) { + return existingContent + } + const after = parseCodexConfigToml(result.content) + if (!after.ok) { + throw new CodexConfigTomlEditRefusedError({ + reason: 'result-invalid', + detail: `the edit would have produced invalid TOML (${after.message}).`, + line: after.line + }) + } + for (const { path, value } of result.expected ?? []) { + if (!tomlValuesEqual(readTomlValueAtPath(after.table, path), value)) { + throw new CodexConfigTomlEditRefusedError({ + reason: 'intended-value-missing', + detail: `the edit would not have set ${path.join('.')} as intended.` + }) + } + } + if ( + comparisonTable && + !removedValuesSurviveIn(comparisonTable, unverifiedRemovals, result.ownedPaths) + ) { + throw new CodexConfigTomlEditRefusedError({ + reason: 'input-invalid', + detail: 'repairing duplicate tables would have removed a value that is defined nowhere else.' + }) + } + if ( + comparisonTable && + !tomlValuesEqual( + omitTomlPaths(comparisonTable, result.ownedPaths), + omitTomlPaths(after.table, result.ownedPaths) + ) + ) { + throw new CodexConfigTomlEditRefusedError({ + reason: 'unrelated-change', + detail: 'the edit would have changed settings it does not own.' + }) + } + return result.content +} + +/** For whole-document producers (the managed-home mirror): the result must at least parse. */ +export function assertCodexConfigTomlParses(content: string): void { + const parsed = parseCodexConfigToml(content) + if (!parsed.ok) { + throw new CodexConfigTomlEditRefusedError({ + reason: 'result-invalid', + detail: `the result would not be valid TOML (${parsed.message}).`, + line: parsed.line + }) + } +} + +const reportedRefusals = new Map() + +/** Logs once per file and message, so a launch-time retry does not flood the log. */ +export function reportCodexConfigOnce(key: string, message: string): void { + if (reportedRefusals.get(key) === message) { + return + } + reportedRefusals.set(key, message) + console.warn(`[codex-config] ${message}`) +} + +export function reportCodexConfigTomlEditRefusal( + error: CodexConfigTomlEditRefusedError, + context: string +): void { + reportCodexConfigOnce(error.configPath ?? context, `${context}: ${error.message}`) +} + +export function clearCodexConfigTomlEditRefusalReport(configPath: string): void { + reportedRefusals.delete(configPath) +} diff --git a/src/main/codex/codex-config-toml-document.ts b/src/main/codex/codex-config-toml-document.ts new file mode 100644 index 000000000000..66bb3be2342a --- /dev/null +++ b/src/main/codex/codex-config-toml-document.ts @@ -0,0 +1,134 @@ +import { createRequire } from 'node:module' + +export type TomlTable = Record + +// Why: smol-toml's runtime is CommonJS-loadable but its only declarations are +// ESM, which the CommonJS CLI build cannot import statically. Load it like the +// other packaged runtime dependencies and type just the two members used. +type SmolToml = { + parse: (source: string, options: { integersAsBigInt: 'asNeeded' }) => TomlTable + TomlError: abstract new (...args: never[]) => Error & { line: number; column: number } +} +const requireFromMain = createRequire(__filename) +const { parse, TomlError }: SmolToml = requireFromMain('smol-toml') +export type TomlKeyPath = readonly string[] + +export type CodexConfigTomlParse = + | { ok: true; table: TomlTable } + | { ok: false; message: string; line: number | null; column: number | null } + +/** Parses Codex config.toml exactly as TOML 1.0 does, so duplicates in any spelling are errors. */ +export function parseCodexConfigToml(content: string): CodexConfigTomlParse { + const source = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content + try { + // Why: Codex reads integers as 64-bit, so values past 2^53 are valid config, not a parse error. + return { ok: true, table: parse(source, { integersAsBigInt: 'asNeeded' }) } + } catch (error) { + if (error instanceof TomlError) { + return { + ok: false, + message: error.message.split('\n')[0] ?? error.message, + line: error.line, + column: error.column + } + } + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + line: null, + column: null + } + } +} + +export function isTomlTable(value: unknown): value is TomlTable { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + // Why: smol-toml's TomlDate extends Date, so this also excludes TOML datetimes. + !(value instanceof Date) + ) +} + +export function getTomlTable(value: unknown): TomlTable | null { + return isTomlTable(value) ? value : null +} + +export function readTomlValueAtPath(table: TomlTable, path: TomlKeyPath): unknown { + let current: unknown = table + for (const segment of path) { + const next = getTomlTable(current) + if (!next || !Object.hasOwn(next, segment)) { + return undefined + } + current = next[segment] + } + return current +} + +export function tomlValuesEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) { + return true + } + if (left instanceof Date || right instanceof Date) { + return left instanceof Date && right instanceof Date && String(left) === String(right) + } + if (Array.isArray(left) || Array.isArray(right)) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => tomlValuesEqual(value, right[index])) + ) + } + const leftTable = getTomlTable(left) + const rightTable = getTomlTable(right) + if (!leftTable || !rightTable) { + return false + } + const leftKeys = Object.keys(leftTable) + return ( + leftKeys.length === Object.keys(rightTable).length && + leftKeys.every( + (key) => Object.hasOwn(rightTable, key) && tomlValuesEqual(leftTable[key], rightTable[key]) + ) + ) +} + +/** Returns a copy without `paths`, dropping tables those removals left empty so a new parent table is not a change. */ +export function omitTomlPaths(table: TomlTable, paths: readonly TomlKeyPath[]): TomlTable { + const copy = cloneTomlTable(table) + for (const path of paths) { + omitTomlPath(copy, path) + } + return copy +} + +function omitTomlPath(table: TomlTable, path: TomlKeyPath): void { + const [head, ...rest] = path + if (head === undefined || !Object.hasOwn(table, head)) { + return + } + if (rest.length === 0) { + delete table[head] + return + } + const child = getTomlTable(table[head]) + if (!child) { + return + } + omitTomlPath(child, rest) + if (Object.keys(child).length === 0) { + delete table[head] + } +} + +function cloneTomlTable(table: TomlTable): TomlTable { + const copy: TomlTable = {} + for (const [key, value] of Object.entries(table)) { + const child = getTomlTable(value) + copy[key] = child ? cloneTomlTable(child) : value + } + return copy +} diff --git a/src/main/codex/codex-config-toml-repair.ts b/src/main/codex/codex-config-toml-repair.ts new file mode 100644 index 000000000000..56900a21b0cc --- /dev/null +++ b/src/main/codex/codex-config-toml-repair.ts @@ -0,0 +1,218 @@ +import { + getAssignmentKeyPath, + getTomlTableBlocks, + readTomlAssignmentValue, + scanTomlStructure, + tomlKeyPathStartsWith, + tomlKeyPathsEqual, + type TomlAssignmentLine, + type TomlTableBlock +} from './codex-config-toml-structure' +import { + parseCodexConfigToml, + readTomlValueAtPath, + tomlValuesEqual, + type TomlTable +} from './codex-config-toml-document' + +// Why (#22592): earlier Orca builds appended `[projects."

"]` beside Codex's +// `["projects"."

"]`, inserted a bare `trust_level` beside `"trust_level"`, +// and mirrored a second `[hooks.state]`. TOML rejects each, so every `codex` +// run failed. Only those exact shapes, or duplicates that carry no value of +// their own, are removed; the user's first definition always wins. + +type TextEdit = { + start: number + end: number + replacement: string + /** Assignments this edit deletes whose value must survive elsewhere at the same path. */ + removes?: readonly TomlAssignmentLine[] +} + +const ORCA_TRUST_LINE = /^trust_level = "(?:trusted|untrusted)"$/ + +export function repairOrcaCodexConfigDuplicates(content: string): string { + return repairOrcaCodexConfigDuplicatesWithRemovals(content).content +} + +export type CodexConfigRepair = { + content: string + /** Removed assignments whose values a caller must still find, when the repaired file does not parse on its own. */ + unverifiedRemovals: readonly TomlAssignmentLine[] +} + +export function repairOrcaCodexConfigDuplicatesWithRemovals(content: string): CodexConfigRepair { + const lines = scanTomlStructure(content) + const blocks = getTomlTableBlocks(lines) + const edits: TextEdit[] = [] + for (const block of blocks) { + collectDuplicateTrustLevelEdits(block, edits) + } + collectDuplicateTableEdits(blocks, edits) + const repaired = applyTextEdits(content, edits) + const removed = edits.flatMap((edit) => edit.removes ?? []) + const parsed = parseCodexConfigToml(repaired) + if (!parsed.ok) { + return { content: repaired, unverifiedRemovals: removed } + } + return removedValuesSurviveIn(parsed.table, removed) + ? { content: repaired, unverifiedRemovals: [] } + : { content, unverifiedRemovals: [] } +} + +/** A repair may not lose a value: each removed assignment must still read the same at its path. */ +export function removedValuesSurviveIn( + table: TomlTable, + removed: readonly TomlAssignmentLine[], + exemptPaths: readonly (readonly string[])[] = [] +): boolean { + return removed.every((line) => { + const path = getAssignmentKeyPath(line) + if (path !== null && exemptPaths.some((exempt) => tomlKeyPathStartsWith(path, exempt))) { + return true + } + return ( + path !== null && + tomlValuesEqual(readTomlValueAtPath(table, path), readTomlAssignmentValue(line)) + ) + }) +} + +function isProjectTable(block: TomlTableBlock): boolean { + return ( + !block.header.isArray && + block.header.segments.length === 2 && + block.header.segments[0] === 'projects' + ) +} + +function assignments(block: TomlTableBlock): TomlAssignmentLine[] { + return block.body.filter((line): line is TomlAssignmentLine => line.kind === 'assignment') +} + +function isOrcaTrustLine(line: TomlAssignmentLine): boolean { + return ORCA_TRUST_LINE.test(line.text.trim()) +} + +function trustLevelLines(block: TomlTableBlock): TomlAssignmentLine[] { + return assignments(block).filter((line) => tomlKeyPathsEqual(line.keySegments, ['trust_level'])) +} + +function collectDuplicateTrustLevelEdits(block: TomlTableBlock, edits: TextEdit[]): void { + if (!isProjectTable(block)) { + return + } + const trustLines = trustLevelLines(block) + if (trustLines.length < 2) { + return + } + const userLines = trustLines.filter((line) => !isOrcaTrustLine(line)) + if (userLines.length > 1) { + return + } + const kept = userLines[0] ?? trustLines[0] + for (const line of trustLines) { + if (line !== kept) { + edits.push({ start: line.lineStart, end: line.nextLineStart, replacement: '' }) + } + } +} + +function collectDuplicateTableEdits(blocks: readonly TomlTableBlock[], edits: TextEdit[]): void { + const firstByIdentity = new Map() + const arrayTablePaths: (readonly string[])[] = [] + for (const block of blocks) { + if (block.header.isArray) { + arrayTablePaths.push(block.header.segments) + continue + } + // Why: `[s.o]` under two `[[s]]` elements is two tables, not a duplicate. + if (arrayTablePaths.some((path) => tomlKeyPathStartsWith(block.header.segments, path))) { + continue + } + const identity = JSON.stringify(block.header.segments) + const first = firstByIdentity.get(identity) + if (!first) { + firstByIdentity.set(identity, block) + continue + } + const duplicateAssignments = assignments(block) + if (duplicateAssignments.length === 0) { + // Why: a header with no values of its own defines nothing; dropping only + // the header line keeps any comments under it. + edits.push(removeLine(block.header)) + continue + } + if (isProjectTable(block) && isOrcaAppendedTrustBlock(block)) { + edits.push({ start: block.header.lineStart, end: block.end, replacement: '' }) + if (trustLevelLines(first).length === 0) { + edits.push(insertAfterHeader(first, duplicateAssignments[0]!.text.trim())) + } + continue + } + if (duplicateAssignments.every((line) => isValueAlreadyDefined(first, line))) { + edits.push(removeLine(block.header)) + for (const line of duplicateAssignments) { + edits.push({ ...removeLine(line), removes: [line] }) + } + } + } +} + +/** The exact block `upsertProjectTrustContent` used to append: the header and one trust line. */ +function isOrcaAppendedTrustBlock(block: TomlTableBlock): boolean { + const meaningful = block.body.filter((line) => line.text.trim() !== '') + return ( + meaningful.length === 1 && + meaningful[0]!.kind === 'assignment' && + isOrcaTrustLine(meaningful[0]!) + ) +} + +function isValueAlreadyDefined(first: TomlTableBlock, line: TomlAssignmentLine): boolean { + const value = readTomlAssignmentValue(line) + return ( + value !== undefined && + assignments(first).some( + (candidate) => + tomlKeyPathsEqual(candidate.keySegments, line.keySegments) && + tomlValuesEqual(readTomlAssignmentValue(candidate), value) + ) + ) +} + +function removeLine(line: { lineStart: number; nextLineStart: number }): TextEdit { + return { start: line.lineStart, end: line.nextLineStart, replacement: '' } +} + +function insertAfterHeader(block: TomlTableBlock, text: string): TextEdit { + const eol = block.header.nextLineStart > block.header.contentEnd + 1 ? '\r\n' : '\n' + return { + start: block.header.nextLineStart, + end: block.header.nextLineStart, + replacement: `${text}${eol}` + } +} + +function applyTextEdits(content: string, edits: readonly TextEdit[]): string { + // Why: an insert at the offset where a removal starts must survive, so zero-width edits sort first. + const ordered = [...edits].sort( + (left, right) => + left.start - right.start || + Number(left.end !== left.start) - Number(right.end !== right.start) || + right.end - left.end + ) + const disjoint: TextEdit[] = [] + for (const edit of ordered) { + // Why: a line inside a table already removed whole is removed with it. + const previous = disjoint.at(-1) + if (!previous || edit.start >= previous.end) { + disjoint.push(edit) + } + } + let result = content + for (const edit of disjoint.toReversed()) { + result = result.slice(0, edit.start) + edit.replacement + result.slice(edit.end) + } + return result +} diff --git a/src/main/codex/codex-config-toml-structure.ts b/src/main/codex/codex-config-toml-structure.ts new file mode 100644 index 000000000000..ad45f6cbcd6b --- /dev/null +++ b/src/main/codex/codex-config-toml-structure.ts @@ -0,0 +1,160 @@ +import { + createTomlLineScanState, + getTomlTableHeader, + isTomlStructuralLine, + updateTomlLineScanState +} from './config-toml-line-scan' +import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path' +import { escapeTomlBasicString } from './config-toml-syntax' +import { + parseCodexConfigToml, + readTomlValueAtPath, + type TomlKeyPath +} from './codex-config-toml-document' + +// Why: every Orca edit of a Codex config.toml locates tables and keys by their +// decoded key path, so `[projects."/x"]`, `["projects"."/x"]` and +// `projects."/x".trust_level` are one identity instead of three spellings. + +export type TomlTableContext = { segments: readonly string[]; isArray: boolean } + +type TomlLineBounds = { + /** Offset of the first character of the line. */ + lineStart: number + /** Offset just past the line's content, before any `\r\n` or `\n`. */ + contentEnd: number + /** Offset of the next line (content length for the last line). */ + nextLineStart: number + /** Line text without the trailing `\r` (and without a leading BOM on line one). */ + text: string +} + +export type TomlTableLine = TomlLineBounds & { + kind: 'table' + segments: readonly string[] + isArray: boolean +} + +export type TomlAssignmentLine = TomlLineBounds & { + kind: 'assignment' + table: TomlTableContext + keySegments: readonly string[] + /** Offset within `text` of the first value character. */ + valueOffset: number +} + +export type TomlStructureLine = + | TomlTableLine + | TomlAssignmentLine + | (TomlLineBounds & { kind: 'other' }) + +const ROOT_TABLE: TomlTableContext = { segments: [], isArray: false } + +export function scanTomlStructure(content: string): TomlStructureLine[] { + const lines: TomlStructureLine[] = [] + let cursor = 0 + let scanState = createTomlLineScanState() + let table = ROOT_TABLE + while (cursor < content.length) { + const newlineIndex = content.indexOf('\n', cursor) + const lineEnd = newlineIndex === -1 ? content.length : newlineIndex + const rawLine = content.slice(cursor, lineEnd) + const withoutCr = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine + const text = cursor === 0 && withoutCr.charCodeAt(0) === 0xfeff ? withoutCr.slice(1) : withoutCr + const bounds: TomlLineBounds = { + lineStart: cursor, + contentEnd: cursor + withoutCr.length, + nextLineStart: newlineIndex === -1 ? content.length : newlineIndex + 1, + text + } + const line = isTomlStructuralLine(scanState) + ? classifyStructuralLine(bounds, table) + : { ...bounds, kind: 'other' as const } + if (line.kind === 'table') { + table = { segments: line.segments, isArray: line.isArray } + } + lines.push(line) + scanState = updateTomlLineScanState(scanState, text) + cursor = bounds.nextLineStart + } + return lines +} + +function classifyStructuralLine( + bounds: TomlLineBounds, + table: TomlTableContext +): TomlStructureLine { + const header = getTomlTableHeader(bounds.text) + if (header !== null) { + const parsed = parseTomlTableHeaderPath(header) + return parsed + ? { ...bounds, kind: 'table', segments: parsed.segments, isArray: parsed.isArray } + : { ...bounds, kind: 'other' } + } + const key = parseTomlKeyPath(bounds.text) + if (!key || bounds.text[key.end] !== '=') { + return { ...bounds, kind: 'other' } + } + let valueOffset = key.end + 1 + while (bounds.text[valueOffset] === ' ' || bounds.text[valueOffset] === '\t') { + valueOffset += 1 + } + return { ...bounds, kind: 'assignment', table, keySegments: key.segments, valueOffset } +} + +/** The decoded absolute key path an assignment defines, or null inside an array of tables. */ +export function getAssignmentKeyPath(line: TomlAssignmentLine): readonly string[] | null { + return line.table.isArray ? null : [...line.table.segments, ...line.keySegments] +} + +/** Reads a single-line assignment's value; multiline values read as undefined. */ +export function readTomlAssignmentValue(line: TomlAssignmentLine): unknown { + const parsed = parseCodexConfigToml(line.text) + return parsed.ok ? readTomlValueAtPath(parsed.table, line.keySegments) : undefined +} + +export function tomlKeyPathsEqual(left: TomlKeyPath, right: TomlKeyPath): boolean { + return left.length === right.length && left.every((segment, index) => segment === right[index]) +} + +export function tomlKeyPathStartsWith(path: TomlKeyPath, prefix: TomlKeyPath): boolean { + return path.length >= prefix.length && prefix.every((segment, index) => segment === path[index]) +} + +export function formatTomlKeyPath(path: TomlKeyPath): string { + return path + .map((segment) => + /^[A-Za-z0-9_-]+$/.test(segment) ? segment : `"${escapeTomlBasicString(segment)}"` + ) + .join('.') +} + +export type TomlTableBlock = { + header: TomlTableLine + /** Lines after the header up to (not including) the next table header. */ + body: TomlStructureLine[] + /** Offset just past the block (the next header's line start, or content length). */ + end: number +} + +export function getTomlTableBlocks(lines: readonly TomlStructureLine[]): TomlTableBlock[] { + const blocks: TomlTableBlock[] = [] + let current: TomlTableBlock | null = null + for (const line of lines) { + if (line.kind === 'table') { + if (current) { + blocks.push(current) + } + current = { header: line, body: [], end: line.nextLineStart } + continue + } + if (current) { + current.body.push(line) + current.end = line.nextLineStart + } + } + if (current) { + blocks.push(current) + } + return blocks +} diff --git a/src/main/codex/codex-hook-legacy-cleanup.ts b/src/main/codex/codex-hook-legacy-cleanup.ts index 415aabf61553..4c715e0e70c8 100644 --- a/src/main/codex/codex-hook-legacy-cleanup.ts +++ b/src/main/codex/codex-hook-legacy-cleanup.ts @@ -213,7 +213,7 @@ function cleanupLegacyCodexProfileHooks(): void { if (next.trim().length === 0) { unlinkSync(profilePath) } else { - writeConfigAtomically(profilePath, next) + writeConfigAtomically(profilePath, next, existing) } } diff --git a/src/main/codex/codex-hook-user-mirroring.ts b/src/main/codex/codex-hook-user-mirroring.ts index c257819cf09c..f38b67267d6e 100644 --- a/src/main/codex/codex-hook-user-mirroring.ts +++ b/src/main/codex/codex-hook-user-mirroring.ts @@ -1,4 +1,3 @@ -import { existsSync, readFileSync } from 'node:fs' import { readHooksJsonWithRaw, removeManagedCommands, @@ -6,10 +5,8 @@ import { } from '../agent-hooks/installer-utils' import { computeTrustKey, - escapeTomlString, getCodexExplicitHomeHookSourcePath, - parseTrustKey, - writeConfigAtomically, + setHookTrustEnabledStates, type CodexTrustEntry } from './config-toml-trust' import { createCodexHookTrustEntry, getCodexHookTrustSignature } from './codex-hook-identity' @@ -158,52 +155,14 @@ export function moveMirroredRuntimeUserTrustAfterManagedStatusHook( }) } -function escapeRegex(value: string): string { - return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - -function buildHookTrustHeaderKeyPattern(key: string): string { - const keyVariants = [key] - const parsed = parseTrustKey(key) - if (parsed && /^[A-Za-z]:[\\/]|^\\\\/.test(parsed.sourcePath)) { - const suffix = `:${parsed.eventLabel}:${parsed.groupIndex}:${parsed.handlerIndex}` - keyVariants.push( - `${parsed.sourcePath.replace(/\\/g, '/')}${suffix}`, - `${parsed.sourcePath.replace(/\//g, '\\')}${suffix}` - ) - } - const alternatives = [...new Set(keyVariants)].flatMap((variant) => { - const quoted = [`"${escapeRegex(escapeTomlString(variant))}"`] - if (!variant.includes("'")) { - // Why: tolerate raw-backslash literal keys from Codex/manual approval while repairing mirrored runtime trust across both Windows variants. - quoted.push(`'${escapeRegex(variant)}'`) - } - return quoted - }) - return `(?:${alternatives.join('|')})` -} - export function applyMirroredRuntimeUserHookTrustStates( tomlPath: string, entries: readonly MirroredRuntimeUserHookTrustEntry[] ): void { - if (entries.length === 0 || !existsSync(tomlPath)) { - return - } - - const existing = readFileSync(tomlPath, 'utf-8') - let updated = existing - for (const { entry, enabled } of entries) { - const headerKeyPattern = buildHookTrustHeaderKeyPattern(computeTrustKey(entry)) - const pattern = new RegExp( - `(\\[hooks\\.state\\.${headerKeyPattern}\\]\\r?\\n[ \\t]*enabled[ \\t]*=[ \\t]*)(true|false)`, - 'g' - ) - updated = updated.replace(pattern, `$1${enabled}`) - } - if (updated !== existing) { - writeConfigAtomically(tomlPath, updated) - } + setHookTrustEnabledStates( + tomlPath, + entries.map(({ entry, enabled }) => ({ key: computeTrustKey(entry), enabled })) + ) } function dedupeHookDefinitions(definitions: readonly HookDefinition[]): HookDefinition[] { diff --git a/src/main/codex/codex-managed-config-validity.ts b/src/main/codex/codex-managed-config-validity.ts new file mode 100644 index 000000000000..3e4bd9a1da05 --- /dev/null +++ b/src/main/codex/codex-managed-config-validity.ts @@ -0,0 +1,210 @@ +import { writeRollingFileBackup } from '../rolling-file-backup' +import { getTomlTable, parseCodexConfigToml } from './codex-config-toml-document' +import { + assertCodexConfigTomlParses, + clearCodexConfigTomlEditRefusalReport, + CodexConfigTomlEditRefusedError, + reportCodexConfigOnce, + reportCodexConfigTomlEditRefusal +} from './codex-config-toml-checked-edit' +import { repairOrcaCodexConfigDuplicates } from './codex-config-toml-repair' +import { normalizeCodexProjectPathForLookup } from './config-toml-trust' +import { + deduplicateProjectTomlSections, + getTomlSectionHeaderKey, + getTomlSections, + isRuntimeProjectTomlSection, + joinTomlBlocks +} from './config-toml-runtime-owned-sections' + +/** True (and reported) when `content` is not TOML Codex could read, so the caller must not write it. */ +export function refuseUnparseableManagedConfig(configPath: string, content: string): boolean { + try { + assertCodexConfigTomlParses(content) + clearCodexConfigTomlEditRefusalReport(configPath) + return false + } catch (error) { + if (!(error instanceof CodexConfigTomlEditRefusedError)) { + throw error + } + reportCodexConfigTomlEditRefusal( + error.forConfigPath(configPath), + 'Skipped writing a managed Codex config' + ) + return true + } +} + +export function findManagedConfigRefusal( + configPath: string, + content: string +): CodexConfigTomlEditRefusedError | null { + try { + assertCodexConfigTomlParses(content) + return null + } catch (error) { + if (error instanceof CodexConfigTomlEditRefusedError) { + return error.forConfigPath(configPath) + } + throw error + } +} + +// Why (#22592): a ~/.codex Orca broke earlier must not carry its duplicates into +// every managed home; launch prep repairs the file itself on the next trust write. +export function repairUnparseableCodexConfig(config: string): string { + return parseCodexConfigToml(config).ok ? config : repairOrcaCodexConfigDuplicates(config) +} + +/** Project keys ~/.codex defines inline or with dotted keys rather than as `[projects."…"]` tables. */ +export function getProjectsDefinedOutsideTables(config: string): ReadonlySet { + const parsed = parseCodexConfigToml(config) + const projects = parsed.ok ? getTomlTable(parsed.table.projects) : null + if (!projects) { + return new Set() + } + const tableProjects = new Set( + getTomlSections(config) + .filter((section) => isRuntimeProjectTomlSection(section.header)) + .map((section) => getTomlSectionHeaderKey(section.header)) + ) + return new Set( + Object.keys(projects) + .map((projectPath) => `project:${normalizeCodexProjectPathForLookup(projectPath)}`) + .filter((key) => !tableProjects.has(key)) + ) +} + +/** The parse error when Codex cannot read `config` even after Orca's own repair, else null. */ +export function findUnparseableCodexConfig(config: string): string | null { + const parsed = parseCodexConfigToml(repairUnparseableCodexConfig(config)) + if (parsed.ok) { + return null + } + return parsed.line ? `line ${parsed.line}: ${parsed.message}` : parsed.message +} + +/** + * Why: an Orca-owned home broken only by Orca's own duplicate trust tables is + * collapsed by the mirror's dedupe (#22592), so it still holds state worth + * keeping; only a config that stays unreadable after that is treated as a copy + * of a broken ~/.codex. + */ +export function findUnparseableManagedCodexConfig(config: string): string | null { + const parseError = findUnparseableCodexConfig(config) + if (parseError === null) { + return null + } + const repaired = repairUnparseableCodexConfig(config) + const sections = getTomlSections(repaired) + const firstSectionLine = sections[0]?.start ?? -1 + const preamble = + firstSectionLine === -1 ? repaired : repaired.split('\n').slice(0, firstSectionLine).join('\n') + const deduplicated = joinTomlBlocks([ + preamble, + ...deduplicateProjectTomlSections(sections).map((section) => section.block) + ]) + return parseCodexConfigToml(deduplicated).ok ? null : parseError +} + +const MIRROR_REFUSAL_TTL_MS = 10 * 60_000 + +type CodexMirrorRefusal = { sourcePath: string; detail: string; at: number } + +const mirrorRefusals = new Map() + +/** + * Why: ~/.codex saved mid-edit must not overwrite a managed home that still + * holds state only it has (an unpromoted /model, an MCP server added in + * Orca-launched Codex, trust answers). Kept per managed file, like launch trust + * failures, so a later surface can show why syncing paused. + */ +export function refuseMirrorFromUnparseableSource( + sourcePath: string, + runtimeConfigPath: string, + parseError: string, + now = Date.now() +): void { + const detail = `${sourcePath} is not valid TOML (${parseError}); left ${runtimeConfigPath} as it was. Fix ${sourcePath} to resume syncing.` + mirrorRefusals.set(runtimeConfigPath, { sourcePath, detail, at: now }) + reportCodexConfigOnce(runtimeConfigPath, detail) +} + +/** + * Why: with nothing in the managed home to lose, an unchanged copy makes + * Orca-launched Codex show Codex's own error, exactly like `codex` typed by hand. + */ +export function reportVerbatimUnparseableCodexSource( + sourcePath: string, + runtimeConfigPath: string, + parseError: string, + now = Date.now() +): void { + const detail = `${sourcePath} is not valid TOML (${parseError}); copied it unchanged into ${runtimeConfigPath} so Orca-launched Codex reports the same error. Fix ${sourcePath} to resume syncing.` + mirrorRefusals.set(runtimeConfigPath, { sourcePath, detail, at: now }) + reportCodexConfigOnce(runtimeConfigPath, detail) +} + +export function clearCodexMirrorRefusal(runtimeConfigPath: string): void { + mirrorRefusals.delete(runtimeConfigPath) +} + +export function getRecentCodexMirrorRefusal( + runtimeConfigPath: string, + now = Date.now() +): string | null { + const refusal = mirrorRefusals.get(runtimeConfigPath) + return refusal && now - refusal.at <= MIRROR_REFUSAL_TTL_MS ? refusal.detail : null +} + +/** + * What the mirror does with a ~/.codex Codex cannot parse: leave a managed + * config that parses untouched, or copy the source verbatim when the managed + * home has nothing to lose. Null when the source parses. + */ +export function applyUnparseableCodexSourceRule(args: { + sourcePath: string + runtimeConfigPath: string + source: string + runtime: string | null + writeVerbatimCopy: () => void +}): 'left-untouched' | 'copied' | null { + const parseError = args.source.trim() === '' ? null : findUnparseableCodexConfig(args.source) + if (parseError === null) { + clearCodexMirrorRefusal(args.runtimeConfigPath) + return null + } + if (args.runtime !== null && findUnparseableManagedCodexConfig(args.runtime) === null) { + refuseMirrorFromUnparseableSource(args.sourcePath, args.runtimeConfigPath, parseError) + return 'left-untouched' + } + reportVerbatimUnparseableCodexSource(args.sourcePath, args.runtimeConfigPath, parseError) + if (args.runtime !== args.source) { + args.writeVerbatimCopy() + } + return 'copied' +} + +/** + * Why: reseeding replaces a managed config Codex cannot parse; unless it is just + * the verbatim copy of ~/.codex, keep it as `.bak` so its bytes are not lost silently. + */ +export function backUpDiscardedManagedConfig(args: { + sourcePath: string + runtimeConfigPath: string + source: string + discarded: string | null +}): void { + if ( + args.discarded === null || + args.discarded === args.source || + findUnparseableManagedCodexConfig(args.discarded) === null + ) { + return + } + writeRollingFileBackup(args.runtimeConfigPath, `${args.runtimeConfigPath}.bak`) + reportCodexConfigOnce( + `${args.runtimeConfigPath}#discarded`, + `${args.runtimeConfigPath} was not valid TOML; replaced it with a fresh copy of ${args.sourcePath} and kept the old file as ${args.runtimeConfigPath}.bak.` + ) +} diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts index 56e499a6c186..1bd37dee16de 100644 --- a/src/main/codex/config-settings-promotion.ts +++ b/src/main/codex/config-settings-promotion.ts @@ -6,6 +6,12 @@ import { writeFileAtomically } from '../codex-accounts/fs-utils' import { parseWslUncPath } from '../../shared/wsl-paths' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' import { upsertPromotedSettingsInContent } from './codex-config-settings-upsert' +import { findUnparseableManagedCodexConfig } from './codex-managed-config-validity' +import { + applyCheckedCodexConfigTomlEdit, + CodexConfigTomlEditRefusedError, + reportCodexConfigTomlEditRefusal +} from './codex-config-toml-checked-edit' import { PROMOTED_STRUCTURED_KEYS, readPromotedSettingValues, @@ -141,6 +147,10 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( // config nobody read. throw runtimeTomlObservation.error } + if (findUnparseableManagedCodexConfig(runtimeTomlObservation.value) !== null) { + // Why: an unparseable managed config is a verbatim copy of a broken ~/.codex, never the user's in-Codex change. + return emptyPromotionPlan() + } // Why: without a baseline, a stale runtime scalar looks like a fresh in-Codex change; skip until the mirror writes one. const baselineObservation = observeCodexSettingsBaseline(runtimeHomePath) if (baselineObservation.kind === 'indeterminate') { @@ -199,18 +209,42 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( writeTargetObservation.kind === 'present' ? writeTargetObservation.value : extractOrdinaryCodexSettings(runtimeTomlObservation.value) - const withPromotedSettings = upsertPromotedSettingsInContent(systemContent, updates) - // Why: plan against the content actually being edited, not a second read of the - // source — when the system config is seeded from the runtime, its registration - // tables are already present and re-appending them would duplicate the table. - const nextContent = applyCodexRegistrationPromotions( - withPromotedSettings, - planCodexRegistrationPromotion( - runtimeTomlObservation.value, - withPromotedSettings, - baseline?.registrations ?? new Map() - ) - ) + // Why (#22592): this is the user's real config, so the edit must leave it readable and change only what it promotes. + let nextContent: string + try { + nextContent = applyCheckedCodexConfigTomlEdit(systemContent, (content) => { + const withPromotedSettings = upsertPromotedSettingsInContent(content, updates) + // Why: plan against the content actually being edited, not a second read of the + // source — when the system config is seeded from the runtime, its registration + // tables are already present and re-appending them would duplicate the table. + return { + content: applyCodexRegistrationPromotions( + withPromotedSettings, + planCodexRegistrationPromotion( + runtimeTomlObservation.value, + withPromotedSettings, + baseline?.registrations ?? new Map() + ) + ), + ownedPaths: [ + ...[...updates.keys()].map((key) => key.split('.')), + ['marketplaces'], + ['plugins'] + ] + } + }) + } catch (error) { + if (!(error instanceof CodexConfigTomlEditRefusedError)) { + throw error + } + const refusal = error.forConfigPath(systemTomlPath) + reportCodexConfigTomlEditRefusal(refusal, 'Skipped promoting Codex settings') + if (refusal.reason === 'input-invalid') { + // Why: an unparseable ~/.codex is copied verbatim by the mirror, so promotion must not stall it. + return { conflicts, runtimeValuesToPreserve, mirroredMcpServers, mirroredMcpServerRoot } + } + throw refusal + } if (nextContent === systemContent) { return { conflicts, runtimeValuesToPreserve, mirroredMcpServers, mirroredMcpServerRoot } } diff --git a/src/main/codex/config-toml-hook-trust-blocks.ts b/src/main/codex/config-toml-hook-trust-blocks.ts index e97b17faecd1..cbe08585cdbf 100644 --- a/src/main/codex/config-toml-hook-trust-blocks.ts +++ b/src/main/codex/config-toml-hook-trust-blocks.ts @@ -5,6 +5,11 @@ import { } from './config-toml-line-scan' import { normalizeCodexHookTrustLookupKey } from './codex-trust-identity' import { findNextTomlTableHeader, parseHookStateTomlHeaderKey } from './config-toml-syntax' +import { + scanTomlStructure, + tomlKeyPathsEqual, + type TomlTableLine +} from './codex-config-toml-structure' export type HookTrustBlockRange = { start: number @@ -38,7 +43,12 @@ export function findHookTrustBlockRanges( const headerLineEnd = rawLine.endsWith('\r') ? lineEnd - 1 : lineEnd const nextHeaderOffset = findNextTomlTableHeader(content.slice(nextCursor)) const blockEnd = nextHeaderOffset === -1 ? content.length : nextCursor + nextHeaderOffset - ranges.push({ start: cursor, headerLineEnd, contentStart: nextCursor, end: blockEnd }) + ranges.push({ + start: cursor, + headerLineEnd, + contentStart: nextCursor, + end: excludeTrailingComments(content, nextCursor, blockEnd) + }) cursor = Math.max(blockEnd, nextCursor) continue } @@ -48,6 +58,29 @@ export function findHookTrustBlockRanges( return ranges } +/** Comments directly above the next table describe that table, so an edit of this block keeps them. */ +function excludeTrailingComments(content: string, contentStart: number, blockEnd: number): number { + const lines = content.slice(contentStart, blockEnd).split('\n') + if (lines.at(-1) === '') { + lines.pop() + } + let firstKept = lines.length + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]?.trim() ?? '' + if (line.startsWith('#')) { + firstKept = index + } else if (line !== '') { + break + } + } + if (firstKept === lines.length) { + return blockEnd + } + return ( + contentStart + lines.slice(0, firstKept).reduce((length, line) => length + line.length + 1, 0) + ) +} + export function findAllHookTrustBlocks(content: string): (HookTrustBlockRange & { key: string })[] { const blocks: (HookTrustBlockRange & { key: string })[] = [] let cursor = 0 @@ -83,14 +116,20 @@ export function findAllHookTrustBlocks(content: string): (HookTrustBlockRange & } export function ensureHooksStateParentTable(content: string): string { - if (/^[ \t]*\[hooks\.state\][ \t]*(?:#[^\r\n]*)?$/m.test(content)) { + const tables = scanTomlStructure(content).filter( + (line): line is TomlTableLine => line.kind === 'table' && !line.isArray + ) + if (tables.some((line) => tomlKeyPathsEqual(line.segments, ['hooks', 'state']))) { return content } const eol = content.includes('\r\n') ? '\r\n' : '\n' const parent = `[hooks.state]${eol}` - const hookHeader = /^[ \t]*\[hooks\.state\.(?:"|')/m.exec(content) - if (hookHeader) { - return `${content.slice(0, hookHeader.index)}${parent}${eol}${content.slice(hookHeader.index)}` + const firstHookHeader = tables.find( + (line) => + line.segments.length === 3 && line.segments[0] === 'hooks' && line.segments[1] === 'state' + ) + if (firstHookHeader) { + return `${content.slice(0, firstHookHeader.lineStart)}${parent}${eol}${content.slice(firstHookHeader.lineStart)}` } if (content.length === 0) { return parent diff --git a/src/main/codex/config-toml-hook-trust-edit.ts b/src/main/codex/config-toml-hook-trust-edit.ts index a8487012f058..73702be1e169 100644 --- a/src/main/codex/config-toml-hook-trust-edit.ts +++ b/src/main/codex/config-toml-hook-trust-edit.ts @@ -9,45 +9,141 @@ import { } from './codex-trust-identity' import { ensureHooksStateParentTable, + findAllHookTrustBlocks, findHookTrustBlockRanges, type HookTrustBlockRange } from './config-toml-hook-trust-blocks' import { escapeTomlBasicString } from './config-toml-syntax' +import { applyCheckedCodexConfigTomlEdit } from './codex-config-toml-checked-edit' +import { + readTomlAssignmentValue, + scanTomlStructure, + tomlKeyPathsEqual +} from './codex-config-toml-structure' export function upsertHookTrustContent( existingContent: string, entries: readonly CodexTrustEntry[] ): string { const existing = stripLeadingBom(existingContent) - let updated = entries.some((entry) => + const writes = entries.map((entry) => ({ + keys: getTrustKeyWriteVariants(computeCodexTrustKey(entry)), + hash: entry.trustedHash ?? computeCodexTrustedHash(entry), + explicitEnabled: entry.enabled + })) + const needsParentTable = entries.some((entry) => usesWindowsCodexPathSeparators(normalizeCodexTrustSourcePath(entry.sourcePath)) ) - ? ensureHooksStateParentTable(existing) - : existing - for (const entry of entries) { - updated = upsertTrustBlocks( - updated, - getTrustKeyWriteVariants(computeCodexTrustKey(entry)), - entry.trustedHash ?? computeCodexTrustedHash(entry), - entry.enabled - ) + const updated = applyCheckedCodexConfigTomlEdit( + existing, + (content) => { + let next = needsParentTable ? ensureHooksStateParentTable(content) : content + for (const write of writes) { + next = upsertTrustBlocks(next, write.keys, write.hash, write.explicitEnabled) + } + return { + content: next, + ownedPaths: getOwnedHookStatePaths( + content, + writes.flatMap((write) => write.keys) + ), + expected: writes.flatMap((write) => + write.keys.map((key) => ({ + path: ['hooks', 'state', key, 'trusted_hash'], + value: write.hash + })) + ) + } + }, + { + collapsesOwnedDuplicates: (content) => + stripHookTrustBlocks( + content, + new Set(writes.flatMap((write) => write.keys).map(normalizeCodexHookTrustLookupKey)) + ) + } + ) + return updated === existing ? existingContent : updated +} + +function stripHookTrustBlocks(content: string, normalizedKeys: ReadonlySet): string { + let cursor = 0 + let stripped = '' + for (const range of findHookTrustBlockRanges(content, normalizedKeys)) { + stripped += content.slice(cursor, range.start) + cursor = range.end } - return updated + return stripped + content.slice(cursor) } export function removeHookTrustContent(content: string, keys: readonly string[]): string { const normalizedKeys = new Set(keys.map(normalizeCodexHookTrustLookupKey)) - const ranges = findHookTrustBlockRanges(content, normalizedKeys) - if (ranges.length === 0) { + if (findHookTrustBlockRanges(content, normalizedKeys).length === 0) { return content } - let cursor = 0 - let updated = '' - for (const range of ranges) { - updated += content.slice(cursor, range.start) - cursor = range.end + return applyCheckedCodexConfigTomlEdit( + content, + (input) => ({ + content: stripHookTrustBlocks(input, normalizedKeys), + ownedPaths: getOwnedHookStatePaths(input, keys) + }), + { collapsesOwnedDuplicates: (input) => stripHookTrustBlocks(input, normalizedKeys) } + ) +} + +/** Sets `enabled` on existing hooks.state tables only; a missing table stays missing. */ +export function setHookTrustEnabledContent( + existingContent: string, + states: readonly { key: string; enabled: boolean }[] +): string { + const existing = stripLeadingBom(existingContent) + const updated = applyCheckedCodexConfigTomlEdit(existing, (content) => { + let next = content + for (const { key, enabled } of states) { + next = setEnabledInTrustBlocks(next, key, enabled) + } + return { + content: next, + ownedPaths: getOwnedHookStatePaths( + content, + states.map((state) => state.key) + ).map((path) => [...path, 'enabled']) + } + }) + return updated === existing ? existingContent : updated +} + +function setEnabledInTrustBlocks(content: string, key: string, enabled: boolean): string { + const ranges = findHookTrustBlockRanges(content, new Set([normalizeCodexHookTrustLookupKey(key)])) + let next = content + for (const range of ranges.toReversed()) { + const enabledLine = scanTomlStructure(next.slice(range.contentStart, range.end)).find( + (line) => line.kind === 'assignment' && tomlKeyPathsEqual(line.keySegments, ['enabled']) + ) + if (enabledLine?.kind === 'assignment') { + const valueStart = range.contentStart + enabledLine.lineStart + enabledLine.valueOffset + const token = /^(?:true|false)/.exec(next.slice(valueStart)) + if (token) { + next = `${next.slice(0, valueStart)}${enabled}${next.slice(valueStart + token[0].length)}` + } + } else if (!enabled) { + const eol = next.includes('\r\n') ? '\r\n' : '\n' + next = `${next.slice(0, range.contentStart)}enabled = false${eol}${next.slice(range.contentStart)}` + } } - return updated + content.slice(cursor) + return next +} + +/** The written keys plus every spelling of them the edit replaces. */ +function getOwnedHookStatePaths(content: string, keys: readonly string[]): string[][] { + const normalized = new Set(keys.map(normalizeCodexHookTrustLookupKey)) + const owned = new Set(keys) + for (const block of findAllHookTrustBlocks(content)) { + if (normalized.has(normalizeCodexHookTrustLookupKey(block.key))) { + owned.add(block.key) + } + } + return [...owned].map((key) => ['hooks', 'state', key]) } function upsertTrustBlocks( @@ -78,9 +174,12 @@ function upsertTrustBlocks( } function isBlockDisabled(content: string, range: HookTrustBlockRange): boolean { - const block = content.slice(range.headerLineEnd, range.end) - const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t\r]*(?:#.*)?$/m.exec(block) - return enabledMatch?.[1] === 'false' + return scanTomlStructure(content.slice(range.contentStart, range.end)).some( + (line) => + line.kind === 'assignment' && + tomlKeyPathsEqual(line.keySegments, ['enabled']) && + readTomlAssignmentValue(line) === false + ) } function appendTrustBlocks( diff --git a/src/main/codex/config-toml-hook-trust-read.ts b/src/main/codex/config-toml-hook-trust-read.ts index 690a2a8538a4..a5b0937f7202 100644 --- a/src/main/codex/config-toml-hook-trust-read.ts +++ b/src/main/codex/config-toml-hook-trust-read.ts @@ -1,12 +1,12 @@ import type { CodexHookTrustState } from './config-toml-trust' import { normalizeCodexHookTrustLookupKey } from './codex-trust-identity' import { findAllHookTrustBlocks } from './config-toml-hook-trust-blocks' +import { getTomlTable, parseCodexConfigToml } from './codex-config-toml-document' import { - createTomlLineScanState, - isTomlStructuralLine, - updateTomlLineScanState -} from './config-toml-line-scan' -import { unescapeTomlBasicString } from './config-toml-syntax' + readTomlAssignmentValue, + scanTomlStructure, + tomlKeyPathsEqual +} from './codex-config-toml-structure' export class CodexHookTrustEntryMap extends Map { override get(key: string): CodexHookTrustState | undefined { @@ -26,12 +26,13 @@ export class CodexHookTrustEntryMap extends Map { } } +type HookTrustStateSource = { key: string; trustedHashes: Set; enabled?: boolean } + export function readHookTrustContent(content: string): Map { const result = new CodexHookTrustEntryMap() const conflictingTrustedHashKeys = new Set() - for (const block of findAllHookTrustBlocks(content)) { - const state = readHookTrustBlockState(content.slice(block.contentStart, block.end)) - const normalizedKey = normalizeCodexHookTrustLookupKey(block.key) + for (const state of readHookTrustStateSources(content)) { + const normalizedKey = normalizeCodexHookTrustLookupKey(state.key) const existingState = result.get(normalizedKey) const trustedHash = state.trustedHashes.size === 1 ? state.trustedHashes.values().next().value : undefined @@ -56,32 +57,52 @@ export function readHookTrustContent(content: string): Map { + const state = getTomlTable(value) + if (!state) { + return [] + } + const trustedHashes = new Set() + if (typeof state.trusted_hash === 'string') { + trustedHashes.add(state.trusted_hash) + } + return [ + { + key, + trustedHashes, + enabled: typeof state.enabled === 'boolean' ? state.enabled : undefined + } + ] + }) + } + return findAllHookTrustBlocks(content).map((block) => ({ + key: block.key, + ...readHookTrustBlockState(content.slice(block.contentStart, block.end)) + })) +} + function readHookTrustBlockState(block: string): { trustedHashes: Set enabled?: boolean } { const trustedHashes = new Set() let enabled: boolean | undefined - let cursor = 0 - let scanState = createTomlLineScanState() - while (cursor < block.length) { - const newlineIndex = block.indexOf('\n', cursor) - const lineEnd = newlineIndex === -1 ? block.length : newlineIndex - const line = block.slice(cursor, lineEnd).replace(/\r$/, '') - if (isTomlStructuralLine(scanState)) { - const hashMatch = /^[ \t]*trusted_hash[ \t]*=[ \t]*"((?:[^"\\]|\\.)*)"[ \t]*(?:#.*)?$/.exec( - line - ) - if (hashMatch) { - trustedHashes.add(unescapeTomlBasicString(hashMatch[1]!)) - } - const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t]*(?:#.*)?$/.exec(line) - if (enabledMatch) { - enabled = enabled !== false && enabledMatch[1] === 'true' - } + for (const line of scanTomlStructure(block)) { + if (line.kind !== 'assignment' || line.table.segments.length > 0) { + continue + } + const value = readTomlAssignmentValue(line) + if (tomlKeyPathsEqual(line.keySegments, ['trusted_hash']) && typeof value === 'string') { + trustedHashes.add(value) + } + if (tomlKeyPathsEqual(line.keySegments, ['enabled']) && typeof value === 'boolean') { + enabled = enabled !== false && value } - scanState = updateTomlLineScanState(scanState, line) - cursor = newlineIndex === -1 ? block.length : newlineIndex + 1 } return { trustedHashes, enabled } } diff --git a/src/main/codex/config-toml-project-trust.ts b/src/main/codex/config-toml-project-trust.ts index c4cd8f149d1c..fb678a9f00a3 100644 --- a/src/main/codex/config-toml-project-trust.ts +++ b/src/main/codex/config-toml-project-trust.ts @@ -1,16 +1,22 @@ import { realpathSync } from 'node:fs' import type { CodexProjectTrustLevel } from './config-toml-trust' import { normalizeCodexTrustProjectPath } from './codex-trust-identity' +import { escapeTomlBasicString } from './config-toml-syntax' +import { getTomlTable, type TomlTable } from './codex-config-toml-document' import { - createTomlLineScanState, - isTomlStructuralLine, - updateTomlLineScanState -} from './config-toml-line-scan' + applyCheckedCodexConfigTomlEdit, + CodexConfigTomlEditRefusedError, + type CodexConfigTomlEditResult +} from './codex-config-toml-checked-edit' import { - escapeTomlBasicString, - findNextTomlTableHeader, - parseProjectTomlHeaderPath -} from './config-toml-syntax' + formatTomlKeyPath, + getAssignmentKeyPath, + scanTomlStructure, + tomlKeyPathsEqual, + tomlKeyPathStartsWith, + type TomlAssignmentLine, + type TomlStructureLine +} from './codex-config-toml-structure' export function upsertProjectTrustContent( existingContent: string, @@ -18,29 +24,128 @@ export function upsertProjectTrustContent( trustLevel: CodexProjectTrustLevel, options?: { alreadyCanonical?: boolean } ): string { - const existing = stripLeadingBom(existingContent) const trustedProjectPath = options?.alreadyCanonical ? projectPath : canonicalizeLocalProjectPath(projectPath) - const headerLineEnd = findProjectHeaderLineEnd(existing, trustedProjectPath) - const eol = existing.includes('\r\n') ? '\r\n' : '\n' - const trustLine = `trust_level = "${trustLevel}"` - if (headerLineEnd === null) { - return appendProjectTrustBlock(existing, trustedProjectPath, trustLine, eol) + const existing = stripLeadingBom(existingContent) + const updated = applyCheckedCodexConfigTomlEdit(existing, (content, table) => + editProjectTrust(content, table, trustedProjectPath, trustLevel) + ) + return updated === existing ? existingContent : updated +} + +/** Decides from the parsed document, so any spelling Codex accepts is found before Orca appends. */ +function editProjectTrust( + content: string, + table: TomlTable | null, + projectPath: string, + trustLevel: CodexProjectTrustLevel +): CodexConfigTomlEditResult { + const projectKey = findExistingProjectKey(table, projectPath) ?? projectPath + const trustPath = ['projects', projectKey, 'trust_level'] + const result = (next: string): CodexConfigTomlEditResult => ({ + content: next, + ownedPaths: [trustPath], + expected: [{ path: trustPath, value: trustLevel }] + }) + const project = getTomlTable(getTomlTable(table?.projects)?.[projectKey]) + if (project?.trust_level === trustLevel) { + return result(content) + } + const eol = content.includes('\r\n') ? '\r\n' : '\n' + const trustValue = `"${trustLevel}"` + const lines = scanTomlStructure(content) + const trustLine = lines.find( + (line): line is TomlAssignmentLine => + line.kind === 'assignment' && pathEquals(getAssignmentKeyPath(line), trustPath) + ) + if (trustLine) { + return result(replaceAssignmentValue(content, trustLine, trustValue)) + } + const header = lines.find( + (line) => + line.kind === 'table' && + !line.isArray && + tomlKeyPathsEqual(line.segments, trustPath.slice(0, 2)) + ) + if (header) { + return result(insertLineAt(content, header, `trust_level = ${trustValue}`, eol)) + } + const dottedProjectLine = findLastDottedProjectAssignment(lines, trustPath.slice(0, 2)) + if (dottedProjectLine) { + const relativeKey = formatTomlKeyPath(trustPath.slice(dottedProjectLine.table.segments.length)) + return result(insertLineAt(content, dottedProjectLine, `${relativeKey} = ${trustValue}`, eol)) + } + const inlineProject = lines.some( + (line) => + line.kind === 'assignment' && pathEquals(getAssignmentKeyPath(line), trustPath.slice(0, 2)) + ) + if (inlineProject) { + throw new CodexConfigTomlEditRefusedError({ + reason: 'unsupported-form', + detail: `projects."${projectKey}" is an inline table; set trust_level there or trust the folder in Codex.` + }) } - const nextHeaderOffset = findNextTomlTableHeader(existing.slice(headerLineEnd)) - const blockEnd = nextHeaderOffset === -1 ? existing.length : headerLineEnd + nextHeaderOffset - const existingBlock = existing.slice(headerLineEnd, blockEnd) - const trustLevelPattern = - /^[ \t]*trust_level[ \t]*=[ \t]*(?:"(?:trusted|untrusted)"|'(?:trusted|untrusted)')[ \t\r]*(?:#.*)?$/m - if (trustLevelPattern.test(existingBlock)) { + return result(appendProjectTrustBlock(content, projectKey, `trust_level = ${trustValue}`, eol)) +} + +function findExistingProjectKey(table: TomlTable | null, projectPath: string): string | null { + const projects = getTomlTable(table?.projects) + if (!projects) { + return null + } + if (Object.hasOwn(projects, projectPath)) { + return projectPath + } + const lookupPath = normalizeCodexTrustProjectPath(projectPath) + return ( + Object.keys(projects).find((key) => normalizeCodexTrustProjectPath(key) === lookupPath) ?? null + ) +} + +function pathEquals(path: readonly string[] | null, expected: readonly string[]): boolean { + return path !== null && tomlKeyPathsEqual(path, expected) +} + +/** A project defined only by dotted keys (`"/x".model = …` under `[projects]`) takes its trust as one more dotted key. */ +function findLastDottedProjectAssignment( + lines: readonly TomlStructureLine[], + projectTablePath: readonly string[] +): TomlAssignmentLine | undefined { + return lines.findLast((line): line is TomlAssignmentLine => { + if (line.kind !== 'assignment') { + return false + } + const path = getAssignmentKeyPath(line) return ( - existing.slice(0, headerLineEnd) + - existingBlock.replace(trustLevelPattern, trustLine) + - existing.slice(blockEnd) + path !== null && + path.length > projectTablePath.length && + tomlKeyPathStartsWith(path, projectTablePath) && + line.table.segments.length < projectTablePath.length ) - } - return `${existing.slice(0, headerLineEnd)}${eol}${trustLine}${existing.slice(headerLineEnd)}` + }) +} + +function replaceAssignmentValue( + content: string, + line: TomlAssignmentLine, + renderedValue: string +): string { + const valueStart = line.lineStart + line.valueOffset + const rest = content.slice(valueStart, line.contentEnd) + const stringValue = /^(?:"(?:[^"\\]|\\.)*"|'[^']*')/.exec(rest) + const valueEnd = stringValue ? valueStart + stringValue[0].length : line.contentEnd + return content.slice(0, valueStart) + renderedValue + content.slice(valueEnd) +} + +function insertLineAt( + content: string, + after: TomlStructureLine, + text: string, + eol: string +): string { + const lineEnd = after.contentEnd + return `${content.slice(0, lineEnd)}${eol}${text}${content.slice(lineEnd)}` } function canonicalizeLocalProjectPath(projectPath: string): string { @@ -69,28 +174,6 @@ function appendProjectTrustBlock( return `${existing}${separator}${block}${eol}` } -function findProjectHeaderLineEnd(content: string, projectPath: string): number | null { - const lookupPath = normalizeCodexTrustProjectPath(projectPath) - let cursor = 0 - let scanState = createTomlLineScanState() - while (cursor < content.length) { - const newlineIndex = content.indexOf('\n', cursor) - const lineEnd = newlineIndex === -1 ? content.length : newlineIndex - const rawLine = content.slice(cursor, lineEnd) - const line = rawLine.replace(/\r$/, '') - const existingPath = isTomlStructuralLine(scanState) ? parseProjectTomlHeaderPath(line) : null - if (existingPath !== null && normalizeCodexTrustProjectPath(existingPath) === lookupPath) { - return rawLine.endsWith('\r') ? lineEnd - 1 : lineEnd - } - scanState = updateTomlLineScanState(scanState, line) - if (newlineIndex === -1) { - return null - } - cursor = newlineIndex + 1 - } - return null -} - function stripLeadingBom(content: string): string { return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content } diff --git a/src/main/codex/config-toml-runtime-owned-sections.ts b/src/main/codex/config-toml-runtime-owned-sections.ts index fee0cfdf6432..b3df908fd429 100644 --- a/src/main/codex/config-toml-runtime-owned-sections.ts +++ b/src/main/codex/config-toml-runtime-owned-sections.ts @@ -6,6 +6,12 @@ import { updateTomlLineScanState } from './config-toml-line-scan' import { parseTomlTableHeaderPath } from './config-toml-key-path' +import { parseStandardTableHeaderSegments } from './config-toml-syntax' +import { + readTomlAssignmentValue, + scanTomlStructure, + tomlKeyPathsEqual +} from './codex-config-toml-structure' import { normalizeCodexProjectPathForLookup, normalizeCodexProjectPathForRevocationLookup, @@ -82,10 +88,15 @@ export function isRuntimePreservedTomlSection(header: string): boolean { } export function isRuntimeHookTrustTomlSection(header: string): boolean { - const trimmed = header.trim() + const segments = parseStandardTableHeaderSegments(header) // Why: Codex's config writer materializes the parent table on Windows. It is // part of runtime-owned trust and must survive the next config mirror too. - return trimmed === '[hooks.state]' || trimmed.startsWith('[hooks.state.') + return ( + segments !== null && + (segments.length === 2 || segments.length === 3) && + segments[0] === 'hooks' && + segments[1] === 'state' + ) } export function isRuntimeProjectTomlSection(header: string): boolean { @@ -106,16 +117,24 @@ export function getMcpServerTomlSectionName(header: string): string | null { export function getTomlSectionHeaderKey(header: string): string { const projectPath = parseCodexProjectHeaderPath(header) return projectPath === null - ? header.trim() + ? getDecodedTomlSectionIdentity(header) : `project:${normalizeCodexProjectPathForLookup(projectPath)}` } +// Why (#22592): spellings of one table share one identity, as they do for Codex. +function getDecodedTomlSectionIdentity(header: string): string { + const table = parseTomlTableHeaderPath(header) + return table + ? `${table.isArray ? 'array' : 'table'}:${JSON.stringify(table.segments)}` + : header.trim() +} + // Why: configs written before WSL tails compared case-sensitively can hold a // revocation under drifted casing; match it loosely so trust is not resurrected. export function getRevocationTomlSectionHeaderKey(header: string): string { const projectPath = parseCodexProjectHeaderPath(header) return projectPath === null - ? header.trim() + ? getDecodedTomlSectionIdentity(header) : `project:${normalizeCodexProjectPathForRevocationLookup(projectPath)}` } @@ -124,7 +143,17 @@ export function getRevocationTomlSectionHeaderKey(header: string): string { export function deduplicateProjectTomlSections(sections: TomlSection[]): TomlSection[] { const deduplicated: TomlSection[] = [] const projectIndexes = new Map() + const hookTrustIdentities = new Set() for (const section of sections) { + if (isRuntimeHookTrustTomlSection(section.header)) { + // Why: two definitions of one hooks.state table make the whole file unreadable to Codex. + const identity = getDecodedTomlSectionIdentity(section.header) + if (!hookTrustIdentities.has(identity)) { + hookTrustIdentities.add(identity) + deduplicated.push(section) + } + continue + } if (!isRuntimeProjectTomlSection(section.header)) { deduplicated.push(section) continue @@ -150,12 +179,15 @@ export function deduplicateProjectTomlSections(sections: TomlSection[]): TomlSec } export function getProjectTrustLevel(block: string): 'trusted' | 'untrusted' | null { - const match = - /^[ \t]*trust_level[ \t]*=[ \t]*(?:"(trusted|untrusted)"|'(trusted|untrusted)')[ \t\r]*(?:#.*)?$/m.exec( - block - ) - const trustLevel = match?.[1] ?? match?.[2] ?? null - return trustLevel === 'trusted' || trustLevel === 'untrusted' ? trustLevel : null + for (const line of scanTomlStructure(block)) { + if (line.kind === 'assignment' && tomlKeyPathsEqual(line.keySegments, ['trust_level'])) { + const value = readTomlAssignmentValue(line) + if (value === 'trusted' || value === 'untrusted') { + return value + } + } + } + return null } export function joinTomlBlocks(blocks: string[]): string { diff --git a/src/main/codex/config-toml-syntax.ts b/src/main/codex/config-toml-syntax.ts index e3db4d546d34..ffc56bf537ed 100644 --- a/src/main/codex/config-toml-syntax.ts +++ b/src/main/codex/config-toml-syntax.ts @@ -1,13 +1,10 @@ import { createTomlLineScanState, + getTomlTableHeader, isTomlStructuralLine, updateTomlLineScanState } from './config-toml-line-scan' - -type ParsedTomlString = { - value: string - endIndex: number -} +import { parseTomlTableHeaderPath } from './config-toml-key-path' export function escapeTomlBasicString(value: string): string { return value @@ -20,76 +17,24 @@ export function escapeTomlBasicString(value: string): string { .replaceAll('\t', '\\t') } +// Why (#22592): identity is the decoded key path, so `["hooks"."state"."k"]` +// and `[hooks.state.'k']` are the same table Codex sees. export function parseHookStateTomlHeaderKey(line: string): string | null { - const trimmed = line.trimStart() - const prefixMatch = /^\[[ \t]*hooks[ \t]*\.[ \t]*state[ \t]*\.[ \t]*/.exec(trimmed) - return prefixMatch ? parseTomlTableStringKey(trimmed, prefixMatch[0].length) : null + const segments = parseStandardTableHeaderSegments(line) + return segments?.length === 3 && segments[0] === 'hooks' && segments[1] === 'state' + ? (segments[2] ?? null) + : null } export function parseProjectTomlHeaderPath(line: string): string | null { - const trimmed = line.replace(/\r$/, '').trimStart() - const prefixMatch = /^\[[ \t]*projects[ \t]*\.[ \t]*/.exec(trimmed) - return prefixMatch ? parseTomlTableStringKey(trimmed, prefixMatch[0].length) : null -} - -function parseTomlTableStringKey(line: string, startIndex: number): string | null { - const parsedKey = parseTomlSingleLineString(line, startIndex) - if (!parsedKey) { - return null - } - let index = skipTomlInlineWhitespace(line, parsedKey.endIndex) - if (line[index] !== ']') { - return null - } - index = skipTomlInlineWhitespace(line, index + 1) - return index === line.length || line[index] === '#' ? parsedKey.value : null -} - -function parseTomlSingleLineString(line: string, startIndex: number): ParsedTomlString | null { - if (line[startIndex] === '"') { - return parseTomlBasicSingleLineString(line, startIndex + 1) - } - if (line[startIndex] === "'") { - return parseTomlLiteralSingleLineString(line, startIndex + 1) - } - return null -} - -function parseTomlBasicSingleLineString(line: string, startIndex: number): ParsedTomlString | null { - let value = '' - let index = startIndex - while (index < line.length) { - const char = line[index] - if (char === '"') { - return { value, endIndex: index + 1 } - } - if (char === '\\' && index + 1 < line.length) { - value += unescapeTomlBasicStringEscape(line[index + 1]!) - index += 2 - continue - } - value += char - index += 1 - } - return null -} - -function parseTomlLiteralSingleLineString( - line: string, - startIndex: number -): ParsedTomlString | null { - const endIndex = line.indexOf("'", startIndex) - return endIndex === -1 - ? null - : { value: line.slice(startIndex, endIndex), endIndex: endIndex + 1 } + const segments = parseStandardTableHeaderSegments(line) + return segments?.length === 2 && segments[0] === 'projects' ? (segments[1] ?? null) : null } -function skipTomlInlineWhitespace(line: string, startIndex: number): number { - let index = startIndex - while (line[index] === ' ' || line[index] === '\t') { - index += 1 - } - return index +export function parseStandardTableHeaderSegments(line: string): readonly string[] | null { + const header = getTomlTableHeader(line.replace(/\r$/, '')) + const parsed = header === null ? null : parseTomlTableHeaderPath(header) + return parsed && !parsed.isArray ? parsed.segments : null } export function findNextTomlTableHeader(text: string): number { diff --git a/src/main/codex/config-toml-trust-stale-writes.test.ts b/src/main/codex/config-toml-trust-stale-writes.test.ts index 6a8fb8398dec..e9ccf01aeabc 100644 --- a/src/main/codex/config-toml-trust-stale-writes.test.ts +++ b/src/main/codex/config-toml-trust-stale-writes.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { chmodSync, readFileSync, readdirSync, statSync } from 'node:fs' import * as trustApi from './config-toml-trust' +import { CodexConfigTomlEditRefusedError } from './codex-config-toml-checked-edit' import { createTrustConfigFixture, removeTrustConfigFixture @@ -15,19 +16,15 @@ afterEach(() => { }) describe('config.toml partial and stale writes', () => { - it('preserves a truncated malformed prefix while appending trust', () => { + it('refuses to append trust to a truncated malformed file instead of editing it further', () => { const partial = ['model = "gpt-5"', '[mcp_servers.partial', 'command = "still-user-data'].join( '\n' ) - const updated = trustApi.upsertProjectTrustLevelInContent( - partial, - 'C:/Remote/Repo', - 'trusted', - { alreadyCanonical: true } - ) - - expect(updated.startsWith(partial)).toBe(true) - expect(updated).toContain('[projects."C:/Remote/Repo"]') + expect(() => + trustApi.upsertProjectTrustLevelInContent(partial, 'C:/Remote/Repo', 'trusted', { + alreadyCanonical: true + }) + ).toThrow(CodexConfigTomlEditRefusedError) }) it('pins the no-lock stale-writer policy to one complete last snapshot', () => { diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index 8b1943c058e2..e8632b623ae4 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -11,11 +11,20 @@ import { parseCodexTrustKey } from './codex-trust-identity' import { writeTomlConfigAtomically } from './config-toml-atomic-write' -import { removeHookTrustContent, upsertHookTrustContent } from './config-toml-hook-trust-edit' +import { + removeHookTrustContent, + setHookTrustEnabledContent, + upsertHookTrustContent +} from './config-toml-hook-trust-edit' import { CodexHookTrustEntryMap, readHookTrustContent } from './config-toml-hook-trust-read' import { upsertProjectTrustContent } from './config-toml-project-trust' import { escapeTomlBasicString, parseProjectTomlHeaderPath } from './config-toml-syntax' import { observe } from './codex-path-observation' +import { parseCodexConfigToml } from './codex-config-toml-document' +import { + assertCodexConfigTomlParses, + CodexConfigTomlEditRefusedError +} from './codex-config-toml-checked-edit' export type CodexEventLabel = | 'pre_tool_use' @@ -104,9 +113,11 @@ export function upsertHookTrustEntries( entries: readonly CodexTrustEntry[] ): void { const existing = readTomlForMutation(configPath) - const updated = upsertHookTrustEntriesInContent(existing, entries) + const updated = withConfigPath(configPath, () => + upsertHookTrustEntriesInContent(existing, entries) + ) if (updated !== existing) { - writeConfigAtomically(configPath, updated) + writeTomlConfigAtomically(configPath, updated) } } @@ -117,15 +128,32 @@ export function upsertHookTrustEntriesInContent( return upsertHookTrustContent(existingContent, entries) } +/** Mirrors the user's own enabled/disabled choice onto hooks.state tables that already exist. */ +export function setHookTrustEnabledStates( + configPath: string, + states: readonly { key: string; enabled: boolean }[] +): void { + if (states.length === 0 || !existsSync(configPath)) { + return + } + const existing = readTomlFile(configPath) + const updated = withConfigPath(configPath, () => setHookTrustEnabledContent(existing, states)) + if (updated !== existing) { + writeTomlConfigAtomically(configPath, updated) + } +} + export function upsertProjectTrustLevel( configPath: string, projectPath: string, trustLevel: CodexProjectTrustLevel ): void { const existing = readTomlForMutation(configPath) - const updated = upsertProjectTrustLevelInContent(existing, projectPath, trustLevel) + const updated = withConfigPath(configPath, () => + upsertProjectTrustLevelInContent(existing, projectPath, trustLevel) + ) if (updated !== existing) { - writeConfigAtomically(configPath, updated) + writeTomlConfigAtomically(configPath, updated) } } @@ -150,18 +178,38 @@ export function parseCodexProjectHeaderPath(line: string): string | null { return parseProjectTomlHeaderPath(line) } -export function writeConfigAtomically(configPath: string, contents: string): void { +/** Writes a whole-file edit, refusing one that would turn a config Codex can read into one it cannot. */ +export function writeConfigAtomically( + configPath: string, + contents: string, + previousContents?: string +): void { + if (previousContents === undefined || parseCodexConfigToml(previousContents).ok) { + withConfigPath(configPath, () => assertCodexConfigTomlParses(contents)) + } writeTomlConfigAtomically(configPath, contents) } +function withConfigPath(configPath: string, run: () => T): T { + try { + return run() + } catch (error) { + throw error instanceof CodexConfigTomlEditRefusedError && error.configPath === null + ? error.forConfigPath(configPath) + : error + } +} + export function removeHookTrustEntries(configPath: string, keys: readonly string[]): void { if (!existsSync(configPath)) { return } const existing = readTomlFile(configPath) - const updated = removeHookTrustEntriesFromContent(existing, keys) + const updated = withConfigPath(configPath, () => + removeHookTrustEntriesFromContent(existing, keys) + ) if (updated !== existing) { - writeConfigAtomically(configPath, updated) + writeTomlConfigAtomically(configPath, updated) } } diff --git a/src/main/remote-agent-trust-presets.test.ts b/src/main/remote-agent-trust-presets.test.ts index 4b0692bb1427..3be4f10ac09d 100644 --- a/src/main/remote-agent-trust-presets.test.ts +++ b/src/main/remote-agent-trust-presets.test.ts @@ -214,6 +214,41 @@ describe('markRemoteAgentWorkspaceTrusted', () => { '[projects."/real/repo"]\ntrust_level = "trusted"\n' ) }) + + it('edits the remote host’s quoted ["projects"."…"] table instead of appending a duplicate (#22592)', async () => { + const existing = '["projects"."/real/repo"]\ntrust_level = "untrusted"\n' + const fsProvider = makeFsProvider({ + readFile: vi.fn(async () => ({ content: existing, isBinary: false })) + }) + mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) + + await markRemoteAgentWorkspaceTrusted({ + preset: 'codex', + connectionId: 'ssh-1', + workspacePath: '/repo' + }) + + expect(fsProvider.writeFile).toHaveBeenCalledWith( + '/home/u/.codex/config.toml', + '["projects"."/real/repo"]\ntrust_level = "trusted"\n' + ) + }) + + it('never writes an unreadable remote config and names the remote file', async () => { + const fsProvider = makeFsProvider({ + readFile: vi.fn(async () => ({ content: '[a]\nx = 1\n[a]\ny = 2\n', isBinary: false })) + }) + mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) + + await expect( + markRemoteAgentWorkspaceTrusted({ + preset: 'codex', + connectionId: 'ssh-1', + workspacePath: '/repo' + }) + ).rejects.toThrow('/home/u/.codex/config.toml') + expect(fsProvider.writeFile).not.toHaveBeenCalled() + }) }) describe('remote Qoder trust', () => { diff --git a/src/main/remote-agent-trust-presets.ts b/src/main/remote-agent-trust-presets.ts index 021f18683cae..fa403f93a9b4 100644 --- a/src/main/remote-agent-trust-presets.ts +++ b/src/main/remote-agent-trust-presets.ts @@ -1,6 +1,7 @@ import { markRemoteQoderWorkspaceTrusted } from './qoder/workspace-trust' import type { AgentTrustPreset } from './agent-trust-presets' import { upsertProjectTrustLevelInContent } from './codex/config-toml-trust' +import { CodexConfigTomlEditRefusedError } from './codex/codex-config-toml-checked-edit' import { getActiveMultiplexer } from './ssh/ssh-target-registry' import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' import type { FileReadResult, IFilesystemProvider } from './providers/types' @@ -101,11 +102,17 @@ async function markRemoteCodexProjectTrusted( const codexDir = `${remoteHome}/.codex` const configPath = `${codexDir}/config.toml` const existing = await readRemoteTextFile(fsProvider, configPath) - const updated = upsertProjectTrustLevelInContent(existing, workspacePath, 'trusted', { - // Why: workspacePath was resolved by the remote filesystem provider; local - // realpath would canonicalize the wrong machine on SSH. - alreadyCanonical: true - }) + let updated: string + try { + // Why: the checked edit runs here because this process holds the parser; the remote gets only validated bytes. + updated = upsertProjectTrustLevelInContent(existing, workspacePath, 'trusted', { + // Why: workspacePath was resolved by the remote filesystem provider; local + // realpath would canonicalize the wrong machine on SSH. + alreadyCanonical: true + }) + } catch (error) { + throw error instanceof CodexConfigTomlEditRefusedError ? error.forConfigPath(configPath) : error + } if (updated === existing) { return } From 91cf10b5ff15f08620fa8f6246d84a417572f05e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:42:07 -0700 Subject: [PATCH 03/25] chore(deps): pin smol-toml exactly; its strictness is part of Codex config validation --- package.json | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index a7adbfab7023..1eea5f8797a1 100644 --- a/package.json +++ b/package.json @@ -193,7 +193,7 @@ "react-i18next": "17.0.15", "serve-sim": "0.1.40", "sherpa-onnx": "1.12.37", - "smol-toml": "^1.8.0", + "smol-toml": "1.8.0", "ssh2": "^1.17.0", "tldts": "7.4.14", "tweetnacl": "^1.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 998f7807032c..e07623f97394 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,7 +182,7 @@ importers: specifier: 1.12.37 version: 1.12.37 smol-toml: - specifier: ^1.8.0 + specifier: 1.8.0 version: 1.8.0 ssh2: specifier: ^1.17.0 From 0ad843c9b638b7b5cc9a678e91e526ee1c9b0c43 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:24:25 -0700 Subject: [PATCH 04/25] fix(codex): mark Orca's verbatim copy of a broken ~/.codex so it is never taken for managed state A ~/.codex broken only by a duplicate project or hook-trust table was copied verbatim into a fresh managed Codex home, and the mirror's own dedupe then made that copy look like readable managed state: later passes kept it, merged its sections and promoted from it. After the user fixed and trimmed ~/.codex, Orca wrote a deleted marketplace back into the real ~/.codex, and the managed home kept a deleted MCP server and a trusted folder. The copy now ends with a marker comment (`# orca: verbatim copy of ; replaced when the source parses`), appended so Codex reports the same error lines as in the user's file. A marked managed config is always reseeded, never kept, merged or promoted from; the marker disappears with the reseed, so it cannot strand. --- .../codex-config-legacy-shared-home-mirror.ts | 8 +--- ...dex-config-mirror-duplicate-repair.test.ts | 43 ++++++++++++++++++- src/main/codex/codex-config-mirror.ts | 2 +- .../codex/codex-managed-config-validity.ts | 25 +++++++++-- 4 files changed, 66 insertions(+), 12 deletions(-) diff --git a/src/main/codex/codex-config-legacy-shared-home-mirror.ts b/src/main/codex/codex-config-legacy-shared-home-mirror.ts index a71cdeaa7445..b8d4b975db57 100644 --- a/src/main/codex/codex-config-legacy-shared-home-mirror.ts +++ b/src/main/codex/codex-config-legacy-shared-home-mirror.ts @@ -53,12 +53,8 @@ export function syncSystemConfigIntoLegacySharedCodexHome( runtimeConfigPath, source: rawSystemConfig, runtime: runtimeConfigBeforeMirror, - writeVerbatimCopy: () => - writeFileAtomicallyIfUnchanged( - runtimeConfigPath, - runtimeConfigBeforeMirror, - rawSystemConfig - ) + writeVerbatimCopy: (copy) => + writeFileAtomicallyIfUnchanged(runtimeConfigPath, runtimeConfigBeforeMirror, copy) }) ) { return diff --git a/src/main/codex/codex-config-mirror-duplicate-repair.test.ts b/src/main/codex/codex-config-mirror-duplicate-repair.test.ts index 04f46a6e3aee..f95a8d57b0b0 100644 --- a/src/main/codex/codex-config-mirror-duplicate-repair.test.ts +++ b/src/main/codex/codex-config-mirror-duplicate-repair.test.ts @@ -142,7 +142,9 @@ describe('managed-home mirror never writes a config Codex cannot read (#22592)', syncSystemConfigIntoManagedCodexHome() syncSystemConfigIntoManagedCodexHome() - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe(broken) + expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe( + `${broken}${verbatimMarker(getSystemConfigPath())}` + ) const copies = warn.mock.calls .flat() .filter((line) => String(line).includes('copied it unchanged')) @@ -228,7 +230,9 @@ describe('managed-home mirror never writes a config Codex cannot read (#22592)', syncSystemConfigIntoManagedCodexHome() - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('log_dir = "logs\\uD800dir"\n') + expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe( + `log_dir = "logs\\uD800dir"\n${verbatimMarker(getSystemConfigPath())}` + ) warn.mockRestore() }) }) @@ -236,6 +240,10 @@ describe('managed-home mirror never writes a config Codex cannot read (#22592)', // Why: an invalid save of ~/.codex followed by a fix must end exactly where the // same edits without the invalid save end; nothing managed-only may be lost and // nothing stale may be promoted into ~/.codex. +function verbatimMarker(sourcePath: string): string { + return `# orca: verbatim copy of ${sourcePath}; replaced when the source parses\n` +} + describe('break-then-fix of ~/.codex keeps managed-home state', () => { function makeHomes(): { homes: { systemHomePath: string; runtimeHomePath: string } @@ -318,4 +326,35 @@ describe('break-then-fix of ~/.codex keeps managed-home state', () => { rmSync(root, { recursive: true, force: true }) } }) + + it('never treats a verbatim copy as managed state, even when the mirror dedupe would make it parse', () => { + const { homes, root } = makeHomes() + const sysPath = join(homes.systemHomePath, 'config.toml') + const rtPath = join(homes.runtimeHomePath, 'config.toml') + const good = 'model = "A"\n\n[mcp_servers.keep]\ncommand = "k"\n' + // Why: broken only by a duplicate project table that Orca's repair must not collapse. + const broken = + 'model = "A"\n\n[marketplaces.stale]\nsource = "s"\n\n[mcp_servers.tmp]\ncommand = "t"\n\n[projects."/x"]\ntrust_level = "trusted"\nfoo = 1\n\n[projects."/x"]\nbar = 2\n' + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + writeFileSync(sysPath, broken) + syncSystemConfigIntoManagedCodexHome(homes) + expect(readFileSync(rtPath, 'utf8')).toBe(`${broken}${verbatimMarker(sysPath)}`) + syncSystemConfigIntoManagedCodexHome(homes) + // The user fixes ~/.codex and removes the stale marketplace, the server and the project. + writeFileSync(sysPath, good) + syncSystemConfigIntoManagedCodexHome(homes) + syncSystemConfigIntoManagedCodexHome(homes) + + expect(readFileSync(sysPath, 'utf8')).toBe(good) + const runtime = readFileSync(rtPath, 'utf8') + expect(runtime).not.toContain('marketplaces.stale') + expect(runtime).not.toContain('mcp_servers.tmp') + expect(runtime).not.toContain('projects."/x"') + expect(runtime).not.toContain('orca: verbatim copy') + } finally { + warn.mockRestore() + rmSync(root, { recursive: true, force: true }) + } + }) }) diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 85c00edb27a9..fe2c868f6fc1 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -226,7 +226,7 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe( runtimeConfigPath, source: rawSystemConfig, runtime: existingRuntimeConfig, - writeVerbatimCopy: () => writeFileAtomically(runtimeConfigPath, rawSystemConfig) + writeVerbatimCopy: (copy) => writeFileAtomically(runtimeConfigPath, copy) }) if (unparseableSource) { return { diff --git a/src/main/codex/codex-managed-config-validity.ts b/src/main/codex/codex-managed-config-validity.ts index 3e4bd9a1da05..3656156ac608 100644 --- a/src/main/codex/codex-managed-config-validity.ts +++ b/src/main/codex/codex-managed-config-validity.ts @@ -91,6 +91,10 @@ export function findUnparseableCodexConfig(config: string): string | null { * of a broken ~/.codex. */ export function findUnparseableManagedCodexConfig(config: string): string | null { + if (isVerbatimCodexSourceCopy(config)) { + // Why: a copy of a broken ~/.codex is never managed state, even where the mirror's dedupe would make it parse. + return 'verbatim copy of an unparseable ~/.codex' + } const parseError = findUnparseableCodexConfig(config) if (parseError === null) { return null @@ -167,7 +171,7 @@ export function applyUnparseableCodexSourceRule(args: { runtimeConfigPath: string source: string runtime: string | null - writeVerbatimCopy: () => void + writeVerbatimCopy: (content: string) => void }): 'left-untouched' | 'copied' | null { const parseError = args.source.trim() === '' ? null : findUnparseableCodexConfig(args.source) if (parseError === null) { @@ -179,12 +183,27 @@ export function applyUnparseableCodexSourceRule(args: { return 'left-untouched' } reportVerbatimUnparseableCodexSource(args.sourcePath, args.runtimeConfigPath, parseError) - if (args.runtime !== args.source) { - args.writeVerbatimCopy() + const copy = markVerbatimCodexSourceCopy(args.source, args.sourcePath) + if (args.runtime !== copy) { + args.writeVerbatimCopy(copy) } return 'copied' } +const VERBATIM_COPY_MARKER = '# orca: verbatim copy of ' + +/** Appended, so Codex reports the same line numbers as in the user's own file. */ +function markVerbatimCodexSourceCopy(source: string, sourcePath: string): string { + const eol = source.includes('\r\n') ? '\r\n' : '\n' + const body = source.endsWith('\n') ? source : `${source}${eol}` + return `${body}${VERBATIM_COPY_MARKER}${sourcePath}; replaced when the source parses${eol}` +} + +function isVerbatimCodexSourceCopy(config: string): boolean { + const lastLine = config.trimEnd().split('\n').at(-1) ?? '' + return lastLine.trimStart().startsWith(VERBATIM_COPY_MARKER) +} + /** * Why: reseeding replaces a managed config Codex cannot parse; unless it is just * the verbatim copy of ~/.codex, keep it as `.bak` so its bytes are not lost silently. From e75fde9dff6a500eeed21430f45d0655bc0c3dc2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:54:59 -0700 Subject: [PATCH 05/25] fix(codex): still trust the managed Codex home when ~/.codex cannot be parsed markCodexProjectTrusted wrote ~/.codex first; a refused write there (the file does not parse) threw before the managed-home write ran, so Orca-launched Codex asked for trust again. Each home is now attempted independently and the failure(s) surface afterwards. --- src/main/agent-trust-presets.test.ts | 28 ++++++++++++++++++++++++++++ src/main/agent-trust-presets.ts | 20 ++++++++++++++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts index 5e7521ae4687..f51d3cdcd875 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -327,6 +327,34 @@ describe('markCodexProjectTrusted', () => { } }) + it('still trusts the managed home when ~/.codex cannot be parsed', async () => { + const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-')) + try { + const codexDir = join(testState.fakeHomeDir, '.codex') + mkdirSync(codexDir, { recursive: true }) + const brokenSystemConfig = 'model = "o3"\nmodel_reasoning_effort = \n' + writeFileSync(join(codexDir, 'config.toml'), brokenSystemConfig, 'utf-8') + + await expect(markCodexProjectTrusted(workspace)).rejects.toMatchObject({ + name: 'CodexConfigTomlEditRefusedError', + reason: 'input-invalid' + }) + + const runtimeConfigPath = join( + testState.userDataDir, + 'codex-runtime-home', + 'home', + 'config.toml' + ) + expect(readFileSync(join(codexDir, 'config.toml'), 'utf-8')).toBe(brokenSystemConfig) + expect(readFileSync(runtimeConfigPath, 'utf-8')).toContain( + `[projects."${escapeTomlBasicString(realpathSync.native(workspace))}"]` + ) + } finally { + rmSync(workspace, { recursive: true, force: true }) + } + }) + it('preserves existing config keys and updates an existing project block', async () => { const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-')) const realpath = realpathSync.native(workspace) diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index d84fc68a4680..13da34681048 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -173,8 +173,24 @@ export function markCodexProjectTrusted(workspacePath: string): Promise { // reverted. Same runtime-before-system lock order the installer takes. return runExclusivelyForCodexTrustConfig(runtimeTomlPath, () => runExclusivelyForCodexTrustConfig(systemTomlPath, async () => { - upsertProjectTrustLevel(systemTomlPath, absPath, 'trusted') - upsertProjectTrustLevel(runtimeTomlPath, absPath, 'trusted') + // Why: a ~/.codex Orca refuses to edit must not also leave Orca-launched Codex untrusted. + const failures: unknown[] = [] + for (const tomlPath of [systemTomlPath, runtimeTomlPath]) { + try { + upsertProjectTrustLevel(tomlPath, absPath, 'trusted') + } catch (error) { + failures.push(error) + } + } + if (failures.length === 1) { + throw failures[0] + } + if (failures.length > 1) { + throw new AggregateError( + failures, + 'Orca could not mark the project trusted in either Codex home' + ) + } }) ) } From 96de9db53de46ffc8c2cc9bca99dac243fe63652 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:54:59 -0700 Subject: [PATCH 06/25] fix(codex): report a refused Codex trust write once per file Trust-write refusals were dropped by empty catch blocks or warned on every launch. They now go through the once-per-file-and-message reporter; other failures keep their existing handling, and nothing throws into launch. --- .../codex-config-toml-checked-edit.test.ts | 29 +++++++++++++++++-- .../codex/codex-config-toml-checked-edit.ts | 15 ++++++++++ src/main/ipc/agent-trust.ts | 4 ++- src/main/ipc/worktree-remote.ts | 4 ++- .../runtime/runtime-worktree-agent-startup.ts | 7 +++-- src/main/startup/codex-launch-preparation.ts | 6 +++- .../startup/codex-session-resume-launch.ts | 9 +++++- 7 files changed, 66 insertions(+), 8 deletions(-) diff --git a/src/main/codex/codex-config-toml-checked-edit.test.ts b/src/main/codex/codex-config-toml-checked-edit.test.ts index f4b990e400ff..0f6137694230 100644 --- a/src/main/codex/codex-config-toml-checked-edit.test.ts +++ b/src/main/codex/codex-config-toml-checked-edit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { parse } from 'smol-toml' import { readHookTrustEntriesFromContent, @@ -7,7 +7,10 @@ import { upsertProjectTrustLevelInContent, type CodexTrustEntry } from './config-toml-trust' -import { CodexConfigTomlEditRefusedError } from './codex-config-toml-checked-edit' +import { + CodexConfigTomlEditRefusedError, + reportCodexTrustWriteRefusals +} from './codex-config-toml-checked-edit' import { getTomlTable, readTomlValueAtPath } from './codex-config-toml-document' import { setHookTrustEnabledContent } from './config-toml-hook-trust-edit' import { repairOrcaCodexConfigDuplicates } from './codex-config-toml-repair' @@ -310,3 +313,25 @@ describe('review follow-ups for the checked writer', () => { expect(() => parse(upsertHookTrustEntriesInContent(ownedOnly, [hookEntry()]))).not.toThrow() }) }) + +describe('refused trust writes are reported once per file', () => { + it('logs each refusal once, including inside an AggregateError, and returns other failures', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const refusal = new CodexConfigTomlEditRefusedError({ + reason: 'input-invalid', + detail: 'the file is not valid TOML', + configPath: '/tmp/report-once-probe/config.toml' + }) + const other = new Error('EACCES') + expect( + reportCodexTrustWriteRefusals(new AggregateError([refusal, other], 'trust write failed')) + ).toEqual([other]) + expect(reportCodexTrustWriteRefusals(refusal)).toEqual([]) + expect(warn).toHaveBeenCalledTimes(1) + expect(String(warn.mock.calls[0]?.[0])).toContain('/tmp/report-once-probe/config.toml') + } finally { + warn.mockRestore() + } + }) +}) diff --git a/src/main/codex/codex-config-toml-checked-edit.ts b/src/main/codex/codex-config-toml-checked-edit.ts index c56047812caa..892e83e8726a 100644 --- a/src/main/codex/codex-config-toml-checked-edit.ts +++ b/src/main/codex/codex-config-toml-checked-edit.ts @@ -186,6 +186,21 @@ export function reportCodexConfigTomlEditRefusal( reportCodexConfigOnce(error.configPath ?? context, `${context}: ${error.message}`) } +/** + * Why: a refused trust write recurs on every launch until the user fixes the + * file, so it is reported once per file; returns the other failures for the caller. + */ +export function reportCodexTrustWriteRefusals(error: unknown): unknown[] { + const failures = error instanceof AggregateError ? error.errors : [error] + return failures.filter((failure) => { + if (!(failure instanceof CodexConfigTomlEditRefusedError)) { + return true + } + reportCodexConfigTomlEditRefusal(failure, 'Skipped marking a workspace trusted for Codex') + return false + }) +} + export function clearCodexConfigTomlEditRefusalReport(configPath: string): void { reportedRefusals.delete(configPath) } diff --git a/src/main/ipc/agent-trust.ts b/src/main/ipc/agent-trust.ts index 59b7eea3cbcf..6a447dd7dead 100644 --- a/src/main/ipc/agent-trust.ts +++ b/src/main/ipc/agent-trust.ts @@ -9,6 +9,7 @@ import { } from '../agent-trust-presets' import { awaitAgentTrustWriteWithinDeadline } from '../agent-trust-write-deadline' import { markRemoteAgentWorkspaceTrusted } from '../remote-agent-trust-presets' +import { reportCodexTrustWriteRefusals } from '../codex/codex-config-toml-checked-edit' /** * Why: cursor-agent, GitHub Copilot CLI, and Codex gate first-launch in an @@ -57,9 +58,10 @@ export function registerAgentTrustHandlers(): void { } else if (args.preset === 'antigravity') { markAntigravityWorkspaceTrusted(args.workspacePath) } - } catch { + } catch (error) { // Best-effort: see Why above. The user can still accept the trust // prompt manually if writing the artifact fails. + reportCodexTrustWriteRefusals(error) } } ) diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 767c6ece2460..1d42db63f8d1 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -159,6 +159,7 @@ import { markCursorWorkspaceTrusted } from '../agent-trust-presets' import { awaitAgentTrustWriteWithinDeadline } from '../agent-trust-write-deadline' +import { reportCodexTrustWriteRefusals } from '../codex/codex-config-toml-checked-edit' import { getLocalProjectGitExecOptions, getLocalProjectWorktreeGitOptions, @@ -456,8 +457,9 @@ async function spawnLocalStartupAndSetupTerminals(args: { workspacePath: worktree.path }) } - } catch { + } catch (error) { // Best-effort: launch still proceeds and the agent can ask interactively. + reportCodexTrustWriteRefusals(error) } } const terminal = await runtime.createTerminal(`id:${worktree.id}`, { diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index faca0baf8fa4..beacbe4eede7 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -17,6 +17,7 @@ import { markCursorWorkspaceTrusted } from '../agent-trust-presets' import { awaitAgentTrustWriteWithinDeadline } from '../agent-trust-write-deadline' +import { reportCodexTrustWriteRefusals } from '../codex/codex-config-toml-checked-edit' import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents @@ -207,8 +208,9 @@ export async function markLocalWorktreeTrusted( } else if (preset === 'antigravity') { markAntigravityWorkspaceTrusted(workspacePath) } - } catch { + } catch (error) { // Best-effort: the user can still accept the agent trust prompt manually. + reportCodexTrustWriteRefusals(error) } } @@ -223,7 +225,8 @@ export async function markRemoteWorktreeTrusted( } try { await markRemoteAgentWorkspaceTrusted({ preset, connectionId, workspacePath }) - } catch { + } catch (error) { // Best-effort: the user can still accept the remote agent trust prompt manually. + reportCodexTrustWriteRefusals(error) } } diff --git a/src/main/startup/codex-launch-preparation.ts b/src/main/startup/codex-launch-preparation.ts index e01ce2c287ad..54a3020f1975 100644 --- a/src/main/startup/codex-launch-preparation.ts +++ b/src/main/startup/codex-launch-preparation.ts @@ -3,6 +3,7 @@ import type { CodexHomeLaunchContext } from '../ipc/pty' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import { markCodexProjectTrusted } from '../agent-trust-presets' import { awaitAgentTrustWriteWithinDeadline } from '../agent-trust-write-deadline' +import { reportCodexTrustWriteRefusals } from '../codex/codex-config-toml-checked-edit' import { codexHookService } from '../codex/hook-service' import { getDefaultWslDistro } from '../wsl' import { isAgentStatusHooksEnabledForAgent } from '../agent-hooks/managed-agent-hook-controls' @@ -33,7 +34,10 @@ export async function prepareCodexRuntimeHomeForLaunch( } ) } catch (error) { - console.warn('[codex-project-trust] failed to pre-mark launch workspace:', error) + const unreported = reportCodexTrustWriteRefusals(error) + if (unreported.length > 0) { + console.warn('[codex-project-trust] failed to pre-mark launch workspace:', ...unreported) + } } } const ensureRealHomeHooksIfSelected = async (): Promise => { diff --git a/src/main/startup/codex-session-resume-launch.ts b/src/main/startup/codex-session-resume-launch.ts index 4da4e7ef052f..955997bc699e 100644 --- a/src/main/startup/codex-session-resume-launch.ts +++ b/src/main/startup/codex-session-resume-launch.ts @@ -11,6 +11,7 @@ import { ensureCodexDaemonSocketGuard } from '../codex/codex-config-mirror' import { isAgentStatusHooksEnabledForAgent } from '../agent-hooks/managed-agent-hook-controls' import { markCodexProjectTrusted } from '../agent-trust-presets' import { awaitAgentTrustWriteWithinDeadline } from '../agent-trust-write-deadline' +import { reportCodexTrustWriteRefusals } from '../codex/codex-config-toml-checked-edit' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { mainProcessState as state } from './main-process-state' @@ -90,7 +91,13 @@ export async function prepareCodexSessionResumeForLaunch(args: { workspacePath: args.workspacePath }) } catch (error) { - console.warn('[codex-project-trust] failed to pre-mark resumed workspace:', error) + const unreported = reportCodexTrustWriteRefusals(error) + if (unreported.length > 0) { + console.warn( + '[codex-project-trust] failed to pre-mark resumed workspace:', + ...unreported + ) + } } } const isSystemHome = From 8378de463d7357551a0afe3062359084c8006d2e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:54:59 -0700 Subject: [PATCH 07/25] fix(codex): replace the verbatim copy once ~/.codex is removed or emptied - A marked copy of a broken ~/.codex is replaced with a fresh config when the user fixes it by deleting or emptying config.toml (managed, account and legacy shared homes). - A file is the copy only if it ends in the marker and does not parse, so a valid user config ending in that text is no longer reseeded. - A broken managed config is kept as .bak before the copy replaces it, and the copy itself is never backed up. - Drop the refusal map and TTL getter that nothing in production read. --- .../codex-accounts/codex-config-mirror.ts | 7 +- .../codex-config-legacy-shared-home-mirror.ts | 9 +- ...dex-config-mirror-duplicate-repair.test.ts | 103 +++++++++++++++++- src/main/codex/codex-config-mirror.ts | 31 +++++- .../codex/codex-managed-config-validity.ts | 58 ++++------ 5 files changed, 161 insertions(+), 47 deletions(-) diff --git a/src/main/codex-accounts/codex-config-mirror.ts b/src/main/codex-accounts/codex-config-mirror.ts index a45f85360687..21f73d5fe538 100644 --- a/src/main/codex-accounts/codex-config-mirror.ts +++ b/src/main/codex-accounts/codex-config-mirror.ts @@ -3,7 +3,7 @@ import { homedir } from 'node:os' import { join } from 'node:path' import { parseWslUncPath } from '../../shared/wsl-paths' import { - ensureCodexDaemonSocketGuard, + syncManagedCodexHomeWithoutSourceConfig, syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' import { readCodexTopLevelModelProvider } from '../codex/codex-model-provider-config' @@ -112,7 +112,10 @@ export class CodexConfigMirror { // Why: with no ~/.codex/config.toml there is nothing to mirror, but Codex still cannot start in a long home without the daemon guard. // WSL homes are skipped: their ownership check is a blocking wsl.exe call (startup, account switch), and WSL launch prep guards the home it launches. if (!parseWslUncPath(managedHomePath)) { - ensureCodexDaemonSocketGuard(this.assertManagedHomePath(managedHomePath, expectedAccountId)) + syncManagedCodexHomeWithoutSourceConfig({ + runtimeHomePath: this.assertManagedHomePath(managedHomePath, expectedAccountId), + systemHomePath: join(homedir(), '.codex') + }) } return } diff --git a/src/main/codex/codex-config-legacy-shared-home-mirror.ts b/src/main/codex/codex-config-legacy-shared-home-mirror.ts index b8d4b975db57..fae86f43d732 100644 --- a/src/main/codex/codex-config-legacy-shared-home-mirror.ts +++ b/src/main/codex/codex-config-legacy-shared-home-mirror.ts @@ -11,6 +11,7 @@ import { applyUnparseableCodexSourceRule, backUpDiscardedManagedConfig, findUnparseableManagedCodexConfig, + isVerbatimCodexSourceCopy, refuseUnparseableManagedConfig } from './codex-managed-config-validity' import { mergeSystemCodexConfigIntoRuntime } from './codex-config-mirror-merge' @@ -62,8 +63,12 @@ export function syncSystemConfigIntoLegacySharedCodexHome( const runtimeParses = runtimeConfigBeforeMirror !== null && findUnparseableManagedCodexConfig(runtimeConfigBeforeMirror) === null - // Why: a missing cloud-synced source is not proof the user cleared config. - let mirroredRuntimeConfig = runtimeConfigBeforeMirror ?? '' + // Why: a missing cloud-synced source is not proof the user cleared config, + // but Orca's copy of a since-removed broken source holds nothing to keep. + let mirroredRuntimeConfig = + runtimeConfigBeforeMirror !== null && !isVerbatimCodexSourceCopy(runtimeConfigBeforeMirror) + ? runtimeConfigBeforeMirror + : '' if (rawSystemConfig.trim() !== '') { const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(homes.systemHomePath) // The retired home has no ownership baseline; its entire MCP root stays canonical. diff --git a/src/main/codex/codex-config-mirror-duplicate-repair.test.ts b/src/main/codex/codex-config-mirror-duplicate-repair.test.ts index f95a8d57b0b0..a048516555ef 100644 --- a/src/main/codex/codex-config-mirror-duplicate-repair.test.ts +++ b/src/main/codex/codex-config-mirror-duplicate-repair.test.ts @@ -30,8 +30,11 @@ vi.mock('node:os', async () => { } }) -import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' -import { getRecentCodexMirrorRefusal } from './codex-managed-config-validity' +import { + syncManagedCodexHomeWithoutSourceConfig, + syncSystemConfigIntoLegacySharedCodexHome, + syncSystemConfigIntoManagedCodexHome +} from './codex-config-mirror' let fakeHomeDir: string let userDataDir: string @@ -209,7 +212,7 @@ describe('managed-home mirror never writes a config Codex cannot read (#22592)', expect(existsSync(`${getRuntimeConfigPath()}.bak`)).toBe(false) }) - it('leaves a managed config that parses untouched while ~/.codex is broken, and records why', () => { + it('leaves a managed config that parses untouched while ~/.codex is broken, and logs why once', () => { writeFileSync(getSystemConfigPath(), 'model = "A"\nbroken = \n', 'utf-8') mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) writeFileSync(getRuntimeConfigPath(), 'model = "A"\n', 'utf-8') @@ -219,7 +222,6 @@ describe('managed-home mirror never writes a config Codex cannot read (#22592)', syncSystemConfigIntoManagedCodexHome() expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "A"\n') - expect(getRecentCodexMirrorRefusal(getRuntimeConfigPath())).toContain('left') expect(warn.mock.calls.flat().filter((line) => String(line).includes('left '))).toHaveLength(1) warn.mockRestore() }) @@ -357,4 +359,97 @@ describe('break-then-fix of ~/.codex keeps managed-home state', () => { rmSync(root, { recursive: true, force: true }) } }) + + describe('the verbatim copy is replaced once the user deletes or empties ~/.codex/config.toml', () => { + const broken = 'model = "A"\nbroken = \n' + const good = 'model = "C"\n' + + it.each([ + [ + 'deleted, managed mirror', + (sysPath: string) => rmSync(sysPath), + syncSystemConfigIntoManagedCodexHome + ], + [ + 'emptied, managed mirror', + (sysPath: string) => writeFileSync(sysPath, ''), + syncSystemConfigIntoManagedCodexHome + ], + [ + 'deleted, account home without a source', + (sysPath: string) => rmSync(sysPath), + syncManagedCodexHomeWithoutSourceConfig + ], + [ + 'deleted, legacy shared home', + (sysPath: string) => rmSync(sysPath), + syncSystemConfigIntoLegacySharedCodexHome + ] + ])('%s', (_label, clearSource, syncWithoutSource) => { + const { homes, root } = makeHomes() + const sysPath = join(homes.systemHomePath, 'config.toml') + const rtPath = join(homes.runtimeHomePath, 'config.toml') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + writeFileSync(sysPath, broken) + syncSystemConfigIntoManagedCodexHome(homes) + expect(readFileSync(rtPath, 'utf8')).toBe(`${broken}${verbatimMarker(sysPath)}`) + + clearSource(sysPath) + syncWithoutSource(homes) + expect(readFileSync(rtPath, 'utf8')).toBe('') + + writeFileSync(sysPath, good) + syncSystemConfigIntoManagedCodexHome(homes) + expect(readFileSync(sysPath, 'utf8')).toBe(good) + expect(readFileSync(rtPath, 'utf8')).toBe(good) + } finally { + warn.mockRestore() + rmSync(root, { recursive: true, force: true }) + } + }) + }) + + it('treats a valid ~/.codex that ends in the marker text as the user config, not a copy', () => { + const { homes, root } = makeHomes() + const sysPath = join(homes.systemHomePath, 'config.toml') + const rtPath = join(homes.runtimeHomePath, 'config.toml') + const markerLine = '# orca: verbatim copy of /x/config.toml; replaced when the source parses\n' + try { + writeFileSync(sysPath, `model = "A"\n${markerLine}`) + syncSystemConfigIntoManagedCodexHome(homes) + // Codex inside Orca: /model B. + writeFileSync(rtPath, readFileSync(rtPath, 'utf8').replace('model = "A"', 'model = "B"')) + syncSystemConfigIntoManagedCodexHome(homes) + + expect(parse(readFileSync(sysPath, 'utf8')).model).toBe('B') + expect(parse(readFileSync(rtPath, 'utf8')).model).toBe('B') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('keeps a broken managed config as .bak before the verbatim copy replaces it, and never backs up the copy', () => { + const { homes, root } = makeHomes() + const sysPath = join(homes.systemHomePath, 'config.toml') + const rtPath = join(homes.runtimeHomePath, 'config.toml') + const good = 'model = "A"\n' + const brokenManaged = 'model = "A"\n\n[mcp_servers.mine]\ncommand = "m"\n\nx = = 1\n' + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + writeFileSync(sysPath, `${good}broken = \n`) + writeFileSync(rtPath, brokenManaged) + syncSystemConfigIntoManagedCodexHome(homes) + expect(readFileSync(rtPath, 'utf8')).toContain('orca: verbatim copy') + expect(readFileSync(`${rtPath}.bak`, 'utf8')).toBe(brokenManaged) + + writeFileSync(sysPath, good) + syncSystemConfigIntoManagedCodexHome(homes) + expect(readFileSync(rtPath, 'utf8')).toBe(good) + expect(readFileSync(`${rtPath}.bak`, 'utf8')).toBe(brokenManaged) + } finally { + warn.mockRestore() + rmSync(root, { recursive: true, force: true }) + } + }) }) diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index fe2c868f6fc1..8d42f69663de 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -23,6 +23,7 @@ import { applyUnparseableCodexSourceRule, backUpDiscardedManagedConfig, findUnparseableManagedCodexConfig, + isVerbatimCodexSourceCopy, refuseUnparseableManagedConfig } from './codex-managed-config-validity' import { mergeSystemCodexConfigIntoRuntime } from './codex-config-mirror-merge' @@ -155,8 +156,26 @@ export function ensureCodexDaemonSocketGuard(runtimeHomePath: string): void { } } -function writeCodexDaemonSocketGuard(runtimeHomePath: string, runtimeConfig: string | null): void { - const guarded = applyCodexDaemonSocketGuard(runtimeConfig ?? '', runtimeHomePath) +/** + * For a managed home when no source config was read. Orca's verbatim copy goes + * through the full mirror, which re-reads the source and replaces the copy once + * it is gone or blank; any other config gets only the daemon guard. + */ +export function syncManagedCodexHomeWithoutSourceConfig(homes: CodexSettingsPromotionHomes): void { + const observation = observeAgentStateFile(join(homes.runtimeHomePath, 'config.toml')) + if (observation.kind === 'present' && isVerbatimCodexSourceCopy(observation.value)) { + syncSystemConfigIntoManagedCodexHome(homes) + return + } + ensureCodexDaemonSocketGuard(homes.runtimeHomePath) +} + +function writeCodexDaemonSocketGuard( + runtimeHomePath: string, + runtimeConfig: string | null, + baseConfig = runtimeConfig ?? '' +): void { + const guarded = applyCodexDaemonSocketGuard(baseConfig, runtimeHomePath) if (guarded !== (runtimeConfig ?? '')) { const runtimeConfigPath = join(runtimeHomePath, 'config.toml') if (refuseUnparseableManagedConfig(runtimeConfigPath, guarded)) { @@ -204,12 +223,16 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe( // it would erase every ordinary setting from an existing managed runtime, and // a 0-byte file is what a half-written or unhydrated cloud-synced home shows. if (rawSystemConfig.trim() === '') { + const runtimeConfig = runtimeConfigExists ? runtimeConfigObservation.value : null + // Why: once the user deletes or empties a broken ~/.codex, Orca's copy of it holds nothing to keep. + const replacesVerbatimCopy = runtimeConfig !== null && isVerbatimCodexSourceCopy(runtimeConfig) // Why: no mirror write happens here, but the daemon guard must still land. writeCodexDaemonSocketGuard( runtimeHomePath, - runtimeConfigExists ? runtimeConfigObservation.value : null + runtimeConfig, + replacesVerbatimCopy ? '' : (runtimeConfig ?? '') ) - return runtimeConfigExists + return runtimeConfig !== null && !replacesVerbatimCopy ? { status: 'skipped-missing-source' } : { status: 'mirrored', diff --git a/src/main/codex/codex-managed-config-validity.ts b/src/main/codex/codex-managed-config-validity.ts index 3656156ac608..da43e6d576e9 100644 --- a/src/main/codex/codex-managed-config-validity.ts +++ b/src/main/codex/codex-managed-config-validity.ts @@ -111,54 +111,35 @@ export function findUnparseableManagedCodexConfig(config: string): string | null return parseCodexConfigToml(deduplicated).ok ? null : parseError } -const MIRROR_REFUSAL_TTL_MS = 10 * 60_000 - -type CodexMirrorRefusal = { sourcePath: string; detail: string; at: number } - -const mirrorRefusals = new Map() - /** * Why: ~/.codex saved mid-edit must not overwrite a managed home that still * holds state only it has (an unpromoted /model, an MCP server added in - * Orca-launched Codex, trust answers). Kept per managed file, like launch trust - * failures, so a later surface can show why syncing paused. + * Orca-launched Codex, trust answers). */ -export function refuseMirrorFromUnparseableSource( +function refuseMirrorFromUnparseableSource( sourcePath: string, runtimeConfigPath: string, - parseError: string, - now = Date.now() + parseError: string ): void { - const detail = `${sourcePath} is not valid TOML (${parseError}); left ${runtimeConfigPath} as it was. Fix ${sourcePath} to resume syncing.` - mirrorRefusals.set(runtimeConfigPath, { sourcePath, detail, at: now }) - reportCodexConfigOnce(runtimeConfigPath, detail) + reportCodexConfigOnce( + runtimeConfigPath, + `${sourcePath} is not valid TOML (${parseError}); left ${runtimeConfigPath} as it was. Fix ${sourcePath} to resume syncing.` + ) } /** * Why: with nothing in the managed home to lose, an unchanged copy makes * Orca-launched Codex show Codex's own error, exactly like `codex` typed by hand. */ -export function reportVerbatimUnparseableCodexSource( +function reportVerbatimUnparseableCodexSource( sourcePath: string, runtimeConfigPath: string, - parseError: string, - now = Date.now() + parseError: string ): void { - const detail = `${sourcePath} is not valid TOML (${parseError}); copied it unchanged into ${runtimeConfigPath} so Orca-launched Codex reports the same error. Fix ${sourcePath} to resume syncing.` - mirrorRefusals.set(runtimeConfigPath, { sourcePath, detail, at: now }) - reportCodexConfigOnce(runtimeConfigPath, detail) -} - -export function clearCodexMirrorRefusal(runtimeConfigPath: string): void { - mirrorRefusals.delete(runtimeConfigPath) -} - -export function getRecentCodexMirrorRefusal( - runtimeConfigPath: string, - now = Date.now() -): string | null { - const refusal = mirrorRefusals.get(runtimeConfigPath) - return refusal && now - refusal.at <= MIRROR_REFUSAL_TTL_MS ? refusal.detail : null + reportCodexConfigOnce( + runtimeConfigPath, + `${sourcePath} is not valid TOML (${parseError}); copied it unchanged into ${runtimeConfigPath} so Orca-launched Codex reports the same error. Fix ${sourcePath} to resume syncing.` + ) } /** @@ -175,7 +156,6 @@ export function applyUnparseableCodexSourceRule(args: { }): 'left-untouched' | 'copied' | null { const parseError = args.source.trim() === '' ? null : findUnparseableCodexConfig(args.source) if (parseError === null) { - clearCodexMirrorRefusal(args.runtimeConfigPath) return null } if (args.runtime !== null && findUnparseableManagedCodexConfig(args.runtime) === null) { @@ -185,6 +165,12 @@ export function applyUnparseableCodexSourceRule(args: { reportVerbatimUnparseableCodexSource(args.sourcePath, args.runtimeConfigPath, parseError) const copy = markVerbatimCodexSourceCopy(args.source, args.sourcePath) if (args.runtime !== copy) { + backUpDiscardedManagedConfig({ + sourcePath: args.sourcePath, + runtimeConfigPath: args.runtimeConfigPath, + source: args.source, + discarded: args.runtime + }) args.writeVerbatimCopy(copy) } return 'copied' @@ -199,9 +185,10 @@ function markVerbatimCodexSourceCopy(source: string, sourcePath: string): string return `${body}${VERBATIM_COPY_MARKER}${sourcePath}; replaced when the source parses${eol}` } -function isVerbatimCodexSourceCopy(config: string): boolean { +/** Only an unparseable file is a copy: a valid config that merely ends in the marker text is the user's own. */ +export function isVerbatimCodexSourceCopy(config: string): boolean { const lastLine = config.trimEnd().split('\n').at(-1) ?? '' - return lastLine.trimStart().startsWith(VERBATIM_COPY_MARKER) + return lastLine.trimStart().startsWith(VERBATIM_COPY_MARKER) && !parseCodexConfigToml(config).ok } /** @@ -217,6 +204,7 @@ export function backUpDiscardedManagedConfig(args: { if ( args.discarded === null || args.discarded === args.source || + isVerbatimCodexSourceCopy(args.discarded) || findUnparseableManagedCodexConfig(args.discarded) === null ) { return From ad90e7e9846a40c470b64f672f2686aa7c20de25 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:54:59 -0700 Subject: [PATCH 08/25] chore(codex): remove unused unescapeTomlBasicString --- src/main/codex/config-toml-syntax.ts | 29 ---------------------------- 1 file changed, 29 deletions(-) diff --git a/src/main/codex/config-toml-syntax.ts b/src/main/codex/config-toml-syntax.ts index ffc56bf537ed..722d0dedbdac 100644 --- a/src/main/codex/config-toml-syntax.ts +++ b/src/main/codex/config-toml-syntax.ts @@ -93,32 +93,3 @@ function isCompleteTomlTableHeader(line: string): boolean { } return false } - -function unescapeTomlBasicStringEscape(next: string): string { - const escaped: Record = { - n: '\n', - r: '\r', - t: '\t', - b: '\b', - f: '\f', - '"': '"', - '\\': '\\' - } - return escaped[next] ?? `\\${next}` -} - -export function unescapeTomlBasicString(escaped: string): string { - let result = '' - let index = 0 - while (index < escaped.length) { - const char = escaped[index] - if (char === '\\' && index + 1 < escaped.length) { - result += unescapeTomlBasicStringEscape(escaped[index + 1]!) - index += 2 - continue - } - result += char - index += 1 - } - return result -} From 9d7d5f2566a0a6ee795dbfe8ce524bfb3aef2bcb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:08:31 -0700 Subject: [PATCH 09/25] fix(codex): log each Codex config refusal once per kind Trust-write, promotion, and mirror reports about one config file shared a single last-message slot, so they displaced each other and every one was logged again on every launch. Remember the last message per file and kind; a mirror pass that succeeds forgets only mirror reports, and a trust write that succeeds forgets the trust refusal so a later one is reported again. --- .../codex-config-toml-checked-edit.test.ts | 55 +++++++++++++++++++ .../codex/codex-config-toml-checked-edit.ts | 34 +++++++++--- src/main/codex/config-toml-trust.ts | 18 ++++-- 3 files changed, 93 insertions(+), 14 deletions(-) diff --git a/src/main/codex/codex-config-toml-checked-edit.test.ts b/src/main/codex/codex-config-toml-checked-edit.test.ts index 0f6137694230..a5c43fcb9767 100644 --- a/src/main/codex/codex-config-toml-checked-edit.test.ts +++ b/src/main/codex/codex-config-toml-checked-edit.test.ts @@ -1,14 +1,20 @@ import { describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { parse } from 'smol-toml' import { readHookTrustEntriesFromContent, removeHookTrustEntriesFromContent, upsertHookTrustEntriesInContent, + upsertProjectTrustLevel, upsertProjectTrustLevelInContent, type CodexTrustEntry } from './config-toml-trust' import { + clearCodexConfigTomlEditRefusalReport, CodexConfigTomlEditRefusedError, + reportCodexConfigTomlEditRefusal, reportCodexTrustWriteRefusals } from './codex-config-toml-checked-edit' import { getTomlTable, readTomlValueAtPath } from './codex-config-toml-document' @@ -335,3 +341,52 @@ describe('refused trust writes are reported once per file', () => { } }) }) + +describe('Codex config reports about one file log once per kind', () => { + it('does not re-log interleaved trust, promotion, and mirror reports on every launch', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const dir = mkdtempSync(join(tmpdir(), 'codex-report-once-')) + try { + const systemPath = join(dir, 'system.toml') + const runtimePath = join(dir, 'runtime.toml') + const refusal = (configPath: string) => + new CodexConfigTomlEditRefusedError({ + reason: 'input-invalid', + detail: 'the file is not valid TOML', + configPath + }) + for (let launch = 0; launch < 4; launch++) { + reportCodexTrustWriteRefusals( + new AggregateError([refusal(systemPath), refusal(runtimePath)], 'trust write failed') + ) + reportCodexConfigTomlEditRefusal(refusal(systemPath), 'Skipped promoting Codex settings') + reportCodexConfigTomlEditRefusal( + refusal(runtimePath), + 'Skipped writing a managed Codex config' + ) + } + const trustReports = () => + warn.mock.calls.filter(([message]) => String(message).includes('trusted for Codex')) + expect(trustReports()).toHaveLength(2) + expect(warn).toHaveBeenCalledTimes(4) + + // A mirror pass that succeeds forgets mirror reports, not a trust refusal that still recurs. + clearCodexConfigTomlEditRefusalReport(runtimePath) + reportCodexTrustWriteRefusals(refusal(runtimePath)) + reportCodexConfigTomlEditRefusal( + refusal(runtimePath), + 'Skipped writing a managed Codex config' + ) + expect(trustReports()).toHaveLength(2) + expect(warn).toHaveBeenCalledTimes(5) + + writeFileSync(systemPath, 'model = "a"\n') + upsertProjectTrustLevel(systemPath, '/work/repo', 'trusted') + reportCodexTrustWriteRefusals(refusal(systemPath)) + expect(trustReports()).toHaveLength(3) + } finally { + rmSync(dir, { recursive: true, force: true }) + warn.mockRestore() + } + }) +}) diff --git a/src/main/codex/codex-config-toml-checked-edit.ts b/src/main/codex/codex-config-toml-checked-edit.ts index 892e83e8726a..5b580fb35c11 100644 --- a/src/main/codex/codex-config-toml-checked-edit.ts +++ b/src/main/codex/codex-config-toml-checked-edit.ts @@ -168,14 +168,21 @@ export function assertCodexConfigTomlParses(content: string): void { } } -const reportedRefusals = new Map() +// Keyed by file, then by what Orca was doing, so interleaved reports about one file each log once. +const reportedMessages = new Map>() +const TRUST_WRITE_CONTEXT = 'Skipped marking a workspace trusted for Codex' -/** Logs once per file and message, so a launch-time retry does not flood the log. */ -export function reportCodexConfigOnce(key: string, message: string): void { - if (reportedRefusals.get(key) === message) { +/** Logs once per file, kind, and message, so a launch-time retry does not flood the log. */ +export function reportCodexConfigOnce(key: string, message: string, kind = ''): void { + let byKind = reportedMessages.get(key) + if (!byKind) { + byKind = new Map() + reportedMessages.set(key, byKind) + } + if (byKind.get(kind) === message) { return } - reportedRefusals.set(key, message) + byKind.set(kind, message) console.warn(`[codex-config] ${message}`) } @@ -183,7 +190,7 @@ export function reportCodexConfigTomlEditRefusal( error: CodexConfigTomlEditRefusedError, context: string ): void { - reportCodexConfigOnce(error.configPath ?? context, `${context}: ${error.message}`) + reportCodexConfigOnce(error.configPath ?? context, `${context}: ${error.message}`, context) } /** @@ -196,11 +203,22 @@ export function reportCodexTrustWriteRefusals(error: unknown): unknown[] { if (!(failure instanceof CodexConfigTomlEditRefusedError)) { return true } - reportCodexConfigTomlEditRefusal(failure, 'Skipped marking a workspace trusted for Codex') + reportCodexConfigTomlEditRefusal(failure, TRUST_WRITE_CONTEXT) return false }) } +/** Forgets mirror/write reports for a file; a trust refusal is only forgotten by a trust write that succeeds. */ export function clearCodexConfigTomlEditRefusalReport(configPath: string): void { - reportedRefusals.delete(configPath) + const byKind = reportedMessages.get(configPath) + const trust = byKind?.get(TRUST_WRITE_CONTEXT) + if (trust === undefined) { + reportedMessages.delete(configPath) + } else { + reportedMessages.set(configPath, new Map([[TRUST_WRITE_CONTEXT, trust]])) + } +} + +export function clearCodexTrustWriteRefusalReport(configPath: string): void { + reportedMessages.get(configPath)?.delete(TRUST_WRITE_CONTEXT) } diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index e8632b623ae4..027f961d0937 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -23,6 +23,7 @@ import { observe } from './codex-path-observation' import { parseCodexConfigToml } from './codex-config-toml-document' import { assertCodexConfigTomlParses, + clearCodexTrustWriteRefusalReport, CodexConfigTomlEditRefusedError } from './codex-config-toml-checked-edit' @@ -113,7 +114,7 @@ export function upsertHookTrustEntries( entries: readonly CodexTrustEntry[] ): void { const existing = readTomlForMutation(configPath) - const updated = withConfigPath(configPath, () => + const updated = withTrustEdit(configPath, () => upsertHookTrustEntriesInContent(existing, entries) ) if (updated !== existing) { @@ -137,7 +138,7 @@ export function setHookTrustEnabledStates( return } const existing = readTomlFile(configPath) - const updated = withConfigPath(configPath, () => setHookTrustEnabledContent(existing, states)) + const updated = withTrustEdit(configPath, () => setHookTrustEnabledContent(existing, states)) if (updated !== existing) { writeTomlConfigAtomically(configPath, updated) } @@ -149,7 +150,7 @@ export function upsertProjectTrustLevel( trustLevel: CodexProjectTrustLevel ): void { const existing = readTomlForMutation(configPath) - const updated = withConfigPath(configPath, () => + const updated = withTrustEdit(configPath, () => upsertProjectTrustLevelInContent(existing, projectPath, trustLevel) ) if (updated !== existing) { @@ -200,14 +201,19 @@ function withConfigPath(configPath: string, run: () => T): T { } } +// Why: a trust edit that succeeds ends the refusal, so a later one is reported again. +function withTrustEdit(configPath: string, run: () => T): T { + const result = withConfigPath(configPath, run) + clearCodexTrustWriteRefusalReport(configPath) + return result +} + export function removeHookTrustEntries(configPath: string, keys: readonly string[]): void { if (!existsSync(configPath)) { return } const existing = readTomlFile(configPath) - const updated = withConfigPath(configPath, () => - removeHookTrustEntriesFromContent(existing, keys) - ) + const updated = withTrustEdit(configPath, () => removeHookTrustEntriesFromContent(existing, keys)) if (updated !== existing) { writeTomlConfigAtomically(configPath, updated) } From f0bcb38b54f91ceb9e02300b65c6071c356ac641 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:08:31 -0700 Subject: [PATCH 10/25] perf(codex): skip the full key-path parse for headers that cannot match The mirror parses every table header through the full key-path parser to decide whether it is a projects or hooks.state table. Reject a header that contains neither the table names nor a backslash (an escape is the only way to spell a key without its letters), and read the canonical [projects."path"] form with one anchored regex. Mirror pass on a 5,000-project config: ~165 ms -> ~116 ms. --- .../config-toml-runtime-owned-sections.ts | 5 ++- src/main/codex/config-toml-syntax.test.ts | 43 +++++++++++++++++++ src/main/codex/config-toml-syntax.ts | 26 ++++++++++- 3 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 src/main/codex/config-toml-syntax.test.ts diff --git a/src/main/codex/config-toml-runtime-owned-sections.ts b/src/main/codex/config-toml-runtime-owned-sections.ts index b3df908fd429..97803da3390b 100644 --- a/src/main/codex/config-toml-runtime-owned-sections.ts +++ b/src/main/codex/config-toml-runtime-owned-sections.ts @@ -6,7 +6,7 @@ import { updateTomlLineScanState } from './config-toml-line-scan' import { parseTomlTableHeaderPath } from './config-toml-key-path' -import { parseStandardTableHeaderSegments } from './config-toml-syntax' +import { mayNameTomlKeys, parseStandardTableHeaderSegments } from './config-toml-syntax' import { readTomlAssignmentValue, scanTomlStructure, @@ -88,6 +88,9 @@ export function isRuntimePreservedTomlSection(header: string): boolean { } export function isRuntimeHookTrustTomlSection(header: string): boolean { + if (!mayNameTomlKeys(header, ['hooks', 'state'])) { + return false + } const segments = parseStandardTableHeaderSegments(header) // Why: Codex's config writer materializes the parent table on Windows. It is // part of runtime-owned trust and must survive the next config mirror too. diff --git a/src/main/codex/config-toml-syntax.test.ts b/src/main/codex/config-toml-syntax.test.ts new file mode 100644 index 000000000000..08528d371a54 --- /dev/null +++ b/src/main/codex/config-toml-syntax.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { + parseHookStateTomlHeaderKey, + parseProjectTomlHeaderPath, + parseStandardTableHeaderSegments +} from './config-toml-syntax' +import { isRuntimeHookTrustTomlSection } from './config-toml-runtime-owned-sections' + +function fullProjectParse(line: string): string | null { + const segments = parseStandardTableHeaderSegments(line) + return segments?.length === 2 && segments[0] === 'projects' ? (segments[1] ?? null) : null +} + +describe('header fast paths agree with the full key-path parse', () => { + it.each([ + ['[projects."/work/repo"]', '/work/repo'], + [' [ projects . "/work/repo" ] # note "quoted" ]', '/work/repo'], + ['[projects."/work/a#b]c"]\r', '/work/a#b]c'], + ['[projects."C:\\\\Users\\\\me"]', 'C:\\Users\\me'], + ['["\\u0070rojects"."/work/repo"]', '/work/repo'], + ["['projects'.'/work/repo']", '/work/repo'], + ['["projects"."/work/\\u00e9"]', '/work/é'], + ['[projects."/work/tab\there"]', '/work/tab\there'], + ['[projects."/work/😀"]', '/work/😀'], + ['[[projects."/work/repo"]]', null], + ['[projects."/work/repo".child]', null], + ['[projects."/work/repo"] trailing', null], + ['[model_providers."x"]', null], + ['model = "projects"', null] + ])('parses %j as %j', (line, expected) => { + expect(parseProjectTomlHeaderPath(line)).toBe(expected) + expect(fullProjectParse(line)).toBe(expected) + }) + + it('still recognizes hooks.state tables spelled with escapes', () => { + const escaped = '["hoo\\u006bs"."\\u0073tate"."/h.json:stop:0:0"]' + expect(parseHookStateTomlHeaderKey(escaped)).toBe('/h.json:stop:0:0') + expect(isRuntimeHookTrustTomlSection(escaped)).toBe(true) + expect(isRuntimeHookTrustTomlSection('[hooks.state]')).toBe(true) + expect(isRuntimeHookTrustTomlSection('[projects."/hooks/state"]')).toBe(false) + expect(isRuntimeHookTrustTomlSection('[mcp_servers.x]')).toBe(false) + }) +}) diff --git a/src/main/codex/config-toml-syntax.ts b/src/main/codex/config-toml-syntax.ts index 722d0dedbdac..cbda03ae6724 100644 --- a/src/main/codex/config-toml-syntax.ts +++ b/src/main/codex/config-toml-syntax.ts @@ -20,17 +20,41 @@ export function escapeTomlBasicString(value: string): string { // Why (#22592): identity is the decoded key path, so `["hooks"."state"."k"]` // and `[hooks.state.'k']` are the same table Codex sees. export function parseHookStateTomlHeaderKey(line: string): string | null { + if (!mayNameTomlKeys(line, ['hooks', 'state'])) { + return null + } const segments = parseStandardTableHeaderSegments(line) return segments?.length === 3 && segments[0] === 'hooks' && segments[1] === 'state' ? (segments[2] ?? null) : null } +// Only the canonical `[projects."path"]` whose path is printable with no `"` or `\`; else the full parse. +const PLAIN_PROJECT_HEADER = + /^[ \t]*\[[ \t]*projects[ \t]*\.[ \t]*"([ !#-[\]-~\u0080-\uffff]*)"[ \t]*\][ \t]*(?:#.*)?$/ + export function parseProjectTomlHeaderPath(line: string): string | null { - const segments = parseStandardTableHeaderSegments(line) + if (!mayNameTomlKeys(line, ['projects'])) { + return null + } + const withoutCr = line.replace(/\r$/, '') + const plain = PLAIN_PROJECT_HEADER.exec(withoutCr) + if (plain) { + return plain[1] ?? null + } + const segments = parseStandardTableHeaderSegments(withoutCr) return segments?.length === 2 && segments[0] === 'projects' ? (segments[1] ?? null) : null } +/** + * Why: header parsing runs per table on every mirror pass. A key spells its + * name literally unless a basic-string escape is used, so a line without the + * names and without a backslash cannot name that table. + */ +export function mayNameTomlKeys(line: string, keys: readonly string[]): boolean { + return line.includes('\\') || keys.every((key) => line.includes(key)) +} + export function parseStandardTableHeaderSegments(line: string): readonly string[] | null { const header = getTomlTableHeader(line.replace(/\r$/, '')) const parsed = header === null ? null : parseTomlTableHeaderPath(header) From 2570e70fea4e168a7b01311d918a52c6f1c0ec0e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:48:43 -0700 Subject: [PATCH 11/25] refactor(codex): leave a hand-broken ~/.codex to base mirror behavior; broken-config handling moves to a follow-up This PR fixes #22592 (Orca's own duplicate tables). It had also grown a separate feature for a ~/.codex the user broke by hand: a marked verbatim copy in the managed home, keeping a working managed copy untouched, a .bak on reseed, a no-source path in the accounts mirror, and skipping settings promotion from the copy. That feature moves to a later PR. For a ~/.codex that does not parse even after Orca's duplicate repair, the managed-home, legacy shared-home and accounts mirrors now do exactly what they did before this PR. Kept: the checked writer, the repair applied to ~/.codex and to the managed config before mirroring, and the rule that a mirror never writes a result Codex cannot parse when every input parses. Settings promotion still never edits a ~/.codex Codex cannot parse. When it has nothing to write back, the mirror proceeds as before; when an in-Codex change is pending, the pass stalls (as for any failed write-back) so the mirror cannot erase that change, and the refusal is logged once. codex-managed-config-validity.ts is gone; the two helpers still used moved beside their callers. A mirror pass now parses whole files 4 times instead of 10. --- config/tsconfig.cli.json | 1 - .../codex-accounts/codex-config-mirror.ts | 7 +- .../codex-config-legacy-shared-home-mirror.ts | 49 +-- ...dex-config-mirror-duplicate-repair.test.ts | 320 ++++-------------- src/main/codex/codex-config-mirror-merge.ts | 26 +- src/main/codex/codex-config-mirror-source.ts | 2 +- src/main/codex/codex-config-mirror.test.ts | 9 +- src/main/codex/codex-config-mirror.ts | 122 +++---- .../codex-config-toml-checked-edit.test.ts | 28 ++ .../codex/codex-config-toml-checked-edit.ts | 40 ++- src/main/codex/codex-config-toml-repair.ts | 6 + .../codex/codex-managed-config-validity.ts | 217 ------------ src/main/codex/config-settings-promotion.ts | 60 ++-- 13 files changed, 244 insertions(+), 643 deletions(-) delete mode 100644 src/main/codex/codex-managed-config-validity.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index a7f697f304e9..bae8f6516d42 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -123,7 +123,6 @@ "../src/main/codex/codex-config-toml-document.ts", "../src/main/codex/codex-config-toml-repair.ts", "../src/main/codex/codex-config-toml-structure.ts", - "../src/main/codex/codex-managed-config-validity.ts", "../src/main/codex/codex-config-path-reference-rewrite.ts", "../src/main/codex/codex-config-settings-preservation.ts", "../src/main/codex/codex-config-settings-removal.ts", diff --git a/src/main/codex-accounts/codex-config-mirror.ts b/src/main/codex-accounts/codex-config-mirror.ts index 21f73d5fe538..a45f85360687 100644 --- a/src/main/codex-accounts/codex-config-mirror.ts +++ b/src/main/codex-accounts/codex-config-mirror.ts @@ -3,7 +3,7 @@ import { homedir } from 'node:os' import { join } from 'node:path' import { parseWslUncPath } from '../../shared/wsl-paths' import { - syncManagedCodexHomeWithoutSourceConfig, + ensureCodexDaemonSocketGuard, syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' import { readCodexTopLevelModelProvider } from '../codex/codex-model-provider-config' @@ -112,10 +112,7 @@ export class CodexConfigMirror { // Why: with no ~/.codex/config.toml there is nothing to mirror, but Codex still cannot start in a long home without the daemon guard. // WSL homes are skipped: their ownership check is a blocking wsl.exe call (startup, account switch), and WSL launch prep guards the home it launches. if (!parseWslUncPath(managedHomePath)) { - syncManagedCodexHomeWithoutSourceConfig({ - runtimeHomePath: this.assertManagedHomePath(managedHomePath, expectedAccountId), - systemHomePath: join(homedir(), '.codex') - }) + ensureCodexDaemonSocketGuard(this.assertManagedHomePath(managedHomePath, expectedAccountId)) } return } diff --git a/src/main/codex/codex-config-legacy-shared-home-mirror.ts b/src/main/codex/codex-config-legacy-shared-home-mirror.ts index fae86f43d732..3a0231564e6c 100644 --- a/src/main/codex/codex-config-legacy-shared-home-mirror.ts +++ b/src/main/codex/codex-config-legacy-shared-home-mirror.ts @@ -7,13 +7,7 @@ import { observeAgentStateFile } from './codex-path-observation' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' import type { CodexSettingsPromotionHomes } from './config-settings-promotion' import { applyCodexDaemonSocketGuard } from './codex-daemon-socket-path-guard' -import { - applyUnparseableCodexSourceRule, - backUpDiscardedManagedConfig, - findUnparseableManagedCodexConfig, - isVerbatimCodexSourceCopy, - refuseUnparseableManagedConfig -} from './codex-managed-config-validity' +import { refuseUnreadableCodexConfigResult } from './codex-config-toml-checked-edit' import { mergeSystemCodexConfigIntoRuntime } from './codex-config-mirror-merge' import { prepareSystemConfigForFreshRuntimeMirror, @@ -48,33 +42,13 @@ export function syncSystemConfigIntoLegacySharedCodexHome( } const runtimeConfigBeforeMirror = runtimeConfigObservation.kind === 'present' ? runtimeConfigObservation.value : null - if ( - applyUnparseableCodexSourceRule({ - sourcePath: systemConfigPath, - runtimeConfigPath, - source: rawSystemConfig, - runtime: runtimeConfigBeforeMirror, - writeVerbatimCopy: (copy) => - writeFileAtomicallyIfUnchanged(runtimeConfigPath, runtimeConfigBeforeMirror, copy) - }) - ) { - return - } - const runtimeParses = - runtimeConfigBeforeMirror !== null && - findUnparseableManagedCodexConfig(runtimeConfigBeforeMirror) === null - // Why: a missing cloud-synced source is not proof the user cleared config, - // but Orca's copy of a since-removed broken source holds nothing to keep. - let mirroredRuntimeConfig = - runtimeConfigBeforeMirror !== null && !isVerbatimCodexSourceCopy(runtimeConfigBeforeMirror) - ? runtimeConfigBeforeMirror - : '' + // Why: a missing cloud-synced source is not proof the user cleared config. + let mirroredRuntimeConfig = runtimeConfigBeforeMirror ?? '' if (rawSystemConfig.trim() !== '') { const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(homes.systemHomePath) // The retired home has no ownership baseline; its entire MCP root stays canonical. - // Why: sections are never carried out of a managed config Codex cannot parse. mirroredRuntimeConfig = - runtimeConfigBeforeMirror !== null && runtimeParses + runtimeConfigBeforeMirror !== null ? mergeSystemCodexConfigIntoRuntime( runtimeConfigBeforeMirror, prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir), @@ -90,18 +64,15 @@ export function syncSystemConfigIntoLegacySharedCodexHome( ) if ( (runtimeConfigBeforeMirror ?? '') === nextRuntimeConfig || - refuseUnparseableManagedConfig(runtimeConfigPath, nextRuntimeConfig) + refuseUnreadableCodexConfigResult({ + configPath: runtimeConfigPath, + result: nextRuntimeConfig, + inputs: [rawSystemConfig, runtimeConfigBeforeMirror], + context: 'Skipped mirroring the Codex config into a managed home' + }) ) { return } - if (rawSystemConfig.trim() !== '') { - backUpDiscardedManagedConfig({ - sourcePath: systemConfigPath, - runtimeConfigPath, - source: rawSystemConfig, - discarded: runtimeConfigBeforeMirror - }) - } // Why: stage first, then compare immediately before replace so a retained // Codex trust write during mirror preparation wins. writeFileAtomicallyIfUnchanged(runtimeConfigPath, runtimeConfigBeforeMirror, nextRuntimeConfig) diff --git a/src/main/codex/codex-config-mirror-duplicate-repair.test.ts b/src/main/codex/codex-config-mirror-duplicate-repair.test.ts index a048516555ef..a44e3f871485 100644 --- a/src/main/codex/codex-config-mirror-duplicate-repair.test.ts +++ b/src/main/codex/codex-config-mirror-duplicate-repair.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import type * as NodeOs from 'node:os' import { join } from 'node:path' @@ -31,7 +31,6 @@ vi.mock('node:os', async () => { }) import { - syncManagedCodexHomeWithoutSourceConfig, syncSystemConfigIntoLegacySharedCodexHome, syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' @@ -135,27 +134,7 @@ describe('managed-home mirror never writes a config Codex cannot read (#22592)', }) }) - it('copies an unparseable ~/.codex verbatim so Orca-launched Codex fails like hand-typed codex', () => { - const broken = '[mcp_servers.a]\ncommand = "x"\n[mcp_servers.a]\ncommand = "y"\n' - writeFileSync(getSystemConfigPath(), broken, 'utf-8') - mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) - writeFileSync(getRuntimeConfigPath(), 'model = "stale"\n[a\n', 'utf-8') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - syncSystemConfigIntoManagedCodexHome() - syncSystemConfigIntoManagedCodexHome() - - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe( - `${broken}${verbatimMarker(getSystemConfigPath())}` - ) - const copies = warn.mock.calls - .flat() - .filter((line) => String(line).includes('copied it unchanged')) - expect(copies).toHaveLength(1) - warn.mockRestore() - }) - - it('collapses Orca\u2019s own duplicate hook tables instead of reseeding, so managed-only trust survives', () => { + it('collapses Orca\u2019s own duplicate hook tables, so managed-only trust survives', () => { writeFileSync(getSystemConfigPath(), 'model = "m"\n', 'utf-8') mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) const key = '/rt/hooks.json:stop:0:0' @@ -184,82 +163,68 @@ describe('managed-home mirror never writes a config Codex cannot read (#22592)', projects: { '/answered-in-orca': { trust_level: 'trusted' } }, hooks: { state: { [key]: { trusted_hash: 'sha256:a' } } } }) - expect(existsSync(`${getRuntimeConfigPath()}.bak`)).toBe(false) - }) - - it('keeps a discarded unparseable managed config as .bak when it differs from ~/.codex', () => { - writeFileSync(getSystemConfigPath(), 'model = "A"\n', 'utf-8') - mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) - const discarded = 'model = "mine"\n[a\n' - writeFileSync(getRuntimeConfigPath(), discarded, 'utf-8') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - syncSystemConfigIntoManagedCodexHome() - - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "A"\n') - expect(readFileSync(`${getRuntimeConfigPath()}.bak`, 'utf-8')).toBe(discarded) - expect(warn.mock.calls.flat().join(' ')).toContain('.bak') - warn.mockRestore() }) +}) - it('keeps no .bak when the managed config it replaces still parses', () => { - writeFileSync(getSystemConfigPath(), 'model = "B"\n', 'utf-8') - mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) - writeFileSync(getRuntimeConfigPath(), 'model = "A"\n', 'utf-8') - - syncSystemConfigIntoManagedCodexHome() - - expect(existsSync(`${getRuntimeConfigPath()}.bak`)).toBe(false) - }) +// Why: handling a ~/.codex the user broke by hand is a separate change; until +// then the mirrors must do exactly what they did before the checked writer. +describe('a ~/.codex the user broke by hand is mirrored as before', () => { + const brokenSource = 'model = "A"\n\n[mcp_servers.keep]\ncommand = "k"\nbroken = \n' + const managedOnly = + '\n[mcp_servers.mine]\ncommand = "m"\n\n[projects."/sub"]\ntrust_level = "trusted"\n' + const managed = `model = "A"\n\n[mcp_servers.keep]\ncommand = "k"\n${managedOnly}` + + function makeHomes(): { systemHomePath: string; runtimeHomePath: string } { + const homes = { + systemHomePath: join(fakeHomeDir, 'sys'), + runtimeHomePath: join(fakeHomeDir, 'rt') + } + mkdirSync(homes.systemHomePath, { recursive: true }) + mkdirSync(homes.runtimeHomePath, { recursive: true }) + writeFileSync(join(homes.systemHomePath, 'config.toml'), brokenSource) + return homes + } - it('leaves a managed config that parses untouched while ~/.codex is broken, and logs why once', () => { - writeFileSync(getSystemConfigPath(), 'model = "A"\nbroken = \n', 'utf-8') - mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) - writeFileSync(getRuntimeConfigPath(), 'model = "A"\n', 'utf-8') + it.each([ + [ + 'managed home', + syncSystemConfigIntoManagedCodexHome, + `model = "A"\n\n[mcp_servers.keep]\ncommand = "k"\nbroken =\n${managedOnly}` + ], + [ + 'legacy shared home', + syncSystemConfigIntoLegacySharedCodexHome, + 'model = "A"\n\n[mcp_servers.keep]\ncommand = "k"\nbroken =\n\n[projects."/sub"]\ntrust_level = "trusted"\n' + ] + ])('merges it into a %s holding managed-only state', (_label, sync, expected) => { + const homes = makeHomes() + const rtPath = join(homes.runtimeHomePath, 'config.toml') + writeFileSync(rtPath, managed) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - syncSystemConfigIntoManagedCodexHome() - syncSystemConfigIntoManagedCodexHome() + sync(homes) + sync(homes) - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "A"\n') - expect(warn.mock.calls.flat().filter((line) => String(line).includes('left '))).toHaveLength(1) + expect(readFileSync(rtPath, 'utf8')).toBe(expected) + expect(warn).not.toHaveBeenCalled() warn.mockRestore() }) - it('seeds a fresh managed home with the same verbatim copy', () => { - writeFileSync(getSystemConfigPath(), 'log_dir = "logs\\uD800dir"\n', 'utf-8') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + it.each([ + ['managed home', syncSystemConfigIntoManagedCodexHome], + ['legacy shared home', syncSystemConfigIntoLegacySharedCodexHome] + ])('seeds a fresh %s from it', (_label, sync) => { + const homes = makeHomes() - syncSystemConfigIntoManagedCodexHome() + sync(homes) - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe( - `log_dir = "logs\\uD800dir"\n${verbatimMarker(getSystemConfigPath())}` + expect(readFileSync(join(homes.runtimeHomePath, 'config.toml'), 'utf8')).toBe( + 'model = "A"\n\n[mcp_servers.keep]\ncommand = "k"\nbroken =\n' ) - warn.mockRestore() }) -}) - -// Why: an invalid save of ~/.codex followed by a fix must end exactly where the -// same edits without the invalid save end; nothing managed-only may be lost and -// nothing stale may be promoted into ~/.codex. -function verbatimMarker(sourcePath: string): string { - return `# orca: verbatim copy of ${sourcePath}; replaced when the source parses\n` -} - -describe('break-then-fix of ~/.codex keeps managed-home state', () => { - function makeHomes(): { - homes: { systemHomePath: string; runtimeHomePath: string } - root: string - } { - const root = mkdtempSync(join(tmpdir(), 'orca-break-fix-')) - const homes = { systemHomePath: join(root, 'sys'), runtimeHomePath: join(root, 'rt') } - mkdirSync(homes.systemHomePath, { recursive: true }) - mkdirSync(homes.runtimeHomePath, { recursive: true }) - return { homes, root } - } - function runInCodexChangesThenFix(breakFirst: boolean): { system: string; runtime: string } { - const { homes, root } = makeHomes() + it('holds an in-Codex change back until ~/.codex parses, then promotes it', () => { + const homes = makeHomes() const sysPath = join(homes.systemHomePath, 'config.toml') const rtPath = join(homes.runtimeHomePath, 'config.toml') const good = 'model = "A"\n\n[mcp_servers.keep]\ncommand = "k"\n' @@ -267,189 +232,32 @@ describe('break-then-fix of ~/.codex keeps managed-home state', () => { try { writeFileSync(sysPath, good) syncSystemConfigIntoManagedCodexHome(homes) - // Codex inside Orca: /model B, an MCP server added there, a trust answer. - writeFileSync( - rtPath, - `${readFileSync(rtPath, 'utf8').replace('model = "A"', 'model = "B"')}\n[mcp_servers.mine]\ncommand = "m"\n\n[projects."/sub"]\ntrust_level = "trusted"\n` - ) - if (breakFirst) { - writeFileSync(sysPath, `${good}broken = \n`) - syncSystemConfigIntoManagedCodexHome(homes) - } - writeFileSync(sysPath, good) - syncSystemConfigIntoManagedCodexHome(homes) - syncSystemConfigIntoManagedCodexHome(homes) - return { system: readFileSync(sysPath, 'utf8'), runtime: readFileSync(rtPath, 'utf8') } - } finally { - warn.mockRestore() - rmSync(root, { recursive: true, force: true }) - } - } - - it('ends where the same edits without the invalid save end', () => { - const withBreak = runInCodexChangesThenFix(true) - expect(withBreak).toEqual(runInCodexChangesThenFix(false)) - expect(withBreak.runtime).toContain('[mcp_servers.mine]') - expect(withBreak.runtime).toContain('[projects."/sub"]') - }) - - it('never promotes a value from the broken save into ~/.codex', () => { - const { homes, root } = makeHomes() - const sysPath = join(homes.systemHomePath, 'config.toml') - const rtPath = join(homes.runtimeHomePath, 'config.toml') - const good = - 'model = "A"\nsandbox_mode = "workspace-write"\n\n[mcp_servers.keep]\ncommand = "k"\n' - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - writeFileSync(sysPath, good) - syncSystemConfigIntoManagedCodexHome(homes) + // Codex inside Orca: /model B and a trust answer; then the user breaks ~/.codex. writeFileSync( rtPath, - `${readFileSync(rtPath, 'utf8')}\n[projects."/w"]\ntrust_level = "trusted"\n` - ) - syncSystemConfigIntoManagedCodexHome(homes) - writeFileSync( - sysPath, - 'model = "A"\nsandbox_mode = "danger-full-access"\nbroken = \n\n[mcp_servers.keep]\ncommand = "k"\n\n[mcp_servers.tmp]\ncommand = "t"\n' + `${readFileSync(rtPath, 'utf8').replace('model = "A"', 'model = "B"')}${managedOnly}` ) - syncSystemConfigIntoManagedCodexHome(homes) - writeFileSync(sysPath, good) + const runtimeBeforeBreak = readFileSync(rtPath, 'utf8') + writeFileSync(sysPath, brokenSource) syncSystemConfigIntoManagedCodexHome(homes) syncSystemConfigIntoManagedCodexHome(homes) - expect(readFileSync(sysPath, 'utf8')).toBe(good) - const runtime = readFileSync(rtPath, 'utf8') - expect(runtime).toContain('sandbox_mode = "workspace-write"') - expect(runtime).toContain('[projects."/w"]') - expect(runtime).not.toContain('mcp_servers.tmp') - expect(runtime).not.toContain('danger-full-access') - } finally { - warn.mockRestore() - rmSync(root, { recursive: true, force: true }) - } - }) + expect(readFileSync(sysPath, 'utf8')).toBe(brokenSource) + expect(readFileSync(rtPath, 'utf8')).toBe(runtimeBeforeBreak) + expect( + warn.mock.calls.flat().filter((line) => String(line).includes('Skipped promoting')) + ).toHaveLength(1) - it('never treats a verbatim copy as managed state, even when the mirror dedupe would make it parse', () => { - const { homes, root } = makeHomes() - const sysPath = join(homes.systemHomePath, 'config.toml') - const rtPath = join(homes.runtimeHomePath, 'config.toml') - const good = 'model = "A"\n\n[mcp_servers.keep]\ncommand = "k"\n' - // Why: broken only by a duplicate project table that Orca's repair must not collapse. - const broken = - 'model = "A"\n\n[marketplaces.stale]\nsource = "s"\n\n[mcp_servers.tmp]\ncommand = "t"\n\n[projects."/x"]\ntrust_level = "trusted"\nfoo = 1\n\n[projects."/x"]\nbar = 2\n' - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - writeFileSync(sysPath, broken) - syncSystemConfigIntoManagedCodexHome(homes) - expect(readFileSync(rtPath, 'utf8')).toBe(`${broken}${verbatimMarker(sysPath)}`) - syncSystemConfigIntoManagedCodexHome(homes) - // The user fixes ~/.codex and removes the stale marketplace, the server and the project. writeFileSync(sysPath, good) syncSystemConfigIntoManagedCodexHome(homes) - syncSystemConfigIntoManagedCodexHome(homes) - - expect(readFileSync(sysPath, 'utf8')).toBe(good) - const runtime = readFileSync(rtPath, 'utf8') - expect(runtime).not.toContain('marketplaces.stale') - expect(runtime).not.toContain('mcp_servers.tmp') - expect(runtime).not.toContain('projects."/x"') - expect(runtime).not.toContain('orca: verbatim copy') - } finally { - warn.mockRestore() - rmSync(root, { recursive: true, force: true }) - } - }) - - describe('the verbatim copy is replaced once the user deletes or empties ~/.codex/config.toml', () => { - const broken = 'model = "A"\nbroken = \n' - const good = 'model = "C"\n' - - it.each([ - [ - 'deleted, managed mirror', - (sysPath: string) => rmSync(sysPath), - syncSystemConfigIntoManagedCodexHome - ], - [ - 'emptied, managed mirror', - (sysPath: string) => writeFileSync(sysPath, ''), - syncSystemConfigIntoManagedCodexHome - ], - [ - 'deleted, account home without a source', - (sysPath: string) => rmSync(sysPath), - syncManagedCodexHomeWithoutSourceConfig - ], - [ - 'deleted, legacy shared home', - (sysPath: string) => rmSync(sysPath), - syncSystemConfigIntoLegacySharedCodexHome - ] - ])('%s', (_label, clearSource, syncWithoutSource) => { - const { homes, root } = makeHomes() - const sysPath = join(homes.systemHomePath, 'config.toml') - const rtPath = join(homes.runtimeHomePath, 'config.toml') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - writeFileSync(sysPath, broken) - syncSystemConfigIntoManagedCodexHome(homes) - expect(readFileSync(rtPath, 'utf8')).toBe(`${broken}${verbatimMarker(sysPath)}`) - - clearSource(sysPath) - syncWithoutSource(homes) - expect(readFileSync(rtPath, 'utf8')).toBe('') - - writeFileSync(sysPath, good) - syncSystemConfigIntoManagedCodexHome(homes) - expect(readFileSync(sysPath, 'utf8')).toBe(good) - expect(readFileSync(rtPath, 'utf8')).toBe(good) - } finally { - warn.mockRestore() - rmSync(root, { recursive: true, force: true }) - } - }) - }) - - it('treats a valid ~/.codex that ends in the marker text as the user config, not a copy', () => { - const { homes, root } = makeHomes() - const sysPath = join(homes.systemHomePath, 'config.toml') - const rtPath = join(homes.runtimeHomePath, 'config.toml') - const markerLine = '# orca: verbatim copy of /x/config.toml; replaced when the source parses\n' - try { - writeFileSync(sysPath, `model = "A"\n${markerLine}`) - syncSystemConfigIntoManagedCodexHome(homes) - // Codex inside Orca: /model B. - writeFileSync(rtPath, readFileSync(rtPath, 'utf8').replace('model = "A"', 'model = "B"')) - syncSystemConfigIntoManagedCodexHome(homes) - expect(parse(readFileSync(sysPath, 'utf8')).model).toBe('B') - expect(parse(readFileSync(rtPath, 'utf8')).model).toBe('B') - } finally { - rmSync(root, { recursive: true, force: true }) - } - }) - - it('keeps a broken managed config as .bak before the verbatim copy replaces it, and never backs up the copy', () => { - const { homes, root } = makeHomes() - const sysPath = join(homes.systemHomePath, 'config.toml') - const rtPath = join(homes.runtimeHomePath, 'config.toml') - const good = 'model = "A"\n' - const brokenManaged = 'model = "A"\n\n[mcp_servers.mine]\ncommand = "m"\n\nx = = 1\n' - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - writeFileSync(sysPath, `${good}broken = \n`) - writeFileSync(rtPath, brokenManaged) - syncSystemConfigIntoManagedCodexHome(homes) - expect(readFileSync(rtPath, 'utf8')).toContain('orca: verbatim copy') - expect(readFileSync(`${rtPath}.bak`, 'utf8')).toBe(brokenManaged) - - writeFileSync(sysPath, good) - syncSystemConfigIntoManagedCodexHome(homes) - expect(readFileSync(rtPath, 'utf8')).toBe(good) - expect(readFileSync(`${rtPath}.bak`, 'utf8')).toBe(brokenManaged) + expect(parse(readFileSync(rtPath, 'utf8'))).toMatchObject({ + model: 'B', + mcp_servers: { mine: { command: 'm' } }, + projects: { '/sub': { trust_level: 'trusted' } } + }) } finally { warn.mockRestore() - rmSync(root, { recursive: true, force: true }) } }) }) diff --git a/src/main/codex/codex-config-mirror-merge.ts b/src/main/codex/codex-config-mirror-merge.ts index afc0680e05ff..260345a29b5c 100644 --- a/src/main/codex/codex-config-mirror-merge.ts +++ b/src/main/codex/codex-config-mirror-merge.ts @@ -1,8 +1,7 @@ import { readMcpServerTomlOwnership } from './config-toml-mcp-servers' -import { - getProjectsDefinedOutsideTables, - repairUnparseableCodexConfig -} from './codex-managed-config-validity' +import { getTomlTable, parseCodexConfigToml } from './codex-config-toml-document' +import { repairUnparseableCodexConfig } from './codex-config-toml-repair' +import { normalizeCodexProjectPathForLookup } from './config-toml-trust' import { deduplicateProjectTomlSections, getMcpServerTomlSectionName, @@ -84,3 +83,22 @@ export function mergeSystemCodexConfigIntoRuntime( .map((section) => section.block) ]) } + +/** Project keys ~/.codex defines inline or with dotted keys rather than as `[projects."…"]` tables. */ +function getProjectsDefinedOutsideTables(config: string): ReadonlySet { + const parsed = parseCodexConfigToml(config) + const projects = parsed.ok ? getTomlTable(parsed.table.projects) : null + if (!projects) { + return new Set() + } + const tableProjects = new Set( + getTomlSections(config) + .filter((section) => isRuntimeProjectTomlSection(section.header)) + .map((section) => getTomlSectionHeaderKey(section.header)) + ) + return new Set( + Object.keys(projects) + .map((projectPath) => `project:${normalizeCodexProjectPathForLookup(projectPath)}`) + .filter((key) => !tableProjects.has(key)) + ) +} diff --git a/src/main/codex/codex-config-mirror-source.ts b/src/main/codex/codex-config-mirror-source.ts index b5e2ad86f32b..7b7f0e94e824 100644 --- a/src/main/codex/codex-config-mirror-source.ts +++ b/src/main/codex/codex-config-mirror-source.ts @@ -2,7 +2,7 @@ import { dirname, join } from 'node:path' import { parseWslUncPath } from '../../shared/wsl-paths' import { rewriteRelativePathConfigValues } from './codex-config-path-reference-rewrite' import { normalizeDeprecatedCodexHookFeatureFlag } from './config-toml-deprecated-hook-flag' -import { repairUnparseableCodexConfig } from './codex-managed-config-validity' +import { repairUnparseableCodexConfig } from './codex-config-toml-repair' import { stripRuntimeOwnedTomlSections } from './config-toml-runtime-owned-sections' // Why: how ~/.codex is prepared before it is mirrored into an Orca-owned Codex home. diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts index 356e4eddee6e..74db1cd9c05b 100644 --- a/src/main/codex/codex-config-mirror.test.ts +++ b/src/main/codex/codex-config-mirror.test.ts @@ -231,9 +231,12 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { }) it('leaves values with lone-surrogate unicode escapes untouched', () => { - expect( - prepareSystemConfigForFreshRuntimeMirror('log_dir = "logs\\uD800dir"\n', '/home/me/.codex') - ).toContain('log_dir = "logs\\uD800dir"') + 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', () => { diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 8d42f69663de..6ea79fabe59c 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -15,17 +15,8 @@ import { preserveRuntimeConflictValues } from './codex-config-settings-preservat import { applyCodexDaemonSocketGuard } from './codex-daemon-socket-path-guard' import { clearCodexConfigTomlEditRefusalReport, - type CodexConfigTomlEditRefusedError, - reportCodexConfigTomlEditRefusal + refuseUnreadableCodexConfigResult } from './codex-config-toml-checked-edit' -import { - findManagedConfigRefusal, - applyUnparseableCodexSourceRule, - backUpDiscardedManagedConfig, - findUnparseableManagedCodexConfig, - isVerbatimCodexSourceCopy, - refuseUnparseableManagedConfig -} from './codex-managed-config-validity' import { mergeSystemCodexConfigIntoRuntime } from './codex-config-mirror-merge' import { prepareSystemConfigForFreshRuntimeMirror, @@ -87,20 +78,8 @@ function mirrorSystemConfigIntoManagedCodexHome(homes: CodexSettingsPromotionHom if (mirrorResult.status === 'refused-invalid') { // Why: writing a config Codex cannot parse breaks every managed-home launch; // keep the last good copy and advance nothing, like an unreadable source. - reportCodexConfigTomlEditRefusal( - mirrorResult.error, - 'Skipped mirroring the Codex config into a managed home' - ) return false } - if (mirrorResult.status === 'refused-unparseable-source') { - // Why: the managed home keeps its own state; nothing mirrored, so nothing advances. - return false - } - if (mirrorResult.status === 'copied-unparseable') { - // Why: a mirror ran (the verbatim copy), so no daemon guard may edit it and no baseline advances from it. - return true - } if (mirrorResult.status === 'refused-indeterminate') { // Why: no mirror ran, so this must behave exactly like the throwing path // above — surface the reason and advance nothing. Advancing the baseline @@ -156,29 +135,18 @@ export function ensureCodexDaemonSocketGuard(runtimeHomePath: string): void { } } -/** - * For a managed home when no source config was read. Orca's verbatim copy goes - * through the full mirror, which re-reads the source and replaces the copy once - * it is gone or blank; any other config gets only the daemon guard. - */ -export function syncManagedCodexHomeWithoutSourceConfig(homes: CodexSettingsPromotionHomes): void { - const observation = observeAgentStateFile(join(homes.runtimeHomePath, 'config.toml')) - if (observation.kind === 'present' && isVerbatimCodexSourceCopy(observation.value)) { - syncSystemConfigIntoManagedCodexHome(homes) - return - } - ensureCodexDaemonSocketGuard(homes.runtimeHomePath) -} - -function writeCodexDaemonSocketGuard( - runtimeHomePath: string, - runtimeConfig: string | null, - baseConfig = runtimeConfig ?? '' -): void { - const guarded = applyCodexDaemonSocketGuard(baseConfig, runtimeHomePath) +function writeCodexDaemonSocketGuard(runtimeHomePath: string, runtimeConfig: string | null): void { + const guarded = applyCodexDaemonSocketGuard(runtimeConfig ?? '', runtimeHomePath) if (guarded !== (runtimeConfig ?? '')) { const runtimeConfigPath = join(runtimeHomePath, 'config.toml') - if (refuseUnparseableManagedConfig(runtimeConfigPath, guarded)) { + if ( + refuseUnreadableCodexConfigResult({ + configPath: runtimeConfigPath, + result: guarded, + inputs: [runtimeConfig], + context: 'Skipped writing a managed Codex config' + }) + ) { return } writeFileAtomicallyIfUnchanged(runtimeConfigPath, runtimeConfig, guarded) @@ -187,10 +155,8 @@ function writeCodexDaemonSocketGuard( type CodexConfigMirrorResult = | { status: 'skipped-missing-source' } - | { status: 'copied-unparseable' } - | { status: 'refused-unparseable-source' } | { status: 'refused-indeterminate'; error: unknown } - | { status: 'refused-invalid'; error: CodexConfigTomlEditRefusedError } + | { status: 'refused-invalid' } | { status: 'mirrored' preservedConflictKeys: ReadonlySet @@ -223,16 +189,12 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe( // it would erase every ordinary setting from an existing managed runtime, and // a 0-byte file is what a half-written or unhydrated cloud-synced home shows. if (rawSystemConfig.trim() === '') { - const runtimeConfig = runtimeConfigExists ? runtimeConfigObservation.value : null - // Why: once the user deletes or empties a broken ~/.codex, Orca's copy of it holds nothing to keep. - const replacesVerbatimCopy = runtimeConfig !== null && isVerbatimCodexSourceCopy(runtimeConfig) // Why: no mirror write happens here, but the daemon guard must still land. writeCodexDaemonSocketGuard( runtimeHomePath, - runtimeConfig, - replacesVerbatimCopy ? '' : (runtimeConfig ?? '') + runtimeConfigExists ? runtimeConfigObservation.value : null ) - return runtimeConfig !== null && !replacesVerbatimCopy + return runtimeConfigExists ? { status: 'skipped-missing-source' } : { status: 'mirrored', @@ -242,41 +204,16 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe( } } - const existingRuntimeConfig = - runtimeConfigObservation.kind === 'present' ? runtimeConfigObservation.value : null - const unparseableSource = applyUnparseableCodexSourceRule({ - sourcePath: systemConfigPath, - runtimeConfigPath, - source: rawSystemConfig, - runtime: existingRuntimeConfig, - writeVerbatimCopy: (copy) => writeFileAtomically(runtimeConfigPath, copy) - }) - if (unparseableSource) { - return { - status: unparseableSource === 'copied' ? 'copied-unparseable' : 'refused-unparseable-source' - } - } - const runtimeParses = - existingRuntimeConfig !== null && - findUnparseableManagedCodexConfig(existingRuntimeConfig) === null const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(systemHomePath, systemConfigDir) - // Why: a managed config Codex cannot parse (the verbatim copy of a broken ~/.codex) holds nothing to keep; reseed it. - if (!runtimeParses || existingRuntimeConfig === null) { + if (!runtimeConfigExists) { const freshRuntimeConfig = applyCodexDaemonSocketGuard( prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir), runtimeHomePath ) - const refusal = findManagedConfigRefusal(runtimeConfigPath, freshRuntimeConfig) - if (refusal) { - return { status: 'refused-invalid', error: refusal } + if (refuseUnreadableMirrorResult(runtimeConfigPath, freshRuntimeConfig, [rawSystemConfig])) { + return { status: 'refused-invalid' } } const ownership = readMcpServerTomlOwnership(freshRuntimeConfig) - backUpDiscardedManagedConfig({ - sourcePath: systemConfigPath, - runtimeConfigPath, - source: rawSystemConfig, - discarded: existingRuntimeConfig - }) writeFileAtomically(runtimeConfigPath, freshRuntimeConfig) return { status: 'mirrored', @@ -291,7 +228,7 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe( readMcpServerTomlOwnership(systemConfig) // Why: reuse the bytes already observed above rather than re-reading. A second // read could succeed where the first failed and re-open the gap this closes. - const runtimeConfig = existingRuntimeConfig + const runtimeConfig = runtimeConfigObservation.value const preserved = preserveRuntimeConflictValues( mergeSystemCodexConfigIntoRuntime( runtimeConfig, @@ -303,9 +240,13 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe( ) const nextRuntimeConfig = applyCodexDaemonSocketGuard(preserved.content, runtimeHomePath) if (nextRuntimeConfig !== runtimeConfig) { - const refusal = findManagedConfigRefusal(runtimeConfigPath, nextRuntimeConfig) - if (refusal) { - return { status: 'refused-invalid', error: refusal } + if ( + refuseUnreadableMirrorResult(runtimeConfigPath, nextRuntimeConfig, [ + rawSystemConfig, + runtimeConfig + ]) + ) { + return { status: 'refused-invalid' } } writeFileAtomically(runtimeConfigPath, nextRuntimeConfig) } @@ -317,3 +258,16 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe( mirroredMcpServerRoot } } + +function refuseUnreadableMirrorResult( + runtimeConfigPath: string, + result: string, + inputs: readonly (string | null)[] +): boolean { + return refuseUnreadableCodexConfigResult({ + configPath: runtimeConfigPath, + result, + inputs, + context: 'Skipped mirroring the Codex config into a managed home' + }) +} diff --git a/src/main/codex/codex-config-toml-checked-edit.test.ts b/src/main/codex/codex-config-toml-checked-edit.test.ts index a5c43fcb9767..2fc69c770f5a 100644 --- a/src/main/codex/codex-config-toml-checked-edit.test.ts +++ b/src/main/codex/codex-config-toml-checked-edit.test.ts @@ -14,6 +14,7 @@ import { import { clearCodexConfigTomlEditRefusalReport, CodexConfigTomlEditRefusedError, + refuseUnreadableCodexConfigResult, reportCodexConfigTomlEditRefusal, reportCodexTrustWriteRefusals } from './codex-config-toml-checked-edit' @@ -390,3 +391,30 @@ describe('Codex config reports about one file log once per kind', () => { } }) }) + +describe('a whole-document mirror result', () => { + const unreadable = 'model = "a"\nmodel = "b"\n' + const refuse = (inputs: (string | null)[]) => + refuseUnreadableCodexConfigResult({ + configPath: '/tmp/mirror-result-probe/config.toml', + result: unreadable, + inputs, + context: 'Skipped mirroring the Codex config into a managed home' + }) + + it('is refused when every input parses, counting Orca-repairable duplicates as parsing', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const orcaDuplicates = + '["projects"."/r"]\ntrust_level = "trusted"\n\n[projects."/r"]\ntrust_level = "trusted"\n' + expect(refuse(['model = "a"\n', null, orcaDuplicates])).toBe(true) + expect(warn).toHaveBeenCalledTimes(1) + } finally { + warn.mockRestore() + } + }) + + it('is written as before when an input was broken by hand', () => { + expect(refuse(['model = "a"\n', 'broken = \n'])).toBe(false) + }) +}) diff --git a/src/main/codex/codex-config-toml-checked-edit.ts b/src/main/codex/codex-config-toml-checked-edit.ts index 5b580fb35c11..20458a33c266 100644 --- a/src/main/codex/codex-config-toml-checked-edit.ts +++ b/src/main/codex/codex-config-toml-checked-edit.ts @@ -8,7 +8,8 @@ import { } from './codex-config-toml-document' import { removedValuesSurviveIn, - repairOrcaCodexConfigDuplicatesWithRemovals + repairOrcaCodexConfigDuplicatesWithRemovals, + repairUnparseableCodexConfig } from './codex-config-toml-repair' import type { TomlAssignmentLine } from './codex-config-toml-structure' @@ -156,7 +157,7 @@ export function applyCheckedCodexConfigTomlEdit( return result.content } -/** For whole-document producers (the managed-home mirror): the result must at least parse. */ +/** For whole-document writers: the result must at least parse. */ export function assertCodexConfigTomlParses(content: string): void { const parsed = parseCodexConfigToml(content) if (!parsed.ok) { @@ -168,12 +169,45 @@ export function assertCodexConfigTomlParses(content: string): void { } } +/** + * For whole-document producers (the managed-home mirrors): true, and reported + * once, when `result` does not parse although every input does after Orca's own + * repair. An input the user broke by hand is mirrored as it is. + */ +export function refuseUnreadableCodexConfigResult(args: { + configPath: string + result: string + inputs: readonly (string | null)[] + context: string +}): boolean { + const parsed = parseCodexConfigToml(args.result) + if ( + parsed.ok || + args.inputs.some( + (input) => input !== null && !parseCodexConfigToml(repairUnparseableCodexConfig(input)).ok + ) + ) { + clearCodexConfigTomlEditRefusalReport(args.configPath) + return false + } + reportCodexConfigTomlEditRefusal( + new CodexConfigTomlEditRefusedError({ + reason: 'result-invalid', + detail: `the result would not be valid TOML (${parsed.message}).`, + line: parsed.line, + configPath: args.configPath + }), + args.context + ) + return true +} + // Keyed by file, then by what Orca was doing, so interleaved reports about one file each log once. const reportedMessages = new Map>() const TRUST_WRITE_CONTEXT = 'Skipped marking a workspace trusted for Codex' /** Logs once per file, kind, and message, so a launch-time retry does not flood the log. */ -export function reportCodexConfigOnce(key: string, message: string, kind = ''): void { +function reportCodexConfigOnce(key: string, message: string, kind = ''): void { let byKind = reportedMessages.get(key) if (!byKind) { byKind = new Map() diff --git a/src/main/codex/codex-config-toml-repair.ts b/src/main/codex/codex-config-toml-repair.ts index 56900a21b0cc..58f6f8d10e00 100644 --- a/src/main/codex/codex-config-toml-repair.ts +++ b/src/main/codex/codex-config-toml-repair.ts @@ -35,6 +35,12 @@ export function repairOrcaCodexConfigDuplicates(content: string): string { return repairOrcaCodexConfigDuplicatesWithRemovals(content).content } +// Why (#22592): a config Orca broke earlier must not carry its duplicates into +// every managed home; launch prep repairs the file itself on the next trust write. +export function repairUnparseableCodexConfig(config: string): string { + return parseCodexConfigToml(config).ok ? config : repairOrcaCodexConfigDuplicates(config) +} + export type CodexConfigRepair = { content: string /** Removed assignments whose values a caller must still find, when the repaired file does not parse on its own. */ diff --git a/src/main/codex/codex-managed-config-validity.ts b/src/main/codex/codex-managed-config-validity.ts deleted file mode 100644 index da43e6d576e9..000000000000 --- a/src/main/codex/codex-managed-config-validity.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { writeRollingFileBackup } from '../rolling-file-backup' -import { getTomlTable, parseCodexConfigToml } from './codex-config-toml-document' -import { - assertCodexConfigTomlParses, - clearCodexConfigTomlEditRefusalReport, - CodexConfigTomlEditRefusedError, - reportCodexConfigOnce, - reportCodexConfigTomlEditRefusal -} from './codex-config-toml-checked-edit' -import { repairOrcaCodexConfigDuplicates } from './codex-config-toml-repair' -import { normalizeCodexProjectPathForLookup } from './config-toml-trust' -import { - deduplicateProjectTomlSections, - getTomlSectionHeaderKey, - getTomlSections, - isRuntimeProjectTomlSection, - joinTomlBlocks -} from './config-toml-runtime-owned-sections' - -/** True (and reported) when `content` is not TOML Codex could read, so the caller must not write it. */ -export function refuseUnparseableManagedConfig(configPath: string, content: string): boolean { - try { - assertCodexConfigTomlParses(content) - clearCodexConfigTomlEditRefusalReport(configPath) - return false - } catch (error) { - if (!(error instanceof CodexConfigTomlEditRefusedError)) { - throw error - } - reportCodexConfigTomlEditRefusal( - error.forConfigPath(configPath), - 'Skipped writing a managed Codex config' - ) - return true - } -} - -export function findManagedConfigRefusal( - configPath: string, - content: string -): CodexConfigTomlEditRefusedError | null { - try { - assertCodexConfigTomlParses(content) - return null - } catch (error) { - if (error instanceof CodexConfigTomlEditRefusedError) { - return error.forConfigPath(configPath) - } - throw error - } -} - -// Why (#22592): a ~/.codex Orca broke earlier must not carry its duplicates into -// every managed home; launch prep repairs the file itself on the next trust write. -export function repairUnparseableCodexConfig(config: string): string { - return parseCodexConfigToml(config).ok ? config : repairOrcaCodexConfigDuplicates(config) -} - -/** Project keys ~/.codex defines inline or with dotted keys rather than as `[projects."…"]` tables. */ -export function getProjectsDefinedOutsideTables(config: string): ReadonlySet { - const parsed = parseCodexConfigToml(config) - const projects = parsed.ok ? getTomlTable(parsed.table.projects) : null - if (!projects) { - return new Set() - } - const tableProjects = new Set( - getTomlSections(config) - .filter((section) => isRuntimeProjectTomlSection(section.header)) - .map((section) => getTomlSectionHeaderKey(section.header)) - ) - return new Set( - Object.keys(projects) - .map((projectPath) => `project:${normalizeCodexProjectPathForLookup(projectPath)}`) - .filter((key) => !tableProjects.has(key)) - ) -} - -/** The parse error when Codex cannot read `config` even after Orca's own repair, else null. */ -export function findUnparseableCodexConfig(config: string): string | null { - const parsed = parseCodexConfigToml(repairUnparseableCodexConfig(config)) - if (parsed.ok) { - return null - } - return parsed.line ? `line ${parsed.line}: ${parsed.message}` : parsed.message -} - -/** - * Why: an Orca-owned home broken only by Orca's own duplicate trust tables is - * collapsed by the mirror's dedupe (#22592), so it still holds state worth - * keeping; only a config that stays unreadable after that is treated as a copy - * of a broken ~/.codex. - */ -export function findUnparseableManagedCodexConfig(config: string): string | null { - if (isVerbatimCodexSourceCopy(config)) { - // Why: a copy of a broken ~/.codex is never managed state, even where the mirror's dedupe would make it parse. - return 'verbatim copy of an unparseable ~/.codex' - } - const parseError = findUnparseableCodexConfig(config) - if (parseError === null) { - return null - } - const repaired = repairUnparseableCodexConfig(config) - const sections = getTomlSections(repaired) - const firstSectionLine = sections[0]?.start ?? -1 - const preamble = - firstSectionLine === -1 ? repaired : repaired.split('\n').slice(0, firstSectionLine).join('\n') - const deduplicated = joinTomlBlocks([ - preamble, - ...deduplicateProjectTomlSections(sections).map((section) => section.block) - ]) - return parseCodexConfigToml(deduplicated).ok ? null : parseError -} - -/** - * Why: ~/.codex saved mid-edit must not overwrite a managed home that still - * holds state only it has (an unpromoted /model, an MCP server added in - * Orca-launched Codex, trust answers). - */ -function refuseMirrorFromUnparseableSource( - sourcePath: string, - runtimeConfigPath: string, - parseError: string -): void { - reportCodexConfigOnce( - runtimeConfigPath, - `${sourcePath} is not valid TOML (${parseError}); left ${runtimeConfigPath} as it was. Fix ${sourcePath} to resume syncing.` - ) -} - -/** - * Why: with nothing in the managed home to lose, an unchanged copy makes - * Orca-launched Codex show Codex's own error, exactly like `codex` typed by hand. - */ -function reportVerbatimUnparseableCodexSource( - sourcePath: string, - runtimeConfigPath: string, - parseError: string -): void { - reportCodexConfigOnce( - runtimeConfigPath, - `${sourcePath} is not valid TOML (${parseError}); copied it unchanged into ${runtimeConfigPath} so Orca-launched Codex reports the same error. Fix ${sourcePath} to resume syncing.` - ) -} - -/** - * What the mirror does with a ~/.codex Codex cannot parse: leave a managed - * config that parses untouched, or copy the source verbatim when the managed - * home has nothing to lose. Null when the source parses. - */ -export function applyUnparseableCodexSourceRule(args: { - sourcePath: string - runtimeConfigPath: string - source: string - runtime: string | null - writeVerbatimCopy: (content: string) => void -}): 'left-untouched' | 'copied' | null { - const parseError = args.source.trim() === '' ? null : findUnparseableCodexConfig(args.source) - if (parseError === null) { - return null - } - if (args.runtime !== null && findUnparseableManagedCodexConfig(args.runtime) === null) { - refuseMirrorFromUnparseableSource(args.sourcePath, args.runtimeConfigPath, parseError) - return 'left-untouched' - } - reportVerbatimUnparseableCodexSource(args.sourcePath, args.runtimeConfigPath, parseError) - const copy = markVerbatimCodexSourceCopy(args.source, args.sourcePath) - if (args.runtime !== copy) { - backUpDiscardedManagedConfig({ - sourcePath: args.sourcePath, - runtimeConfigPath: args.runtimeConfigPath, - source: args.source, - discarded: args.runtime - }) - args.writeVerbatimCopy(copy) - } - return 'copied' -} - -const VERBATIM_COPY_MARKER = '# orca: verbatim copy of ' - -/** Appended, so Codex reports the same line numbers as in the user's own file. */ -function markVerbatimCodexSourceCopy(source: string, sourcePath: string): string { - const eol = source.includes('\r\n') ? '\r\n' : '\n' - const body = source.endsWith('\n') ? source : `${source}${eol}` - return `${body}${VERBATIM_COPY_MARKER}${sourcePath}; replaced when the source parses${eol}` -} - -/** Only an unparseable file is a copy: a valid config that merely ends in the marker text is the user's own. */ -export function isVerbatimCodexSourceCopy(config: string): boolean { - const lastLine = config.trimEnd().split('\n').at(-1) ?? '' - return lastLine.trimStart().startsWith(VERBATIM_COPY_MARKER) && !parseCodexConfigToml(config).ok -} - -/** - * Why: reseeding replaces a managed config Codex cannot parse; unless it is just - * the verbatim copy of ~/.codex, keep it as `.bak` so its bytes are not lost silently. - */ -export function backUpDiscardedManagedConfig(args: { - sourcePath: string - runtimeConfigPath: string - source: string - discarded: string | null -}): void { - if ( - args.discarded === null || - args.discarded === args.source || - isVerbatimCodexSourceCopy(args.discarded) || - findUnparseableManagedCodexConfig(args.discarded) === null - ) { - return - } - writeRollingFileBackup(args.runtimeConfigPath, `${args.runtimeConfigPath}.bak`) - reportCodexConfigOnce( - `${args.runtimeConfigPath}#discarded`, - `${args.runtimeConfigPath} was not valid TOML; replaced it with a fresh copy of ${args.sourcePath} and kept the old file as ${args.runtimeConfigPath}.bak.` - ) -} diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts index 1bd37dee16de..7c90c44092c1 100644 --- a/src/main/codex/config-settings-promotion.ts +++ b/src/main/codex/config-settings-promotion.ts @@ -6,7 +6,6 @@ import { writeFileAtomically } from '../codex-accounts/fs-utils' import { parseWslUncPath } from '../../shared/wsl-paths' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' import { upsertPromotedSettingsInContent } from './codex-config-settings-upsert' -import { findUnparseableManagedCodexConfig } from './codex-managed-config-validity' import { applyCheckedCodexConfigTomlEdit, CodexConfigTomlEditRefusedError, @@ -123,7 +122,10 @@ export function promoteCodexRuntimeSettingsToSystem( return promoteCodexRuntimeSettingsToSystemUnsafe(homes ?? getHostPromotionHomes()) } catch (error) { // Why: promotion is best-effort launch prep; a malformed file must not block Codex launch. - console.warn('[codex-settings-promotion] failed to promote runtime settings', error) + // A refused edit was already reported once per file. + if (!(error instanceof CodexConfigTomlEditRefusedError)) { + console.warn('[codex-settings-promotion] failed to promote runtime settings', error) + } return null } } @@ -147,10 +149,6 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( // config nobody read. throw runtimeTomlObservation.error } - if (findUnparseableManagedCodexConfig(runtimeTomlObservation.value) !== null) { - // Why: an unparseable managed config is a verbatim copy of a broken ~/.codex, never the user's in-Codex change. - return emptyPromotionPlan() - } // Why: without a baseline, a stale runtime scalar looks like a fresh in-Codex change; skip until the mirror writes one. const baselineObservation = observeCodexSettingsBaseline(runtimeHomePath) if (baselineObservation.kind === 'indeterminate') { @@ -209,40 +207,42 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( writeTargetObservation.kind === 'present' ? writeTargetObservation.value : extractOrdinaryCodexSettings(runtimeTomlObservation.value) + const promote = (content: string): string => { + const withPromotedSettings = upsertPromotedSettingsInContent(content, updates) + // Why: plan against the content actually being edited, not a second read of the + // source — when the system config is seeded from the runtime, its registration + // tables are already present and re-appending them would duplicate the table. + return applyCodexRegistrationPromotions( + withPromotedSettings, + planCodexRegistrationPromotion( + runtimeTomlObservation.value, + withPromotedSettings, + baseline?.registrations ?? new Map() + ) + ) + } // Why (#22592): this is the user's real config, so the edit must leave it readable and change only what it promotes. let nextContent: string try { - nextContent = applyCheckedCodexConfigTomlEdit(systemContent, (content) => { - const withPromotedSettings = upsertPromotedSettingsInContent(content, updates) - // Why: plan against the content actually being edited, not a second read of the - // source — when the system config is seeded from the runtime, its registration - // tables are already present and re-appending them would duplicate the table. - return { - content: applyCodexRegistrationPromotions( - withPromotedSettings, - planCodexRegistrationPromotion( - runtimeTomlObservation.value, - withPromotedSettings, - baseline?.registrations ?? new Map() - ) - ), - ownedPaths: [ - ...[...updates.keys()].map((key) => key.split('.')), - ['marketplaces'], - ['plugins'] - ] - } - }) + nextContent = applyCheckedCodexConfigTomlEdit(systemContent, (content) => ({ + content: promote(content), + ownedPaths: [ + ...[...updates.keys()].map((key) => key.split('.')), + ['marketplaces'], + ['plugins'] + ] + })) } catch (error) { if (!(error instanceof CodexConfigTomlEditRefusedError)) { throw error } const refusal = error.forConfigPath(systemTomlPath) - reportCodexConfigTomlEditRefusal(refusal, 'Skipped promoting Codex settings') - if (refusal.reason === 'input-invalid') { - // Why: an unparseable ~/.codex is copied verbatim by the mirror, so promotion must not stall it. + if (refusal.reason === 'input-invalid' && promote(systemContent) === systemContent) { + // Why: nothing to write back, so a ~/.codex the user broke by hand is mirrored as before. return { conflicts, runtimeValuesToPreserve, mirroredMcpServers, mirroredMcpServerRoot } } + // Why: a write-back that cannot land stalls the mirror, which would otherwise erase the runtime change. + reportCodexConfigTomlEditRefusal(refusal, 'Skipped promoting Codex settings') throw refusal } if (nextContent === systemContent) { From 32b74b391c7feee2c67c0fd14700397242858fb1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:27:37 -0700 Subject: [PATCH 12/25] fix(codex): skip no-op hook trust edits and log a refused hook trust write once When ~/.codex does not parse, the managed home mirrors it as before this PR, so the managed config.toml does not parse either. Two hook paths then went wrong on every Codex launch: - A hook trust edit with nothing to write (no entries to upsert, or no existing hooks.state table to toggle) still ran the checked writer, which refuses a file it cannot parse. With status hooks off, the user-hook refresh returned 'error', warned on every launch, and skipped the trust provenance snapshot and legacy cleanup that follow. Before this PR it was a silent no-op. It is again. - A hook trust write with real entries is correctly refused, but launch prep logged that refusal on every launch. It is now logged once per file, like a refused workspace trust write, and launch prep does not repeat it. The hook status is unchanged ('error' with the same detail). A trust refusal report now ends only when a trust write actually lands; a no-op edit proves nothing about the file, and otherwise the no-op above would re-arm the workspace trust refusal log on every launch. --- .../codex-config-toml-checked-edit.test.ts | 37 ++++++++++++++++++ .../codex/codex-config-toml-checked-edit.ts | 28 +++++++++---- src/main/codex/codex-hook-local-install.ts | 5 ++- .../codex/codex-hook-local-maintenance.ts | 5 ++- .../codex/codex-hook-trust-write-failure.ts | 20 ++++++++++ src/main/codex/config-toml-hook-trust-edit.ts | 8 ++++ src/main/codex/config-toml-trust.ts | 20 ++++++---- .../hook-service-user-hook-mirroring.test.ts | 39 +++++++++++++++++++ src/main/startup/codex-launch-preparation.ts | 3 +- .../codex-launch-trust-write-deadline.test.ts | 31 ++++++++++++++- 10 files changed, 176 insertions(+), 20 deletions(-) create mode 100644 src/main/codex/codex-hook-trust-write-failure.ts diff --git a/src/main/codex/codex-config-toml-checked-edit.test.ts b/src/main/codex/codex-config-toml-checked-edit.test.ts index 2fc69c770f5a..cf64296abdc4 100644 --- a/src/main/codex/codex-config-toml-checked-edit.test.ts +++ b/src/main/codex/codex-config-toml-checked-edit.test.ts @@ -6,6 +6,7 @@ import { parse } from 'smol-toml' import { readHookTrustEntriesFromContent, removeHookTrustEntriesFromContent, + upsertHookTrustEntries, upsertHookTrustEntriesInContent, upsertProjectTrustLevel, upsertProjectTrustLevelInContent, @@ -16,6 +17,7 @@ import { CodexConfigTomlEditRefusedError, refuseUnreadableCodexConfigResult, reportCodexConfigTomlEditRefusal, + reportCodexHookTrustWriteRefusals, reportCodexTrustWriteRefusals } from './codex-config-toml-checked-edit' import { getTomlTable, readTomlValueAtPath } from './codex-config-toml-document' @@ -392,6 +394,41 @@ describe('Codex config reports about one file log once per kind', () => { }) }) +describe('a hook trust edit with nothing to write', () => { + const handBroken = 'model = "a"\nbroken = \n' + + it('leaves a config Codex cannot parse as it is instead of refusing', () => { + expect(upsertHookTrustEntriesInContent(handBroken, [])).toBe(handBroken) + expect( + setHookTrustEnabledContent(handBroken, [{ key: '/rt/hooks.json:stop:0:0', enabled: false }]) + ).toBe(handBroken) + }) + + it('does not end a trust refusal it never proved resolved', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const dir = mkdtempSync(join(tmpdir(), 'codex-noop-trust-')) + try { + const configPath = join(dir, 'config.toml') + writeFileSync(configPath, handBroken) + const refusal = new CodexConfigTomlEditRefusedError({ + reason: 'input-invalid', + detail: 'the file is not valid TOML', + configPath + }) + for (let launch = 0; launch < 3; launch++) { + reportCodexTrustWriteRefusals(refusal) + reportCodexHookTrustWriteRefusals(refusal) + upsertHookTrustEntries(configPath, []) + clearCodexConfigTomlEditRefusalReport(configPath) + } + expect(warn).toHaveBeenCalledTimes(2) + } finally { + rmSync(dir, { recursive: true, force: true }) + warn.mockRestore() + } + }) +}) + describe('a whole-document mirror result', () => { const unreadable = 'model = "a"\nmodel = "b"\n' const refuse = (inputs: (string | null)[]) => diff --git a/src/main/codex/codex-config-toml-checked-edit.ts b/src/main/codex/codex-config-toml-checked-edit.ts index 20458a33c266..2a877835135f 100644 --- a/src/main/codex/codex-config-toml-checked-edit.ts +++ b/src/main/codex/codex-config-toml-checked-edit.ts @@ -205,6 +205,11 @@ export function refuseUnreadableCodexConfigResult(args: { // Keyed by file, then by what Orca was doing, so interleaved reports about one file each log once. const reportedMessages = new Map>() const TRUST_WRITE_CONTEXT = 'Skipped marking a workspace trusted for Codex' +const HOOK_TRUST_WRITE_CONTEXT = 'Skipped writing Codex hook trust' +const TRUST_WRITE_KINDS: ReadonlySet = new Set([ + TRUST_WRITE_CONTEXT, + HOOK_TRUST_WRITE_CONTEXT +]) /** Logs once per file, kind, and message, so a launch-time retry does not flood the log. */ function reportCodexConfigOnce(key: string, message: string, kind = ''): void { @@ -232,12 +237,20 @@ export function reportCodexConfigTomlEditRefusal( * file, so it is reported once per file; returns the other failures for the caller. */ export function reportCodexTrustWriteRefusals(error: unknown): unknown[] { + return reportTrustWriteRefusals(error, TRUST_WRITE_CONTEXT) +} + +export function reportCodexHookTrustWriteRefusals(error: unknown): unknown[] { + return reportTrustWriteRefusals(error, HOOK_TRUST_WRITE_CONTEXT) +} + +function reportTrustWriteRefusals(error: unknown, context: string): unknown[] { const failures = error instanceof AggregateError ? error.errors : [error] return failures.filter((failure) => { if (!(failure instanceof CodexConfigTomlEditRefusedError)) { return true } - reportCodexConfigTomlEditRefusal(failure, TRUST_WRITE_CONTEXT) + reportCodexConfigTomlEditRefusal(failure, context) return false }) } @@ -245,14 +258,15 @@ export function reportCodexTrustWriteRefusals(error: unknown): unknown[] { /** Forgets mirror/write reports for a file; a trust refusal is only forgotten by a trust write that succeeds. */ export function clearCodexConfigTomlEditRefusalReport(configPath: string): void { const byKind = reportedMessages.get(configPath) - const trust = byKind?.get(TRUST_WRITE_CONTEXT) - if (trust === undefined) { - reportedMessages.delete(configPath) - } else { - reportedMessages.set(configPath, new Map([[TRUST_WRITE_CONTEXT, trust]])) + for (const kind of byKind?.keys() ?? []) { + if (!TRUST_WRITE_KINDS.has(kind)) { + byKind?.delete(kind) + } } } export function clearCodexTrustWriteRefusalReport(configPath: string): void { - reportedMessages.get(configPath)?.delete(TRUST_WRITE_CONTEXT) + for (const kind of TRUST_WRITE_KINDS) { + reportedMessages.get(configPath)?.delete(kind) + } } diff --git a/src/main/codex/codex-hook-local-install.ts b/src/main/codex/codex-hook-local-install.ts index be3582d3879e..6d793a64d4ce 100644 --- a/src/main/codex/codex-hook-local-install.ts +++ b/src/main/codex/codex-hook-local-install.ts @@ -28,6 +28,7 @@ import { cleanupLegacyManagedHookRepresentations } from './codex-hook-legacy-cle import { getManagedScript } from './codex-hook-script' import { grantManagedCodexHookTrust } from './codex-hook-trust-grant' import { removeStaleRuntimeHookTrustEntries } from './codex-hook-trust-cleanup' +import { reportCodexHookTrustWriteFailure } from './codex-hook-trust-write-failure' import { promoteCodexRuntimeHookApprovalsToSystem, snapshotCodexRuntimeHookTrustProvenance @@ -171,13 +172,13 @@ export async function installCodexHooksExclusively( } applyMirroredRuntimeUserHookTrustStates(tomlPath, mirroredUserTrustEntries) } catch (error) { - return { + return reportCodexHookTrustWriteFailure(error, { agent: 'codex', state: 'error', configPath, managedHooksPresent: true, detail: `Hooks installed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.` - } + }) } snapshotCodexRuntimeHookTrustProvenance(runtimeHomePath) await cleanupLegacyManagedHookRepresentations() diff --git a/src/main/codex/codex-hook-local-maintenance.ts b/src/main/codex/codex-hook-local-maintenance.ts index 8b45049e5160..c082eedcd47f 100644 --- a/src/main/codex/codex-hook-local-maintenance.ts +++ b/src/main/codex/codex-hook-local-maintenance.ts @@ -14,6 +14,7 @@ import { removeRuntimeManagedHookTrustEntries, removeStaleRuntimeHookTrustEntries } from './codex-hook-trust-cleanup' +import { reportCodexHookTrustWriteFailure } from './codex-hook-trust-write-failure' import { applyMirroredRuntimeUserHookTrustStates, getRuntimeHooksWithSystemUserHooks @@ -74,13 +75,13 @@ export async function refreshCodexRuntimeUserHooksExclusively( removeStaleRuntimeHookTrustEntries(tomlPath, configPath, trustEntries) applyMirroredRuntimeUserHookTrustStates(tomlPath, hookPlan.trustEntries) } catch (error) { - return { + return reportCodexHookTrustWriteFailure(error, { agent: 'codex', state: 'error', configPath, managedHooksPresent: false, detail: `User hooks refreshed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.` - } + }) } snapshotCodexRuntimeHookTrustProvenance(runtimeHomePath) diff --git a/src/main/codex/codex-hook-trust-write-failure.ts b/src/main/codex/codex-hook-trust-write-failure.ts new file mode 100644 index 000000000000..55396eaeb069 --- /dev/null +++ b/src/main/codex/codex-hook-trust-write-failure.ts @@ -0,0 +1,20 @@ +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { reportCodexHookTrustWriteRefusals } from './codex-config-toml-checked-edit' + +// Why: a refused trust write recurs every launch until the user fixes the file; launch prep must not re-log it. +const reportedRefusalStatuses = new WeakSet() + +/** Returns `status` for trust entries that could not be written, logging a refused write once per file. */ +export function reportCodexHookTrustWriteFailure( + error: unknown, + status: AgentHookInstallStatus +): AgentHookInstallStatus { + if (reportCodexHookTrustWriteRefusals(error).length === 0) { + reportedRefusalStatuses.add(status) + } + return status +} + +export function isReportedCodexHookTrustWriteRefusal(status: AgentHookInstallStatus): boolean { + return reportedRefusalStatuses.has(status) +} diff --git a/src/main/codex/config-toml-hook-trust-edit.ts b/src/main/codex/config-toml-hook-trust-edit.ts index 73702be1e169..d9fd8802870d 100644 --- a/src/main/codex/config-toml-hook-trust-edit.ts +++ b/src/main/codex/config-toml-hook-trust-edit.ts @@ -25,6 +25,10 @@ export function upsertHookTrustContent( existingContent: string, entries: readonly CodexTrustEntry[] ): string { + // Why: nothing to write, so a config Codex cannot parse must not turn into a refusal. + if (entries.length === 0) { + return existingContent + } const existing = stripLeadingBom(existingContent) const writes = entries.map((entry) => ({ keys: getTrustKeyWriteVariants(computeCodexTrustKey(entry)), @@ -97,6 +101,10 @@ export function setHookTrustEnabledContent( states: readonly { key: string; enabled: boolean }[] ): string { const existing = stripLeadingBom(existingContent) + const keys = new Set(states.map((state) => normalizeCodexHookTrustLookupKey(state.key))) + if (findHookTrustBlockRanges(existing, keys).length === 0) { + return existingContent + } const updated = applyCheckedCodexConfigTomlEdit(existing, (content) => { let next = content for (const { key, enabled } of states) { diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index 027f961d0937..6a1751cc0cdb 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -114,7 +114,7 @@ export function upsertHookTrustEntries( entries: readonly CodexTrustEntry[] ): void { const existing = readTomlForMutation(configPath) - const updated = withTrustEdit(configPath, () => + const updated = withTrustEdit(configPath, existing, () => upsertHookTrustEntriesInContent(existing, entries) ) if (updated !== existing) { @@ -138,7 +138,9 @@ export function setHookTrustEnabledStates( return } const existing = readTomlFile(configPath) - const updated = withTrustEdit(configPath, () => setHookTrustEnabledContent(existing, states)) + const updated = withTrustEdit(configPath, existing, () => + setHookTrustEnabledContent(existing, states) + ) if (updated !== existing) { writeTomlConfigAtomically(configPath, updated) } @@ -150,7 +152,7 @@ export function upsertProjectTrustLevel( trustLevel: CodexProjectTrustLevel ): void { const existing = readTomlForMutation(configPath) - const updated = withTrustEdit(configPath, () => + const updated = withTrustEdit(configPath, existing, () => upsertProjectTrustLevelInContent(existing, projectPath, trustLevel) ) if (updated !== existing) { @@ -201,10 +203,12 @@ function withConfigPath(configPath: string, run: () => T): T { } } -// Why: a trust edit that succeeds ends the refusal, so a later one is reported again. -function withTrustEdit(configPath: string, run: () => T): T { +// Why: a trust write that lands ends the refusal, so a later one is reported again; a no-op proves nothing. +function withTrustEdit(configPath: string, existing: string, run: () => string): string { const result = withConfigPath(configPath, run) - clearCodexTrustWriteRefusalReport(configPath) + if (result !== existing) { + clearCodexTrustWriteRefusalReport(configPath) + } return result } @@ -213,7 +217,9 @@ export function removeHookTrustEntries(configPath: string, keys: readonly string return } const existing = readTomlFile(configPath) - const updated = withTrustEdit(configPath, () => removeHookTrustEntriesFromContent(existing, keys)) + const updated = withTrustEdit(configPath, existing, () => + removeHookTrustEntriesFromContent(existing, keys) + ) if (updated !== existing) { writeTomlConfigAtomically(configPath, updated) } diff --git a/src/main/codex/hook-service-user-hook-mirroring.test.ts b/src/main/codex/hook-service-user-hook-mirroring.test.ts index f13953a60514..2ab5f0967e49 100644 --- a/src/main/codex/hook-service-user-hook-mirroring.test.ts +++ b/src/main/codex/hook-service-user-hook-mirroring.test.ts @@ -29,6 +29,7 @@ vi.mock('os', async (importOriginal) => { }) import { CodexHookService } from './hook-service' +import { isReportedCodexHookTrustWriteRefusal } from './codex-hook-trust-write-failure' const homes = setupCodexHookHomes(homedirMock, getPathMock) @@ -579,3 +580,41 @@ describe('CodexHookService', () => { expect(runtimeToml).not.toContain(':permission_request:0:0') }) }) + +describe('hook trust against a ~/.codex config the user broke by hand', () => { + function seedHandBrokenSystemConfig(): void { + const systemCodexHome = join(homes.tmpHome, '.codex') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + join(systemCodexHome, 'hooks.json'), + `${JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'user-hook' }] }] } })}\n`, + 'utf-8' + ) + writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "a"\nbroken = \n', 'utf-8') + } + + it('refreshes user hooks with no trust to write without reporting an error', async () => { + seedHandBrokenSystemConfig() + const status = await new CodexHookService().refreshRuntimeUserHooks() + expect(status.state).not.toBe('error') + }) + + it('logs a refused managed trust write once, not on every launch', async () => { + seedHandBrokenSystemConfig() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const service = new CodexHookService() + for (let launch = 0; launch < 3; launch++) { + const status = await service.install() + expect(status.state).toBe('error') + expect(isReportedCodexHookTrustWriteRefusal(status)).toBe(true) + } + const hookTrustReports = warn.mock.calls.filter(([message]) => + String(message).includes('Skipped writing Codex hook trust') + ) + expect(hookTrustReports).toHaveLength(1) + } finally { + warn.mockRestore() + } + }) +}) diff --git a/src/main/startup/codex-launch-preparation.ts b/src/main/startup/codex-launch-preparation.ts index 54a3020f1975..8b8b655c2da7 100644 --- a/src/main/startup/codex-launch-preparation.ts +++ b/src/main/startup/codex-launch-preparation.ts @@ -5,6 +5,7 @@ import { markCodexProjectTrusted } from '../agent-trust-presets' import { awaitAgentTrustWriteWithinDeadline } from '../agent-trust-write-deadline' import { reportCodexTrustWriteRefusals } from '../codex/codex-config-toml-checked-edit' import { codexHookService } from '../codex/hook-service' +import { isReportedCodexHookTrustWriteRefusal } from '../codex/codex-hook-trust-write-failure' import { getDefaultWslDistro } from '../wsl' import { isAgentStatusHooksEnabledForAgent } from '../agent-hooks/managed-agent-hook-controls' import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install' @@ -89,7 +90,7 @@ export async function prepareCodexRuntimeHomeForLaunch( hookTarget, hooksEnabled ) - if (status.state === 'error') { + if (status.state === 'error' && !isReportedCodexHookTrustWriteRefusal(status)) { console.warn( `[codex-hook-service] failed to ${hooksEnabled ? 'refresh' : 'refresh user'} runtime hooks before launch`, status.detail diff --git a/src/main/startup/codex-launch-trust-write-deadline.test.ts b/src/main/startup/codex-launch-trust-write-deadline.test.ts index c445c9157471..dd772decb114 100644 --- a/src/main/startup/codex-launch-trust-write-deadline.test.ts +++ b/src/main/startup/codex-launch-trust-write-deadline.test.ts @@ -15,7 +15,7 @@ const mocks = vi.hoisted(() => ({ isHostSystemDefaultRealHome: vi.fn(() => false), getHostCodexHomePathsForSessionDiscovery: vi.fn((): string[] => []), resolveSelectedHostAccountCodexHomePathForResume: vi.fn(() => null), - prepareRuntimeHomeForLaunch: vi.fn(async () => ({ state: 'ok' as const })), + prepareRuntimeHomeForLaunch: vi.fn(async (): Promise<{ state: string }> => ({ state: 'ok' })), installForLaunchPrep: vi.fn(async () => {}), refreshRuntimeUserHooksForLaunchPrep: vi.fn(async () => {}), ensureRealHomeCodexHookState: vi.fn(async () => {}), @@ -66,6 +66,8 @@ vi.mock('./main-process-state', () => ({ })) import { AGENT_TRUST_WRITE_DEADLINE_MS } from '../agent-trust-write-deadline' +import { CodexConfigTomlEditRefusedError } from '../codex/codex-config-toml-checked-edit' +import { reportCodexHookTrustWriteFailure } from '../codex/codex-hook-trust-write-failure' import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' @@ -204,4 +206,31 @@ describe('Codex launch-prep trust writes', () => { expect(preparation?.outcome).toBe('resume') expect(mocks.markCodexProjectTrusted).toHaveBeenCalledTimes(1) }) + + it('does not re-log a refused hook trust write that was already reported once', async () => { + const refused = reportCodexHookTrustWriteFailure( + new CodexConfigTomlEditRefusedError({ + reason: 'input-invalid', + detail: 'the file is not valid TOML', + configPath: '/tmp/launch-hook-trust-probe/config.toml' + }), + { + agent: 'codex', + state: 'error', + configPath: '/tmp/launch-hook-trust-probe/hooks.json', + managedHooksPresent: true, + detail: 'Hooks installed but trust entries could not be written' + } + ) + mocks.prepareRuntimeHomeForLaunch.mockResolvedValue(refused) + warn.mockClear() + for (let launch = 0; launch < 3; launch++) { + await prepareCodexRuntimeHomeForLaunch() + } + expect(warn).not.toHaveBeenCalled() + + mocks.prepareRuntimeHomeForLaunch.mockResolvedValue({ ...refused }) + await prepareCodexRuntimeHomeForLaunch() + expect(String(warn.mock.calls[0]?.[0])).toContain('[codex-hook-service]') + }) }) From 417e69dabc19de85aae0e4a987c1796934b2f383 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:27:49 -0700 Subject: [PATCH 13/25] fix(codex): report a remote trust refusal again after a remote trust write lands A refused Codex trust write is logged once per config file. The local writer forgets that report when a later trust write lands, so a new refusal is logged again; the SSH writer never did, so after one refusal every later refusal of that remote file stayed silent for the session. The remote writer now clears it the same way. --- src/main/remote-agent-trust-presets.test.ts | 29 +++++++++++++++++++++ src/main/remote-agent-trust-presets.ts | 7 ++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/main/remote-agent-trust-presets.test.ts b/src/main/remote-agent-trust-presets.test.ts index 3be4f10ac09d..974a0d69076d 100644 --- a/src/main/remote-agent-trust-presets.test.ts +++ b/src/main/remote-agent-trust-presets.test.ts @@ -14,6 +14,7 @@ vi.mock('./providers/ssh-filesystem-dispatch', () => ({ })) const { markRemoteAgentWorkspaceTrusted } = await import('./remote-agent-trust-presets') +const { reportCodexTrustWriteRefusals } = await import('./codex/codex-config-toml-checked-edit') function makeFsProvider(overrides: Record = {}) { return { @@ -249,6 +250,34 @@ describe('markRemoteAgentWorkspaceTrusted', () => { ).rejects.toThrow('/home/u/.codex/config.toml') expect(fsProvider.writeFile).not.toHaveBeenCalled() }) + + it('reports a remote trust refusal again after a remote trust write lands', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + let remoteConfig = '[a]\nx = 1\n[a]\ny = 2\n' + const fsProvider = makeFsProvider({ + readFile: vi.fn(async () => ({ content: remoteConfig, isBinary: false })) + }) + mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) + const attempt = () => + markRemoteAgentWorkspaceTrusted({ + preset: 'codex', + connectionId: 'ssh-1', + workspacePath: '/repo' + }).catch((error: unknown) => reportCodexTrustWriteRefusals(error)) + try { + await attempt() + await attempt() + expect(warn).toHaveBeenCalledTimes(1) + remoteConfig = 'model = "m"\n' + await attempt() + expect(fsProvider.writeFile).toHaveBeenCalledTimes(1) + remoteConfig = '[a]\nx = 1\n[a]\ny = 2\n' + await attempt() + expect(warn).toHaveBeenCalledTimes(2) + } finally { + warn.mockRestore() + } + }) }) describe('remote Qoder trust', () => { diff --git a/src/main/remote-agent-trust-presets.ts b/src/main/remote-agent-trust-presets.ts index fa403f93a9b4..f382e0941ff6 100644 --- a/src/main/remote-agent-trust-presets.ts +++ b/src/main/remote-agent-trust-presets.ts @@ -1,7 +1,10 @@ import { markRemoteQoderWorkspaceTrusted } from './qoder/workspace-trust' import type { AgentTrustPreset } from './agent-trust-presets' import { upsertProjectTrustLevelInContent } from './codex/config-toml-trust' -import { CodexConfigTomlEditRefusedError } from './codex/codex-config-toml-checked-edit' +import { + clearCodexTrustWriteRefusalReport, + CodexConfigTomlEditRefusedError +} from './codex/codex-config-toml-checked-edit' import { getActiveMultiplexer } from './ssh/ssh-target-registry' import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' import type { FileReadResult, IFilesystemProvider } from './providers/types' @@ -118,6 +121,8 @@ async function markRemoteCodexProjectTrusted( } await fsProvider.createDir(codexDir) await fsProvider.writeFile(configPath, updated) + // Why: same as the local trust write, a landed write ends the refusal so a later one is reported again. + clearCodexTrustWriteRefusalReport(configPath) } async function markRemoteCursorWorkspaceTrusted( From 1134881d111e71e2af8404e070701026d34d77a4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:27:55 -0700 Subject: [PATCH 14/25] fix(codex): keep managed trust for sub-table-only projects and re-report promotion stalls - A project that ~/.codex defines only through a sub-table, such as [projects."/a".extra], was treated as defined inline, so the mirror dropped the managed home's [projects."/a"] trust on every pass (Codex then asked to trust the folder again). Any header under projects."/a" now counts as a table definition, as before this PR. - A settings promotion stall was logged once and never forgotten, so a second stall after a successful promotion was silent. A promotion pass that does not stall now ends the report. --- ...dex-config-mirror-duplicate-repair.test.ts | 29 +++++++++++++++++++ src/main/codex/codex-config-mirror-merge.ts | 27 ++++++++++++++--- src/main/codex/config-settings-promotion.ts | 7 ++++- 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/main/codex/codex-config-mirror-duplicate-repair.test.ts b/src/main/codex/codex-config-mirror-duplicate-repair.test.ts index a44e3f871485..93636f6d8297 100644 --- a/src/main/codex/codex-config-mirror-duplicate-repair.test.ts +++ b/src/main/codex/codex-config-mirror-duplicate-repair.test.ts @@ -134,6 +134,27 @@ describe('managed-home mirror never writes a config Codex cannot read (#22592)', }) }) + it('keeps managed trust for a project ~/.codex defines only through a sub-table', () => { + writeFileSync( + getSystemConfigPath(), + 'model = "m"\n\n[projects."/repo".extra]\nx = 1\n', + 'utf-8' + ) + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + 'model = "m"\n\n[projects."/repo".extra]\nx = 1\n\n[projects."/repo"]\ntrust_level = "trusted"\n', + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + expect(parse(readFileSync(getRuntimeConfigPath(), 'utf-8'))).toEqual({ + model: 'm', + projects: { '/repo': { extra: { x: 1 }, trust_level: 'trusted' } } + }) + }) + it('collapses Orca\u2019s own duplicate hook tables, so managed-only trust survives', () => { writeFileSync(getSystemConfigPath(), 'model = "m"\n', 'utf-8') mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) @@ -256,6 +277,14 @@ describe('a ~/.codex the user broke by hand is mirrored as before', () => { mcp_servers: { mine: { command: 'm' } }, projects: { '/sub': { trust_level: 'trusted' } } }) + + // The promotion ended that stall, so the next one is reported again. + writeFileSync(rtPath, readFileSync(rtPath, 'utf8').replace('model = "B"', 'model = "C"')) + writeFileSync(sysPath, brokenSource.replace('model = "A"', 'model = "B"')) + syncSystemConfigIntoManagedCodexHome(homes) + expect( + warn.mock.calls.flat().filter((line) => String(line).includes('Skipped promoting')) + ).toHaveLength(2) } finally { warn.mockRestore() } diff --git a/src/main/codex/codex-config-mirror-merge.ts b/src/main/codex/codex-config-mirror-merge.ts index 260345a29b5c..ca0ecfc2cb3b 100644 --- a/src/main/codex/codex-config-mirror-merge.ts +++ b/src/main/codex/codex-config-mirror-merge.ts @@ -14,6 +14,8 @@ import { joinTomlBlocks, stripRuntimeOwnedTomlSections } from './config-toml-runtime-owned-sections' +import { parseTomlTableHeaderPath } from './config-toml-key-path' +import { mayNameTomlKeys, parseProjectTomlHeaderPath } from './config-toml-syntax' /** Ordinary settings from ~/.codex plus the trust and MCP tables the managed home owns. */ export function mergeSystemCodexConfigIntoRuntime( @@ -92,13 +94,30 @@ function getProjectsDefinedOutsideTables(config: string): ReadonlySet { return new Set() } const tableProjects = new Set( - getTomlSections(config) - .filter((section) => isRuntimeProjectTomlSection(section.header)) - .map((section) => getTomlSectionHeaderKey(section.header)) + getTomlSections(config).flatMap((section) => { + const projectPath = getProjectTablePath(section.header) + return projectPath === null ? [] : [projectKey(projectPath)] + }) ) return new Set( Object.keys(projects) - .map((projectPath) => `project:${normalizeCodexProjectPathForLookup(projectPath)}`) + .map(projectKey) .filter((key) => !tableProjects.has(key)) ) } + +function projectKey(projectPath: string): string { + return `project:${normalizeCodexProjectPathForLookup(projectPath)}` +} + +// Why: a sub-table header such as `[projects."/a".extra]` also defines `/a` as a table. +function getProjectTablePath(header: string): string | null { + const projectPath = parseProjectTomlHeaderPath(header) + if (projectPath !== null || !mayNameTomlKeys(header, ['projects'])) { + return projectPath + } + const table = parseTomlTableHeaderPath(header) + return table?.segments[0] === 'projects' && table.segments.length > 2 + ? (table.segments[1] ?? null) + : null +} diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts index 7c90c44092c1..9275e11349d0 100644 --- a/src/main/codex/config-settings-promotion.ts +++ b/src/main/codex/config-settings-promotion.ts @@ -8,6 +8,7 @@ import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-hom import { upsertPromotedSettingsInContent } from './codex-config-settings-upsert' import { applyCheckedCodexConfigTomlEdit, + clearCodexConfigTomlEditRefusalReport, CodexConfigTomlEditRefusedError, reportCodexConfigTomlEditRefusal } from './codex-config-toml-checked-edit' @@ -118,8 +119,12 @@ function getHostPromotionHomes(): CodexSettingsPromotionHomes { export function promoteCodexRuntimeSettingsToSystem( homes?: CodexSettingsPromotionHomes ): CodexSettingsPromotionPlan | null { + const resolvedHomes = homes ?? getHostPromotionHomes() try { - return promoteCodexRuntimeSettingsToSystemUnsafe(homes ?? getHostPromotionHomes()) + const plan = promoteCodexRuntimeSettingsToSystemUnsafe(resolvedHomes) + // Why: a pass that did not stall ends the stall, so a later one is reported again. + clearCodexConfigTomlEditRefusalReport(join(resolvedHomes.systemHomePath, 'config.toml')) + return plan } catch (error) { // Why: promotion is best-effort launch prep; a malformed file must not block Codex launch. // A refused edit was already reported once per file. From df27968c9944b3e3a7c176b67d7aa6e55cf6ab60 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:27:59 -0700 Subject: [PATCH 15/25] docs(codex): correct two stale comments about config sync and TOML parsing The sync status is derived from source-side checks only, so it can read as synced while a pass stalls on content (a refused settings promotion or an unparseable merge result); the comment claimed the two can never disagree. The line scanner comment still said Orca has no TOML dependency. --- src/main/codex/config-sync-stall.ts | 3 ++- src/main/codex/config-toml-line-scan.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/codex/config-sync-stall.ts b/src/main/codex/config-sync-stall.ts index 6971b8d62412..c5f7b9887a00 100644 --- a/src/main/codex/config-sync-stall.ts +++ b/src/main/codex/config-sync-stall.ts @@ -20,7 +20,8 @@ import type { * blank, which is silent by design — but the stall can persist for every launch * (a downed WSL distro, an unhydrated cloud-synced home), leaving "Orca ignores * my config edits" with nothing to diagnose. Derived on demand from the same - * predicates the mirror uses, so the two can never disagree. + * source predicates the mirror uses; a pass that stalls on content (a refused + * settings promotion or an unparseable merge result) still reads as synced here. */ export function getCodexConfigSyncStatus( homes: CodexSettingsPromotionHomes = { diff --git a/src/main/codex/config-toml-line-scan.ts b/src/main/codex/config-toml-line-scan.ts index 0e983aac2dde..71c05245d4da 100644 --- a/src/main/codex/config-toml-line-scan.ts +++ b/src/main/codex/config-toml-line-scan.ts @@ -1,4 +1,4 @@ -// Why: Orca edits Codex config.toml byte-preservingly (no TOML dependency), so +// Why: Orca edits Codex config.toml as text to preserve the user's bytes, 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. From 0b7eea8893bab8a6da020c7d9441665752f475ae Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:41:16 -0700 Subject: [PATCH 16/25] fix(codex): treat a sub-table project as table-defined only when no dotted key also sets it A project that ~/.codex defines through a sub-table such as [projects."/a".extra] and also through dotted keys ([projects] with "/a".trust_level = ..., or root projects."/a".trust_level = ...) was counted as table-defined, so the mirror kept the managed home's [projects."/a"] table, the merged file redefined /a, and every mirror pass was refused. A project now counts as table-defined only when it has its own [projects."/a"] header or every key under it comes from a sub-table header. --- ...dex-config-mirror-duplicate-repair.test.ts | 24 +++++++++ src/main/codex/codex-config-mirror-merge.ts | 51 +++++++++++-------- 2 files changed, 54 insertions(+), 21 deletions(-) diff --git a/src/main/codex/codex-config-mirror-duplicate-repair.test.ts b/src/main/codex/codex-config-mirror-duplicate-repair.test.ts index 93636f6d8297..eca66eb8bc6d 100644 --- a/src/main/codex/codex-config-mirror-duplicate-repair.test.ts +++ b/src/main/codex/codex-config-mirror-duplicate-repair.test.ts @@ -155,6 +155,30 @@ describe('managed-home mirror never writes a config Codex cannot read (#22592)', }) }) + it.each([ + ['dotted keys under [projects]', '[projects]\n"/repo".trust_level = "untrusted"\n'], + ['root dotted keys', 'projects."/repo".trust_level = "untrusted"\n'] + ])( + 'drops managed trust for a project ~/.codex defines through a sub-table and %s', + (_shape, dotted) => { + const systemConfig = `model = "m"\n${dotted}\n[projects."/repo".extra]\nx = 1\n` + writeFileSync(getSystemConfigPath(), systemConfig, 'utf-8') + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + 'model = "m"\n\n[projects."/repo"]\ntrust_level = "trusted"\n', + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + expect(parse(readFileSync(getRuntimeConfigPath(), 'utf-8'))).toEqual({ + model: 'm', + projects: { '/repo': { trust_level: 'untrusted', extra: { x: 1 } } } + }) + } + ) + it('collapses Orca\u2019s own duplicate hook tables, so managed-only trust survives', () => { writeFileSync(getSystemConfigPath(), 'model = "m"\n', 'utf-8') mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) diff --git a/src/main/codex/codex-config-mirror-merge.ts b/src/main/codex/codex-config-mirror-merge.ts index ca0ecfc2cb3b..082394b1113a 100644 --- a/src/main/codex/codex-config-mirror-merge.ts +++ b/src/main/codex/codex-config-mirror-merge.ts @@ -93,31 +93,40 @@ function getProjectsDefinedOutsideTables(config: string): ReadonlySet { if (!projects) { return new Set() } - const tableProjects = new Set( - getTomlSections(config).flatMap((section) => { - const projectPath = getProjectTablePath(section.header) - return projectPath === null ? [] : [projectKey(projectPath)] - }) - ) + const headerProjects = new Set() + const subTableKeys = new Map>() + for (const { header } of getTomlSections(config)) { + if (!mayNameTomlKeys(header, ['projects'])) { + continue + } + const projectPath = parseProjectTomlHeaderPath(header) + if (projectPath !== null) { + headerProjects.add(projectKey(projectPath)) + continue + } + const [root, subProjectPath, subKey] = parseTomlTableHeaderPath(header)?.segments ?? [] + if (root === 'projects' && subProjectPath !== undefined && subKey !== undefined) { + const key = projectKey(subProjectPath) + subTableKeys.set(key, (subTableKeys.get(key) ?? new Set()).add(subKey)) + } + } + // Why: a sub-table header such as `[projects."/a".extra]` defines `/a` as a + // table only when no dotted or inline key also sets a value under `/a`. return new Set( - Object.keys(projects) - .map(projectKey) - .filter((key) => !tableProjects.has(key)) + Object.entries(projects) + .filter(([projectPath, value]) => { + const key = projectKey(projectPath) + const project = getTomlTable(value) + const subKeys = subTableKeys.get(key) + return ( + !headerProjects.has(key) && + !(project && subKeys && Object.keys(project).every((name) => subKeys.has(name))) + ) + }) + .map(([projectPath]) => projectKey(projectPath)) ) } function projectKey(projectPath: string): string { return `project:${normalizeCodexProjectPathForLookup(projectPath)}` } - -// Why: a sub-table header such as `[projects."/a".extra]` also defines `/a` as a table. -function getProjectTablePath(header: string): string | null { - const projectPath = parseProjectTomlHeaderPath(header) - if (projectPath !== null || !mayNameTomlKeys(header, ['projects'])) { - return projectPath - } - const table = parseTomlTableHeaderPath(header) - return table?.segments[0] === 'projects' && table.segments.length > 2 - ? (table.segments[1] ?? null) - : null -} From c227bc754db65e4cce4317820d5015e2c4440502 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:49:03 -0700 Subject: [PATCH 17/25] refactor(codex): log each Codex config refusal once per session and never clear The refusal logging grew per-file, per-kind bookkeeping with clear calls in promotion, the mirror, local and remote trust writes, and a WeakSet of hook statuses in launch prep; three review-loop bugs came from its clearing rules. Replace it with one rule: each distinct (config path, message) logs once per app session, in a bounded set. Launch prep logs a hook status error through the same call instead of checking a WeakSet; hook status values are unchanged. Refusals are still contained exactly as before. --- ...dex-config-mirror-duplicate-repair.test.ts | 8 --- src/main/codex/codex-config-mirror.ts | 6 +- .../codex-config-toml-checked-edit.test.ts | 72 +++---------------- .../codex/codex-config-toml-checked-edit.ts | 68 ++++++------------ src/main/codex/codex-hook-local-install.ts | 5 +- .../codex/codex-hook-local-maintenance.ts | 5 +- .../codex/codex-hook-trust-write-failure.ts | 20 ------ src/main/codex/config-settings-promotion.ts | 9 +-- src/main/codex/config-toml-trust.ts | 20 ++---- .../hook-service-user-hook-mirroring.test.ts | 21 ++---- src/main/remote-agent-trust-presets.test.ts | 11 +-- src/main/remote-agent-trust-presets.ts | 7 +- src/main/startup/codex-launch-preparation.ts | 15 ++-- .../codex-launch-trust-write-deadline.test.ts | 40 +++++------ 14 files changed, 77 insertions(+), 230 deletions(-) delete mode 100644 src/main/codex/codex-hook-trust-write-failure.ts diff --git a/src/main/codex/codex-config-mirror-duplicate-repair.test.ts b/src/main/codex/codex-config-mirror-duplicate-repair.test.ts index eca66eb8bc6d..85b253a79aec 100644 --- a/src/main/codex/codex-config-mirror-duplicate-repair.test.ts +++ b/src/main/codex/codex-config-mirror-duplicate-repair.test.ts @@ -301,14 +301,6 @@ describe('a ~/.codex the user broke by hand is mirrored as before', () => { mcp_servers: { mine: { command: 'm' } }, projects: { '/sub': { trust_level: 'trusted' } } }) - - // The promotion ended that stall, so the next one is reported again. - writeFileSync(rtPath, readFileSync(rtPath, 'utf8').replace('model = "B"', 'model = "C"')) - writeFileSync(sysPath, brokenSource.replace('model = "A"', 'model = "B"')) - syncSystemConfigIntoManagedCodexHome(homes) - expect( - warn.mock.calls.flat().filter((line) => String(line).includes('Skipped promoting')) - ).toHaveLength(2) } finally { warn.mockRestore() } diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 6ea79fabe59c..eaf506649a1e 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -13,10 +13,7 @@ import { readCodexSettingsBaseline } from './config-settings-baseline' import { getCodexConfigSyncStatus, reportCodexConfigSyncOutcome } from './config-sync-stall' import { preserveRuntimeConflictValues } from './codex-config-settings-preservation' import { applyCodexDaemonSocketGuard } from './codex-daemon-socket-path-guard' -import { - clearCodexConfigTomlEditRefusalReport, - refuseUnreadableCodexConfigResult -} from './codex-config-toml-checked-edit' +import { refuseUnreadableCodexConfigResult } from './codex-config-toml-checked-edit' import { mergeSystemCodexConfigIntoRuntime } from './codex-config-mirror-merge' import { prepareSystemConfigForFreshRuntimeMirror, @@ -250,7 +247,6 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe( } writeFileAtomically(runtimeConfigPath, nextRuntimeConfig) } - clearCodexConfigTomlEditRefusalReport(runtimeConfigPath) return { status: 'mirrored', preservedConflictKeys: preserved.keys, diff --git a/src/main/codex/codex-config-toml-checked-edit.test.ts b/src/main/codex/codex-config-toml-checked-edit.test.ts index cf64296abdc4..613fd68f6bd9 100644 --- a/src/main/codex/codex-config-toml-checked-edit.test.ts +++ b/src/main/codex/codex-config-toml-checked-edit.test.ts @@ -1,23 +1,16 @@ import { describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { parse } from 'smol-toml' import { readHookTrustEntriesFromContent, removeHookTrustEntriesFromContent, - upsertHookTrustEntries, upsertHookTrustEntriesInContent, - upsertProjectTrustLevel, upsertProjectTrustLevelInContent, type CodexTrustEntry } from './config-toml-trust' import { - clearCodexConfigTomlEditRefusalReport, CodexConfigTomlEditRefusedError, refuseUnreadableCodexConfigResult, reportCodexConfigTomlEditRefusal, - reportCodexHookTrustWriteRefusals, reportCodexTrustWriteRefusals } from './codex-config-toml-checked-edit' import { getTomlTable, readTomlValueAtPath } from './codex-config-toml-document' @@ -323,7 +316,7 @@ describe('review follow-ups for the checked writer', () => { }) }) -describe('refused trust writes are reported once per file', () => { +describe('refused Codex config writes', () => { it('logs each refusal once, including inside an AggregateError, and returns other failures', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { @@ -343,19 +336,17 @@ describe('refused trust writes are reported once per file', () => { warn.mockRestore() } }) -}) -describe('Codex config reports about one file log once per kind', () => { - it('does not re-log interleaved trust, promotion, and mirror reports on every launch', () => { + it('logs each distinct refusal once across launches and a new message again', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const dir = mkdtempSync(join(tmpdir(), 'codex-report-once-')) try { - const systemPath = join(dir, 'system.toml') - const runtimePath = join(dir, 'runtime.toml') - const refusal = (configPath: string) => + const systemPath = '/tmp/report-once-launches/system.toml' + const runtimePath = '/tmp/report-once-launches/runtime.toml' + const refusal = (configPath: string, line = 2) => new CodexConfigTomlEditRefusedError({ reason: 'input-invalid', detail: 'the file is not valid TOML', + line, configPath }) for (let launch = 0; launch < 4; launch++) { @@ -363,32 +354,13 @@ describe('Codex config reports about one file log once per kind', () => { new AggregateError([refusal(systemPath), refusal(runtimePath)], 'trust write failed') ) reportCodexConfigTomlEditRefusal(refusal(systemPath), 'Skipped promoting Codex settings') - reportCodexConfigTomlEditRefusal( - refusal(runtimePath), - 'Skipped writing a managed Codex config' - ) } - const trustReports = () => - warn.mock.calls.filter(([message]) => String(message).includes('trusted for Codex')) - expect(trustReports()).toHaveLength(2) - expect(warn).toHaveBeenCalledTimes(4) + expect(warn).toHaveBeenCalledTimes(3) - // A mirror pass that succeeds forgets mirror reports, not a trust refusal that still recurs. - clearCodexConfigTomlEditRefusalReport(runtimePath) - reportCodexTrustWriteRefusals(refusal(runtimePath)) - reportCodexConfigTomlEditRefusal( - refusal(runtimePath), - 'Skipped writing a managed Codex config' - ) - expect(trustReports()).toHaveLength(2) - expect(warn).toHaveBeenCalledTimes(5) - - writeFileSync(systemPath, 'model = "a"\n') - upsertProjectTrustLevel(systemPath, '/work/repo', 'trusted') - reportCodexTrustWriteRefusals(refusal(systemPath)) - expect(trustReports()).toHaveLength(3) + reportCodexTrustWriteRefusals(refusal(systemPath, 7)) + expect(warn).toHaveBeenCalledTimes(4) + expect(String(warn.mock.calls[3]?.[0])).toContain('(line 7)') } finally { - rmSync(dir, { recursive: true, force: true }) warn.mockRestore() } }) @@ -403,30 +375,6 @@ describe('a hook trust edit with nothing to write', () => { setHookTrustEnabledContent(handBroken, [{ key: '/rt/hooks.json:stop:0:0', enabled: false }]) ).toBe(handBroken) }) - - it('does not end a trust refusal it never proved resolved', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const dir = mkdtempSync(join(tmpdir(), 'codex-noop-trust-')) - try { - const configPath = join(dir, 'config.toml') - writeFileSync(configPath, handBroken) - const refusal = new CodexConfigTomlEditRefusedError({ - reason: 'input-invalid', - detail: 'the file is not valid TOML', - configPath - }) - for (let launch = 0; launch < 3; launch++) { - reportCodexTrustWriteRefusals(refusal) - reportCodexHookTrustWriteRefusals(refusal) - upsertHookTrustEntries(configPath, []) - clearCodexConfigTomlEditRefusalReport(configPath) - } - expect(warn).toHaveBeenCalledTimes(2) - } finally { - rmSync(dir, { recursive: true, force: true }) - warn.mockRestore() - } - }) }) describe('a whole-document mirror result', () => { diff --git a/src/main/codex/codex-config-toml-checked-edit.ts b/src/main/codex/codex-config-toml-checked-edit.ts index 2a877835135f..cbed444e64c1 100644 --- a/src/main/codex/codex-config-toml-checked-edit.ts +++ b/src/main/codex/codex-config-toml-checked-edit.ts @@ -187,7 +187,6 @@ export function refuseUnreadableCodexConfigResult(args: { (input) => input !== null && !parseCodexConfigToml(repairUnparseableCodexConfig(input)).ok ) ) { - clearCodexConfigTomlEditRefusalReport(args.configPath) return false } reportCodexConfigTomlEditRefusal( @@ -202,71 +201,44 @@ export function refuseUnreadableCodexConfigResult(args: { return true } -// Keyed by file, then by what Orca was doing, so interleaved reports about one file each log once. -const reportedMessages = new Map>() const TRUST_WRITE_CONTEXT = 'Skipped marking a workspace trusted for Codex' -const HOOK_TRUST_WRITE_CONTEXT = 'Skipped writing Codex hook trust' -const TRUST_WRITE_KINDS: ReadonlySet = new Set([ - TRUST_WRITE_CONTEXT, - HOOK_TRUST_WRITE_CONTEXT -]) +const MAX_REPORTED_WARNINGS = 500 +const reportedWarnings = new Set() -/** Logs once per file, kind, and message, so a launch-time retry does not flood the log. */ -function reportCodexConfigOnce(key: string, message: string, kind = ''): void { - let byKind = reportedMessages.get(key) - if (!byKind) { - byKind = new Map() - reportedMessages.set(key, byKind) - } - if (byKind.get(kind) === message) { +/** + * Why: a refused write recurs on every launch and mirror pass until the user + * edits the file, so each distinct message about a file logs once per session. + */ +export function warnCodexConfigOnce(configPath: string, message: string): void { + const key = `${configPath}\n${message}` + if (reportedWarnings.has(key)) { return } - byKind.set(kind, message) - console.warn(`[codex-config] ${message}`) + if (reportedWarnings.size >= MAX_REPORTED_WARNINGS) { + const oldest = reportedWarnings.values().next() + if (!oldest.done) { + reportedWarnings.delete(oldest.value) + } + } + reportedWarnings.add(key) + console.warn(message) } export function reportCodexConfigTomlEditRefusal( error: CodexConfigTomlEditRefusedError, context: string ): void { - reportCodexConfigOnce(error.configPath ?? context, `${context}: ${error.message}`, context) + warnCodexConfigOnce(error.configPath ?? context, `[codex-config] ${context}: ${error.message}`) } -/** - * Why: a refused trust write recurs on every launch until the user fixes the - * file, so it is reported once per file; returns the other failures for the caller. - */ +/** Logs each refused trust write once and returns the other failures for the caller. */ export function reportCodexTrustWriteRefusals(error: unknown): unknown[] { - return reportTrustWriteRefusals(error, TRUST_WRITE_CONTEXT) -} - -export function reportCodexHookTrustWriteRefusals(error: unknown): unknown[] { - return reportTrustWriteRefusals(error, HOOK_TRUST_WRITE_CONTEXT) -} - -function reportTrustWriteRefusals(error: unknown, context: string): unknown[] { const failures = error instanceof AggregateError ? error.errors : [error] return failures.filter((failure) => { if (!(failure instanceof CodexConfigTomlEditRefusedError)) { return true } - reportCodexConfigTomlEditRefusal(failure, context) + reportCodexConfigTomlEditRefusal(failure, TRUST_WRITE_CONTEXT) return false }) } - -/** Forgets mirror/write reports for a file; a trust refusal is only forgotten by a trust write that succeeds. */ -export function clearCodexConfigTomlEditRefusalReport(configPath: string): void { - const byKind = reportedMessages.get(configPath) - for (const kind of byKind?.keys() ?? []) { - if (!TRUST_WRITE_KINDS.has(kind)) { - byKind?.delete(kind) - } - } -} - -export function clearCodexTrustWriteRefusalReport(configPath: string): void { - for (const kind of TRUST_WRITE_KINDS) { - reportedMessages.get(configPath)?.delete(kind) - } -} diff --git a/src/main/codex/codex-hook-local-install.ts b/src/main/codex/codex-hook-local-install.ts index 6d793a64d4ce..be3582d3879e 100644 --- a/src/main/codex/codex-hook-local-install.ts +++ b/src/main/codex/codex-hook-local-install.ts @@ -28,7 +28,6 @@ import { cleanupLegacyManagedHookRepresentations } from './codex-hook-legacy-cle import { getManagedScript } from './codex-hook-script' import { grantManagedCodexHookTrust } from './codex-hook-trust-grant' import { removeStaleRuntimeHookTrustEntries } from './codex-hook-trust-cleanup' -import { reportCodexHookTrustWriteFailure } from './codex-hook-trust-write-failure' import { promoteCodexRuntimeHookApprovalsToSystem, snapshotCodexRuntimeHookTrustProvenance @@ -172,13 +171,13 @@ export async function installCodexHooksExclusively( } applyMirroredRuntimeUserHookTrustStates(tomlPath, mirroredUserTrustEntries) } catch (error) { - return reportCodexHookTrustWriteFailure(error, { + return { agent: 'codex', state: 'error', configPath, managedHooksPresent: true, detail: `Hooks installed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.` - }) + } } snapshotCodexRuntimeHookTrustProvenance(runtimeHomePath) await cleanupLegacyManagedHookRepresentations() diff --git a/src/main/codex/codex-hook-local-maintenance.ts b/src/main/codex/codex-hook-local-maintenance.ts index c082eedcd47f..8b45049e5160 100644 --- a/src/main/codex/codex-hook-local-maintenance.ts +++ b/src/main/codex/codex-hook-local-maintenance.ts @@ -14,7 +14,6 @@ import { removeRuntimeManagedHookTrustEntries, removeStaleRuntimeHookTrustEntries } from './codex-hook-trust-cleanup' -import { reportCodexHookTrustWriteFailure } from './codex-hook-trust-write-failure' import { applyMirroredRuntimeUserHookTrustStates, getRuntimeHooksWithSystemUserHooks @@ -75,13 +74,13 @@ export async function refreshCodexRuntimeUserHooksExclusively( removeStaleRuntimeHookTrustEntries(tomlPath, configPath, trustEntries) applyMirroredRuntimeUserHookTrustStates(tomlPath, hookPlan.trustEntries) } catch (error) { - return reportCodexHookTrustWriteFailure(error, { + return { agent: 'codex', state: 'error', configPath, managedHooksPresent: false, detail: `User hooks refreshed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.` - }) + } } snapshotCodexRuntimeHookTrustProvenance(runtimeHomePath) diff --git a/src/main/codex/codex-hook-trust-write-failure.ts b/src/main/codex/codex-hook-trust-write-failure.ts deleted file mode 100644 index 55396eaeb069..000000000000 --- a/src/main/codex/codex-hook-trust-write-failure.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' -import { reportCodexHookTrustWriteRefusals } from './codex-config-toml-checked-edit' - -// Why: a refused trust write recurs every launch until the user fixes the file; launch prep must not re-log it. -const reportedRefusalStatuses = new WeakSet() - -/** Returns `status` for trust entries that could not be written, logging a refused write once per file. */ -export function reportCodexHookTrustWriteFailure( - error: unknown, - status: AgentHookInstallStatus -): AgentHookInstallStatus { - if (reportCodexHookTrustWriteRefusals(error).length === 0) { - reportedRefusalStatuses.add(status) - } - return status -} - -export function isReportedCodexHookTrustWriteRefusal(status: AgentHookInstallStatus): boolean { - return reportedRefusalStatuses.has(status) -} diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts index 9275e11349d0..84ea9c4e3474 100644 --- a/src/main/codex/config-settings-promotion.ts +++ b/src/main/codex/config-settings-promotion.ts @@ -8,7 +8,6 @@ import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-hom import { upsertPromotedSettingsInContent } from './codex-config-settings-upsert' import { applyCheckedCodexConfigTomlEdit, - clearCodexConfigTomlEditRefusalReport, CodexConfigTomlEditRefusedError, reportCodexConfigTomlEditRefusal } from './codex-config-toml-checked-edit' @@ -119,15 +118,11 @@ function getHostPromotionHomes(): CodexSettingsPromotionHomes { export function promoteCodexRuntimeSettingsToSystem( homes?: CodexSettingsPromotionHomes ): CodexSettingsPromotionPlan | null { - const resolvedHomes = homes ?? getHostPromotionHomes() try { - const plan = promoteCodexRuntimeSettingsToSystemUnsafe(resolvedHomes) - // Why: a pass that did not stall ends the stall, so a later one is reported again. - clearCodexConfigTomlEditRefusalReport(join(resolvedHomes.systemHomePath, 'config.toml')) - return plan + return promoteCodexRuntimeSettingsToSystemUnsafe(homes ?? getHostPromotionHomes()) } catch (error) { // Why: promotion is best-effort launch prep; a malformed file must not block Codex launch. - // A refused edit was already reported once per file. + // A refused edit was already reported. if (!(error instanceof CodexConfigTomlEditRefusedError)) { console.warn('[codex-settings-promotion] failed to promote runtime settings', error) } diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index 6a1751cc0cdb..e8632b623ae4 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -23,7 +23,6 @@ import { observe } from './codex-path-observation' import { parseCodexConfigToml } from './codex-config-toml-document' import { assertCodexConfigTomlParses, - clearCodexTrustWriteRefusalReport, CodexConfigTomlEditRefusedError } from './codex-config-toml-checked-edit' @@ -114,7 +113,7 @@ export function upsertHookTrustEntries( entries: readonly CodexTrustEntry[] ): void { const existing = readTomlForMutation(configPath) - const updated = withTrustEdit(configPath, existing, () => + const updated = withConfigPath(configPath, () => upsertHookTrustEntriesInContent(existing, entries) ) if (updated !== existing) { @@ -138,9 +137,7 @@ export function setHookTrustEnabledStates( return } const existing = readTomlFile(configPath) - const updated = withTrustEdit(configPath, existing, () => - setHookTrustEnabledContent(existing, states) - ) + const updated = withConfigPath(configPath, () => setHookTrustEnabledContent(existing, states)) if (updated !== existing) { writeTomlConfigAtomically(configPath, updated) } @@ -152,7 +149,7 @@ export function upsertProjectTrustLevel( trustLevel: CodexProjectTrustLevel ): void { const existing = readTomlForMutation(configPath) - const updated = withTrustEdit(configPath, existing, () => + const updated = withConfigPath(configPath, () => upsertProjectTrustLevelInContent(existing, projectPath, trustLevel) ) if (updated !== existing) { @@ -203,21 +200,12 @@ function withConfigPath(configPath: string, run: () => T): T { } } -// Why: a trust write that lands ends the refusal, so a later one is reported again; a no-op proves nothing. -function withTrustEdit(configPath: string, existing: string, run: () => string): string { - const result = withConfigPath(configPath, run) - if (result !== existing) { - clearCodexTrustWriteRefusalReport(configPath) - } - return result -} - export function removeHookTrustEntries(configPath: string, keys: readonly string[]): void { if (!existsSync(configPath)) { return } const existing = readTomlFile(configPath) - const updated = withTrustEdit(configPath, existing, () => + const updated = withConfigPath(configPath, () => removeHookTrustEntriesFromContent(existing, keys) ) if (updated !== existing) { diff --git a/src/main/codex/hook-service-user-hook-mirroring.test.ts b/src/main/codex/hook-service-user-hook-mirroring.test.ts index 2ab5f0967e49..2f0d8c1df1a7 100644 --- a/src/main/codex/hook-service-user-hook-mirroring.test.ts +++ b/src/main/codex/hook-service-user-hook-mirroring.test.ts @@ -29,7 +29,6 @@ vi.mock('os', async (importOriginal) => { }) import { CodexHookService } from './hook-service' -import { isReportedCodexHookTrustWriteRefusal } from './codex-hook-trust-write-failure' const homes = setupCodexHookHomes(homedirMock, getPathMock) @@ -599,22 +598,10 @@ describe('hook trust against a ~/.codex config the user broke by hand', () => { expect(status.state).not.toBe('error') }) - it('logs a refused managed trust write once, not on every launch', async () => { + it('reports a refused managed trust write in the install status', async () => { seedHandBrokenSystemConfig() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - const service = new CodexHookService() - for (let launch = 0; launch < 3; launch++) { - const status = await service.install() - expect(status.state).toBe('error') - expect(isReportedCodexHookTrustWriteRefusal(status)).toBe(true) - } - const hookTrustReports = warn.mock.calls.filter(([message]) => - String(message).includes('Skipped writing Codex hook trust') - ) - expect(hookTrustReports).toHaveLength(1) - } finally { - warn.mockRestore() - } + const status = await new CodexHookService().install() + expect(status.state).toBe('error') + expect(status.detail).toContain('config.toml unchanged') }) }) diff --git a/src/main/remote-agent-trust-presets.test.ts b/src/main/remote-agent-trust-presets.test.ts index 974a0d69076d..8cee09957b51 100644 --- a/src/main/remote-agent-trust-presets.test.ts +++ b/src/main/remote-agent-trust-presets.test.ts @@ -251,9 +251,9 @@ describe('markRemoteAgentWorkspaceTrusted', () => { expect(fsProvider.writeFile).not.toHaveBeenCalled() }) - it('reports a remote trust refusal again after a remote trust write lands', async () => { + it('logs a recurring remote trust refusal once', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - let remoteConfig = '[a]\nx = 1\n[a]\ny = 2\n' + const remoteConfig = '[a]\nx = 1\n[a]\ny = 2\n' const fsProvider = makeFsProvider({ readFile: vi.fn(async () => ({ content: remoteConfig, isBinary: false })) }) @@ -268,12 +268,7 @@ describe('markRemoteAgentWorkspaceTrusted', () => { await attempt() await attempt() expect(warn).toHaveBeenCalledTimes(1) - remoteConfig = 'model = "m"\n' - await attempt() - expect(fsProvider.writeFile).toHaveBeenCalledTimes(1) - remoteConfig = '[a]\nx = 1\n[a]\ny = 2\n' - await attempt() - expect(warn).toHaveBeenCalledTimes(2) + expect(String(warn.mock.calls[0]?.[0])).toContain('/home/u/.codex/config.toml') } finally { warn.mockRestore() } diff --git a/src/main/remote-agent-trust-presets.ts b/src/main/remote-agent-trust-presets.ts index f382e0941ff6..fa403f93a9b4 100644 --- a/src/main/remote-agent-trust-presets.ts +++ b/src/main/remote-agent-trust-presets.ts @@ -1,10 +1,7 @@ import { markRemoteQoderWorkspaceTrusted } from './qoder/workspace-trust' import type { AgentTrustPreset } from './agent-trust-presets' import { upsertProjectTrustLevelInContent } from './codex/config-toml-trust' -import { - clearCodexTrustWriteRefusalReport, - CodexConfigTomlEditRefusedError -} from './codex/codex-config-toml-checked-edit' +import { CodexConfigTomlEditRefusedError } from './codex/codex-config-toml-checked-edit' import { getActiveMultiplexer } from './ssh/ssh-target-registry' import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' import type { FileReadResult, IFilesystemProvider } from './providers/types' @@ -121,8 +118,6 @@ async function markRemoteCodexProjectTrusted( } await fsProvider.createDir(codexDir) await fsProvider.writeFile(configPath, updated) - // Why: same as the local trust write, a landed write ends the refusal so a later one is reported again. - clearCodexTrustWriteRefusalReport(configPath) } async function markRemoteCursorWorkspaceTrusted( diff --git a/src/main/startup/codex-launch-preparation.ts b/src/main/startup/codex-launch-preparation.ts index 8b8b655c2da7..51bd238c2354 100644 --- a/src/main/startup/codex-launch-preparation.ts +++ b/src/main/startup/codex-launch-preparation.ts @@ -3,9 +3,11 @@ import type { CodexHomeLaunchContext } from '../ipc/pty' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import { markCodexProjectTrusted } from '../agent-trust-presets' import { awaitAgentTrustWriteWithinDeadline } from '../agent-trust-write-deadline' -import { reportCodexTrustWriteRefusals } from '../codex/codex-config-toml-checked-edit' +import { + reportCodexTrustWriteRefusals, + warnCodexConfigOnce +} from '../codex/codex-config-toml-checked-edit' import { codexHookService } from '../codex/hook-service' -import { isReportedCodexHookTrustWriteRefusal } from '../codex/codex-hook-trust-write-failure' import { getDefaultWslDistro } from '../wsl' import { isAgentStatusHooksEnabledForAgent } from '../agent-hooks/managed-agent-hook-controls' import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install' @@ -90,10 +92,11 @@ export async function prepareCodexRuntimeHomeForLaunch( hookTarget, hooksEnabled ) - if (status.state === 'error' && !isReportedCodexHookTrustWriteRefusal(status)) { - console.warn( - `[codex-hook-service] failed to ${hooksEnabled ? 'refresh' : 'refresh user'} runtime hooks before launch`, - status.detail + if (status.state === 'error') { + // Why: a refused trust write returns the same detail on every launch until the user fixes the file. + warnCodexConfigOnce( + status.configPath, + `[codex-hook-service] failed to ${hooksEnabled ? 'refresh' : 'refresh user'} runtime hooks before launch: ${status.detail}` ) } } catch (error) { diff --git a/src/main/startup/codex-launch-trust-write-deadline.test.ts b/src/main/startup/codex-launch-trust-write-deadline.test.ts index dd772decb114..0ca70749c8d9 100644 --- a/src/main/startup/codex-launch-trust-write-deadline.test.ts +++ b/src/main/startup/codex-launch-trust-write-deadline.test.ts @@ -15,7 +15,9 @@ const mocks = vi.hoisted(() => ({ isHostSystemDefaultRealHome: vi.fn(() => false), getHostCodexHomePathsForSessionDiscovery: vi.fn((): string[] => []), resolveSelectedHostAccountCodexHomePathForResume: vi.fn(() => null), - prepareRuntimeHomeForLaunch: vi.fn(async (): Promise<{ state: string }> => ({ state: 'ok' })), + prepareRuntimeHomeForLaunch: vi.fn( + async (): Promise<{ state: string; configPath?: string; detail?: string }> => ({ state: 'ok' }) + ), installForLaunchPrep: vi.fn(async () => {}), refreshRuntimeUserHooksForLaunchPrep: vi.fn(async () => {}), ensureRealHomeCodexHookState: vi.fn(async () => {}), @@ -66,8 +68,6 @@ vi.mock('./main-process-state', () => ({ })) import { AGENT_TRUST_WRITE_DEADLINE_MS } from '../agent-trust-write-deadline' -import { CodexConfigTomlEditRefusedError } from '../codex/codex-config-toml-checked-edit' -import { reportCodexHookTrustWriteFailure } from '../codex/codex-hook-trust-write-failure' import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' @@ -207,30 +207,28 @@ describe('Codex launch-prep trust writes', () => { expect(mocks.markCodexProjectTrusted).toHaveBeenCalledTimes(1) }) - it('does not re-log a refused hook trust write that was already reported once', async () => { - const refused = reportCodexHookTrustWriteFailure( - new CodexConfigTomlEditRefusedError({ - reason: 'input-invalid', - detail: 'the file is not valid TOML', - configPath: '/tmp/launch-hook-trust-probe/config.toml' - }), - { - agent: 'codex', - state: 'error', - configPath: '/tmp/launch-hook-trust-probe/hooks.json', - managedHooksPresent: true, - detail: 'Hooks installed but trust entries could not be written' - } - ) + it('logs a repeated hook status error once and a new one again', async () => { + const detail = + 'Hooks installed but trust entries could not be written: Orca left /tmp/launch-hook-trust-probe/config.toml unchanged (line 2): the file is not valid TOML.' + const refused = { + state: 'error', + configPath: '/tmp/launch-hook-trust-probe/hooks.json', + detail + } mocks.prepareRuntimeHomeForLaunch.mockResolvedValue(refused) warn.mockClear() for (let launch = 0; launch < 3; launch++) { await prepareCodexRuntimeHomeForLaunch() } - expect(warn).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledTimes(1) + expect(String(warn.mock.calls[0]?.[0])).toContain('[codex-hook-service]') + expect(String(warn.mock.calls[0]?.[0])).toContain('(line 2)') - mocks.prepareRuntimeHomeForLaunch.mockResolvedValue({ ...refused }) + mocks.prepareRuntimeHomeForLaunch.mockResolvedValue({ + ...refused, + detail: detail.replace('line 2', 'line 5') + }) await prepareCodexRuntimeHomeForLaunch() - expect(String(warn.mock.calls[0]?.[0])).toContain('[codex-hook-service]') + expect(warn).toHaveBeenCalledTimes(2) }) }) From 4a5a570be8eb874d52c79f4a3147a1e1721cf658 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:51:11 -0700 Subject: [PATCH 18/25] fix(codex): count a project as table-defined only when headers define every value under it A deeper header such as [projects."/a".extra.deep] counted `extra` as a header-defined sub-table even when a dotted key ("/a".extra.q = 1 under [projects], or root projects."/a".extra.q = 1) created `extra`. Orca then kept its managed [projects."/a"] table, the merged file redefined /a, and the mirror was refused on every pass. A project now counts as table-defined only when every value under it sits under some [projects."/a"...] header, checked recursively. Also drops a stale "loaded via createRequire" note: smol-toml is now loaded with a literal require. --- config/packaged-runtime-node-modules.cjs | 2 +- ...dex-config-mirror-duplicate-repair.test.ts | 48 +++++++++++++++++ src/main/codex/codex-config-mirror-merge.ts | 52 ++++++++++++------- 3 files changed, 81 insertions(+), 21 deletions(-) diff --git a/config/packaged-runtime-node-modules.cjs b/config/packaged-runtime-node-modules.cjs index 206b5e29afd3..0cdcb4faf1ac 100644 --- a/config/packaged-runtime-node-modules.cjs +++ b/config/packaged-runtime-node-modules.cjs @@ -28,7 +28,7 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [ // serve-sim (for CLI JS entry + closure + state/middleware + to make packaged require('serve-sim') + its internal relatives work; mirrors other runtime JS like ws/yaml/zod. Natives/dylibs still via extraResources + the node_modules/serve-sim copy in resources from builder. Client if added too. 'serve-sim', 'qrcode', - // smol-toml: Codex config.toml reads/validation in main and the CLI (loaded via createRequire). + // smol-toml: Codex config.toml reads/validation in main and the CLI. 'smol-toml', 'ssh2', 'tweetnacl', diff --git a/src/main/codex/codex-config-mirror-duplicate-repair.test.ts b/src/main/codex/codex-config-mirror-duplicate-repair.test.ts index 85b253a79aec..7156a24d858c 100644 --- a/src/main/codex/codex-config-mirror-duplicate-repair.test.ts +++ b/src/main/codex/codex-config-mirror-duplicate-repair.test.ts @@ -179,6 +179,54 @@ describe('managed-home mirror never writes a config Codex cannot read (#22592)', } ) + it.each([ + ['[projects]', '[projects]\n"/repo".extra.q = 1\n'], + ['the root', 'projects."/repo".extra.q = 1\n'] + ])( + 'drops managed trust when a dotted key under %s creates the parent of a deeper sub-table', + (_shape, dotted) => { + writeFileSync( + getSystemConfigPath(), + `model = "m"\n${dotted}\n[projects."/repo".extra.deep]\nz = 1\n`, + 'utf-8' + ) + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + 'model = "m"\n\n[projects."/repo"]\ntrust_level = "trusted"\n', + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + expect(parse(readFileSync(getRuntimeConfigPath(), 'utf-8'))).toEqual({ + model: 'm', + projects: { '/repo': { extra: { q: 1, deep: { z: 1 } } } } + }) + } + ) + + it('keeps managed trust for a project ~/.codex defines only through a deeper sub-table', () => { + writeFileSync( + getSystemConfigPath(), + 'model = "m"\n\n[projects."/repo".extra.deep]\nz = 1\n', + 'utf-8' + ) + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + 'model = "m"\n\n[projects."/repo"]\ntrust_level = "trusted"\n', + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + expect(parse(readFileSync(getRuntimeConfigPath(), 'utf-8'))).toEqual({ + model: 'm', + projects: { '/repo': { extra: { deep: { z: 1 } }, trust_level: 'trusted' } } + }) + }) + it('collapses Orca\u2019s own duplicate hook tables, so managed-only trust survives', () => { writeFileSync(getSystemConfigPath(), 'model = "m"\n', 'utf-8') mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) diff --git a/src/main/codex/codex-config-mirror-merge.ts b/src/main/codex/codex-config-mirror-merge.ts index 082394b1113a..9dde17c67e9e 100644 --- a/src/main/codex/codex-config-mirror-merge.ts +++ b/src/main/codex/codex-config-mirror-merge.ts @@ -93,40 +93,52 @@ function getProjectsDefinedOutsideTables(config: string): ReadonlySet { if (!projects) { return new Set() } - const headerProjects = new Set() - const subTableKeys = new Map>() + // Header paths below each project, JSON-encoded; `[]` is the project's own header. + const headerPaths = new Map>() for (const { header } of getTomlSections(config)) { if (!mayNameTomlKeys(header, ['projects'])) { continue } const projectPath = parseProjectTomlHeaderPath(header) - if (projectPath !== null) { - headerProjects.add(projectKey(projectPath)) - continue - } - const [root, subProjectPath, subKey] = parseTomlTableHeaderPath(header)?.segments ?? [] - if (root === 'projects' && subProjectPath !== undefined && subKey !== undefined) { + const [root, subProjectPath, ...rest] = + projectPath === null + ? (parseTomlTableHeaderPath(header)?.segments ?? []) + : ['projects', projectPath] + if (root === 'projects' && subProjectPath !== undefined) { const key = projectKey(subProjectPath) - subTableKeys.set(key, (subTableKeys.get(key) ?? new Set()).add(subKey)) + headerPaths.set(key, (headerPaths.get(key) ?? new Set()).add(JSON.stringify(rest))) } } - // Why: a sub-table header such as `[projects."/a".extra]` defines `/a` as a - // table only when no dotted or inline key also sets a value under `/a`. return new Set( Object.entries(projects) - .filter(([projectPath, value]) => { - const key = projectKey(projectPath) - const project = getTomlTable(value) - const subKeys = subTableKeys.get(key) - return ( - !headerProjects.has(key) && - !(project && subKeys && Object.keys(project).every((name) => subKeys.has(name))) - ) - }) + .filter( + ([projectPath, value]) => + !isDefinedByHeaders(value, [], headerPaths.get(projectKey(projectPath)) ?? new Set()) + ) .map(([projectPath]) => projectKey(projectPath)) ) } +// Why: `[projects."/a".extra]` defines `/a` as a table only when no dotted or +// inline key sets a value anywhere under `/a` outside a header. +function isDefinedByHeaders( + value: unknown, + path: readonly string[], + headerPaths: ReadonlySet +): boolean { + if (headerPaths.has(JSON.stringify(path))) { + return true + } + const table = getTomlTable(value) + return ( + table !== null && + Object.keys(table).length > 0 && + Object.entries(table).every(([name, child]) => + isDefinedByHeaders(child, [...path, name], headerPaths) + ) + ) +} + function projectKey(projectPath: string): string { return `project:${normalizeCodexProjectPathForLookup(projectPath)}` } From c68497c691e8b838b8e012fdd0060583509d44be Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:00:55 -0700 Subject: [PATCH 19/25] fix(codex): repair a broken remote config on Node 18 SSH hosts The relay runs the checked writer on the remote host, and SSH hosts may run Node 18, which has no Array.prototype.toReversed. The repair and the hook enable/disable edit iterated with it, so repairing a config an older Orca broke threw a TypeError there and left the file broken. --- .../relay-codex-config-toml-bundle.test.mjs | 35 +++++++++++++++++++ src/main/codex/codex-config-toml-repair.ts | 4 ++- src/main/codex/config-toml-hook-trust-edit.ts | 4 ++- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/config/scripts/relay-codex-config-toml-bundle.test.mjs b/config/scripts/relay-codex-config-toml-bundle.test.mjs index 30dddbf523c9..6da3d65e03e9 100644 --- a/config/scripts/relay-codex-config-toml-bundle.test.mjs +++ b/config/scripts/relay-codex-config-toml-bundle.test.mjs @@ -3,6 +3,7 @@ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSy import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' +import { parse } from 'smol-toml' import { afterAll, beforeAll, describe, expect, it } from 'vitest' // Why: relay hosts run the esbuild bundle with no node_modules, so the Codex config @@ -51,4 +52,38 @@ describe('relay bundle Codex trust write', () => { `["projects"."${workspace}"]\ntrust_level = "trusted"\n` ) }) + + it('repairs a duplicate an older Orca wrote using only Node 18 array methods', async () => { + const codexHome = join(root, 'codex-home-node18') + const workspace = join(root, 'wt-node18') + mkdirSync(codexHome) + mkdirSync(workspace) + const configPath = join(codexHome, 'config.toml') + writeFileSync( + configPath, + `["projects"."${workspace}"]\ntrust_level = "trusted"\n\n[projects."${workspace}"]\ntrust_level = "trusted"\n` + ) + // Why: SSH hosts may run the relay on Node 18, which lacks the Node 20 copy-array methods. + const node20Methods = ['toReversed', 'toSorted', 'toSpliced', 'with'] + const saved = node20Methods.map((name) => [name, Array.prototype[name]]) + for (const name of node20Methods) { + delete Array.prototype[name] + } + try { + await applyRelayAgentWorkspaceTrust( + { workspacePath: workspace }, + 'codex', + { HOME: root, CODEX_HOME: codexHome }, + { wslShell: false } + ) + } finally { + for (const [name, method] of saved) { + Array.prototype[name] = method + } + } + + const repaired = readFileSync(configPath, 'utf-8') + expect(parse(repaired).projects[workspace].trust_level).toBe('trusted') + expect(repaired.match(/trust_level/g)).toHaveLength(1) + }) }) diff --git a/src/main/codex/codex-config-toml-repair.ts b/src/main/codex/codex-config-toml-repair.ts index 58f6f8d10e00..78eb4e0f0cdc 100644 --- a/src/main/codex/codex-config-toml-repair.ts +++ b/src/main/codex/codex-config-toml-repair.ts @@ -217,7 +217,9 @@ function applyTextEdits(content: string, edits: readonly TextEdit[]): string { } } let result = content - for (const edit of disjoint.toReversed()) { + // Why: no toReversed(); the SSH relay runs this on Node 18. + for (let index = disjoint.length - 1; index >= 0; index -= 1) { + const edit = disjoint[index] result = result.slice(0, edit.start) + edit.replacement + result.slice(edit.end) } return result diff --git a/src/main/codex/config-toml-hook-trust-edit.ts b/src/main/codex/config-toml-hook-trust-edit.ts index d9fd8802870d..5169096473d2 100644 --- a/src/main/codex/config-toml-hook-trust-edit.ts +++ b/src/main/codex/config-toml-hook-trust-edit.ts @@ -124,7 +124,9 @@ export function setHookTrustEnabledContent( function setEnabledInTrustBlocks(content: string, key: string, enabled: boolean): string { const ranges = findHookTrustBlockRanges(content, new Set([normalizeCodexHookTrustLookupKey(key)])) let next = content - for (const range of ranges.toReversed()) { + // Why: no toReversed(); the SSH relay runs this on Node 18. + for (let index = ranges.length - 1; index >= 0; index -= 1) { + const range = ranges[index] const enabledLine = scanTomlStructure(next.slice(range.contentStart, range.end)).find( (line) => line.kind === 'assignment' && tomlKeyPathsEqual(line.keySegments, ['enabled']) ) From 60dcc2e065088ba1da12d1a8cab51121691d3907 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:04:42 -0700 Subject: [PATCH 20/25] test(codex): name the mirror sub-table case parameter by its role --- src/main/codex/codex-config-mirror-duplicate-repair.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/codex/codex-config-mirror-duplicate-repair.test.ts b/src/main/codex/codex-config-mirror-duplicate-repair.test.ts index 7156a24d858c..97606a13e45c 100644 --- a/src/main/codex/codex-config-mirror-duplicate-repair.test.ts +++ b/src/main/codex/codex-config-mirror-duplicate-repair.test.ts @@ -160,7 +160,7 @@ describe('managed-home mirror never writes a config Codex cannot read (#22592)', ['root dotted keys', 'projects."/repo".trust_level = "untrusted"\n'] ])( 'drops managed trust for a project ~/.codex defines through a sub-table and %s', - (_shape, dotted) => { + (_placement, dotted) => { const systemConfig = `model = "m"\n${dotted}\n[projects."/repo".extra]\nx = 1\n` writeFileSync(getSystemConfigPath(), systemConfig, 'utf-8') mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) @@ -184,7 +184,7 @@ describe('managed-home mirror never writes a config Codex cannot read (#22592)', ['the root', 'projects."/repo".extra.q = 1\n'] ])( 'drops managed trust when a dotted key under %s creates the parent of a deeper sub-table', - (_shape, dotted) => { + (_placement, dotted) => { writeFileSync( getSystemConfigPath(), `model = "m"\n${dotted}\n[projects."/repo".extra.deep]\nz = 1\n`, From 01030ef0b39d7d3b336f9027924fe159370d276e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:55:16 -0700 Subject: [PATCH 21/25] test(codex): pin reading quoted hooks.state field names Codex can write "trusted_hash" and "enabled" quoted; main's reader only matched the bare names, so a quoted "enabled" = false read as enabled. --- .../codex/config-toml-hook-trust-read.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/main/codex/config-toml-hook-trust-read.test.ts diff --git a/src/main/codex/config-toml-hook-trust-read.test.ts b/src/main/codex/config-toml-hook-trust-read.test.ts new file mode 100644 index 000000000000..3a391db66dfe --- /dev/null +++ b/src/main/codex/config-toml-hook-trust-read.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { readHookTrustContent } from './config-toml-hook-trust-read' + +const KEY = '/h/hooks.json:stop:0:0' + +// Why: Codex may quote field names too; a quoted `"enabled" = false` must still read as disabled. +describe('readHookTrustContent quoted field names', () => { + const block = `["hooks"."state"."${KEY}"]\n"trusted_hash" = "sha256:abc"\n"enabled" = false\n` + + it('reads them from a file that parses', () => { + expect(readHookTrustContent(block).get(KEY)).toEqual({ + trustedHash: 'sha256:abc', + enabled: false + }) + }) + + it('reads them from a file that does not parse (#22592 duplicate key)', () => { + const broken = `model = "a"\nmodel = "b"\n\n${block}` + expect(readHookTrustContent(broken).get(KEY)).toEqual({ + trustedHash: 'sha256:abc', + enabled: false + }) + }) +}) From 02625186456982b0143c0a6043631b8d8654a892 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:15:14 -0700 Subject: [PATCH 22/25] fix(codex): still sweep retired hooks from ~/.codex when the trust write is refused When ~/.codex/config.toml can't be parsed, the managed mirror copies it, so the managed-home trust write is refused first. Its catch returned before the legacy sweep, so a retired Orca entry stayed in ~/.codex/hooks.json and the user hook's trust move was never attempted (found in Windows QA). The sweep now runs on that error path too; its move is refused and logged once, as on any unreadable file. The status detail no longer ends "parse.. Run /hooks in Codex to approve." for an unreadable file, where /hooks can't help. --- .../codex/codex-config-toml-checked-edit.ts | 13 ++ src/main/codex/codex-hook-local-install.ts | 8 +- .../codex/codex-hook-local-maintenance.ts | 8 +- src/main/codex/codex-hook-remote-install.ts | 9 +- src/main/codex/codex-hook-wsl-runtime.ts | 6 +- ...service-refused-trust-legacy-sweep.test.ts | 119 ++++++++++++++++++ 6 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 src/main/codex/hook-service-refused-trust-legacy-sweep.test.ts diff --git a/src/main/codex/codex-config-toml-checked-edit.ts b/src/main/codex/codex-config-toml-checked-edit.ts index cbed444e64c1..a98591f67ec2 100644 --- a/src/main/codex/codex-config-toml-checked-edit.ts +++ b/src/main/codex/codex-config-toml-checked-edit.ts @@ -231,6 +231,19 @@ export function reportCodexConfigTomlEditRefusal( warnCodexConfigOnce(error.configPath ?? context, `[codex-config] ${context}: ${error.message}`) } +/** Status detail for a failed hook trust write; `/hooks` cannot help while Codex cannot read the file. */ +export function describeHookTrustWriteFailure( + summary: string, + error: unknown, + approveAdvice = 'Run /hooks in Codex to approve.' +): string { + if (error instanceof CodexConfigTomlEditRefusedError && error.reason === 'input-invalid') { + return `${summary}: ${error.message}` + } + const message = (error instanceof Error ? error.message : String(error)).replace(/\.+$/, '') + return `${summary}: ${message}. ${approveAdvice}` +} + /** Logs each refused trust write once and returns the other failures for the caller. */ export function reportCodexTrustWriteRefusals(error: unknown): unknown[] { const failures = error instanceof AggregateError ? error.errors : [error] diff --git a/src/main/codex/codex-hook-local-install.ts b/src/main/codex/codex-hook-local-install.ts index 0c76835e4153..933c88cbd6d9 100644 --- a/src/main/codex/codex-hook-local-install.ts +++ b/src/main/codex/codex-hook-local-install.ts @@ -25,6 +25,7 @@ import { } from './codex-hook-definition' import { getCodexManagedScriptFileName } from './codex-hook-identity' import { cleanupLegacyManagedHookRepresentations } from './codex-hook-legacy-cleanup' +import { describeHookTrustWriteFailure } from './codex-config-toml-checked-edit' import { getManagedScript } from './codex-hook-script' import { grantManagedCodexHookTrust, @@ -179,12 +180,17 @@ export async function installCodexHooksExclusively( } applyMirroredRuntimeUserHookTrustStates(tomlPath, mirroredUserTrustEntries) } catch (error) { + // Why: the legacy sweep edits ~/.codex, not this config; a refused trust write here must not skip it. + await cleanupLegacyManagedHookRepresentations() return { agent: 'codex', state: 'error', configPath, managedHooksPresent: true, - detail: `Hooks installed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.` + detail: describeHookTrustWriteFailure( + 'Hooks installed but trust entries could not be written', + error + ) } } snapshotCodexRuntimeHookTrustProvenance(runtimeHomePath) diff --git a/src/main/codex/codex-hook-local-maintenance.ts b/src/main/codex/codex-hook-local-maintenance.ts index bb89b4e6dc84..aee9f0e78ea8 100644 --- a/src/main/codex/codex-hook-local-maintenance.ts +++ b/src/main/codex/codex-hook-local-maintenance.ts @@ -10,6 +10,7 @@ import { upsertHookTrustEntries } from './config-toml-trust' import { getCodexConfigTomlPath, getConfigPath, writeCodexHooksJson } from './codex-hook-definition' import { getCodexManagedScriptFileName } from './codex-hook-identity' import { cleanupLegacyManagedHookRepresentations } from './codex-hook-legacy-cleanup' +import { describeHookTrustWriteFailure } from './codex-config-toml-checked-edit' import { removeRealHomeCodexHookForOptOut } from './codex-real-home-hook-install' import { removeRuntimeManagedHookTrustEntries, @@ -75,12 +76,17 @@ export async function refreshCodexRuntimeUserHooksExclusively( removeStaleRuntimeHookTrustEntries(tomlPath, configPath, trustEntries) applyMirroredRuntimeUserHookTrustStates(tomlPath, hookPlan.trustEntries) } catch (error) { + // Why: the legacy sweep edits ~/.codex, not this config; a refused trust write here must not skip it. + await cleanupLegacyManagedHookRepresentations() return { agent: 'codex', state: 'error', configPath, managedHooksPresent: false, - detail: `User hooks refreshed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.` + detail: describeHookTrustWriteFailure( + 'User hooks refreshed but trust entries could not be written', + error + ) } } snapshotCodexRuntimeHookTrustProvenance(runtimeHomePath) diff --git a/src/main/codex/codex-hook-remote-install.ts b/src/main/codex/codex-hook-remote-install.ts index 2b6105cdc3df..8ce87fa7e40f 100644 --- a/src/main/codex/codex-hook-remote-install.ts +++ b/src/main/codex/codex-hook-remote-install.ts @@ -22,6 +22,7 @@ import { wrapReadablePosixHookCommand } from './codex-hook-definition' import { getManagedScript } from './codex-hook-script' +import { describeHookTrustWriteFailure } from './codex-config-toml-checked-edit' export async function installCodexHooksRemote( sftp: SFTPWrapper, @@ -118,9 +119,11 @@ export async function installCodexHooksRemote( state: 'error', configPath: remoteConfigPath, managedHooksPresent: true, - detail: `Hooks installed but trust entries could not be written: ${ - error instanceof Error ? error.message : String(error) - }. Run /hooks in Codex on the remote host to approve.` + detail: describeHookTrustWriteFailure( + 'Hooks installed but trust entries could not be written', + error, + 'Run /hooks in Codex on the remote host to approve.' + ) } } diff --git a/src/main/codex/codex-hook-wsl-runtime.ts b/src/main/codex/codex-hook-wsl-runtime.ts index e2318e30b96f..54c1b6c87576 100644 --- a/src/main/codex/codex-hook-wsl-runtime.ts +++ b/src/main/codex/codex-hook-wsl-runtime.ts @@ -26,6 +26,7 @@ import { } from './codex-hook-trust-grant' import { removeSelfComputedTrustBeforeGrant } from './codex-managed-trust-grant-plan' import { getManagedScript } from './codex-hook-script' +import { describeHookTrustWriteFailure } from './codex-config-toml-checked-edit' import { removeStaleWslRuntimeManagedHookTrustEntries, removeWslRuntimeManagedHookTrustEntries @@ -136,7 +137,10 @@ async function installManagedHooksIntoWslRuntimeExclusively( state: 'error', configPath: plan.configPath, managedHooksPresent: true, - detail: `Hooks installed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.` + detail: describeHookTrustWriteFailure( + 'Hooks installed but trust entries could not be written', + error + ) } } diff --git a/src/main/codex/hook-service-refused-trust-legacy-sweep.test.ts b/src/main/codex/hook-service-refused-trust-legacy-sweep.test.ts new file mode 100644 index 000000000000..12bc2fdf83ef --- /dev/null +++ b/src/main/codex/hook-service-refused-trust-legacy-sweep.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import type * as Os from 'node:os' +import { join } from 'node:path' +import { setupCodexHookHomes } from './hook-service-test-harness' + +const { getPathMock, homedirMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: homedirMock + } +}) + +import { CodexHookService } from './hook-service' + +const homes = setupCodexHookHomes(homedirMock, getPathMock) + +// Why a retired form: the sweep removes only commands no current build writes. +function retiredManagedHookCommand(): string { + if (process.platform === 'win32') { + return join(homes.userDataDir, 'agent-hooks', 'codex-hook.cmd') + } + const quoted = `'${join(homes.tmpHome, '.orca', 'agent-hooks', 'codex-hook.sh')}'` + return `if [ -x ${quoted} ]; then /bin/sh ${quoted}; fi` +} + +type HooksFile = { hooks: Record } + +function seedRetiredEntryAheadOfUserHook(): { hooksPath: string; tomlPath: string; toml: string } { + const systemCodexHome = join(homes.tmpHome, '.codex') + const hooksPath = join(systemCodexHome, 'hooks.json') + const tomlPath = join(systemCodexHome, 'config.toml') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + hooksPath, + `${JSON.stringify( + { + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: retiredManagedHookCommand() }] }, + { hooks: [{ type: 'command', command: 'user-stop-hook', timeout: 30 }] } + ] + } + }, + null, + 2 + )}\n`, + 'utf-8' + ) + // Why `model =`: a hand-broken line no repair may touch, so every config.toml write is refused. + const toml = [ + 'model =', + '', + `[hooks.state."${hooksPath.replaceAll('\\', '\\\\')}:stop:1:0"]`, + 'trusted_hash = "sha256:user"', + '' + ].join('\n') + writeFileSync(tomlPath, toml, 'utf-8') + return { hooksPath, tomlPath, toml } +} + +function moveRefusalWarnings(warn: ReturnType): unknown[][] { + return warn.mock.calls.filter( + ([message]) => + typeof message === 'string' && message.includes('Skipped moving shifted user hook trust') + ) +} + +// Why: the managed mirror copies a hand-broken ~/.codex/config.toml, so its trust +// write is refused first; that refusal must not skip the sweep of ~/.codex/hooks.json. +describe('CodexHookService legacy sweep after a refused trust write', () => { + let warn: ReturnType + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + afterEach(() => { + warn.mockRestore() + }) + + it.each([ + ['hooks on', true], + ['hooks off', false] + ])( + 'with %s, removes the retired entry and refuses the trust move once', + async (_label, hooksEnabled) => { + const { hooksPath, tomlPath, toml } = seedRetiredEntryAheadOfUserHook() + const service = new CodexHookService() + + const first = await service.prepareRuntimeHomeForLaunch(undefined, undefined, hooksEnabled) + + expect(first.state).toBe('error') + expect(first.detail).not.toContain('..') + expect(first.detail).not.toContain('Run /hooks') + const hooks = JSON.parse(readFileSync(hooksPath, 'utf-8')) as HooksFile + expect(hooks.hooks.Stop).toEqual([ + { hooks: [{ type: 'command', command: 'user-stop-hook', timeout: 30 }] } + ]) + expect(readFileSync(tomlPath, 'utf-8')).toBe(toml) + expect(moveRefusalWarnings(warn)).toHaveLength(1) + + seedRetiredEntryAheadOfUserHook() + await service.prepareRuntimeHomeForLaunch(undefined, undefined, hooksEnabled) + + expect(moveRefusalWarnings(warn)).toHaveLength(1) + } + ) +}) From 4dcb6200df1ed729f332cc83cbc7798a29e4aeb2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:29:31 -0700 Subject: [PATCH 23/25] fix(codex): wait to sweep retired hooks until config.toml can be read Removing a retired Orca entry from ~/.codex/hooks.json shifts the user hooks after it, so their approvals must move in config.toml in the same step. When config.toml can't be parsed that move is refused, and sweeping anyway left the user's approval at the old key and the retired entry's trust at the new one: after the user fixed the file, Codex asked them to review their own hook again. The retired-entry sweep now plans the config.toml edit first and, if the file can't be read, leaves hooks.json untouched and logs once. The next launch re-derives the sweep from hooks.json and moves the approval cleanly. A refusal no longer escapes as a stack trace, and the legacy profile cleanup no longer depends on the sweep succeeding. The real-home lanes are unchanged. --- src/main/codex/codex-hook-legacy-cleanup.ts | 74 +++++---- .../codex/codex-retired-hook-sweep-trust.ts | 57 +++++++ ...service-refused-trust-legacy-sweep.test.ts | 143 ++++++++++++------ 3 files changed, 197 insertions(+), 77 deletions(-) create mode 100644 src/main/codex/codex-retired-hook-sweep-trust.ts diff --git a/src/main/codex/codex-hook-legacy-cleanup.ts b/src/main/codex/codex-hook-legacy-cleanup.ts index de881e9f7864..6942d196af4b 100644 --- a/src/main/codex/codex-hook-legacy-cleanup.ts +++ b/src/main/codex/codex-hook-legacy-cleanup.ts @@ -16,12 +16,10 @@ import { } from './codex-hook-definition' import { isRetiredCodexHookCommand } from './codex-hook-retired-commands' import { getSystemCodexHomePath } from './codex-home-paths' -import { - collectManagedTrustEntries, - removeSelfComputedMatchingTrustEntries -} from './codex-hook-trust-cleanup' +import { collectManagedTrustEntries } from './codex-hook-trust-cleanup' import { runExclusivelyForCodexTrustConfig } from './codex-trust-config-mutation-queue' -import { mutateRealHomeHooksPreservingUserTrust } from './codex-user-hook-trust-moves' +import { reportCodexConfigTomlEditRefusal } from './codex-config-toml-checked-edit' +import { planRetiredHookSweepTrust } from './codex-retired-hook-sweep-trust' const LEGACY_ORCA_PROFILE_NAME = 'orca-agent-status' const LEGACY_ORCA_PROFILE_BLOCK_START = '# BEGIN ORCA AGENT STATUS HOOKS' @@ -90,28 +88,43 @@ async function sweepLegacySystemManagedHooks(): Promise { } // Why: Codex hooks moved to Orca's managed CODEX_HOME in #2350; hooks from before then would keep external Codex sessions reporting into Orca. - if (removedManagedHook) { - // Why: this is the user's system hooks file, not Orca's runtime copy. - // Remove only retired Orca hook entries and preserve other managers' metadata. - const hooksWritePath = resolveHooksJsonWritePath(legacyConfigPath) - mutateRealHomeHooksPreservingUserTrust({ - sourcePath: legacyConfigPath, - tomlPath: getSystemCodexConfigTomlPath(), - beforeHooks: config.hooks, - afterHooks: nextHooks, - writeHooks: () => { - if ( - readFileSync(legacyConfigPath, 'utf-8') !== previousRaw || - resolveHooksJsonWritePath(legacyConfigPath) !== hooksWritePath - ) { - // Why: another process may have saved since the read; never replace - // that newer dotfiles generation with this stale parse. - throw new Error('System Codex hooks changed since Orca read them') - } - writeHooksJson(hooksWritePath, { ...config, hooks: nextHooks }, { preserveMode: true }) - } - }) - removeSelfComputedMatchingTrustEntries(getSystemCodexConfigTomlPath(), trustEntries) + if (!removedManagedHook) { + return + } + const tomlPath = getSystemCodexConfigTomlPath() + const trustPlan = planRetiredHookSweepTrust({ + tomlPath, + hooksPath: legacyConfigPath, + beforeHooks: config.hooks, + afterHooks: nextHooks, + retiredTrustEntries: trustEntries + }) + if (trustPlan.kind === 'refused' && trustPlan.error.reason === 'input-invalid') { + // Why: a retired entry is inert while Codex cannot read config.toml, but removing it + // now would strand the trust it shifts; the next launch re-derives the sweep from hooks.json. + reportCodexConfigTomlEditRefusal( + trustPlan.error, + 'Waiting to remove retired Orca hook entries until config.toml can be read' + ) + return + } + // Why: this is the user's system hooks file, not Orca's runtime copy. + // Remove only retired Orca hook entries and preserve other managers' metadata. + const hooksWritePath = resolveHooksJsonWritePath(legacyConfigPath) + if ( + readFileSync(legacyConfigPath, 'utf-8') !== previousRaw || + resolveHooksJsonWritePath(legacyConfigPath) !== hooksWritePath + ) { + // Why: another process may have saved since the read; never replace + // that newer dotfiles generation with this stale parse. + throw new Error('System Codex hooks changed since Orca read them') + } + writeHooksJson(hooksWritePath, { ...config, hooks: nextHooks }, { preserveMode: true }) + if (trustPlan.kind === 'edit') { + writeConfigAtomically(tomlPath, trustPlan.next, trustPlan.previous) + } else if (trustPlan.kind === 'refused') { + // Why no rollback: Codex reads this file, so the retired entry must go; it lists the shifted hooks for review. + reportCodexConfigTomlEditRefusal(trustPlan.error, 'Skipped moving shifted user hook trust') } } @@ -170,8 +183,13 @@ function cleanupLegacyCodexProfileHooks(): void { export async function cleanupLegacyManagedHookRepresentations(): Promise { try { await cleanupLegacySystemManagedHooks() - cleanupLegacyCodexProfileHooks() } catch (error) { console.warn('[codex-hook-service] failed to clean legacy Codex hooks', error) } + // Why separate: the profile file is unrelated to ~/.codex/hooks.json, so its cleanup never waits on that sweep. + try { + cleanupLegacyCodexProfileHooks() + } catch (error) { + console.warn('[codex-hook-service] failed to clean the legacy Codex profile', error) + } } diff --git a/src/main/codex/codex-retired-hook-sweep-trust.ts b/src/main/codex/codex-retired-hook-sweep-trust.ts new file mode 100644 index 000000000000..2fd3b4ae4378 --- /dev/null +++ b/src/main/codex/codex-retired-hook-sweep-trust.ts @@ -0,0 +1,57 @@ +import { existsSync, readFileSync } from 'node:fs' +import type { HookDefinition } from '../agent-hooks/installer-utils' +import { + computeTrustKey, + computeTrustedHash, + readHookTrustEntriesFromContent, + removeHookTrustEntriesFromContent, + type CodexTrustEntry +} from './config-toml-trust' +import { moveHookTrustContent } from './config-toml-hook-trust-edit' +import { CodexConfigTomlEditRefusedError } from './codex-config-toml-checked-edit' +import { getMovedCodexUserHookTrust } from './codex-user-hook-trust-moves' + +/** + * The config.toml side of sweeping retired Orca entries from ~/.codex/hooks.json: + * the user hooks the removal shifts carry their trust to the new keys, and the + * retired entries' own self-computed trust goes. + */ +export type RetiredHookSweepTrustPlan = + | { kind: 'unchanged' } + | { kind: 'edit'; previous: string; next: string } + | { kind: 'refused'; error: CodexConfigTomlEditRefusedError } + +export function planRetiredHookSweepTrust(args: { + tomlPath: string + hooksPath: string + beforeHooks: Record + afterHooks: Record + retiredTrustEntries: readonly CodexTrustEntry[] +}): RetiredHookSweepTrustPlan { + if (!existsSync(args.tomlPath)) { + return { kind: 'unchanged' } + } + const raw = readFileSync(args.tomlPath, 'utf-8') + const previous = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw + try { + const moves = getMovedCodexUserHookTrust(args.hooksPath, args.beforeHooks, args.afterHooks) + const moved = moves.length > 0 ? moveHookTrustContent(previous, moves) : previous + // Why after the move: a shifted user hook may now hold a retired entry's key. + const trust = readHookTrustEntriesFromContent(moved) + const ownedKeys = args.retiredTrustEntries + .filter( + (entry) => trust.get(computeTrustKey(entry))?.trustedHash === computeTrustedHash(entry) + ) + .map(computeTrustKey) + const next = ownedKeys.length > 0 ? removeHookTrustEntriesFromContent(moved, ownedKeys) : moved + return next === previous ? { kind: 'unchanged' } : { kind: 'edit', previous, next } + } catch (error) { + if (!(error instanceof CodexConfigTomlEditRefusedError)) { + throw error + } + return { + kind: 'refused', + error: error.configPath === null ? error.forConfigPath(args.tomlPath) : error + } + } +} diff --git a/src/main/codex/hook-service-refused-trust-legacy-sweep.test.ts b/src/main/codex/hook-service-refused-trust-legacy-sweep.test.ts index 12bc2fdf83ef..8a085807b14c 100644 --- a/src/main/codex/hook-service-refused-trust-legacy-sweep.test.ts +++ b/src/main/codex/hook-service-refused-trust-legacy-sweep.test.ts @@ -2,6 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import type * as Os from 'node:os' import { join } from 'node:path' +import { + computeTrustKey, + computeTrustedHash, + readHookTrustEntriesFromContent, + type CodexTrustEntry +} from './config-toml-trust' import { setupCodexHookHomes } from './hook-service-test-harness' const { getPathMock, homedirMock } = vi.hoisted(() => ({ @@ -38,49 +44,74 @@ function retiredManagedHookCommand(): string { type HooksFile = { hooks: Record } -function seedRetiredEntryAheadOfUserHook(): { hooksPath: string; tomlPath: string; toml: string } { +type Seeded = { + hooksPath: string + tomlPath: string + hooks: string + toml: string + userAtOne: CodexTrustEntry + userAtZero: CodexTrustEntry + retired: CodexTrustEntry +} + +function trustBlock(entry: CodexTrustEntry): string { + return [ + `[hooks.state."${computeTrustKey(entry).replaceAll('\\', '\\\\')}"]`, + `trusted_hash = "${computeTrustedHash(entry)}"` + ].join('\n') +} + +/** A retired Orca entry ahead of a user hook, both trusted, in a config.toml headed by `firstLine`. */ +function seedRetiredEntryAheadOfUserHook(firstLine: string): Seeded { const systemCodexHome = join(homes.tmpHome, '.codex') const hooksPath = join(systemCodexHome, 'hooks.json') const tomlPath = join(systemCodexHome, 'config.toml') mkdirSync(systemCodexHome, { recursive: true }) - writeFileSync( - hooksPath, - `${JSON.stringify( - { - hooks: { - Stop: [ - { hooks: [{ type: 'command', command: retiredManagedHookCommand() }] }, - { hooks: [{ type: 'command', command: 'user-stop-hook', timeout: 30 }] } - ] - } - }, - null, - 2 - )}\n`, - 'utf-8' - ) - // Why `model =`: a hand-broken line no repair may touch, so every config.toml write is refused. - const toml = [ - 'model =', - '', - `[hooks.state."${hooksPath.replaceAll('\\', '\\\\')}:stop:1:0"]`, - 'trusted_hash = "sha256:user"', - '' - ].join('\n') + const userHook = { type: 'command', command: 'user-stop-hook', timeout: 30 } + const hooks = `${JSON.stringify( + { + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: retiredManagedHookCommand() }] }, + { hooks: [userHook] } + ] + } + }, + null, + 2 + )}\n` + writeFileSync(hooksPath, hooks, 'utf-8') + const base = { sourcePath: hooksPath, eventLabel: 'stop' as const, handlerIndex: 0 } + const retired = { ...base, groupIndex: 0, command: retiredManagedHookCommand() } + const userAtOne = { ...base, groupIndex: 1, command: userHook.command, timeoutSec: 30 } + const toml = [firstLine, '', trustBlock(retired), '', trustBlock(userAtOne), ''].join('\n') writeFileSync(tomlPath, toml, 'utf-8') - return { hooksPath, tomlPath, toml } + return { + hooksPath, + tomlPath, + hooks, + toml, + userAtOne, + userAtZero: { ...userAtOne, groupIndex: 0 }, + retired + } } -function moveRefusalWarnings(warn: ReturnType): unknown[][] { +function warnings(warn: ReturnType, text: string): unknown[][] { return warn.mock.calls.filter( - ([message]) => - typeof message === 'string' && message.includes('Skipped moving shifted user hook trust') + ([message]) => typeof message === 'string' && message.includes(text) ) } +const HOOK_SETTINGS = [ + ['hooks on', true], + ['hooks off', false] +] as const + // Why: the managed mirror copies a hand-broken ~/.codex/config.toml, so its trust -// write is refused first; that refusal must not skip the sweep of ~/.codex/hooks.json. -describe('CodexHookService legacy sweep after a refused trust write', () => { +// write is refused first; the ~/.codex sweep must still run, and while the trust it +// shifts cannot move, it must wait rather than strand the user's approval. +describe('CodexHookService retired-entry sweep while config.toml is unreadable', () => { let warn: ReturnType beforeEach(() => { warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -89,31 +120,45 @@ describe('CodexHookService legacy sweep after a refused trust write', () => { warn.mockRestore() }) - it.each([ - ['hooks on', true], - ['hooks off', false] - ])( - 'with %s, removes the retired entry and refuses the trust move once', + it.each(HOOK_SETTINGS)('with %s, waits and logs once', async (_label, hooksEnabled) => { + // Why `model =`: a hand-broken line no repair may touch. + const seeded = seedRetiredEntryAheadOfUserHook('model =') + const service = new CodexHookService() + + for (let launch = 0; launch < 2; launch++) { + const status = await service.prepareRuntimeHomeForLaunch(undefined, undefined, hooksEnabled) + expect(status.state).toBe('error') + expect(status.detail).not.toContain('..') + expect(status.detail).not.toContain('Run /hooks') + } + + expect(readFileSync(seeded.hooksPath, 'utf-8')).toBe(seeded.hooks) + expect(readFileSync(seeded.tomlPath, 'utf-8')).toBe(seeded.toml) + expect(warnings(warn, 'Waiting to remove retired Orca hook entries')).toHaveLength(1) + expect(warnings(warn, 'failed to clean legacy Codex hooks')).toHaveLength(0) + }) + + it.each(HOOK_SETTINGS)( + 'with %s, sweeps and moves the approval once the file is fixed', async (_label, hooksEnabled) => { - const { hooksPath, tomlPath, toml } = seedRetiredEntryAheadOfUserHook() + const seeded = seedRetiredEntryAheadOfUserHook('model =') const service = new CodexHookService() + await service.prepareRuntimeHomeForLaunch(undefined, undefined, hooksEnabled) - const first = await service.prepareRuntimeHomeForLaunch(undefined, undefined, hooksEnabled) + writeFileSync(seeded.tomlPath, seeded.toml.replace('model =', 'model = "gpt-5"'), 'utf-8') + await service.prepareRuntimeHomeForLaunch(undefined, undefined, hooksEnabled) - expect(first.state).toBe('error') - expect(first.detail).not.toContain('..') - expect(first.detail).not.toContain('Run /hooks') - const hooks = JSON.parse(readFileSync(hooksPath, 'utf-8')) as HooksFile + const hooks = JSON.parse(readFileSync(seeded.hooksPath, 'utf-8')) as HooksFile expect(hooks.hooks.Stop).toEqual([ { hooks: [{ type: 'command', command: 'user-stop-hook', timeout: 30 }] } ]) - expect(readFileSync(tomlPath, 'utf-8')).toBe(toml) - expect(moveRefusalWarnings(warn)).toHaveLength(1) - - seedRetiredEntryAheadOfUserHook() - await service.prepareRuntimeHomeForLaunch(undefined, undefined, hooksEnabled) - - expect(moveRefusalWarnings(warn)).toHaveLength(1) + const toml = readFileSync(seeded.tomlPath, 'utf-8') + const trust = readHookTrustEntriesFromContent(toml) + expect(trust.get(computeTrustKey(seeded.userAtZero))?.trustedHash).toBe( + computeTrustedHash(seeded.userAtOne) + ) + expect(trust.has(computeTrustKey(seeded.userAtOne))).toBe(false) + expect(toml).not.toContain(computeTrustedHash(seeded.retired)) } ) }) From 37a7f5e3922dca1e5ec33efe76ad1810452698b2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:37:20 -0700 Subject: [PATCH 24/25] build(cli): list the retired-hook sweep trust planner in the CLI project --- config/tsconfig.cli.json | 1 + 1 file changed, 1 insertion(+) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index b1b1fed2118a..cd9aa3e465c7 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -159,6 +159,7 @@ "../src/main/codex/codex-real-home-hook-sweep.ts", "../src/main/codex/codex-real-home-hook-withdrawal.ts", "../src/main/codex/codex-real-home-hooks-json.ts", + "../src/main/codex/codex-retired-hook-sweep-trust.ts", "../src/main/codex/codex-state-db.ts", "../src/main/codex/codex-trust-identity.ts", "../src/main/codex/codex-trust-config-mutation-queue.ts", From 8d89699efff8d34314748e42484c9da6bbfc6f89 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:55:01 -0700 Subject: [PATCH 25/25] test(codex): read the swept hooks.json without a type assertion --- .../hook-service-refused-trust-legacy-sweep.test.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/main/codex/hook-service-refused-trust-legacy-sweep.test.ts b/src/main/codex/hook-service-refused-trust-legacy-sweep.test.ts index 8a085807b14c..0284a4dc9301 100644 --- a/src/main/codex/hook-service-refused-trust-legacy-sweep.test.ts +++ b/src/main/codex/hook-service-refused-trust-legacy-sweep.test.ts @@ -42,8 +42,6 @@ function retiredManagedHookCommand(): string { return `if [ -x ${quoted} ]; then /bin/sh ${quoted}; fi` } -type HooksFile = { hooks: Record } - type Seeded = { hooksPath: string tomlPath: string @@ -148,10 +146,9 @@ describe('CodexHookService retired-entry sweep while config.toml is unreadable', writeFileSync(seeded.tomlPath, seeded.toml.replace('model =', 'model = "gpt-5"'), 'utf-8') await service.prepareRuntimeHomeForLaunch(undefined, undefined, hooksEnabled) - const hooks = JSON.parse(readFileSync(seeded.hooksPath, 'utf-8')) as HooksFile - expect(hooks.hooks.Stop).toEqual([ - { hooks: [{ type: 'command', command: 'user-stop-hook', timeout: 30 }] } - ]) + expect(JSON.parse(readFileSync(seeded.hooksPath, 'utf-8'))).toMatchObject({ + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'user-stop-hook', timeout: 30 }] }] } + }) const toml = readFileSync(seeded.tomlPath, 'utf-8') const trust = readHookTrustEntriesFromContent(toml) expect(trust.get(computeTrustKey(seeded.userAtZero))?.trustedHash).toBe(