diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 85f3529b2dcb..cd47d038c2a8 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -116,6 +116,7 @@ "../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-mirror-merge.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", @@ -169,6 +170,8 @@ "../src/main/codex/config-toml-line-scan.ts", "../src/main/codex/config-toml-mcp-servers.ts", "../src/main/codex/config-toml-project-trust.ts", + "../src/main/codex/config-toml-project-trust-level.ts", + "../src/main/codex/config-toml-project-duplicate-repair.ts", "../src/main/codex/config-toml-runtime-owned-sections.ts", "../src/main/codex/config-toml-syntax.ts", "../src/main/codex/config-toml-trust.ts", diff --git a/src/main/codex/codex-config-mirror-hook-trust-carry.test.ts b/src/main/codex/codex-config-mirror-hook-trust-carry.test.ts new file mode 100644 index 000000000000..3ffd963a9669 --- /dev/null +++ b/src/main/codex/codex-config-mirror-hook-trust-carry.test.ts @@ -0,0 +1,324 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import type * as NodeOs from 'node:os' +import { join } from 'node:path' + +// 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 { + prepareSystemConfigForFreshRuntimeMirror, + syncSystemConfigIntoManagedCodexHome +} from './codex-config-mirror' +import { parseTomlTableHeaderPath } from './config-toml-key-path' +import { extractOrdinaryCodexSettings, getTomlSections } from './config-toml-runtime-owned-sections' +import { escapeTomlString } from './config-toml-trust' + +const PLUGIN_KEY = 'demo@mkt:hooks/hooks.json:stop:0:0' +const PROJECT_KEY = '/repo/.codex/hooks.json:stop:0:0' + +let fakeHomeDir: string +let userDataDir: string +let previousUserDataPath: string | undefined + +function systemCodexHome(): string { + return join(fakeHomeDir, '.codex') +} + +function runtimeConfigPath(): string { + return join(userDataDir, 'codex-runtime-home', 'home', 'config.toml') +} + +function bareParent(key: string): string { + return `[hooks.state."${escapeTomlString(key)}"]` +} + +function fullyQuoted(key: string): string { + return `["hooks"."state"."${escapeTomlString(key)}"]` +} + +function trustBlock(header: string, hash: string, enabled = true): string { + return [header, `enabled = ${enabled}`, `trusted_hash = "${hash}"`].join('\n') +} + +function writeSystemConfig(blocks: string[]): void { + writeFileSync( + join(systemCodexHome(), 'config.toml'), + `${['model = "system-model"', ...blocks].join('\n\n')}\n`, + 'utf-8' + ) +} + +function writeRuntimeConfig(blocks: string[]): void { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync(runtimeConfigPath(), `${['model = "runtime-model"', ...blocks].join('\n\n')}\n`) +} + +function mirror(): string { + syncSystemConfigIntoManagedCodexHome() + return readFileSync(runtimeConfigPath(), 'utf-8') +} + +// Why: Codex refuses a file that declares one table twice under any spelling (#22592). +function expectNoDuplicateTables(toml: string): void { + const tables = getTomlSections(toml).flatMap((section) => { + const table = parseTomlTableHeaderPath(section.header) + return table && !table.isArray ? [JSON.stringify(table.segments)] : [] + }) + expect(new Set(tables).size).toBe(tables.length) +} + +function countHookTrustTables(toml: string, key: string): number { + return getTomlSections(toml).filter((section) => { + const table = parseTomlTableHeaderPath(section.header) + return table?.segments.length === 3 && table.segments[2] === key + }).length +} + +beforeEach(() => { + fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-trust-carry-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-trust-carry-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(systemCodexHome(), { 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('carrying plain-Codex hook trust into the managed Codex home', () => { + it.each([ + ['plugin', 'bare parent', PLUGIN_KEY, bareParent], + ['plugin', 'fully quoted', PLUGIN_KEY, fullyQuoted], + ['project', 'bare parent', PROJECT_KEY, bareParent], + ['project', 'fully quoted', PROJECT_KEY, fullyQuoted] + ])( + 'carries %s hook trust in the %s spelling into a fresh home', + (_kind, _spelling, key, spell) => { + const block = trustBlock(spell(key), 'sha256:plain-codex') + writeSystemConfig([block]) + + const runtimeConfig = mirror() + + expect(runtimeConfig).toContain(block) + expectNoDuplicateTables(runtimeConfig) + } + ) + + // Why: Codex >=0.145 adds session_end and >=0.150 interrupt, which Orca's own parser rejects. + it.each( + ['session_end', 'interrupt'].flatMap((event) => + [ + ['plugin', `demo@mkt:hooks/hooks.json:${event}:0:0`], + ['project', `/repo/.codex/hooks.json:${event}:0:0`] + ].flatMap(([kind, key]) => [ + [event, kind, 'bare parent', key, bareParent] as const, + [event, kind, 'fully quoted', key, fullyQuoted] as const + ]) + ) + )('carries %s %s hook trust in the %s spelling', (_event, _kind, _spelling, key, spell) => { + const block = trustBlock(spell(key), 'sha256:newer-event') + writeSystemConfig([block]) + + mirror() + const runtimeConfig = mirror() + + expect(runtimeConfig).toContain(block) + expect(countHookTrustTables(runtimeConfig, key)).toBe(1) + expectNoDuplicateTables(runtimeConfig) + }) + + it.each(['session_end', 'interrupt'])('leaves out user-layer %s trust', (event) => { + const key = `${join(systemCodexHome(), 'hooks.json')}:${event}:0:0` + writeSystemConfig([trustBlock(fullyQuoted(key), 'sha256:user-hook')]) + + expect(mirror()).not.toContain('sha256:user-hook') + }) + + it.each([ + 'not-a-hook-key', + '/repo/.codex/hooks.json:stop:0', + '/repo/.codex/hooks.json:stop:01:0', + '/repo/.codex/hooks.json:Stop:0:0', + ':stop:0:0' + ])('does not carry the unattributable key %s', (key) => { + writeSystemConfig([ + trustBlock(bareParent(key), 'sha256:unattributable'), + trustBlock(fullyQuoted(PLUGIN_KEY), 'sha256:plugin') + ]) + + const runtimeConfig = mirror() + + expect(runtimeConfig).toContain('sha256:plugin') + expect(runtimeConfig).not.toContain('sha256:unattributable') + }) + + it.each([ + ['hooks.json', bareParent], + ['hooks.json', fullyQuoted], + ['config.toml', bareParent], + ['config.toml', fullyQuoted] + ])('leaves out user-layer trust keyed by ~/.codex/%s', (file, spell) => { + const logicalKey = `${join(systemCodexHome(), file)}:stop:0:0` + const realKey = `${join(realpathSync.native(systemCodexHome()), file)}:stop:1:0` + writeSystemConfig([ + trustBlock(spell(logicalKey), 'sha256:user-hook'), + trustBlock(spell(realKey), 'sha256:user-hook-realpath') + ]) + + const runtimeConfig = mirror() + + expect(runtimeConfig).toContain('model = "system-model"') + expect(runtimeConfig).not.toContain('sha256:user-hook') + }) + + it.each([ + ['fully quoted', fullyQuoted], + ['bare parent', bareParent] + ])('keeps one %s shared table across repeated launches', (_spelling, spell) => { + const block = trustBlock(spell(PLUGIN_KEY), 'sha256:plain-codex') + writeSystemConfig([block]) + + mirror() + const secondLaunch = mirror() + const thirdLaunch = mirror() + + expect(thirdLaunch).toBe(secondLaunch) + expect(countHookTrustTables(thirdLaunch, PLUGIN_KEY)).toBe(1) + expect(thirdLaunch).toContain(block) + expectNoDuplicateTables(thirdLaunch) + }) + + it.each([ + ['bare parent', 'fully quoted', bareParent, fullyQuoted], + ['fully quoted', 'bare parent', fullyQuoted, bareParent] + ])( + 'keeps the runtime %s copy over a system %s copy of the same key', + (_runtimeSpelling, _systemSpelling, runtimeSpell, systemSpell) => { + const runtimeBlock = trustBlock(runtimeSpell(PROJECT_KEY), 'sha256:approved-in-orca') + writeRuntimeConfig([runtimeBlock]) + writeSystemConfig([trustBlock(systemSpell(PROJECT_KEY), 'sha256:plain-codex')]) + + const runtimeConfig = mirror() + + expect(runtimeConfig).toContain(runtimeBlock) + expect(runtimeConfig).not.toContain('sha256:plain-codex') + expect(countHookTrustTables(runtimeConfig, PROJECT_KEY)).toBe(1) + expectNoDuplicateTables(runtimeConfig) + } + ) + + it('carries a shared key only once when ~/.codex already repeats it', () => { + writeSystemConfig([ + trustBlock(bareParent(PLUGIN_KEY), 'sha256:first'), + trustBlock(fullyQuoted(PLUGIN_KEY), 'sha256:second') + ]) + + const runtimeConfig = mirror() + + expect(countHookTrustTables(runtimeConfig, PLUGIN_KEY)).toBe(1) + expect(runtimeConfig).toContain('sha256:first') + expectNoDuplicateTables(runtimeConfig) + }) + + it('treats basic and literal spellings of a Windows key as one table', () => { + const windowsKey = 'C:\\r\\.codex\\hooks.json:stop:0:0' + const runtimeBlock = trustBlock(`[hooks.state.'${windowsKey}']`, 'sha256:runtime') + writeRuntimeConfig(['[hooks.state]', runtimeBlock]) + writeSystemConfig([ + '["hooks"."state"]', + trustBlock(fullyQuoted(windowsKey), 'sha256:system-same-key'), + trustBlock(bareParent('C:/r/.codex/hooks.json:stop:0:0'), 'sha256:system-slash-variant') + ]) + + const runtimeConfig = mirror() + + expect(runtimeConfig).toContain(runtimeBlock) + expect(runtimeConfig).not.toContain('sha256:system-same-key') + // Why: Codex 0.140 reads the other slash variant, which is a distinct table. + expect(runtimeConfig).toContain('sha256:system-slash-variant') + expect(countHookTrustTables(runtimeConfig, windowsKey)).toBe(1) + expectNoDuplicateTables(runtimeConfig) + }) + + it.each([ + ['[hooks.state]', '["hooks"."state"]'], + ['["hooks"."state"]', '[hooks.state]'] + ])('keeps only the runtime %s parent table over the system %s', (runtimeParent, systemParent) => { + writeRuntimeConfig([`${runtimeParent}\n# runtime parent`]) + writeSystemConfig([ + `${systemParent}\n# system parent`, + trustBlock(bareParent(PLUGIN_KEY), 'sha256:plain-codex') + ]) + + const runtimeConfig = mirror() + + expect(runtimeConfig).toContain('# runtime parent') + expect(runtimeConfig).not.toContain('# system parent') + expect(runtimeConfig).toContain('sha256:plain-codex') + expectNoDuplicateTables(runtimeConfig) + }) + + it('carries shared trust when seeding a WSL home from its Linux-side path', () => { + const prepared = prepareSystemConfigForFreshRuntimeMirror( + [ + trustBlock(fullyQuoted('/home/alice/.codex/hooks.json:stop:0:0'), 'sha256:user'), + trustBlock(fullyQuoted('/home/alice/repo/.codex/hooks.json:stop:0:0'), 'sha256:project') + ].join('\n\n'), + '/home/alice/.codex' + ) + + expect(prepared).not.toContain('sha256:user') + expect(prepared).toContain('sha256:project') + }) + + it('never promotes runtime hook trust into ~/.codex settings', () => { + const ordinary = extractOrdinaryCodexSettings( + [ + 'model = "runtime-model"', + trustBlock(bareParent(PLUGIN_KEY), 'sha256:plugin'), + trustBlock(fullyQuoted(PROJECT_KEY), 'sha256:project') + ].join('\n\n') + ) + + expect(ordinary).toBe('model = "runtime-model"') + }) +}) 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..ffdaa6b712d5 --- /dev/null +++ b/src/main/codex/codex-config-mirror-merge.ts @@ -0,0 +1,78 @@ +import { readMcpServerTomlOwnership } from './config-toml-mcp-servers' +import { + deduplicateProjectTomlSections, + getHookTrustTomlSectionKeys, + getMcpServerTomlSectionName, + getProjectTrustLevel, + getRevocationTomlSectionHeaderKey, + getTomlSectionHeaderKey, + getTomlSections, + isRuntimePreservedTomlSection, + isRuntimeProjectTomlSection, + joinTomlBlocks, + stripRuntimeOwnedTomlSections +} from './config-toml-runtime-owned-sections' + +/** Ordinary settings and shared hook trust from ~/.codex plus the tables the managed home owns. */ +export function mergeSystemCodexConfigIntoRuntime( + runtimeConfig: string, + systemConfig: string, + systemConfigDir: 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, { + systemHomeDir: systemConfigDir, + runtimeHookTrustKeys: getHookTrustTomlSectionKeys(runtimeSections) + }), + ...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.test.ts b/src/main/codex/codex-config-mirror.test.ts index 74db1cd9c05b..0de545e43e10 100644 --- a/src/main/codex/codex-config-mirror.test.ts +++ b/src/main/codex/codex-config-mirror.test.ts @@ -35,6 +35,7 @@ import { syncSystemConfigIntoLegacySharedCodexHome, syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' +import { escapeTomlString } from './config-toml-trust' let fakeHomeDir: string let userDataDir: string @@ -52,6 +53,10 @@ function getRuntimeConfigPath(): string { return join(userDataDir, 'codex-runtime-home', 'home', 'config.toml') } +function systemUserHookTrustHeader(): string { + return `[hooks.state."${escapeTomlString(join(getSystemCodexHomePath(), 'hooks.json'))}:stop:0:0"]` +} + function getRuntimeBaselinePath(): string { return join(userDataDir, 'codex-runtime-home', 'home', '.orca-config-settings-baseline.json') } @@ -83,13 +88,13 @@ afterEach(() => { }) describe('syncSystemConfigIntoManagedCodexHome', () => { - it('seeds a missing runtime config without copying system hook trust', () => { + it('seeds a missing runtime config without copying system user-hook trust', () => { writeFileSync( getSystemConfigPath(), [ 'model = "system-model"', '', - '[hooks.state."system-hooks:stop:0:0"]', + systemUserHookTrustHeader(), 'enabled = true', 'trusted_hash = "sha256:system"', '', @@ -105,7 +110,7 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') expect(runtimeConfig).toContain('model = "system-model"') expect(runtimeConfig).toContain('[projects."/repo"]') - expect(runtimeConfig).not.toContain('[hooks.state."system-hooks:stop:0:0"]') + expect(runtimeConfig).not.toContain(systemUserHookTrustHeader()) }) it('normalizes deprecated codex_hooks feature flag only in runtime config', () => { @@ -344,7 +349,7 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { '[projects."/system-only"]', 'trust_level = "trusted"', '', - '[hooks.state."system-hooks:stop:0:0"]', + systemUserHookTrustHeader(), 'enabled = true', 'trusted_hash = "sha256:system"', '' @@ -361,7 +366,7 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { expect(runtimeConfig).toContain('[projects."/runtime-only"]') expect(runtimeConfig).toContain('[projects."/system-only"]') expect(runtimeConfig).toContain('[hooks.state."runtime-hooks:stop:0:0"]') - expect(runtimeConfig).not.toContain('[hooks.state."system-hooks:stop:0:0"]') + expect(runtimeConfig).not.toContain(systemUserHookTrustHeader()) expect(runtimeConfig).toContain('# runtime-owned parent') expect(runtimeConfig).not.toContain('# system-owned parent') expect(runtimeConfig).toContain('trust_level = "untrusted"') @@ -825,7 +830,7 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { "# system example: ''' in a comment", 'model = "system-model"', '', - '[hooks.state."system-hooks:stop:0:0"]', + systemUserHookTrustHeader(), 'enabled = true', 'trusted_hash = "sha256:system"', '' @@ -839,7 +844,7 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { expect(runtimeConfig).toContain('model = "system-model"') expect(runtimeConfig).toContain('[hooks.state."runtime-hooks:stop:0:0"]') expect(runtimeConfig).toContain('trusted_hash = "sha256:runtime"') - expect(runtimeConfig).not.toContain('[hooks.state."system-hooks:stop:0:0"]') + expect(runtimeConfig).not.toContain(systemUserHookTrustHeader()) expect(runtimeConfig).not.toContain('trusted_hash = "sha256:system"') }) @@ -891,7 +896,7 @@ describe('prepareSystemConfigForFreshRuntimeMirror', () => { ).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'") }) - it('rewrites relative paths against a Linux-side home and strips hook trust', () => { + it('rewrites relative paths against a Linux-side home and strips user-hook trust', () => { const prepared = prepareSystemConfigForFreshRuntimeMirror( [ 'model_instructions_file = "instructions.md"', @@ -899,7 +904,7 @@ describe('prepareSystemConfigForFreshRuntimeMirror', () => { '[features]', 'codex_hooks = true', '', - '[hooks.state."system-hooks:stop:0:0"]', + '[hooks.state."/home/alice/.codex/hooks.json:stop:0:0"]', 'enabled = true', '', '[projects."/home/alice/repo"]', @@ -915,6 +920,6 @@ describe('prepareSystemConfigForFreshRuntimeMirror', () => { expect(prepared).toContain('hooks = true') expect(prepared).not.toContain('codex_hooks') expect(prepared).toContain('[projects."/home/alice/repo"]') - expect(prepared).not.toContain('[hooks.state."system-hooks:stop:0:0"]') + expect(prepared).not.toContain('[hooks.state."/home/alice/.codex/hooks.json:stop:0:0"]') }) }) diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 275e350328a2..a97a8b9644c8 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -20,18 +20,8 @@ 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 { - deduplicateProjectTomlSections, - getMcpServerTomlSectionName, - getProjectTrustLevel, - getRevocationTomlSectionHeaderKey, - getTomlSectionHeaderKey, - getTomlSections, - isRuntimePreservedTomlSection, - isRuntimeProjectTomlSection, - joinTomlBlocks, - stripRuntimeOwnedTomlSections -} from './config-toml-runtime-owned-sections' +import { stripRuntimeOwnedTomlSections } from './config-toml-runtime-owned-sections' +import { mergeSystemCodexConfigIntoRuntime } from './codex-config-mirror-merge' export function syncSystemConfigIntoManagedCodexHome( homes: CodexSettingsPromotionHomes = { @@ -177,6 +167,7 @@ export function syncSystemConfigIntoLegacySharedCodexHome( ? mergeSystemCodexConfigIntoRuntime( runtimeConfigBeforeMirror, prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir), + sourceConfigDir, new Set(), true ) @@ -271,6 +262,7 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe( mergeSystemCodexConfigIntoRuntime( runtimeConfig, systemConfig, + sourceConfigDir, promotionPlan.mirroredMcpServers, promotionPlan.mirroredMcpServerRoot ), @@ -306,72 +298,16 @@ function prepareSystemConfigForRuntimeMirror(config: string, systemConfigDir: st ) } -// 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. +// Why: install re-keys ~/.codex user-hook trust; plugin and project hook trust carries as-is. +// 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)) + return stripRuntimeOwnedTomlSections( + prepareSystemConfigForRuntimeMirror(config, systemConfigDir), + new Set(), + { systemHomeDir: systemConfigDir, runtimeHookTrustKeys: new Set() } ) - 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/config-toml-duplicate-declarations-test-fixture.ts b/src/main/codex/config-toml-duplicate-declarations-test-fixture.ts new file mode 100644 index 000000000000..2f01d2804d52 --- /dev/null +++ b/src/main/codex/config-toml-duplicate-declarations-test-fixture.ts @@ -0,0 +1,32 @@ +import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path' +import { getTomlTableHeader } from './config-toml-line-scan' + +/** Duplicate tables or keys Codex's TOML parser would reject; fixtures hold no multiline values. */ +export function findDuplicateTomlDeclarations(content: string): string[] { + const duplicates: string[] = [] + const tables = new Set() + let table = '[]' + let keys = new Set() + for (const rawLine of content.split('\n')) { + const line = rawLine.replace(/\r$/, '') + const header = getTomlTableHeader(line) + if (header !== null) { + table = JSON.stringify(parseTomlTableHeaderPath(header)?.segments ?? header) + if (tables.has(table)) { + duplicates.push(`table ${table}`) + } + tables.add(table) + keys = new Set() + continue + } + const key = parseTomlKeyPath(line) + if (key && line[key.end] === '=') { + const keyName = JSON.stringify(key.segments) + if (keys.has(keyName)) { + duplicates.push(`key ${keyName} in ${table}`) + } + keys.add(keyName) + } + } + return duplicates +} diff --git a/src/main/codex/config-toml-hook-state-duplicate-repair.test.ts b/src/main/codex/config-toml-hook-state-duplicate-repair.test.ts new file mode 100644 index 000000000000..f28912ce906f --- /dev/null +++ b/src/main/codex/config-toml-hook-state-duplicate-repair.test.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' +import { findDuplicateTomlDeclarations } from './config-toml-duplicate-declarations-test-fixture' +import { repairOrcaDuplicateTrustTables } from './config-toml-project-duplicate-repair' +import { + upsertHookTrustEntriesInContent, + upsertProjectTrustLevel, + type CodexTrustEntry +} from './config-toml-trust' +import { + createTrustConfigFixture, + removeTrustConfigFixture +} from './config-toml-trust-test-fixtures' + +// Why (#22592): Codex's writer spells hook trust as ["hooks"."state".""]. +const HOOK_KEY = '/Users/me/.codex/hooks.json:stop:0:0' +const hookEntry: CodexTrustEntry = { + sourcePath: '/Users/me/.codex/hooks.json', + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 0, + command: 'orca-hook', + trustedHash: 'sha256:new' +} + +let tmpDir: string +let configPath: string +let warn: ReturnType + +beforeEach(() => { + const fixture = createTrustConfigFixture() + tmpDir = fixture.tmpDir + configPath = fixture.configPath + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + warn.mockRestore() + removeTrustConfigFixture(tmpDir) +}) + +describe('hook trust recognises Codex quoted hooks.state spellings', () => { + it('rewrites a quoted hook table instead of appending a bare copy', () => { + const original = [ + `["hooks"."state"."${HOOK_KEY}"]`, + 'enabled = true', + 'trusted_hash = "sha256:old"', + '' + ].join('\n') + + const updated = upsertHookTrustEntriesInContent(original, [hookEntry]) + + expect(updated).toBe( + [`[hooks.state."${HOOK_KEY}"]`, 'enabled = true', 'trusted_hash = "sha256:new"', ''].join( + '\n' + ) + ) + }) + + it('does not add a bare parent table beside a quoted one for Windows hooks', () => { + const windowsEntry = { ...hookEntry, sourcePath: 'C:\\Users\\me\\.codex\\hooks.json' } + const original = ['["hooks"."state"]', ''].join('\n') + + const updated = upsertHookTrustEntriesInContent(original, [windowsEntry]) + + expect(updated.startsWith('["hooks"."state"]\n')).toBe(true) + expect(updated).not.toContain('[hooks.state]') + expect(findDuplicateTomlDeclarations(updated)).toEqual([]) + }) + + it('treats a quoted hook table as runtime-owned trust in the config mirror', () => { + const runtimeHomePath = join(tmpDir, 'runtime') + const systemHomePath = join(tmpDir, 'system') + mkdirSync(runtimeHomePath) + mkdirSync(systemHomePath) + writeFileSync( + join(systemHomePath, 'config.toml'), + ['model = "gpt-5.5"', '', '["hooks"."state"]', '', `["hooks"."state"."${HOOK_KEY}"]`] + .concat(['enabled = true', 'trusted_hash = "sha256:system"', '']) + .join('\n') + ) + writeFileSync( + join(runtimeHomePath, 'config.toml'), + ['[hooks.state]', '', `[hooks.state."${HOOK_KEY}"]`] + .concat(['enabled = true', 'trusted_hash = "sha256:runtime"', '']) + .join('\n') + ) + + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + + const runtimeConfig = readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8') + expect(runtimeConfig).toContain('trusted_hash = "sha256:runtime"') + expect(runtimeConfig).not.toContain('"hooks"') + expect(findDuplicateTomlDeclarations(runtimeConfig)).toEqual([]) + }) +}) + +describe('repairing Orca hooks.state duplicates', () => { + it('fully repairs a file holding both project and hook duplicates', () => { + const projectPath = '/Users/me/Projects/agentic-WPS' + const broken = [ + 'model = "gpt-5.5"', + '', + `["projects"."${projectPath}"]`, + '"trust_level" = "trusted"', + '', + '["hooks"."state"]', + '', + `["hooks"."state"."${HOOK_KEY}"]`, + 'enabled = false', + 'trusted_hash = "sha256:user"', + '', + `[projects."${projectPath}"]`, + 'trust_level = "trusted"', + '', + '[hooks.state]', + '', + `[hooks.state."${HOOK_KEY}"]`, + 'enabled = true', + 'trusted_hash = "sha256:orca"', + '' + ].join('\n') + writeFileSync(configPath, broken) + + upsertProjectTrustLevel(configPath, projectPath, 'trusted') + + const repaired = readFileSync(configPath, 'utf-8') + expect(repaired).toBe( + [ + 'model = "gpt-5.5"', + '', + `["projects"."${projectPath}"]`, + 'trust_level = "trusted"', + '', + '["hooks"."state"]', + '', + `["hooks"."state"."${HOOK_KEY}"]`, + 'enabled = false', + 'trusted_hash = "sha256:user"', + '' + ].join('\n') + ) + expect(findDuplicateTomlDeclarations(repaired)).toEqual([]) + expect(readFileSync(`${configPath}.bak`, 'utf-8')).toBe(broken) + expect(warn).not.toHaveBeenCalled() + }) + + it('removes the Orca-shaped copy when it comes before the user table', () => { + const broken = [ + '[projects."/orca-first"]', + 'trust_level = "trusted"', + '', + `[hooks.state.'C:\\Users\\me\\.codex\\hooks.json:stop:0:0']`, + 'enabled = true', + 'trusted_hash = "sha256:orca"', + '', + '["projects"."/orca-first"]', + 'trust_level = "untrusted"', + '', + '["hooks"."state"."C:\\\\Users\\\\me\\\\.codex\\\\hooks.json:stop:0:0"]', + 'enabled = false', + 'trusted_hash = "sha256:user"', + '' + ].join('\n') + + expect(repairOrcaDuplicateTrustTables(broken)).toBe( + [ + '["projects"."/orca-first"]', + 'trust_level = "untrusted"', + '', + '["hooks"."state"."C:\\\\Users\\\\me\\\\.codex\\\\hooks.json:stop:0:0"]', + 'enabled = false', + 'trusted_hash = "sha256:user"', + '' + ].join('\n') + ) + }) + + it('leaves two user-shaped hook tables alone and logs once', () => { + const broken = [ + '["hooks"."state"."/two-users:stop:0:0"]', + 'enabled = true', + '', + '[hooks.state."/two-users:stop:0:0"]', + 'enabled = true', + 'trusted_hash = "sha256:a"', + 'note = "hand-written"', + '' + ].join('\n') + + expect(repairOrcaDuplicateTrustTables(broken)).toBe(broken) + expect(repairOrcaDuplicateTrustTables(broken)).toBe(broken) + expect(warn).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/codex/config-toml-hook-trust-blocks.ts b/src/main/codex/config-toml-hook-trust-blocks.ts index e97b17faecd1..31dd30a1af5f 100644 --- a/src/main/codex/config-toml-hook-trust-blocks.ts +++ b/src/main/codex/config-toml-hook-trust-blocks.ts @@ -1,5 +1,7 @@ +import { parseTomlTableHeaderPath } from './config-toml-key-path' import { createTomlLineScanState, + getTomlTableHeader, isTomlStructuralLine, updateTomlLineScanState } from './config-toml-line-scan' @@ -83,14 +85,14 @@ export function findAllHookTrustBlocks(content: string): (HookTrustBlockRange & } export function ensureHooksStateParentTable(content: string): string { - if (/^[ \t]*\[hooks\.state\][ \t]*(?:#[^\r\n]*)?$/m.test(content)) { + const { hasParent, firstChildOffset } = findHooksStateHeaders(content) + if (hasParent) { 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)}` + if (firstChildOffset !== null) { + return `${content.slice(0, firstChildOffset)}${parent}${eol}${content.slice(firstChildOffset)}` } if (content.length === 0) { return parent @@ -98,3 +100,39 @@ export function ensureHooksStateParentTable(content: string): string { const separator = content.endsWith(`${eol}${eol}`) ? '' : content.endsWith(eol) ? eol : eol + eol return `${content}${separator}${parent}` } + +// Why (#22592): `["hooks"."state"]` is the same table; a bare-only match appended a duplicate. +function findHooksStateHeaders(content: string): { + hasParent: boolean + firstChildOffset: number | null +} { + let firstChildOffset: number | null = null + let cursor = 0 + let scanState = createTomlLineScanState() + while (cursor < content.length) { + const newlineIndex = content.indexOf('\n', cursor) + const lineEnd = newlineIndex === -1 ? content.length : newlineIndex + const line = content.slice(cursor, lineEnd).replace(/\r$/, '') + if (isTomlStructuralLine(scanState)) { + const header = getTomlTableHeader(line) + const table = header === null ? null : parseTomlTableHeaderPath(header) + if ( + table && + !table.isArray && + table.segments[0] === 'hooks' && + table.segments[1] === 'state' + ) { + if (table.segments.length === 2) { + return { hasParent: true, firstChildOffset } + } + firstChildOffset ??= cursor + } + } + scanState = updateTomlLineScanState(scanState, line) + if (newlineIndex === -1) { + break + } + cursor = newlineIndex + 1 + } + return { hasParent: false, firstChildOffset } +} diff --git a/src/main/codex/config-toml-hook-trust-edit.ts b/src/main/codex/config-toml-hook-trust-edit.ts index a8487012f058..bd739f0ec3ff 100644 --- a/src/main/codex/config-toml-hook-trust-edit.ts +++ b/src/main/codex/config-toml-hook-trust-edit.ts @@ -13,12 +13,13 @@ import { type HookTrustBlockRange } from './config-toml-hook-trust-blocks' import { escapeTomlBasicString } from './config-toml-syntax' +import { repairOrcaDuplicateTrustTables } from './config-toml-project-duplicate-repair' export function upsertHookTrustContent( existingContent: string, entries: readonly CodexTrustEntry[] ): string { - const existing = stripLeadingBom(existingContent) + const existing = repairOrcaDuplicateTrustTables(stripLeadingBom(existingContent)) let updated = entries.some((entry) => usesWindowsCodexPathSeparators(normalizeCodexTrustSourcePath(entry.sourcePath)) ) diff --git a/src/main/codex/config-toml-project-duplicate-repair.test.ts b/src/main/codex/config-toml-project-duplicate-repair.test.ts new file mode 100644 index 000000000000..2c3df27d49d8 --- /dev/null +++ b/src/main/codex/config-toml-project-duplicate-repair.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { readFileSync, writeFileSync } from 'node:fs' +import { findDuplicateTomlDeclarations } from './config-toml-duplicate-declarations-test-fixture' +import { repairOrcaDuplicateTrustTables } from './config-toml-project-duplicate-repair' +import { upsertProjectTrustLevel, upsertProjectTrustLevelInContent } from './config-toml-trust' +import { + createTrustConfigFixture, + removeTrustConfigFixture +} from './config-toml-trust-test-fixtures' + +// Why: each case uses its own path because refusals are logged once per process. +let tmpDir: string +let configPath: string +let warn: ReturnType + +beforeEach(() => { + const fixture = createTrustConfigFixture() + tmpDir = fixture.tmpDir + configPath = fixture.configPath + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + warn.mockRestore() + removeTrustConfigFixture(tmpDir) +}) + +describe('repairing configs broken by #22592', () => { + it('removes the table Orca appended beside Codex quoted spelling and keeps a .bak', () => { + const projectPath = '/Users/me/Projects/agentic-WPS' + // The reported file: Codex's table, then the duplicate an older Orca appended. + const broken = [ + 'model = "gpt-5.5"', + '', + `["projects"."${projectPath}"]`, + '"trust_level" = "trusted"', + '', + `[projects."${projectPath}"]`, + 'trust_level = "trusted"', + '' + ].join('\n') + writeFileSync(configPath, broken) + expect(findDuplicateTomlDeclarations(broken)).not.toEqual([]) + + upsertProjectTrustLevel(configPath, projectPath, 'trusted') + + const repaired = readFileSync(configPath, 'utf-8') + expect(repaired).toBe( + [ + 'model = "gpt-5.5"', + '', + `["projects"."${projectPath}"]`, + 'trust_level = "trusted"', + '' + ].join('\n') + ) + expect(findDuplicateTomlDeclarations(repaired)).toEqual([]) + expect(readFileSync(`${configPath}.bak`, 'utf-8')).toBe(broken) + expect(warn).not.toHaveBeenCalled() + }) + + it('repairs a duplicate for another project than the one being trusted', () => { + const broken = [ + "['projects'.'/repo-a']", + 'trust_level = "untrusted"', + 'notes = "user"', + '', + '[projects."/repo-a"]', + 'trust_level = "trusted"', + '' + ].join('\n') + + const updated = upsertProjectTrustLevelInContent(broken, '/repo-b', 'trusted', { + alreadyCanonical: true + }) + + expect(updated).toBe( + [ + "['projects'.'/repo-a']", + 'trust_level = "untrusted"', + 'notes = "user"', + '', + '[projects."/repo-b"]', + 'trust_level = "trusted"', + '' + ].join('\n') + ) + }) + + it('removes the bare trust_level line Orca inserted under a quoted key', () => { + const broken = [ + '["projects"."/repo-c"]', + 'trust_level = "trusted"', + '"trust_level" = "trusted"', + '' + ].join('\r\n') + + const repaired = repairOrcaDuplicateTrustTables(broken) + + expect(repaired).toBe(['["projects"."/repo-c"]', '"trust_level" = "trusted"', ''].join('\r\n')) + }) + + it('repairs Windows-path tables that decode to the same key', () => { + const broken = [ + "['projects'.'C:\\Users\\me\\repo']", + "trust_level = 'trusted'", + '', + '[projects."C:\\\\Users\\\\me\\\\repo"]', + 'trust_level = "trusted"', + '' + ].join('\n') + + const repaired = repairOrcaDuplicateTrustTables(broken) + + expect(repaired).toBe( + ["['projects'.'C:\\Users\\me\\repo']", "trust_level = 'trusted'", ''].join('\n') + ) + }) + + it('keeps Windows slash variants, which are distinct TOML keys', () => { + const config = [ + "['projects'.'C:\\Users\\me\\slashes']", + 'trust_level = "trusted"', + '', + '[projects."C:/Users/me/slashes"]', + 'trust_level = "trusted"', + '' + ].join('\n') + + expect(repairOrcaDuplicateTrustTables(config)).toBe(config) + expect(warn).not.toHaveBeenCalled() + }) + + it('leaves a user-authored duplicate alone and logs it once', () => { + const broken = [ + '["projects"."/repo-d"]', + 'trust_level = "trusted"', + '', + '[projects."/repo-d"]', + 'trust_level = "trusted"', + 'notes = "hand-written"', + '' + ].join('\n') + + const first = upsertProjectTrustLevelInContent(broken, '/repo-d', 'trusted', { + alreadyCanonical: true + }) + const second = upsertProjectTrustLevelInContent(broken, '/repo-d', 'trusted', { + alreadyCanonical: true + }) + + expect(first).toBe(broken) + expect(second).toBe(broken) + expect(warn).toHaveBeenCalledTimes(1) + }) + + it('leaves the file untouched when another duplicate would still break it', () => { + const broken = [ + '["projects"."/repo-e"]', + 'trust_level = "trusted"', + '', + '[profiles.fast]', + 'model = "a"', + '', + '["profiles"."fast"]', + 'model = "b"', + '', + '[projects."/repo-e"]', + 'trust_level = "trusted"', + '' + ].join('\n') + + expect(repairOrcaDuplicateTrustTables(broken)).toBe(broken) + expect(warn).toHaveBeenCalledTimes(1) + }) + + it('does not touch a valid config', () => { + const config = ['["projects"."/repo-f"]', '"trust_level" = "trusted"', ''].join('\n') + + expect(repairOrcaDuplicateTrustTables(config)).toBe(config) + expect(warn).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/codex/config-toml-project-duplicate-repair.ts b/src/main/codex/config-toml-project-duplicate-repair.ts new file mode 100644 index 000000000000..92e7a506e6ce --- /dev/null +++ b/src/main/codex/config-toml-project-duplicate-repair.ts @@ -0,0 +1,200 @@ +import { parseTomlTableHeaderPath } from './config-toml-key-path' +import { + createTomlLineScanState, + getTomlTableHeader, + isTomlStructuralLine, + updateTomlLineScanState +} from './config-toml-line-scan' +import { findProjectTrustLevelEntries } from './config-toml-project-trust-level' +import { escapeTomlBasicString } from './config-toml-syntax' + +type TomlTable = { + /** Line index of the header; the body runs to the next table's header. */ + headerLine: number + endLine: number + header: string + segments: string[] | null + isArray: boolean +} + +const ORCA_TRUST_LINE = 'trust_level = "trusted"' +const loggedRefusals = new Set() + +/** + * Removes the duplicates older Orca builds wrote beside Codex-spelled trust + * tables (#22592): an Orca-shaped `[projects."

