mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
Merge remote-tracking branch 'origin/brennanb2025/codex-trust-rpc-grant' into brennanb2025/codex-real-home-phase1
# Conflicts: # src/main/codex/hook-service-trust-grant.test.ts
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
"../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-process-exit-deadline.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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -90,6 +90,7 @@ function writeFileSyncSafe(file, contents) { require('node:fs').writeFileSync(fi
|
||||
let tempRoots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
for (const root of tempRoots) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
@@ -149,6 +150,8 @@ function managedHook(key: string, trustStatus = 'untrusted'): StubHook {
|
||||
|
||||
describe('runCodexHookTrustGrantSession', () => {
|
||||
it('grants and verifies exactly the expected managed entries', async () => {
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout')
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout')
|
||||
const keys = [
|
||||
'/home/a/.codex/hooks.json:session_start:0:0',
|
||||
'/home/a/.codex/hooks.json:stop:0:0'
|
||||
@@ -191,6 +194,13 @@ describe('runCodexHookTrustGrantSession', () => {
|
||||
expect(written.edits[0].mergeStrategy).toBe('upsert')
|
||||
expect(Object.keys(written.edits[0].value).sort()).toEqual([...keys].sort())
|
||||
expect(written.reloadUserConfig).toBe(true)
|
||||
// Why: the entry process waits on these handles after setting exitCode, so
|
||||
// an uncleared grace timer adds its full delay to synchronous launch prep.
|
||||
const timerHandles = setTimeoutSpy.mock.results.map(({ value }) => value)
|
||||
expect(timerHandles).toHaveLength(2)
|
||||
expect(clearTimeoutSpy.mock.calls.map(([handle]) => handle)).toEqual(
|
||||
expect.arrayContaining(timerHandles)
|
||||
)
|
||||
})
|
||||
|
||||
it('skips config/batchWrite when every expected entry is already trusted', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { normalizeHookTrustKeyForLookup } from './config-toml-trust'
|
||||
import { waitForProcessExitUntil } from './codex-process-exit-deadline'
|
||||
|
||||
// Why: Codex gates hooks on a `trusted_hash` it computes from a private
|
||||
// canonical-JSON identity. Orca used to replicate that algorithm
|
||||
@@ -363,11 +364,10 @@ export async function runCodexHookTrustGrantSession(
|
||||
if (!exited) {
|
||||
// Why: the server exits promptly on stdin EOF; the grace period only
|
||||
// bounds a wedged child before the guaranteed SIGKILL reap.
|
||||
const grace = new Promise<void>((resolve) => setTimeout(resolve, 1500))
|
||||
await Promise.race([exitPromise, grace])
|
||||
await waitForProcessExitUntil(exitPromise, 1500)
|
||||
if (!exited) {
|
||||
child.kill('SIGKILL')
|
||||
await Promise.race([exitPromise, new Promise<void>((resolve) => setTimeout(resolve, 1000))])
|
||||
await waitForProcessExitUntil(exitPromise, 1000)
|
||||
}
|
||||
}
|
||||
clearTimeout(deadline)
|
||||
|
||||
@@ -268,6 +268,9 @@ describe('syncSystemConfigIntoManagedCodexHome', () => {
|
||||
[
|
||||
'model = "runtime-model"',
|
||||
'',
|
||||
'[hooks.state]',
|
||||
'# runtime-owned parent',
|
||||
'',
|
||||
'[hooks.state."runtime-hooks:stop:0:0"]',
|
||||
'enabled = false',
|
||||
'trusted_hash = "sha256:runtime"',
|
||||
@@ -286,6 +289,9 @@ describe('syncSystemConfigIntoManagedCodexHome', () => {
|
||||
[
|
||||
'model = "system-model"',
|
||||
'',
|
||||
'[hooks.state]',
|
||||
'# system-owned parent',
|
||||
'',
|
||||
'[projects."/repo"] # explicit revocation',
|
||||
'trust_level = "untrusted"',
|
||||
'',
|
||||
@@ -310,6 +316,8 @@ describe('syncSystemConfigIntoManagedCodexHome', () => {
|
||||
expect(runtimeConfig).toContain('[projects."/system-only"]')
|
||||
expect(runtimeConfig).toContain('[hooks.state."runtime-hooks:stop:0:0"]')
|
||||
expect(runtimeConfig).not.toContain('[hooks.state."system-hooks:stop:0:0"]')
|
||||
expect(runtimeConfig).toContain('# runtime-owned parent')
|
||||
expect(runtimeConfig).not.toContain('# system-owned parent')
|
||||
expect(runtimeConfig).toContain('trust_level = "untrusted"')
|
||||
expect(runtimeConfig.match(/\[projects\."\/repo"\]/g)?.length).toBe(1)
|
||||
})
|
||||
|
||||
@@ -275,7 +275,10 @@ function isRuntimePreservedTomlSection(header: string): boolean {
|
||||
}
|
||||
|
||||
function isRuntimeHookTrustTomlSection(header: string): boolean {
|
||||
return header.trimStart().startsWith('[hooks.state.')
|
||||
const trimmed = header.trim()
|
||||
// Why: Codex's config writer materializes the parent table on Windows. It is
|
||||
// part of runtime-owned trust and must survive the next config mirror too.
|
||||
return trimmed === '[hooks.state]' || trimmed.startsWith('[hooks.state.')
|
||||
}
|
||||
|
||||
function isRuntimeProjectTomlSection(header: string): boolean {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export async function waitForProcessExitUntil(
|
||||
exitPromise: Promise<void>,
|
||||
timeoutMs: number
|
||||
): Promise<void> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const timeout = new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, timeoutMs)
|
||||
})
|
||||
try {
|
||||
await Promise.race([exitPromise, timeout])
|
||||
} finally {
|
||||
// Why: this runs in a short-lived entry; a live grace timer delays the
|
||||
// parent spawnSync even after the app-server process has already exited.
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,12 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod
|
||||
import { tmpdir } from 'node:os'
|
||||
import type * as Os from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { wrapPosixHookCommand } from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
computeTrustKey,
|
||||
computeTrustedHash,
|
||||
escapeTomlString,
|
||||
parseTrustKey,
|
||||
readHookTrustEntries,
|
||||
upsertHookTrustEntries,
|
||||
@@ -72,6 +74,32 @@ afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Why: model codex's own config/batchWrite — exactly one
|
||||
// `[hooks.state."<key>"]` table per reported key, keyed verbatim, blank-line
|
||||
// separated (the shape the real 0.144.x binary writes). Orca's
|
||||
// upsertHookTrustEntries writes BOTH separator variants for a Windows key (a
|
||||
// fallback-lane compat shim real codex never does), which would fabricate
|
||||
// duplicate tables on win32 that the RPC path never produces.
|
||||
function writeCodexLikeTrust(configPath: string, entries: CodexTrustEntry[]): void {
|
||||
let content = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : ''
|
||||
if (!/^\[hooks\.state\][ \t]*$/m.test(content)) {
|
||||
const separator = content.length === 0 ? '' : content.endsWith('\n') ? '' : '\n'
|
||||
content += `${separator}[hooks.state]\n`
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const header = `[hooks.state."${escapeTomlString(computeTrustKey(entry))}"]`
|
||||
// Why: replace any existing table for this exact key so re-grants upgrade
|
||||
// in place instead of duplicating (mirrors codex's upsert merge strategy).
|
||||
const existingBlock = new RegExp(
|
||||
`(?:\\n)?${header.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\n(?:[^[\\n].*\\n?|\\n)*`,
|
||||
'g'
|
||||
)
|
||||
content = content.replace(existingBlock, '')
|
||||
content += `${content.endsWith('\n') ? '' : '\n'}\n${header}\ntrusted_hash = "${escapeTomlString(entry.trustedHash!)}"\n`
|
||||
}
|
||||
writeFileSync(configPath, content)
|
||||
}
|
||||
|
||||
function installCodexLikeGrantRunner(): ReturnType<typeof vi.fn> {
|
||||
const codexHash = (key: string): string =>
|
||||
`sha256:codex-${parseTrustKey(key)?.eventLabel ?? 'unknown'}`
|
||||
@@ -86,7 +114,7 @@ function installCodexLikeGrantRunner(): ReturnType<typeof vi.fn> {
|
||||
trustedHash: codexHash(key)
|
||||
}
|
||||
})
|
||||
upsertHookTrustEntries(join(codexHome!, 'config.toml'), entries)
|
||||
writeCodexLikeTrust(join(codexHome!, 'config.toml'), entries)
|
||||
return {
|
||||
outcome: 'granted' as const,
|
||||
wroteTrust: true,
|
||||
@@ -199,7 +227,7 @@ describe('CodexHookService app-server trust grant lane', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('upgrades self-computed trust in place without duplicate tables', () => {
|
||||
it('upgrades self-computed trust in place without duplicate logical entries', () => {
|
||||
prepareSystemHome()
|
||||
const service = new CodexHookService()
|
||||
process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1'
|
||||
@@ -210,6 +238,9 @@ describe('CodexHookService app-server trust grant lane', () => {
|
||||
|
||||
expect(service.install().state).toBe('installed')
|
||||
const upgraded = readFileSync(join(managedHome, 'config.toml'), 'utf-8')
|
||||
// Why: the legacy Windows fallback intentionally writes slash variants;
|
||||
// duplicate detection is about the normalized trust identity.
|
||||
const upgradedEntries = readHookTrustEntries(join(managedHome, 'config.toml'))
|
||||
for (const eventLabel of [
|
||||
'session_start',
|
||||
'user_prompt_submit',
|
||||
@@ -218,13 +249,12 @@ describe('CodexHookService app-server trust grant lane', () => {
|
||||
'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)
|
||||
const count = [...upgradedEntries.keys()].filter((key) =>
|
||||
key.endsWith(`:${eventLabel}:0:0`)
|
||||
).length
|
||||
expect(count, `duplicate trust entries for ${eventLabel}`).toBe(1)
|
||||
}
|
||||
expect(upgraded).toContain('sha256:codex-session_start')
|
||||
})
|
||||
|
||||
it('leaves user trust byte-untouched while granting managed entries', () => {
|
||||
|
||||
@@ -209,7 +209,8 @@ describe('Codex WSL runtime hook install', () => {
|
||||
settlement: { status: 'unavailable' },
|
||||
isCurrentGeneration: true,
|
||||
installedTrustConfigPath: '/mnt/d/home/hooks.json',
|
||||
resolvedTrustConfigPath: null
|
||||
resolvedTrustConfigPath: null,
|
||||
installSucceeded: false
|
||||
})
|
||||
).toBe('none')
|
||||
|
||||
@@ -218,7 +219,8 @@ describe('Codex WSL runtime hook install', () => {
|
||||
settlement: { status: 'missing' },
|
||||
isCurrentGeneration: false,
|
||||
installedTrustConfigPath: '/mnt/d/home/hooks.json',
|
||||
resolvedTrustConfigPath: null
|
||||
resolvedTrustConfigPath: null,
|
||||
installSucceeded: false
|
||||
})
|
||||
).toBe('none')
|
||||
|
||||
@@ -227,25 +229,42 @@ describe('Codex WSL runtime hook install', () => {
|
||||
settlement: { status: 'missing' },
|
||||
isCurrentGeneration: true,
|
||||
installedTrustConfigPath: '/mnt/d/home/hooks.json',
|
||||
resolvedTrustConfigPath: null
|
||||
resolvedTrustConfigPath: null,
|
||||
installSucceeded: false
|
||||
})
|
||||
).toBe('remove')
|
||||
|
||||
// Why: a `missing` probe right after a verified grant is a false negative;
|
||||
// revoking would delete the fresh trust the launching pane reads (#8847).
|
||||
expect(
|
||||
_internals.getWslHookReconciliationAction({
|
||||
settlement: { status: 'resolved', canonicalPath: '/windows/d/home' },
|
||||
settlement: { status: 'missing' },
|
||||
isCurrentGeneration: true,
|
||||
installedTrustConfigPath: '/windows/d/home/hooks.json',
|
||||
resolvedTrustConfigPath: '/windows/d/home/hooks.json'
|
||||
installedTrustConfigPath: '/mnt/d/home/hooks.json',
|
||||
resolvedTrustConfigPath: null,
|
||||
installSucceeded: true
|
||||
})
|
||||
).toBe('none')
|
||||
|
||||
expect(
|
||||
_internals.getWslHookReconciliationAction({
|
||||
settlement: { status: 'resolved', canonicalPath: '/windows/d/home' },
|
||||
isCurrentGeneration: true,
|
||||
installedTrustConfigPath: '/windows/d/home/hooks.json',
|
||||
resolvedTrustConfigPath: '/windows/d/home/hooks.json',
|
||||
installSucceeded: true
|
||||
})
|
||||
).toBe('none')
|
||||
|
||||
// Why: a genuinely moved home resolves to a different path and still
|
||||
// reinstalls, even though the original install succeeded.
|
||||
expect(
|
||||
_internals.getWslHookReconciliationAction({
|
||||
settlement: { status: 'resolved', canonicalPath: '/windows/d/home' },
|
||||
isCurrentGeneration: true,
|
||||
installedTrustConfigPath: '/mnt/d/home/hooks.json',
|
||||
resolvedTrustConfigPath: '/windows/d/home/hooks.json'
|
||||
resolvedTrustConfigPath: '/windows/d/home/hooks.json',
|
||||
installSucceeded: true
|
||||
})
|
||||
).toBe('reinstall')
|
||||
})
|
||||
@@ -522,7 +541,11 @@ describe('Codex WSL runtime hook install app-server grant lane', () => {
|
||||
const basePlan = createTestPlan()
|
||||
writeFileSync(basePlan.configPath, '{"hooks":{}}\n', 'utf-8')
|
||||
writeFileSync(basePlan.tomlPath, '', 'utf-8')
|
||||
let staleKeyExpectedRemoved: string | null = null
|
||||
const runner = vi.fn((request: CodexHookTrustGrantRequest) => {
|
||||
if (staleKeyExpectedRemoved) {
|
||||
expect(readHookTrustEntries(basePlan.tomlPath).has(staleKeyExpectedRemoved)).toBe(false)
|
||||
}
|
||||
const entries = request.expectedTrustKeys.map((key) => {
|
||||
const parsed = parseTrustKey(key)!
|
||||
return {
|
||||
@@ -557,6 +580,7 @@ describe('Codex WSL runtime hook install app-server grant lane', () => {
|
||||
const oldKey = computeTrustKey(
|
||||
getManagedTrustEntry(oldPlan, expectedManagedCommand(oldPlan.commandScriptPath))
|
||||
)
|
||||
staleKeyExpectedRemoved = oldKey
|
||||
|
||||
const newPlan = {
|
||||
...basePlan,
|
||||
|
||||
@@ -904,6 +904,13 @@ function installManagedHooksIntoWslRuntime(
|
||||
// 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)
|
||||
// Why: Codex's verified RPC write must be the final config mutation. A
|
||||
// host-side rewrite after verification can race or invalidate that grant.
|
||||
removeStaleWslRuntimeManagedHookTrustEntries(
|
||||
plan.tomlPath,
|
||||
trustEntries,
|
||||
previousLedgerHome ? [previousLedgerHome] : []
|
||||
)
|
||||
const grant = grantManagedCodexHookTrust({
|
||||
runtimeHomePath,
|
||||
tomlPath: plan.tomlPath,
|
||||
@@ -911,17 +918,10 @@ function installManagedHooksIntoWslRuntime(
|
||||
managedEntries: trustEntries,
|
||||
host: { kind: 'wsl', distro: plan.wslDistro, linuxRuntimeHome: plan.linuxRuntimeHome }
|
||||
})
|
||||
if (grant.lane === 'rpc') {
|
||||
removeStaleWslRuntimeManagedHookTrustEntries(
|
||||
plan.tomlPath,
|
||||
grant.entries,
|
||||
previousLedgerHome ? [previousLedgerHome] : []
|
||||
)
|
||||
} else {
|
||||
if (grant.lane === 'fallback') {
|
||||
// Why: WSL runtime homes may carry user hook approvals we did not rebuild
|
||||
// here; only upsert Orca's entries instead of sweeping the whole source.
|
||||
upsertHookTrustEntries(plan.tomlPath, trustEntries)
|
||||
removeStaleWslRuntimeManagedHookTrustEntries(plan.tomlPath, trustEntries)
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -992,12 +992,19 @@ function getWslHookReconciliationAction(args: {
|
||||
isCurrentGeneration: boolean
|
||||
installedTrustConfigPath: string | null
|
||||
resolvedTrustConfigPath: string | null
|
||||
/** Whether the synchronous install for this generation wrote trust. */
|
||||
installSucceeded: boolean
|
||||
}): 'none' | 'remove' | 'reinstall' {
|
||||
if (!args.isCurrentGeneration) {
|
||||
return 'none'
|
||||
}
|
||||
if (args.settlement.status === 'missing') {
|
||||
return 'remove'
|
||||
// Why: a `missing` directory probe right after a verified install/grant is
|
||||
// a false negative — the RPC (or fallback) just wrote and read trust in
|
||||
// that home, so it exists. Revoking here would delete the fresh grant the
|
||||
// launching pane needs, resurfacing "hooks need review". A genuinely moved
|
||||
// home resolves to a different path and takes the `reinstall` branch below.
|
||||
return args.installSucceeded ? 'none' : 'remove'
|
||||
}
|
||||
if (
|
||||
args.settlement.status !== 'resolved' ||
|
||||
@@ -1034,6 +1041,10 @@ export class CodexHookService {
|
||||
): AgentHookInstallStatus | null {
|
||||
const generation = this.supersedeWslReconciliation(runtimeHomePath)
|
||||
let installedTrustConfigPath: string | null = null
|
||||
// Why: JS is single-threaded, so the synchronous install below finishes
|
||||
// before any async `wsl.exe` settlement callback runs — this flag is
|
||||
// always set by the time the callback reads it.
|
||||
let installSucceeded = false
|
||||
const onCanonicalPathSettled = (settlement: WslCanonicalPathSettlement): void => {
|
||||
if (!runtimeHomePath) {
|
||||
return
|
||||
@@ -1051,7 +1062,8 @@ export class CodexHookService {
|
||||
settlement,
|
||||
isCurrentGeneration: this.wslReconciliationGeneration.get(key) === generation,
|
||||
installedTrustConfigPath,
|
||||
resolvedTrustConfigPath: resolvedPlan?.trustConfigPath ?? null
|
||||
resolvedTrustConfigPath: resolvedPlan?.trustConfigPath ?? null,
|
||||
installSucceeded
|
||||
})
|
||||
if (action === 'none') {
|
||||
return
|
||||
@@ -1076,6 +1088,7 @@ export class CodexHookService {
|
||||
return
|
||||
}
|
||||
installedTrustConfigPath = resolvedPlan.trustConfigPath
|
||||
installSucceeded = status.state === 'installed'
|
||||
}
|
||||
const wslPlan = createCodexWslRuntimeHookInstallPlan(
|
||||
runtimeHomePath,
|
||||
@@ -1084,7 +1097,9 @@ export class CodexHookService {
|
||||
onCanonicalPathSettled
|
||||
)
|
||||
installedTrustConfigPath = wslPlan?.trustConfigPath ?? null
|
||||
return wslPlan ? installManagedHooksIntoWslRuntime(wslPlan) : null
|
||||
const status = wslPlan ? installManagedHooksIntoWslRuntime(wslPlan) : null
|
||||
installSucceeded = status?.state === 'installed'
|
||||
return status
|
||||
}
|
||||
|
||||
refreshRuntimeUserHooksForRuntimeHome(
|
||||
|
||||
Reference in New Issue
Block a user