mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 08:02:51 +00:00
* 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.
155 lines
5.5 KiB
TypeScript
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
|
|
}
|
|
}
|