From d4f549364bddb71fffd90e0fd91187b9001772c2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:34:20 -0700 Subject: [PATCH] fix(codex): harden trust grant reconciliation --- config/tsconfig.cli.json | 2 + .../codex/codex-app-server-client.test.ts | 49 +++- .../codex/codex-app-server-grant-bridge.ts | 21 +- src/main/codex/codex-hook-trust-grant.test.ts | 29 +++ src/main/codex/codex-hook-trust-grant.ts | 105 +++----- .../codex-managed-trust-reconciliation.ts | 151 +++++++++++ .../codex/codex-trust-config-rollback.test.ts | 54 ++++ src/main/codex/codex-trust-config-rollback.ts | 59 ++++- src/main/codex/codex-trust-grant-host.test.ts | 49 ++++ src/main/codex/codex-trust-grant-host.ts | 94 +++++++ .../codex/codex-wsl-hook-install-plan.test.ts | 13 +- src/main/codex/codex-wsl-hook-install-plan.ts | 6 +- .../codex/hook-service-trust-grant.test.ts | 236 ++++++++++++++++++ .../codex/hook-service-wsl-runtime.test.ts | 58 +++++ src/main/codex/hook-service.test.ts | 232 +---------------- src/main/codex/hook-service.ts | 215 ++++------------ 16 files changed, 891 insertions(+), 482 deletions(-) create mode 100644 src/main/codex/codex-managed-trust-reconciliation.ts create mode 100644 src/main/codex/codex-trust-config-rollback.test.ts create mode 100644 src/main/codex/codex-trust-grant-host.test.ts create mode 100644 src/main/codex/codex-trust-grant-host.ts create mode 100644 src/main/codex/hook-service-trust-grant.test.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 81792bba35e..0d278a1e854 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -20,7 +20,9 @@ "../src/main/codex/codex-home-paths.ts", "../src/main/codex/codex-hook-identity.ts", "../src/main/codex/codex-hook-trust-grant.ts", + "../src/main/codex/codex-managed-trust-reconciliation.ts", "../src/main/codex/codex-trust-config-rollback.ts", + "../src/main/codex/codex-trust-grant-host.ts", "../src/main/codex/codex-trust-grant-ledger.ts", "../src/main/codex/codex-wsl-hook-install-plan.ts", "../src/main/codex/config-settings-promotion.ts", diff --git a/src/main/codex/codex-app-server-client.test.ts b/src/main/codex/codex-app-server-client.test.ts index f59f888568a..2e263f12384 100644 --- a/src/main/codex/codex-app-server-client.test.ts +++ b/src/main/codex/codex-app-server-client.test.ts @@ -9,7 +9,10 @@ import { runCodexHookTrustGrantSession, type CodexHookTrustGrantRequest } from './codex-app-server-client' -import { runCodexHookTrustGrantSessionSync } from './codex-app-server-grant-bridge' +import { + resolveCodexGrantEntryPath, + runCodexHookTrustGrantSessionSync +} from './codex-app-server-grant-bridge' // Stub codex app-server speaking the same JSONL protocol: initialize → // initialized → hooks/list → config/batchWrite → hooks/list. Scenario-driven @@ -331,4 +334,48 @@ describe('runCodexHookTrustGrantSessionSync', () => { /produced no result \(exit 7\)/ ) }) + + it('classifies the spawnSync deadline as a typed timeout', () => { + const entryPath = writeEntryFixture(`setInterval(() => {}, 1000)`) + const request = { + ...baseRequest, + invocation: { ...baseRequest.invocation, timeoutMs: 20 } + } + expect(() => + runCodexHookTrustGrantSessionSync(request, { entryPath, timeoutMarginMs: 20 }) + ).toThrow(CodexAppServerTimeoutError) + }) +}) + +describe('resolveCodexGrantEntryPath', () => { + const entryName = 'codex-app-server-grant-entry.js' + + it('finds the sibling entry from emitted main and chunk directories', () => { + const mainDir = join('/opt', 'orca', 'out', 'main') + expect( + resolveCodexGrantEntryPath( + (candidate) => candidate === join(mainDir, 'codex', entryName), + mainDir + ) + ).toBe(join(mainDir, 'codex', entryName)) + + const chunkDir = join(mainDir, 'chunks') + expect( + resolveCodexGrantEntryPath( + (candidate) => candidate === join(mainDir, 'codex', entryName), + chunkDir + ) + ).toBe(join(mainDir, 'codex', entryName)) + }) + + it('redirects app.asar to unpacked without double-unpacking an existing path', () => { + const resourcesDir = join('/Applications', 'Orca.app', 'Contents', 'Resources') + const expected = join(resourcesDir, 'app.asar.unpacked', 'out', 'main', 'codex', entryName) + for (const archiveDir of ['app.asar', 'app.asar.unpacked']) { + const moduleDir = join(resourcesDir, archiveDir, 'out', 'main', 'chunks') + expect(resolveCodexGrantEntryPath((candidate) => candidate === expected, moduleDir)).toBe( + expected + ) + } + }) }) diff --git a/src/main/codex/codex-app-server-grant-bridge.ts b/src/main/codex/codex-app-server-grant-bridge.ts index a42c2a3b7ac..88f8d6ef80a 100644 --- a/src/main/codex/codex-app-server-grant-bridge.ts +++ b/src/main/codex/codex-app-server-grant-bridge.ts @@ -22,7 +22,8 @@ const GRANT_ENTRY_TIMEOUT_MARGIN_MS = 5_000 const GRANT_ENTRY_MAX_BUFFER_BYTES = 16 * 1024 * 1024 export function resolveCodexGrantEntryPath( - pathExists: (candidate: string) => boolean = existsSync + pathExists: (candidate: string) => boolean = existsSync, + moduleDir = __dirname ): string | null { // Why: resolved from __dirname (not electron's app paths) so this module // stays loadable in plain-node CLI entries — the build guard rejects any @@ -30,9 +31,9 @@ export function resolveCodexGrantEntryPath( // out/main or out/main/chunks, so the entry is one or two levels up. // ELECTRON_RUN_AS_NODE bypasses asar integration, so packaged builds must // run the copy under app.asar.unpacked (out/main/codex/** is asarUnpacked). - const baseDirs = [__dirname, join(__dirname, '..')].map((dir) => - dir.includes('app.asar') ? dir.replace('app.asar', 'app.asar.unpacked') : dir - ) + const toUnpackedDir = (dir: string): string => + dir.replace(/([\\/])app\.asar(?=([\\/]|$))/, '$1app.asar.unpacked') + const baseDirs = [moduleDir, join(moduleDir, '..')].map(toUnpackedDir) for (const baseDir of baseDirs) { const candidate = join(baseDir, 'codex', GRANT_ENTRY_FILE_NAME) if (pathExists(candidate)) { @@ -45,6 +46,8 @@ export function resolveCodexGrantEntryPath( export type RunGrantSessionSyncOptions = { entryPath?: string nodeCommand?: string + /** Test-only override; production keeps enough margin for child cleanup. */ + timeoutMarginMs?: number } /** @@ -67,12 +70,20 @@ export function runCodexHookTrustGrantSessionSync( const spawned = spawnSync(options.nodeCommand ?? process.execPath, [entryPath], { input: JSON.stringify(request), encoding: 'utf8', - timeout: request.invocation.timeoutMs + GRANT_ENTRY_TIMEOUT_MARGIN_MS, + timeout: + request.invocation.timeoutMs + (options.timeoutMarginMs ?? GRANT_ENTRY_TIMEOUT_MARGIN_MS), killSignal: 'SIGKILL', maxBuffer: GRANT_ENTRY_MAX_BUFFER_BYTES, windowsHide: true, env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } }) + if ((spawned.error as NodeJS.ErrnoException | undefined)?.code === 'ETIMEDOUT') { + // Why: spawnSync reports its own deadline through error.code before the + // signal field; preserve the typed timeout so cooldown diagnostics work. + throw new CodexAppServerTimeoutError( + `codex trust-grant entry exceeded ${request.invocation.timeoutMs}ms session deadline` + ) + } if (spawned.error) { throw spawned.error } diff --git a/src/main/codex/codex-hook-trust-grant.test.ts b/src/main/codex/codex-hook-trust-grant.test.ts index 0b26f04f9ff..5f184355b58 100644 --- a/src/main/codex/codex-hook-trust-grant.test.ts +++ b/src/main/codex/codex-hook-trust-grant.test.ts @@ -13,6 +13,7 @@ import { CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS, getCodexTrustGrantDiagnostics, grantManagedCodexHookTrust, + setCodexTrustGrantTelemetry, type CodexManagedTrustGrantPlan } from './codex-hook-trust-grant' import { readCodexTrustGrantLedgerHome } from './codex-trust-grant-ledger' @@ -39,6 +40,7 @@ beforeEach(() => { afterEach(() => { vi.useRealTimers() _internals.setGrantSessionRunnerSync(null) + setCodexTrustGrantTelemetry(() => {}) codexAppServerCapabilityCache.clear() if (previousUserDataPath === undefined) { delete process.env.ORCA_USER_DATA_PATH @@ -218,6 +220,33 @@ describe('grantManagedCodexHookTrust', () => { expect(getCodexTrustGrantDiagnostics()).toMatchObject({ verifyFailed: 1 }) }) + it('rejects duplicate granted keys instead of treating another key as covered', () => { + const entries = [managedEntry('session_start'), managedEntry('stop')] + const duplicated = grantedSessionResult([entries[0]!, entries[0]!]) + _internals.setGrantSessionRunnerSync(() => duplicated) + + expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ + lane: 'fallback', + reason: 'verify-failed' + }) + expect(readCodexTrustGrantLedgerHome(runtimeHomeDir)).toBeNull() + }) + + it('keeps grant and fallback outcomes stable when telemetry throws', () => { + const entries = [managedEntry('session_start')] + setCodexTrustGrantTelemetry(() => { + throw new Error('telemetry unavailable') + }) + _internals.setGrantSessionRunnerSync(() => grantedSessionResult(entries)) + + expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ lane: 'rpc' }) + process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' + expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ + lane: 'fallback', + reason: 'disabled' + }) + }) + it('restores exact config bytes before fallback after a mutating RPC error', () => { const entries = [managedEntry('session_start')] const plan = buildPlan(entries) diff --git a/src/main/codex/codex-hook-trust-grant.ts b/src/main/codex/codex-hook-trust-grant.ts index ec8edecfaaa..b66c63b65cb 100644 --- a/src/main/codex/codex-hook-trust-grant.ts +++ b/src/main/codex/codex-hook-trust-grant.ts @@ -1,6 +1,3 @@ -import { resolveCodexCommand } from '../codex-cli/command' -import { getSpawnArgsForWindows } from '../win32-utils' -import { buildWslCodexAppServerArgs } from '../codex-accounts/wsl-codex-command' import { isCodexAppServerUnsupportedError, type CodexHookTrustGrantRequest, @@ -12,9 +9,6 @@ import { getCodexAppServerHostKey } from './codex-app-server-capability-cache' import { - binaryStampsMatch, - buildNativeCodexBinaryStamp, - readCodexTrustGrantLedgerHome, writeCodexTrustGrantLedgerHome, type CodexTrustGrantBinaryStamp, type CodexTrustGrantLedgerEntry @@ -27,12 +21,12 @@ import { } from './config-toml-trust' import { getCodexHookTrustSignature } from './codex-hook-identity' import { captureCodexTrustConfig, restoreCodexTrustConfig } from './codex-trust-config-rollback' +import { + readCodexTrustGrantLedgerHomeMatchingStamp, + resolveCodexTrustGrantHost, + type CodexTrustGrantHost +} from './codex-trust-grant-host' -// Why: grants must never make launch prep slower than the codex TUI's own -// startup on the same host. Native sessions complete in ~100ms; WSL pays -// wsl.exe + login-shell + possible cold-distro costs, so it gets more room. -const NATIVE_GRANT_TIMEOUT_MS = 10_000 -const WSL_GRANT_TIMEOUT_MS = 30_000 // Why: a transiently hung app-server must not block launch prep on every pane. // The legacy lane remains available while a short, host-scoped cooldown runs. export const CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS = 5 * 60_000 @@ -40,10 +34,6 @@ export const CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS = 5 * 60_000 /** Ops escape hatch (not a setting): forces the unchanged fallback lane. */ const DISABLE_ENV_FLAG = 'ORCA_DISABLE_CODEX_TRUST_RPC' -export type CodexTrustGrantHost = - | { kind: 'native' } - | { kind: 'wsl'; distro: string; linuxRuntimeHome: string } - export type CodexManagedTrustGrantPlan = { /** Host-visible runtime home path (UNC for WSL) — ledger key + config reads. */ runtimeHomePath: string @@ -105,6 +95,16 @@ export function setCodexTrustGrantTelemetry(tracker: CodexTrustGrantTelemetry): telemetry = tracker } +function emitTelemetry(event: Parameters[0]): void { + try { + telemetry(event) + } catch (error) { + // Why: observability must never turn a verified grant into fallback or + // violate this launch-prep API's no-throw contract. + console.warn('[codex-trust-grant] failed to emit telemetry', error) + } +} + type GrantSessionRunnerSync = ( request: CodexHookTrustGrantRequest ) => CodexHookTrustGrantSessionResult @@ -125,7 +125,7 @@ function fallback( `[codex-trust-grant] falling back to self-computed trust (reason=${reason}, host=${plan.host.kind})`, detail ?? '' ) - telemetry({ + emitTelemetry({ outcome: reason === 'verify-failed' ? 'verify_failed' : 'fallback', hostKind: plan.host.kind, reason @@ -147,24 +147,13 @@ function buildExpectedEntries(plan: CodexManagedTrustGrantPlan): ExpectedManaged })) } -function resolveCurrentBinaryStamp(host: CodexTrustGrantHost): CodexTrustGrantBinaryStamp | null { - if (host.kind === 'wsl') { - return { kind: 'wsl', distro: host.distro } - } - const command = resolveCodexCommand() - // Why: an unresolved bare command cannot be stat'ed; a null stamp still - // allows ledger skips (config + signature checks gate them) and heals to a - // real stamp on the next grant once the binary is resolvable. - return command === 'codex' ? null : buildNativeCodexBinaryStamp(command) -} - function findLedgerGrant( plan: CodexManagedTrustGrantPlan, expected: ExpectedManagedEntry[], currentStamp: CodexTrustGrantBinaryStamp | null ): CodexTrustEntry[] | null { - const home = readCodexTrustGrantLedgerHome(plan.runtimeHomePath) - if (!home || !binaryStampsMatch(home.binary, currentStamp)) { + const home = readCodexTrustGrantLedgerHomeMatchingStamp(plan.runtimeHomePath, currentStamp) + if (!home) { return null } let trustStates: ReturnType @@ -187,39 +176,6 @@ function findLedgerGrant( return entries } -function buildGrantRequest( - plan: CodexManagedTrustGrantPlan, - expected: ExpectedManagedEntry[] -): CodexHookTrustGrantRequest { - if (plan.host.kind === 'wsl') { - return { - invocation: { - command: 'wsl.exe', - args: buildWslCodexAppServerArgs(plan.host.distro, plan.host.linuxRuntimeHome), - timeoutMs: WSL_GRANT_TIMEOUT_MS - }, - hooksListCwd: plan.host.linuxRuntimeHome, - expectedTrustKeys: expected.map(({ normalizedKey }) => normalizedKey), - managedCommand: plan.managedCommand - } - } - const codexCommand = resolveCodexCommand() - // Why: npm-installed codex on Windows is a .cmd shim that spawn cannot run - // without cmd.exe /c; args-array + shell:true would hit DEP0190 instead. - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(codexCommand, ['app-server']) - return { - invocation: { - command: spawnCmd, - args: spawnArgs, - env: { CODEX_HOME: plan.runtimeHomePath }, - timeoutMs: NATIVE_GRANT_TIMEOUT_MS - }, - hooksListCwd: plan.runtimeHomePath, - expectedTrustKeys: expected.map(({ normalizedKey }) => normalizedKey), - managedCommand: plan.managedCommand - } -} - /** * Grants trust for Orca's managed Codex hooks through codex's own app-server * RPCs, verified by re-list. Returns the granted entries carrying Codex's @@ -239,7 +195,8 @@ export function grantManagedCodexHookTrust( return fallback(plan, 'no-managed-entries') } const expected = buildExpectedEntries(plan) - const currentStamp = resolveCurrentBinaryStamp(plan.host) + const resolvedHost = resolveCodexTrustGrantHost(plan.host) + const currentStamp = resolvedHost.binaryStamp const ledgerEntries = findLedgerGrant(plan, expected, currentStamp) if (ledgerEntries !== null) { diagnostics.ledgerHits += 1 @@ -265,7 +222,13 @@ export function grantManagedCodexHookTrust( const configSnapshot = captureCodexTrustConfig(plan.tomlPath) let result: CodexHookTrustGrantSessionResult try { - result = runSessionSync(buildGrantRequest(plan, expected)) + result = runSessionSync( + resolvedHost.buildRequest({ + runtimeHomePath: plan.runtimeHomePath, + managedCommand: plan.managedCommand, + expectedTrustKeys: expected.map(({ normalizedKey }) => normalizedKey) + }) + ) } catch (error) { restoreCodexTrustConfig(plan.tomlPath, configSnapshot) if (isCodexAppServerUnsupportedError(error)) { @@ -292,6 +255,7 @@ export function grantManagedCodexHookTrust( } const byNormalizedKey = new Map(expected.map((item) => [item.normalizedKey, item])) + const seenNormalizedKeys = new Set() const grantedEntries: CodexTrustEntry[] = [] const ledgerRecord: Record = {} for (const granted of result.entries) { @@ -304,13 +268,22 @@ export function grantManagedCodexHookTrust( ) return fallback(plan, 'verify-failed', `unexpected granted key ${granted.key}`) } + if (seenNormalizedKeys.has(granted.normalizedKey)) { + restoreCodexTrustConfig(plan.tomlPath, configSnapshot) + transientRetryAfterByHost.set( + hostKey, + Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS + ) + return fallback(plan, 'verify-failed', `duplicate granted key ${granted.key}`) + } + seenNormalizedKeys.add(granted.normalizedKey) grantedEntries.push({ ...match.entry, trustedHash: granted.trustedHash }) ledgerRecord[granted.normalizedKey] = { signature: match.signature, trustedHash: granted.trustedHash } } - if (grantedEntries.length !== expected.length) { + if (seenNormalizedKeys.size !== expected.length) { restoreCodexTrustConfig(plan.tomlPath, configSnapshot) transientRetryAfterByHost.set( hostKey, @@ -333,7 +306,7 @@ export function grantManagedCodexHookTrust( `[codex-trust-grant] granted ${grantedEntries.length} managed hook entries via codex app-server ` + `(host=${plan.host.kind}, wrote=${result.wroteTrust}, ${Date.now() - startedAtMs}ms)` ) - telemetry({ outcome: 'granted', hostKind: plan.host.kind }) + emitTelemetry({ outcome: 'granted', hostKind: plan.host.kind }) return { lane: 'rpc', entries: grantedEntries } } catch (error) { return fallback(plan, 'error', error) diff --git a/src/main/codex/codex-managed-trust-reconciliation.ts b/src/main/codex/codex-managed-trust-reconciliation.ts new file mode 100644 index 00000000000..e422f2a4c32 --- /dev/null +++ b/src/main/codex/codex-managed-trust-reconciliation.ts @@ -0,0 +1,151 @@ +import { + computeTrustKey, + computeTrustedHash, + getCodexCanonicalTrustPath, + normalizeHookTrustKeyForLookup, + parseTrustKey, + readHookTrustEntries, + removeHookTrustEntries, + type CodexEventLabel, + type CodexTrustEntry +} from './config-toml-trust' +import { getCodexHookTrustSignature } from './codex-hook-identity' +import { + readCodexTrustGrantLedgerHome, + removeCodexTrustGrantLedgerHome, + type CodexTrustGrantLedgerHome +} from './codex-trust-grant-ledger' + +export function readCodexTrustGrantLedgerHomeForReconciliation( + runtimeHomePath: string +): CodexTrustGrantLedgerHome | null { + try { + return readCodexTrustGrantLedgerHome(runtimeHomePath) + } catch { + return null + } +} + +export function getCodexLedgerTrustedHash( + ledgerHome: CodexTrustGrantLedgerHome | null, + key: string, + expectedEntry: CodexTrustEntry +): string | null { + const granted = ledgerHome?.entries[normalizeHookTrustKeyForLookup(key)] + return granted?.trustedHash && granted.signature === getCodexHookTrustSignature(expectedEntry) + ? granted.trustedHash + : null +} + +function addLedgerRecognizedHashes( + hashes: Set, + ledgerHomes: readonly (CodexTrustGrantLedgerHome | null)[], + key: string, + expectedEntry: CodexTrustEntry +): void { + for (const ledgerHome of ledgerHomes) { + const hash = getCodexLedgerTrustedHash(ledgerHome, key, expectedEntry) + if (hash) { + hashes.add(hash) + } + } +} + +export function removeCodexManagedHookTrustEntries(options: { + tomlPath: string + runtimeHomePath: string + sourcePath: string + command: string + managedEventLabels: ReadonlySet + timeoutSec: number +}): void { + const existingEntries = readHookTrustEntries(options.tomlPath) + const ledgerHome = readCodexTrustGrantLedgerHomeForReconciliation(options.runtimeHomePath) + const canonicalSourcePath = getCodexCanonicalTrustPath(options.sourcePath) + const ownedKeys: string[] = [] + for (const [key, state] of existingEntries) { + const parts = parseTrustKey(key) + if ( + !parts || + getCodexCanonicalTrustPath(parts.sourcePath) !== canonicalSourcePath || + !options.managedEventLabels.has(parts.eventLabel) + ) { + continue + } + const expectedEntry: CodexTrustEntry = { + sourcePath: options.sourcePath, + eventLabel: parts.eventLabel, + groupIndex: parts.groupIndex, + handlerIndex: parts.handlerIndex, + command: options.command, + timeoutSec: options.timeoutSec + } + const recognizedHashes = new Set([ + computeTrustedHash(expectedEntry), + computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) + ]) + addLedgerRecognizedHashes(recognizedHashes, [ledgerHome], key, expectedEntry) + if (state.trustedHash && recognizedHashes.has(state.trustedHash)) { + ownedKeys.push(key) + } + } + if (ownedKeys.length > 0) { + removeHookTrustEntries(options.tomlPath, ownedKeys) + } + // Why: retain the ledger until trust removal succeeds so a later retry can + // still prove ownership of Codex-computed hashes. + removeCodexTrustGrantLedgerHome(options.runtimeHomePath) +} + +export function removeStaleWslCodexManagedHookTrustEntries(options: { + tomlPath: string + runtimeHomePath: string + desiredEntries: readonly CodexTrustEntry[] + managedEventLabels: ReadonlySet + timeoutSec: number + buildManagedCommand: (linuxRuntimeHome: string) => string + priorLedgerHomes?: readonly CodexTrustGrantLedgerHome[] +}): void { + const desiredKeys = new Set( + options.desiredEntries.map((entry) => normalizeHookTrustKeyForLookup(computeTrustKey(entry))) + ) + const ledgerHomes = [ + readCodexTrustGrantLedgerHomeForReconciliation(options.runtimeHomePath), + ...(options.priorLedgerHomes ?? []) + ] + const ownedKeys: string[] = [] + for (const [key, state] of readHookTrustEntries(options.tomlPath)) { + if (desiredKeys.has(normalizeHookTrustKeyForLookup(key))) { + continue + } + const parts = parseTrustKey(key) + if (!parts || !options.managedEventLabels.has(parts.eventLabel)) { + continue + } + // Why: this cleanup owns only guest-side WSL trust. A runtime config can + // still contain user Windows/remote hooks, which must remain untouched. + if (!parts.sourcePath.startsWith('/') || !parts.sourcePath.endsWith('/hooks.json')) { + continue + } + const linuxRuntimeHome = parts.sourcePath.slice(0, -'/hooks.json'.length) + const expectedEntry: CodexTrustEntry = { + sourcePath: parts.sourcePath, + eventLabel: parts.eventLabel, + groupIndex: parts.groupIndex, + handlerIndex: parts.handlerIndex, + command: options.buildManagedCommand(linuxRuntimeHome), + timeoutSec: options.timeoutSec + } + const recognizedHashes = new Set([ + computeTrustedHash(expectedEntry), + computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) + ]) + addLedgerRecognizedHashes(recognizedHashes, ledgerHomes, key, expectedEntry) + if (state.trustedHash && recognizedHashes.has(state.trustedHash)) { + ownedKeys.push(key) + } + } + if (ownedKeys.length > 0) { + removeHookTrustEntries(options.tomlPath, ownedKeys) + } +} diff --git a/src/main/codex/codex-trust-config-rollback.test.ts b/src/main/codex/codex-trust-config-rollback.test.ts new file mode 100644 index 00000000000..ae53cfaaadb --- /dev/null +++ b/src/main/codex/codex-trust-config-rollback.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { captureCodexTrustConfig, restoreCodexTrustConfig } from './codex-trust-config-rollback' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function tempConfigPath(): string { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-rollback-')) + roots.push(root) + return join(root, 'config.toml') +} + +describe('Codex trust config rollback', () => { + it('treats a missing config as absent and tolerates it remaining absent', () => { + const configPath = tempConfigPath() + const snapshot = captureCodexTrustConfig(configPath) + + expect(snapshot).toEqual({ existed: false }) + expect(() => restoreCodexTrustConfig(configPath, snapshot)).not.toThrow() + }) + + it('removes a config created after an absent snapshot', () => { + const configPath = tempConfigPath() + const snapshot = captureCodexTrustConfig(configPath) + writeFileSync(configPath, 'rpc mutation') + + restoreCodexTrustConfig(configPath, snapshot) + expect(() => readFileSync(configPath)).toThrowError(/ENOENT/) + }) + + it('atomically recreates exact contents and mode after the file disappears', () => { + const configPath = tempConfigPath() + const original = Buffer.from('# comment\r\n[hooks]\r\n') + writeFileSync(configPath, original) + chmodSync(configPath, 0o640) + const snapshot = captureCodexTrustConfig(configPath) + rmSync(configPath) + + restoreCodexTrustConfig(configPath, snapshot) + + expect(readFileSync(configPath)).toEqual(original) + if (process.platform !== 'win32') { + expect(statSync(configPath).mode & 0o777).toBe(0o640) + } + }) +}) diff --git a/src/main/codex/codex-trust-config-rollback.ts b/src/main/codex/codex-trust-config-rollback.ts index 57e2cd0252a..74cea0d425a 100644 --- a/src/main/codex/codex-trust-config-rollback.ts +++ b/src/main/codex/codex-trust-config-rollback.ts @@ -1,17 +1,31 @@ -import { existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs' +import { closeSync, fstatSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import { renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' export type CodexTrustConfigSnapshot = | { existed: false } | { existed: true; contents: Buffer; mode: number } export function captureCodexTrustConfig(tomlPath: string): CodexTrustConfigSnapshot { - if (!existsSync(tomlPath)) { - return { existed: false } + let descriptor: number + try { + descriptor = openSync(tomlPath, 'r') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { existed: false } + } + throw error } - return { - existed: true, - contents: readFileSync(tomlPath), - mode: statSync(tomlPath).mode + try { + // Why: read and stat the same open file so replacement between two path + // lookups cannot pair one file's contents with another file's mode. + return { + existed: true, + contents: readFileSync(descriptor), + mode: fstatSync(descriptor).mode + } + } finally { + closeSync(descriptor) } } @@ -20,13 +34,36 @@ export function restoreCodexTrustConfig( snapshot: CodexTrustConfigSnapshot ): void { if (!snapshot.existed) { - if (existsSync(tomlPath)) { + try { unlinkSync(tomlPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } } return } - if (existsSync(tomlPath) && readFileSync(tomlPath).equals(snapshot.contents)) { - return + try { + if (readFileSync(tomlPath).equals(snapshot.contents)) { + return + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + } + // Why: rollback protects config integrity too; direct truncating writes can + // leave Codex unusable if Orca exits midway through recovery. + const tempPath = `${tomlPath}.${process.pid}.${randomUUID()}.rollback.tmp` + try { + writeFileSync(tempPath, snapshot.contents, { mode: snapshot.mode }) + renameFileWithWindowsRetry(tempPath, tomlPath) + } catch (error) { + try { + unlinkSync(tempPath) + } catch { + // Best effort; preserve the rollback failure as the actionable error. + } + throw error } - writeFileSync(tomlPath, snapshot.contents, { mode: snapshot.mode }) } diff --git a/src/main/codex/codex-trust-grant-host.test.ts b/src/main/codex/codex-trust-grant-host.test.ts new file mode 100644 index 00000000000..4fb2296abdc --- /dev/null +++ b/src/main/codex/codex-trust-grant-host.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const resolveCodexCommandMock = vi.hoisted(() => vi.fn()) + +vi.mock('../codex-cli/command', () => ({ + resolveCodexCommand: resolveCodexCommandMock +})) + +import { resolveCodexTrustGrantHost } from './codex-trust-grant-host' + +beforeEach(() => { + resolveCodexCommandMock.mockReset() + resolveCodexCommandMock.mockReturnValue(process.execPath) +}) + +describe('resolveCodexTrustGrantHost', () => { + it('resolves the native command once for both the binary stamp and request', () => { + const host = resolveCodexTrustGrantHost({ kind: 'native' }) + const input = { + runtimeHomePath: '/tmp/codex-home', + managedCommand: '/bin/sh codex-hook.sh', + expectedTrustKeys: ['managed-key'] + } + + expect(host.binaryStamp).toMatchObject({ kind: 'native', path: process.execPath }) + expect(host.buildRequest(input).invocation.command).toBe(process.execPath) + expect(host.buildRequest(input).invocation.command).toBe(process.execPath) + // Why: PATH/version-manager scans are synchronous launch-path I/O. Reusing + // the resolved command keeps one grant at one scan regardless of consumers. + expect(resolveCodexCommandMock).toHaveBeenCalledTimes(1) + }) + + it('builds WSL requests without scanning the native PATH', () => { + const host = resolveCodexTrustGrantHost({ + kind: 'wsl', + distro: 'Ubuntu', + linuxRuntimeHome: '/home/alice/.codex-runtime' + }) + const request = host.buildRequest({ + runtimeHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex-runtime', + managedCommand: '/bin/sh codex-hook.sh', + expectedTrustKeys: ['managed-key'] + }) + + expect(host.binaryStamp).toEqual({ kind: 'wsl', distro: 'Ubuntu' }) + expect(request.invocation.command).toBe('wsl.exe') + expect(resolveCodexCommandMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/codex/codex-trust-grant-host.ts b/src/main/codex/codex-trust-grant-host.ts new file mode 100644 index 00000000000..bf51c800e10 --- /dev/null +++ b/src/main/codex/codex-trust-grant-host.ts @@ -0,0 +1,94 @@ +import { resolveCodexCommand } from '../codex-cli/command' +import { getSpawnArgsForWindows } from '../win32-utils' +import { buildWslCodexAppServerArgs } from '../codex-accounts/wsl-codex-command' +import type { CodexHookTrustGrantRequest } from './codex-app-server-client' +import { + binaryStampsMatch, + buildNativeCodexBinaryStamp, + readCodexTrustGrantLedgerHome, + type CodexTrustGrantBinaryStamp, + type CodexTrustGrantLedgerHome +} from './codex-trust-grant-ledger' + +// Why: native sessions finish in ~100ms; WSL also pays cold-distro and +// login-shell startup, but both stay hard-bounded on launch prep. +const NATIVE_GRANT_TIMEOUT_MS = 10_000 +const WSL_GRANT_TIMEOUT_MS = 30_000 + +export type CodexTrustGrantHost = + | { kind: 'native' } + | { kind: 'wsl'; distro: string; linuxRuntimeHome: string } + +type CodexTrustGrantRequestInput = { + runtimeHomePath: string + managedCommand: string + expectedTrustKeys: string[] +} + +export type ResolvedCodexTrustGrantHost = { + binaryStamp: CodexTrustGrantBinaryStamp | null + buildRequest: (input: CodexTrustGrantRequestInput) => CodexHookTrustGrantRequest +} + +export function resolveCodexTrustGrantHost(host: CodexTrustGrantHost): ResolvedCodexTrustGrantHost { + if (host.kind === 'wsl') { + return { + binaryStamp: { kind: 'wsl', distro: host.distro }, + buildRequest: (input) => ({ + invocation: { + command: 'wsl.exe', + args: buildWslCodexAppServerArgs(host.distro, host.linuxRuntimeHome), + timeoutMs: WSL_GRANT_TIMEOUT_MS + }, + hooksListCwd: host.linuxRuntimeHome, + expectedTrustKeys: input.expectedTrustKeys, + managedCommand: input.managedCommand + }) + } + } + + // Why: command resolution scans PATH/version-manager directories. Resolve + // once per grant and reuse it for both the binary stamp and invocation. + const command = resolveCodexCommand() + return { + binaryStamp: command === 'codex' ? null : buildNativeCodexBinaryStamp(command), + buildRequest: (input) => { + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, ['app-server']) + return { + invocation: { + command: spawnCmd, + args: spawnArgs, + env: { CODEX_HOME: input.runtimeHomePath }, + timeoutMs: NATIVE_GRANT_TIMEOUT_MS + }, + hooksListCwd: input.runtimeHomePath, + expectedTrustKeys: input.expectedTrustKeys, + managedCommand: input.managedCommand + } + } + } +} + +export function readCodexTrustGrantLedgerHomeMatchingStamp( + runtimeHomePath: string, + currentStamp: CodexTrustGrantBinaryStamp | null +): CodexTrustGrantLedgerHome | null { + const home = readCodexTrustGrantLedgerHome(runtimeHomePath) + return home && binaryStampsMatch(home.binary, currentStamp) ? home : null +} + +export function readCurrentCodexTrustGrantLedgerHome( + runtimeHomePath: string, + host: CodexTrustGrantHost +): CodexTrustGrantLedgerHome | null { + try { + return readCodexTrustGrantLedgerHomeMatchingStamp( + runtimeHomePath, + resolveCodexTrustGrantHost(host).binaryStamp + ) + } catch { + // Why: status is diagnostic and best-effort; unreadable ledger/binary + // paths must trigger conservative self-hash handling, not throw. + return null + } +} diff --git a/src/main/codex/codex-wsl-hook-install-plan.test.ts b/src/main/codex/codex-wsl-hook-install-plan.test.ts index 1c485abf934..83ab0141a34 100644 --- a/src/main/codex/codex-wsl-hook-install-plan.test.ts +++ b/src/main/codex/codex-wsl-hook-install-plan.test.ts @@ -6,7 +6,7 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock })) -import { _internals } from './codex-wsl-hook-install-plan' +import { _internals, createCodexWslRuntimeHookInstallPlan } from './codex-wsl-hook-install-plan' const originalPlatform = process.platform @@ -24,6 +24,17 @@ afterEach(() => { }) describe('canonicalizeWslLinuxPath', () => { + it('joins guest paths without producing a double slash at the filesystem root', () => { + const plan = createCodexWslRuntimeHookInstallPlan( + 'C:\\runtime', + { runtime: 'wsl', wslDistro: 'Ubuntu' }, + () => '/' + ) + + expect(plan?.commandScriptPath).toBe('/.orca/agent-hooks/codex-hook.sh') + expect(plan?.trustConfigPath).toBe('/hooks.json') + }) + it('returns the path unchanged off Windows without spawning wsl.exe', () => { setPlatform('linux') expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alice')).toBe('/home/alice') diff --git a/src/main/codex/codex-wsl-hook-install-plan.ts b/src/main/codex/codex-wsl-hook-install-plan.ts index 0f63d036443..4db2bdea93f 100644 --- a/src/main/codex/codex-wsl-hook-install-plan.ts +++ b/src/main/codex/codex-wsl-hook-install-plan.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process' -import { win32 as pathWin32 } from 'node:path' +import { posix as pathPosix, win32 as pathWin32 } from 'node:path' import { parseWslUncPath } from '../../shared/wsl-paths' export type CodexWslRuntimeHookTarget = { @@ -188,8 +188,8 @@ export function createCodexWslRuntimeHookInstallPlan( configPath: pathWin32.join(runtimeHomePath, 'hooks.json'), tomlPath: pathWin32.join(runtimeHomePath, 'config.toml'), scriptPath: pathWin32.join(runtimeHomePath, '.orca', 'agent-hooks', 'codex-hook.sh'), - commandScriptPath: `${linuxRuntimeHome}/.orca/agent-hooks/codex-hook.sh`, - trustConfigPath: `${linuxRuntimeHome}/hooks.json`, + commandScriptPath: pathPosix.join(linuxRuntimeHome, '.orca', 'agent-hooks', 'codex-hook.sh'), + trustConfigPath: pathPosix.join(linuxRuntimeHome, 'hooks.json'), wslDistro: distro, linuxRuntimeHome } diff --git a/src/main/codex/hook-service-trust-grant.test.ts b/src/main/codex/hook-service-trust-grant.test.ts new file mode 100644 index 00000000000..b317f5b3b65 --- /dev/null +++ b/src/main/codex/hook-service-trust-grant.test.ts @@ -0,0 +1,236 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' +import { wrapPosixHookCommand } from '../agent-hooks/installer-utils' +import { + computeTrustedHash, + parseTrustKey, + upsertHookTrustEntries, + type CodexTrustEntry +} from './config-toml-trust' +import { codexAppServerCapabilityCache } from './codex-app-server-capability-cache' +import { _internals as trustGrantInternals } from './codex-hook-trust-grant' +import { + readCodexTrustGrantLedgerHome, + writeCodexTrustGrantLedgerHome +} from './codex-trust-grant-ledger' +import type { CodexHookTrustGrantRequest } from './codex-app-server-client' + +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' + +let tmpHome: string +let userDataDir: string +let previousUserDataPath: string | undefined + +beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'orca-codex-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-user-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(tmpHome) + getPathMock.mockImplementation((name: string) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + }) + trustGrantInternals.resetDiagnostics() + codexAppServerCapabilityCache.clear() +}) + +afterEach(() => { + trustGrantInternals.setGrantSessionRunnerSync(null) + trustGrantInternals.resetDiagnostics() + codexAppServerCapabilityCache.clear() + delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC + rmSync(tmpHome, { 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() +}) + +function installCodexLikeGrantRunner(): ReturnType { + const codexHash = (key: string): string => + `sha256:codex-${parseTrustKey(key)?.eventLabel ?? 'unknown'}` + const runner = vi.fn((request: CodexHookTrustGrantRequest) => { + const codexHome = request.invocation.env?.CODEX_HOME + expect(codexHome).toBeTruthy() + const entries: CodexTrustEntry[] = request.expectedTrustKeys.map((key) => { + const parsed = parseTrustKey(key)! + return { + ...parsed, + command: request.managedCommand, + trustedHash: codexHash(key) + } + }) + upsertHookTrustEntries(join(codexHome!, 'config.toml'), entries) + return { + outcome: 'granted' as const, + wroteTrust: true, + entries: request.expectedTrustKeys.map((key) => ({ + key, + normalizedKey: key, + trustedHash: codexHash(key) + })) + } + }) + trustGrantInternals.setGrantSessionRunnerSync(runner) + return runner +} + +function prepareSystemHome(): void { + mkdirSync(join(tmpHome, '.codex'), { recursive: true }) +} + +describe('CodexHookService app-server trust grant lane', () => { + it('treats Codex hashes as authoritative and records the verified grant', () => { + prepareSystemHome() + const runner = installCodexLikeGrantRunner() + + const status = new CodexHookService().install() + + expect(status.state).toBe('installed') + expect(runner).toHaveBeenCalledTimes(1) + const managedHome = join(userDataDir, 'codex-runtime-home', 'home') + const trustConfig = readFileSync(join(managedHome, 'config.toml'), 'utf-8') + expect(trustConfig).toContain('sha256:codex-session_start') + const selfComputed = computeTrustedHash({ + sourcePath: join(managedHome, 'hooks.json'), + eventLabel: 'session_start', + groupIndex: 0, + handlerIndex: 0, + command: wrapPosixHookCommand(join(tmpHome, '.orca', 'agent-hooks', 'codex-hook.sh')), + timeoutSec: 10 + }) + expect(trustConfig).not.toContain(selfComputed) + expect(Object.keys(readCodexTrustGrantLedgerHome(managedHome)!.entries)).toHaveLength(6) + }) + + it('keeps config byte-stable and skips the session on a repeat ledger hit', () => { + prepareSystemHome() + const runner = installCodexLikeGrantRunner() + const service = new CodexHookService() + expect(service.install().state).toBe('installed') + const managedHome = join(userDataDir, 'codex-runtime-home', 'home') + const firstToml = readFileSync(join(managedHome, 'config.toml')) + + expect(service.install().state).toBe('installed') + expect(runner).toHaveBeenCalledTimes(1) + expect(readFileSync(join(managedHome, 'config.toml'))).toEqual(firstToml) + }) + + it('does not accept a ledger hash after the recorded Codex binary stamp changes', () => { + prepareSystemHome() + installCodexLikeGrantRunner() + const service = new CodexHookService() + expect(service.install().state).toBe('installed') + const managedHome = join(userDataDir, 'codex-runtime-home', 'home') + const ledger = readCodexTrustGrantLedgerHome(managedHome)! + writeCodexTrustGrantLedgerHome(managedHome, { + ...ledger, + binary: { kind: 'native', path: '/definitely/not/current/codex', size: 1, mtimeMs: 1 } + }) + + expect(service.getStatus()).toMatchObject({ + state: 'partial', + detail: expect.stringContaining('Trust entry missing or stale') + }) + }) + + it('upgrades self-computed trust in place without duplicate tables', () => { + prepareSystemHome() + const service = new CodexHookService() + process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' + expect(service.install().state).toBe('installed') + const managedHome = join(userDataDir, 'codex-runtime-home', 'home') + delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC + installCodexLikeGrantRunner() + + expect(service.install().state).toBe('installed') + const upgraded = readFileSync(join(managedHome, 'config.toml'), 'utf-8') + for (const eventLabel of [ + 'session_start', + 'user_prompt_submit', + 'pre_tool_use', + 'permission_request', + 'post_tool_use', + 'stop' + ]) { + const count = upgraded + .split('\n') + .filter( + (line) => line.startsWith('[hooks.state.') && line.includes(`:${eventLabel}:0:0`) + ).length + expect(count, `duplicate trust tables for ${eventLabel}`).toBe(1) + } + }) + + it('leaves user trust byte-untouched while granting managed entries', () => { + prepareSystemHome() + const managedHome = join(userDataDir, 'codex-runtime-home', 'home') + mkdirSync(managedHome, { recursive: true }) + const userBlock = [ + '[hooks.state."/home/user/.codex/hooks.json:stop:3:1"]', + 'enabled = false', + 'trusted_hash = "sha256:user-owned-hash"' + ].join('\n') + writeFileSync(join(managedHome, 'config.toml'), `${userBlock}\n`) + installCodexLikeGrantRunner() + + expect(new CodexHookService().install().state).toBe('installed') + expect(readFileSync(join(managedHome, 'config.toml'), 'utf-8')).toContain(userBlock) + }) + + it('keeps the forced fallback on self-computed writes', () => { + prepareSystemHome() + process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' + const runner = vi.fn() + trustGrantInternals.setGrantSessionRunnerSync(runner) + + expect(new CodexHookService().install().state).toBe('installed') + expect(runner).not.toHaveBeenCalled() + }) + + it('restores exact config bytes before fallback after a mutating RPC failure', () => { + prepareSystemHome() + const service = new CodexHookService() + process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' + expect(service.install().state).toBe('installed') + const managedHome = join(userDataDir, 'codex-runtime-home', 'home') + const baseline = readFileSync(join(managedHome, 'config.toml')) + + delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC + rmSync(managedHome, { recursive: true, force: true }) + trustGrantInternals.resetDiagnostics() + const runner = vi.fn((request: CodexHookTrustGrantRequest) => { + const codexHome = request.invocation.env?.CODEX_HOME + writeFileSync( + join(codexHome!, 'config.toml'), + '[hooks.state."rpc-partial"]\ntrusted_hash = "sha256:changed"\n' + ) + throw new Error('transport failed after config/batchWrite') + }) + trustGrantInternals.setGrantSessionRunnerSync(runner) + + expect(service.install().state).toBe('installed') + expect(runner).toHaveBeenCalledTimes(1) + expect(readFileSync(join(managedHome, 'config.toml'))).toEqual(baseline) + }) +}) diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index 1877fe8ebd8..cc887208f4c 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -9,6 +9,7 @@ import { computeTrustKey, computeTrustedHash, normalizeHookTrustKeyForLookup, + parseTrustKey, readHookTrustEntries, upsertHookTrustEntries, type CodexTrustEntry @@ -516,4 +517,61 @@ describe('Codex WSL runtime hook install app-server grant lane', () => { trustedHash: computeTrustedHash(managedTrustEntry) }) }) + + it('uses the previous ledger to remove stale Codex hashes after a canonical path change', () => { + const basePlan = createTestPlan() + writeFileSync(basePlan.configPath, '{"hooks":{}}\n', 'utf-8') + writeFileSync(basePlan.tomlPath, '', 'utf-8') + const runner = vi.fn((request: CodexHookTrustGrantRequest) => { + const entries = request.expectedTrustKeys.map((key) => { + const parsed = parseTrustKey(key)! + return { + sourcePath: parsed.sourcePath, + eventLabel: parsed.eventLabel, + groupIndex: parsed.groupIndex, + handlerIndex: parsed.handlerIndex, + command: request.managedCommand, + trustedHash: `sha256:codex-verbatim-${parsed.eventLabel}` + } + }) + upsertHookTrustEntries(basePlan.tomlPath, entries) + return { + outcome: 'granted' as const, + wroteTrust: true, + entries: request.expectedTrustKeys.map((key) => ({ + key, + normalizedKey: normalizeHookTrustKeyForLookup(key), + trustedHash: `sha256:codex-verbatim-${parseTrustKey(key)!.eventLabel}` + })) + } + }) + trustGrantInternals.setGrantSessionRunnerSync(runner) + + const oldPlan = { + ...basePlan, + commandScriptPath: '/old/home/.orca/agent-hooks/codex-hook.sh', + trustConfigPath: '/old/home/hooks.json', + linuxRuntimeHome: '/old/home' + } + expect(_internals.installManagedHooksIntoWslRuntime(oldPlan).state).toBe('installed') + const oldKey = computeTrustKey( + getManagedTrustEntry(oldPlan, expectedManagedCommand(oldPlan.commandScriptPath)) + ) + + const newPlan = { + ...basePlan, + commandScriptPath: '/new/home/.orca/agent-hooks/codex-hook.sh', + trustConfigPath: '/new/home/hooks.json', + linuxRuntimeHome: '/new/home' + } + expect(_internals.installManagedHooksIntoWslRuntime(newPlan).state).toBe('installed') + const newKey = computeTrustKey( + getManagedTrustEntry(newPlan, expectedManagedCommand(newPlan.commandScriptPath)) + ) + const trustEntries = readHookTrustEntries(basePlan.tomlPath) + + expect(runner).toHaveBeenCalledTimes(2) + expect(trustEntries.has(oldKey)).toBe(false) + expect(trustEntries.get(newKey)?.trustedHash).toBe('sha256:codex-verbatim-user_prompt_submit') + }) }) diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts index 22a54eb0cde..526a64d99ab 100644 --- a/src/main/codex/hook-service.test.ts +++ b/src/main/codex/hook-service.test.ts @@ -17,17 +17,7 @@ import { spawn } from 'node:child_process' import { createServer } from 'node:http' import type { AddressInfo } from 'node:net' import { createManagedCommandMatcher, wrapPosixHookCommand } from '../agent-hooks/installer-utils' -import { - computeTrustedHash, - upsertHookTrustEntries, - upsertHookTrustEntriesInContent, - parseTrustKey, - type CodexTrustEntry -} from './config-toml-trust' -import { codexAppServerCapabilityCache } from './codex-app-server-capability-cache' -import { _internals as trustGrantInternals } from './codex-hook-trust-grant' -import { readCodexTrustGrantLedgerHome } from './codex-trust-grant-ledger' -import type { CodexHookTrustGrantRequest } from './codex-app-server-client' +import { computeTrustedHash, upsertHookTrustEntriesInContent } from './config-toml-trust' const { getPathMock, homedirMock } = vi.hoisted(() => ({ getPathMock: vi.fn<(name: string) => string>(), @@ -1464,223 +1454,3 @@ describe('CodexHookService', () => { expect(trustConfig).not.toContain('model = "runtime-model"') }) }) - -describe('CodexHookService app-server trust grant lane', () => { - beforeEach(() => { - trustGrantInternals.resetDiagnostics() - codexAppServerCapabilityCache.clear() - }) - - afterEach(() => { - trustGrantInternals.setGrantSessionRunnerSync(null) - trustGrantInternals.resetDiagnostics() - codexAppServerCapabilityCache.clear() - delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC - }) - - // Simulates codex's side of a successful grant: write trusted_hash blocks - // through codex's shape (trusted_hash only, no enabled line) and report the - // entries trusted, exactly like the real app-server session result. - function installCodexLikeGrantRunner(): { - runner: ReturnType - codexHash: (key: string) => string - } { - const codexHash = (key: string): string => - `sha256:codex-${parseTrustKey(key)?.eventLabel ?? 'unknown'}` - const runner = vi.fn((request: CodexHookTrustGrantRequest) => { - const codexHome = request.invocation.env?.CODEX_HOME - expect(codexHome).toBeTruthy() - const entries: CodexTrustEntry[] = request.expectedTrustKeys.map((key) => { - const parsed = parseTrustKey(key)! - return { - sourcePath: parsed.sourcePath, - eventLabel: parsed.eventLabel, - groupIndex: parsed.groupIndex, - handlerIndex: parsed.handlerIndex, - command: request.managedCommand, - trustedHash: codexHash(key) - } - }) - upsertHookTrustEntries(join(codexHome!, 'config.toml'), entries) - return { - outcome: 'granted' as const, - wroteTrust: true, - entries: request.expectedTrustKeys.map((key) => ({ - key, - normalizedKey: key, - trustedHash: codexHash(key) - })) - } - }) - trustGrantInternals.setGrantSessionRunnerSync(runner) - return { runner, codexHash } - } - - it('grants managed trust through codex and treats the codex hash as authoritative', () => { - const systemCodexHome = join(tmpHome, '.codex') - mkdirSync(systemCodexHome, { recursive: true }) - const { runner } = installCodexLikeGrantRunner() - - const status = new CodexHookService().install() - - // Why: the codex-written hash intentionally differs from - // computeTrustedHash — a drifted replica must no longer read as - // partial/stale (that misreport was the #7896/#7110/#8699 bug class). - expect(status.state).toBe('installed') - expect(runner).toHaveBeenCalledTimes(1) - - const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') - const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') - expect(trustConfig).toContain('sha256:codex-session_start') - expect(trustConfig).toContain('sha256:codex-stop') - - const scriptPath = join(tmpHome, '.orca', 'agent-hooks', 'codex-hook.sh') - const command = wrapPosixHookCommand(scriptPath) - const selfComputedHash = computeTrustedHash({ - sourcePath: join(managedCodexHome, 'hooks.json'), - eventLabel: 'session_start', - groupIndex: 0, - handlerIndex: 0, - command, - timeoutSec: 10 - }) - expect(trustConfig).not.toContain(selfComputedHash) - - const ledgerHome = readCodexTrustGrantLedgerHome(managedCodexHome) - expect(ledgerHome).not.toBeNull() - expect(Object.keys(ledgerHome!.entries)).toHaveLength(6) - }) - - it('keeps config byte-stable and skips the session on a repeat install (ledger hit)', () => { - const systemCodexHome = join(tmpHome, '.codex') - mkdirSync(systemCodexHome, { recursive: true }) - const { runner } = installCodexLikeGrantRunner() - const service = new CodexHookService() - - expect(service.install().state).toBe('installed') - const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') - const tomlAfterFirst = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') - const hooksAfterFirst = readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8') - - const secondStatus = service.install() - - expect(secondStatus.state).toBe('installed') - expect(runner).toHaveBeenCalledTimes(1) - expect(readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')).toBe(tomlAfterFirst) - expect(readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8')).toBe(hooksAfterFirst) - }) - - it('upgrades a home carrying self-computed trust entries in place without duplicates', () => { - const systemCodexHome = join(tmpHome, '.codex') - mkdirSync(systemCodexHome, { recursive: true }) - const service = new CodexHookService() - - // Old-Orca state: fallback lane writes the self-computed hashes. - process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' - expect(service.install().state).toBe('installed') - const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') - const legacyToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') - expect(legacyToml).toContain('trusted_hash = "sha256:') - - delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC - installCodexLikeGrantRunner() - expect(service.install().state).toBe('installed') - - const upgradedToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') - expect(upgradedToml).toContain('sha256:codex-session_start') - for (const eventLabel of [ - 'session_start', - 'user_prompt_submit', - 'pre_tool_use', - 'permission_request', - 'post_tool_use', - 'stop' - ]) { - const headerCount = upgradedToml - .split('\n') - .filter( - (line) => line.startsWith('[hooks.state.') && line.includes(`:${eventLabel}:0:0`) - ).length - expect(headerCount, `duplicate trust tables for ${eventLabel}`).toBe(1) - } - }) - - it('leaves user hook trust byte-untouched while granting managed entries', () => { - const systemCodexHome = join(tmpHome, '.codex') - mkdirSync(systemCodexHome, { recursive: true }) - const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') - mkdirSync(managedCodexHome, { recursive: true }) - // Why: only the block itself is asserted — comments around runtime-owned - // sections are the config mirror's pre-existing concern, and codex-writer - // comment preservation is proven against the real binary instead. - const userBlock = [ - '[hooks.state."/home/user/.codex/hooks.json:stop:3:1"]', - 'enabled = false', - 'trusted_hash = "sha256:user-owned-hash"' - ].join('\n') - writeFileSync(join(managedCodexHome, 'config.toml'), `${userBlock}\n`, 'utf-8') - - installCodexLikeGrantRunner() - expect(new CodexHookService().install().state).toBe('installed') - - const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') - expect(trustConfig).toContain(userBlock) - expect(trustConfig).toContain('sha256:codex-session_start') - }) - - it('routes the forced-fallback lane through the unchanged self-computed writes', () => { - const systemCodexHome = join(tmpHome, '.codex') - mkdirSync(systemCodexHome, { recursive: true }) - process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' - const runner = vi.fn() - trustGrantInternals.setGrantSessionRunnerSync(runner) - - const status = new CodexHookService().install() - - expect(status.state).toBe('installed') - expect(runner).not.toHaveBeenCalled() - const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') - const scriptPath = join(tmpHome, '.orca', 'agent-hooks', 'codex-hook.sh') - const command = wrapPosixHookCommand(scriptPath) - const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') - expect(trustConfig).toContain( - computeTrustedHash({ - sourcePath: join(managedCodexHome, 'hooks.json'), - eventLabel: 'session_start', - groupIndex: 0, - handlerIndex: 0, - command, - timeoutSec: 10 - }) - ) - }) - - it('keeps fallback output byte-identical after an RPC mutates config and then fails', () => { - const systemCodexHome = join(tmpHome, '.codex') - mkdirSync(systemCodexHome, { recursive: true }) - const service = new CodexHookService() - - process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' - expect(service.install().state).toBe('installed') - const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') - const baseline = readFileSync(join(managedCodexHome, 'config.toml')) - - delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC - rmSync(managedCodexHome, { recursive: true, force: true }) - trustGrantInternals.resetDiagnostics() - const runner = vi.fn((request: CodexHookTrustGrantRequest) => { - const codexHome = request.invocation.env?.CODEX_HOME - expect(codexHome).toBeTruthy() - writeFileSync( - join(codexHome!, 'config.toml'), - '[hooks.state."rpc-partial"]\ntrusted_hash = "sha256:changed"\n' - ) - throw new Error('transport failed after config/batchWrite') - }) - trustGrantInternals.setGrantSessionRunnerSync(runner) - - expect(service.install().state).toBe('installed') - expect(runner).toHaveBeenCalledTimes(1) - expect(readFileSync(join(managedCodexHome, 'config.toml'))).toEqual(baseline) - }) -}) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index a7032ede08d..522c37f84e1 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -67,11 +67,14 @@ import { snapshotCodexRuntimeHookTrustProvenance } from './hook-trust-promotion' import { grantManagedCodexHookTrust } from './codex-hook-trust-grant' +import { readCurrentCodexTrustGrantLedgerHome } from './codex-trust-grant-host' import { - readCodexTrustGrantLedgerHome, - removeCodexTrustGrantLedgerHome, - type CodexTrustGrantLedgerHome -} from './codex-trust-grant-ledger' + getCodexLedgerTrustedHash, + readCodexTrustGrantLedgerHomeForReconciliation, + removeCodexManagedHookTrustEntries, + removeStaleWslCodexManagedHookTrustEntries +} from './codex-managed-trust-reconciliation' +import type { CodexTrustGrantLedgerHome } from './codex-trust-grant-ledger' // Why: PreToolUse/PostToolUse give the dashboard a live readout of the // in-flight tool (name + input preview) between UserPromptSubmit and Stop. @@ -636,79 +639,16 @@ function cleanupLegacyManagedHookRepresentations(): void { } } -function readLedgerHomeForCleanup(runtimeHomePath: string): CodexTrustGrantLedgerHome | null { - try { - return readCodexTrustGrantLedgerHome(runtimeHomePath) - } catch { - return null - } -} - -// Why: RPC-granted entries carry Codex's hash, which need not equal the -// self-computed one — the grant ledger is what proves those blocks are ours. -function addLedgerRecognizedHash( - recognizedHashes: Set, - ledgerHome: CodexTrustGrantLedgerHome | null, - key: string, - expectedEntry: CodexTrustEntry -): void { - const granted = ledgerHome?.entries[normalizeHookTrustKeyForLookup(key)] - if (granted?.trustedHash && granted.signature === getCodexHookTrustSignature(expectedEntry)) { - recognizedHashes.add(granted.trustedHash) - } -} - function removeRuntimeManagedHookTrustEntries(configPath: string): void { try { - const tomlPath = getCodexConfigTomlPath() - const existingEntries = readHookTrustEntries(tomlPath) - const scriptPath = getManagedScriptPath() - const command = getManagedCommand(scriptPath) - const ledgerHome = readLedgerHomeForCleanup(getOrcaManagedCodexHomePath()) - const managedEventLabels = new Set( - CODEX_EVENTS.map((event) => CODEX_EVENT_LABEL[event]) - ) - // Why: only drop entries WE wrote. The same config.toml can contain - // user-approved trust entries for non-Orca commands, so match by hash - // equivalence to our managed command — a sourcePath-only filter would - // wipe the user's manually-approved entries. - const ourKeys: string[] = [] - const canonicalConfigPath = getCodexCanonicalTrustPath(configPath) - for (const [key, state] of existingEntries) { - const parts = parseTrustKey(key) - if (parts === null) { - continue - } - if (getCodexCanonicalTrustPath(parts.sourcePath) !== canonicalConfigPath) { - continue - } - if (!managedEventLabels.has(parts.eventLabel)) { - continue - } - const expectedEntry: CodexTrustEntry = { - sourcePath: configPath, - eventLabel: parts.eventLabel, - groupIndex: parts.groupIndex, - handlerIndex: parts.handlerIndex, - command, - // Why: match the timeout install() wrote, or remove() would fail to - // recognize (and clean up) its own managed trust entries. - timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS - } - const recognizedHashes = new Set([ - computeTrustedHash(expectedEntry), - computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) - ]) - addLedgerRecognizedHash(recognizedHashes, ledgerHome, key, expectedEntry) - if (!state.trustedHash || !recognizedHashes.has(state.trustedHash)) { - continue - } - ourKeys.push(key) - } - if (ourKeys.length > 0) { - removeHookTrustEntries(tomlPath, ourKeys) - } - removeCodexTrustGrantLedgerHome(getOrcaManagedCodexHomePath()) + removeCodexManagedHookTrustEntries({ + tomlPath: getCodexConfigTomlPath(), + runtimeHomePath: getOrcaManagedCodexHomePath(), + sourcePath: configPath, + command: getManagedCommand(getManagedScriptPath()), + managedEventLabels: CODEX_MANAGED_EVENT_LABELS, + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + }) } catch (error) { // Best effort — stale trust entries are harmless once hooks.json no // longer references the hook. Log so a programmer error doesn't disappear silently. @@ -718,46 +658,14 @@ function removeRuntimeManagedHookTrustEntries(configPath: string): void { function removeWslRuntimeManagedHookTrustEntries(plan: CodexWslRuntimeHookInstallPlan): void { try { - const existingEntries = readHookTrustEntries(plan.tomlPath) - const command = wrapReadablePosixHookCommand(plan.commandScriptPath) - const ledgerHome = readLedgerHomeForCleanup(pathWin32.dirname(plan.tomlPath)) - const managedEventLabels = new Set( - CODEX_EVENTS.map((event) => CODEX_EVENT_LABEL[event]) - ) - const canonicalConfigPath = getCodexCanonicalTrustPath(plan.trustConfigPath) - const ourKeys: string[] = [] - for (const [key, state] of existingEntries) { - const parts = parseTrustKey(key) - if (parts === null) { - continue - } - if (getCodexCanonicalTrustPath(parts.sourcePath) !== canonicalConfigPath) { - continue - } - if (!managedEventLabels.has(parts.eventLabel)) { - continue - } - const expectedEntry: CodexTrustEntry = { - sourcePath: plan.trustConfigPath, - eventLabel: parts.eventLabel, - groupIndex: parts.groupIndex, - handlerIndex: parts.handlerIndex, - command, - timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS - } - const recognizedHashes = new Set([ - computeTrustedHash(expectedEntry), - computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) - ]) - addLedgerRecognizedHash(recognizedHashes, ledgerHome, key, expectedEntry) - if (state.trustedHash && recognizedHashes.has(state.trustedHash)) { - ourKeys.push(key) - } - } - if (ourKeys.length > 0) { - removeHookTrustEntries(plan.tomlPath, ourKeys) - } - removeCodexTrustGrantLedgerHome(pathWin32.dirname(plan.tomlPath)) + removeCodexManagedHookTrustEntries({ + tomlPath: plan.tomlPath, + runtimeHomePath: pathWin32.dirname(plan.tomlPath), + sourcePath: plan.trustConfigPath, + command: wrapReadablePosixHookCommand(plan.commandScriptPath), + managedEventLabels: CODEX_MANAGED_EVENT_LABELS, + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + }) } catch (error) { // Why: removing disabled WSL status hooks should be best-effort like the // host cleanup path; stale trust is inert once hooks.json no longer points at us. @@ -767,50 +675,19 @@ function removeWslRuntimeManagedHookTrustEntries(plan: CodexWslRuntimeHookInstal function removeStaleWslRuntimeManagedHookTrustEntries( tomlPath: string, - desiredEntries: readonly CodexTrustEntry[] + desiredEntries: readonly CodexTrustEntry[], + priorLedgerHomes: readonly CodexTrustGrantLedgerHome[] = [] ): void { - const desiredKeys = new Set( - desiredEntries.map((entry) => normalizeHookTrustKeyForLookup(computeTrustKey(entry))) - ) - const existingEntries = readHookTrustEntries(tomlPath) - const ledgerHome = readLedgerHomeForCleanup(pathWin32.dirname(tomlPath)) - const ourKeys: string[] = [] - for (const [key, state] of existingEntries) { - if (desiredKeys.has(normalizeHookTrustKeyForLookup(key))) { - continue - } - const parts = parseTrustKey(key) - if (!parts || !CODEX_MANAGED_EVENT_LABELS.has(parts.eventLabel)) { - continue - } - const sourcePath = parts.sourcePath - // Why: this cleanup owns only guest-side WSL trust. A runtime config can - // still contain user Windows/remote hooks, which must remain untouched. - if (!sourcePath.startsWith('/') || !sourcePath.endsWith('/hooks.json')) { - continue - } - const runtimeHome = sourcePath.slice(0, -'/hooks.json'.length) - const command = wrapReadablePosixHookCommand(`${runtimeHome}/.orca/agent-hooks/codex-hook.sh`) - const expectedEntry: CodexTrustEntry = { - sourcePath: parts.sourcePath, - eventLabel: parts.eventLabel, - groupIndex: parts.groupIndex, - handlerIndex: parts.handlerIndex, - command, - timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS - } - const recognizedHashes = new Set([ - computeTrustedHash(expectedEntry), - computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) - ]) - addLedgerRecognizedHash(recognizedHashes, ledgerHome, key, expectedEntry) - if (state.trustedHash && recognizedHashes.has(state.trustedHash)) { - ourKeys.push(key) - } - } - if (ourKeys.length > 0) { - removeHookTrustEntries(tomlPath, ourKeys) - } + removeStaleWslCodexManagedHookTrustEntries({ + tomlPath, + runtimeHomePath: pathWin32.dirname(tomlPath), + desiredEntries, + managedEventLabels: CODEX_MANAGED_EVENT_LABELS, + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS, + buildManagedCommand: (linuxRuntimeHome) => + wrapReadablePosixHookCommand(`${linuxRuntimeHome}/.orca/agent-hooks/codex-hook.sh`), + priorLedgerHomes + }) } function getManagedScript(target: 'local' | 'posix' = 'local'): string { @@ -961,15 +838,23 @@ function installManagedHooksIntoWslRuntime( try { // Why: same grant-then-fallback split as the host install — codex runs // inside the distro so the hash authority matches the codex the pane runs. + const runtimeHomePath = pathWin32.dirname(plan.tomlPath) + // Why: a successful re-grant replaces the ledger. Keep the previous + // records long enough to prove ownership of stale canonical-path keys. + const previousLedgerHome = readCodexTrustGrantLedgerHomeForReconciliation(runtimeHomePath) const grant = grantManagedCodexHookTrust({ - runtimeHomePath: pathWin32.dirname(plan.tomlPath), + runtimeHomePath, tomlPath: plan.tomlPath, managedCommand: command, managedEntries: trustEntries, host: { kind: 'wsl', distro: plan.wslDistro, linuxRuntimeHome: plan.linuxRuntimeHome } }) if (grant.lane === 'rpc') { - removeStaleWslRuntimeManagedHookTrustEntries(plan.tomlPath, grant.entries) + removeStaleWslRuntimeManagedHookTrustEntries( + plan.tomlPath, + grant.entries, + previousLedgerHome ? [previousLedgerHome] : [] + ) } else { // Why: WSL runtime homes may carry user hook approvals we did not rebuild // here; only upsert Orca's entries instead of sweeping the whole source. @@ -1183,7 +1068,9 @@ export class CodexHookService { // Why: RPC-granted entries store Codex's own hash, which is authoritative // even when it differs from computeTrustedHash — that difference is the // drift bug class this lane exists to absorb, not a stale entry. - const ledgerHome = readLedgerHomeForCleanup(getOrcaManagedCodexHomePath()) + const ledgerHome = readCurrentCodexTrustGrantLedgerHome(getOrcaManagedCodexHomePath(), { + kind: 'native' + }) const missing: string[] = [] const trustMissing: string[] = [] @@ -1230,9 +1117,9 @@ export class CodexHookService { } const trustKey = computeTrustKey(trustInput) const validHashes = new Set([computeTrustedHash(trustInput)]) - const granted = ledgerHome?.entries[normalizeHookTrustKeyForLookup(trustKey)] - if (granted && granted.signature === getCodexHookTrustSignature(trustInput)) { - validHashes.add(granted.trustedHash) + const grantedHash = getCodexLedgerTrustedHash(ledgerHome, trustKey, trustInput) + if (grantedHash) { + validHashes.add(grantedHash) } const actualState = trustEntries.get(trustKey) if (!actualState?.trustedHash || !validHashes.has(actualState.trustedHash)) {