"]`, `[hooks.state.""]` or + * empty `[hooks.state]` that repeats a table the user (or Codex) wrote in + * another spelling, and the bare `trust_level` line Orca inserted under a + * quoted `"trust_level"`. The user's table always wins; anything else leaves + * the content untouched. + */ +export function repairOrcaDuplicateTrustTables(content: string): string { + const lines = content.split('\n') + const groups = new Map() + for (const table of readTomlTables(lines)) { + if (getTrustTableKind(table) !== null) { + const key = JSON.stringify(table.segments) + groups.set(key, [...(groups.get(key) ?? []), table]) + } + } + const removedLines = new Set() + for (const group of groups.values()) { + const userTables = group.filter((table) => !isOrcaWrittenTable(lines, table)) + const survivor = group.length === 1 ? group[0] : userTables[0] + // Why: an identical spelling or two user tables cannot come from #22592, so only a person can merge them. + if ( + !survivor || + (group.length > 1 && + (userTables.length !== 1 || + group.some((table) => table !== survivor && table.header === survivor.header))) + ) { + return refuseRepair(content, `duplicate table ${group[0]?.header}`) + } + for (const table of group) { + if (table !== survivor) { + for (let index = table.headerLine; index < table.endLine; index += 1) { + removedLines.add(index) + } + } + } + if (getTrustTableKind(survivor) === 'project') { + const insertedLine = findOrcaInsertedTrustLine(lines, survivor) + if (insertedLine === 'unsafe') { + return refuseRepair(content, `duplicate trust_level in ${survivor.header}`) + } + if (insertedLine !== null) { + removedLines.add(insertedLine) + } + } + } + if (removedLines.size === 0) { + return content + } + const kept = lines.filter((_, index) => !removedLines.has(index)).join('\n') + const repaired = content.endsWith('\n') && !kept.endsWith('\n') ? `${kept}\n` : kept + // Why: a partial repair would still fail Codex's parse, so only write a file it can load. + const remainingDuplicate = findDuplicateTable(repaired) + return remainingDuplicate === null + ? repaired + : refuseRepair(content, `duplicate table ${remainingDuplicate} remains`) +} + +function readTomlTables(lines: string[]): TomlTable[] { + const tables: TomlTable[] = [] + let scanState = createTomlLineScanState() + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? '' + const header = isTomlStructuralLine(scanState) ? getTomlTableHeader(line) : null + scanState = updateTomlLineScanState(scanState, line) + if (header === null) { + continue + } + const previous = tables.at(-1) + if (previous) { + previous.endLine = index + } + const parsed = parseTomlTableHeaderPath(header) + tables.push({ + headerLine: index, + endLine: lines.length, + header: header.trim(), + segments: parsed?.segments ?? null, + isArray: parsed?.isArray ?? false + }) + } + return tables +} + +type TrustTableKind = 'project' | 'hook-state' | 'hook-state-parent' + +function getTrustTableKind(table: TomlTable): TrustTableKind | null { + const segments = table.segments + if (table.isArray || !segments) { + return null + } + if (segments.length === 2 && segments[0] === 'projects') { + return 'project' + } + if (segments[0] !== 'hooks' || segments[1] !== 'state') { + return null + } + return segments.length === 2 ? 'hook-state-parent' : segments.length === 3 ? 'hook-state' : null +} + +// Why: only the exact bytes Orca's writers emit are safe to delete without asking. +function isOrcaWrittenTable(lines: string[], table: TomlTable): boolean { + const header = stripCr(lines[table.headerLine]) + const body = lines + .slice(table.headerLine + 1, table.endLine) + .map(stripCr) + .filter((line) => line.trim() !== '') + const leaf = table.segments?.at(-1) ?? '' + switch (getTrustTableKind(table)) { + case 'project': + return ( + header === `[projects."${escapeTomlBasicString(leaf)}"]` && + body.length === 1 && + body[0] === ORCA_TRUST_LINE + ) + case 'hook-state': + return ( + (header === `[hooks.state."${escapeTomlBasicString(leaf)}"]` || + header === `[hooks.state.'${leaf}']`) && + body.length === 2 && + /^enabled = (?:true|false)$/.test(body[0] ?? '') && + /^trusted_hash = "(?:[^"\\]|\\.)*"$/.test(body[1] ?? '') + ) + case 'hook-state-parent': + return header === '[hooks.state]' && body.length === 0 + case null: + return false + } +} + +// Why: older Orca inserted its bare line directly under the header when it missed a quoted key. +function findOrcaInsertedTrustLine(lines: string[], table: TomlTable): number | 'unsafe' | null { + const body = lines.slice(table.headerLine + 1, table.endLine).join('\n') + const entries = findProjectTrustLevelEntries(body) + if (entries.length < 2) { + return null + } + const [inserted, ...others] = entries + const isOrcaInsert = + inserted?.start === 0 && + inserted.line === ORCA_TRUST_LINE && + others.length === 1 && + !/^[ \t]*trust_level/.test(others[0]?.line ?? '') + return isOrcaInsert ? table.headerLine + 1 : 'unsafe' +} + +function findDuplicateTable(content: string): string | null { + const lines = content.split('\n') + const seen = new Set() + for (const table of readTomlTables(lines)) { + if (!table.segments || table.isArray) { + continue + } + const key = JSON.stringify(table.segments) + const body = lines.slice(table.headerLine + 1, table.endLine).join('\n') + if ( + seen.has(key) || + (getTrustTableKind(table) === 'project' && findProjectTrustLevelEntries(body).length > 1) + ) { + return table.header + } + seen.add(key) + } + return null +} + +function refuseRepair(content: string, reason: string): string { + if (!loggedRefusals.has(reason)) { + loggedRefusals.add(reason) + console.warn(`[codex-config] Left a duplicate in config.toml unrepaired: ${reason}`) + } + return content +} + +function stripCr(line: string | undefined): string { + return (line ?? '').replace(/\r$/, '') +} diff --git a/src/main/codex/config-toml-project-trust-level.ts b/src/main/codex/config-toml-project-trust-level.ts new file mode 100644 index 000000000000..9cfa20b515c6 --- /dev/null +++ b/src/main/codex/config-toml-project-trust-level.ts @@ -0,0 +1,55 @@ +import { parseTomlKeyPath } from './config-toml-key-path' +import { + createTomlLineScanState, + isTomlStructuralLine, + parseTomlSingleLineStringValue, + updateTomlLineScanState +} from './config-toml-line-scan' + +export type ProjectTrustLevelEntry = { + /** Null when the value is not a recognised trust level; the key still exists. */ + value: 'trusted' | 'untrusted' | null + /** Offsets of the line within the scanned text, excluding any trailing `\r`. */ + start: number + end: number + line: string +} + +// Why (#22592): Codex writes `"trust_level" = …`; decode the key instead of matching bare text. +export function findProjectTrustLevelEntries(text: string): ProjectTrustLevelEntry[] { + const entries: ProjectTrustLevelEntry[] = [] + let cursor = 0 + let scanState = createTomlLineScanState() + while (cursor <= text.length) { + const newlineIndex = text.indexOf('\n', cursor) + const lineEnd = newlineIndex === -1 ? text.length : newlineIndex + const line = text.slice(cursor, lineEnd).replace(/\r$/, '') + if (isTomlStructuralLine(scanState)) { + const key = parseTomlKeyPath(line) + if (key && key.segments.length === 1 && key.segments[0] === 'trust_level') { + if (line[key.end] === '=') { + entries.push({ + value: parseTrustLevelValue(line, key.end + 1), + start: cursor, + end: cursor + line.length, + line + }) + } + } + } + scanState = updateTomlLineScanState(scanState, line) + if (newlineIndex === -1) { + break + } + cursor = newlineIndex + 1 + } + return entries +} + +function parseTrustLevelValue(line: string, offset: number): 'trusted' | 'untrusted' | null { + const parsed = parseTomlSingleLineStringValue(line, offset) + if (!parsed || !/^[ \t]*(?:#.*)?$/.test(line.slice(parsed.end))) { + return null + } + return parsed.value === 'trusted' || parsed.value === 'untrusted' ? parsed.value : null +} diff --git a/src/main/codex/config-toml-project-trust-spellings.test.ts b/src/main/codex/config-toml-project-trust-spellings.test.ts new file mode 100644 index 000000000000..dd54169542c4 --- /dev/null +++ b/src/main/codex/config-toml-project-trust-spellings.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' +import { findDuplicateTomlDeclarations } from './config-toml-duplicate-declarations-test-fixture' +import { + deduplicateProjectTomlSections, + getProjectTrustLevel, + getTomlSections +} from './config-toml-runtime-owned-sections' +import { upsertProjectTrustLevelInContent } from './config-toml-trust' + +// Why (#22592): Codex's settings screen writes these spellings; each is the same TOML key as Orca's. +const PROJECT = '/Users/me/Projects/agentic-WPS' +const HEADER_SPELLINGS = [ + `["projects"."${PROJECT}"]`, + `['projects'.'${PROJECT}']`, + `[projects.'${PROJECT}']`, + `[ "projects" . "${PROJECT}" ] # trusted from the Codex TUI` +] + +function upsertTrusted(content: string, projectPath = PROJECT): string { + return upsertProjectTrustLevelInContent(content, projectPath, 'trusted', { + alreadyCanonical: true + }) +} + +describe('project trust writes recognise every spelling of the project table', () => { + it.each(HEADER_SPELLINGS)('updates %s instead of appending a duplicate table', (header) => { + const original = [header, 'trust_level = "untrusted"', ''].join('\n') + + const updated = upsertTrusted(original) + + expect(updated).toBe([header, 'trust_level = "trusted"', ''].join('\n')) + expect(findDuplicateTomlDeclarations(updated)).toEqual([]) + }) + + it.each(['"trust_level" = "untrusted"', "'trust_level' = 'untrusted'"])( + 'rewrites a quoted %s key instead of inserting a second one', + (trustLine) => { + const original = [`["projects"."${PROJECT}"]`, 'notes = "keep"', trustLine, ''].join('\n') + + const updated = upsertTrusted(original) + + expect(updated).toBe( + [`["projects"."${PROJECT}"]`, 'notes = "keep"', 'trust_level = "trusted"', ''].join('\n') + ) + } + ) + + it('leaves Codex quoted trust untouched in content when it is already trusted', () => { + const original = [`["projects"."${PROJECT}"]`, '"trust_level" = "trusted"', ''].join('\n') + + const updated = upsertTrusted(original) + + expect(findDuplicateTomlDeclarations(updated)).toEqual([]) + expect(updated.match(/\[/g)).toHaveLength(1) + }) + + it('keeps CRLF line endings on the rewritten trust line', () => { + const original = [`["projects"."${PROJECT}"]`, '"trust_level" = "untrusted"', ''].join('\r\n') + + expect(upsertTrusted(original)).toBe( + [`["projects"."${PROJECT}"]`, 'trust_level = "trusted"', ''].join('\r\n') + ) + }) + + it.each([ + { header: "['projects'.'C:\\Users\\me\\repo']", projectPath: 'C:\\Users\\me\\repo' }, + { header: '["projects"."C:\\\\Users\\\\me\\\\repo"]', projectPath: 'C:\\Users\\me\\repo' }, + { header: '["projects"."c:/users/me/repo"]', projectPath: 'C:\\Users\\me\\repo' }, + { + header: '["projects"."\\\\\\\\wsl.localhost\\\\Ubuntu\\\\home\\\\me\\\\repo"]', + projectPath: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' + } + ])('matches the Windows-path table $header', ({ header, projectPath }) => { + const original = [header, '"trust_level" = "untrusted"', ''].join('\n') + + const updated = upsertTrusted(original, projectPath) + + expect(updated).toBe([header, 'trust_level = "trusted"', ''].join('\n')) + }) +}) + +describe('the config mirror treats every spelling as one project table', () => { + it('reads a quoted trust_level key', () => { + expect(getProjectTrustLevel(`["projects"."${PROJECT}"]\n"trust_level" = "untrusted"`)).toBe( + 'untrusted' + ) + }) + + it('deduplicates quoted, literal and bare spellings of one project', () => { + const config = [ + `["projects"."${PROJECT}"]`, + '"trust_level" = "trusted"', + '', + `['projects'.'${PROJECT}']`, + "trust_level = 'trusted'", + '', + `[projects."${PROJECT}"]`, + 'trust_level = "trusted"', + '' + ].join('\n') + + const sections = deduplicateProjectTomlSections(getTomlSections(config)) + + expect(sections.map((section) => section.header)).toEqual([`["projects"."${PROJECT}"]`]) + }) + + describe('syncSystemConfigIntoManagedCodexHome', () => { + let root: string + let runtimeHomePath: string + let systemHomePath: string + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-project-spellings-')) + runtimeHomePath = join(root, 'runtime') + systemHomePath = join(root, 'system') + mkdirSync(runtimeHomePath) + mkdirSync(systemHomePath) + }) + + afterEach(() => rmSync(root, { recursive: true, force: true })) + + it('writes one table when the system and managed homes spell the project differently', () => { + writeFileSync( + join(systemHomePath, 'config.toml'), + [ + 'model = "gpt-5.5"', + '', + `["projects"."${PROJECT}"]`, + '"trust_level" = "trusted"', + '' + ].join('\n') + ) + writeFileSync( + join(runtimeHomePath, 'config.toml'), + [`[projects."${PROJECT}"]`, 'trust_level = "trusted"', ''].join('\n') + ) + + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + + const runtimeConfig = readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8') + expect(runtimeConfig).toContain('model = "gpt-5.5"') + expect(runtimeConfig.match(/projects/g)).toHaveLength(1) + expect(findDuplicateTomlDeclarations(runtimeConfig)).toEqual([]) + }) + + it('collapses a managed home already holding both spellings', () => { + writeFileSync(join(systemHomePath, 'config.toml'), 'model = "gpt-5.5"\n') + writeFileSync( + join(runtimeHomePath, 'config.toml'), + [ + `["projects"."${PROJECT}"]`, + '"trust_level" = "trusted"', + '', + `[projects."${PROJECT}"]`, + 'trust_level = "trusted"', + '' + ].join('\n') + ) + + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + + const runtimeConfig = readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8') + expect(runtimeConfig.match(/projects/g)).toHaveLength(1) + expect(findDuplicateTomlDeclarations(runtimeConfig)).toEqual([]) + }) + }) +}) diff --git a/src/main/codex/config-toml-project-trust.ts b/src/main/codex/config-toml-project-trust.ts index c4cd8f149d1c..9335605cb15b 100644 --- a/src/main/codex/config-toml-project-trust.ts +++ b/src/main/codex/config-toml-project-trust.ts @@ -11,6 +11,8 @@ import { findNextTomlTableHeader, parseProjectTomlHeaderPath } from './config-toml-syntax' +import { findProjectTrustLevelEntries } from './config-toml-project-trust-level' +import { repairOrcaDuplicateTrustTables } from './config-toml-project-duplicate-repair' export function upsertProjectTrustContent( existingContent: string, @@ -18,7 +20,7 @@ export function upsertProjectTrustContent( trustLevel: CodexProjectTrustLevel, options?: { alreadyCanonical?: boolean } ): string { - const existing = stripLeadingBom(existingContent) + const existing = repairOrcaDuplicateTrustTables(stripLeadingBom(existingContent)) const trustedProjectPath = options?.alreadyCanonical ? projectPath : canonicalizeLocalProjectPath(projectPath) @@ -30,14 +32,13 @@ export function upsertProjectTrustContent( } 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)) { + // Why: rewrite any existing trust_level key, whatever its spelling or value, so none is duplicated. + const existingEntry = findProjectTrustLevelEntries(existing.slice(headerLineEnd, blockEnd))[0] + if (existingEntry) { return ( - existing.slice(0, headerLineEnd) + - existingBlock.replace(trustLevelPattern, trustLine) + - existing.slice(blockEnd) + existing.slice(0, headerLineEnd + existingEntry.start) + + trustLine + + existing.slice(headerLineEnd + existingEntry.end) ) } return `${existing.slice(0, headerLineEnd)}${eol}${trustLine}${existing.slice(headerLineEnd)}` diff --git a/src/main/codex/config-toml-runtime-owned-sections.ts b/src/main/codex/config-toml-runtime-owned-sections.ts index fee0cfdf6432..6e07cca99cd9 100644 --- a/src/main/codex/config-toml-runtime-owned-sections.ts +++ b/src/main/codex/config-toml-runtime-owned-sections.ts @@ -6,7 +6,11 @@ import { updateTomlLineScanState } from './config-toml-line-scan' import { parseTomlTableHeaderPath } from './config-toml-key-path' +import { findProjectTrustLevelEntries } from './config-toml-project-trust-level' +import { parseHookStateTomlHeaderKey } from './config-toml-syntax' import { + codexHookSourcePathsEqual, + getCodexExplicitHomeHookSourcePath, normalizeCodexProjectPathForLookup, normalizeCodexProjectPathForRevocationLookup, parseCodexProjectHeaderPath @@ -18,19 +22,32 @@ export type TomlSection = { start: number } +export type SharedHookTrustCarry = { + systemHomeDir: string + /** Decoded hook-trust keys the runtime config already holds. */ + runtimeHookTrustKeys: ReadonlySet +} + export function stripRuntimeOwnedTomlSections( config: string, - runtimeProjectHeaders = new Set() + runtimeProjectHeaders = new Set(), + sharedHookTrust?: SharedHookTrustCarry ): string { const lines = config.split('\n') const sourceSections = getTomlSections(config) const sections = deduplicateProjectTomlSections(sourceSections) const firstSectionIndex = sourceSections[0]?.start ?? -1 const preamble = firstSectionIndex === -1 ? config : lines.slice(0, firstSectionIndex).join('\n') + const carriedHookTrustKeys = new Set(sharedHookTrust?.runtimeHookTrustKeys) return joinTomlBlocks([ preamble, ...sections - .filter((section) => !isRuntimeHookTrustTomlSection(section.header)) + .filter( + (section) => + !isRuntimeHookTrustTomlSection(section.header) || + (sharedHookTrust !== undefined && + claimSharedHookTrustSection(section.header, sharedHookTrust, carriedHookTrustKeys)) + ) .filter( (section) => !isRuntimeProjectTomlSection(section.header) || @@ -82,10 +99,59 @@ export function isRuntimePreservedTomlSection(header: string): boolean { } export function isRuntimeHookTrustTomlSection(header: string): boolean { - const trimmed = header.trim() + const table = parseTomlTableHeaderPath(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.') + // Its `["hooks"."state"]` spelling is the same table (#22592). + return !!table && !table.isArray && table.segments[0] === 'hooks' && table.segments[1] === 'state' +} + +// Why: Codex's `{source}:{event}:{group}:{handler}` for any label, incl. session_end/interrupt. +const CODEX_HOOK_TRUST_KEY = /^(.+):[a-z_]+:(?:0|[1-9]\d*):(?:0|[1-9]\d*)$/ + +// Why: user-layer keys name the home's own hooks.json/config.toml; plugin/project keys don't. +export function classifyHookTrustKey(key: string, homeDir: string): 'home-scoped' | 'shared' { + const sourcePath = CODEX_HOOK_TRUST_KEY.exec(key)?.[1] + if (sourcePath === undefined) { + // Why: Codex never writes another key shape; carry nothing we cannot attribute. + return 'home-scoped' + } + const homeFiles = ['hooks.json', 'config.toml'].flatMap((file) => { + // Why: not path.join; a WSL home is a Linux path even when Orca runs on Windows. + const logicalPath = `${homeDir.replace(/[\\/]+$/, '')}/${file}` + return [logicalPath, getCodexExplicitHomeHookSourcePath(logicalPath)] + }) + return homeFiles.some((homeFile) => codexHookSourcePathsEqual(sourcePath, homeFile)) + ? 'home-scoped' + : 'shared' +} + +export function getHookTrustTomlSectionKeys(sections: readonly TomlSection[]): Set { + const keys = new Set() + for (const section of sections) { + const key = parseHookStateTomlHeaderKey(section.header) + if (key !== null) { + keys.add(key) + } + } + return keys +} + +// Why: runtime copy wins, else a duplicate table breaks Codex; exact keys keep slash variants apart. +function claimSharedHookTrustSection( + header: string, + { systemHomeDir }: SharedHookTrustCarry, + claimedKeys: Set +): boolean { + const key = parseHookStateTomlHeaderKey(header) + if (key === null || classifyHookTrustKey(key, systemHomeDir) === 'home-scoped') { + return false + } + if (claimedKeys.has(key)) { + return false + } + claimedKeys.add(key) + return true } export function isRuntimeProjectTomlSection(header: string): boolean { @@ -150,12 +216,7 @@ 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 + return findProjectTrustLevelEntries(block).find((entry) => entry.value !== null)?.value ?? 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..a9f9ed5efc4c 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 @@ -21,75 +18,26 @@ export function escapeTomlBasicString(value: string): string { } 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 + return parseTomlTableHeaderLeafKey(line, ['hooks', 'state']) } 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 + return parseTomlTableHeaderLeafKey(line, ['projects']) } -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] !== ']') { +// Why (#22592): Codex writes `["projects"."/p"]`; every spelling of the key path must match. +function parseTomlTableHeaderLeafKey(line: string, parent: readonly string[]): string | null { + const header = getTomlTableHeader(line.replace(/\r$/, '')) + const table = header === null ? null : parseTomlTableHeaderPath(header) + if ( + !table || + table.isArray || + table.segments.length !== parent.length + 1 || + parent.some((segment, index) => table.segments[index] !== segment) + ) { 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 } -} - -function skipTomlInlineWhitespace(line: string, startIndex: number): number { - let index = startIndex - while (line[index] === ' ' || line[index] === '\t') { - index += 1 - } - return index + return table.segments.at(-1) ?? null } export function findNextTomlTableHeader(text: string): number { diff --git a/src/main/codex/hook-service-user-hook-trust-spellings.test.ts b/src/main/codex/hook-service-user-hook-trust-spellings.test.ts new file mode 100644 index 000000000000..3a052f7580b7 --- /dev/null +++ b/src/main/codex/hook-service-user-hook-trust-spellings.test.ts @@ -0,0 +1,135 @@ +import { 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 { + escapeTomlString, + getCodexExplicitHomeHookSourcePath, + upsertHookTrustEntriesInContent +} from './config-toml-trust' +import { parseHookStateTomlHeaderKey } from './config-toml-syntax' +import { getTomlSections } from './config-toml-runtime-owned-sections' +import { hookTrustHeader, 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 (#22592): Codex may write any of these; each names the same hooks.state table. +const HOOK_STATE_SPELLINGS = [ + ['bare parent', (key: string) => `[hooks.state."${escapeTomlString(key)}"]`], + ['fully quoted', (key: string) => `["hooks"."state"."${escapeTomlString(key)}"]`], + ['literal leaf', (key: string) => `[hooks.state.'${key}']`], + ['mixed', (key: string) => `[hooks."state"."${escapeTomlString(key)}"]`] +] as const + +function seedTrustedSystemUserHook( + spell: (key: string) => string, + enabled: boolean +): { trustedHash: string } { + const systemCodexHome = join(homes.tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'user-hook' }] }] } })}\n`, + 'utf-8' + ) + const bareToml = upsertHookTrustEntriesInContent('model = "system-model"\n', [ + { + sourcePath: systemHooksPath, + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 0, + command: 'user-hook', + enabled + } + ]) + const headerLine = bareToml.split('\n').find((line) => parseHookStateTomlHeaderKey(line) !== null) + const key = headerLine === undefined ? null : parseHookStateTomlHeaderKey(headerLine) + const trustedHash = /trusted_hash = "([^"]+)"/.exec(bareToml)?.[1] + expect(headerLine).toBeDefined() + expect(key).not.toBeNull() + expect(trustedHash).toBeDefined() + writeFileSync( + join(systemCodexHome, 'config.toml'), + bareToml.replace(headerLine!, spell(key!)), + 'utf-8' + ) + return { trustedHash: trustedHash! } +} + +function expectNoDuplicateTables(toml: string): void { + const headers = getTomlSections(toml).map((section) => section.header.replace(/\s+/g, '')) + expect(new Set(headers).size).toBe(headers.length) + const hookKeys = headers.flatMap((header) => parseHookStateTomlHeaderKey(header) ?? []) + expect(new Set(hookKeys).size).toBe(hookKeys.length) +} + +describe('CodexHookService user-hook trust spellings', () => { + describe.each(HOOK_STATE_SPELLINGS)('%s ~/.codex trust', (_name, spell) => { + it.each([true, false])( + 're-keys trusted_hash and enabled=%s into the runtime CODEX_HOME', + async (enabled) => { + const { trustedHash } = seedTrustedSystemUserHook(spell, enabled) + + const service = new CodexHookService() + expect((await service.install()).state).toBe('installed') + // Why: the second launch mirrors onto a runtime that already holds the entry. + expect((await service.install()).state).toBe('installed') + + const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home') + const managedHooksPath = join(managedCodexHome, 'hooks.json') + const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(runtimeToml).toContain( + `${hookTrustHeader(`${managedHooksPath}:stop:1:0`)}\nenabled = ${enabled}\ntrusted_hash = "${trustedHash}"` + ) + expectNoDuplicateTables(runtimeToml) + } + ) + }) + + it.each(HOOK_STATE_SPELLINGS)( + 'applies ~/.codex enabled=false over a %s runtime copy Codex wrote inside Orca', + async (_name, spell) => { + const { trustedHash } = seedTrustedSystemUserHook(HOOK_STATE_SPELLINGS[0][1], false) + const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home') + const managedHooksPath = join(managedCodexHome, 'hooks.json') + mkdirSync(managedCodexHome, { recursive: true }) + const runtimeKey = `${getCodexExplicitHomeHookSourcePath(managedHooksPath)}:stop:1:0` + writeFileSync( + join(managedCodexHome, 'config.toml'), + `${spell(runtimeKey)}\nenabled = true\ntrusted_hash = "sha256:stale"\n`, + 'utf-8' + ) + + expect((await new CodexHookService().install()).state).toBe('installed') + + const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(runtimeToml).toContain( + `${hookTrustHeader(`${managedHooksPath}:stop:1:0`)}\nenabled = false\ntrusted_hash = "${trustedHash}"` + ) + expect(runtimeToml).not.toContain('sha256:stale') + expectNoDuplicateTables(runtimeToml) + } + ) +})