test(codex): cover app-server trust grant client, cache, ledger, and lanes

This commit is contained in:
Brennan Benson
2026-07-14 22:41:23 -07:00
parent eb5a76c31a
commit be366ab828
6 changed files with 980 additions and 4 deletions
@@ -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,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\)/
)
})
})
@@ -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')
})
})
@@ -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)
})
})
@@ -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
})
)
})
})