diff --git a/build-plugins/plain-node-entry-guard.ts b/build-plugins/plain-node-entry-guard.ts index 1fddb5f6a12..81abb08cf79 100644 --- a/build-plugins/plain-node-entry-guard.ts +++ b/build-plugins/plain-node-entry-guard.ts @@ -18,7 +18,8 @@ const PLAIN_NODE_ENTRY_NAMES = [ 'daemon-entry', 'parcel-watcher-process-entry', 'computer-sidecar', - 'agent-hooks/managed-agent-hook-controls' + 'agent-hooks/managed-agent-hook-controls', + 'codex/codex-app-server-grant-entry' ] as const const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/ diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 7b8c9cb8318..57c6ffe792b 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -185,6 +185,12 @@ export default defineConfig({ // Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults // can't take down the main process (issue #7547). 'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'), + // Why: run under ELECTRON_RUN_AS_NODE while the caller blocks on + // spawnSync — codex app-server trust grants need a live event loop + // but must finish before a Codex pane launch proceeds. + 'codex/codex-app-server-grant-entry': resolve( + 'src/main/codex/codex-app-server-grant-entry.ts' + ), // Why: electron-vite cleans out/main in dev. The dev CLI imports // this path for `orca agent hooks ...`, so it must survive rebuilds. 'agent-hooks/managed-agent-hook-controls': resolve( diff --git a/src/main/codex-accounts/wsl-codex-command.ts b/src/main/codex-accounts/wsl-codex-command.ts index 02527c8ed23..570129ea273 100644 --- a/src/main/codex-accounts/wsl-codex-command.ts +++ b/src/main/codex-accounts/wsl-codex-command.ts @@ -12,6 +12,19 @@ export function buildWslCodexAvailabilityArgs(distro: string): string[] { return buildWslCodexShellArgs(distro, command) } +export function buildWslCodexAppServerArgs(distro: string, linuxHomePath: string): string[] { + const command = [ + buildCodexPathLookup(), + 'if [ -z "$resolved" ]; then', + " printf '%s\\n' 'Codex CLI not found in the WSL login-shell PATH.' >&2", + ' exit 127', + 'fi', + `export CODEX_HOME=${quotePosixShell(linuxHomePath)}`, + 'exec "$resolved" app-server' + ].join('\n') + return buildWslCodexShellArgs(distro, command) +} + export function buildWslCodexLoginArgs(distro: string, linuxHomePath: string): string[] { const command = [ buildCodexPathLookup(), 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-capability-cache.ts b/src/main/codex/codex-app-server-capability-cache.ts new file mode 100644 index 00000000000..85816c9cf0c --- /dev/null +++ b/src/main/codex/codex-app-server-capability-cache.ts @@ -0,0 +1,84 @@ +// Why: suppress a known-missing RPC surface without pinning it forever — an +// in-place codex upgrade during a long Orca session self-heals after the +// interval, mirroring GitCapabilityCache's rationale. +export const CODEX_APP_SERVER_CAPABILITY_RETRY_INTERVAL_MS = 30 * 60_000 + +/** Execution host that runs the codex binary. WSL distros are isolated from + * the native host and from each other — each can carry a different codex. */ +export type CodexAppServerHostKey = 'native' | `wsl:${string}` + +export function getCodexAppServerHostKey( + host: { kind: 'native' } | { kind: 'wsl'; distro: string } +): CodexAppServerHostKey { + return host.kind === 'wsl' ? `wsl:${host.distro}` : 'native' +} + +/** + * Capability cache for the codex app-server trust-grant RPC pair, modeled on + * GitCapabilityCache but with a synchronous runner: the grant client blocks + * the main thread by design (launch prep), so probes cannot overlap — the + * unsupported mark alone is what keeps later installs off the dead probe. + */ +export class CodexAppServerCapabilityCache { + private readonly retryAfterByHost = new Map() + private readonly supportedHosts = new Set() + + shouldTry(hostKey: CodexAppServerHostKey, nowMs = Date.now()): boolean { + const retryAfterMs = this.retryAfterByHost.get(hostKey) + if (retryAfterMs === undefined) { + return true + } + if (nowMs < retryAfterMs) { + return false + } + this.retryAfterByHost.delete(hostKey) + return true + } + + isKnownSupported(hostKey: CodexAppServerHostKey): boolean { + return this.supportedHosts.has(hostKey) + } + + rememberUnsupported(hostKey: CodexAppServerHostKey, nowMs = Date.now()): void { + this.supportedHosts.delete(hostKey) + this.retryAfterByHost.set(hostKey, nowMs + CODEX_APP_SERVER_CAPABILITY_RETRY_INTERVAL_MS) + } + + rememberSupported(hostKey: CodexAppServerHostKey): void { + this.retryAfterByHost.delete(hostKey) + this.supportedHosts.add(hostKey) + } + + runWithFallbackSync( + hostKey: CodexAppServerHostKey, + runPreferred: () => T, + runFallback: () => T, + isUnsupportedError: (error: unknown) => boolean, + nowMs = Date.now() + ): T { + if (!this.supportedHosts.has(hostKey) && !this.shouldTry(hostKey, nowMs)) { + return runFallback() + } + try { + const result = runPreferred() + this.rememberSupported(hostKey) + return result + } catch (error) { + // Why: only a positive absence signal (unknown method / missing + // subcommand) marks unsupported. Transient spawn failures, timeouts, + // and RPC errors fall back once without poisoning the capability. + if (!isUnsupportedError(error)) { + throw error + } + this.rememberUnsupported(hostKey, nowMs) + return runFallback() + } + } + + clear(): void { + this.retryAfterByHost.clear() + this.supportedHosts.clear() + } +} + +export const codexAppServerCapabilityCache = new CodexAppServerCapabilityCache() 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-app-server-client.ts b/src/main/codex/codex-app-server-client.ts new file mode 100644 index 00000000000..bb1fccf48c7 --- /dev/null +++ b/src/main/codex/codex-app-server-client.ts @@ -0,0 +1,362 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { normalizeHookTrustKeyForLookup } from './config-toml-trust' + +// Why: Codex gates hooks on a `trusted_hash` it computes from a private +// canonical-JSON identity. Orca used to replicate that algorithm +// (computeTrustedHash), which drifted from the real one across Codex releases +// (#7896, #7110, #8699). `codex app-server` exposes the same sanctioned RPCs +// the Codex TUI "Trust all" button uses — hooks/list (returns Codex's own +// currentHash per hook) and config/batchWrite (upserts hooks.state through +// Codex's comment-preserving writer) — so this client grants trust with +// Codex as the only hash authority. See upstream codex-rs/tui/src/hooks_rpc.rs +// and codex-rs/tui/src/startup_hooks_review.rs. + +export type CodexAppServerInvocation = { + command: string + args: string[] + /** Overlay applied on top of the inherited environment (e.g. CODEX_HOME). */ + env?: Record + /** Whole-session deadline. The codex child is SIGKILLed when it lapses. */ + timeoutMs: number +} + +export type CodexHookTrustGrantRequest = { + invocation: CodexAppServerInvocation + /** cwd passed to hooks/list. Discovery of the managed CODEX_HOME's + * hooks.json is cwd-independent (user scope); this only scopes which + * project hooks appear, which the key filter below ignores anyway. */ + hooksListCwd: string + /** Lookup-normalized trust keys (normalizeHookTrustKeyForLookup shape) for + * the managed entries Orca just wrote. Grants are restricted to hooks whose + * reported key normalizes into this set — user hooks are never touched. */ + expectedTrustKeys: string[] + /** Exact command string written to the managed hooks.json entries. */ + managedCommand: string +} + +export type CodexGrantedHookTrust = { + /** Trust key exactly as Codex reported it. */ + key: string + normalizedKey: string + /** Codex-computed hash now stored as trusted_hash for this key. */ + trustedHash: string +} + +export type CodexHookTrustGrantSessionResult = + | { + outcome: 'granted' + entries: CodexGrantedHookTrust[] + /** False when every expected entry was already trusted (no write). */ + wroteTrust: boolean + } + | { outcome: 'verify-failed'; reason: string } + +/** Codex-side absence of the trust-grant RPC surface (old CLI without the + * app-server subcommand, or a server without hooks/list / config/batchWrite). + * This is the ONLY error class the capability cache marks unsupported. */ +export class CodexAppServerUnsupportedError extends Error { + constructor(message: string) { + super(message) + this.name = 'CodexAppServerUnsupportedError' + } +} + +export class CodexAppServerTimeoutError extends Error { + constructor(message: string) { + super(message) + this.name = 'CodexAppServerTimeoutError' + } +} + +export function isCodexAppServerUnsupportedError(error: unknown): boolean { + return error instanceof Error && error.name === 'CodexAppServerUnsupportedError' +} + +type JsonRpcResponse = { + id?: number + result?: unknown + error?: { code?: number; message?: string } +} + +type CodexHookListing = { + key: string + command: string | null + currentHash: string + trustStatus: string +} + +const JSON_RPC_METHOD_NOT_FOUND = -32601 +const STDERR_TAIL_MAX_BYTES = 8192 + +function isMethodNotFoundError(error: { code?: number; message?: string }): boolean { + return error.code === JSON_RPC_METHOD_NOT_FOUND || /method not found/i.test(error.message ?? '') +} + +// Why: a CLI predating the app-server subcommand fails argv parsing before +// speaking any JSON-RPC; that shape is a capability signal, not a transient. +function stderrIndicatesMissingAppServer(stderrTail: string): boolean { + return /unrecognized subcommand|unexpected argument|invalid subcommand/i.test(stderrTail) +} + +function collectHookListings(result: unknown): CodexHookListing[] { + const data = + result && typeof result === 'object' && Array.isArray((result as { data?: unknown }).data) + ? ((result as { data: unknown[] }).data as { hooks?: unknown }[]) + : [] + const listings: CodexHookListing[] = [] + const seenKeys = new Set() + for (const entry of data) { + const hooks = Array.isArray(entry?.hooks) ? entry.hooks : [] + for (const hook of hooks as Record[]) { + if ( + typeof hook?.key !== 'string' || + typeof hook.currentHash !== 'string' || + typeof hook.trustStatus !== 'string' + ) { + continue + } + // Why: hooks/list repeats user-scope hooks per requested cwd; grants + // must consider each key once. + if (seenKeys.has(hook.key)) { + continue + } + seenKeys.add(hook.key) + listings.push({ + key: hook.key, + command: typeof hook.command === 'string' ? hook.command : null, + currentHash: hook.currentHash, + trustStatus: hook.trustStatus + }) + } + } + return listings +} + +/** + * Runs one short-lived `codex app-server` session over stdio JSON-RPC (JSONL) + * and grants trust for exactly the expected managed entries: + * initialize → initialized → hooks/list → config/batchWrite → hooks/list. + * The child is reaped on every path; the session deadline SIGKILLs it. + */ +export async function runCodexHookTrustGrantSession( + request: CodexHookTrustGrantRequest, + spawnImpl: typeof spawn = spawn +): Promise { + const { invocation } = request + const child = spawnImpl(invocation.command, invocation.args, { + env: { ...process.env, ...invocation.env }, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true + }) as ChildProcessWithoutNullStreams + + let stderrTail = '' + let exited = false + let nextRequestId = 1 + let timedOut = false + const pending = new Map< + number, + { resolve: (r: JsonRpcResponse) => void; reject: (e: Error) => void } + >() + + const exitPromise = new Promise((resolve) => { + child.on('exit', () => { + exited = true + resolve() + }) + }) + // Why: 'error' fires instead of 'exit' when the spawn itself fails + // (ENOENT); surface it to every in-flight request or they wait forever. + let spawnError: Error | null = null + child.on('error', (error) => { + spawnError = error + exited = true + failPending(error) + }) + // Why: 'close' (not 'exit') guarantees the stderr tail is complete, so an + // early death classifies correctly as missing-subcommand vs transient. + child.on('close', () => { + failPending(buildEarlyExitError()) + }) + child.stderr.on('data', (chunk: Buffer) => { + stderrTail = (stderrTail + chunk.toString('utf8')).slice(-STDERR_TAIL_MAX_BYTES) + }) + + let stdoutBuffer = '' + child.stdout.on('data', (chunk: Buffer) => { + stdoutBuffer += chunk.toString('utf8') + let newlineIndex + while ((newlineIndex = stdoutBuffer.indexOf('\n')) !== -1) { + const line = stdoutBuffer.slice(0, newlineIndex).trim() + stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1) + if (!line) { + continue + } + let message: JsonRpcResponse + try { + message = JSON.parse(line) as JsonRpcResponse + } catch { + continue + } + if (typeof message.id === 'number' && pending.has(message.id)) { + const waiter = pending.get(message.id)! + pending.delete(message.id) + waiter.resolve(message) + } + } + }) + + function failPending(error: Error): void { + for (const waiter of pending.values()) { + waiter.reject(error) + } + pending.clear() + } + + const deadline = setTimeout(() => { + timedOut = true + child.kill('SIGKILL') + failPending( + new CodexAppServerTimeoutError( + `codex app-server session exceeded ${invocation.timeoutMs}ms (${invocation.command})` + ) + ) + }, invocation.timeoutMs) + + function sendLine(payload: Record): void { + child.stdin.write(`${JSON.stringify(payload)}\n`) + } + + async function requestRpc(method: string, params?: Record): Promise { + if (spawnError) { + throw spawnError + } + if (timedOut) { + throw new CodexAppServerTimeoutError('codex app-server session already timed out') + } + if (exited) { + throw buildEarlyExitError() + } + const id = nextRequestId++ + const response = await new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }) + const payload: Record = { method, id } + if (params !== undefined) { + payload.params = params + } + try { + sendLine(payload) + } catch (error) { + pending.delete(id) + reject(error instanceof Error ? error : new Error(String(error))) + } + }) + if (response.error) { + if (isMethodNotFoundError(response.error)) { + throw new CodexAppServerUnsupportedError( + `codex app-server does not support ${method}: ${response.error.message ?? 'method not found'}` + ) + } + throw new Error( + `codex app-server ${method} failed: ${response.error.message ?? 'unknown error'}` + ) + } + return response.result + } + + function buildEarlyExitError(): Error { + if (stderrIndicatesMissingAppServer(stderrTail)) { + return new CodexAppServerUnsupportedError( + `codex CLI does not support the app-server subcommand: ${stderrTail.trim().slice(0, 400)}` + ) + } + return new Error( + `codex app-server exited before completing the session${stderrTail ? `: ${stderrTail.trim().slice(0, 400)}` : ''}` + ) + } + + try { + await requestRpc('initialize', { + clientInfo: { name: 'orca_desktop', title: 'Orca', version: '0.0.0' } + }) + sendLine({ method: 'initialized' }) + + const expectedKeys = new Set(request.expectedTrustKeys) + const matchManaged = (listing: CodexHookListing): boolean => + listing.command === request.managedCommand && + expectedKeys.has(normalizeHookTrustKeyForLookup(listing.key)) + + const listResult = await requestRpc('hooks/list', { cwds: [request.hooksListCwd] }) + const managedListings = collectHookListings(listResult).filter(matchManaged) + if (managedListings.length !== expectedKeys.size) { + return { + outcome: 'verify-failed', + reason: `hooks/list reported ${managedListings.length} of ${expectedKeys.size} expected managed entries` + } + } + + const needingTrust = managedListings.filter((listing) => listing.trustStatus !== 'trusted') + if (needingTrust.length > 0) { + // Why: same wire shape as the Codex TUI "Trust all" flow — one upsert + // edit under hooks.state with each key's Codex-computed current hash. + const value: Record = {} + for (const listing of needingTrust) { + value[listing.key] = { trusted_hash: listing.currentHash } + } + await requestRpc('config/batchWrite', { + edits: [{ keyPath: 'hooks.state', value, mergeStrategy: 'upsert' }], + reloadUserConfig: true + }) + } + + const verifyResult = await requestRpc('hooks/list', { cwds: [request.hooksListCwd] }) + const verifiedListings = collectHookListings(verifyResult).filter(matchManaged) + const untrusted = verifiedListings.filter((listing) => listing.trustStatus !== 'trusted') + if (verifiedListings.length !== expectedKeys.size || untrusted.length > 0) { + return { + outcome: 'verify-failed', + reason: + untrusted.length > 0 + ? `post-grant verify left ${untrusted.length} entries ${untrusted[0].trustStatus}` + : `post-grant verify reported ${verifiedListings.length} of ${expectedKeys.size} entries` + } + } + return { + outcome: 'granted', + wroteTrust: needingTrust.length > 0, + entries: verifiedListings.map((listing) => ({ + key: listing.key, + normalizedKey: normalizeHookTrustKeyForLookup(listing.key), + trustedHash: listing.currentHash + })) + } + } catch (error) { + if ( + error instanceof Error && + !(error instanceof CodexAppServerUnsupportedError) && + !(error instanceof CodexAppServerTimeoutError) && + stderrIndicatesMissingAppServer(stderrTail) + ) { + throw new CodexAppServerUnsupportedError( + `codex CLI does not support the app-server subcommand: ${stderrTail.trim().slice(0, 400)}` + ) + } + throw error + } finally { + clearTimeout(deadline) + try { + child.stdin.end() + } catch { + // stdin may already be destroyed after a kill; reaping below still runs. + } + if (!exited) { + // Why: the server exits promptly on stdin EOF; the grace period only + // bounds a wedged child before the guaranteed SIGKILL reap. + const grace = new Promise((resolve) => setTimeout(resolve, 1500)) + await Promise.race([exitPromise, grace]) + if (!exited) { + child.kill('SIGKILL') + await Promise.race([exitPromise, new Promise((resolve) => setTimeout(resolve, 1000))]) + } + } + } +} diff --git a/src/main/codex/codex-app-server-grant-bridge.ts b/src/main/codex/codex-app-server-grant-bridge.ts new file mode 100644 index 00000000000..ef03183b162 --- /dev/null +++ b/src/main/codex/codex-app-server-grant-bridge.ts @@ -0,0 +1,150 @@ +import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { + CodexAppServerTimeoutError, + CodexAppServerUnsupportedError, + isCodexAppServerUnsupportedError, + type CodexHookTrustGrantRequest, + type CodexHookTrustGrantSessionResult +} from './codex-app-server-client' + +// Why: hook install/refresh is synchronous launch prep — a Codex pane must +// not start before its trust is settled — but a stdio JSON-RPC session needs +// a live event loop. This bridge blocks the caller on spawnSync of a bundled +// ELECTRON_RUN_AS_NODE entry (same pattern as the daemon and parcel-watcher +// entries) that runs the session and reports one JSON envelope on stdout. + +export type GrantEntryEnvelope = + | { ok: true; result: CodexHookTrustGrantSessionResult } + | { ok: false; errorName: string; message: string; unsupported?: boolean } + +export function buildGrantEntryEnvelope( + run: Promise +): Promise { + return run.then( + (result) => ({ ok: true as const, result }), + (error: unknown) => ({ + ok: false as const, + errorName: error instanceof Error ? error.name : 'Error', + message: error instanceof Error ? error.message : String(error), + ...(isCodexAppServerUnsupportedError(error) ? { unsupported: true as const } : {}) + }) + ) +} + +const GRANT_ENTRY_FILE_NAME = 'codex-app-server-grant-entry.js' +// Why: spawnSync must outlive the session deadline so the entry's own timeout +// (and its result envelope) win the race; the margin only reaps a hung entry. +const GRANT_ENTRY_TIMEOUT_MARGIN_MS = 5_000 +const GRANT_ENTRY_MAX_BUFFER_BYTES = 16 * 1024 * 1024 + +function loadElectronApp(): { getAppPath(): string; isPackaged: boolean } | null { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return ( + (require('electron') as { app?: { getAppPath(): string; isPackaged: boolean } }).app ?? null + ) + } catch { + return null + } +} + +export function resolveCodexGrantEntryPath( + pathExists: (candidate: string) => boolean = existsSync +): string | null { + const app = loadElectronApp() + let appPath: string | undefined + try { + appPath = app?.getAppPath() + } catch { + appPath = undefined + } + // Why: ELECTRON_RUN_AS_NODE bypasses Electron's asar integration, so the + // packaged entry must run from app.asar.unpacked (out/main/codex/** is in + // the asarUnpack list). + const unpackedAppPath = + app?.isPackaged && appPath ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath + const candidates = [ + // Dev/E2E: electron-vite's appPath is already out/main. + unpackedAppPath ? join(unpackedAppPath, 'codex', GRANT_ENTRY_FILE_NAME) : null, + unpackedAppPath ? join(unpackedAppPath, 'out', 'main', 'codex', GRANT_ENTRY_FILE_NAME) : null, + // Plain-node CLI context (no electron): resolve relative to this chunk. + join(__dirname, 'codex', GRANT_ENTRY_FILE_NAME), + join(__dirname, '..', 'codex', GRANT_ENTRY_FILE_NAME) + ].filter((candidate): candidate is string => candidate !== null) + for (const candidate of candidates) { + if (pathExists(candidate)) { + return candidate + } + } + return null +} + +export type RunGrantSessionSyncOptions = { + entryPath?: string + nodeCommand?: string +} + +/** + * Blocking wrapper for the grant session. Hook install/refresh is synchronous + * launch prep (pane launch must not proceed until trust is settled), and a + * stdio JSON-RPC session needs a live event loop — so the session runs in a + * short-lived ELECTRON_RUN_AS_NODE child (same pattern as the daemon and + * parcel-watcher entries) while the caller blocks on spawnSync. spawnSync + * always reaps the entry; a killed entry closes the codex child's stdin, + * which makes codex app-server exit on EOF. + */ +export function runCodexHookTrustGrantSessionSync( + request: CodexHookTrustGrantRequest, + options: RunGrantSessionSyncOptions = {} +): CodexHookTrustGrantSessionResult { + const entryPath = options.entryPath ?? resolveCodexGrantEntryPath() + if (!entryPath) { + throw new Error('codex trust-grant entry bundle not found') + } + const spawned = spawnSync(options.nodeCommand ?? process.execPath, [entryPath], { + input: JSON.stringify(request), + encoding: 'utf8', + timeout: request.invocation.timeoutMs + 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) { + throw spawned.error + } + if (spawned.signal) { + throw new CodexAppServerTimeoutError( + `codex trust-grant entry killed by ${spawned.signal} after ${request.invocation.timeoutMs}ms deadline` + ) + } + const lines = (spawned.stdout ?? '').split('\n').filter((line) => line.trim().length > 0) + const lastLine = lines.at(-1) + let envelope: GrantEntryEnvelope | null = null + if (lastLine) { + try { + envelope = JSON.parse(lastLine) as GrantEntryEnvelope + } catch { + envelope = null + } + } + if (!envelope) { + throw new Error( + `codex trust-grant entry produced no result (exit ${spawned.status ?? 'unknown'})${ + spawned.stderr ? `: ${spawned.stderr.trim().slice(0, 400)}` : '' + }` + ) + } + if (!envelope.ok) { + if (envelope.unsupported) { + throw new CodexAppServerUnsupportedError(envelope.message) + } + if (envelope.errorName === 'CodexAppServerTimeoutError') { + throw new CodexAppServerTimeoutError(envelope.message) + } + throw new Error(envelope.message) + } + return envelope.result +} diff --git a/src/main/codex/codex-app-server-grant-entry.ts b/src/main/codex/codex-app-server-grant-entry.ts new file mode 100644 index 00000000000..b4613461a78 --- /dev/null +++ b/src/main/codex/codex-app-server-grant-entry.ts @@ -0,0 +1,68 @@ +// Forked (ELECTRON_RUN_AS_NODE) child that runs one codex app-server +// trust-grant session. The parent blocks on spawnSync because hook +// install/refresh must finish before a Codex pane launch proceeds, while the +// JSONL RPC session itself needs a live event loop. Reads the request JSON +// from stdin, writes a single result-envelope JSON line to stdout, and never +// imports electron (see PLAIN_NODE_ENTRY_NAMES in the build guard). +import { buildGrantEntryEnvelope } from './codex-app-server-grant-bridge' +import { + runCodexHookTrustGrantSession, + type CodexHookTrustGrantRequest +} from './codex-app-server-client' + +const HARD_EXIT_MARGIN_MS = 2_000 + +async function readStdin(): Promise { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer) + } + return Buffer.concat(chunks).toString('utf8') +} + +async function main(): Promise { + const raw = await readStdin() + let request: CodexHookTrustGrantRequest + try { + request = JSON.parse(raw) as CodexHookTrustGrantRequest + } catch (error) { + process.stdout.write( + `${JSON.stringify({ + ok: false, + errorName: 'Error', + message: `invalid trust-grant request JSON: ${error instanceof Error ? error.message : String(error)}` + })}\n` + ) + return + } + // Why: backstop for a session whose own deadline failed to fire (clock + // suspend mid-session); exiting closes the codex child's stdio so it + // exits on EOF instead of orphaning. + const hardExit = setTimeout(() => { + process.stdout.write( + `${JSON.stringify({ + ok: false, + errorName: 'CodexAppServerTimeoutError', + message: `trust-grant entry hard deadline (${request.invocation.timeoutMs + HARD_EXIT_MARGIN_MS}ms) elapsed` + })}\n` + ) + process.exit(3) + }, request.invocation.timeoutMs + HARD_EXIT_MARGIN_MS) + const envelope = await buildGrantEntryEnvelope(runCodexHookTrustGrantSession(request)) + clearTimeout(hardExit) + process.stdout.write(`${JSON.stringify(envelope)}\n`) +} + +void main().then( + () => process.exit(0), + (error: unknown) => { + process.stdout.write( + `${JSON.stringify({ + ok: false, + errorName: error instanceof Error ? error.name : 'Error', + message: error instanceof Error ? error.message : String(error) + })}\n` + ) + process.exit(0) + } +) 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-hook-trust-grant.ts b/src/main/codex/codex-hook-trust-grant.ts new file mode 100644 index 00000000000..69cf30db302 --- /dev/null +++ b/src/main/codex/codex-hook-trust-grant.ts @@ -0,0 +1,315 @@ +import { resolveCodexCommand } from '../codex-cli/command' +import { getSpawnArgsForWindows } from '../win32-utils' +import { buildWslCodexAppServerArgs } from '../codex-accounts/wsl-codex-command' +import { + isCodexAppServerUnsupportedError, + type CodexHookTrustGrantRequest, + type CodexHookTrustGrantSessionResult +} from './codex-app-server-client' +import { runCodexHookTrustGrantSessionSync } from './codex-app-server-grant-bridge' +import { + codexAppServerCapabilityCache, + getCodexAppServerHostKey +} from './codex-app-server-capability-cache' +import { + binaryStampsMatch, + buildNativeCodexBinaryStamp, + readCodexTrustGrantLedgerHome, + writeCodexTrustGrantLedgerHome, + type CodexTrustGrantBinaryStamp, + type CodexTrustGrantLedgerEntry +} from './codex-trust-grant-ledger' +import { + computeTrustKey, + normalizeHookTrustKeyForLookup, + readHookTrustEntries, + type CodexTrustEntry +} from './config-toml-trust' +import { getCodexHookTrustSignature } from './codex-hook-identity' + +// 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 + +/** 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 + /** Host-visible config.toml path holding the trust entries. */ + tomlPath: string + /** Exact command string written to the managed hooks.json entries. */ + managedCommand: string + /** Managed trust identities Orca just wrote (no trustedHash). */ + managedEntries: readonly CodexTrustEntry[] + host: CodexTrustGrantHost +} + +export type CodexTrustGrantFallbackReason = + | 'disabled' + | 'no-managed-entries' + | 'unsupported' + | 'unsupported-cached' + | 'verify-failed' + | 'error' + +export type CodexManagedTrustGrantOutcome = + | { lane: 'rpc'; entries: CodexTrustEntry[] } + | { lane: 'fallback'; reason: CodexTrustGrantFallbackReason } + +export type CodexTrustGrantDiagnostics = { + granted: number + ledgerHits: number + fellBack: number + verifyFailed: number + lastFallbackReason: CodexTrustGrantFallbackReason | null +} + +const diagnostics: CodexTrustGrantDiagnostics = { + granted: 0, + ledgerHits: 0, + fellBack: 0, + verifyFailed: 0, + lastFallbackReason: null +} + +export function getCodexTrustGrantDiagnostics(): CodexTrustGrantDiagnostics { + return { ...diagnostics } +} + +type CodexTrustGrantTelemetry = (event: { + outcome: 'granted' | 'fallback' | 'verify_failed' + hostKind: 'native' | 'wsl' + reason?: CodexTrustGrantFallbackReason +}) => void + +// Why: hook-service is bundled into plain-node CLI entries where electron +// (and therefore the telemetry client) cannot load; the Electron main process +// injects the tracker at startup instead of a static import. +let telemetry: CodexTrustGrantTelemetry = () => {} + +export function setCodexTrustGrantTelemetry(tracker: CodexTrustGrantTelemetry): void { + telemetry = tracker +} + +type GrantSessionRunnerSync = ( + request: CodexHookTrustGrantRequest +) => CodexHookTrustGrantSessionResult + +let runSessionSync: GrantSessionRunnerSync = runCodexHookTrustGrantSessionSync + +function fallback( + plan: CodexManagedTrustGrantPlan, + reason: CodexTrustGrantFallbackReason, + detail?: unknown +): CodexManagedTrustGrantOutcome { + diagnostics.fellBack += 1 + diagnostics.lastFallbackReason = reason + if (reason === 'verify-failed') { + diagnostics.verifyFailed += 1 + } + console.warn( + `[codex-trust-grant] falling back to self-computed trust (reason=${reason}, host=${plan.host.kind})`, + detail ?? '' + ) + telemetry({ + outcome: reason === 'verify-failed' ? 'verify_failed' : 'fallback', + hostKind: plan.host.kind, + reason + }) + return { lane: 'fallback', reason } +} + +type ExpectedManagedEntry = { + entry: CodexTrustEntry + normalizedKey: string + signature: string +} + +function buildExpectedEntries(plan: CodexManagedTrustGrantPlan): ExpectedManagedEntry[] { + return plan.managedEntries.map((entry) => ({ + entry, + normalizedKey: normalizeHookTrustKeyForLookup(computeTrustKey(entry)), + signature: getCodexHookTrustSignature(entry) + })) +} + +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)) { + return null + } + let trustStates: ReturnType + try { + trustStates = readHookTrustEntries(plan.tomlPath) + } catch { + return null + } + const entries: CodexTrustEntry[] = [] + for (const { entry, normalizedKey, signature } of expected) { + const recorded = home.entries[normalizedKey] + if (!recorded || recorded.signature !== signature) { + return null + } + if (trustStates.get(normalizedKey)?.trustedHash !== recorded.trustedHash) { + return null + } + entries.push({ ...entry, trustedHash: recorded.trustedHash }) + } + 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 + * verbatim hashes, or a fallback marker — the caller then runs the previous + * computeTrustedHash lane, byte-identical to the pre-RPC behavior. Never + * throws: any unexpected failure is a fallback, because hook install is + * best-effort launch prep. + */ +export function grantManagedCodexHookTrust( + plan: CodexManagedTrustGrantPlan +): CodexManagedTrustGrantOutcome { + try { + if (process.env[DISABLE_ENV_FLAG] === '1') { + return fallback(plan, 'disabled') + } + if (plan.managedEntries.length === 0) { + return fallback(plan, 'no-managed-entries') + } + const expected = buildExpectedEntries(plan) + const currentStamp = resolveCurrentBinaryStamp(plan.host) + const ledgerEntries = findLedgerGrant(plan, expected, currentStamp) + if (ledgerEntries !== null) { + diagnostics.ledgerHits += 1 + return { lane: 'rpc', entries: ledgerEntries } + } + + const hostKey = getCodexAppServerHostKey(plan.host) + if (!codexAppServerCapabilityCache.shouldTry(hostKey)) { + return fallback(plan, 'unsupported-cached') + } + + const startedAtMs = Date.now() + let result: CodexHookTrustGrantSessionResult + try { + result = runSessionSync(buildGrantRequest(plan, expected)) + } catch (error) { + if (isCodexAppServerUnsupportedError(error)) { + codexAppServerCapabilityCache.rememberUnsupported(hostKey) + return fallback(plan, 'unsupported', error) + } + return fallback(plan, 'error', error) + } + // Why: the RPC surface answered, even if our entries were not verifiable — + // remember support so a later drift event retries the preferred lane. + codexAppServerCapabilityCache.rememberSupported(hostKey) + if (result.outcome === 'verify-failed') { + return fallback(plan, 'verify-failed', result.reason) + } + + const byNormalizedKey = new Map(expected.map((item) => [item.normalizedKey, item])) + const grantedEntries: CodexTrustEntry[] = [] + const ledgerRecord: Record = {} + for (const granted of result.entries) { + const match = byNormalizedKey.get(granted.normalizedKey) + if (!match) { + return fallback(plan, 'verify-failed', `unexpected granted key ${granted.key}`) + } + grantedEntries.push({ ...match.entry, trustedHash: granted.trustedHash }) + ledgerRecord[granted.normalizedKey] = { + signature: match.signature, + trustedHash: granted.trustedHash + } + } + if (grantedEntries.length !== expected.length) { + return fallback(plan, 'verify-failed', 'granted entry set did not cover expected entries') + } + try { + writeCodexTrustGrantLedgerHome(plan.runtimeHomePath, { + binary: currentStamp, + entries: ledgerRecord + }) + } catch (error) { + // Why: a ledger write failure only costs an extra session next launch. + console.warn('[codex-trust-grant] failed to persist grant ledger', error) + } + diagnostics.granted += 1 + console.log( + `[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 }) + return { lane: 'rpc', entries: grantedEntries } + } catch (error) { + return fallback(plan, 'error', error) + } +} + +export const _internals = { + setGrantSessionRunnerSync(runner: GrantSessionRunnerSync | null): void { + runSessionSync = runner ?? runCodexHookTrustGrantSessionSync + }, + resetDiagnostics(): void { + diagnostics.granted = 0 + diagnostics.ledgerHits = 0 + diagnostics.fellBack = 0 + diagnostics.verifyFailed = 0 + diagnostics.lastFallbackReason = null + } +} 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/codex-trust-grant-ledger.ts b/src/main/codex/codex-trust-grant-ledger.ts new file mode 100644 index 00000000000..5b7786ffb50 --- /dev/null +++ b/src/main/codex/codex-trust-grant-ledger.ts @@ -0,0 +1,143 @@ +import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { getOrcaManagedCodexHomePath } from './codex-home-paths' +import { normalizeCodexProjectPathForLookup } from './config-toml-trust' + +// Why: a grant session blocks launch prep, so it must not run on every pane +// launch. This ledger records what a *verified* codex-side grant left behind +// (per runtime home): the hook identity that was granted, the Codex-computed +// hash, and the codex binary that computed it. Install skips the RPC while +// all three still hold; any drift (hook edit, config wipe, codex upgrade) +// re-triggers a grant before the pane launches. + +export type CodexTrustGrantBinaryStamp = + | { kind: 'native'; path: string; size: number; mtimeMs: number } + // Why: there is no cheap way to stat the codex binary inside a WSL distro + // from the host, so WSL grants revalidate only on hook/config drift. A WSL + // codex upgrade that changes the hash algorithm re-grants on verify-fail of + // the next launch's status rather than pre-emptively. + | { kind: 'wsl'; distro: string } + +export type CodexTrustGrantLedgerEntry = { + /** getCodexHookTrustSignature() of the granted hook identity. */ + signature: string + /** Codex-computed hash verified as trusted via hooks/list. */ + trustedHash: string +} + +export type CodexTrustGrantLedgerHome = { + binary: CodexTrustGrantBinaryStamp | null + /** Keyed by normalizeHookTrustKeyForLookup(trust key). */ + entries: Record +} + +type CodexTrustGrantLedgerFile = { + version: 1 + homes: Record +} + +export function getCodexTrustGrantLedgerPath(): string { + return join(dirname(getOrcaManagedCodexHomePath()), 'trust-grant-ledger.json') +} + +export function getCodexTrustGrantHomeKey(runtimeHomePath: string): string { + return normalizeCodexProjectPathForLookup(runtimeHomePath) +} + +function readLedgerFile(ledgerPath: string): CodexTrustGrantLedgerFile { + const empty: CodexTrustGrantLedgerFile = { version: 1, homes: {} } + if (!existsSync(ledgerPath)) { + return empty + } + try { + const parsed: unknown = JSON.parse(readFileSync(ledgerPath, 'utf-8')) + if ( + !parsed || + typeof parsed !== 'object' || + Array.isArray(parsed) || + (parsed as CodexTrustGrantLedgerFile).version !== 1 + ) { + return empty + } + const homes = (parsed as CodexTrustGrantLedgerFile).homes + if (!homes || typeof homes !== 'object' || Array.isArray(homes)) { + return empty + } + return { version: 1, homes } + } catch { + // Why: a corrupt ledger only costs one extra grant session; never let it + // block hook install. + return empty + } +} + +export function readCodexTrustGrantLedgerHome( + runtimeHomePath: string, + ledgerPath = getCodexTrustGrantLedgerPath() +): CodexTrustGrantLedgerHome | null { + const home = readLedgerFile(ledgerPath).homes[getCodexTrustGrantHomeKey(runtimeHomePath)] + if (!home || typeof home !== 'object' || Array.isArray(home)) { + return null + } + if (!home.entries || typeof home.entries !== 'object' || Array.isArray(home.entries)) { + return null + } + return home +} + +export function writeCodexTrustGrantLedgerHome( + runtimeHomePath: string, + home: CodexTrustGrantLedgerHome, + ledgerPath = getCodexTrustGrantLedgerPath() +): void { + const file = readLedgerFile(ledgerPath) + file.homes[getCodexTrustGrantHomeKey(runtimeHomePath)] = home + writeFileSync(ledgerPath, `${JSON.stringify(file, null, 2)}\n`, { + encoding: 'utf-8', + mode: 0o600 + }) +} + +export function removeCodexTrustGrantLedgerHome( + runtimeHomePath: string, + ledgerPath = getCodexTrustGrantLedgerPath() +): void { + const file = readLedgerFile(ledgerPath) + const homeKey = getCodexTrustGrantHomeKey(runtimeHomePath) + if (!(homeKey in file.homes)) { + return + } + delete file.homes[homeKey] + writeFileSync(ledgerPath, `${JSON.stringify(file, null, 2)}\n`, { + encoding: 'utf-8', + mode: 0o600 + }) +} + +export function buildNativeCodexBinaryStamp(binaryPath: string): CodexTrustGrantBinaryStamp | null { + try { + const stat = statSync(binaryPath) + return { kind: 'native', path: binaryPath, size: stat.size, mtimeMs: stat.mtimeMs } + } catch { + return null + } +} + +export function binaryStampsMatch( + recorded: CodexTrustGrantBinaryStamp | null, + current: CodexTrustGrantBinaryStamp | null +): boolean { + if (recorded === null || current === null) { + // Why: an unresolvable binary stamp must not wedge installs into + // re-granting forever; the config/signature checks still gate the skip. + return recorded === null && current === null + } + if (recorded.kind === 'wsl' || current.kind === 'wsl') { + return recorded.kind === 'wsl' && current.kind === 'wsl' && recorded.distro === current.distro + } + return ( + recorded.path === current.path && + recorded.size === current.size && + recorded.mtimeMs === current.mtimeMs + ) +} diff --git a/src/main/codex/codex-wsl-hook-install-plan.ts b/src/main/codex/codex-wsl-hook-install-plan.ts index 6edc4aed660..0f63d036443 100644 --- a/src/main/codex/codex-wsl-hook-install-plan.ts +++ b/src/main/codex/codex-wsl-hook-install-plan.ts @@ -13,6 +13,11 @@ export type CodexWslRuntimeHookInstallPlan = { scriptPath: string commandScriptPath: string trustConfigPath: string + /** Distro that executes Codex for this runtime home (RPC trust grants run + * codex inside it). */ + wslDistro: string + /** Canonical Linux-side runtime home — CODEX_HOME for in-distro codex runs. */ + linuxRuntimeHome: string } export type WslCanonicalPathSettlement = @@ -184,7 +189,9 @@ export function createCodexWslRuntimeHookInstallPlan( 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` + trustConfigPath: `${linuxRuntimeHome}/hooks.json`, + wslDistro: distro, + linuxRuntimeHome } } 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 + }) + ) + }) +}) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index b09a94f5ede..1011d595ac3 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -66,6 +66,12 @@ import { promoteCodexRuntimeHookApprovalsToSystem, snapshotCodexRuntimeHookTrustProvenance } from './hook-trust-promotion' +import { grantManagedCodexHookTrust } from './codex-hook-trust-grant' +import { + readCodexTrustGrantLedgerHome, + removeCodexTrustGrantLedgerHome, + 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. @@ -630,12 +636,34 @@ 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 +): void { + const granted = ledgerHome?.entries[normalizeHookTrustKeyForLookup(key)] + if (granted?.trustedHash) { + 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]) ) @@ -670,6 +698,7 @@ function removeRuntimeManagedHookTrustEntries(configPath: string): void { computeTrustedHash(expectedEntry), computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) ]) + addLedgerRecognizedHash(recognizedHashes, ledgerHome, key) if (!state.trustedHash || !recognizedHashes.has(state.trustedHash)) { continue } @@ -678,6 +707,7 @@ function removeRuntimeManagedHookTrustEntries(configPath: string): void { if (ourKeys.length > 0) { removeHookTrustEntries(tomlPath, ourKeys) } + removeCodexTrustGrantLedgerHome(getOrcaManagedCodexHomePath()) } 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. @@ -689,6 +719,7 @@ function removeWslRuntimeManagedHookTrustEntries(plan: CodexWslRuntimeHookInstal 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]) ) @@ -717,6 +748,7 @@ function removeWslRuntimeManagedHookTrustEntries(plan: CodexWslRuntimeHookInstal computeTrustedHash(expectedEntry), computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) ]) + addLedgerRecognizedHash(recognizedHashes, ledgerHome, key) if (state.trustedHash && recognizedHashes.has(state.trustedHash)) { ourKeys.push(key) } @@ -724,6 +756,7 @@ function removeWslRuntimeManagedHookTrustEntries(plan: CodexWslRuntimeHookInstal if (ourKeys.length > 0) { removeHookTrustEntries(plan.tomlPath, ourKeys) } + removeCodexTrustGrantLedgerHome(pathWin32.dirname(plan.tomlPath)) } 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. @@ -739,6 +772,7 @@ function removeStaleWslRuntimeManagedHookTrustEntries( 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))) { @@ -768,6 +802,7 @@ function removeStaleWslRuntimeManagedHookTrustEntries( computeTrustedHash(expectedEntry), computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) ]) + addLedgerRecognizedHash(recognizedHashes, ledgerHome, key) if (state.trustedHash && recognizedHashes.has(state.trustedHash)) { ourKeys.push(key) } @@ -923,10 +958,23 @@ function installManagedHooksIntoWslRuntime( writeManagedScript(plan.scriptPath, getManagedScript('posix')) writeCodexHooksJson(plan.configPath, nextHooks) try { - // 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. - upsertHookTrustEntries(plan.tomlPath, trustEntries) - removeStaleWslRuntimeManagedHookTrustEntries(plan.tomlPath, trustEntries) + // 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 grant = grantManagedCodexHookTrust({ + runtimeHomePath: pathWin32.dirname(plan.tomlPath), + 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) + } 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. + upsertHookTrustEntries(plan.tomlPath, trustEntries) + removeStaleWslRuntimeManagedHookTrustEntries(plan.tomlPath, trustEntries) + } } catch (error) { return { agent: 'codex', @@ -1131,6 +1179,10 @@ export class CodexHookService { trustEntries = new Map() trustReadError = error instanceof Error ? error.message : String(error) } + // 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 missing: string[] = [] const trustMissing: string[] = [] @@ -1175,9 +1227,14 @@ export class CodexHookService { command, timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS } - const expectedHash = computeTrustedHash(trustInput) - const actualState = trustEntries.get(computeTrustKey(trustInput)) - if (actualState?.trustedHash !== expectedHash) { + 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 actualState = trustEntries.get(trustKey) + if (!actualState?.trustedHash || !validHashes.has(actualState.trustedHash)) { trustMissing.push(eventName) } else if (actualState?.enabled === false) { disabled.push(eventName) @@ -1277,7 +1334,10 @@ export class CodexHookService { const mirroredUserTrustEntries = moveMirroredRuntimeUserTrustAfterManagedStatusHook( hookPlan.trustEntries ) - const trustEntries: CodexTrustEntry[] = mirroredUserTrustEntries.map(({ entry }) => entry) + const mirroredTrustEntries: CodexTrustEntry[] = mirroredUserTrustEntries.map( + ({ entry }) => entry + ) + const managedTrustEntries: CodexTrustEntry[] = [] for (const eventName of CODEX_EVENTS) { const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] const cleaned = removeManagedCommands(current, isManagedCommand) @@ -1290,7 +1350,7 @@ export class CodexHookService { // state while Codex visibly reports that hooks are still running. // timeoutSec mirrors the hook's `timeout` so the trust hash matches the // entry actually written to hooks.json. - trustEntries.push({ + managedTrustEntries.push({ sourcePath: configPath, eventLabel: CODEX_EVENT_LABEL[eventName], groupIndex: 0, @@ -1299,6 +1359,7 @@ export class CodexHookService { timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS }) } + const trustEntries: CodexTrustEntry[] = [...mirroredTrustEntries, ...managedTrustEntries] config.hooks = nextHooks writeManagedScript(scriptPath, getManagedScript()) @@ -1309,13 +1370,34 @@ export class CodexHookService { try { const tomlPath = getCodexConfigTomlPath() syncSystemConfigIntoManagedCodexHome() - // Why: system user hook approvals are mirrored into runtime CODEX_HOME. - // If the user later revokes approval in ~/.codex/config.toml, preserving - // all old runtime [hooks.state.*] blocks would keep Orca Codex trusted. - // Upsert first so duplicate repair can preserve a disabled managed copy - // before stale cleanup removes old managed hook keys. - upsertHookTrustEntries(tomlPath, trustEntries) - removeStaleRuntimeHookTrustEntries(tomlPath, configPath, trustEntries) + // Why: Codex is the only authority on its trust-hash algorithm, so the + // managed entries are granted through codex app-server RPCs (verified by + // re-list) whenever the installed CLI supports them; the granted entries + // then carry Codex's verbatim hashes into stale cleanup so it cannot + // delete what Codex just wrote. Mirrored user trust keeps its existing + // verbatim-carry lane either way. + const grant = grantManagedCodexHookTrust({ + runtimeHomePath: getOrcaManagedCodexHomePath(), + tomlPath, + managedCommand: command, + managedEntries: managedTrustEntries, + host: { kind: 'native' } + }) + if (grant.lane === 'rpc') { + upsertHookTrustEntries(tomlPath, mirroredTrustEntries) + removeStaleRuntimeHookTrustEntries(tomlPath, configPath, [ + ...mirroredTrustEntries, + ...grant.entries + ]) + } else { + // Why: system user hook approvals are mirrored into runtime CODEX_HOME. + // If the user later revokes approval in ~/.codex/config.toml, preserving + // all old runtime [hooks.state.*] blocks would keep Orca Codex trusted. + // Upsert first so duplicate repair can preserve a disabled managed copy + // before stale cleanup removes old managed hook keys. + upsertHookTrustEntries(tomlPath, trustEntries) + removeStaleRuntimeHookTrustEntries(tomlPath, configPath, trustEntries) + } applyMirroredRuntimeUserHookTrustStates(tomlPath, mirroredUserTrustEntries) } catch (error) { return { diff --git a/src/main/index.ts b/src/main/index.ts index 525abb8c247..50a6b48aed8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -129,6 +129,7 @@ import { } from './codex-accounts/runtime-selection' import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection' import { codexHookService } from './codex/hook-service' +import { setCodexTrustGrantTelemetry } from './codex/codex-hook-trust-grant' import { getDefaultWslDistro } from './wsl' import { ClaudeAccountService } from './claude-accounts/service' import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' @@ -1734,6 +1735,16 @@ app.whenReady().then(async () => { // the Store reference, seeds common props, and resets per-session burst // caps. Actual transport initialization is still gated by both flags. initTelemetry(store) + // Why: the trust-grant module is bundled into plain-node CLI entries where + // the telemetry client cannot load, so the tracker is injected here instead + // of imported there. + setCodexTrustGrantTelemetry(({ outcome, hostKind, reason }) => { + track('codex_trust_grant', { + outcome, + host_kind: hostKind, + ...(reason !== undefined ? { fallback_reason: reason } : {}) + }) + }) // Why: the error-tracking lane (telemetry-error-tracking.md) is its own // composition root — independent of product telemetry — and must // initialize before any IPC handler / runtime span is created so the diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index ee34b53ec3c..28b754102a2 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -442,6 +442,27 @@ const agentErrorSchema = z // v1.4.129-rc.1, which was otherwise invisible until users filed bug reports. const daemonStartFailedSchema = z.object({ error_class: errorClassSchema }).strict() +// Rollout signal for granting Codex hook trust via codex app-server RPCs +// instead of Orca's self-computed trusted_hash. `fallback`/`verify_failed` +// spikes mean the RPC lane is not taking; steady-state ledger skips are not +// reported (they would only measure launch volume). +const codexTrustGrantSchema = z + .object({ + outcome: z.enum(['granted', 'fallback', 'verify_failed']), + host_kind: z.enum(['native', 'wsl']), + fallback_reason: z + .enum([ + 'disabled', + 'no-managed-entries', + 'unsupported', + 'unsupported-cached', + 'verify-failed', + 'error' + ]) + .optional() + }) + .strict() + const settingsChangedSchema = z .object({ setting_key: settingsChangedKeySchema, @@ -1428,6 +1449,8 @@ export const eventSchemas = { daemon_start_failed: daemonStartFailedSchema, + codex_trust_grant: codexTrustGrantSchema, + settings_changed: settingsChangedSchema, native_chat_toggled: nativeChatToggledSchema,