From be366ab828c93146d2c4ef7086b60718f6e1929c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:41:23 -0700 Subject: [PATCH] test(codex): cover app-server trust grant client, cache, ledger, and lanes --- .../codex-app-server-capability-cache.test.ts | 117 +++++++ .../codex/codex-app-server-client.test.ts | 328 ++++++++++++++++++ src/main/codex/codex-hook-trust-grant.test.ts | 236 +++++++++++++ .../codex/codex-trust-grant-ledger.test.ts | 93 +++++ .../codex/hook-service-wsl-runtime.test.ts | 12 +- src/main/codex/hook-service.test.ts | 198 ++++++++++- 6 files changed, 980 insertions(+), 4 deletions(-) create mode 100644 src/main/codex/codex-app-server-capability-cache.test.ts create mode 100644 src/main/codex/codex-app-server-client.test.ts create mode 100644 src/main/codex/codex-hook-trust-grant.test.ts create mode 100644 src/main/codex/codex-trust-grant-ledger.test.ts diff --git a/src/main/codex/codex-app-server-capability-cache.test.ts b/src/main/codex/codex-app-server-capability-cache.test.ts new file mode 100644 index 00000000000..c6dea6e0031 --- /dev/null +++ b/src/main/codex/codex-app-server-capability-cache.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from 'vitest' +import { + CODEX_APP_SERVER_CAPABILITY_RETRY_INTERVAL_MS, + CodexAppServerCapabilityCache, + getCodexAppServerHostKey +} from './codex-app-server-capability-cache' + +const unsupportedError = new Error('unsupported') +const isUnsupported = (error: unknown): boolean => error === unsupportedError + +describe('CodexAppServerCapabilityCache', () => { + it('retries a host after the compatibility interval', () => { + const cache = new CodexAppServerCapabilityCache() + cache.rememberUnsupported('native', 1_000) + + expect( + cache.shouldTry('native', 1_000 + CODEX_APP_SERVER_CAPABILITY_RETRY_INTERVAL_MS - 1) + ).toBe(false) + expect(cache.shouldTry('native', 1_000 + CODEX_APP_SERVER_CAPABILITY_RETRY_INTERVAL_MS)).toBe( + true + ) + }) + + it('falls back on the first unsupported probe and skips the probe on later calls', () => { + const cache = new CodexAppServerCapabilityCache() + const firstPreferred = vi.fn(() => { + throw unsupportedError + }) + expect( + cache.runWithFallbackSync('native', firstPreferred, () => 'first-fallback', isUnsupported, 5) + ).toBe('first-fallback') + expect(firstPreferred).toHaveBeenCalledTimes(1) + + // Why: probes are synchronous on the main thread, so they can never + // overlap — back-to-back calls inside the retry window are the + // "concurrent probe" equivalent and must share the first probe's result. + const laterPreferred = vi.fn(() => 'unexpected-preferred') + expect( + cache.runWithFallbackSync('native', laterPreferred, () => 'cached-fallback', isUnsupported, 6) + ).toBe('cached-fallback') + expect( + cache.runWithFallbackSync('native', laterPreferred, () => 'cached-fallback', isUnsupported, 7) + ).toBe('cached-fallback') + expect(laterPreferred).not.toHaveBeenCalled() + }) + + it('isolates capability state per execution host', () => { + const cache = new CodexAppServerCapabilityCache() + cache.rememberUnsupported('wsl:Ubuntu', 1_000) + + expect(cache.shouldTry('wsl:Ubuntu', 1_001)).toBe(false) + expect(cache.shouldTry('native', 1_001)).toBe(true) + expect(cache.shouldTry('wsl:Debian', 1_001)).toBe(true) + + const nativePreferred = vi.fn(() => 'native-result') + expect( + cache.runWithFallbackSync('native', nativePreferred, () => 'unexpected', isUnsupported, 1_001) + ).toBe('native-result') + expect(nativePreferred).toHaveBeenCalledTimes(1) + }) + + it('drops known support when a later call reports the capability unsupported', () => { + const cache = new CodexAppServerCapabilityCache() + expect( + cache.runWithFallbackSync( + 'native', + () => 'supported', + () => 'unexpected', + isUnsupported, + 1 + ) + ).toBe('supported') + expect(cache.isKnownSupported('native')).toBe(true) + + expect( + cache.runWithFallbackSync( + 'native', + () => { + throw unsupportedError + }, + () => 'fallback', + isUnsupported, + 2 + ) + ).toBe('fallback') + expect(cache.isKnownSupported('native')).toBe(false) + + const laterPreferred = vi.fn(() => 'unexpected-preferred') + expect( + cache.runWithFallbackSync('native', laterPreferred, () => 'cached-fallback', isUnsupported, 3) + ).toBe('cached-fallback') + expect(laterPreferred).not.toHaveBeenCalled() + }) + + it('rethrows transient errors without marking the host unsupported', () => { + const cache = new CodexAppServerCapabilityCache() + const transient = new Error('spawn ETIMEDOUT') + expect(() => + cache.runWithFallbackSync( + 'native', + () => { + throw transient + }, + () => 'unexpected-fallback', + isUnsupported, + 1 + ) + ).toThrow(transient) + expect(cache.shouldTry('native', 2)).toBe(true) + }) + + it('builds host keys that keep WSL distros apart', () => { + expect(getCodexAppServerHostKey({ kind: 'native' })).toBe('native') + expect(getCodexAppServerHostKey({ kind: 'wsl', distro: 'Ubuntu' })).toBe('wsl:Ubuntu') + expect(getCodexAppServerHostKey({ kind: 'wsl', distro: 'Debian' })).toBe('wsl:Debian') + }) +}) diff --git a/src/main/codex/codex-app-server-client.test.ts b/src/main/codex/codex-app-server-client.test.ts new file mode 100644 index 00000000000..3db80904f54 --- /dev/null +++ b/src/main/codex/codex-app-server-client.test.ts @@ -0,0 +1,328 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + CodexAppServerTimeoutError, + CodexAppServerUnsupportedError, + isCodexAppServerUnsupportedError, + runCodexHookTrustGrantSession, + type CodexHookTrustGrantRequest +} from './codex-app-server-client' +import { 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 +// via STUB_CONFIG so each test controls listings, errors, and hangs. +const STUB_SERVER_SOURCE = ` +const config = JSON.parse(process.env.STUB_CONFIG) +const trusted = new Set(config.hooks.filter(h => h.trustStatus === 'trusted').map(h => h.key)) +let buffer = '' +function send(message) { process.stdout.write(JSON.stringify(message) + '\\n') } +function listing() { + return { + data: [{ + cwd: config.cwd, + hooks: config.hooks.map(h => ({ ...h, trustStatus: trusted.has(h.key) ? 'trusted' : h.trustStatus })), + warnings: [], + errors: [] + }] + } +} +if (config.scenario === 'no-subcommand') { + process.stderr.write("error: unrecognized subcommand 'app-server'\\n") + process.exit(2) +} +process.stdin.setEncoding('utf8') +process.stdin.on('data', (chunk) => { + buffer += chunk + let index + while ((index = buffer.indexOf('\\n')) !== -1) { + const line = buffer.slice(0, index).trim() + buffer = buffer.slice(index + 1) + if (!line) continue + const message = JSON.parse(line) + if (message.method === 'initialize') { + send({ id: message.id, result: { userAgent: 'stub/0.0.0' } }) + continue + } + if (message.method === 'initialized') continue + if (config.scenario === 'hang') continue + if (message.method === 'hooks/list') { + if (config.scenario === 'unknown-method') { + send({ id: message.id, error: { code: -32601, message: 'Method not found' } }) + continue + } + send({ id: message.id, result: listing() }) + continue + } + if (message.method === 'config/batchWrite') { + if (config.scenario === 'reject-write') { + process.exit(9) + } + writeFileSyncSafe(config.recordFile, JSON.stringify(message.params)) + for (const key of Object.keys(message.params.edits[0].value)) trusted.add(key) + send({ id: message.id, result: { status: 'ok', version: 'v1', filePath: config.cwd + '/config.toml' } }) + continue + } + } +}) +process.stdin.on('end', () => process.exit(0)) +function writeFileSyncSafe(file, contents) { require('node:fs').writeFileSync(file, contents) } +` + +let tempRoots: string[] = [] + +afterEach(() => { + for (const root of tempRoots) { + rmSync(root, { recursive: true, force: true }) + } + tempRoots = [] +}) + +type StubHook = { + key: string + command: string | null + currentHash: string + trustStatus: string +} + +function createStubRequest(options: { + scenario: string + hooks: StubHook[] + expectedTrustKeys: string[] + managedCommand: string + timeoutMs?: number +}): { request: CodexHookTrustGrantRequest; recordFile: string } { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-stub-')) + tempRoots.push(root) + const stubPath = join(root, 'stub-app-server.cjs') + writeFileSync(stubPath, STUB_SERVER_SOURCE) + const recordFile = join(root, 'batch-write-params.json') + return { + recordFile, + request: { + invocation: { + command: process.execPath, + args: [stubPath], + env: { + STUB_CONFIG: JSON.stringify({ + scenario: options.scenario, + hooks: options.hooks, + cwd: root, + recordFile + }) + }, + timeoutMs: options.timeoutMs ?? 10_000 + }, + hooksListCwd: root, + expectedTrustKeys: options.expectedTrustKeys, + managedCommand: options.managedCommand + } + } +} + +const MANAGED_COMMAND = "/bin/sh '/tmp/orca/codex-hook.sh'" + +function managedHook(key: string, trustStatus = 'untrusted'): StubHook { + return { key, command: MANAGED_COMMAND, currentHash: `sha256:hash-of-${key}`, trustStatus } +} + +describe('runCodexHookTrustGrantSession', () => { + it('grants and verifies exactly the expected managed entries', async () => { + const keys = [ + '/home/a/.codex/hooks.json:session_start:0:0', + '/home/a/.codex/hooks.json:stop:0:0' + ] + const userHook: StubHook = { + key: '/home/a/.codex/hooks.json:stop:1:0', + command: 'echo user-hook', + currentHash: 'sha256:user-hash', + trustStatus: 'untrusted' + } + const { request, recordFile } = createStubRequest({ + scenario: 'happy', + hooks: [...keys.map((key) => managedHook(key)), userHook], + expectedTrustKeys: keys, + managedCommand: MANAGED_COMMAND + }) + + const result = await runCodexHookTrustGrantSession(request) + expect(result.outcome).toBe('granted') + if (result.outcome !== 'granted') { + return + } + expect(result.wroteTrust).toBe(true) + expect(result.entries.map((entry) => entry.key).sort()).toEqual([...keys].sort()) + expect(result.entries.map((entry) => entry.trustedHash).sort()).toEqual( + keys.map((key) => `sha256:hash-of-${key}`).sort() + ) + + // Why: the write must never include user hooks, even untrusted ones. + const written = JSON.parse(readFileSync(recordFile, 'utf-8')) as { + edits: { + keyPath: string + value: Record + mergeStrategy: string + }[] + reloadUserConfig: boolean + } + expect(written.edits).toHaveLength(1) + expect(written.edits[0].keyPath).toBe('hooks.state') + expect(written.edits[0].mergeStrategy).toBe('upsert') + expect(Object.keys(written.edits[0].value).sort()).toEqual([...keys].sort()) + expect(written.reloadUserConfig).toBe(true) + }) + + it('skips config/batchWrite when every expected entry is already trusted', async () => { + const keys = ['/home/a/.codex/hooks.json:session_start:0:0'] + // Why: the stub exits(9) on batchWrite in this scenario, so a write would + // fail the session instead of silently passing. + const { request, recordFile } = createStubRequest({ + scenario: 'reject-write', + hooks: keys.map((key) => managedHook(key, 'trusted')), + expectedTrustKeys: keys, + managedCommand: MANAGED_COMMAND + }) + + const result = await runCodexHookTrustGrantSession(request) + expect(result).toMatchObject({ outcome: 'granted', wroteTrust: false }) + expect(existsSync(recordFile)).toBe(false) + }) + + it('reports verify-failed when expected entries are missing from the listing', async () => { + const { request } = createStubRequest({ + scenario: 'happy', + hooks: [managedHook('/home/a/.codex/hooks.json:session_start:0:0')], + expectedTrustKeys: [ + '/home/a/.codex/hooks.json:session_start:0:0', + '/home/a/.codex/hooks.json:stop:0:0' + ], + managedCommand: MANAGED_COMMAND + }) + + const result = await runCodexHookTrustGrantSession(request) + expect(result.outcome).toBe('verify-failed') + }) + + it('throws the unsupported error class for unknown JSON-RPC methods', async () => { + const keys = ['/home/a/.codex/hooks.json:session_start:0:0'] + const { request } = createStubRequest({ + scenario: 'unknown-method', + hooks: keys.map((key) => managedHook(key)), + expectedTrustKeys: keys, + managedCommand: MANAGED_COMMAND + }) + + await expect(runCodexHookTrustGrantSession(request)).rejects.toBeInstanceOf( + CodexAppServerUnsupportedError + ) + }) + + it('throws the unsupported error class when the CLI lacks the app-server subcommand', async () => { + const keys = ['/home/a/.codex/hooks.json:session_start:0:0'] + const { request } = createStubRequest({ + scenario: 'no-subcommand', + hooks: [], + expectedTrustKeys: keys, + managedCommand: MANAGED_COMMAND + }) + + const error = await runCodexHookTrustGrantSession(request).catch((caught: unknown) => caught) + expect(isCodexAppServerUnsupportedError(error)).toBe(true) + }) + + it('kills a hung server at the session deadline', async () => { + const keys = ['/home/a/.codex/hooks.json:session_start:0:0'] + const { request } = createStubRequest({ + scenario: 'hang', + hooks: keys.map((key) => managedHook(key)), + expectedTrustKeys: keys, + managedCommand: MANAGED_COMMAND, + timeoutMs: 500 + }) + + const startedAt = Date.now() + await expect(runCodexHookTrustGrantSession(request)).rejects.toBeInstanceOf( + CodexAppServerTimeoutError + ) + // Why: the reap path must not stack the grace periods on top of the + // deadline — a wedged server may ignore everything but SIGKILL. + expect(Date.now() - startedAt).toBeLessThan(5_000) + }) + + it('surfaces spawn failures as regular errors, not capability signals', async () => { + const request: CodexHookTrustGrantRequest = { + invocation: { + command: join(tmpdir(), 'orca-codex-missing-binary-does-not-exist'), + args: [], + timeoutMs: 2_000 + }, + hooksListCwd: tmpdir(), + expectedTrustKeys: ['k'], + managedCommand: MANAGED_COMMAND + } + const error = await runCodexHookTrustGrantSession(request).catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(Error) + expect(isCodexAppServerUnsupportedError(error)).toBe(false) + }) +}) + +describe('runCodexHookTrustGrantSessionSync', () => { + function writeEntryFixture(source: string): string { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-entry-')) + tempRoots.push(root) + const entryPath = join(root, 'grant-entry.cjs') + writeFileSync(entryPath, source) + return entryPath + } + + const baseRequest: CodexHookTrustGrantRequest = { + invocation: { command: 'codex', args: ['app-server'], timeoutMs: 1_000 }, + hooksListCwd: '/tmp', + expectedTrustKeys: ['k'], + managedCommand: MANAGED_COMMAND + } + + it('returns the entry envelope result and passes the request over stdin', () => { + const entryPath = writeEntryFixture(` + let input = '' + process.stdin.setEncoding('utf8') + process.stdin.on('data', (chunk) => { input += chunk }) + process.stdin.on('end', () => { + const request = JSON.parse(input) + process.stdout.write(JSON.stringify({ + ok: true, + result: { + outcome: 'granted', + wroteTrust: true, + entries: [{ key: request.expectedTrustKeys[0], normalizedKey: request.expectedTrustKeys[0], trustedHash: 'sha256:x' }] + } + }) + '\\n') + }) + `) + const result = runCodexHookTrustGrantSessionSync(baseRequest, { entryPath }) + expect(result).toMatchObject({ outcome: 'granted', wroteTrust: true }) + }) + + it('rethrows unsupported envelopes as the unsupported error class', () => { + const entryPath = writeEntryFixture(` + process.stdin.resume() + process.stdin.on('end', () => { + process.stdout.write(JSON.stringify({ ok: false, errorName: 'CodexAppServerUnsupportedError', message: 'no app-server', unsupported: true }) + '\\n') + }) + `) + expect(() => runCodexHookTrustGrantSessionSync(baseRequest, { entryPath })).toThrow( + CodexAppServerUnsupportedError + ) + }) + + it('fails with a clear error when the entry produces no result', () => { + const entryPath = writeEntryFixture( + `process.stdin.resume(); process.stdin.on('end', () => process.exit(7))` + ) + expect(() => runCodexHookTrustGrantSessionSync(baseRequest, { entryPath })).toThrow( + /produced no result \(exit 7\)/ + ) + }) +}) diff --git a/src/main/codex/codex-hook-trust-grant.test.ts b/src/main/codex/codex-hook-trust-grant.test.ts new file mode 100644 index 00000000000..37046f17d47 --- /dev/null +++ b/src/main/codex/codex-hook-trust-grant.test.ts @@ -0,0 +1,236 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + CodexAppServerUnsupportedError, + type CodexHookTrustGrantRequest +} from './codex-app-server-client' +import { codexAppServerCapabilityCache } from './codex-app-server-capability-cache' +import { + _internals, + getCodexTrustGrantDiagnostics, + grantManagedCodexHookTrust, + type CodexManagedTrustGrantPlan +} from './codex-hook-trust-grant' +import { readCodexTrustGrantLedgerHome } from './codex-trust-grant-ledger' +import { + computeTrustKey, + normalizeHookTrustKeyForLookup, + upsertHookTrustEntries, + type CodexTrustEntry +} from './config-toml-trust' + +let userDataDir: string +let runtimeHomeDir: string +let previousUserDataPath: string | undefined + +beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'orca-trust-grant-userdata-')) + runtimeHomeDir = join(userDataDir, 'codex-runtime-home', 'home') + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + codexAppServerCapabilityCache.clear() + _internals.resetDiagnostics() +}) + +afterEach(() => { + _internals.setGrantSessionRunnerSync(null) + codexAppServerCapabilityCache.clear() + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC + rmSync(userDataDir, { recursive: true, force: true }) +}) + +const MANAGED_COMMAND = "/bin/sh '/tmp/orca/codex-hook.sh'" + +function managedEntry(eventLabel: CodexTrustEntry['eventLabel']): CodexTrustEntry { + return { + sourcePath: join(runtimeHomeDir, 'hooks.json'), + eventLabel, + groupIndex: 0, + handlerIndex: 0, + command: MANAGED_COMMAND, + timeoutSec: 10 + } +} + +function buildPlan(entries: CodexTrustEntry[]): CodexManagedTrustGrantPlan { + return { + runtimeHomePath: runtimeHomeDir, + tomlPath: join(runtimeHomeDir, 'config.toml'), + managedCommand: MANAGED_COMMAND, + managedEntries: entries, + host: { kind: 'native' } + } +} + +function grantedSessionResult(entries: CodexTrustEntry[], hashPrefix = 'sha256:codex-') { + return { + outcome: 'granted' as const, + wroteTrust: true, + entries: entries.map((entry) => { + const key = computeTrustKey(entry) + return { + key, + normalizedKey: normalizeHookTrustKeyForLookup(key), + trustedHash: `${hashPrefix}${entry.eventLabel}` + } + }) + } +} + +describe('grantManagedCodexHookTrust', () => { + it('returns granted entries with codex-verbatim hashes and records the ledger', () => { + const entries = [managedEntry('session_start'), managedEntry('stop')] + const runner = vi.fn((_request: CodexHookTrustGrantRequest) => grantedSessionResult(entries)) + _internals.setGrantSessionRunnerSync(runner) + + const outcome = grantManagedCodexHookTrust(buildPlan(entries)) + expect(outcome.lane).toBe('rpc') + if (outcome.lane !== 'rpc') { + return + } + expect(outcome.entries.map((entry) => entry.trustedHash)).toEqual([ + 'sha256:codex-session_start', + 'sha256:codex-stop' + ]) + expect(runner).toHaveBeenCalledTimes(1) + const request = runner.mock.calls[0]![0]! + expect(request.managedCommand).toBe(MANAGED_COMMAND) + expect(request.expectedTrustKeys).toHaveLength(2) + expect(request.invocation.env?.CODEX_HOME).toBe(runtimeHomeDir) + + const ledgerHome = readCodexTrustGrantLedgerHome(runtimeHomeDir) + expect(ledgerHome).not.toBeNull() + expect(Object.keys(ledgerHome!.entries)).toHaveLength(2) + expect(getCodexTrustGrantDiagnostics()).toMatchObject({ granted: 1, fellBack: 0 }) + }) + + 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)) + _internals.setGrantSessionRunnerSync(runner) + const plan = buildPlan(entries) + + const first = grantManagedCodexHookTrust(plan) + expect(first.lane).toBe('rpc') + expect(runner).toHaveBeenCalledTimes(1) + + // Why: the ledger skip only holds while config.toml still carries the + // granted hash at the granted key — write it the way codex left it. + upsertHookTrustEntries(plan.tomlPath, [ + { ...entries[0], trustedHash: 'sha256:codex-session_start' } + ]) + const second = grantManagedCodexHookTrust(plan) + expect(second.lane).toBe('rpc') + expect(runner).toHaveBeenCalledTimes(1) + expect(getCodexTrustGrantDiagnostics()).toMatchObject({ granted: 1, ledgerHits: 1 }) + + // Config drift (user wiped the trust entry) must re-run the session. + upsertHookTrustEntries(plan.tomlPath, [{ ...entries[0], trustedHash: 'sha256:wiped' }]) + const third = grantManagedCodexHookTrust(plan) + expect(third.lane).toBe('rpc') + expect(runner).toHaveBeenCalledTimes(2) + }) + + it('re-grants when the managed hook identity changes', () => { + const entries = [managedEntry('session_start')] + const runner = vi.fn(() => grantedSessionResult(entries)) + _internals.setGrantSessionRunnerSync(runner) + const plan = buildPlan(entries) + grantManagedCodexHookTrust(plan) + upsertHookTrustEntries(plan.tomlPath, [ + { ...entries[0], trustedHash: 'sha256:codex-session_start' } + ]) + + const changedEntries = [{ ...entries[0], timeoutSec: 99 }] + const changedRunner = vi.fn(() => grantedSessionResult(changedEntries)) + _internals.setGrantSessionRunnerSync(changedRunner) + const outcome = grantManagedCodexHookTrust(buildPlan(changedEntries)) + expect(outcome.lane).toBe('rpc') + expect(changedRunner).toHaveBeenCalledTimes(1) + }) + + it('marks the host unsupported only for the unsupported error class', () => { + const entries = [managedEntry('session_start')] + const runner = vi.fn(() => { + throw new CodexAppServerUnsupportedError('no such method') + }) + _internals.setGrantSessionRunnerSync(runner) + const plan = buildPlan(entries) + + expect(grantManagedCodexHookTrust(plan)).toMatchObject({ + lane: 'fallback', + reason: 'unsupported' + }) + expect(runner).toHaveBeenCalledTimes(1) + + // Cached: the second install skips the probe entirely. + expect(grantManagedCodexHookTrust(plan)).toMatchObject({ + lane: 'fallback', + reason: 'unsupported-cached' + }) + expect(runner).toHaveBeenCalledTimes(1) + }) + + it('falls back without poisoning the capability on transient errors', () => { + const entries = [managedEntry('session_start')] + const runner = vi.fn(() => { + throw new Error('spawn ETIMEDOUT') + }) + _internals.setGrantSessionRunnerSync(runner) + const plan = buildPlan(entries) + + expect(grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'fallback', reason: 'error' }) + expect(grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'fallback', reason: 'error' }) + expect(runner).toHaveBeenCalledTimes(2) + }) + + it('falls back on verify-failed without marking unsupported', () => { + const entries = [managedEntry('session_start')] + const runner = vi.fn(() => ({ outcome: 'verify-failed' as const, reason: 'missing entries' })) + _internals.setGrantSessionRunnerSync(runner) + + expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ + lane: 'fallback', + reason: 'verify-failed' + }) + expect(codexAppServerCapabilityCache.shouldTry('native')).toBe(true) + expect(getCodexTrustGrantDiagnostics()).toMatchObject({ verifyFailed: 1 }) + }) + + it('honors the ops kill switch env flag', () => { + process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' + const entries = [managedEntry('session_start')] + const runner = vi.fn(() => grantedSessionResult(entries)) + _internals.setGrantSessionRunnerSync(runner) + + expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ + lane: 'fallback', + reason: 'disabled' + }) + expect(runner).not.toHaveBeenCalled() + }) + + it('builds a WSL invocation that runs codex inside the distro', () => { + const entries = [managedEntry('session_start')] + const runner = vi.fn((_request: CodexHookTrustGrantRequest) => grantedSessionResult(entries)) + _internals.setGrantSessionRunnerSync(runner) + + const outcome = grantManagedCodexHookTrust({ + ...buildPlan(entries), + host: { kind: 'wsl', distro: 'Ubuntu', linuxRuntimeHome: '/home/alice/.codex-runtime' } + }) + expect(outcome.lane).toBe('rpc') + const request = runner.mock.calls[0]![0]! + expect(request.invocation.command).toBe('wsl.exe') + expect(request.invocation.args.slice(0, 2)).toEqual(['-d', 'Ubuntu']) + expect(request.invocation.args.join(' ')).toContain('app-server') + expect(request.hooksListCwd).toBe('/home/alice/.codex-runtime') + }) +}) diff --git a/src/main/codex/codex-trust-grant-ledger.test.ts b/src/main/codex/codex-trust-grant-ledger.test.ts new file mode 100644 index 00000000000..dfb8ecbd4a1 --- /dev/null +++ b/src/main/codex/codex-trust-grant-ledger.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + binaryStampsMatch, + getCodexTrustGrantLedgerPath, + readCodexTrustGrantLedgerHome, + removeCodexTrustGrantLedgerHome, + writeCodexTrustGrantLedgerHome +} from './codex-trust-grant-ledger' + +let userDataDir: string +let previousUserDataPath: string | undefined + +beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'orca-trust-ledger-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir +}) + +afterEach(() => { + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + rmSync(userDataDir, { recursive: true, force: true }) +}) + +describe('codex trust grant ledger', () => { + it('round-trips per-home grant records and isolates homes', () => { + const hostHome = join(userDataDir, 'codex-runtime-home', 'home') + const wslHome = '\\\\wsl.localhost\\Ubuntu\\home\\alice\\runtime-home' + writeCodexTrustGrantLedgerHome(hostHome, { + binary: { kind: 'native', path: '/usr/local/bin/codex', size: 10, mtimeMs: 20 }, + entries: { 'k1:session_start:0:0': { signature: 'sig-1', trustedHash: 'sha256:a' } } + }) + writeCodexTrustGrantLedgerHome(wslHome, { + binary: { kind: 'wsl', distro: 'Ubuntu' }, + entries: { + '/home/alice/hooks.json:stop:0:0': { signature: 'sig-2', trustedHash: 'sha256:b' } + } + }) + + expect(readCodexTrustGrantLedgerHome(hostHome)?.entries['k1:session_start:0:0']).toEqual({ + signature: 'sig-1', + trustedHash: 'sha256:a' + }) + expect(readCodexTrustGrantLedgerHome(wslHome)?.binary).toEqual({ + kind: 'wsl', + distro: 'Ubuntu' + }) + + removeCodexTrustGrantLedgerHome(hostHome) + expect(readCodexTrustGrantLedgerHome(hostHome)).toBeNull() + expect(readCodexTrustGrantLedgerHome(wslHome)).not.toBeNull() + }) + + it('treats Windows path-case variants as the same home', () => { + const home = 'C:\\Users\\Alice\\AppData\\Roaming\\orca\\codex-runtime-home\\home' + writeCodexTrustGrantLedgerHome(home, { binary: null, entries: {} }) + expect( + readCodexTrustGrantLedgerHome('c:/users/alice/appdata/roaming/orca/codex-runtime-home/home') + ).not.toBeNull() + }) + + it('tolerates a corrupt ledger file', () => { + const home = join(userDataDir, 'codex-runtime-home', 'home') + writeFileSync(getCodexTrustGrantLedgerPath(), 'not-json{{{') + expect(readCodexTrustGrantLedgerHome(home)).toBeNull() + // Why: a corrupt file must not block recording the next verified grant. + writeCodexTrustGrantLedgerHome(home, { binary: null, entries: {} }) + expect(readCodexTrustGrantLedgerHome(home)).not.toBeNull() + }) + + it('matches binary stamps only on identical identity', () => { + const stamp = { kind: 'native' as const, path: '/bin/codex', size: 1, mtimeMs: 2 } + expect(binaryStampsMatch(stamp, { ...stamp })).toBe(true) + expect(binaryStampsMatch(stamp, { ...stamp, mtimeMs: 3 })).toBe(false) + expect(binaryStampsMatch(stamp, { ...stamp, size: 9 })).toBe(false) + expect(binaryStampsMatch(stamp, { ...stamp, path: '/other/codex' })).toBe(false) + expect(binaryStampsMatch(stamp, null)).toBe(false) + expect(binaryStampsMatch(null, null)).toBe(true) + expect( + binaryStampsMatch({ kind: 'wsl', distro: 'Ubuntu' }, { kind: 'wsl', distro: 'Ubuntu' }) + ).toBe(true) + expect( + binaryStampsMatch({ kind: 'wsl', distro: 'Ubuntu' }, { kind: 'wsl', distro: 'Debian' }) + ).toBe(false) + expect(binaryStampsMatch({ kind: 'wsl', distro: 'Ubuntu' }, stamp)).toBe(false) + }) +}) diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index d6b06f85189..73c8edd8b80 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -48,7 +48,9 @@ function createTestPlan(): CodexWslRuntimeHookInstallPlan { tomlPath: join(root, 'config.toml'), scriptPath: join(root, '.orca', 'agent-hooks', 'codex-hook.sh'), commandScriptPath: `${linuxHome}/.orca/agent-hooks/codex-hook.sh`, - trustConfigPath: `${linuxHome}/hooks.json` + trustConfigPath: `${linuxHome}/hooks.json`, + wslDistro: 'Ubuntu', + linuxRuntimeHome: linuxHome } } @@ -83,7 +85,9 @@ describe('Codex WSL runtime hook install', () => { scriptPath: pathWin32.join(runtimeHome, '.orca', 'agent-hooks', 'codex-hook.sh'), commandScriptPath: '/home/alice/.local/share/orca/codex-runtime-home/home/.orca/agent-hooks/codex-hook.sh', - trustConfigPath: '/home/alice/.local/share/orca/codex-runtime-home/home/hooks.json' + trustConfigPath: '/home/alice/.local/share/orca/codex-runtime-home/home/hooks.json', + wslDistro: 'Ubuntu', + linuxRuntimeHome: '/home/alice/.local/share/orca/codex-runtime-home/home' }) }) @@ -102,7 +106,9 @@ describe('Codex WSL runtime hook install', () => { scriptPath: pathWin32.join(runtimeHome, '.orca', 'agent-hooks', 'codex-hook.sh'), commandScriptPath: '/mnt/d/wsl-home/.local/share/orca/codex-runtime-home/home/.orca/agent-hooks/codex-hook.sh', - trustConfigPath: '/mnt/d/wsl-home/.local/share/orca/codex-runtime-home/home/hooks.json' + trustConfigPath: '/mnt/d/wsl-home/.local/share/orca/codex-runtime-home/home/hooks.json', + wslDistro: 'Ubuntu', + linuxRuntimeHome: '/mnt/d/wsl-home/.local/share/orca/codex-runtime-home/home' }) }) diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts index 526a64d99ab..afd1b7ea4c3 100644 --- a/src/main/codex/hook-service.test.ts +++ b/src/main/codex/hook-service.test.ts @@ -17,7 +17,17 @@ 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, upsertHookTrustEntriesInContent } from './config-toml-trust' +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' const { getPathMock, homedirMock } = vi.hoisted(() => ({ getPathMock: vi.fn<(name: string) => string>(), @@ -1454,3 +1464,189 @@ describe('CodexHookService', () => { expect(trustConfig).not.toContain('model = "runtime-model"') }) }) + +describe('CodexHookService app-server trust grant lane', () => { + 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 + }) + ) + }) +})