mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +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.
193 lines
7.4 KiB
TypeScript
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
|
|
}
|