fix(codex): harden trust grant reconciliation

This commit is contained in:
Brennan Benson
2026-07-15 13:34:20 -07:00
parent 442cde8d99
commit d4f549364b
16 changed files with 891 additions and 482 deletions
+2
View File
@@ -20,7 +20,9 @@
"../src/main/codex/codex-home-paths.ts",
"../src/main/codex/codex-hook-identity.ts",
"../src/main/codex/codex-hook-trust-grant.ts",
"../src/main/codex/codex-managed-trust-reconciliation.ts",
"../src/main/codex/codex-trust-config-rollback.ts",
"../src/main/codex/codex-trust-grant-host.ts",
"../src/main/codex/codex-trust-grant-ledger.ts",
"../src/main/codex/codex-wsl-hook-install-plan.ts",
"../src/main/codex/config-settings-promotion.ts",
+48 -1
View File
@@ -9,7 +9,10 @@ import {
runCodexHookTrustGrantSession,
type CodexHookTrustGrantRequest
} from './codex-app-server-client'
import { runCodexHookTrustGrantSessionSync } from './codex-app-server-grant-bridge'
import {
resolveCodexGrantEntryPath,
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
@@ -331,4 +334,48 @@ describe('runCodexHookTrustGrantSessionSync', () => {
/produced no result \(exit 7\)/
)
})
it('classifies the spawnSync deadline as a typed timeout', () => {
const entryPath = writeEntryFixture(`setInterval(() => {}, 1000)`)
const request = {
...baseRequest,
invocation: { ...baseRequest.invocation, timeoutMs: 20 }
}
expect(() =>
runCodexHookTrustGrantSessionSync(request, { entryPath, timeoutMarginMs: 20 })
).toThrow(CodexAppServerTimeoutError)
})
})
describe('resolveCodexGrantEntryPath', () => {
const entryName = 'codex-app-server-grant-entry.js'
it('finds the sibling entry from emitted main and chunk directories', () => {
const mainDir = join('/opt', 'orca', 'out', 'main')
expect(
resolveCodexGrantEntryPath(
(candidate) => candidate === join(mainDir, 'codex', entryName),
mainDir
)
).toBe(join(mainDir, 'codex', entryName))
const chunkDir = join(mainDir, 'chunks')
expect(
resolveCodexGrantEntryPath(
(candidate) => candidate === join(mainDir, 'codex', entryName),
chunkDir
)
).toBe(join(mainDir, 'codex', entryName))
})
it('redirects app.asar to unpacked without double-unpacking an existing path', () => {
const resourcesDir = join('/Applications', 'Orca.app', 'Contents', 'Resources')
const expected = join(resourcesDir, 'app.asar.unpacked', 'out', 'main', 'codex', entryName)
for (const archiveDir of ['app.asar', 'app.asar.unpacked']) {
const moduleDir = join(resourcesDir, archiveDir, 'out', 'main', 'chunks')
expect(resolveCodexGrantEntryPath((candidate) => candidate === expected, moduleDir)).toBe(
expected
)
}
})
})
@@ -22,7 +22,8 @@ const GRANT_ENTRY_TIMEOUT_MARGIN_MS = 5_000
const GRANT_ENTRY_MAX_BUFFER_BYTES = 16 * 1024 * 1024
export function resolveCodexGrantEntryPath(
pathExists: (candidate: string) => boolean = existsSync
pathExists: (candidate: string) => boolean = existsSync,
moduleDir = __dirname
): string | null {
// Why: resolved from __dirname (not electron's app paths) so this module
// stays loadable in plain-node CLI entries — the build guard rejects any
@@ -30,9 +31,9 @@ export function resolveCodexGrantEntryPath(
// out/main or out/main/chunks, so the entry is one or two levels up.
// ELECTRON_RUN_AS_NODE bypasses asar integration, so packaged builds must
// run the copy under app.asar.unpacked (out/main/codex/** is asarUnpacked).
const baseDirs = [__dirname, join(__dirname, '..')].map((dir) =>
dir.includes('app.asar') ? dir.replace('app.asar', 'app.asar.unpacked') : dir
)
const toUnpackedDir = (dir: string): string =>
dir.replace(/([\\/])app\.asar(?=([\\/]|$))/, '$1app.asar.unpacked')
const baseDirs = [moduleDir, join(moduleDir, '..')].map(toUnpackedDir)
for (const baseDir of baseDirs) {
const candidate = join(baseDir, 'codex', GRANT_ENTRY_FILE_NAME)
if (pathExists(candidate)) {
@@ -45,6 +46,8 @@ export function resolveCodexGrantEntryPath(
export type RunGrantSessionSyncOptions = {
entryPath?: string
nodeCommand?: string
/** Test-only override; production keeps enough margin for child cleanup. */
timeoutMarginMs?: number
}
/**
@@ -67,12 +70,20 @@ export function runCodexHookTrustGrantSessionSync(
const spawned = spawnSync(options.nodeCommand ?? process.execPath, [entryPath], {
input: JSON.stringify(request),
encoding: 'utf8',
timeout: request.invocation.timeoutMs + GRANT_ENTRY_TIMEOUT_MARGIN_MS,
timeout:
request.invocation.timeoutMs + (options.timeoutMarginMs ?? 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 as NodeJS.ErrnoException | undefined)?.code === 'ETIMEDOUT') {
// Why: spawnSync reports its own deadline through error.code before the
// signal field; preserve the typed timeout so cooldown diagnostics work.
throw new CodexAppServerTimeoutError(
`codex trust-grant entry exceeded ${request.invocation.timeoutMs}ms session deadline`
)
}
if (spawned.error) {
throw spawned.error
}
@@ -13,6 +13,7 @@ import {
CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS,
getCodexTrustGrantDiagnostics,
grantManagedCodexHookTrust,
setCodexTrustGrantTelemetry,
type CodexManagedTrustGrantPlan
} from './codex-hook-trust-grant'
import { readCodexTrustGrantLedgerHome } from './codex-trust-grant-ledger'
@@ -39,6 +40,7 @@ beforeEach(() => {
afterEach(() => {
vi.useRealTimers()
_internals.setGrantSessionRunnerSync(null)
setCodexTrustGrantTelemetry(() => {})
codexAppServerCapabilityCache.clear()
if (previousUserDataPath === undefined) {
delete process.env.ORCA_USER_DATA_PATH
@@ -218,6 +220,33 @@ describe('grantManagedCodexHookTrust', () => {
expect(getCodexTrustGrantDiagnostics()).toMatchObject({ verifyFailed: 1 })
})
it('rejects duplicate granted keys instead of treating another key as covered', () => {
const entries = [managedEntry('session_start'), managedEntry('stop')]
const duplicated = grantedSessionResult([entries[0]!, entries[0]!])
_internals.setGrantSessionRunnerSync(() => duplicated)
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({
lane: 'fallback',
reason: 'verify-failed'
})
expect(readCodexTrustGrantLedgerHome(runtimeHomeDir)).toBeNull()
})
it('keeps grant and fallback outcomes stable when telemetry throws', () => {
const entries = [managedEntry('session_start')]
setCodexTrustGrantTelemetry(() => {
throw new Error('telemetry unavailable')
})
_internals.setGrantSessionRunnerSync(() => grantedSessionResult(entries))
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ lane: 'rpc' })
process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1'
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({
lane: 'fallback',
reason: 'disabled'
})
})
it('restores exact config bytes before fallback after a mutating RPC error', () => {
const entries = [managedEntry('session_start')]
const plan = buildPlan(entries)
+39 -66
View File
@@ -1,6 +1,3 @@
import { resolveCodexCommand } from '../codex-cli/command'
import { getSpawnArgsForWindows } from '../win32-utils'
import { buildWslCodexAppServerArgs } from '../codex-accounts/wsl-codex-command'
import {
isCodexAppServerUnsupportedError,
type CodexHookTrustGrantRequest,
@@ -12,9 +9,6 @@ import {
getCodexAppServerHostKey
} from './codex-app-server-capability-cache'
import {
binaryStampsMatch,
buildNativeCodexBinaryStamp,
readCodexTrustGrantLedgerHome,
writeCodexTrustGrantLedgerHome,
type CodexTrustGrantBinaryStamp,
type CodexTrustGrantLedgerEntry
@@ -27,12 +21,12 @@ import {
} from './config-toml-trust'
import { getCodexHookTrustSignature } from './codex-hook-identity'
import { captureCodexTrustConfig, restoreCodexTrustConfig } from './codex-trust-config-rollback'
import {
readCodexTrustGrantLedgerHomeMatchingStamp,
resolveCodexTrustGrantHost,
type CodexTrustGrantHost
} from './codex-trust-grant-host'
// 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
// Why: a transiently hung app-server must not block launch prep on every pane.
// The legacy lane remains available while a short, host-scoped cooldown runs.
export const CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS = 5 * 60_000
@@ -40,10 +34,6 @@ export const CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS = 5 * 60_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
@@ -105,6 +95,16 @@ export function setCodexTrustGrantTelemetry(tracker: CodexTrustGrantTelemetry):
telemetry = tracker
}
function emitTelemetry(event: Parameters<CodexTrustGrantTelemetry>[0]): void {
try {
telemetry(event)
} catch (error) {
// Why: observability must never turn a verified grant into fallback or
// violate this launch-prep API's no-throw contract.
console.warn('[codex-trust-grant] failed to emit telemetry', error)
}
}
type GrantSessionRunnerSync = (
request: CodexHookTrustGrantRequest
) => CodexHookTrustGrantSessionResult
@@ -125,7 +125,7 @@ function fallback(
`[codex-trust-grant] falling back to self-computed trust (reason=${reason}, host=${plan.host.kind})`,
detail ?? ''
)
telemetry({
emitTelemetry({
outcome: reason === 'verify-failed' ? 'verify_failed' : 'fallback',
hostKind: plan.host.kind,
reason
@@ -147,24 +147,13 @@ function buildExpectedEntries(plan: CodexManagedTrustGrantPlan): ExpectedManaged
}))
}
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)) {
const home = readCodexTrustGrantLedgerHomeMatchingStamp(plan.runtimeHomePath, currentStamp)
if (!home) {
return null
}
let trustStates: ReturnType<typeof readHookTrustEntries>
@@ -187,39 +176,6 @@ function findLedgerGrant(
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
@@ -239,7 +195,8 @@ export function grantManagedCodexHookTrust(
return fallback(plan, 'no-managed-entries')
}
const expected = buildExpectedEntries(plan)
const currentStamp = resolveCurrentBinaryStamp(plan.host)
const resolvedHost = resolveCodexTrustGrantHost(plan.host)
const currentStamp = resolvedHost.binaryStamp
const ledgerEntries = findLedgerGrant(plan, expected, currentStamp)
if (ledgerEntries !== null) {
diagnostics.ledgerHits += 1
@@ -265,7 +222,13 @@ export function grantManagedCodexHookTrust(
const configSnapshot = captureCodexTrustConfig(plan.tomlPath)
let result: CodexHookTrustGrantSessionResult
try {
result = runSessionSync(buildGrantRequest(plan, expected))
result = runSessionSync(
resolvedHost.buildRequest({
runtimeHomePath: plan.runtimeHomePath,
managedCommand: plan.managedCommand,
expectedTrustKeys: expected.map(({ normalizedKey }) => normalizedKey)
})
)
} catch (error) {
restoreCodexTrustConfig(plan.tomlPath, configSnapshot)
if (isCodexAppServerUnsupportedError(error)) {
@@ -292,6 +255,7 @@ export function grantManagedCodexHookTrust(
}
const byNormalizedKey = new Map(expected.map((item) => [item.normalizedKey, item]))
const seenNormalizedKeys = new Set<string>()
const grantedEntries: CodexTrustEntry[] = []
const ledgerRecord: Record<string, CodexTrustGrantLedgerEntry> = {}
for (const granted of result.entries) {
@@ -304,13 +268,22 @@ export function grantManagedCodexHookTrust(
)
return fallback(plan, 'verify-failed', `unexpected granted key ${granted.key}`)
}
if (seenNormalizedKeys.has(granted.normalizedKey)) {
restoreCodexTrustConfig(plan.tomlPath, configSnapshot)
transientRetryAfterByHost.set(
hostKey,
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
)
return fallback(plan, 'verify-failed', `duplicate granted key ${granted.key}`)
}
seenNormalizedKeys.add(granted.normalizedKey)
grantedEntries.push({ ...match.entry, trustedHash: granted.trustedHash })
ledgerRecord[granted.normalizedKey] = {
signature: match.signature,
trustedHash: granted.trustedHash
}
}
if (grantedEntries.length !== expected.length) {
if (seenNormalizedKeys.size !== expected.length) {
restoreCodexTrustConfig(plan.tomlPath, configSnapshot)
transientRetryAfterByHost.set(
hostKey,
@@ -333,7 +306,7 @@ export function grantManagedCodexHookTrust(
`[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 })
emitTelemetry({ outcome: 'granted', hostKind: plan.host.kind })
return { lane: 'rpc', entries: grantedEntries }
} catch (error) {
return fallback(plan, 'error', error)
@@ -0,0 +1,151 @@
import {
computeTrustKey,
computeTrustedHash,
getCodexCanonicalTrustPath,
normalizeHookTrustKeyForLookup,
parseTrustKey,
readHookTrustEntries,
removeHookTrustEntries,
type CodexEventLabel,
type CodexTrustEntry
} from './config-toml-trust'
import { getCodexHookTrustSignature } from './codex-hook-identity'
import {
readCodexTrustGrantLedgerHome,
removeCodexTrustGrantLedgerHome,
type CodexTrustGrantLedgerHome
} from './codex-trust-grant-ledger'
export function readCodexTrustGrantLedgerHomeForReconciliation(
runtimeHomePath: string
): CodexTrustGrantLedgerHome | null {
try {
return readCodexTrustGrantLedgerHome(runtimeHomePath)
} catch {
return null
}
}
export function getCodexLedgerTrustedHash(
ledgerHome: CodexTrustGrantLedgerHome | null,
key: string,
expectedEntry: CodexTrustEntry
): string | null {
const granted = ledgerHome?.entries[normalizeHookTrustKeyForLookup(key)]
return granted?.trustedHash && granted.signature === getCodexHookTrustSignature(expectedEntry)
? granted.trustedHash
: null
}
function addLedgerRecognizedHashes(
hashes: Set<string>,
ledgerHomes: readonly (CodexTrustGrantLedgerHome | null)[],
key: string,
expectedEntry: CodexTrustEntry
): void {
for (const ledgerHome of ledgerHomes) {
const hash = getCodexLedgerTrustedHash(ledgerHome, key, expectedEntry)
if (hash) {
hashes.add(hash)
}
}
}
export function removeCodexManagedHookTrustEntries(options: {
tomlPath: string
runtimeHomePath: string
sourcePath: string
command: string
managedEventLabels: ReadonlySet<CodexEventLabel>
timeoutSec: number
}): void {
const existingEntries = readHookTrustEntries(options.tomlPath)
const ledgerHome = readCodexTrustGrantLedgerHomeForReconciliation(options.runtimeHomePath)
const canonicalSourcePath = getCodexCanonicalTrustPath(options.sourcePath)
const ownedKeys: string[] = []
for (const [key, state] of existingEntries) {
const parts = parseTrustKey(key)
if (
!parts ||
getCodexCanonicalTrustPath(parts.sourcePath) !== canonicalSourcePath ||
!options.managedEventLabels.has(parts.eventLabel)
) {
continue
}
const expectedEntry: CodexTrustEntry = {
sourcePath: options.sourcePath,
eventLabel: parts.eventLabel,
groupIndex: parts.groupIndex,
handlerIndex: parts.handlerIndex,
command: options.command,
timeoutSec: options.timeoutSec
}
const recognizedHashes = new Set([
computeTrustedHash(expectedEntry),
computeTrustedHash({ ...expectedEntry, timeoutSec: undefined })
])
addLedgerRecognizedHashes(recognizedHashes, [ledgerHome], key, expectedEntry)
if (state.trustedHash && recognizedHashes.has(state.trustedHash)) {
ownedKeys.push(key)
}
}
if (ownedKeys.length > 0) {
removeHookTrustEntries(options.tomlPath, ownedKeys)
}
// Why: retain the ledger until trust removal succeeds so a later retry can
// still prove ownership of Codex-computed hashes.
removeCodexTrustGrantLedgerHome(options.runtimeHomePath)
}
export function removeStaleWslCodexManagedHookTrustEntries(options: {
tomlPath: string
runtimeHomePath: string
desiredEntries: readonly CodexTrustEntry[]
managedEventLabels: ReadonlySet<CodexEventLabel>
timeoutSec: number
buildManagedCommand: (linuxRuntimeHome: string) => string
priorLedgerHomes?: readonly CodexTrustGrantLedgerHome[]
}): void {
const desiredKeys = new Set(
options.desiredEntries.map((entry) => normalizeHookTrustKeyForLookup(computeTrustKey(entry)))
)
const ledgerHomes = [
readCodexTrustGrantLedgerHomeForReconciliation(options.runtimeHomePath),
...(options.priorLedgerHomes ?? [])
]
const ownedKeys: string[] = []
for (const [key, state] of readHookTrustEntries(options.tomlPath)) {
if (desiredKeys.has(normalizeHookTrustKeyForLookup(key))) {
continue
}
const parts = parseTrustKey(key)
if (!parts || !options.managedEventLabels.has(parts.eventLabel)) {
continue
}
// Why: this cleanup owns only guest-side WSL trust. A runtime config can
// still contain user Windows/remote hooks, which must remain untouched.
if (!parts.sourcePath.startsWith('/') || !parts.sourcePath.endsWith('/hooks.json')) {
continue
}
const linuxRuntimeHome = parts.sourcePath.slice(0, -'/hooks.json'.length)
const expectedEntry: CodexTrustEntry = {
sourcePath: parts.sourcePath,
eventLabel: parts.eventLabel,
groupIndex: parts.groupIndex,
handlerIndex: parts.handlerIndex,
command: options.buildManagedCommand(linuxRuntimeHome),
timeoutSec: options.timeoutSec
}
const recognizedHashes = new Set([
computeTrustedHash(expectedEntry),
computeTrustedHash({ ...expectedEntry, timeoutSec: undefined })
])
addLedgerRecognizedHashes(recognizedHashes, ledgerHomes, key, expectedEntry)
if (state.trustedHash && recognizedHashes.has(state.trustedHash)) {
ownedKeys.push(key)
}
}
if (ownedKeys.length > 0) {
removeHookTrustEntries(options.tomlPath, ownedKeys)
}
}
@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it } from 'vitest'
import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { captureCodexTrustConfig, restoreCodexTrustConfig } from './codex-trust-config-rollback'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
function tempConfigPath(): string {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-rollback-'))
roots.push(root)
return join(root, 'config.toml')
}
describe('Codex trust config rollback', () => {
it('treats a missing config as absent and tolerates it remaining absent', () => {
const configPath = tempConfigPath()
const snapshot = captureCodexTrustConfig(configPath)
expect(snapshot).toEqual({ existed: false })
expect(() => restoreCodexTrustConfig(configPath, snapshot)).not.toThrow()
})
it('removes a config created after an absent snapshot', () => {
const configPath = tempConfigPath()
const snapshot = captureCodexTrustConfig(configPath)
writeFileSync(configPath, 'rpc mutation')
restoreCodexTrustConfig(configPath, snapshot)
expect(() => readFileSync(configPath)).toThrowError(/ENOENT/)
})
it('atomically recreates exact contents and mode after the file disappears', () => {
const configPath = tempConfigPath()
const original = Buffer.from('# comment\r\n[hooks]\r\n')
writeFileSync(configPath, original)
chmodSync(configPath, 0o640)
const snapshot = captureCodexTrustConfig(configPath)
rmSync(configPath)
restoreCodexTrustConfig(configPath, snapshot)
expect(readFileSync(configPath)).toEqual(original)
if (process.platform !== 'win32') {
expect(statSync(configPath).mode & 0o777).toBe(0o640)
}
})
})
+48 -11
View File
@@ -1,17 +1,31 @@
import { existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
import { closeSync, fstatSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import { randomUUID } from 'node:crypto'
import { renameFileWithWindowsRetry } from '../codex-accounts/fs-utils'
export type CodexTrustConfigSnapshot =
| { existed: false }
| { existed: true; contents: Buffer; mode: number }
export function captureCodexTrustConfig(tomlPath: string): CodexTrustConfigSnapshot {
if (!existsSync(tomlPath)) {
return { existed: false }
let descriptor: number
try {
descriptor = openSync(tomlPath, 'r')
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { existed: false }
}
throw error
}
return {
existed: true,
contents: readFileSync(tomlPath),
mode: statSync(tomlPath).mode
try {
// Why: read and stat the same open file so replacement between two path
// lookups cannot pair one file's contents with another file's mode.
return {
existed: true,
contents: readFileSync(descriptor),
mode: fstatSync(descriptor).mode
}
} finally {
closeSync(descriptor)
}
}
@@ -20,13 +34,36 @@ export function restoreCodexTrustConfig(
snapshot: CodexTrustConfigSnapshot
): void {
if (!snapshot.existed) {
if (existsSync(tomlPath)) {
try {
unlinkSync(tomlPath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error
}
}
return
}
if (existsSync(tomlPath) && readFileSync(tomlPath).equals(snapshot.contents)) {
return
try {
if (readFileSync(tomlPath).equals(snapshot.contents)) {
return
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error
}
}
// Why: rollback protects config integrity too; direct truncating writes can
// leave Codex unusable if Orca exits midway through recovery.
const tempPath = `${tomlPath}.${process.pid}.${randomUUID()}.rollback.tmp`
try {
writeFileSync(tempPath, snapshot.contents, { mode: snapshot.mode })
renameFileWithWindowsRetry(tempPath, tomlPath)
} catch (error) {
try {
unlinkSync(tempPath)
} catch {
// Best effort; preserve the rollback failure as the actionable error.
}
throw error
}
writeFileSync(tomlPath, snapshot.contents, { mode: snapshot.mode })
}
@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const resolveCodexCommandMock = vi.hoisted(() => vi.fn())
vi.mock('../codex-cli/command', () => ({
resolveCodexCommand: resolveCodexCommandMock
}))
import { resolveCodexTrustGrantHost } from './codex-trust-grant-host'
beforeEach(() => {
resolveCodexCommandMock.mockReset()
resolveCodexCommandMock.mockReturnValue(process.execPath)
})
describe('resolveCodexTrustGrantHost', () => {
it('resolves the native command once for both the binary stamp and request', () => {
const host = resolveCodexTrustGrantHost({ kind: 'native' })
const input = {
runtimeHomePath: '/tmp/codex-home',
managedCommand: '/bin/sh codex-hook.sh',
expectedTrustKeys: ['managed-key']
}
expect(host.binaryStamp).toMatchObject({ kind: 'native', path: process.execPath })
expect(host.buildRequest(input).invocation.command).toBe(process.execPath)
expect(host.buildRequest(input).invocation.command).toBe(process.execPath)
// Why: PATH/version-manager scans are synchronous launch-path I/O. Reusing
// the resolved command keeps one grant at one scan regardless of consumers.
expect(resolveCodexCommandMock).toHaveBeenCalledTimes(1)
})
it('builds WSL requests without scanning the native PATH', () => {
const host = resolveCodexTrustGrantHost({
kind: 'wsl',
distro: 'Ubuntu',
linuxRuntimeHome: '/home/alice/.codex-runtime'
})
const request = host.buildRequest({
runtimeHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex-runtime',
managedCommand: '/bin/sh codex-hook.sh',
expectedTrustKeys: ['managed-key']
})
expect(host.binaryStamp).toEqual({ kind: 'wsl', distro: 'Ubuntu' })
expect(request.invocation.command).toBe('wsl.exe')
expect(resolveCodexCommandMock).not.toHaveBeenCalled()
})
})
+94
View File
@@ -0,0 +1,94 @@
import { resolveCodexCommand } from '../codex-cli/command'
import { getSpawnArgsForWindows } from '../win32-utils'
import { buildWslCodexAppServerArgs } from '../codex-accounts/wsl-codex-command'
import type { CodexHookTrustGrantRequest } from './codex-app-server-client'
import {
binaryStampsMatch,
buildNativeCodexBinaryStamp,
readCodexTrustGrantLedgerHome,
type CodexTrustGrantBinaryStamp,
type CodexTrustGrantLedgerHome
} from './codex-trust-grant-ledger'
// Why: native sessions finish in ~100ms; WSL also pays cold-distro and
// login-shell startup, but both stay hard-bounded on launch prep.
const NATIVE_GRANT_TIMEOUT_MS = 10_000
const WSL_GRANT_TIMEOUT_MS = 30_000
export type CodexTrustGrantHost =
| { kind: 'native' }
| { kind: 'wsl'; distro: string; linuxRuntimeHome: string }
type CodexTrustGrantRequestInput = {
runtimeHomePath: string
managedCommand: string
expectedTrustKeys: string[]
}
export type ResolvedCodexTrustGrantHost = {
binaryStamp: CodexTrustGrantBinaryStamp | null
buildRequest: (input: CodexTrustGrantRequestInput) => CodexHookTrustGrantRequest
}
export function resolveCodexTrustGrantHost(host: CodexTrustGrantHost): ResolvedCodexTrustGrantHost {
if (host.kind === 'wsl') {
return {
binaryStamp: { kind: 'wsl', distro: host.distro },
buildRequest: (input) => ({
invocation: {
command: 'wsl.exe',
args: buildWslCodexAppServerArgs(host.distro, host.linuxRuntimeHome),
timeoutMs: WSL_GRANT_TIMEOUT_MS
},
hooksListCwd: host.linuxRuntimeHome,
expectedTrustKeys: input.expectedTrustKeys,
managedCommand: input.managedCommand
})
}
}
// Why: command resolution scans PATH/version-manager directories. Resolve
// once per grant and reuse it for both the binary stamp and invocation.
const command = resolveCodexCommand()
return {
binaryStamp: command === 'codex' ? null : buildNativeCodexBinaryStamp(command),
buildRequest: (input) => {
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, ['app-server'])
return {
invocation: {
command: spawnCmd,
args: spawnArgs,
env: { CODEX_HOME: input.runtimeHomePath },
timeoutMs: NATIVE_GRANT_TIMEOUT_MS
},
hooksListCwd: input.runtimeHomePath,
expectedTrustKeys: input.expectedTrustKeys,
managedCommand: input.managedCommand
}
}
}
}
export function readCodexTrustGrantLedgerHomeMatchingStamp(
runtimeHomePath: string,
currentStamp: CodexTrustGrantBinaryStamp | null
): CodexTrustGrantLedgerHome | null {
const home = readCodexTrustGrantLedgerHome(runtimeHomePath)
return home && binaryStampsMatch(home.binary, currentStamp) ? home : null
}
export function readCurrentCodexTrustGrantLedgerHome(
runtimeHomePath: string,
host: CodexTrustGrantHost
): CodexTrustGrantLedgerHome | null {
try {
return readCodexTrustGrantLedgerHomeMatchingStamp(
runtimeHomePath,
resolveCodexTrustGrantHost(host).binaryStamp
)
} catch {
// Why: status is diagnostic and best-effort; unreadable ledger/binary
// paths must trigger conservative self-hash handling, not throw.
return null
}
}
@@ -6,7 +6,7 @@ vi.mock('node:child_process', () => ({
execFile: execFileMock
}))
import { _internals } from './codex-wsl-hook-install-plan'
import { _internals, createCodexWslRuntimeHookInstallPlan } from './codex-wsl-hook-install-plan'
const originalPlatform = process.platform
@@ -24,6 +24,17 @@ afterEach(() => {
})
describe('canonicalizeWslLinuxPath', () => {
it('joins guest paths without producing a double slash at the filesystem root', () => {
const plan = createCodexWslRuntimeHookInstallPlan(
'C:\\runtime',
{ runtime: 'wsl', wslDistro: 'Ubuntu' },
() => '/'
)
expect(plan?.commandScriptPath).toBe('/.orca/agent-hooks/codex-hook.sh')
expect(plan?.trustConfigPath).toBe('/hooks.json')
})
it('returns the path unchanged off Windows without spawning wsl.exe', () => {
setPlatform('linux')
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alice')).toBe('/home/alice')
@@ -1,5 +1,5 @@
import { execFile } from 'node:child_process'
import { win32 as pathWin32 } from 'node:path'
import { posix as pathPosix, win32 as pathWin32 } from 'node:path'
import { parseWslUncPath } from '../../shared/wsl-paths'
export type CodexWslRuntimeHookTarget = {
@@ -188,8 +188,8 @@ export function createCodexWslRuntimeHookInstallPlan(
configPath: pathWin32.join(runtimeHomePath, 'hooks.json'),
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`,
commandScriptPath: pathPosix.join(linuxRuntimeHome, '.orca', 'agent-hooks', 'codex-hook.sh'),
trustConfigPath: pathPosix.join(linuxRuntimeHome, 'hooks.json'),
wslDistro: distro,
linuxRuntimeHome
}
@@ -0,0 +1,236 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import type * as Os from 'node:os'
import { join } from 'node:path'
import { wrapPosixHookCommand } from '../agent-hooks/installer-utils'
import {
computeTrustedHash,
parseTrustKey,
upsertHookTrustEntries,
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,
writeCodexTrustGrantLedgerHome
} from './codex-trust-grant-ledger'
import type { CodexHookTrustGrantRequest } from './codex-app-server-client'
const { getPathMock, homedirMock } = vi.hoisted(() => ({
getPathMock: vi.fn<(name: string) => string>(),
homedirMock: vi.fn<() => string>()
}))
vi.mock('electron', () => ({ app: { getPath: getPathMock } }))
vi.mock('os', async (importOriginal) => {
const actual = await importOriginal<typeof Os>()
return { ...actual, homedir: homedirMock }
})
import { CodexHookService } from './hook-service'
let tmpHome: string
let userDataDir: string
let previousUserDataPath: string | undefined
beforeEach(() => {
tmpHome = mkdtempSync(join(tmpdir(), 'orca-codex-home-'))
userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-user-data-'))
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
process.env.ORCA_USER_DATA_PATH = userDataDir
homedirMock.mockReturnValue(tmpHome)
getPathMock.mockImplementation((name: string) => {
if (name === 'userData') {
return userDataDir
}
throw new Error(`unexpected app.getPath(${name})`)
})
trustGrantInternals.resetDiagnostics()
codexAppServerCapabilityCache.clear()
})
afterEach(() => {
trustGrantInternals.setGrantSessionRunnerSync(null)
trustGrantInternals.resetDiagnostics()
codexAppServerCapabilityCache.clear()
delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC
rmSync(tmpHome, { recursive: true, force: true })
rmSync(userDataDir, { recursive: true, force: true })
if (previousUserDataPath === undefined) {
delete process.env.ORCA_USER_DATA_PATH
} else {
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
}
vi.clearAllMocks()
})
function installCodexLikeGrantRunner(): ReturnType<typeof vi.fn> {
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 {
...parsed,
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
}
function prepareSystemHome(): void {
mkdirSync(join(tmpHome, '.codex'), { recursive: true })
}
describe('CodexHookService app-server trust grant lane', () => {
it('treats Codex hashes as authoritative and records the verified grant', () => {
prepareSystemHome()
const runner = installCodexLikeGrantRunner()
const status = new CodexHookService().install()
expect(status.state).toBe('installed')
expect(runner).toHaveBeenCalledTimes(1)
const managedHome = join(userDataDir, 'codex-runtime-home', 'home')
const trustConfig = readFileSync(join(managedHome, 'config.toml'), 'utf-8')
expect(trustConfig).toContain('sha256:codex-session_start')
const selfComputed = computeTrustedHash({
sourcePath: join(managedHome, 'hooks.json'),
eventLabel: 'session_start',
groupIndex: 0,
handlerIndex: 0,
command: wrapPosixHookCommand(join(tmpHome, '.orca', 'agent-hooks', 'codex-hook.sh')),
timeoutSec: 10
})
expect(trustConfig).not.toContain(selfComputed)
expect(Object.keys(readCodexTrustGrantLedgerHome(managedHome)!.entries)).toHaveLength(6)
})
it('keeps config byte-stable and skips the session on a repeat ledger hit', () => {
prepareSystemHome()
const runner = installCodexLikeGrantRunner()
const service = new CodexHookService()
expect(service.install().state).toBe('installed')
const managedHome = join(userDataDir, 'codex-runtime-home', 'home')
const firstToml = readFileSync(join(managedHome, 'config.toml'))
expect(service.install().state).toBe('installed')
expect(runner).toHaveBeenCalledTimes(1)
expect(readFileSync(join(managedHome, 'config.toml'))).toEqual(firstToml)
})
it('does not accept a ledger hash after the recorded Codex binary stamp changes', () => {
prepareSystemHome()
installCodexLikeGrantRunner()
const service = new CodexHookService()
expect(service.install().state).toBe('installed')
const managedHome = join(userDataDir, 'codex-runtime-home', 'home')
const ledger = readCodexTrustGrantLedgerHome(managedHome)!
writeCodexTrustGrantLedgerHome(managedHome, {
...ledger,
binary: { kind: 'native', path: '/definitely/not/current/codex', size: 1, mtimeMs: 1 }
})
expect(service.getStatus()).toMatchObject({
state: 'partial',
detail: expect.stringContaining('Trust entry missing or stale')
})
})
it('upgrades self-computed trust in place without duplicate tables', () => {
prepareSystemHome()
const service = new CodexHookService()
process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1'
expect(service.install().state).toBe('installed')
const managedHome = join(userDataDir, 'codex-runtime-home', 'home')
delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC
installCodexLikeGrantRunner()
expect(service.install().state).toBe('installed')
const upgraded = readFileSync(join(managedHome, 'config.toml'), 'utf-8')
for (const eventLabel of [
'session_start',
'user_prompt_submit',
'pre_tool_use',
'permission_request',
'post_tool_use',
'stop'
]) {
const count = upgraded
.split('\n')
.filter(
(line) => line.startsWith('[hooks.state.') && line.includes(`:${eventLabel}:0:0`)
).length
expect(count, `duplicate trust tables for ${eventLabel}`).toBe(1)
}
})
it('leaves user trust byte-untouched while granting managed entries', () => {
prepareSystemHome()
const managedHome = join(userDataDir, 'codex-runtime-home', 'home')
mkdirSync(managedHome, { recursive: true })
const userBlock = [
'[hooks.state."/home/user/.codex/hooks.json:stop:3:1"]',
'enabled = false',
'trusted_hash = "sha256:user-owned-hash"'
].join('\n')
writeFileSync(join(managedHome, 'config.toml'), `${userBlock}\n`)
installCodexLikeGrantRunner()
expect(new CodexHookService().install().state).toBe('installed')
expect(readFileSync(join(managedHome, 'config.toml'), 'utf-8')).toContain(userBlock)
})
it('keeps the forced fallback on self-computed writes', () => {
prepareSystemHome()
process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1'
const runner = vi.fn()
trustGrantInternals.setGrantSessionRunnerSync(runner)
expect(new CodexHookService().install().state).toBe('installed')
expect(runner).not.toHaveBeenCalled()
})
it('restores exact config bytes before fallback after a mutating RPC failure', () => {
prepareSystemHome()
const service = new CodexHookService()
process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1'
expect(service.install().state).toBe('installed')
const managedHome = join(userDataDir, 'codex-runtime-home', 'home')
const baseline = readFileSync(join(managedHome, 'config.toml'))
delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC
rmSync(managedHome, { recursive: true, force: true })
trustGrantInternals.resetDiagnostics()
const runner = vi.fn((request: CodexHookTrustGrantRequest) => {
const codexHome = request.invocation.env?.CODEX_HOME
writeFileSync(
join(codexHome!, 'config.toml'),
'[hooks.state."rpc-partial"]\ntrusted_hash = "sha256:changed"\n'
)
throw new Error('transport failed after config/batchWrite')
})
trustGrantInternals.setGrantSessionRunnerSync(runner)
expect(service.install().state).toBe('installed')
expect(runner).toHaveBeenCalledTimes(1)
expect(readFileSync(join(managedHome, 'config.toml'))).toEqual(baseline)
})
})
@@ -9,6 +9,7 @@ import {
computeTrustKey,
computeTrustedHash,
normalizeHookTrustKeyForLookup,
parseTrustKey,
readHookTrustEntries,
upsertHookTrustEntries,
type CodexTrustEntry
@@ -516,4 +517,61 @@ describe('Codex WSL runtime hook install app-server grant lane', () => {
trustedHash: computeTrustedHash(managedTrustEntry)
})
})
it('uses the previous ledger to remove stale Codex hashes after a canonical path change', () => {
const basePlan = createTestPlan()
writeFileSync(basePlan.configPath, '{"hooks":{}}\n', 'utf-8')
writeFileSync(basePlan.tomlPath, '', 'utf-8')
const runner = vi.fn((request: CodexHookTrustGrantRequest) => {
const entries = 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: `sha256:codex-verbatim-${parsed.eventLabel}`
}
})
upsertHookTrustEntries(basePlan.tomlPath, entries)
return {
outcome: 'granted' as const,
wroteTrust: true,
entries: request.expectedTrustKeys.map((key) => ({
key,
normalizedKey: normalizeHookTrustKeyForLookup(key),
trustedHash: `sha256:codex-verbatim-${parseTrustKey(key)!.eventLabel}`
}))
}
})
trustGrantInternals.setGrantSessionRunnerSync(runner)
const oldPlan = {
...basePlan,
commandScriptPath: '/old/home/.orca/agent-hooks/codex-hook.sh',
trustConfigPath: '/old/home/hooks.json',
linuxRuntimeHome: '/old/home'
}
expect(_internals.installManagedHooksIntoWslRuntime(oldPlan).state).toBe('installed')
const oldKey = computeTrustKey(
getManagedTrustEntry(oldPlan, expectedManagedCommand(oldPlan.commandScriptPath))
)
const newPlan = {
...basePlan,
commandScriptPath: '/new/home/.orca/agent-hooks/codex-hook.sh',
trustConfigPath: '/new/home/hooks.json',
linuxRuntimeHome: '/new/home'
}
expect(_internals.installManagedHooksIntoWslRuntime(newPlan).state).toBe('installed')
const newKey = computeTrustKey(
getManagedTrustEntry(newPlan, expectedManagedCommand(newPlan.commandScriptPath))
)
const trustEntries = readHookTrustEntries(basePlan.tomlPath)
expect(runner).toHaveBeenCalledTimes(2)
expect(trustEntries.has(oldKey)).toBe(false)
expect(trustEntries.get(newKey)?.trustedHash).toBe('sha256:codex-verbatim-user_prompt_submit')
})
})
+1 -231
View File
@@ -17,17 +17,7 @@ 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,
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'
import { computeTrustedHash, upsertHookTrustEntriesInContent } from './config-toml-trust'
const { getPathMock, homedirMock } = vi.hoisted(() => ({
getPathMock: vi.fn<(name: string) => string>(),
@@ -1464,223 +1454,3 @@ describe('CodexHookService', () => {
expect(trustConfig).not.toContain('model = "runtime-model"')
})
})
describe('CodexHookService app-server trust grant lane', () => {
beforeEach(() => {
trustGrantInternals.resetDiagnostics()
codexAppServerCapabilityCache.clear()
})
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
})
)
})
it('keeps fallback output byte-identical after an RPC mutates config and then fails', () => {
const systemCodexHome = join(tmpHome, '.codex')
mkdirSync(systemCodexHome, { recursive: true })
const service = new CodexHookService()
process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1'
expect(service.install().state).toBe('installed')
const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home')
const baseline = readFileSync(join(managedCodexHome, 'config.toml'))
delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC
rmSync(managedCodexHome, { recursive: true, force: true })
trustGrantInternals.resetDiagnostics()
const runner = vi.fn((request: CodexHookTrustGrantRequest) => {
const codexHome = request.invocation.env?.CODEX_HOME
expect(codexHome).toBeTruthy()
writeFileSync(
join(codexHome!, 'config.toml'),
'[hooks.state."rpc-partial"]\ntrusted_hash = "sha256:changed"\n'
)
throw new Error('transport failed after config/batchWrite')
})
trustGrantInternals.setGrantSessionRunnerSync(runner)
expect(service.install().state).toBe('installed')
expect(runner).toHaveBeenCalledTimes(1)
expect(readFileSync(join(managedCodexHome, 'config.toml'))).toEqual(baseline)
})
})
+51 -164
View File
@@ -67,11 +67,14 @@ import {
snapshotCodexRuntimeHookTrustProvenance
} from './hook-trust-promotion'
import { grantManagedCodexHookTrust } from './codex-hook-trust-grant'
import { readCurrentCodexTrustGrantLedgerHome } from './codex-trust-grant-host'
import {
readCodexTrustGrantLedgerHome,
removeCodexTrustGrantLedgerHome,
type CodexTrustGrantLedgerHome
} from './codex-trust-grant-ledger'
getCodexLedgerTrustedHash,
readCodexTrustGrantLedgerHomeForReconciliation,
removeCodexManagedHookTrustEntries,
removeStaleWslCodexManagedHookTrustEntries
} from './codex-managed-trust-reconciliation'
import 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.
@@ -636,79 +639,16 @@ 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,
expectedEntry: CodexTrustEntry
): void {
const granted = ledgerHome?.entries[normalizeHookTrustKeyForLookup(key)]
if (granted?.trustedHash && granted.signature === getCodexHookTrustSignature(expectedEntry)) {
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])
)
// Why: only drop entries WE wrote. The same config.toml can contain
// user-approved trust entries for non-Orca commands, so match by hash
// equivalence to our managed command — a sourcePath-only filter would
// wipe the user's manually-approved entries.
const ourKeys: string[] = []
const canonicalConfigPath = getCodexCanonicalTrustPath(configPath)
for (const [key, state] of existingEntries) {
const parts = parseTrustKey(key)
if (parts === null) {
continue
}
if (getCodexCanonicalTrustPath(parts.sourcePath) !== canonicalConfigPath) {
continue
}
if (!managedEventLabels.has(parts.eventLabel)) {
continue
}
const expectedEntry: CodexTrustEntry = {
sourcePath: configPath,
eventLabel: parts.eventLabel,
groupIndex: parts.groupIndex,
handlerIndex: parts.handlerIndex,
command,
// Why: match the timeout install() wrote, or remove() would fail to
// recognize (and clean up) its own managed trust entries.
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
}
const recognizedHashes = new Set([
computeTrustedHash(expectedEntry),
computeTrustedHash({ ...expectedEntry, timeoutSec: undefined })
])
addLedgerRecognizedHash(recognizedHashes, ledgerHome, key, expectedEntry)
if (!state.trustedHash || !recognizedHashes.has(state.trustedHash)) {
continue
}
ourKeys.push(key)
}
if (ourKeys.length > 0) {
removeHookTrustEntries(tomlPath, ourKeys)
}
removeCodexTrustGrantLedgerHome(getOrcaManagedCodexHomePath())
removeCodexManagedHookTrustEntries({
tomlPath: getCodexConfigTomlPath(),
runtimeHomePath: getOrcaManagedCodexHomePath(),
sourcePath: configPath,
command: getManagedCommand(getManagedScriptPath()),
managedEventLabels: CODEX_MANAGED_EVENT_LABELS,
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
})
} 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.
@@ -718,46 +658,14 @@ function removeRuntimeManagedHookTrustEntries(configPath: string): void {
function removeWslRuntimeManagedHookTrustEntries(plan: CodexWslRuntimeHookInstallPlan): void {
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])
)
const canonicalConfigPath = getCodexCanonicalTrustPath(plan.trustConfigPath)
const ourKeys: string[] = []
for (const [key, state] of existingEntries) {
const parts = parseTrustKey(key)
if (parts === null) {
continue
}
if (getCodexCanonicalTrustPath(parts.sourcePath) !== canonicalConfigPath) {
continue
}
if (!managedEventLabels.has(parts.eventLabel)) {
continue
}
const expectedEntry: CodexTrustEntry = {
sourcePath: plan.trustConfigPath,
eventLabel: parts.eventLabel,
groupIndex: parts.groupIndex,
handlerIndex: parts.handlerIndex,
command,
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
}
const recognizedHashes = new Set([
computeTrustedHash(expectedEntry),
computeTrustedHash({ ...expectedEntry, timeoutSec: undefined })
])
addLedgerRecognizedHash(recognizedHashes, ledgerHome, key, expectedEntry)
if (state.trustedHash && recognizedHashes.has(state.trustedHash)) {
ourKeys.push(key)
}
}
if (ourKeys.length > 0) {
removeHookTrustEntries(plan.tomlPath, ourKeys)
}
removeCodexTrustGrantLedgerHome(pathWin32.dirname(plan.tomlPath))
removeCodexManagedHookTrustEntries({
tomlPath: plan.tomlPath,
runtimeHomePath: pathWin32.dirname(plan.tomlPath),
sourcePath: plan.trustConfigPath,
command: wrapReadablePosixHookCommand(plan.commandScriptPath),
managedEventLabels: CODEX_MANAGED_EVENT_LABELS,
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
})
} 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.
@@ -767,50 +675,19 @@ function removeWslRuntimeManagedHookTrustEntries(plan: CodexWslRuntimeHookInstal
function removeStaleWslRuntimeManagedHookTrustEntries(
tomlPath: string,
desiredEntries: readonly CodexTrustEntry[]
desiredEntries: readonly CodexTrustEntry[],
priorLedgerHomes: readonly CodexTrustGrantLedgerHome[] = []
): void {
const desiredKeys = new Set(
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))) {
continue
}
const parts = parseTrustKey(key)
if (!parts || !CODEX_MANAGED_EVENT_LABELS.has(parts.eventLabel)) {
continue
}
const sourcePath = parts.sourcePath
// Why: this cleanup owns only guest-side WSL trust. A runtime config can
// still contain user Windows/remote hooks, which must remain untouched.
if (!sourcePath.startsWith('/') || !sourcePath.endsWith('/hooks.json')) {
continue
}
const runtimeHome = sourcePath.slice(0, -'/hooks.json'.length)
const command = wrapReadablePosixHookCommand(`${runtimeHome}/.orca/agent-hooks/codex-hook.sh`)
const expectedEntry: CodexTrustEntry = {
sourcePath: parts.sourcePath,
eventLabel: parts.eventLabel,
groupIndex: parts.groupIndex,
handlerIndex: parts.handlerIndex,
command,
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
}
const recognizedHashes = new Set([
computeTrustedHash(expectedEntry),
computeTrustedHash({ ...expectedEntry, timeoutSec: undefined })
])
addLedgerRecognizedHash(recognizedHashes, ledgerHome, key, expectedEntry)
if (state.trustedHash && recognizedHashes.has(state.trustedHash)) {
ourKeys.push(key)
}
}
if (ourKeys.length > 0) {
removeHookTrustEntries(tomlPath, ourKeys)
}
removeStaleWslCodexManagedHookTrustEntries({
tomlPath,
runtimeHomePath: pathWin32.dirname(tomlPath),
desiredEntries,
managedEventLabels: CODEX_MANAGED_EVENT_LABELS,
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS,
buildManagedCommand: (linuxRuntimeHome) =>
wrapReadablePosixHookCommand(`${linuxRuntimeHome}/.orca/agent-hooks/codex-hook.sh`),
priorLedgerHomes
})
}
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
@@ -961,15 +838,23 @@ function installManagedHooksIntoWslRuntime(
try {
// 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 runtimeHomePath = pathWin32.dirname(plan.tomlPath)
// Why: a successful re-grant replaces the ledger. Keep the previous
// records long enough to prove ownership of stale canonical-path keys.
const previousLedgerHome = readCodexTrustGrantLedgerHomeForReconciliation(runtimeHomePath)
const grant = grantManagedCodexHookTrust({
runtimeHomePath: pathWin32.dirname(plan.tomlPath),
runtimeHomePath,
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)
removeStaleWslRuntimeManagedHookTrustEntries(
plan.tomlPath,
grant.entries,
previousLedgerHome ? [previousLedgerHome] : []
)
} 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.
@@ -1183,7 +1068,9 @@ export class CodexHookService {
// 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 ledgerHome = readCurrentCodexTrustGrantLedgerHome(getOrcaManagedCodexHomePath(), {
kind: 'native'
})
const missing: string[] = []
const trustMissing: string[] = []
@@ -1230,9 +1117,9 @@ export class CodexHookService {
}
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 grantedHash = getCodexLedgerTrustedHash(ledgerHome, trustKey, trustInput)
if (grantedHash) {
validHashes.add(grantedHash)
}
const actualState = trustEntries.get(trustKey)
if (!actualState?.trustedHash || !validHashes.has(actualState.trustedHash)) {