mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
fix(codex): harden trust grant reconciliation
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user