Merge trust-plumbing branch (codex app-server trust-grant client) into real-home phase1

This commit is contained in:
Brennan Benson
2026-07-14 22:42:55 -07:00
19 changed files with 2263 additions and 22 deletions
+2 -1
View File
@@ -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*\)/
+6
View File
@@ -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(
@@ -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(),
@@ -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')
})
})
@@ -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<CodexAppServerHostKey, number>()
private readonly supportedHosts = new Set<CodexAppServerHostKey>()
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<T>(
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()
@@ -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<string, { trusted_hash: string }>
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\)/
)
})
})
+362
View File
@@ -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<string, string>
/** 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<string>()
for (const entry of data) {
const hooks = Array.isArray(entry?.hooks) ? entry.hooks : []
for (const hook of hooks as Record<string, unknown>[]) {
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<CodexHookTrustGrantSessionResult> {
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<void>((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<string, unknown>): void {
child.stdin.write(`${JSON.stringify(payload)}\n`)
}
async function requestRpc(method: string, params?: Record<string, unknown>): Promise<unknown> {
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<JsonRpcResponse>((resolve, reject) => {
pending.set(id, { resolve, reject })
const payload: Record<string, unknown> = { 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<string, { trusted_hash: string }> = {}
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<void>((resolve) => setTimeout(resolve, 1500))
await Promise.race([exitPromise, grace])
if (!exited) {
child.kill('SIGKILL')
await Promise.race([exitPromise, new Promise<void>((resolve) => setTimeout(resolve, 1000))])
}
}
}
}
@@ -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<CodexHookTrustGrantSessionResult>
): Promise<GrantEntryEnvelope> {
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
}
@@ -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<string> {
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<void> {
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)
}
)
@@ -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')
})
})
+315
View File
@@ -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<typeof readHookTrustEntries>
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<string, CodexTrustGrantLedgerEntry> = {}
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
}
}
@@ -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)
})
})
+143
View File
@@ -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<string, CodexTrustGrantLedgerEntry>
}
type CodexTrustGrantLedgerFile = {
version: 1
homes: Record<string, CodexTrustGrantLedgerHome>
}
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
)
}
@@ -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
}
}
@@ -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'
})
})
+197 -1
View File
@@ -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<typeof vi.fn>
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
})
)
})
})
+98 -16
View File
@@ -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<string>,
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<CodexEventLabel>(
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<CodexEventLabel>(
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 {
+11
View File
@@ -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
+23
View File
@@ -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,