mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
test(codex): cover app-server trust grant client, cache, ledger, and lanes
This commit is contained in:
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user