From e25985de7371d1c6a1058c89624417df4b8d3dfe Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:41:14 -0700 Subject: [PATCH] fix(codex): make Windows trust upgrade transactional --- ...home-service-per-account-migration.test.ts | 7 +++--- src/main/codex/codex-hook-trust-grant.test.ts | 22 +++++++++++++++++++ src/main/codex/codex-hook-trust-grant.ts | 18 +++++++++++++++ .../codex/hook-service-trust-grant.test.ts | 3 ++- 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts index 51eb745c64d..9d27a7130ed 100644 --- a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts +++ b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type { CodexManagedAccount, GlobalSettings } from '../../shared/types' import type * as NodeOs from 'node:os' +import { readHookTrustEntries } from '../codex/config-toml-trust' const testState = { userData: '', home: '' } const previousEnv: Record = {} @@ -98,9 +99,9 @@ describe('CodexRuntimeHomeService per-account takeover composition', () => { expect(readFileSync(join(account.managedHomePath, 'hooks.json'), 'utf8')).toContain( process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' ) - expect(readFileSync(join(account.managedHomePath, 'config.toml'), 'utf8')).toContain( - '[hooks.state."' - ) + expect( + readHookTrustEntries(join(account.managedHomePath, 'config.toml')).size + ).toBeGreaterThan(0) } const discoveryHomes = service.getHostCodexHomePathsForSessionDiscovery() diff --git a/src/main/codex/codex-hook-trust-grant.test.ts b/src/main/codex/codex-hook-trust-grant.test.ts index 59f2807605d..2c22e32d16b 100644 --- a/src/main/codex/codex-hook-trust-grant.test.ts +++ b/src/main/codex/codex-hook-trust-grant.test.ts @@ -19,7 +19,9 @@ import { import { readCodexTrustGrantLedgerHome } from './codex-trust-grant-ledger' import { computeTrustKey, + computeTrustedHash, normalizeHookTrustKeyForLookup, + readHookTrustEntries, upsertHookTrustEntries, type CodexTrustEntry } from './config-toml-trust' @@ -133,6 +135,26 @@ describe('grantManagedCodexHookTrust', () => { expect(invocation.envToDelete).toContain('CODEX_HOME') }) + it('removes equivalent Windows fallback keys before the RPC writes canonical trust', () => { + const entry: CodexTrustEntry = { + ...managedEntry('stop'), + sourcePath: String.raw`C:\Users\Alice\.codex\hooks.json` + } + const plan = buildPlan([entry]) + upsertHookTrustEntries(plan.tomlPath, [entry]) + expect(readHookTrustEntries(plan.tomlPath).get(computeTrustKey(entry))?.trustedHash).toBe( + computeTrustedHash(entry) + ) + const runner = vi.fn(() => { + expect(readHookTrustEntries(plan.tomlPath).has(computeTrustKey(entry))).toBe(false) + return grantedSessionResult([entry]) + }) + _internals.setGrantSessionRunnerSync(runner) + + expect(grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'rpc' }) + expect(runner).toHaveBeenCalledTimes(1) + }) + it('skips the RPC session while the ledger grant still holds, and re-grants on config drift', () => { const entries = [managedEntry('session_start')] const runner = vi.fn(() => grantedSessionResult(entries)) diff --git a/src/main/codex/codex-hook-trust-grant.ts b/src/main/codex/codex-hook-trust-grant.ts index 322d4b39116..5366187d47b 100644 --- a/src/main/codex/codex-hook-trust-grant.ts +++ b/src/main/codex/codex-hook-trust-grant.ts @@ -15,8 +15,10 @@ import { } from './codex-trust-grant-ledger' import { computeTrustKey, + computeTrustedHash, normalizeHookTrustKeyForLookup, readHookTrustEntries, + removeHookTrustEntries, type CodexTrustEntry } from './config-toml-trust' import { getCodexHookTrustSignature } from './codex-hook-identity' @@ -149,6 +151,19 @@ function buildExpectedEntries(plan: CodexManagedTrustGrantPlan): ExpectedManaged })) } +function removeSelfComputedTrustBeforeGrant(plan: CodexManagedTrustGrantPlan): void { + const trustStates = readHookTrustEntries(plan.tomlPath) + const ownedKeys = plan.managedEntries + .map((entry) => { + const key = computeTrustKey(entry) + return trustStates.get(key)?.trustedHash === computeTrustedHash(entry) ? key : null + }) + .filter((key): key is string => key !== null) + if (ownedKeys.length > 0) { + removeHookTrustEntries(plan.tomlPath, ownedKeys) + } +} + function findLedgerGrant( plan: CodexManagedTrustGrantPlan, expected: ExpectedManagedEntry[], @@ -224,6 +239,9 @@ export function grantManagedCodexHookTrust( const configSnapshot = captureCodexTrustConfig(plan.tomlPath) let result: CodexHookTrustGrantSessionResult try { + // Why: Windows fallback writes equivalent separator variants that Codex's + // canonical RPC key may not overwrite, leaving conflicting logical trust. + removeSelfComputedTrustBeforeGrant(plan) result = runSessionSync( resolvedHost.buildRequest({ runtimeHomePath: plan.runtimeHomePath, diff --git a/src/main/codex/hook-service-trust-grant.test.ts b/src/main/codex/hook-service-trust-grant.test.ts index c7c1da9c486..42b81e02064 100644 --- a/src/main/codex/hook-service-trust-grant.test.ts +++ b/src/main/codex/hook-service-trust-grant.test.ts @@ -19,6 +19,7 @@ import { computeTrustKey, computeTrustedHash, escapeTomlString, + normalizeHookTrustKeyForLookup, parseTrustKey, readHookTrustEntries, upsertHookTrustEntries, @@ -214,7 +215,7 @@ describe('CodexHookService app-server trust grant lane', () => { writeCodexTrustGrantLedgerHome(systemHome, { binary: null, entries: { - [trustKey]: { + [normalizeHookTrustKeyForLookup(trustKey)]: { signature: getCodexHookTrustSignature(entry), trustedHash }