Files
orca/src/main/codex/codex-app-server-client.ts
T
Neil 26721bd632 fix(codex): stop blocking the main thread on trust grants (#16441) (#16594)
* fix(codex): stop blocking the main thread on trust grants (#16441)

Codex hook trust was granted by blocking the Electron main thread on
`spawnSync` of a bundled ELECTRON_RUN_AS_NODE entry for the whole
app-server deadline: 15s native, 35s WSL, ~45s on the real-home path
(rebase inspect + repair + grant). Cold start and every Codex pane
launch showed "Not Responding"; the reported event-loop gap was
15,049 ms.

The subprocess only ever existed to donate an event loop to a
deliberately blocked parent — `runCodexHookTrustGrantSession` was
already the real async implementation. Make the callers async and the
fork is unnecessary, so the bridge, the forked entry and its envelope
are deleted along with their build/knip/tsconfig registrations. The CLI
`agent hooks prepare-codex` handler is already async, so it awaits the
in-process session and saves a process spawn per managed-home shell.

`resolveCodexTrustGrantHost` is async too; the WSL identity probe moves
from `execFileSync` to `runProcess`, dropping that file from the
child-process import allowlist. Status reads keep a synchronous
native-only stamp path.

Two invariants that held only because the lane blocked:

- Overlapping capability probes were impossible by construction.
  `GitCapabilityCache`'s dedupe engine is extracted to a shared
  `CapabilityProbeCache` and `CodexAppServerCapabilityCache` now
  inherits it, so concurrent launches against a cold host share one
  app-server session instead of one each.
- Two grants on one `config.toml` could not interleave capture and
  restore. A reentrant per-file lane now serializes the whole install
  sequence (managed, WSL runtime, real-home ensure, legacy sweep) and
  the grant and rebase inside it.

Cold-start work moves off the critical path: retained-home
reconciliation (N sequential sessions) is fire-and-forget behind the
daemon provider, and the startup real-home ensure chains into managed
hook reconciliation instead of blocking app init.

Every preserved semantic is unchanged: never throws, the
ORCA_DISABLE_CODEX_TRUST_RPC kill switch, ledger hits, backfill-pending
and cooldown fallbacks, config rollback on every failure path,
pre-grant self-computed trust removal, the verify-failure taxonomy,
diagnostics and telemetry.

* fix(codex): widen the trust-config lane to every config.toml writer

Review follow-ups on #16441's async trust grant:

- `markCodexProjectTrusted` now runs inside the runtime+system config.toml
  lanes, so a project-trust write can no longer land inside a hook grant's
  capture->restore window and be silently reverted. Its callers await it.
- `install`/`refreshRuntimeUserHooks`/`remove` hold the system config.toml
  lane as well as the runtime one — they promote approvals into
  ~/.codex/config.toml and mirror it back. Lock order is runtime-before-system
  everywhere.
- The real-home ensure chain resumes after a rejection instead of returning
  the same rejected promise to every later pane launch, and resolving the real
  home is now inside the module's never-throws boundary.
- `buildSpawnEnv` awaits inside a cancelable pending-spawn registration, so
  shutdown during the (now long) env build stops the PTY from launching.
  `prepareLocalPtySpawn` generalizes into `awaitCancelableLocalPtySpawn`.
- CapabilityProbeCache drops the test-only `nowMs` passthrough; its probe
  backstop comment now describes what it actually guards.
- Preflight is a plain async function; the trust dispatch in orca-runtime
  collapses into one `markWorkspaceTrustedForAgent`.

* test(codex): exercise the trust-config lane under real concurrency

The async grant makes two pane launches overlap for the first time. These
drive the real modules end to end on real files: a rollback swallowing a
sibling's grant, a markCodexProjectTrusted write landing inside a capture
-> restore window, shared capability-probe dedupe on a cold host, the
host-scoped transient cooldown, and reentrancy from inside an installer.

Each was verified to fail against a deliberately broken implementation
(lane removed, dedupe disabled, cooldown made global, reentrancy pass-
through disabled).

* test(codex): stop hook-service suites spawning the developer's real codex

The forked grant bundle never existed under vitest, so the RPC lane was
unreachable in tests on main. Running it in-process makes these suites
spawn a real `codex app-server` when one is installed: 38 spawns and two
failures in hook-service-runtime-trust-repair on a machine with codex,
green in CI where there is none. Stand in for the missing binary so both
environments exercise the same fallback lane.

* docs(codex): scope the trust-RPC kill switch comment to what it actually gates

The comment read as though the flag forces the fallback lane everywhere. It
gates the managed grant only: the real-home rebase still runs its own
inspect/repair app-server sessions when Orca's insertion shifts a user's hook
positions, and never reads the flag.

Verified by exercise, not by reading — with the flag set, both
inspect-user-hook-trust and repair-user-hook-trust still ran. Pre-existing:
main has no check there either, it just blocked the main thread while doing it.

Widening the flag to cover the rebase is a follow-up; this only stops the
comment promising something the constant does not do.
2026-08-26 16:44:55 -07:00

193 lines
7.4 KiB
TypeScript

import { spawn } from 'node:child_process'
import { normalizeHookTrustKeyForLookup } from './config-toml-trust'
import { runCodexAppServerSession, type CodexAppServerInvocation } from './codex-app-server-session'
// Why: Codex gates hooks on a `trusted_hash` it computes from a private
// canonical-JSON identity. Orca used to replicate that algorithm
// (computeTrustedHash), which drifted from the real one across Codex releases
// (#7896, #7110, #8699). `codex app-server` exposes the same sanctioned RPCs
// the Codex TUI "Trust all" button uses — hooks/list (returns Codex's own
// currentHash per hook) and config/batchWrite (upserts hooks.state through
// Codex's comment-preserving writer) — so this client grants trust with
// Codex as the only hash authority. See upstream codex-rs/tui/src/hooks_rpc.rs
// and codex-rs/tui/src/startup_hooks_review.rs.
export {
CodexAppServerTimeoutError,
CodexAppServerUnsupportedError,
isCodexAppServerUnsupportedError,
type CodexAppServerInvocation
} from './codex-app-server-session'
export type CodexHookTrustGrantRequest = {
invocation: CodexAppServerInvocation
/** cwd passed to hooks/list. Discovery of the managed CODEX_HOME's
* hooks.json is cwd-independent (user scope); this only scopes which
* project hooks appear, which the key filter below ignores anyway. */
hooksListCwd: string
/** Lookup-normalized trust keys (normalizeHookTrustKeyForLookup shape) for
* the managed entries Orca just wrote. Grants are restricted to hooks whose
* reported key normalizes into this set — user hooks are never touched. */
expectedTrustKeys: string[]
/** Exact command string written to the managed hooks.json entries. */
managedCommand: string
}
export type CodexGrantedHookTrust = {
/** Trust key exactly as Codex reported it. */
key: string
normalizedKey: string
/** Codex-computed hash now stored as trusted_hash for this key. */
trustedHash: string
}
/** Closed verify-failure taxonomy, so telemetry never has to parse the
* free-form `reason` diagnostics string. */
export type CodexTrustGrantSessionVerifyClass =
| 'list-mismatch'
| 'post-grant-untrusted'
| 'post-grant-mismatch'
export type CodexHookTrustGrantSessionResult =
| {
outcome: 'granted'
entries: CodexGrantedHookTrust[]
/** False when every expected entry was already trusted (no write). */
wroteTrust: boolean
}
| { outcome: 'verify-failed'; reason: string; reasonClass: CodexTrustGrantSessionVerifyClass }
type CodexHookListing = {
key: string
command: string | null
currentHash: string
trustStatus: string
}
function collectHookListings(result: unknown): CodexHookListing[] {
const data =
result && typeof result === 'object' && Array.isArray((result as { data?: unknown }).data)
? ((result as { data: unknown[] }).data as { hooks?: unknown }[])
: []
const listings: CodexHookListing[] = []
const seenKeys = new Set<string>()
for (const entry of data) {
const hooks = Array.isArray(entry?.hooks) ? entry.hooks : []
for (const hook of hooks as Record<string, unknown>[]) {
if (
typeof hook?.key !== 'string' ||
typeof hook.currentHash !== 'string' ||
typeof hook.trustStatus !== 'string'
) {
continue
}
// Why: hooks/list repeats user-scope hooks per requested cwd; grants
// must consider each key once.
if (seenKeys.has(hook.key)) {
continue
}
seenKeys.add(hook.key)
listings.push({
key: hook.key,
command: typeof hook.command === 'string' ? hook.command : null,
currentHash: hook.currentHash,
trustStatus: hook.trustStatus
})
}
}
return listings
}
/**
* Runs one short-lived `codex app-server` session over stdio JSON-RPC (JSONL)
* and grants trust for exactly the expected managed entries:
* initialize → initialized → hooks/list → config/batchWrite → hooks/list.
*/
export async function runCodexHookTrustGrantSession(
request: CodexHookTrustGrantRequest,
spawnImpl: typeof spawn = spawn
): Promise<CodexHookTrustGrantSessionResult> {
return runCodexAppServerSession(
request.invocation,
async (rpc) => {
const expectedKeys = new Set(request.expectedTrustKeys)
const matchManaged = (listing: CodexHookListing): boolean =>
listing.command === request.managedCommand &&
expectedKeys.has(normalizeHookTrustKeyForLookup(listing.key))
const listResult = await rpc.request('hooks/list', { cwds: [request.hooksListCwd] })
const managedListings = collectHookListings(listResult).filter(matchManaged)
const managedKeyCoverage = normalizedKeyCoverage(managedListings)
if (
managedListings.length !== expectedKeys.size ||
!setContainsEvery(managedKeyCoverage, expectedKeys)
) {
return {
outcome: 'verify-failed',
reason: `hooks/list reported ${managedListings.length} entries covering ${managedKeyCoverage.size} of ${expectedKeys.size} expected managed entries`,
reasonClass: 'list-mismatch'
}
}
const needingTrust = managedListings.filter((listing) => listing.trustStatus !== 'trusted')
if (needingTrust.length > 0) {
// Why: same wire shape as the Codex TUI "Trust all" flow — one upsert
// edit under hooks.state with each key's Codex-computed current hash.
const value: Record<string, { trusted_hash: string }> = {}
for (const listing of needingTrust) {
value[listing.key] = { trusted_hash: listing.currentHash }
}
await rpc.request('config/batchWrite', {
edits: [{ keyPath: 'hooks.state', value, mergeStrategy: 'upsert' }],
reloadUserConfig: true
})
}
const verifyResult = await rpc.request('hooks/list', { cwds: [request.hooksListCwd] })
const verifiedListings = collectHookListings(verifyResult).filter(matchManaged)
const verifiedKeyCoverage = normalizedKeyCoverage(verifiedListings)
const untrusted = verifiedListings.filter((listing) => listing.trustStatus !== 'trusted')
if (
verifiedListings.length !== expectedKeys.size ||
!setContainsEvery(verifiedKeyCoverage, expectedKeys) ||
untrusted.length > 0
) {
return untrusted.length > 0
? {
outcome: 'verify-failed',
reason: `post-grant verify left ${untrusted.length} entries ${untrusted[0].trustStatus}`,
reasonClass: 'post-grant-untrusted'
}
: {
outcome: 'verify-failed',
reason: `post-grant verify reported ${verifiedListings.length} entries covering ${verifiedKeyCoverage.size} of ${expectedKeys.size} expected entries`,
reasonClass: 'post-grant-mismatch'
}
}
return {
outcome: 'granted',
wroteTrust: needingTrust.length > 0,
entries: verifiedListings.map((listing) => ({
key: listing.key,
normalizedKey: normalizeHookTrustKeyForLookup(listing.key),
trustedHash: listing.currentHash
}))
}
},
spawnImpl
)
}
function normalizedKeyCoverage(listings: readonly CodexHookListing[]): Set<string> {
return new Set(listings.map((listing) => normalizeHookTrustKeyForLookup(listing.key)))
}
function setContainsEvery(values: ReadonlySet<string>, expected: ReadonlySet<string>): boolean {
for (const value of expected) {
if (!values.has(value)) {
return false
}
}
return true
}