Files
orca/config/build-plugins/plain-node-entry-guard.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

281 lines
10 KiB
TypeScript

import { spawn } from 'node:child_process'
import { join } from 'node:path'
import type { Plugin, Rollup } from 'vite'
type NormalizedInputOptions = Rollup.NormalizedInputOptions
type NormalizedOutputOptions = Rollup.NormalizedOutputOptions
type OutputBundle = Rollup.OutputBundle
type OutputChunk = Rollup.OutputChunk
// Why: v1.4.129-rc.1 shipped a dead terminal daemon because a shared main
// chunk gained `require("electron")` (an import edge added in #7642), and the
// daemon is forked as a plain-Node process where electron cannot be required.
// Nothing in CI executes the built daemon-entry under plain Node, so the leak
// stayed invisible until an adopted old daemon died. This guard fails the
// build when any chunk reachable from a plain-Node fork entry requires
// electron, and smoke-loads daemon-entry under plain Node to prove its module
// graph still resolves.
// Entries executed as plain Node (ELECTRON_RUN_AS_NODE / no electron runtime):
// forked daemon, parcel-watcher, WSL filesystem and computer sidecars, and the CLI-run
// agent-hooks entry. require("electron") throws MODULE_NOT_FOUND in all of them.
const PLAIN_NODE_ENTRY_NAMES = [
'daemon-entry',
'parcel-watcher-process-entry',
'computer-sidecar',
'wsl-transcript-fs-process-entry',
'agent-hooks/managed-agent-hook-controls'
] as const
// Entries executed as worker threads of the main process. Electron's module is
// not registered on worker threads, so require("electron") throws
// "Cannot find module 'electron'" there too (verified on Electron 43) and kills
// the worker at startup. These carry hand-written "must stay electron-free"
// comments, which is convention, not enforcement — and the port-scan worker in
// particular sits one import away from a client module that deliberately does
// require electron.
const WORKER_THREAD_ENTRY_NAMES = [
'stt-worker',
'warp-theme-parser-worker',
'session-scanner-opencode-sqlite-worker-entry',
'session-scanner-worker-entry',
'main-thread-hang-watchdog-entry',
'port-scan-command-worker-entry'
] as const
export const GUARDED_ENTRY_NAMES = [
...PLAIN_NODE_ENTRY_NAMES,
...WORKER_THREAD_ENTRY_NAMES
] as const
type EntryRuntime = 'plain-Node process' | 'worker thread'
// Subpaths (electron/main) are as unloadable as the bare module under plain Node.
const ELECTRON_REQUIRE_RE = /require\(\s*["'`]electron(?:\/[^"'`]+)?["'`]\s*\)/
// Why: writeBundle skips any name missing from the bundle, so a renamed or
// removed rollup input would silently drop that entry from the guard and let the
// regression back in. Pin the lists to the input keys at build start instead.
function assertEntryNamesAreRollupInputs(input: NormalizedInputOptions['input']): void {
if (typeof input === 'string' || Array.isArray(input)) {
return
}
const inputNames = new Set(Object.keys(input))
const missing = GUARDED_ENTRY_NAMES.filter((name) => !inputNames.has(name))
if (missing.length > 0) {
throw new Error(
`[plain-node-entry-guard] guarded ${missing.map((name) => `"${name}"`).join(', ')} ` +
`${missing.length === 1 ? 'is not a rollup input' : 'are not rollup inputs'} anymore. ` +
`Update PLAIN_NODE_ENTRY_NAMES/WORKER_THREAD_ENTRY_NAMES in plain-node-entry-guard.ts to ` +
`the current entry names — a stale name silently stops guarding that entry.`
)
}
}
function collectReachableChunks(
entry: OutputChunk,
byFileName: Map<string, OutputChunk>
): OutputChunk[] {
const seen = new Set<string>()
const reachable: OutputChunk[] = []
const stack = [entry.fileName]
while (stack.length > 0) {
const fileName = stack.pop() as string
if (seen.has(fileName)) {
continue
}
seen.add(fileName)
const chunk = byFileName.get(fileName)
if (!chunk) {
continue
}
reachable.push(chunk)
for (const imported of [...chunk.imports, ...chunk.dynamicImports]) {
stack.push(imported)
}
}
return reachable
}
function assertNoElectronRequire(
entryName: string,
entry: OutputChunk,
byFileName: Map<string, OutputChunk>,
runtime: EntryRuntime = 'plain-Node process'
): void {
for (const chunk of collectReachableChunks(entry, byFileName)) {
if (ELECTRON_REQUIRE_RE.test(chunk.code)) {
throw new Error(
`[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` +
`requires electron. "${entryName}" runs as a ${runtime}, where ` +
`require("electron") throws MODULE_NOT_FOUND and kills it at startup (the ` +
`v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.`
)
}
}
}
// Owned by the argv parser in src/main/daemon/daemon-entry.ts — keep in sync.
const DAEMON_USAGE_PREFIX = 'Usage: daemon-entry'
export type SmokeTimings = {
timeoutMs: number
// daemon-entry traps SIGTERM and awaits a native shutdown, so the deadline
// needs an uncatchable follow-up to stay a deadline.
killGraceMs: number
}
const DEFAULT_SMOKE_TIMINGS: SmokeTimings = { timeoutMs: 15_000, killGraceMs: 2_000 }
// Bound the wait for stderr to flush after exit; a grandchild inheriting stdio
// can hold the pipes open long after the child is gone.
const SMOKE_STDERR_DRAIN_MS = 250
type SmokeResult = {
status: number | null
signal: NodeJS.Signals | null
stderr: string
error?: Error
timedOut: boolean
}
// Why not spawnSync({ timeout }): its timeout only sends killSignal and then
// keeps blocking until the child exits, so a child that traps SIGTERM hangs the
// build forever. Escalate to SIGKILL instead.
function runDaemonEntry(entryPath: string, timings: SmokeTimings): Promise<SmokeResult> {
return new Promise((resolve) => {
const child = spawn(process.execPath, [entryPath], { stdio: ['ignore', 'ignore', 'pipe'] })
let stderr = ''
let timedOut = false
let settled = false
let forceKillTimer: NodeJS.Timeout | undefined
let drainTimer: NodeJS.Timeout | undefined
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk: string) => {
stderr += chunk
})
const deadlineTimer = setTimeout(() => {
timedOut = true
child.kill('SIGTERM')
forceKillTimer = setTimeout(() => child.kill('SIGKILL'), timings.killGraceMs)
}, timings.timeoutMs)
const finish = (status: number | null, signal: NodeJS.Signals | null, error?: Error): void => {
if (settled) {
return
}
settled = true
clearTimeout(deadlineTimer)
clearTimeout(forceKillTimer)
clearTimeout(drainTimer)
resolve({ status, signal, stderr, error, timedOut })
}
child.on('error', (error: Error) => finish(null, null, error))
// 'close' gives the full stderr; 'exit' is the fallback so a held-open pipe
// cannot outlast the process itself.
child.on('close', (status, signal) => finish(status, signal))
child.on('exit', (status, signal) => {
drainTimer = setTimeout(() => finish(status, signal), SMOKE_STDERR_DRAIN_MS)
})
})
}
// Why: proves the whole daemon-entry graph resolves under plain Node (no
// unresolved requires). require("electron") does not throw in a dev tree with
// node_modules present, so the static scan above — not this smoke — is the
// electron regression guard; this only catches gross load failures.
async function smokeLoadDaemonEntry(outputDir: string, timings: SmokeTimings): Promise<void> {
const entryPath = join(outputDir, 'daemon-entry.js')
const result = await runDaemonEntry(entryPath, timings)
if (result.error) {
throw new Error(
`[plain-node-entry-guard] could not smoke-load daemon-entry.js under plain Node: ` +
`${result.error.message}`
)
}
// Almost always means the daemon stopped rejecting an empty argv and started
// listening instead.
if (result.timedOut) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js did not exit within ${timings.timeoutMs}ms on an ` +
`empty argv under plain Node, so the smoke killed it.`
)
}
if (result.signal) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js was killed by ${result.signal} under plain Node.`
)
}
const stderr = result.stderr
if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js failed to load under plain Node:\n${stderr}`
)
}
if (result.status === 0 || !stderr.includes(DAEMON_USAGE_PREFIX)) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js did not reject an empty argv under plain Node ` +
`(expected a non-zero exit and the "${DAEMON_USAGE_PREFIX}" error, got exit ` +
`${result.status}). stderr:\n${stderr}`
)
}
}
export function createPlainNodeEntryGuardPlugin(
smokeTimings: SmokeTimings = DEFAULT_SMOKE_TIMINGS
): Plugin {
let daemonOutputDir: string | undefined
return {
name: 'orca-plain-node-entry-guard',
buildStart(options: NormalizedInputOptions) {
assertEntryNamesAreRollupInputs(options.input)
},
writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) {
// Why: skip in `electron-vite dev` watch mode — the smoke would respawn on
// every rebuild, and the guard only needs to gate produced builds.
if (this.meta.watchMode) {
return
}
const chunks = Object.values(bundle).filter(
(item): item is OutputChunk => item.type === 'chunk'
)
const byFileName = new Map(chunks.map((chunk) => [chunk.fileName, chunk]))
const entryByName = new Map<string, OutputChunk>()
for (const chunk of chunks) {
if (chunk.isEntry && chunk.name) {
entryByName.set(chunk.name, chunk)
}
}
for (const entryName of PLAIN_NODE_ENTRY_NAMES) {
const entry = entryByName.get(entryName)
if (entry) {
assertNoElectronRequire(entryName, entry, byFileName, 'plain-Node process')
}
}
for (const entryName of WORKER_THREAD_ENTRY_NAMES) {
const entry = entryByName.get(entryName)
if (entry) {
assertNoElectronRequire(entryName, entry, byFileName, 'worker thread')
}
}
if (entryByName.has('daemon-entry') && options.dir) {
daemonOutputDir = options.dir
}
},
async closeBundle() {
if (daemonOutputDir) {
const outputDir = daemonOutputDir
daemonOutputDir = undefined
await smokeLoadDaemonEntry(outputDir, smokeTimings)
}
}
}
}