Files
orca/src/main/codex/codex-trust-grant-host.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

155 lines
5.5 KiB
TypeScript

import { runProcess } from '../../shared/child-process/run-process'
import { resolveCodexCommand } from '../codex-cli/command'
import { getSpawnArgsForWindows } from '../win32-utils'
import {
buildWslCodexAppServerArgs,
buildWslCodexIdentityProbe,
WSL_CODEX_AVAILABILITY_TIMEOUT_MS
} 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[]
useDefaultCodexHome?: boolean
}
export type ResolvedCodexTrustGrantHost = {
binaryStamp: CodexTrustGrantBinaryStamp | null
buildRequest: (input: CodexTrustGrantRequestInput) => CodexHookTrustGrantRequest
}
/**
* Resolves the host that runs the codex binary for a grant session.
*
* Async because the WSL identity probe shells into the distro; #16441 measured
* a 15s main-thread stall when launch prep did this work synchronously.
*/
export async function resolveCodexTrustGrantHost(
host: CodexTrustGrantHost
): Promise<ResolvedCodexTrustGrantHost> {
if (host.kind === 'wsl') {
return {
binaryStamp: await buildWslCodexBinaryStamp(host.distro),
buildRequest: (input) => ({
invocation: {
command: 'wsl.exe',
// Why null: the guest resolves `codex` inside the distro, so a host path pairs nothing.
cliPath: null,
args: buildWslCodexAppServerArgs(host.distro, host.linuxRuntimeHome),
timeoutMs: WSL_GRANT_TIMEOUT_MS
},
hooksListCwd: host.linuxRuntimeHome,
expectedTrustKeys: input.expectedTrustKeys,
managedCommand: input.managedCommand
})
}
}
return resolveNativeCodexTrustGrantHost()
}
export function resolveNativeCodexTrustGrantHost(): ResolvedCodexTrustGrantHost {
// 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'])
const useDefaultCodexHome = input.useDefaultCodexHome === true
return {
invocation: {
command: spawnCmd,
args: spawnArgs,
cliPath: command,
...(useDefaultCodexHome
? { envToDelete: ['CODEX_HOME'] }
: { env: { CODEX_HOME: input.runtimeHomePath } }),
timeoutMs: NATIVE_GRANT_TIMEOUT_MS
},
hooksListCwd: input.runtimeHomePath,
expectedTrustKeys: input.expectedTrustKeys,
managedCommand: input.managedCommand
}
}
}
}
async function buildWslCodexBinaryStamp(
distro: string
): Promise<CodexTrustGrantBinaryStamp | null> {
try {
// Why: WSL PATH resolution happens inside the distro's login shell. The
// resolved path plus CLI version detects upgrades without assuming UNC access.
const probe = buildWslCodexIdentityProbe(distro)
const result = await runProcess({
program: 'wsl.exe',
args: probe.args,
timeoutMs: WSL_CODEX_AVAILABILITY_TIMEOUT_MS
})
if (result.code !== 0 || result.timedOut) {
return null
}
// Why: the split below is positional, so login-shell rc output ahead of the
// payload would silently become the "path" and destabilize the stamp.
const output = probe.readStdout(result.stdout)
if (output === null) {
return null
}
const lineBreak = output.indexOf('\n')
const path = lineBreak === -1 ? '' : output.slice(0, lineBreak).trim()
const version = lineBreak === -1 ? '' : output.slice(lineBreak + 1).trim()
return path && version ? { kind: 'wsl', distro, path, version } : null
} catch {
return null
}
}
export function readCodexTrustGrantLedgerHomeMatchingStamp(
runtimeHomePath: string,
currentStamp: CodexTrustGrantBinaryStamp | null
): CodexTrustGrantLedgerHome | null {
const home = readCodexTrustGrantLedgerHome(runtimeHomePath)
return home && binaryStampsMatch(home.binary, currentStamp) ? home : null
}
/** Native-only: the WSL stamp needs a subprocess, and status reads must stay
* synchronous for the hook-status readers that never target a distro. */
export function readCurrentNativeCodexTrustGrantLedgerHome(
runtimeHomePath: string
): CodexTrustGrantLedgerHome | null {
try {
const home = readCodexTrustGrantLedgerHome(runtimeHomePath)
if (!home) {
// Why: fallback-only installs have no ledger. Avoid a synchronous PATH
// and version-manager scan when there is no recorded stamp to validate.
return null
}
return binaryStampsMatch(home.binary, resolveNativeCodexTrustGrantHost().binaryStamp)
? home
: null
} catch {
// Why: status is diagnostic and best-effort; unreadable ledger/binary
// paths must trigger conservative self-hash handling, not throw.
return null
}
}