Files
orca/src/main/text-generation/commit-message-agent-environment.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

148 lines
5.3 KiB
TypeScript

import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
import { applyClaudeEnvPatch } from '../claude-accounts/environment'
import { readShellStartupEnvVar } from '../pty/shell-startup-env'
import { parseWslUncPath } from '../../shared/wsl-paths'
export type CommitMessageAgentEnvironmentResolvers = {
prepareForCodexLaunch?: (
target?: CommitMessageAgentRuntimeTarget
) => string | null | Promise<string | null>
prepareForClaudeLaunch?: (
target?: CommitMessageAgentRuntimeTarget
) => Promise<ClaudeRuntimeAuthPreparation>
}
export type CommitMessageAgentRuntimeTarget = {
runtime?: 'host' | 'wsl'
wslDistro?: string | null
}
function cloneProcessEnv(): Record<string, string> {
const env: Record<string, string> = {}
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined) {
env[key] = value
}
}
return env
}
// Why: with system-default real-home routing, the headless Codex commit run
// must use the user's own ~/.codex. If Orca itself was launched from a nested
// Orca terminal it can inherit an Orca-owned CODEX_HOME override; strip only
// that (CODEX_HOME matching the private ORCA_CODEX_HOME marker), preserving a
// user-set CODEX_HOME.
function cloneProcessEnvWithoutOrcaCodexHomeOverride(): Record<string, string> {
const env = cloneProcessEnv()
if (env.ORCA_CODEX_HOME && env.CODEX_HOME === env.ORCA_CODEX_HOME) {
delete env.CODEX_HOME
}
delete env.ORCA_CODEX_HOME
return env
}
function readInheritedOrShellEnvVar(name: string, sourceName?: string): string | undefined {
return (
(sourceName ? process.env[sourceName] : undefined) ??
process.env[name] ??
readShellStartupEnvVar(name, process.env.HOME, process.env.SHELL)
)
}
function prepareShellConfigDirEnv(agentId: string): { ok: true; env?: NodeJS.ProcessEnv } | null {
const configVar =
agentId === 'opencode'
? 'OPENCODE_CONFIG_DIR'
: agentId === 'pi' || agentId === 'omp'
? 'PI_CODING_AGENT_DIR'
: agentId === 'grok'
? 'GROK_HOME'
: null
if (!configVar) {
return null
}
// Why: each kind owns a distinct ORCA_*_SOURCE_* shadow so a headless commit
// run from inside a legacy OMP overlay restores the OMP source dir, never
// the Pi one (and vice versa). PI_CODING_AGENT_DIR is the binary-facing var
// both kinds consume — see src/main/pi/titlebar-extension-service.ts.
const sourceVar =
agentId === 'opencode'
? 'ORCA_OPENCODE_SOURCE_CONFIG_DIR'
: agentId === 'pi'
? 'ORCA_PI_SOURCE_AGENT_DIR'
: agentId === 'omp'
? 'ORCA_OMP_SOURCE_AGENT_DIR'
: undefined
const value = readInheritedOrShellEnvVar(configVar, sourceVar)
if (!value) {
return { ok: true }
}
// Why: GUI-launched Orca may not inherit shell startup exports, but these
// vars point the headless CLI at the user's auth/config root. Nested Orca
// launches inherit PTY overlays, so prefer ORCA_*_SOURCE_* when present.
return { ok: true, env: { ...cloneProcessEnv(), [configVar]: value } }
}
export async function prepareLocalCommitMessageAgentEnv(
agentId: string,
resolvers: CommitMessageAgentEnvironmentResolvers | undefined,
target?: CommitMessageAgentRuntimeTarget
): Promise<{ ok: true; env?: NodeJS.ProcessEnv } | { ok: false; error: string }> {
// Why: a non-null result short-circuits the resolvers below, so any agent added
// to prepareShellConfigDirEnv must not also need a Codex/Claude-style resolver.
const shellConfigEnv = target?.runtime === 'wsl' ? null : prepareShellConfigDirEnv(agentId)
if (shellConfigEnv) {
return shellConfigEnv
}
if (!resolvers) {
return { ok: true }
}
try {
if (agentId === 'codex' && resolvers.prepareForCodexLaunch) {
const codexHomePath = await resolvers.prepareForCodexLaunch(target)
const wslCodexHome = codexHomePath ? parseWslUncPath(codexHomePath) : null
if (target?.runtime === 'wsl') {
const codexHomeForTarget = wslCodexHome?.linuxPath ?? null
// Why: the fallback must still strip Orca-owned overrides, or a
// system-default WSL run inherits the managed CODEX_HOME.
return {
ok: true,
env: codexHomeForTarget
? { ...cloneProcessEnvWithoutOrcaCodexHomeOverride(), CODEX_HOME: codexHomeForTarget }
: cloneProcessEnvWithoutOrcaCodexHomeOverride()
}
}
if (codexHomePath && wslCodexHome) {
// Why: this local generation path spawns the host Codex binary. A WSL
// managed home is only valid when the process is routed through wsl.exe.
return { ok: true }
}
return {
ok: true,
env: codexHomePath
? { ...cloneProcessEnv(), CODEX_HOME: codexHomePath }
: cloneProcessEnvWithoutOrcaCodexHomeOverride()
}
}
if (agentId === 'claude' && resolvers.prepareForClaudeLaunch) {
const preparation = await resolvers.prepareForClaudeLaunch(target)
const env = applyClaudeEnvPatch(cloneProcessEnv(), preparation.envPatch, {
stripAuthEnv: preparation.stripAuthEnv
})
return { ok: true, env }
}
} catch (error) {
console.error('[commit-message] Failed to prepare agent environment:', error)
return {
ok: false,
error: 'Failed to prepare the selected agent account for commit message generation.'
}
}
return { ok: true }
}