mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 00:03:59 +00:00
Merge remote-tracking branch 'origin/brennanb2025/codex-trust-rpc-grant' into brennanb2025/codex-real-home-phase1
# Conflicts: # src/main/codex/codex-app-server-grant-bridge.ts
This commit is contained in:
@@ -11,10 +11,19 @@
|
||||
"../src/main/antigravity/hook-service.ts",
|
||||
"../src/main/claude/hook-settings.ts",
|
||||
"../src/main/claude/hook-service.ts",
|
||||
"../src/main/codex/codex-app-server-capability-cache.ts",
|
||||
"../src/main/codex/codex-app-server-client.ts",
|
||||
"../src/main/codex/codex-app-server-grant-bridge.ts",
|
||||
"../src/main/codex/codex-app-server-grant-envelope.ts",
|
||||
"../src/main/codex/codex-config-mirror.ts",
|
||||
"../src/main/codex/codex-config-path-reference-rewrite.ts",
|
||||
"../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",
|
||||
"../src/main/codex/config-toml-line-scan.ts",
|
||||
@@ -22,6 +31,8 @@
|
||||
"../src/main/codex/hook-service.ts",
|
||||
"../src/main/codex/hook-trust-promotion.ts",
|
||||
"../src/main/codex-accounts/fs-utils.ts",
|
||||
"../src/main/codex-accounts/wsl-codex-command.ts",
|
||||
"../src/main/codex-cli/command.ts",
|
||||
"../src/main/command-code/command-code-managed-script.ts",
|
||||
"../src/main/command-code/hook-service.ts",
|
||||
"../src/main/copilot/hook-service.ts",
|
||||
|
||||
@@ -9,13 +9,17 @@ 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
|
||||
// via STUB_CONFIG so each test controls listings, errors, and hangs.
|
||||
const STUB_SERVER_SOURCE = `
|
||||
const config = JSON.parse(process.env.STUB_CONFIG)
|
||||
require('node:fs').writeFileSync(config.pidFile, String(process.pid))
|
||||
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') }
|
||||
@@ -93,14 +97,16 @@ function createStubRequest(options: {
|
||||
expectedTrustKeys: string[]
|
||||
managedCommand: string
|
||||
timeoutMs?: number
|
||||
}): { request: CodexHookTrustGrantRequest; recordFile: string } {
|
||||
}): { request: CodexHookTrustGrantRequest; recordFile: string; pidFile: 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')
|
||||
const pidFile = join(root, 'app-server.pid')
|
||||
return {
|
||||
recordFile,
|
||||
pidFile,
|
||||
request: {
|
||||
invocation: {
|
||||
command: process.execPath,
|
||||
@@ -110,7 +116,8 @@ function createStubRequest(options: {
|
||||
scenario: options.scenario,
|
||||
hooks: options.hooks,
|
||||
cwd: root,
|
||||
recordFile
|
||||
recordFile,
|
||||
pidFile
|
||||
})
|
||||
},
|
||||
timeoutMs: options.timeoutMs ?? 10_000
|
||||
@@ -234,7 +241,7 @@ describe('runCodexHookTrustGrantSession', () => {
|
||||
|
||||
it('kills a hung server at the session deadline', async () => {
|
||||
const keys = ['/home/a/.codex/hooks.json:session_start:0:0']
|
||||
const { request } = createStubRequest({
|
||||
const { request, pidFile } = createStubRequest({
|
||||
scenario: 'hang',
|
||||
hooks: keys.map((key) => managedHook(key)),
|
||||
expectedTrustKeys: keys,
|
||||
@@ -249,6 +256,8 @@ describe('runCodexHookTrustGrantSession', () => {
|
||||
// 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)
|
||||
const childPid = Number(readFileSync(pidFile, 'utf8'))
|
||||
expect(() => process.kill(childPid, 0)).toThrow()
|
||||
})
|
||||
|
||||
it('surfaces spawn failures as regular errors, not capability signals', async () => {
|
||||
@@ -325,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
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -87,6 +87,7 @@ type CodexHookListing = {
|
||||
|
||||
const JSON_RPC_METHOD_NOT_FOUND = -32601
|
||||
const STDERR_TAIL_MAX_BYTES = 8192
|
||||
const STDOUT_LINE_MAX_BYTES = 1024 * 1024
|
||||
|
||||
function isMethodNotFoundError(error: { code?: number; message?: string }): boolean {
|
||||
return error.code === JSON_RPC_METHOD_NOT_FOUND || /method not found/i.test(error.message ?? '')
|
||||
@@ -180,10 +181,20 @@ export async function runCodexHookTrustGrantSession(
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderrTail = (stderrTail + chunk.toString('utf8')).slice(-STDERR_TAIL_MAX_BYTES)
|
||||
})
|
||||
// Why: a child can exit between the liveness check and stdin.write(); an
|
||||
// EPIPE must reject the RPC instead of becoming an unhandled stream error.
|
||||
child.stdin.on('error', (error) => {
|
||||
failPending(error)
|
||||
})
|
||||
|
||||
let stdoutBuffer = ''
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdoutBuffer += chunk.toString('utf8')
|
||||
if (Buffer.byteLength(stdoutBuffer) > STDOUT_LINE_MAX_BYTES) {
|
||||
child.kill('SIGKILL')
|
||||
failPending(new Error('codex app-server emitted an oversized JSONL response'))
|
||||
return
|
||||
}
|
||||
let newlineIndex
|
||||
while ((newlineIndex = stdoutBuffer.indexOf('\n')) !== -1) {
|
||||
const line = stdoutBuffer.slice(0, newlineIndex).trim()
|
||||
@@ -342,7 +353,6 @@ export async function runCodexHookTrustGrantSession(
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(deadline)
|
||||
try {
|
||||
child.stdin.end()
|
||||
} catch {
|
||||
@@ -358,5 +368,6 @@ export async function runCodexHookTrustGrantSession(
|
||||
await Promise.race([exitPromise, new Promise<void>((resolve) => setTimeout(resolve, 1000))])
|
||||
}
|
||||
}
|
||||
clearTimeout(deadline)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import { join } from 'node:path'
|
||||
import {
|
||||
CodexAppServerTimeoutError,
|
||||
CodexAppServerUnsupportedError,
|
||||
isCodexAppServerUnsupportedError,
|
||||
type CodexHookTrustGrantRequest,
|
||||
type CodexHookTrustGrantSessionResult
|
||||
} from './codex-app-server-client'
|
||||
import type { GrantEntryEnvelope } from './codex-app-server-grant-envelope'
|
||||
|
||||
// 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
|
||||
@@ -15,24 +15,6 @@ import {
|
||||
// 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.
|
||||
@@ -40,25 +22,20 @@ 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: this module is reachable from plain-Node CLI entries, so it must not
|
||||
// require electron (plain-node-entry-guard). __dirname of the built chunk is
|
||||
// out/main (root chunk) or out/main/chunks; the entry sits at
|
||||
// out/main/codex/. ELECTRON_RUN_AS_NODE bypasses asar integration, so the
|
||||
// packaged entry must run from app.asar.unpacked (out/main/codex/** is in
|
||||
// the asarUnpack list) — cover that with a path rewrite instead of app APIs.
|
||||
const chunkDirs = [__dirname, join(__dirname, '..')]
|
||||
const candidates = chunkDirs.flatMap((dir) => {
|
||||
const unpackedDir = dir.includes('app.asar')
|
||||
? dir.replace('app.asar', 'app.asar.unpacked')
|
||||
: null
|
||||
return [
|
||||
...(unpackedDir ? [join(unpackedDir, 'codex', GRANT_ENTRY_FILE_NAME)] : []),
|
||||
join(dir, 'codex', GRANT_ENTRY_FILE_NAME)
|
||||
]
|
||||
})
|
||||
for (const candidate of candidates) {
|
||||
// Why: resolved from __dirname (not electron's app paths) so this module
|
||||
// stays loadable in plain-node CLI entries — the build guard rejects any
|
||||
// electron require reachable from them. The emitted bridge chunk sits in
|
||||
// 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 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)) {
|
||||
return candidate
|
||||
}
|
||||
@@ -69,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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,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
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
// 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 { buildGrantEntryEnvelope } from './codex-app-server-grant-envelope'
|
||||
import { writeSync } from 'node:fs'
|
||||
import {
|
||||
runCodexHookTrustGrantSession,
|
||||
type CodexHookTrustGrantRequest
|
||||
@@ -39,7 +40,10 @@ async function main(): Promise<void> {
|
||||
// suspend mid-session); exiting closes the codex child's stdio so it
|
||||
// exits on EOF instead of orphaning.
|
||||
const hardExit = setTimeout(() => {
|
||||
process.stdout.write(
|
||||
// Why: process.exit() does not flush asynchronous stdout pipes; write the
|
||||
// timeout envelope synchronously so the parent can classify the fallback.
|
||||
writeSync(
|
||||
process.stdout.fd,
|
||||
`${JSON.stringify({
|
||||
ok: false,
|
||||
errorName: 'CodexAppServerTimeoutError',
|
||||
@@ -54,7 +58,9 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
void main().then(
|
||||
() => process.exit(0),
|
||||
() => {
|
||||
process.exitCode = 0
|
||||
},
|
||||
(error: unknown) => {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
@@ -63,6 +69,6 @@ void main().then(
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
})}\n`
|
||||
)
|
||||
process.exit(0)
|
||||
process.exitCode = 0
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
isCodexAppServerUnsupportedError,
|
||||
type CodexHookTrustGrantSessionResult
|
||||
} from './codex-app-server-client'
|
||||
|
||||
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 } : {})
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
CodexAppServerUnsupportedError,
|
||||
type CodexHookTrustGrantRequest
|
||||
type CodexHookTrustGrantRequest,
|
||||
type CodexHookTrustGrantSessionResult
|
||||
} from './codex-app-server-client'
|
||||
import { codexAppServerCapabilityCache } from './codex-app-server-capability-cache'
|
||||
import {
|
||||
_internals,
|
||||
CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS,
|
||||
getCodexTrustGrantDiagnostics,
|
||||
grantManagedCodexHookTrust,
|
||||
setCodexTrustGrantTelemetry,
|
||||
type CodexManagedTrustGrantPlan
|
||||
} from './codex-hook-trust-grant'
|
||||
import { readCodexTrustGrantLedgerHome } from './codex-trust-grant-ledger'
|
||||
@@ -35,7 +38,9 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
_internals.setGrantSessionRunnerSync(null)
|
||||
setCodexTrustGrantTelemetry(() => {})
|
||||
codexAppServerCapabilityCache.clear()
|
||||
if (previousUserDataPath === undefined) {
|
||||
delete process.env.ORCA_USER_DATA_PATH
|
||||
@@ -158,7 +163,7 @@ describe('grantManagedCodexHookTrust', () => {
|
||||
|
||||
it('marks the host unsupported only for the unsupported error class', () => {
|
||||
const entries = [managedEntry('session_start')]
|
||||
const runner = vi.fn(() => {
|
||||
const runner = vi.fn((): CodexHookTrustGrantSessionResult => {
|
||||
throw new CodexAppServerUnsupportedError('no such method')
|
||||
})
|
||||
_internals.setGrantSessionRunnerSync(runner)
|
||||
@@ -178,16 +183,27 @@ describe('grantManagedCodexHookTrust', () => {
|
||||
expect(runner).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('falls back without poisoning the capability on transient errors', () => {
|
||||
it('backs off transient failures without poisoning the capability', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
const entries = [managedEntry('session_start')]
|
||||
const runner = vi.fn(() => {
|
||||
const runner = vi.fn((): CodexHookTrustGrantSessionResult => {
|
||||
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(grantManagedCodexHookTrust(plan)).toMatchObject({
|
||||
lane: 'fallback',
|
||||
reason: 'retry-cached'
|
||||
})
|
||||
expect(runner).toHaveBeenCalledTimes(1)
|
||||
expect(codexAppServerCapabilityCache.shouldTry('native')).toBe(true)
|
||||
|
||||
runner.mockImplementation(() => grantedSessionResult(entries))
|
||||
vi.setSystemTime(1_000 + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS)
|
||||
expect(grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'rpc' })
|
||||
expect(runner).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
@@ -204,6 +220,64 @@ 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)
|
||||
const original = '# user formatting\r\n[hooks]\r\n'
|
||||
mkdirSync(runtimeHomeDir, { recursive: true })
|
||||
writeFileSync(plan.tomlPath, original)
|
||||
_internals.setGrantSessionRunnerSync(() => {
|
||||
writeFileSync(plan.tomlPath, '[hooks.state."rpc-partial"]\ntrusted_hash = "changed"\n')
|
||||
throw new Error('post-write transport failure')
|
||||
})
|
||||
|
||||
expect(grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'fallback', reason: 'error' })
|
||||
expect(readFileSync(plan.tomlPath, 'utf8')).toBe(original)
|
||||
})
|
||||
|
||||
it('removes an RPC-created config before fallback when none existed', () => {
|
||||
const entries = [managedEntry('session_start')]
|
||||
const plan = buildPlan(entries)
|
||||
mkdirSync(runtimeHomeDir, { recursive: true })
|
||||
_internals.setGrantSessionRunnerSync(() => {
|
||||
writeFileSync(plan.tomlPath, '[hooks.state."rpc-partial"]\ntrusted_hash = "changed"\n')
|
||||
return { outcome: 'verify-failed', reason: 'post-write listing failed' }
|
||||
})
|
||||
|
||||
expect(grantManagedCodexHookTrust(plan)).toMatchObject({
|
||||
lane: 'fallback',
|
||||
reason: 'verify-failed'
|
||||
})
|
||||
expect(existsSync(plan.tomlPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('honors the ops kill switch env flag', () => {
|
||||
process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1'
|
||||
const entries = [managedEntry('session_start')]
|
||||
|
||||
@@ -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
|
||||
@@ -26,20 +20,20 @@ import {
|
||||
type CodexTrustEntry
|
||||
} 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
|
||||
|
||||
/** 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
|
||||
@@ -58,6 +52,7 @@ export type CodexTrustGrantFallbackReason =
|
||||
| 'unsupported'
|
||||
| 'unsupported-cached'
|
||||
| 'verify-failed'
|
||||
| 'retry-cached'
|
||||
| 'error'
|
||||
|
||||
export type CodexManagedTrustGrantOutcome =
|
||||
@@ -79,6 +74,7 @@ const diagnostics: CodexTrustGrantDiagnostics = {
|
||||
verifyFailed: 0,
|
||||
lastFallbackReason: null
|
||||
}
|
||||
const transientRetryAfterByHost = new Map<string, number>()
|
||||
|
||||
export function getCodexTrustGrantDiagnostics(): CodexTrustGrantDiagnostics {
|
||||
return { ...diagnostics }
|
||||
@@ -99,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
|
||||
@@ -119,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
|
||||
@@ -141,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>
|
||||
@@ -181,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
|
||||
@@ -233,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
|
||||
@@ -244,42 +207,91 @@ export function grantManagedCodexHookTrust(
|
||||
if (!codexAppServerCapabilityCache.shouldTry(hostKey)) {
|
||||
return fallback(plan, 'unsupported-cached')
|
||||
}
|
||||
const transientRetryAfter = transientRetryAfterByHost.get(hostKey)
|
||||
if (transientRetryAfter !== undefined) {
|
||||
if (Date.now() < transientRetryAfter) {
|
||||
return fallback(plan, 'retry-cached')
|
||||
}
|
||||
transientRetryAfterByHost.delete(hostKey)
|
||||
}
|
||||
|
||||
const startedAtMs = Date.now()
|
||||
// Why: the RPC may rewrite config.toml before a later RPC fails. Restore
|
||||
// its exact pre-session bytes before the legacy lane runs so every fallback
|
||||
// has the same input and output as the pre-RPC implementation.
|
||||
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)) {
|
||||
transientRetryAfterByHost.delete(hostKey)
|
||||
codexAppServerCapabilityCache.rememberUnsupported(hostKey)
|
||||
return fallback(plan, 'unsupported', error)
|
||||
}
|
||||
transientRetryAfterByHost.set(
|
||||
hostKey,
|
||||
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
|
||||
)
|
||||
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') {
|
||||
restoreCodexTrustConfig(plan.tomlPath, configSnapshot)
|
||||
transientRetryAfterByHost.set(
|
||||
hostKey,
|
||||
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
|
||||
)
|
||||
return fallback(plan, 'verify-failed', result.reason)
|
||||
}
|
||||
|
||||
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) {
|
||||
const match = byNormalizedKey.get(granted.normalizedKey)
|
||||
if (!match) {
|
||||
restoreCodexTrustConfig(plan.tomlPath, configSnapshot)
|
||||
transientRetryAfterByHost.set(
|
||||
hostKey,
|
||||
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
|
||||
)
|
||||
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,
|
||||
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
|
||||
)
|
||||
return fallback(plan, 'verify-failed', 'granted entry set did not cover expected entries')
|
||||
}
|
||||
transientRetryAfterByHost.delete(hostKey)
|
||||
try {
|
||||
writeCodexTrustGrantLedgerHome(plan.runtimeHomePath, {
|
||||
binary: currentStamp,
|
||||
@@ -294,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)
|
||||
@@ -311,5 +323,6 @@ export const _internals = {
|
||||
diagnostics.fellBack = 0
|
||||
diagnostics.verifyFailed = 0
|
||||
diagnostics.lastFallbackReason = null
|
||||
transientRetryAfterByHost.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
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 {
|
||||
let descriptor: number
|
||||
try {
|
||||
descriptor = openSync(tomlPath, 'r')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { existed: false }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreCodexTrustConfig(
|
||||
tomlPath: string,
|
||||
snapshot: CodexTrustConfigSnapshot
|
||||
): void {
|
||||
if (!snapshot.existed) {
|
||||
try {
|
||||
unlinkSync(tomlPath)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -8,7 +8,10 @@ import { MANAGED_HOOK_TIMEOUT_SECONDS } from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
computeTrustKey,
|
||||
computeTrustedHash,
|
||||
normalizeHookTrustKeyForLookup,
|
||||
parseTrustKey,
|
||||
readHookTrustEntries,
|
||||
upsertHookTrustEntries,
|
||||
type CodexTrustEntry
|
||||
} from './config-toml-trust'
|
||||
import {
|
||||
@@ -16,6 +19,9 @@ import {
|
||||
createCodexWslRuntimeHookInstallPlan,
|
||||
type CodexWslRuntimeHookInstallPlan
|
||||
} from './hook-service'
|
||||
import type { CodexHookTrustGrantRequest } from './codex-app-server-client'
|
||||
import { codexAppServerCapabilityCache } from './codex-app-server-capability-cache'
|
||||
import { _internals as trustGrantInternals } from './codex-hook-trust-grant'
|
||||
|
||||
type HooksConfig = {
|
||||
hooks: Record<string, { hooks?: { command?: string }[] }[]>
|
||||
@@ -414,3 +420,158 @@ describe('Codex WSL runtime hook install', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Codex WSL runtime hook install app-server grant lane', () => {
|
||||
let userDataDir: string
|
||||
let previousUserDataPath: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
userDataDir = mkdtempSync(join(tmpdir(), 'orca-wsl-grant-userdata-'))
|
||||
tempRoots.push(userDataDir)
|
||||
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
|
||||
process.env.ORCA_USER_DATA_PATH = userDataDir
|
||||
trustGrantInternals.resetDiagnostics()
|
||||
codexAppServerCapabilityCache.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
trustGrantInternals.setGrantSessionRunnerSync(null)
|
||||
trustGrantInternals.resetDiagnostics()
|
||||
codexAppServerCapabilityCache.clear()
|
||||
if (previousUserDataPath === undefined) {
|
||||
delete process.env.ORCA_USER_DATA_PATH
|
||||
} else {
|
||||
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
|
||||
}
|
||||
})
|
||||
|
||||
it('grants WSL managed trust through codex inside the distro instead of self-computed writes', () => {
|
||||
const plan = createTestPlan()
|
||||
writeFileSync(plan.configPath, '{"hooks":{}}\n', 'utf-8')
|
||||
writeFileSync(plan.tomlPath, '', 'utf-8')
|
||||
|
||||
const runner = vi.fn((request: CodexHookTrustGrantRequest) => {
|
||||
// Simulate codex's side: write trusted_hash blocks the way its config
|
||||
// writer would, then report the entries trusted.
|
||||
upsertHookTrustEntries(
|
||||
plan.tomlPath,
|
||||
request.expectedTrustKeys.map((key) => ({
|
||||
sourcePath: plan.trustConfigPath,
|
||||
eventLabel: key.split(':').at(-3) as CodexTrustEntry['eventLabel'],
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: request.managedCommand,
|
||||
trustedHash: `sha256:codex-${key.split(':').at(-3)}`
|
||||
}))
|
||||
)
|
||||
return {
|
||||
outcome: 'granted' as const,
|
||||
wroteTrust: true,
|
||||
entries: request.expectedTrustKeys.map((key) => ({
|
||||
key,
|
||||
normalizedKey: normalizeHookTrustKeyForLookup(key),
|
||||
trustedHash: `sha256:codex-${key.split(':').at(-3)}`
|
||||
}))
|
||||
}
|
||||
})
|
||||
trustGrantInternals.setGrantSessionRunnerSync(runner)
|
||||
|
||||
expect(_internals.installManagedHooksIntoWslRuntime(plan).state).toBe('installed')
|
||||
|
||||
expect(runner).toHaveBeenCalledTimes(1)
|
||||
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.hooksListCwd).toBe(plan.linuxRuntimeHome)
|
||||
|
||||
const command = expectedManagedCommand(plan.commandScriptPath)
|
||||
const managedTrustEntry = getManagedTrustEntry(plan, command)
|
||||
const trustEntries = readHookTrustEntries(plan.tomlPath)
|
||||
// Why: the codex-granted hash must be authoritative — the self-computed
|
||||
// hash must not overwrite it after a successful grant.
|
||||
expect(trustEntries.get(computeTrustKey(managedTrustEntry))?.trustedHash).toBe(
|
||||
'sha256:codex-user_prompt_submit'
|
||||
)
|
||||
expect(trustEntries.get(computeTrustKey(managedTrustEntry))?.trustedHash).not.toBe(
|
||||
computeTrustedHash(managedTrustEntry)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the unchanged self-computed lane when the WSL grant falls back', () => {
|
||||
const plan = createTestPlan()
|
||||
writeFileSync(plan.configPath, '{"hooks":{}}\n', 'utf-8')
|
||||
writeFileSync(plan.tomlPath, '', 'utf-8')
|
||||
|
||||
const runner = vi.fn(() => {
|
||||
throw new Error('wsl.exe not reachable')
|
||||
})
|
||||
trustGrantInternals.setGrantSessionRunnerSync(runner)
|
||||
|
||||
expect(_internals.installManagedHooksIntoWslRuntime(plan).state).toBe('installed')
|
||||
|
||||
expect(runner).toHaveBeenCalledTimes(1)
|
||||
const command = expectedManagedCommand(plan.commandScriptPath)
|
||||
const managedTrustEntry = getManagedTrustEntry(plan, command)
|
||||
expect(readHookTrustEntries(plan.tomlPath).get(computeTrustKey(managedTrustEntry))).toEqual({
|
||||
enabled: true,
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,189 +1454,3 @@ 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
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
+51
-163
@@ -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.
|
||||
@@ -671,78 +674,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
|
||||
): 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])
|
||||
)
|
||||
// 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)
|
||||
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.
|
||||
@@ -752,46 +693,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)
|
||||
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.
|
||||
@@ -801,50 +710,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)
|
||||
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 {
|
||||
@@ -995,15 +873,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.
|
||||
@@ -1217,7 +1103,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[] = []
|
||||
@@ -1264,9 +1152,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)) {
|
||||
|
||||
@@ -457,6 +457,7 @@ const codexTrustGrantSchema = z
|
||||
'unsupported',
|
||||
'unsupported-cached',
|
||||
'verify-failed',
|
||||
'retry-cached',
|
||||
'error'
|
||||
])
|
||||
.optional()
|
||||
|
||||
Reference in New Issue
Block a user