mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
Merge trust-plumbing branch (codex app-server trust-grant client) into real-home phase1
This commit is contained in:
@@ -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*\)/
|
||||
|
||||
@@ -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\)/
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user