From a65332a8bdaec95a48cc0ade4a7c482586bb9370 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:55:20 -0700 Subject: [PATCH] feat(claude): move structured native chat onto the Claude Agent SDK and enable it on macOS and Linux (#18560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Join structured attach teardown through journal bind * fix: restore structured chat parity * feat: add Claude structured session adapter * fix: harden Claude structured adapter * fix: close Claude adapter edge cases * fix: start Claude init deadline after launch * feat: wire Claude structured sessions * fix: harden Claude structured runtime * fix: fence Claude structured compatibility * fix: preserve Claude free-text prompt answers * fix: decode addressed Claude prompt text * feat: enable Claude structured chat on mobile * fix(mobile): keep structured chat provider-aware * fix(mobile): negotiate Claude structured tabs * fix: keep scoped RPC tests native-free * fix: secure mobile structured image delivery * fix: close structured session data-loss gaps * fix: prove real Claude structured startup * fix: consume pre-spawn proof before retry * feat(native-chat): add desktop structured sessions * fix(native-chat): satisfy structured session cleanup gates * fix(native-chat): keep structured renders pure * fix(native-chat): open composer pickers upward * fix(native-chat): use existing view for structured sessions * fix: harden structured desktop status projection * fix: close structured desktop lifecycle gaps * fix: fence structured AI Vault resumes * fix: fence structured AI Vault resumes * fix: preserve structured tabs during activation * feat: toggle structured sessions between chat and TUI * fix: harden structured session handoffs * fix: bind structured TUI before rollout proof * fix: complete structured chat round trips * fix: align structured TUI return readiness * fix(native-chat): make reverse handoff transactional * Add Claude structured TUI handoff seams * fix(native-chat): clear sticky handoff recovery * fix(native-chat): complete mobile reverse after TUI exit * fix(native-chat): keep TUI transcripts readable * fix(native-chat): recover TUI transcript gaps * fix(native-chat): recover claimed TUI owners * fix(native-chat): retain cold TUI proof authority * fix(native-chat): preserve Claude handoff authority * fix(native-chat): recover TUI transcripts read-only * fix(native-chat): harden Claude handoff recovery * fix(native-chat): serialize structured handoff recovery * fix(native-chat): close handoff admission races * fix(native-chat): validate pinned launch environment * fix(native-chat): revalidate restored and retried owners * fix(native-chat): gate restart recovery publications * fix(i18n): catalog Claude session controls * fix(native-chat): wait for structured TUI process proof * fix(native-chat): queue stale idle TUI handoffs * fix(native-chat): route structured Codex options directly * fix(native-chat): persist structured session options * fix(native-chat): hydrate resumed structured options * fix(native-chat): preserve options across structured handoffs * fix(native-chat): replay pending option mutations * fix(native-chat): rotate settled handoff operations * fix(native-chat): rotate refused send operations * test(native-chat): derive refusal retry state from host * test(native-chat): give the host-oracle matrix test an explicit timeout * fix(native-chat): keep Claude option controls idle * fix mobile structured first-send hydration race * fix(native-chat): preserve handoff launch authority * fix(native-chat): harden shared handoff recovery * fix(native-chat): serialize structured handoff recovery * fix(native-chat): close handoff admission races * fix(native-chat): validate pinned launch environment * fix(native-chat): revalidate restored and retried owners * fix(native-chat): gate restart recovery publications * fix(i18n): catalog structured session recovery control * fix(native-chat): wait for structured TUI process proof * fix(native-chat): queue stale idle TUI handoffs * fix(native-chat): keep structured recovery provider-neutral * fix(native-chat): drop local terminal topology from structured sync * fix structured outbox and tab restore races * fix(native-chat): preserve Claude question groups * fix structured provider visibility and request handling * fix structured session TUI handoff recovery * fix reverse structured session handoff * fix(native-chat): recover Claude outbox and resume state * chore(mobile): preserve the working-tree lockfile state before the main merge Carries the pre-existing uncommitted mobile/pnpm-lock.yaml modification into history so the main merge cannot overwrite it. Verified benign pnpm drift (babel 7.29.7->7.29.8 transitives plus deprecation metadata); drops no patchedDependencies (the mobile lockfile declares none). * test(native-chat): drop orphaned Claude handoff-auth test left by the main merge 'pins Claude handoff auth through the terminal provider boundary' is absent from main and its production counterpart preserveClaudeAuthEnv no longer exists outside this test - orphaned residue of the terminal/native handoff work this PR excludes by scope. Removed rather than repaired: the failure was a renamed field (providerHome -> providerRoot), and renaming it would have carried out-of-scope handoff code into the merge. Body preserved as evidence and logged in CLAUDE-STRUCTURED-DISPOSITION-TABLE.md. * Fix mobile structured turn state * fix Claude structured session blockers * fix claude structured lane blockers * fix Claude acquisition exit proof * fix(claude): route stream-json launch through process wrapper * fix(claude): gate structured chat support * Fix Claude structured launch gating * fix(claude): split session acquisition and prune mobile scope * test(claude): align structured session fixtures * fix(agent-session): preserve handoff launch arguments * fix(claude): open journals through the factory after origin/main split The journal opener moved to journal-store-factory on main; retarget the Claude structured tests that still imported the old path. * fix(claude): resolve Claude structured launch args, auth, and win32 proof The origin/main merge re-expressed the lane's Claude wiring onto main's split orca-runtime facade and dropped three wires past green typecheck and lint. - resolveLaunchArgs discarded its provider parameter, so structured Claude sessions were launched with Codex app-server flags; Claude exits on --dangerously-bypass-approvals-and-sandbox, and a Codex arg-parse throw could block Claude session creation outright. - resolveClaudeLaunchEnv was no longer supplied, so the launch resolver fell back to the whole process env as configuredEnv and buildClaudeChildProcessEnv re-applied every auth var it had just stripped. The resolver now merges the Claude overlay onto a strip-applied copy of the inherited env, which also keeps PATH intact for withCliRuntimeOnPath. - The windowsProcessStartTimeAvailable producer was gone while the contract field and both consumers survived, so the renderer gate fail-closed and structured native chat was unreachable on every win32 host. Separately, structured Claude pinned CLAUDE_CONFIG_DIR unconditionally. An explicit pin makes the CLI abandon the macOS Keychain even when it names the CLI's own default, so a default claude.ai account could not authenticate where the legacy Claude terminal could. Pin only a home the CLI would not resolve on its own, matching ClaudeRuntimePathResolver, and compare against the env the child would otherwise inherit so a diverging overlay cannot outrank the record's account home. Also await the now-async revealNativeSession in its regression test, and set the native status before revealing so a rejecting reveal cannot leave a session released but never marked native. Claude-Session: https://claude.ai/code/session_013UqKCRB6k5e8UaYhXUHeWY * fix(claude): scrub case-insensitive Windows auth env * fix(native-chat): settle handoff outcome-write failures instead of leaking them A store write failure while recording a handoff outcome escaped the flow runner's catch handler, so the client never received the failure and the flow surfaced as an unhandled rejection (seen as an intermittent agent_session_store_corrupt error in the proven-dead-retry suite, whose teardown raced the flow's trailing outcome write). Record the failed outcome best-effort, and drain the coordinator before that test's teardown removes the store root. Claude-Session: https://claude.ai/code/session_011aXkcHyeiRJuezupQdjZaM * fix(native-chat): make the structured close-failure toast provider-neutral The structuredSessionCloseFailed toast fires for any structured session, but its copy said 'Codex chat', so a Claude structured session that fails to close showed the wrong provider name. The launch-failure toast is only reachable behind the agent === 'codex' gate, so its copy stays as is. Claude-Session: https://claude.ai/code/session_013ugSpCx4AWkySaJb69BQax * fix(native-chat): wire structured handoff proof recovery * fix(native-chat): wire structured handoff proof recovery * fix(native-chat): correct the structured chat opt-in copy The one `experimentalStructuredNativeChat` toggle gates both providers — `useStructuredAgentSessionCreate` runs `canUseStructuredNativeChat` for `'claude'` as well as `'codex'` — but its description named only Codex. Its scope line also said Windows keeps using terminal chat, while the gate refuses win32 only until the host proves it can read a process start time. `structured-native-chat-availability.test.ts` already pins that Windows is allowed once the proof is cached, so the two contradicted each other. Claude-Session: https://claude.ai/code/session_01RJFsidQWmKYFmeoUuVu4Tp * test(claude): pin @anthropic-ai/claude-agent-sdk 0.3.251 contracts against a scripted CLI PR 1 of the SDK migration: dependency + test-only harness, no product wiring. - Pin @anthropic-ai/claude-agent-sdk to exactly 0.3.251 — not the newest release — because 0.3.251 (published 2026-08-28) clears the repo's 3-day minimumReleaseAge supply-chain gate with no exclusion, while the newest release was minutes old and would have required excluding a brand-new publish from the exact control built to catch brand-new malicious publishes. Every contract this design depends on was verified identical on 0.3.251: the full option surface, no pid on SpawnedProcess (custom spawner stays mandatory), env defaulting to process.env when omitted, and --replay-user-messages appearing only via extraArgs. - Exclude all eight bundled CLI platform binaries via ignoredOptionalDependencies. The setting lives in pnpm-workspace.yaml because pnpm 12 no longer reads the package.json "pnpm" field (it warns and ignores it; verified by install ablation). Excluding the binaries is what makes Orca's pathToClaudeCodeExecutable override mandatory rather than merely preferred. Note: pnpm 12.0.0 honors the ignore list when reconciling an existing lockfile but not on fresh resolution of a new dependency, so the lockfile's SDK entry was pinned surgically; both 'pnpm install' and 'pnpm install --frozen-lockfile' verify clean and stable against the committed lockfile. - Contract-pin suite drives the real SDK against a scripted fake CLI and pins: unknown type/field/content-block pass-through (and keep_alive interception), spawner env fidelity plus the omitted-env process.env inheritance sharp edge, extraArgs producing --replay-user-messages, argument parity for every CLAUDE_STRUCTURED_BASE_ARGS entry plus --session-id/--resume/ --resume-session-at, canUseTool wire request_id stability and abort on control_cancel_request, one spawn per query, pathToClaudeCodeExecutable honored by the default spawner, the exact SDK version, and the eight platform binaries staying uninstalled. Claude-Session: https://claude.ai/code/session_01FGCRfYUnb4hbvfTAHGtJKQ * feat(claude): drive the structured transport through the agent SDK Replaces the hand-rolled `claude -p --input-format stream-json` transport with @anthropic-ai/claude-agent-sdk 0.3.251, keeping the existing connection interface for this commit so the acquisition path changes minimally. The control-plane rewrite is a separate change. Orca still supplies the process. `spawnClaudeCodeProcess` routes through `spawnProcess`, retains the child and its pid — the triple the durable lease adjudicates on — drains stderr so exit errors keep their tail, and hands `.cmd` shims to Orca's Windows argument encoder rather than the SDK's plain spawn. `close()` keeps Orca's own bounded tree-kill and exit deadline, so it still resolves true only after an observed exit. Launch resolution emits an SDK options object instead of argv; durable `launchArgs` translate to a typed option where one exists and to `extraArgs` otherwise, refusing a token neither can carry rather than dropping it. The child env is always passed explicitly — omitting it would let the SDK inherit `process.env` and reintroduce the ambient `ANTHROPIC_*` leak. The stdout line parser is deleted; the SDK owns framing, and unknown frames still reach the translator verbatim. Claude-Session: https://claude.ai/code/session_01JMhFjh9HEnkcJ5YTfCdgD3 * fix(claude): settle the frame the SDK pulled but never wrote The SDK's input pump is `for await (frame of prompt) { await transport.write(frame) }`. When that write rejects — the child dies between Orca's liveness guard and the write — the for-await ends abruptly and calls the generator's `return()`, so the code after `yield` never runs. The frame was already shift()ed out of `queued`, so the later `fail()` from the exit path could not reach it and `send()` never settled: `dispatchClaudeTurn` awaits that send before it can return `unknown`, wedging the caller and the durable outbox. The pre-SDK transport rejected on the stdin write callback instead. Retain the in-flight entry and settle it from the generator's cleanup, and let fail() reach it too for the pump that never resumes at all. Claude-Session: https://claude.ai/code/session_01AobxxokqQ3qcxS7sy7ckum * fix(claude): keep the agent SDK behind the structured-Claude boundary The ordinary OrcaRuntimeService graph statically reaches the Claude adapter and so the transport module, whose first line imported @anthropic-ai/claude-agent-sdk. The SDK is evaluated whenever the regular runtime loads, before any structured Claude session is chosen: it sets process.env.NoDefaultCurrentDirectoryInExePath, changing Windows executable resolution for later subprocesses, and a missing or incompatible install would break normal runtime startup — for a user who never leaves the terminal/TUI path. Defer the SDK to the connection, memoized so it loads once per process, and add the import-graph ratchet: a walk from the Electron main entry that fails on any static import of the package, plus a clean-fork check that loading the runtime leaves the Windows search variable untouched and a child-process pin that the side effect is still real. Claude-Session: https://claude.ai/code/session_01AobxxokqQ3qcxS7sy7ckum * fix(claude): answer list_models so the picker stops serving the seed sendControlRequest had no list_models case, so every request hit the default reject; readClaudeStructuredSessionOptions swallows that with .catch(() => null) and falls back to the static catalog. Every structured session therefore served a hardcoded model list with no per-model effort levels, no resolvedModel and no default detection, and nothing surfaced the failure. The pre-SDK transport got the live catalog from the CLI. Route it through the SDK's supportedModels(), wrapped in the { models } envelope the existing parser reads. Claude-Session: https://claude.ai/code/session_01AobxxokqQ3qcxS7sy7ckum * fix(claude): reap the child's descendants before killing it The forced step of the exit ladder went through the Codex helper, which spawns `pkill -KILL -P ` and SIGKILLs the parent in the same tick: the parent usually dies first, the descendants reparent to pid 1, and `-P` matches nothing. An MCP or launcher descendant of a stubborn Claude child was left running. The test named for that requirement declined to assert it and killed the survivor by hand instead, so it could not fail for the thing it was named after. Route the Claude reap through Orca's existing sweep, which snapshots descendants while their parent link still exists and signals them before the root goes, and on Windows uses the identity-gated `taskkill /T /F`. The test now asserts the descendant is dead; the manual kill stays only as a failure-safe. close() still returns true only on an observed exit. Claude-Session: https://claude.ai/code/session_01AobxxokqQ3qcxS7sy7ckum * fix(native-chat): merge the duplicated handoff type import CI's static-analysis lint (`oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings`) exits 1 on the two separate `import type` statements from the same module. Claude-Session: https://claude.ai/code/session_01AobxxokqQ3qcxS7sy7ckum * fix(claude): answer a permission callback whose signal already aborted settleFrom registered the abort listener and then delivered the request. A callback that arrives already aborted never fires that event, so the promise stayed pending behind a durable prompt with no cancel path. Check the signal first, emit the cancel, and resolve the SDK's null sentinel without registering. Claude-Session: https://claude.ai/code/session_01AobxxokqQ3qcxS7sy7ckum * test(claude): wait for the child to record the frame, not just for its report The scripted CLI writes its report at startup, so `until(readReport)` returned a report with no user messages whenever the child had not yet read the line. The assertion then failed under parallel load. Poll for the frame instead of for the file. Claude-Session: https://claude.ai/code/session_01AobxxokqQ3qcxS7sy7ckum * fix(claude): coalesce partial deltas onto one assistant item and stop painting result frames Under --include-partial-messages every stream_event frame carries its own uuid, and the final assistant frame for a block carries yet another; only message.id ties them. The translator keyed each delta by its frame uuid, so a reply painted as one bubble per delta chunk followed by a complete duplicate under the final frame's uuid. The block's first stream frame now mints the claude:(sessionId, uuid) identity, deltas coalesce onto it through the shared 60ms seam, and the final frame reconciles onto that same item. Known SDK bookkeeping no longer reaches the provider-fallback row: result subtypes are catalogued and settled by the turn lifecycle, an empty thinking block (redacted thinking) is a modeled kind, a string-content user replay is a text block, and an empty user frame paints nothing. An unmodeled result subtype or content kind still lands on the bounded fallback row. Claude-Session: https://claude.ai/code/session_01GaP5HpYQbvy2hYehVhwfEW * fix(claude): prove descendant exit at the close boundary instead of on an unref'd timer close() reported proven=true as soon as the direct child exited while the descendant sweep's SIGKILL sat on an unref'd 2 s timer, so a SIGTERM-resistant MCP server outlived the lease release. The reaper now composes the same shared primitives the Codex structured provider uses: snapshot, verified bounded descendant termination on POSIX, taskkill /T /F on Windows. The proof is false whenever descendants outlive the deadline, a retried close re-verifies the retained snapshot rather than trusting the dead root, and the raw pipe child no longer goes through the PTY job sweep it never owned a job for. Measured on macOS: a killed child of a SIGSTOPped parent stays a matching zombie row in ps, so the root is killed while verification runs rather than stopped first as the Codex non-group path does. Claude-Session: https://claude.ai/code/session_0161QFm3KVRNJKfdzWVGVNWk * feat(claude): replace the hand-rolled control plane with the SDK's native surface PR 3 of the Claude structured SDK migration removes the wire-frame scaffolding PR 2 kept, so Orca drives the SDK's typed control surface directly. Inbound permissions move from a rebuilt control_request dispatch to the SDK's canUseTool / onUserDialog callbacks. The prompt registry now carries the callback's own resolver: a decodable can_use_tool becomes a durable prompt whose answer settles the callback; a malformed one is denied without registering; the SDK's abort signal (fired on control_cancel_request, which the SDK matches and dedups itself) forgets the prompt and settles it null, and a late answer after abort finds no prompt and is refused. Closing settles every in-flight callback so no promise dangles. The claude-agent-sdk-control-bridge that rebuilt the wire frame is deleted. Outbound control maps to Query methods: interrupt() for cancel, setModel / setPermissionMode / applyFlagSettings for options, supportedModels for the model list, initializationResult() for init proof, each under Orca's own request deadline and error classification. Cancel is interrupt-receipt aware: a CLI advertising interrupt_cancel_queued_v1 gets cancel_queued in one round trip, otherwise the receipt's still_queued uuids are swept with cancel_async_message so a cancelled turn cannot spawn a later unexpected turn; older CLIs resolve no receipt. Init keeps the 10s deadline and the unauthenticated-startup guidance. Every behavior is failing-first and ablation-proven; the toggle-off import boundary and the accepted loss of unknown-control visibility rows are unchanged. Claude-Session: https://claude.ai/code/session_01Pqjduxt5G4rr9aYvtp7rNm * fix(claude): arm the descendant snapshot before stdin closes and make the tree verdict unproven by default A healthy Claude root leaves within the graceful window, and the close ladder only snapshotted descendants when the root was still alive after that window. So the common close never looked at the tree: `treeExited` stayed null, `!== false` passed it, and close() reported a proven exit with an MCP child still running. A root that died before the walk made the snapshot vacuous too. The proof is now unproven by default. The reaper holds one verdict in Orca's vocabulary (exited / live / unverifiable), assigned in exactly one place from the bounded verification, and close() returns true only on `exited`. The snapshot is armed before stdin closes, while the root can still be walked, and is verified after the root exits; a root that left before any snapshot could be armed stays unverifiable rather than vouching for descendants it never showed us. The shared verifier gains the three-way verdict behind its boolean face, and the connection reports the root and tree verdicts separately along with the child's exit status. One verification per close attempt: the retried close re-verifies, so the intra-attempt re-reap is gone from the teardown budget. Claude-Session: https://claude.ai/code/session_01BSmXgkWSsNHft8jFkdBFG9 * fix(claude): verify the Windows tree after taskkill instead of trusting that it ran `terminateWindowsProcessTree` resolves from taskkill's callback whatever the error says, so a timeout, an access denial, a recycled root and a surviving descendant all looked identical to the reaper — which then returned a proven exit unconditionally. close() reported true and the lease was released with an MCP descendant potentially still live. The Windows branch now snapshots the root's descendants while it is alive and, after taskkill, polls a fresh process table to a bounded deadline: a row still matching by pid AND creation time is `live`, an unreadable table is `unverifiable`, and only a table with no match is `exited`. Creation time is the PID-reuse guard the POSIX path gets from ps lstart, so a descendant that denied a creation-time query is omitted rather than signalled on a bare pid. A root already observed exited is never taskkilled: `/T /F` on a recycled pid would take an unrelated tree down with it. The captured tree is tagged by platform so neither verifier can be handed the other's rows. Claude-Session: https://claude.ai/code/session_01BSmXgkWSsNHft8jFkdBFG9 * fix(claude): release a reservation on a first-hand root exit instead of latching it into manual recovery Making close() strict about the descendant tree exposed a second defect at the same boundary. A create-time acquisition has no ownerProcess until publication, so an unproven cleanup mapped to handoffStage `manual-recovery`, and adjudication then refuses every later attach with agent_session_ownership_unknown. A user who was merely signed out, or whose --resume the CLI rejected, wedged the session id permanently. Each question now answers from its own evidence. close() is unchanged and stays strict about the tree. Separately, the lease is keyed on the root's pid and start time, so when Orca's own child handle observed that root exit and no descendant snapshot was ever admissible, the reservation is released and the CLI's exit code and stderr reach the user. A descendant observed still alive, or a root Orca never saw leave, stays unproven and keeps the reservation. The settlement records only what was observed: the released lease says the provider process exited and its descendants were not verifiable, rather than reusing the wording that claims cleanup proved no child remains. Claude-Session: https://claude.ai/code/session_01BSmXgkWSsNHft8jFkdBFG9 * fix(claude): surface an API error a result frame reports instead of settling the turn on it The SDK models an API failure as a SUCCESS-subtype result whose `result` string is the user-facing error text, with no assistant frame behind it. The translator suppressed every catalogued result subtype as turn bookkeeping, so that turn tombstoned its lifecycle and showed the user a completed, empty reply with no sign anything had failed. Suppression is now by meaning. A result reporting a failure routes to the bounded provider-error surface, leading with the provider's own sentence and keeping the raw frame behind the row's disclosure; ordinary successful results stay off the timeline as before. A turn the user aborted also stays suppressed: its interrupt frame already says so, and its execution diagnostic would only be noise on every stop. Claude-Session: https://claude.ai/code/session_01BSmXgkWSsNHft8jFkdBFG9 * fix(claude): drop the stream state of turns that never received their final frame Every streamed delta recorded its block's identity, latest text and checkpoint length. Only the final assistant frame removed them, so an interrupted turn left its whole accumulated reply reachable until the session was disposed, and a long session with repeated interruptions grew those maps without bound. The partial text was already journaled by the flush that precedes settlement, so the live copy was pure retention. That state now lives in its own module, named for what it does — grow a streamed block's journal row between its deltas and its final frame — and turn settlement drops every block still awaiting a final. The translator reports how many remain, which is the invariant: a settled turn leaves none. Also makes a timed-out process-table read retryable while the root is still alive. A loaded host can miss the table's one-second deadline, and latching that as "no descendants" both lost the descendant sweep and, on a busy machine, made the close ladder report unproven for a tree it never actually looked at. Only the root's death still makes a missing snapshot final. Claude-Session: https://claude.ai/code/session_01BSmXgkWSsNHft8jFkdBFG9 * perf(claude): capture the Windows descendant tree from one process-table read The capture walked the descendant tree and then read the table again for the creation times the walk's projection drops. Each read is bounded in seconds and both run inside the close ladder's budget, so the second one cost the worst-case teardown three seconds for data the first read already held. The walk is now exported from the module that owns it and runs over rows the caller has already read, which is also what lets the snapshot keep the PID-reuse guard the projection cannot carry. Claude-Session: https://claude.ai/code/session_01BSmXgkWSsNHft8jFkdBFG9 * fix(pty): spend the descendant verification window instead of surrendering on one slow table read The verification abandoned the whole check the first time a process-table read missed its own one-second deadline, with seconds of its window still unspent. On a loaded host that reported a tree unverifiable without ever having looked at it, which the Claude close ladder then turned into an unproven close and a retried teardown. It also made the descendant-exit tests flake under a parallel suite run, for the same reason and with the same honest-but-premature verdict. A read that missed its deadline is now simply not an answer: the loop waits and reads again until its own deadline, and only a window that ends without a readable table reports unverifiable. This can only turn a premature verdict into one backed by evidence; it never manufactures a proof. Claude-Session: https://claude.ai/code/session_01BSmXgkWSsNHft8jFkdBFG9 * fix(claude): never let a later failed look collapse an observed live descendant into unverifiable The reaper's single assignment site latched only 'exited', so a second reap whose table reads all missed their deadline overwrote an earlier completed verification's 'live' with 'unverifiable'. The acquisition release gate discriminates on exactly that pair, so a root exit after such a decay released the lease over a descendant that had been observed alive. The latch is now monotone in trust order: exited is final, and live is only ever raised to exited. Claude-Session: https://claude.ai/code/session_01HfdhsvSJucLw4cTZxzg2CP * fix(claude): never prove a Windows tree gone while a descendant denied identification The Windows snapshot dropped rows that denied the creation-time query, and an emptied snapshot was judged exited without any table read: a descendant Orca was refused information about was treated as one that had left. The snapshot now counts the unidentified rows it saw, and verification caps its verdict at unverifiable while any exist. Nothing is ever signalled on a bare pid, as before. Claude-Session: https://claude.ai/code/session_01HfdhsvSJucLw4cTZxzg2CP * fix(claude): classify cleanup after a first-hand exit as a root exit instead of a proven tree When the CLI died between a successful acquire and the host's commit or proof of the lease, handleExit had already removed the session, so releaseAcquisition found nothing and reported true. The attach flow then settled exit-proven with deathEvidence claiming cleanup proved no provider child remains, though the tree was never verified. The adapter now keeps the exit that removed a published session until the session is acquired again; acquisition cleanup runs that connection's close ladder and classifies its verdict exactly as a start-time failure would be, so the record reads root-exit-observed. The wire helper keeps that typed classification and its provider diagnostic instead of wrapping it as unproven, and the router gives up its owner even when the release throws. Claude-Session: https://claude.ai/code/session_01HfdhsvSJucLw4cTZxzg2CP * fix(claude): integrate SDK teardown and picker lifecycle fixes * fix(claude): preserve resume leaf and settle processless spawns * fix(claude): reacquire from persisted resume leaf * fix(native-chat): restore Claude grouped question handling * fix(claude): persist only resumable transcript leaves * fix(claude): recover structured session exits safely * fix(claude): close remaining structured session P1s * fix(claude): harden transcript branch proof * Remove superseded root fix reports * fix(windows): restore indexed descendant row walk * fix(router): forward force-close lifecycle * fix(claude): fence stale turn cancellations * fix(claude): fence cancellation after unknown dispatch * fix(claude): fence replay and option recovery races * fix(claude): block replay fallback after waiter eviction * fix(claude): fence evicted slash results * fix(claude): fence ambiguous results and restore options safely * fix(claude): scrub SDK child env and localize pending launch * fix(claude): pin transcript roots and exit recovery proofs * fix(claude): retain unproven SDK exits * fix(claude): settle retained exit before reacquire * fix(claude): resume from settled retained cursor * chore: remove tracked review artifact * fix: harden Claude SDK transport session cleanup * fix: close Claude sessions safely * fix(claude): close races with fresh child snapshots * fix(claude): fail closed on recycled child identities * fix(claude): gate root cleanup on process identity * fix(claude): fence same-second root identity reuse * fix(claude): restore the root SIGKILL fallback the identity gate took away The direct root kill goes through the handle Node owns, not through a pid: libuv drops that handle in the same turn it reaps, so the signal either reaches the process Orca spawned or reaches nothing at all. Gating it on a process-table probe therefore bought no safety and cost the tree its only fallback whenever the probe declined -- a first capture landing in the fork's own second, a recycled descendant pid voiding the snapshot, or a process table that could not be read on either platform. Identity verification stays where a bare pid is genuinely addressed: Windows `taskkill /T /F`, and the descendant sweep's own revalidation before it signals. Also stops a declined root probe from collapsing an observed `live` or `exited` descendant verdict into `unverifiable`, and stops a successful taskkill from reporting `unverifiable` because a later probe found the root correctly dead. * docs(claude): rewrap the root-kill ordering comment * Match the Claude structured launch to the terminal path's managed-account auth rules The SDK path stripped ambient Anthropic auth unconditionally, let an explicit agentDefaultEnv override beat a pinned managed account, and had no account-switch guard. Reuse the terminal preflight's own predicate and messages so both transports strip, refuse, and report identically, and cover the CLI transcript location that mobile native chat depends on. * Reach the Claude structured chat lane from the desktop UI The main process has had a complete, correctly gated Claude Agent SDK lane for a while, but no renderer ever asked for it: the launch route accepted only `codex`, and the create path was typed `agent: 'codex'` end to end. Widen both to the structured provider union that already exists (`AgentSessionHandleProvider`), and generalize the codex-named create path instead of adding a Claude twin beside it. The pending-launch registry is now keyed by agent as well as workspace — a shared key handed a second caller the first agent's intent, so a Claude and a Codex launch in one worktree collided. Windows, per agent. Codex's client-side win32 refusal is deliberate and settled elsewhere, so it stays exactly as it was. Claude's answer is no longer guessed from the client's platform: a structured session fences its provider child on that child's process start time, and only the executing host knows whether it can read one. `agentSession.createSupport` already answers precisely that, per agent, and had no renderer caller — so the Claude create path asks it before creating and turns a "no", or a probe it cannot get answered, into the definitive refusal the launch fallback already handles. Fail closed either way. That refusal mapping also closes a real gap: the host reports an unsupported location by throwing `structured_agent_session_unsupported`, which reaches the client as a transport rejection rather than a refusal envelope, so `StructuredAgentSessionCreateRefusalError` never fired. The launch would retry the create, strand itself in `visibilityUnknown`, run no legacy fallback, and show an error toast. Close a fail-open hole while Claude and win32 become reachable: `create` with a client-supplied location, and `ensure`, both skip the worktree-resolving support check. They now ask the executing host the same question directly, so a host that cannot fence a provider child no longer creates one on a client's say-so. Also deletes `structured-agent-session-provider-routing.ts`, a duplicate of `structured-agent-session-provider-support.ts` with no importers. WSL, SSH and paired hosts, floating workspaces, draft prompt delivery, explicit TUI customization and initial session options all keep refusing; folder workspaces keep working. * P1-1: make the structured Claude auth policy required and testable The optional dep plus a {stripAuthEnv:false} fallback meant a dropped wiring under-stripped silently. Required at all three hops, asserted at install time for the @ts-nocheck caller, and the settings-to-policy mapping is now a named tested function. * P2-3: mobile's default Claude transcript root must follow CLAUDE_CONFIG_DIR session-file-resolver's default ignored the variable the pinned account home follows, so a CLAUDE_CONFIG_DIR launch wrote one tree and mobile read another. The Task-4 test now resolves with no root override (mobile's own call) and checks the answer against the root the CLI itself reports, instead of mirroring the code under test's own expression. * P2-1/P2-2/P3: close the teardown window, join the live-auth gate, align the refusal P2-1: a switch beginning inside the acquire teardown left a dead chat and no replacement. Past that point the launch waits the swap out and refuses only if it never settles; the entry guard still refuses outright, because nothing is torn down there yet. P2-2: structured children now hold the same OAuth-refresh gate a Claude PTY does, so a managed refresh cannot rotate the token out from under a live turn. P3: the refusal now matches the strip it guards (case-folded on win32, presence not truthiness), and the dead structured-to-TUI builder states its auth policy instead of silently signing a system-auth user out. * Make the live-auth gate tests independent of sibling connection teardown order * Do not offer structured Claude under a WSL-only managed account Structured Claude launches against the ambient Claude config, which the account service keeps in sync with the selected HOST account. A WSL-bound managed account lives inside the distro and is never synced there, so on Windows a structured session would authenticate as whatever the ambient identity happens to be while the UI names the WSL account — the user is told one identity and given another. That was unreachable only because nothing offered structured Claude on win32. Enabling it makes it reachable, so gate it here rather than patching the auth layer: refuse the structured path when the active managed Claude account is WSL-bound, and let the terminal-backed path — which resolves the account per runtime — handle that account shape. The answer rides the agentSession.createSupport seam the renderer already consumes, so no new capability and no renderer knowledge of account internals. A create the host declines becomes the definitive refusal the launch fallback already turns into a legacy native chat tab, with no error toast. Unknown answers refuse. An install with no managed accounts claims no identity and is fine, but an active selection that cannot be resolved — or account state that cannot be read at all — is not evidence that the ambient identity is right. Claude only. Codex resolves its account through a different path and its createSupport answer is untouched, as is every Codex routing decision. * Read the structured Claude account gate through the auth policy's accessor The gate resolved the active account from the account-service snapshot's runtime map; the auth policy resolves it with getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }). Those are two sources and two resolution rules, and they disagree on a legacy settings blob that carries the selection only in the flat activeClaudeManagedAccountId: the accessor falls through to it, a direct read of the runtime map does not. The gate would then refuse a launch the policy would have run under host-1 — and in the mirror case a session could be admitted under a policy computed from a different account than the gate approved. Read the same settings through the same accessor so agreement is structural rather than coincidental, and drop the controller accessor that existed only to reach the snapshot. No behaviour change for any state both already agreed on; Codex is untouched. * Round-3 review fixes: N-1 empty-value regression, N-2 gate leak window, N-4 lost history N-1: my presence-based conflict predicate refused a terminal launch that works today. 'ANTHROPIC_API_KEY=' is how a user blanks a variable and the settings pipeline preserves that empty value; an empty override cannot beat the pinned account and the strip removes the name anyway. Back to truthiness for the value, keeping the win32 case folding. N-2: enter the live-auth gate only after the exit/close handlers that release it, so no throw in between can leave an entry nothing reconciles. N-4: the Claude transcript resolver searches config-dir-then-default and de-dupes, matching the Codex sibling in the same file, so adopting CLAUDE_CONFIG_DIR no longer hides history written before it. * Run the managed-account gate on every Claude acquisition, not just create createSupport gates the create path, but a session's account state can change while it lives. A reacquire after an unexpected child exit re-resolves the launch and re-derives auth, with nothing re-checking the gate — so a session created while supported could come back up in the refused shape. With the strip predicate keyed on there being an active non-WSL account, the WSL-only user's normalized steady state (accounts exist, none active) does not strip, and that reacquire reaches the child with ambient auth while the UI names the account. Gate at resolveLaunch, the one choke point every acquisition passes through, refusing with the pre-spawn error the caller already handles. Same predicate as create-time, now sharing one settings reader so the two cannot drift. Claude only; Codex resolves its account on a different path and is untouched. The runtime class that wires this does not typecheck its own `this` calls — a missing hookup compiles clean — so the wiring is pinned behaviourally rather than trusted to the compiler. * Move the structured Claude gate out of the @ts-nocheck runtime files Both call sites of the managed-account gate sat in files whose first line is `// @ts-nocheck`, so neither was typechecked: three arguments to a one-argument function plus an undeclared identifier compiled clean. New auth-identity decision logic had no compiler behind it. Move the verdict into a checked module that takes the two facts the runtime owns — the adapter's answer and a settings getter — and decides. The runtime class now only forwards. Move the gate reader's construction into the checked installer too, so the nocheck file passes a plain settings closure and never names a gate symbol. Every reference to the gate predicate and its reader now lives in a checked file, so the ablation that used to pass silently is a compile error at both the create-support and reacquire sites. Removing the file-level @ts-nocheck is a separate, larger job and is not attempted here. * Derive the gate test's auth policy from the settings under test A hardcoded stripAuthEnv asserts a gate/policy pairing production cannot produce, and false additionally lets launch.env inherit the runner's real process.env. Derive via claudeStructuredAuthPolicyForSettings instead: the gate settings type is the same Pick the policy takes, and both resolve the account through getSelectedClaudeAccountIdForTarget. * Pin the absent-vs-empty distinction in the managed-account gate An empty claudeManagedAccounts array is a real answer: the user has no managed accounts, nothing claims an identity, and the ambient path is legitimate. A readable settings object with no such field is settings we failed to parse — the same unknown as unreadable — so it refuses. The two are one character apart in the code and the difference is invisible without the reasoning, so record it at the branch and pin both sides. The test fails under the obvious "consistency fix" of treating a missing field as empty. * fix(claude): keep command queue bookkeeping out of the transcript Claude Code 2.1.258 emits a `command_lifecycle` frame for every uuid-stamped command it starts, completes or cancels. The frame carries a command uuid and a state and no content, and the CLI keeps it out of its own transcript -- but it is absent from the SDK's SDKMessage union and so from Orca's frame catalogue, where an uncatalogued kind defaults to a substantive row. Every structured turn therefore painted raw JSON rows into the user-visible transcript. Catalogue it and disposition it as status chrome. The unknown-kind default stays `timeline-substantive`: a kind we have never seen is likelier to carry content than to be chrome, and a visible row we can catalogue later beats content we silently dropped. A lifecycle state that reads as a failure still surfaces, because the payload error check in `classifyProviderFrame` outranks the catalogue. * fix(claude): let a re-walked descendant become eligible for the forced sweep A descendant first observed by a capture inside its own birth second could never be SIGKILLed: `ps lstart` is second-resolution, so that capture cannot rule out a pid recycled later in the same second, and the merge pinned each retained row to the boundary of the walk that first saw it. SIGTERM-resistant children forked in that window were signalled and then never escalated -- they survived close, quit and restart, reparented to init, and had to be killed by hand. Advancing that boundary on any later capture would be unsound: a later capture matching pid, pgid and start-second is exactly what an impostor would also show. But a capture is not a match -- it is a fresh ppid walk from a root Node pins through its own handle, so a row it re-derives is proved ours at that instant without appealing to its start time. Chain the fence from there instead, and take that walk at the close boundary while the root certainly still lives: the root may leave inside the grace window, and the post-timeout refresh never runs. A row absent from the later walk still keeps its earlier boundary, and a row no walk has ever re-derived in a later second is still never escalated. * Treat an absent managed-account list as empty, not as unreadable An empty claudeManagedAccounts array and a missing one are the same answer: this user has no managed Claude accounts, so nothing claims an identity and ambient auth is the truth. Refusing on absence strands any profile that simply never wrote the key, and it disagrees with the auth policy, whose own predicate takes `(accounts ?? [])` for exactly this reason. Only settings that cannot be READ stay unknown, and those still refuse — as do a WSL-bound active account and a selection naming an account the list does not explain. The earlier reasoning treated a missing field as settings we failed to parse. That conflated "not present" with "not readable"; only the second is unknown. * Support structured Claude when accounts are registered but none is selected Registered-but-deselected Claude accounts were refused, which is behaviourally identical to having no accounts at all: the auth policy does not strip, ambient auth is the truth, and the UI names no host identity. A user who deselected their accounts silently got legacy chat with nothing explaining why. Nothing selected for the host runtime is two states the settings cannot tell apart after the fact, because pruneInvalidClaudeRuntimeSelection empties the host slot and persists null in the second one: honest deselection -> ambient auth, UI names nothing -> SUPPORTED the WSL-only steady state -> ambient auth, UI names the WSL account -> REFUSED The presence of any WSL-bound account in the list decides. Simplifying this to "none active -> supported" re-opens the auth-identity misrepresentation, so the tests fail loudly on exactly that: five of them, across the unit rule and the createSupport path. * Stop treating an unanswerable create-support probe as a refusal A worktree is not resolvable for a beat after createWorktree resolves, so a probe fired immediately after creation fails the RPC with selector_not_found instead of answering. The catch collapsed that into `supported = false`, so the composer refused and quietly built a terminal session — the gate never said no, it was never asked successfully. Elapsed time was the only input that decided whether a Claude launch went structured. "Could not answer" and "answered no" are different states and only the second is a verdict. Retry while the host cannot yet resolve the selector, with a bounded backoff that covers the measured window with margin, and keep refusing on the first ask for everything else. Fail-closed is unchanged: a probe that still cannot be answered when the budget is spent refuses. The retry is narrowed with the shared error-code matcher, which classifies a token that transports re-wrap into a longer message without matching prose that merely mentions it. Codex never probes, so this race has never been able to refuse a Codex launch — the race itself is identical for it. Recorded at the early return, because whoever gives Codex a probe inherits the bug. * fix(claude): fence the forced sweep on re-derivation, not on lstart's second A descendant forked in the same wall-clock second as every walk that sees it was signalled with SIGTERM and then never escalated, so a SIGTERM-resistant child survived tab close, app quit and a full relaunch. Two children of one parent 96ms apart across a second boundary took opposite paths. The leak predates this branch: it reproduces with the change reverted. `ps lstart` has one-second resolution, so a walk landing inside a row's birth second can never rule out a pid recycled later in that same second. But a walk is not a match: a ppid walk only reaches what the root actually parents, and the root is pinned by Node's own handle, so a row the walk re-derived is ours whatever second it was born in -- a stranger would have to have been forked into our tree, and then it is not a stranger. Fence the escalation on that. Rows a merge retained from an earlier walk are not re-derived and still answer to the start-time fence, which remains correct for them. Scoped to callers that revalidate identity before signalling, which is the Claude close path. Codex teardown reaches this same verifier and is unchanged; the argument holds there too, but widening it is its own deliberate change. Also reverts two changes from the previous attempt at this leak. Advancing the capture boundary on a later walk is inert once the sweep fences on re-derivation -- both key on the same set of rows, so the new term short-circuits for exactly the rows whose boundary it advanced. The extra ladder refresh was a duplicate full process-table read: close() already awaits tree.refresh() immediately before proveClaudeChildExit, on the only path that reaches it. Known property: the kill lands roughly a grace window after the walk that proved membership, so a pid recycled inside that gap could in principle be signalled. It is bounded -- matchingSnapshotRows already requires the live row to carry the same start-second and pgid, so an impostor must be born in the remainder of that one second, land on that exact pid, and sit in the same process group, and it has already received the unfenced SIGTERM from the same loop. * Run the Claude structured integration suite as a runtime client The suite exercises agentSession.* for Claude, not the mobile surface: nothing in it asserts anything mobile-specific and its sibling integration suites use 'runtime'. Mobile now additionally requires the experimental structured-chat setting, which structured-agent-session.test.ts pins in both states, so the stale 'mobile' fixture was claiming coverage it never had. * fix(claude): report effort from get_settings, which is the only frame that has it The composer's Effort pill rendered blank in every structured session. This is not a missing source: the publication reads `effortLevel` off the `system/init` frame, and that frame has never carried an effort of any kind, while the correct value is already fetched at acquisition and thrown away on the auth diagnostic. Verified two ways -- a live get_settings probe against Claude Code 2.1.258, and the shipped binary's own init frame construction, which lists `model` and no effort. So `reportedOptions.effort` was always empty, the options reader dropped the key, and the pill had no value. Model survived only because `currentModelId()` has a fallback chain. The get_settings call acquisition already makes reports the session's current effort as `effective.effortLevel`; pass that into the publication instead. Selecting an effort already worked, so this is the arrival value only. The legacy PTY path is unaffected and must not be "fixed" to match: it reads its effort by parsing the startup banner (`CLAUDE_MODEL_EFFORT` in src/renderer/src/components/native-chat/claude-terminal-session-options.ts), which is why it shows a value where the structured path does not. Also removes the fixture that hid this: the fake init frame invented `effortLevel: 'high'`, a field the CLI does not send, which is why every gate stayed green over a value that is always empty in production. The fixture's get_settings now returns the real {applied, effective, sources} shape instead of a bare `{env: {}}`, so the two adapter tests that asserted an effort keep asserting it through the path production actually uses. The reader returns null rather than defaulting: an effort nothing measured would repeat the fixture's mistake, and a blank pill is the honest degradation if the provider ever renames the key. * fix(claude): only record an effort the child confirms it adopted apply_flag_settings answers `success` for an effort it then ignores. Measured against Claude Code 2.1.258: applying `bogus-effort-xyz` returns subtype "success" with no error while `applied.effort` stays at its previous value, and a valid `low` moves it. The option write treated the absence of a throw as adoption and recorded the requested value unconditionally, so Orca would show and persist an effort the child was not using, with nothing anywhere reporting a problem. Read the effort back after applying it, through the same reader the arrival value uses, and reject when the child reports a different one. A readback that could not be taken is not evidence of a refusal -- the apply itself succeeded -- so it still records; only a readback that disagrees rejects. Not reachable from today's picker, which offers catalog values only, but the CLI's effort catalog is server-delivered and has changed before, so a retired id would otherwise become a pill confidently displaying a setting that never took. * test(claude): assert the effort contract against the real binary The blank pill survived every gate because the only tests that touched it were fixture-backed, and the fixture invented the field. A test that pins the shape we read cannot catch the provider renaming the key, which is the failure mode that produced this defect. Asserts both halves against a live authenticated CLI: that no frame it publishes carries an effort at all, and that the session's current effort arrives through get_settings. Which frame proves the session varies by host -- this machine proves it with a SessionStart hook rather than a system/init frame -- so the negative half asserts over every published frame rather than picking one. Skips with the rest of the file when no authenticated CLI is present. * fix(claude): stop the synthesised content-part kinds leaking into the transcript Sending an image put a bare `claude · message:user:content:image` row between the user's bubble and the answer. Two causes, and only the second is a family. An image part counted as modelled only when `source.type === 'url'`, but claudeDispatchMessageContent sends a local attachment as a base64 source and the CLI replays that shape back, so every attached image was classified unmodelled. Accept the base64 and file sources Orca itself sends. The family is the real defect. `message::content:` kinds are synthesised at runtime from whatever `part.type` arrives, so unlike the top-level frame catalogue they can never be enumerated ahead of time -- the `?? 'timeline-substantive'` default then prints the synthesised name at a user who cannot act on it. That default is right for top-level frames, where "substantive" means show the frame; here it meant show our own vocabulary, which drops the content AND leaks the opcode. So an unrenderable part now renders a sentence saying exactly that, with the kind and payload still on the row's disclosure. A part that carries its own readable sentence keeps it -- the placeholder is a fallback, not an override. An unknown future part type is therefore visible, never silently dropped and never printed as a kind: the same principle as the effort readback, which records only what the provider confirms. * Declare agentSession.requestHandoff on the cross-version wire surface The manifest is a ratchet for cross-version reachability, so the method is declared with real HandoffParams rather than counted. requestHandoff is capability-gated through requireStructuredHost and has no client caller, so declaring it is the whole of the change. Also model two host capabilities the harness omitted: the stub host's supportsCreate, and the fake adapter's, without which adapterSupportsCreate falls through to a supportsLocation the fake also lacks. Every ensure was refused for the harness's silence rather than for its location. * Gate structured Claude session tabs on the client capability that names them The Claude structured lane deleted the projection's `agent !== 'codex'` filter and added CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY in the same commit, but never wired the constant to anything. Paired clients then received agent-session tabs for Claude, which no shipped client renders -- mobile's resolveMobileNativeChat returns null for every agent but codex, so the row listed and selected into a pane with neither chat nor terminal. Restore the filter behind the declared capability instead of the bare agent name. No client advertises it yet, so this matches main's behaviour today and becomes a negotiation a future client can opt into. * Confirm the structured Claude model against the model the CLI reports set_model answers success for any string, including a model it cannot resolve — the failure only surfaces when the turn runs — and get_settings reports the settings-file model, not the session's. The init frame that opens each turn is the only channel carrying the adopted model, so keep the session's reported model current from it instead of reading it once at acquisition. Also stop rejecting an effort the readback cannot represent: max is session-scoped and excluded from the persisted effortLevel, so a readback reporting the level underneath it is an absence of evidence, not a refusal. * Clear the session-option hedge when the provider confirms the value The pill claimed every option was unconfirmed for the life of the session: the renderer recorded each write as dispatched and nothing ever moved it, so a model the CLI had already reported back still read as unconfirmed. Carry the provider's own confirmation to the surface. Main reports which option ids the provider named rather than merely accepted, and the client re-reads options as a turn changes, because the frame that opens a turn is where the adopted model arrives. A value the provider has not reported stays hedged, including an effort whose readback could not be taken. The confirmed list is optional on the wire: a host that predates it sends nothing and the client keeps hedging, which is the behaviour it had. * Keep the model report current across an acquisition fence bump * Show the picked session-option value and let the provider report correct it The pill showed a "not confirmed" second tooltip line for any value we had sent but not yet seen reported back. Nothing acts on it, and for the PTY lane it was permanent — that transport has no report channel. The pill now shows the picked value immediately and the provider's per-turn report corrects it when the two disagree; a newer local write still outranks a report that precedes it. `dispatched` stays as a provenance member rather than collapsing into `applied`: it is produced independently by the PTY lane, and it is where the `confirmed` wire field lands, which would otherwise be unobservable. Effort keeps its readback and its rejection path. That matters more now, not less: with the hedge gone the rejection is the only user-visible failure signal on this surface, so a spurious one would be the loudest bug here. Skipping the readback for an effort the settings response structurally cannot echo is what prevents it — the response carries the persisted level, so reading it back for a session-scoped value would report the level underneath and fail a valid write. * Hedge a session-option value only when the terminal transport sent it Both lanes emit `dispatched`, so it could never say which one produced a value. The descriptor now carries the transport that built it, set once in the shared snapshot builder from a parameter that is required rather than defaulted — the builder is the only place a descriptor is constructed, so a new producer has to name its lane or fail to compile. The structured lane confirms every value from the provider's own per-turn report, which makes the hedge transient noise there. The terminal lane can only learn an outcome by parsing the screen back, and only for Claude: every other agent's `dispatched` value stays unconfirmed for the life of the session, so the line is the only signal that we sent something we never saw land. * Refuse an effort the session's model advertises no control for * Refuse tab mutations on a Claude row the client never negotiated The branch added a case asserting a client advertising only agent-session.structured.v1 may mutate a claude row. That is the same ungated behaviour the projection gate removes, encoded a second time — mutation authorization reads the projection, so hiding the row refuses the write. Assert that contract instead, and add the positive case for a client that does negotiate Claude rows. * Resolve the Claude session's current model in one place so the effort guard and the pill agree * Record an effort the child did not adopt instead of refusing the write apply_flag_settings answers success for an effort it then ignores, so the readback exists to detect that. Refusing on it made the detection a veto, and a veto is only correct if the readback can never be wrong about which model is current -- which it was, twice. The pre-flight guard already refuses a level the model advertises no control for, so the veto guarded a door that is now locked upstream. Keep the detection, drop the refusal: a disagreement records the child's own answer and omits the option from confirmed, so main stops vouching for a value the provider rejected without blocking the user's write. * Stop a slow whole-machine ps from being read as an absent process `ps -axo ...command=` pays a per-pid argv read: measured 1.15s for 1,948 processes (0.03s without `command=`), and CPU contention stretched the same capture to 6.0s. Two budgets sized for a cheap look then misreport a readable machine. The reader's 3s ceiling killed 6 of 20 consecutive captures at load 27, so every consumer answered "unverifiable" about a table it could read. Raise it to 15s, and stamp the capture instant at ps START so `capturedAgeMs` is the upper bound its contract promises -- a 6s capture used to report itself as freshly taken, understating staleness against a 5s kill gate. The TTL keys on completion so a slow capture still coalesces instead of forking ps per caller. `readStructuredTuiProcessIdentity` then spent its whole 5s wait inside one capture and concluded "no exact child" after a single look taken before the child existed (observed landing at ~3.5s). Absence needs a look that did not race the spawn, so require two captures before the deadline can end the loop. Both surfaced by the real-binary Claude TUI resume test, which failed ~1 in 5 under load; 14/14 now, 8 of those runs containing a capture the old 3s budget would have killed. * Let the desktop renderer negotiate Claude structured tabs The paired-client gate hides agent-session rows an agent the client cannot render. The desktop renderer's own IPC dispatches as clientKind 'runtime' advertising only agent-session.structured.v1, so the gate hid Claude rows from the surface this feature ships on. It renders them; it should say so. * Stop a slow process table from silently blinding every freshness gate Stamping `capturedAgeMs` at ps START made the number honest, and honest broke both consumers that read it. `ps -axo ...command=` measured 2.5-9.0s on an idle 2,002-process laptop and 4.0-18.6s at load 46, so the age it now reports lands past every budget: `planRelayPtySweep` refuses the stop as "too old", and the renderer's `admitRemoteForegroundEvidence` refuses the record outright. That second one is the expensive half and was outside the diff -- a refusal bumps `consecutiveInspectionErrors`, the poll scheduler backs off to its 10s floor, and agent-completion detection stops for the pane. The subsystem went blind on exactly the loaded hosts the honest stamp was meant to serve. The evidence-publishing read now gives up at 1,200ms instead of waiting out `PS_TIMEOUT_MS`. It is one budget for one question: these consumers ask whether an observation describes NOW, and past this it does not -- a late answer is refused by the age gate anyway, having first blocked a polled path for the whole capture, so a prompt `unverifiable` is both the truthful verdict and the cheap one. Both relay call sites already produce it from a rejection, and an admitted `unverifiable` costs a poll where a refusal costs the cadence. Identity proof keeps the full 15s through `getFreshProcessTableSnapshot`, because it asks whether a process EXISTS and must never read slow as absent. The budget bounds the wait, never the capture: the reader coalesces, so an abandoned wait leaves its capture running to fill the cache rather than forking a second whole-machine `ps` on the host that can least afford one. 1,200ms is bracketed rather than picked. The floor is the capture's own cost -- `command=` measured 1.15s for 1,948 processes on an idle host, and a budget under that answers `unverifiable` about a machine nobody is straining. The ceiling is the consumer's: 2,000ms, less the 500ms a TTL-shared capture may already have aged, leaves 1,500ms, and transit takes the rest. That ceiling only fits once the capture stops being charged twice. `ps` runs inside the RPC round trip, so its duration is already in `receiveDelay`, and `capturedAgeMs` is that same duration on the host's clock; summing them halved the budget this gate grants a host from ~2.0s of `ps` to ~1.0s, which is why a 1.2s capture arriving at 1.3s read as 2.5s old and was refused. Admission now takes the larger of the two. The sweep's gate keeps its sum, which is correct there: `evidenceAgeSinceListingMs` is stamped after the listing ARRIVES, so it measures planning time and overlaps nothing. A stated limit rather than an assumed one: 15s is not proven sufficient for identity proof. The same capture reached 18.6s at load 46, so that path can still time out and answer "no exact child" about a host it simply could not read in time. Narrowing it needs a cheaper question than a whole-machine argv read, not a larger number. The one test guarding this field could not fail. `beginPtyHandlerTest` installs fake timers, so `Date.now()` is frozen, the real reader reports exactly +0, and `0 <= 500` held identically for a hardcoded zero, for completion-stamping and for start-stamping -- while the real reader on that host returns thousands of ms. It now drives a measured age in and asserts the handler publishes it rather than restamping; that the reader MEASURES it correctly stays pinned separately, against a controllable clock. Both consumers get boundary coverage either side, and each new gate was ablated red before it went green. * Keep the compatibility fields off the capture the budget just abandoned inspectProcess falls back to processHasChildren and listProcesses to getForegroundProcessName, and both read the same TTL-shared capture with no budget of their own. On a slow host they joined the in-flight capture the budgeted evidence read had just given up on, so the call still blocked for the full 6-18s and the budget bought nothing -- once for inspectProcess and once per managed PTY for listProcesses. Use the degraded answers those helpers already give for an unreadable table, reached promptly. pty.hasChildProcesses keeps its unbudgeted fresh probe: it is a one-shot destructive gate that can afford to wait. --------- Co-authored-by: Merge Sim Co-authored-by: Merge Sim --- .../scripts/verify-localization-catalog.mjs | 7 +- ...bileNativeChatSessionOptionPickers.test.ts | 44 + .../MobileNativeChatSessionOptionPickers.tsx | 9 +- .../use-mobile-native-chat-session-options.ts | 3 +- package.json | 1 + pnpm-lock.yaml | 133 ++- pnpm-workspace.yaml | 14 + .../claude-structured-auth-policy.test.ts | 168 ++++ .../claude-structured-auth-policy.ts | 37 + src/main/claude-accounts/environment.ts | 101 +- src/main/claude-accounts/live-pty-gate.ts | 73 ++ .../runtime-auth/runtime-auth-preparation.ts | 6 +- .../claude-agent-sdk-scripted-cli.mjs | 144 +++ .../claude-agent-sdk-contract-pins.test.ts | 519 ++++++++++ .../claude-agent-sdk-control-requests.ts | 154 +++ ...aude-agent-sdk-exit-proof-identity.test.ts | 104 ++ .../claude-agent-sdk-exit-proof.test.ts | 934 ++++++++++++++++++ .../claude/claude-agent-sdk-exit-proof.ts | 366 +++++++ .../claude-agent-sdk-import-boundary.test.ts | 154 +++ .../claude-agent-sdk-process-spawn.test.ts | 107 ++ .../claude/claude-agent-sdk-process-spawn.ts | 69 ++ ...laude-agent-sdk-root-kill-fallback.test.ts | 190 ++++ ...laude-agent-sdk-user-message-queue.test.ts | 65 ++ .../claude-agent-sdk-user-message-queue.ts | 100 ++ .../claude/claude-child-exit-proof-ladder.ts | 41 + .../claude-child-process-environment.test.ts | 63 ++ .../claude-child-process-environment.ts | 69 ++ .../claude/claude-child-root-termination.ts | 54 + src/main/claude/claude-child-tree-snapshot.ts | 128 +++ .../claude-command-lifecycle-frames.test.ts | 115 +++ src/main/claude/claude-config-dir-pin.test.ts | 34 + src/main/claude/claude-config-dir-pin.ts | 37 + ...ude-descendant-escalation-boundary.test.ts | 124 +++ ...laude-stream-json-connection-close.test.ts | 126 +++ .../claude-stream-json-connection.test.ts | 768 ++++++++++++++ .../claude/claude-stream-json-connection.ts | 283 ++++++ .../claude/claude-streamed-block-identity.ts | 110 +++ .../claude-streamed-text-checkpoints.test.ts | 93 ++ .../claude-streamed-text-checkpoints.ts | 105 ++ .../claude-structured-acquisition-release.ts | 43 + .../claude-structured-auth-parity.test.ts | 235 +++++ .../claude-structured-content-parts.test.ts | 98 ++ .../claude-structured-control-actions.test.ts | 113 +++ .../claude-structured-control-actions.ts | 60 ++ .../claude-structured-dispatch-content.ts | 165 ++++ .../claude/claude-structured-dispatch.test.ts | 598 +++++++++++ src/main/claude/claude-structured-dispatch.ts | 264 +++++ ...claude-structured-effort-reporting.test.ts | 257 +++++ .../claude-structured-inbound-control.test.ts | 164 +++ .../claude-structured-inbound-control.ts | 91 ++ .../claude/claude-structured-init-deadline.ts | 68 ++ .../claude/claude-structured-init-proof.ts | 88 ++ .../claude-structured-item-translation.ts | 179 ++++ ...ude-structured-journal-translation.test.ts | 811 +++++++++++++++ .../claude-structured-journal-translation.ts | 287 ++++++ ...laude-structured-launch-resolution.test.ts | 392 ++++++++ .../claude-structured-launch-resolution.ts | 273 +++++ ...claude-structured-location-support.test.ts | 91 ++ .../claude-structured-location-support.ts | 11 + ...aude-structured-model-confirmation.test.ts | 209 ++++ ...ude-structured-option-confirmation.test.ts | 193 ++++ .../claude/claude-structured-options.test.ts | 51 + src/main/claude/claude-structured-options.ts | 147 +++ .../claude-structured-owner-identity.test.ts | 39 + .../claude-structured-owner-identity.ts | 41 + .../claude-structured-prompt-items.test.ts | 110 +++ .../claude/claude-structured-prompt-items.ts | 134 +++ .../claude-structured-prompt-replies.ts | 297 ++++++ ...laude-structured-provider-fallback.test.ts | 117 +++ .../claude-structured-provider-fallback.ts | 125 +++ .../claude/claude-structured-real-cli.test.ts | 299 ++++++ ...ed-session-acquisition-processless.test.ts | 70 ++ .../claude-structured-session-acquisition.ts | 298 ++++++ .../claude-structured-session-adapter.test.ts | 891 +++++++++++++++++ .../claude-structured-session-adapter.ts | 246 +++++ .../claude-structured-session-close.test.ts | 50 + .../claude/claude-structured-session-close.ts | 256 +++++ .../claude-structured-session-options.ts | 183 ++++ .../claude-structured-session-publication.ts | 68 ++ ...claude-structured-session-recovery.test.ts | 619 ++++++++++++ .../claude/claude-structured-session-state.ts | 276 ++++++ .../claude-structured-session-test-support.ts | 260 +++++ .../claude/claude-transcript-branch-proof.ts | 135 ++- src/main/claude/claude-tui-exit.test.ts | 159 +++ src/main/claude/claude-tui-exit.ts | 119 +++ .../claude/claude-tui-resume-launch.test.ts | 227 +++++ src/main/claude/claude-tui-resume-launch.ts | 101 ++ .../claude/claude-tui-resume-proof.test.ts | 80 ++ src/main/claude/claude-tui-resume-proof.ts | 111 +++ ...tui-resume-real-binary.integration.test.ts | 279 ++++++ .../codex-structured-session-close.test.ts | 35 + src/main/ipc/pty/ipc/spawn-env.ts | 12 +- src/main/ipc/pty/ipc/spawn-preflight.ts | 3 +- src/main/ipc/pty/runtime/spawn-preflight.ts | 14 +- src/main/ipc/runtime.test.ts | 34 + src/main/ipc/runtime.ts | 15 +- .../claude-stream-json-frame-schema.ts | 12 +- .../provider-frame-disposition.test.ts | 23 + .../provider-frame-disposition.ts | 13 +- ...tured-agent-session-adapter-router.test.ts | 114 +++ ...structured-agent-session-adapter-router.ts | 124 +++ .../structured-agent-session-adapter.test.ts | 22 + .../structured-agent-session-adapter.ts | 34 +- ...structured-agent-session-attach-context.ts | 3 - .../structured-agent-session-attach-flow.ts | 13 +- ...ured-agent-session-attach-orchestration.ts | 23 +- .../structured-agent-session-attach.ts | 3 +- ...-session-claude-options-round-trip.test.ts | 182 ++++ ...tured-agent-session-grouped-prompt.test.ts | 166 ++++ ...uctured-agent-session-handoff-admission.ts | 138 +++ ...-agent-session-handoff-flow-runner.test.ts | 98 ++ ...tured-agent-session-handoff-flow-runner.ts | 110 +++ ...nt-session-handoff-operation-guard.test.ts | 221 +++++ ...d-agent-session-handoff-operation-guard.ts | 125 +++ ...ured-agent-session-handoff-options.test.ts | 286 ++++++ ...tructured-agent-session-handoff-options.ts | 23 + ...tured-agent-session-handoff-queue-start.ts | 46 + .../structured-agent-session-handoff-queue.ts | 133 +++ ...tructured-agent-session-handoff-recover.ts | 30 + ...structured-agent-session-handoff-result.ts | 32 + ...ured-agent-session-handoff-revalidation.ts | 52 + ...ured-agent-session-handoff-reverse.test.ts | 100 ++ ...tructured-agent-session-handoff-reverse.ts | 14 +- ...-agent-session-handoff-test-coordinator.ts | 80 ++ ...d-agent-session-handoff-test-identities.ts | 40 + ...red-agent-session-handoff-test-requests.ts | 53 + .../structured-agent-session-handoff.ts | 277 +++++- .../structured-agent-session-host.ts | 17 +- ...tructured-agent-session-manual-recovery.ts | 103 ++ ...ctured-agent-session-option-restoration.ts | 10 +- ...ed-agent-session-proven-dead-retry.test.ts | 178 ++++ ...tured-agent-session-recovery-exits.test.ts | 2 +- .../structured-agent-session-turns-prompt.ts | 20 +- .../unhandled-provider-frame.ts | 5 +- ...structured-managed-account-support.test.ts | 154 +++ ...aude-structured-managed-account-support.ts | 61 ++ ...session-file-resolver-claude-roots.test.ts | 97 ++ .../native-chat/session-file-resolver.test.ts | 303 +++++- src/main/native-chat/session-file-resolver.ts | 40 +- ...tured-agent-session-create-support.test.ts | 83 ++ ...structured-agent-session-create-support.ts | 48 + .../windows-foreground-process-rows.ts | 20 + src/main/pty-descendant-exit-verification.ts | 147 ++- src/main/pty-descendant-termination.test.ts | 146 ++- src/main/pty-descendant-termination.ts | 44 +- ...-session-acquisition-failure-settlement.ts | 76 +- .../agent-session-launch-env-backfill.test.ts | 90 ++ .../agent-session-record-options.test.ts | 14 + .../runtime/agent-session-resume-args.test.ts | 33 + src/main/runtime/agent-session-resume-args.ts | 17 + ...ude-structured-session-integration.test.ts | 747 ++++++++++++++ ...e-get-agent-session-execution-namespace.ts | 21 +- .../runtime/orca-runtime-get-worktree-ps.ts | 41 +- ...lve-recovered-structured-tui-transcript.ts | 54 +- ...tore-structured-agent-session-tabs-once.ts | 8 +- ...ctured-agent-session-create-intent.test.ts | 104 ++ ...ructured-agent-session-launch-args.test.ts | 86 ++ ...ime-structured-agent-session-launch-tui.ts | 7 +- ...ime-structured-claude-account-gate.test.ts | 112 +++ ...time-structured-claude-gate-wiring.test.ts | 64 ++ ...runtime-structured-session-restore.test.ts | 50 + ...runtime-structured-tui-tab-binding.test.ts | 730 ++++++++++++++ src/main/runtime/rpc/e2ee-channel-v2.test.ts | 19 + .../runtime/rpc/methods/clipboard.test.ts | 100 +- src/main/runtime/rpc/methods/clipboard.ts | 72 +- .../methods/mobile-markdown-tab-methods.ts | 22 + ...ion-tab-agent-capability-mutations.test.ts | 14 +- ...ession-tab-agent-status-projection.test.ts | 134 ++- .../session-tab-agent-status-projection.ts | 10 +- .../structured-agent-session-schemas.ts | 13 +- .../methods/structured-agent-session.test.ts | 116 ++- .../rpc/methods/structured-agent-session.ts | 55 +- .../mobile-clipboard-image-provenance.test.ts | 53 + .../rpc/mobile-clipboard-image-provenance.ts | 90 ++ .../rpc/mobile-e2ee-v2-client-capabilities.ts | 21 + .../runtime/rpc/mobile-socket-wiring.test.ts | 82 ++ src/main/runtime/rpc/mobile-socket-wiring.ts | 4 +- ...d-agent-session-integration-replay.test.ts | 2 + ...ructured-agent-session-integration.test.ts | 1 + .../structured-agent-session-owner-probe.ts | 108 ++ ...uctured-agent-session-runtime-exit.test.ts | 3 + .../structured-agent-session-runtime.test.ts | 9 +- .../structured-agent-session-runtime.ts | 170 ++-- ...ructured-claude-auth-policy-wiring.test.ts | 59 ++ .../structured-claude-runtime-adapter.ts | 98 ++ .../structured-tui-process-identity.test.ts | 35 + .../structured-tui-process-identity.ts | 12 +- ...ndows-descendant-exit-verification.test.ts | 175 ++++ .../windows-descendant-exit-verification.ts | 156 +++ .../pty-handler-ownership-attestation.test.ts | 42 +- src/relay/pty-handler-spawn-admission.test.ts | 40 + src/relay/pty-handler.ts | 21 +- .../NativeChatQuestionCard.test.tsx | 69 +- .../native-chat/NativeChatQuestionCard.tsx | 5 +- .../NativeChatSessionOptionPickers.test.tsx | 66 +- .../NativeChatSessionOptionPickers.tsx | 13 +- .../NativeChatStructuredSession.test.tsx | 147 ++- .../NativeChatStructuredSession.tsx | 54 +- ...ructuredAgentSessionHandoffChrome.test.tsx | 58 ++ .../StructuredAgentSessionHandoffChrome.tsx | 225 +++++ .../native-chat-pty-session-options.test.ts | 1 + .../native-chat-pty-session-options.ts | 6 +- .../native-chat-session-option-labels.test.ts | 1 + .../native-chat-session-option-snapshot.ts | 4 +- .../use-structured-agent-session.ts | 8 +- .../settings/ExperimentalPane.test.tsx | 6 +- .../NativeChatExperimentalSetting.tsx | 4 +- .../components/sidebar/NonGitFolderDialog.tsx | 12 +- .../folder-workspace-composer-submit.ts | 9 +- .../components/tab-bar/QuickLaunchButton.tsx | 41 +- .../TabBarCreateEntry.keyboard.test.tsx | 2 +- .../components/tab-bar/TabBarCreateEntry.tsx | 34 +- ...abCloseCommands.structured-session.test.ts | 8 +- .../tab-group/useTabGroupTabCloseCommands.ts | 10 +- ...cturedAgentSessionTerminalReturnButton.tsx | 25 + ...-completion-stale-evidence-backoff.test.ts | 136 +++ .../terminal/terminal-tab-actions.ts | 2 +- .../full-creation-structured-launch.test.ts | 12 +- .../full-creation-structured-launch.ts | 9 +- src/renderer/src/i18n/locales/en.json | 43 +- .../src/lib/agent-launch-routing.test.ts | 72 +- src/renderer/src/lib/agent-launch-routing.ts | 8 +- .../src/lib/launch-agent-in-new-tab.ts | 7 +- ...launch-agent-structured-chat-guard.test.ts | 62 +- .../launch-structured-agent-session.test.ts | 244 +++++ .../lib/launch-structured-agent-session.ts | 159 +++ .../launch-structured-codex-session.test.ts | 87 -- .../lib/launch-structured-codex-session.ts | 87 -- ...nch-work-item-direct-agent-routing.test.ts | 12 +- .../launch-work-item-direct-agent-routing.ts | 9 +- ...structured-agent-session-launch-callers.ts | 10 +- .../structured-agent-session-launch-prompt.ts | 2 +- ...tructured-agent-session-launch-recovery.ts | 22 +- .../structured-agent-session-launch.test.ts | 130 ++- .../lib/structured-agent-session-launch.ts | 73 +- .../src/lib/worktree-creation-flow-execute.ts | 3 +- ...rktree-creation-structured-session.test.ts | 16 +- .../worktree-creation-structured-session.ts | 15 +- .../structured-agent-session-handoff-store.ts | 50 + .../src/store/repos/repo-add-actions.ts | 12 +- .../agent-status-provider-session.test.ts | 21 + src/shared/agent-session-journal-schemas.ts | 20 +- src/shared/agent-session-journal-types.ts | 12 + .../agent-session-question-answer.test.ts | 49 + src/shared/agent-session-question-answer.ts | 84 ++ src/shared/agent-session-wire.ts | 6 + ...ative-chat-session-option-snapshot.test.ts | 64 +- .../native-chat-session-option-snapshot.ts | 12 +- .../native-chat-session-option-state.ts | 15 +- src/shared/native-chat-session-options.ts | 22 +- src/shared/process-table-snapshot-reader.ts | 63 +- src/shared/process-table-snapshot.test.ts | 139 ++- src/shared/protocol-version.ts | 4 + .../remote-foreground-evidence-admission.ts | 11 +- src/shared/remote-foreground-evidence.test.ts | 70 +- .../runtime-mobile-session-tab-contracts.ts | 2 +- .../ssh-relay-pty-ownership-proof.test.ts | 46 + .../structured-agent-session-mutation.ts | 27 + .../structured-agent-session-options.test.ts | 6 +- .../structured-agent-session-options.ts | 12 +- ...ss-version-agent-session-wire.unit.test.ts | 21 + 261 files changed, 26210 insertions(+), 887 deletions(-) create mode 100644 src/main/claude-accounts/claude-structured-auth-policy.test.ts create mode 100644 src/main/claude-accounts/claude-structured-auth-policy.ts create mode 100644 src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs create mode 100644 src/main/claude/claude-agent-sdk-contract-pins.test.ts create mode 100644 src/main/claude/claude-agent-sdk-control-requests.ts create mode 100644 src/main/claude/claude-agent-sdk-exit-proof-identity.test.ts create mode 100644 src/main/claude/claude-agent-sdk-exit-proof.test.ts create mode 100644 src/main/claude/claude-agent-sdk-exit-proof.ts create mode 100644 src/main/claude/claude-agent-sdk-import-boundary.test.ts create mode 100644 src/main/claude/claude-agent-sdk-process-spawn.test.ts create mode 100644 src/main/claude/claude-agent-sdk-process-spawn.ts create mode 100644 src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts create mode 100644 src/main/claude/claude-agent-sdk-user-message-queue.test.ts create mode 100644 src/main/claude/claude-agent-sdk-user-message-queue.ts create mode 100644 src/main/claude/claude-child-exit-proof-ladder.ts create mode 100644 src/main/claude/claude-child-process-environment.test.ts create mode 100644 src/main/claude/claude-child-process-environment.ts create mode 100644 src/main/claude/claude-child-root-termination.ts create mode 100644 src/main/claude/claude-child-tree-snapshot.ts create mode 100644 src/main/claude/claude-command-lifecycle-frames.test.ts create mode 100644 src/main/claude/claude-config-dir-pin.test.ts create mode 100644 src/main/claude/claude-config-dir-pin.ts create mode 100644 src/main/claude/claude-descendant-escalation-boundary.test.ts create mode 100644 src/main/claude/claude-stream-json-connection-close.test.ts create mode 100644 src/main/claude/claude-stream-json-connection.test.ts create mode 100644 src/main/claude/claude-stream-json-connection.ts create mode 100644 src/main/claude/claude-streamed-block-identity.ts create mode 100644 src/main/claude/claude-streamed-text-checkpoints.test.ts create mode 100644 src/main/claude/claude-streamed-text-checkpoints.ts create mode 100644 src/main/claude/claude-structured-acquisition-release.ts create mode 100644 src/main/claude/claude-structured-auth-parity.test.ts create mode 100644 src/main/claude/claude-structured-content-parts.test.ts create mode 100644 src/main/claude/claude-structured-control-actions.test.ts create mode 100644 src/main/claude/claude-structured-control-actions.ts create mode 100644 src/main/claude/claude-structured-dispatch-content.ts create mode 100644 src/main/claude/claude-structured-dispatch.test.ts create mode 100644 src/main/claude/claude-structured-dispatch.ts create mode 100644 src/main/claude/claude-structured-effort-reporting.test.ts create mode 100644 src/main/claude/claude-structured-inbound-control.test.ts create mode 100644 src/main/claude/claude-structured-inbound-control.ts create mode 100644 src/main/claude/claude-structured-init-deadline.ts create mode 100644 src/main/claude/claude-structured-init-proof.ts create mode 100644 src/main/claude/claude-structured-item-translation.ts create mode 100644 src/main/claude/claude-structured-journal-translation.test.ts create mode 100644 src/main/claude/claude-structured-journal-translation.ts create mode 100644 src/main/claude/claude-structured-launch-resolution.test.ts create mode 100644 src/main/claude/claude-structured-launch-resolution.ts create mode 100644 src/main/claude/claude-structured-location-support.test.ts create mode 100644 src/main/claude/claude-structured-location-support.ts create mode 100644 src/main/claude/claude-structured-model-confirmation.test.ts create mode 100644 src/main/claude/claude-structured-option-confirmation.test.ts create mode 100644 src/main/claude/claude-structured-options.test.ts create mode 100644 src/main/claude/claude-structured-options.ts create mode 100644 src/main/claude/claude-structured-owner-identity.test.ts create mode 100644 src/main/claude/claude-structured-prompt-items.test.ts create mode 100644 src/main/claude/claude-structured-prompt-items.ts create mode 100644 src/main/claude/claude-structured-prompt-replies.ts create mode 100644 src/main/claude/claude-structured-provider-fallback.test.ts create mode 100644 src/main/claude/claude-structured-provider-fallback.ts create mode 100644 src/main/claude/claude-structured-real-cli.test.ts create mode 100644 src/main/claude/claude-structured-session-acquisition-processless.test.ts create mode 100644 src/main/claude/claude-structured-session-acquisition.ts create mode 100644 src/main/claude/claude-structured-session-adapter.test.ts create mode 100644 src/main/claude/claude-structured-session-adapter.ts create mode 100644 src/main/claude/claude-structured-session-close.test.ts create mode 100644 src/main/claude/claude-structured-session-close.ts create mode 100644 src/main/claude/claude-structured-session-options.ts create mode 100644 src/main/claude/claude-structured-session-publication.ts create mode 100644 src/main/claude/claude-structured-session-recovery.test.ts create mode 100644 src/main/claude/claude-structured-session-state.ts create mode 100644 src/main/claude/claude-structured-session-test-support.ts create mode 100644 src/main/claude/claude-tui-exit.test.ts create mode 100644 src/main/claude/claude-tui-exit.ts create mode 100644 src/main/claude/claude-tui-resume-launch.test.ts create mode 100644 src/main/claude/claude-tui-resume-launch.ts create mode 100644 src/main/claude/claude-tui-resume-proof.test.ts create mode 100644 src/main/claude/claude-tui-resume-proof.ts create mode 100644 src/main/claude/claude-tui-resume-real-binary.integration.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-claude-options-round-trip.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-admission.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue-start.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-recover.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-result.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-revalidation.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-identities.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-requests.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-manual-recovery.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts create mode 100644 src/main/native-chat/claude-structured-managed-account-support.test.ts create mode 100644 src/main/native-chat/claude-structured-managed-account-support.ts create mode 100644 src/main/native-chat/session-file-resolver-claude-roots.test.ts create mode 100644 src/main/native-chat/structured-agent-session-create-support.test.ts create mode 100644 src/main/native-chat/structured-agent-session-create-support.ts create mode 100644 src/main/runtime/agent-session-launch-env-backfill.test.ts create mode 100644 src/main/runtime/agent-session-resume-args.test.ts create mode 100644 src/main/runtime/agent-session-resume-args.ts create mode 100644 src/main/runtime/claude-structured-session-integration.test.ts create mode 100644 src/main/runtime/orca-runtime-structured-agent-session-launch-args.test.ts create mode 100644 src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts create mode 100644 src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts create mode 100644 src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts create mode 100644 src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts create mode 100644 src/main/runtime/rpc/mobile-clipboard-image-provenance.test.ts create mode 100644 src/main/runtime/rpc/mobile-clipboard-image-provenance.ts create mode 100644 src/main/runtime/rpc/mobile-e2ee-v2-client-capabilities.ts create mode 100644 src/main/runtime/structured-agent-session-owner-probe.ts create mode 100644 src/main/runtime/structured-claude-auth-policy-wiring.test.ts create mode 100644 src/main/runtime/structured-claude-runtime-adapter.ts create mode 100644 src/main/windows-descendant-exit-verification.test.ts create mode 100644 src/main/windows-descendant-exit-verification.ts create mode 100644 src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.test.tsx create mode 100644 src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx create mode 100644 src/renderer/src/components/terminal-pane/StructuredAgentSessionTerminalReturnButton.tsx create mode 100644 src/renderer/src/components/terminal-pane/agent-completion-stale-evidence-backoff.test.ts create mode 100644 src/renderer/src/lib/launch-structured-agent-session.test.ts create mode 100644 src/renderer/src/lib/launch-structured-agent-session.ts delete mode 100644 src/renderer/src/lib/launch-structured-codex-session.test.ts delete mode 100644 src/renderer/src/lib/launch-structured-codex-session.ts create mode 100644 src/renderer/src/runtime/structured-agent-session-handoff-store.ts create mode 100644 src/shared/agent-session-question-answer.test.ts create mode 100644 src/shared/agent-session-question-answer.ts diff --git a/config/scripts/verify-localization-catalog.mjs b/config/scripts/verify-localization-catalog.mjs index 002a84360f6..a73e9d5e3cc 100644 --- a/config/scripts/verify-localization-catalog.mjs +++ b/config/scripts/verify-localization-catalog.mjs @@ -11,7 +11,12 @@ import { repairTranslatedValue } from './locale-translation-policy.mjs' const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts']) const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets']) -const LOCALIZATION_FUNCTION_NAMES = new Set(['t', 'translate', 'translateMain', 'translateSearchKeyword']) +const LOCALIZATION_FUNCTION_NAMES = new Set([ + 't', + 'translate', + 'translateMain', + 'translateSearchKeyword' +]) const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g const LOCALES_RELATIVE_DIR = path.join('src', 'renderer', 'src', 'i18n', 'locales') export const LOCALIZATION_SOURCE_ROOTS = [ diff --git a/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts b/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts index 4430c60115a..5a959b870bf 100644 --- a/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts +++ b/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts @@ -41,6 +41,7 @@ const MODEL_DESCRIPTOR: SessionOptionDescriptor = { ] }, valueSource: 'reported', + transport: 'catalog', settable: true } @@ -57,6 +58,7 @@ const EFFORT_DESCRIPTOR: SessionOptionDescriptor = { ] }, valueSource: 'dispatched', + transport: 'catalog', settable: true } @@ -66,6 +68,7 @@ const FAST_MODE_DESCRIPTOR: SessionOptionDescriptor = { category: 'mode', kind: { type: 'boolean', currentValue: false }, valueSource: 'reported', + transport: 'catalog', settable: true } @@ -227,6 +230,7 @@ describe('MobileNativeChatSessionOptionPickers', () => { ...MODEL_DESCRIPTOR, kind: { type: 'select', choices: [] }, valueSource: 'unknown', + transport: 'catalog', action: { type: 'agent-picker' } } ]) @@ -236,6 +240,46 @@ describe('MobileNativeChatSessionOptionPickers', () => { expect(invokeAction).toHaveBeenCalledWith('model') }) + // The terminal transport can only learn the outcome by parsing the screen back, + // so the sheet admits the value is unconfirmed; the structured transport reports + // it every turn, which makes the same caption noise there. + it.each([ + { transport: 'catalog' as const, caption: true }, + { transport: 'agent-session' as const, caption: false } + ])('captions a dispatched value only on the terminal transport', async (scenario) => { + mount([ + MODEL_DESCRIPTOR, + { ...EFFORT_DESCRIPTOR, valueSource: 'dispatched', transport: scenario.transport } + ]) + await act(async () => pill('Model').props.onPress()) + await act(async () => rowByText('Effort').props.onPress()) + const captions = renderer!.root + .findAll((node) => node.type === 'Text') + .filter( + (node) => + (node.props as { children?: unknown }).children === 'Sent to the agent — not confirmed' + ) + expect(captions.length > 0).toBe(scenario.caption) + }) + + it.each(['catalog', 'agent-session'] as const)( + 'does not caption a reported value on the %s transport', + async (transport) => { + mount([MODEL_DESCRIPTOR, { ...EFFORT_DESCRIPTOR, valueSource: 'reported', transport }]) + await act(async () => pill('Model').props.onPress()) + await act(async () => rowByText('Effort').props.onPress()) + expect( + renderer!.root + .findAll((node) => node.type === 'Text') + .some( + (node) => + (node.props as { children?: unknown }).children === + 'Sent to the agent — not confirmed' + ) + ).toBe(false) + } + ) + it('locks the pills while the agent is working', () => { mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR], true) expect(pill('Model').props).toMatchObject({ disabled: true }) diff --git a/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx index bfa5244a398..c9d641f74ec 100644 --- a/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx +++ b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx @@ -3,9 +3,10 @@ import { ActivityIndicator, Keyboard, Pressable, StyleSheet, Text, View } from ' import { ChevronLeft, X } from 'lucide-react-native' import { BottomDrawer } from '../components/BottomDrawer' import { colors, radii, spacing, typography } from '../theme/mobile-theme' -import type { - SessionOptionDescriptor, - SessionOptionValue +import { + sessionOptionDispatchUnconfirmed, + type SessionOptionDescriptor, + type SessionOptionValue } from '../../../src/shared/native-chat-session-options' import { mobileModelPillLabel, @@ -119,7 +120,7 @@ export function MobileNativeChatSessionOptionPickers({ ) : null} - {activeDescriptor.valueSource === 'dispatched' ? ( + {sessionOptionDispatchUnconfirmed(activeDescriptor) ? ( Sent to the agent — not confirmed ) : null} {reason ? {reason} : null} diff --git a/mobile/src/session/use-mobile-native-chat-session-options.ts b/mobile/src/session/use-mobile-native-chat-session-options.ts index 66a929aeb56..6acdf8b3750 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.ts @@ -168,7 +168,8 @@ export function useMobileNativeChatSessionOptions(args: { models: activeModels(catalog, record), record, mode: 'live', - modelLabel: 'Model' + modelLabel: 'Model', + liveTransport: 'catalog' }) }, [agent, catalog, scopeKey, version]) diff --git a/package.json b/package.json index 58f4805d937..c519d7ea6b1 100644 --- a/package.json +++ b/package.json @@ -153,6 +153,7 @@ "repro:live-remote-realistic-freeze": "node config/scripts/live-remote-realistic-freeze-repro.mjs" }, "dependencies": { + "@anthropic-ai/claude-agent-sdk": "0.3.251", "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", "@floating-ui/dom": "1.7.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7481f2e556..6b59d23e026 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: .: dependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: 0.3.251 + version: 0.3.251(@anthropic-ai/sdk@0.122.0(zod@4.5.4))(@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.5.4))(zod@4.5.4) '@electron-toolkit/preload': specifier: ^3.0.2 version: 3.0.2(electron@43.4.1(supports-color@7.2.0)) @@ -535,6 +538,23 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@anthropic-ai/claude-agent-sdk@0.3.251': + resolution: {integrity: sha512-DqSi8mH2tQYRlVV0G+lJnQ/WbjJZ/a+8cJ3vPuYoqh8esIIvXHm1ZOXV1UPGsFYRnbBytEoiSGitguEXd+sQ+Q==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@anthropic-ai/sdk': '>=0.93.0' + '@modelcontextprotocol/sdk': ^1.29.0 + zod: ^4.0.0 + + '@anthropic-ai/sdk@0.122.0': + resolution: {integrity: sha512-GGPNftt0caaz9MDlmNQGHX8855Ojaduyy5pm9Sm1h7HalCn0cWNb5/bweadJF+4yzbal+QL6ztBa09WAAOzLmQ==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -2628,6 +2648,9 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@stablyai/playwright-base@2.1.14': resolution: {integrity: sha512-/iAgMW5tC0ETDo3mFyTzszRrD7rGFIT4fgDgtZxqa9vPhiTLix/1+GeOOBNY0uS+XRLFY0Uc/irsC3XProL47g==} engines: {node: '>=18'} @@ -4493,6 +4516,9 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -5039,6 +5065,10 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -6425,6 +6455,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.1.1: + resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==} + stat-mode@1.0.0: resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} engines: {node: '>= 6'} @@ -6608,6 +6641,9 @@ packages: truncate-utf8-bytes@1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-dedent@2.2.0: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} @@ -7007,6 +7043,16 @@ packages: zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} +ignoredOptionalDependencies: + - '@anthropic-ai/claude-agent-sdk-darwin-arm64' + - '@anthropic-ai/claude-agent-sdk-darwin-x64' + - '@anthropic-ai/claude-agent-sdk-linux-arm64' + - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl' + - '@anthropic-ai/claude-agent-sdk-linux-x64' + - '@anthropic-ai/claude-agent-sdk-linux-x64-musl' + - '@anthropic-ai/claude-agent-sdk-win32-arm64' + - '@anthropic-ai/claude-agent-sdk-win32-x64' + snapshots: '@adobe/css-tools@4.5.0': {} @@ -7016,6 +7062,19 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.1.2 + '@anthropic-ai/claude-agent-sdk@0.3.251(@anthropic-ai/sdk@0.122.0(zod@4.5.4))(@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.5.4))(zod@4.5.4)': + dependencies: + '@anthropic-ai/sdk': 0.122.0(zod@4.5.4) + '@modelcontextprotocol/sdk': 1.30.0(supports-color@7.2.0)(zod@4.5.4) + zod: 4.5.4 + + '@anthropic-ai/sdk@0.122.0(zod@4.5.4)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.1.1 + optionalDependencies: + zod: 4.5.4 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -7669,6 +7728,28 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 + '@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.5.4)': + dependencies: + '@hono/node-server': 2.1.0(hono@4.13.0) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.8 + express: 5.2.1(supports-color@7.2.0) + express-rate-limit: 8.5.2(express@5.2.1(supports-color@7.2.0)) + hono: 4.13.0 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.5.4 + zod-to-json-schema: 3.25.2(zod@4.5.4) + transitivePeerDependencies: + - supports-color + '@modelcontextprotocol/sdk@1.30.0(zod@3.25.76)': dependencies: '@hono/node-server': 2.1.0(hono@4.13.0) @@ -7679,8 +7760,8 @@ snapshots: cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.0.8 - express: 5.2.1 - express-rate-limit: 8.5.2(express@5.2.1) + express: 5.2.1(supports-color@7.2.0) + express-rate-limit: 8.5.2(express@5.2.1(supports-color@7.2.0)) hono: 4.13.0 jose: 6.2.3 json-schema-typed: 8.0.2 @@ -8917,6 +8998,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@stablelib/base64@1.0.1': {} + '@stablyai/playwright-base@2.1.14(@playwright/test@1.59.1)(zod@4.5.4)': dependencies: '@playwright/test': 1.59.1 @@ -9923,7 +10006,7 @@ snapshots: bluebird@3.7.2: {} - body-parser@2.3.0: + body-parser@2.3.0(supports-color@7.2.0): dependencies: bytes: 3.1.2 content-type: 2.0.0 @@ -10779,15 +10862,15 @@ snapshots: exponential-backoff@3.1.3: {} - express-rate-limit@8.5.2(express@5.2.1): + express-rate-limit@8.5.2(express@5.2.1(supports-color@7.2.0)): dependencies: - express: 5.2.1 + express: 5.2.1(supports-color@7.2.0) ip-address: 10.4.0 - express@5.2.1: + express@5.2.1(supports-color@7.2.0): dependencies: accepts: 2.0.0 - body-parser: 2.3.0 + body-parser: 2.3.0(supports-color@7.2.0) content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 @@ -10797,7 +10880,7 @@ snapshots: encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.1 + finalhandler: 2.1.1(supports-color@7.2.0) fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 @@ -10808,9 +10891,9 @@ snapshots: proxy-addr: 2.0.7 qs: 6.15.2 range-parser: 1.2.1 - router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 + router: 2.2.0(supports-color@7.2.0) + send: 1.2.1(supports-color@7.2.0) + serve-static: 2.2.1(supports-color@7.2.0) statuses: 2.0.2 type-is: 2.1.0 vary: 1.1.2 @@ -10831,6 +10914,8 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-sha256@1.3.0: {} + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -10871,7 +10956,7 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@2.1.1: + finalhandler@2.1.1(supports-color@7.2.0): dependencies: debug: 4.4.3(supports-color@7.2.0) encodeurl: 2.0.0 @@ -11445,6 +11530,11 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.7 + ts-algebra: 2.0.0 + json-schema-traverse@1.0.0: {} json-schema-typed@8.0.2: {} @@ -13037,7 +13127,7 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 - router@2.2.0: + router@2.2.0(supports-color@7.2.0): dependencies: debug: 4.4.3(supports-color@7.2.0) depd: 2.0.0 @@ -13080,7 +13170,7 @@ snapshots: semver@7.8.1: {} - send@1.2.1: + send@1.2.1(supports-color@7.2.0): dependencies: debug: 4.4.3(supports-color@7.2.0) encodeurl: 2.0.0 @@ -13107,12 +13197,12 @@ snapshots: transitivePeerDependencies: - typescript - serve-static@2.2.1: + serve-static@2.2.1(supports-color@7.2.0): dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1 + send: 1.2.1(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -13267,6 +13357,11 @@ snapshots: stackback@0.0.2: {} + standardwebhooks@1.1.1: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + stat-mode@1.0.0: {} state-local@1.0.7: {} @@ -13437,6 +13532,8 @@ snapshots: dependencies: utf8-byte-length: 1.0.5 + ts-algebra@2.0.0: {} + ts-dedent@2.2.0: {} ts-morph@26.0.0: @@ -13786,6 +13883,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.2(zod@4.5.4): + dependencies: + zod: 4.5.4 + zod@3.25.76: {} zod@4.5.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d241459f884..97920f087c5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,6 +12,20 @@ minimumReleaseAgeExclude: - zod@4.5.4 shamefullyHoist: true +# Orca always launches the user's own resolved Claude CLI via +# pathToClaudeCodeExecutable, so the SDK's bundled ~95 MB-per-platform CLI +# binaries must never be installed. Excluding them is what makes the path +# override mandatory rather than merely preferred. +ignoredOptionalDependencies: + - '@anthropic-ai/claude-agent-sdk-darwin-arm64' + - '@anthropic-ai/claude-agent-sdk-darwin-x64' + - '@anthropic-ai/claude-agent-sdk-linux-arm64' + - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl' + - '@anthropic-ai/claude-agent-sdk-linux-x64' + - '@anthropic-ai/claude-agent-sdk-linux-x64-musl' + - '@anthropic-ai/claude-agent-sdk-win32-arm64' + - '@anthropic-ai/claude-agent-sdk-win32-x64' + supportedArchitectures: os: - current diff --git a/src/main/claude-accounts/claude-structured-auth-policy.test.ts b/src/main/claude-accounts/claude-structured-auth-policy.test.ts new file mode 100644 index 00000000000..a2d7c7d5da8 --- /dev/null +++ b/src/main/claude-accounts/claude-structured-auth-policy.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' +import { + CLAUDE_AUTH_ENV_VARS, + hasClaudeAuthEnvConflict, + shouldStripClaudeAuthEnvForAccount +} from './environment' +import { + normalizeTuiAgentEnvRecord, + resolveTuiAgentLaunchEnv +} from '../../shared/tui-agent-launch-defaults' +import { claudeStructuredAuthPolicyForSettings } from './claude-structured-auth-policy' + +const HOST_ACCOUNT = { id: 'host-a', managedAuthRuntime: 'host' } as ClaudeManagedAccount +const WSL_ACCOUNT = { id: 'wsl-b', managedAuthRuntime: 'wsl' } as ClaudeManagedAccount +const LEGACY_ACCOUNT = { id: 'legacy-c' } as ClaudeManagedAccount + +function settings( + overrides: Partial< + Pick< + GlobalSettings, + | 'claudeManagedAccounts' + | 'activeClaudeManagedAccountId' + | 'activeClaudeManagedAccountIdsByRuntime' + > + > +): Parameters[0] { + return { + claudeManagedAccounts: [HOST_ACCOUNT, WSL_ACCOUNT, LEGACY_ACCOUNT], + activeClaudeManagedAccountId: null, + ...overrides + } as Parameters[0] +} + +// The predicate now backs BOTH transports (runtime-auth-preparation.ts and the +// structured wiring), so it needs a test of its own: forcing it to a constant used +// to leave ~1000 tests green. +describe('shouldStripClaudeAuthEnvForAccount', () => { + it('does not strip when no managed account is selected', () => { + expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT], null)).toBe(false) + expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT], undefined)).toBe(false) + expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT], '')).toBe(false) + }) + + it('strips for a host-managed account', () => { + expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT, WSL_ACCOUNT], 'host-a')).toBe(true) + }) + + it('strips for an account with no explicit runtime (the legacy host shape)', () => { + expect(shouldStripClaudeAuthEnvForAccount([LEGACY_ACCOUNT], 'legacy-c')).toBe(true) + }) + + it('does not strip for a WSL-managed account, matching runtime-auth-preparation', () => { + expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT, WSL_ACCOUNT], 'wsl-b')).toBe(false) + }) + + it('strips for a selected id no account list explains', () => { + // Fail-safe: an id we cannot resolve is treated as a pinned account, never as + // "no account", so an unreadable settings blob cannot open the strip. + expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT], 'deleted-d')).toBe(true) + expect(shouldStripClaudeAuthEnvForAccount(undefined, 'deleted-d')).toBe(true) + expect(shouldStripClaudeAuthEnvForAccount([], 'deleted-d')).toBe(true) + }) +}) + +describe('claudeStructuredAuthPolicyForSettings', () => { + it('reads the host runtime selection, not the legacy flat field alone', () => { + expect( + claudeStructuredAuthPolicyForSettings( + settings({ + activeClaudeManagedAccountId: 'host-a', + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } + }) + ) + ).toEqual({ stripAuthEnv: true }) + }) + + it('strips when a host account is pinned by runtime selection', () => { + expect( + claudeStructuredAuthPolicyForSettings( + settings({ activeClaudeManagedAccountIdsByRuntime: { host: 'host-a', wsl: {} } }) + ) + ).toEqual({ stripAuthEnv: true }) + }) + + it('does not strip for system auth, so an API-key-only user keeps their sign-in', () => { + expect(claudeStructuredAuthPolicyForSettings(settings({}))).toEqual({ stripAuthEnv: false }) + }) + + it('ignores a WSL-only selection: the structured child is always a native host process', () => { + expect( + claudeStructuredAuthPolicyForSettings( + settings({ + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-b' } } + }) + ) + ).toEqual({ stripAuthEnv: false }) + }) +}) + +describe('the strip vocabulary the policy governs', () => { + it('covers every Anthropic auth variable the terminal path knows about', () => { + // A new auth var added to the list without a matching refusal/strip path is the + // shape of the leak this lane already shipped once. + expect([...CLAUDE_AUTH_ENV_VARS]).toEqual([ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'CLAUDE_CODE_OAUTH_TOKEN', + 'AWS_BEARER_TOKEN_BEDROCK' + ]) + }) +}) + +// The refusal has to cover exactly what the strip removes. Anything narrower lets an +// override reach the child that applyClaudeEnvPatch would have deleted. +describe('hasClaudeAuthEnvConflict matches the strip it guards', () => { + it('refuses each Anthropic auth variable', () => { + for (const key of CLAUDE_AUTH_ENV_VARS) { + expect(hasClaudeAuthEnvConflict({ [key]: 'v' }, 'linux')).toBe(true) + } + }) + + // `ANTHROPIC_API_KEY=` in the agent env box is how a user blanks a variable, and the + // settings pipeline preserves the empty value (agent-default-env-draft.ts assigns + // everything after the `=`; normalizeTuiAgentEnvRecord drops empty KEYS only). An + // empty value cannot beat the pinned account and the strip removes the name anyway, + // so refusing it would break a terminal launch that works today for no security gain. + it('admits an override whose value is empty, the documented way to blank a variable', () => { + expect(hasClaudeAuthEnvConflict({ ANTHROPIC_API_KEY: '' }, 'linux')).toBe(false) + expect(hasClaudeAuthEnvConflict({ anthropic_api_key: '' }, 'win32')).toBe(false) + expect(hasClaudeAuthEnvConflict({ ANTHROPIC_CUSTOM_HEADERS: '' }, 'linux')).toBe(false) + }) + + it('still refuses the same names once they carry a value', () => { + expect(hasClaudeAuthEnvConflict({ ANTHROPIC_API_KEY: 'sk-ant' }, 'linux')).toBe(true) + }) + + // The end-to-end shape the regression actually took: settings text -> normalized + // record -> launch env -> the predicate the terminal preflight gates on. + it('admits a blanked variable all the way from the settings record', () => { + const configured = normalizeTuiAgentEnvRecord({ claude: { ANTHROPIC_API_KEY: '' } }) + const launchEnv = resolveTuiAgentLaunchEnv('claude', configured) + + expect(launchEnv).toEqual({ ANTHROPIC_API_KEY: '' }) + expect(hasClaudeAuthEnvConflict(launchEnv, 'linux')).toBe(false) + }) + + it('folds case on win32, where the OS does', () => { + expect(hasClaudeAuthEnvConflict({ anthropic_api_key: 'sk-lower' }, 'win32')).toBe(true) + expect(hasClaudeAuthEnvConflict({ Anthropic_Custom_Headers: 'x-api-key: v' }, 'win32')).toBe( + true + ) + }) + + it('keeps env names case-sensitive off win32', () => { + expect(hasClaudeAuthEnvConflict({ anthropic_api_key: 'sk-lower' }, 'linux')).toBe(false) + }) + + it('admits non-auth Anthropic settings on both platforms', () => { + expect(hasClaudeAuthEnvConflict({ ANTHROPIC_BASE_URL: 'https://gw.test' }, 'linux')).toBe(false) + expect(hasClaudeAuthEnvConflict({ ANTHROPIC_BASE_URL: 'https://gw.test' }, 'win32')).toBe(false) + expect(hasClaudeAuthEnvConflict({ ANTHROPIC_CUSTOM_HEADERS: 'X-Trace: 1' }, 'linux')).toBe( + false + ) + expect(hasClaudeAuthEnvConflict(undefined, 'linux')).toBe(false) + }) +}) diff --git a/src/main/claude-accounts/claude-structured-auth-policy.ts b/src/main/claude-accounts/claude-structured-auth-policy.ts new file mode 100644 index 00000000000..c30cd69b827 --- /dev/null +++ b/src/main/claude-accounts/claude-structured-auth-policy.ts @@ -0,0 +1,37 @@ +import type { GlobalSettings } from '../../shared/global-settings-types' +import { shouldStripClaudeAuthEnvForAccount } from './environment' +import { getSelectedClaudeAccountIdForTarget } from './runtime-selection' + +/** The structured mirror of the terminal preflight's `prepareClaudeAuth` result: + * the one field a launch resolution needs from the managed-account state. */ +export type ClaudeStructuredAuthPolicy = { + stripAuthEnv: boolean +} + +/** + * The only supported way to build a structured launch's auth policy. + * + * It exists as a named function rather than an inline object at the wiring site so + * that the settings-to-policy mapping is testable on its own: the one production + * wiring lives in a `@ts-nocheck` file, where neither the compiler nor a type test + * can see a dropped field. + * + * Structured Claude always spawns a native local-host child — the launch resolver + * refuses any record with a remote execution host or a WSL distro — so the host + * selection, not the platform default target, owns its auth. + */ +export function claudeStructuredAuthPolicyForSettings( + settings: Pick< + GlobalSettings, + | 'claudeManagedAccounts' + | 'activeClaudeManagedAccountId' + | 'activeClaudeManagedAccountIdsByRuntime' + > +): ClaudeStructuredAuthPolicy { + return { + stripAuthEnv: shouldStripClaudeAuthEnvForAccount( + settings.claudeManagedAccounts, + getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) + ) + } +} diff --git a/src/main/claude-accounts/environment.ts b/src/main/claude-accounts/environment.ts index 83fe3b40209..b85dd60a854 100644 --- a/src/main/claude-accounts/environment.ts +++ b/src/main/claude-accounts/environment.ts @@ -1,3 +1,5 @@ +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' + export const CLAUDE_AUTH_ENV_VARS = [ 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', @@ -13,14 +15,21 @@ export type ClaudeEnvPatch = { export function applyClaudeEnvPatch( baseEnv: Record, patch: ClaudeEnvPatch, - options?: { stripAuthEnv?: boolean } + options?: { stripAuthEnv?: boolean; platform?: NodeJS.Platform } ): Record { if (options?.stripAuthEnv) { for (const key of CLAUDE_AUTH_ENV_VARS) { delete baseEnv[key] } - if (isAuthLikeCustomHeaders(baseEnv.ANTHROPIC_CUSTOM_HEADERS)) { - delete baseEnv.ANTHROPIC_CUSTOM_HEADERS + const platform = options.platform ?? process.platform + for (const key of Object.keys(baseEnv)) { + const normalized = platform === 'win32' ? key.toUpperCase() : key + if ( + (platform === 'win32' && CLAUDE_AUTH_ENV_VARS.some((authKey) => authKey === normalized)) || + (normalized === 'ANTHROPIC_CUSTOM_HEADERS' && isAuthLikeCustomHeaders(baseEnv[key])) + ) { + delete baseEnv[key] + } } } @@ -34,16 +43,94 @@ export function applyClaudeEnvPatch( return baseEnv } -export function hasClaudeAuthEnvConflict(env: Record | undefined): boolean { - if (!env) { +/** One string for every transport, so a terminal launch and a structured launch + * cannot drift into telling the user two different things about one refusal. */ +export const CLAUDE_AUTH_ENV_CONFLICT_MESSAGE = + 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' + +export const CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE = + 'A Claude account switch is in progress. Try again after it finishes.' + +/** + * Whether a launch on the host runtime must drop inherited Anthropic auth. + * + * Only a pinned host-managed account owns the credential, so only it may strip: + * with no managed account the user's own `ANTHROPIC_*` is their sign-in, and + * removing it signs them out of a CLI that would otherwise have worked. + */ +export function shouldStripClaudeAuthEnvForAccount( + accounts: readonly ClaudeManagedAccount[] | undefined, + activeAccountId: string | null | undefined +): boolean { + if (!activeAccountId) { return false } return ( - CLAUDE_AUTH_ENV_VARS.some((key) => Boolean(env[key])) || - isAuthLikeCustomHeaders(env.ANTHROPIC_CUSTOM_HEADERS) + (accounts ?? []).find((account) => account.id === activeAccountId)?.managedAuthRuntime !== 'wsl' ) } +/** + * Whether a launch's explicit env carries Anthropic auth a managed account must own. + * + * The key comparison mirrors applyClaudeEnvPatch's strip exactly: case-insensitive on + * win32, where the OS folds env names so `anthropic_api_key` is an effective + * `ANTHROPIC_API_KEY`, and case-sensitive elsewhere. A refusal narrower than the strip + * lets an override through that the strip would have removed. + * + * A non-empty value is what makes it a conflict. `ANTHROPIC_API_KEY=` in the agent env + * box is how a user blanks a variable — the settings pipeline preserves that empty value + * (normalizeTuiAgentEnvRecord drops empty KEYS only) — and an empty override can neither + * authenticate nor beat the pinned account, while the strip removes the name regardless. + * Refusing it would break a terminal launch that works today for no security gain. + */ +/** + * The inherited Anthropic auth a non-stripping launch has to carry forward explicitly. + * + * applyClaudeEnvPatch always strips the inherited half of a child env, and the + * configured half is what overrides it — so a system-auth user's own key only survives + * if the caller puts it back deliberately. Returns the exact keys present, so a + * win32 `anthropic_api_key` is carried under the name the OS actually has. + */ +export function claudeAuthEnvCarriedForward( + inherited: NodeJS.ProcessEnv, + platform: NodeJS.Platform = process.platform +): Record { + const carried: Record = {} + for (const [key, value] of Object.entries(inherited)) { + if (value === undefined) { + continue + } + const normalized = platform === 'win32' ? key.toUpperCase() : key + if ( + CLAUDE_AUTH_ENV_VARS.some((authKey) => authKey === normalized) || + (normalized === 'ANTHROPIC_CUSTOM_HEADERS' && isAuthLikeCustomHeaders(value)) + ) { + carried[key] = value + } + } + return carried +} + +export function hasClaudeAuthEnvConflict( + env: Record | undefined, + platform: NodeJS.Platform = process.platform +): boolean { + if (!env) { + return false + } + for (const [key, value] of Object.entries(env)) { + const normalized = platform === 'win32' ? key.toUpperCase() : key + if (value && CLAUDE_AUTH_ENV_VARS.some((authKey) => authKey === normalized)) { + return true + } + if (normalized === 'ANTHROPIC_CUSTOM_HEADERS' && isAuthLikeCustomHeaders(value)) { + return true + } + } + return false +} + function isAuthLikeCustomHeaders(value: string | undefined): boolean { if (!value) { return false diff --git a/src/main/claude-accounts/live-pty-gate.ts b/src/main/claude-accounts/live-pty-gate.ts index 9e30b621924..caab66c3430 100644 --- a/src/main/claude-accounts/live-pty-gate.ts +++ b/src/main/claude-accounts/live-pty-gate.ts @@ -5,6 +5,13 @@ const liveClaudePtyIds = new Set() // survived the app restart inside the daemon. const seededUnconfirmedPtyIds = new Set() let switchInProgress = false +// Woken by endClaudeAuthSwitch so a caller past the point of no return can wait the +// swap out instead of refusing. See whenClaudeAuthSwitchSettles. +const switchSettledListeners = new Set<() => void>() + +/** A managed account swap is a credential-file rewrite, not a network round trip; + * anything past this is a wedged switch, and refusing beats waiting forever. */ +export const CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS = 15_000 export type ClaudeLivePtyPersistence = { addClaudeLivePtySessionId(sessionId: string): void @@ -81,6 +88,35 @@ export function markClaudePtyExited(ptyId: string): void { notifyDrainedOnTransition(hadLivePtys) } +/** + * Register a structured Claude child with the same gate the terminal path uses. + * + * The gate is what makes the managed OAuth refresh defer instead of rotating a + * single-use refresh token out from under a running Claude (runtime-auth-sync.ts). + * A structured session's child is as much a live Claude as a PTY's is, so it has to + * hold the gate too — otherwise a refresh mid-turn breaks its next API call while an + * identical terminal session is protected. + * + * Deliberately not persisted, unlike markClaudePtySpawned: these children are direct + * children of this process and cannot survive a restart, so seeding them back on the + * next launch would hold the gate closed for a process that is provably gone. + */ +export function markClaudeStructuredChildSpawned(childKey: string): void { + liveClaudePtyIds.add(structuredChildGateId(childKey)) +} + +export function markClaudeStructuredChildExited(childKey: string): void { + const hadLivePtys = liveClaudePtyIds.size > 0 + liveClaudePtyIds.delete(structuredChildGateId(childKey)) + notifyDrainedOnTransition(hadLivePtys) +} + +// Namespaced so a structured child can never collide with a daemon PTY session id, +// which confirmSeededClaudeLivePtys reconciles against the daemon's own list. +function structuredChildGateId(childKey: string): string { + return `claude-structured:${childKey}` +} + export function hasLiveClaudePtys(): boolean { return liveClaudePtyIds.size > 0 } @@ -93,7 +129,44 @@ export function beginClaudeAuthSwitch(): void { } export function endClaudeAuthSwitch(): void { + const wasInProgress = switchInProgress switchInProgress = false + if (!wasInProgress) { + return + } + // Each listener removes itself as it settles; Set iteration is defined over that. + for (const listener of switchSettledListeners) { + listener() + } +} + +/** + * Resolves `true` once no account switch is running, `false` if one is still running + * at the deadline. + * + * Exists for callers that have already done irreversible work — a structured acquire + * has closed the old child by the time it resolves its launch, so turning a switch + * into a refusal there strands the user with a dead session and no replacement. + * Waiting for the swap and then launching against it is the recoverable answer; + * refusing is only correct when nothing has been torn down yet. + */ +export function whenClaudeAuthSwitchSettles( + timeoutMs = CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS +): Promise { + if (!switchInProgress) { + return Promise.resolve(true) + } + return new Promise((resolve) => { + const settle = (settled: boolean): void => { + switchSettledListeners.delete(listener) + clearTimeout(timer) + resolve(settled) + } + const listener = (): void => settle(true) + switchSettledListeners.add(listener) + const timer = setTimeout(() => settle(false), timeoutMs) + timer.unref?.() + }) } export function isClaudeAuthSwitchInProgress(): boolean { diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts index ae79c4c7bbb..dabcd9d472f 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts @@ -2,6 +2,7 @@ import { join } from 'node:path' import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' import { resolveLocalAccountRuntimeTarget } from '../../../shared/local-account-runtime' import { parseWslUncPath } from '../../../shared/wsl-paths' +import { shouldStripClaudeAuthEnvForAccount } from '../environment' import { getDefaultWslDistro, getWslHome } from '../../wsl' import { getSelectedClaudeAccountIdForTarget, @@ -69,7 +70,10 @@ export class ClaudeRuntimeAuthPreparationService extends ClaudeRuntimeAuthSnapsh wslDistro: null, wslLinuxConfigDir: null, envPatch: paths.envPatch, - stripAuthEnv: Boolean(activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl'), + stripAuthEnv: shouldStripClaudeAuthEnvForAccount( + settings.claudeManagedAccounts, + activeAccountId + ), managedRefreshDeferredByLivePty: Boolean( activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl' && diff --git a/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs b/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs new file mode 100644 index 00000000000..4f2a09425fd --- /dev/null +++ b/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs @@ -0,0 +1,144 @@ +// Scripted stand-in for the Claude Code CLI, driven by the SDK contract-pin +// tests. It speaks just enough stream-json to satisfy the SDK: it answers every +// inbound control_request with a success control_response, records everything it +// observes to a report file, and plays back the steps listed in a scenario file. +// +// Env contract (set by the test): +// ORCA_SDK_CONTRACT_SCENARIO_PATH — JSON file +// { steps: Step[], controlResponses?: { [subtype]: } } where a Step is +// { emit: } | { awaitUserMessage: true } | { stderr: } | +// { awaitControlResponse: } | { delayMs: } | { exit: } +// ORCA_SDK_CONTRACT_REPORT_PATH — where argv/env observations are written +// ORCA_SDK_CONTRACT_IGNORE_SIGTERM — trap SIGTERM/SIGINT and outlive stdin close +// ORCA_SDK_CONTRACT_IGNORE_CONTROL_REQUESTS — record control requests but never answer +// ORCA_SDK_CONTRACT_DESCENDANT — fork an idle grandchild and report its pid +import { spawn } from 'node:child_process' +import { readFileSync, writeFileSync } from 'node:fs' +import { createInterface } from 'node:readline' + +const scenarioPath = process.env.ORCA_SDK_CONTRACT_SCENARIO_PATH +const reportPath = process.env.ORCA_SDK_CONTRACT_REPORT_PATH + +const report = { + argv: process.argv.slice(1), + execPath: process.execPath, + controlRequests: [], + controlResponses: [], + userMessages: [], + descendantPid: null +} +const writeReport = () => { + if (reportPath) { + writeFileSync(reportPath, JSON.stringify(report)) + } +} +// Written immediately so a test can prove which script the SDK executed even if +// the session dies before the scenario completes. +writeReport() + +const scenario = scenarioPath ? JSON.parse(readFileSync(scenarioPath, 'utf8')) : { steps: [] } + +if (process.env.ORCA_SDK_CONTRACT_IGNORE_SIGTERM) { + process.on('SIGTERM', () => {}) + process.on('SIGINT', () => {}) + setInterval(() => {}, 1_000_000) +} +if (process.env.ORCA_SDK_CONTRACT_DESCENDANT) { + const descendant = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000000)'], { + stdio: 'ignore' + }) + descendant.unref() + report.descendantPid = descendant.pid ?? null + writeReport() +} + +const emit = (frame) => process.stdout.write(`${JSON.stringify(frame)}\n`) + +const waiters = [] +const settle = (kind, requestId) => { + for (let i = waiters.length - 1; i >= 0; i--) { + const waiter = waiters[i] + if ( + waiter.kind === kind && + (waiter.requestId === undefined || waiter.requestId === requestId) + ) { + waiters.splice(i, 1) + waiter.resolve() + } + } +} +const waitFor = (kind, requestId) => { + if (kind === 'user' && report.userMessages.length > 0) { + return Promise.resolve() + } + if ( + kind === 'control_response' && + report.controlResponses.some((frame) => frame.response?.request_id === requestId) + ) { + return Promise.resolve() + } + return new Promise((resolve) => waiters.push({ kind, requestId, resolve })) +} + +createInterface({ input: process.stdin }).on('line', (line) => { + let frame + try { + frame = JSON.parse(line) + } catch { + return + } + if (frame.type === 'control_request') { + report.controlRequests.push(frame) + writeReport() + if (process.env.ORCA_SDK_CONTRACT_IGNORE_CONTROL_REQUESTS) { + return + } + emit({ + type: 'control_response', + response: { + subtype: 'success', + request_id: frame.request_id, + response: scenario.controlResponses?.[frame.request?.subtype] ?? { + commands: [], + models: [] + } + } + }) + return + } + if (frame.type === 'control_response') { + report.controlResponses.push(frame) + writeReport() + settle('control_response', frame.response?.request_id) + return + } + if (frame.type === 'user') { + report.userMessages.push(frame) + writeReport() + settle('user') + } +}) + +// Never outlive a wedged test: the readline subscription would otherwise hold +// this process open forever if the SDK side stops driving the scenario. +setTimeout(() => process.exit(3), 20_000).unref() + +for (const step of scenario.steps) { + if (step.emit) { + emit(step.emit) + } else if (step.stderr !== undefined) { + process.stderr.write(step.stderr) + } else if (step.awaitUserMessage) { + await waitFor('user') + } else if (step.awaitControlResponse !== undefined) { + await waitFor('control_response', step.awaitControlResponse) + } else if (step.delayMs) { + await new Promise((resolve) => setTimeout(resolve, step.delayMs)) + } else if (step.exit !== undefined) { + // A CLI that refuses to start: leave with its own status, stderr already written. + writeReport() + process.exit(step.exit) + } +} +writeReport() +process.exit(0) diff --git a/src/main/claude/claude-agent-sdk-contract-pins.test.ts b/src/main/claude/claude-agent-sdk-contract-pins.test.ts new file mode 100644 index 00000000000..46bcb7219d3 --- /dev/null +++ b/src/main/claude/claude-agent-sdk-contract-pins.test.ts @@ -0,0 +1,519 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { + query, + type CanUseTool, + type Options, + type SDKUserMessage, + type SpawnedProcess as SdkSpawnedProcess, + type SpawnOptions as SdkSpawnOptions +} from '@anthropic-ai/claude-agent-sdk' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { spawnProcess } from '../../shared/child-process/run-process' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { claudeQuerySettingsReader } from './claude-agent-sdk-control-requests' +import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' + +// Contract pins for @anthropic-ai/claude-agent-sdk, run against the real SDK +// driving a scripted fake CLI (never the real Claude binary). These tests exist +// to catch a future SDK version drifting under Orca: unknown-frame pass-through, +// spawner env fidelity, argument parity with the pre-SDK argv, +// permission-callback semantics, and executable-path override. + +const FAKE_CLI = join(__dirname, '__fixtures__', 'claude-agent-sdk-scripted-cli.mjs') +const SESSION_ID = '5348c19f-6a54-4c2e-9c68-9c2b1a3d4e5f' +const LEAF_UUID = 'ad0f7c9e-1b2c-4d3e-8f90-abc123def456' +const PINNED_SDK_VERSION = '0.3.251' +const SDK_PLATFORM_PACKAGE_BASENAMES = [ + 'claude-agent-sdk-darwin-arm64', + 'claude-agent-sdk-darwin-x64', + 'claude-agent-sdk-linux-arm64', + 'claude-agent-sdk-linux-arm64-musl', + 'claude-agent-sdk-linux-x64', + 'claude-agent-sdk-linux-x64-musl', + 'claude-agent-sdk-win32-arm64', + 'claude-agent-sdk-win32-x64' +] + +/** + * The exact argv the hand-rolled transport built before the SDK swap. Frozen here + * as the parity oracle: CLAUDE_STRUCTURED_BASE_OPTIONS has to keep producing it. + */ +const PRE_SDK_ARGV = [ + '-p', + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + '--include-partial-messages', + '--verbose', + '--replay-user-messages', + '--permission-prompt-tool', + 'stdio', + '--setting-sources', + 'user,project,local' +] + +const RESULT_FRAME = { + type: 'result', + subtype: 'success', + is_error: false, + duration_ms: 1, + duration_api_ms: 1, + num_turns: 1, + result: 'ok', + session_id: SESSION_ID, + total_cost_usd: 0, + usage: { input_tokens: 1, output_tokens: 1 }, + uuid: 'uuid-result-1' +} + +type ScenarioStep = Record +type SpawnSeen = { + command: string + args: string[] + cwd: string | undefined + env: Record +} +type ScriptedCliReport = { + argv: string[] + execPath: string + controlRequests: { request_id: string; request: { subtype: string } }[] + controlResponses: { response: { request_id: string; response?: Record } }[] + userMessages: Record[] +} + +const scratchDirs: string[] = [] +afterEach(() => { + vi.unstubAllEnvs() + for (const dir of scratchDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function scriptScenario( + steps: ScenarioStep[], + controlResponses: Record = {} +): { + scenarioPath: string + reportPath: string + cwd: string + readReport: () => ScriptedCliReport +} { + const dir = mkdtempSync(join(tmpdir(), 'claude-sdk-contract-')) + scratchDirs.push(dir) + const scenarioPath = join(dir, 'scenario.json') + const reportPath = join(dir, 'report.json') + writeFileSync(scenarioPath, JSON.stringify({ steps, controlResponses })) + return { + scenarioPath, + reportPath, + cwd: dir, + readReport: () => JSON.parse(readFileSync(reportPath, 'utf8')) as ScriptedCliReport + } +} + +function scenarioEnv(scenario: { scenarioPath: string; reportPath: string }) { + return { + PATH: process.env.PATH, + ORCA_SDK_CONTRACT_SCENARIO_PATH: scenario.scenarioPath, + ORCA_SDK_CONTRACT_REPORT_PATH: scenario.reportPath + } +} + +function recordingSpawner(spawns: SpawnSeen[]) { + return (opts: SdkSpawnOptions): SdkSpawnedProcess => { + spawns.push({ + command: opts.command, + args: [...opts.args], + cwd: opts.cwd, + env: { ...opts.env } + }) + return spawnProcess({ + program: opts.command, + args: opts.args, + cwd: opts.cwd, + env: opts.env as NodeJS.ProcessEnv, + signal: opts.signal + }) as unknown as SdkSpawnedProcess + } +} + +function resolvedLaunch(launchArgs: string[]) { + const record = { + sessionId: 'contract-pin-session', + provider: 'claude', + location: { + executionHostId: LOCAL_EXECUTION_HOST_ID, + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/work/.claude' }, + providerHandleChain: [], + launchArgs + } as unknown as AgentSessionRecord + return createClaudeStructuredLaunchResolver({ + store: { getRecord: () => record } as unknown as AgentSessionRecordStore, + resolveWorkspacePath: async () => '/repos/workspace-1', + resolveCommand: () => FAKE_CLI, + resolveAuthPolicy: () => ({ stripAuthEnv: true }) + })({ identity: { sessionId: record.sessionId } as never }) +} + +function singleUserTurn(): AsyncIterable { + return (async function* () { + yield { + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + parent_tool_use_id: null, + session_id: SESSION_ID + } as SDKUserMessage + // Hold input open; the stream ends when the scripted CLI exits, and an + // unresolved bare promise does not keep the event loop alive. + await new Promise(() => {}) + })() +} + +async function drainQuery(options: Options): Promise[]> { + const messages: Record[] = [] + for await (const message of query({ prompt: singleUserTurn(), options })) { + messages.push(message as unknown as Record) + } + return messages +} + +/** Expand `--flag=value` argv entries so both SDK spellings compare equal. */ +function normalizeArgv(args: string[]): string[] { + return args.flatMap((arg) => { + if (!arg.startsWith('--')) { + return [arg] + } + const eq = arg.indexOf('=') + return eq === -1 ? [arg] : [arg.slice(0, eq), arg.slice(eq + 1)] + }) +} + +/** Group the pre-SDK argv into flag/value pairs. */ +function flagTable(args: readonly string[]): { flag: string; value: string | null }[] { + const table: { flag: string; value: string | null }[] = [] + for (let i = 0; i < args.length; i++) { + const flag = args[i]! + const next = args[i + 1] + if (next !== undefined && !next.startsWith('-')) { + table.push({ flag, value: next }) + i++ + } else { + table.push({ flag, value: null }) + } + } + return table +} + +describe('Claude Agent SDK contract pins', () => { + it('yields unknown types, unknown fields and unknown content blocks verbatim, and consumes keep_alive', async () => { + const unknownTopLevel = { + type: 'message_kind_from_the_future', + session_id: SESSION_ID, + uuid: 'uuid-unknown-1', + payload: { alpha: 1, nested: { flags: ['a', 'b'] } } + } + const assistantWithUnknowns = { + type: 'assistant', + message: { + id: 'msg-1', + type: 'message', + role: 'assistant', + model: 'claude-x', + content: [ + { type: 'text', text: 'hello back' }, + { type: 'content_block_from_the_future', payload: { depth: 3 } } + ], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 2 } + }, + parent_tool_use_id: null, + uuid: 'uuid-assistant-1', + session_id: SESSION_ID, + field_from_the_future: 'preserved' + } + const scenario = scriptScenario([ + { awaitUserMessage: true }, + { emit: { type: 'keep_alive' } }, + { emit: unknownTopLevel }, + { emit: assistantWithUnknowns }, + { emit: RESULT_FRAME } + ]) + const spawns: SpawnSeen[] = [] + const messages = await drainQuery({ + pathToClaudeCodeExecutable: FAKE_CLI, + cwd: scenario.cwd, + env: scenarioEnv(scenario), + spawnClaudeCodeProcess: recordingSpawner(spawns) + }) + + expect(messages.find((m) => m.uuid === 'uuid-unknown-1')).toEqual(unknownTopLevel) + expect(messages.find((m) => m.uuid === 'uuid-assistant-1')).toEqual(assistantWithUnknowns) + // The SDK intercepts keep_alive internally — a liveness signal must never + // be derived from it reaching the consumer, because it does not. + expect(messages.some((m) => m.type === 'keep_alive')).toBe(false) + expect(messages.some((m) => m.type === 'result')).toBe(true) + }) + + it('hands the custom spawner exactly the caller-supplied env, plus the two pinned SDK mutations', async () => { + vi.stubEnv('ANTHROPIC_API_KEY', 'ambient-key-must-not-leak') + const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }]) + const spawns: SpawnSeen[] = [] + await drainQuery({ + pathToClaudeCodeExecutable: FAKE_CLI, + cwd: scenario.cwd, + env: { + ...scenarioEnv(scenario), + CLAUDE_CONFIG_DIR: '/pinned/claude-config', + ORCA_AGENT_SESSION_SPAWN_TOKEN: 'spawn-token-1', + NODE_OPTIONS: '--max-old-space-size=64' + }, + spawnClaudeCodeProcess: recordingSpawner(spawns) + }) + + const env = spawns[0]!.env + // Supplied values arrive verbatim: the config-dir pin and spawn token are + // observable at this boundary, so Orca's auth scrubbing stays assertable. + expect(env.CLAUDE_CONFIG_DIR).toBe('/pinned/claude-config') + expect(env.ORCA_AGENT_SESSION_SPAWN_TOKEN).toBe('spawn-token-1') + // Ambient process.env is NOT merged in when env is supplied. + expect(env.ANTHROPIC_API_KEY).toBeUndefined() + // The SDK's two documented mutations, pinned so a change is noticed. + expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-ts') + expect('NODE_OPTIONS' in env).toBe(false) + }) + + it('inherits process.env into the child when env is omitted — the ambient-auth sharp edge', async () => { + const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }]) + vi.stubEnv('ORCA_SDK_CONTRACT_SCENARIO_PATH', scenario.scenarioPath) + vi.stubEnv('ORCA_SDK_CONTRACT_REPORT_PATH', scenario.reportPath) + vi.stubEnv('ORCA_SDK_CONTRACT_AMBIENT_CANARY', 'inherited-from-process-env') + const spawns: SpawnSeen[] = [] + await drainQuery({ + pathToClaudeCodeExecutable: FAKE_CLI, + cwd: scenario.cwd, + spawnClaudeCodeProcess: recordingSpawner(spawns) + }) + + // Omitting env reproduces the ambient-auth-leak failure mode: the child + // sees everything in process.env. Orca must therefore always pass an + // explicit, fully-constructed env. + expect(spawns[0]!.env.ORCA_SDK_CONTRACT_AMBIENT_CANARY).toBe('inherited-from-process-env') + }) + + it('emits --replay-user-messages only through extraArgs, never on its own', async () => { + const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }]) + const bareSpawns: SpawnSeen[] = [] + await drainQuery({ + pathToClaudeCodeExecutable: FAKE_CLI, + cwd: scenario.cwd, + env: scenarioEnv(scenario), + spawnClaudeCodeProcess: recordingSpawner(bareSpawns) + }) + expect(bareSpawns[0]!.args).not.toContain('--replay-user-messages') + + const replayScenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }]) + const replaySpawns: SpawnSeen[] = [] + await drainQuery({ + pathToClaudeCodeExecutable: FAKE_CLI, + cwd: replayScenario.cwd, + env: scenarioEnv(replayScenario), + extraArgs: { 'replay-user-messages': null }, + spawnClaudeCodeProcess: recordingSpawner(replaySpawns) + }) + const replayArgs = replaySpawns[0]!.args + expect(replayArgs.filter((arg) => arg === '--replay-user-messages')).toHaveLength(1) + }) + + it('produces a matching CLI flag for every pre-SDK argv entry', async () => { + const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }]) + const spawns: SpawnSeen[] = [] + // Driven by the real resolver, so the argv walk covers the durable-launchArgs + // translation and its merge order, not a hand-written options literal. + const launch = await resolvedLaunch(['--model', 'claude-sonnet-4-5', '--effort', 'high']) + await drainQuery({ + ...launch.options, + pathToClaudeCodeExecutable: FAKE_CLI, + cwd: scenario.cwd, + env: scenarioEnv(scenario), + canUseTool: (async () => ({ behavior: 'deny', message: 'unused' })) as CanUseTool, + spawnClaudeCodeProcess: recordingSpawner(spawns) + }) + + expect(spawns).toHaveLength(1) + const argv = normalizeArgv(spawns[0]!.args) + // Typed-first translation must not also spell the flag through extraArgs. + for (const flag of ['--model', '--effort']) { + expect( + argv.filter((arg) => arg === flag), + `${flag} occurrences` + ).toHaveLength(1) + } + expect(argv[argv.indexOf('--model') + 1]).toBe('claude-sonnet-4-5') + expect(argv[argv.indexOf('--effort') + 1]).toBe('high') + // Headless print mode is the SDK's only mode; `query()` never passes `-p`, + // and if the SDK ever started passing it this pin would notice. + const impliedByHeadlessQuery = new Set(['-p']) + for (const entry of flagTable(PRE_SDK_ARGV)) { + if (impliedByHeadlessQuery.has(entry.flag)) { + expect(argv, `${entry.flag} is implied, never spelled`).not.toContain(entry.flag) + continue + } + const at = argv.indexOf(entry.flag) + expect(at, `SDK argv is missing ${entry.flag}`).toBeGreaterThanOrEqual(0) + if (entry.value !== null) { + expect(argv[at + 1], `value of ${entry.flag}`).toBe(entry.value) + } + } + // The launch resolver always carries one of --session-id / --resume. + const sessionAt = argv.indexOf('--session-id') + expect(sessionAt).toBeGreaterThanOrEqual(0) + expect(argv[sessionAt + 1]).toBe(launch.providerSessionId) + }) + + it('still exposes the runtime get_settings reader the auth diagnostic depends on', async () => { + // 0.3.251 ships getSettings() but redacts it from the Query declaration. This pin + // is the drift alarm: if a bump drops or reshapes it, the diagnostic degrades and + // this test says so instead of the degradation shipping silently. + const settings = { env: { ANTHROPIC_BASE_URL: 'https://settings.example.test' } } + const scenario = scriptScenario([{ delayMs: 3_000 }], { get_settings: settings }) + const session = query({ + prompt: singleUserTurn(), + options: { + pathToClaudeCodeExecutable: FAKE_CLI, + cwd: scenario.cwd, + env: scenarioEnv(scenario) + } + }) + try { + const read = claudeQuerySettingsReader(session) + expect(read, 'the SDK no longer exposes get_settings at runtime').not.toBeNull() + await expect(read?.()).resolves.toEqual(settings) + } finally { + await session.return(undefined) + } + }) + + it('maps resume identity to --resume and --resume-session-at', async () => { + const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }]) + const spawns: SpawnSeen[] = [] + await drainQuery({ + pathToClaudeCodeExecutable: FAKE_CLI, + cwd: scenario.cwd, + env: scenarioEnv(scenario), + resume: SESSION_ID, + resumeSessionAt: LEAF_UUID, + spawnClaudeCodeProcess: recordingSpawner(spawns) + }) + + const argv = normalizeArgv(spawns[0]!.args) + const resumeAt = argv.indexOf('--resume') + expect(resumeAt).toBeGreaterThanOrEqual(0) + expect(argv[resumeAt + 1]).toBe(SESSION_ID) + const leafAt = argv.indexOf('--resume-session-at') + expect(leafAt).toBeGreaterThanOrEqual(0) + expect(argv[leafAt + 1]).toBe(LEAF_UUID) + }) + + it('gives canUseTool the wire request_id and fires its abort signal on control_cancel_request', async () => { + const scenario = scriptScenario([ + { awaitUserMessage: true }, + { + emit: { + type: 'control_request', + request_id: 'perm-421', + request: { + subtype: 'can_use_tool', + tool_name: 'Bash', + input: { command: 'echo hi' }, + tool_use_id: 'tool-use-9' + } + } + }, + { delayMs: 120 }, + { emit: { type: 'control_cancel_request', request_id: 'perm-421' } }, + { awaitControlResponse: 'perm-421' }, + { emit: RESULT_FRAME } + ]) + const seen: { toolName: string; requestId: string; toolUseID: string }[] = [] + let abortFired = false + const canUseTool: CanUseTool = (toolName, _input, { signal, requestId, toolUseID }) => { + seen.push({ toolName, requestId, toolUseID }) + return new Promise((resolve) => { + signal.addEventListener('abort', () => { + abortFired = true + resolve({ behavior: 'deny', message: 'cancelled by test' }) + }) + }) + } + const spawns: SpawnSeen[] = [] + await drainQuery({ + pathToClaudeCodeExecutable: FAKE_CLI, + cwd: scenario.cwd, + env: scenarioEnv(scenario), + canUseTool, + spawnClaudeCodeProcess: recordingSpawner(spawns) + }) + + expect(seen).toEqual([{ toolName: 'Bash', requestId: 'perm-421', toolUseID: 'tool-use-9' }]) + expect(abortFired).toBe(true) + // The callback's settlement is written back onto the wire against the same id. + const settled = scenario + .readReport() + .controlResponses.find((frame) => frame.response.request_id === 'perm-421') + expect(settled?.response.response?.behavior).toBe('deny') + // Exactly one process spawn per query, control traffic included. + expect(spawns).toHaveLength(1) + }) + + it('runs the executable given via pathToClaudeCodeExecutable under the default spawner', async () => { + const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }]) + const messages = await drainQuery({ + pathToClaudeCodeExecutable: FAKE_CLI, + cwd: scenario.cwd, + env: scenarioEnv(scenario) + }) + + expect(messages.some((m) => m.type === 'result')).toBe(true) + const report = scenario.readReport() + // The SDK executed exactly the script we pointed it at — no bundled binary. + expect(report.argv[0]).toBe(FAKE_CLI) + expect(report.execPath).toContain('node') + // And the streaming handshake went to it: the SDK sent its initialize + // control request to our script. + expect(report.controlRequests.some((frame) => frame.request.subtype === 'initialize')).toBe( + true + ) + }) + + it('pins the SDK version the contract was verified against', () => { + const sdkEntry = createRequire(__filename).resolve('@anthropic-ai/claude-agent-sdk') + const manifest = JSON.parse(readFileSync(join(dirname(sdkEntry), 'package.json'), 'utf8')) as { + version: string + } + expect(manifest.version).toBe(PINNED_SDK_VERSION) + }) + + it('keeps the eight bundled CLI platform binaries out of the install', () => { + const sdkEntry = createRequire(__filename).resolve('@anthropic-ai/claude-agent-sdk') + // The SDK's own scoped directory is where pnpm would link its optional + // platform packages; ignoredOptionalDependencies must keep them all absent. + const scopeDir = dirname(dirname(sdkEntry)) + for (const basename of SDK_PLATFORM_PACKAGE_BASENAMES) { + expect( + existsSync(join(scopeDir, basename, 'package.json')), + `${basename} must not be installed` + ).toBe(false) + } + }) +}) diff --git a/src/main/claude/claude-agent-sdk-control-requests.ts b/src/main/claude/claude-agent-sdk-control-requests.ts new file mode 100644 index 00000000000..6bd396413fa --- /dev/null +++ b/src/main/claude/claude-agent-sdk-control-requests.ts @@ -0,0 +1,154 @@ +import type { + PermissionMode, + Query, + SDKControlInterruptResponse +} from '@anthropic-ai/claude-agent-sdk' + +export class ClaudeControlRequestError extends Error { + constructor( + readonly subtype: string, + message: string + ) { + super(message) + this.name = 'ClaudeControlRequestError' + } +} + +export const CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS = 30_000 + +/** The SDK closes a query out from under an in-flight control request with this exact message. */ +const QUERY_CLOSED_MESSAGE = 'Query closed before response received' + +/** 0.3.251 ships getSettings() but redacts it from the Query declaration; the typeof guard below is its degradation path. */ +type ClaudeQuerySettingsReader = { getSettings?: () => Promise } + +export function claudeQuerySettingsReader(query: Query): (() => Promise) | null { + const reader = (query as unknown as ClaudeQuerySettingsReader).getSettings + return typeof reader === 'function' ? reader.bind(query) : null +} + +/** + * cancel_async_message is a runtime Query method the shipped 0.3.251 declaration omits; + * it withdraws a single still-queued async user message by uuid so an interrupted turn + * cannot spawn a later unexpected turn. The typeof guard is its degradation path. + */ +type ClaudeQueryAsyncCanceller = { cancelAsyncMessage?: (uuid: string) => Promise } + +export function claudeQueryAsyncCanceller( + query: Query +): ((uuid: string) => Promise) | null { + const cancel = (query as unknown as ClaudeQueryAsyncCanceller).cancelAsyncMessage + return typeof cancel === 'function' ? cancel.bind(query) : null +} + +export type ClaudeControlOptions = { timeoutMs?: number } + +/** + * Run one native Query control method under Orca's deadline and error classification. + * + * The SDK owns correlation but applies no deadline, so the timeout stays here — and its + * message is load-bearing: the init proof matches on `claude initialize request timed out`. + * A closed query is a transport failure, not the CLI rejecting the request, so only the + * latter is re-thrown as a `ClaudeControlRequestError` a caller may surface as a rejection. + */ +export function runClaudeControl( + subtype: string, + run: () => Promise, + timeoutMs: number = CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS +): Promise { + let timer: ReturnType | null = null + const deadline = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`claude ${subtype} request timed out`)), timeoutMs) + timer.unref?.() + }) + return Promise.race([ + Promise.resolve() + .then(run) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + if (error instanceof ClaudeControlRequestError || message === QUERY_CLOSED_MESSAGE) { + throw error + } + throw new ClaudeControlRequestError(subtype, message) + }), + deadline + ]).finally(() => { + if (timer) { + clearTimeout(timer) + } + }) +} + +/** The native control surface Orca drives, one method per Query control request. */ +export type ClaudeControlSurface = { + interrupt: ( + options?: ClaudeControlOptions & { cancelQueued?: boolean } + ) => Promise + cancelAsyncMessage: (uuid: string, options?: ClaudeControlOptions) => Promise + setModel: (model: string | undefined, options?: ClaudeControlOptions) => Promise + setPermissionMode: (mode: PermissionMode, options?: ClaudeControlOptions) => Promise + applyFlagSettings: ( + settings: Parameters[0], + options?: ClaudeControlOptions + ) => Promise + supportedModels: (options?: ClaudeControlOptions) => Promise + initializationResult: (options?: ClaudeControlOptions) => Promise + getSettings: (options?: ClaudeControlOptions) => Promise +} + +type InterruptingQuery = { + interrupt: (options?: { + cancelQueued?: boolean + }) => Promise +} + +export function createClaudeControlSurface(query: Query): ClaudeControlSurface { + return { + interrupt: (options) => + runClaudeControl( + 'interrupt', + () => + (query as unknown as InterruptingQuery).interrupt( + options?.cancelQueued ? { cancelQueued: true } : undefined + ), + options?.timeoutMs + ), + cancelAsyncMessage: (uuid, options) => { + const cancel = claudeQueryAsyncCanceller(query) + return cancel + ? runClaudeControl('cancel_async_message', () => cancel(uuid), options?.timeoutMs).then( + () => {} + ) + : Promise.resolve() + }, + setModel: (model, options) => + runClaudeControl('set_model', () => query.setModel(model), options?.timeoutMs).then(() => {}), + setPermissionMode: (mode, options) => + runClaudeControl( + 'set_permission_mode', + () => query.setPermissionMode(mode), + options?.timeoutMs + ).then(() => {}), + applyFlagSettings: (settings, options) => + runClaudeControl( + 'apply_flag_settings', + () => query.applyFlagSettings(settings), + options?.timeoutMs + ).then(() => {}), + supportedModels: (options) => + runClaudeControl('list_models', () => query.supportedModels(), options?.timeoutMs), + initializationResult: (options) => + runClaudeControl('initialize', () => query.initializationResult(), options?.timeoutMs), + getSettings: (options) => { + const read = claudeQuerySettingsReader(query) + return read + ? runClaudeControl('get_settings', read, options?.timeoutMs) + : Promise.reject( + new ClaudeControlRequestError( + 'get_settings', + 'this SDK exposes no get_settings request' + ) + ) + } + } +} diff --git a/src/main/claude/claude-agent-sdk-exit-proof-identity.test.ts b/src/main/claude/claude-agent-sdk-exit-proof-identity.test.ts new file mode 100644 index 00000000000..04d58067353 --- /dev/null +++ b/src/main/claude/claude-agent-sdk-exit-proof-identity.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest' +import type { DescendantSnapshot } from '../pty-descendant-termination' +import type { WindowsDescendantSnapshot } from '../windows-descendant-exit-verification' +import { collectDescendantRows } from '../pty-descendant-termination' +import { createClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof' +import { mergeClaudeCapturedTrees } from './claude-child-tree-snapshot' + +function posixSnapshot(capturedAtMs: number): DescendantSnapshot { + return { + root: { pid: 100, startedAt: 'Mon Jan 1 00:00:00 2026' }, + rootPgid: 100, + descendants: [{ pid: 200, ppid: 100, pgid: 100, startedAt: 'Mon Jan 1 00:00:01 2026' }], + capturedAtMs + } +} + +function windowsSnapshot(): WindowsDescendantSnapshot { + return { + root: { pid: 100, creationTimeMs: 5 }, + descendants: [{ pid: 200, creationTimeMs: 7 }], + unidentifiedCount: 0, + capturedAtMs: 1 + } +} + +describe('Claude child root identity', () => { + it('keeps a retained row boundary when a refresh observes no new descendants', () => { + const previous = posixSnapshot(1_700_000_000_900) + const next = posixSnapshot(1_700_000_002_100) + + expect( + mergeClaudeCapturedTrees( + { platform: 'posix', tree: previous }, + { platform: 'posix', tree: next } + ) + ).toEqual({ + platform: 'posix', + tree: { ...next, capturedAtMsByPid: { '200': previous.capturedAtMs } } + }) + }) + + it('keeps the descendant verdict when a POSIX root probe is unavailable', async () => { + const child = { pid: 100, kill: vi.fn(() => true) } + const terminateDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants: vi.fn(async () => posixSnapshot(1)), + terminateDescendants, + verifyRootIdentity: vi.fn(async () => false) + }) + + // POSIX runs no bare-pid root operation, so a declined probe withholds + // nothing: the handle kill still lands and the verification still speaks. + await expect(tree.reap()).resolves.toBe('exited') + expect(terminateDescendants).toHaveBeenCalled() + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('rejects mixed old and recycled root rows instead of making the tree killable', async () => { + const child = { pid: 100, kill: vi.fn(() => true) } + const terminateDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants: vi.fn(async () => + collectDescendantRows( + 100, + [ + { pid: 100, ppid: 1, pgid: 100, startedAt: 'Mon Jan 1 00:00:00 2026' }, + { pid: 100, ppid: 1, pgid: 101, startedAt: 'Mon Jan 1 00:00:01 2026' }, + { pid: 200, ppid: 100, pgid: 200, startedAt: 'Mon Jan 1 00:00:00 2026' } + ], + 1 + ) + ), + terminateDescendants, + verifyRootIdentity: vi.fn(async () => true) + }) + + await expect(tree.reap()).resolves.toBe('unverifiable') + // No admissible snapshot means no row may be signalled from its number, but + // the root still leaves through the handle Node owns. + expect(terminateDescendants).not.toHaveBeenCalled() + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('fails closed when Windows root identity revalidation is unavailable', async () => { + const child = { pid: 100, kill: vi.fn(() => true) } + const terminateWindowsTree = vi.fn(async () => {}) + const terminateWindowsDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'win32', + captureWindowsDescendants: vi.fn(async () => windowsSnapshot()), + terminateWindowsTree, + terminateWindowsDescendants, + verifyRootIdentity: vi.fn(async () => false) + }) + + await expect(tree.reap()).resolves.toBe('unverifiable') + // taskkill /T /F addresses a bare pid and stays gated; the handle does not. + expect(terminateWindowsTree).not.toHaveBeenCalled() + expect(terminateWindowsDescendants).not.toHaveBeenCalled() + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) +}) diff --git a/src/main/claude/claude-agent-sdk-exit-proof.test.ts b/src/main/claude/claude-agent-sdk-exit-proof.test.ts new file mode 100644 index 00000000000..3f15f8e8fca --- /dev/null +++ b/src/main/claude/claude-agent-sdk-exit-proof.test.ts @@ -0,0 +1,934 @@ +import { execFileSync } from 'node:child_process' +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { describe, expect, it, vi } from 'vitest' +import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' +import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification' +import type { DescendantSnapshot } from '../pty-descendant-termination' +import type { WindowsDescendantSnapshot } from '../windows-descendant-exit-verification' +import { + createClaudeChildTreeReaper as createClaudeChildTreeReaperImpl, + proveClaudeChildExit, + type ClaudeChildTreeReaper +} from './claude-agent-sdk-exit-proof' + +// The descendant models an MCP server: it either cooperates or, when it traps +// SIGTERM, only a forced, verified sweep can reach it. The root either traps +// SIGTERM too, or leaves promptly on stdin end the way a healthy CLI does — +// which is the path that used to skip descendant proof entirely. +function childWithDescendantScript(input: { + rootTrapsSigterm: boolean + descendantTrapsSigterm: boolean +}): string { + const descendantScript = `${input.descendantTrapsSigterm ? 'process.on("SIGTERM", () => {}); ' : ''}setInterval(() => {}, 1000000)` + const rootBehaviour = input.rootTrapsSigterm + ? `process.on('SIGTERM', () => {}) +process.on('SIGINT', () => {}) +setInterval(() => {}, 1000000)` + : `process.stdin.on('end', () => process.exit(0)) +process.stdin.resume()` + return ` +const descendant = require('node:child_process').spawn( + process.execPath, + ['-e', ${JSON.stringify(descendantScript)}], + { stdio: 'ignore' } +) +descendant.unref() +process.stdout.write(JSON.stringify({ descendantPid: descendant.pid }) + '\\n') +${rootBehaviour} +` +} + +const COOPERATIVE_CHILD = ` +process.stdin.on('end', () => process.exit(0)) +process.stdin.resume() +process.stdout.write('ready\\n') +` + +/** + * Sampled synchronously so it reads the exact moment the close boundary is + * crossed. A zombie has exited (its parent just has not reaped it yet), so a + * kill(pid, 0) probe would misreport it as running. + */ +function descendantState(pid: number): 'running' | 'exited' { + let state: string + try { + state = execFileSync('ps', ['-o', 'state=', '-p', String(pid)], { + encoding: 'utf8', + env: { ...process.env, LANG: 'C', LC_ALL: 'C' } + }).trim() + } catch (error) { + // ps exits 1 when no process matches; anything else is a failed probe, not an answer. + if ((error as { status?: number }).status !== 1) { + throw error + } + return 'exited' + } + return state.startsWith('Z') ? 'exited' : 'running' +} + +/** + * ps lstart is second-resolution, so the identity-safe sweep only SIGKILLs a row + * born strictly before the second the snapshot was captured in. The snapshot is + * armed the moment close begins, so a descendant born in that same second can + * only be asked, never forced — the same bound an MCP server spawned within a + * second of the user closing the chat would hit. + */ +function ageDescendantPastTheCaptureSecond(): Promise { + return new Promise((resolve) => setTimeout(resolve, 1_000 - (Date.now() % 1_000) + 20)) +} + +/** + * The close ladder as production drives it: `closeProcessRegistry` retries an + * unproven close, and each retry re-verifies the retained snapshot. A loaded + * host can spend one attempt's whole window inside `ps`, and reporting false + * there is the honest verdict — the requirement is that TRUE never outruns the + * observation, which the caller asserts at whichever boundary returns it. + */ +async function proveExitWithRetries( + input: Parameters[0], + attempts = 3 +): Promise { + for (let attempt = 1; attempt < attempts; attempt += 1) { + if (await proveClaudeChildExit(input)) { + return true + } + } + return proveClaudeChildExit(input) +} + +function spawnScript(script: string): ReturnType { + return spawnProcess({ + program: process.execPath, + args: ['-e', script], + stdio: ['pipe', 'pipe', 'pipe'] + }) +} + +function firstStdoutLine(child: ReturnType): Promise { + return new Promise((resolve) => { + child.stdout.setEncoding('utf8').once('data', (chunk: string) => resolve(chunk.trim())) + }) +} + +function observeExit(child: EventEmitter): { exitPromise: Promise; exited: () => boolean } { + let exited = false + const exitPromise = new Promise((resolve) => { + child.once('exit', () => { + exited = true + resolve() + }) + }) + return { exitPromise, exited: () => exited } +} + +/** `null` models a spawn that failed before a pid existed. */ +function mockChild( + pid: number | null = 424242 +): EventEmitter & + Pick & { kill: ReturnType } { + const child = new EventEmitter() + return Object.assign(child, { + pid: pid ?? undefined, + stdin: new PassThrough(), + kill: vi.fn(() => true) + }) as never +} + +/** A tree whose verdict is scripted per reap, recording when it was armed. */ +function mockTree(verdicts: DescendantTreeVerdict[]): ClaudeChildTreeReaper & { + capture: ReturnType + reap: ReturnType +} { + let treeVerdict: DescendantTreeVerdict = 'unverifiable' + return { + capture: vi.fn(async () => {}), + reap: vi.fn(async () => { + treeVerdict = verdicts.shift() ?? treeVerdict + return treeVerdict + }), + get treeVerdict() { + return treeVerdict + } + } +} + +function windowsSnapshotOf(descendantPid: number): WindowsDescendantSnapshot { + return { + root: { pid: 424242, creationTimeMs: 1_700_000_000_001 }, + descendants: [{ pid: descendantPid, creationTimeMs: 1_700_000_000_000 }], + unidentifiedCount: 0, + capturedAtMs: 1 + } +} + +function snapshotOf(descendantPid: number): DescendantSnapshot { + return { + root: { pid: 424242, startedAt: 'Mon Jan 1 00:00:00 2026' }, + rootPgid: 1, + descendants: [ + { pid: descendantPid, ppid: 424242, pgid: 1, startedAt: 'Mon Jan 1 00:00:00 2026' } + ], + capturedAtMs: 1 + } +} + +// Unit tests use synthetic process ids; production always supplies the fresh +// identity probe, so the harness explicitly models a matching probe. +function createClaudeChildTreeReaper( + child: Parameters[0], + deps: Parameters[1] = {} +): ReturnType { + return createClaudeChildTreeReaperImpl(child, { + verifyRootIdentity: async () => true, + ...deps + }) +} + +describe('claude child exit proof', () => { + it.runIf(process.platform !== 'win32')( + 'reports a proven exit only once a SIGTERM-resistant descendant is gone at the close boundary', + async () => { + const child = spawnScript( + childWithDescendantScript({ rootTrapsSigterm: true, descendantTrapsSigterm: true }) + ) + const { descendantPid } = JSON.parse(await firstStdoutLine(child)) as { + descendantPid: number + } + expect(descendantState(descendantPid)).toBe('running') + await ageDescendantPastTheCaptureSecond() + + try { + const proven = await proveExitWithRetries({ child, ...observeExit(child) }) + // Evaluated AT the boundary, not by polling until a deferred sweep timer + // wins: true releases the lease, so a descendant still running here is + // exactly the orphan the proof exists to prevent. False would be the + // honest verdict for a tree that outlived the bounded ladder. + expect({ proven, descendant: descendantState(descendantPid) }).toEqual({ + proven: true, + descendant: 'exited' + }) + } finally { + // Failure-safe only: the assertion above owns the requirement, this just + // stops a failing run from leaking a process. + try { + process.kill(descendantPid, 'SIGKILL') + } catch { + // Already gone. + } + } + }, + 20_000 + ) + + it.runIf(process.platform !== 'win32')( + 'proves a promptly exiting root only once its stubborn descendant is gone too', + async () => { + // The ordinary healthy close: the root leaves on stdin end within the graceful + // window. Its descendant must still be proven gone, not assumed gone with it. + const child = spawnScript( + childWithDescendantScript({ rootTrapsSigterm: false, descendantTrapsSigterm: true }) + ) + const { descendantPid } = JSON.parse(await firstStdoutLine(child)) as { + descendantPid: number + } + expect(descendantState(descendantPid)).toBe('running') + await ageDescendantPastTheCaptureSecond() + + try { + const proven = await proveExitWithRetries({ child, ...observeExit(child) }) + expect({ proven, descendant: descendantState(descendantPid) }).toEqual({ + proven: true, + descendant: 'exited' + }) + } finally { + try { + process.kill(descendantPid, 'SIGKILL') + } catch { + // Already gone. + } + } + }, + 20_000 + ) + + it.runIf(process.platform !== 'win32')( + 'still proves a stubborn child whose descendant honours SIGTERM', + async () => { + const child = spawnScript( + childWithDescendantScript({ rootTrapsSigterm: true, descendantTrapsSigterm: false }) + ) + const { descendantPid } = JSON.parse(await firstStdoutLine(child)) as { + descendantPid: number + } + try { + const proven = await proveExitWithRetries({ child, ...observeExit(child) }) + expect({ proven, descendant: descendantState(descendantPid) }).toEqual({ + proven: true, + descendant: 'exited' + }) + } finally { + try { + process.kill(descendantPid, 'SIGKILL') + } catch { + // Already gone. + } + } + }, + 20_000 + ) + + it('arms the snapshot before stdin closes and verifies it after a clean exit', async () => { + const child = spawnScript(COOPERATIVE_CHILD) + expect(await firstStdoutLine(child)).toBe('ready') + const exit = observeExit(child) + const tree = mockTree(['exited']) + let exitedWhenArmed: boolean | null = null + tree.capture.mockImplementation(async () => { + exitedWhenArmed = exit.exited() + }) + + await expect(proveClaudeChildExit({ child, ...exit, tree })).resolves.toBe(true) + // The snapshot is the only proof that survives the root: taken while it lived, + // verified once it left. A reap before the exit would have been the forced ladder. + expect(exitedWhenArmed).toBe(false) + expect(tree.reap).toHaveBeenCalledTimes(1) + expect(exit.exited()).toBe(true) + }, 20_000) + + it('proves a clean close of a childless root with one snapshot and no signal', async () => { + const child = spawnScript(COOPERATIVE_CHILD) + expect(await firstStdoutLine(child)).toBe('ready') + + await expect(proveClaudeChildExit({ child, ...observeExit(child) })).resolves.toBe(true) + }, 20_000) + + it('reports an unprovable exit as false rather than assuming the child died', async () => { + const child = mockChild() + const tree = mockTree(['exited']) + + await expect( + proveClaudeChildExit({ + child, + exitPromise: new Promise(() => {}), + exited: () => false, + tree + }) + ).resolves.toBe(false) + expect(tree.reap).toHaveBeenCalledTimes(1) + }, 20_000) + + it('reports false when the root exit was observed but a descendant was seen alive', async () => { + const child = mockChild() + const exit = observeExit(child) + const tree = mockTree(['live']) + tree.reap.mockImplementation(async () => { + child.emit('exit', null, 'SIGKILL') + return 'live' + }) + + await expect(proveClaudeChildExit({ child, ...exit, tree })).resolves.toBe(false) + expect(exit.exited()).toBe(true) + // One verification per attempt: the retried close re-verifies, this one does not. + expect(tree.reap).toHaveBeenCalledTimes(1) + }, 20_000) + + it('re-verifies an unproven tree on a retried close instead of trusting the dead root', async () => { + const child = mockChild() + const tree = mockTree(['exited']) + + await expect( + proveClaudeChildExit({ child, exitPromise: Promise.resolve(), exited: () => true, tree }) + ).resolves.toBe(true) + expect(tree.reap).toHaveBeenCalledTimes(1) + }) + + it('stays unproven for a root that left before any snapshot could be armed', async () => { + const child = mockChild() + const captureDescendants = vi.fn(async () => snapshotOf(4243)) + const terminateDescendants = vi.fn() + const tree = createClaudeChildTreeReaper(child, { + platform: 'darwin', + exited: () => true, + captureDescendants, + terminateDescendants + }) + + await expect( + proveClaudeChildExit({ child, exitPromise: Promise.resolve(), exited: () => true, tree }) + ).resolves.toBe(false) + // A dead root's descendants have reparented: walking its pid now could only + // sweep a stranger, so no walk is attempted and nothing is proven. + expect(captureDescendants).not.toHaveBeenCalled() + expect(terminateDescendants).not.toHaveBeenCalled() + expect(tree.treeVerdict).toBe('unverifiable') + }) +}) + +describe('claude child tree reaper', () => { + it('kills the root while verification runs and never stops it first', async () => { + const child = mockChild() + const release = Promise.withResolvers() + const terminateDescendants = vi.fn(() => release.promise) + const captureDescendants = vi.fn(async () => snapshotOf(4243)) + const tree = createClaudeChildTreeReaper(child, { + platform: 'darwin', + captureDescendants, + terminateDescendants + }) + + const first = tree.reap() + const second = tree.reap() + await vi.waitFor(() => expect(terminateDescendants).toHaveBeenCalledTimes(1)) + // A stopped root cannot verify: its killed children stay zombie rows in ps. + expect(child.kill.mock.calls).toEqual([['SIGKILL']]) + expect(tree.treeVerdict).toBe('unverifiable') + + release.resolve('exited') + await expect(Promise.all([first, second])).resolves.toEqual(['exited', 'exited']) + expect(captureDescendants).toHaveBeenCalledTimes(1) + expect(tree.treeVerdict).toBe('exited') + }) + + it('re-verifies the retained snapshot on a later reap rather than re-walking a dead root', async () => { + const child = mockChild() + const captureDescendants = vi.fn(async () => snapshotOf(4243)) + const terminateDescendants = vi + .fn() + .mockResolvedValueOnce('live') + .mockResolvedValueOnce('exited') + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants, + terminateDescendants + }) + + await expect(tree.reap()).resolves.toBe('live') + expect(tree.treeVerdict).toBe('live') + await expect(tree.reap()).resolves.toBe('exited') + expect(captureDescendants).toHaveBeenCalledTimes(1) + expect(terminateDescendants).toHaveBeenNthCalledWith(2, snapshotOf(4243)) + expect(tree.treeVerdict).toBe('exited') + }) + + it('keeps an observed exit when a later re-read cannot see the table', async () => { + const child = mockChild() + const terminateDescendants = vi + .fn() + .mockResolvedValueOnce('exited') + .mockResolvedValueOnce('unverifiable') + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants: vi.fn(async () => snapshotOf(4243)), + terminateDescendants + }) + + await expect(tree.reap()).resolves.toBe('exited') + await expect(tree.reap()).resolves.toBe('unverifiable') + expect(tree.treeVerdict).toBe('exited') + }) + + it('keeps an observed live descendant when a later re-read cannot see the table', async () => { + const child = mockChild() + // Reap #1 completed and saw a descendant alive at its deadline; the root then + // left on its own and the re-verification on a loaded host could not read the + // table. "Could not look" must not erase "was seen alive": the lease release + // gate is exactly the pair this distinguishes. + const terminateDescendants = vi + .fn() + .mockResolvedValueOnce('live') + .mockResolvedValueOnce('unverifiable') + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants: vi.fn(async () => snapshotOf(4243)), + terminateDescendants + }) + + await expect(tree.reap()).resolves.toBe('live') + await expect(tree.reap()).resolves.toBe('unverifiable') + expect(tree.treeVerdict).toBe('live') + }) + + it('treats an unreadable process table as unproven and re-walks the live root', async () => { + const child = mockChild() + // A loaded host can miss the table's deadline; while the root still lives + // that is a retryable read, not evidence that it has no descendants. + const captureDescendants = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(snapshotOf(4243)) + const terminateDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants, + terminateDescendants + }) + + await expect(tree.reap()).resolves.toBe('unverifiable') + expect(terminateDescendants).not.toHaveBeenCalled() + await expect(tree.reap()).resolves.toBe('exited') + expect(captureDescendants).toHaveBeenCalledTimes(2) + }) + + it('does not latch a missing root while it is still live', async () => { + const child = mockChild() + const captureDescendants = vi + .fn() + .mockResolvedValueOnce({ rootPgid: null, descendants: [], capturedAtMs: 1 }) + .mockResolvedValueOnce(snapshotOf(4243)) + const terminateDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants, + terminateDescendants + }) + + await tree.capture() + await expect(tree.reap()).resolves.toBe('exited') + expect(captureDescendants).toHaveBeenCalledTimes(2) + expect(terminateDescendants).toHaveBeenCalledWith(snapshotOf(4243)) + }) + + it('refreshes the live snapshot at close time so late descendants are included', async () => { + const child = mockChild() + const first = snapshotOf(4243) + const second = { + ...first, + descendants: [...first.descendants, { ...first.descendants[0], pid: 4244 }] + } + const captureDescendants = vi.fn().mockResolvedValueOnce(first).mockResolvedValueOnce(second) + const terminateDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants, + terminateDescendants + }) + + await tree.capture() + await tree.refresh?.() + await tree.reap() + + expect(captureDescendants).toHaveBeenCalledTimes(2) + expect(terminateDescendants).toHaveBeenCalledWith(second) + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('keeps the original capture boundary for retained POSIX rows', async () => { + const child = mockChild() + const first = { + ...snapshotOf(4243), + capturedAtMs: 1_700_000_000_900 + } + const refreshed = { + ...first, + capturedAtMs: 1_700_000_002_100, + descendants: [ + ...first.descendants, + { + pid: 4244, + ppid: 424242, + pgid: 1, + startedAt: 'Tue Jan 2 00:00:00 2026' + } + ] + } + const captureDescendants = vi.fn().mockResolvedValueOnce(first).mockResolvedValueOnce(refreshed) + const terminateDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants, + terminateDescendants + }) + + await tree.capture() + await tree.refresh?.() + await tree.reap() + + expect(terminateDescendants).toHaveBeenCalledWith({ + ...refreshed, + // The retained 4243 row was first observed in the earlier displayed + // second. Its per-row boundary must not advance with the refresh. + capturedAtMsByPid: { + '4243': first.capturedAtMs, + '4244': refreshed.capturedAtMs + } + }) + }) + + it('fails closed when a POSIX refresh reuses a PID with a new identity', async () => { + const child = mockChild() + const first = snapshotOf(4243) + const replacement = { + ...first, + descendants: [ + { + ...first.descendants[0], + pgid: 9, + startedAt: 'Tue Jan 2 00:00:00 2026' + } + ] + } + const captureDescendants = vi + .fn() + .mockResolvedValueOnce(first) + .mockResolvedValueOnce(replacement) + const terminateDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants, + terminateDescendants + }) + + await tree.capture() + await tree.refresh?.() + + await expect(tree.reap()).resolves.toBe('unverifiable') + // The descendant evidence is discarded; the root's identity never was in doubt. + expect(terminateDescendants).not.toHaveBeenCalled() + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('fails closed when a Windows refresh reuses a PID with a new creation time', async () => { + const child = mockChild() + const first = windowsSnapshotOf(4243) + const replacement = { + ...first, + descendants: [{ pid: 4243, creationTimeMs: first.descendants[0].creationTimeMs + 1 }] + } + const captureWindowsDescendants = vi + .fn() + .mockResolvedValueOnce(first) + .mockResolvedValueOnce(replacement) + const terminateWindowsTree = vi.fn(async () => {}) + const terminateWindowsDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'win32', + captureWindowsDescendants, + terminateWindowsTree, + terminateWindowsDescendants + }) + + await tree.capture() + await tree.refresh?.() + + await expect(tree.reap()).resolves.toBe('unverifiable') + expect(terminateWindowsTree).not.toHaveBeenCalled() + expect(terminateWindowsDescendants).not.toHaveBeenCalled() + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('queues a fresh boundary behind an output-triggered capture already in flight', async () => { + const child = mockChild() + const firstDone = Promise.withResolvers() + const first = snapshotOf(4243) + const second = { + ...first, + descendants: [...first.descendants, { ...first.descendants[0], pid: 4244 }] + } + const captureDescendants = vi + .fn() + .mockImplementationOnce(async () => { + await firstDone.promise + return first + }) + .mockResolvedValueOnce(second) + const terminateDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants, + terminateDescendants + }) + + const outputCapture = tree.refresh!() + await vi.waitFor(() => expect(captureDescendants).toHaveBeenCalledTimes(1)) + const closeCapture = tree.refresh!() + await Promise.resolve() + expect(captureDescendants).toHaveBeenCalledTimes(1) + + firstDone.resolve() + await closeCapture + await tree.reap() + + expect(captureDescendants).toHaveBeenCalledTimes(2) + expect(terminateDescendants).toHaveBeenCalledWith(second) + await outputCapture + }) + + it('retains a replacement descendant when the prior identity exited', async () => { + const child = mockChild() + const first = snapshotOf(4243) + const replacement = snapshotOf(4244) + const captureDescendants = vi + .fn() + .mockResolvedValueOnce(first) + .mockResolvedValueOnce(replacement) + const terminateDescendants = vi.fn(async (snapshot: DescendantSnapshot) => + snapshot.descendants.some((row) => row.pid === 4244) ? ('live' as const) : ('exited' as const) + ) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants, + terminateDescendants + }) + + await tree.capture() + await tree.refresh?.() + await expect(tree.reap()).resolves.toBe('live') + + expect(terminateDescendants).toHaveBeenCalledWith({ + ...replacement, + descendants: [...first.descendants, ...replacement.descendants] + }) + }) + + it('retains a Windows replacement descendant while preserving unidentified rows', async () => { + const child = mockChild() + const first = windowsSnapshotOf(4243) + const replacement = { + ...windowsSnapshotOf(4244), + unidentifiedCount: 0 + } + const captureWindowsDescendants = vi + .fn() + .mockResolvedValueOnce({ ...first, unidentifiedCount: 1 }) + .mockResolvedValueOnce(replacement) + const terminateWindowsTree = vi.fn(async () => {}) + const terminateWindowsDescendants = vi.fn(async (snapshot: WindowsDescendantSnapshot) => + snapshot.descendants.some((row) => row.pid === 4244) ? ('live' as const) : ('exited' as const) + ) + const tree = createClaudeChildTreeReaper(child, { + platform: 'win32', + captureWindowsDescendants, + terminateWindowsTree, + terminateWindowsDescendants + }) + + await tree.capture() + await tree.refresh?.() + await expect(tree.reap()).resolves.toBe('live') + + expect(terminateWindowsDescendants).toHaveBeenCalledWith({ + ...replacement, + descendants: [...first.descendants, ...replacement.descendants], + unidentifiedCount: 1 + }) + }) + + it('retains the prior identity-safe snapshot when a refresh is partial', async () => { + const child = mockChild() + const first = { + ...snapshotOf(4243), + descendants: [ + ...snapshotOf(4243).descendants, + { ...snapshotOf(4243).descendants[0], pid: 4244 } + ] + } + const captureDescendants = vi + .fn() + .mockResolvedValueOnce(first) + .mockResolvedValueOnce({ + ...first, + descendants: first.descendants.slice(0, 1) + }) + const terminateDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants, + terminateDescendants + }) + + await tree.capture() + await tree.refresh?.() + await tree.reap() + + expect(terminateDescendants).toHaveBeenCalledWith(first) + }) + + it('stops re-walking once the root is gone, however the table behaved', async () => { + const child = mockChild() + let exited = false + const captureDescendants = vi.fn(async () => null) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + exited: () => exited, + captureDescendants, + terminateDescendants: vi.fn() + }) + + await expect(tree.reap()).resolves.toBe('unverifiable') + // An unreadable table costs the snapshot, never the kill on the live root. + expect(child.kill).toHaveBeenCalledTimes(1) + exited = true + await expect(tree.reap()).resolves.toBe('unverifiable') + expect(captureDescendants).toHaveBeenCalledTimes(1) + // The second attempt observes a dead root: Node has dropped the handle, so + // there is nothing left to signal and no recycled pid to reach. + expect(child.kill).toHaveBeenCalledTimes(1) + }) + + it('discards a walk that found no root instead of proving an empty tree', async () => { + const child = mockChild() + const captureDescendants = vi.fn(async () => ({ + rootPgid: null, + descendants: [], + capturedAtMs: 1 + })) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants, + terminateDescendants: vi.fn() + }) + + await tree.capture() + await expect(tree.reap()).resolves.toBe('unverifiable') + // A vacuous walk remains retryable while the root is live; no empty-tree + // verdict is latched from a missing root row. + expect(captureDescendants).toHaveBeenCalledTimes(2) + }) + + it('discards a walk that raced the root exit instead of proving an empty tree', async () => { + const child = mockChild() + let exited = false + const captureDescendants = vi.fn(async () => { + exited = true + return { rootPgid: 1, descendants: [], capturedAtMs: 1 } + }) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + exited: () => exited, + captureDescendants, + terminateDescendants: vi.fn() + }) + + await tree.capture() + await expect(tree.reap()).resolves.toBe('unverifiable') + }) + + it('proves a childless snapshot without signalling anything', async () => { + const child = mockChild() + const terminateDescendants = vi.fn() + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants: vi.fn(async () => ({ + root: { pid: 424242, startedAt: 'Mon Jan 1 00:00:00 2026' }, + rootPgid: 1, + descendants: [], + capturedAtMs: 1 + })), + terminateDescendants + }) + + await expect(tree.reap()).resolves.toBe('exited') + expect(terminateDescendants).not.toHaveBeenCalled() + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('waits for the Windows tree kill before releasing the root', async () => { + const child = mockChild() + const release = Promise.withResolvers() + const terminateWindowsTree = vi.fn(() => release.promise) + const captureDescendants = vi.fn() + const terminateWindowsDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'win32', + captureDescendants, + captureWindowsDescendants: vi.fn(async () => windowsSnapshotOf(4243)), + terminateWindowsTree, + terminateWindowsDescendants + }) + + const reap = tree.reap() + await vi.waitFor(() => + expect(terminateWindowsTree).toHaveBeenCalledWith({ + pid: 424242, + creationTimeMs: 1_700_000_000_001 + }) + ) + expect(child.kill).not.toHaveBeenCalled() + expect(terminateWindowsDescendants).not.toHaveBeenCalled() + release.resolve() + await expect(reap).resolves.toBe('exited') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + expect(terminateWindowsDescendants).toHaveBeenCalledWith(windowsSnapshotOf(4243)) + expect(captureDescendants).not.toHaveBeenCalled() + }) + + it('stays unproven on Windows when taskkill fails and a descendant is still observed', async () => { + const child = mockChild() + const tree = createClaudeChildTreeReaper(child, { + platform: 'win32', + captureWindowsDescendants: vi.fn(async () => windowsSnapshotOf(4243)), + terminateWindowsTree: vi.fn(async () => { + throw new Error('taskkill: access denied') + }), + terminateWindowsDescendants: vi.fn(async () => 'live' as const) + }) + + // taskkill's own outcome is not the proof; the table read after it is. + await expect(tree.reap()).resolves.toBe('live') + expect(tree.treeVerdict).toBe('live') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('stays unproven on Windows when taskkill resolves but a descendant survives it', async () => { + const child = mockChild() + const terminateWindowsTree = vi.fn(async () => {}) + const tree = createClaudeChildTreeReaper(child, { + platform: 'win32', + captureWindowsDescendants: vi.fn(async () => windowsSnapshotOf(4243)), + terminateWindowsTree, + terminateWindowsDescendants: vi.fn(async () => 'live' as const) + }) + + await expect(tree.reap()).resolves.toBe('live') + expect(terminateWindowsTree).toHaveBeenCalledTimes(1) + expect(tree.treeVerdict).toBe('live') + }) + + it('never taskkills a Windows root that already exited, but still verifies its snapshot', async () => { + const child = mockChild() + let exited = false + const terminateWindowsTree = vi.fn(async () => {}) + const terminateWindowsDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'win32', + exited: () => exited, + captureWindowsDescendants: vi.fn(async () => windowsSnapshotOf(4243)), + terminateWindowsTree, + terminateWindowsDescendants + }) + + await tree.capture() + exited = true + await expect(tree.reap()).resolves.toBe('exited') + // A dead root's pid may already belong to a stranger: taskkill /T /F on it + // would take down an unrelated tree. + expect(terminateWindowsTree).not.toHaveBeenCalled() + expect(terminateWindowsDescendants).toHaveBeenCalledWith(windowsSnapshotOf(4243)) + }) + + it('treats an unreadable Windows table as unproven', async () => { + const child = mockChild() + const terminateWindowsDescendants = vi.fn() + const tree = createClaudeChildTreeReaper(child, { + platform: 'win32', + captureWindowsDescendants: vi.fn(async () => null), + terminateWindowsTree: vi.fn(async () => {}), + terminateWindowsDescendants + }) + + await expect(tree.reap()).resolves.toBe('unverifiable') + expect(terminateWindowsDescendants).not.toHaveBeenCalled() + // A host that cannot supply creation times blocks taskkill, not the root kill. + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('has nothing to reap for a child that never spawned', async () => { + const child = mockChild(null) + const captureDescendants = vi.fn() + const tree = createClaudeChildTreeReaper(child, { platform: 'linux', captureDescendants }) + + await expect(tree.reap()).resolves.toBe('exited') + expect(captureDescendants).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/claude/claude-agent-sdk-exit-proof.ts b/src/main/claude/claude-agent-sdk-exit-proof.ts new file mode 100644 index 00000000000..17533f87a70 --- /dev/null +++ b/src/main/claude/claude-agent-sdk-exit-proof.ts @@ -0,0 +1,366 @@ +import type { SpawnedProcess } from '../../shared/child-process/run-process' +import { + terminateDescendantSnapshotWithVerdict, + type DescendantTreeVerdict +} from '../pty-descendant-exit-verification' +import { + captureDescendantSnapshot, + type DescendantSnapshot, + type PosixProcessIdentity +} from '../pty-descendant-termination' +import { + captureWindowsDescendantSnapshot, + terminateIdentifiedWindowsProcessTree, + verifyWindowsDescendantSnapshotExit, + verifyWindowsProcessIdentity, + type WindowsDescendantSnapshot, + type WindowsProcessIdentity +} from '../windows-descendant-exit-verification' +import { mergeClaudeCapturedTrees, type ClaudeCapturedTree } from './claude-child-tree-snapshot' +import { terminateClaudeRoot, terminateClaudeWindowsRoot } from './claude-child-root-termination' +import { + proveClaudeChildExitWithReaper, + type ClaudeChildExitProofInput +} from './claude-child-exit-proof-ladder' + +/** + * A later reap may only raise the latched verdict. An observed exit is final, and + * a descendant seen alive at a deadline is never forgotten by a later look that + * could not read the table: the lease gate discriminates on exactly that pair. + */ +const TREE_VERDICT_TRUST: Record = { + unverifiable: 0, + live: 1, + exited: 2 +} + +type ReapableChild = Pick + +/** + * A walk is only admissible while the root it walked was alive. A POSIX walk + * that found no root says so with a null pgid; either platform's walk can also + * have raced the root's death. Both can only have missed descendants that + * already reparented away, so neither is evidence about the tree. + */ +function admissibleTree( + captured: DescendantSnapshot | WindowsDescendantSnapshot | null, + platform: NodeJS.Platform, + exited: boolean +): ClaudeCapturedTree | null { + if (!captured || exited) { + return null + } + if (platform === 'win32') { + return { platform: 'win32', tree: captured as WindowsDescendantSnapshot } + } + const tree = captured as DescendantSnapshot + return tree.rootPgid === null ? null : { platform: 'posix', tree } +} + +export type ClaudeChildTreeReaperDeps = { + platform?: NodeJS.Platform + /** Whether the root's exit has been observed; only a live root can be walked. */ + exited?: () => boolean + captureDescendants?: (rootPid: number) => Promise + terminateDescendants?: (snapshot: DescendantSnapshot) => Promise + terminateWindowsTree?: (root: WindowsProcessIdentity) => Promise + captureWindowsDescendants?: (rootPid: number) => Promise + terminateWindowsDescendants?: ( + snapshot: WindowsDescendantSnapshot + ) => Promise + /** Identity probe for the bare-pid tree kill; only Windows has one to gate. */ + verifyRootIdentity?: (root: PosixProcessIdentity | WindowsProcessIdentity) => Promise +} + +export type ClaudeChildTreeReaper = { + /** + * Snapshot the root's live descendants. The moment the root dies they reparent + * and no table walk can find them again, so this has to run before anything + * gives the root a reason to leave. Held once; later calls are no-ops. + */ + capture(): Promise + /** Refresh a live root's snapshot at the close boundary; a failed refresh keeps the prior proof. */ + refresh?: () => Promise + /** + * Kill the child's whole tree and report what the bounded verification + * observed. Concurrent calls share one reap, and a later call re-verifies the + * same snapshot rather than trusting a root that has since died on its own. + */ + reap(): Promise + /** + * `unverifiable` until a reap observes otherwise. `exited` is the only verdict + * that lets a close release the lease; `live` names a descendant that was seen + * still running, which no later caller may collapse into "unknown". + */ + readonly treeVerdict: DescendantTreeVerdict +} + +/** + * The same shared primitives the Codex structured provider composes: a raw + * pipe child owns no PTY job, so there is nothing for the PTY job sweep to + * terminate on Windows and no unref'd timer is allowed to outlive the proof. + * + * The proof is unproven by default. `treeVerdict` is assigned in exactly one + * place, from the verdict of `judgeTree`, so a code path that never reaches a + * verification cannot report the tree gone by omission. + */ +export function createClaudeChildTreeReaper( + child: ReapableChild, + deps: ClaudeChildTreeReaperDeps = {} +): ClaudeChildTreeReaper { + const platform = deps.platform ?? process.platform + const exited = deps.exited ?? (() => false) + // Undefined until captured; null when no admissible snapshot exists — the root + // was already gone, or the table could not be read while it was alive — which + // no later read can make up for. + let snapshot: ClaudeCapturedTree | null | undefined + let capturing: Promise | null = null + let refreshing: Promise | null = null + let queuedRefresh: Promise | null = null + let inFlight: Promise | null = null + let treeVerdict: DescendantTreeVerdict = 'unverifiable' + + // Consulted only on win32: POSIX signals descendants by revalidated identity + // and reaches the root solely through Node's handle, so neither needs a probe. + const verifyRoot = + deps.verifyRootIdentity ?? + ((root: PosixProcessIdentity | WindowsProcessIdentity) => + verifyWindowsProcessIdentity(root as WindowsProcessIdentity)) + + function captureOnce(): Promise { + if (refreshing) { + const pending = refreshing + return pending.then(() => queuedRefresh ?? undefined) + } + if (snapshot !== undefined) { + return Promise.resolve() + } + if (capturing) { + const pending = capturing + return pending.then(() => queuedRefresh ?? undefined) + } + const rootPid = child.pid + if (!rootPid || exited()) { + // Only the root's death makes a missing snapshot final: its descendants + // have reparented, and no later walk can reach them. + snapshot = exited() ? null : snapshot + return Promise.resolve() + } + const capture = + platform === 'win32' + ? (deps.captureWindowsDescendants ?? captureWindowsDescendantSnapshot) + : (deps.captureDescendants ?? captureDescendantSnapshot) + capturing = capture(rootPid) + .catch(() => null) + .then((captured) => { + // A walk that found no root, or that raced the root's death, can only + // have missed descendants that already reparented away. A table that + // could not be read in time is not an answer at all: while the root + // still lives the walk is simply retried, rather than latching a failed + // read as proof that there was nothing to find. + const rootExited = exited() + const tree = admissibleTree(captured, platform, rootExited) + if (tree) { + snapshot = tree + } else if (rootExited) { + // Once the root has exited its descendants may have reparented; no + // later table read can make an absent snapshot safe to signal. + snapshot = null + } else { + // A failed read or a walk that did not observe the live root is + // retryable while the root remains alive. Never latch a vacuous null. + snapshot = undefined + } + }) + .finally(() => { + capturing = null + }) + return capturing + } + + function startRefresh(): Promise { + if (exited()) { + return Promise.resolve() + } + const rootPid = child.pid + if (!rootPid) { + return Promise.resolve() + } + const capture = + platform === 'win32' + ? (deps.captureWindowsDescendants ?? captureWindowsDescendantSnapshot) + : (deps.captureDescendants ?? captureDescendantSnapshot) + const operation = (async () => { + const captured = await capture(rootPid).catch(() => null) + if (exited()) { + return + } + const tree = admissibleTree(captured, platform, false) + if (!tree) { + return + } + if (snapshot === undefined) { + snapshot = tree + return + } + if (snapshot !== null) { + // A merge that returns null saw a same-PID identity change: a + // recycle/replace decision, not an absent descendant, so no row here may + // be signalled from its number. Only the descendant evidence is lost — + // the root still leaves through the handle no recycled pid can reach. + snapshot = mergeClaudeCapturedTrees(snapshot, tree) + } + // Keep an earlier admissible snapshot when this close-boundary read fails; + // it remains the only identity-safe evidence after root exit. + })() + refreshing = operation + const clearRefreshing = (): void => { + if (refreshing === operation) { + refreshing = null + } + } + void operation.then(clearRefreshing, clearRefreshing) + return operation + } + + function queueRefreshAfter(pending: Promise): Promise { + if (queuedRefresh) { + return queuedRefresh + } + const operation = pending.then(() => { + if (exited()) { + return + } + return startRefresh() + }) + queuedRefresh = operation + const clearQueuedRefresh = (): void => { + if (queuedRefresh === operation) { + queuedRefresh = null + } + } + void operation.then(clearQueuedRefresh, clearQueuedRefresh) + return operation + } + + async function refresh(): Promise { + const pending = capturing ?? refreshing + if (pending) { + await queueRefreshAfter(pending) + return + } + if (queuedRefresh) { + await queuedRefresh + return + } + try { + await startRefresh() + } catch { + // A refresh is advisory; capture failures leave the prior proof intact. + } + } + + /** The only source of a tree verdict: every `exited` here is an observation. */ + async function judgeTree(): Promise { + const killRoot = (): boolean => terminateClaudeRoot({ child, exited }) + const rootPid = child.pid + if (!rootPid) { + // Never spawned, so the OS never created a tree to orphan. + return 'exited' + } + await captureOnce() + if (platform === 'win32') { + // Why taskkill's own outcome is never the verdict: it resolves identically + // on a timeout, an access denial, a recycled root and a real kill. + const { rootVerified } = await terminateClaudeWindowsRoot({ + snapshot: snapshot?.platform === 'win32' ? snapshot.tree : null, + exited, + verifyRoot: (root) => verifyRoot(root), + terminateTree: (root) => + deps.terminateWindowsTree + ? deps.terminateWindowsTree(root) + : terminateIdentifiedWindowsProcessTree(root, { + ownsRoot: () => !exited() + }).then(() => undefined), + killRoot + }) + if (!rootVerified && !exited()) { + return 'unverifiable' + } + return snapshot?.platform === 'win32' + ? await (deps.terminateWindowsDescendants ?? verifyWindowsDescendantSnapshotExit)( + snapshot.tree + ) + : 'unverifiable' + } + if (snapshot?.platform !== 'posix') { + killRoot() + return 'unverifiable' + } + if (snapshot.tree.descendants.length === 0) { + // Read while the root was alive and childless: a later table read has no + // row it could match, so it would add nothing to this observation. + killRoot() + return 'exited' + } + // Why the root is killed while verification is already running, and never + // SIGSTOPped first the way the Codex non-group path does: measured on macOS, a + // killed child of a stopped parent stays a zombie row in ps with its lstart + // and pgid intact, so verification cannot pass until the root is dead. The + // descendants are signalled by the verifier as soon as it revalidates their + // identities; the root's death then reparents any zombies to init, which + // reaps them. After a root exit the kill is a no-op: Node drops the handle + // on exit and never signals a possibly recycled pid. + const verdictPromise = deps.terminateDescendants + ? deps.terminateDescendants(snapshot.tree) + : terminateDescendantSnapshotWithVerdict(snapshot.tree, { + requireIdentityBeforeSignal: true + }) + killRoot() + // What the verification observed is the verdict: a kill that reports no + // signal means the handle was already gone, never that the tree survived. + return verdictPromise + } + + return { + capture: captureOnce, + refresh, + reap() { + if (inFlight) { + return inFlight + } + const attempt = judgeTree() + .catch((): DescendantTreeVerdict => 'unverifiable') + .then((verdict) => { + treeVerdict = + TREE_VERDICT_TRUST[verdict] > TREE_VERDICT_TRUST[treeVerdict] ? verdict : treeVerdict + return verdict + }) + inFlight = attempt + void attempt.finally(() => { + if (inFlight === attempt) { + inFlight = null + } + }) + return attempt + }, + get treeVerdict() { + return treeVerdict + } + } +} + +/** + * Orca's own shutdown ladder on the child it spawned, kept because the SDK's + * close path returns no proof and Orca never releases a lease on an assumed exit. + * + * Resolves true only after the child actually emitted exit and its snapshotted + * descendants were observed gone; false is unproven. A root that left on its + * own before a snapshot could be armed stays unproven: its descendants had + * already reparented out of reach when the ladder first looked. + */ +export function proveClaudeChildExit(input: ClaudeChildExitProofInput): Promise { + return proveClaudeChildExitWithReaper(input, () => + createClaudeChildTreeReaper(input.child, { exited: input.exited }) + ) +} diff --git a/src/main/claude/claude-agent-sdk-import-boundary.test.ts b/src/main/claude/claude-agent-sdk-import-boundary.test.ts new file mode 100644 index 00000000000..f1a38466d41 --- /dev/null +++ b/src/main/claude/claude-agent-sdk-import-boundary.test.ts @@ -0,0 +1,154 @@ +import { existsSync, readFileSync, statSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { spawnProcess } from '../../shared/child-process/run-process' + +/** + * Keep the agent SDK on the structured-Claude side of the toggle. + * + * A user who never leaves the terminal/TUI Claude path must not pay for the SDK: + * importing it evaluates a package that rewrites + * `process.env.NoDefaultCurrentDirectoryInExePath`, changing how Windows resolves + * executables for every later subprocess, and a missing or incompatible install + * would take normal runtime startup down with it. The ordinary + * `OrcaRuntimeService` graph reaches the Claude transport module, so only a + * deferred import keeps that boundary — and only a walk of the real import graph + * keeps the next static import from quietly restoring it. + */ +const SDK_PACKAGE = '@anthropic-ai/claude-agent-sdk' +const REPO_ROOT = resolve(__dirname, '..', '..', '..') + +/** The Electron main entry: everything the app loads before any session exists. */ +const ROOT = 'src/main/index.ts' +/** Proof the walk goes all the way into the Claude transport rather than stopping short. */ +const TRANSPORT_MODULE = 'src/main/claude/claude-stream-json-connection.ts' + +/** + * Static, value-carrying specifiers only, read statement by statement so a + * multi-line `import { ... } from '...'` counts. `import type` is erased before + * the module ever loads and a bare `import(...)` is the deferral this guards, so + * neither is an edge the runtime traverses at load time. + */ +const STATEMENT_START = /^\s*(?:import|export)\b/ +const TYPE_ONLY = /^\s*(?:import|export)\s+type\b/ +const FROM_SPECIFIER = /(?:^|\s)from\s*['"]([^'"]+)['"]/ +const SIDE_EFFECT_IMPORT = /^\s*import\s*['"]([^'"]+)['"]/ +/** An import statement never spans more lines than its longest specifier list. */ +const MAX_STATEMENT_LINES = 60 + +function readSpecifiers(source: string): string[] { + const lines = source.split('\n') + const found: string[] = [] + for (let index = 0; index < lines.length; index += 1) { + const first = lines[index] as string + if (!STATEMENT_START.test(first) || TYPE_ONLY.test(first)) { + continue + } + const sideEffect = SIDE_EFFECT_IMPORT.exec(first) + if (sideEffect) { + found.push(sideEffect[1] as string) + continue + } + for (let scan = index; scan < Math.min(lines.length, index + MAX_STATEMENT_LINES); scan += 1) { + if (scan > index && STATEMENT_START.test(lines[scan] as string)) { + break + } + const specifier = FROM_SPECIFIER.exec(lines[scan] as string) + if (specifier) { + found.push(specifier[1] as string) + break + } + } + } + return found +} + +/** Resolve a relative specifier the way the bundler does; unresolvable means not a module. */ +function resolveRelative(fromFile: string, specifier: string): string | null { + const base = join(dirname(fromFile), specifier) + for (const candidate of [base, `${base}.ts`, `${base}.tsx`, join(base, 'index.ts')]) { + if (existsSync(candidate) && statSync(candidate).isFile()) { + return candidate + } + } + return null +} + +function walkStaticImports(rootFile: string): { visited: Set; sdkImporters: string[] } { + const visited = new Set() + const sdkImporters: string[] = [] + const queue = [resolve(REPO_ROOT, rootFile)] + while (queue.length > 0) { + const file = queue.pop() as string + const key = relative(REPO_ROOT, file).split('\\').join('/') + if (visited.has(key)) { + continue + } + visited.add(key) + for (const specifier of readSpecifiers(readFileSync(file, 'utf8'))) { + if (specifier === SDK_PACKAGE || specifier.startsWith(`${SDK_PACKAGE}/`)) { + sdkImporters.push(key) + continue + } + if (!specifier.startsWith('.')) { + continue + } + const target = resolveRelative(file, specifier) + if (target) { + queue.push(target) + } + } + } + return { visited, sdkImporters } +} + +describe('claude agent SDK import boundary', () => { + const walk = walkStaticImports(ROOT) + + it('walks a graph deep enough to reach the Claude transport', () => { + // Without this the guard passes for the wrong reason the moment the walk breaks. + expect(walk.visited.size).toBeGreaterThan(500) + expect([...walk.visited]).toContain(TRANSPORT_MODULE) + }) + + it('never reaches the SDK through a static import from the main entry', () => { + expect( + walk.sdkImporters, + `${SDK_PACKAGE} must stay behind the structured-Claude boundary. Load it with a deferred import inside the session path instead.` + ).toEqual([]) + }) + + it('leaves the Windows executable-search environment alone when the runtime loads', async () => { + // A vitest file runs in its own fork, so this is a clean process; the ambient + // value is cleared first because the developer's own shell may carry one. + delete process.env.NoDefaultCurrentDirectoryInExePath + await import('../runtime/structured-agent-session-runtime') + + expect(process.env.NoDefaultCurrentDirectoryInExePath).toBeUndefined() + }) + + it('still lets the SDK set it, so the guard above is not measuring nothing', async () => { + // A separate process, not this fork: the assertion has to be about a first + // evaluation of the package, which a cached module registry cannot give. + const { NoDefaultCurrentDirectoryInExePath: _cleared, ...env } = process.env + const probe = spawnProcess({ + program: process.execPath, + args: [ + '-e', + `import(${JSON.stringify(SDK_PACKAGE)}).then(() => console.log(String(process.env.NoDefaultCurrentDirectoryInExePath)))` + ], + cwd: REPO_ROOT, + env: env as Record, + stdio: ['ignore', 'pipe', 'ignore'] + }) + const observed = await new Promise((settle) => { + let output = '' + probe.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + output += chunk + }) + probe.once('close', () => settle(output.trim())) + }) + + expect(observed).toBe('1') + }) +}) diff --git a/src/main/claude/claude-agent-sdk-process-spawn.test.ts b/src/main/claude/claude-agent-sdk-process-spawn.test.ts new file mode 100644 index 00000000000..cd3520cf6d5 --- /dev/null +++ b/src/main/claude/claude-agent-sdk-process-spawn.test.ts @@ -0,0 +1,107 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { describe, expect, it, vi } from 'vitest' +import type { SpawnOptions as SdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk' +import { resolveSpawn, type spawnProcess } from '../../shared/child-process/run-process' +import type { ProcessSpec } from '../../shared/child-process/process-spec' +import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn' + +type FakeChild = EventEmitter & { + pid: number + stdin: PassThrough + stdout: PassThrough + stderr: PassThrough + kill: ReturnType +} + +function fakeSpawn() { + const child = new EventEmitter() as FakeChild + child.pid = 4321 + child.stdin = new PassThrough() + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn(() => true) + const specs: ProcessSpec[] = [] + const spawnImpl = ((spec: ProcessSpec) => { + specs.push(spec) + return child + }) as unknown as typeof spawnProcess + return { child, spawnImpl, specs } +} + +function sdkOptions(overrides: Partial = {}): SdkSpawnOptions { + return { + command: '/usr/local/bin/claude', + args: ['--output-format', 'stream-json'], + cwd: '/work/repo', + env: { PATH: '/usr/bin', CLAUDE_CONFIG_DIR: '/accounts/one', UNSET: undefined }, + signal: new AbortController().signal, + ...overrides + } +} + +describe('claude agent SDK process spawn', () => { + it('routes the SDK spawn through Orca and retains the pid the lease adjudicates on', () => { + const process = fakeSpawn() + const spawn = createClaudeCodeProcessSpawn(process.spawnImpl) + + expect(spawn.pid).toBeUndefined() + expect(spawn.child).toBeNull() + const child = spawn.spawn(sdkOptions()) + + expect(child).toBe(process.child) + expect(spawn.child).toBe(process.child) + expect(spawn.pid).toBe(4321) + expect(process.specs[0]).toEqual({ + program: '/usr/local/bin/claude', + args: ['--output-format', 'stream-json'], + cwd: '/work/repo', + env: { PATH: '/usr/bin', CLAUDE_CONFIG_DIR: '/accounts/one' }, + stdio: ['pipe', 'pipe', 'pipe'] + }) + }) + + it('keeps the child out of the SDK abort path so exit proof stays Orca-owned', () => { + const process = fakeSpawn() + const controller = new AbortController() + createClaudeCodeProcessSpawn(process.spawnImpl).spawn(sdkOptions({ signal: controller.signal })) + + // Node's spawn({signal}) kills the child on abort; Orca's ladder must be the + // only thing that can end this process, or close() would report an assumed exit. + expect(process.specs[0]).not.toHaveProperty('signal') + }) + + it('drains stderr into a bounded tail so an exit error still carries it', async () => { + const process = fakeSpawn() + const spawn = createClaudeCodeProcessSpawn(process.spawnImpl) + spawn.spawn(sdkOptions()) + + process.child.stderr.write('x'.repeat(9000)) + process.child.stderr.write('claude: not signed in') + await new Promise((resolve) => setImmediate(resolve)) + + expect(spawn.stderrTail).toMatch(/claude: not signed in$/) + expect(spawn.stderrTail.length).toBe(8192) + }) + + it('hands a Windows .cmd shim to Orca\u2019s argument encoder', () => { + const process = fakeSpawn() + createClaudeCodeProcessSpawn(process.spawnImpl).spawn( + sdkOptions({ + command: 'C:\\Users\\dev\\AppData\\npm\\claude.cmd', + args: ['--setting-sources=user,project,local', '--session-id', 'a b&c'] + }) + ) + + // The spec the spawner builds is what Orca's Windows branch encodes; the SDK's + // own spawn would hand `.cmd` straight to Node and mangle the argument. + const resolved = resolveSpawn(process.specs[0] as ProcessSpec, 'win32') + expect(resolved.file.toLowerCase()).toContain('cmd.exe') + expect(resolved.options.windowsVerbatimArguments).toBe(true) + expect(resolved.args).toHaveLength(1) + // `/v:off` plus the quoted argument is what keeps `&` from splitting the line. + expect(resolved.args[0]).toContain('/v:off') + expect(resolved.args[0]).toContain('"a b&c"') + expect(resolved.args[0]).toContain('"--setting-sources=user,project,local"') + }) +}) diff --git a/src/main/claude/claude-agent-sdk-process-spawn.ts b/src/main/claude/claude-agent-sdk-process-spawn.ts new file mode 100644 index 00000000000..a2b1ad7f158 --- /dev/null +++ b/src/main/claude/claude-agent-sdk-process-spawn.ts @@ -0,0 +1,69 @@ +import type { SpawnOptions as ClaudeAgentSdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk' +import { spawnProcess } from '../../shared/child-process/run-process' + +/** Derived rather than imported: only src/shared/child-process may name node:child_process. */ +type ClaudeCodeChild = ReturnType + +const STDERR_TAIL_MAX_BYTES = 8192 + +export type ClaudeCodeProcessSpawn = { + /** Pass as the SDK's `spawnClaudeCodeProcess`; the SDK never learns the pid because it never owns it. */ + spawn: (options: ClaudeAgentSdkSpawnOptions) => ClaudeCodeChild + /** The retained child, so Orca keeps its own tree-kill and exit-proof ladder. Null until the SDK spawns. */ + readonly child: ClaudeCodeChild | null + /** Ownership proof: the durable lease adjudicates on this pid plus start time plus the spawn token. */ + readonly pid: number | undefined + readonly stderrTail: string +} + +function definedEnv(env: Record): Record { + const next: Record = {} + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + next[key] = value + } + } + return next +} + +/** + * Orca supplies the Claude Code child rather than letting the SDK spawn it. + * + * Two independent reasons: the SDK's `SpawnedProcess` has no pid, and Orca's + * spawner is the only path that encodes `.cmd` arguments safely on Windows. + */ +export function createClaudeCodeProcessSpawn( + spawnImpl: typeof spawnProcess = spawnProcess +): ClaudeCodeProcessSpawn { + let child: ClaudeCodeChild | null = null + let stderrTail = '' + return { + spawn: (options) => { + // Why `options.signal` is dropped: it would let the SDK kill the child outside + // Orca's ladder, and close() may never report an exit it did not observe. + const spawned = spawnImpl({ + program: options.command, + args: [...options.args], + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + env: definedEnv(options.env), + stdio: ['pipe', 'pipe', 'pipe'] + }) + child = spawned + // The SDK drains stderr only for its own local spawn, so a custom spawner must: + // otherwise the child blocks on a full pipe and exit errors lose their tail. + spawned.stderr.setEncoding('utf8').on('data', (chunk: string) => { + stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_MAX_BYTES) + }) + return spawned + }, + get child() { + return child + }, + get pid() { + return child?.pid + }, + get stderrTail() { + return stderrTail + } + } +} diff --git a/src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts b/src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts new file mode 100644 index 00000000000..9f843527e30 --- /dev/null +++ b/src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts @@ -0,0 +1,190 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { describe, expect, it, vi } from 'vitest' +import type { SpawnedProcess } from '../../shared/child-process/run-process' +import type { DescendantSnapshot } from '../pty-descendant-termination' +import type { WindowsDescendantSnapshot } from '../windows-descendant-exit-verification' +import { createClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof' +import { mergeClaudeCapturedTrees } from './claude-child-tree-snapshot' + +const ROOT_PID = 424242 +const ROOT_STARTED_AT = 'Mon Jan 1 00:00:00 2026' +const ROOT_FORK_MS = Date.parse(ROOT_STARTED_AT) + +function mockChild(): EventEmitter & + Pick & { kill: ReturnType } { + return Object.assign(new EventEmitter(), { + pid: ROOT_PID, + stdin: new PassThrough(), + kill: vi.fn(() => true) + }) as never +} + +function posixSnapshot(input: { + capturedAtMs: number + descendants?: DescendantSnapshot['descendants'] +}): DescendantSnapshot { + return { + root: { pid: ROOT_PID, startedAt: ROOT_STARTED_AT }, + rootPgid: ROOT_PID, + descendants: input.descendants ?? [], + capturedAtMs: input.capturedAtMs + } +} + +function windowsSnapshot(capturedAtMs = 1): WindowsDescendantSnapshot { + return { + root: { pid: ROOT_PID, creationTimeMs: 1_700_000_000_001 }, + descendants: [{ pid: 4243, creationTimeMs: 1_700_000_000_000 }], + unidentifiedCount: 0, + capturedAtMs + } +} + +describe('Claude root kill fallback', () => { + it('kills the root when the first capture landed in the fork second', async () => { + // The production POSIX verifier declines a root born in its capture second, + // and that verdict must not cost the tree the kill on Node's own handle. + const child = mockChild() + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + exited: () => false, + captureDescendants: vi.fn(async () => posixSnapshot({ capturedAtMs: ROOT_FORK_MS + 300 })) + }) + + await expect(tree.reap()).resolves.toBe('exited') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('kills the root after a recycled descendant pid voided the snapshot', async () => { + const child = mockChild() + const captureDescendants = vi + .fn() + .mockResolvedValueOnce( + posixSnapshot({ + capturedAtMs: ROOT_FORK_MS + 5_000, + descendants: [{ pid: 100, ppid: ROOT_PID, pgid: ROOT_PID, startedAt: ROOT_STARTED_AT }] + }) + ) + .mockResolvedValueOnce( + posixSnapshot({ + capturedAtMs: ROOT_FORK_MS + 6_000, + descendants: [ + { pid: 100, ppid: ROOT_PID, pgid: ROOT_PID, startedAt: 'Mon Jan 1 00:00:30 2026' } + ] + }) + ) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + exited: () => false, + captureDescendants, + terminateDescendants: vi.fn(async () => 'exited' as const), + verifyRootIdentity: vi.fn(async () => true) + }) + + await tree.capture() + await tree.refresh?.() + // The descendant evidence is rightly discarded; the root's never was in doubt. + await expect(tree.reap()).resolves.toBe('unverifiable') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('keeps an observed live descendant when the root identity probe declined', async () => { + const child = mockChild() + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + exited: () => false, + captureDescendants: vi.fn(async () => + posixSnapshot({ + capturedAtMs: ROOT_FORK_MS + 5_000, + descendants: [{ pid: 100, ppid: ROOT_PID, pgid: ROOT_PID, startedAt: ROOT_STARTED_AT }] + }) + ), + terminateDescendants: vi.fn(async () => 'live' as const), + verifyRootIdentity: vi.fn(async () => false) + }) + + await expect(tree.reap()).resolves.toBe('live') + expect(tree.treeVerdict).toBe('live') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('reports a Windows taskkill that worked as exited, not unverifiable', async () => { + const child = mockChild() + // Probe 1 gates taskkill; a later probe correctly finds the root already dead. + const verifyRootIdentity = vi.fn().mockResolvedValueOnce(true).mockResolvedValue(false) + const tree = createClaudeChildTreeReaper(child, { + platform: 'win32', + exited: () => false, + captureWindowsDescendants: vi.fn(async () => windowsSnapshot()), + terminateWindowsTree: vi.fn(async () => {}), + terminateWindowsDescendants: vi.fn(async () => 'exited' as const), + verifyRootIdentity + }) + + await expect(tree.reap()).resolves.toBe('exited') + }) + + it('kills the root when no POSIX snapshot could be read', async () => { + const child = mockChild() + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + exited: () => false, + captureDescendants: vi.fn(async () => null), + terminateDescendants: vi.fn() + }) + + await expect(tree.reap()).resolves.toBe('unverifiable') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('kills the root when the Windows process table is unreadable', async () => { + const child = mockChild() + const terminateWindowsTree = vi.fn(async () => {}) + const tree = createClaudeChildTreeReaper(child, { + platform: 'win32', + exited: () => false, + captureWindowsDescendants: vi.fn(async () => null), + terminateWindowsTree, + terminateWindowsDescendants: vi.fn() + }) + + await expect(tree.reap()).resolves.toBe('unverifiable') + // No identity means no bare-pid tree kill, but the owned handle is still ours. + expect(terminateWindowsTree).not.toHaveBeenCalled() + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('never signals a root the reaper already saw exit', async () => { + const child = mockChild() + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + exited: () => true, + captureDescendants: vi.fn(async () => null) + }) + + await expect(tree.reap()).resolves.toBe('unverifiable') + expect(child.kill).not.toHaveBeenCalled() + }) + + it('chains per-pid Windows boundaries across a second merge', async () => { + const first = windowsSnapshot(1_000) + const second: WindowsDescendantSnapshot = { + ...windowsSnapshot(2_000), + descendants: [ + { pid: 4243, creationTimeMs: 1_700_000_000_000 }, + { pid: 4244, creationTimeMs: 1_700_000_000_002 } + ] + } + const third: WindowsDescendantSnapshot = { ...second, capturedAtMs: 3_000 } + + const merged = mergeClaudeCapturedTrees( + { platform: 'win32', tree: first }, + { platform: 'win32', tree: second } + ) + expect(merged?.tree.capturedAtMsByPid).toEqual({ '4243': 1_000, '4244': 2_000 }) + const rechained = mergeClaudeCapturedTrees(merged!, { platform: 'win32', tree: third }) + + expect(rechained?.tree.capturedAtMsByPid).toEqual({ '4243': 1_000, '4244': 2_000 }) + }) +}) diff --git a/src/main/claude/claude-agent-sdk-user-message-queue.test.ts b/src/main/claude/claude-agent-sdk-user-message-queue.test.ts new file mode 100644 index 00000000000..62fbd8cf203 --- /dev/null +++ b/src/main/claude/claude-agent-sdk-user-message-queue.test.ts @@ -0,0 +1,65 @@ +import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk' +import { describe, expect, it } from 'vitest' +import { createClaudeUserMessageQueue } from './claude-agent-sdk-user-message-queue' + +/** + * The SDK's input pump is `for await (const frame of prompt) { await transport.write(frame) }`. + * A rejected write — or an abort — ends that loop abruptly, which calls the + * generator's `return()`. Everything below drives that exact shape, because the + * frame the pump already pulled is the one nothing else can reach. + */ +const frame = (text: string): SDKUserMessage => + ({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text }] } + }) as unknown as SDKUserMessage + +const settled = (promise: Promise): Promise<'settled' | 'pending'> => + Promise.race([ + promise.then( + () => 'settled' as const, + () => 'settled' as const + ), + new Promise<'pending'>((resolve) => setTimeout(() => resolve('pending'), 100)) + ]) + +describe('claude user message queue', () => { + it('rejects the frame the SDK pulled but abandoned without writing', async () => { + const queue = createClaudeUserMessageQueue() + const pump = queue.messages[Symbol.asyncIterator]() + const sent = queue.push(frame('hello')) + + await pump.next() + await pump.return?.(undefined) + + await expect(settled(sent)).resolves.toBe('settled') + await expect(sent).rejects.toThrow( + 'claude stream-json input ended before the frame was written' + ) + }) + + it('rejects an in-flight frame from fail() when the SDK never resumes the pump', async () => { + const queue = createClaudeUserMessageQueue() + const pump = queue.messages[Symbol.asyncIterator]() + const sent = queue.push(frame('hello')) + + await pump.next() + queue.fail(new Error('claude stream-json exited: child died')) + + await expect(settled(sent)).resolves.toBe('settled') + await expect(sent).rejects.toThrow('claude stream-json exited: child died') + }) + + it('still settles a written frame only once the pump asks for the next one', async () => { + const queue = createClaudeUserMessageQueue() + const pump = queue.messages[Symbol.asyncIterator]() + const sent = queue.push(frame('hello')) + + const pulled = await pump.next() + expect(pulled.value).toMatchObject({ type: 'user' }) + // The write proof is the pump coming back for more, exactly as before. + await expect(settled(sent)).resolves.toBe('pending') + void pump.next() + await expect(sent).resolves.toBeUndefined() + }) +}) diff --git a/src/main/claude/claude-agent-sdk-user-message-queue.ts b/src/main/claude/claude-agent-sdk-user-message-queue.ts new file mode 100644 index 00000000000..87fa6660159 --- /dev/null +++ b/src/main/claude/claude-agent-sdk-user-message-queue.ts @@ -0,0 +1,100 @@ +import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk' + +type QueuedMessage = { + message: SDKUserMessage + resolve: () => void + reject: (error: Error) => void +} + +export type ClaudeUserMessageQueue = { + /** The SDK's streaming-input prompt; it stays open until `end`. */ + messages: AsyncIterable + /** Resolves once the SDK has finished writing the frame to the child. */ + push: (message: SDKUserMessage) => Promise + /** Reject every unwritten frame, in-flight included; a caller waiting on a send must not hang past the exit. */ + fail: (error: Error) => void + end: () => void +} + +/** The rejection an abandoned frame carries when nothing else has named a cause yet. */ +const UNWRITTEN_FRAME_MESSAGE = 'claude stream-json input ended before the frame was written' + +export function createClaudeUserMessageQueue(): ClaudeUserMessageQueue { + const queued: QueuedMessage[] = [] + // The frame the SDK has taken but not yet acknowledged. It is out of `queued`, + // so it is unreachable from anywhere else and would otherwise never settle. + let inFlight: QueuedMessage | null = null + let wake: (() => void) | null = null + let ended = false + let failure: Error | null = null + const notify = (): void => { + wake?.() + wake = null + } + const rejectInFlight = (error: Error): void => { + const abandoned = inFlight + inFlight = null + abandoned?.reject(error) + } + + async function* drain(): AsyncGenerator { + for (;;) { + const next = queued.shift() + if (next) { + inFlight = next + let written = false + try { + yield next.message + written = true + } finally { + // The SDK's input pump abandons this iterator when its + // `await transport.write(...)` rejects or the query aborts, and the code + // after a `yield` never runs on that path. Settling here is the only + // place a frame it already took can be reached. + if (written) { + inFlight = null + // Resumed only after the SDK's `await transport.write(...)` settled, so this + // is the same "the frame reached the child" proof the hand-rolled write gave. + next.resolve() + } else { + rejectInFlight(failure ?? new Error(UNWRITTEN_FRAME_MESSAGE)) + } + } + continue + } + if (ended || failure) { + return + } + await new Promise((resolve) => { + wake = resolve + }) + } + } + + return { + messages: drain(), + push: (message) => + new Promise((resolve, reject) => { + if (failure) { + reject(failure) + return + } + queued.push({ message, resolve, reject }) + notify() + }), + fail: (error) => { + failure ??= error + for (const entry of queued.splice(0)) { + entry.reject(error) + } + // A pump that never resumes cannot run the generator's cleanup, so the + // exit path has to reach the in-flight frame itself. + rejectInFlight(error) + notify() + }, + end: () => { + ended = true + notify() + } + } +} diff --git a/src/main/claude/claude-child-exit-proof-ladder.ts b/src/main/claude/claude-child-exit-proof-ladder.ts new file mode 100644 index 00000000000..85ed629f1b9 --- /dev/null +++ b/src/main/claude/claude-child-exit-proof-ladder.ts @@ -0,0 +1,41 @@ +import type { SpawnedProcess } from '../../shared/child-process/run-process' +import { waitForProcessExitUntil } from '../codex/codex-process-exit-deadline' +import type { ClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof' + +const GRACEFUL_EXIT_MS = 1_500 +const FORCED_EXIT_MS = 1_000 + +export type ClaudeChildExitProofInput = { + child: Pick + exitPromise: Promise + exited: () => boolean + tree?: ClaudeChildTreeReaper +} + +export async function proveClaudeChildExitWithReaper( + input: ClaudeChildExitProofInput, + createTree: () => ClaudeChildTreeReaper +): Promise { + const tree = input.tree ?? createTree() + // Arm before stdin closes: only a live root can identify its descendants. + await tree.capture() + try { + input.child.stdin?.end() + } catch { + // The reap below still owns the process. + } + let reaped = false + if (!input.exited()) { + await waitForProcessExitUntil(input.exitPromise, GRACEFUL_EXIT_MS) + if (!input.exited()) { + reaped = true + await tree.refresh?.() + await tree.reap() + await waitForProcessExitUntil(input.exitPromise, FORCED_EXIT_MS) + } + } + if (!reaped && input.exited() && tree.treeVerdict !== 'exited') { + await tree.reap() + } + return input.exited() && tree.treeVerdict === 'exited' +} diff --git a/src/main/claude/claude-child-process-environment.test.ts b/src/main/claude/claude-child-process-environment.test.ts new file mode 100644 index 00000000000..8299fdcdd61 --- /dev/null +++ b/src/main/claude/claude-child-process-environment.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { applyClaudeEnvPatch } from '../claude-accounts/environment' +import { buildClaudeChildProcessEnv } from './claude-child-process-environment' + +describe('Claude child process environment', () => { + it('strips case-insensitive auth headers through the shared env patch on Windows', () => { + expect( + applyClaudeEnvPatch( + { + anthropic_api_key: 'inherited-key', + Anthropic_Custom_Headers: 'Authorization: inherited', + SAFE_VALUE: 'preserved' + }, + {}, + { stripAuthEnv: true, platform: 'win32' } + ) + ).toEqual({ SAFE_VALUE: 'preserved' }) + }) + + it('strips case-insensitive inherited auth and session stamps on Windows', () => { + const env = buildClaudeChildProcessEnv( + { + ANTHROPIC_AUTH_TOKEN: 'configured-token', + Claude_Code_Session_Id: 'configured-session' + }, + { + platform: 'win32', + inheritedEnv: { + anthropic_api_key: 'inherited-key', + Anthropic_Custom_Headers: 'Authorization: inherited', + claude_code_child_session: '1', + CLAUDE_CODE_SESSION_ID: 'inherited-session', + SAFE_VALUE: 'preserved' + } + } + ) + + expect(env).toEqual({ + ANTHROPIC_AUTH_TOKEN: 'configured-token', + Claude_Code_Session_Id: 'configured-session', + SAFE_VALUE: 'preserved' + }) + }) + + it('can strip child-session stamps reintroduced by a full SDK launch overlay', () => { + expect( + buildClaudeChildProcessEnv( + { + CLAUDE_CODE_CHILD_SESSION: 'configured-child-session', + CLAUDE_CODE_SESSION_ID: 'configured-session', + CLAUDE_CODE_BRIDGE_SESSION_ID: 'configured-bridge-session' + }, + { + scrubConfiguredChildSessionStamps: true, + inheritedEnv: { + CLAUDE_CODE_CHILD_SESSION: 'inherited-child-session', + SAFE_VALUE: 'preserved' + } + } + ) + ).toEqual({ SAFE_VALUE: 'preserved' }) + }) +}) diff --git a/src/main/claude/claude-child-process-environment.ts b/src/main/claude/claude-child-process-environment.ts new file mode 100644 index 00000000000..58f00c5c1b8 --- /dev/null +++ b/src/main/claude/claude-child-process-environment.ts @@ -0,0 +1,69 @@ +import { CLAUDE_AUTH_ENV_VARS, applyClaudeEnvPatch } from '../claude-accounts/environment' + +const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [ + 'CLAUDE_CODE_CHILD_SESSION', + 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_CODE_BRIDGE_SESSION_ID' +] as const + +function cloneProcessEnv(source: NodeJS.ProcessEnv): Record { + const env: Record = {} + for (const [key, value] of Object.entries(source)) { + if (value !== undefined) { + env[key] = value + } + } + return env +} + +function stripClaudeChildSessionStamps( + env: Record, + platform: NodeJS.Platform +): Record { + for (const key of CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS) { + for (const envKey of Object.keys(env)) { + if (envKey === key || (platform === 'win32' && envKey.toUpperCase() === key)) { + delete env[envKey] + } + } + } + return env +} + +export function buildClaudeChildProcessEnv( + configuredEnv: Record = {}, + options: { + inheritedEnv?: NodeJS.ProcessEnv + platform?: NodeJS.Platform + scrubConfiguredChildSessionStamps?: boolean + } = {} +): Record { + const inheritedEnv = options.inheritedEnv ?? process.env + const platform = options.platform ?? process.platform + const env = applyClaudeEnvPatch( + cloneProcessEnv(inheritedEnv), + {}, + { + stripAuthEnv: true, + platform + } + ) + if (platform === 'win32') { + const authKeys = new Set(CLAUDE_AUTH_ENV_VARS.map((key) => key.toUpperCase())) + for (const [key, value] of Object.entries(env)) { + const normalized = key.toUpperCase() + if ( + authKeys.has(normalized) || + (normalized === 'ANTHROPIC_CUSTOM_HEADERS' && + /authorization|x-api-key|api-key|bearer/i.test(value)) + ) { + delete env[key] + } + } + } + if (options.scrubConfiguredChildSessionStamps) { + return stripClaudeChildSessionStamps({ ...env, ...configuredEnv }, platform) + } + stripClaudeChildSessionStamps(env, platform) + return { ...env, ...configuredEnv } +} diff --git a/src/main/claude/claude-child-root-termination.ts b/src/main/claude/claude-child-root-termination.ts new file mode 100644 index 00000000000..bed422532e1 --- /dev/null +++ b/src/main/claude/claude-child-root-termination.ts @@ -0,0 +1,54 @@ +import type { SpawnedProcess } from '../../shared/child-process/run-process' +import type { PosixProcessIdentity } from '../pty-descendant-termination' +import type { + WindowsDescendantSnapshot, + WindowsProcessIdentity +} from '../windows-descendant-exit-verification' + +export type ClaudeRootIdentity = PosixProcessIdentity | WindowsProcessIdentity + +type RootTerminationInput = { + child: Pick + exited: () => boolean +} + +/** + * Kills the root through the handle Node owns rather than through its pid, which + * is why no identity probe gates it: libuv drops that handle in the same turn it + * reaps, so the signal either reaches the process Orca spawned or reaches + * nothing. A probe here could only let an unreadable process table cost the tree + * the one fallback that still works once every table read has failed. + * + * False means no signal was sent, because the root had already left. + */ +export function terminateClaudeRoot(input: RootTerminationInput): boolean { + return input.exited() ? false : input.child.kill('SIGKILL') +} + +type WindowsRootTerminationInput = { + snapshot: WindowsDescendantSnapshot | null + exited: () => boolean + verifyRoot: (root: WindowsProcessIdentity) => Promise + terminateTree: (root: WindowsProcessIdentity) => Promise + killRoot: () => boolean +} + +/** + * `taskkill /T /F` addresses a bare pid, so a dead root's pid may already belong + * to a stranger whose whole tree it would take down: that one is identity-gated. + * The direct root kill after it runs however the probe decided. + */ +export async function terminateClaudeWindowsRoot( + input: WindowsRootTerminationInput +): Promise<{ rootVerified: boolean }> { + const { snapshot, exited, verifyRoot, terminateTree, killRoot } = input + let rootVerified = false + if (!exited() && snapshot) { + rootVerified = await verifyRoot(snapshot.root).catch(() => false) + if (rootVerified && !exited()) { + await terminateTree(snapshot.root).catch(() => {}) + } + } + killRoot() + return { rootVerified } +} diff --git a/src/main/claude/claude-child-tree-snapshot.ts b/src/main/claude/claude-child-tree-snapshot.ts new file mode 100644 index 00000000000..e0955648b02 --- /dev/null +++ b/src/main/claude/claude-child-tree-snapshot.ts @@ -0,0 +1,128 @@ +import type { DescendantSnapshot } from '../pty-descendant-termination' +import type { WindowsDescendantSnapshot } from '../windows-descendant-exit-verification' + +/** One platform's descendant tree, tagged so neither verifier can be handed the other's rows. */ +export type ClaudeCapturedTree = + | { platform: 'posix'; tree: DescendantSnapshot } + | { platform: 'win32'; tree: WindowsDescendantSnapshot } + +/** + * Process-table reads are not atomic: a refresh can omit a still-live row, but + * it can also observe a new process after the old row exited. Retain rows absent + * from the refresh, but reject a PID whose identity changed between reads. + */ +function mergeRowsByPid( + previous: readonly Row[], + next: readonly Row[], + sameIdentity: (previous: Row, next: Row) => boolean, + previousBoundary: (row: Row) => number, + nextBoundary: (row: Row) => number, + refreshBoundary: number +): { rows: Row[]; capturedAtMsByPid?: Readonly> } | null { + const merged = new Map() + const capturedAtMsByPid: Record = {} + for (const row of previous) { + const prior = merged.get(row.pid) + if (prior && !sameIdentity(prior, row)) { + return null + } + merged.set(row.pid, row) + capturedAtMsByPid[String(row.pid)] = previousBoundary(row) + } + for (const row of next) { + const prior = merged.get(row.pid) + if (prior && !sameIdentity(prior, row)) { + return null + } + if (!prior) { + capturedAtMsByPid[String(row.pid)] = nextBoundary(row) + } + merged.set(row.pid, row) + } + const boundaries = Object.values(capturedAtMsByPid) + const needsBoundaryMap = + new Set(boundaries).size > 1 || boundaries.some((boundary) => boundary !== refreshBoundary) + return { + rows: [...merged.values()], + ...(needsBoundaryMap ? { capturedAtMsByPid } : {}) + } +} + +export function mergeClaudeCapturedTrees( + previous: ClaudeCapturedTree, + next: ClaudeCapturedTree +): ClaudeCapturedTree | null { + if (previous.platform !== next.platform) { + return null + } + if (previous.platform === 'posix' && next.platform === 'posix') { + if (previous.tree.rootPgid !== next.tree.rootPgid) { + return null + } + // A refresh cannot repair an earlier capture that lacked root identity; + // retaining those rows would permit a later numeric-pid kill without proof. + if (!previous.tree.root || !next.tree.root) { + return null + } + if ( + previous.tree.root.pid !== next.tree.root.pid || + previous.tree.root.startedAt !== next.tree.root.startedAt + ) { + return null + } + const descendants = mergeRowsByPid( + previous.tree.descendants, + next.tree.descendants, + (left, right) => left.pgid === right.pgid && left.startedAt === right.startedAt, + (row) => previous.tree.capturedAtMsByPid?.[String(row.pid)] ?? previous.tree.capturedAtMs, + (row) => next.tree.capturedAtMsByPid?.[String(row.pid)] ?? next.tree.capturedAtMs, + next.tree.capturedAtMs + ) + if (!descendants) { + return null + } + return { + platform: 'posix', + tree: { + ...next.tree, + // Retained rows keep their earlier boundary; new rows use the refresh + // boundary. The scalar remains the latest scan for legacy consumers. + descendants: descendants.rows, + ...(descendants.capturedAtMsByPid + ? { capturedAtMsByPid: descendants.capturedAtMsByPid } + : {}) + } + } + } + if (previous.platform === 'win32' && next.platform === 'win32') { + if ( + previous.tree.root.pid !== next.tree.root.pid || + previous.tree.root.creationTimeMs !== next.tree.root.creationTimeMs + ) { + return null + } + const descendants = mergeRowsByPid( + previous.tree.descendants, + next.tree.descendants, + (left, right) => left.creationTimeMs === right.creationTimeMs, + (row) => previous.tree.capturedAtMsByPid?.[String(row.pid)] ?? previous.tree.capturedAtMs, + (row) => next.tree.capturedAtMsByPid?.[String(row.pid)] ?? next.tree.capturedAtMs, + next.tree.capturedAtMs + ) + if (!descendants) { + return null + } + return { + platform: 'win32', + tree: { + ...next.tree, + descendants: descendants.rows, + ...(descendants.capturedAtMsByPid + ? { capturedAtMsByPid: descendants.capturedAtMsByPid } + : {}), + unidentifiedCount: Math.max(previous.tree.unidentifiedCount, next.tree.unidentifiedCount) + } + } + } + return null +} diff --git a/src/main/claude/claude-command-lifecycle-frames.test.ts b/src/main/claude/claude-command-lifecycle-frames.test.ts new file mode 100644 index 00000000000..8126a546ed8 --- /dev/null +++ b/src/main/claude/claude-command-lifecycle-frames.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +function sinkState() { + const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => items.push({ identity, body }), + appendTombstone: () => {}, + publish: vi.fn() + } + return { sink, items } +} + +function providerFrameKinds(items: { body: AgentJournalItemBody }[]): string[] { + return items.flatMap((item) => + item.body.kind === 'status' && item.body.providerFrame ? [item.body.providerFrame.kind] : [] + ) +} + +/** + * The queue-bookkeeping frame Claude Code 2.1.258 emits for every uuid-stamped + * command: `command_uuid` plus a state, and no content of its own. Shape and + * states taken from the CLI's own emission sites. + */ +function commandLifecycle(state: 'started' | 'completed' | 'cancelled', uuid: string) { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'command_lifecycle', + command_uuid: 'command-1', + state, + uuid, + session_id: 'claude-session' + } + } +} + +function userTurn(uuid: string, text: string) { + return { + type: 'message' as const, + sessionId: 'orca-session', + startsTurn: true as const, + message: { + type: 'user', + uuid, + session_id: 'claude-session', + parent_tool_use_id: null, + isReplay: true, + message: { role: 'user', content: text } + } + } +} + +function assistantReply(uuid: string, text: string) { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'assistant', + uuid, + session_id: 'claude-session', + parent_tool_use_id: null, + message: { role: 'assistant', content: [{ type: 'text', text }] } + } + } +} + +describe('Claude command_lifecycle frames', () => { + it('keeps queue bookkeeping off the transcript for a whole turn', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(userTurn('user-1', 'Reply with exactly PROBE_OK and nothing else.')) + translator.handle(commandLifecycle('started', 'lifecycle-1')) + translator.handle(assistantReply('assistant-1', 'PROBE_OK')) + translator.handle(commandLifecycle('completed', 'lifecycle-2')) + translator.handle(commandLifecycle('completed', 'lifecycle-3')) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { + type: 'result', + subtype: 'success', + uuid: 'result-1', + session_id: 'claude-session', + is_error: false, + result: 'PROBE_OK', + terminal_reason: 'completed' + } + }) + + expect(providerFrameKinds(state.items)).toEqual([]) + // The turn's real content is untouched. + expect( + state.items.flatMap((item) => + item.body.kind === 'message' && item.body.role === 'assistant' ? [item.body.blocks] : [] + ) + ).toEqual([[{ type: 'text', text: 'PROBE_OK' }]]) + }) + + it('keeps a cancelled command off the transcript too', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(commandLifecycle('cancelled', 'lifecycle-4')) + + expect(providerFrameKinds(state.items)).toEqual([]) + }) +}) diff --git a/src/main/claude/claude-config-dir-pin.test.ts b/src/main/claude/claude-config-dir-pin.test.ts new file mode 100644 index 00000000000..c7be40a6f38 --- /dev/null +++ b/src/main/claude/claude-config-dir-pin.test.ts @@ -0,0 +1,34 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { claudeConfigDirEnvPatch, defaultClaudeConfigDir } from './claude-config-dir-pin' + +describe('claude config dir pin', () => { + it('does not pin the CLI default home, so the macOS Keychain stays reachable', () => { + expect(claudeConfigDirEnvPatch(join(homedir(), '.claude'), { env: {} })).toEqual({}) + expect(claudeConfigDirEnvPatch(`${join(homedir(), '.claude')}/`, { env: {} })).toEqual({}) + expect(claudeConfigDirEnvPatch(' ', { env: {} })).toEqual({}) + }) + + it('pins a managed account home the CLI would not find on its own', () => { + expect(claudeConfigDirEnvPatch('/accounts/claude/managed', { env: {} })).toEqual({ + CLAUDE_CONFIG_DIR: '/accounts/claude/managed' + }) + }) + + it('treats an inherited CLAUDE_CONFIG_DIR as the default the CLI already resolves', () => { + const env = { CLAUDE_CONFIG_DIR: '/inherited/home' } + expect(defaultClaudeConfigDir(env)).toBe('/inherited/home') + expect(claudeConfigDirEnvPatch('/inherited/home', { env })).toEqual({}) + expect(claudeConfigDirEnvPatch('/other/home', { env })).toEqual({ + CLAUDE_CONFIG_DIR: '/other/home' + }) + }) + + it('compares Windows homes case-insensitively', () => { + const env = { CLAUDE_CONFIG_DIR: 'C:\\Users\\Work\\.claude' } + expect(claudeConfigDirEnvPatch('c:\\users\\work\\.claude', { env, platform: 'win32' })).toEqual( + {} + ) + }) +}) diff --git a/src/main/claude/claude-config-dir-pin.ts b/src/main/claude/claude-config-dir-pin.ts new file mode 100644 index 00000000000..d5cc0b8b186 --- /dev/null +++ b/src/main/claude/claude-config-dir-pin.ts @@ -0,0 +1,37 @@ +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' + +/** The config dir the Claude CLI resolves for itself when nothing pins one. */ +export function defaultClaudeConfigDir(env: NodeJS.ProcessEnv = process.env): string { + return env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') +} + +function samePath(a: string, b: string, platform: NodeJS.Platform): boolean { + const left = resolve(a) + const right = resolve(b) + return platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right +} + +/** + * An explicit CLAUDE_CONFIG_DIR moves the Claude CLI off the default Keychain item onto + * one derived from the pinned path, so a claude.ai OAuth login stops working even when + * the pin names the CLI's own default. Pin only a home the CLI would not find on its + * own — the same rule the legacy PTY path applies via `ClaudeRuntimePathResolver`. + * + * The pinned value is the account home verbatim: the CLI keys its credential lookup on + * the literal string, so re-spelling an equivalent path (absolute vs `~`, trailing + * separator) selects a different identity. Normalization here is for the equality test + * only and must never reach the env. + */ +export function claudeConfigDirEnvPatch( + accountHome: string, + options: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {} +): { CLAUDE_CONFIG_DIR?: string } { + const env = options.env ?? process.env + const platform = options.platform ?? process.platform + const resolved = accountHome.trim() + if (!resolved || samePath(resolved, defaultClaudeConfigDir(env), platform)) { + return {} + } + return { CLAUDE_CONFIG_DIR: resolved } +} diff --git a/src/main/claude/claude-descendant-escalation-boundary.test.ts b/src/main/claude/claude-descendant-escalation-boundary.test.ts new file mode 100644 index 00000000000..55459df0c7f --- /dev/null +++ b/src/main/claude/claude-descendant-escalation-boundary.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from 'vitest' +import { terminateDescendantSnapshotWithVerdict } from '../pty-descendant-exit-verification' +import { + collectDescendantRows, + type DescendantSnapshot, + type ProcessTableRow +} from '../pty-descendant-termination' +import { createClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof' + +const ROOT_PID = 500 +const ORCA_PGID = 400 +const ROOT_STARTED_AT = 'Thu Sep 3 18:04:50 2026' +/** The second both close-time walks land in. */ +const WALK_SECOND = 'Thu Sep 3 18:05:04 2026' +const WALK_MS = Date.parse(WALK_SECOND) +const EARLIER_SECOND = 'Thu Sep 3 18:05:03 2026' + +/** The measured split: `s20` at :03.946 died, `s21` at :04.042 leaked. */ +const EARLIER_BORN = [700, 701, 702] +const WALK_SECOND_BORN = [721, 722, 723, 724] + +type Cohort = { pids: number[]; startedAt: string } + +const LIVE_TREE: Cohort[] = [ + { pids: EARLIER_BORN, startedAt: EARLIER_SECOND }, + { pids: WALK_SECOND_BORN, startedAt: WALK_SECOND } +] + +function rowsFor(cohorts: Cohort[]): ProcessTableRow[] { + return [ + { pid: ROOT_PID, ppid: 1, pgid: ORCA_PGID, startedAt: ROOT_STARTED_AT }, + ...cohorts.flatMap((cohort) => + cohort.pids.map((pid) => ({ + pid, + ppid: ROOT_PID, + pgid: ORCA_PGID, + startedAt: cohort.startedAt + })) + ) + ] +} + +/** A real ppid walk from the root, exactly as production captures one. */ +function walk(capturedAtMs: number, cohorts: Cohort[] = LIVE_TREE): DescendantSnapshot { + return collectDescendantRows(ROOT_PID, rowsFor(cohorts), capturedAtMs) +} + +function killedPids(calls: [number, NodeJS.Signals][]): number[] { + return calls.flatMap(([pid, signal]) => (signal === 'SIGKILL' ? [pid] : [])).sort((a, b) => a - b) +} + +function signalledPids(calls: [number, NodeJS.Signals][]): number[] { + return calls.flatMap(([pid, signal]) => (signal === 'SIGTERM' ? [pid] : [])).sort((a, b) => a - b) +} + +/** + * Drives the real reaper and the real verifier against a process table where + * every descendant traps SIGTERM, so only a forced sweep can end them. The root + * is alive for both walks and gone by the sweep, which is the measured teardown. + */ +async function sweep( + captures: DescendantSnapshot[], + liveTree: Cohort[] = LIVE_TREE +): Promise<[number, NodeJS.Signals][]> { + const calls: [number, NodeJS.Signals][] = [] + const captureDescendants = vi.fn() + for (const capture of captures) { + captureDescendants.mockResolvedValueOnce(capture) + } + const tree = createClaudeChildTreeReaper( + { pid: ROOT_PID, kill: vi.fn(() => true) }, + { + platform: 'linux', + exited: () => false, + captureDescendants, + terminateDescendants: (snapshot) => + terminateDescendantSnapshotWithVerdict(snapshot, { + requireIdentityBeforeSignal: true, + graceMs: 0, + verifyMs: 120, + sendSignal: (pid, signal) => calls.push([pid, signal]), + readTable: async () => ({ rows: rowsFor(liveTree), capturedAtMs: Date.now() }) + }) + } + ) + // The close ladder's shape: arm, then re-walk the live root at the boundary. + await tree.capture() + await tree.refresh?.() + await tree.reap() + return calls +} + +describe('Claude descendant forced-sweep fence', () => { + it('escalates a descendant forked in the same second as both close walks', async () => { + // Both walks land inside second :04, one ps duration apart, and the root is + // gone before a third could run. A descendant born at :04.042 is no less + // ours than its sibling born 96ms earlier at :03.946. + const calls = await sweep([walk(WALK_MS + 42), walk(WALK_MS + 140)]) + + expect(signalledPids(calls)).toEqual([...EARLIER_BORN, ...WALK_SECOND_BORN]) + expect(killedPids(calls)).toEqual([...EARLIER_BORN, ...WALK_SECOND_BORN]) + }) + + it('still escalates descendants born before the walk that first saw them', async () => { + const onlyEarlier = [{ pids: EARLIER_BORN, startedAt: EARLIER_SECOND }] + const calls = await sweep([walk(WALK_MS + 42, onlyEarlier)], onlyEarlier) + + expect(killedPids(calls)).toEqual(EARLIER_BORN) + }) + + it('withholds the sweep from a row no walk re-derived, on its start second alone', async () => { + // 900 was seen once, in its own birth second, and the refresh did not find + // it. The merge retains the row, but nothing re-proved it belongs to us, so + // the second-resolution fence is all there is and it still says no. + const retained = { pids: [900], startedAt: WALK_SECOND } + const firstWalk = walk(WALK_MS + 42, [...LIVE_TREE, retained]) + const refresh = walk(WALK_MS + 140) + + const calls = await sweep([firstWalk, refresh], [...LIVE_TREE, retained]) + + expect(signalledPids(calls)).toEqual([...EARLIER_BORN, ...WALK_SECOND_BORN, 900]) + expect(killedPids(calls)).toEqual([...EARLIER_BORN, ...WALK_SECOND_BORN]) + }) +}) diff --git a/src/main/claude/claude-stream-json-connection-close.test.ts b/src/main/claude/claude-stream-json-connection-close.test.ts new file mode 100644 index 00000000000..e824139a776 --- /dev/null +++ b/src/main/claude/claude-stream-json-connection-close.test.ts @@ -0,0 +1,126 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import type { ChildProcessWithoutNullStreams } from 'node:child_process' +import { describe, expect, it, vi } from 'vitest' +import type { query } from '@anthropic-ai/claude-agent-sdk' +import { + openClaudeStreamJsonConnection, + type ClaudeStreamJsonLaunch +} from './claude-stream-json-connection' + +const mocks = vi.hoisted(() => { + const refresh = vi.fn() + const proveClaudeChildExit = vi.fn() + const tree = { + capture: vi.fn(async () => {}), + refresh: (...args: unknown[]) => refresh(...args), + reap: vi.fn(async () => 'exited' as const), + treeVerdict: 'unverifiable' as const + } + return { proveClaudeChildExit, refresh, tree } +}) + +vi.mock('./claude-agent-sdk-exit-proof', () => ({ + createClaudeChildTreeReaper: vi.fn(() => mocks.tree), + proveClaudeChildExit: (...args: unknown[]) => mocks.proveClaudeChildExit(...args) +})) + +function fakeChild(): ChildProcessWithoutNullStreams { + const child = new EventEmitter() + return Object.assign(child, { + pid: 424242, + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn() + }) as unknown as ChildProcessWithoutNullStreams +} + +describe('Claude stream-json close ordering', () => { + it('waits for the live tree refresh before ending stdin', async () => { + const refreshDone = Promise.withResolvers() + mocks.refresh.mockReturnValueOnce(refreshDone.promise) + mocks.proveClaudeChildExit.mockResolvedValueOnce(true) + const child = fakeChild() + const launch: ClaudeStreamJsonLaunch = { + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo' + } + const queryImpl = ((params: Parameters[0]) => { + if (!params.options) { + throw new Error('missing SDK options') + } + params.options.spawnClaudeCodeProcess?.({ + command: 'claude', + args: [], + env: {}, + signal: new AbortController().signal + }) + void (async () => { + for await (const _message of params.prompt) { + // The SDK owns the transport write; the close test only needs its EOF boundary. + } + child.stdin.end() + })() + return (async function* () {})() + }) as typeof query + const connection = await openClaudeStreamJsonConnection(launch, {}, () => child, queryImpl) + + const closing = connection.close() + await new Promise((resolve) => setImmediate(resolve)) + expect(child.stdin.writableEnded).toBe(false) + + refreshDone.resolve() + await expect(closing).resolves.toBe(true) + expect(child.stdin.writableEnded).toBe(true) + }) + + it('requests a fresh close boundary after an output capture starts', async () => { + mocks.refresh.mockReset() + mocks.proveClaudeChildExit.mockReset() + const outputCapture = Promise.withResolvers() + const closeCapture = Promise.withResolvers() + mocks.refresh + .mockReturnValueOnce(outputCapture.promise) + .mockReturnValueOnce(closeCapture.promise) + mocks.proveClaudeChildExit.mockResolvedValueOnce(true) + const child = fakeChild() + const launch: ClaudeStreamJsonLaunch = { + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo' + } + const queryImpl = ((params: Parameters[0]) => { + params.options?.spawnClaudeCodeProcess?.({ + command: 'claude', + args: [], + env: {}, + signal: new AbortController().signal + }) + void (async () => { + for await (const _message of params.prompt) { + // The SDK owns the transport write; the close test only needs its EOF boundary. + } + child.stdin.end() + })() + return (async function* () {})() + }) as typeof query + const connection = await openClaudeStreamJsonConnection(launch, {}, () => child, queryImpl) + + child.stderr.emit('data', 'output') + await vi.waitFor(() => expect(mocks.refresh).toHaveBeenCalledTimes(1)) + const closing = connection.close() + await Promise.resolve() + + expect(mocks.refresh).toHaveBeenCalledTimes(2) + expect(child.stdin.writableEnded).toBe(false) + + outputCapture.resolve() + await Promise.resolve() + expect(child.stdin.writableEnded).toBe(false) + closeCapture.resolve() + await expect(closing).resolves.toBe(true) + expect(child.stdin.writableEnded).toBe(true) + }) +}) diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts new file mode 100644 index 00000000000..c4f1f9a6fca --- /dev/null +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -0,0 +1,768 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' +import { hasLiveClaudePtys } from '../claude-accounts/live-pty-gate' +import type { ProcessSpec } from '../../shared/child-process/process-spec' +import { query, type CanUseTool, type Options } from '@anthropic-ai/claude-agent-sdk' +import { + openClaudeStreamJsonConnection, + type ClaudeStreamJsonConnection, + type ClaudeStreamJsonLaunch +} from './claude-stream-json-connection' +import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' +import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { claudeAuthDiagnostic } from './claude-structured-init-proof' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' +import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' +import type { ClaudeSession } from './claude-structured-session-state' +import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' + +// These drive the real SDK against the scripted fake CLI, so every assertion is +// about the environment, argv and frames a real child actually saw. +const FAKE_CLI = join(__dirname, '__fixtures__', 'claude-agent-sdk-scripted-cli.mjs') +const SESSION_ID = '5348c19f-6a54-4c2e-9c68-9c2b1a3d4e5f' +const HOLD_OPEN = { delayMs: 10_000 } + +type ScriptedCliReport = { + argv: string[] + controlRequests: { request_id: string; request: { subtype: string } }[] + controlResponses: { response: { request_id: string; response?: unknown } }[] + userMessages: Record[] + descendantPid: number | null +} + +const scratchDirs: string[] = [] +const openConnections: ClaudeStreamJsonConnection[] = [] + +afterEach(async () => { + for (const connection of openConnections.splice(0)) { + await connection.close() + } + for (const dir of scratchDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + spawned.splice(0) + spawnedChildren.splice(0) + vi.unstubAllEnvs() +}) + +function scriptScenario( + steps: Record[], + controlResponses: Record = {} +) { + const dir = mkdtempSync(join(tmpdir(), 'claude-sdk-connection-')) + scratchDirs.push(dir) + const scenarioPath = join(dir, 'scenario.json') + const reportPath = join(dir, 'report.json') + writeFileSync(scenarioPath, JSON.stringify({ steps, controlResponses })) + return { + cwd: dir, + env: { + PATH: process.env.PATH ?? '', + ORCA_SDK_CONTRACT_SCENARIO_PATH: scenarioPath, + ORCA_SDK_CONTRACT_REPORT_PATH: reportPath + }, + readReport: () => JSON.parse(readFileSync(reportPath, 'utf8')) as ScriptedCliReport + } +} + +function launchFor( + scenario: { cwd: string; env: Record }, + env: Record = {} +): ClaudeStreamJsonLaunch { + return { + pathToClaudeCodeExecutable: FAKE_CLI, + options: { ...CLAUDE_STRUCTURED_BASE_OPTIONS, sessionId: SESSION_ID }, + cwd: scenario.cwd, + env: { ...scenario.env, ...env } + } +} + +/** The derived child environment, captured where Orca actually hands it to the OS. */ +const spawned: ProcessSpec[] = [] +/** The retained child, so a test can end it the way a crashing CLI would. */ +const spawnedChildren: SpawnedProcess[] = [] + +async function open( + launch: ClaudeStreamJsonLaunch, + handlers: Parameters[1] = {}, + queryImpl?: typeof query +): Promise { + const connection = await openClaudeStreamJsonConnection( + launch, + handlers, + (spec) => { + spawned.push(spec) + const child = spawnProcess(spec) + spawnedChildren.push(child) + return child + }, + queryImpl + ) + openConnections.push(connection) + return connection +} + +function childEnv(): Record { + return (spawned.at(-1)?.env ?? {}) as Record +} + +async function until(read: () => T | null | undefined, label: string): Promise { + for (let attempt = 0; attempt < 400; attempt++) { + const value = read() + if (value !== null && value !== undefined) { + return value + } + await new Promise((resolve) => setTimeout(resolve, 25)) + } + throw new Error(`timed out waiting for ${label}`) +} + +function readReportSafely(scenario: { readReport: () => ScriptedCliReport }) { + try { + return scenario.readReport() + } catch { + return null + } +} + +function processState(pid: number): 'running' | 'exited' { + try { + const state = execFileSync('ps', ['-o', 'state=', '-p', String(pid)], { + encoding: 'utf8', + env: { ...process.env, LANG: 'C', LC_ALL: 'C' } + }).trim() + return state.startsWith('Z') ? 'exited' : 'running' + } catch (error) { + if ((error as { status?: number }).status === 1) { + return 'exited' + } + throw error + } +} + +describe('Claude stream-json connection', () => { + it('passes the Claude Code system-prompt preset through to SDK query', async () => { + const scenario = scriptScenario([HOLD_OPEN]) + let captured: Options | undefined + await open(launchFor(scenario), {}, (params) => { + captured = params.options + return query(params) + }) + + expect(captured?.systemPrompt).toEqual({ type: 'preset', preset: 'claude_code' }) + }) + + it('hands the child a derived environment, the resolved CLI path, and keeps the pid', async () => { + vi.stubEnv('ANTHROPIC_API_KEY', 'sk-ant-SHELL-LEAK') + vi.stubEnv('CLAUDE_CODE_CHILD_SESSION', '1') + vi.stubEnv('NODE_OPTIONS', '--require=/tmp/inject.js') + // An inherited value wins over the SDK's default, so clear it to pin the default. + vi.stubEnv('CLAUDE_CODE_ENTRYPOINT', undefined) + vi.stubEnv('ORCA_CONNECTION_MARKER', 'inherited') + const scenario = scriptScenario([HOLD_OPEN]) + const connection = await open( + launchFor(scenario, { + CLAUDE_CONFIG_DIR: '/accounts/managed/home', + ANTHROPIC_AUTH_TOKEN: 'configured-token', + ORCA_AGENT_SESSION_SPAWN_TOKEN: 'spawn-9', + CLAUDE_CODE_CHILD_SESSION: 'configured-child-session', + CLAUDE_CODE_SESSION_ID: 'configured-session', + CLAUDE_CODE_BRIDGE_SESSION_ID: 'configured-bridge-session' + }) + ) + + // Ownership proof: the pid is a real live process, not a value the SDK reported. + expect(connection.pid).toEqual(expect.any(Number)) + expect(() => process.kill(connection.pid as number, 0)).not.toThrow() + const env = childEnv() + // The managed home is pinned verbatim: the CLI keys credential lookup on the literal string. + expect(env.CLAUDE_CONFIG_DIR).toBe('/accounts/managed/home') + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('configured-token') + expect(env.ORCA_AGENT_SESSION_SPAWN_TOKEN).toBe('spawn-9') + expect(env.ORCA_CONNECTION_MARKER).toBe('inherited') + expect(env.ANTHROPIC_API_KEY).toBeUndefined() + expect(env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined() + expect(env.CLAUDE_CODE_SESSION_ID).toBeUndefined() + expect(env.CLAUDE_CODE_BRIDGE_SESSION_ID).toBeUndefined() + // Two SDK mutations of the child env, pinned so a bump cannot change them unseen. + expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-ts') + expect(env.NODE_OPTIONS).toBeUndefined() + // The bundled binary is excluded from the install, so the resolved path is mandatory. + const report = await until(() => readReportSafely(scenario), 'the scripted CLI report') + expect(report.argv[0]).toBe(FAKE_CLI) + // The .mjs fixture makes the SDK run it under node; a real CLI path is the program + // itself. Either way the resolved path is what Orca's spawner is asked to execute. + expect([spawned.at(-1)?.program, ...(spawned.at(-1)?.args ?? [])]).toContain(FAKE_CLI) + expect(report.argv).toContain('--replay-user-messages') + expect(report.argv).toContain(`--session-id=${SESSION_ID}`) + }) + + it('leaves the default CLI home unpinned so macOS Keychain OAuth keeps working', async () => { + const scenario = scriptScenario([HOLD_OPEN]) + await open(launchFor(scenario)) + + await until(() => readReportSafely(scenario), 'the scripted CLI report') + expect(childEnv().CLAUDE_CONFIG_DIR).toBeUndefined() + }) + + it('settles a send only once the frame reached the child, and replays reach onMessage', async () => { + const replay = { + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + parent_tool_use_id: null, + isReplay: true, + session_id: SESSION_ID, + uuid: 'uuid-replay-1' + } + const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: replay }, HOLD_OPEN]) + const messages: Record[] = [] + const connection = await open(launchFor(scenario), { + onMessage: (message) => messages.push(message) + }) + + await connection.send({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + parent_tool_use_id: null, + session_id: SESSION_ID + }) + // The report exists from the child's first line of work, so poll for the frame + // itself: `send` settles on the SDK's completed write, and the child still has + // to read that line before it can record it. + const report = await until( + () => (readReportSafely(scenario)?.userMessages.length ? readReportSafely(scenario) : null), + 'the user frame recorded by the child' + ) + expect(report.userMessages).toHaveLength(1) + + await until(() => messages.find((message) => message.uuid === 'uuid-replay-1'), 'the replay') + // The replay is delivered verbatim, so the dispatch acknowledgement still binds on it. + expect(messages.find((message) => message.uuid === 'uuid-replay-1')).toEqual(replay) + }) + + it('rejects a send the SDK pulled but could not write to a terminated child', async () => { + const scenario = scriptScenario([{ awaitUserMessage: true }, HOLD_OPEN]) + const connection = await open(launchFor(scenario)) + const child = spawnedChildren.at(-1) + + // Same tick as the send, so the liveness guard still passes and the frame + // reaches the SDK's input pump: its `transport.write` is what fails, which is + // the window a child crashing mid-send actually opens. + child?.kill('SIGKILL') + const sent = connection.send({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + parent_tool_use_id: null, + session_id: SESSION_ID + }) + + await expect(sent).rejects.toThrow() + expect(readReportSafely(scenario)?.userMessages ?? []).toHaveLength(0) + }) + + it('delivers an unmodeled frame verbatim so the provider-fallback row survives', async () => { + const unknown = { + type: 'frame_kind_from_the_future', + session_id: SESSION_ID, + uuid: 'uuid-unknown-1', + payload: { nested: { flags: ['a', 'b'] } } + } + const scenario = scriptScenario([{ emit: unknown }, HOLD_OPEN]) + const messages: Record[] = [] + await open(launchFor(scenario), { onMessage: (message) => messages.push(message) }) + + await until(() => messages.find((message) => message.uuid === 'uuid-unknown-1'), 'the frame') + expect(messages.find((message) => message.uuid === 'uuid-unknown-1')).toEqual(unknown) + }) + + it('commits the real partial-message cadence as one assistant item through the translator', async () => { + // The frame order and per-frame uuids are the ones Claude Code 2.1.258 emits + // under --include-partial-messages: every stream_event and the block's final + // assistant frame each carry their own uuid; only message.id ties them. + const stream = (uuid: string, event: Record) => ({ + type: 'stream_event', + uuid, + session_id: SESSION_ID, + parent_tool_use_id: null, + event + }) + const frames = [ + stream('uuid-message-start', { + type: 'message_start', + message: { id: 'msg_01', role: 'assistant', content: [] } + }), + stream('uuid-block-start', { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' } + }), + stream('uuid-delta-1', { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'ST' } + }), + stream('uuid-delta-2', { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'REAMOK_ELEC_64E632' } + }), + { + type: 'assistant', + uuid: 'uuid-assistant-final', + session_id: SESSION_ID, + parent_tool_use_id: null, + message: { + id: 'msg_01', + role: 'assistant', + content: [{ type: 'text', text: 'STREAMOK_ELEC_64E632' }], + stop_reason: null + } + }, + stream('uuid-block-stop', { type: 'content_block_stop', index: 0 }), + stream('uuid-message-delta', { type: 'message_delta', delta: { stop_reason: 'end_turn' } }), + stream('uuid-message-stop', { type: 'message_stop' }), + { + type: 'result', + subtype: 'success', + is_error: false, + duration_ms: 1, + duration_api_ms: 1, + num_turns: 1, + result: 'STREAMOK_ELEC_64E632', + stop_reason: 'end_turn', + session_id: SESSION_ID, + uuid: 'uuid-result' + } + ] + const scenario = scriptScenario([...frames.map((frame) => ({ emit: frame })), HOLD_OPEN]) + const journal = await openAgentSessionJournal({ + identity: { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: SESSION_ID, leafUuid: 'leaf-1' } + }, + journalDir: join(scenario.cwd, 'journal'), + now: () => 1_700_000_000_000, + mintEpoch: () => 'epoch-1' + }) + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind({ journal, fence: 1, publish: vi.fn() }) + const translator = createClaudeJournalTranslator({ sink: deferred.sink }) + let settled = false + await open(launchFor(scenario), { + onMessage: (message) => { + translator.handle({ type: 'message', sessionId: 'session-1', message }) + settled ||= message.type === 'result' + } + }) + + await until(() => (settled ? true : null), 'the result frame') + await deferred.drained() + const items = journal.snapshot().items + const assistant = items.filter( + (item) => item.body.kind === 'message' && item.body.role === 'assistant' + ) + expect(assistant.map((item) => item.body)).toEqual([ + { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'STREAMOK_ELEC_64E632' }] + } + ]) + expect(assistant.map((item) => item.itemId)).toEqual([`claude:${SESSION_ID}:uuid-block-start`]) + expect( + items.flatMap((item) => + item.body.kind === 'status' && item.body.providerFrame ? [item.body.providerFrame.kind] : [] + ) + ).toEqual([]) + // The journal owns a SQLite connection now; afterEach removes this temp root and an open + // handle blocks that on Windows. + await journal.close() + }) + + it('feeds an inbound permission request to canUseTool and writes its answer back on the same id', async () => { + const scenario = scriptScenario([ + { + emit: { + type: 'control_request', + request_id: 'perm-421', + request: { + subtype: 'can_use_tool', + tool_name: 'Bash', + input: { command: 'ls' }, + tool_use_id: 'toolu_1', + permission_suggestions: [{ type: 'addRules' }] + } + } + }, + { awaitControlResponse: 'perm-421' }, + HOLD_OPEN + ]) + const seen: { toolName: string; requestId: string; toolUseID: string; suggestions: unknown }[] = + [] + const canUseTool: CanUseTool = (toolName, _input, options) => { + seen.push({ + toolName, + requestId: options.requestId, + toolUseID: options.toolUseID, + suggestions: options.suggestions + }) + return Promise.resolve({ behavior: 'deny', message: 'No', toolUseID: options.toolUseID }) + } + await open(launchFor(scenario), { canUseTool }) + + await until(() => (seen.length > 0 ? seen : null), 'the inbound permission request') + expect(seen).toEqual([ + { + toolName: 'Bash', + requestId: 'perm-421', + toolUseID: 'toolu_1', + suggestions: [{ type: 'addRules' }] + } + ]) + const written = await until( + () => + readReportSafely(scenario)?.controlResponses.find( + (frame) => frame.response.request_id === 'perm-421' + ), + 'the permission answer' + ) + expect(written.response.response).toMatchObject({ behavior: 'deny', message: 'No' }) + }) + + it('drives Orca control methods onto the SDK and times out with the init proof message', async () => { + const scenario = scriptScenario([HOLD_OPEN], { + initialize: { models: [{ value: 'sonnet' }], account: { tokenSource: 'oauth' } }, + get_settings: { env: { ANTHROPIC_BASE_URL: 'https://settings.example.test' } } + }) + const connection = await open(launchFor(scenario)) + + await expect(connection.initializationResult()).resolves.toMatchObject({ + models: [{ value: 'sonnet' }] + }) + await expect(connection.getSettings()).resolves.toEqual({ + env: { ANTHROPIC_BASE_URL: 'https://settings.example.test' } + }) + await expect(connection.setModel('opus')).resolves.toBeUndefined() + const requests = await until( + () => + readReportSafely(scenario)?.controlRequests.find( + (frame) => frame.request.subtype === 'set_model' + ), + 'the set_model control request' + ) + expect(requests.request.subtype).toBe('set_model') + }) + + it('reads supportedModels from the catalog the running CLI reported', async () => { + const scenario = scriptScenario([HOLD_OPEN], { + initialize: { + models: [ + { value: 'default', resolvedModel: 'claude-opus-5' }, + { + value: 'opus', + displayName: 'Opus 5', + description: 'The live row, not the seed', + resolvedModel: 'claude-opus-5', + supportsEffort: true, + supportedEffortLevels: ['low', 'high'] + } + ] + } + }) + const connection = await open(launchFor(scenario)) + + await expect(connection.supportedModels()).resolves.toMatchObject([ + { value: 'default', resolvedModel: 'claude-opus-5' }, + { value: 'opus', displayName: 'Opus 5', supportedEffortLevels: ['low', 'high'] } + ]) + }) + + it('serves the picker the live catalog rather than falling back to the static seed', async () => { + const scenario = scriptScenario([HOLD_OPEN], { + initialize: { + models: [ + { value: 'default', resolvedModel: 'claude-opus-5' }, + { + value: 'opus', + displayName: 'Opus 5', + description: 'The live row, not the seed', + resolvedModel: 'claude-opus-5', + supportsEffort: true, + supportedEffortLevels: ['low', 'high'] + } + ] + } + }) + const connection = await open(launchFor(scenario)) + const session = { + connection, + options: new Map(), + reportedOptions: {} + } as unknown as ClaudeSession + + const options = await readClaudeStructuredSessionOptions(session, 5_000) + + // The seed carries neither this description nor a two-level effort list, so + // both can only have come from the child. + expect(options.models).toContainEqual({ + id: 'opus', + label: 'Opus 5', + description: 'The live row, not the seed', + isDefault: true, + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + }) + expect(options.current.model).toBe('opus') + }) + + it('feeds the auth diagnostic from the settings the running child reports', async () => { + for (const key of ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY']) { + vi.stubEnv(key, undefined) + } + const scenario = scriptScenario([HOLD_OPEN], { + get_settings: { + env: { + ANTHROPIC_BASE_URL: 'https://settings.example.test', + ANTHROPIC_AUTH_TOKEN: 'secret' + } + } + }) + const connection = await open(launchFor(scenario)) + const init = { providerSessionId: SESSION_ID, uuid: null, model: null, message: {} } + + // With no ambient auth, every true below can only have come from the CLI's settings. + expect(claudeAuthDiagnostic(init, null)).toMatchObject({ + baseUrlConfigured: false, + authTokenConfigured: false + }) + const diagnostic = claudeAuthDiagnostic(init, await connection.getSettings()) + expect(diagnostic).toMatchObject({ + baseUrlConfigured: true, + authTokenConfigured: true, + apiKeyConfigured: false + }) + expect(JSON.stringify(diagnostic)).not.toContain('secret') + }) + + it('reports an unauthenticated start through the init deadline instead of hanging', async () => { + // The scripted CLI never answers, which is the shape of a silently unauthenticated CLI. + const scenario = scriptScenario([HOLD_OPEN]) + const connection = await open({ + ...launchFor(scenario), + env: { ...launchFor(scenario).env, ORCA_SDK_CONTRACT_IGNORE_CONTROL_REQUESTS: '1' } + }) + + await expect(connection.initializationResult({ timeoutMs: 200 })).rejects.toThrow( + 'claude initialize request timed out' + ) + }) + + it('reports a self-exit with its status and stderr, and leaves its tree unverifiable', async () => { + const scenario = scriptScenario([{ stderr: 'claude: not signed in\n' }, { exit: 1 }]) + let exit: Error | null = null + const connection = await open(launchFor(scenario), { + onExit: (error) => { + exit = error + } + }) + + await until(() => exit, 'the exit error') + // The status and stderr are the only diagnostic a refused start leaves behind. + expect((exit as unknown as Error).message).toMatch(/exited \(code 1\): claude: not signed in/) + expect(connection.closed).toBe(true) + // The root's exit is first-hand, but it left before a descendant snapshot + // could be armed, so close() has no tree proof to offer and says so. + await expect(connection.close()).resolves.toBe(false) + expect(connection.exitVerdict).toEqual({ root: 'exited', tree: 'unverifiable' }) + }) + + it.runIf(process.platform !== 'win32')( + 'proves a natural SDK exit and cleans up its descendant before recovery', + async () => { + const scenario = scriptScenario([ + { stderr: 'claude: natural exit\n' }, + { delayMs: 500 }, + { exit: 1 } + ]) + let exit: Error | null = null + const connection = await open( + { + ...launchFor(scenario), + env: { ...launchFor(scenario).env, ORCA_SDK_CONTRACT_DESCENDANT: '1' } + }, + { onExit: (error) => (exit = error) } + ) + const report = await until(() => { + const current = readReportSafely(scenario) + return current?.descendantPid ? current : null + }, 'the descendant report') + await until(() => exit, 'the natural exit error') + try { + await expect(connection.close()).resolves.toBe(true) + expect(connection.exitVerdict).toEqual({ root: 'exited', tree: 'exited' }) + expect(processState(report.descendantPid as number)).toBe('exited') + } finally { + try { + process.kill(report.descendantPid as number, 'SIGKILL') + } catch { + // Already gone. + } + } + }, + 20_000 + ) + + it('settles a spawn error followed by close as processless and closes idempotently', async () => { + const scenario = scriptScenario([HOLD_OPEN]) + const missingCli = join(scenario.cwd, 'claude-that-does-not-exist') + let fault: Error | null = null + let exit: Error | null = null + const connection = await open( + { ...launchFor(scenario), pathToClaudeCodeExecutable: missingCli }, + { + onFault: (error) => { + fault = error + }, + onExit: (error) => { + exit = error + } + } + ) + + await until( + () => (connection.exitVerdict.root === 'processless' ? connection.exitVerdict : null), + 'the processless spawn settlement' + ) + expect(connection.pid).toBeUndefined() + expect(fault).toBeInstanceOf(Error) + expect(exit).toBeNull() + await expect(Promise.all([connection.close(), connection.close()])).resolves.toEqual([ + true, + true + ]) + await expect(connection.close()).resolves.toBe(true) + expect(connection.exitVerdict).toEqual({ root: 'processless', tree: 'exited' }) + }) + + it('does not treat a child error event as first-hand root exit proof', async () => { + const scenario = scriptScenario([HOLD_OPEN]) + let exit: Error | null = null + const connection = await open(launchFor(scenario), { + onExit: (error) => { + exit = error + } + }) + const child = spawnedChildren.at(-1) + expect(child).toBeDefined() + + child?.emit('error', new Error('child transport fault')) + + expect(exit).toBeNull() + expect(connection.exitVerdict.root).toBe('live') + await until(() => exit, 'the distinct child exit') + expect(connection.exitVerdict.root).toBe('exited') + }) + + it('proves the exit of a child that ignores a graceful shutdown', async () => { + const scenario = scriptScenario([HOLD_OPEN]) + const connection = await open({ + ...launchFor(scenario), + env: { ...launchFor(scenario).env, ORCA_SDK_CONTRACT_IGNORE_SIGTERM: '1' } + }) + + // Keep the lstart capture boundary outside the child's displayed start second. + await new Promise((resolve) => setTimeout(resolve, 1_100)) + await expect(connection.close()).resolves.toBe(true) + }, 20_000) +}) + +// A structured Claude child owns the account's credentials while it runs, exactly as +// a Claude PTY does. The gate is what makes runtime-auth-sync defer the managed OAuth +// refresh instead of rotating the single-use token out from under a live session, and +// structured sessions used to be invisible to it. +describe('the managed-auth live gate', () => { + it('holds while a structured child runs and releases when it ends', async () => { + // The gate is a process-wide singleton and a sibling test's release lands on its + // child's 'close' event, which can settle after that test's close() resolved. + await until(() => (hasLiveClaudePtys() ? null : true), 'a drained auth gate') + const scenario = scriptScenario([ + { emit: { type: 'system', subtype: 'init', session_id: SESSION_ID, uuid: 'init-1' } }, + { wait: HOLD_OPEN } + ]) + const connection = await open(launchFor(scenario)) + + expect(hasLiveClaudePtys()).toBe(true) + + await connection.close() + + await until(() => (hasLiveClaudePtys() ? null : true), 'the auth gate to drain') + expect(hasLiveClaudePtys()).toBe(false) + }, 30_000) + + it('releases when the child dies on its own rather than through close()', async () => { + await until(() => (hasLiveClaudePtys() ? null : true), 'a drained auth gate') + const scenario = scriptScenario([ + { emit: { type: 'system', subtype: 'init', session_id: SESSION_ID, uuid: 'init-1' } }, + { wait: HOLD_OPEN } + ]) + await open(launchFor(scenario)) + expect(hasLiveClaudePtys()).toBe(true) + + spawnedChildren.at(-1)?.kill('SIGKILL') + + await until(() => (hasLiveClaudePtys() ? null : true), 'the auth gate to drain') + expect(hasLiveClaudePtys()).toBe(false) + }, 30_000) + + // The gate entry is deliberately unpersisted, so confirmSeededClaudeLivePtys can never + // reconcile a stray one: a leak here defers the managed OAuth refresh for the life of + // the process. Entering the gate only after the release handlers are attached makes + // that unreachable regardless of what the setup in between does. + it('leaks no gate entry when setup throws between spawn and handler attachment', async () => { + await until(() => (hasLiveClaudePtys() ? null : true), 'a drained auth gate') + const scenario = scriptScenario([ + { emit: { type: 'system', subtype: 'init', session_id: SESSION_ID, uuid: 'init-1' } }, + { wait: HOLD_OPEN } + ]) + let started: SpawnedProcess | null = null + + try { + await expect( + openClaudeStreamJsonConnection(launchFor(scenario), {}, (spec) => { + const child = spawnProcess(spec) + started = child + const attach = child.stderr.on.bind(child.stderr) + // Measured attach order: the SDK binds stderr 'data' from inside query(), + // before the child is even assigned. The SECOND bind is this connection's own + // armTreeOnOutput — the first statement that runs after the child exists and + // before its 'exit'/'close' release handlers. Throwing on the first is + // vacuous: it escapes before any gate entry could have happened. + let dataAttaches = 0 + child.stderr.on = ((event: string, listener: (...args: unknown[]) => void) => { + if (event === 'data') { + dataAttaches += 1 + if (dataAttaches === 2) { + throw new Error('stderr listener attach failed') + } + } + return attach(event, listener) + }) as typeof child.stderr.on + return child + }) + ).rejects.toThrow('stderr listener attach failed') + + expect(hasLiveClaudePtys()).toBe(false) + } finally { + ;(started as SpawnedProcess | null)?.kill('SIGKILL') + } + }, 30_000) +}) diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts new file mode 100644 index 00000000000..dd6bbc8a5eb --- /dev/null +++ b/src/main/claude/claude-stream-json-connection.ts @@ -0,0 +1,283 @@ +import { randomUUID } from 'node:crypto' +import type * as ClaudeAgentSdk from '@anthropic-ai/claude-agent-sdk' +import type { CanUseTool, OnUserDialog, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk' +import { spawnProcess } from '../../shared/child-process/run-process' +import { + markClaudeStructuredChildExited, + markClaudeStructuredChildSpawned +} from '../claude-accounts/live-pty-gate' +import { buildClaudeChildProcessEnv } from './claude-child-process-environment' +import { + ClaudeControlRequestError, + createClaudeControlSurface, + type ClaudeControlSurface +} from './claude-agent-sdk-control-requests' +import { createClaudeChildTreeReaper, proveClaudeChildExit } from './claude-agent-sdk-exit-proof' +import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification' +import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn' +import { createClaudeUserMessageQueue } from './claude-agent-sdk-user-message-queue' +import type { ClaudeStructuredSdkOptions } from './claude-structured-launch-resolution' + +export { ClaudeControlRequestError } + +/** + * The SDK is loaded at the structured-Claude boundary rather than by this module's + * import. The ordinary runtime's class graph statically reaches this file, and the + * SDK sets `process.env.NoDefaultCurrentDirectoryInExePath` at import time — a + * Windows executable-search change that a user who never leaves the terminal/TUI + * path never opted into, and a missing SDK would fail runtime startup. Memoized, + * so a session pays the import once per process rather than once per connection. + */ +let claudeAgentSdk: Promise | null = null + +function loadClaudeAgentSdk(): Promise { + claudeAgentSdk ??= import('@anthropic-ai/claude-agent-sdk') + return claudeAgentSdk +} + +export type ClaudeStreamJsonLaunch = { + /** Orca's resolved user CLI; the SDK falls back to a bundled binary that is not installed. */ + pathToClaudeCodeExecutable: string + options: ClaudeStructuredSdkOptions + cwd: string + env?: Record +} + +export type ClaudeStreamJsonConnectionHandlers = { + onMessage?: (message: Record) => void + /** + * The SDK owns inbound permission control: it hands `can_use_tool` to this callback with + * a stable requestId and an abort signal, dedups duplicate delivery, and matches the + * response by request_id itself. Setting it makes the SDK pass `--permission-prompt-tool + * stdio` automatically; it must not be paired with `permissionPromptToolName`. + */ + canUseTool?: CanUseTool + /** `request_user_dialog` control; the CLI only emits kinds declared in `supportedDialogKinds`. */ + onUserDialog?: OnUserDialog + /** A transport/process fault that is not itself first-hand root exit proof. */ + onFault?: (error: Error) => void + onExit?: (error: Error) => void +} + +/** + * Two questions with their own evidence. The root's verdict is first-hand: Orca's + * own child handle reported exit, or reported error then close before it ever had + * a pid. The tree's comes from bounded descendant verification, and `unverifiable` + * is never collapsed into either neighbour. + */ +export type ClaudeChildExitVerdict = { + root: 'exited' | 'live' | 'processless' + tree: DescendantTreeVerdict +} + +export type ClaudeStreamJsonConnection = ClaudeControlSurface & { + readonly pid: number | undefined + readonly closed: boolean + /** What the ladder has observed so far; read after a `close()` that returned false. */ + readonly exitVerdict: ClaudeChildExitVerdict + send: (message: Record) => Promise + /** Resolves true after processless settlement, or root exit plus observed tree exit. */ + close: () => Promise +} + +type ExitStatus = { code: number | null; signal: NodeJS.Signals | null } + +function exitError(stderrTail: string, status: ExitStatus | null, cause?: Error): Error { + const detail = stderrTail.trim() + // The status is the diagnostic a signed-out or refused start leaves behind; + // it has to survive every wrapper between here and the user. + const how = + status?.signal !== null && status?.signal !== undefined + ? ` (signal ${status.signal})` + : status?.code !== null && status?.code !== undefined + ? ` (code ${status.code})` + : '' + const message = `claude stream-json exited${how}${detail ? `: ${detail}` : ''}` + return cause ? new Error(message, { cause }) : new Error(message) +} + +export async function openClaudeStreamJsonConnection( + launch: ClaudeStreamJsonLaunch, + handlers: ClaudeStreamJsonConnectionHandlers = {}, + spawnImpl: typeof spawnProcess = spawnProcess, + queryImpl?: typeof ClaudeAgentSdk.query +): Promise { + const { query } = await loadClaudeAgentSdk() + const spawner = createClaudeCodeProcessSpawn(spawnImpl) + const inbox = createClaudeUserMessageQueue() + const session = (queryImpl ?? query)({ + prompt: inbox.messages, + options: { + ...launch.options, + cwd: launch.cwd, + // Why env is never omitted: the SDK inherits process.env when it is, which is + // exactly the ambient ANTHROPIC_* auth leak this lane already shipped once. + env: buildClaudeChildProcessEnv(launch.env, { scrubConfiguredChildSessionStamps: true }), + pathToClaudeCodeExecutable: launch.pathToClaudeCodeExecutable, + spawnClaudeCodeProcess: spawner.spawn, + ...(handlers.canUseTool ? { canUseTool: handlers.canUseTool } : {}), + ...(handlers.onUserDialog ? { onUserDialog: handlers.onUserDialog } : {}) + } + }) + const child = spawner.child + if (!child) { + throw new Error('the claude agent SDK returned without spawning a child') + } + // This child owns the account's credentials for as long as it runs, exactly as a + // Claude PTY does — hold the OAuth-refresh gate so a managed refresh cannot rotate + // the single-use token out from under it mid-turn. Entered below, once a release + // path exists. + const authGateKey = randomUUID() + const releaseAuthGate = (): void => markClaudeStructuredChildExited(authGateKey) + let exited = false + let exitStatus: ExitStatus | null = null + let closing = false + let processless = false + let prePidSpawnError = false + let terminalError: Error | null = null + let faultReported = false + let exitReported = false + let closePromise: Promise | null = null + // One reaper per child: every close attempt and error-path reap shares its proof. + const rootSettled = (): boolean => exited || processless + const tree = createClaudeChildTreeReaper(child, { exited: rootSettled }) + + // Arm lazily on actual child output instead of issuing a process-table scan for + // every session at startup. A natural SDK exit can race a later close, while + // output-triggered observation still catches the usual live-child window. + let outputObservationArmed = false + const armTreeOnOutput = (): void => { + if (outputObservationArmed) { + return + } + outputObservationArmed = true + void (tree.refresh?.() ?? tree.capture()) + } + child.stderr.on('data', armTreeOnOutput) + // The SDK may synchronously spawn the CLI and consume an early stderr chunk + // before this connection can attach its listener; the bounded tail preserves + // that observation for the same lazy arm. + if (spawner.stderrTail.length > 0) { + armTreeOnOutput() + } + + let settleExit = (): void => {} + const exitPromise = new Promise((resolve) => { + settleExit = resolve + }) + const markExited = (): void => { + exited = true + releaseAuthGate() + settleExit() + } + child.on('exit', (code, signal) => { + exitStatus = { code, signal } + markExited() + handleUnexpectedEnd() + }) + + const handleUnexpectedEnd = (cause?: Error): void => { + terminalError ??= exitError(spawner.stderrTail, exitStatus, cause) + inbox.fail(terminalError) + if (!closing && !faultReported) { + faultReported = true + handlers.onFault?.(terminalError) + } + if (!closing && exited && !exitReported) { + exitReported = true + handlers.onExit?.(terminalError) + } + } + + void (async () => { + for await (const message of session) { + handlers.onMessage?.(message as unknown as Record) + } + })().catch((error: unknown) => { + // The SDK ends its generator in error when the child dies or the transport + // fails; a transport failure with a live child still has to reap the tree. + if (!closing && !exited) { + void tree.reap() + } + handleUnexpectedEnd(error instanceof Error ? error : new Error(String(error))) + }) + + child.on('error', (error) => { + if (spawner.pid === undefined) { + prePidSpawnError = true + } + if (!closing && !exited) { + void tree.reap() + } + handleUnexpectedEnd(error) + }) + child.on('close', () => { + // Covers the spawn-failure path too, where no 'exit' ever arrives. + releaseAuthGate() + if (prePidSpawnError && spawner.pid === undefined) { + processless = true + settleExit() + } + handleUnexpectedEnd() + }) + child.stdin.on('error', (error) => { + if (!closing) { + void tree.reap() + handleUnexpectedEnd(error) + } + }) + // Why here and not at spawn: a structured gate entry is deliberately unpersisted, so + // confirmSeededClaudeLivePtys can never reconcile a stray one and a leak defers the + // managed OAuth refresh for the life of the process. Entering only after 'exit' and + // 'close' are attached makes that unreachable — any later throw still leaves a + // listener that releases. Nothing between spawn and here can yield, so the child + // cannot end before the gate is entered. + markClaudeStructuredChildSpawned(authGateKey) + + const send = (message: Record): Promise => { + if (closing || exited || terminalError || child.stdin.destroyed || !child.stdin.writable) { + return Promise.reject(terminalError ?? new Error('claude stream-json connection is closed')) + } + return inbox.push(message as unknown as SDKUserMessage) + } + + const close = (): Promise => { + closePromise ??= (async () => { + closing = true + // Arm the descendant proof before ending stdin. The SDK may exit the root + // immediately; a post-exit walk cannot recover descendants that reparented. + await (tree.refresh?.() ?? tree.capture()) + inbox.end() + const proven = await proveClaudeChildExit({ + child, + exitPromise, + exited: rootSettled, + tree + }) + inbox.fail(new Error('claude stream-json connection closed')) + if (!proven) { + closePromise = null + } + return proven + })() + return closePromise + } + + return { + ...createClaudeControlSurface(session), + get pid() { + return spawner.pid + }, + get closed() { + return closing || exited || terminalError !== null + }, + get exitVerdict() { + return { + root: processless ? 'processless' : exited ? 'exited' : 'live', + tree: tree.treeVerdict + } as const + }, + send, + close + } +} diff --git a/src/main/claude/claude-streamed-block-identity.ts b/src/main/claude/claude-streamed-block-identity.ts new file mode 100644 index 00000000000..5cbf6674159 --- /dev/null +++ b/src/main/claude/claude-streamed-block-identity.ts @@ -0,0 +1,110 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { claudeRecord, claudeText } from './claude-structured-item-translation' + +// Under --include-partial-messages every stream_event frame carries its own +// uuid, and the block's final `assistant` frame carries yet another; only +// `message.id` ties them together. The block's first stream frame mints the +// journal identity, and the final frame lands on it in block order instead of +// appending a duplicate under its own uuid. + +export type ClaudeStreamedTextDelta = { identity: AgentJournalItemIdentity; text: string } + +type StreamedMessage = { + messageId: string | null + blocks: Map + /** Streamed text blocks whose final assistant frame has not arrived, in block order. */ + awaitingFinal: AgentJournalItemIdentity[] +} + +export type ClaudeStreamedBlockRegistry = { + /** Text a stream_event frame appends to its block, or null when it carries none. */ + observe: (frame: Record) => ClaudeStreamedTextDelta | null + /** The streamed identity a final assistant frame reconciles onto, if its block streamed. */ + reconcile: (frame: { + sessionId: string + parentToolUseId: string | null + messageId: string | null + }) => AgentJournalItemIdentity | null + clear: () => void +} + +function scopeKey(sessionId: string, parentToolUseId: string | null): string { + return `${sessionId}/${parentToolUseId ?? ''}` +} + +export function createClaudeStreamedBlockRegistry(): ClaudeStreamedBlockRegistry { + const messages = new Map() + + const messageFor = (scope: string): StreamedMessage => { + let streamed = messages.get(scope) + if (!streamed) { + streamed = { messageId: null, blocks: new Map(), awaitingFinal: [] } + messages.set(scope, streamed) + } + return streamed + } + + const mint = ( + streamed: StreamedMessage, + sessionId: string, + index: number, + uuid: string + ): AgentJournalItemIdentity => { + const identity: AgentJournalItemIdentity = { provider: 'claude', sessionId, uuid } + streamed.blocks.set(index, identity) + streamed.awaitingFinal.push(identity) + return identity + } + + return { + observe: (frame) => { + const event = claudeRecord(frame.event) + const sessionId = claudeText(frame.session_id) + const uuid = claudeText(frame.uuid) + if (frame.type !== 'stream_event' || !event || !sessionId || !uuid) { + return null + } + const scope = scopeKey(sessionId, claudeText(frame.parent_tool_use_id)) + if (event.type === 'message_start') { + messages.set(scope, { + messageId: claudeText(claudeRecord(event.message)?.id), + blocks: new Map(), + awaitingFinal: [] + }) + return null + } + const index = typeof event.index === 'number' ? event.index : 0 + if (event.type === 'content_block_start') { + const block = claudeRecord(event.content_block) + if (block?.type !== 'text') { + return null + } + const identity = mint(messageFor(scope), sessionId, index, uuid) + const text = claudeText(block.text) + return text ? { identity, text } : null + } + if (event.type !== 'content_block_delta') { + return null + } + const delta = claudeRecord(event.delta) + const text = delta?.type === 'text_delta' ? claudeText(delta.text) : null + if (!text) { + return null + } + const streamed = messageFor(scope) + const identity = streamed.blocks.get(index) ?? mint(streamed, sessionId, index, uuid) + return { identity, text } + }, + reconcile: (frame) => { + const streamed = messages.get(scopeKey(frame.sessionId, frame.parentToolUseId)) + if ( + !streamed || + (frame.messageId && streamed.messageId && frame.messageId !== streamed.messageId) + ) { + return null + } + return streamed.awaitingFinal.shift() ?? null + }, + clear: () => messages.clear() + } +} diff --git a/src/main/claude/claude-streamed-text-checkpoints.test.ts b/src/main/claude/claude-streamed-text-checkpoints.test.ts new file mode 100644 index 00000000000..00a0bc0edd6 --- /dev/null +++ b/src/main/claude/claude-streamed-text-checkpoints.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints' + +function identityOf(uuid: string): AgentJournalItemIdentity { + return { provider: 'claude', sessionId: 'claude-session', uuid } +} + +function checkpoints() { + const rows: { uuid: string; text: string }[] = [] + let scheduled: (() => void) | null = null + const store = createClaudeStreamedTextCheckpoints({ + persist: (identity, text) => { + rows.push({ uuid: 'uuid' in identity ? identity.uuid : '', text }) + }, + schedule: (run) => { + scheduled = run + return () => { + scheduled = null + } + } + }) + return { + store, + rows, + runWindow: () => { + const run = scheduled as (() => void) | null + run?.() + } + } +} + +describe('claude streamed text checkpoints', () => { + it('rewrites a block row with the full text accumulated so far', () => { + const { store, rows, runWindow } = checkpoints() + + store.append(identityOf('block-1'), 'hel') + store.append(identityOf('block-1'), 'lo') + runWindow() + + expect(rows).toEqual([{ uuid: 'block-1', text: 'hello' }]) + expect(store.pending).toBe(1) + }) + + it('drops every block still awaiting its final frame at settlement', () => { + const { store, rows, runWindow } = checkpoints() + + store.append(identityOf('block-1'), 'partial answer') + runWindow() + store.settle() + + expect(store.pending).toBe(0) + // The row written before settlement stays; nothing is rewritten afterwards. + store.flush() + expect(rows).toEqual([{ uuid: 'block-1', text: 'partial answer' }]) + }) + + it('keeps a block whose final frame arrived out of the settlement sweep', () => { + const { store } = checkpoints() + + store.append(identityOf('block-1'), 'one') + store.append(identityOf('block-2'), 'two') + store.forget('claude:claude-session:block-1') + + expect(store.pending).toBe(1) + store.settle() + expect(store.pending).toBe(0) + }) + + it('flushes text the widening checkpoint interval has not written yet', () => { + const { store, rows } = checkpoints() + + store.append(identityOf('block-1'), 'x') + store.flush() + + expect(rows).toEqual([{ uuid: 'block-1', text: 'x' }]) + // Already at the row's length: a second flush has nothing to write. + store.flush() + expect(rows).toHaveLength(1) + }) + + it('stops persisting once disposed', () => { + const { store, rows, runWindow } = checkpoints() + + store.append(identityOf('block-1'), 'text') + store.dispose() + runWindow() + store.flush() + + expect(rows).toEqual([]) + expect(store.pending).toBe(0) + }) +}) diff --git a/src/main/claude/claude-streamed-text-checkpoints.ts b/src/main/claude/claude-streamed-text-checkpoints.ts new file mode 100644 index 00000000000..348ecd99558 --- /dev/null +++ b/src/main/claude/claude-streamed-text-checkpoints.ts @@ -0,0 +1,105 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + createAgentSessionDeltaCoalescer, + type AgentSessionDeltaCoalescerDeps +} from '../native-chat/agent-session-wire/agent-session-delta-coalescer' + +export type ClaudeStreamedTextCheckpointDeps = { + /** Rewrites the block's journal row with the text accumulated so far. */ + persist: (identity: AgentJournalItemIdentity, text: string) => void + coalesceMs?: number + schedule?: AgentSessionDeltaCoalescerDeps['schedule'] +} + +export type ClaudeStreamedTextCheckpoints = { + /** Accumulate a delta; the row is rewritten on the coalescer's own cadence. */ + append: (identity: AgentJournalItemIdentity, text: string) => void + /** Write every block whose row is behind the text received for it. */ + flush: () => void + /** Drop one block's state, for a block whose final frame has now landed. */ + forget: (key: string) => void + /** + * Drop every block still awaiting its final frame, at turn settlement. Their + * text is already journaled by the flush that precedes settlement; keeping it + * live would grow with every interrupted turn for the life of the session. + */ + settle: () => void + /** Blocks still awaiting a final frame. A settled turn must leave none. */ + readonly pending: number + dispose: () => void +} + +/** + * Growth of a streamed block's row between its deltas and its final frame. + * + * The row is rewritten on a widening interval rather than per delta: a 200-line + * reply would otherwise rewrite the same journal row once per token. + */ +export function createClaudeStreamedTextCheckpoints( + deps: ClaudeStreamedTextCheckpointDeps +): ClaudeStreamedTextCheckpoints { + const identities = new Map() + const latestText = new Map() + const checkpointLengths = new Map() + + const persist = (key: string, text: string, force: boolean): void => { + latestText.set(key, text) + const checkpointLength = checkpointLengths.get(key) ?? 0 + const nextLength = Math.max(checkpointLength + 32, Math.ceil(checkpointLength * 1.125)) + if (!force && checkpointLength > 0 && text.length < nextLength) { + return + } + const identity = identities.get(key) + if (!identity) { + return + } + checkpointLengths.set(key, text.length) + deps.persist(identity, text) + } + + const coalescer = createAgentSessionDeltaCoalescer({ + ...(deps.coalesceMs === undefined ? {} : { windowMs: deps.coalesceMs }), + ...(deps.schedule ? { schedule: deps.schedule } : {}), + emit: (key, text) => persist(key, text, false) + }) + + const drop = (key: string): void => { + coalescer.forget(key) + identities.delete(key) + latestText.delete(key) + checkpointLengths.delete(key) + } + + return { + append: (identity, text) => { + const key = agentJournalItemKey(identity) + identities.set(key, identity) + coalescer.append(key, text) + }, + flush: () => { + coalescer.flushAll() + for (const [key, text] of latestText) { + if (checkpointLengths.get(key) !== text.length) { + persist(key, text, true) + } + } + }, + forget: drop, + settle: () => { + // Map iteration tolerates deletion of the entry just visited. + for (const key of identities.keys()) { + drop(key) + } + }, + get pending() { + return identities.size + }, + dispose: () => { + coalescer.dispose() + identities.clear() + latestText.clear() + checkpointLengths.clear() + } + } +} diff --git a/src/main/claude/claude-structured-acquisition-release.ts b/src/main/claude/claude-structured-acquisition-release.ts new file mode 100644 index 00000000000..1b633553e89 --- /dev/null +++ b/src/main/claude/claude-structured-acquisition-release.ts @@ -0,0 +1,43 @@ +import { + closeClaudeSession, + claudeAcquisitionCleanupError +} from './claude-structured-session-close' +import type { + ClaudeAcquisitionRegistry, + ClaudeSession, + ClaudeSessionExit, + ClaudeStructuredSessionAdapterDeps +} from './claude-structured-session-state' + +/** + * Cleanup for an acquisition the host could not commit or prove. A session that + * a first-hand exit already removed is not an absence to report as proven: the + * ladder on its connection still answers, and that answer is classified exactly + * as a start-time failure would be. + */ +export async function releaseClaudeAcquisition(input: { + sessionId: string + sessions: Map + acquisitions: ClaudeAcquisitionRegistry + exits: Map + onExitProven?: (sessionId: string, exit: ClaudeSessionExit) => Promise + persistHandle?: ClaudeStructuredSessionAdapterDeps['persistHandle'] + onEvent?: ClaudeStructuredSessionAdapterDeps['onEvent'] +}): Promise { + const exit = input.exits.get(input.sessionId) + if (!exit || input.sessions.has(input.sessionId) || input.acquisitions.get(input.sessionId)) { + return closeClaudeSession(input) + } + const firstProof = exit.closePromise ? await exit.closePromise : false + // A failed exit-path proof is retained as evidence, not as a terminal result; + // a release retry must drive a fresh tree verification on the same connection. + const retriedProof = firstProof || (await exit.connection.close()) + if (retriedProof) { + await input.onExitProven?.(input.sessionId, exit) + // Keep the first-hand exit evidence indexed until the tree proof succeeds; + // a failed close must be retryable and cannot look like an absent session. + input.exits.delete(input.sessionId) + return true + } + throw claudeAcquisitionCleanupError(exit.connection, exit.error) +} diff --git a/src/main/claude/claude-structured-auth-parity.test.ts b/src/main/claude/claude-structured-auth-parity.test.ts new file mode 100644 index 00000000000..ddc69366aad --- /dev/null +++ b/src/main/claude/claude-structured-auth-parity.test.ts @@ -0,0 +1,235 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from '../claude-accounts/live-pty-gate' +import { + CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE +} from '../claude-accounts/environment' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' +import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' +import { + PROVIDER_SESSION_ID, + adapterFor, + fakeClaude, + identityFor +} from './claude-structured-session-test-support' + +const SESSION_ID = 'orca-session-auth' +const IDENTITY = { sessionId: SESSION_ID } as Parameters< + ReturnType +>[0]['identity'] + +function record(): AgentSessionRecord { + return { + sessionId: SESSION_ID, + provider: 'claude', + location: { + executionHostId: LOCAL_EXECUTION_HOST_ID, + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/work/.claude' }, + providerHandleChain: [] + } as unknown as AgentSessionRecord +} + +function resolverFor(options: { + stripAuthEnv: boolean + overlay?: Record + authSwitchSettleTimeoutMs?: number +}): ReturnType { + return createClaudeStructuredLaunchResolver({ + store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + resolveWorkspacePath: async (id) => `/repos/${id}`, + resolveCommand: () => '/usr/local/bin/claude', + resolveAuthPolicy: () => ({ stripAuthEnv: options.stripAuthEnv }), + authSwitchSettleTimeoutMs: options.authSwitchSettleTimeoutMs ?? 20, + ...(options.overlay ? { resolveEnv: () => options.overlay as Record } : {}) + }) +} + +/** + * An adapter driven by the REAL launch resolver, not the stub in the shared test + * support — the stub has no auth guard at all, so a teardown-window test built on it + * would pass whatever the guard did. + */ +function realResolverAdapter( + claude: ReturnType, + authSwitchSettleTimeoutMs: number +): ClaudeStructuredSessionAdapter { + const resumable = { + ...record(), + providerHandleChain: [ + { handle: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } } + ] + } as unknown as AgentSessionRecord + return new ClaudeStructuredSessionAdapter({ + resolveLaunch: createClaudeStructuredLaunchResolver({ + store: { getRecord: () => resumable } as unknown as AgentSessionRecordStore, + resolveWorkspacePath: async (id) => `/repos/${id}`, + resolveCommand: () => '/usr/local/bin/claude', + resolveAuthPolicy: () => ({ stripAuthEnv: false }), + authSwitchSettleTimeoutMs + }), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => 1_700_000_000_500, + persistHandle: async () => {} + }) +} + +function withAmbientAuth(value: string, run: () => Promise): Promise { + const restore = process.env.ANTHROPIC_API_KEY + process.env.ANTHROPIC_API_KEY = value + return run().finally(() => { + if (restore === undefined) { + delete process.env.ANTHROPIC_API_KEY + } else { + process.env.ANTHROPIC_API_KEY = restore + } + }) +} + +describe('claude structured auth parity with the terminal preflight', () => { + afterEach(() => { + endClaudeAuthSwitch() + }) + + // Task 1 — the terminal preflight refuses this at spawn-env.ts:25 and + // runtime/spawn-preflight.ts:139; the structured path used to let the override win. + it('refuses an explicit Anthropic auth override while a managed account is pinned', async () => { + await expect( + resolverFor({ stripAuthEnv: true, overlay: { ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED' } })({ + identity: IDENTITY + }) + ).rejects.toThrow(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + }) + + it('refuses an auth-like ANTHROPIC_CUSTOM_HEADERS override while a managed account is pinned', async () => { + await expect( + resolverFor({ + stripAuthEnv: true, + overlay: { ANTHROPIC_CUSTOM_HEADERS: 'Authorization: Bearer sk-ant-CONFIGURED' } + })({ identity: IDENTITY }) + ).rejects.toThrow(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + }) + + it('still admits a non-auth env overlay under a managed account', async () => { + const launch = await resolverFor({ + stripAuthEnv: true, + overlay: { ANTHROPIC_BASE_URL: 'https://gateway.example.test' } + })({ identity: IDENTITY }) + + expect(launch.env?.ANTHROPIC_BASE_URL).toBe('https://gateway.example.test') + }) + + // Task 2 — legacy computes stripAuthEnv at runtime-auth-preparation.ts:72, so a + // system-auth user's own shell key is their sign-in and must survive. + it('passes an ambient Anthropic key through when no managed account is active', async () => { + await withAmbientAuth('sk-ant-SHELL', async () => { + const launch = await resolverFor({ stripAuthEnv: false })({ identity: IDENTITY }) + + expect(launch.env?.ANTHROPIC_API_KEY).toBe('sk-ant-SHELL') + }) + }) + + it('lets an explicit overlay override the ambient key when no managed account is active', async () => { + await withAmbientAuth('sk-ant-SHELL', async () => { + const launch = await resolverFor({ + stripAuthEnv: false, + overlay: { ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED' } + })({ identity: IDENTITY }) + + expect(launch.env?.ANTHROPIC_API_KEY).toBe('sk-ant-CONFIGURED') + }) + }) + + it('still strips the ambient Anthropic key when a managed account is pinned', async () => { + await withAmbientAuth('sk-ant-SHELL', async () => { + const launch = await resolverFor({ stripAuthEnv: true })({ identity: IDENTITY }) + + expect(launch.env?.ANTHROPIC_API_KEY).toBeUndefined() + }) + }) + + // Task 3 — the terminal preflight guards this at four sites; the structured path had none. + it('refuses launch resolution when an account switch never settles', async () => { + beginClaudeAuthSwitch() + + await expect( + resolverFor({ stripAuthEnv: true, authSwitchSettleTimeoutMs: 20 })({ identity: IDENTITY }) + ).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + }) + + it('waits a settling account switch out rather than refusing a resolved launch', async () => { + beginClaudeAuthSwitch() + setTimeout(() => endClaudeAuthSwitch(), 20) + + const launch = await resolverFor({ + stripAuthEnv: true, + authSwitchSettleTimeoutMs: 5_000 + })({ identity: IDENTITY }) + + expect(launch.claudeConfigDir).toBe('/home/work/.claude') + }) + + it('refuses an acquire before it tears the previous session down', async () => { + const claude = fakeClaude() + const adapter = adapterFor(claude) + beginClaudeAuthSwitch() + + await expect( + adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + ).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + // Nothing was spawned, so the refusal must not have opened a connection. + expect(claude.connections).toHaveLength(0) + }) + + // The teardown between the entry guard and launch resolution closes the live child + // and proves its tree — seconds, not milliseconds. A switch that begins inside it + // has already cost the user their session, so refusing there produces exactly the + // outcome the entry guard advertises against: a dead chat and no replacement. + it('replaces the session when a switch begins inside the acquire teardown', async () => { + const claude = fakeClaude() + const adapter = realResolverAdapter(claude, 5_000) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + const live = claude.connections[0]! + const closeWithSwitch = live.close + live.close = async () => { + beginClaudeAuthSwitch() + setTimeout(() => endClaudeAuthSwitch(), 20) + return closeWithSwitch() + } + + await expect( + adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) + ).resolves.toMatchObject({ process: { spawnToken: 'spawn-10' } }) + expect(live.closed).toBe(true) + // The replacement child exists: the user's chat came back. + expect(claude.connections).toHaveLength(2) + expect(claude.connections[1]!.closed).toBe(false) + await adapter.closeAll() + }) + + it('still refuses a mid-teardown switch that never settles, leaving nothing half-open', async () => { + const claude = fakeClaude() + const adapter = realResolverAdapter(claude, 20) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + const live = claude.connections[0]! + const closeWithSwitch = live.close + live.close = async () => { + beginClaudeAuthSwitch() + return closeWithSwitch() + } + + await expect( + adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) + ).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + // No replacement child was opened, so nothing is left running unowned. + expect(claude.connections).toHaveLength(1) + await adapter.closeAll() + }) +}) diff --git a/src/main/claude/claude-structured-content-parts.test.ts b/src/main/claude/claude-structured-content-parts.test.ts new file mode 100644 index 00000000000..d2142150937 --- /dev/null +++ b/src/main/claude/claude-structured-content-parts.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +function sinkState() { + const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => items.push({ identity, body }), + appendTombstone: () => {}, + publish: vi.fn() + } + return { sink, items } +} + +function providerRows(items: { body: AgentJournalItemBody }[]) { + return items.flatMap((item) => + item.body.kind === 'status' && item.body.providerFrame + ? [{ kind: item.body.providerFrame.kind, text: item.body.text }] + : [] + ) +} + +function userMessageWith(part: unknown) { + return { + type: 'message' as const, + sessionId: 'orca-session', + startsTurn: true as const, + message: { + type: 'user', + uuid: 'user-1', + session_id: 'claude-session', + parent_tool_use_id: null, + isReplay: true, + message: { role: 'user', content: [{ type: 'text', text: 'look at this' }, part] } + } + } +} + +/** Exactly what claudeDispatchMessageContent sends for a local attachment. */ +const BASE64_IMAGE = { + type: 'image', + source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgoAAAANSUhEUg==' } +} + +describe('Claude message content parts', () => { + it('does not leak a wire kind for a locally attached image', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(userMessageWith(BASE64_IMAGE)) + + expect(providerRows(state.items)).toEqual([]) + }) + + it('still renders an image the CLI sends by url', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle( + userMessageWith({ type: 'image', source: { type: 'url', url: 'https://x.test/a.png' } }) + ) + + expect(providerRows(state.items)).toEqual([]) + expect( + state.items.flatMap((item) => (item.body.kind === 'message' ? item.body.blocks : [])) + ).toContainEqual({ type: 'image-ref', url: 'https://x.test/a.png' }) + }) + + it('says what is true for a content part it cannot render, not the wire kind', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(userMessageWith({ type: 'some_future_part', payload: { a: 1 } })) + + const rows = providerRows(state.items) + expect(rows).toHaveLength(1) + // The kind stays on the row for debugging, behind the disclosure. + expect(rows[0].kind).toBe('message:user:content:some_future_part') + // ...but the visible text is a sentence, not the opcode. + expect(rows[0].text).not.toContain('message:user:content') + expect(rows[0].text.toLowerCase()).toContain('claude') + }) + + it('prefers a readable sentence the part carries over the placeholder', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle( + userMessageWith({ type: 'some_future_part', message: 'the server refused the upload' }) + ) + + expect(providerRows(state.items)[0].text).toBe('the server refused the upload') + }) +}) diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts new file mode 100644 index 00000000000..c471a8aca80 --- /dev/null +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest' +import { cancelClaudeTurn, answerClaudePrompt } from './claude-structured-control-actions' +import { ClaudeControlRequestError } from './claude-stream-json-connection' +import { ClaudePromptRegistry } from './claude-structured-prompt-replies' +import type { ClaudeSession } from './claude-structured-session-state' + +type InterruptResult = Awaited> + +function sessionWith(input: { + capabilities?: string[] + interrupt: (options?: { cancelQueued?: boolean; timeoutMs?: number }) => Promise + cancelAsyncMessage?: (uuid: string) => Promise + prompts?: ClaudePromptRegistry +}): { + session: ClaudeSession + interrupt: ReturnType + cancelAsyncMessage: ReturnType +} { + const interrupt = vi.fn(input.interrupt) + const cancelAsyncMessage = vi.fn(input.cancelAsyncMessage ?? (async () => {})) + const session = { + capabilities: input.capabilities ?? [], + prompts: input.prompts ?? new ClaudePromptRegistry(), + connection: { interrupt, cancelAsyncMessage } + } as unknown as ClaudeSession + return { session, interrupt, cancelAsyncMessage } +} + +describe('cancelClaudeTurn', () => { + it('interrupts without a receipt on an older CLI and reports the turn cancelled', async () => { + const { session, interrupt, cancelAsyncMessage } = sessionWith({ + interrupt: async () => undefined + }) + + await expect(cancelClaudeTurn(session, 5_000)).resolves.toEqual({ cancelled: true }) + expect(interrupt).toHaveBeenCalledWith({ timeoutMs: 5_000 }) + expect(cancelAsyncMessage).not.toHaveBeenCalled() + }) + + it('withdraws every still-queued message a plain interrupt receipt reports', async () => { + const { session, interrupt, cancelAsyncMessage } = sessionWith({ + capabilities: ['interrupt_receipt_v1'], + interrupt: async () => ({ still_queued: ['queued-1', 'queued-2'] }) + }) + + await expect(cancelClaudeTurn(session, 5_000)).resolves.toEqual({ cancelled: true }) + // No cancel_queued capability, so the queue is swept one uuid at a time. + expect(interrupt).toHaveBeenCalledWith({ timeoutMs: 5_000 }) + expect(cancelAsyncMessage.mock.calls.map((call) => call[0])).toEqual(['queued-1', 'queued-2']) + }) + + it('sends cancel_queued and never sweeps when the CLI advertises the capability', async () => { + const { session, interrupt, cancelAsyncMessage } = sessionWith({ + capabilities: ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1'], + interrupt: async () => ({ still_queued: [], cancelled: ['queued-1'] }) + }) + + await expect(cancelClaudeTurn(session, 5_000)).resolves.toEqual({ cancelled: true }) + expect(interrupt).toHaveBeenCalledWith({ cancelQueued: true, timeoutMs: 5_000 }) + expect(cancelAsyncMessage).not.toHaveBeenCalled() + }) + + it('reports a not-running interrupt as not cancelled without throwing', async () => { + const { session } = sessionWith({ + interrupt: async () => { + throw new ClaudeControlRequestError('interrupt', 'not running') + } + }) + + await expect(cancelClaudeTurn(session, 5_000)).resolves.toEqual({ cancelled: false }) + }) + + it('propagates a transport failure such as an interrupt timeout', async () => { + const { session } = sessionWith({ + interrupt: async () => { + throw new Error('claude interrupt request timed out') + } + }) + + await expect(cancelClaudeTurn(session, 5_000)).rejects.toThrow('timed out') + }) +}) + +describe('answerClaudePrompt', () => { + it('settles the pending prompt callback and forgets it', async () => { + const prompts = new ClaudePromptRegistry() + const settle = vi.fn() + const prompt = prompts.register({ + requestId: 'perm-1', + toolName: 'Bash', + toolUseId: 'tool-1', + input: { command: 'ls' }, + suggestions: [], + settle + })! + prompts.bindJournalItemId('journal-1', prompt.promptKey) + const { session } = sessionWith({ interrupt: async () => undefined, prompts }) + + await answerClaudePrompt(session, { itemId: 'journal-1', kind: 'approval', optionId: 'allow' }) + + expect(settle).toHaveBeenCalledWith( + expect.objectContaining({ behavior: 'allow', toolUseID: 'tool-1' }) + ) + expect(prompts.find('journal-1')).toBeNull() + }) + + it('refuses an answer for a prompt Claude is no longer waiting on', async () => { + const { session } = sessionWith({ interrupt: async () => undefined }) + await expect( + answerClaudePrompt(session, { itemId: 'missing', kind: 'approval', optionId: 'allow' }) + ).rejects.toThrow(/no longer waiting/) + }) +}) diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts new file mode 100644 index 00000000000..d4484963aae --- /dev/null +++ b/src/main/claude/claude-structured-control-actions.ts @@ -0,0 +1,60 @@ +import { applyClaudePromptAnswer } from './claude-structured-prompt-replies' +import { ClaudeControlRequestError } from './claude-stream-json-connection' +import type { ClaudeSession } from './claude-structured-session-state' + +const INTERRUPT_CANCEL_QUEUED_CAPABILITY = 'interrupt_cancel_queued_v1' + +export type ClaudeTurnCancellationGuard = () => boolean + +/** + * Interrupt the running turn, then make sure no queued async user message survives to spawn a + * later unexpected turn. On a CLI advertising `interrupt_cancel_queued_v1` one round trip + * cancels the queue alongside the abort; otherwise the interrupt receipt lists `still_queued` + * uuids, and each is withdrawn best-effort with `cancel_async_message`. Older CLIs resolve no + * receipt, so there is nothing to sweep. + */ +export async function cancelClaudeTurn( + session: ClaudeSession, + timeoutMs: number | undefined, + isCurrent: ClaudeTurnCancellationGuard = () => true +): Promise<{ cancelled: boolean }> { + // The SDK interrupt is session-scoped. Re-check the caller's turn/fence + // immediately before issuing it so a delayed request cannot stop a later turn. + if (!isCurrent()) { + return { cancelled: false } + } + const cancelQueued = session.capabilities.includes(INTERRUPT_CANCEL_QUEUED_CAPABILITY) + try { + const receipt = await session.connection.interrupt({ + ...(cancelQueued ? { cancelQueued: true } : {}), + timeoutMs + }) + if (!cancelQueued) { + for (const uuid of receipt?.still_queued ?? []) { + await session.connection.cancelAsyncMessage(uuid, { timeoutMs }).catch(() => {}) + } + } + return { cancelled: true } + } catch (error) { + if (error instanceof ClaudeControlRequestError) { + return { cancelled: false } + } + throw error + } +} + +export async function answerClaudePrompt( + session: ClaudeSession, + input: { itemId: string; kind: 'approval' | 'question'; optionId: string } +): Promise { + const found = session.prompts.find(input.itemId) + if (!found || found.prompt.kind !== input.kind) { + throw new Error(`claude is no longer waiting on ${input.itemId}`) + } + const response = applyClaudePromptAnswer(found, input.optionId) + if (response === null) { + return + } + session.prompts.forget(found.prompt) + found.prompt.settle(response) +} diff --git a/src/main/claude/claude-structured-dispatch-content.ts b/src/main/claude/claude-structured-dispatch-content.ts new file mode 100644 index 00000000000..71f180bc3ac --- /dev/null +++ b/src/main/claude/claude-structured-dispatch-content.ts @@ -0,0 +1,165 @@ +import { createHash } from 'node:crypto' +import { open } from 'node:fs/promises' +import { extname } from 'node:path' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { NativeChatBlock } from '../../shared/native-chat-types' + +const MAX_IMAGE_BYTES = 5 * 1024 * 1024 +const MAX_IMAGE_COUNT = 20 +const MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024 +const MAX_REPLAY_CONTENT_KEY_BYTES = 256 + +type ImageBudget = { + count: number + localBytes: number +} + +export async function readClaudeImage(path: string, openImpl: typeof open = open): Promise { + const file = await openImpl(path, 'r') + try { + const invalidImage = (): Error => + new Error(`Claude image must be a non-empty file no larger than ${MAX_IMAGE_BYTES} bytes`) + const info = await file.stat() + if (!info.isFile()) { + throw new Error('Claude image must be a file') + } + if (info.size > MAX_IMAGE_BYTES) { + throw invalidImage() + } + const buffer = Buffer.allocUnsafe(info.size + 1) + let bytesRead = 0 + while (bytesRead < buffer.length) { + const result = await file.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead) + if (result.bytesRead === 0) { + break + } + bytesRead += result.bytesRead + } + // A file can grow after the initial stat and after the final read returns + // zero. Prove the descriptor's size matches what was copied before sending. + const finalInfo = await file.stat() + if (bytesRead === 0 || bytesRead > MAX_IMAGE_BYTES || finalInfo.size !== bytesRead) { + throw invalidImage() + } + return buffer.subarray(0, bytesRead) + } finally { + await file.close() + } +} + +const IMAGE_MIME_BY_EXTENSION: Record = { + '.gif': 'image/gif', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp' +} + +async function imageContent( + block: Extract, + budget: ImageBudget +): Promise { + budget.count += 1 + if (budget.count > MAX_IMAGE_COUNT) { + throw new Error(`Claude messages support at most ${MAX_IMAGE_COUNT} images`) + } + if (block.url) { + return { type: 'image', source: { type: 'url', url: block.url } } + } + if (!block.path) { + throw new Error('image reference has neither a path nor a URL') + } + const data = await readClaudeImage(block.path) + budget.localBytes += data.byteLength + if (budget.localBytes > MAX_TOTAL_IMAGE_BYTES) { + throw new Error(`Claude images must total no more than ${MAX_TOTAL_IMAGE_BYTES} bytes`) + } + const mediaType = IMAGE_MIME_BY_EXTENSION[extname(block.path).toLowerCase()] + if (!mediaType) { + throw new Error(`Claude does not support the image type ${extname(block.path)}`) + } + return { + type: 'image', + source: { + type: 'base64', + media_type: mediaType, + data: data.toString('base64') + } + } +} + +export async function claudeDispatchMessageContent( + body: AgentJournalMessageItem +): Promise { + if (body.role !== 'user') { + throw new Error('Claude dispatch accepts only user messages') + } + const content: unknown[] = [] + const imageBudget: ImageBudget = { count: 0, localBytes: 0 } + for (const block of body.blocks as NativeChatBlock[]) { + if (block.type === 'text' && block.text.length > 0) { + content.push({ type: 'text', text: block.text }) + } else if (block.type === 'image-ref') { + content.push(await imageContent(block, imageBudget)) + } + } + if (content.length === 0) { + throw new Error('Claude dispatch requires text or an image') + } + return content +} + +/** + * Keep waiter metadata bounded even when a dispatch contains large base64 images. + * The digest is only diagnostic: replay acknowledgement must use provider identity. + */ +export function claudeDispatchContentKey(content: readonly unknown[]): string { + const digest = createHash('sha256') + const summary = content + .map((part) => { + const record = + typeof part === 'object' && part !== null && !Array.isArray(part) + ? (part as Record) + : null + const type = typeof record?.type === 'string' ? record.type : 'unknown' + if (type === 'text') { + return `text:${typeof record?.text === 'string' ? record.text.length : 0}` + } + const source = + typeof record?.source === 'object' && record.source !== null + ? (record.source as Record) + : null + if (type === 'image' && source?.type === 'base64') { + return `image:${typeof source.media_type === 'string' ? source.media_type : ''}:${typeof source.data === 'string' ? source.data.length : 0}` + } + return type + }) + .join(',') + for (const [index, part] of content.entries()) { + const record = + typeof part === 'object' && part !== null && !Array.isArray(part) + ? (part as Record) + : null + const type = typeof record?.type === 'string' ? record.type : 'unknown' + digest.update(`${index}:${type}:`) + if (type === 'text' && typeof record?.text === 'string') { + digest.update(record.text) + continue + } + const source = + typeof record?.source === 'object' && record.source !== null + ? (record.source as Record) + : null + if (type === 'image' && source?.type === 'base64') { + digest.update(typeof source.media_type === 'string' ? source.media_type : '') + digest.update(':') + if (typeof source.data === 'string') { + digest.update(source.data) + } + continue + } + digest.update(JSON.stringify(part)) + } + const key = `v1:${summary.slice(0, 128)}:${digest.digest('hex')}` + return key.slice(0, MAX_REPLAY_CONTENT_KEY_BYTES) +} diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts new file mode 100644 index 00000000000..4e8289a89e3 --- /dev/null +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -0,0 +1,598 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { dispatchClaudeTurn, resolveClaudeReplayWaiter } from './claude-structured-dispatch' +import { readClaudeImage } from './claude-structured-dispatch-content' +import type { ClaudeSession } from './claude-structured-session-state' + +function sessionFor(send = vi.fn().mockResolvedValue(undefined)): ClaudeSession { + return { + connection: { send } as unknown as ClaudeSession['connection'], + providerSessionId: 'provider-session', + claudeConfigDir: '/accounts/claude', + leafUuid: null, + fence: 1, + acquisitionGeneration: 'generation-1', + prompts: {} as ClaudeSession['prompts'], + dispatchWaiters: [], + retiredDispatchWaiters: [], + replayContentFallbackBlocked: false, + dispatchSequence: 0, + optionMutationSequence: 0, + options: new Map(), + reportedOptions: {}, + reportedModelMutation: 0, + confirmedOptions: new Set(), + restoreSkippedOptions: new Set(), + capabilities: [], + events: undefined, + translator: null + } +} + +function userMessage(blocks: AgentJournalMessageItem['blocks']): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks } +} + +function userReplayFrame(uuid: string, text: string): Record { + return { + type: 'user', + parent_tool_use_id: null, + session_id: 'provider-session', + uuid, + message: { role: 'user', content: [{ type: 'text', text }] } + } +} + +describe('Claude structured dispatch image limits', () => { + it('recovers the active identity when a timed-out replay arrives late', async () => { + const session = sessionFor() + const dispatched = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) }, + 500 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const sentUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid + await expect(dispatched).resolves.toMatchObject({ state: 'unknown' }) + + expect(resolveClaudeReplayWaiter(session, userReplayFrame(sentUuid!, 'one'))).toBe(true) + expect(session.activeTurnId).toBe(sentUuid) + expect(session.activeTurnSequence).toBe(session.dispatchSequence) + }) + + it('never lets a late replay for dispatch A resolve dispatch B', async () => { + const session = sessionFor() + const first = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) }, + 500 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const firstUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid + await expect(first).resolves.toMatchObject({ state: 'unknown' }) + + const second = dispatchClaudeTurn( + session, + { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'two' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const secondUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid + + expect(resolveClaudeReplayWaiter(session, userReplayFrame(firstUuid!, 'one'))).toBe(false) + expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid }) + expect(resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid!, 'two'))).toBe(true) + await expect(second).resolves.toMatchObject({ providerIdentity: { uuid: secondUuid } }) + }) + + it('does not let an identical late replay for dispatch A resolve active dispatch B', async () => { + const session = sessionFor() + const first = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'same prompt' }]) }, + 500 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + await expect(first).resolves.toMatchObject({ state: 'unknown' }) + + const second = dispatchClaudeTurn( + session, + { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'same prompt' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const secondUuid = session.dispatchWaiters[0]!.sentUuid + + expect(resolveClaudeReplayWaiter(session, userReplayFrame('provider-a', 'same prompt'))).toBe( + false + ) + expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid }) + + resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid, 'same prompt')) + await expect(second).resolves.toMatchObject({ providerIdentity: { uuid: secondUuid } }) + }) + + it('does not let a fresh-UUID replay for an evicted dispatch resolve active dispatch B', async () => { + const session = sessionFor() + const first = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'same prompt' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + await expect(first).resolves.toMatchObject({ state: 'unknown' }) + const firstUuid = session.retiredDispatchWaiters[0]!.sentUuid + + const fillerDispatches = await Promise.all( + Array.from({ length: 64 }, (_, index) => + dispatchClaudeTurn( + session, + { + clientMessageId: `filler-${index}`, + body: userMessage([{ type: 'text', text: 'same prompt' }]) + }, + 5 + ) + ) + ) + expect(fillerDispatches.every((outcome) => outcome.state === 'unknown')).toBe(true) + expect(session.retiredDispatchWaiters).toHaveLength(64) + expect(session.replayContentFallbackBlocked).toBe(true) + expect(session.retiredDispatchWaiters.some((waiter) => waiter.sentUuid === firstUuid)).toBe( + false + ) + + while (session.retiredDispatchWaiters.length > 0) { + const sentUuid = session.retiredDispatchWaiters[0]!.sentUuid + resolveClaudeReplayWaiter(session, userReplayFrame(sentUuid, 'same prompt')) + } + expect(session.retiredDispatchWaiters).toHaveLength(0) + + const second = dispatchClaudeTurn( + session, + { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'same prompt' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const secondUuid = session.dispatchWaiters[0]!.sentUuid + + expect( + resolveClaudeReplayWaiter(session, userReplayFrame('provider-a-late', 'same prompt')) + ).toBe(false) + expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid }) + + resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid, 'same prompt')) + await expect(second).resolves.toMatchObject({ providerIdentity: { uuid: secondUuid } }) + }) + + it('does not let a fresh-UUID result for an evicted slash dispatch resolve active dispatch B', async () => { + const session = sessionFor() + const first = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + await expect(first).resolves.toMatchObject({ state: 'unknown' }) + const firstUuid = session.retiredDispatchWaiters[0]!.sentUuid + + const fillerDispatches = await Promise.all( + Array.from({ length: 64 }, (_, index) => + dispatchClaudeTurn( + session, + { + clientMessageId: `filler-${index}`, + body: userMessage([{ type: 'text', text: '/permissions' }]) + }, + 5 + ) + ) + ) + expect(fillerDispatches.every((outcome) => outcome.state === 'unknown')).toBe(true) + expect(session.retiredDispatchWaiters).toHaveLength(64) + expect(session.replayContentFallbackBlocked).toBe(true) + expect(session.retiredDispatchWaiters.some((waiter) => waiter.sentUuid === firstUuid)).toBe( + false + ) + + while (session.retiredDispatchWaiters.length > 0) { + const sentUuid = session.retiredDispatchWaiters[0]!.sentUuid + expect( + resolveClaudeReplayWaiter(session, { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: `result-${sentUuid}`, + user_message_uuid: sentUuid + }) + ).toBe(false) + } + expect(session.retiredDispatchWaiters).toHaveLength(0) + + const second = dispatchClaudeTurn( + session, + { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: '/permissions' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const secondUuid = session.dispatchWaiters[0]!.sentUuid + + expect( + resolveClaudeReplayWaiter(session, { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: 'result-a-late' + }) + ).toBe(false) + expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid }) + + expect( + resolveClaudeReplayWaiter(session, { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: 'result-b', + user_message_uuid: secondUuid + }) + ).toBe(false) + await expect(second).resolves.toMatchObject({ + providerIdentity: { uuid: 'result-b' } + }) + }) + + it('does not let a legacy result for timed-out ordinary dispatch A resolve slash dispatch B', async () => { + const session = sessionFor() + const first = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'ordinary' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + await expect(first).resolves.toMatchObject({ state: 'unknown' }) + + const second = dispatchClaudeTurn( + session, + { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: '/permissions' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + + expect( + resolveClaudeReplayWaiter(session, { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: 'legacy-result-a' + }) + ).toBe(false) + await expect(second).resolves.toMatchObject({ state: 'unknown' }) + }) + + it('removes only its own waiter when a later send fails', async () => { + const session = sessionFor() + const first = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const firstWaiter = session.dispatchWaiters[0] + session.connection.send = vi.fn().mockRejectedValue(new Error('broken pipe')) + + await expect( + dispatchClaudeTurn( + session, + { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'two' }]) }, + 100 + ) + ).resolves.toMatchObject({ state: 'unknown', reason: 'broken pipe' }) + expect(session.dispatchWaiters).toEqual([firstWaiter]) + + const firstUuid = (firstWaiter as { sentUuid?: string }).sentUuid + resolveClaudeReplayWaiter(session, userReplayFrame(firstUuid!, 'one')) + await expect(first).resolves.toMatchObject({ providerIdentity: { uuid: firstUuid } }) + }) + + it('keeps a replay accepted before its send reports failure', async () => { + let session!: ClaudeSession + const send = vi.fn(async (message: Record) => { + resolveClaudeReplayWaiter(session, { ...message, uuid: 'turn-race' }) + throw new Error('write raced provider acknowledgement') + }) + session = sessionFor(send) + + await expect( + dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) }, + 100 + ) + ).resolves.toMatchObject({ state: 'accepted', providerIdentity: { uuid: 'turn-race' } }) + expect(session.dispatchWaiters).toHaveLength(0) + }) + + it('accepts a slash command from its result receipt when Claude omits the user replay', async () => { + const session = sessionFor() + const dispatched = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + + expect( + resolveClaudeReplayWaiter(session, { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: 'command-result-uuid' + }) + ).toBe(false) + + await expect(dispatched).resolves.toEqual({ + state: 'accepted', + providerIdentity: { + provider: 'claude', + sessionId: 'provider-session', + uuid: 'command-result-uuid' + } + }) + }) + + it('correlates a later slash-command result by user_message_uuid despite a timed-out slash waiter', async () => { + const session = sessionFor() + const first = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) }, + 500 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + await expect(first).resolves.toMatchObject({ state: 'unknown' }) + + const second = dispatchClaudeTurn( + session, + { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: '/permissions' }]) }, + 500 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const secondUuid = session.dispatchWaiters[0]!.sentUuid + + expect( + resolveClaudeReplayWaiter(session, { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: 'result-b', + user_message_uuid: secondUuid + }) + ).toBe(false) + await expect(second).resolves.toMatchObject({ + state: 'accepted', + providerIdentity: { uuid: 'result-b' } + }) + }) + + it('does not mistake a normal turn result for its missing user replay', async () => { + const session = sessionFor() + const dispatched = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'hello' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + + expect( + resolveClaudeReplayWaiter(session, { + type: 'result', + session_id: 'provider-session', + uuid: 'unrelated-result-uuid' + }) + ).toBe(false) + expect(session.dispatchWaiters).toHaveLength(1) + expect( + resolveClaudeReplayWaiter(session, { + type: 'user', + parent_tool_use_id: null, + session_id: 'provider-session', + uuid: 'user-replay-uuid', + message: { + role: 'user', + content: [{ type: 'text', text: 'hello' }] + } + }) + ).toBe(true) + + await expect(dispatched).resolves.toMatchObject({ + state: 'accepted', + providerIdentity: { uuid: 'user-replay-uuid' } + }) + }) + + it('ignores a top-level tool-result user frame while waiting for a slash command replay', async () => { + const session = sessionFor() + const dispatched = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + + resolveClaudeReplayWaiter(session, { + type: 'user', + parent_tool_use_id: null, + session_id: 'provider-session', + uuid: 'tool-result-uuid', + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'done' }] + } + }) + expect(session.dispatchWaiters).toHaveLength(1) + + resolveClaudeReplayWaiter(session, { + type: 'user', + parent_tool_use_id: null, + session_id: 'provider-session', + uuid: 'user-replay-uuid', + message: { + role: 'user', + content: [{ type: 'text', text: '/permissions' }] + } + }) + + await expect(dispatched).resolves.toEqual({ + state: 'accepted', + providerIdentity: { + provider: 'claude', + sessionId: 'provider-session', + uuid: 'user-replay-uuid' + } + }) + }) + + it('rejects more than twenty URL images before sending', async () => { + const session = sessionFor() + const body = userMessage( + Array.from({ length: 21 }, (_, index) => ({ + type: 'image-ref' as const, + url: `https://example.test/${index}.png` + })) + ) + + await expect( + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1) + ).resolves.toEqual({ state: 'rejected', reason: 'Claude messages support at most 20 images' }) + expect(session.connection.send).not.toHaveBeenCalled() + }) + + it('rejects local images whose aggregate size exceeds twenty MiB', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-images-')) + try { + const paths = await Promise.all( + Array.from({ length: 5 }, async (_, index) => { + const path = join(directory, `${index}.png`) + await writeFile(path, Buffer.alloc(5 * 1024 * 1024)) + return path + }) + ) + const session = sessionFor() + const body = userMessage(paths.map((path) => ({ type: 'image-ref' as const, path }))) + + await expect( + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1) + ).resolves.toEqual({ + state: 'rejected', + reason: `Claude images must total no more than ${20 * 1024 * 1024} bytes` + }) + expect(session.connection.send).not.toHaveBeenCalled() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('rejects a local image by actual bytes read beyond the per-image cap', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) + try { + const path = join(directory, 'oversized.png') + await writeFile(path, Buffer.alloc(5 * 1024 * 1024 + 1)) + const session = sessionFor() + const body = userMessage([{ type: 'image-ref', path }]) + + await expect( + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1) + ).resolves.toEqual({ + state: 'rejected', + reason: `Claude image must be a non-empty file no larger than ${5 * 1024 * 1024} bytes` + }) + expect(session.connection.send).not.toHaveBeenCalled() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('allocates local image reads from the file size, not the maximum cap', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) + const allocUnsafe = vi.spyOn(Buffer, 'allocUnsafe') + try { + const path = join(directory, 'small.png') + await writeFile(path, Buffer.alloc(64)) + const session = sessionFor() + const dispatched = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'image-ref', path }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const sentUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid + resolveClaudeReplayWaiter(session, { + ...userReplayFrame(sentUuid!, ''), + message: { + role: 'user', + content: [ + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: '' } } + ] + } + }) + await expect(dispatched).resolves.toMatchObject({ state: 'accepted' }) + expect(allocUnsafe).toHaveBeenCalled() + expect(allocUnsafe.mock.calls.some(([size]) => size === 64 + 1)).toBe(true) + expect(allocUnsafe.mock.calls.some(([size]) => size >= 5 * 1024 * 1024)).toBe(false) + } finally { + allocUnsafe.mockRestore() + await rm(directory, { recursive: true, force: true }) + } + }) + + it('bounds retained waiter identity bytes when image dispatches time out', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) + try { + const path = join(directory, 'large.png') + await writeFile(path, Buffer.alloc(64 * 1024)) + const session = sessionFor() + const body = userMessage([{ type: 'image-ref', path }]) + await Promise.all( + Array.from({ length: 64 }, (_, index) => + dispatchClaudeTurn(session, { clientMessageId: `client-${index}`, body }, 1) + ) + ) + + expect(session.retiredDispatchWaiters).toHaveLength(64) + const retainedKeyBytes = session.retiredDispatchWaiters.reduce( + (total, waiter) => total + waiter.replayContentKey.length, + 0 + ) + expect(retainedKeyBytes).toBeLessThan(64 * 512) + expect( + session.retiredDispatchWaiters.every((waiter) => waiter.replayContentKey.length < 512) + ).toBe(true) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('rejects a local image when it grows after the initial stat', async () => { + const stat = vi + .fn() + .mockResolvedValueOnce({ isFile: () => true, size: 64 }) + .mockResolvedValueOnce({ isFile: () => true, size: 128 }) + const read = vi.fn(async (buffer: Buffer, offset: number) => { + if (read.mock.calls.length === 1) { + buffer.fill(1, offset, offset + 64) + return { bytesRead: 64, buffer } + } + return { bytesRead: 0, buffer } + }) + const open = vi.fn().mockResolvedValue({ + stat, + read, + close: vi.fn().mockResolvedValue(undefined) + } as never) + await expect(readClaudeImage('/controlled/growing.png', open)).rejects.toThrow( + `Claude image must be a non-empty file no larger than ${5 * 1024 * 1024} bytes` + ) + }) +}) diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts new file mode 100644 index 00000000000..96271e41d71 --- /dev/null +++ b/src/main/claude/claude-structured-dispatch.ts @@ -0,0 +1,264 @@ +import { randomUUID } from 'node:crypto' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + claudeHasReplayContent, + readClaudeMessageEnvelope +} from './claude-structured-item-translation' +import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-session-state' +import { readClaudeFrameString } from './claude-structured-init-proof' +import { + claudeDispatchContentKey, + claudeDispatchMessageContent +} from './claude-structured-dispatch-content' + +const MAX_RETIRED_DISPATCH_WAITERS = 64 + +export function resolveClaudeReplayWaiter( + session: ClaudeSession, + message: Record +): boolean { + const envelope = readClaudeMessageEnvelope(message) + const isUserReplay = + envelope?.role === 'user' && + message.parent_tool_use_id === null && + claudeHasReplayContent(envelope) + const isCompletedCommand = message.type === 'result' + if ( + (!isUserReplay && !isCompletedCommand) || + readClaudeFrameString(message, 'session_id') !== session.providerSessionId + ) { + return false + } + const uuid = readClaudeFrameString(message, 'uuid') + if (!uuid) { + return false + } + + // Newer SDK frames carry the client uuid that caused a turn. A correlation + // value is authoritative: never fall back to queue order or content, since + // identical prompts may be in flight across a timeout boundary. + const userMessageUuid = readClaudeFrameString(message, 'user_message_uuid') + if (userMessageUuid) { + const exact = session.dispatchWaiters.find( + (candidate) => candidate.sentUuid === userMessageUuid + ) + if (exact) { + settleWaiter(session, exact, uuid) + return isUserReplay && exact.dispatchSequence === session.dispatchSequence + } + const retired = session.retiredDispatchWaiters.find( + (candidate) => candidate.sentUuid === userMessageUuid + ) + if (retired) { + forgetRetiredWaiter(session, retired) + return recoverLateIdentity(session, retired, uuid, isUserReplay) + } + return false + } + + const exact = session.dispatchWaiters.find((candidate) => candidate.sentUuid === uuid) + if (exact) { + settleWaiter(session, exact, uuid) + return isUserReplay && exact.dispatchSequence === session.dispatchSequence + } + const retired = session.retiredDispatchWaiters.find((candidate) => candidate.sentUuid === uuid) + if (retired) { + forgetRetiredWaiter(session, retired) + return recoverLateIdentity(session, retired, uuid, isUserReplay) + } + + if (isUserReplay) { + // Compatibility CLIs may mint a new replay uuid instead of echoing the + // client uuid. Content is an acceptable join only when it is the sole + // candidate on one side of the timeout boundary; with active and retired + // candidates present, identical prompts are intentionally left unknown. + const replayContentKey = claudeDispatchContentKey(envelope.content) + if (!session.replayContentFallbackBlocked && session.retiredDispatchWaiters.length === 0) { + const compatible = session.dispatchWaiters.filter( + (candidate) => candidate.replayContentKey === replayContentKey + ) + if (compatible.length === 1) { + settleWaiter(session, compatible[0]!, uuid) + return compatible[0]!.dispatchSequence === session.dispatchSequence + } + } else if (!session.replayContentFallbackBlocked && session.dispatchWaiters.length === 0) { + const lateCompatible = session.retiredDispatchWaiters.filter( + (candidate) => candidate.replayContentKey === replayContentKey + ) + if (lateCompatible.length === 1) { + const [candidate] = lateCompatible + forgetRetiredWaiter(session, candidate!) + return recoverLateIdentity(session, candidate!, uuid, true) + } + } + return false + } + const current = session.dispatchWaiters[0] + if (isCompletedCommand && !current?.acceptsResult) { + return false + } + // A legacy result has no dispatch correlation. Any retired waiter makes queue order ambiguous, + // even when the retired dispatch was an ordinary turn rather than a slash command. + if (isCompletedCommand && session.retiredDispatchWaiters.length > 0) { + return false + } + // Once an eviction occurred, a fresh result uuid cannot be joined to a waiter by queue order. + if (isCompletedCommand && session.replayContentFallbackBlocked) { + return false + } + const waiter = uuid ? session.dispatchWaiters.shift() : undefined + if (waiter && uuid) { + clearTimeout(waiter.timer) + waiter.settledUuid = uuid + waiter.resolve(uuid) + return isUserReplay + } + return false +} + +function settleWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter, uuid: string): void { + const index = session.dispatchWaiters.indexOf(waiter) + if (index !== -1) { + session.dispatchWaiters.splice(index, 1) + } + clearTimeout(waiter.timer) + waiter.settledUuid = uuid + waiter.resolve(uuid) +} + +function forgetRetiredWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): void { + const index = session.retiredDispatchWaiters.indexOf(waiter) + if (index !== -1) { + session.retiredDispatchWaiters.splice(index, 1) + } +} + +function recoverLateIdentity( + session: ClaudeSession, + waiter: ClaudeDispatchWaiter, + uuid: string, + isUserReplay: boolean +): boolean { + if (!isUserReplay && !waiter.acceptsResult) { + return false + } + if (waiter.dispatchSequence === session.dispatchSequence) { + session.activeTurnId = uuid + session.activeTurnSequence = waiter.dispatchSequence + } + return isUserReplay && waiter.dispatchSequence === session.dispatchSequence +} + +function waitForReplay( + session: ClaudeSession, + timeoutMs: number, + acceptsResult: boolean, + sentUuid: string, + replayContentKey: string +): { waiter: ClaudeDispatchWaiter; promise: Promise } { + let waiter!: ClaudeDispatchWaiter + const promise = new Promise((resolve) => { + waiter = { + acceptsResult, + sentUuid, + dispatchSequence: session.dispatchSequence, + replayContentKey, + resolve, + timer: setTimeout(() => { + const index = session.dispatchWaiters.indexOf(waiter) + if (index !== -1) { + session.dispatchWaiters.splice(index, 1) + } + retireWaiter(session, waiter) + resolve(null) + }, timeoutMs) + } + waiter.timer.unref?.() + session.dispatchWaiters.push(waiter) + }) + return { waiter, promise } +} + +function retireWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): void { + const index = session.dispatchWaiters.indexOf(waiter) + if (index !== -1) { + session.dispatchWaiters.splice(index, 1) + } + clearTimeout(waiter.timer) + if (!waiter.retired) { + waiter.retired = true + session.retiredDispatchWaiters.push(waiter) + if (session.retiredDispatchWaiters.length > MAX_RETIRED_DISPATCH_WAITERS) { + session.replayContentFallbackBlocked = true + session.retiredDispatchWaiters.splice( + 0, + session.retiredDispatchWaiters.length - MAX_RETIRED_DISPATCH_WAITERS + ) + } + } +} + +export async function dispatchClaudeTurn( + session: ClaudeSession, + input: { clientMessageId: string; body: AgentJournalMessageItem }, + timeoutMs: number +): Promise { + let content: unknown[] + try { + content = await claudeDispatchMessageContent(input.body) + } catch (error) { + return { state: 'rejected', reason: (error as Error).message } + } + const dispatchSequence = ++session.dispatchSequence + const acceptsResult = input.body.blocks.some( + (block) => block.type === 'text' && block.text.trimStart().startsWith('/') + ) + const sentUuid = randomUUID() + const replay = waitForReplay( + session, + timeoutMs, + acceptsResult, + sentUuid, + claudeDispatchContentKey(content) + ) + const replayed = replay.promise + try { + await session.connection.send({ + type: 'user', + uuid: sentUuid, + message: { role: 'user', content }, + parent_tool_use_id: null, + session_id: session.providerSessionId + }) + } catch (error) { + const waiter = replay.waiter + if (waiter.settledUuid) { + const uuid = await replayed + if (uuid) { + session.activeTurnId = uuid + session.activeTurnSequence = dispatchSequence + return { + state: 'accepted', + providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid } + } + } + } + if (!waiter.retired) { + retireWaiter(session, waiter) + waiter.resolve(null) + } + return { state: 'unknown', reason: (error as Error).message } + } + const uuid = await replayed + if (uuid) { + session.activeTurnId = uuid + session.activeTurnSequence = dispatchSequence + } + return uuid + ? { + state: 'accepted', + providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid } + } + : { state: 'unknown', reason: 'claude accepted a message but did not replay its uuid in time' } +} diff --git a/src/main/claude/claude-structured-effort-reporting.test.ts b/src/main/claude/claude-structured-effort-reporting.test.ts new file mode 100644 index 00000000000..be022d86956 --- /dev/null +++ b/src/main/claude/claude-structured-effort-reporting.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from 'vitest' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { + restoreClaudeStructuredSessionOptions, + setClaudeStructuredOption +} from './claude-structured-options' +import { readClaudeSettingsEffort } from './claude-structured-session-options' +import type { ClaudeSession } from './claude-structured-session-state' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' +import { acquired, fakeClaude } from './claude-structured-session-test-support' + +/** Verbatim from Claude Code 2.1.258's get_settings response. */ +const REAL_SETTINGS = { + applied: { model: 'claude-opus-5[1m]', effort: 'high', advisor: null, ultracode: false }, + effective: { model: 'claude-opus-5[1m]', effortLevel: 'high', env: {} }, + sources: {} +} + +function sessionWith( + reported: string | null, + calls: string[] = [], + listed?: { model: string; catalog: readonly Record[] } +) { + return { + session: { + options: new Map(listed ? [['model', listed.model]] : []), + reportedOptions: {} as { model?: string; effort?: string }, + optionMutationSequence: 0, + reportedModelMutation: 0, + confirmedOptions: new Set(), + restoreSkippedOptions: new Set(), + connection: { + supportedModels: async () => { + calls.push('list_models') + return [...(listed?.catalog ?? [])] + }, + setModel: async (model: string) => { + calls.push(`set_model:${model}`) + }, + applyFlagSettings: async (settings: { effortLevel?: string }) => { + // The measured behaviour: an unknown effort is accepted and ignored. + calls.push(`apply:${settings.effortLevel}`) + }, + getSettings: async () => { + calls.push('get_settings') + return reported === null + ? { applied: {}, effective: {}, sources: {} } + : { applied: { effort: reported }, effective: { effortLevel: reported }, sources: {} } + } + } + } as unknown as ClaudeSession, + calls + } +} + +describe('Claude effort reporting', () => { + it('reads the effort get_settings reports', () => { + expect(readClaudeSettingsEffort(REAL_SETTINGS)).toBe('high') + }) + + it.each([ + [ + 'the provider stops reporting it', + { applied: { effort: 'high' }, effective: {}, sources: {} } + ], + ['the payload carries no effective block', { applied: { effort: 'high' } }], + ['the request failed outright', null] + ])('reports no effort when %s', (_case, settings) => { + // Never defaulted: an effort nothing measured would be worse than a blank + // pill, and this is the assertion that goes red if the key is renamed. + expect(readClaudeSettingsEffort(settings)).toBeNull() + }) + + it('publishes the effort from get_settings, which system/init never carries', async () => { + const claude = fakeClaude({ settings: REAL_SETTINGS }) + const adapter = await acquired(claude) + + await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ + current: { effort: 'high' } + }) + }) + + it('leaves the effort unreported when the session never learns one', async () => { + const claude = fakeClaude({ settings: { applied: {}, effective: {}, sources: {} } }) + const adapter = await acquired(claude) + + const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(options.current.effort).toBeUndefined() + expect(options.current.model).toBeTruthy() + }) + + it('keeps the init fixture free of an effort the real frame never sends', async () => { + const events: ClaudeStructuredSessionEvent[] = [] + await acquired(fakeClaude(), {}, events) + const init = events.flatMap((event) => + event.type === 'message' && event.message.subtype === 'init' ? [event.message] : [] + ) + + expect(init).toHaveLength(1) + expect(init[0]).toHaveProperty('model') + // The regression that hid this defect: a fixture inventing `effortLevel` + // kept every gate green over a value that is always empty in production. + expect(Object.keys(init[0])).not.toContain('effortLevel') + }) +}) + +describe('Claude effort readback', () => { + it('records an effort the child did not adopt without vouching for it', async () => { + const { session, calls } = sessionWith('high') + + // The disagreement stops the confirmation, not the write: no other client + // vetoes here, and the pre-flight catalog guard already refuses the levels + // the model cannot run. + await expect( + setClaudeStructuredOption(session, { key: 'effort', value: 'bogus-effort-xyz' }, undefined) + ).resolves.toEqual({ effort: 'bogus-effort-xyz' }) + expect(session.confirmedOptions.has('effort')).toBe(false) + // The child's own answer is kept rather than discarded with the refusal. + expect(session.reportedOptions.effort).toBe('high') + expect(calls).toEqual(['apply:bogus-effort-xyz', 'get_settings']) + }) + + it('records an effort the child confirms', async () => { + const { session } = sessionWith('low') + + await expect( + setClaudeStructuredOption(session, { key: 'effort', value: 'low' }, undefined) + ).resolves.toEqual({ effort: 'low' }) + }) + + it('records the request when the readback is unavailable', async () => { + // No evidence of a refusal is not evidence of one; the apply itself succeeded. + const { session } = sessionWith(null) + + await expect( + setClaudeStructuredOption(session, { key: 'effort', value: 'low' }, undefined) + ).resolves.toEqual({ effort: 'low' }) + }) +}) + +describe('Claude effort against the model that must run it', () => { + const HAIKU = { value: 'haiku', resolvedModel: 'claude-haiku-4-5-20251001', displayName: 'Haiku' } + const SONNET = { + value: 'sonnet', + resolvedModel: 'claude-sonnet-5', + displayName: 'Sonnet', + supportsEffort: true, + supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'] + } + + it('refuses an effort the current model advertises no control for', async () => { + const { session, calls } = sessionWith('high', [], { model: 'haiku', catalog: [HAIKU, SONNET] }) + + await expect( + setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined) + ).rejects.toBeInstanceOf(AgentSessionOptionRejectedError) + // Measured on Claude Code 2.1.260: apply_flag_settings stores `high` on a + // haiku session and get_settings reads it straight back, so a send here is + // never undone. The refusal has to land before the write. + expect(calls).toEqual(['list_models']) + expect(session.options.has('effort')).toBe(false) + }) + + it('refuses a level outside the ones the current model advertises', async () => { + const { session } = sessionWith('high', [], { + model: 'sonnet', + catalog: [{ ...SONNET, supportedEffortLevels: ['low', 'medium'] }] + }) + + await expect( + setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined) + ).rejects.toBeInstanceOf(AgentSessionOptionRejectedError) + }) + + it('sends an effort the current model advertises', async () => { + const { session, calls } = sessionWith('high', [], { + model: 'sonnet', + catalog: [HAIKU, SONNET] + }) + + await expect( + setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined) + ).resolves.toEqual({ model: 'sonnet', effort: 'high' }) + expect(calls).toEqual(['list_models', 'apply:high', 'get_settings']) + expect(session.confirmedOptions.has('effort')).toBe(true) + }) + + it('sends `max`, which the readback cannot report, when the model advertises it', async () => { + // UNREPORTED_EFFORTS still governs: no get_settings, so no false disagreement. + const { session, calls } = sessionWith('high', [], { model: 'sonnet', catalog: [SONNET] }) + + await expect( + setClaudeStructuredOption(session, { key: 'effort', value: 'max' }, undefined) + ).resolves.toEqual({ model: 'sonnet', effort: 'max' }) + expect(calls).toEqual(['list_models', 'apply:max']) + expect(session.confirmedOptions.has('effort')).toBe(false) + }) + + it('sends the effort when the model is not in the catalog the CLI listed', async () => { + // An unlisted model is an unknown one, not one that refuses effort. + const { session, calls } = sessionWith('high', [], { model: 'sonnet', catalog: [HAIKU] }) + + await expect( + setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined) + ).resolves.toEqual({ model: 'sonnet', effort: 'high' }) + expect(calls).toEqual(['list_models', 'apply:high', 'get_settings']) + }) + + it('sends the effort when list_models is unavailable', async () => { + const calls: string[] = [] + const { session } = sessionWith('high', calls, { model: 'sonnet', catalog: [] }) + session.connection.supportedModels = async () => { + calls.push('list_models') + throw new Error('this CLI predates list_models') + } + + await expect( + setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined) + ).resolves.toEqual({ model: 'sonnet', effort: 'high' }) + expect(calls).toEqual(['list_models', 'apply:high', 'get_settings']) + }) + + it('matches the model the init frame reported, not just the id the user picked', async () => { + const { session } = sessionWith('high', [], { model: 'sonnet', catalog: [HAIKU, SONNET] }) + session.options.delete('model') + session.reportedOptions.model = 'claude-haiku-4-5-20251001' + + await expect( + setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined) + ).rejects.toBeInstanceOf(AgentSessionOptionRejectedError) + }) + + it('keeps a disagreeing effort through restore instead of skipping it', async () => { + const calls: string[] = [] + const { session } = sessionWith('high', calls, { model: 'sonnet', catalog: [SONNET] }) + session.options.set('effort', 'low') + + await restoreClaudeStructuredSessionOptions(session, undefined) + + expect(session.options.get('effort')).toBe('low') + expect(session.restoreSkippedOptions.has('effort')).toBe(false) + expect(session.confirmedOptions.has('effort')).toBe(false) + }) + + it('drops a stale effort on restore instead of replaying it onto the new model', async () => { + const calls: string[] = [] + const { session } = sessionWith('high', calls, { model: 'sonnet', catalog: [HAIKU, SONNET] }) + session.options.set('model', 'haiku') + session.options.set('effort', 'high') + + await restoreClaudeStructuredSessionOptions(session, undefined) + + expect(session.options.has('effort')).toBe(false) + expect(session.restoreSkippedOptions.has('effort')).toBe(true) + expect(calls.filter((call) => call.startsWith('apply:'))).toEqual([]) + }) +}) diff --git a/src/main/claude/claude-structured-inbound-control.test.ts b/src/main/claude/claude-structured-inbound-control.test.ts new file mode 100644 index 00000000000..07be4bbb516 --- /dev/null +++ b/src/main/claude/claude-structured-inbound-control.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from 'vitest' +import type { CanUseTool } from '@anthropic-ai/claude-agent-sdk' +import { ClaudePromptRegistry } from './claude-structured-prompt-replies' +import { + buildClaudePermissionCallbacks, + CLAUDE_BLOCKING_CONTROL_CALLBACKS, + CLAUDE_CAN_USE_TOOL_SUBTYPE, + CLAUDE_REQUEST_USER_DIALOG_SUBTYPE +} from './claude-structured-inbound-control' + +type CanUseToolOptions = Parameters[2] + +function permissionOptions( + requestId: string, + toolUseID: string, + signal: AbortSignal, + suggestions?: unknown[] +): CanUseToolOptions { + return { + requestId, + toolUseID, + signal, + ...(suggestions ? { suggestions } : {}) + } as unknown as CanUseToolOptions +} + +function callbacksFor() { + const prompts = new ClaudePromptRegistry() + const emit = vi.fn() + const { canUseTool, onUserDialog } = buildClaudePermissionCallbacks({ + sessionId: 'session-1', + prompts, + emit + }) + return { prompts, emit, canUseTool, onUserDialog } +} + +describe('Claude permission callbacks', () => { + it('registers a decodable can_use_tool as a durable prompt and settles it from the registry', async () => { + const control = callbacksFor() + const answered = control.canUseTool( + 'Bash', + { command: 'git status' }, + permissionOptions('perm-1', 'tool-1', new AbortController().signal, [{ type: 'addRules' }]) + ) + + expect(control.emit).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'prompt', + sessionId: 'session-1', + prompt: expect.objectContaining({ promptKey: 'perm-1', toolName: 'Bash', kind: 'approval' }) + }) + ) + const found = control.prompts.find('perm-1') + expect(found?.prompt.suggestions).toEqual([{ type: 'addRules' }]) + // The prompt's settle is the SDK callback's own resolve — answering resolves this promise. + found?.prompt.settle({ behavior: 'allow', toolUseID: 'tool-1' }) + await expect(answered).resolves.toEqual({ behavior: 'allow', toolUseID: 'tool-1' }) + }) + + it('denies a malformed permission request without registering a prompt', async () => { + const control = callbacksFor() + const answered = control.canUseTool( + '', + {}, + permissionOptions('perm-2', 'tool-2', new AbortController().signal) + ) + + await expect(answered).resolves.toEqual({ + behavior: 'deny', + message: 'Orca could not decode this permission request.', + toolUseID: 'tool-2' + }) + expect(control.prompts.find('perm-2')).toBeNull() + expect(control.emit).not.toHaveBeenCalled() + }) + + it('settles a pending prompt with null and forgets it when the abort signal fires', async () => { + const control = callbacksFor() + const controller = new AbortController() + const answered = control.canUseTool( + 'Bash', + { command: 'ls' }, + permissionOptions('perm-3', 'tool-3', controller.signal) + ) + expect(control.prompts.find('perm-3')).not.toBeNull() + + controller.abort() + + await expect(answered).resolves.toBeNull() + expect(control.emit).toHaveBeenLastCalledWith( + expect.objectContaining({ type: 'prompt-cancelled', promptKey: 'perm-3' }) + ) + // Forgotten: a late answer can no longer find the prompt to authorize the wrong tool. + expect(control.prompts.find('perm-3')).toBeNull() + }) + + it('cancels a request whose abort raced ahead of delivery without emitting a prompt', async () => { + const control = callbacksFor() + const controller = new AbortController() + controller.abort() + + const answered = control.canUseTool( + 'Bash', + { command: 'ls' }, + permissionOptions('perm-4', 'tool-4', controller.signal) + ) + + await expect(answered).resolves.toBeNull() + expect(control.prompts.find('perm-4')).toBeNull() + expect(control.emit).toHaveBeenCalledTimes(1) + expect(control.emit).toHaveBeenCalledWith( + expect.objectContaining({ type: 'prompt-cancelled', promptKey: 'perm-4' }) + ) + }) + + it('settles every in-flight prompt with null when the registry is cleared', async () => { + const control = callbacksFor() + const first = control.canUseTool( + 'Bash', + { command: 'a' }, + permissionOptions('perm-5', 'tool-5', new AbortController().signal) + ) + const second = control.canUseTool( + 'Bash', + { command: 'b' }, + permissionOptions('perm-6', 'tool-6', new AbortController().signal) + ) + + // What session close does: settle each pending callback so no promise dangles. + for (const prompt of control.prompts.clear()) { + prompt.settle(null) + } + + await expect(first).resolves.toBeNull() + await expect(second).resolves.toBeNull() + }) + + it('answers a user dialog deny-safe', async () => { + const control = callbacksFor() + await expect( + control.onUserDialog( + { dialogKind: 'refusal_fallback_prompt', payload: {} }, + { signal: new AbortController().signal, requestId: 'dialog-1' } + ) + ).resolves.toEqual({ behavior: 'cancelled' }) + }) + + it('enumerates every blocking control request and wires a callback for each', () => { + // The stable surface of controls a turn can block on. Adding one here without wiring its + // callback below fails this test rather than silently leaving a control unhandled. + expect(new Set(Object.keys(CLAUDE_BLOCKING_CONTROL_CALLBACKS))).toEqual( + new Set([CLAUDE_CAN_USE_TOOL_SUBTYPE, CLAUDE_REQUEST_USER_DIALOG_SUBTYPE]) + ) + const callbacks = buildClaudePermissionCallbacks({ + sessionId: 'session-1', + prompts: new ClaudePromptRegistry(), + emit: vi.fn() + }) as unknown as Record + for (const callbackName of Object.values(CLAUDE_BLOCKING_CONTROL_CALLBACKS)) { + expect(typeof callbacks[callbackName], `${callbackName} must be wired`).toBe('function') + } + }) +}) diff --git a/src/main/claude/claude-structured-inbound-control.ts b/src/main/claude/claude-structured-inbound-control.ts new file mode 100644 index 00000000000..343e76d4ea5 --- /dev/null +++ b/src/main/claude/claude-structured-inbound-control.ts @@ -0,0 +1,91 @@ +import type { CanUseTool, OnUserDialog, PermissionResult } from '@anthropic-ai/claude-agent-sdk' +import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' + +export const CLAUDE_CAN_USE_TOOL_SUBTYPE = 'can_use_tool' +export const CLAUDE_REQUEST_USER_DIALOG_SUBTYPE = 'request_user_dialog' + +/** + * The blocking control requests Orca answers, each mapped to the SDK consumer callback that + * answers it. This is the stable surface a real turn can block on: `can_use_tool` through + * `canUseTool` and `request_user_dialog` through `onUserDialog`. Every other control-request + * subtype the SDK routes (elicitation, oauth/host token refresh, mcp_message, hook_callback) + * is either not surfaced to this consumer or fails closed inside the SDK; adding a new + * blocking control Orca must answer means adding its callback here, and the catalog test + * fails if a named callback is missing. + */ +export const CLAUDE_BLOCKING_CONTROL_CALLBACKS = { + [CLAUDE_CAN_USE_TOOL_SUBTYPE]: 'canUseTool', + [CLAUDE_REQUEST_USER_DIALOG_SUBTYPE]: 'onUserDialog' +} as const + +export type ClaudeBlockingControlSubtype = keyof typeof CLAUDE_BLOCKING_CONTROL_CALLBACKS + +export type ClaudePermissionCallbackDeps = { + sessionId: string + prompts: ClaudePromptRegistry + emit: (event: ClaudeStructuredSessionEvent) => void +} + +function denySafeResult(toolUseId: string | undefined): PermissionResult { + return { + behavior: 'deny', + message: 'Orca could not decode this permission request.', + ...(toolUseId ? { toolUseID: toolUseId } : {}) + } +} + +/** + * Build the SDK permission callbacks from the durable prompt registry. + * + * A decodable `can_use_tool` becomes a durable prompt whose `settle` resolves this callback; + * a malformed one is denied without registering. The SDK's abort signal fires on + * `control_cancel_request` (a cancelled turn), which forgets the prompt and settles it with + * `null` — never authorizing a tool. A late answer after abort finds no prompt and is refused + * by `answerClaudePrompt`. `onUserDialog` is deny-safe; the CLI only emits dialog kinds Orca + * declares in `supportedDialogKinds`, which is empty. + */ +export function buildClaudePermissionCallbacks(deps: ClaudePermissionCallbackDeps): { + canUseTool: CanUseTool + onUserDialog: OnUserDialog +} { + const canUseTool: CanUseTool = (toolName, input, options) => + new Promise((resolve) => { + const prompt = deps.prompts.register({ + requestId: options.requestId, + toolName, + toolUseId: options.toolUseID, + input, + suggestions: options.suggestions ?? [], + settle: resolve as (response: Record | null) => void + }) + if (!prompt) { + resolve(denySafeResult(options.toolUseID)) + return + } + const cancel = (): void => { + if (deps.prompts.forgetIfPending(prompt)) { + deps.emit({ + type: 'prompt-cancelled', + sessionId: deps.sessionId, + promptKey: prompt.promptKey + }) + // Null is the SDK's "no response written" sentinel: a cancelled request must not + // be answered, only forgotten. + resolve(null) + } + } + if (options.signal.aborted) { + // No abort event can still fire, so registering a listener would park the callback + // forever behind a prompt nothing will answer. + cancel() + return + } + options.signal.addEventListener('abort', cancel, { once: true }) + deps.emit({ type: 'prompt', sessionId: deps.sessionId, prompt }) + }) + + const onUserDialog: OnUserDialog = () => Promise.resolve({ behavior: 'cancelled' }) + + return { canUseTool, onUserDialog } +} diff --git a/src/main/claude/claude-structured-init-deadline.ts b/src/main/claude/claude-structured-init-deadline.ts new file mode 100644 index 00000000000..f3acd6c3af9 --- /dev/null +++ b/src/main/claude/claude-structured-init-deadline.ts @@ -0,0 +1,68 @@ +import type { ClaudeInitObservation } from './claude-structured-init-proof' +import { claudeInitializationAuthError } from './claude-structured-init-proof' +import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' + +export type ClaudeInitDeadline = { + promise: Promise + resolve: (init: ClaudeInitObservation) => void + reject: (error: Error) => void + start: () => void + clear: () => void +} + +export function claudeInitTimeoutError( + sessionId: string, + timeoutMs: number +): AgentSessionAcquisitionRefusal { + return new AgentSessionAcquisitionRefusal( + `Claude did not finish starting session ${sessionId} within ${Math.ceil(timeoutMs / 1000)} seconds. Verify the selected Claude account is signed in and CLAUDE_CONFIG_DIR contains valid credentials, then retry; no SessionStart or system/init proof arrived.` + ) +} + +export async function requestClaudeInitialization( + connection: ClaudeStreamJsonConnection, + sessionId: string, + timeoutMs: number +): Promise { + try { + const result = await connection.initializationResult({ timeoutMs }) + const authError = claudeInitializationAuthError(result) + if (authError) { + throw authError + } + return result + } catch (error) { + if (error instanceof Error && error.message === 'claude initialize request timed out') { + throw claudeInitTimeoutError(sessionId, timeoutMs) + } + throw error + } +} + +export function createClaudeInitDeadline(sessionId: string, timeoutMs: number): ClaudeInitDeadline { + let resolve = (_init: ClaudeInitObservation): void => {} + let reject = (_error: Error): void => {} + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + void promise.catch(() => {}) + let timer: ReturnType | null = null + + return { + promise, + resolve, + reject, + start: () => { + timer = setTimeout(() => reject(claudeInitTimeoutError(sessionId, timeoutMs)), timeoutMs) + timer.unref?.() + }, + clear: () => { + if (timer) { + clearTimeout(timer) + timer = null + } + } + } +} diff --git a/src/main/claude/claude-structured-init-proof.ts b/src/main/claude/claude-structured-init-proof.ts new file mode 100644 index 00000000000..c29cb2d4715 --- /dev/null +++ b/src/main/claude/claude-structured-init-proof.ts @@ -0,0 +1,88 @@ +import { CLAUDE_DEFAULT_SETTING_SOURCES } from './claude-structured-launch-resolution' +import type { ClaudeAuthDiagnostic } from './claude-structured-session-state' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' + +export type ClaudeInitObservation = { + providerSessionId: string + uuid: string | null + /** The resolved model id the CLI reports it is running; only `system/init` carries it. */ + model: string | null + message: Record +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function readClaudeFrameString(source: Record, key: string): string | null { + const value = source[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +export function readClaudeInit(message: Record): ClaudeInitObservation | null { + const hookName = readClaudeFrameString(message, 'hook_name') + const isInit = message.type === 'system' && message.subtype === 'init' + const isSessionStart = + message.type === 'system' && + (message.subtype === 'hook_started' || message.subtype === 'hook_response') && + hookName?.startsWith('SessionStart:') === true + if (!isInit && !isSessionStart) { + return null + } + const providerSessionId = readClaudeFrameString(message, 'session_id') + return providerSessionId + ? { + providerSessionId, + uuid: isInit ? readClaudeFrameString(message, 'uuid') : null, + model: isInit ? readClaudeFrameString(message, 'model') : null, + message + } + : null +} + +export function readClaudeModels(initialization: unknown): unknown[] { + return isRecord(initialization) && Array.isArray(initialization.models) + ? initialization.models + : [] +} + +/** CLI capabilities advertised on the initialize result or the yielded system/init frame. */ +export function readClaudeCapabilities( + init: ClaudeInitObservation, + initialization: unknown +): string[] { + const fromResult = isRecord(initialization) ? initialization.capabilities : undefined + const fromFrame = init.message.capabilities + const source = Array.isArray(fromResult) ? fromResult : Array.isArray(fromFrame) ? fromFrame : [] + return source.filter((value): value is string => typeof value === 'string') +} + +export function claudeInitializationAuthError( + initialization: unknown +): AgentSessionAcquisitionRefusal | null { + const account = + isRecord(initialization) && isRecord(initialization.account) ? initialization.account : null + return readClaudeFrameString(account ?? {}, 'tokenSource') === 'none' + ? new AgentSessionAcquisitionRefusal( + 'Claude is not signed in for the selected account. Sign in with the Claude CLI for this CLAUDE_CONFIG_DIR, then retry.' + ) + : null +} + +export function claudeAuthDiagnostic( + init: ClaudeInitObservation, + settings: unknown +): ClaudeAuthDiagnostic { + const env = isRecord(settings) && isRecord(settings.env) ? settings.env : {} + const apiKeySource = readClaudeFrameString(init.message, 'apiKeySource') + const configured = (key: string): boolean => + (typeof env[key] === 'string' && (env[key] as string).trim().length > 0) || + Boolean(process.env[key]?.trim()) + return { + apiKeySourceConfigured: apiKeySource !== null && apiKeySource !== 'none', + baseUrlConfigured: configured('ANTHROPIC_BASE_URL'), + authTokenConfigured: configured('ANTHROPIC_AUTH_TOKEN'), + apiKeyConfigured: configured('ANTHROPIC_API_KEY'), + settingSources: CLAUDE_DEFAULT_SETTING_SOURCES + } +} diff --git a/src/main/claude/claude-structured-item-translation.ts b/src/main/claude/claude-structured-item-translation.ts new file mode 100644 index 00000000000..d86093ee0a5 --- /dev/null +++ b/src/main/claude/claude-structured-item-translation.ts @@ -0,0 +1,179 @@ +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalMessageItem +} from '../../shared/agent-session-journal-types' +import type { NativeChatBlock } from '../../shared/native-chat-types' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' + +export type ClaudeMessageEnvelope = { + sessionId: string + uuid: string + role: 'assistant' | 'user' + content: unknown[] + /** Messages API id shared by every frame of one streamed assistant message. */ + messageId: string | null + parentToolUseId: string | null +} + +export type ClaudeToolUse = { id: string; name: string; input: unknown } +export type ClaudeToolResult = { toolUseId: string; output: string; failed: boolean } + +export function claudeRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null +} + +export function claudeText(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +export function readClaudeMessageEnvelope( + frame: Record +): ClaudeMessageEnvelope | null { + if (frame.type !== 'assistant' && frame.type !== 'user') { + return null + } + const message = claudeRecord(frame.message) + const sessionId = claudeText(frame.session_id) + const uuid = claudeText(frame.uuid) + const role = message?.role + return sessionId && uuid && (role === 'assistant' || role === 'user') + ? { + sessionId, + uuid, + role, + content: messageContent(message?.content), + messageId: claudeText(message?.id), + parentToolUseId: claudeText(frame.parent_tool_use_id) + } + : null +} + +// A user replay may carry its text as a bare string (MessageParam), not blocks. +function messageContent(content: unknown): unknown[] { + if (Array.isArray(content)) { + return content + } + const text = claudeText(content) + return text ? [{ type: 'text', text }] : [] +} + +export function claudeMessageIdentity( + envelope: Pick +): AgentJournalItemIdentity { + return { provider: 'claude', sessionId: envelope.sessionId, uuid: envelope.uuid } +} + +function messageBlocks(envelope: ClaudeMessageEnvelope): NativeChatBlock[] { + const blocks: NativeChatBlock[] = [] + for (const value of envelope.content) { + const part = claudeRecord(value) + const text = claudeText(part?.text) + if (part?.type === 'text' && text) { + blocks.push({ type: 'text', text }) + continue + } + const source = claudeRecord(part?.source) + const url = claudeText(source?.url) + if (part?.type === 'image' && source?.type === 'url' && url) { + blocks.push({ type: 'image-ref', url }) + } + } + return blocks +} + +export function claudeMessageBody(envelope: ClaudeMessageEnvelope): AgentJournalMessageItem | null { + const blocks = messageBlocks(envelope) + return blocks.length > 0 ? { kind: 'message', role: envelope.role, blocks } : null +} + +export function claudeHasReplayContent(envelope: ClaudeMessageEnvelope): boolean { + return envelope.content.some((value) => { + const part = claudeRecord(value) + return part !== null && part.type !== 'tool_result' + }) +} + +export function claudeToolUses(envelope: ClaudeMessageEnvelope): ClaudeToolUse[] { + return envelope.content.flatMap((value) => { + const part = claudeRecord(value) + const id = claudeText(part?.id) + const name = claudeText(part?.name) + return part?.type === 'tool_use' && id && name ? [{ id, name, input: part.input ?? null }] : [] + }) +} + +function resultText(value: unknown): string { + if (typeof value === 'string') { + return value + } + if (!Array.isArray(value)) { + return value === undefined ? '' : JSON.stringify(value) + } + return value + .flatMap((entry) => { + if (typeof entry === 'string') { + return [entry] + } + const part = claudeRecord(entry) + return part?.type === 'text' && typeof part.text === 'string' ? [part.text] : [] + }) + .join('\n') +} + +export function claudeToolResults(envelope: ClaudeMessageEnvelope): ClaudeToolResult[] { + return envelope.content.flatMap((value) => { + const part = claudeRecord(value) + const toolUseId = claudeText(part?.tool_use_id) + return part?.type === 'tool_result' && toolUseId + ? [ + { + toolUseId, + output: resultText(part.content), + failed: part.is_error === true + } + ] + : [] + }) +} + +export function claudeThinkingText(envelope: ClaudeMessageEnvelope): string | null { + const parts = envelope.content.flatMap((value) => { + const part = claudeRecord(value) + const thinking = claudeText(part?.thinking) + return part?.type === 'thinking' && thinking ? [thinking] : [] + }) + return parts.length > 0 ? parts.join('\n') : null +} + +export function claudeToolBody(input: { + tool: ClaudeToolUse + result?: ClaudeToolResult +}): AgentJournalItemBody { + return { + kind: 'tool-call', + name: input.tool.name, + input: input.tool.input, + state: input.result ? (input.result.failed ? 'failed' : 'completed') : 'running', + ...(input.result + ? { output: boundInlineText(input.result.output, DEFAULT_JOURNAL_PAYLOAD_LIMITS).bounded } + : {}) + } +} + +export function claudeStreamingMessageBody(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} + +export function claudeToolIdentity(sessionId: string, toolUseId: string): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `claude-tool:${sessionId}:${toolUseId}` } +} + +export function claudeThinkingIdentity(sessionId: string, uuid: string): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `claude-thinking:${sessionId}:${uuid}` } +} diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts new file mode 100644 index 00000000000..f403313dae8 --- /dev/null +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -0,0 +1,811 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalRenderItem, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { activeStructuredAgentSessionTurnId } from '../../shared/structured-agent-session-projection' +import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' +import { + createDeferredStructuredAgentSessionEventSink, + type StructuredAgentSessionEventSink +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import type { ClaudePendingPrompt } from './claude-structured-prompt-replies' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +function sinkState() { + const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const tombstones: AgentJournalItemIdentity[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => items.push({ identity, body }), + appendTombstone: (identity) => tombstones.push(identity), + publish: vi.fn() + } + return { sink, items, tombstones } +} + +function message( + type: 'assistant' | 'user', + uuid: string, + content: unknown[], + parentToolUseId: string | null = null +) { + return { + type: 'message' as const, + sessionId: 'orca-session', + ...(type === 'user' && parentToolUseId === null ? { startsTurn: true as const } : {}), + message: { + type, + uuid, + session_id: 'claude-session', + parent_tool_use_id: parentToolUseId, + message: { role: type, content } + } + } +} + +// Frames below follow the Claude Code 2.1.258 / SDK 0.3.251 partial-message +// cadence captured from the real CLI: every stream_event carries its own uuid, +// the final assistant frame for a block carries yet another, and only +// message.id ties them together. +function streamEvent(uuid: string, event: Record) { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'stream_event', + uuid, + session_id: 'claude-session', + parent_tool_use_id: null, + event + } + } +} + +function resultFrame(subtype: string, fields: Record) { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'result', + subtype, + duration_ms: 1200, + duration_api_ms: 1100, + num_turns: 1, + session_id: 'claude-session', + uuid: `result-${subtype}`, + ...fields + } + } +} + +/** One streamed text turn in wire order: message_start, the block's start frame, + * one delta per chunk, the block's final assistant frame, the stop frames and + * the success result. */ +function streamedTextTurn(input: { + messageId: string + startUuid: string + finalUuid: string + chunks: string[] +}) { + const text = input.chunks.join('') + return { + start: [ + streamEvent(`${input.messageId}-message-start`, { + type: 'message_start', + message: { id: input.messageId, role: 'assistant', content: [] } + }), + streamEvent(input.startUuid, { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' } + }) + ], + deltas: input.chunks.map((chunk, index) => + streamEvent(`${input.messageId}-delta-${index}`, { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: chunk } + }) + ), + final: { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'assistant', + uuid: input.finalUuid, + session_id: 'claude-session', + parent_tool_use_id: null, + message: { + id: input.messageId, + role: 'assistant', + content: [{ type: 'text', text }], + stop_reason: null + } + } + }, + stop: [ + streamEvent(`${input.messageId}-block-stop`, { type: 'content_block_stop', index: 0 }), + streamEvent(`${input.messageId}-message-delta`, { + type: 'message_delta', + delta: { stop_reason: 'end_turn' } + }), + streamEvent(`${input.messageId}-message-stop`, { type: 'message_stop' }), + resultFrame('success', { + is_error: false, + result: text, + stop_reason: 'end_turn', + terminal_reason: 'completed' + }) + ], + text + } +} + +function assistantMessages(items: T[]): T[] { + return items.filter((item) => item.body.kind === 'message' && item.body.role === 'assistant') +} + +function providerFrameKinds(items: { body: AgentJournalItemBody }[]): string[] { + return items.flatMap((item) => + item.body.kind === 'status' && item.body.providerFrame ? [item.body.providerFrame.kind] : [] + ) +} + +const JOURNAL_IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: 'leaf-1' } +} + +let journalRoot = '' + +beforeEach(async () => { + journalRoot = await mkdtemp(join(tmpdir(), 'orca-claude-journal-translation-')) +}) + +afterEach(async () => { + await rm(journalRoot, { recursive: true, force: true }) +}) + +describe('Claude structured journal translation', () => { + it('coalesces partial deltas onto the block identity and reconciles the final frame onto it', () => { + const state = sinkState() + let scheduled: (() => void) | null = null + const translator = createClaudeJournalTranslator({ + sink: state.sink, + schedule: (run, delay) => { + expect(delay).toBe(60) + scheduled = run + return () => { + scheduled = null + } + } + }) + const turn = streamedTextTurn({ + messageId: 'msg_01', + startUuid: 'block-start-1', + finalUuid: 'assistant-final-1', + chunks: ['ST', 'REAMOK_ELEC_64E632'] + }) + const streamedIdentity = { + provider: 'claude', + sessionId: 'claude-session', + uuid: 'block-start-1' + } + + for (const event of turn.start) { + translator.handle(event) + } + for (const delta of turn.deltas) { + translator.handle(delta) + } + expect(state.items).toEqual([]) + + const run = scheduled as (() => void) | null + run?.() + expect(state.items.at(-1)).toEqual({ + identity: streamedIdentity, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: turn.text }] } + }) + + translator.handle(turn.final) + for (const event of turn.stop) { + translator.handle(event) + } + const assistant = assistantMessages(state.items) + expect(assistant.at(-1)).toEqual({ + identity: streamedIdentity, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: turn.text }] } + }) + expect(new Set(assistant.map((item) => agentJournalItemKey(item.identity))).size).toBe(1) + expect(providerFrameKinds(state.items)).toEqual([]) + }) + + it('journals a count-to-200 stream as one assistant item carrying the complete reply', async () => { + const journal = await openAgentSessionJournal({ + identity: JOURNAL_IDENTITY, + journalDir: journalRoot, + now: () => 1_700_000_000_000, + mintEpoch: () => 'epoch-1' + }) + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind({ journal, fence: 1, publish: vi.fn() }) + let scheduled: (() => void) | null = null + const translator = createClaudeJournalTranslator({ + sink: deferred.sink, + schedule: (run) => { + scheduled = run + return () => { + scheduled = null + } + } + }) + const numbers = Array.from({ length: 200 }, (_, index) => String(index + 1)) + // The chunk boundaries the real CLI produced for this prompt. + const boundaries = [0, 1, 45, 93, 141, 189, 200] + const chunks = boundaries.slice(1).map((end, index) => { + const slice = numbers.slice(boundaries[index], end).join('\n') + return index === 0 ? slice : `\n${slice}` + }) + const turn = streamedTextTurn({ + messageId: 'msg_count', + startUuid: 'count-start', + finalUuid: 'count-final', + chunks + }) + + for (const event of turn.start) { + translator.handle(event) + } + for (const delta of turn.deltas) { + translator.handle(delta) + // Each chunk lands in its own coalescing window, as it did on the wire. + const run = scheduled as (() => void) | null + run?.() + } + translator.handle(turn.final) + for (const event of turn.stop) { + translator.handle(event) + } + await deferred.drained() + + const items: AgentJournalRenderItem[] = journal.snapshot().items + const assistant = assistantMessages(items) + expect(assistant.map((item) => item.itemId)).toEqual(['claude:claude-session:count-start']) + expect(assistant[0]?.body).toEqual({ + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: numbers.join('\n') }] + }) + expect(providerFrameKinds(items)).toEqual([]) + }) + + it('settles result frames, empty thinking and string user replays without painting a row', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle({ + type: 'message', + sessionId: 'orca-session', + startsTurn: true, + message: { + type: 'user', + uuid: 'user-replay-1', + session_id: 'claude-session', + parent_tool_use_id: null, + isReplay: true, + timestamp: '2026-09-01T00:00:00.000Z', + message: { role: 'user', content: 'Reply with exactly PROBE_OK_1 and nothing else.' } + } + }) + translator.handle( + message('assistant', 'assistant-thinking-empty', [ + { type: 'thinking', thinking: '', signature: 'CAQS6QcKEAgRGAI4AUIIdGhpbmtpbmc' } + ]) + ) + translator.handle( + resultFrame('success', { + is_error: false, + result: 'PROBE_OK_1', + stop_reason: 'end_turn', + terminal_reason: 'completed' + }) + ) + translator.handle( + message('user', 'user-interrupt', [{ type: 'text', text: '[Request interrupted by user]' }]) + ) + translator.handle( + resultFrame('error_during_execution', { + is_error: true, + errors: ['[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=null'], + stop_reason: null, + terminal_reason: 'aborted_streaming', + permission_denials: [] + }) + ) + translator.handle(message('user', 'control-only', [])) + + expect(providerFrameKinds(state.items)).toEqual([]) + expect( + state.items.flatMap((item) => + item.body.kind === 'message' && item.body.role === 'user' ? [item.body.blocks] : [] + ) + ).toEqual([ + [{ type: 'text', text: 'Reply with exactly PROBE_OK_1 and nothing else.' }], + [{ type: 'text', text: '[Request interrupted by user]' }] + ]) + expect( + state.items.some((item) => item.body.kind === 'status' && !item.body.turnLifecycle) + ).toBe(false) + expect( + state.tombstones.flatMap((identity) => + identity.provider === 'legacy' ? [identity.recordId] : [] + ) + ).toEqual(['turn-lifecycle:user-replay-1', 'turn-lifecycle:user-interrupt']) + }) + + it('does not reopen a completed turn when the SDK replays its user row after restart', () => { + const live = sinkState() + const liveTranslator = createClaudeJournalTranslator({ sink: live.sink }) + const replay = { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'user', + uuid: 'picker-command-1', + session_id: 'claude-session', + parent_tool_use_id: null, + isReplay: true, + message: { role: 'user', content: '/model' } + } + } + + liveTranslator.handle({ ...replay, startsTurn: true }) + liveTranslator.handle(resultFrame('success', { is_error: false, result: '' })) + expect(live.tombstones).toContainEqual({ + provider: 'legacy', + agent: 'claude', + sessionId: 'claude-session', + recordId: 'turn-lifecycle:picker-command-1' + }) + liveTranslator.dispose() + + const restarted = sinkState() + const restartedTranslator = createClaudeJournalTranslator({ sink: restarted.sink }) + restartedTranslator.handle(replay) + + expect( + activeStructuredAgentSessionTurnId( + restarted.items.map((item, sequence) => ({ + itemId: agentJournalItemKey(item.identity), + revision: 1, + body: item.body, + sequence, + observedAt: sequence + })) + ) + ).toBeNull() + }) + + it('surfaces an API error carried by a success-subtype result with no assistant frame', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(message('user', 'user-1', [{ type: 'text', text: 'summarize this' }])) + // The SDK models this as a SUCCESS-subtype result whose `result` string is the + // user-facing API error. Suppressing it as ordinary turn bookkeeping ends the + // turn with nothing shown at all. + translator.handle( + resultFrame('success', { + is_error: true, + result: 'API Error: 529 upstream overloaded', + stop_reason: null, + terminal_reason: 'api_error' + }) + ) + + expect(providerFrameKinds(state.items)).toEqual(['message:result:success']) + expect(state.items.at(-1)?.body).toMatchObject({ + kind: 'status', + text: 'API Error: 529 upstream overloaded' + }) + // The turn still settles: the error is an extra row, not a stuck lifecycle. + expect( + state.tombstones.flatMap((identity) => + identity.provider === 'legacy' ? [identity.recordId] : [] + ) + ).toEqual(['turn-lifecycle:user-1']) + }) + + it('drops the stream state of turns that ended without their final frame', () => { + const state = sinkState() + let scheduled: (() => void) | null = null + const translator = createClaudeJournalTranslator({ + sink: state.sink, + schedule: (run) => { + scheduled = run + return () => { + scheduled = null + } + } + }) + for (let turn = 0; turn < 3; turn += 1) { + const aborted = streamedTextTurn({ + messageId: `msg_abort_${turn}`, + startUuid: `abort-start-${turn}`, + finalUuid: `abort-final-${turn}`, + chunks: ['x'.repeat(4_000)] + }) + for (const event of [...aborted.start, ...aborted.deltas]) { + translator.handle(event) + } + const run = scheduled as (() => void) | null + run?.() + // The user interrupts: the result arrives with no final assistant frame, + // so nothing ever reconciles these blocks. + translator.handle( + resultFrame('error_during_execution', { + is_error: true, + terminal_reason: 'aborted_streaming' + }) + ) + // The partial text is already journaled; only the live state is dropped. + expect(translator.pendingStreamedBlocks).toBe(0) + } + + expect(assistantMessages(state.items)).toHaveLength(3) + }) + + it('keeps an ordinary successful result off the timeline', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(resultFrame('success', { is_error: false, result: 'done', errors: [] })) + + expect(providerFrameKinds(state.items)).toEqual([]) + }) + + it('surfaces the reason an error-subtype result stopped the turn', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle( + resultFrame('error_max_turns', { is_error: true, errors: ['turn limit reached'] }) + ) + + expect(providerFrameKinds(state.items)).toEqual(['message:result:error_max_turns']) + }) + + it('keeps an unmodeled result subtype on the bounded provider fallback', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle( + resultFrame('error_from_the_future', { is_error: true, errors: ['budget exhausted'] }) + ) + + expect(providerFrameKinds(state.items)).toEqual(['message:result:error_from_the_future']) + }) + + it('journals turn lifecycle and updates one tool row through its result', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(message('user', 'user-1', [{ type: 'text', text: 'List files' }])) + translator.handle( + message('assistant', 'assistant-tool', [ + { type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'ls' } } + ]) + ) + translator.handle( + message( + 'user', + 'tool-result-1', + [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'a.ts\nb.ts' }], + 'tool-1' + ) + ) + + const keyed = new Map( + state.items.map((item) => [agentJournalItemKey(item.identity), item.body]) + ) + expect(keyed.get('claude:claude-session:user-1')).toMatchObject({ + kind: 'message', + role: 'user' + }) + expect(keyed.get('orca:claude-tool%3Aclaude-session%3Atool-1')).toMatchObject({ + kind: 'tool-call', + name: 'Bash', + state: 'completed', + output: { head: 'a.ts\nb.ts', truncated: false } + }) + expect( + state.items.some( + (item) => item.body.kind === 'status' && item.body.turnLifecycle?.turnId === 'user-1' + ) + ).toBe(true) + + translator.handle( + message( + 'user', + 'tool-result-2', + [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'done again' }], + 'tool-1' + ) + ) + expect(state.items.at(-1)?.body).toMatchObject({ + kind: 'tool-call', + name: 'tool', + input: null, + output: { head: 'done again' } + }) + + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'result', session_id: 'claude-session', uuid: 'result-1' } + }) + expect(state.tombstones.at(-1)).toMatchObject({ + provider: 'legacy', + agent: 'claude', + recordId: 'turn-lifecycle:user-1' + }) + }) + + it('bounds persisted thinking text to the shared journal payload limit', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + const thinking = 'considering '.repeat(20_000) + + translator.handle(message('assistant', 'assistant-thinking', [{ type: 'thinking', thinking }])) + + expect(state.items.at(-1)?.body).toEqual({ + kind: 'status', + text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + }) + }) + + it('starts a cancellable lifecycle for image-only root user replays', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle( + message('user', 'user-image', [ + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AA==' } } + ]) + ) + + expect(state.items.at(-1)?.body).toEqual({ + kind: 'status', + text: 'Claude is working…', + turnLifecycle: { turnId: 'user-image', state: 'running' } + }) + }) + + it('does not start a lifecycle for a top-level user tool result', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle( + message('user', 'tool-result-only', [ + { type: 'tool_result', tool_use_id: 'tool-1', content: 'done' } + ]) + ) + + expect(state.items.map((item) => agentJournalItemKey(item.identity))).toEqual([ + 'orca:claude-tool%3Aclaude-session%3Atool-1' + ]) + expect(state.items[0]?.body).toMatchObject({ + kind: 'tool-call', + state: 'completed', + output: { head: 'done' } + }) + expect( + state.items.some( + (item) => item.body.kind === 'status' && item.body.turnLifecycle !== undefined + ) + ).toBe(false) + }) + + it('paints nothing for a user frame that carries no content', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(message('user', 'control-only', [])) + + expect(state.items).toEqual([]) + expect(state.tombstones).toEqual([]) + }) + + it('renders unmodeled substantive Claude frames as bounded provider rows', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'system', subtype: 'local_command_output', summary: 'x'.repeat(100_000) } + }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'system', subtype: 'hook_response', hook_name: 'PostToolUse' } + }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'system', subtype: 'command_started', command: '/compact' } + }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'result', usage: { input_tokens: 12 }, total_cost_usd: 0.01 } + }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'tool_progress', tool_use_id: 'tool-1', elapsed_time_seconds: 2 } + }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'prompt_suggestion', suggestion: '/compact' } + }) + translator.handle( + message('user', 'attachment-1', [ + { type: 'document', source: { type: 'base64', media_type: 'application/pdf' } } + ]) + ) + translator.handle({ + type: 'provider-frame', + sessionId: 'orca-session', + kind: 'control_request:future_control', + payload: { subtype: 'future_control' } + }) + + const frames = state.items.flatMap((item) => + item.body.kind === 'status' && item.body.providerFrame ? [item.body.providerFrame] : [] + ) + expect(frames.map((frame) => frame.kind)).toEqual( + expect.arrayContaining([ + 'message:system:local_command_output', + 'message:system:command_started', + 'message:result', + 'message:user:content:document', + 'control_request:future_control' + ]) + ) + expect(frames.map((frame) => frame.kind)).not.toEqual( + expect.arrayContaining([ + 'message:system:hook_response', + 'message:tool_progress', + 'message:prompt_suggestion' + ]) + ) + expect( + frames.find((frame) => frame.kind === 'message:system:local_command_output')?.payload + ).toEqual(expect.objectContaining({ truncated: true, byteLength: expect.any(Number) })) + }) + + it('preserves a question group as one addressable prompt and cancels it durably', () => { + const state = sinkState() + const bindings: unknown[][] = [] + const translator = createClaudeJournalTranslator({ + sink: state.sink, + bindPromptItemId: (...args) => bindings.push(args) + }) + const approval = prompt({ + requestId: 'permission-1', + promptKey: 'permission-1', + toolUseId: 'tool-1', + toolName: 'Bash', + kind: 'approval', + input: { command: 'git status' }, + questionIds: [] + }) + translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt: approval }) + expect(state.items.at(-1)?.body).toMatchObject({ + kind: 'approval', + title: 'Allow Bash?', + options: expect.arrayContaining([{ id: 'allow', label: 'Allow' }]) + }) + expect(bindings[0]).toEqual([ + 'orca:claude-prompt%3Aorca-session%3Apermission-1', + 'permission-1' + ]) + + const questions = prompt({ + requestId: 'questions-1', + promptKey: 'questions-1', + toolUseId: 'tool-q', + toolName: 'AskUserQuestion', + kind: 'question', + input: { + questions: [ + { question: 'Library?', options: [{ label: 'Luxon' }] }, + { question: 'Ship?', options: [{ label: 'Yes' }] } + ] + }, + questionIds: ['Library?', 'Ship?'] + }) + translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt: questions }) + expect(state.items.filter((item) => item.body.kind === 'question')).toHaveLength(1) + expect(state.items.at(-1)?.body).toMatchObject({ + kind: 'question', + questions: [ + { id: 'q1', question: 'Library?', multiSelect: false }, + { id: 'q2', question: 'Ship?', multiSelect: false } + ] + }) + expect(bindings.at(-1)).toEqual([ + 'orca:claude-prompt%3Aorca-session%3Aquestions-1', + 'questions-1' + ]) + + const multiSelect = prompt({ + requestId: 'questions-multi', + promptKey: 'questions-multi', + toolUseId: 'tool-multi', + toolName: 'AskUserQuestion', + kind: 'question', + input: { + questions: [ + { + question: 'Libraries?', + multiSelect: true, + options: [{ label: 'Luxon' }, { label: 'Temporal' }] + } + ] + }, + questionIds: ['Libraries?'] + }) + translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt: multiSelect }) + expect(state.items.at(-1)?.body).toMatchObject({ + kind: 'question', + question: '1 grouped question from Claude', + options: [], + questions: [ + { + id: 'q1', + question: 'Libraries?', + multiSelect: true, + options: [{ label: 'Luxon' }, { label: 'Temporal' }], + freeTextQuestionId: 'q1' + } + ] + }) + + translator.handle({ + type: 'prompt-cancelled', + sessionId: 'orca-session', + promptKey: 'questions-1' + }) + expect(state.tombstones).toHaveLength(1) + }) +}) + +function prompt( + input: Pick< + ClaudePendingPrompt, + 'requestId' | 'promptKey' | 'toolUseId' | 'toolName' | 'kind' | 'input' | 'questionIds' + > +): ClaudePendingPrompt { + return { + ...input, + suggestions: [], + answers: new Map(), + settle: () => {} + } +} diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts new file mode 100644 index 00000000000..ffaad4da570 --- /dev/null +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -0,0 +1,287 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { + claudeMessageBody, + claudeMessageIdentity, + claudeHasReplayContent, + claudeRecord, + claudeStreamingMessageBody, + claudeText, + claudeThinkingIdentity, + claudeThinkingText, + claudeToolBody, + claudeToolIdentity, + claudeToolResults, + claudeToolUses, + readClaudeMessageEnvelope, + type ClaudeToolUse +} from './claude-structured-item-translation' +import { + claudeApprovalItem, + claudePromptIdentity, + claudeQuestionItems +} from './claude-structured-prompt-items' +import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' +import { readableProviderFrameText } from '../native-chat/agent-session-wire/unhandled-provider-frame' +import { + CLAUDE_UNRENDERABLE_CONTENT_TEXT, + claudeProviderFrameKind, + claudeResultFailure, + createClaudeProviderFrameFallback, + isModeledClaudeContent, + isSettledClaudeResultKind +} from './claude-structured-provider-fallback' +import { createClaudeStreamedBlockRegistry } from './claude-streamed-block-identity' +import { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints' + +export type ClaudeJournalTranslatorDeps = { + sink: StructuredAgentSessionEventSink + bindPromptItemId?: (journalItemId: string, promptKey: string, questionId?: string) => void + coalesceMs?: number + schedule?: AgentSessionDeltaCoalescerDeps['schedule'] + fallbackIdPrefix?: string +} + +export type ClaudeJournalTranslator = { + handle: (event: ClaudeStructuredSessionEvent) => void + flush: () => void + /** Streamed blocks still awaiting a final frame. A settled turn leaves none. */ + readonly pendingStreamedBlocks: number + dispose: () => void +} + +export function createClaudeSessionJournalTranslator( + sink: StructuredAgentSessionEventSink | undefined, + prompts: ClaudePromptRegistry, + fallbackIdPrefix: string +): ClaudeJournalTranslator | null { + return sink + ? createClaudeJournalTranslator({ + sink, + fallbackIdPrefix, + bindPromptItemId: (itemId, promptKey, questionId) => + prompts.bindJournalItemId(itemId, promptKey, questionId) + }) + : null +} + +function lifecycleIdentity(sessionId: string, turnId: string): AgentJournalItemIdentity { + return { + provider: 'legacy', + agent: 'claude', + sessionId, + recordId: `turn-lifecycle:${turnId}` + } +} + +export function createClaudeJournalTranslator( + deps: ClaudeJournalTranslatorDeps +): ClaudeJournalTranslator { + const tools = new Map() + const promptItems = new Map() + const streamedBlocks = createClaudeStreamedBlockRegistry() + let currentTurn: { sessionId: string; turnId: string } | null = null + const providerFallback = createClaudeProviderFrameFallback( + deps.sink, + deps.fallbackIdPrefix ?? 'acquisition' + ) + const streamedText = createClaudeStreamedTextCheckpoints({ + ...(deps.coalesceMs === undefined ? {} : { coalesceMs: deps.coalesceMs }), + ...(deps.schedule ? { schedule: deps.schedule } : {}), + persist: (identity, text) => { + deps.sink.appendItem(identity, claudeStreamingMessageBody(text)) + deps.sink.publish() + } + }) + + const publishLifecycle = (sessionId: string, turnId: string, running: boolean): void => { + const identity = lifecycleIdentity(sessionId, turnId) + if (running) { + deps.sink.appendItem(identity, { + kind: 'status', + text: 'Claude is working…', + turnLifecycle: { turnId, state: 'running' } + }) + } else { + deps.sink.appendTombstone(identity) + } + deps.sink.publish() + } + + const handleStream = (message: Record): boolean => { + const delta = streamedBlocks.observe(message) + if (!delta) { + return false + } + streamedText.append(delta.identity, delta.text) + return true + } + + const handleMessage = (message: Record, startsTurn: boolean): boolean => { + const envelope = readClaudeMessageEnvelope(message) + if (!envelope) { + return false + } + let changed = false + const body = claudeMessageBody(envelope) + // The final frame of a streamed block lands on the block's identity, not its own uuid. + const identity = + (body && envelope.role === 'assistant' ? streamedBlocks.reconcile(envelope) : null) ?? + claudeMessageIdentity(envelope) + streamedText.forget(agentJournalItemKey(identity)) + if (body) { + deps.sink.appendItem(identity, body) + changed = true + } + for (const tool of claudeToolUses(envelope)) { + tools.set(tool.id, tool) + deps.sink.appendItem( + claudeToolIdentity(envelope.sessionId, tool.id), + claudeToolBody({ tool }) + ) + changed = true + } + for (const result of claudeToolResults(envelope)) { + const tool = tools.get(result.toolUseId) ?? { + id: result.toolUseId, + name: 'tool', + input: null + } + deps.sink.appendItem( + claudeToolIdentity(envelope.sessionId, result.toolUseId), + claudeToolBody({ tool, result }) + ) + // Tool inputs are only needed until their matching result arrives. + tools.delete(result.toolUseId) + changed = true + } + const thinking = claudeThinkingText(envelope) + if (thinking) { + deps.sink.appendItem(claudeThinkingIdentity(envelope.sessionId, envelope.uuid), { + kind: 'status', + text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + }) + changed = true + } + const unhandledContent = envelope.content.filter((part) => !isModeledClaudeContent(part)) + for (const part of unhandledContent) { + const partType = claudeText(claudeRecord(part)?.type) ?? 'unknown' + providerFallback.append( + `message:${envelope.role}:content:${partType}`, + part, + readableProviderFrameText(part) ?? CLAUDE_UNRENDERABLE_CONTENT_TEXT + ) + changed = true + } + // An empty user frame is a replay with nothing to show, not an unknown kind. + if (envelope.content.length === 0 && envelope.role === 'assistant') { + providerFallback.append(`message:${envelope.role}:empty`, message) + changed = true + } + if ( + envelope.role === 'user' && + startsTurn && + claudeHasReplayContent(envelope) && + message.parent_tool_use_id === null + ) { + if (currentTurn) { + publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false) + } + currentTurn = { sessionId: envelope.sessionId, turnId: envelope.uuid } + publishLifecycle(envelope.sessionId, envelope.uuid, true) + } + if (changed) { + deps.sink.publish() + } + return true + } + + const handlePrompt = (event: Extract): void => { + const identities: AgentJournalItemIdentity[] = [] + if (event.prompt.kind === 'question') { + for (const question of claudeQuestionItems({ + sessionId: event.sessionId, + prompt: event.prompt + })) { + identities.push(question.identity) + deps.sink.appendItem(question.identity, question.body) + deps.bindPromptItemId?.(agentJournalItemKey(question.identity), event.prompt.promptKey) + } + } else { + const identity = claudePromptIdentity({ + sessionId: event.sessionId, + promptKey: event.prompt.promptKey + }) + identities.push(identity) + deps.sink.appendItem(identity, claudeApprovalItem(event.prompt)) + deps.bindPromptItemId?.(agentJournalItemKey(identity), event.prompt.promptKey) + } + promptItems.set(event.prompt.promptKey, identities) + deps.sink.publish() + } + + return { + handle: (event) => { + if (event.type === 'ended') { + streamedText.flush() + if (currentTurn) { + publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false) + currentTurn = null + } + return + } + if (event.type === 'message' && handleStream(event.message)) { + return + } + streamedText.flush() + if (event.type === 'prompt') { + handlePrompt(event) + } else if (event.type === 'prompt-cancelled') { + for (const identity of promptItems.get(event.promptKey) ?? []) { + deps.sink.appendTombstone(identity) + } + promptItems.delete(event.promptKey) + deps.sink.publish() + } else if (event.type === 'message' && event.message.type === 'result') { + if (currentTurn) { + publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false) + currentTurn = null + } + // The turn is over. A block still awaiting its final keeps the text the + // flush above journaled, but its live state goes: an interrupted turn + // would otherwise retain that text for the life of the session. + streamedBlocks.clear() + streamedText.settle() + const kind = claudeProviderFrameKind(event.message) + // Ordinary turn bookkeeping stays suppressed; a reported failure never does. + const failure = claudeResultFailure(event.message) + if (failure || !isSettledClaudeResultKind(kind)) { + providerFallback.append(kind, event.message, failure?.text) + } + } else if (event.type === 'message') { + if (!handleMessage(event.message, event.startsTurn === true)) { + providerFallback.append(claudeProviderFrameKind(event.message), event.message) + } + } else if (event.type === 'provider-frame') { + providerFallback.append(event.kind, event.payload) + } + }, + flush: streamedText.flush, + get pendingStreamedBlocks() { + return streamedText.pending + }, + dispose: () => { + streamedText.dispose() + tools.clear() + promptItems.clear() + streamedBlocks.clear() + } + } +} diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts new file mode 100644 index 00000000000..650947cffa1 --- /dev/null +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -0,0 +1,392 @@ +import { chmodSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { claudeStructuredAuthPolicyForSettings } from '../claude-accounts/claude-structured-auth-policy' +import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' +import { + CLAUDE_DEFAULT_SETTING_SOURCES, + CLAUDE_STRUCTURED_BASE_OPTIONS, + claudeSdkOptionsForLaunchArgs, + claudeSessionIdForOrcaSession, + createClaudeStructuredLaunchResolver +} from './claude-structured-launch-resolution' + +const SESSION_ID = 'orca-session-1' +const IDENTITY = { sessionId: SESSION_ID } as Parameters< + ReturnType +>[0]['identity'] + +function record(overrides: Partial = {}): AgentSessionRecord { + return { + sessionId: SESSION_ID, + provider: 'claude', + location: { + executionHostId: LOCAL_EXECUTION_HOST_ID, + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/work/.claude' }, + providerHandleChain: [], + ...overrides + } as AgentSessionRecord +} + +function identityAt(leafUuid: string | null): typeof IDENTITY { + return { + ...IDENTITY, + providerHandle: { kind: 'claude', sessionId: 'provider-current', leafUuid } + } +} + +function makeExecutable(path: string): void { + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, '') + if (process.platform !== 'win32') { + chmodSync(path, 0o755) + } +} + +function resolverFor( + value: AgentSessionRecord | null, + resolveEnv?: () => Record, + stripAuthEnv = false +) { + return createClaudeStructuredLaunchResolver({ + store: { getRecord: () => value } as unknown as AgentSessionRecordStore, + resolveWorkspacePath: async (id) => `/repos/${id}`, + resolveCommand: () => '/usr/local/bin/claude', + resolveAuthPolicy: () => ({ stripAuthEnv }), + ...(resolveEnv ? { resolveEnv } : {}) + }) +} + +function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') { + return { + id, + email: `${id}@example.com`, + managedAuthPath: `/managed/${id}`, + managedAuthRuntime, + authMethod: 'subscription-oauth' as const, + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +const HOST_SELECTED: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('host-1', 'host')], + activeClaudeManagedAccountId: 'host-1', + activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } +} + +/** The normalized steady state of a Windows user whose only Claude account is WSL-managed: the + * prune drops the WSL account out of the host slot and persists that. */ +const WSL_ONLY_NORMALIZED: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('wsl-1', 'wsl')], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } +} + +const RESUMABLE = record({ + providerHandleChain: [ + { handle: { provider: 'claude', sessionId: 'provider-current', leafUuid: 'leaf-current' } } + ] as AgentSessionRecord['providerHandleChain'] +}) + +describe('claude structured launch resolution', () => { + it('pre-mints a stable provider id and pins interactive setting sources', async () => { + const first = await resolverFor(record())({ identity: IDENTITY }) + const second = await resolverFor(record())({ identity: IDENTITY }) + + expect(first.providerSessionId).toBe(claudeSessionIdForOrcaSession(SESSION_ID)) + expect(second.providerSessionId).toBe(first.providerSessionId) + expect(first).toMatchObject({ + pathToClaudeCodeExecutable: '/usr/local/bin/claude', + cwd: '/repos/workspace-1', + claudeConfigDir: '/home/work/.claude', + resumeLeafUuid: null, + resumed: false + }) + expect(first.options).toEqual({ + includePartialMessages: true, + settingSources: [...CLAUDE_DEFAULT_SETTING_SOURCES], + supportedDialogKinds: [], + extraArgs: { 'replay-user-messages': null }, + systemPrompt: { type: 'preset', preset: 'claude_code' }, + sessionId: first.providerSessionId + }) + expect(first.options.resume).toBeUndefined() + expect(CLAUDE_STRUCTURED_BASE_OPTIONS.includePartialMessages).toBe(true) + }) + + it('resumes the session and leaf at the durable chain head', async () => { + const launch = await resolverFor( + record({ + providerHandleChain: [ + { handle: { provider: 'claude', sessionId: 'provider-old', leafUuid: 'leaf-old' } }, + { + handle: { + provider: 'claude', + sessionId: 'provider-current', + leafUuid: 'leaf-current' + } + } + ] as AgentSessionRecord['providerHandleChain'] + }) + )({ identity: identityAt('leaf-current') }) + + expect(launch).toMatchObject({ + providerSessionId: 'provider-current', + resumeLeafUuid: 'leaf-current', + resumed: true + }) + expect(launch.options.resume).toBe('provider-current') + expect(launch.options.resumeSessionAt).toBe('leaf-current') + expect(launch.options.sessionId).toBeUndefined() + }) + + it('refuses a durable journal leaf that diverged before resume resolution', async () => { + const resolve = resolverFor( + record({ + providerHandleChain: [ + { + handle: { + provider: 'claude', + sessionId: 'provider-current', + leafUuid: 'leaf-current' + } + } + ] as AgentSessionRecord['providerHandleChain'] + }) + ) + + await expect(resolve({ identity: identityAt('leaf-stale') })).rejects.toThrow( + 'durable resume identity changed before spawn' + ) + }) + + it('keeps session-only resume when the durable handle has no leaf', async () => { + const launch = await resolverFor( + record({ + providerHandleChain: [ + { + handle: { + provider: 'claude', + sessionId: 'provider-current', + leafUuid: null + } + } + ] as AgentSessionRecord['providerHandleChain'] + }) + )({ identity: identityAt(null) }) + + expect(launch.options.resume).toBe('provider-current') + expect(launch.options.resumeSessionAt).toBeUndefined() + }) + + it('preserves durable Claude launch arguments as typed options and extraArgs', async () => { + const launch = await resolverFor( + record({ + launchArgs: [ + '--model', + 'claude-sonnet-4-5', + '--effort', + 'high', + '--dangerously-skip-permissions' + ] + }) + )({ identity: IDENTITY }) + + expect(launch.options.model).toBe('claude-sonnet-4-5') + expect(launch.options.effort).toBe('high') + expect(launch.options.extraArgs).toEqual({ + 'dangerously-skip-permissions': null, + 'replay-user-messages': null + }) + }) + + it('routes durable launch arguments to a typed option first and refuses what neither can carry', () => { + // The catalog's own output: each flag lands in exactly one place, so the SDK + // cannot emit it twice with two different values. + expect(claudeSdkOptionsForLaunchArgs(['--model', 'opus', '--effort', 'xhigh'])).toEqual({ + model: 'opus', + effort: 'xhigh' + }) + // An effort the SDK's union does not name still reaches the CLI, unchanged. + expect(claudeSdkOptionsForLaunchArgs(['--effort', 'ultra'])).toEqual({ + extraArgs: { effort: 'ultra' } + }) + expect(claudeSdkOptionsForLaunchArgs(['--settings=/tmp/s.json'])).toEqual({ + extraArgs: { settings: '/tmp/s.json' } + }) + expect(() => claudeSdkOptionsForLaunchArgs(['-m', 'opus'])).toThrow(/no SDK option/) + }) + + it('keeps the session launch environment pinned after account settings change', async () => { + const resolver = resolverFor(record(), () => ({ + ANTHROPIC_AUTH_TOKEN: 'rotated-token', + ANTHROPIC_BASE_URL: 'https://gateway.example.test' + })) + + expect((await resolver({ identity: IDENTITY })).env).toMatchObject({ + ANTHROPIC_AUTH_TOKEN: 'rotated-token', + ANTHROPIC_BASE_URL: 'https://gateway.example.test' + }) + expect((await resolver({ identity: IDENTITY })).env?.ANTHROPIC_AUTH_TOKEN).toBe('rotated-token') + }) + + // Stripping is the managed-account rule the terminal preflight computes at + // runtime-auth-preparation.ts:72; claude-structured-auth-parity.test.ts covers + // the system-auth half, where the user's own key has to survive. + it('strips ambient Anthropic auth under a managed account but keeps the rest of the env', async () => { + const restore = { + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, + CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN, + ORCA_LAUNCH_RESOLUTION_MARKER: process.env.ORCA_LAUNCH_RESOLUTION_MARKER + } + process.env.ANTHROPIC_API_KEY = 'sk-ant-SHELL-LEAK' + process.env.ANTHROPIC_AUTH_TOKEN = 'tok-SHELL-LEAK' + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'oauth-SHELL-LEAK' + process.env.ORCA_LAUNCH_RESOLUTION_MARKER = 'inherited' + try { + const launch = await resolverFor(record(), undefined, true)({ identity: IDENTITY }) + + expect(launch.env?.ANTHROPIC_API_KEY).toBeUndefined() + expect(launch.env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined() + expect(launch.env?.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined() + // The inherited env is still the base — only auth is removed from it. + expect(launch.env?.ORCA_LAUNCH_RESOLUTION_MARKER).toBe('inherited') + expect(launch.env?.PATH ?? launch.env?.Path).toBeTruthy() + } finally { + for (const [key, value] of Object.entries(restore)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + }) + + it('lets an explicit Claude env overlay override ambient auth under system auth', async () => { + const restore = process.env.ANTHROPIC_API_KEY + process.env.ANTHROPIC_API_KEY = 'sk-ant-SHELL-LEAK' + try { + const launch = await resolverFor(record(), () => ({ + ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED' + }))({ identity: IDENTITY }) + + expect(launch.env?.ANTHROPIC_API_KEY).toBe('sk-ant-CONFIGURED') + } finally { + if (restore === undefined) { + delete process.env.ANTHROPIC_API_KEY + } else { + process.env.ANTHROPIC_API_KEY = restore + } + } + }) + + it('pairs a resolved Claude CLI with its sibling Node runtime', async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-claude-launch-')) + const binDir = join(root, 'bin') + const claudeCommand = join(binDir, process.platform === 'win32' ? 'claude.cmd' : 'claude') + const nodeCommand = join(binDir, process.platform === 'win32' ? 'node.cmd' : 'node') + makeExecutable(claudeCommand) + makeExecutable(nodeCommand) + + const launch = await createClaudeStructuredLaunchResolver({ + store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + resolveWorkspacePath: async (id) => `/repos/${id}`, + resolveCommand: () => claudeCommand, + resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveEnv: () => ({ + PATH: '/usr/bin', + CLAUDE_CONFIG_DIR: '/accounts/selected/home' + }) + })({ identity: IDENTITY }) + + expect((launch.env?.PATH ?? launch.env?.Path)?.split(delimiter)[0]).toBe(binDir) + }) + + it('refuses other hosts, WSL, providers, and account-home variables', async () => { + await expect( + resolverFor(record({ location: { ...record().location, executionHostId: 'ssh:build' } }))({ + identity: IDENTITY + }) + ).rejects.toThrow(/local host/) + await expect( + resolverFor(record({ location: { ...record().location, wslDistro: 'Ubuntu' } }))({ + identity: IDENTITY + }) + ).rejects.toThrow(/local host/) + await expect( + resolverFor(record({ provider: 'codex' } as Partial))({ + identity: IDENTITY + }) + ).rejects.toThrow(/codex session/) + await expect( + resolverFor(record({ accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' } }))({ + identity: IDENTITY + }) + ).rejects.toThrow(/CLAUDE_CONFIG_DIR/) + }) + + /** The account state can change while a session lives, and a reacquire after an unexpected child + * exit re-resolves the launch. Without the gate here, that reacquire spawns under whatever the + * account state has become. */ + describe('managed-account gate on every acquisition', () => { + function resolverWithGate(read: () => ClaudeManagedAccountGateSettings | null) { + return createClaudeStructuredLaunchResolver({ + store: { getRecord: () => RESUMABLE } as unknown as AgentSessionRecordStore, + resolveWorkspacePath: async (id) => `/repos/${id}`, + resolveCommand: () => '/usr/local/bin/claude', + // Derived, not a literal: the gate and the policy must read the SAME account state, so a + // hardcoded value could assert a pairing production cannot produce. + resolveAuthPolicy: () => { + const settings = read() + if (!settings) { + throw new Error('the gate refuses before the auth policy is computed') + } + return claudeStructuredAuthPolicyForSettings(settings) + }, + readManagedAccountGate: read + }) + } + + it('refuses a reacquire once the account state becomes the refused shape', async () => { + let gate: ClaudeManagedAccountGateSettings | null = HOST_SELECTED + const resolve = resolverWithGate(() => gate) + + // Created while supported: the launch resolves and would spawn. + await expect(resolve({ identity: identityAt('leaf-current') })).resolves.toMatchObject({ + providerSessionId: 'provider-current' + }) + + gate = WSL_ONLY_NORMALIZED + + // Reacquire after the account state changed: refused before anything spawns. + await expect(resolve({ identity: identityAt('leaf-current') })).rejects.toBeInstanceOf( + AgentSessionPreSpawnError + ) + }) + + it('fails closed when the account state cannot be read', async () => { + await expect( + resolverWithGate(() => null)({ identity: identityAt('leaf-current') }) + ).rejects.toBeInstanceOf(AgentSessionPreSpawnError) + }) + + it('keeps resolving when no gate is wired, so other embedders are unaffected', async () => { + await expect( + resolverFor(RESUMABLE)({ identity: identityAt('leaf-current') }) + ).resolves.toMatchObject({ providerSessionId: 'provider-current' }) + }) + }) +}) diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts new file mode 100644 index 00000000000..4f28f14ad65 --- /dev/null +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -0,0 +1,273 @@ +import { createHash } from 'node:crypto' +import type { EffortLevel, Options as ClaudeAgentSdkOptions } from '@anthropic-ai/claude-agent-sdk' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' +import { + CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, + applyClaudeEnvPatch, + hasClaudeAuthEnvConflict +} from '../claude-accounts/environment' +import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' +import { + CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS, + whenClaudeAuthSwitchSettles +} from '../claude-accounts/live-pty-gate' +import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + structuredClaudeMatchesActiveManagedAccount, + type ClaudeManagedAccountGateSettings +} from '../native-chat/claude-structured-managed-account-support' +import { resolveClaudeCommand } from '../codex-cli/command' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' + +export const CLAUDE_DEFAULT_SETTING_SOURCES = ['user', 'project', 'local'] as const + +export type ClaudeStructuredSdkOptions = Pick< + ClaudeAgentSdkOptions, + | 'includePartialMessages' + | 'systemPrompt' + | 'settingSources' + | 'supportedDialogKinds' + | 'extraArgs' + | 'model' + | 'effort' + | 'sessionId' + | 'resume' + | 'resumeSessionAt' +> + +/** + * The options translation of the flags this transport used to build by hand. + * + * `-p`, `--input-format`, `--output-format` and `--verbose` are implied by + * `query()`; `--permission-prompt-tool stdio` is emitted because a `canUseTool` + * callback is supplied. `--replay-user-messages` has no option — the SDK never + * emits it — and Orca's send acknowledgement depends on the replay. + */ +export const CLAUDE_STRUCTURED_BASE_OPTIONS: ClaudeStructuredSdkOptions = { + includePartialMessages: true, + // Keep the SDK on Claude Code's own system-prompt contract. + systemPrompt: { type: 'preset', preset: 'claude_code' }, + settingSources: [...CLAUDE_DEFAULT_SETTING_SOURCES], + supportedDialogKinds: [], + extraArgs: { 'replay-user-messages': null } +} + +const EFFORT_LEVELS: readonly string[] = ['low', 'medium', 'high', 'xhigh', 'max'] + +function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record): Record { + const next: Record = {} + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + next[key] = value + } + } + return next +} + +/** + * Translate the record's durable launch arguments into SDK options. + * + * Typed option first so a flag is never emitted twice; `extraArgs` carries + * anything without one. A token expressible neither way is refused rather than + * dropped — a silent drop is how this lane loses launch flags. + */ +export function claudeSdkOptionsForLaunchArgs( + args: readonly string[] +): Pick { + let model: string | undefined + let effort: EffortLevel | undefined + const extraArgs: Record = {} + for (let index = 0; index < args.length; index += 1) { + const token = args[index] ?? '' + if (!token.startsWith('--') || token.length <= 2) { + throw new Error( + `claude launch argument ${token} has no SDK option; refusing rather than dropping it` + ) + } + const equals = token.indexOf('=') + const flag = equals === -1 ? token : token.slice(0, equals) + let value = equals === -1 ? null : token.slice(equals + 1) + if (value === null) { + const next = args[index + 1] + if (next !== undefined && !next.startsWith('-')) { + value = next + index += 1 + } + } + if (flag === '--model' && value !== null) { + model = value + } else if (flag === '--effort' && value !== null && EFFORT_LEVELS.includes(value)) { + effort = value as EffortLevel + } else { + extraArgs[flag.slice(2)] = value + } + } + return { + ...(model === undefined ? {} : { model }), + ...(effort === undefined ? {} : { effort }), + ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}) + } +} + +export type ClaudeStructuredLaunch = { + /** Always Orca's resolved user CLI: the SDK's bundled binaries are excluded from the install. */ + pathToClaudeCodeExecutable: string + options: ClaudeStructuredSdkOptions + cwd: string + env?: Record + claudeConfigDir: string + providerSessionId: string + resumeLeafUuid: string | null + resumed: boolean +} + +export type ClaudeStructuredLaunchResolverDeps = { + store: AgentSessionRecordStore + resolveWorkspacePath: (workspaceId: string) => Promise + resolveCommand?: () => string + resolveEnv?: () => + | Promise | undefined> + | Record + | undefined + /** + * Required, and deliberately not defaulted. `stripAuthEnv` used to be a literal + * `true` here, so a missing dependency could not under-strip. Now it can, and the + * failure is silent — so every caller states the account's policy rather than + * inherit a guess. Build it with claudeStructuredAuthPolicyForSettings. + */ + resolveAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy + /** How long an in-flight account switch may hold a launch before it is refused. */ + authSwitchSettleTimeoutMs?: number + /** Account state for the managed-account gate; null when it cannot be read, which refuses. */ + readManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null +} + +/** + * Wait a running account switch out, and refuse only if it never settles. + * + * Launch resolution is reached from `acquireClaudeSession` *after* the old child has + * been closed and proved, so a plain refusal here would leave the user with a dead + * chat and no replacement — the very harm the acquire-entry guard exists to prevent. + * The entry guard still refuses outright, because nothing has been torn down yet. + */ +export async function assertClaudeAuthSwitchSettled( + timeoutMs = CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS +): Promise { + if (!(await whenClaudeAuthSwitchSettles(timeoutMs))) { + throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + } +} + +export function claudeSessionIdForOrcaSession(sessionId: string): string { + const bytes = createHash('sha256').update(`orca-claude:${sessionId}`).digest().subarray(0, 16) + bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40 + bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80 + const hex = bytes.toString('hex') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} + +export function createClaudeStructuredLaunchResolver( + deps: ClaudeStructuredLaunchResolverDeps +): (input: { identity: AgentSessionJournalIdentity }) => Promise { + return async ({ identity }) => { + await assertClaudeAuthSwitchSettled(deps.authSwitchSettleTimeoutMs) + const record = deps.store.getRecord(identity.sessionId) + if (!record) { + throw new Error(`no durable agent-session record for ${identity.sessionId}`) + } + if (record.provider !== 'claude') { + throw new Error(`session ${identity.sessionId} is a ${record.provider} session`) + } + if ( + record.location.executionHostId !== LOCAL_EXECUTION_HOST_ID || + record.location.wslDistro !== null + ) { + throw new Error( + `claude structured sessions run on the local host, not ${record.location.executionHostId}` + ) + } + if (record.accountHome.variable !== 'CLAUDE_CONFIG_DIR') { + throw new Error(`claude sessions pin CLAUDE_CONFIG_DIR, not ${record.accountHome.variable}`) + } + // Every acquisition, not just the first: the account state can change under a live session, and + // a reacquire after an unexpected exit would otherwise spawn under whatever it has become. + // Codex has no gate here — it resolves its account on a different path. + if ( + deps.readManagedAccountGate && + !structuredClaudeMatchesActiveManagedAccount(deps.readManagedAccountGate()) + ) { + throw new AgentSessionPreSpawnError( + 'structured Claude is not offered under the active managed Claude account' + ) + } + const head = agentSessionProviderHandleChainHead(record.providerHandleChain) + if ( + head?.handle.provider === 'claude' && + (identity.providerHandle.kind !== 'claude' || + identity.providerHandle.sessionId !== head.handle.sessionId || + identity.providerHandle.leafUuid !== head.handle.leafUuid) + ) { + throw new Error('claude durable resume identity changed before spawn') + } + const providerSessionId = + head?.handle.provider === 'claude' + ? head.handle.sessionId + : claudeSessionIdForOrcaSession(identity.sessionId) + const durable = claudeSdkOptionsForLaunchArgs(record.launchArgs ?? []) + const command = (deps.resolveCommand ?? resolveClaudeCommand)() + const auth = await deps.resolveAuthPolicy() + const overlay = await deps.resolveEnv?.() + // A switch can begin while the policy and overlay resolve, exactly as it can + // during the terminal preflight's prepareClaudeAuth — recheck after the awaits. + await assertClaudeAuthSwitchSettled(deps.authSwitchSettleTimeoutMs) + // Under a managed account the pinned credential is the only auth this launch may + // use, so an explicit override is refused rather than silently beating the pin. + if (auth.stripAuthEnv && hasClaudeAuthEnvConflict(overlay)) { + throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + } + // Why the overlay merges onto the inherited env rather than replacing it: the child + // still needs PATH and the rest of the shell environment, and withCliRuntimeOnPath + // derives PATH from what it is handed. Ambient Anthropic auth is stripped from the + // inherited half only when a managed account owns the credential; a system-auth + // user's own key is their sign-in and must reach the child. + const env = withCliRuntimeOnPath( + command, + { + ...applyClaudeEnvPatch( + cloneDefinedEnv(process.env), + {}, + { + stripAuthEnv: auth.stripAuthEnv, + platform: process.platform + } + ), + ...(overlay ? cloneDefinedEnv(overlay) : {}) + }, + { platform: process.platform } + ) + return { + pathToClaudeCodeExecutable: command, + options: { + ...durable, + ...CLAUDE_STRUCTURED_BASE_OPTIONS, + extraArgs: { ...durable.extraArgs, ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs }, + ...(head?.handle.provider === 'claude' + ? { + resume: providerSessionId, + ...(head.handle.leafUuid === null ? {} : { resumeSessionAt: head.handle.leafUuid }) + } + : { sessionId: providerSessionId }) + }, + cwd: await deps.resolveWorkspacePath(record.location.workspaceId), + env, + claudeConfigDir: record.accountHome.path, + providerSessionId, + resumeLeafUuid: head?.handle.provider === 'claude' ? head.handle.leafUuid : null, + resumed: head?.handle.provider === 'claude' + } + } +} diff --git a/src/main/claude/claude-structured-location-support.test.ts b/src/main/claude/claude-structured-location-support.test.ts new file mode 100644 index 00000000000..1106667d544 --- /dev/null +++ b/src/main/claude/claude-structured-location-support.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + __setWindowsProcessTreeLoaderForTests, + resetWindowsProcessTableForTests +} from '../windows/windows-process-table' +import { supportsClaudeStructuredLocation } from './claude-structured-location-support' + +function setPlatform(platform: NodeJS.Platform): PropertyDescriptor | undefined { + const previous = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + return previous +} + +describe('supportsClaudeStructuredLocation', () => { + let previousPlatform: PropertyDescriptor | undefined + + beforeEach(() => { + previousPlatform = setPlatform('darwin') + __setWindowsProcessTreeLoaderForTests() + }) + + afterEach(() => { + __setWindowsProcessTreeLoaderForTests() + resetWindowsProcessTableForTests() + if (previousPlatform) { + Object.defineProperty(process, 'platform', previousPlatform) + } + }) + + it('allows local non-WSL locations on macOS and Linux', () => { + expect( + supportsClaudeStructuredLocation({ + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }) + ).toBe(true) + }) + + it('rejects Windows local locations until creation-time proof is available', () => { + previousPlatform = setPlatform('win32') + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + getAllProcesses: () => undefined + })) + expect( + supportsClaudeStructuredLocation({ + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }) + ).toBe(false) + }) + + it('accepts Windows local locations once creation-time proof is available', () => { + previousPlatform = setPlatform('win32') + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, + getAllProcesses: () => undefined + })) + expect( + supportsClaudeStructuredLocation({ + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }) + ).toBe(true) + }) + + it('rejects WSL and remote locations', () => { + expect( + supportsClaudeStructuredLocation({ + executionHostId: 'local', + wslDistro: 'Ubuntu', + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }) + ).toBe(false) + expect( + supportsClaudeStructuredLocation({ + executionHostId: 'runtime:env-1', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }) + ).toBe(false) + }) +}) diff --git a/src/main/claude/claude-structured-location-support.ts b/src/main/claude/claude-structured-location-support.ts new file mode 100644 index 00000000000..9c784a91325 --- /dev/null +++ b/src/main/claude/claude-structured-location-support.ts @@ -0,0 +1,11 @@ +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' + +export function supportsClaudeStructuredLocation(location: AgentSessionExecutionLocation): boolean { + return ( + location.executionHostId === LOCAL_EXECUTION_HOST_ID && + location.wslDistro === null && + (process.platform !== 'win32' || isWindowsProcessStartTimeAvailable()) + ) +} diff --git a/src/main/claude/claude-structured-model-confirmation.test.ts b/src/main/claude/claude-structured-model-confirmation.test.ts new file mode 100644 index 00000000000..7bd8f629119 --- /dev/null +++ b/src/main/claude/claude-structured-model-confirmation.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from 'vitest' +import { setClaudeStructuredOption } from './claude-structured-options' +import type { ClaudeSession } from './claude-structured-session-state' +import { PROVIDER_SESSION_ID, acquired, fakeClaude } from './claude-structured-session-test-support' + +/** Verbatim rows from Claude Code 2.1.258's list_models response. */ +const CATALOG = [ + { + value: 'default', + resolvedModel: 'claude-opus-5[1m]', + displayName: 'Default (recommended)', + supportsEffort: true, + supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'] + }, + { + value: 'sonnet', + resolvedModel: 'claude-sonnet-5', + displayName: 'Sonnet', + supportsEffort: true, + supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'] + }, + { value: 'haiku', resolvedModel: 'claude-haiku-4-5-20251001', displayName: 'Haiku' } +] + +function initFrame(model: string): Record { + // Keys mirror the real per-turn system/init frame: it carries `model` as the + // resolved id, and no effort of any kind. + return { + type: 'system', + subtype: 'init', + session_id: PROVIDER_SESSION_ID, + uuid: 'turn-init-uuid', + model, + apiKeySource: 'none' + } +} + +describe('Claude model confirmation', () => { + it('adopts the model a later turn reports when nothing was set since', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + + await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ + current: { model: 'sonnet' } + }) + + // The CLI's own report of what it is running — the only channel that carries + // it, since set_model answers success for a model it never resolves. + claude.connections[0]!.handlers.onMessage?.(initFrame('claude-haiku-4-5-20251001')) + + await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ + current: { model: 'haiku' } + }) + }) + + it('keeps a just-set model until the next turn reports one', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) + + // No turn has run, so the acquisition-time report is older than the write and + // must not flip the pill back to the model the session started on. + await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ + current: { model: 'haiku' } + }) + }) + + it('corrects the record when the turn runs a different model than was set', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) + claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5')) + + await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ + current: { model: 'sonnet' } + }) + }) + + it('guards an effort against the model the turn reported, not the one that was set', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) + // set_model answered success for a model it never resolved; the turn runs sonnet. + claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5')) + + // The picker offers sonnet's levels, so refusing one under haiku — a model the + // pill does not show and the child is not running — is the false positive. + await expect( + adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 }) + ).resolves.toMatchObject({ effort: 'high' }) + }) + + it('keeps guarding against the reported model across a second effort write', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) + claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5')) + await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 }) + + // The effort write bumps the option fence but does not change what the child + // runs, so the sonnet report is still current and still governs the guard. + // `max` skips the settings readback by contract, so only the catalog gates it: + // sonnet advertises it, haiku advertises no effort control at all. + await expect( + adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'max', fence: 7 }) + ).resolves.toMatchObject({ effort: 'max' }) + }) + + it('guards an effort against a just-set model no turn has reported yet', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) + + // The acquisition-time report predates the write, so haiku — which advertises + // no effort control — is still the model the guard must answer for. + await expect( + adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 }) + ).rejects.toThrow('claude model haiku does not accept effort high') + }) + + it('stops vouching for a confirmed effort once the model changes', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + + await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 }) + await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ + current: { model: 'sonnet', effort: 'high', confirmed: ['model', 'effort'] } + }) + + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) + + // The readback was taken under sonnet; nothing has reported haiku holding it. + const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(options.current.effort).toBe('high') + expect(options.current.confirmed).toBeUndefined() + }) +}) + +describe('Claude effort the settings readback cannot report', () => { + function sessionWith( + reported: string, + calls: string[] = [] + ): { session: ClaudeSession; calls: string[] } { + return { + session: { + options: new Map([['model', 'sonnet']]), + reportedOptions: {}, + optionMutationSequence: 0, + confirmedOptions: new Set(), + connection: { + supportedModels: async () => { + calls.push('list_models') + return CATALOG + }, + applyFlagSettings: async (settings: { effortLevel?: string }) => { + calls.push(`apply:${settings.effortLevel}`) + }, + getSettings: async () => { + calls.push('get_settings') + return { + applied: { effort: reported }, + effective: { effortLevel: reported }, + sources: {} + } + } + } + } as unknown as ClaudeSession, + calls + } + } + + it('records a session-scoped effort the persisted settings never carry', async () => { + // `max` applies for the session and is deliberately excluded from the + // persisted effortLevel, so the readback reporting `high` is an absence of + // evidence, not a refusal — and the CLI offers `max` in its own catalog. + const { session, calls } = sessionWith('high') + + await expect( + setClaudeStructuredOption(session, { key: 'effort', value: 'max' }, undefined) + ).resolves.toEqual({ model: 'sonnet', effort: 'max' }) + expect(calls).toEqual(['list_models', 'apply:max']) + }) +}) diff --git a/src/main/claude/claude-structured-option-confirmation.test.ts b/src/main/claude/claude-structured-option-confirmation.test.ts new file mode 100644 index 00000000000..ca7b8b70f1c --- /dev/null +++ b/src/main/claude/claude-structured-option-confirmation.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest' +import { + applyStructuredAgentSessionOptions, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../shared/structured-agent-session-options' +import { CLAUDE_SESSION_OPTION_CATALOG } from '../../shared/agent-session-option-catalog-claude-codex' +import type { AgentSessionOptionsResult } from '../../shared/agent-session-wire' +import type { SessionOptionDescriptor } from '../../shared/native-chat-session-options' +import { setClaudeStructuredOption } from './claude-structured-options' +import type { ClaudeSession } from './claude-structured-session-state' +import { PROVIDER_SESSION_ID, acquired, fakeClaude } from './claude-structured-session-test-support' + +/** Verbatim rows from Claude Code 2.1.260's list_models response: `haiku` really + * does omit both effort keys, which is what makes an effort under it refusable. */ +const CATALOG = [ + { + value: 'sonnet', + resolvedModel: 'claude-sonnet-5', + displayName: 'Sonnet', + supportsEffort: true, + supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'] + }, + { value: 'haiku', resolvedModel: 'claude-haiku-4-5-20251001', displayName: 'Haiku' } +] + +function initFrame(model: string): Record { + return { + type: 'system', + subtype: 'init', + session_id: PROVIDER_SESSION_ID, + uuid: 'turn-init-uuid', + model, + apiKeySource: 'none' + } +} + +function modelPill(result: AgentSessionOptionsResult): SessionOptionDescriptor | undefined { + const state = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('claude'), + CLAUDE_SESSION_OPTION_CATALOG, + result + ) + return structuredAgentSessionOptionSnapshot(state).find((d) => d.category === 'model') +} + +/** Provenance the record keeps. Nothing renders it — the pill shows the value + * either way, and a report that disagrees is what corrects it. */ +function modelSource(result: AgentSessionOptionsResult): string | undefined { + return modelPill(result)?.valueSource +} + +function modelValue(result: AgentSessionOptionsResult): string | undefined { + const kind = modelPill(result)?.kind + return kind?.type === 'select' ? kind.currentValue : undefined +} + +describe('structured option confirmation reaches the pill', () => { + it('shows a just-set model before any turn reports it', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) + + const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(result.current.confirmed ?? []).not.toContain('model') + expect(modelSource(result)).toBe('dispatched') + }) + + it('marks the model reported once the provider names it back', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) + claude.connections[0]!.handlers.onMessage?.(initFrame('claude-haiku-4-5-20251001')) + + const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(result.current.confirmed).toContain('model') + expect(modelSource(result)).toBe('reported') + }) + + it('records an effort the readback could not take without confirming it', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + settings: { applied: {}, effective: {}, sources: {} }, + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + // `max` is session-scoped and absent from the persisted settings, so it records + // without a readback — recorded, never vouched for. + await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'max', fence: 7 }) + + const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(result.current.effort).toBe('max') + expect(result.current.confirmed ?? []).not.toContain('effort') + }) + + it('confirms an effort the readback agreed with', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + settings: { applied: { effort: 'low' }, effective: { effortLevel: 'low' }, sources: {} }, + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'low', fence: 7 }) + + const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(result.current.confirmed).toContain('effort') + }) + + it('treats a host that reports no confirmation as unconfirmed', () => { + // Wire compatibility: an older host omits `confirmed` entirely. Absence must + // read as unconfirmed provenance, and the pill still shows the host's value. + const result = { + models: [{ id: 'haiku', label: 'Haiku', isDefault: false, efforts: [] }], + current: { model: 'haiku' } + } + expect(modelSource(result)).toBe('dispatched') + expect(modelValue(result)).toBe('haiku') + }) +}) + +describe('the provider report corrects the pill', () => { + it('moves the pill to the model the turn actually ran', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) + expect(modelValue(await adapter.readOptions({ sessionId: 'session-1', fence: 7 }))).toBe( + 'haiku' + ) + + claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5')) + + const corrected = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(modelValue(corrected)).toBe('sonnet') + expect(corrected.current.confirmed).toContain('model') + }) + + it('lets a newer write outrank the report it precedes', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5')) + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) + + const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(modelValue(result)).toBe('haiku') + expect(result.current.confirmed ?? []).not.toContain('model') + }) +}) + +describe('confirmation never outlives the write it belongs to', () => { + it('drops an earlier effort confirmation when the value changes', async () => { + const calls: string[] = [] + let reported = 'low' + const session = { + options: new Map([['model', 'sonnet']]), + reportedOptions: {}, + optionMutationSequence: 0, + confirmedOptions: new Set(), + connection: { + supportedModels: async () => CATALOG, + applyFlagSettings: async (s: { effortLevel?: string }) => { + calls.push(`apply:${s.effortLevel}`) + }, + getSettings: async () => ({ + applied: { effort: reported }, + effective: { effortLevel: reported }, + sources: {} + }) + } + } as unknown as ClaudeSession + + await setClaudeStructuredOption(session, { key: 'effort', value: 'low' }, undefined) + expect(session.confirmedOptions.has('effort')).toBe(true) + + // The provider now reports a level it cannot represent; the stale confirmation + // must not survive into the new value. + await setClaudeStructuredOption(session, { key: 'effort', value: 'max' }, undefined) + expect(session.options.get('effort')).toBe('max') + expect(session.confirmedOptions.has('effort')).toBe(false) + expect(calls).toEqual(['apply:low', 'apply:max']) + }) +}) diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts new file mode 100644 index 00000000000..bc18a589e10 --- /dev/null +++ b/src/main/claude/claude-structured-options.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest' +import { setClaudeStructuredOption } from './claude-structured-options' +import type { ClaudeSession } from './claude-structured-session-state' + +function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSession { + return { + connection: { setModel } as ClaudeSession['connection'], + providerSessionId: 'provider-session', + claudeConfigDir: '/accounts/claude', + leafUuid: null, + fence: 1, + acquisitionGeneration: 'generation-1', + prompts: {} as ClaudeSession['prompts'], + dispatchWaiters: [], + retiredDispatchWaiters: [], + replayContentFallbackBlocked: false, + dispatchSequence: 0, + optionMutationSequence: 0, + options: new Map(), + reportedOptions: {}, + reportedModelMutation: 0, + confirmedOptions: new Set(), + restoreSkippedOptions: new Set(), + capabilities: [], + events: undefined, + translator: null + } +} + +describe('Claude structured option mutation fencing', () => { + it('does not let a delayed earlier apply overwrite a later option', async () => { + let releaseFirst!: () => void + const firstApply = new Promise((resolve) => { + releaseFirst = resolve + }) + const setModel = vi + .fn() + .mockReturnValueOnce(firstApply) + .mockResolvedValue(undefined) + const session = sessionFor(setModel) + + const first = setClaudeStructuredOption(session, { key: 'model', value: 'old' }, undefined) + await vi.waitFor(() => expect(setModel).toHaveBeenCalledTimes(1)) + const second = setClaudeStructuredOption(session, { key: 'model', value: 'new' }, undefined) + await expect(second).resolves.toEqual({ model: 'new' }) + + releaseFirst() + await expect(first).resolves.toEqual({ model: 'new' }) + expect(session.options).toEqual(new Map([['model', 'new']])) + }) +}) diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts new file mode 100644 index 00000000000..3d1377b12c6 --- /dev/null +++ b/src/main/claude/claude-structured-options.ts @@ -0,0 +1,147 @@ +import type { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk' +import { ClaudeControlRequestError } from './claude-stream-json-connection' +import { + AgentSessionOptionRejectedError, + isAgentSessionOptionRejectedError +} from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { + readClaudeCurrentModel, + readClaudeModelEffortLevels, + readClaudeSettingsEffort +} from './claude-structured-session-options' +import type { ClaudeSession } from './claude-structured-session-state' + +const OPTION_ORDER = ['model', 'effort', 'permissionMode'] as const + +/** + * Efforts the settings readback cannot report. `max` applies for the rest of the + * session and is excluded from the persisted `effortLevel` by contract, so + * `get_settings` answers with the level underneath it — an absence of evidence + * that must not be read as the child refusing a level its own catalog offers. + */ +const UNREPORTED_EFFORTS: ReadonlySet = new Set(['max']) + +export function restoredClaudeStructuredSessionOptions( + options: Readonly> | undefined +): Map { + return new Map( + OPTION_ORDER.flatMap((key) => { + const value = options?.[key] + return value ? [[key, value] as const] : [] + }) + ) +} + +export async function setClaudeStructuredOption( + session: ClaudeSession, + input: { key: string; value: string }, + timeoutMs: number | undefined +): Promise>> { + const apply = + input.key === 'model' + ? () => session.connection.setModel(input.value, { timeoutMs }) + : input.key === 'permissionMode' + ? () => session.connection.setPermissionMode(input.value as PermissionMode, { timeoutMs }) + : input.key === 'effort' + ? () => + session.connection.applyFlagSettings( + { effortLevel: input.value as EffortLevel }, + { timeoutMs } + ) + : null + if (!apply) { + throw new AgentSessionOptionRejectedError( + `claude stream-json has no session option named ${input.key}` + ) + } + // The child stores an effort its model has no control for and keeps it across + // every later model switch and restore, so refuse before the write rather than + // read the acceptance back as adoption. Refused here, restore drops the stale + // value instead of replaying it onto a model that cannot use it. + if (input.key === 'effort') { + const { modelId, levels } = await readClaudeModelEffortLevels(session, timeoutMs) + if (levels && !levels.has(input.value)) { + throw new AgentSessionOptionRejectedError( + `claude model ${modelId} does not accept effort ${input.value}` + ) + } + } + const modelWasConfirmed = readClaudeCurrentModel(session).confirmed + const mutationSequence = ++session.optionMutationSequence + // Only a model write can stale the model report — an effort or permission-mode + // write does not change what the child is running. Leaving the stamp behind + // would drop the session back to the written model and refuse, on the next + // effort write, a level the model actually running advertises. + if (modelWasConfirmed && input.key !== 'model') { + session.reportedModelMutation = mutationSequence + } + try { + await apply() + } catch (error) { + if (error instanceof ClaudeControlRequestError) { + throw new AgentSessionOptionRejectedError(error) + } + throw error + } + // apply_flag_settings answers `success` for an effort it then ignores, so the + // absence of a throw proves nothing. Ask what the child actually holds. + const adopted = + input.key === 'effort' && !UNREPORTED_EFFORTS.has(input.value) + ? await session.connection + .getSettings({ timeoutMs }) + .then(readClaudeSettingsEffort) + .catch(() => null) + : null + if (mutationSequence !== session.optionMutationSequence) { + return Object.fromEntries(session.options) + } + // A disagreement stops main vouching for the value, it does not veto the write: + // the pre-flight guard already refuses levels the model advertises no control + // for, and no other client refuses on a readback. Keep the child's own answer so + // the disagreement survives as the level a later read falls back to. + if (adopted !== null && adopted !== input.value) { + session.reportedOptions.effort = adopted + } + session.options.set(input.key, input.value) + // Only a readback that agreed is adoption evidence; one that disagreed or could + // not be taken records the value but must not also claim the provider vouched for it. + if (adopted !== null && adopted === input.value) { + session.confirmedOptions.add(input.key) + } else { + session.confirmedOptions.delete(input.key) + } + // The effort readback was taken under the old model, so a model switch retires + // it: the child keeps the value but nothing has reported the new model holding + // it, and vouching for it would show a confirmed effort no readback covers. + if (input.key === 'model') { + session.confirmedOptions.delete('effort') + } + return Object.fromEntries(session.options) +} + +export async function restoreClaudeStructuredSessionOptions( + session: ClaudeSession, + timeoutMs: number | undefined +): Promise { + // Any write that was already in flight belongs to the previous acquisition + // state and must not repopulate this map after restore starts. + session.optionMutationSequence += 1 + // The fence bump is not a write, so the report the session already holds is still + // current as of this instant; leaving the stamp behind would make every restored + // session read as unconfirmed until its next turn. + session.reportedModelMutation = session.optionMutationSequence + const options = [...session.options.entries()] + session.options.clear() + for (const [key, value] of options) { + try { + await setClaudeStructuredOption(session, { key, value }, timeoutMs) + } catch (error) { + if (!isAgentSessionOptionRejectedError(error)) { + throw error + } + // A stale or unavailable preference must not poison every future acquire; + // the provider's current value remains authoritative and is re-persisted. + session.restoreSkippedOptions.add(key) + } + } +} diff --git a/src/main/claude/claude-structured-owner-identity.test.ts b/src/main/claude/claude-structured-owner-identity.test.ts new file mode 100644 index 00000000000..592b61df338 --- /dev/null +++ b/src/main/claude/claude-structured-owner-identity.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest' +import { CLAUDE_SPAWN_TOKEN_ENV, claudeProcessIdentity } from './claude-structured-owner-identity' + +const IDENTITY = { + sessionId: 'session-identity', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'claude' as const, + providerHandle: { kind: 'claude' as const, sessionId: 'session-1', leafUuid: 'leaf-1' } +} + +describe('claude structured owner identity', () => { + it('exports the spawn token env and records the observed process identity', async () => { + expect(CLAUDE_SPAWN_TOKEN_ENV).toBe('ORCA_AGENT_SESSION_SPAWN_TOKEN') + await expect( + claudeProcessIdentity( + { identity: IDENTITY, spawnToken: 'spawn-a', pid: 4242 }, + async () => 123 + ) + ).resolves.toEqual({ + hostId: 'local', + pid: 4242, + processStartTimeMs: 123, + spawnToken: 'spawn-a' + }) + }) + + it('retries a failed start-time read before giving up', async () => { + const readStartTime = vi + .fn<(pid: number) => Promise>() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(456) + await expect( + claudeProcessIdentity({ identity: IDENTITY, spawnToken: 'spawn-a', pid: 4242 }, readStartTime) + ).resolves.toMatchObject({ processStartTimeMs: 456 }) + expect(readStartTime).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/main/claude/claude-structured-owner-identity.ts b/src/main/claude/claude-structured-owner-identity.ts index 1d13e6ec7c2..e8f251d41f9 100644 --- a/src/main/claude/claude-structured-owner-identity.ts +++ b/src/main/claude/claude-structured-owner-identity.ts @@ -1,4 +1,7 @@ +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { readProcessStartTimeMs } from '../runtime/agent-session-process-identity-probe' export function claudeProviderHandleLink(input: { sessionId: string @@ -19,3 +22,41 @@ export function claudeProviderHandleLink(input: { observedAt: input.observedAt } } + +/** The child echoes its spawn token here so the owner probe can tell a live + * child of this reservation from a same-pid stranger. */ +export const CLAUDE_SPAWN_TOKEN_ENV = 'ORCA_AGENT_SESSION_SPAWN_TOKEN' + +const START_TIME_READ_ATTEMPTS = 3 + +export async function claudeProcessIdentity( + input: { + identity: AgentSessionJournalIdentity + spawnToken: string + pid: number | undefined + }, + readStartTime: (pid: number) => Promise = readProcessStartTimeMs +): Promise { + if (input.pid === undefined) { + throw new Error('claude app-server started without a pid') + } + let processStartTimeMs: number | null = null + for ( + let attempt = 0; + attempt < START_TIME_READ_ATTEMPTS && processStartTimeMs === null; + attempt += 1 + ) { + processStartTimeMs = await readStartTime(input.pid) + } + if (processStartTimeMs === null) { + // Why: recording null makes every later owner probe indeterminate — a durable latch. + // Failing here reaps the child and leaves a retryable refusal instead. + throw new Error(`claude app-server start time for pid ${input.pid} could not be read`) + } + return { + hostId: input.identity.hostId, + pid: input.pid, + processStartTimeMs, + spawnToken: input.spawnToken + } +} diff --git a/src/main/claude/claude-structured-prompt-items.test.ts b/src/main/claude/claude-structured-prompt-items.test.ts new file mode 100644 index 00000000000..79916d6a507 --- /dev/null +++ b/src/main/claude/claude-structured-prompt-items.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { encodeAgentSessionQuestionAnswers } from '../../shared/agent-session-question-answer' +import { claudeQuestionItems } from './claude-structured-prompt-items' +import { + applyClaudePromptAnswer, + encodeClaudeQuestionOptionId, + type ClaudePendingPrompt +} from './claude-structured-prompt-replies' + +describe('Claude structured question addressing', () => { + it('keeps wire IDs bounded while returning the original question and choice', () => { + const questionId = 'Which option? '.repeat(100) + const label = 'A detailed choice '.repeat(100) + const prompt: ClaudePendingPrompt = { + requestId: 'question-1', + promptKey: 'question-1', + toolUseId: 'tool-1', + toolName: 'AskUserQuestion', + kind: 'question', + input: { questions: [{ question: questionId, options: [{ label }] }] }, + suggestions: [], + questionIds: [questionId], + answers: new Map(), + settle: () => {} + } + + const item = claudeQuestionItems({ sessionId: 'session-1', prompt })[0]! + expect(agentJournalItemKey(item.identity).length).toBeLessThan(512) + expect(item.body.options[0]!.id.length).toBeLessThan(512) + expect(item.body.freeTextQuestionId).toBe('q1') + expect(applyClaudePromptAnswer({ prompt }, item.body.options[0]!.id)).toMatchObject({ + updatedInput: { answers: { [questionId]: label } } + }) + }) + + it('preserves colon-containing free-text answers', () => { + const questionId = 'Where should this run?' + const prompt: ClaudePendingPrompt = { + requestId: 'question-1', + promptKey: 'question-1', + toolUseId: 'tool-1', + toolName: 'AskUserQuestion', + kind: 'question', + input: { questions: [{ question: questionId }] }, + suggestions: [], + questionIds: [questionId], + answers: new Map(), + settle: () => {} + } + const answer = 'https://example.test:8443/path' + + expect( + applyClaudePromptAnswer({ prompt }, encodeClaudeQuestionOptionId('q1', answer)) + ).toMatchObject({ + updatedInput: { answers: { [questionId]: answer } } + }) + }) + + it('returns arrays for multi-select and preserves mixed single and Other answers', () => { + const multiQuestion = 'Which targets?' + const singleQuestion = 'Which mode?' + const otherQuestion = 'Where should it run?' + const prompt: ClaudePendingPrompt = { + requestId: 'question-1', + promptKey: 'question-1', + toolUseId: 'tool-1', + toolName: 'AskUserQuestion', + kind: 'question', + input: { + questions: [ + { + question: multiQuestion, + multiSelect: true, + options: [{ label: 'frontend' }, { label: 'backend' }] + }, + { + question: singleQuestion, + options: [{ label: 'fast' }, { label: 'safe' }] + }, + { question: otherQuestion, options: [] } + ] + }, + suggestions: [], + questionIds: [multiQuestion, singleQuestion, otherQuestion], + answers: new Map(), + settle: () => {} + } + const item = claudeQuestionItems({ sessionId: 'session-1', prompt })[0]! + const questions = item.body.questions! + const encoded = encodeAgentSessionQuestionAnswers([ + { + questionId: 'q1', + optionIds: [questions[0]!.options[0]!.id, questions[0]!.options[1]!.id] + }, + { questionId: 'q2', optionIds: [questions[1]!.options[1]!.id] }, + { questionId: 'q3', optionIds: [], other: 'remote host' } + ]) + + expect(applyClaudePromptAnswer({ prompt }, encoded)).toMatchObject({ + updatedInput: { + answers: { + [multiQuestion]: ['frontend', 'backend'], + [singleQuestion]: 'safe', + [otherQuestion]: 'remote host' + } + } + }) + }) +}) diff --git a/src/main/claude/claude-structured-prompt-items.ts b/src/main/claude/claude-structured-prompt-items.ts new file mode 100644 index 00000000000..3bdf8ab6091 --- /dev/null +++ b/src/main/claude/claude-structured-prompt-items.ts @@ -0,0 +1,134 @@ +import type { + AgentJournalApprovalItem, + AgentJournalItemIdentity, + AgentJournalPromptOption, + AgentJournalQuestion, + AgentJournalQuestionItem +} from '../../shared/agent-session-journal-types' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import { claudeRecord, claudeText } from './claude-structured-item-translation' +import { + CLAUDE_APPROVAL_DECISIONS, + encodeClaudeQuestionOptionId, + type ClaudeApprovalDecision, + type ClaudePendingPrompt +} from './claude-structured-prompt-replies' + +const APPROVAL_LABELS: Record = { + allow: 'Allow', + allowForSession: 'Allow for this session', + deny: 'Deny', + cancel: 'Stop' +} + +const PENDING = { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null +} as const + +export function claudePromptIdentity(input: { + sessionId: string + promptKey: string + questionId?: string +}): AgentJournalItemIdentity { + const suffix = input.questionId ? `:${input.questionId}` : '' + return { + provider: 'orca', + clientMessageId: `claude-prompt:${input.sessionId}:${input.promptKey}${suffix}` + } +} + +export function claudeApprovalItem(prompt: ClaudePendingPrompt): AgentJournalApprovalItem { + const serialized = JSON.stringify(prompt.input) + return { + kind: 'approval', + title: `Allow ${prompt.toolName}?`, + detail: serialized ? boundInlineText(serialized, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text : null, + options: CLAUDE_APPROVAL_DECISIONS.map((decision) => ({ + id: decision, + label: APPROVAL_LABELS[decision] + })), + resolution: { ...PENDING } + } +} + +export type ClaudeQuestionItem = { + identity: AgentJournalItemIdentity + body: AgentJournalQuestionItem +} + +function questionOptions( + question: Record, + questionAddress: string +): AgentJournalPromptOption[] { + if (!Array.isArray(question.options)) { + return [] + } + return question.options.flatMap((value, index) => { + const option = claudeRecord(value) + const label = claudeText(option?.label) + const description = claudeText(option?.description) + return label + ? [ + { + id: encodeClaudeQuestionOptionId(questionAddress, `choice-${index + 1}`), + label, + ...(description ? { description } : {}) + } + ] + : [] + }) +} + +export function claudeQuestionItems(input: { + sessionId: string + prompt: ClaudePendingPrompt +}): ClaudeQuestionItem[] { + const values = Array.isArray(input.prompt.input.questions) ? input.prompt.input.questions : [] + const questions = values.flatMap((value, index): AgentJournalQuestion[] => { + const question = claudeRecord(value) + const questionAddress = `q${index + 1}` + const text = claudeText(question?.question) ?? claudeText(question?.header) + const header = claudeText(question?.header) + return question && input.prompt.questionIds[index] && text + ? [ + { + id: questionAddress, + question: text, + ...(header ? { header } : {}), + options: questionOptions(question, questionAddress), + multiSelect: question.multiSelect === true, + freeTextQuestionId: questionAddress + } + ] + : [] + }) + if (questions.length === 0) { + return [] + } + const legacyCompatible = questions.length === 1 && questions[0]?.multiSelect === false + const first = questions[0]! + return [ + { + identity: claudePromptIdentity({ + sessionId: input.sessionId, + promptKey: input.prompt.promptKey + }), + body: { + kind: 'question', + question: legacyCompatible + ? first.question + : `${questions.length} grouped question${questions.length === 1 ? '' : 's'} from Claude`, + options: legacyCompatible ? first.options : [], + ...(legacyCompatible ? { freeTextQuestionId: first.freeTextQuestionId } : {}), + questions, + resolution: { ...PENDING } + } + } + ] +} diff --git a/src/main/claude/claude-structured-prompt-replies.ts b/src/main/claude/claude-structured-prompt-replies.ts new file mode 100644 index 00000000000..deec74b7308 --- /dev/null +++ b/src/main/claude/claude-structured-prompt-replies.ts @@ -0,0 +1,297 @@ +import { decodeAgentSessionQuestionAnswers } from '../../shared/agent-session-question-answer' + +export const CLAUDE_APPROVAL_DECISIONS = ['allow', 'allowForSession', 'deny', 'cancel'] as const +export type ClaudeApprovalDecision = (typeof CLAUDE_APPROVAL_DECISIONS)[number] + +/** Settles the SDK's `canUseTool` promise; `null` is the SDK's "no response written" sentinel. */ +export type ClaudePromptSettle = (response: Record | null) => void + +export type ClaudePendingPrompt = { + requestId: string + promptKey: string + toolUseId: string + toolName: string + kind: 'approval' | 'question' + input: Record + suggestions: unknown[] + questionIds: readonly string[] + answers: Map + settle: ClaudePromptSettle +} + +export type ClaudePromptRegistration = { + requestId: string + toolName: string + toolUseId: string + input: Record + suggestions: unknown[] + settle: ClaudePromptSettle +} + +type PromptBinding = { + address: string + questionId?: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null +} + +function questionsFrom(input: Record): Record[] { + return Array.isArray(input.questions) ? input.questions.filter(isRecord) : [] +} + +function questionIdFromAddress(prompt: ClaudePendingPrompt, address: string): string | null { + const match = /^q([1-9]\d*)$/.exec(address) + const index = match ? Number(match[1]) - 1 : -1 + return index >= 0 ? (prompt.questionIds[index] ?? null) : null +} + +function questionAnswer(prompt: ClaudePendingPrompt, questionId: string, optionId: string): string { + const decoded = decodeClaudeQuestionOptionId(optionId) + if (!decoded) { + return optionId + } + const questionIndex = prompt.questionIds.indexOf(questionId) + if (questionIndex === -1) { + return optionId + } + const choice = /^choice-([1-9]\d*)$/.exec(decoded.answer) + const optionIndex = choice ? Number(choice[1]) - 1 : -1 + const question = questionsFrom(prompt.input)[questionIndex] + const options = Array.isArray(question?.options) ? question.options : [] + const option = options[optionIndex] + const label = isRecord(option) ? readString(option.label) : null + if (decoded.questionId === `q${questionIndex + 1}` && label) { + return label + } + if (decoded.questionId === `q${questionIndex + 1}`) { + return decoded.answer + } + const legacyChoice = options.some( + (candidate) => isRecord(candidate) && readString(candidate.label) === decoded.answer + ) + return decoded.questionId === questionId && (legacyChoice || decoded.answer.trim().length > 0) + ? decoded.answer + : optionId +} + +function questionId(question: Record, index: number): string { + return readString(question.question) ?? readString(question.header) ?? `question-${index + 1}` +} + +export function encodeClaudeQuestionOptionId(questionId: string, answer: string): string { + return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}` +} + +export function decodeClaudeQuestionOptionId( + optionId: string +): { questionId: string; answer: string } | null { + const separator = optionId.indexOf(':') + if (separator <= 0) { + return null + } + try { + return { + questionId: decodeURIComponent(optionId.slice(0, separator)), + answer: decodeURIComponent(optionId.slice(separator + 1)) + } + } catch { + return null + } +} + +export class ClaudePromptRegistry { + private readonly prompts = new Map() + private readonly journalBindings = new Map() + + register(registration: ClaudePromptRegistration): ClaudePendingPrompt | null { + const toolUseId = readString(registration.toolUseId) + const toolName = readString(registration.toolName) + const input = isRecord(registration.input) ? registration.input : null + if (!toolUseId || !toolName || !input) { + return null + } + const questions = toolName === 'AskUserQuestion' ? questionsFrom(input) : [] + const prompt: ClaudePendingPrompt = { + requestId: registration.requestId, + promptKey: registration.requestId, + toolUseId, + toolName, + kind: questions.length > 0 ? 'question' : 'approval', + input, + suggestions: Array.isArray(registration.suggestions) ? registration.suggestions : [], + questionIds: questions.map(questionId), + answers: new Map(), + settle: registration.settle + } + this.prompts.set(prompt.promptKey, prompt) + return prompt + } + + /** True only if the prompt was still pending; lets an abort and an answer race settle once. */ + forgetIfPending(prompt: ClaudePendingPrompt): boolean { + if (!this.prompts.has(prompt.promptKey)) { + return false + } + this.forget(prompt) + return true + } + + bindJournalItemId(journalItemId: string, promptKey: string, questionIdForItem?: string): void { + this.journalBindings.set(journalItemId, { + address: promptKey, + ...(questionIdForItem ? { questionId: questionIdForItem } : {}) + }) + } + + find(itemId: string): { prompt: ClaudePendingPrompt; questionId?: string } | null { + const binding = this.journalBindings.get(itemId) + const prompt = this.prompts.get(binding?.address ?? itemId) + return prompt + ? { prompt, ...(binding?.questionId ? { questionId: binding.questionId } : {}) } + : null + } + + cancel(requestId: string): ClaudePendingPrompt | null { + const prompt = this.prompts.get(requestId) ?? null + if (prompt) { + this.forget(prompt) + } + return prompt + } + + forget(prompt: ClaudePendingPrompt): void { + this.prompts.delete(prompt.promptKey) + for (const [itemId, binding] of this.journalBindings) { + if (binding.address === prompt.promptKey) { + this.journalBindings.delete(itemId) + } + } + } + + clear(): ClaudePendingPrompt[] { + const pending = [...this.prompts.values()] + this.prompts.clear() + this.journalBindings.clear() + return pending + } +} + +function approvalResponse(prompt: ClaudePendingPrompt, optionId: string): Record { + if (!(CLAUDE_APPROVAL_DECISIONS as readonly string[]).includes(optionId)) { + throw new Error(`${optionId} is not a Claude approval decision`) + } + const decision = optionId as ClaudeApprovalDecision + if (decision === 'allow' || decision === 'allowForSession') { + return { + behavior: 'allow', + updatedInput: prompt.input, + ...(decision === 'allowForSession' && prompt.suggestions.length > 0 + ? { updatedPermissions: prompt.suggestions } + : {}), + toolUseID: prompt.toolUseId + } + } + return { + behavior: 'deny', + message: decision === 'cancel' ? 'User stopped this turn.' : 'User denied this action.', + ...(decision === 'cancel' ? { interrupt: true } : {}), + toolUseID: prompt.toolUseId + } +} + +function questionResponse( + prompt: ClaudePendingPrompt, + optionId: string, + boundQuestionId?: string +): Record | null { + const decoded = decodeClaudeQuestionOptionId(optionId) + const decodedQuestionId = decoded + ? (questionIdFromAddress(prompt, decoded.questionId) ?? + (prompt.questionIds.includes(decoded.questionId) ? decoded.questionId : null)) + : null + const selectedQuestionId = + boundQuestionId ?? + decodedQuestionId ?? + (prompt.questionIds.length === 1 ? prompt.questionIds[0] : null) + if (!selectedQuestionId || !prompt.questionIds.includes(selectedQuestionId)) { + throw new Error(`${optionId} does not name a question on Claude prompt ${prompt.promptKey}`) + } + const answer = questionAnswer(prompt, selectedQuestionId, optionId) + prompt.answers.set(selectedQuestionId, answer) + if (prompt.questionIds.some((id) => !prompt.answers.has(id))) { + return null + } + const answers: Record = {} + for (const id of prompt.questionIds) { + answers[id] = prompt.answers.get(id) as string + } + return { + behavior: 'allow', + updatedInput: { ...prompt.input, answers }, + toolUseID: prompt.toolUseId + } +} + +function groupedQuestionResponse( + prompt: ClaudePendingPrompt, + optionId: string +): Record | null { + const grouped = decodeAgentSessionQuestionAnswers(optionId) + if (!grouped) { + return null + } + const questions = questionsFrom(prompt.input) + if (grouped.length !== prompt.questionIds.length) { + throw new Error(`Grouped answer does not match Claude prompt ${prompt.promptKey}`) + } + const answers: Record = {} + for (let index = 0; index < questions.length; index += 1) { + const question = questions[index]! + const providerQuestionId = prompt.questionIds[index] + const answer = grouped.find((entry) => entry.questionId === `q${index + 1}`) + if (!providerQuestionId || !answer) { + throw new Error(`Grouped answer does not name question ${index + 1}`) + } + const selected = answer.optionIds.map((selectedId) => + questionAnswer(prompt, providerQuestionId, selectedId) + ) + const other = answer.other?.trim() + if (question.multiSelect === true) { + const values = [...selected, ...(other ? [other] : [])] + if (values.length === 0) { + throw new Error(`Grouped answer leaves question ${index + 1} empty`) + } + answers[providerQuestionId] = values + } else { + const value = other || selected[0] + if (!value || selected.length > 1) { + throw new Error(`Grouped answer is invalid for question ${index + 1}`) + } + answers[providerQuestionId] = value + } + } + return { + behavior: 'allow', + updatedInput: { ...prompt.input, answers }, + toolUseID: prompt.toolUseId + } +} + +export function applyClaudePromptAnswer( + found: { prompt: ClaudePendingPrompt; questionId?: string }, + optionId: string +): Record | null { + if (found.prompt.kind === 'approval') { + return approvalResponse(found.prompt, optionId) + } + return ( + groupedQuestionResponse(found.prompt, optionId) ?? + questionResponse(found.prompt, optionId, found.questionId) + ) +} diff --git a/src/main/claude/claude-structured-provider-fallback.test.ts b/src/main/claude/claude-structured-provider-fallback.test.ts new file mode 100644 index 00000000000..e8b27114da4 --- /dev/null +++ b/src/main/claude/claude-structured-provider-fallback.test.ts @@ -0,0 +1,117 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' +import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: 'leaf-1' } +} + +let root = '' + +function message( + role: 'assistant' | 'user', + uuid: string, + content: unknown[] +): ClaudeStructuredSessionEvent { + return { + type: 'message', + sessionId: 'orca-session', + message: { + type: role, + uuid, + session_id: 'provider-1', + message: { role, content } + } + } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-provider-fallback-')) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('Claude provider fallback', () => { + it('drops suppressed init frames instead of dereferencing a null translation', () => { + const items: { identity: unknown; body: AgentJournalItemBody }[] = [] + const sink = { + appendItem: (identity: unknown, body: AgentJournalItemBody) => { + items.push({ identity, body }) + }, + appendTombstone: vi.fn(), + publish: vi.fn() + } + const translator = createClaudeJournalTranslator({ sink }) + const initEvent: ClaudeStructuredSessionEvent = { + type: 'message', + sessionId: 'orca-session', + message: { + type: 'system', + subtype: 'init', + session_id: 'provider-1', + uuid: 'init-1' + } + } + + expect(() => translator.handle(initEvent)).not.toThrow() + expect(items).toEqual([]) + }) + + it('keeps provider-fallback rows distinct across acquisitions', async () => { + const journal = await openAgentSessionJournal({ + identity: IDENTITY, + journalDir: root, + now: () => 1_700_000_000_000, + mintEpoch: () => 'epoch-1' + }) + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind({ + journal, + fence: 1, + publish: vi.fn() + }) + + const first = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: '1' }) + const second = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: '2' }) + + first.handle(message('assistant', 'assistant-1', [{ type: 'future_event', message: 'first' }])) + await deferred.drained() + second.handle( + message('assistant', 'assistant-2', [{ type: 'future_event', message: 'second' }]) + ) + await deferred.drained() + + const fallbackRows = journal + .snapshot() + .items.filter( + (item) => + item.body.kind === 'status' && + item.body.providerFrame?.kind === 'message:assistant:content:future_event' + ) + + expect(fallbackRows).toHaveLength(2) + expect(fallbackRows.map(statusText)).toEqual(['first', 'second']) + }) +}) + +function statusText(row: { body: AgentJournalItemBody }): string { + if (row.body.kind !== 'status') { + throw new Error('expected status row') + } + return row.body.text +} diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts new file mode 100644 index 00000000000..2528ac027df --- /dev/null +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -0,0 +1,125 @@ +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import { CLAUDE_STREAM_JSON_FRAME_KINDS } from '../native-chat/agent-session-wire/claude-stream-json-frame-schema' +import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' +import { claudeRecord, claudeText } from './claude-structured-item-translation' + +export function claudeProviderFrameKind(message: Record): string { + const type = claudeText(message.type) ?? 'unknown' + const subtype = claudeText(message.subtype) + const eventType = claudeText(claudeRecord(message.event)?.type) + return ['message', type, subtype ?? eventType].filter(Boolean).join(':') +} + +const SETTLED_RESULT_KINDS: ReadonlySet = new Set( + CLAUDE_STREAM_JSON_FRAME_KINDS.filter((kind) => kind.startsWith('message:result:')) +) + +/** A catalogued result subtype is the turn-complete signal the translator settles + * itself; only an unmodeled subtype still needs the provider-fallback row. */ +export function isSettledClaudeResultKind(kind: string): boolean { + return SETTLED_RESULT_KINDS.has(kind) +} + +/** + * The failure a result frame carries that the turn's own frames never showed. + * + * Suppression is by meaning, not by kind. The SDK models an API failure as a + * SUCCESS-subtype result whose `result` string IS the error text and which has + * no assistant frame behind it, so keying on the subtype tombstones the turn and + * shows the user a completed, empty reply. A turn the user aborted is the + * opposite: its interrupt frame already says so, and the diagnostic in `errors` + * would only be noise. + */ +export function claudeResultFailure( + message: Record +): { text: string | null } | null { + if (message.is_error !== true) { + return null + } + const terminalReason = claudeText(message.terminal_reason) + if (terminalReason === 'aborted_streaming' || terminalReason === 'aborted_tools') { + return null + } + const result = claudeText(message.result)?.trim() + if (result) { + return { text: result } + } + const errors = Array.isArray(message.errors) + ? message.errors.flatMap((entry) => { + const text = claudeText(entry)?.trim() + return text ? [text] : [] + }) + : [] + // Nothing readable to lead with, but a reported failure still gets its row. + return { text: errors.length > 0 ? errors.join('\n') : null } +} + +/** + * What a message part that Orca cannot render says for itself. The kinds under + * `message::content:*` are synthesised from whatever `part.type` the CLI + * sends, so they can never be catalogued ahead of time; printing one is leaking + * wire vocabulary at a user who cannot act on it. The frame stays on the row's + * disclosure, so nothing is dropped and the next reader can still name it. + */ +export const CLAUDE_UNRENDERABLE_CONTENT_TEXT = 'Claude sent content Orca cannot display yet' + +export function isModeledClaudeContent(value: unknown): boolean { + const part = claudeRecord(value) + if (!part) { + return false + } + if (part.type === 'text') { + return claudeText(part.text) !== null + } + if (part.type === 'image') { + const source = claudeRecord(part.source) + if (source?.type === 'url') { + return claudeText(source.url) !== null + } + // A local attachment is replayed as the base64 (or file) source Orca itself + // sent, so it is content we recognise -- not an unknown part to surface. + return source?.type === 'base64' || source?.type === 'file' + } + if (part.type === 'tool_use') { + return claudeText(part.id) !== null && claudeText(part.name) !== null + } + if (part.type === 'tool_result') { + return claudeText(part.tool_use_id) !== null + } + // Redacted thinking arrives as an empty string plus a signature. + return part.type === 'thinking' || part.type === 'redacted_thinking' +} + +export function createClaudeProviderFrameFallback( + sink: StructuredAgentSessionEventSink, + acquisitionId: string +): { + /** `displayText` leads the row when Claude knows the sentence the frame itself does not name. */ + append: (kind: string, payload: unknown, displayText?: string | null) => void +} { + let sequence = 0 + return { + append: (kind, payload, displayText) => { + sequence += 1 + const translated = unhandledProviderFrameJournalItem('claude', kind, payload) + if (!translated) { + return + } + const bounded = displayText + ? boundInlineText(displayText, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + : null + sink.appendItem( + { + provider: 'orca', + clientMessageId: `provider-frame:claude:${acquisitionId}:${sequence}` + }, + bounded ? { ...translated.body, text: bounded } : translated.body + ) + sink.publish() + } + } +} diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts new file mode 100644 index 00000000000..0f22c175cc6 --- /dev/null +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -0,0 +1,299 @@ +import { spawnSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' +import { basename, join, relative } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { resolveClaudeCommand } from '../codex-cli/command' +import { resolveSessionFilePath } from '../native-chat/session-file-resolver' +import { getSpawnArgsForWindows } from '../win32-utils' +import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' +import { + ClaudeStructuredSessionAdapter, + type ClaudeStructuredSessionEvent +} from './claude-structured-session-adapter' + +const command = resolveClaudeCommand() +const versionLaunch = getSpawnArgsForWindows(command, ['--version']) +const realClaudeAvailable = + spawnSync(versionLaunch.spawnCmd, versionLaunch.spawnArgs, { + stdio: 'ignore', + windowsHide: true, + timeout: 5_000 + }).status === 0 +const authStatusLaunch = getSpawnArgsForWindows(command, ['auth', 'status', '--json']) +/** The CLI's own account report — the only source of truth for where it writes that + * is not derived from Orca's own path expressions. */ +const realClaudeAuthStatus = (() => { + if (!realClaudeAvailable) { + return null + } + const result = spawnSync(authStatusLaunch.spawnCmd, authStatusLaunch.spawnArgs, { + encoding: 'utf8', + windowsHide: true, + timeout: 5_000 + }) + if (result.status !== 0) { + return null + } + try { + return JSON.parse(result.stdout) as { loggedIn?: boolean; projectsDirectory?: string } + } catch { + return null + } +})() +const realClaudeAuthenticated = realClaudeAuthStatus?.loggedIn === true + +function realAdapter( + providerSessionId: string, + claudeConfigDir: string, + events: ClaudeStructuredSessionEvent[] = [] +): ClaudeStructuredSessionAdapter { + return new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: command, + options: { ...CLAUDE_STRUCTURED_BASE_OPTIONS, sessionId: providerSessionId }, + cwd: process.cwd(), + claudeConfigDir, + providerSessionId, + resumeLeafUuid: null, + resumed: false + }), + onEvent: (event) => events.push(event), + readProcessStartTime: async () => 1, + now: () => 2, + initTimeoutMs: 5_000 + }) +} + +function identity(providerSessionId: string): AgentSessionJournalIdentity { + return { + sessionId: 'real-cli-handshake', + workspaceId: 'real-cli-workspace', + hostId: 'local', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: providerSessionId, leafUuid: null } + } +} + +/** The CLI flushes its transcript on its own schedule; poll rather than race it. */ +async function waitForResolvedTranscript( + providerSessionId: string, + timeoutMs = 15_000 +): Promise { + const deadline = Date.now() + timeoutMs + for (;;) { + // No options: the exact call transcript-read-cache.ts makes for mobile. + const resolved = await resolveSessionFilePath('claude', providerSessionId) + if (resolved || Date.now() >= deadline) { + return resolved + } + await new Promise((resolve) => setTimeout(resolve, 250)) + } +} + +describe.skipIf(!realClaudeAvailable)('Claude structured real CLI handshake', () => { + it.skipIf(!realClaudeAuthenticated)( + 'proves a pre-minted session before the first user message', + async () => { + const providerSessionId = randomUUID() + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = realAdapter(providerSessionId, claudeConfigDir, events) + + try { + const acquisition = await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli' + }) + const observedSubtypes = events.flatMap((event) => + event.type === 'message' ? [event.message.subtype] : [] + ) + + expect(acquisition.link.handle).toMatchObject({ + provider: 'claude', + sessionId: providerSessionId, + // Init/SessionStart UUIDs are protocol frames, not resumable + // main-transcript leaves; no cursor exists before the first user turn. + leafUuid: null + }) + expect(observedSubtypes).toContain('hook_started') + } finally { + await adapter.closeAll() + } + }, + 10_000 + ) + + // Unit tests can only pin the shape we read, which is exactly how the blank + // Effort pill survived every gate: the fixture invented an `effortLevel` on a + // frame the CLI does not send. This asserts both halves against the live + // binary — that get_settings reports the effort, and that init does not. + it.skipIf(!realClaudeAuthenticated)( + 'reports the current effort through get_settings and never on the init frame', + async () => { + const providerSessionId = randomUUID() + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = realAdapter(providerSessionId, claudeConfigDir, events) + + try { + await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-effort' + }) + const published = events.flatMap((event) => + event.type === 'message' ? [event.message] : [] + ) + const options = await adapter.readOptions({ sessionId: 'real-cli-handshake', fence: 1 }) + + expect(published.length).toBeGreaterThan(0) + // Not just the init frame: no frame the CLI publishes carries an effort + // at all. Goes red the day one does, which is when the simpler fix + // becomes available. Which frame proves the session varies by host, so + // this asserts over all of them rather than picking one. + expect(published.filter((frame) => 'effortLevel' in frame)).toEqual([]) + // Goes red if `effective.effortLevel` is renamed or dropped, which no + // fixture-backed test can see. + expect(options.current.effort).toEqual(expect.any(String)) + } finally { + await adapter.closeAll() + } + }, + 15_000 + ) + + // Mobile native chat never reads the structured journal — it reads the CLI's own + // transcript through native-chat/session-file-resolver.ts. So this resolves the way + // transcript-read-cache.ts:104 does, with NO root override, and checks the answer + // against the root the CLI itself reports. Deriving the expected root from Orca's own + // `CLAUDE_CONFIG_DIR || ~/.claude` expression — the same one the code under test uses — + // would move both sides together and stay green in exactly the environment that + // blacks mobile out. + // The turn is what creates the file: an init-only handshake writes nothing. + it.skipIf(!realClaudeAuthenticated || !realClaudeAuthStatus?.projectsDirectory)( + 'writes its transcript where the mobile session-file resolver looks for it', + async () => { + const providerSessionId = randomUUID() + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const adapter = realAdapter(providerSessionId, claudeConfigDir) + const cliProjectsDir = realClaudeAuthStatus?.projectsDirectory as string + + let transcriptPath: string | null = null + try { + await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-transcript' + }) + await adapter.dispatch({ + sessionId: 'real-cli-handshake', + clientMessageId: 'real-cli-transcript-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + fence: 1 + }) + transcriptPath = await waitForResolvedTranscript(providerSessionId) + } finally { + await adapter.closeAll() + } + + expect(transcriptPath).not.toBeNull() + expect(basename(transcriptPath ?? '')).toBe(`${providerSessionId}.jsonl`) + // `//.jsonl` + expect(relative(cliProjectsDir, transcriptPath ?? '').split(/[\\/]/)).toHaveLength(2) + // And the pinned account home is that same root, so the host-side leaf recovery + // (structured-claude-runtime-adapter.ts:64) and mobile agree. + expect(join(claudeConfigDir, 'projects')).toBe(cliProjectsDir) + }, + 45_000 + ) + + // The model half of the same lesson: a fixture can only pin the shape we read. + // set_model answers success for a model it never resolves — a nonexistent id is + // accepted and only fails once a turn runs — so the CLI's own report is the only + // adoption evidence, and it arrives on the init frame that opens each turn. This + // asserts that frame carries the resolved model against the live binary; it goes + // red the day the CLI stops reporting it, which is the day the confirmation + // silently degrades to echoing back whatever Orca sent. + it.skipIf(!realClaudeAuthenticated)( + 'reports the model it adopted on the init frame that opens each turn', + async () => { + const providerSessionId = randomUUID() + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = realAdapter(providerSessionId, claudeConfigDir, events) + + try { + await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-model' + }) + await adapter.setOption({ + sessionId: 'real-cli-handshake', + key: 'model', + value: 'haiku', + fence: 1 + }) + const before = events.length + await adapter.dispatch({ + sessionId: 'real-cli-handshake', + clientMessageId: 'real-cli-model-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Say ok' }] }, + fence: 1 + }) + const deadline = Date.now() + 60_000 + let frames: Record[] = [] + for (;;) { + frames = events + .slice(before) + .flatMap((event) => + event.type === 'message' && + event.message.type === 'system' && + event.message.subtype === 'init' + ? [event.message] + : [] + ) + if (frames.length > 0 || Date.now() >= deadline) { + break + } + await new Promise((resolve) => setTimeout(resolve, 250)) + } + + expect(frames).not.toHaveLength(0) + // Both halves: the field exists, and it names the model the picker asked + // for in the catalog's resolved shape rather than the id Orca sent. + expect(frames[0]?.model).toEqual(expect.any(String)) + expect(frames[0]?.model).toBe('claude-haiku-4-5-20251001') + await expect( + adapter.readOptions({ sessionId: 'real-cli-handshake', fence: 1 }) + ).resolves.toMatchObject({ current: { model: 'haiku' } }) + } finally { + await adapter.closeAll() + } + }, + 90_000 + ) + + it('turns a real silent unauthenticated startup into sign-in guidance', async () => { + const claudeConfigDir = await mkdtemp(join(tmpdir(), 'orca-claude-no-auth-')) + const providerSessionId = randomUUID() + const adapter = realAdapter(providerSessionId, claudeConfigDir) + + try { + await expect( + adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-no-auth' + }) + ).rejects.toThrow(/not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s) + } finally { + await adapter.closeAll() + await rm(claudeConfigDir, { recursive: true, force: true }) + } + }, 10_000) +}) diff --git a/src/main/claude/claude-structured-session-acquisition-processless.test.ts b/src/main/claude/claude-structured-session-acquisition-processless.test.ts new file mode 100644 index 00000000000..e0996477e84 --- /dev/null +++ b/src/main/claude/claude-structured-session-acquisition-processless.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + ClaudeStreamJsonConnection, + openClaudeStreamJsonConnection +} from './claude-stream-json-connection' +import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' + +const PROVIDER_SESSION_ID = '819cf9f8-e43c-4ad7-b50f-54aa158a726a' +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-processless', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'claude', + providerHandle: { kind: 'opaque', agent: 'claude', value: 'pending' } +} + +describe('Claude structured processless acquisition', () => { + it('classifies pre-pid error and close as processless with idempotent cleanup', async () => { + const fault = new Error('spawn claude ENOENT') + const close = vi.fn(async () => true) + const openConnection: typeof openClaudeStreamJsonConnection = async ( + _launch, + handlers = {} + ) => { + const connection: ClaudeStreamJsonConnection = { + pid: undefined, + closed: true, + exitVerdict: { root: 'processless', tree: 'exited' }, + initializationResult: async () => { + handlers.onFault?.(fault) + throw fault + }, + getSettings: async () => ({}), + supportedModels: async () => [], + interrupt: async () => undefined, + cancelAsyncMessage: async () => {}, + setModel: async () => {}, + setPermissionMode: async () => {}, + applyFlagSettings: async () => {}, + send: async () => {}, + close + } + return connection + } + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo', + claudeConfigDir: '/accounts/claude', + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumed: false + }), + openConnection + }) + + const error = await adapter + .acquire({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' }) + .catch((cause: unknown) => cause) + + expect(error).toBeInstanceOf(AgentSessionPreSpawnError) + expect(error).toMatchObject({ message: fault.message }) + expect(close).toHaveBeenCalledOnce() + await expect(adapter.releaseAcquisition({ sessionId: IDENTITY.sessionId })).resolves.toBe(true) + expect(close).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts new file mode 100644 index 00000000000..e8d09bd78d9 --- /dev/null +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -0,0 +1,298 @@ +import { + AgentSessionAcquisitionExitUnprovenError, + AgentSessionPreSpawnError +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + AgentSessionAcquisition, + StructuredAgentSessionAcquireInput +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE } from '../claude-accounts/environment' +import { isClaudeAuthSwitchInProgress } from '../claude-accounts/live-pty-gate' +import { openClaudeStreamJsonConnection } from './claude-stream-json-connection' +import { buildClaudePermissionCallbacks } from './claude-structured-inbound-control' +import { resolveClaudeReplayWaiter } from './claude-structured-dispatch' +import { + claudeAuthDiagnostic, + readClaudeCapabilities, + readClaudeFrameString, + readClaudeInit, + readClaudeModels +} from './claude-structured-init-proof' +import { + createClaudeInitDeadline, + requestClaudeInitialization +} from './claude-structured-init-deadline' +import { claudeConfigDirEnvPatch } from './claude-config-dir-pin' +import { CLAUDE_SPAWN_TOKEN_ENV, claudeProcessIdentity } from './claude-structured-owner-identity' +import { + restoreClaudeStructuredSessionOptions, + restoredClaudeStructuredSessionOptions +} from './claude-structured-options' +import { ClaudePromptRegistry } from './claude-structured-prompt-replies' +import { createClaudeSessionJournalTranslator } from './claude-structured-journal-translation' +import { readClaudeSettingsEffort } from './claude-structured-session-options' +import { createClaudeSessionPublication } from './claude-structured-session-publication' +import { + cancelClaudeAcquisitionAttempt, + mintClaudeAcquisitionGeneration, + type ClaudeAcquisitionRegistry, + type ClaudeSession, + type ClaudeSessionExit, + type ClaudeStructuredSessionAdapterDeps, + type ClaudeAcquireCallbacks +} from './claude-structured-session-state' +import { + closeClaudePublishedSessionForDeps, + claudeAcquisitionCleanupError +} from './claude-structured-session-close' +import { readClaudeTranscriptEntryUuid } from './claude-tui-exit' + +export const CLAUDE_STRUCTURED_INIT_TIMEOUT_MS = 10_000 + +export async function acquireClaudeSession({ + input, + deps, + sessions, + acquisitions, + exits, + callbacks +}: { + input: StructuredAgentSessionAcquireInput + deps: ClaudeStructuredSessionAdapterDeps + sessions: Map + acquisitions: ClaudeAcquisitionRegistry + exits: Map + callbacks: ClaudeAcquireCallbacks +}): Promise { + // A managed-account switch is mid-swap of the pinned credential home; refuse here, + // before this acquisition cancels the previous attempt and closes the live session. + if (isClaudeAuthSwitchInProgress()) { + throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)) + } + const sessionId = input.identity.sessionId + const prompts = new ClaudePromptRegistry() + const translator = createClaudeSessionJournalTranslator( + input.events, + prompts, + String(input.fence) + ) + const { previous, attempt } = acquisitions.start(sessionId, prompts) + let liveSession: ClaudeSession | null = null + let observedLeafUuid: string | null = null, + expectedProviderSessionId: string | null = null + // Frames are admitted only after launch resolution proves the provider session + // this acquisition owns. Keep the check ahead of every stateful consumer. + const initTimeoutMs = deps.initTimeoutMs ?? CLAUDE_STRUCTURED_INIT_TIMEOUT_MS + const initDeadline = createClaudeInitDeadline(sessionId, initTimeoutMs) + + const onMessage = (message: Record): void => { + const init = readClaudeInit(message) + if (readClaudeFrameString(message, 'session_id') !== expectedProviderSessionId) { + // An init proof for another (or unnamed) provider must fail acquisition + // promptly, while ordinary foreign frames stay quarantined silently. + if (init || (message.type === 'system' && message.subtype === 'init')) { + initDeadline.reject(new Error('claude provider session expected')) + } + return + } + if (init) { + initDeadline.resolve(init) + // Every turn opens with an init frame naming the model the CLI is actually + // running; set_model answers success for a model it never resolves, so this + // report is the session's only adoption evidence. + if (liveSession && init.model) { + liveSession.reportedOptions.model = init.model + liveSession.reportedModelMutation = liveSession.optionMutationSequence + } + } + observedLeafUuid = readClaudeTranscriptEntryUuid(message) ?? observedLeafUuid + if (liveSession) { + liveSession.leafUuid = observedLeafUuid + } + const startsTurn = liveSession ? resolveClaudeReplayWaiter(liveSession, message) : false + callbacks.deliver(attempt, sessionId, () => + callbacks.emit(liveSession, input.events, { + type: 'message', + sessionId, + message, + ...(startsTurn ? { startsTurn: true } : {}) + }) + ) + } + const { canUseTool, onUserDialog } = buildClaudePermissionCallbacks({ + sessionId, + prompts, + emit: (event) => + callbacks.deliver(attempt, sessionId, () => callbacks.emit(liveSession, input.events, event)) + }) + + try { + if (previous && !(await cancelClaudeAcquisitionAttempt(previous))) { + acquisitions.restoreIfCurrent(sessionId, attempt, previous) + throw new AgentSessionAcquisitionExitUnprovenError( + new Error(`claude acquisition for session ${sessionId} could not be stopped`) + ) + } + acquisitions.assertCurrent(sessionId, attempt) + let resumeSession = sessions.get(sessionId) + if (!(await closeClaudePublishedSessionForDeps(sessions, sessionId, deps))) { + throw new AgentSessionAcquisitionExitUnprovenError( + new Error(`claude session ${sessionId} could not be stopped`) + ) + } + // A first-hand exit that has not yet proved its full tree still owns a cleanup + // obligation; never let a new acquisition hide that evidence by omission. + const retainedExit = exits.get(sessionId) + if (retainedExit) { + const firstProof = retainedExit.closePromise ? await retainedExit.closePromise : false + const proven = firstProof || (await retainedExit.connection.close().catch(() => false)) + if (!proven) { + throw claudeAcquisitionCleanupError(retainedExit.connection, retainedExit.error) + } + // The old child is superseded by this acquisition. Settle its lifecycle + // before discarding the retained proof so its cursor and callbacks are + // cleaned up exactly once. + await callbacks.settleExit(sessionId, retainedExit) + resumeSession ??= retainedExit.session + } + acquisitions.assertCurrent(sessionId, attempt) + // Both close paths persist their final leaf, so launch validates that durable head. + const launchIdentity = resumeSession + ? { + ...input.identity, + providerHandle: { + kind: 'claude' as const, + sessionId: resumeSession.providerSessionId, + leafUuid: resumeSession.leafUuid + } + } + : input.identity + const launch = await deps + .resolveLaunch({ identity: launchIdentity }) + .catch((error: unknown) => { + throw error instanceof AgentSessionPreSpawnError + ? error + : new AgentSessionPreSpawnError(error) + }) + expectedProviderSessionId = launch.providerSessionId + observedLeafUuid = launch.resumeLeafUuid + acquisitions.assertCurrent(sessionId, attempt) + const open = deps.openConnection ?? openClaudeStreamJsonConnection + const connection = await open( + { + pathToClaudeCodeExecutable: launch.pathToClaudeCodeExecutable, + options: launch.options, + cwd: launch.cwd, + env: { + ...launch.env, + [CLAUDE_SPAWN_TOKEN_ENV]: input.spawnToken, + // Compared against what the child would otherwise inherit, so the record's + // account home still wins over a diverging overlay without a needless pin. + // (`process` is shadowed by a local later in this function, so it is not named here.) + ...claudeConfigDirEnvPatch(launch.claudeConfigDir, launch.env ? { env: launch.env } : {}) + } + }, + { + onMessage, + canUseTool, + onUserDialog, + onFault: (error) => { + if (!attempt.published) { + initDeadline.reject(error) + } + }, + onExit: (error) => { + if (!attempt.published) { + initDeadline.reject(error) + } + callbacks.handleExit(sessionId, attempt, error) + } + } + ) + attempt.connection = connection + acquisitions.assertCurrent(sessionId, attempt) + initDeadline.start() + const [initialization, init] = await Promise.all([ + requestClaudeInitialization(connection, sessionId, initTimeoutMs), + initDeadline.promise + ]) + const models = readClaudeModels(initialization) + callbacks.deliver(attempt, sessionId, () => + callbacks.emit(liveSession, input.events, { type: 'options', sessionId, models }) + ) + initDeadline.clear() + acquisitions.assertCurrent(sessionId, attempt) + if (init.providerSessionId !== launch.providerSessionId) { + throw new Error( + `claude proved session ${init.providerSessionId}, expected ${launch.providerSessionId}` + ) + } + const settings = await connection + .getSettings({ timeoutMs: deps.requestTimeoutMs }) + .catch(() => null) + callbacks.deliver(attempt, sessionId, () => + callbacks.emit(liveSession, input.events, { + type: 'auth-diagnostic', + sessionId, + diagnostic: claudeAuthDiagnostic(init, settings) + }) + ) + const process = await claudeProcessIdentity( + { ...input, pid: connection.pid }, + deps.readProcessStartTime + ) + acquisitions.assertCurrent(sessionId, attempt) + if (connection.closed) { + throw new Error(`claude stream-json for session ${sessionId} exited while being acquired`) + } + const publication = createClaudeSessionPublication({ + connection, + init, + claudeConfigDir: launch.claudeConfigDir, + leafUuid: observedLeafUuid, + fence: input.fence, + effort: readClaudeSettingsEffort(settings), + resumed: launch.resumed, + prompts, + translator, + events: input.events, + process, + acquisitionGeneration: mintClaudeAcquisitionGeneration(deps), + options: restoredClaudeStructuredSessionOptions(input.options), + capabilities: readClaudeCapabilities(init, initialization), + ...(deps.mintLinkId ? { linkId: deps.mintLinkId() } : {}), + observedAt: deps.now?.() ?? Date.now() + }) + const acquired: AgentSessionAcquisition = publication.acquisition + liveSession = publication.session + await restoreClaudeStructuredSessionOptions(liveSession, deps.requestTimeoutMs) + acquisitions.assertCurrent(sessionId, attempt) + acquisitions.deleteIfCurrent(sessionId, attempt) + sessions.set(sessionId, liveSession) + attempt.published = true + for (const event of attempt.buffered.splice(0)) { + event() + } + return acquired + } catch (error) { + initDeadline.clear() + let acquisitionError = error + if (sessions.get(sessionId)?.connection !== attempt.connection) { + translator?.dispose() + // Settle any callback that fired before the failure so no SDK promise dangles. + for (const prompt of prompts.clear()) { + prompt.settle(null) + } + const closed = (await attempt.connection?.close()) ?? true + if (attempt.connection?.exitVerdict.root === 'processless') { + acquisitionError = new AgentSessionPreSpawnError(error) + } else if (!closed) { + acquisitionError = claudeAcquisitionCleanupError(attempt.connection, error) + } + } + acquisitions.deleteIfCurrent(sessionId, attempt) + throw acquisitionError + } finally { + attempt.finish() + } +} diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts new file mode 100644 index 00000000000..859ba9a8ed8 --- /dev/null +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -0,0 +1,891 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { + AgentSessionAcquisitionExitUnprovenError, + AgentSessionAcquisitionRefusal, + AgentSessionAcquisitionRootExitObservedError +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' +import { ClaudeControlRequestError } from './claude-stream-json-connection' +import { CLAUDE_SPAWN_TOKEN_ENV } from './claude-structured-owner-identity' +import { encodeClaudeQuestionOptionId } from './claude-structured-prompt-replies' +import { + CLAUDE_STRUCTURED_INIT_TIMEOUT_MS, + type ClaudeStructuredSessionAdapter, + type ClaudeStructuredSessionEvent +} from './claude-structured-session-adapter' +import { + acquired, + adapterFor, + fakeClaude, + identityFor, + invokeCanUseTool, + PROVIDER_SESSION_ID, + tick, + USER_MESSAGE, + type FakeConnection +} from './claude-structured-session-test-support' + +describe('ClaudeStructuredSessionAdapter.acquire', () => { + it('finishes its startup deadline before the paired mobile request deadline', () => { + expect(CLAUDE_STRUCTURED_INIT_TIMEOUT_MS).toBeLessThan(30_000) + }) + + it('pins the account and proves init without treating the system-frame uuid as a chain leaf', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) + + const acquisition = await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9' + }) + + expect(claude.connections[0].launch).toMatchObject({ + cwd: '/work/repo', + env: { + [CLAUDE_SPAWN_TOKEN_ENV]: 'spawn-9', + CLAUDE_CONFIG_DIR: '/accounts/claude' + } + }) + // supportedDialogKinds is now a query() launch option, not an initialize request param. + expect(claude.connections[0].calls.slice(0, 2)).toEqual([ + { subtype: 'initialize' }, + { subtype: 'get_settings' } + ]) + expect(acquisition.process).toEqual({ + hostId: 'host-1', + pid: 4321, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-9' + }) + expect(acquisition.link).toEqual({ + linkId: `claude-7-${PROVIDER_SESSION_ID}-empty`, + handle: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null }, + origin: 'created', + mintedAtFence: 7, + observedAt: 1_700_000_000_500 + }) + expect(events[0]).toMatchObject({ type: 'message', message: { subtype: 'init' } }) + }) + + it('restores persisted model and effort before publishing a reacquired session', async () => { + const claude = fakeClaude() + const adapter = adapterFor(claude, { resumed: true }) + + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + options: { model: 'opus', effort: 'high' } + }) + + expect(claude.connections[0].calls.slice(-4)).toEqual([ + { subtype: 'set_model', params: { model: 'opus' } }, + // The restored model's advertised levels gate the replay, so a stale effort + // is dropped rather than re-applied to a model with no effort control. + { subtype: 'list_models' }, + { subtype: 'apply_flag_settings', params: { settings: { effortLevel: 'high' } } }, + // The effort is only recorded once the child reports having adopted it. + { subtype: 'get_settings' } + ]) + await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ + current: { model: 'opus', effort: 'high' } + }) + }) + + it.each([ + ['model', 'set_model', { model: 'retired-model' }], + ['effort', 'apply_flag_settings', { effort: 'retired-effort' }], + ['permissionMode', 'set_permission_mode', { permissionMode: 'retired-mode' }] + ] as const)( + 'self-heals a persisted %s rejected during restore', + async (key, subtype, options) => { + const claude = fakeClaude({ + routes: { + [subtype]: () => { + throw new ClaudeControlRequestError(subtype, 'value is no longer available') + } + } + }) + const adapter = adapterFor(claude) + + await expect( + adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + options + }) + ).resolves.toBeDefined() + expect(adapter.readOptionRestoreFailures('session-1')).toEqual([key]) + } + ) + + it('does not treat a transport timeout while restoring an option as recoverable', async () => { + const claude = fakeClaude({ + routes: { + set_model: () => { + throw new Error('claude set_model request timed out') + } + } + }) + const adapter = adapterFor(claude) + const input = { + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + options: { model: 'temporarily-unavailable' } + } + + await expect(adapter.acquire(input)).rejects.toThrow('claude set_model request timed out') + expect(claude.connections[0]?.closeCount).toBe(1) + }) + + it('recovers a cancellable lifecycle when a timed-out replay arrives late', async () => { + const claude = fakeClaude({ replayUuid: null }) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = await acquired(claude, {}, events) + + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toMatchObject({ state: 'unknown' }) + const sent = claude.connections[0]!.sent[0]! + claude.connections[0]!.handlers.onMessage?.({ + ...sent, + uuid: 'late-turn-1' + }) + + expect(events).toContainEqual( + expect.objectContaining({ + type: 'message', + startsTurn: true, + message: expect.objectContaining({ uuid: 'late-turn-1' }) + }) + ) + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'late-turn-1', fence: 7 }) + ).resolves.toEqual({ cancelled: true }) + }) + + it('quarantines SDK frames without the acquired session identity', async () => { + const claude = fakeClaude({ replayUuid: null }) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = await acquired(claude, {}, events) + const connection = claude.connections[0]! + + connection.handlers.onMessage?.({ + type: 'assistant', + uuid: 'foreign-leaf', + session_id: 'foreign-provider-session', + message: { role: 'assistant', content: [{ type: 'text', text: 'do not admit' }] } + }) + connection.handlers.onMessage?.({ + type: 'assistant', + uuid: 'missing-session-leaf', + message: { role: 'assistant', content: [{ type: 'text', text: 'do not admit' }] } + }) + + const dispatch = adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + await Promise.resolve() + expect(connection.sent).toHaveLength(1) + connection.handlers.onMessage?.({ + ...connection.sent[0], + uuid: 'foreign-replay', + session_id: 'foreign-provider-session' + }) + await Promise.resolve() + expect(events.filter((event) => event.type === 'message')).toHaveLength(1) + + connection.handlers.onMessage?.({ + ...connection.sent[0], + session_id: PROVIDER_SESSION_ID + }) + await expect(dispatch).resolves.toMatchObject({ + state: 'accepted', + providerIdentity: { uuid: connection.sent[0]!.uuid } + }) + }) + + it('forwards configured launch environment while keeping ownership pins authoritative', async () => { + const claude = fakeClaude() + const adapter = adapterFor(claude, { + env: { + ANTHROPIC_AUTH_TOKEN: 'configured-token', + ANTHROPIC_BASE_URL: 'https://gateway.example.test', + CLAUDE_CONFIG_DIR: '/wrong/account', + [CLAUDE_SPAWN_TOKEN_ENV]: 'wrong-token' + } + }) + + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + + expect(claude.connections[0].launch.env).toEqual({ + ANTHROPIC_AUTH_TOKEN: 'configured-token', + ANTHROPIC_BASE_URL: 'https://gateway.example.test', + CLAUDE_CONFIG_DIR: '/accounts/claude', + [CLAUDE_SPAWN_TOKEN_ENV]: 'spawn-9' + }) + }) + + it('leaves CLAUDE_CONFIG_DIR unset when the account home is the CLI default', async () => { + const claude = fakeClaude() + const adapter = adapterFor(claude, { claudeConfigDir: join(homedir(), '.claude'), env: {} }) + + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + + // Pinning the CLI's own default suppresses the macOS Keychain and breaks claude.ai login. + expect(claude.connections[0].launch.env).toEqual({ [CLAUDE_SPAWN_TOKEN_ENV]: 'spawn-9' }) + }) + + it('re-pins the account home when the launch env would send the child elsewhere', async () => { + const claude = fakeClaude() + const accountHome = join(homedir(), '.claude') + const adapter = adapterFor(claude, { + claudeConfigDir: accountHome, + env: { CLAUDE_CONFIG_DIR: '/other/account' } + }) + + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + + expect(claude.connections[0].launch.env).toEqual({ + CLAUDE_CONFIG_DIR: accountHome, + [CLAUDE_SPAWN_TOKEN_ENV]: 'spawn-9' + }) + }) + + it('accepts SessionStart as pre-turn proof without treating its system uuid as a leaf', async () => { + const claude = fakeClaude({ initProof: 'session-start', initUuid: 'session-start-uuid' }) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) + + const acquisition = await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9' + }) + + expect(acquisition.link.handle).toEqual({ + provider: 'claude', + sessionId: PROVIDER_SESSION_ID, + leafUuid: null + }) + expect(events[0]).toMatchObject({ + type: 'message', + message: { subtype: 'hook_started', hook_name: 'SessionStart:startup' } + }) + }) + + it('records only non-secret effective auth-lane diagnostics', async () => { + const claude = fakeClaude({ + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://gateway.example.test', + ANTHROPIC_AUTH_TOKEN: 'secret' + } + } + }) + const events: ClaudeStructuredSessionEvent[] = [] + await acquired(claude, {}, events) + + const diagnostic = events.find((event) => event.type === 'auth-diagnostic') + expect(diagnostic).toEqual({ + type: 'auth-diagnostic', + sessionId: 'session-1', + diagnostic: { + apiKeySourceConfigured: false, + baseUrlConfigured: true, + authTokenConfigured: true, + apiKeyConfigured: false, + settingSources: ['user', 'project', 'local'] + } + }) + expect(JSON.stringify(diagnostic)).not.toContain('secret') + expect(JSON.stringify(diagnostic)).not.toContain('gateway.example.test') + }) + + it('resumes the same provider id and refuses an init proof for another session', async () => { + const resumedClaude = fakeClaude() + const resumed = adapterFor(resumedClaude, { + resumed: true, + resumeLeafUuid: 'leaf-before' + }) + const acquisition = await resumed.acquire({ + identity: identityFor(), + fence: 9, + spawnToken: 'spawn-9' + }) + expect(acquisition.link.origin).toBe('resumed') + expect(acquisition.link.handle).toEqual({ + provider: 'claude', + sessionId: PROVIDER_SESSION_ID, + leafUuid: 'leaf-before' + }) + + const wrongClaude = fakeClaude({ initSessionId: 'different-session' }) + const wrong = adapterFor(wrongClaude) + await expect( + wrong.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + ).rejects.toThrow(/expected/) + expect(wrongClaude.connections[0].closeCount).toBe(1) + }) + + it('surfaces a CLI startup failure instead of waiting for the init deadline', async () => { + const claude = fakeClaude({ exitBeforeInit: 'Claude login required' }) + const adapter = adapterFor(claude) + + await expect( + adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + ).rejects.toThrow('Claude login required') + expect(claude.connections[0].closeCount).toBe(1) + }) + + it('closes a silent unauthenticated startup with actionable account guidance', async () => { + const claude = fakeClaude({ initProof: 'none' }) + const adapter = adapterFor(claude, {}, [], [], 20) + + const error = await adapter + .acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + .catch((cause: unknown) => cause) + + expect(error).toBeInstanceOf(AgentSessionAcquisitionRefusal) + expect(error).toMatchObject({ + message: expect.stringMatching(/selected Claude account is signed in.*CLAUDE_CONFIG_DIR/s) + }) + expect(claude.connections[0].calls[0]).toEqual({ subtype: 'initialize' }) + expect(claude.connections[0].closeCount).toBe(1) + }) + + it('refuses an unauthenticated initialize response even when SessionStart runs', async () => { + const claude = fakeClaude({ + initProof: 'session-start', + initAccount: { apiProvider: 'firstParty', tokenSource: 'none' } + }) + const adapter = adapterFor(claude) + + await expect( + adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + ).rejects.toThrow(/not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s) + expect(claude.connections[0].closeCount).toBe(1) + }) +}) + +describe('ClaudeStructuredSessionAdapter turns and controls', () => { + it('accepts a dispatch only after Claude replays its provider uuid', async () => { + const claude = fakeClaude({ replayUuid: 'user-provider-uuid' }) + const adapter = await acquired(claude) + + const result = await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + + expect(result).toEqual({ + state: 'accepted', + providerIdentity: { + provider: 'claude', + sessionId: PROVIDER_SESSION_ID, + uuid: 'user-provider-uuid' + } + }) + expect(claude.connections[0].sent[0]).toMatchObject({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'ship it' }] }, + session_id: PROVIDER_SESSION_ID + }) + }) + + it('leaves delivery unconfirmed when no replay uuid arrives', async () => { + const adapter = await acquired(fakeClaude({ replayUuid: null })) + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toMatchObject({ state: 'unknown' }) + }) + + it('requires an acknowledged interrupt and supports controlled options', async () => { + const claude = fakeClaude() + const adapter = await acquired(claude) + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + ).resolves.toEqual({ cancelled: true }) + await expect( + adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'sonnet', fence: 7 }) + ).resolves.toEqual({ model: 'sonnet' }) + expect(claude.connections[0].calls.slice(-2)).toEqual([ + { subtype: 'interrupt', params: {} }, + { subtype: 'set_model', params: { model: 'sonnet' } } + ]) + + claude.routes.interrupt = () => { + throw new ClaudeControlRequestError('interrupt', 'not running') + } + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-2', fence: 7 }) + ).resolves.toEqual({ cancelled: false }) + + claude.routes.interrupt = () => { + throw new Error('claude interrupt request timed out') + } + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-3', fence: 7 }) + ).rejects.toThrow('timed out') + }) + + it('does not let a delayed cancellation for an earlier turn interrupt the later turn', async () => { + const claude = fakeClaude({ replayUuids: ['turn-T', 'turn-U'] }) + const adapter = await acquired(claude) + + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-T', + body: USER_MESSAGE, + fence: 7 + }) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-U', + body: USER_MESSAGE, + fence: 7 + }) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-T', fence: 7 }) + ).resolves.toEqual({ cancelled: false }) + expect(claude.connections[0].calls.filter((call) => call.subtype === 'interrupt')).toHaveLength( + 0 + ) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-U', fence: 6 }) + ).resolves.toEqual({ cancelled: false }) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-U', fence: 7 }) + ).resolves.toEqual({ cancelled: true }) + expect(claude.connections[0].calls.filter((call) => call.subtype === 'interrupt')).toHaveLength( + 1 + ) + }) + + it('does not cancel an acknowledged turn after a later dispatch returns unknown', async () => { + const claude = fakeClaude({ replayUuids: ['turn-T', null] }) + const adapter = await acquired(claude) + + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-T', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toMatchObject({ + state: 'accepted', + providerIdentity: { uuid: 'turn-T' } + }) + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-U', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toMatchObject({ state: 'unknown' }) + expect(claude.connections[0].sent).toHaveLength(2) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-T', fence: 7 }) + ).resolves.toEqual({ cancelled: false }) + expect(claude.connections[0].calls.filter((call) => call.subtype === 'interrupt')).toHaveLength( + 0 + ) + }) + + it('classifies provider-declined options without treating timeouts as settled', async () => { + const claude = fakeClaude({ + routes: { + set_model: () => { + throw new ClaudeControlRequestError('set_model', 'model unavailable') + } + } + }) + const adapter = await acquired(claude) + + await expect( + adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'fable', fence: 7 }) + ).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' }) + claude.routes.set_model = () => { + throw new Error('claude set_model request timed out') + } + await expect( + adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'opus', fence: 7 }) + ).rejects.toThrow('timed out') + }) + + it('hydrates live model choices and maps the resolved current model to its CLI id', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { + list_models: () => [ + { value: 'default', resolvedModel: 'claude-opus-5', displayName: 'Default' }, + { + value: 'opus', + resolvedModel: 'claude-opus-5', + displayName: 'Opus', + supportsEffort: true, + supportedEffortLevels: ['low', 'high'] + }, + { + value: 'sonnet', + resolvedModel: 'claude-sonnet-5', + displayName: 'Sonnet' + } + ] + } + }) + const adapter = await acquired(claude) + + await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + models: [ + { + id: 'opus', + label: 'Opus', + isDefault: true, + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + }, + { id: 'sonnet', label: 'Sonnet', isDefault: false, efforts: [] } + ], + current: { model: 'sonnet', effort: 'high', confirmed: ['model', 'effort'] } + }) + }) + + it('keeps the shared Claude seed when live model discovery is unavailable', async () => { + const claude = fakeClaude({ + initModel: 'custom-model', + routes: { + list_models: () => { + throw new Error('unsupported') + } + } + }) + const adapter = await acquired(claude) + const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + + expect(result.models.map((model) => model.id)).toEqual([ + 'fable', + 'opus', + 'sonnet', + 'haiku', + 'custom-model' + ]) + expect(result.current).toEqual({ + model: 'custom-model', + effort: 'high', + confirmed: ['model', 'effort'] + }) + }) +}) + +describe('ClaudeStructuredSessionAdapter acquisition cleanup', () => { + /** A start that fails after the child self-exited, with its close verdict scripted. */ + function failedStart( + unprovenCloseVerdict: ClaudeStreamJsonConnection['exitVerdict'] + ): Promise { + const claude = fakeClaude({ + exitBeforeInit: 'claude stream-json exited (code 1): not logged in', + unprovenCloseVerdict + }) + return adapterFor(claude) + .acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + .catch((error: unknown) => error) + } + + it('releases on a first-hand root exit while still carrying the CLI diagnostic', async () => { + // The root's pid and start time are the lease's identity, and they are + // provably dead: latching the session would strand a signed-out user. + const error = await failedStart({ root: 'exited', tree: 'unverifiable' }) + + expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) + expect((error as Error).message).toBe('claude stream-json exited (code 1): not logged in') + }) + + it('never releases while a descendant was observed alive', async () => { + const error = await failedStart({ root: 'exited', tree: 'live' }) + + expect(error).toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) + expect(error).not.toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) + }) + + it('never releases for a root Orca never saw leave', async () => { + const error = await failedStart({ root: 'live', tree: 'unverifiable' }) + + expect(error).toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) + expect(error).not.toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) + }) + + /** A published session whose CLI then exits first-hand, with the verdict its ladder holds. */ + async function exitedAfterPublish( + exitVerdict: ClaudeStreamJsonConnection['exitVerdict'] + ): Promise<{ adapter: ClaudeStructuredSessionAdapter; connection: FakeConnection }> { + const claude = fakeClaude({ unprovenCloseVerdict: exitVerdict }) + const adapter = await acquired(claude) + const connection = claude.connections[0] + connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) + return { adapter, connection } + } + + it('classifies cleanup after a first-hand exit removed the session as a root exit, never as proven', async () => { + // The host may still be committing or proving the lease when the child dies; + // its cleanup must find the exit the ladder observed, not an absence. + const { adapter, connection } = await exitedAfterPublish({ + root: 'exited', + tree: 'unverifiable' + }) + const error = await adapter.releaseAcquisition({ sessionId: 'session-1' }).catch((e) => e) + + expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) + expect((error as Error).message).toBe('claude stream-json exited (code 1): crashed') + expect(connection.closeCount).toBe(2) + }) + + it('never releases after an exit that left a descendant observed alive', async () => { + const { adapter } = await exitedAfterPublish({ root: 'exited', tree: 'live' }) + const error = await adapter.releaseAcquisition({ sessionId: 'session-1' }).catch((e) => e) + + expect(error).toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) + expect(error).not.toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) + }) + + it('forgets a retained exit once the session is acquired again', async () => { + const options: Parameters[0] = {} + const claude = fakeClaude(options) + const adapter = await acquired(claude) + const first = claude.connections[0] + first.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) + first.exitVerdict = { root: 'exited', tree: 'unverifiable' } + first.close = async () => false + options.exitBeforeInit = 'claude stream-json exited (code 1): not logged in' + + await expect( + adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) + ).rejects.toThrow('not logged in') + // The second start's own proven close is the answer; the first exit is stale. + await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true) + expect(first.closeCount).toBe(1) + }) + + it('reports unproven published-session cleanup so callers can retry safely', async () => { + const claude = fakeClaude() + const adapter = await acquired(claude) + const connection = claude.connections[0] + connection.close = vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) as unknown as FakeConnection['close'] + + await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(false) + expect(await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).toMatchObject({ + current: { model: 'claude-sonnet-5' } + }) + await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true) + expect(() => adapter.readOptions({ sessionId: 'session-1', fence: 7 })).toThrow( + 'no live claude stream-json session' + ) + }) + + it('does not report a second release as successful while retained exit evidence is unproven', async () => { + const claude = fakeClaude({ unprovenCloseVerdict: { root: 'exited', tree: 'unverifiable' } }) + const adapter = await acquired(claude) + const connection = claude.connections[0] + connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) + connection.close = vi.fn().mockResolvedValue(false) as unknown as FakeConnection['close'] + + await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).rejects.toBeInstanceOf( + AgentSessionAcquisitionRootExitObservedError + ) + await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).rejects.toBeInstanceOf( + AgentSessionAcquisitionRootExitObservedError + ) + expect(connection.close).toHaveBeenCalledTimes(2) + }) + + it('keeps shutdown pending until a retained unexpected-exit proof settles', async () => { + const claude = fakeClaude() + const adapter = await acquired(claude) + const connection = claude.connections[0] + const proof = Promise.withResolvers() + connection.close = vi + .fn<() => Promise>() + .mockImplementationOnce(() => proof.promise) + .mockResolvedValueOnce(true) as unknown as FakeConnection['close'] + + connection.handlers.onExit?.(new Error('crashed')) + await tick() + let settled = false + const closing = adapter.closeAll().then(() => { + settled = true + }) + await tick() + expect(settled).toBe(false) + + proof.resolve(false) + await expect(closing).resolves.toBeUndefined() + expect(connection.close).toHaveBeenCalledTimes(2) + }) + + it('does not claim shutdown success for a retained false exit proof', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = await acquired(claude, {}, events) + const connection = claude.connections[0] + connection.close = vi + .fn<() => Promise>() + .mockResolvedValue(false) as unknown as FakeConnection['close'] + + connection.handlers.onExit?.(new Error('crashed')) + await tick() + + await expect(adapter.closeAll()).rejects.toThrow( + 'claude structured session shutdown could not prove every child stopped' + ) + expect(events.filter((event) => event.type === 'ended')).toEqual([]) + expect(connection.close).toHaveBeenCalledTimes(4) + }) +}) + +describe('ClaudeStructuredSessionAdapter prompts', () => { + it('turns can_use_tool into an addressable durable approval that settles the SDK callback', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = await acquired(claude, {}, events) + const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-1', 'tool-1', { + input: { command: 'git status' }, + suggestions: [{ type: 'addRules' }] + }) + expect(events.at(-1)).toMatchObject({ + type: 'prompt', + prompt: { kind: 'approval', toolName: 'Bash', promptKey: 'permission-1' } + }) + + adapter.bindPromptItemId('session-1', 'journal-approval', 'permission-1') + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-approval', + kind: 'approval', + optionId: 'allowForSession', + fence: 7 + }) + // The answer resolves the SDK's own callback promise; the SDK writes the wire response. + await expect(answered.promise).resolves.toEqual({ + behavior: 'allow', + updatedInput: { command: 'git status' }, + updatedPermissions: [{ type: 'addRules' }], + toolUseID: 'tool-1' + }) + }) + + it('collects every AskUserQuestion card before settling the one callback', async () => { + const claude = fakeClaude() + const adapter = await acquired(claude) + const answered = invokeCanUseTool( + claude.connections[0], + 'AskUserQuestion', + 'question-1', + 'tool-question', + { + input: { + questions: [ + { question: 'Library?', options: [{ label: 'Luxon' }] }, + { question: 'Ship now?', options: [{ label: 'Yes' }] } + ] + } + } + ) + adapter.bindPromptItemId('session-1', 'journal-q1', 'question-1', 'Library?') + adapter.bindPromptItemId('session-1', 'journal-q2', 'question-1', 'Ship now?') + + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-q1', + kind: 'question', + optionId: encodeClaudeQuestionOptionId('Library?', 'Luxon'), + fence: 7 + }) + await tick() + expect(answered.settled()).toBe(false) + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-q2', + kind: 'question', + optionId: encodeClaudeQuestionOptionId('Ship now?', 'Yes'), + fence: 7 + }) + await expect(answered.promise).resolves.toMatchObject({ + behavior: 'allow', + updatedInput: { answers: { 'Library?': 'Luxon', 'Ship now?': 'Yes' } }, + toolUseID: 'tool-question' + }) + }) + + it('leaves a prompt cancelled and unanswerable once the SDK abort signal fires', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = await acquired(claude, {}, events) + const controller = new AbortController() + const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-9', 'tool-9', { + input: { command: 'rm -rf /' }, + signal: controller.signal + }) + adapter.bindPromptItemId('session-1', 'journal-9', 'permission-9') + + controller.abort() + // A cancelled request is forgotten and settled with null — never an authorization. + await expect(answered.promise).resolves.toBeNull() + expect(events.at(-1)).toMatchObject({ type: 'prompt-cancelled', promptKey: 'permission-9' }) + // A late answer after the abort must not authorize the wrong tool. + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-9', + kind: 'approval', + optionId: 'allow', + fence: 7 + }) + ).rejects.toThrow(/no longer waiting/) + }) + + it('settles an in-flight permission callback when the session closes, leaving no dangling promise', async () => { + const claude = fakeClaude() + const adapter = await acquired(claude) + const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-close', 'tool-c', { + input: { command: 'ls' } + }) + await tick() + expect(answered.settled()).toBe(false) + + await adapter.closeSession('session-1') + + await expect(answered.promise).resolves.toBeNull() + }) +}) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts new file mode 100644 index 00000000000..f28b6e37f8f --- /dev/null +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -0,0 +1,246 @@ +import type { + AgentSessionAcquisition, + StructuredAgentSessionAcquireInput, + StructuredAgentSessionAdapter +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { answerClaudePrompt, cancelClaudeTurn } from './claude-structured-control-actions' +import { dispatchClaudeTurn } from './claude-structured-dispatch' +import { releaseClaudeAcquisition } from './claude-structured-acquisition-release' +import { acquireClaudeSession } from './claude-structured-session-acquisition' +export { CLAUDE_STRUCTURED_INIT_TIMEOUT_MS } from './claude-structured-session-acquisition' +import { supportsClaudeStructuredLocation } from './claude-structured-location-support' +import { setClaudeStructuredOption } from './claude-structured-options' +import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' +import { + ClaudeAcquisitionRegistry, + type ClaudeAcquisitionAttempt, + type ClaudeSession, + type ClaudeSessionExit, + type ClaudeStructuredSessionAdapterDeps, + type ClaudeStructuredSessionEvent +} from './claude-structured-session-state' +import { + closeAllClaudeSessions, + closeClaudeSession, + settleClaudeExitedSession +} from './claude-structured-session-close' +import { readClaudeTranscriptLeafWithReproof } from './claude-transcript-branch-proof' + +export type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' +export type { + ClaudeAuthDiagnostic, + ClaudeStructuredSessionAdapterDeps, + ClaudeStructuredSessionEvent +} from './claude-structured-session-state' + +const DISPATCH_ACK_TIMEOUT_MS = 10_000 + +export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAdapter { + private readonly sessions = new Map() + private readonly acquisitions = new ClaudeAcquisitionRegistry() + private readonly exits = new Map() + + constructor(private readonly deps: ClaudeStructuredSessionAdapterDeps) {} + + supportsLocation = supportsClaudeStructuredLocation + + acquire = (input: StructuredAgentSessionAcquireInput): Promise => + acquireClaudeSession({ + input, + deps: this.deps, + sessions: this.sessions, + acquisitions: this.acquisitions, + exits: this.exits, + callbacks: { + deliver: (attempt, sessionId, event) => this.deliver(attempt, sessionId, event), + emit: (session, events, event) => this.emit(session, events, event), + handleExit: (sessionId, attempt, error) => this.handleExit(sessionId, attempt, error), + settleExit: (sessionId, exit) => this.settleUnexpectedExit(sessionId, exit) + } + }) + + private deliver(attempt: ClaudeAcquisitionAttempt, sessionId: string, event: () => void): void { + if (!attempt.published) { + attempt.buffered.push(event) + return + } + if (this.sessions.get(sessionId)?.connection === attempt.connection) { + event() + } + } + + private handleExit(sessionId: string, attempt: ClaudeAcquisitionAttempt, error: Error): void { + const session = this.sessions.get(sessionId) + if (!session || session.connection !== attempt.connection) { + return + } + this.sessions.delete(sessionId) + // Re-enter the provider's close ladder before publishing lifecycle recovery. + // An exit callback is root evidence only; the retained tree proof must run + // before the host releases and reacquires this exact child. + const closePromise = session.connection.close().catch(() => false) + const exit: ClaudeSessionExit = { + connection: session.connection, + session, + error, + closePromise + } + this.exits.set(sessionId, exit) + void closePromise + .then((proven) => (proven ? this.settleUnexpectedExit(sessionId, exit) : undefined)) + .catch(() => undefined) + } + + /** Lifecycle recovery is published only after the child tree proof is true. */ + private settleUnexpectedExit(sessionId: string, exit: ClaudeSessionExit): Promise { + exit.settlementPromise ??= (async () => { + if (this.exits.get(sessionId) !== exit) { + settleClaudeExitedSession(exit.session) + return + } + // Persist the transcript-derived cursor before publishing the lifecycle + // event that lets the host release and reacquire this exact child. + await this.persistSessionHandle(sessionId, exit.session).catch(() => undefined) + if (this.exits.get(sessionId) !== exit) { + settleClaudeExitedSession(exit.session) + return + } + this.exits.delete(sessionId) + const ended: ClaudeStructuredSessionEvent = { + type: 'ended', + sessionId, + reason: exit.error.message, + cause: 'unexpected-exit', + fence: exit.session.fence, + acquisitionGeneration: exit.session.acquisitionGeneration + } + try { + this.emit(exit.session, exit.session.events, ended) + } finally { + settleClaudeExitedSession(exit.session) + } + })() + return exit.settlementPromise + } + + private async persistSessionHandle(sessionId: string, session: ClaudeSession): Promise { + try { + const transcriptLeaf = this.deps.readTranscriptLeaf + ? await readClaudeTranscriptLeafWithReproof({ + readTranscriptLeaf: this.deps.readTranscriptLeaf, + providerSessionId: session.providerSessionId, + previousLeafUuid: session.leafUuid, + claudeConfigDir: session.claudeConfigDir + }) + : null + if (transcriptLeaf) { + session.leafUuid = transcriptLeaf + } + } catch { + // A stale or unavailable tail must not overwrite the last observed leaf. + } + await this.deps.persistHandle?.({ + sessionId, + providerSessionId: session.providerSessionId, + leafUuid: session.leafUuid, + fence: session.fence + }) + } + + private emit( + _session: ClaudeSession | null, + _events: StructuredAgentSessionEventSink | undefined, + event: ClaudeStructuredSessionEvent + ): void { + _session?.translator?.handle(event) + this.deps.onEvent?.(event) + } + + bindPromptItemId( + sessionId: string, + journalItemId: string, + promptKey: string, + questionId?: string + ): void { + this.sessions.get(sessionId)?.prompts.bindJournalItemId(journalItemId, promptKey, questionId) + } + + dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => + dispatchClaudeTurn( + this.session(input.sessionId), + input, + this.deps.dispatchAckTimeoutMs ?? DISPATCH_ACK_TIMEOUT_MS + ) + + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (input) => { + const session = this.session(input.sessionId) + const acquisitionGeneration = session.acquisitionGeneration + return cancelClaudeTurn(session, this.deps.requestTimeoutMs, () => { + // Keep every ownership check adjacent to the provider interrupt. The + // session map check fences a replaced child; the turn check fences a + // delayed cancel after a newer turn was admitted on the same child. + return ( + this.sessions.get(input.sessionId) === session && + session.fence === input.fence && + session.acquisitionGeneration === acquisitionGeneration && + (session.activeTurnId === undefined + ? session.dispatchSequence === 0 + : session.activeTurnId === input.turnId && + session.activeTurnSequence === session.dispatchSequence) + ) + }) + } + answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) => + answerClaudePrompt(this.session(input.sessionId), input) + setOption: StructuredAgentSessionAdapter['setOption'] = (input) => + setClaudeStructuredOption(this.session(input.sessionId), input, this.deps.requestTimeoutMs) + readOptions = (input: { sessionId: string; fence: number }) => + readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) + + readOptionRestoreFailures = (sessionId: string): readonly string[] => [ + ...(this.sessions.get(sessionId)?.restoreSkippedOptions ?? []) + ] + + releaseAcquisition = (input: { sessionId: string }): Promise => + releaseClaudeAcquisition({ + sessionId: input.sessionId, + sessions: this.sessions, + acquisitions: this.acquisitions, + exits: this.exits, + onExitProven: (sessionId, exit) => this.settleUnexpectedExit(sessionId, exit), + ...(this.deps.persistHandle ? { persistHandle: this.deps.persistHandle } : {}), + ...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {}) + }) + + closeSession = (sessionId: string): Promise => { + if (this.exits.has(sessionId)) { + return this.releaseAcquisition({ sessionId }) + } + return closeClaudeSession({ + sessionId, + sessions: this.sessions, + acquisitions: this.acquisitions, + ...(this.deps.persistHandle ? { persistHandle: this.deps.persistHandle } : {}), + ...(this.deps.readTranscriptLeaf ? { readTranscriptLeaf: this.deps.readTranscriptLeaf } : {}), + ...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {}) + }) + } + + closeAll = (): Promise => + closeAllClaudeSessions({ + sessions: this.sessions, + acquisitions: this.acquisitions, + exits: this.exits, + closeSession: this.closeSession, + closeExit: (sessionId) => this.releaseAcquisition({ sessionId }) + }) + + private session(sessionId: string): ClaudeSession { + const session = this.sessions.get(sessionId) + if (!session) { + throw new Error(`no live claude stream-json session for ${sessionId}`) + } + return session + } +} diff --git a/src/main/claude/claude-structured-session-close.test.ts b/src/main/claude/claude-structured-session-close.test.ts new file mode 100644 index 00000000000..f92975e9ef4 --- /dev/null +++ b/src/main/claude/claude-structured-session-close.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { + ClaudeStructuredSessionAdapterDeps, + ClaudeStructuredSessionEvent +} from './claude-structured-session-adapter' +import { adapterFor, fakeClaude, identityFor } from './claude-structured-session-test-support' + +describe('Claude published session close lifecycle', () => { + it('ends the session even when the durable handle write rejects', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const persistenceError = new Error('store unavailable') + const persistHandle = vi + .fn>() + .mockRejectedValueOnce(persistenceError) + .mockResolvedValueOnce(undefined) + const adapter = adapterFor(claude, {}, events, [], undefined, undefined, persistHandle) + const journalSink: StructuredAgentSessionEventSink = { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {} + } + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: journalSink + }) + const session = ( + adapter as unknown as { + sessions: Map void } | null }> + } + ).sessions.get('session-1') + const disposeTranslator = vi.spyOn(session!.translator!, 'dispose') + + await expect(adapter.closeSession('session-1')).rejects.toBe(persistenceError) + // The child is provably dead; a failed cursor write may not suppress the end. + expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) + expect(events.filter((event) => event.type === 'handle')).toHaveLength(0) + expect(disposeTranslator).toHaveBeenCalledOnce() + + await expect(adapter.closeSession('session-1')).resolves.toBe(true) + expect(persistHandle).toHaveBeenCalledTimes(2) + // The retry persists the same cursor without a second lifecycle end. + expect(events.filter((event) => event.type === 'handle')).toHaveLength(1) + expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) + expect(disposeTranslator).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts new file mode 100644 index 00000000000..d37d3917796 --- /dev/null +++ b/src/main/claude/claude-structured-session-close.ts @@ -0,0 +1,256 @@ +import type { + ClaudeAcquisitionRegistry, + ClaudeSession, + ClaudeSessionExit, + ClaudeStructuredSessionEvent +} from './claude-structured-session-state' +import { cancelClaudeAcquisitionAttempt } from './claude-structured-session-state' +import { + AgentSessionAcquisitionExitUnprovenError, + AgentSessionAcquisitionRootExitObservedError, + AgentSessionPreSpawnError +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' +import { closeProcessRegistry } from '../../shared/child-process/close-process-registry' +import { readClaudeTranscriptLeafWithReproof } from './claude-transcript-branch-proof' + +export function claudeAcquisitionCleanupError( + connection: ClaudeStreamJsonConnection | null | undefined, + cause: unknown +): Error { + const verdict = connection?.exitVerdict + if (verdict?.root === 'processless') { + return new AgentSessionPreSpawnError(cause) + } + return verdict?.root === 'exited' && verdict.tree === 'unverifiable' + ? new AgentSessionAcquisitionRootExitObservedError(cause) + : new AgentSessionAcquisitionExitUnprovenError(cause) +} + +export function settleClaudeDispatchWaiters(session: ClaudeSession): void { + for (const waiter of session.dispatchWaiters.splice(0)) { + clearTimeout(waiter.timer) + waiter.resolve(null) + } +} + +export function settleClaudeExitedSession(session: ClaudeSession): void { + settleClaudeDispatchWaiters(session) + for (const prompt of session.prompts.clear()) { + prompt.settle(null) + } + session.translator?.dispose() +} + +type CloseClaudePublishedSessionInput = { + sessions: Map + sessionId: string + persistHandle?: (handle: { + sessionId: string + providerSessionId: string + leafUuid: string | null + fence: number + }) => Promise + onEvent?: (event: ClaudeStructuredSessionEvent) => void + readTranscriptLeaf?: (input: { + providerSessionId: string + previousLeafUuid: string | null + claudeConfigDir: string + }) => Promise +} + +async function finalizeClaudePublishedSession( + input: CloseClaudePublishedSessionInput, + session: ClaudeSession +): Promise { + settleClaudeDispatchWaiters(session) + // Settle every in-flight permission callback so closing leaves no dangling promise; `null` + // writes no response, and the SDK ignores any post-cleanup answer regardless. + for (const prompt of session.prompts.clear()) { + prompt.settle(null) + } + if ((await session.connection.close()) !== true) { + return false + } + try { + const transcriptLeaf = input.readTranscriptLeaf + ? await readClaudeTranscriptLeafWithReproof({ + readTranscriptLeaf: input.readTranscriptLeaf, + providerSessionId: session.providerSessionId, + previousLeafUuid: session.leafUuid, + claudeConfigDir: session.claudeConfigDir + }) + : null + if (transcriptLeaf) { + session.leafUuid = transcriptLeaf + } + } catch { + // Keep the last observed main-transcript frame when the durable tail is + // unavailable or proves a stale/divergent branch. + } + const persistence = + session.closePersistence ?? + (session.closePersistence = (async () => { + await input.persistHandle?.({ + sessionId: input.sessionId, + providerSessionId: session.providerSessionId, + leafUuid: session.leafUuid, + fence: session.fence + }) + })()) + const ended = { + type: 'ended', + sessionId: input.sessionId, + reason: 'claude session closed' + } as const + let callbackError: unknown + let callbackThrew = false + const deliver = (event: ClaudeStructuredSessionEvent): void => { + try { + input.onEvent?.(event) + } catch (error) { + callbackThrew = true + callbackError ??= error + } + } + let persistenceError: unknown + try { + await persistence + session.closeFinalized = true + input.sessions.delete(input.sessionId) + deliver({ + type: 'handle', + sessionId: input.sessionId, + providerSessionId: session.providerSessionId, + leafUuid: session.leafUuid, + fence: session.fence + }) + } catch (error) { + // Keep the closed session indexed so a retry can persist the same cursor. + // Removing it first would turn a durable-write failure into a no-op retry. + if (session.closePersistence === persistence) { + session.closePersistence = undefined + } + persistenceError = error + } + // The connection already proved the child dead, so the session has ended + // whatever the durable write did: withholding it would strand the renderer on + // a session nothing re-drives. Emitted once, so a retry only re-persists. + if (!session.closeEnded) { + session.closeEnded = true + try { + try { + session.translator?.handle(ended) + } catch (error) { + callbackThrew = true + callbackError ??= error + } + deliver(ended) + } finally { + session.translator?.dispose() + } + } + if (persistenceError) { + throw persistenceError + } + if (callbackThrew) { + throw callbackError + } + return true +} + +export async function closeClaudePublishedSession( + input: CloseClaudePublishedSessionInput +): Promise { + const session = input.sessions.get(input.sessionId) + if (!session) { + return true + } + if (session.closeFinalized) { + return true + } + if (session.closeFinalization) { + return session.closeFinalization + } + const finalization = finalizeClaudePublishedSession(input, session) + session.closeFinalization = finalization + try { + return await finalization + } finally { + if (session.closeFinalization === finalization && !session.closeFinalized) { + session.closeFinalization = undefined + } + } +} + +export function closeClaudePublishedSessionForDeps( + sessions: Map, + sessionId: string, + deps: { + persistHandle?: (handle: { + sessionId: string + providerSessionId: string + leafUuid: string | null + fence: number + }) => Promise + onEvent?: (event: ClaudeStructuredSessionEvent) => void + readTranscriptLeaf?: (input: { + providerSessionId: string + previousLeafUuid: string | null + claudeConfigDir: string + }) => Promise + } +): Promise { + return closeClaudePublishedSession({ sessions, sessionId, ...deps }) +} + +export async function closeClaudeSession(input: { + sessionId: string + sessions: Map + acquisitions: ClaudeAcquisitionRegistry + persistHandle?: (handle: { + sessionId: string + providerSessionId: string + leafUuid: string | null + fence: number + }) => Promise + onEvent?: (event: ClaudeStructuredSessionEvent) => void + readTranscriptLeaf?: (input: { + providerSessionId: string + previousLeafUuid: string | null + claudeConfigDir: string + }) => Promise +}): Promise { + const attempt = input.acquisitions.get(input.sessionId) + if (!(await cancelClaudeAcquisitionAttempt(attempt))) { + return false + } + if (attempt) { + input.acquisitions.deleteIfCurrent(input.sessionId, attempt) + } + return closeClaudePublishedSession(input) +} + +export async function closeAllClaudeSessions(input: { + sessions: Map + acquisitions: ClaudeAcquisitionRegistry + exits: Map + closeSession: (sessionId: string) => Promise + closeExit: (sessionId: string) => Promise +}): Promise { + input.acquisitions.close() + await closeProcessRegistry({ + attempts: 3, + hasEntries: () => + input.sessions.size > 0 || input.acquisitions.size > 0 || input.exits.size > 0, + entryIds: () => + new Set([ + ...input.sessions.keys(), + ...input.acquisitions.sessionIds(), + ...input.exits.keys() + ]), + closeEntry: async (sessionId) => + input.exits.has(sessionId) ? input.closeExit(sessionId) : input.closeSession(sessionId), + failureMessage: 'claude structured session shutdown could not prove every child stopped' + }) +} diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts new file mode 100644 index 00000000000..afb4fd65076 --- /dev/null +++ b/src/main/claude/claude-structured-session-options.ts @@ -0,0 +1,183 @@ +import type { + AgentSessionModelOption, + AgentSessionOptionChoice, + AgentSessionOptionsResult +} from '../../shared/agent-session-wire' +import { CLAUDE_SESSION_OPTION_CATALOG } from '../../shared/agent-session-option-catalog-claude-codex' +import type { CatalogModel } from '../../shared/agent-session-option-catalog-types' +import type { ClaudeSession } from './claude-structured-session-state' + +type ListedModel = AgentSessionModelOption & { resolvedModel: string | null } + +function record(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null +} + +function text(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value : null +} + +/** + * The session's current effort, which only `get_settings` reports: the + * `system/init` frame carries `model` but has never carried an effort of any + * kind. Null when the provider stops reporting it, so the pill goes empty + * rather than showing an effort nothing measured. + */ +export function readClaudeSettingsEffort(settings: unknown): string | null { + return text(record(record(settings)?.effective)?.effortLevel) +} + +function effortLabel(value: string): string { + return value === 'xhigh' ? 'Extra high' : `${value.charAt(0).toUpperCase()}${value.slice(1)}` +} + +function listedEfforts(row: Record): AgentSessionOptionChoice[] { + return row.supportsEffort === true && Array.isArray(row.supportedEffortLevels) + ? row.supportedEffortLevels.flatMap((value) => { + const effort = text(value) + return effort ? [{ value: effort, label: effortLabel(effort) }] : [] + }) + : [] +} + +function listedModels(value: unknown): ListedModel[] { + const response = record(value) + const rows = Array.isArray(response?.models) + ? response.models.map(record).filter((row): row is Record => row !== null) + : [] + const defaultRow = rows.find((row) => text(row.value) === 'default') + const defaultResolvedModel = text(defaultRow?.resolvedModel) + const seen = new Set() + return rows.flatMap((row) => { + const id = text(row.value) + if (!id || id === 'default' || seen.has(id)) { + return [] + } + seen.add(id) + const resolvedModel = text(row.resolvedModel) + const description = text(row.description) + return [ + { + id, + label: text(row.displayName) ?? id, + ...(description ? { description } : {}), + isDefault: resolvedModel !== null && resolvedModel === defaultResolvedModel, + efforts: listedEfforts(row), + resolvedModel + } + ] + }) +} + +function seedEfforts(model: CatalogModel): AgentSessionOptionChoice[] { + const effort = model.options.find((option) => option.id === 'effort') + return effort?.kind.type === 'select' ? effort.kind.choices : [] +} + +function seedModels(): ListedModel[] { + return CLAUDE_SESSION_OPTION_CATALOG.models.map((model) => ({ + id: model.id, + label: model.label, + ...(model.description ? { description: model.description } : {}), + isDefault: model.isDefault === true, + efforts: seedEfforts(model), + resolvedModel: null + })) +} + +function currentModelId(models: ListedModel[], reportedModel: string | undefined): string { + const matched = reportedModel + ? models.find((model) => model.id === reportedModel || model.resolvedModel === reportedModel) + : undefined + return ( + matched?.id ?? reportedModel ?? models.find((model) => model.isDefault)?.id ?? models[0]!.id + ) +} + +/** + * The model the session is running. A report the CLI made after the last write + * outranks the write: it names the model the session ran. An older one does not + * — a model set between turns has no report yet, and deferring to the previous + * turn's would flip the pill back. + * + * Sole resolver of that question: every surface that acts on "the current model" + * — the pill, the effort guard, the rejection it names — reads it here, so two + * of them cannot answer it differently and offer an effort a third then refuses. + */ +export function readClaudeCurrentModel(session: ClaudeSession): { + id: string | undefined + confirmed: boolean +} { + const confirmed = + session.reportedModelMutation === session.optionMutationSequence && + session.reportedOptions.model !== undefined + return { + id: confirmed + ? session.reportedOptions.model + : (session.options.get('model') ?? session.reportedOptions.model), + confirmed + } +} + +/** + * The effort levels the session's current model advertises, with the catalog id + * that matched so a refusal names the model the pill shows. Levels are null when + * nothing identified the model: `apply_flag_settings` accepts and stores any + * level for a model with no effort control, so the catalog is the only evidence + * of a refusal — and an absent or unlisted one is not evidence, or a live CLI + * that predates `list_models` would have every effort refused under it. + */ +export async function readClaudeModelEffortLevels( + session: ClaudeSession, + timeoutMs: number | undefined +): Promise<{ modelId: string | undefined; levels: ReadonlySet | null }> { + const modelId = readClaudeCurrentModel(session).id + if (!modelId) { + return { modelId, levels: null } + } + const catalog = await session.connection.supportedModels({ timeoutMs }).catch(() => null) + const matched = catalog + ? listedModels({ models: catalog }).find( + (model) => model.id === modelId || model.resolvedModel === modelId + ) + : undefined + return { + modelId: matched?.id ?? modelId, + levels: matched ? new Set(matched.efforts.map((choice) => choice.value)) : null + } +} + +export async function readClaudeStructuredSessionOptions( + session: ClaudeSession, + timeoutMs: number | undefined +): Promise { + const catalog = await session.connection.supportedModels({ timeoutMs }).catch(() => null) + const discovered = listedModels(catalog ? { models: catalog } : null) + const models = discovered.length > 0 ? discovered : seedModels() + const current = readClaudeCurrentModel(session) + const model = currentModelId(models, current.id) + if (!models.some((entry) => entry.id === model)) { + models.push({ id: model, label: model, isDefault: false, efforts: [], resolvedModel: null }) + } + const effort = session.options.get('effort') ?? session.reportedOptions.effort + const confirmed = [ + ...(current.confirmed ? ['model'] : []), + ...(effort && session.confirmedOptions.has('effort') ? ['effort'] : []) + ] + return { + models: models.map((entry) => ({ + id: entry.id, + label: entry.label, + ...(entry.description ? { description: entry.description } : {}), + isDefault: entry.isDefault, + efforts: entry.efforts + })), + current: { + model, + ...(effort ? { effort } : {}), + ...(confirmed.length > 0 ? { confirmed } : {}) + } + } +} diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts new file mode 100644 index 00000000000..29d1113c814 --- /dev/null +++ b/src/main/claude/claude-structured-session-publication.ts @@ -0,0 +1,68 @@ +import type { AgentSessionAcquisition } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { ClaudeInitObservation } from './claude-structured-init-proof' +import { claudeProviderHandleLink } from './claude-structured-owner-identity' +import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' +import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeSession } from './claude-structured-session-state' + +export function createClaudeSessionPublication(input: { + connection: ClaudeSession['connection'] + init: ClaudeInitObservation + claudeConfigDir: string + leafUuid: string | null + fence: number + acquisitionGeneration: string + resumed: boolean + prompts: ClaudePromptRegistry + translator: ClaudeJournalTranslator | null + events: ClaudeSession['events'] + process: AgentSessionAcquisition['process'] + linkId?: string + observedAt: number + options?: ReadonlyMap + capabilities: readonly string[] + /** Read from `get_settings`; `system/init` never reports an effort. */ + effort: string | null +}): { acquisition: AgentSessionAcquisition; session: ClaudeSession } { + const model = input.init.model + const effort = input.effort + return { + acquisition: { + process: input.process, + link: claudeProviderHandleLink({ + sessionId: input.init.providerSessionId, + leafUuid: input.leafUuid, + resumed: input.resumed, + fence: input.fence, + ...(input.linkId ? { linkId: input.linkId } : {}), + observedAt: input.observedAt + }), + acquisitionGeneration: input.acquisitionGeneration + }, + session: { + connection: input.connection, + providerSessionId: input.init.providerSessionId, + claudeConfigDir: input.claudeConfigDir, + leafUuid: input.leafUuid, + fence: input.fence, + acquisitionGeneration: input.acquisitionGeneration, + prompts: input.prompts, + dispatchWaiters: [], + retiredDispatchWaiters: [], + replayContentFallbackBlocked: false, + dispatchSequence: 0, + optionMutationSequence: 0, + options: new Map(input.options), + capabilities: input.capabilities, + reportedOptions: { + ...(model ? { model } : {}), + ...(effort ? { effort } : {}) + }, + reportedModelMutation: 0, + confirmedOptions: new Set(effort ? ['effort'] : []), + restoreSkippedOptions: new Set(), + translator: input.translator, + events: input.events + } + } +} diff --git a/src/main/claude/claude-structured-session-recovery.test.ts b/src/main/claude/claude-structured-session-recovery.test.ts new file mode 100644 index 00000000000..5bfe56cf156 --- /dev/null +++ b/src/main/claude/claude-structured-session-recovery.test.ts @@ -0,0 +1,619 @@ +import { describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + ClaudeStructuredSessionAdapter, + type ClaudeStructuredSessionAdapterDeps, + type ClaudeStructuredSessionEvent +} from './claude-structured-session-adapter' +import { ClaudeTranscriptPreviousCursorMissingError } from './claude-transcript-branch-proof' +import { + adapterFor, + fakeClaude, + identityFor, + invokeCanUseTool, + PROVIDER_SESSION_ID, + tick +} from './claude-structured-session-test-support' + +describe('ClaudeStructuredSessionAdapter transcript-derived recovery', () => { + it('shares concurrent close finalization and emits lifecycle once', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const persistence = Promise.withResolvers() + const persistHandle = vi.fn(() => persistence.promise) + const adapter = adapterFor(claude, {}, events, [], undefined, undefined, persistHandle) + const journalSink: StructuredAgentSessionEventSink = { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {} + } + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: journalSink + }) + const session = ( + adapter as unknown as { + sessions: Map void } | null }> + } + ).sessions.get('session-1') + const disposeTranslator = vi.spyOn(session!.translator!, 'dispose') + + const first = adapter.closeSession('session-1') + const second = adapter.closeSession('session-1') + await tick() + expect(persistHandle).toHaveBeenCalledOnce() + expect(claude.connections[0].closeCount).toBe(1) + + persistence.resolve() + await expect(Promise.all([first, second])).resolves.toEqual([true, true]) + expect(events.filter((event) => event.type === 'handle')).toHaveLength(1) + expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) + expect(disposeTranslator).toHaveBeenCalledOnce() + }) + + it('still emits ended and disposes state when handle delivery throws', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const callbackError = new Error('handle delivery failed') + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo', + claudeConfigDir: '/accounts/claude', + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumed: false + }), + onEvent: (event) => { + events.push(event) + if (event.type === 'handle') { + throw callbackError + } + }, + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + persistHandle: vi.fn(async () => undefined) + }) + const journalSink: StructuredAgentSessionEventSink = { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {} + } + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: journalSink + }) + const session = ( + adapter as unknown as { + sessions: Map void } | null }> + } + ).sessions.get('session-1') + const disposeTranslator = vi.spyOn(session!.translator!, 'dispose') + + await expect(adapter.closeSession('session-1')).rejects.toBe(callbackError) + expect(events.filter((event) => event.type === 'handle')).toHaveLength(1) + expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) + expect(disposeTranslator).toHaveBeenCalledOnce() + }) + + it('retains a closed session until its durable cursor persistence succeeds', async () => { + const claude = fakeClaude() + const persistenceError = new Error('store unavailable') + const persistHandle = vi + .fn>() + .mockRejectedValueOnce(persistenceError) + .mockResolvedValueOnce(undefined) + const adapter = adapterFor(claude, {}, [], [], undefined, undefined, persistHandle) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + + await expect(adapter.closeSession('session-1')).rejects.toBe(persistenceError) + expect(persistHandle).toHaveBeenCalledTimes(1) + await expect(adapter.closeSession('session-1')).resolves.toBe(true) + expect(persistHandle).toHaveBeenCalledTimes(2) + }) + + it('persists only the last transcript-entry uuid before graceful close', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const persistedHandles: unknown[] = [] + const adapter = adapterFor(claude, {}, events, persistedHandles) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + claude.connections[0].handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION_ID, + uuid: 'assistant-leaf' + }) + claude.connections[0].handlers.onMessage?.({ + type: 'result', + session_id: PROVIDER_SESSION_ID, + uuid: 'result-frame-uuid' + }) + claude.connections[0].handlers.onMessage?.({ + type: 'stream_event', + session_id: PROVIDER_SESSION_ID, + uuid: 'stream-event-frame-uuid' + }) + + await adapter.closeSession('session-1') + + expect(persistedHandles).toEqual([ + { + sessionId: 'session-1', + providerSessionId: PROVIDER_SESSION_ID, + leafUuid: 'assistant-leaf', + fence: 7 + } + ]) + expect(events.at(-2)).toEqual({ + type: 'handle', + sessionId: 'session-1', + providerSessionId: PROVIDER_SESSION_ID, + leafUuid: 'assistant-leaf', + fence: 7 + }) + expect(claude.connections[0].closeCount).toBe(1) + }) + + it('prefers a validated durable transcript leaf at graceful close', async () => { + const claude = fakeClaude() + const persistedHandles: unknown[] = [] + const readTranscriptLeaf = vi.fn().mockResolvedValue('durable-tail') + const adapter = adapterFor(claude, {}, [], persistedHandles, undefined, readTranscriptLeaf) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + claude.connections[0].handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION_ID, + uuid: 'observed-tail' + }) + + await adapter.closeSession('session-1') + + expect(readTranscriptLeaf).toHaveBeenCalledWith({ + providerSessionId: PROVIDER_SESSION_ID, + previousLeafUuid: 'observed-tail', + claudeConfigDir: '/accounts/claude' + }) + expect(persistedHandles.at(-1)).toMatchObject({ leafUuid: 'durable-tail' }) + }) + + it('passes the pinned Claude account home to transcript validation', async () => { + const claude = fakeClaude() + const persistedHandles: unknown[] = [] + const readTranscriptLeaf = vi.fn().mockResolvedValue('durable-tail') + const adapter = adapterFor( + claude, + { claudeConfigDir: '/accounts/selected' }, + [], + persistedHandles, + undefined, + readTranscriptLeaf + ) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + claude.connections[0].handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION_ID, + uuid: 'observed-tail' + }) + + await adapter.closeSession('session-1') + + expect(readTranscriptLeaf).toHaveBeenCalledWith({ + providerSessionId: PROVIDER_SESSION_ID, + previousLeafUuid: 'observed-tail', + claudeConfigDir: '/accounts/selected' + }) + }) + + it('re-proves from the transcript root when the observed cursor is missing', async () => { + const claude = fakeClaude() + const persistedHandles: unknown[] = [] + const readTranscriptLeaf = vi + .fn() + .mockRejectedValueOnce(new ClaudeTranscriptPreviousCursorMissingError()) + .mockResolvedValueOnce('reproved-main-leaf') + const adapter = adapterFor(claude, {}, [], persistedHandles, undefined, readTranscriptLeaf) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + claude.connections[0].handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION_ID, + uuid: 'observed-tail' + }) + + await adapter.closeSession('session-1') + + expect(readTranscriptLeaf).toHaveBeenNthCalledWith(1, { + providerSessionId: PROVIDER_SESSION_ID, + previousLeafUuid: 'observed-tail', + claudeConfigDir: '/accounts/claude' + }) + expect(readTranscriptLeaf).toHaveBeenNthCalledWith(2, { + providerSessionId: PROVIDER_SESSION_ID, + previousLeafUuid: null, + claudeConfigDir: '/accounts/claude' + }) + expect(persistedHandles.at(-1)).toMatchObject({ leafUuid: 'reproved-main-leaf' }) + }) + + it('keeps the observed leaf when transcript validation proves a sibling branch', async () => { + const claude = fakeClaude() + const persistedHandles: unknown[] = [] + const readTranscriptLeaf = vi + .fn() + .mockRejectedValue(new Error('latest marker is on a sibling branch')) + const adapter = adapterFor(claude, {}, [], persistedHandles, undefined, readTranscriptLeaf) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + claude.connections[0].handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION_ID, + uuid: 'observed-tail' + }) + + await adapter.closeSession('session-1') + + expect(readTranscriptLeaf).toHaveBeenCalledTimes(1) + expect(persistedHandles.at(-1)).toMatchObject({ leafUuid: 'observed-tail' }) + }) + + it('persists the last transcript leaf before an unexpected first-hand exit', async () => { + const claude = fakeClaude() + const persistedHandles: unknown[] = [] + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events, persistedHandles) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + claude.connections[0].handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION_ID, + uuid: 'crash-leaf' + }) + + claude.connections[0].handlers.onExit?.( + new Error('claude stream-json exited (code 1): crashed unexpectedly') + ) + await tick() + + expect(persistedHandles).toContainEqual({ + sessionId: 'session-1', + providerSessionId: PROVIDER_SESSION_ID, + leafUuid: 'crash-leaf', + fence: 7 + }) + expect(events.at(-1)).toMatchObject({ + type: 'ended', + cause: 'unexpected-exit', + fence: 7, + acquisitionGeneration: expect.any(String) + }) + }) + + it('derives the crash cursor from the validated transcript tail', async () => { + const claude = fakeClaude() + const persistedHandles: unknown[] = [] + const adapter = adapterFor( + claude, + {}, + [], + persistedHandles, + undefined, + vi.fn().mockResolvedValue('durable-crash-leaf') + ) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + claude.connections[0].handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION_ID, + uuid: 'stale-observed-tail' + }) + claude.connections[0].handlers.onExit?.( + new Error('claude stream-json exited (signal SIGKILL): crashed') + ) + await tick() + + expect(persistedHandles.at(-1)).toMatchObject({ leafUuid: 'durable-crash-leaf' }) + }) + + it('re-proves a first-hand crash cursor from the transcript root after stale validation', async () => { + const claude = fakeClaude() + const persistedHandles: unknown[] = [] + const readTranscriptLeaf = vi + .fn() + .mockRejectedValueOnce(new ClaudeTranscriptPreviousCursorMissingError()) + .mockResolvedValueOnce('reproved-crash-leaf') + const adapter = adapterFor(claude, {}, [], persistedHandles, undefined, readTranscriptLeaf) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + claude.connections[0].handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION_ID, + uuid: 'stale-observed-tail' + }) + claude.connections[0].handlers.onExit?.(new Error('crashed')) + await tick() + + expect(readTranscriptLeaf).toHaveBeenNthCalledWith(1, { + providerSessionId: PROVIDER_SESSION_ID, + previousLeafUuid: 'stale-observed-tail', + claudeConfigDir: '/accounts/claude' + }) + expect(readTranscriptLeaf).toHaveBeenNthCalledWith(2, { + providerSessionId: PROVIDER_SESSION_ID, + previousLeafUuid: null, + claudeConfigDir: '/accounts/claude' + }) + expect(persistedHandles.at(-1)).toMatchObject({ leafUuid: 'reproved-crash-leaf' }) + }) + + it('keeps the observed crash leaf when transcript validation proves a sibling branch', async () => { + const claude = fakeClaude() + const persistedHandles: unknown[] = [] + const readTranscriptLeaf = vi + .fn() + .mockRejectedValue(new Error('latest marker is on a sibling branch')) + const adapter = adapterFor(claude, {}, [], persistedHandles, undefined, readTranscriptLeaf) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + claude.connections[0].handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION_ID, + uuid: 'observed-crash-tail' + }) + claude.connections[0].handlers.onExit?.(new Error('crashed')) + await tick() + + expect(readTranscriptLeaf).toHaveBeenCalledTimes(1) + expect(persistedHandles.at(-1)).toMatchObject({ leafUuid: 'observed-crash-tail' }) + }) + + it('publishes lifecycle recovery even when crash-cursor persistence fails', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor( + claude, + {}, + events, + [], + undefined, + undefined, + vi.fn().mockRejectedValue(new Error('store unavailable')) + ) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + + claude.connections[0].handlers.onExit?.(new Error('crashed')) + await tick() + + expect(events.at(-1)).toMatchObject({ type: 'ended', cause: 'unexpected-exit' }) + }) + + it('runs the child close proof before publishing unexpected-exit recovery', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + const close = vi.spyOn(claude.connections[0], 'close').mockResolvedValue(true) + + claude.connections[0].handlers.onExit?.(new Error('crashed')) + await tick() + + expect(close).toHaveBeenCalledOnce() + expect(events.at(-1)).toMatchObject({ type: 'ended', cause: 'unexpected-exit' }) + }) + + it('does not publish recovery while an unexpected-exit close proof is false', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const persistedHandles: unknown[] = [] + const adapter = adapterFor(claude, {}, events, persistedHandles) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + claude.connections[0].close = vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) as unknown as (typeof claude.connections)[0]['close'] + + claude.connections[0].handlers.onExit?.(new Error('crashed')) + await tick() + + expect(events.filter((event) => event.type === 'ended')).toEqual([]) + expect(persistedHandles).toEqual([]) + }) + + it('retains pending prompts while an unexpected-exit proof is unproven', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-1', 'tool-1') + claude.connections[0].close = vi + .fn<() => Promise>() + .mockResolvedValue(false) as unknown as (typeof claude.connections)[0]['close'] + + claude.connections[0].handlers.onExit?.(new Error('crashed')) + await tick() + + expect(answered.settled()).toBe(false) + expect(events.filter((event) => event.type === 'ended')).toEqual([]) + }) + + it('publishes unexpected recovery exactly once after a retained proof retries successfully', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + claude.connections[0].close = vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) as unknown as (typeof claude.connections)[0]['close'] + + claude.connections[0].handlers.onExit?.(new Error('crashed')) + await tick() + await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true) + await tick() + + expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) + }) + + it('launches the first replacement from the settled retained transcript cursor', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const persistedHandles: unknown[] = [] + const journalSink: StructuredAgentSessionEventSink = { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {} + } + const readTranscriptLeaf = vi.fn().mockResolvedValue('durable-retained-leaf') + let durableLeafUuid: string | null = null + const resolveLaunch = vi.fn(async ({ identity }) => { + if ( + identity.providerHandle.kind !== 'claude' || + identity.providerHandle.sessionId !== PROVIDER_SESSION_ID || + identity.providerHandle.leafUuid !== durableLeafUuid + ) { + throw new Error('claude durable resume identity changed before spawn') + } + if (durableLeafUuid === null) { + return { + pathToClaudeCodeExecutable: 'claude', + options: { sessionId: PROVIDER_SESSION_ID }, + cwd: '/work/repo', + claudeConfigDir: '/accounts/claude', + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumed: false + } + } + return { + pathToClaudeCodeExecutable: 'claude', + options: { resume: PROVIDER_SESSION_ID, resumeSessionAt: durableLeafUuid }, + cwd: '/work/repo', + claudeConfigDir: '/accounts/claude', + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: durableLeafUuid, + resumed: true + } + }) + const persistHandle = vi.fn>( + async (handle) => { + durableLeafUuid = handle.leafUuid + persistedHandles.push(handle) + } + ) + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch, + openConnection: claude.openConnection, + onEvent: (event) => events.push(event), + readProcessStartTime: async () => 1_700_000_000_000, + now: () => 1_700_000_000_500, + readTranscriptLeaf, + persistHandle + }) + const firstAcquisition = await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: journalSink + }) + const first = claude.connections[0] + const oldPrompt = invokeCanUseTool(first, 'Bash', 'permission-retained', 'tool-retained') + const oldSession = ( + adapter as unknown as { + sessions: Map< + string, + { + translator: { dispose: () => void } | null + prompts: { + find: (itemId: string) => { prompt: { settle: (value: unknown) => void } } | null + } + } + > + } + ).sessions.get('session-1') + expect(oldSession?.translator).not.toBeNull() + const disposeTranslator = vi.spyOn(oldSession!.translator!, 'dispose') + const pendingPrompt = oldSession?.prompts.find('permission-retained') + expect(pendingPrompt).not.toBeNull() + const settlePrompt = vi.spyOn(pendingPrompt!.prompt, 'settle') + first.handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION_ID, + uuid: 'observed-retained-leaf' + }) + first.close = vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) as unknown as (typeof first)['close'] + first.handlers.onExit?.(new Error('crashed before replacement')) + await tick() + + expect(oldPrompt.settled()).toBe(false) + expect(events.filter((event) => event.type === 'ended')).toEqual([]) + + const replacement = await adapter.acquire({ + identity: { + ...identityFor(), + providerHandle: { + kind: 'claude', + sessionId: PROVIDER_SESSION_ID, + leafUuid: 'observed-retained-leaf' + } + }, + fence: 8, + spawnToken: 'spawn-10', + events: journalSink + }) + + expect(disposeTranslator).toHaveBeenCalledOnce() + expect(settlePrompt).toHaveBeenCalledOnce() + expect(settlePrompt).toHaveBeenCalledWith(null) + expect(persistHandle).toHaveBeenCalledOnce() + expect(persistedHandles).toEqual([ + { + sessionId: 'session-1', + providerSessionId: PROVIDER_SESSION_ID, + leafUuid: 'durable-retained-leaf', + fence: 7 + } + ]) + expect(readTranscriptLeaf).toHaveBeenCalledOnce() + expect(readTranscriptLeaf).toHaveBeenCalledWith({ + providerSessionId: PROVIDER_SESSION_ID, + previousLeafUuid: 'observed-retained-leaf', + claudeConfigDir: '/accounts/claude' + }) + expect(resolveLaunch).toHaveBeenNthCalledWith(2, { + identity: { + ...identityFor(), + providerHandle: { + kind: 'claude', + sessionId: PROVIDER_SESSION_ID, + leafUuid: 'durable-retained-leaf' + } + } + }) + expect(oldPrompt.settled()).toBe(true) + expect(events.filter((event) => event.type === 'ended')).toEqual([ + { + type: 'ended', + sessionId: 'session-1', + reason: 'crashed before replacement', + cause: 'unexpected-exit', + fence: 7, + acquisitionGeneration: firstAcquisition.acquisitionGeneration + } + ]) + expect(replacement.link).toMatchObject({ + handle: { + provider: 'claude', + sessionId: PROVIDER_SESSION_ID, + leafUuid: 'durable-retained-leaf' + }, + origin: 'resumed', + mintedAtFence: 8 + }) + expect(claude.connections[1]?.launch.options).toMatchObject({ + resume: PROVIDER_SESSION_ID, + resumeSessionAt: 'durable-retained-leaf' + }) + expect(claude.connections).toHaveLength(2) + }) +}) diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts new file mode 100644 index 00000000000..346ff686f76 --- /dev/null +++ b/src/main/claude/claude-structured-session-state.ts @@ -0,0 +1,276 @@ +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { + ClaudeStreamJsonConnection, + openClaudeStreamJsonConnection +} from './claude-stream-json-connection' +import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' +import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudePendingPrompt, ClaudePromptRegistry } from './claude-structured-prompt-replies' +import { cancelProcessAcquisition } from '../../shared/child-process/cancel-process-acquisition' +import { randomUUID } from 'node:crypto' + +export type ClaudeAuthDiagnostic = { + apiKeySourceConfigured: boolean + baseUrlConfigured: boolean + authTokenConfigured: boolean + apiKeyConfigured: boolean + settingSources: readonly string[] +} + +export type ClaudeStructuredSessionEvent = + | { + type: 'message' + sessionId: string + message: Record + /** Present only when this replay acknowledged Orca's in-flight dispatch. */ + startsTurn?: true + } + | { type: 'provider-frame'; sessionId: string; kind: string; payload: unknown } + | { type: 'prompt'; sessionId: string; prompt: ClaudePendingPrompt } + | { type: 'prompt-cancelled'; sessionId: string; promptKey: string } + | { type: 'options'; sessionId: string; models: unknown[] } + | { + type: 'handle' + sessionId: string + providerSessionId: string + leafUuid: string | null + fence: number + } + | { type: 'auth-diagnostic'; sessionId: string; diagnostic: ClaudeAuthDiagnostic } + | { + type: 'ended' + sessionId: string + reason: string + /** Present for first-hand child exits so the host can fence recovery. */ + cause?: 'unexpected-exit' | 'requested-close' + fence?: number + acquisitionGeneration?: string + settlementRetryRequired?: boolean + } + +export type ClaudeStructuredSessionAdapterDeps = { + resolveLaunch: (input: { + identity: AgentSessionJournalIdentity + }) => Promise + onEvent?: (event: ClaudeStructuredSessionEvent) => void + openConnection?: typeof openClaudeStreamJsonConnection + readProcessStartTime?: (pid: number) => Promise + mintLinkId?: () => string + mintAcquisitionGeneration?: () => string + now?: () => number + requestTimeoutMs?: number + initTimeoutMs?: number + dispatchAckTimeoutMs?: number + persistHandle?: (input: { + sessionId: string + providerSessionId: string + leafUuid: string | null + fence: number + }) => Promise + /** Read the durable transcript branch after a child has flushed its final rows. */ + readTranscriptLeaf?: (input: { + providerSessionId: string + previousLeafUuid: string | null + /** Account-scoped Claude config root that owns this provider session. */ + claudeConfigDir: string + }) => Promise +} + +export type ClaudeDispatchWaiter = { + resolve: (uuid: string | null) => void + timer: ReturnType + acceptsResult: boolean + /** Client uuid echoed by Claude so a replay is tied to its own dispatch. */ + sentUuid: string + /** Sequence used to fence a late identity from a newer dispatch. */ + dispatchSequence: number + /** Set when the provider replay settled this waiter before send returned. */ + settledUuid?: string + /** The waiter timed out or its write failed, but its replay may still arrive. */ + retired?: boolean + /** Bounded digest/summary for compatibility CLIs that mint UUIDs. */ + replayContentKey: string +} + +export type ClaudeSession = { + connection: ClaudeStreamJsonConnection + providerSessionId: string + /** Durable transcript files live under this account's `projects` directory. */ + claudeConfigDir: string + leafUuid: string | null + fence: number + acquisitionGeneration: string + prompts: ClaudePromptRegistry + dispatchWaiters: ClaudeDispatchWaiter[] + /** Bounded identities for dispatches whose ack was unknown when they returned. */ + retiredDispatchWaiters: ClaudeDispatchWaiter[] + /** Once a retired waiter is evicted, legacy content-only replay matching is unsafe. */ + replayContentFallbackBlocked: boolean + options: Map + reportedOptions: { model?: string; effort?: string } + /** `optionMutationSequence` when `reportedOptions.model` was last observed, so a + * write still awaiting its first turn outranks the report it will replace. */ + reportedModelMutation: number + /** Options whose recorded value the provider reported, not merely accepted. */ + confirmedOptions: Set + restoreSkippedOptions: Set + /** CLI-advertised protocol capabilities from init; gates interrupt-receipt handling. */ + capabilities: readonly string[] + /** Provider uuid of the most recently admitted turn, if one is active. */ + activeTurnId?: string + /** Monotonic fence advanced when a dispatch starts, including unresolved dispatches. */ + dispatchSequence: number + /** Dispatch sequence that admitted activeTurnId. */ + activeTurnSequence?: number + /** Fences overlapping option writes so a late completion cannot restore stale state. */ + optionMutationSequence: number + /** Shared durable-close write; a failed write clears this for a retry. */ + closePersistence?: Promise + /** Shared full close/finalization operation; a failed operation clears this for a retry. */ + closeFinalization?: Promise + /** Set only after the durable close write succeeds, before lifecycle emission. */ + closeFinalized?: boolean + /** Set once `ended` has been emitted, so a persistence retry cannot repeat it. */ + closeEnded?: boolean + translator: ClaudeJournalTranslator | null + events: StructuredAgentSessionEventSink | undefined +} + +export function mintClaudeAcquisitionGeneration(deps: ClaudeStructuredSessionAdapterDeps): string { + return deps.mintAcquisitionGeneration?.() ?? randomUUID() +} + +/** + * The first-hand exit that removed a published session. Kept until the session + * is acquired again so acquisition cleanup that arrives after the exit finds + * what the ladder observed, not an absence it would otherwise report as proven. + */ +export type ClaudeSessionExit = { + connection: ClaudeStreamJsonConnection + /** Full session identity retained until its child tree is proven gone. */ + session: ClaudeSession + error: Error + /** The exit path's first proof attempt; retries must observe this result. */ + closePromise?: Promise + /** Shared lifecycle settlement for concurrent proof retries. */ + settlementPromise?: Promise +} + +export type ClaudeAcquisitionAttempt = { + connection: ClaudeStreamJsonConnection | null + prompts: ClaudePromptRegistry + buffered: (() => void)[] + published: boolean + cancelled: boolean + exitProven: boolean + finished: Promise + finish: () => void +} + +export function createClaudeAcquisitionAttempt( + prompts: ClaudePromptRegistry +): ClaudeAcquisitionAttempt { + let finish = (): void => {} + const finished = new Promise((resolve) => { + finish = resolve + }) + return { + connection: null, + prompts, + buffered: [], + published: false, + cancelled: false, + exitProven: false, + finished, + finish + } +} + +export class ClaudeAcquisitionRegistry { + private readonly attempts = new Map() + private closing = false + + get size(): number { + return this.attempts.size + } + + start( + sessionId: string, + prompts: ClaudePromptRegistry + ): { + previous: ClaudeAcquisitionAttempt | undefined + attempt: ClaudeAcquisitionAttempt + } { + if (this.closing) { + throw new Error('claude structured session adapter is closing') + } + const previous = this.attempts.get(sessionId) + const attempt = createClaudeAcquisitionAttempt(prompts) + this.attempts.set(sessionId, attempt) + return { previous, attempt } + } + + assertCurrent(sessionId: string, attempt: ClaudeAcquisitionAttempt): void { + if (this.closing || attempt.cancelled || this.attempts.get(sessionId) !== attempt) { + throw new Error(`claude session ${sessionId} was superseded while being acquired`) + } + } + + get(sessionId: string): ClaudeAcquisitionAttempt | undefined { + return this.attempts.get(sessionId) + } + + deleteIfCurrent(sessionId: string, attempt: ClaudeAcquisitionAttempt): void { + if (this.attempts.get(sessionId) === attempt) { + this.attempts.delete(sessionId) + } + } + + restoreIfCurrent( + sessionId: string, + replacement: ClaudeAcquisitionAttempt, + previous: ClaudeAcquisitionAttempt + ): void { + if (this.attempts.get(sessionId) === replacement) { + this.attempts.set(sessionId, previous) + } + } + + sessionIds(): IterableIterator { + return this.attempts.keys() + } + + close(): void { + this.closing = true + } +} + +export async function cancelClaudeAcquisitionAttempt( + attempt: ClaudeAcquisitionAttempt | undefined +): Promise { + if (!attempt) { + return true + } + return cancelProcessAcquisition({ + cancel: () => { + attempt.cancelled = true + }, + connection: () => attempt.connection, + exitProven: () => attempt.exitProven, + finished: attempt.finished + }) +} + +/** What an acquisition hands back to the adapter that owns the session map: + * event delivery ordered against publication, and the two exit settlements. */ +export type ClaudeAcquireCallbacks = { + deliver: (attempt: ClaudeAcquisitionAttempt, sessionId: string, event: () => void) => void + emit: ( + session: ClaudeSession | null, + events: StructuredAgentSessionEventSink | undefined, + event: ClaudeStructuredSessionEvent + ) => void + handleExit: (sessionId: string, attempt: ClaudeAcquisitionAttempt, error: Error) => void + settleExit: (sessionId: string, exit: ClaudeSessionExit) => Promise +} diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts new file mode 100644 index 00000000000..6b0768b5134 --- /dev/null +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -0,0 +1,260 @@ +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import type { + ClaudeStreamJsonConnection, + ClaudeStreamJsonConnectionHandlers, + ClaudeStreamJsonLaunch, + openClaudeStreamJsonConnection +} from './claude-stream-json-connection' +import { + ClaudeStructuredSessionAdapter, + type ClaudeStructuredSessionAdapterDeps, + type ClaudeStructuredLaunch, + type ClaudeStructuredSessionEvent +} from './claude-structured-session-adapter' + +export const PROVIDER_SESSION_ID = '819cf9f8-e43c-4ad7-b50f-54aa158a726a' + +export const USER_MESSAGE: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'ship it' }] +} + +export function identityFor(sessionId = 'session-1'): AgentSessionJournalIdentity { + return { + sessionId, + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + } +} + +type Route = (params: Record | undefined) => unknown + +export type FakeConnection = Omit & { + closed: boolean + exitVerdict: ClaudeStreamJsonConnection['exitVerdict'] + launch: ClaudeStreamJsonLaunch + handlers: ClaudeStreamJsonConnectionHandlers + calls: { subtype: string; params?: Record }[] + sent: Record[] + closeCount: number +} + +export function fakeClaude( + options: { + initSessionId?: string + initUuid?: string + initModel?: string + initProof?: 'init' | 'session-start' | 'none' + initAccount?: unknown + exitBeforeInit?: string + settings?: unknown + replayUuid?: string | null + replayUuids?: (string | null)[] + capabilities?: string[] + unprovenCloseVerdict?: ClaudeStreamJsonConnection['exitVerdict'] + routes?: Record + } = {} +): { + connections: FakeConnection[] + openConnection: typeof openClaudeStreamJsonConnection + routes: Record +} { + const connections: FakeConnection[] = [] + const routes = options.routes ?? {} + let replayIndex = 0 + const routed = (subtype: string, params?: Record): unknown => { + const route = routes[subtype] + return route ? route(params) : undefined + } + const openConnection = (async (launch, handlers = {}) => { + const connection: FakeConnection = { + launch, + handlers, + calls: [], + sent: [], + closeCount: 0, + pid: 4321, + closed: false, + initializationResult: async () => { + connection.calls.push({ subtype: 'initialize' }) + if (options.exitBeforeInit) { + handlers.onExit?.(new Error(options.exitBeforeInit)) + return { models: [] } + } + if (options.initProof === 'session-start') { + handlers.onMessage?.({ + type: 'system', + subtype: 'hook_started', + hook_name: 'SessionStart:startup', + session_id: options.initSessionId ?? PROVIDER_SESSION_ID, + uuid: options.initUuid ?? 'init-uuid' + }) + } else if (options.initProof !== 'none') { + // Keys mirror the real system/init frame, which carries `model` but no + // effort of any kind: the current effort only comes back from + // get_settings. Never add a field the CLI does not send. + handlers.onMessage?.({ + type: 'system', + subtype: 'init', + session_id: options.initSessionId ?? PROVIDER_SESSION_ID, + uuid: options.initUuid ?? 'init-uuid', + model: options.initModel ?? 'claude-sonnet-5', + apiKeySource: 'none', + ...(options.capabilities ? { capabilities: options.capabilities } : {}) + }) + } + return { + models: [{ value: 'claude-sonnet', displayName: 'Sonnet' }], + ...(options.initAccount === undefined ? {} : { account: options.initAccount }) + } + }, + getSettings: async () => { + connection.calls.push({ subtype: 'get_settings' }) + // Shape measured from Claude Code 2.1.258: {applied, effective, sources}, + // and the only place the session's current effort is reported. + return ( + options.settings ?? { + applied: { model: 'claude-sonnet-5', effort: 'high', advisor: null, ultracode: false }, + effective: { model: 'claude-sonnet-5', effortLevel: 'high', env: {} }, + sources: {} + } + ) + }, + supportedModels: async () => { + connection.calls.push({ subtype: 'list_models' }) + return (routed('list_models') as unknown[] | undefined) ?? [] + }, + setModel: async (model) => { + connection.calls.push({ subtype: 'set_model', params: { model } }) + routed('set_model', { model }) + }, + setPermissionMode: async (mode) => { + connection.calls.push({ subtype: 'set_permission_mode', params: { mode } }) + routed('set_permission_mode', { mode }) + }, + applyFlagSettings: async (settings) => { + connection.calls.push({ subtype: 'apply_flag_settings', params: { settings } }) + routed('apply_flag_settings', { settings }) + }, + interrupt: async (interruptOptions) => { + connection.calls.push({ + subtype: 'interrupt', + params: interruptOptions?.cancelQueued ? { cancelQueued: true } : {} + }) + return routed('interrupt', interruptOptions) as + | Awaited> + | undefined + }, + cancelAsyncMessage: async (uuid) => { + connection.calls.push({ subtype: 'cancel_async_message', params: { uuid } }) + routed('cancel_async_message', { uuid }) + }, + send: async (message) => { + connection.sent.push(message) + if (message.type === 'user' && options.replayUuid !== null) { + const configuredReplayUuid = options.replayUuids + ? options.replayUuids[replayIndex++] + : options.replayUuid + const replayUuid = + configuredReplayUuid === undefined ? `user-uuid-${replayIndex}` : configuredReplayUuid + if (replayUuid !== null) { + handlers.onMessage?.({ + ...message, + uuid: replayUuid + }) + } + } + }, + exitVerdict: options.unprovenCloseVerdict ?? { root: 'live', tree: 'unverifiable' }, + close: async () => { + connection.closeCount += 1 + connection.closed = true + return options.unprovenCloseVerdict === undefined + } + } + connections.push(connection) + return connection + }) as typeof openClaudeStreamJsonConnection + return { connections, openConnection, routes } +} + +export function adapterFor( + claude: ReturnType, + launch: Partial = {}, + events: ClaudeStructuredSessionEvent[] = [], + persistedHandles: unknown[] = [], + initTimeoutMs?: number, + readTranscriptLeaf?: ClaudeStructuredSessionAdapterDeps['readTranscriptLeaf'], + persistHandle?: ClaudeStructuredSessionAdapterDeps['persistHandle'] +): ClaudeStructuredSessionAdapter { + return new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo', + claudeConfigDir: '/accounts/claude', + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumed: false, + ...launch + }), + onEvent: (event) => events.push(event), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => 1_700_000_000_500, + ...(initTimeoutMs === undefined ? {} : { initTimeoutMs }), + dispatchAckTimeoutMs: 10, + persistHandle: + persistHandle ?? + (async (handle) => { + persistedHandles.push(handle) + }), + ...(readTranscriptLeaf ? { readTranscriptLeaf } : {}) + }) +} + +export async function acquired( + claude: ReturnType, + launch: Partial = {}, + events: ClaudeStructuredSessionEvent[] = [] +): Promise { + const adapter = adapterFor(claude, launch, events) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + return adapter +} + +export function tick(): Promise { + return new Promise((resolve) => setImmediate(resolve)) +} + +export function invokeCanUseTool( + connection: FakeConnection, + toolName: string, + requestId: string, + toolUseID: string, + extra: { + input?: Record + suggestions?: unknown[] + signal?: AbortSignal + } = {} +): { promise: Promise; settled: () => boolean } { + const options = { + requestId, + toolUseID, + signal: extra.signal ?? new AbortController().signal, + ...(extra.suggestions ? { suggestions: extra.suggestions } : {}) + } as unknown as Parameters>[2] + let done = false + const promise = Promise.resolve( + connection.handlers.canUseTool?.(toolName, extra.input ?? {}, options) + ).finally(() => { + done = true + }) + return { promise, settled: () => done } +} diff --git a/src/main/claude/claude-transcript-branch-proof.ts b/src/main/claude/claude-transcript-branch-proof.ts index d7065caa275..605f619eb92 100644 --- a/src/main/claude/claude-transcript-branch-proof.ts +++ b/src/main/claude/claude-transcript-branch-proof.ts @@ -5,6 +5,10 @@ const MAX_CLAUDE_TRANSCRIPT_ANCESTRY = 10_000 type TranscriptNode = { parentUuid: string | null sessionId: string | null + /** First line where this UUID was observed in the append-only transcript. */ + lineIndex: number + /** UUIDs from result/init/stream frames and sidechains are never leaves. */ + disallowedLeaf: boolean } export type ClaudeTranscriptBranchProof = { @@ -27,6 +31,54 @@ export class ClaudeTranscriptTailIncompleteError extends Error { } } +/** The sampled cursor is no longer present, so a root proof may still recover safely. */ +export class ClaudeTranscriptPreviousCursorMissingError extends Error { + constructor() { + super( + 'Claude transcript branch proof failed: previous cursor is missing from the session graph' + ) + this.name = 'ClaudeTranscriptPreviousCursorMissingError' + } +} + +function proveMainLineAncestry( + nodes: Map, + startUuid: string, + providerSessionId: string +): void { + const visited = new Set() + let cursor: string | null = startUuid + for (let depth = 0; cursor !== null && depth < MAX_CLAUDE_TRANSCRIPT_ANCESTRY; depth += 1) { + if (visited.has(cursor)) { + throw transcriptError('cycle in parentUuid ancestry') + } + visited.add(cursor) + const node = nodes.get(cursor) + if (!node || node.sessionId !== providerSessionId) { + throw transcriptError(`missing ancestor ${cursor}`) + } + if (node.disallowedLeaf) { + throw transcriptError(`ancestor ${cursor} is not on the main transcript`) + } + cursor = node.parentUuid + } + if (cursor !== null) { + throw transcriptError('ancestry exceeds the bounded proof limit') + } +} + +function proveAppendOrder(nodes: Map): void { + for (const node of nodes.values()) { + if (!node.parentUuid) { + continue + } + const parent = nodes.get(node.parentUuid) + if (parent && parent.lineIndex >= node.lineIndex) { + throw transcriptError('parent row follows descendant') + } + } +} + export function proveClaudeTranscriptBranchFromJsonl(input: { contents: string providerSessionId: string @@ -34,6 +86,7 @@ export function proveClaudeTranscriptBranchFromJsonl(input: { }): ClaudeTranscriptBranchProof { const nodes = new Map() let leafUuid: string | null = null + let leafMarkerLineIndex = -1 const lines = input.contents.split('\n') for (const [index, line] of lines.entries()) { if (!line.trim()) { @@ -59,6 +112,7 @@ export function proveClaudeTranscriptBranchFromJsonl(input: { throw transcriptError('invalid last-prompt marker') } leafUuid = markerLeaf + leafMarkerLineIndex = index } const uuid = nonEmptyString(row.uuid) if (!uuid) { @@ -70,27 +124,60 @@ export function proveClaudeTranscriptBranchFromJsonl(input: { } const sessionId = nonEmptyString(row.sessionId) const existing = nodes.get(uuid) - if (existing && (existing.parentUuid !== parentUuid || existing.sessionId !== sessionId)) { + const disallowedLeaf = + row.isSidechain === true || + row.parent_tool_use_id != null || + row.type === 'result' || + row.type === 'stream_event' || + (row.type === 'system' && row.subtype === 'init') + if ( + existing && + (existing.parentUuid !== parentUuid || + existing.sessionId !== sessionId || + existing.disallowedLeaf !== disallowedLeaf) + ) { throw transcriptError(`record ${uuid} has conflicting ancestry`) } - nodes.set(uuid, { parentUuid, sessionId }) + nodes.set(uuid, { + parentUuid, + sessionId, + lineIndex: existing?.lineIndex ?? index, + disallowedLeaf + }) } if (!leafUuid) { throw transcriptError('missing last-prompt marker') } const leaf = nodes.get(leafUuid) - if (!leaf || leaf.sessionId !== input.providerSessionId) { + if (!leaf || leaf.sessionId !== input.providerSessionId || leaf.disallowedLeaf) { throw transcriptError('marker leaf is missing from the session graph') } + if (leaf.lineIndex > leafMarkerLineIndex) { + throw transcriptError('marker precedes its leaf record') + } const previousLeafUuid = input.previousLeafUuid if (!previousLeafUuid) { + proveMainLineAncestry(nodes, leafUuid, input.providerSessionId) + // A branch proof is based on an append-only snapshot. A child that appears + // before its claimed parent is not a post-snapshot descendant observation; + // accepting that graph would turn reordered/torn rows into durable ancestry. + proveAppendOrder(nodes) return { leafUuid, relation: 'initial' } } const previous = nodes.get(previousLeafUuid) - if (!previous || previous.sessionId !== input.providerSessionId) { - throw transcriptError('previous cursor is missing from the session graph') + if (!previous) { + throw new ClaudeTranscriptPreviousCursorMissingError() } + if (previous.sessionId !== input.providerSessionId || previous.disallowedLeaf) { + throw transcriptError('previous cursor is not on the main transcript') + } + // The latest marker can be equal to, or descend from, a sampled cursor. In + // either case prove the sampled cursor's own ancestry before accepting it; + // otherwise a cursor that descended through a parent-tool-use sidechain + // could be persisted and resumed as if it were on the main transcript. + proveMainLineAncestry(nodes, previousLeafUuid, input.providerSessionId) if (leafUuid === previousLeafUuid) { + proveAppendOrder(nodes) return { leafUuid, relation: 'same' } } const visited = new Set() @@ -104,8 +191,12 @@ export function proveClaudeTranscriptBranchFromJsonl(input: { if (!node || node.sessionId !== input.providerSessionId) { throw transcriptError(`missing ancestor ${cursor}`) } + if (node.disallowedLeaf) { + throw transcriptError(`ancestor ${cursor} is not on the main transcript`) + } cursor = node.parentUuid if (cursor === previousLeafUuid) { + proveAppendOrder(nodes) return { leafUuid, relation: 'descendant' } } } @@ -126,3 +217,37 @@ export async function proveClaudeTranscriptBranch(input: { previousLeafUuid: input.previousLeafUuid }) } + +/** Re-run a durable branch proof from the transcript root when a sampled cursor is stale. */ +export async function readClaudeTranscriptLeafWithReproof(input: { + readTranscriptLeaf: (input: { + providerSessionId: string + previousLeafUuid: string | null + claudeConfigDir: string + }) => Promise + claudeConfigDir: string + providerSessionId: string + previousLeafUuid: string | null +}): Promise { + try { + return await input.readTranscriptLeaf({ + providerSessionId: input.providerSessionId, + previousLeafUuid: input.previousLeafUuid, + claudeConfigDir: input.claudeConfigDir + }) + } catch (error) { + // A missing cursor can be stale after compaction and is safe to re-prove from the root. A torn + // tail is still being written; dropping the cursor would make a later sibling look admissible. + if ( + input.previousLeafUuid === null || + !(error instanceof ClaudeTranscriptPreviousCursorMissingError) + ) { + throw error + } + return input.readTranscriptLeaf({ + providerSessionId: input.providerSessionId, + previousLeafUuid: null, + claudeConfigDir: input.claudeConfigDir + }) + } +} diff --git a/src/main/claude/claude-tui-exit.test.ts b/src/main/claude/claude-tui-exit.test.ts new file mode 100644 index 00000000000..6e1140b0f4d --- /dev/null +++ b/src/main/claude/claude-tui-exit.test.ts @@ -0,0 +1,159 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + completeClaudeTuiExit, + readClaudeTranscriptEntryUuid, + readClaudeTranscriptLeafUuid +} from './claude-tui-exit' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('Claude TUI exit', () => { + it('does not sample UUIDs from subagent stdout frames with a parent tool use', () => { + expect( + readClaudeTranscriptEntryUuid({ + type: 'assistant', + uuid: 'subagent-assistant', + parent_tool_use_id: 'parent-tool' + }) + ).toBeNull() + expect( + readClaudeTranscriptEntryUuid({ + type: 'assistant', + uuid: 'main-assistant', + parent_tool_use_id: null + }) + ).toBe('main-assistant') + }) + + it('reads the authoritative last-prompt leaf from a transcript tail', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-claude-tui-exit-')) + roots.push(root) + const transcriptPath = join(root, 'session.jsonl') + await writeFile( + transcriptPath, + [ + { type: 'user', uuid: 'user-one' }, + { type: 'assistant', uuid: 'assistant-one' }, + { type: 'last-prompt', leafUuid: 'chain-head' }, + { type: 'file-history-snapshot', snapshot: {} } + ] + .map((entry) => JSON.stringify(entry)) + .join('\n') + ) + + await expect(readClaudeTranscriptLeafUuid(transcriptPath)).resolves.toBe('chain-head') + }) + + it('falls back to the last persisted message when last-prompt metadata is absent', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-claude-tui-exit-')) + roots.push(root) + const transcriptPath = join(root, 'session.jsonl') + await writeFile( + transcriptPath, + [ + { type: 'user', uuid: 'user-one' }, + { type: 'assistant', uuid: 'assistant-one' }, + { type: 'system', subtype: 'init', uuid: 'init-frame' }, + { type: 'result', uuid: 'result-frame' }, + { type: 'stream_event', uuid: 'stream-event-frame' } + ] + .map((entry) => JSON.stringify(entry)) + .join('\n') + ) + + await expect(readClaudeTranscriptLeafUuid(transcriptPath)).resolves.toBe('assistant-one') + }) + + it('ignores sidechain messages when selecting a fallback transcript leaf', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-claude-tui-sidechain-leaf-')) + const transcriptPath = join(root, 'session.jsonl') + await writeFile( + transcriptPath, + [ + { type: 'assistant', uuid: 'main-assistant' }, + { type: 'assistant', uuid: 'subagent-assistant', isSidechain: true }, + { type: 'result', uuid: 'result-frame' } + ] + .map((entry) => JSON.stringify(entry)) + .join('\n'), + 'utf8' + ) + + await expect(readClaudeTranscriptLeafUuid(transcriptPath)).resolves.toBe('main-assistant') + }) + + it('persists the resumed chain head only after the exact Claude child exits', async () => { + let resolveExit!: (exit: { + pid: number + exitCode: number | null + signal: string | null + }) => void + const exitPromise = new Promise<{ + pid: number + exitCode: number | null + signal: string | null + }>((resolve) => { + resolveExit = resolve + }) + const persistHandle = vi.fn(async () => undefined) + const completion = completeClaudeTuiExit({ + childPid: 4210, + waitForChildExit: () => exitPromise, + sessionId: 'provider-session', + transcriptPath: '/accounts/claude/session.jsonl', + fence: 7, + persistHandle, + readLeafUuid: async () => 'tui-leaf', + linkId: 'tui-resumed-link', + now: () => 12 + }) + + expect(persistHandle).not.toHaveBeenCalled() + resolveExit({ pid: 4210, exitCode: 0, signal: null }) + + await expect(completion).resolves.toMatchObject({ + link: { + linkId: 'tui-resumed-link', + handle: { provider: 'claude', sessionId: 'provider-session', leafUuid: 'tui-leaf' }, + origin: 'resumed', + mintedAtFence: 7, + observedAt: 12 + } + }) + expect(persistHandle).toHaveBeenCalledTimes(1) + }) + + it('refuses another process exit and a missing transcript leaf', async () => { + const persistHandle = vi.fn(async () => undefined) + await expect( + completeClaudeTuiExit({ + childPid: 4210, + waitForChildExit: async () => ({ pid: 4211, exitCode: 0, signal: null }), + sessionId: 'provider-session', + transcriptPath: '/session.jsonl', + fence: 2, + persistHandle, + readLeafUuid: async () => 'leaf' + }) + ).rejects.toThrow(/did not belong to the Claude child/) + await expect( + completeClaudeTuiExit({ + childPid: 4210, + waitForChildExit: async () => ({ pid: 4210, exitCode: 1, signal: null }), + sessionId: 'provider-session', + transcriptPath: '/session.jsonl', + fence: 2, + persistHandle, + readLeafUuid: async () => null + }) + ).rejects.toThrow(/resumable transcript leaf/) + expect(persistHandle).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/claude/claude-tui-exit.ts b/src/main/claude/claude-tui-exit.ts new file mode 100644 index 00000000000..3772e9c5e52 --- /dev/null +++ b/src/main/claude/claude-tui-exit.ts @@ -0,0 +1,119 @@ +import { open } from 'node:fs/promises' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { claudeProviderHandleLink } from './claude-structured-owner-identity' + +const TRANSCRIPT_TAIL_CHUNK_BYTES = 64 * 1024 +const TRANSCRIPT_TAIL_READ_LIMIT_BYTES = 4 * 1024 * 1024 + +type TranscriptLeafCandidate = { leafUuid: string; authoritative: boolean } + +function validLeafUuid(value: unknown): string | null { + if (typeof value !== 'string' || value.length === 0 || value.length > 512) { + return null + } + const hasControlCharacter = [...value].some((character) => { + const code = character.codePointAt(0) ?? 0 + return code <= 0x1f || code === 0x7f + }) + return value === value.trim() && !hasControlCharacter ? value : null +} + +export function readClaudeTranscriptEntryUuid(value: Record): string | null { + return value.isSidechain === true || + value.parent_tool_use_id != null || + (value.type !== 'user' && value.type !== 'assistant') + ? null + : validLeafUuid(value.uuid) +} + +function readLeafCandidate(line: string): TranscriptLeafCandidate | null { + try { + const value = JSON.parse(line) as Record + const lastPromptLeaf = value.type === 'last-prompt' ? validLeafUuid(value.leafUuid) : null + if (lastPromptLeaf) { + return { leafUuid: lastPromptLeaf, authoritative: true } + } + const messageLeaf = readClaudeTranscriptEntryUuid(value) + return messageLeaf ? { leafUuid: messageLeaf, authoritative: false } : null + } catch { + return null + } +} + +export async function readClaudeTranscriptLeafUuid(transcriptPath: string): Promise { + const file = await open(transcriptPath, 'r') + try { + const { size } = await file.stat() + let position = size + let suffix = '' + let fallback: string | null = null + let scanned = 0 + while (position > 0 && scanned < TRANSCRIPT_TAIL_READ_LIMIT_BYTES) { + const length = Math.min(TRANSCRIPT_TAIL_CHUNK_BYTES, position) + position -= length + scanned += length + const buffer = Buffer.alloc(length) + await file.read(buffer, 0, length, position) + const lines = `${buffer.toString('utf8')}${suffix}`.split(/\r?\n/) + suffix = position > 0 ? (lines.shift() ?? '') : '' + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]?.trim() + if (!line) { + continue + } + const candidate = readLeafCandidate(line) + if (!candidate) { + continue + } + if (candidate.authoritative) { + return candidate.leafUuid + } + fallback ??= candidate.leafUuid + } + } + return fallback + } finally { + await file.close() + } +} + +export type ClaudeTuiChildExit = { + pid: number + exitCode: number | null + signal: string | null +} + +export async function completeClaudeTuiExit(input: { + childPid: number + waitForChildExit: () => Promise + sessionId: string + transcriptPath: string + fence: number + persistHandle: (link: AgentSessionProviderHandleLink) => Promise + readLeafUuid?: (transcriptPath: string) => Promise + linkId?: string + now?: () => number +}): Promise<{ + exit: ClaudeTuiChildExit + transcriptPath: string + link: AgentSessionProviderHandleLink +}> { + const exit = await input.waitForChildExit() + if (exit.pid !== input.childPid) { + throw new Error('The observed process exit did not belong to the Claude child.') + } + const leafUuid = await (input.readLeafUuid ?? readClaudeTranscriptLeafUuid)(input.transcriptPath) + if (!leafUuid) { + throw new Error('The exited Claude TUI did not persist a resumable transcript leaf.') + } + const link = claudeProviderHandleLink({ + sessionId: input.sessionId, + leafUuid, + resumed: true, + fence: input.fence, + ...(input.linkId ? { linkId: input.linkId } : {}), + observedAt: input.now?.() ?? Date.now() + }) + await input.persistHandle(link) + return { exit, transcriptPath: input.transcriptPath, link } +} diff --git a/src/main/claude/claude-tui-resume-launch.test.ts b/src/main/claude/claude-tui-resume-launch.test.ts new file mode 100644 index 00000000000..f907d1fcb4e --- /dev/null +++ b/src/main/claude/claude-tui-resume-launch.test.ts @@ -0,0 +1,227 @@ +import { chmodSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { CLAUDE_AUTH_ENV_CONFLICT_MESSAGE } from '../claude-accounts/environment' +import { CLAUDE_DEFAULT_SETTING_SOURCES } from './claude-structured-launch-resolution' +import { CLAUDE_SPAWN_TOKEN_ENV } from './claude-structured-owner-identity' +import { createClaudeTuiResumeLaunchBuilder } from './claude-tui-resume-launch' + +function record(overrides: Partial = {}): AgentSessionRecord { + return { + sessionId: 'orca-session-1', + provider: 'claude', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-folder', + workspaceKind: 'folder' + }, + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/accounts/claude-one' }, + providerHandleChain: [ + { + linkId: 'created', + handle: { provider: 'claude', sessionId: 'provider-session', leafUuid: 'leaf-one' }, + origin: 'created', + mintedAtFence: 1, + observedAt: 1 + } + ], + ...overrides + } as AgentSessionRecord +} + +function makeExecutable(path: string): void { + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, '') + if (process.platform !== 'win32') { + chmodSync(path, 0o755) + } +} + +describe('Claude TUI resume launch', () => { + it('pins the workspace, account home, setting sources, and launch identity', async () => { + const build = createClaudeTuiResumeLaunchBuilder({ + resolveWorkspacePath: async (workspaceId) => `/workspaces/${workspaceId}`, + resolveCommand: () => '/usr/local/bin/claude', + resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveEnv: () => ({ + SELECTED_ACCOUNT: 'one', + ANTHROPIC_AUTH_TOKEN: 'selected-account-token' + }), + inheritedEnv: { + ANTHROPIC_API_KEY: 'inherited-gateway-key', + ANTHROPIC_BASE_URL: 'https://inherited-gateway.invalid', + CLAUDE_CODE_SESSION_ID: 'parent-session', + SAFE_PARENT: 'kept' + } + }) + + const launch = await build({ record: record(), spawnToken: 'spawn-one' }) + + expect(launch).toMatchObject({ + command: '/usr/local/bin/claude', + args: [ + '--setting-sources', + CLAUDE_DEFAULT_SETTING_SOURCES.join(','), + '--resume', + 'provider-session' + ], + cwd: '/workspaces/workspace-folder', + providerSessionId: 'provider-session', + resumeLeafUuid: 'leaf-one' + }) + expect(launch.env).toMatchObject({ + SAFE_PARENT: 'kept', + SELECTED_ACCOUNT: 'one', + CLAUDE_CONFIG_DIR: '/accounts/claude-one', + ORCA_AGENT_LAUNCH_TOKEN: 'spawn-one', + [CLAUDE_SPAWN_TOKEN_ENV]: 'spawn-one', + ANTHROPIC_AUTH_TOKEN: 'selected-account-token' + }) + // System auth (the only state an explicit ANTHROPIC_AUTH_TOKEN overlay is legal in): + // the user's own inherited key is their sign-in and survives. The managed-account + // half — where it is stripped — is covered by 'structured-to-TUI handoff auth'. + expect(launch.env.ANTHROPIC_API_KEY).toBe('inherited-gateway-key') + // Endpoint selection is not credential material; the existing adapter pinning preserves it. + expect(launch.env.ANTHROPIC_BASE_URL).toBe('https://inherited-gateway.invalid') + expect(launch.env.CLAUDE_CODE_SESSION_ID).toBeUndefined() + }) + + it('pairs the resumed Claude CLI with its sibling Node runtime', async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-claude-resume-')) + const binDir = join(root, 'bin') + const claudeCommand = join(binDir, process.platform === 'win32' ? 'claude.cmd' : 'claude') + const nodeCommand = join(binDir, process.platform === 'win32' ? 'node.cmd' : 'node') + makeExecutable(claudeCommand) + makeExecutable(nodeCommand) + + const build = createClaudeTuiResumeLaunchBuilder({ + resolveWorkspacePath: async () => '/workspace', + resolveCommand: () => claudeCommand, + resolveAuthPolicy: () => ({ stripAuthEnv: true }), + resolveEnv: () => ({ PATH: '/usr/bin' }), + inheritedEnv: {} + }) + + const launch = await build({ record: record(), spawnToken: 'spawn' }) + + expect((launch.env.PATH ?? launch.env.Path)?.split(delimiter)[0]).toBe(binDir) + }) + + it('uses the durable session environment instead of current account settings', async () => { + const build = createClaudeTuiResumeLaunchBuilder({ + resolveWorkspacePath: async () => '/workspace', + resolveCommand: () => 'claude', + resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveEnv: () => ({ ANTHROPIC_AUTH_TOKEN: 'pinned-token' }), + inheritedEnv: {} + }) + + const launch = await build({ record: record(), spawnToken: 'spawn' }) + + expect(launch.env.ANTHROPIC_AUTH_TOKEN).toBe('pinned-token') + }) + + it('resolves the durable chain head instead of an earlier Claude leaf', async () => { + const nextRecord = record({ + providerHandleChain: [ + ...record().providerHandleChain, + { + linkId: 'resumed', + handle: { provider: 'claude', sessionId: 'provider-session', leafUuid: 'leaf-two' }, + origin: 'resumed', + mintedAtFence: 2, + observedAt: 2 + } + ] + }) + const build = createClaudeTuiResumeLaunchBuilder({ + resolveWorkspacePath: async () => '/workspace', + resolveCommand: () => 'claude', + resolveAuthPolicy: () => ({ stripAuthEnv: true }), + inheritedEnv: {} + }) + + await expect(build({ record: nextRecord, spawnToken: 'spawn-two' })).resolves.toMatchObject({ + providerSessionId: 'provider-session', + resumeLeafUuid: 'leaf-two' + }) + }) + + it('preserves durable Claude launch arguments before resume defaults', async () => { + const build = createClaudeTuiResumeLaunchBuilder({ + resolveWorkspacePath: async () => '/workspace', + resolveCommand: () => 'claude', + resolveAuthPolicy: () => ({ stripAuthEnv: true }), + inheritedEnv: {} + }) + + const launch = await build({ + record: record({ launchArgs: ['--model', 'claude-sonnet-4-5'] }), + spawnToken: 'spawn' + }) + + expect(launch.args.slice(0, 3)).toEqual(['--model', 'claude-sonnet-4-5', '--setting-sources']) + }) + + it('rejects missing Claude handles and unpinned account homes', async () => { + const build = createClaudeTuiResumeLaunchBuilder({ + resolveWorkspacePath: async () => '/workspace', + resolveCommand: () => 'claude', + resolveAuthPolicy: () => ({ stripAuthEnv: true }), + inheritedEnv: {} + }) + + await expect( + build({ record: record({ providerHandleChain: [] }), spawnToken: 'spawn' }) + ).rejects.toThrow('claude_tui_resume_handle_required') + await expect( + build({ + record: record({ accountHome: { variable: 'CODEX_HOME', path: '/wrong' } }), + spawnToken: 'spawn' + }) + ).rejects.toThrow(/CLAUDE_CONFIG_DIR/) + }) +}) + +// buildClaudeChildProcessEnv strips its inherited half unconditionally, so this module +// would have signed a system-auth user out of the session the structured path had just +// honoured. It is not wired up yet; the required policy is what stops the next caller +// from inheriting that. +describe('structured-to-TUI handoff auth', () => { + it('carries a system-auth user their own inherited credential', async () => { + const launch = await createClaudeTuiResumeLaunchBuilder({ + resolveWorkspacePath: async () => '/repos/workspace-1', + resolveCommand: () => '/usr/local/bin/claude', + resolveAuthPolicy: () => ({ stripAuthEnv: false }), + inheritedEnv: { ANTHROPIC_API_KEY: 'sk-ant-SHELL', PATH: '/usr/bin' } + })({ record: record(), spawnToken: 'token-1' }) + + expect(launch.env.ANTHROPIC_API_KEY).toBe('sk-ant-SHELL') + }) + + it('still strips it once a managed account owns the credential', async () => { + const launch = await createClaudeTuiResumeLaunchBuilder({ + resolveWorkspacePath: async () => '/repos/workspace-1', + resolveCommand: () => '/usr/local/bin/claude', + resolveAuthPolicy: () => ({ stripAuthEnv: true }), + inheritedEnv: { ANTHROPIC_API_KEY: 'sk-ant-SHELL', PATH: '/usr/bin' } + })({ record: record(), spawnToken: 'token-1' }) + + expect(launch.env.ANTHROPIC_API_KEY).toBeUndefined() + }) + + it('refuses a configured override of a pinned managed account, as the terminal path does', async () => { + await expect( + createClaudeTuiResumeLaunchBuilder({ + resolveWorkspacePath: async () => '/repos/workspace-1', + resolveCommand: () => '/usr/local/bin/claude', + resolveAuthPolicy: () => ({ stripAuthEnv: true }), + resolveEnv: () => ({ ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED' }), + inheritedEnv: {} + })({ record: record(), spawnToken: 'token-1' }) + ).rejects.toThrow(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + }) +}) diff --git a/src/main/claude/claude-tui-resume-launch.ts b/src/main/claude/claude-tui-resume-launch.ts new file mode 100644 index 00000000000..8c09335fd9d --- /dev/null +++ b/src/main/claude/claude-tui-resume-launch.ts @@ -0,0 +1,101 @@ +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' +import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' +import { resolveClaudeCommand } from '../codex-cli/command' +import { getSpawnArgsForWindows } from '../win32-utils' +import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' +import { + CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + claudeAuthEnvCarriedForward, + hasClaudeAuthEnvConflict +} from '../claude-accounts/environment' +import { buildClaudeChildProcessEnv } from './claude-child-process-environment' +import { claudeConfigDirEnvPatch } from './claude-config-dir-pin' +import { CLAUDE_DEFAULT_SETTING_SOURCES } from './claude-structured-launch-resolution' +import { CLAUDE_SPAWN_TOKEN_ENV } from './claude-structured-owner-identity' + +export const CLAUDE_TUI_RESUME_BASE_ARGS = [ + '--setting-sources', + CLAUDE_DEFAULT_SETTING_SOURCES.join(',') +] as const + +export type ClaudeTuiResumeLaunch = { + command: string + args: string[] + cwd: string + env: Record + providerSessionId: string + resumeLeafUuid: string | null +} + +export type ClaudeTuiResumeLaunchBuilderDeps = { + resolveWorkspacePath: (workspaceId: string) => Promise + resolveCommand?: () => string + resolveEnv?: () => Record + inheritedEnv?: NodeJS.ProcessEnv + /** + * Required so whoever wires this module up has to answer the question rather than + * inherit the wrong default: buildClaudeChildProcessEnv strips its inherited half + * unconditionally, which would sign out a system-auth user whose own ANTHROPIC_* + * is their only credential. Build it with claudeStructuredAuthPolicyForSettings. + */ + resolveAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy +} + +export function createClaudeTuiResumeLaunchBuilder( + deps: ClaudeTuiResumeLaunchBuilderDeps +): (input: { record: AgentSessionRecord; spawnToken: string }) => Promise { + return async ({ record, spawnToken }) => { + if (record.provider !== 'claude') { + throw new Error(`session ${record.sessionId} is a ${record.provider} session`) + } + if (record.accountHome.variable !== 'CLAUDE_CONFIG_DIR') { + throw new Error(`claude sessions pin CLAUDE_CONFIG_DIR, not ${record.accountHome.variable}`) + } + const head = agentSessionProviderHandleChainHead(record.providerHandleChain) + if (head?.handle.provider !== 'claude') { + throw new Error('claude_tui_resume_handle_required') + } + + const command = (deps.resolveCommand ?? resolveClaudeCommand)() + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, [ + ...(record.launchArgs ?? []), + ...CLAUDE_TUI_RESUME_BASE_ARGS, + '--resume', + head.handle.sessionId + ]) + const auth = await deps.resolveAuthPolicy() + const configuredEnv = deps.resolveEnv?.() ?? {} + if (auth.stripAuthEnv && hasClaudeAuthEnvConflict(configuredEnv)) { + throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + } + // The inherited half is always stripped downstream, so a system-auth user's own + // credential only reaches the resumed TUI if it is carried in the configured half. + const carriedAuth = auth.stripAuthEnv + ? {} + : claudeAuthEnvCarriedForward(deps.inheritedEnv ?? process.env) + // Compared against what the child would otherwise inherit, so the record's account + // home still wins over a diverging overlay without a needless pin. + const inheritedEnv = { ...(deps.inheritedEnv ?? process.env), ...configuredEnv } + const env = buildClaudeChildProcessEnv( + { + ...carriedAuth, + ...configuredEnv, + ...claudeConfigDirEnvPatch(record.accountHome.path, { env: inheritedEnv }), + ORCA_AGENT_LAUNCH_TOKEN: spawnToken, + [CLAUDE_SPAWN_TOKEN_ENV]: spawnToken + }, + { inheritedEnv: deps.inheritedEnv } + ) + const pairedEnv = withCliRuntimeOnPath(command, env, { platform: process.platform }) + + return { + command: spawnCmd, + args: spawnArgs, + cwd: await deps.resolveWorkspacePath(record.location.workspaceId), + env: pairedEnv, + providerSessionId: head.handle.sessionId, + resumeLeafUuid: head.handle.leafUuid + } + } +} diff --git a/src/main/claude/claude-tui-resume-proof.test.ts b/src/main/claude/claude-tui-resume-proof.test.ts new file mode 100644 index 00000000000..6d2e99d4b00 --- /dev/null +++ b/src/main/claude/claude-tui-resume-proof.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { proveClaudeTuiResume, readClaudeTuiSessionStartEvidence } from './claude-tui-resume-proof' + +const SESSION = '91deba8d-a398-4b69-a05d-35041536fe8e' +const TRANSCRIPT = '/accounts/claude/projects/workspace/transcript.jsonl' + +function envelope(overrides: Record = {}): Record { + return { + launchToken: 'spawn-one', + payload: JSON.stringify({ + hook_event_name: 'SessionStart', + source: 'resume', + session_id: SESSION, + transcript_path: TRANSCRIPT, + ...overrides + }) + } +} + +describe('Claude TUI resume proof', () => { + it('reads SessionStart identity from the hook envelope', () => { + expect(readClaudeTuiSessionStartEvidence(envelope())).toEqual({ + hookEventName: 'SessionStart', + source: 'resume', + sessionId: SESSION, + transcriptPath: TRANSCRIPT, + launchToken: 'spawn-one' + }) + }) + + it('proves the exact launched session and transcript without terminal output', async () => { + await expect( + proveClaudeTuiResume({ + expectedSessionId: SESSION, + expectedTranscriptPath: TRANSCRIPT, + expectedLaunchToken: 'spawn-one', + waitForSessionStart: async () => envelope() + }) + ).resolves.toMatchObject({ sessionId: SESSION, transcriptPath: TRANSCRIPT }) + }) + + it.each([ + ['source', { source: 'startup' }, /resume SessionStart/], + ['session', { session_id: 'other-session' }, /different Claude session/], + ['transcript', { transcript_path: '/other/transcript.jsonl' }, /different Claude transcript/] + ])('rejects a mismatched %s', async (_name, overrides, expected) => { + await expect( + proveClaudeTuiResume({ + expectedSessionId: SESSION, + expectedTranscriptPath: TRANSCRIPT, + expectedLaunchToken: 'spawn-one', + waitForSessionStart: async () => envelope(overrides) + }) + ).rejects.toThrow(expected) + }) + + it('rejects a SessionStart from another launched process', async () => { + await expect( + proveClaudeTuiResume({ + expectedSessionId: SESSION, + expectedTranscriptPath: TRANSCRIPT, + expectedLaunchToken: 'spawn-two', + waitForSessionStart: async () => envelope() + }) + ).rejects.toThrow(/different launched process/) + }) + + it('compares Windows paths using host path semantics', async () => { + await expect( + proveClaudeTuiResume({ + expectedSessionId: SESSION, + expectedTranscriptPath: 'C:\\Users\\Dev\\session.jsonl', + expectedLaunchToken: 'spawn-one', + platform: 'win32', + waitForSessionStart: async () => + envelope({ transcript_path: 'c:\\users\\dev\\session.jsonl' }) + }) + ).resolves.toMatchObject({ sessionId: SESSION }) + }) +}) diff --git a/src/main/claude/claude-tui-resume-proof.ts b/src/main/claude/claude-tui-resume-proof.ts new file mode 100644 index 00000000000..f352423116e --- /dev/null +++ b/src/main/claude/claude-tui-resume-proof.ts @@ -0,0 +1,111 @@ +import { posix, win32 } from 'node:path' + +export type ClaudeTuiSessionStartEvidence = { + hookEventName: 'SessionStart' + source: 'resume' + sessionId: string + transcriptPath: string + launchToken: string +} + +function record(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +function hookPayload(envelope: Record): Record | null { + if (typeof envelope.payload === 'string') { + try { + return record(JSON.parse(envelope.payload)) + } catch { + return null + } + } + return record(envelope.payload) ?? envelope +} + +export function readClaudeTuiSessionStartEvidence( + value: unknown +): ClaudeTuiSessionStartEvidence | null { + const envelope = record(value) + if (!envelope) { + return null + } + const payload = hookPayload(envelope) + if (!payload) { + return null + } + const hookEventName = nonEmptyString(payload.hook_event_name ?? payload.hookEventName) + const source = nonEmptyString(payload.source) + const sessionId = nonEmptyString(payload.session_id ?? payload.sessionId) + const transcriptPath = nonEmptyString(payload.transcript_path ?? payload.transcriptPath) + const launchToken = nonEmptyString(envelope.launchToken ?? payload.launchToken) + return hookEventName === 'SessionStart' && + source === 'resume' && + sessionId && + transcriptPath && + launchToken + ? { hookEventName, source, sessionId, transcriptPath, launchToken } + : null +} + +function comparablePath(value: string, platform: NodeJS.Platform): string | null { + if (value.includes('\0')) { + return null + } + const path = platform === 'win32' ? win32 : posix + if (!path.isAbsolute(value)) { + return null + } + const normalized = path.normalize(value) + return platform === 'win32' ? normalized.toLowerCase() : normalized +} + +export async function proveClaudeTuiResume(input: { + expectedSessionId: string + expectedTranscriptPath: string + expectedLaunchToken: string + waitForSessionStart: () => Promise + timeoutMs?: number + platform?: NodeJS.Platform +}): Promise { + const timeoutMs = input.timeoutMs ?? 15_000 + let timer: ReturnType | undefined + try { + const evidence = readClaudeTuiSessionStartEvidence( + await Promise.race([ + input.waitForSessionStart(), + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error('The agent terminal did not prove the expected Claude resume.')), + timeoutMs + ) + timer.unref?.() + }) + ]) + ) + if (!evidence) { + throw new Error('The agent terminal did not emit a Claude resume SessionStart proof.') + } + if (evidence.launchToken !== input.expectedLaunchToken) { + throw new Error('The Claude resume proof came from a different launched process.') + } + if (evidence.sessionId !== input.expectedSessionId) { + throw new Error('The agent terminal resumed a different Claude session.') + } + const platform = input.platform ?? process.platform + const expectedPath = comparablePath(input.expectedTranscriptPath, platform) + const observedPath = comparablePath(evidence.transcriptPath, platform) + if (!expectedPath || !observedPath || observedPath !== expectedPath) { + throw new Error('The agent terminal resumed a different Claude transcript.') + } + return evidence + } finally { + clearTimeout(timer) + } +} diff --git a/src/main/claude/claude-tui-resume-real-binary.integration.test.ts b/src/main/claude/claude-tui-resume-real-binary.integration.test.ts new file mode 100644 index 00000000000..9ba3daf2285 --- /dev/null +++ b/src/main/claude/claude-tui-resume-real-binary.integration.test.ts @@ -0,0 +1,279 @@ +import { spawnSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import * as pty from 'node-pty' +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { resolveClaudeCommand } from '../codex-cli/command' +import { readStructuredTuiProcessIdentity } from '../runtime/structured-tui-process-identity' +import { getSpawnArgsForWindows } from '../win32-utils' +import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' +import { + ClaudeStructuredSessionAdapter, + type ClaudeStructuredSessionEvent +} from './claude-structured-session-adapter' +import { createClaudeTuiResumeLaunchBuilder } from './claude-tui-resume-launch' +import { proveClaudeTuiResume } from './claude-tui-resume-proof' + +const command = resolveClaudeCommand() +const claudeAvailable = + spawnSync(command, ['--version'], { stdio: 'ignore', timeout: 5_000 }).status === 0 +const authStatusLaunch = getSpawnArgsForWindows(command, ['auth', 'status', '--json']) +const claudeAuthenticated = (() => { + if (!claudeAvailable) { + return false + } + const result = spawnSync(authStatusLaunch.spawnCmd, authStatusLaunch.spawnArgs, { + encoding: 'utf8', + windowsHide: true, + timeout: 5_000 + }) + return result.status === 0 && /"loggedIn"\s*:\s*true/.test(result.stdout) +})() +const roots: string[] = [] +const transcripts: string[] = [] + +function shellQuote(value: string): string { + return process.platform === 'win32' + ? `"${value.replace(/"/g, '""')}"` + : `'${value.replace(/'/g, `'"'"'`)}'` +} + +async function installCaptureHook( + root: string +): Promise<{ eventsPath: string; settingsPath: string }> { + const scriptPath = join(root, 'capture-session-start.cjs') + const eventsPath = join(root, 'session-start.jsonl') + const settingsPath = join(root, 'settings.json') + await writeFile( + scriptPath, + [ + "const { appendFileSync } = require('node:fs')", + "let input = ''", + "process.stdin.setEncoding('utf8')", + "process.stdin.on('data', (chunk) => { input += chunk })", + "process.stdin.on('end', () => {", + ' const payload = JSON.parse(input)', + ' payload.launchToken = process.env.ORCA_AGENT_LAUNCH_TOKEN', + ' appendFileSync(process.argv[2], `${JSON.stringify(payload)}\\n`)', + '})', + '' + ].join('\n') + ) + await writeFile( + settingsPath, + JSON.stringify({ + theme: 'dark', + hooks: { + SessionStart: [ + { + hooks: [ + { + type: 'command', + command: [process.execPath, scriptPath, eventsPath].map(shellQuote).join(' ') + } + ] + } + ] + } + }) + ) + return { eventsPath, settingsPath } +} + +async function waitForHook( + eventsPath: string, + source: 'startup' | 'resume' +): Promise> { + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + const contents = await readFile(eventsPath, 'utf8').catch(() => '') + for (const line of contents.split(/\r?\n/)) { + if (!line.trim()) { + continue + } + const event = JSON.parse(line) as Record + if (event.hook_event_name === 'SessionStart' && event.source === source) { + return event + } + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + throw new Error(`Claude did not emit a ${source} SessionStart hook`) +} + +type RunningTui = { proc: pty.IPty; exited: Promise } + +function spawnResumeTui(args: string[], env: Record): RunningTui { + const direct = process.platform === 'win32' + const proc = pty.spawn( + direct ? command : process.env.SHELL || '/bin/zsh', + direct ? args : ['-l'], + { + name: 'xterm-256color', + cols: 100, + rows: 30, + cwd: process.cwd(), + env: { ...env, TERM: 'xterm-256color' } + } + ) + if (!direct) { + setTimeout(() => { + proc.write(`${[command, ...args].map(shellQuote).join(' ')}\r`) + }, 100).unref() + } + return { proc, exited: new Promise((resolve) => proc.onExit(() => resolve())) } +} + +function structuredIdentity(providerSessionId: string): AgentSessionJournalIdentity { + return { + sessionId: 'orca-real-claude-resume', + workspaceId: 'workspace-real', + hostId: 'local', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: providerSessionId, leafUuid: null } + } +} + +async function waitForStructuredResult(events: ClaudeStructuredSessionEvent[]): Promise { + const deadline = Date.now() + 30_000 + while (Date.now() < deadline) { + if (events.some((event) => event.type === 'message' && event.message.type === 'result')) { + return + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + throw new Error('Claude structured session did not finish its product-path turn') +} + +async function stopTui(tui: RunningTui): Promise { + try { + tui.proc.kill('SIGKILL') + } catch { + return + } + await Promise.race([ + tui.exited, + new Promise((_resolve, reject) => + setTimeout(() => reject(new Error('Claude TUI did not exit after cleanup')), 5_000) + ) + ]) +} + +afterEach(async () => { + await Promise.all(transcripts.splice(0).map((path) => rm(path, { force: true }))) + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe.skipIf(!claudeAuthenticated)('real Claude TUI resume proof', () => { + it('resumes a product-created structured session and proves its exact child', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-claude-tui-resume-')) + roots.push(root) + const { eventsPath, settingsPath } = await installCaptureHook(root) + const providerSessionId = randomUUID() + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: command, + options: { + ...CLAUDE_STRUCTURED_BASE_OPTIONS, + extraArgs: { ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, settings: settingsPath }, + sessionId: providerSessionId + }, + cwd: process.cwd(), + claudeConfigDir, + providerSessionId, + resumeLeafUuid: null, + resumed: false + }), + onEvent: (event) => events.push(event), + readProcessStartTime: async () => 1 + }) + let resumed: RunningTui | null = null + try { + const acquisition = await adapter.acquire({ + identity: structuredIdentity(providerSessionId), + fence: 1, + spawnToken: 'real-create' + }) + await expect( + adapter.dispatch({ + sessionId: 'orca-real-claude-resume', + clientMessageId: 'real-product-turn', + fence: 1, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Reply only with ORCA_RESUME_READY.' }] + } + }) + ).resolves.toMatchObject({ state: 'accepted' }) + await waitForStructuredResult(events) + const started = await waitForHook(eventsPath, 'startup') + const transcriptPath = String(started.transcript_path) + transcripts.push(transcriptPath) + expect(started.session_id).toBe(providerSessionId) + await adapter.closeAll() + + const record = { + sessionId: 'orca-real-claude-resume', + provider: 'claude', + location: { workspaceId: 'workspace-real' }, + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: claudeConfigDir }, + providerHandleChain: [ + { + linkId: 'created-real', + handle: acquisition.link.handle, + origin: 'created', + mintedAtFence: 1, + observedAt: 1 + } + ] + } as AgentSessionRecord + const launch = await createClaudeTuiResumeLaunchBuilder({ + resolveWorkspacePath: async () => process.cwd(), + resolveCommand: () => command, + // The real binary authenticates from the developer's own environment here, + // which is the system-auth case: stripping it would sign the resume out. + resolveAuthPolicy: () => ({ stripAuthEnv: false }) + })({ record, spawnToken: 'real-resume' }) + resumed = spawnResumeTui([...launch.args, '--settings', settingsPath], launch.env) + let resumedOutput = '' + resumed.proc.onData((data) => { + resumedOutput = `${resumedOutput}${data}`.slice(-4_000) + }) + + const [processIdentity, proof] = await Promise.all([ + readStructuredTuiProcessIdentity({ + hostId: 'local', + rootPid: resumed.proc.pid, + spawnToken: 'real-resume', + agent: 'claude' + }), + proveClaudeTuiResume({ + expectedSessionId: providerSessionId, + expectedTranscriptPath: transcriptPath, + expectedLaunchToken: 'real-resume', + waitForSessionStart: () => waitForHook(eventsPath, 'resume') + }).catch((error) => { + throw new Error(`${String(error)}\nClaude output: ${resumedOutput}`) + }) + ]) + expect(processIdentity).toMatchObject({ + hostId: 'local', + spawnToken: 'real-resume', + pid: expect.any(Number) + }) + expect(proof).toMatchObject({ sessionId: providerSessionId, transcriptPath }) + } finally { + await adapter.closeAll() + if (resumed) { + await stopTui(resumed) + } + } + }, 30_000) +}) diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index 4238c75de9e..b04e7bc2540 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -11,6 +11,8 @@ import { } from './codex-structured-session-adapter' import { handleCodexSessionExit } from './codex-structured-session-close' import type { CodexSession } from './codex-structured-session-state' +import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' const THREAD = 'thread-1' @@ -60,6 +62,16 @@ function adapterFixture() { return { adapter, connections, events } } +function claudeAdapterStub(): StructuredAgentSessionAdapter { + return { + acquire: vi.fn(async () => ({ process: { pid: 1 } }) as never), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } +} + describe('Codex structured session close lifecycle', () => { it('forwards a one-shot exit when lifecycle admission is rejected', () => { const connection: CodexAppServerConnection = { @@ -164,4 +176,27 @@ describe('Codex structured session close lifecycle', () => { { cause: 'unexpected-exit', reason: 'sink failed', fence: 7 } ]) }) + + it('routes Codex sink-failure recovery through force-close and preserves unexpected-exit settlement', async () => { + const { adapter, connections, events } = adapterFixture() + const router = new StructuredAgentSessionAdapterRouter( + { claude: claudeAdapterStub(), codex: adapter }, + async () => {} + ) + await router.acquire({ identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' }) + const current = connections[0] + if (!current) { + throw new Error('missing connection') + } + current.connection.close = async () => { + current.handlers.onExit?.(new Error('journal sink failed')) + return true + } + + const forceCloseSession = router.forceCloseSession + await expect(forceCloseSession('session-1')).resolves.toBe(true) + expect(events.filter((event) => event.type === 'ended')).toMatchObject([ + { cause: 'unexpected-exit', reason: 'journal sink failed', fence: 7 } + ]) + }) }) diff --git a/src/main/ipc/pty/ipc/spawn-env.ts b/src/main/ipc/pty/ipc/spawn-env.ts index af5da3858bd..94f1acf363e 100644 --- a/src/main/ipc/pty/ipc/spawn-env.ts +++ b/src/main/ipc/pty/ipc/spawn-env.ts @@ -7,7 +7,11 @@ import { isRemoteAgentHooksEnabled } from '../../../../shared/agent-hook-relay' import { isOpaqueRemintedPaneKey } from '../../../../shared/pane-key-alias' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' -import { hasClaudeAuthEnvConflict } from '../../../claude-accounts/environment' +import { + CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, + hasClaudeAuthEnvConflict +} from '../../../claude-accounts/environment' import { LocalPtyProvider } from '../../../providers/local-pty-provider' import { resolvePathEnvKey } from '../../../pty/windows-environment-path' import { routesFreshSpawnsToLocalProvider } from '../host-env/fresh-spawn-routing' @@ -20,12 +24,10 @@ import { assemblePtyIpcSpawnCodexEnv } from './spawn-env-codex' export async function assemblePtyIpcSpawnEnv(ctx: PtyIpcSpawnState): Promise { const args = ctx.args if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') + throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) } if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { - throw new Error( - 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' - ) + throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) } // Why: the daemon-backed provider skips LocalPtyProvider's buildSpawnEnv, so assemble the same host-local env here for parity. // Safety: skip entirely for SSH — every injection is a loopback secret or a local path that leaks or misleads on the remote host. diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index f40b46e5dd5..f885d6e2f6f 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -4,6 +4,7 @@ import { } from '../../../../shared/local-windows-terminal-runtime' import { isWslUncPath, toWindowsWslPath } from '../../../../shared/wsl-paths' import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' +import { CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE } from '../../../claude-accounts/environment' import { mintPtySessionId } from '../../../daemon/pty-session-id' import { resolveWslSessionContext } from '../../../daemon/wsl-session-context' import { LocalPtyProvider } from '../../../providers/local-pty-provider' @@ -193,7 +194,7 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') + throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) } ctx.terminalRuntimeOptions = process.platform === 'win32' && !args.connectionId diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index aed89b44df8..43fd2778119 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -23,7 +23,11 @@ import { import { stripRemotePaneEnvWhenHooksDisabled } from '../provider/liveness' import { isTuiAgent } from '../../../../shared/tui-agent-config' import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' -import { hasClaudeAuthEnvConflict } from '../../../claude-accounts/environment' +import { + CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, + hasClaudeAuthEnvConflict +} from '../../../claude-accounts/environment' import { isSafePtySessionId, mintPtySessionId, @@ -65,7 +69,7 @@ export async function prepareRuntimePtySpawn( ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') + throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) } // Why: runtime-created terminals carry no renderer-computed projectRuntime; resolve from worktreeId to honor the project's Windows runtime. ctx.terminalRuntimeOptions = @@ -134,12 +138,10 @@ export async function prepareRuntimePtySpawn( ? await ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget) : null if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') + throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) } if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { - throw new Error( - 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' - ) + throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) } ctx.shouldPersistHostSessionBinding = args.persistHostSessionBinding === true diff --git a/src/main/ipc/runtime.test.ts b/src/main/ipc/runtime.test.ts index dc7f8a01cd7..07010087363 100644 --- a/src/main/ipc/runtime.test.ts +++ b/src/main/ipc/runtime.test.ts @@ -136,6 +136,40 @@ describe('registerRuntimeHandlers', () => { }) }) + it('projects Claude structured tabs to the same-version desktop client', async () => { + const claudeTab = { + type: 'agent-session', + id: 'agent-session:claude-1', + title: 'Claude Chat', + sessionId: 'claude-1', + agent: 'claude', + isActive: true + } + const runtime = { + getRuntimeId: vi.fn().mockReturnValue('runtime-1'), + restoreStructuredAgentSessionTabs: vi.fn(async () => undefined), + listMobileSessionTabs: vi.fn(async () => ({ + worktree: 'workspace-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: claudeTab.id, + activeTabType: 'agent-session', + tabGroups: [{ id: 'group-1', activeTabId: claudeTab.id, tabOrder: [claudeTab.id] }], + tabs: [claudeTab] + })) + } + + registerRuntimeHandlers(runtime as never) + const callRegistration = handleMock.mock.calls.find(([channel]) => channel === 'runtime:call') + const result = await callRegistration![1](runtimeCallEvent(), { + method: 'session.tabs.list', + params: { worktree: 'id:workspace-1' } + }) + + expect(result).toMatchObject({ ok: true, result: { tabs: [claudeTab] } }) + }) + it('registers project group runtime RPC methods for local desktop callers', async () => { const runtime = { syncWindowGraph: vi.fn(), diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index 901d14bfce6..3901d8b1ffa 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -10,7 +10,10 @@ import type { import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-restore-deadline' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../shared/protocol-version' import { RpcDispatcher } from '../runtime/rpc/dispatcher' import { ALL_RPC_METHODS } from '../runtime/rpc/methods' import { DesktopRuntimeSenderLifecycle } from './desktop-runtime-sender-lifecycle' @@ -76,7 +79,10 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { clientId: 'desktop-renderer', clientKind: 'runtime', connectionId: desktopSenders.connectionIdFor(event.sender), - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + clientCapabilities: [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ] } )) as RuntimeRpcResponse } @@ -121,7 +127,10 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { clientId: 'desktop-renderer', clientKind: 'runtime', connectionId, - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + clientCapabilities: [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ] } ) .finally(stop) diff --git a/src/main/native-chat/agent-session-wire/claude-stream-json-frame-schema.ts b/src/main/native-chat/agent-session-wire/claude-stream-json-frame-schema.ts index 4f3ef118af5..4f21285e417 100644 --- a/src/main/native-chat/agent-session-wire/claude-stream-json-frame-schema.ts +++ b/src/main/native-chat/agent-session-wire/claude-stream-json-frame-schema.ts @@ -1,4 +1,4 @@ -// SDKMessage discriminators from Claude Agent SDK 0.3.231 / Claude Code 2.1.231. +// SDKMessage discriminators from Claude Agent SDK 0.3.251 / Claude Code 2.1.258. export const CLAUDE_STREAM_JSON_FRAME_KINDS = [ 'message:assistant', 'message:user', @@ -42,7 +42,15 @@ export const CLAUDE_STREAM_JSON_FRAME_KINDS = [ 'message:prompt_suggestion', 'message:system:mirror_error', 'message:system:informational', - 'message:conversation_reset' + 'message:conversation_reset', + // Queue bookkeeping the CLI emits per client-supplied command uuid. Absent + // from the SDK's SDKMessage union, which is why it reached users as raw JSON. + 'message:command_lifecycle', + 'message:result:success', + 'message:result:error_during_execution', + 'message:result:error_max_turns', + 'message:result:error_max_budget_usd', + 'message:result:error_max_structured_output_retries' ] as const export type ClaudeStreamJsonFrameKind = (typeof CLAUDE_STREAM_JSON_FRAME_KINDS)[number] diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts index ad9ca66c52a..22bd645d8a6 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts @@ -65,6 +65,29 @@ describe('provider frame classification catalog', () => { ).toBe('error-surface') }) + it('keeps command queue bookkeeping off the transcript without hiding a failed one', () => { + expect( + classifyProviderFrame('claude', 'message:command_lifecycle', { + command_uuid: 'command-1', + state: 'started' + }) + ).toBe('status-chrome') + expect( + classifyProviderFrame('claude', 'message:command_lifecycle', { + command_uuid: 'command-1', + state: 'cancelled' + }) + ).toBe('status-chrome') + // Payload inspection outranks the catalogue, so suppressing the kind cannot + // swallow a state the provider reports as a failure. + expect( + classifyProviderFrame('claude', 'message:command_lifecycle', { + command_uuid: 'command-1', + state: 'failed' + }) + ).toBe('error-surface') + }) + it('keeps unknown future frames on the substantive bounded fallback path', () => { expect(classifyProviderFrame('codex', 'notification:future/event', {})).toBe( 'timeline-substantive' diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts index 8d11df995a6..474b1385a4f 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts @@ -131,7 +131,18 @@ export const PROVIDER_FRAME_CLASSIFICATIONS = { 'message:prompt_suggestion': 'status-chrome', 'message:system:mirror_error': 'error-surface', 'message:system:informational': 'timeline-substantive', - 'message:conversation_reset': 'status-chrome' + 'message:conversation_reset': 'status-chrome', + // A `started`/`completed`/`cancelled` state for one queued command uuid and + // nothing else; the CLI keeps it out of its own transcript too. A state that + // reads as a failure still surfaces, via the payload check in classify. + 'message:command_lifecycle': 'status-chrome', + // The turn-complete signal: lifecycle, never a transcript row. Error subtypes + // included — the turn's assistant frames already carry any user-facing text. + 'message:result:success': 'status-chrome', + 'message:result:error_during_execution': 'status-chrome', + 'message:result:error_max_turns': 'status-chrome', + 'message:result:error_max_budget_usd': 'status-chrome', + 'message:result:error_max_structured_output_retries': 'status-chrome' } } as const satisfies ProviderFrameClassificationTable diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts new file mode 100644 index 00000000000..c6566083eac --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' + +function adapterOf( + releaseAcquisition: StructuredAgentSessionAdapter['releaseAcquisition'] +): StructuredAgentSessionAdapter { + return { + acquire: vi.fn(async () => ({ process: { pid: 1 } }) as never), + releaseAcquisition, + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } as unknown as StructuredAgentSessionAdapter +} + +describe('StructuredAgentSessionAdapterRouter.releaseAcquisition', () => { + it('drops the owner even when its release reports a typed failure', async () => { + const failure = new Error('root exited') + const claude = adapterOf(vi.fn().mockRejectedValueOnce(failure).mockResolvedValue(false)) + const codex = adapterOf(vi.fn(async () => false)) + const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const identity = { sessionId: 'session-1', agent: 'claude' } as never + await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) + + await expect(router.releaseAcquisition({ sessionId: 'session-1' })).rejects.toBe(failure) + // With no owner left, a later release asks every adapter instead of the stale one. + await expect(router.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(false) + expect(claude.releaseAcquisition).toHaveBeenCalledTimes(2) + expect(codex.releaseAcquisition).toHaveBeenCalledTimes(1) + }) +}) + +describe('StructuredAgentSessionAdapterRouter.closeSession', () => { + it('retains the owner after an unproven close so a later retry reaches the same adapter', async () => { + const claude = adapterOf(vi.fn(async () => true)) + const closeSession = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) + const dispatch = vi.fn().mockResolvedValue({ state: 'unknown', reason: 'test' }) + claude.closeSession = closeSession + claude.dispatch = dispatch + const codex = adapterOf(vi.fn(async () => false)) + const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const identity = { sessionId: 'session-1', agent: 'claude' } as never + await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) + + await expect(router.closeSession('session-1')).resolves.toBe(false) + await expect( + router.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: {} as never, + fence: 1 + }) + ).resolves.toMatchObject({ state: 'unknown' }) + await expect(router.closeSession('session-1')).resolves.toBe(true) + expect(closeSession).toHaveBeenCalledTimes(2) + expect(dispatch).toHaveBeenCalledTimes(1) + }) +}) + +describe('StructuredAgentSessionAdapterRouter optional lifecycle methods', () => { + it.each([ + ['forceCloseSession', 'forceCloseSession'], + ['disposeSession', 'disposeSession'] + ] as const)( + '%s forwards to the owner and retains it until proven stopped', + async (_label, method) => { + const claude = adapterOf(vi.fn(async () => true)) + const stop = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) + claude[method] = stop + const dispatch = vi.fn().mockResolvedValue({ state: 'unknown', reason: 'test' }) + claude.dispatch = dispatch + const codex = adapterOf(vi.fn(async () => false)) + const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const identity = { sessionId: 'session-1', agent: 'claude' } as never + await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) + const stopSession = router[method] + + await expect(stopSession('session-1')).resolves.toBe(false) + await expect( + router.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: {} as never, + fence: 1 + }) + ).resolves.toMatchObject({ state: 'unknown' }) + await expect(stopSession('session-1')).resolves.toBe(true) + expect(stop).toHaveBeenCalledTimes(2) + expect(dispatch).toHaveBeenCalledOnce() + } + ) + + it.each(['forceCloseSession', 'disposeSession'] as const)( + 'falls back to closeSession when an owner lacks %s', + async (method) => { + const closeSession = vi.fn().mockResolvedValue(true) + const claude = adapterOf(vi.fn(async () => true)) + claude.closeSession = closeSession + const codex = adapterOf(vi.fn(async () => false)) + const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + await router.acquire({ + identity: { sessionId: 'session-1', agent: 'claude' } as never, + fence: 1, + spawnToken: 'spawn-1' + }) + const stopSession = router[method] + + await expect(stopSession('session-1')).resolves.toBe(true) + expect(closeSession).toHaveBeenCalledWith('session-1') + } + ) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts new file mode 100644 index 00000000000..6ac0c8e0fbf --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -0,0 +1,124 @@ +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' + +type RoutedAgent = 'claude' | 'codex' + +export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessionAdapter { + private readonly owners = new Map() + + constructor( + private readonly adapters: Record, + private readonly closeAdapters: () => Promise + ) {} + + supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => { + const adapter = this.adapterForAgent(agent) + return adapter ? (adapter.supportsLocation?.(location) ?? false) : false + } + + supportsLocation = (location: AgentSessionExecutionLocation): boolean => + Object.values(this.adapters).some((adapter) => adapter.supportsLocation?.(location) ?? false) + + async acquire(input: Parameters[0]) { + const adapter = this.requireAgent(input.identity) + const acquired = await adapter.acquire(input) + this.owners.set(input.identity.sessionId, adapter) + return acquired + } + + async releaseAcquisition(input: { sessionId: string }): Promise { + const adapter = this.owners.get(input.sessionId) + if (adapter) { + try { + return (await adapter.releaseAcquisition?.(input)) === true + } finally { + this.owners.delete(input.sessionId) + } + } + let released = false + for (const candidate of Object.values(this.adapters)) { + released = (await candidate.releaseAcquisition?.(input)) === true || released + } + return released + } + + dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => + this.owner(input.sessionId).dispatch(input) + + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (input) => + this.owner(input.sessionId).cancelTurn(input) + + answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) => + this.owner(input.sessionId).answerPrompt(input) + + setOption: StructuredAgentSessionAdapter['setOption'] = (input) => + this.owner(input.sessionId).setOption(input) + + readOptions = (input: { sessionId: string; fence: number }) => { + const reader = this.owner(input.sessionId).readOptions + if (!reader) { + throw new Error(`structured session ${input.sessionId} does not report options`) + } + return reader(input) + } + + readOptionRestoreFailures = (sessionId: string): readonly string[] => + this.owner(sessionId).readOptionRestoreFailures?.(sessionId) ?? [] + + historyFilePath = (input: { identity: AgentSessionJournalIdentity }) => + this.requireAgent(input.identity).historyFilePath?.(input) ?? Promise.resolve(null) + + closeSession = (sessionId: string): Promise => + this.stopSession(sessionId, (adapter) => adapter.closeSession) + + forceCloseSession = (sessionId: string): Promise => + this.stopSession(sessionId, (adapter) => adapter.forceCloseSession ?? adapter.closeSession) + + disposeSession = (sessionId: string): Promise => + this.stopSession(sessionId, (adapter) => adapter.disposeSession ?? adapter.closeSession) + + private async stopSession( + sessionId: string, + selectStop: ( + adapter: StructuredAgentSessionAdapter + ) => NonNullable | undefined + ): Promise { + const adapter = this.owners.get(sessionId) + if (!adapter) { + return false + } + const stop = selectStop(adapter) + const stopped = await stop?.call(adapter, sessionId) + if (stopped === true) { + this.owners.delete(sessionId) + return true + } + return false + } + + async closeAll(): Promise { + this.owners.clear() + await this.closeAdapters() + } + + private owner(sessionId: string): StructuredAgentSessionAdapter { + const adapter = this.owners.get(sessionId) + if (!adapter) { + throw new Error(`no live structured adapter owns ${sessionId}`) + } + return adapter + } + + private requireAgent(identity: AgentSessionJournalIdentity): StructuredAgentSessionAdapter { + const adapter = this.adapterForAgent(identity.agent) + if (!adapter) { + throw new Error(`structured sessions do not support ${identity.agent}`) + } + return adapter + } + + private adapterForAgent(agent: string): StructuredAgentSessionAdapter | null { + return agent === 'claude' || agent === 'codex' ? this.adapters[agent] : null + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts index 5f67240c1c6..77cce9153f5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { AgentSessionAcquisitionExitUnprovenError, + AgentSessionAcquisitionRootExitObservedError, rethrowAfterAgentSessionAcquisitionCleanup } from './structured-agent-session-adapter' @@ -28,6 +29,27 @@ describe('failed agent-session acquisition cleanup', () => { ).rejects.toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) }) + it('keeps a first-hand root exit that cleanup observed, with the provider diagnostic', async () => { + const cause = new Error('proof failed') + const exit = new AgentSessionAcquisitionRootExitObservedError( + new Error('claude stream-json exited (code 1): crashed') + ) + const error = await rethrowAfterAgentSessionAcquisitionCleanup( + { + releaseAcquisition: vi.fn(async () => { + throw exit + }) + }, + 'session-1', + cause + ).catch((thrown: unknown) => thrown) + + expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) + expect(error).not.toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) + expect((error as Error).message).toBe('claude stream-json exited (code 1): crashed') + expect((error as Error).cause).toMatchObject({ errors: [cause, exit] }) + }) + it('reports unproven exit when cleanup throws', async () => { const error = await rethrowAfterAgentSessionAcquisitionCleanup( { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index cccc8ce6f13..01c16a60e55 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -32,6 +32,20 @@ export class AgentSessionAcquisitionRefusal extends Error { } } +/** + * The provider's own root process was observed to exit, but its descendant tree + * could not be verified. The lease keys on the root's pid and start time, so its + * observed death releases the reservation; nothing is claimed about descendants. + * Never thrown when a descendant was observed still alive — that stays unproven. + */ +export class AgentSessionAcquisitionRootExitObservedError extends Error { + constructor(cause: unknown) { + // The provider's own diagnostic is the only thing the user can act on. + super(cause instanceof Error ? cause.message : String(cause), { cause }) + this.name = 'AgentSessionAcquisitionRootExitObservedError' + } +} + export class AgentSessionAcquisitionExitUnprovenError extends Error { constructor(cause: unknown) { super('agent_session_acquisition_exit_unproven', { cause }) @@ -49,7 +63,7 @@ export type AgentSessionAcquisition = { acquisitionGeneration?: string } -/** Acquisition validation failed before the adapter attempted to spawn. */ +/** Acquisition failed with first-hand proof that no provider process existed. */ export class AgentSessionPreSpawnError extends Error { constructor(cause: unknown) { super(cause instanceof Error ? cause.message : String(cause), { cause }) @@ -105,7 +119,9 @@ export type StructuredAgentSessionAdapter = { * at — the store rejects a link minted at any other fence. */ acquire(input: StructuredAgentSessionAcquireInput): Promise /** Reaps an acquired provider when the host cannot commit or prove its lease. - * Returns true only after provider child exit is proven. */ + * Returns true only after provider child exit is proven. Throws + * `AgentSessionAcquisitionRootExitObservedError` when the provider root's own + * exit was observed first-hand but its descendants could not be verified. */ releaseAcquisition?(input: { sessionId: string }): Promise dispatch(input: { sessionId: string @@ -133,6 +149,8 @@ export type StructuredAgentSessionAdapter = { input: StructuredAgentSessionSetOptionInput ): Promise>> readOptions?(input: { sessionId: string; fence: number }): Promise + /** Option keys skipped after a provider rejected their persisted restore value. */ + readOptionRestoreFailures?(sessionId: string): readonly string[] /** Transcript path for journal recovery. Omit to let the existing session-file * resolver discover it from the provider session id. */ historyFilePath?(input: { identity: AgentSessionJournalIdentity }): Promise @@ -154,9 +172,15 @@ export async function rethrowAfterAgentSessionAcquisitionCleanup( try { released = (await adapter.releaseAcquisition?.({ sessionId })) === true } catch (cleanupError) { - throw new AgentSessionAcquisitionExitUnprovenError( - new AggregateError([cause, cleanupError], 'agent session acquisition cleanup failed') - ) + // A root exit the cleanup observed first-hand keeps its classification and its + // provider diagnostic; the failure that triggered cleanup rides along as cause. + throw cleanupError instanceof AgentSessionAcquisitionRootExitObservedError + ? new AgentSessionAcquisitionRootExitObservedError( + new AggregateError([cause, cleanupError], cleanupError.message) + ) + : new AgentSessionAcquisitionExitUnprovenError( + new AggregateError([cause, cleanupError], 'agent session acquisition cleanup failed') + ) } if (released) { throw cause diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts index 05c3d8c9e5e..7113be8d54b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts @@ -5,7 +5,6 @@ import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types' -import type { AgentSessionAttachParams } from './structured-agent-session-attach' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionHostDeps, @@ -30,8 +29,6 @@ export type StructuredAgentSessionAttachContext = { } tasks: StructuredAgentSessionTaskQueue reconcileLeases: (sessionId: string) => Promise - /** Retries a durable provider-exit journal settlement before a new owner is reserved. */ - retryPendingSettlement?: (sessionId: string, params: AgentSessionAttachParams) => Promise serialize: (sessionId: string, task: () => Promise) => Promise now: () => number } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index a21edcee35c..b08a56ea4d9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -26,6 +26,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { AgentSessionAcquisitionExitUnprovenError, + AgentSessionAcquisitionRootExitObservedError, AgentSessionAcquisitionRefusal, AgentSessionPreSpawnError, isAgentSessionPreSpawnError, @@ -119,7 +120,9 @@ export async function performAttach( ? 'processless' : error instanceof AgentSessionAcquisitionExitUnprovenError ? 'unproven' - : 'exit-proven' + : error instanceof AgentSessionAcquisitionRootExitObservedError + ? 'root-exit-observed' + : 'exit-proven' const outcome = error instanceof AgentSessionAcquisitionExitUnprovenError ? { @@ -209,13 +212,17 @@ async function settlePostAcquisitionAttachFailure( cause: unknown ): Promise { let cleanupError: unknown = cause - let exitProof: 'exit-proven' | 'unproven' = 'unproven' + let exitProof: 'exit-proven' | 'root-exit-observed' | 'unproven' = 'unproven' try { await rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, cause) } catch (error) { cleanupError = error exitProof = - error instanceof AgentSessionAcquisitionExitUnprovenError ? 'unproven' : 'exit-proven' + error instanceof AgentSessionAcquisitionExitUnprovenError + ? 'unproven' + : error instanceof AgentSessionAcquisitionRootExitObservedError + ? 'root-exit-observed' + : 'exit-proven' } // Why: the close is awaited so the map entry is gone only once its handle is // released, but a failed close must not also cost the store settlement below. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index f4551ef9313..a22bbdcbb3e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -17,6 +17,7 @@ import { pinnedAgentSessionLaunchEnv } from './structured-agent-session-launch-env' import { refuseAgentSessionMutation } from './structured-agent-session-mutation-admission' +import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry' @@ -41,14 +42,20 @@ export function attachStructuredAgentSession( return refuseAgentSessionMutation(unreconciled) } await context.runtimeState.resolveRecovery(sessionId) - if (context.retryPendingSettlement) { - const settled = await context.retryPendingSettlement(sessionId, params) - if (!settled) { - return refuseAgentSessionMutation({ - code: 'agent_session_ownership_unknown', - message: 'The provider-exit terminal journal settlement is still pending; retry attach.' - }) - } + // Retries a durable provider-exit journal settlement before a new owner is reserved. Answers + // settled when the record has none pending, so every attach can ask unconditionally. + const settled = await retryPendingStructuredAgentSessionSettlement({ + deps: context.deps, + sessions: context.sessions, + sessionId, + params, + now: () => context.now() + }) + if (!settled) { + return refuseAgentSessionMutation({ + code: 'agent_session_ownership_unknown', + message: 'The provider-exit terminal journal settlement is still pending; retry attach.' + }) } const eventSink = context.runtimeState.eventSinkFor(sessionId) const attached = await performAttach({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 83766f25fc5..ce58e31b4ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -5,7 +5,6 @@ // the record store's compare-and-swap, which also owns the idempotency row, so // a retried attach replays instead of reserving a second owner. -import type { AgentType } from '../../../shared/agent-status-types' import type { AgentSessionJournalIdentity, AgentSessionProviderHandle @@ -52,7 +51,7 @@ export type AgentSessionAttachParams = { envelope: AgentSessionMutationEnvelope location: AgentSessionExecutionLocation provider: AgentSessionHandleProvider - agent: AgentType + agent: AgentSessionHandleProvider accountHome: AgentSessionAccountHome runtimeKind: AgentSessionOwnerRuntimeKind /** Omitted only for create-by-intent; the adapter proves the durable handle. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-options-round-trip.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-options-round-trip.test.ts new file mode 100644 index 00000000000..87bc33bc4b9 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-options-round-trip.test.ts @@ -0,0 +1,182 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { + AgentSessionHandoffDirection, + AgentSessionHandoffRequest, + AgentSessionMutationEnvelope +} from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import type { + StructuredAgentSessionHandoffTransport, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +const CALLER = { callerKey: 'client-claude' } +const CLAUDE_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' +const DEFAULT_MODEL = 'sonnet' +const PICKED_MODEL = 'opus' + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +let activeModel: string +let transcriptPath: string + +function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +function handoff(direction: AgentSessionHandoffDirection): AgentSessionHandoffRequest { + const fields = { direction, mode: 'now' as const, action: 'start' as const } + return { envelope: envelope('agentSession.requestHandoff', fields), ...fields } +} + +function owner(fence: number, spawnToken: string): StructuredTuiOwner { + return { + terminal: { + handle: 'term-claude', + tabId: 'tab-claude', + paneKey: 'pane-claude', + ptyId: 'pty-claude' + }, + process: { hostId: 'local', pid: 5200, processStartTimeMs: NOW, spawnToken }, + link: { + linkId: `claude-tui-${fence}`, + handle: { provider: 'claude', sessionId: CLAUDE_SESSION, leafUuid: 'tui-leaf' }, + origin: 'resumed', + mintedAtFence: fence, + observedAt: NOW + }, + transcriptPath + } +} + +function transport(): StructuredAgentSessionHandoffTransport { + return { + hostLabel: 'Test host', + launchTui: async ({ fence, spawnToken }) => owner(fence, spawnToken), + reproveTuiOwner: async ({ owner: current }) => current, + recoverTuiOwner: async (record) => + owner( + record.lease.runtimeFence, + record.lease.ownerProcess?.spawnToken ?? record.lease.reservedSpawnToken ?? 'recovered' + ), + stopRecoveredOwner: async () => undefined, + waitForTuiExit: async (current) => ({ transcriptPath: current.transcriptPath }), + waitForTuiIdleOrExit: async () => 'idle', + tuiStatus: () => 'idle' + } +} + +function adapter(): StructuredAgentSessionAdapter { + acquire = vi.fn(async ({ fence, spawnToken, options }) => { + activeModel = options?.model ?? DEFAULT_MODEL + return { + process: { hostId: 'local', pid: 4200, processStartTimeMs: NOW, spawnToken }, + link: { + linkId: `claude-native-${fence}`, + handle: { provider: 'claude', sessionId: CLAUDE_SESSION, leafUuid: 'native-leaf' }, + origin: acquire.mock.calls.length === 1 ? 'created' : 'resumed', + mintedAtFence: fence, + observedAt: NOW + } + } + }) + return { + acquire, + dispatch: vi.fn(), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async ({ value }) => { + activeModel = value + return { model: value } + }), + readOptions: vi.fn(async () => ({ current: { model: activeModel }, models: [] })), + closeSession: vi.fn(async () => { + activeModel = DEFAULT_MODEL + return true + }) + } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-handoff-options-')) + resetHostTestOperationIds() + activeModel = DEFAULT_MODEL + transcriptPath = join(root, 'claude.jsonl') + await writeFile(transcriptPath, '', 'utf8') + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-claude', + handoffTransport: transport(), + now: () => NOW + }) + expect( + await host.attach( + CALLER, + hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: CLAUDE_SESSION, leafUuid: 'native-leaf' } + }) + ) + ).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)) + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }) +}) + +describe('Claude structured session handoff options', () => { + it('keeps a directly selected model through chat to TUI to chat', async () => { + const fields = { key: 'model', value: PICKED_MODEL } + expect( + await host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', fields), + ...fields + }) + ).toMatchObject({ ok: true, value: { options: { model: PICKED_MODEL } } }) + + expect(await host.requestHandoff(CALLER, handoff('to-tui'))).toMatchObject({ ok: true }) + await vi.waitFor(async () => + expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'tui' }) + ) + expect(await host.requestHandoff(CALLER, handoff('to-native'))).toMatchObject({ ok: true }) + await vi.waitFor(async () => + expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'native' }) + ) + + expect(acquire.mock.calls[1]?.[0].options).toEqual({ model: PICKED_MODEL }) + expect(store.getRecord(SESSION)?.options).toEqual({ model: PICKED_MODEL }) + expect(activeModel).toBe(PICKED_MODEL) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts new file mode 100644 index 00000000000..6082ab074f5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts @@ -0,0 +1,166 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import { encodeAgentSessionQuestionAnswers } from '../../../shared/agent-session-question-answer' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +const attachParams = (): AgentSessionAttachParams => hostTestAttachParams(null) + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +let answerPrompt: Mock +let ordinal = 0 + +function adapter(): StructuredAgentSessionAdapter { + const dispatch = vi.fn(async (): Promise => { + ordinal += 1 + return { + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal } + } + }) + return { + acquire, + releaseAcquisition: vi.fn(async () => true), + dispatch, + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt, + setOption: vi.fn(async () => undefined) + } +} + +async function seedGroupedQuestion(): Promise<{ itemId: string; revision: number }> { + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: journalDirectoryFor(root, { workspaceId: 'workspace-1', sessionId: SESSION }) + }) + const appended = await journal.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 100 }, + { + kind: 'question', + question: '2 grouped questions from Claude', + options: [], + questions: [ + { + id: 'q1', + question: 'Targets', + multiSelect: true, + options: [ + { id: 'target-web', label: 'Web' }, + { id: 'target-mobile', label: 'Mobile' } + ] + }, + { + id: 'q2', + question: 'Host', + multiSelect: false, + options: [], + freeTextQuestionId: 'q2' + } + ], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { fence: 1 } + ) + return { itemId: appended.itemId, revision: appended.revision } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-wire-grouped-')) + resetHostTestOperationIds() + ordinal = 0 + acquire = vi.fn(async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + })) + answerPrompt = vi.fn(async () => undefined) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('grouped question admission', () => { + it('admits renderer question-group payloads with child ids and multi-select answers', async () => { + const prompt = await seedGroupedQuestion() + const attached = await host.attach(CALLER, attachParams()) + expect(attached.ok).toBe(true) + const optionId = encodeAgentSessionQuestionAnswers([ + { questionId: 'q1', optionIds: ['target-web', 'target-mobile'] }, + { questionId: 'q2', optionIds: [], other: 'SSH host' } + ]) + const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId } + const result = await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:question', fields), + kind: 'question', + ...fields + }) + expect(result).toMatchObject({ ok: true, value: { resolution: { state: 'resolved' } } }) + expect(answerPrompt).toHaveBeenCalledWith( + expect.objectContaining({ itemId: prompt.itemId, optionId }) + ) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-admission.ts new file mode 100644 index 00000000000..b881d55e771 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-admission.ts @@ -0,0 +1,138 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffResult, + AgentSessionHandoffStatus, + AgentSessionMutationResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import { AGENT_SESSION_WIRE_REFUSAL_CODES } from '../../../shared/agent-session-wire' +import { + agentSessionFingerprintConflict, + computeAgentSessionPayloadFingerprint +} from '../../../shared/agent-session-mutation-envelope' +import type { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' +import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types' + +export type StructuredHandoffAdmission = + | { decision: 'continue'; record: AgentSessionRecord; fingerprint: string } + | { decision: 'replay'; outcome: AgentSessionOperationOutcome } + | { decision: 'refused'; refusal: AgentSessionWireRefusal } + +export async function admitStructuredHandoffRequest(input: { + deps: StructuredAgentSessionHandoffDeps + operationGuard: StructuredAgentSessionHandoffOperationGuard + callerKey: string + params: AgentSessionHandoffRequest + record: AgentSessionRecord + status?: AgentSessionHandoffStatus +}): Promise { + const action = input.params.action ?? 'start' + const requestFingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.requestHandoff', + sessionId: input.record.sessionId, + fields: { direction: input.params.direction, mode: input.params.mode, action } + }) + const conflict = agentSessionFingerprintConflict(input.params.envelope, requestFingerprint) + if (conflict) { + return { decision: 'refused', refusal: conflict } + } + const fingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.requestHandoff.operation', + sessionId: input.record.sessionId, + fields: { direction: input.params.direction } + }) + const operation = await input.operationGuard.check({ + callerKey: input.callerKey, + sessionId: input.record.sessionId, + operationId: input.params.envelope.clientOperationId, + fingerprint, + action, + ...(input.status ? { status: input.status } : {}), + now: input.deps.now() + }) + if (operation.decision === 'replay') { + return { decision: 'replay', outcome: operation.outcome } + } + if (operation.decision === 'refused') { + return { + decision: 'refused', + refusal: { + code: operation.code as 'agent_session_operation_conflict', + message: 'This handoff operation could not be admitted.' + } + } + } + if (input.params.envelope.expectedRuntimeFence !== input.record.lease.runtimeFence) { + await input.deps.store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: input.params.envelope.clientOperationId, + outcome: { status: 'failed', code: 'agent_session_checkpoint_stale' } + }) + input.operationGuard.finish(input.record.sessionId, input.params.envelope.clientOperationId) + return { + decision: 'refused', + refusal: { + code: 'agent_session_checkpoint_stale', + message: 'The session owner changed before the handoff request arrived.', + currentFence: input.record.lease.runtimeFence + } + } + } + return { decision: 'continue', record: input.record, fingerprint } +} + +export function replayedStructuredHandoffRefusal( + outcome: AgentSessionOperationOutcome +): AgentSessionWireRefusal | null { + if ( + outcome.status !== 'failed' || + !AGENT_SESSION_WIRE_REFUSAL_CODES.includes( + outcome.code as (typeof AGENT_SESSION_WIRE_REFUSAL_CODES)[number] + ) + ) { + return null + } + return { + code: outcome.code as (typeof AGENT_SESSION_WIRE_REFUSAL_CODES)[number], + message: 'This handoff request was previously refused.' + } +} + +export async function refuseAdmittedStructuredHandoff(input: { + deps: StructuredAgentSessionHandoffDeps + callerKey: string + params: AgentSessionHandoffRequest + refusal: AgentSessionWireRefusal +}): Promise> { + await input.deps.store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: input.params.envelope.clientOperationId, + outcome: { status: 'failed', code: input.refusal.code } + }) + return { ok: false, refusal: input.refusal } +} + +export function structuredHandoffRetryIsAdmissible( + status: AgentSessionHandoffStatus, + params: AgentSessionHandoffRequest +): boolean { + return ( + status.phase === 'failed' && + status.direction === params.direction && + status.operationId === params.envelope.clientOperationId && + status.error?.recoverableOwner !== 'none' + ) +} + +export function structuredHandoffRetryResumesStoppedOwner( + record: AgentSessionRecord, + params: AgentSessionHandoffRequest +): boolean { + return ( + record.lease.claimStatus === 'released' && + record.lease.handoffStage === 'old-owner-stopped' && + record.lease.handoffOperationId === params.envelope.clientOperationId + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.test.ts new file mode 100644 index 00000000000..7f2bec98962 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.test.ts @@ -0,0 +1,98 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { StructuredAgentSessionHandoffFlowRunner } from './structured-agent-session-handoff-flow-runner' +import { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' +import type { StructuredAgentSessionHandoffFlowContext } from './structured-agent-session-handoff-types' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-flow-runner-outcome-write-failure' +const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' +const OPERATION = `${NOW}-00000000000000000000000000000002` +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('structured handoff flow runner outcome-write failure', () => { + it('still reports the flow failure when the failed-outcome ledger write throws', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-handoff-flow-runner-')) + roots.push(root) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + // Materialize the store file so its later disappearance reads as corruption, + // making every subsequent ledger write reject. + await store.admitOperation({ + callerKey: 'seed', + operationId: `${NOW}-00000000000000000000000000000009`, + fingerprint: 'seed', + now: NOW + }) + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: join(root, 'journal') + }) + await rm(join(root, 'store'), { recursive: true, force: true }) + const failures: unknown[] = [] + const fields = { + direction: 'to-native' as const, + mode: 'now' as const, + action: 'retry' as const + } + const params: AgentSessionHandoffRequest = { + envelope: { + sessionId: SESSION, + clientOperationId: OPERATION, + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.requestHandoff', + sessionId: SESSION, + fields + }) + }, + ...fields + } + const runner = new StructuredAgentSessionHandoffFlowRunner({ + deps: { + store, + claimKeyId: 'key-1', + session: () => ({ journal, fence: 1 }), + suspendNative: async () => ({ state: 'stopped' as const }), + acquireNative: async () => { + throw new Error('unused') + }, + importTuiHistory: async () => {}, + publish: () => {}, + schedule: async () => { + throw new Error('scheduling failed') + }, + now: () => NOW + }, + operationGuard: new StructuredAgentSessionHandoffOperationGuard(store), + flowContext: (): StructuredAgentSessionHandoffFlowContext => { + throw new Error('unreachable: scheduling rejects before the flow needs context') + }, + fail: (_params, error) => { + failures.push(error) + } + }) + runner.begin({ callerKey: 'client-1', params, turnId: null, fingerprint: 'fp' }) + await runner.drain() + expect(failures).toHaveLength(1) + expect((failures[0] as Error).message).toBe('scheduling failed') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts new file mode 100644 index 00000000000..7278502ce1f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts @@ -0,0 +1,110 @@ +import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' +import { stopStructuredNativeTurn } from './structured-agent-session-handoff-flow-context' +import { handoffStructuredSessionToTui } from './structured-agent-session-handoff-forward' +import type { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' +import { assertScheduledStructuredHandoffIsAdmissible } from './structured-agent-session-handoff-revalidation' +import { handoffStructuredSessionToNative } from './structured-agent-session-handoff-reverse' +import { structuredTuiStatus } from './structured-agent-session-handoff-status' +import type { + StructuredAgentSessionHandoffDeps, + StructuredAgentSessionHandoffFlowContext +} from './structured-agent-session-handoff-types' + +export class StructuredAgentSessionHandoffFlowRunner { + private readonly active = new Set>() + + constructor( + private readonly input: { + deps: StructuredAgentSessionHandoffDeps + operationGuard: StructuredAgentSessionHandoffOperationGuard + flowContext: () => StructuredAgentSessionHandoffFlowContext + fail: (params: AgentSessionHandoffRequest, error: unknown) => void + } + ) {} + + async drain(): Promise { + await Promise.allSettled(this.active) + } + + track(task: Promise): void { + this.active.add(task) + void task.finally(() => this.active.delete(task)) + } + + begin(input: { + callerKey: string + params: AgentSessionHandoffRequest + turnId: string | null + fingerprint: string + tuiAlreadyExited?: boolean + }): void { + const { callerKey, params, turnId, fingerprint, tuiAlreadyExited = false } = input + const sessionId = params.envelope.sessionId + const journalSequence = this.input.deps.session(sessionId).journal.cursor().sequence + this.input.operationGuard.start(sessionId, { + callerKey, + operationId: params.envelope.clientOperationId, + fingerprint + }) + const flow = this.run(params, turnId, tuiAlreadyExited, journalSequence) + .then(() => { + this.input.operationGuard.finish(sessionId, params.envelope.clientOperationId) + return this.input.deps.store.recordOperationOutcome({ + callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'succeeded', sessionId } + }) + }) + .catch(async (error) => { + try { + await this.input.deps.store.recordOperationOutcome({ + callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'failed', code: 'agent_session_handoff_failed' } + }) + } catch { + // Best-effort: a store write failure must not suppress the client's failure + // notification or leak the flow as an unhandled rejection. + } + this.input.operationGuard.finish(sessionId, params.envelope.clientOperationId) + this.input.fail(params, error) + }) + .finally(() => this.input.operationGuard.finish(sessionId, params.envelope.clientOperationId)) + this.track(flow) + } + + private run( + params: AgentSessionHandoffRequest, + turnId: string | null, + tuiAlreadyExited: boolean, + journalSequence: number + ): Promise { + const sessionId = params.envelope.sessionId + return this.input.deps.schedule(sessionId, async () => { + const context = this.input.flowContext() + assertScheduledStructuredHandoffIsAdmissible({ + record: context.requireRecord(sessionId), + journal: this.input.deps.session(sessionId).journal, + params, + turnId, + journalSequence, + tuiAlreadyExited, + tuiStatus: structuredTuiStatus(context.owner(sessionId), this.input.deps.transport) + }) + if (turnId && params.mode === 'stop-turn') { + const stopped = await stopStructuredNativeTurn(this.input.deps, sessionId, turnId) + if (!stopped) { + throw new Error('The current turn did not acknowledge cancellation.') + } + } + await (params.direction === 'to-tui' + ? handoffStructuredSessionToTui(context, params, params.action === 'retry') + : handoffStructuredSessionToNative( + context, + params, + params.action === 'retry', + tuiAlreadyExited + )) + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.test.ts new file mode 100644 index 00000000000..7c77806ad91 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.test.ts @@ -0,0 +1,221 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffStatus +} from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { + queuedStructuredHandoffCanBegin, + StructuredAgentSessionHandoffQueue +} from './structured-agent-session-handoff-queue' +import { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' +import { assertScheduledStructuredHandoffIsAdmissible } from './structured-agent-session-handoff-revalidation' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-alpha-1' +const OPERATION_A = `${NOW}-00000000000000000000000000000001` +const OPERATION_B = `${NOW}-00000000000000000000000000000002` + +let root: string | null = null + +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } +}) + +async function createGuard() { + root = await mkdtemp(join(tmpdir(), 'orca-handoff-operation-guard-')) + const store = await AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) + return { guard: new StructuredAgentSessionHandoffOperationGuard(store), store } +} + +function status(phase: 'switching' | 'queued' | 'idle'): AgentSessionHandoffStatus { + return { + owner: phase === 'idle' ? 'native' : 'none', + direction: phase === 'idle' ? null : 'to-tui', + phase, + stage: phase === 'switching' ? 'preparing' : null, + operationId: phase === 'idle' ? null : OPERATION_A + } +} + +describe('structured handoff operation ownership', () => { + it('reserves one winner across concurrent admissions', async () => { + const { guard } = await createGuard() + const check = (operationId: string) => + guard.check({ + callerKey: operationId, + sessionId: SESSION, + operationId, + fingerprint: operationId, + action: 'start', + now: NOW + }) + + const decisions = await Promise.all([check(OPERATION_A), check(OPERATION_B)]) + + expect(decisions.map(({ decision }) => decision).sort()).toEqual(['new', 'refused']) + }) + + it.each(['switching', 'queued'] as const)( + 'durably refuses a distinct operation while the %s operation owns the session', + async (phase) => { + const { guard } = await createGuard() + guard.start(SESSION, { + callerKey: 'client-a', + operationId: OPERATION_A, + fingerprint: 'fingerprint-a' + }) + + expect( + await guard.check({ + callerKey: 'client-b', + sessionId: SESSION, + operationId: OPERATION_B, + fingerprint: 'fingerprint-b', + action: 'start', + status: status(phase), + now: NOW + }) + ).toEqual({ decision: 'refused', code: 'agent_session_operation_conflict' }) + + guard.finish(SESSION, OPERATION_A) + expect( + await guard.check({ + callerKey: 'client-b', + sessionId: SESSION, + operationId: OPERATION_B, + fingerprint: 'fingerprint-b', + action: 'start', + status: status('idle'), + now: NOW + }) + ).toMatchObject({ + decision: 'replay', + outcome: { status: 'failed', code: 'agent_session_operation_conflict' } + }) + } + ) + + it('admits only cancellation beside a queued operation', async () => { + const { guard } = await createGuard() + guard.start(SESSION, { + callerKey: 'client-a', + operationId: OPERATION_A, + fingerprint: 'fingerprint-a' + }) + + await expect( + guard.check({ + callerKey: 'client-b', + sessionId: SESSION, + operationId: OPERATION_B, + fingerprint: 'fingerprint-b', + action: 'cancel-queued', + status: status('queued'), + now: NOW + }) + ).resolves.toEqual({ decision: 'new' }) + }) +}) + +describe('queued handoff fence revalidation', () => { + const params: AgentSessionHandoffRequest = { + envelope: { + sessionId: SESSION, + clientOperationId: OPERATION_A, + expectedRuntimeFence: 7, + payloadFingerprint: 'fingerprint' + }, + direction: 'to-tui', + mode: 'after-turn', + action: 'start' + } + const queued = status('queued') + + it('accepts the same live owner and fence', () => { + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ runtimeKind: 'native', ownerProcess: null }) + ) + expect(queuedStructuredHandoffCanBegin(record, queued, params)).toBe(true) + }) + + it.each([ + agentSessionLeaseFixture({ runtimeKind: 'native', runtimeFence: 8, ownerProcess: null }), + agentSessionLeaseFixture({ runtimeKind: 'tui' }), + agentSessionLeaseFixture({ + runtimeKind: 'native', + ownerProcess: null, + handoffStage: 'preparing' + }) + ])('refuses a changed durable owner or fence', (lease) => { + expect(queuedStructuredHandoffCanBegin(agentSessionRecordFixture(lease), queued, params)).toBe( + false + ) + }) + + it('cannot cancel after the idle waiter claims the queued operation', async () => { + const queue = new StructuredAgentSessionHandoffQueue() + const ready = vi.fn() + queue.enqueue(SESSION, () => true, ready) + await vi.waitFor(() => expect(ready).toHaveBeenCalledOnce()) + expect(queue.cancel(SESSION)).toBe(false) + }) +}) + +describe('scheduled handoff revalidation', () => { + it('refuses a native turn accepted ahead of the scheduled handoff', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-handoff-revalidation-')) + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: SESSION, leafUuid: null } + }, + journalDir: join(root, 'journal') + }) + const journalSequence = journal.cursor().sequence + await journal.appendItem( + { provider: 'orca', clientMessageId: 'turn-running' }, + { kind: 'status', text: 'running', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, + { fence: 7 } + ) + const params: AgentSessionHandoffRequest = { + envelope: { + sessionId: SESSION, + clientOperationId: OPERATION_A, + expectedRuntimeFence: 7, + payloadFingerprint: 'fingerprint' + }, + direction: 'to-tui', + mode: 'now', + action: 'start' + } + + expect(() => + assertScheduledStructuredHandoffIsAdmissible({ + record: agentSessionRecordFixture( + agentSessionLeaseFixture({ runtimeKind: 'native', ownerProcess: null }) + ), + journal, + params, + turnId: null, + journalSequence, + tuiAlreadyExited: false, + tuiStatus: 'busy' + }) + ).toThrow('session changed') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts new file mode 100644 index 00000000000..da8d2eaad3d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts @@ -0,0 +1,125 @@ +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import type { + AgentSessionOperationOutcome, + AgentSessionOperationRefusalCode +} from '../../../shared/agent-session-operation-ledger' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' + +type ActiveOperation = { callerKey: string; operationId: string; fingerprint: string } + +export type HandoffOperationDecision = + | { decision: 'new' } + | { decision: 'replay'; outcome: AgentSessionOperationOutcome } + | { decision: 'retry' } + | { decision: 'refused'; code: AgentSessionOperationRefusalCode } + +export class StructuredAgentSessionHandoffOperationGuard { + private readonly activeBySession = new Map() + + constructor(private readonly store: AgentSessionRecordStore) {} + + async check(input: { + callerKey: string + sessionId: string + operationId: string + fingerprint: string + action: 'start' | 'cancel-queued' | 'retry' | 'recover' + status?: AgentSessionHandoffStatus + now: number + }): Promise { + const ledger = await this.store.admitOperation({ + callerKey: input.callerKey, + operationId: input.operationId, + fingerprint: input.fingerprint, + now: input.now + }) + if (ledger.decision === 'refused') { + return { decision: 'refused', code: ledger.code } + } + const active = this.activeBySession.get(input.sessionId) + const queuedCancellation = + input.action === 'cancel-queued' && + input.status?.phase === 'queued' && + input.status.operationId === active?.operationId + const activeConflict = Boolean( + active && + ((active.operationId === input.operationId && + (active.fingerprint !== input.fingerprint || active.callerKey !== input.callerKey)) || + (active.operationId !== input.operationId && !queuedCancellation)) + ) + const queuedConflict = Boolean( + !active && + input.status?.phase === 'queued' && + input.status.operationId !== input.operationId && + input.action !== 'cancel-queued' + ) + if (activeConflict || queuedConflict) { + if (ledger.decision === 'admit') { + await this.store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: input.operationId, + outcome: { status: 'failed', code: 'agent_session_operation_conflict' } + }) + } + return { decision: 'refused', code: 'agent_session_operation_conflict' } + } + if (ledger.decision === 'admit') { + this.reserve(input) + return { decision: 'new' } + } + if (input.action === 'retry' && ledger.row.outcome.status === 'failed') { + await this.store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: input.operationId, + outcome: { status: 'pending' } + }) + this.reserve(input) + return { decision: 'retry' } + } + if ( + ledger.row.outcome.status === 'pending' && + !active && + input.status?.operationId !== input.operationId + ) { + this.reserve(input) + return { decision: 'new' } + } + return { decision: 'replay', outcome: ledger.row.outcome } + } + + start(sessionId: string, operation: ActiveOperation): void { + this.activeBySession.set(sessionId, operation) + } + + private reserve(input: { + action: 'start' | 'cancel-queued' | 'retry' | 'recover' + callerKey: string + sessionId: string + operationId: string + fingerprint: string + }): void { + if (input.action !== 'cancel-queued') { + this.start(input.sessionId, input) + } + } + + finish(sessionId: string, operationId: string): void { + if (this.activeBySession.get(sessionId)?.operationId === operationId) { + this.activeBySession.delete(sessionId) + } + } + + async settle( + sessionId: string, + operationId: string, + outcome: AgentSessionOperationOutcome + ): Promise { + const active = this.activeBySession.get(sessionId) + await this.store.recordOperationOutcome({ + ...(active?.operationId === operationId ? { callerKey: active.callerKey } : {}), + operationId, + outcome + }) + this.finish(sessionId, operationId) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.test.ts new file mode 100644 index 00000000000..e5ee4f7ca9b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.test.ts @@ -0,0 +1,286 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { + AgentSessionHandoffDirection, + AgentSessionHandoffRequest, + AgentSessionMutationEnvelope +} from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { AgentSessionOptionRejectedError } from './structured-agent-session-option-error' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import type { + StructuredAgentSessionHandoffTransport, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +const CALLER = { callerKey: 'client-1' } +const DEFAULT_MODEL = 'gpt-default' +const PICKED_MODEL = 'gpt-picked' +const PICKED_EFFORT = 'medium' + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +let activeModel: string +let activeEffort: string | null +let transcriptPath: string +let optionFailure: Error | null +const dispatchedModels: string[] = [] +const launchedOptions: (Readonly> | undefined)[] = [] +const closedTuiOwners: StructuredTuiOwner[] = [] + +function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +function handoff(direction: AgentSessionHandoffDirection): AgentSessionHandoffRequest { + const fields = { direction, mode: 'now' as const, action: 'start' as const } + return { envelope: envelope('agentSession.requestHandoff', fields), ...fields } +} + +function tuiOwner(fence: number, spawnToken: string): StructuredTuiOwner { + return { + terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui', ptyId: 'pty-tui' }, + process: { + hostId: 'local', + pid: 5200, + processStartTimeMs: NOW, + spawnToken + }, + link: { + linkId: `tui-link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'resumed', + mintedAtFence: fence, + observedAt: NOW + }, + transcriptPath + } +} + +function handoffTransport(): StructuredAgentSessionHandoffTransport { + return { + hostLabel: 'Test host', + launchTui: async ({ record, fence, spawnToken }) => { + launchedOptions.push(record.options) + return tuiOwner(fence, spawnToken) + }, + reproveTuiOwner: async ({ owner }) => owner, + recoverTuiOwner: async (record) => + tuiOwner( + record.lease.runtimeFence, + record.lease.ownerProcess?.spawnToken ?? record.lease.reservedSpawnToken ?? 'recovered' + ), + stopRecoveredOwner: async () => undefined, + closeTuiOwner: async (owner) => { + closedTuiOwners.push(owner) + return { transcriptPath: owner.transcriptPath } + }, + waitForTuiExit: async (owner) => ({ transcriptPath: owner.transcriptPath }), + waitForTuiIdleOrExit: async () => 'idle', + tuiStatus: () => 'idle' + } +} + +function adapter(): StructuredAgentSessionAdapter { + acquire = vi.fn(async ({ fence, spawnToken, options }) => { + activeModel = options?.model ?? DEFAULT_MODEL + activeEffort = options?.effort ?? null + return { + process: { + hostId: 'local', + pid: 4200 + acquire.mock.calls.length, + processStartTimeMs: NOW, + spawnToken + }, + link: { + linkId: `native-link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: acquire.mock.calls.length === 1 ? 'created' : 'resumed', + mintedAtFence: fence, + observedAt: NOW + } + } + }) + return { + acquire, + dispatch: vi.fn(async () => { + dispatchedModels.push(activeModel) + return { + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } + } + }), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async ({ key, value }) => { + if (optionFailure) { + const error = optionFailure + optionFailure = null + throw error + } + if (key === 'model') { + activeModel = value + } else if (key === 'effort') { + activeEffort = value + } + return { + model: activeModel, + ...(activeEffort ? { effort: activeEffort } : {}) + } + }), + readOptions: vi.fn(async () => ({ + current: { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) }, + models: [] + })), + closeSession: vi.fn(async () => { + activeModel = DEFAULT_MODEL + return true + }) + } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-handoff-options-')) + resetHostTestOperationIds() + activeModel = DEFAULT_MODEL + activeEffort = null + optionFailure = null + dispatchedModels.length = 0 + launchedOptions.length = 0 + closedTuiOwners.length = 0 + const accountHome = join(root, 'codex-home') + const sessionsDir = join(accountHome, 'sessions', '2026', '08', '12') + transcriptPath = join(sessionsDir, `rollout-2026-08-12T10-00-00-${THREAD}.jsonl`) + await mkdir(sessionsDir, { recursive: true }) + await writeFile( + transcriptPath, + `${JSON.stringify({ + type: 'session_meta', + timestamp: '2026-08-12T10:00:00.000Z', + payload: { id: THREAD, session_id: THREAD } + })}\n`, + 'utf8' + ) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-native', + handoffTransport: handoffTransport(), + now: () => NOW + }) + const attached = await host.attach( + CALLER, + hostTestAttachParams(null, { accountHome: { variable: 'CODEX_HOME', path: accountHome } }) + ) + expect(attached).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('structured session handoff options', () => { + it('settles a pre-mutation rejection so a fresh retry can succeed', async () => { + optionFailure = new AgentSessionOptionRejectedError('model list unavailable') + const fields = { key: 'model', value: PICKED_MODEL } + const rejected = { + envelope: envelope('agentSession.setOption', fields), + ...fields + } + + expect(await host.setOption(CALLER, rejected)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid', message: 'model list unavailable' } + }) + expect(await host.setOption(CALLER, rejected)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + expect( + await host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', fields), + ...fields + }) + ).toMatchObject({ ok: true, value: { options: { model: PICKED_MODEL } } }) + expect(store.getRecord(SESSION)?.options).toEqual({ model: PICKED_MODEL }) + }) + + it('keeps a picked model through a native to TUI to native round trip', async () => { + const optionFields = { key: 'model', value: PICKED_MODEL } + expect( + await host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', optionFields), + ...optionFields + }) + ).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.options).toEqual({ model: PICKED_MODEL }) + + const effortFields = { key: 'effort', value: PICKED_EFFORT } + expect( + await host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', effortFields), + ...effortFields + }) + ).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.options).toEqual({ + model: PICKED_MODEL, + effort: PICKED_EFFORT + }) + + expect(await host.requestHandoff(CALLER, handoff('to-tui'))).toMatchObject({ ok: true }) + await vi.waitFor(async () => + expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'tui' }) + ) + expect(await host.requestHandoff(CALLER, handoff('to-native'))).toMatchObject({ ok: true }) + await vi.waitFor(async () => + expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'native' }) + ) + + expect(launchedOptions).toEqual([{ model: PICKED_MODEL, effort: PICKED_EFFORT }]) + expect(closedTuiOwners).toHaveLength(1) + expect(acquire.mock.calls[1]?.[0].options).toEqual({ + model: PICKED_MODEL, + effort: PICKED_EFFORT + }) + expect(store.getRecord(SESSION)?.options).toEqual({ + model: PICKED_MODEL, + effort: PICKED_EFFORT + }) + const body = hostTestMessage('use the selected model') + expect( + await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + ).toMatchObject({ ok: true }) + expect(dispatchedModels).toEqual([PICKED_MODEL]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.ts new file mode 100644 index 00000000000..a5afd9891a1 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.ts @@ -0,0 +1,23 @@ +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' + +export async function readNativeHandoffSessionOptions(input: { + adapter: Pick + sessionId: string + fence: number + priorOptions?: Readonly> +}): Promise> | undefined> { + const { adapter, sessionId, fence, priorOptions } = input + const reported = await adapter.readOptions?.({ + sessionId, + fence + }) + if (!reported) { + return undefined + } + const { model: _model, effort: _effort, ...restored } = priorOptions ?? {} + return { + ...restored, + model: reported.current.model, + ...(reported.current.effort ? { effort: reported.current.effort } : {}) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue-start.ts new file mode 100644 index 00000000000..f8d6db2bace --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue-start.ts @@ -0,0 +1,46 @@ +import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import type { StructuredAgentSessionHandoffQueue } from './structured-agent-session-handoff-queue' +import type { + StructuredAgentSessionHandoffDeps, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +export function queueStructuredHandoffAfterTurn(input: { + callerKey: string + params: AgentSessionHandoffRequest + deps: StructuredAgentSessionHandoffDeps + queue: StructuredAgentSessionHandoffQueue + owner: (sessionId: string) => StructuredTuiOwner | undefined + setStatus: ( + sessionId: string, + status: Parameters[1] + ) => void + begin: (callerKey: string, params: AgentSessionHandoffRequest, tuiAlreadyExited?: boolean) => void +}): void { + const { callerKey, params, deps, queue, owner, setStatus, begin } = input + const sessionId = params.envelope.sessionId + let tuiReadiness: 'idle' | 'exited' | null = null + setStatus(sessionId, { + owner: params.direction === 'to-tui' ? 'native' : 'tui', + direction: params.direction, + phase: 'queued', + stage: null, + operationId: params.envelope.clientOperationId, + hostLabel: deps.transport?.hostLabel + }) + const tuiOwner = owner(sessionId) + queue.enqueue( + sessionId, + async (signal) => { + if (params.direction === 'to-tui') { + return !activeStructuredAgentSessionTurnId(deps.session(sessionId).journal.snapshot().items) + } + tuiReadiness = tuiOwner + ? ((await deps.transport?.waitForTuiIdleOrExit(tuiOwner, signal)) ?? null) + : null + return tuiReadiness !== null + }, + () => begin(callerKey, { ...params, mode: 'now' }, tuiReadiness === 'exited') + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts new file mode 100644 index 00000000000..9ea3d7ff08f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts @@ -0,0 +1,133 @@ +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffStatus +} from '../../../shared/agent-session-wire' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import type { + StructuredAgentSessionHandoffDeps, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +export class StructuredAgentSessionHandoffQueue { + private readonly controllers = new Map() + + cancel(sessionId: string): boolean { + const controller = this.controllers.get(sessionId) + controller?.abort() + this.controllers.delete(sessionId) + return controller !== undefined + } + + enqueue( + sessionId: string, + isIdle: (signal: AbortSignal) => boolean | Promise, + onReady: () => void + ): void { + this.cancel(sessionId) + const controller = new AbortController() + this.controllers.set(sessionId, controller) + void this.waitUntilIdle(sessionId, controller, isIdle).then((ready) => { + if (ready) { + onReady() + } + }) + } + + private async waitUntilIdle( + sessionId: string, + controller: AbortController, + isIdle: (signal: AbortSignal) => boolean | Promise + ): Promise { + while (this.controllers.get(sessionId) === controller && !controller.signal.aborted) { + try { + if (await isIdle(controller.signal)) { + this.controllers.delete(sessionId) + return true + } + } catch { + if (controller.signal.aborted) { + return false + } + } + await new Promise((resolve) => setTimeout(resolve, 150)) + } + return false + } +} + +export function queuedStructuredHandoffCanBegin( + record: AgentSessionRecord, + status: AgentSessionHandoffStatus, + params: AgentSessionHandoffRequest +): boolean { + const expectedOwner = params.direction === 'to-tui' ? 'native' : 'tui' + return ( + record.sessionId === params.envelope.sessionId && + status.phase === 'queued' && + status.direction === params.direction && + status.operationId === params.envelope.clientOperationId && + record.lease.runtimeFence === params.envelope.expectedRuntimeFence && + record.lease.runtimeKind === expectedOwner && + record.lease.claimStatus === 'live' && + record.lease.handoffStage === null && + !record.lease.unreconciled + ) +} + +export function enqueueStructuredHandoffAfterTurn(input: { + deps: StructuredAgentSessionHandoffDeps + queue: StructuredAgentSessionHandoffQueue + params: AgentSessionHandoffRequest + tuiOwner: StructuredTuiOwner | undefined + status: () => AgentSessionHandoffStatus + requireRecord: () => AgentSessionRecord + setStatus: (status: AgentSessionHandoffStatus) => void + begin: (params: AgentSessionHandoffRequest, tuiAlreadyExited: boolean) => void + refuse: (record: AgentSessionRecord) => void +}): void { + const { deps, params, queue, tuiOwner } = input + const sessionId = params.envelope.sessionId + let tuiReadiness: 'idle' | 'exited' | null = null + let observedTuiQueue = false + input.setStatus({ + owner: params.direction === 'to-tui' ? 'native' : 'tui', + direction: params.direction, + phase: 'queued', + stage: null, + operationId: params.envelope.clientOperationId, + hostLabel: deps.transport?.hostLabel + }) + queue.enqueue( + sessionId, + async (signal) => { + if (params.direction === 'to-tui') { + return !activeStructuredAgentSessionTurnId(deps.session(sessionId).journal.snapshot().items) + } + if (!observedTuiQueue) { + observedTuiQueue = true + return false + } + tuiReadiness = tuiOwner + ? ((await deps.transport?.waitForTuiIdleOrExit(tuiOwner, signal)) ?? null) + : null + if (tuiReadiness === 'exited') { + return true + } + if (!activeStructuredAgentSessionTurnId(deps.session(sessionId).journal.snapshot().items)) { + tuiReadiness = 'idle' + return true + } + return false + }, + () => { + const record = input.requireRecord() + const status = input.status() + if (!queuedStructuredHandoffCanBegin(record, status, params)) { + input.refuse(record) + return + } + input.begin(params, tuiReadiness === 'exited') + } + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-recover.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-recover.ts new file mode 100644 index 00000000000..0aab4f0335b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-recover.ts @@ -0,0 +1,30 @@ +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffStatus +} from '../../../shared/agent-session-wire' +import { + beginStructuredManualRecovery, + structuredManualRecoveryIsAdmissible +} from './structured-agent-session-manual-recovery' +import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types' +import type { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' + +export async function requestStructuredManualRecovery(input: { + deps: StructuredAgentSessionHandoffDeps + operationGuard: StructuredAgentSessionHandoffOperationGuard + callerKey: string + params: AgentSessionHandoffRequest + fingerprint: string + record: AgentSessionRecord + status: AgentSessionHandoffStatus + requireRecord: (sessionId: string) => AgentSessionRecord + restore: (sessionId: string) => Promise + setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void +}): Promise { + if (!structuredManualRecoveryIsAdmissible(input.record, input.status)) { + return false + } + beginStructuredManualRecovery(input) + return true +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-result.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-result.ts new file mode 100644 index 00000000000..fe480d6359c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-result.ts @@ -0,0 +1,32 @@ +import type { + AgentSessionHandoffResult, + AgentSessionMutationResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types' + +export function structuredHandoffRefusal( + code: AgentSessionWireRefusal['code'], + message: string +): AgentSessionWireRefusal { + return { code, message } +} + +export function structuredHandoffSuccess( + deps: StructuredAgentSessionHandoffDeps, + sessionId: string, + replayed: boolean, + status: AgentSessionHandoffResult['status'] +): AgentSessionMutationResult { + const record = deps.store.getRecord(sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + return { + ok: true, + replayed, + fence: record.lease.runtimeFence, + cursor: deps.session(sessionId).journal.cursor(), + value: { status } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-revalidation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-revalidation.ts new file mode 100644 index 00000000000..66005959378 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-revalidation.ts @@ -0,0 +1,52 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { structuredHandoffRetryResumesStoppedOwner } from './structured-agent-session-handoff-admission' +import { structuredSessionHasPendingPrompt } from './structured-agent-session-handoff-status' + +export function assertScheduledStructuredHandoffIsAdmissible(input: { + record: AgentSessionRecord + journal: AgentSessionJournal + params: AgentSessionHandoffRequest + turnId: string | null + journalSequence: number + tuiAlreadyExited: boolean + tuiStatus: 'idle' | 'busy' +}): void { + const { params, record } = input + if (params.action === 'retry' && structuredHandoffRetryResumesStoppedOwner(record, params)) { + return + } + const expectedOwner = params.direction === 'to-tui' ? 'native' : 'tui' + if ( + record.lease.runtimeFence !== params.envelope.expectedRuntimeFence || + record.lease.runtimeKind !== expectedOwner || + record.lease.claimStatus !== 'live' || + record.lease.handoffStage !== null || + record.lease.unreconciled + ) { + throw new Error('agent_session_checkpoint_stale') + } + if (structuredSessionHasPendingPrompt(input.journal)) { + throw new Error('Resolve the pending question or approval before switching.') + } + if (params.mode !== 'stop-turn' && input.journal.cursor().sequence !== input.journalSequence) { + throw new Error('The session changed before the handoff started.') + } + const activeTurn = activeStructuredAgentSessionTurnId(input.journal.snapshot().items) + if (params.direction === 'to-tui') { + const expectedTurn = params.mode === 'stop-turn' ? input.turnId : null + if (activeTurn !== expectedTurn) { + throw new Error('The native turn changed before the handoff started.') + } + return + } + if ( + !input.tuiAlreadyExited && + input.tuiStatus !== 'idle' && + (params.mode !== 'after-turn' || activeTurn !== null) + ) { + throw new Error('The agent terminal became busy before the handoff started.') + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.test.ts new file mode 100644 index 00000000000..91c6163bd17 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { handoffStructuredSessionToNative } from './structured-agent-session-handoff-reverse' +import type { StructuredAgentSessionHandoffFlowContext } from './structured-agent-session-handoff-types' + +const OPERATION_ID = 'operation-1' +const SESSION_ID = 'session-1' + +vi.mock('../../runtime/agent-session-handoff-record-transitions', () => ({ + abandonStoredAgentSessionHandoffAttempt: vi.fn(async () => undefined), + reserveStoredAgentSessionHandoffOwner: vi.fn(async () => record()), + rollbackStoredAgentSessionHandoffPreparation: vi.fn(async () => undefined), + stopStoredAgentSessionOwnerForHandoff: vi.fn(async () => record()) +})) + +function record(): AgentSessionRecord { + return { + sessionId: SESSION_ID, + provider: 'claude', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + lease: { + runtimeFence: 3, + handoffStage: 'old-owner-stopped', + handoffOperationId: OPERATION_ID + } + } as unknown as AgentSessionRecord +} + +function contextWith( + revealNativeSession: () => Promise, + statuses: AgentSessionHandoffStatus[] +): StructuredAgentSessionHandoffFlowContext { + return { + deps: { + store: {} as never, + claimKeyId: 'key-1', + now: () => 1_800_000_000_000, + importTuiHistory: vi.fn(async () => undefined), + acquireNative: vi.fn(async () => record()), + transport: { revealNativeSession } + } as never, + owner: () => undefined, + retainOwner: vi.fn(), + releaseOwner: vi.fn(), + setStatus: (_sessionId, status) => statuses.push(status), + enterPreparing: vi.fn(async () => undefined), + publishStage: vi.fn(), + requireRecord: () => record() + } +} + +// Why this ordering matters: releaseOwner has already run by the time the reveal fires, +// so a reveal that rejects before the status flip leaves the session released but never +// marked native — a stuck chat with no owner on either side. +describe('handoffStructuredSessionToNative', () => { + it('marks the session native before revealing it', async () => { + const statuses: AgentSessionHandoffStatus[] = [] + const order: string[] = [] + const context = contextWith(async () => { + order.push('reveal') + }, statuses) + const setStatus = context.setStatus + context.setStatus = (sessionId, status) => { + order.push('status') + setStatus(sessionId, status) + } + + await handoffStructuredSessionToNative( + context, + { envelope: { sessionId: SESSION_ID, clientOperationId: OPERATION_ID } } as never, + true + ) + + expect(order).toEqual(['status', 'reveal']) + expect(statuses.at(-1)).toMatchObject({ owner: 'native', direction: null, phase: 'idle' }) + }) + + it('still leaves the session marked native when the reveal rejects', async () => { + const statuses: AgentSessionHandoffStatus[] = [] + const context = contextWith(async () => { + throw new Error('publish failed') + }, statuses) + + await expect( + handoffStructuredSessionToNative( + context, + { envelope: { sessionId: SESSION_ID, clientOperationId: OPERATION_ID } } as never, + true + ) + ).rejects.toThrow('publish failed') + + expect(statuses.at(-1)).toMatchObject({ owner: 'native' }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts index f59f7735245..ebfca81525c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts @@ -130,12 +130,8 @@ export async function handoffStructuredSessionToNative( throw error } context.releaseOwner(sessionId) - await deps.transport?.revealNativeSession?.({ - workspaceId: record.location.workspaceId, - sessionId, - agent: record.provider, - ...(owner?.adoptedTerminal ? { adoptedTerminal: true } : {}) - }) + // Why status lands before the reveal: the native owner is already proven here, and a + // reveal that rejects must not leave the session released but never marked native. context.setStatus(sessionId, { owner: 'native', direction: null, @@ -143,4 +139,10 @@ export async function handoffStructuredSessionToNative( stage: record.lease.handoffStage, operationId: record.lease.handoffOperationId }) + await deps.transport?.revealNativeSession?.({ + workspaceId: record.location.workspaceId, + sessionId, + agent: record.provider, + ...(owner?.adoptedTerminal ? { adoptedTerminal: true } : {}) + }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts new file mode 100644 index 00000000000..e5dd478f719 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts @@ -0,0 +1,80 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff' +import type { + StructuredAgentSessionHandoffTransport, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +type TestCoordinatorInput = { + store: AgentSessionRecordStore + journal: AgentSessionJournal + sessionId: string + provider: 'claude' | 'codex' + claudeSessionId: string + codexThreadId: string + now: number + launchTui: StructuredAgentSessionHandoffTransport['launchTui'] + reproveTuiOwner: StructuredAgentSessionHandoffTransport['reproveTuiOwner'] + stopRecoveredOwner: StructuredAgentSessionHandoffTransport['stopRecoveredOwner'] + closeTuiOwner: NonNullable + waitForTuiExit: StructuredAgentSessionHandoffTransport['waitForTuiExit'] + waitForTuiIdleOrExit: StructuredAgentSessionHandoffTransport['waitForTuiIdleOrExit'] + stopFailedTuiLaunch: NonNullable + recoverTuiOwner: (record: AgentSessionRecord) => Promise + tuiStatus: () => 'idle' | 'busy' + acquireNative: (input: { + sessionId: string + fence: number + spawnToken: string + }) => Promise + acquireNativeStop: (turnId: string) => Promise + takeImportFailure: () => Error | null + statuses: AgentSessionHandoffStatus[] +} + +export function createStructuredAgentSessionHandoffTestCoordinator( + input: TestCoordinatorInput +): StructuredAgentSessionHandoffCoordinator { + return new StructuredAgentSessionHandoffCoordinator({ + store: input.store, + claimKeyId: 'key-1', + transport: { + hostLabel: 'Test host', + launchTui: input.launchTui, + reproveTuiOwner: input.reproveTuiOwner, + recoverTuiOwner: input.recoverTuiOwner, + stopRecoveredOwner: input.stopRecoveredOwner, + closeTuiOwner: input.closeTuiOwner, + waitForTuiExit: input.waitForTuiExit, + waitForTuiIdleOrExit: input.waitForTuiIdleOrExit, + tuiStatus: input.tuiStatus, + stopFailedTuiLaunch: input.stopFailedTuiLaunch + }, + session: () => ({ + journal: input.journal, + fence: input.store.getRecord(input.sessionId)?.lease.runtimeFence ?? 1 + }), + suspendNative: async () => ({ state: 'stopped' as const }), + acquireNative: input.acquireNative, + acquireNativeStop: (_sessionId, turnId) => input.acquireNativeStop(turnId), + importTuiHistory: async ({ fence }) => { + const importFailure = input.takeImportFailure() + if (importFailure) { + throw importFailure + } + await input.journal.appendItem( + input.provider === 'claude' + ? { provider: 'claude', sessionId: input.claudeSessionId, uuid: 'tui-turn' } + : { provider: 'codex', threadId: input.codexThreadId, turnId: 'tui-turn', ordinal: 0 }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'from tui' }] }, + { fence, recovered: true } + ) + }, + publish: (_sessionId, status) => input.statuses.push(status), + schedule: async (_sessionId, task) => task(), + now: () => input.now + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-identities.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-identities.ts new file mode 100644 index 00000000000..ca33e486999 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-identities.ts @@ -0,0 +1,40 @@ +export type StructuredHandoffProviderCase = { + provider: 'claude' | 'codex' + accountHome: { variable: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME'; pathName: string } +} + +export const STRUCTURED_HANDOFF_PROVIDER_CASES: StructuredHandoffProviderCase[] = [ + { provider: 'codex', accountHome: { variable: 'CODEX_HOME', pathName: 'codex-home' } }, + { + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', pathName: 'claude-home' } + } +] + +export function structuredHandoffTestProcess(now: number, spawnToken: string, pid: number) { + return { hostId: 'local', pid, processStartTimeMs: now - 1_000, spawnToken } +} + +export function structuredHandoffTestLink(input: { + provider: 'claude' | 'codex' + fence: number + id: string + now: number + claudeSessionId: string + codexThreadId: string +}) { + return { + linkId: input.id, + handle: + input.provider === 'claude' + ? ({ + provider: 'claude' as const, + sessionId: input.claudeSessionId, + leafUuid: input.id.startsWith('native-link') ? 'tui-exit-leaf' : 'current-leaf' + } as const) + : ({ provider: 'codex' as const, threadId: input.codexThreadId } as const), + origin: 'resumed' as const, + mintedAtFence: input.fence, + observedAt: input.now + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-requests.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-requests.ts new file mode 100644 index 00000000000..550ebded0da --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-requests.ts @@ -0,0 +1,53 @@ +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { + AgentSessionHandoffDirection, + AgentSessionHandoffAction, + AgentSessionHandoffMode, + AgentSessionHandoffRequest +} from '../../../shared/agent-session-wire' + +export type StructuredHandoffTestRequestOptions = { + action?: AgentSessionHandoffAction + operationId?: string +} + +export class StructuredHandoffTestRequests { + private operations = 0 + + constructor( + private readonly now: number, + private readonly sessionId: string, + private readonly readFence: () => number + ) {} + + reset(): void { + this.operations = 0 + } + + operationId(): string { + this.operations += 1 + return `${this.now}-${this.operations.toString(16).padStart(32, '0')}` + } + + request( + direction: AgentSessionHandoffDirection, + mode: AgentSessionHandoffMode, + options: StructuredHandoffTestRequestOptions = {} + ): AgentSessionHandoffRequest { + const action = options.action ?? 'start' + const fields = { direction, mode, action } + return { + envelope: { + sessionId: this.sessionId, + clientOperationId: options.operationId ?? this.operationId(), + expectedRuntimeFence: this.readFence(), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.requestHandoff', + sessionId: this.sessionId, + fields + }) + }, + ...fields + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts index 0e213921609..57333d1c90c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts @@ -1,63 +1,286 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffResult, + AgentSessionHandoffStatus, + AgentSessionMutationResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import { + admitStructuredHandoffRequest, + refuseAdmittedStructuredHandoff, + replayedStructuredHandoffRefusal, + structuredHandoffRetryIsAdmissible +} from './structured-agent-session-handoff-admission' import { createStructuredHandoffFlowContext, requireStructuredHandoffRecord } from './structured-agent-session-handoff-flow-context' -import { restoreStructuredAgentSessionHandoff } from './structured-agent-session-handoff-restart' +import { StructuredAgentSessionHandoffFlowRunner } from './structured-agent-session-handoff-flow-runner' +import { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' +import { StructuredAgentSessionHandoffQueue } from './structured-agent-session-handoff-queue' +import { queueStructuredHandoffAfterTurn } from './structured-agent-session-handoff-queue-start' import { closeRetainedTuiOwner } from './structured-agent-session-handoff-owner-close' +import { requestStructuredManualRecovery } from './structured-agent-session-handoff-recover' +import { restoreStructuredAgentSessionHandoff } from './structured-agent-session-handoff-restart' +import { + structuredHandoffRefusal as refusal, + structuredHandoffSuccess +} from './structured-agent-session-handoff-result' +import { + failedStructuredHandoffStatus, + idleStructuredHandoffStatus, + structuredSessionHasPendingPrompt, + structuredTuiStatus +} from './structured-agent-session-handoff-status' import type { StructuredAgentSessionHandoffDeps, StructuredAgentSessionHandoffFlowContext } from './structured-agent-session-handoff-types' import { StructuredAgentSessionHandoffState } from './structured-agent-session-handoff-state' - export class StructuredAgentSessionHandoffCoordinator { private readonly state: StructuredAgentSessionHandoffState - + private readonly queue = new StructuredAgentSessionHandoffQueue() + private readonly operationGuard: StructuredAgentSessionHandoffOperationGuard + private readonly flowRunner: StructuredAgentSessionHandoffFlowRunner constructor(private readonly deps: StructuredAgentSessionHandoffDeps) { - // oxfmt-ignore - this.state = new StructuredAgentSessionHandoffState({ requireRecord: (sessionId) => this.requireRecord(sessionId), publish: deps.publish, hostLabel: deps.transport?.hostLabel }) - } - - status = (sessionId: string) => this.state.status(sessionId) - - closeRetainedTuiOwner = (sessionId: string): Promise => - closeRetainedTuiOwner({ - sessionId, - deps: this.deps, - owner: this.state.owner, - requireRecord: this.requireRecord, - releaseOwner: this.state.releaseOwner + this.state = new StructuredAgentSessionHandoffState({ + requireRecord: (sessionId) => this.requireRecord(sessionId), + publish: deps.publish, + hostLabel: deps.transport?.hostLabel }) - + this.operationGuard = new StructuredAgentSessionHandoffOperationGuard(deps.store) + this.flowRunner = new StructuredAgentSessionHandoffFlowRunner({ + deps, + operationGuard: this.operationGuard, + flowContext: () => this.flowContext(), + fail: (params, error) => this.fail(params, error) + }) + } + status = (sessionId: string): AgentSessionHandoffStatus => this.state.status(sessionId) + drain = (): Promise => this.flowRunner.drain() + closeRetainedTuiOwner = (sessionId: string): Promise => + this.closeRetainedOwner(sessionId) setStatus = (sessionId: string, status: AgentSessionHandoffStatus): void => this.state.setStatus(sessionId, status) - + async request( + callerKey: string, + params: AgentSessionHandoffRequest + ): Promise> { + const record = this.requireRecord(params.envelope.sessionId) + const currentStatus = this.state.cachedStatus(record.sessionId) + const admission = await admitStructuredHandoffRequest({ + deps: this.deps, + operationGuard: this.operationGuard, + callerKey, + params, + record, + ...(currentStatus ? { status: currentStatus } : {}) + }) + if (admission.decision === 'replay') { + const replayedRefusal = replayedStructuredHandoffRefusal(admission.outcome) + if (replayedRefusal) { + return { ok: false, refusal: replayedRefusal } + } + return this.success(record.sessionId, true) + } + if (admission.decision === 'refused') { + return { ok: false, refusal: admission.refusal } + } + const { fingerprint } = admission + const action = params.action ?? 'start' + if (action === 'cancel-queued') { + if (currentStatus?.phase !== 'queued' || currentStatus?.direction !== params.direction) { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_operation_conflict', + 'No matching queued handoff exists.' + ) + } + this.queue.cancel(record.sessionId) + this.setStatus(record.sessionId, idleStructuredHandoffStatus(record)) + await this.deps.store.recordOperationOutcome({ + callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'succeeded', sessionId: record.sessionId } + }) + return this.success(record.sessionId, false) + } + if (!this.deps.transport) { + return this.refuseAdmitted( + callerKey, + params, + 'structured_agent_session_unsupported', + 'Agent TUI handoff is unavailable on this host.' + ) + } + if (action === 'recover') { + const status = this.status(record.sessionId) + const started = await requestStructuredManualRecovery({ + deps: this.deps, + operationGuard: this.operationGuard, + callerKey, + params, + fingerprint, + record, + status, + requireRecord: this.requireRecord, + restore: this.restore, + setStatus: this.setStatus + }) + if (!started) { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_operation_conflict', + 'This handoff is no longer eligible for proof recovery.' + ) + } + return this.success(record.sessionId, false) + } + if (action === 'retry') { + if (!structuredHandoffRetryIsAdmissible(this.status(record.sessionId), params)) { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_operation_conflict', + 'This handoff is no longer retryable.' + ) + } + this.begin(callerKey, params, null, fingerprint) + return this.success(record.sessionId, false) + } + const expectedOwner = params.direction === 'to-tui' ? 'native' : 'tui' + if (record.lease.runtimeKind !== expectedOwner || record.lease.claimStatus !== 'live') { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_conflict', + `The ${expectedOwner} runtime does not own this session.` + ) + } + if (structuredSessionHasPendingPrompt(this.deps.session(record.sessionId).journal)) { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_conflict', + 'Resolve the pending question or approval before switching.' + ) + } + const turnId = activeStructuredAgentSessionTurnId( + this.deps.session(record.sessionId).journal.snapshot().items + ) + const tuiOwner = this.state.owner(record.sessionId) + const busy = + expectedOwner === 'native' + ? turnId !== null + : structuredTuiStatus(tuiOwner, this.deps.transport) !== 'idle' + if (busy && params.mode === 'now') { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_conflict', + 'The current turn must finish before switching.' + ) + } + if (busy && params.mode === 'after-turn') { + queueStructuredHandoffAfterTurn({ + callerKey, + params, + deps: this.deps, + queue: this.queue, + owner: (sessionId) => this.state.owner(sessionId), + setStatus: this.setStatus, + begin: (key, next, tuiAlreadyExited) => + this.begin(key, next, null, fingerprint, tuiAlreadyExited) + }) + return this.success(record.sessionId, false) + } + if (busy && expectedOwner === 'tui' && params.mode === 'stop-turn') { + return this.refuseAdmitted( + callerKey, + params, + 'structured_agent_session_unsupported', + 'Exit the agent terminal after this turn to continue in chat.' + ) + } + this.begin(callerKey, params, turnId, fingerprint) + return this.success(record.sessionId, false) + } async restore(sessionId: string): Promise { await restoreStructuredAgentSessionHandoff( { deps: this.deps, requireRecord: (id) => this.requireRecord(id), flowContext: () => this.flowContext(), - retainOwner: this.state.retainOwner, - setStatus: this.state.setStatus + retainOwner: (id, owner) => this.state.retainOwner(id, owner), + setStatus: (id, status) => this.state.setStatus(id, status) }, sessionId ) } - + private refuseAdmitted( + callerKey: string, + params: AgentSessionHandoffRequest, + code: AgentSessionWireRefusal['code'], + message: string + ): Promise> { + return refuseAdmittedStructuredHandoff({ + deps: this.deps, + callerKey, + params, + refusal: refusal(code, message) + }) + } + private success( + sessionId: string, + replayed: boolean + ): AgentSessionMutationResult { + return structuredHandoffSuccess(this.deps, sessionId, replayed, this.status(sessionId)) + } + private begin( + callerKey: string, + params: AgentSessionHandoffRequest, + turnId: string | null, + fingerprint: string, + tuiAlreadyExited = false + ): void { + this.flowRunner.begin({ + callerKey, + params, + turnId, + fingerprint, + tuiAlreadyExited + }) + } private flowContext(): StructuredAgentSessionHandoffFlowContext { return createStructuredHandoffFlowContext({ deps: this.deps, - owner: this.state.owner, - retainOwner: this.state.retainOwner, - releaseOwner: this.state.releaseOwner, - setStatus: this.state.setStatus, + owner: (sessionId) => this.state.owner(sessionId), + retainOwner: (sessionId, owner) => this.state.retainOwner(sessionId, owner), + releaseOwner: (sessionId) => this.state.releaseOwner(sessionId), + setStatus: (sessionId, status) => this.state.setStatus(sessionId, status), requireRecord: (sessionId) => this.requireRecord(sessionId) }) } - + private fail(params: AgentSessionHandoffRequest, error: unknown): void { + const record = this.requireRecord(params.envelope.sessionId) + this.setStatus( + record.sessionId, + failedStructuredHandoffStatus(record, params, error, this.deps.transport?.hostLabel) + ) + } + private closeRetainedOwner(sessionId: string): Promise { + return closeRetainedTuiOwner({ + sessionId, + deps: this.deps, + owner: this.state.owner, + requireRecord: this.requireRecord, + releaseOwner: this.state.releaseOwner + }) + } private requireRecord = (sessionId: string): AgentSessionRecord => requireStructuredHandoffRecord(this.deps, sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 99d6c212c10..c38f64c3318 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -6,6 +6,8 @@ import type { AgentSessionAttachResult, AgentSessionHistoryRequest, AgentSessionHistoryResult, + AgentSessionHandoffRequest, + AgentSessionHandoffResult, AgentSessionHandoffStatus, AgentSessionMutationResult, AgentSessionOptionsResult, @@ -56,7 +58,6 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { readStructuredAgentSessionHistoryResult } from './structured-agent-session-history-result' -import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' import { StructuredAgentSessionEventRecovery } from './structured-agent-session-event-recovery' export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' export class StructuredAgentSessionHost { @@ -181,14 +182,6 @@ export class StructuredAgentSessionHost { subscribers: this.subscribers, tasks: this.tasks, reconcileLeases: (sessionId) => this.reconcileLeases(sessionId), - retryPendingSettlement: (sessionId, params) => - retryPendingStructuredAgentSessionSettlement({ - deps: this.deps, - sessions: this.sessions, - sessionId, - params, - now: () => this.now() - }), serialize: (sessionId, task) => this.serialize(sessionId, task), now: () => this.now() } @@ -299,6 +292,12 @@ export class StructuredAgentSessionHost { ): ReturnType => setStructuredAgentSessionOption(this.mutationContext(), caller, params) + requestHandoff = ( + caller: StructuredAgentSessionCaller, + params: AgentSessionHandoffRequest + ): Promise> => + this.handoffs.request(caller.callerKey, params) + readOptions = (sessionId: string): Promise => readStructuredAgentSessionOptions(this.mutationContext(), sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-manual-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-manual-recovery.ts new file mode 100644 index 00000000000..91be1a15aa5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-manual-recovery.ts @@ -0,0 +1,103 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffStatus +} from '../../../shared/agent-session-wire' +import { setStoredAgentSessionHandoffStage } from '../../runtime/agent-session-handoff-record-transitions' +import type { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' +import { idleStructuredHandoffStatus } from './structured-agent-session-handoff-status' +import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types' + +export function structuredManualRecoveryIsAdmissible( + record: AgentSessionRecord, + status: AgentSessionHandoffStatus | undefined +): boolean { + return ( + record.lease.handoffStage === 'manual-recovery' && + record.lease.runtimeKind === 'tui' && + record.lease.ownerProcess !== null && + status?.error?.canRetryProof === true + ) +} + +export function beginStructuredManualRecovery(input: { + deps: StructuredAgentSessionHandoffDeps + operationGuard: StructuredAgentSessionHandoffOperationGuard + callerKey: string + params: AgentSessionHandoffRequest + fingerprint: string + requireRecord: (sessionId: string) => AgentSessionRecord + restore: (sessionId: string) => Promise + setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void +}): Promise { + const { + callerKey, + deps, + fingerprint, + operationGuard, + params, + requireRecord, + restore, + setStatus + } = input + const sessionId = params.envelope.sessionId + operationGuard.start(sessionId, { + callerKey, + operationId: params.envelope.clientOperationId, + fingerprint + }) + setStatus(sessionId, { + owner: 'none', + direction: params.direction, + phase: 'switching', + stage: 'recovering', + operationId: params.envelope.clientOperationId, + hostLabel: deps.transport?.hostLabel + }) + return deps + .schedule(sessionId, async () => { + let record = requireRecord(sessionId) + if (record.lease.claimStatus === 'reserved' && record.lease.handoffOperationId !== null) { + record = await setStoredAgentSessionHandoffStage(deps.store, { + sessionId, + fence: record.lease.runtimeFence, + stage: 'new-owner-proving', + handoffOperationId: record.lease.handoffOperationId, + now: deps.now() + }) + } + await restore(record.sessionId) + if (requireRecord(sessionId).lease.handoffStage === 'manual-recovery') { + throw new Error('The TUI owner proof is still unavailable.') + } + }) + .then(() => { + operationGuard.finish(sessionId, params.envelope.clientOperationId) + return deps.store.recordOperationOutcome({ + callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'succeeded', sessionId } + }) + }) + .catch(async (error) => { + await deps.store.recordOperationOutcome({ + callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'failed', code: 'agent_session_handoff_failed' } + }) + operationGuard.finish(sessionId, params.envelope.clientOperationId) + const status = idleStructuredHandoffStatus(requireRecord(sessionId)) + setStatus(sessionId, { + ...status, + ...(status.error + ? { + error: { + ...status.error, + details: error instanceof Error ? error.message : String(error) + } + } + : {}) + }) + }) + .finally(() => operationGuard.finish(sessionId, params.envelope.clientOperationId)) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts index b9ba03ff327..6e71f822170 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts @@ -1,7 +1,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' export async function readNativeSessionOptions(input: { - adapter: Pick + adapter: Pick sessionId: string fence: number priorOptions?: Readonly> @@ -11,7 +11,13 @@ export async function readNativeSessionOptions(input: { if (!reported) { return undefined } - const { model: _model, effort: _effort, ...restored } = priorOptions ?? {} + const skipped = new Set(input.adapter.readOptionRestoreFailures?.(sessionId) ?? []) + const restored = priorOptions ? { ...priorOptions } : {} + delete restored.model + delete restored.effort + for (const key of skipped) { + delete restored[key] + } return { ...restored, model: reported.current.model, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts new file mode 100644 index 00000000000..3caba894cb9 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts @@ -0,0 +1,178 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { recoverStoredDeadTuiOwnerForHandoff } from '../../runtime/agent-session-handoff-record-transitions' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff' +import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-proven-dead-retry' +const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' +const CREATE_OPERATION = `${NOW}-00000000000000000000000000000000` +const OPERATION = `${NOW}-00000000000000000000000000000001` +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('structured session proven-dead TUI retry', () => { + it('acquires native ownership without trying to close the dead TUI again', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-handoff-dead-retry-')) + roots.push(root) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const reserved = await store.reserveOwner({ + sessionId: SESSION, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, + runtimeKind: 'tui', + expectedFence: null, + spawnToken: 'tui-spawn', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { callerKey: 'test', operationId: CREATE_OPERATION, fingerprint: 'create' }, + now: NOW + }) + const tuiFence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId: SESSION, + fence: tuiFence, + process: { + hostId: 'local', + pid: 4200, + processStartTimeMs: NOW - 1_000, + spawnToken: 'tui-spawn' + }, + now: NOW + }) + await store.proveOwner({ + sessionId: SESSION, + fence: tuiFence, + link: { + linkId: 'tui-link', + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: tuiFence, + observedAt: NOW + }, + now: NOW + }) + await recoverStoredDeadTuiOwnerForHandoff(store, { + sessionId: SESSION, + expectedFence: tuiFence, + operationId: OPERATION, + probe: { outcome: 'pid-absent' }, + now: NOW + }) + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: join(root, 'journal') + }) + const closeTuiOwner = + vi.fn>() + const coordinator = new StructuredAgentSessionHandoffCoordinator({ + store, + claimKeyId: 'key-1', + transport: { + hostLabel: 'Test host', + launchTui: vi.fn(), + reproveTuiOwner: vi.fn(), + recoverTuiOwner: vi.fn(), + stopRecoveredOwner: vi.fn(), + closeTuiOwner, + waitForTuiExit: vi.fn(), + waitForTuiIdleOrExit: vi.fn(), + tuiStatus: () => 'busy' + }, + session: () => ({ journal, fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1 }), + suspendNative: vi.fn(), + acquireNative: async ({ fence, spawnToken }) => { + await store.commitProcessIdentity({ + sessionId: SESSION, + fence, + process: { + hostId: 'local', + pid: 4300, + processStartTimeMs: NOW, + spawnToken + }, + now: NOW + }) + return store.proveOwner({ + sessionId: SESSION, + fence, + link: { + linkId: 'native-link', + handle: { provider: 'codex', threadId: THREAD }, + origin: 'resumed', + mintedAtFence: fence, + observedAt: NOW + }, + now: NOW + }) + }, + acquireNativeStop: vi.fn(async () => true), + importTuiHistory: vi.fn(), + publish: vi.fn(), + schedule: async (_sessionId, task) => task(), + now: () => NOW + }) + const fields = { + direction: 'to-native' as const, + mode: 'now' as const, + action: 'retry' as const + } + const request: AgentSessionHandoffRequest = { + envelope: { + sessionId: SESSION, + clientOperationId: OPERATION, + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.requestHandoff', + sessionId: SESSION, + fields + }) + }, + ...fields + } + + expect(coordinator.status(SESSION)).toMatchObject({ phase: 'failed', owner: 'tui' }) + expect( + await ( + coordinator as { + request: (callerKey: string, params: AgentSessionHandoffRequest) => Promise + } + ).request('client-1', request) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native')) + // Settle the flow's trailing outcome write before afterEach removes the store root. + await coordinator.drain() + expect(closeTuiOwner).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'live', + handoffStage: null + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index a5927dc0c14..5cd888cc5cc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -8,7 +8,7 @@ import { spawnProcess } from '../../../shared/child-process/run-process' import { CODEX_SPAWN_TOKEN_ENV } from '../../codex/codex-structured-owner-identity' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { readProcessStartTimeMs } from '../../runtime/agent-session-process-identity-probe' -import { createStructuredAgentSessionOwnerProbe } from '../../runtime/structured-agent-session-runtime' +import { createStructuredAgentSessionOwnerProbe } from '../../runtime/structured-agent-session-owner-probe' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts index 9d16e19a7f7..26ad85b5cfb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts @@ -1,6 +1,11 @@ import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + decodeAgentSessionQuestionAnswers, + isValidAgentSessionQuestionAnswers +} from '../../../shared/agent-session-question-answer' import type { AgentJournalItemBody, + AgentJournalQuestion, AgentJournalResolution } from '../../../shared/agent-session-journal-types' import type { AgentSessionPromptResult } from '../../../shared/agent-session-wire' @@ -14,6 +19,7 @@ function invalid(message: string): TurnOutcome { function promptBodyOf(body: AgentJournalItemBody): { options: readonly { id: string }[] freeTextQuestionId?: string + questions?: AgentJournalQuestion[] resolution: AgentJournalResolution } | null { return body.kind === 'approval' || body.kind === 'question' ? body : null @@ -64,7 +70,19 @@ export async function performPrompt( prompt.freeTextQuestionId !== undefined && freeText?.questionId === prompt.freeTextQuestionId && freeText.answer.trim().length > 0 - if (!acceptsFreeText && !prompt.options.some((option) => option.id === input.optionId)) { + const grouped = + item.body.kind === 'question' && prompt.questions + ? decodeAgentSessionQuestionAnswers(input.optionId) + : null + const acceptsGrouped = + grouped !== null && + prompt.questions !== undefined && + isValidAgentSessionQuestionAnswers(prompt.questions, grouped) + if ( + !acceptsFreeText && + !acceptsGrouped && + !prompt.options.some((option) => option.id === input.optionId) + ) { return invalid(`Option ${input.optionId} is not offered by item ${input.itemId}.`) } const identity = parseAgentJournalItemKey(input.itemId) diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts index 804d2900a22..60f34707ac9 100644 --- a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts @@ -54,7 +54,8 @@ function directReadableMessage(payload: unknown): string | null { return null } -function readableMessage(payload: unknown): string | null { +/** The provider's own sentence for a frame, when it carries one. */ +export function readableProviderFrameText(payload: unknown): string | null { const direct = directReadableMessage(payload) if (direct || typeof payload !== 'object' || payload === null || Array.isArray(payload)) { return direct @@ -89,7 +90,7 @@ export function unhandledProviderFrameJournalItem( // Why: the opcode alone ("codex · notification:warning") tells the user nothing // and reads as protocol noise. Lead with the provider's own sentence when it has // one; the raw frame stays behind the row's disclosure either way. - const message = readableMessage(payload) + const message = readableProviderFrameText(payload) const display = message ? boundInlineText(message, limits) : null return { body: { diff --git a/src/main/native-chat/claude-structured-managed-account-support.test.ts b/src/main/native-chat/claude-structured-managed-account-support.test.ts new file mode 100644 index 00000000000..f647579d03e --- /dev/null +++ b/src/main/native-chat/claude-structured-managed-account-support.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest' +import { getSelectedClaudeAccountIdForTarget } from '../claude-accounts/runtime-selection' +import { + structuredClaudeMatchesActiveManagedAccount, + type ClaudeManagedAccountGateSettings +} from './claude-structured-managed-account-support' + +function account(id: string, managedAuthRuntime: 'host' | 'wsl') { + return { + id, + email: `${id}@example.com`, + managedAuthPath: `/managed/${id}`, + managedAuthRuntime, + authMethod: 'subscription-oauth' as const, + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +function settings( + overrides: Partial +): ClaudeManagedAccountGateSettings { + return { claudeManagedAccounts: [], activeClaudeManagedAccountId: null, ...overrides } +} + +describe('structuredClaudeMatchesActiveManagedAccount', () => { + it('allows an unmanaged install, where nothing claims an identity', () => { + expect(structuredClaudeMatchesActiveManagedAccount(settings({}))).toBe(true) + }) + + it('allows a selected host account, which the runtime syncs into the ambient config', () => { + expect( + structuredClaudeMatchesActiveManagedAccount( + settings({ + claudeManagedAccounts: [account('host-1', 'host')], + activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } + }) + ) + ).toBe(true) + }) + + it('refuses a WSL-only managed account, which never reaches the ambient config', () => { + expect( + structuredClaudeMatchesActiveManagedAccount( + settings({ + claudeManagedAccounts: [account('wsl-1', 'wsl')], + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } + }) + ) + ).toBe(false) + }) + + it('refuses when a host selection names an account that is WSL-bound or missing', () => { + expect( + structuredClaudeMatchesActiveManagedAccount( + settings({ + claudeManagedAccounts: [account('wsl-1', 'wsl')], + activeClaudeManagedAccountIdsByRuntime: { host: 'wsl-1', wsl: {} } + }) + ) + ).toBe(false) + expect( + structuredClaudeMatchesActiveManagedAccount( + settings({ + claudeManagedAccounts: [account('host-1', 'host')], + activeClaudeManagedAccountIdsByRuntime: { host: 'gone', wsl: {} } + }) + ) + ).toBe(false) + }) + + /** Absent and empty are the same answer: this user has no managed Claude accounts, so nothing + * claims an identity and the ambient path is legitimate. Only settings that cannot be READ are + * unknown. Treating a missing key as unknown strands profiles that simply never wrote it — the + * auth policy's own predicate takes `(accounts ?? [])` for exactly this reason. */ + it('treats an absent account list the same as an empty one', () => { + expect( + structuredClaudeMatchesActiveManagedAccount(settings({ claudeManagedAccounts: [] })) + ).toBe(true) + expect( + structuredClaudeMatchesActiveManagedAccount({ + activeClaudeManagedAccountId: null + } as unknown as ClaudeManagedAccountGateSettings) + ).toBe(true) + }) + + it('fails closed when the settings cannot be read at all', () => { + expect(structuredClaudeMatchesActiveManagedAccount(null)).toBe(false) + expect(structuredClaudeMatchesActiveManagedAccount(undefined)).toBe(false) + }) + + /** The four states this gate exists to tell apart, pinned together so a change to one is visible + * against the others. */ + it.each([ + ['no managed accounts', [], null, true], + ['accounts present, none active, no WSL account', [account('host-1', 'host')], null, true], + ['host account selected', [account('host-1', 'host')], 'host-1', true], + ['WSL-only, normalized to no host selection', [account('wsl-1', 'wsl')], null, false] + ] as const)('resolves %s', (_name, claudeManagedAccounts, activeId, expected) => { + expect( + structuredClaudeMatchesActiveManagedAccount( + settings({ + claudeManagedAccounts: [...claudeManagedAccounts], + activeClaudeManagedAccountIdsByRuntime: { host: activeId, wsl: {} } + }) + ) + ).toBe(expected) + }) + + /** THE discriminator, and the whole of this rule. With nothing selected for the host runtime the + * settings alone cannot distinguish honest deselection from the WSL-only steady state, because + * `pruneInvalidClaudeRuntimeSelection` empties the host slot in the second case and persists it. + * So the presence of ANY WSL-bound account decides. Simplifying this to "none active -> + * supported" re-opens the auth-identity misrepresentation this gate exists to prevent. */ + it('splits none-active on whether a WSL-bound account exists at all', () => { + const noneActive = (accounts: ReturnType[]) => + structuredClaudeMatchesActiveManagedAccount( + settings({ + claudeManagedAccounts: accounts, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } + }) + ) + + expect(noneActive([account('host-1', 'host')])).toBe(true) + expect(noneActive([account('host-1', 'host'), account('host-2', 'host')])).toBe(true) + expect(noneActive([account('wsl-1', 'wsl')])).toBe(false) + // Mixed list still refuses: the WSL account is present and nothing is selected. + expect(noneActive([account('host-1', 'host'), account('wsl-1', 'wsl')])).toBe(false) + }) + + /** The gate and the auth policy must resolve the SAME account. A legacy settings blob carries the + * selection only in the flat `activeClaudeManagedAccountId`, which is where the accessor's + * fall-through lives — reading the runtime map directly silently disagrees with the policy. */ + it('resolves the same account as the auth policy on a legacy flat selection', () => { + const legacy = settings({ + claudeManagedAccounts: [account('host-1', 'host')], + activeClaudeManagedAccountId: 'host-1' + }) + + expect(getSelectedClaudeAccountIdForTarget(legacy, { runtime: 'host' })).toBe('host-1') + expect(structuredClaudeMatchesActiveManagedAccount(legacy)).toBe(true) + }) + + it('agrees with the auth policy that a legacy flat WSL selection is refused', () => { + const legacy = settings({ + claudeManagedAccounts: [account('wsl-1', 'wsl')], + activeClaudeManagedAccountId: 'wsl-1' + }) + + expect(getSelectedClaudeAccountIdForTarget(legacy, { runtime: 'host' })).toBe('wsl-1') + expect(structuredClaudeMatchesActiveManagedAccount(legacy)).toBe(false) + }) +}) diff --git a/src/main/native-chat/claude-structured-managed-account-support.ts b/src/main/native-chat/claude-structured-managed-account-support.ts new file mode 100644 index 00000000000..dccf6216bda --- /dev/null +++ b/src/main/native-chat/claude-structured-managed-account-support.ts @@ -0,0 +1,61 @@ +import type { GlobalSettings } from '../../shared/global-settings-types' +import { getSelectedClaudeAccountIdForTarget } from '../claude-accounts/runtime-selection' + +export type ClaudeManagedAccountGateSettings = Pick< + GlobalSettings, + | 'claudeManagedAccounts' + | 'activeClaudeManagedAccountId' + | 'activeClaudeManagedAccountIdsByRuntime' +> + +/** + * A structured Claude session launches against the ambient Claude config, which the account service + * keeps in sync with the selected HOST account. A WSL-bound managed account lives inside the distro + * and is never synced there, so such a session would authenticate as whatever the ambient identity + * happens to be while the UI names the WSL account — the user is told one identity and given + * another. Refuse the structured path there and let the terminal-backed one, which resolves the + * account per runtime, handle that account shape. + * + * Reads the selection through the same accessor the auth policy uses. Resolving it any other way + * lets the two disagree, and a session admitted by this gate would then run under a policy computed + * from a different account than the one approved here. + * + * Unknown answers refuse, and only genuinely unknown ones: settings that cannot be read at all, or + * an active selection this cannot resolve. An install with no managed accounts — the list empty or + * never written — claims no identity and is fine. + */ +export function structuredClaudeMatchesActiveManagedAccount( + settings: ClaudeManagedAccountGateSettings | null | undefined +): boolean { + if (!settings) { + return false + } + // Absent is the same answer as empty — this user has no managed Claude accounts, so nothing + // claims an identity and ambient auth is the truth. Only settings that cannot be READ are + // unknown, and those refuse above. The auth policy reads the list the same way. + const accounts = settings.claudeManagedAccounts ?? [] + if (accounts.length === 0) { + return true + } + const activeHostId = getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) + if (!activeHostId) { + // Nothing selected for the host runtime is two different states that the settings cannot tell + // apart after the fact: honest deselection, where ambient auth is the truth and the UI names no + // identity, and the WSL-only case, where the prune emptied the host slot and persisted null + // while the UI still names the WSL account. The presence of any WSL-bound account decides. + return !accounts.some((candidate) => candidate.managedAuthRuntime === 'wsl') + } + const active = accounts.find((candidate) => candidate.id === activeHostId) + return active ? active.managedAuthRuntime !== 'wsl' : false +} + +/** Reads the gate's settings, answering null when they cannot be read so callers refuse. */ +export function readClaudeManagedAccountGateSettings( + getSettings: () => ClaudeManagedAccountGateSettings +): ClaudeManagedAccountGateSettings | null { + try { + return getSettings() + } catch { + return null + } +} diff --git a/src/main/native-chat/session-file-resolver-claude-roots.test.ts b/src/main/native-chat/session-file-resolver-claude-roots.test.ts new file mode 100644 index 00000000000..87ebd570280 --- /dev/null +++ b/src/main/native-chat/session-file-resolver-claude-roots.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const scanned = vi.hoisted(() => ({ dirs: [] as string[], hits: {} as Record })) +vi.mock('../ai-vault/session-scanner-discovery', () => ({ + walkSessionFiles: async (dir: string) => { + scanned.dirs.push(dir) + const hit = scanned.hits[dir] + return hit ? [hit] : [] + } +})) + +import { homedir } from 'node:os' +import { join } from 'node:path' +import { resolveSessionFilePath } from './session-file-resolver' + +const DEFAULT_ROOT = join(homedir(), '.claude', 'projects') +const CONFIG_DIR = '/opt/claude-home' +const CONFIG_ROOT = join(CONFIG_DIR, 'projects') + +let previousConfigDir: string | undefined + +beforeEach(() => { + previousConfigDir = process.env.CLAUDE_CONFIG_DIR + scanned.dirs = [] + scanned.hits = {} +}) + +afterEach(() => { + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir + } +}) + +/** + * Honouring CLAUDE_CONFIG_DIR fixed new sessions but would otherwise hide every + * transcript written before the user adopted the variable. The Codex resolver in this + * same file already searches managed-then-default and de-dupes; Claude does the same. + */ +describe('claude transcript roots', () => { + it('searches the config-dir root first, then the default home', async () => { + process.env.CLAUDE_CONFIG_DIR = CONFIG_DIR + + await resolveSessionFilePath('claude', 'session-1') + + expect(scanned.dirs).toEqual([CONFIG_ROOT, DEFAULT_ROOT]) + }) + + it('still finds history written before CLAUDE_CONFIG_DIR was adopted', async () => { + process.env.CLAUDE_CONFIG_DIR = CONFIG_DIR + const legacy = join(DEFAULT_ROOT, '-repos-old', 'session-1.jsonl') + scanned.hits[DEFAULT_ROOT] = legacy + + await expect(resolveSessionFilePath('claude', 'session-1')).resolves.toBe(legacy) + }) + + it('prefers the config-dir root when both hold the session', async () => { + process.env.CLAUDE_CONFIG_DIR = CONFIG_DIR + scanned.hits[CONFIG_ROOT] = join(CONFIG_ROOT, '-repos-new', 'session-1.jsonl') + scanned.hits[DEFAULT_ROOT] = join(DEFAULT_ROOT, '-repos-old', 'session-1.jsonl') + + await expect(resolveSessionFilePath('claude', 'session-1')).resolves.toBe( + scanned.hits[CONFIG_ROOT] + ) + // The default root is never reached, so the common case pays for one scan. + expect(scanned.dirs).toEqual([CONFIG_ROOT]) + }) + + it('scans one root when the variable is unset', async () => { + delete process.env.CLAUDE_CONFIG_DIR + + await resolveSessionFilePath('claude', 'session-1') + + expect(scanned.dirs).toEqual([DEFAULT_ROOT]) + }) + + it('de-dupes when CLAUDE_CONFIG_DIR names the default home', async () => { + process.env.CLAUDE_CONFIG_DIR = join(homedir(), '.claude') + + await resolveSessionFilePath('claude', 'session-1') + + expect(scanned.dirs).toEqual([DEFAULT_ROOT]) + }) + + it('honours an explicit root override without adding fallbacks', async () => { + process.env.CLAUDE_CONFIG_DIR = CONFIG_DIR + // The account-home callers (structured-claude-runtime-adapter, the host handoff) + // know the exact tree their session pinned; a fallback there could resolve a + // different account's transcript. + await resolveSessionFilePath('claude', 'session-1', { + claudeProjectsDir: '/accounts/pinned/projects' + }) + + expect(scanned.dirs).toEqual(['/accounts/pinned/projects']) + }) +}) diff --git a/src/main/native-chat/session-file-resolver.test.ts b/src/main/native-chat/session-file-resolver.test.ts index 584d8a25a9d..04946f5b464 100644 --- a/src/main/native-chat/session-file-resolver.test.ts +++ b/src/main/native-chat/session-file-resolver.test.ts @@ -1,9 +1,12 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' -import { ClaudeTranscriptTailIncompleteError } from '../claude/claude-transcript-branch-proof' +import { + ClaudeTranscriptTailIncompleteError, + readClaudeTranscriptLeafWithReproof +} from '../claude/claude-transcript-branch-proof' import { readClaudeTranscriptLeafUuid, resolveSessionFilePath } from './session-file-resolver' let tempRoots: string[] = [] @@ -139,6 +142,263 @@ describe('resolveSessionFilePath', () => { ) }) + it('rejects non-transcript and sidechain UUIDs as the durable leaf', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-leaf-filter-') + const transcript = join(root, 'session.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, + { + type: 'assistant', + uuid: 'sidechain-assistant', + parentUuid: 'main-user', + sessionId: 'session-1', + isSidechain: true + }, + { type: 'result', uuid: 'result-frame', parentUuid: 'main-user', sessionId: 'session-1' }, + { + type: 'system', + subtype: 'init', + uuid: 'init-frame', + parentUuid: null, + sessionId: 'session-1' + }, + { type: 'stream_event', uuid: 'stream-frame', parentUuid: null, sessionId: 'session-1' }, + { type: 'last-prompt', leafUuid: 'sidechain-assistant', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1')).rejects.toThrow( + 'marker leaf is missing from the session graph' + ) + }) + + it('rejects a main leaf whose ancestry crosses a subagent sidechain', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-sidechain-ancestry-') + const transcript = join(root, 'session.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, + { + type: 'assistant', + uuid: 'sidechain-assistant', + parentUuid: 'main-user', + sessionId: 'session-1', + isSidechain: true + }, + { + type: 'assistant', + uuid: 'main-after-sidechain', + parentUuid: 'sidechain-assistant', + sessionId: 'session-1' + }, + { type: 'last-prompt', leafUuid: 'main-after-sidechain', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1')).rejects.toThrow( + 'not on the main transcript' + ) + }) + + it('rejects a main leaf whose ancestry crosses a parent-tool-use sidechain', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-parent-tool-ancestry-') + const transcript = join(root, 'transcript.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, + { + type: 'assistant', + uuid: 'subagent-assistant', + parentUuid: 'main-user', + sessionId: 'session-1', + parent_tool_use_id: 'tool-use-1' + }, + { + type: 'assistant', + uuid: 'main-after-sidechain', + parentUuid: 'subagent-assistant', + sessionId: 'session-1' + }, + { type: 'last-prompt', leafUuid: 'main-after-sidechain', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1')).rejects.toThrow( + 'not on the main transcript' + ) + }) + + it('rejects a previous cursor descended from a parent-tool-use sidechain', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-parent-tool-cursor-') + const transcript = join(root, 'transcript.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, + { + type: 'assistant', + uuid: 'subagent-assistant', + parentUuid: 'main-user', + sessionId: 'session-1', + parent_tool_use_id: 'tool-use-1' + }, + { + type: 'assistant', + uuid: 'main-after-sidechain', + parentUuid: 'subagent-assistant', + sessionId: 'session-1' + }, + { type: 'last-prompt', leafUuid: 'main-after-sidechain', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect( + readClaudeTranscriptLeafUuid(transcript, 'session-1', 'main-after-sidechain') + ).rejects.toThrow('not on the main transcript') + }) + + it('rejects a latest marker descended from a parent-tool-use cursor sidechain', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-parent-tool-cursor-descendant-') + const transcript = join(root, 'transcript.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, + { + type: 'assistant', + uuid: 'subagent-assistant', + parentUuid: 'main-user', + sessionId: 'session-1', + parent_tool_use_id: 'tool-use-1' + }, + { + type: 'assistant', + uuid: 'main-after-sidechain', + parentUuid: 'subagent-assistant', + sessionId: 'session-1' + }, + { + type: 'assistant', + uuid: 'latest-after-sidechain', + parentUuid: 'main-after-sidechain', + sessionId: 'session-1' + }, + { type: 'last-prompt', leafUuid: 'latest-after-sidechain', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect( + readClaudeTranscriptLeafUuid(transcript, 'session-1', 'main-after-sidechain') + ).rejects.toThrow('not on the main transcript') + }) + + it('rejects a post-snapshot descendant whose parent row was observed later', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-post-snapshot-') + const transcript = join(root, 'transcript.jsonl') + await writeFile( + transcript, + [ + { + type: 'assistant', + uuid: 'descendant', + parentUuid: 'previous', + sessionId: 'session-1' + }, + { type: 'assistant', uuid: 'previous', parentUuid: null, sessionId: 'session-1' }, + { type: 'last-prompt', leafUuid: 'descendant', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1', 'previous')).rejects.toThrow( + 'parent row follows descendant' + ) + }) + + it('does not re-prove a divergent sibling after the sampled cursor rejects', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-sibling-reproof-') + const transcript = join(root, 'transcript.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'root', parentUuid: null, sessionId: 'session-1' }, + { type: 'assistant', uuid: 'old', parentUuid: 'root', sessionId: 'session-1' }, + { type: 'assistant', uuid: 'new', parentUuid: 'root', sessionId: 'session-1' }, + { type: 'last-prompt', leafUuid: 'new', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + const calls: (string | null)[] = [] + const readTranscriptLeaf = async ({ + previousLeafUuid + }: { + previousLeafUuid: string | null + }) => { + calls.push(previousLeafUuid) + return readClaudeTranscriptLeafUuid(transcript, 'session-1', previousLeafUuid) + } + + await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1', 'old')).rejects.toThrow( + 'sibling branch' + ) + + await expect( + readClaudeTranscriptLeafWithReproof({ + readTranscriptLeaf, + claudeConfigDir: '/accounts/claude', + providerSessionId: 'session-1', + previousLeafUuid: 'old' + }) + ).rejects.toThrow('sibling branch') + expect(calls).toEqual(['old']) + }) + + it('does not accept a divergent sibling after a truncated-tail reproof', async () => { + const calls: (string | null)[] = [] + const readTranscriptLeaf = vi.fn( + async ({ previousLeafUuid }: { previousLeafUuid: string | null }) => { + calls.push(previousLeafUuid) + if (calls.length === 1) { + throw new ClaudeTranscriptTailIncompleteError() + } + return 'divergent-sibling' + } + ) + + await expect( + readClaudeTranscriptLeafWithReproof({ + readTranscriptLeaf, + claudeConfigDir: '/accounts/claude', + providerSessionId: 'session-1', + previousLeafUuid: 'old' + }) + ).rejects.toBeInstanceOf(ClaudeTranscriptTailIncompleteError) + expect(calls).toEqual(['old']) + }) + it('globs Claude project subdirs for .jsonl', async () => { const root = await makeRoot('orca-native-chat-resolve-claude-') const claudeProjectsDir = join(root, 'claude-projects') @@ -410,3 +670,42 @@ describe('resolveSessionFilePath', () => { expect(resolved).toBe(target) }) }) + +// Mobile native chat resolves with no root override (transcript-read-cache.ts:104), +// while the account home a structured Claude session pins is +// `CLAUDE_CONFIG_DIR || ~/.claude` (runtime-paths.ts:15). When the two disagree the +// CLI writes one place and mobile reads another, and the chat goes dark with no +// wire-level error — so the default root has to honour the same variable. +describe('the default Claude transcript root mobile falls back to', () => { + it('follows CLAUDE_CONFIG_DIR, the same variable the pinned account home follows', async () => { + const configDir = await makeRoot('orca-native-chat-claude-config-dir-') + const slugDir = join(configDir, 'projects', '-repos-workspace-1') + await mkdir(slugDir, { recursive: true }) + const transcript = join(slugDir, 'session-under-config-dir.jsonl') + await writeFile(transcript, '', 'utf8') + const previous = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = configDir + + try { + // No `claudeProjectsDir` override: exactly the call mobile makes. + await expect(resolveSessionFilePath('claude', 'session-under-config-dir')).resolves.toBe( + transcript + ) + } finally { + restoreEnv('CLAUDE_CONFIG_DIR', previous) + } + }) + + it('ignores a blank CLAUDE_CONFIG_DIR rather than resolving against the filesystem root', async () => { + const previous = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = ' ' + + try { + await expect( + resolveSessionFilePath('claude', 'session-that-does-not-exist') + ).resolves.toBeNull() + } finally { + restoreEnv('CLAUDE_CONFIG_DIR', previous) + } + }) +}) diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 0ee73fddc8d..12d2e615742 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -31,8 +31,20 @@ import { proveClaudeTranscriptBranch } from '../claude/claude-transcript-branch- // the remote main resolves its local home, so we never hardcode an absolute // user path — homedir()/CODEX_HOME resolution stays runtime-relative and is // computed per call (not at module load) so it tracks the live home. -function claudeProjectsDir(): string { - return join(homedir(), '.claude', 'projects') +// Why CLAUDE_CONFIG_DIR and not just homedir(): a structured Claude session pins its +// account home to `CLAUDE_CONFIG_DIR || ~/.claude` (claude-accounts/runtime-paths.ts), +// and the CLI writes its transcript under whatever home it was given. Mobile native chat +// resolves with no root override, so a default that ignored the variable read a different +// tree than the CLI wrote — a silent blackout, not an error. +// Why both roots and not just that one: adopting the variable would otherwise hide every +// transcript written before it was set. Same managed-then-default shape as +// codexSessionsDirs below, de-duped so the usual case still scans once. +function claudeProjectsDirs(): string[] { + const candidates = [ + join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude'), 'projects'), + join(homedir(), '.claude', 'projects') + ] + return candidates.filter((dir, index) => candidates.indexOf(dir) === index) } // Why: Orca launches Codex with ORCA_CODEX_HOME pointing at its own managed @@ -173,9 +185,11 @@ async function resolveSessionFileById( } if (transcriptAgent === 'claude') { + // An explicit root is the caller naming the exact account tree its session pinned; + // adding a fallback there could resolve a different account's transcript. return resolveClaudeSessionFile( trimmedId, - options.claudeProjectsDir ?? claudeProjectsDir(), + options.claudeProjectsDir ? [options.claudeProjectsDir] : claudeProjectsDirs(), signal ) } @@ -205,16 +219,22 @@ async function resolveSessionFileById( async function resolveClaudeSessionFile( sessionId: string, - projectsDir: string, + projectsDirs: readonly string[], signal?: AbortSignal ): Promise { const targetName = `${sessionId}.jsonl` - const files = await walkSessionFiles(projectsDir, 'claude', [], { - extensions: new Set(['.jsonl']), - filePredicate: (path) => basename(path) === targetName, - signal - }) - return files[0] ?? null + for (const projectsDir of projectsDirs) { + // No existence pre-check: walkSessionFiles already yields [] for a missing root. + const files = await walkSessionFiles(projectsDir, 'claude', [], { + extensions: new Set(['.jsonl']), + filePredicate: (path) => basename(path) === targetName, + signal + }) + if (files[0]) { + return files[0] + } + } + return null } async function resolveCodexSessionFile( diff --git a/src/main/native-chat/structured-agent-session-create-support.test.ts b/src/main/native-chat/structured-agent-session-create-support.test.ts new file mode 100644 index 00000000000..ab19d369bac --- /dev/null +++ b/src/main/native-chat/structured-agent-session-create-support.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import type { ClaudeManagedAccountGateSettings } from './claude-structured-managed-account-support' +import { resolveStructuredAgentSessionCreateSupport } from './structured-agent-session-create-support' + +const LOCAL: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' +} + +function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') { + return { + id, + email: `${id}@example.com`, + managedAuthPath: `/managed/${id}`, + managedAuthRuntime, + authMethod: 'subscription-oauth' as const, + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +const HOST_SELECTED: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('host-1', 'host')], + activeClaudeManagedAccountId: 'host-1', + activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } +} + +const WSL_ONLY: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('wsl-1', 'wsl')], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } +} + +function support( + overrides: Partial[0]> = {} +) { + return resolveStructuredAgentSessionCreateSupport({ + agent: 'claude', + location: LOCAL, + adapterSupportsCreate: true, + getSettings: () => HOST_SELECTED, + ...overrides + }) +} + +describe('resolveStructuredAgentSessionCreateSupport', () => { + it('supports Claude under a selected host account', () => { + expect(support()).toEqual({ supported: true }) + }) + + it('refuses Claude under a WSL-only managed account', () => { + expect(support({ getSettings: () => WSL_ONLY })).toEqual({ supported: false, reason: 'wsl' }) + }) + + it('fails closed for Claude when the settings throw', () => { + expect( + support({ + getSettings: () => { + throw new Error('no store') + } + }) + ).toEqual({ supported: false, reason: 'wsl' }) + }) + + it('leaves Codex to the adapter answer under the same WSL-only account', () => { + expect(support({ agent: 'codex', getSettings: () => WSL_ONLY })).toEqual({ supported: true }) + }) + + it.each([ + ['remote', { ...LOCAL, executionHostId: 'ssh:host-a' }, 'remote'], + ['wsl workspace', { ...LOCAL, wslDistro: 'Ubuntu' }, 'wsl'], + ['unsupported agent', LOCAL, 'agent'] + ] as const)('keeps the adapter refusal reason for %s', (_name, location, reason) => { + expect(support({ adapterSupportsCreate: false, location })).toEqual({ + supported: false, + reason + }) + }) +}) diff --git a/src/main/native-chat/structured-agent-session-create-support.ts b/src/main/native-chat/structured-agent-session-create-support.ts new file mode 100644 index 00000000000..9b96a1af4be --- /dev/null +++ b/src/main/native-chat/structured-agent-session-create-support.ts @@ -0,0 +1,48 @@ +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { + readClaudeManagedAccountGateSettings, + structuredClaudeMatchesActiveManagedAccount, + type ClaudeManagedAccountGateSettings +} from './claude-structured-managed-account-support' + +export type StructuredAgentSessionCreateSupport = { + supported: boolean + reason?: 'agent' | 'remote' | 'wsl' +} + +/** + * The create-support verdict, kept out of the runtime class file because that file is `@ts-nocheck` + * — a call site there is not typechecked, so an auth-identity decision written inline would compile + * however wrong it was. The runtime hands over the two facts it owns and this decides. + */ +export function resolveStructuredAgentSessionCreateSupport(input: { + agent: 'claude' | 'codex' + location: AgentSessionExecutionLocation + adapterSupportsCreate: boolean + getSettings: () => ClaudeManagedAccountGateSettings +}): StructuredAgentSessionCreateSupport { + if (!input.adapterSupportsCreate) { + return { + supported: false, + reason: + input.location.executionHostId !== LOCAL_EXECUTION_HOST_ID + ? 'remote' + : input.location.wslDistro + ? 'wsl' + : 'agent' + } + } + // Claude only: Codex resolves its account on a different path, so its answer is untouched here. + // `wsl` is the closest existing reason — the cause is a WSL-bound account rather than a WSL + // workspace — and no client reads the field, so it stays as-is. + if ( + input.agent === 'claude' && + !structuredClaudeMatchesActiveManagedAccount( + readClaudeManagedAccountGateSettings(input.getSettings) + ) + ) { + return { supported: false, reason: 'wsl' } + } + return { supported: true } +} diff --git a/src/main/providers/windows-foreground-process-rows.ts b/src/main/providers/windows-foreground-process-rows.ts index 5f462649e6c..e8320a6d00a 100644 --- a/src/main/providers/windows-foreground-process-rows.ts +++ b/src/main/providers/windows-foreground-process-rows.ts @@ -108,6 +108,26 @@ export async function queryWindowsPaneProcessInventory( } } +/** + * The descendant walk over rows the caller already read. + * + * Why exported: a caller that needs a field this module's projection drops — + * process creation time, for a PID-reuse-safe teardown snapshot — would + * otherwise read the whole table a second time to get it. + * Null when the root is absent, which is a stale or filtered snapshot rather + * than a root with no descendants. + */ +export function windowsDescendantsFromRows( + rows: Row[], + rootPid: number +): (Row & { depth: number })[] | null { + const index = getProcessTableIndex(rows) + if (!index.byPid.has(rootPid)) { + return null + } + return collectDescendantsFromIndex(index, rootPid).sort((a, b) => b.depth - a.depth) +} + /** Test-only: clear the shared snapshot so one case's rows never serve the next. */ export function resetWindowsProcessRowsSnapshotForTests(): void { resetWindowsProcessTableForTests() diff --git a/src/main/pty-descendant-exit-verification.ts b/src/main/pty-descendant-exit-verification.ts index c0ed223704a..4c8471fa955 100644 --- a/src/main/pty-descendant-exit-verification.ts +++ b/src/main/pty-descendant-exit-verification.ts @@ -21,50 +21,143 @@ function waitForDelay(ms: number): Promise { function matchingSnapshotRows( snapshot: DescendantSnapshot, - table: readonly ProcessTableRow[] + table: readonly ProcessTableRow[], + rejectDuplicatePids = false ): ProcessTableRow[] { const expected = new Map(snapshot.descendants.map((row) => [row.pid, row])) - return table.filter((live) => { - const row = expected.get(live.pid) - return row?.startedAt === live.startedAt && row.pgid === live.pgid + const rowsByPid = new Map() + for (const live of table) { + const rows = rowsByPid.get(live.pid) + if (rows) { + rows.push(live) + } else { + rowsByPid.set(live.pid, [live]) + } + } + return [...expected.entries()].flatMap(([pid, row]) => { + const rows = rowsByPid.get(pid) + if (rejectDuplicatePids && rows?.length !== 1) { + // Duplicate PID rows make this non-atomic process-table read ambiguous; + // never signal or count either identity as proof of liveness. + return [] + } + return (rows ?? []).filter((live) => live.startedAt === row.startedAt && live.pgid === row.pgid) }) } +function hasDuplicateSnapshotPids( + snapshot: DescendantSnapshot, + table: readonly ProcessTableRow[] +): boolean { + const expected = new Set(snapshot.descendants.map((row) => row.pid)) + const counts = new Map() + for (const live of table) { + if (expected.has(live.pid)) { + counts.set(live.pid, (counts.get(live.pid) ?? 0) + 1) + } + } + return [...counts.values()].some((count) => count > 1) +} + type VerificationDeps = TerminateDeps & { verifyMs?: number + /** Revalidate identities before signaling; used by Claude's close proof. */ + requireIdentityBeforeSignal?: boolean } +/** + * Orca's verdict vocabulary for a snapshotted tree, with no synonyms: `live` is + * an identity-matched descendant still observed at the deadline; `unverifiable` + * is a table that could not be read, which is never evidence either way. + */ +export type DescendantTreeVerdict = 'exited' | 'live' | 'unverifiable' + /** An unreadable process table is never proof that a stopped descendant exited. */ export async function terminateDescendantSnapshotAndWait( snapshot: DescendantSnapshot, deps: VerificationDeps = {} ): Promise { + return (await terminateDescendantSnapshotWithVerdict(snapshot, deps)) === 'exited' +} + +/** Signals the snapshot, then reports what the last table read observed. */ +export async function terminateDescendantSnapshotWithVerdict( + snapshot: DescendantSnapshot, + deps: VerificationDeps = {} +): Promise { const sendSignal = deps.sendSignal ?? sendDescendantSignal const readTable = deps.readTable ?? readProcessTable const graceMs = deps.graceMs ?? DESCENDANT_KILL_GRACE_MS const verifyMs = deps.verifyMs ?? DESCENDANT_KILL_VERIFY_MS const deadline = Date.now() + verifyMs - for (const row of snapshot.descendants) { - sendSignal(row.pid, 'SIGTERM') - } let forced = false + let signalled = !deps.requireIdentityBeforeSignal + let missingObservations = 0 + if (signalled) { + for (const row of snapshot.descendants) { + sendSignal(row.pid, 'SIGTERM') + } + } while (Date.now() < deadline) { const capture = await readProcessTableBeforeDeadline( readTable, deps.timeoutMs ?? DESCENDANT_SNAPSHOT_TIMEOUT_MS ) - if (!capture) { - return false - } - const live = matchingSnapshotRows(snapshot, capture.rows) - if (live.length === 0) { - return true - } - if (!forced && Date.now() >= deadline - verifyMs + graceMs) { - forced = true - for (const row of live) { - if (hasUnambiguousStartIdentity(row, snapshot.capturedAtMs)) { - sendSignal(row.pid, 'SIGKILL') + // A read that missed its own deadline is not an answer, and surrendering on + // the first slow one spends none of the window this verification was given: + // on a loaded host that reported a tree unverifiable without ever seeing it. + if (capture) { + if (deps.requireIdentityBeforeSignal && hasDuplicateSnapshotPids(snapshot, capture.rows)) { + // A duplicate target pid is an ambiguous non-atomic read. Do not signal + // either row and do not turn that uncertainty into an exited verdict. + await waitForDelay(50) + continue + } + const live = matchingSnapshotRows(snapshot, capture.rows, deps.requireIdentityBeforeSignal) + if (live.length === 0) { + // Before a signal has been sent, an empty identity match means the + // snapshotted descendants already exited or were replaced. Signalling + // those old numeric pids would be unsafe. + if (deps.requireIdentityBeforeSignal) { + // A single process-table read can race a fork or return a partial + // view; require two bounded absences before claiming the tree gone. + missingObservations += 1 + if (missingObservations < 2) { + await waitForDelay(50) + continue + } + } + return 'exited' + } + missingObservations = 0 + if (!signalled) { + // Revalidate every identity immediately before the first signal. A PID + // can be recycled between the original walk and close, so never signal + // from the stale snapshot alone. + for (const row of live) { + sendSignal(row.pid, 'SIGTERM') + } + signalled = true + } + if (!forced && Date.now() >= deadline - verifyMs + graceMs) { + forced = true + for (const row of live) { + // A row a walk re-derived from a live root is ours whatever second it + // was born in, which start time alone can never establish for one born + // in its own capture second. Rows no walk re-derived still answer to + // the second-resolution fence, which is all the evidence they have. + // Scoped to the identity-revalidating callers; the same argument holds + // for the rest, but widening it is a deliberate change of its own. + if ( + (deps.requireIdentityBeforeSignal === true && + snapshot.reDerivedPids?.has(row.pid) === true) || + hasUnambiguousStartIdentity( + row, + snapshot.capturedAtMsByPid?.[String(row.pid)] ?? snapshot.capturedAtMs + ) + ) { + sendSignal(row.pid, 'SIGKILL') + } } } } @@ -74,5 +167,19 @@ export async function terminateDescendantSnapshotAndWait( readTable, deps.timeoutMs ?? DESCENDANT_SNAPSHOT_TIMEOUT_MS ) - return finalCapture !== null && matchingSnapshotRows(snapshot, finalCapture.rows).length === 0 + if (!finalCapture) { + return 'unverifiable' + } + if (deps.requireIdentityBeforeSignal && hasDuplicateSnapshotPids(snapshot, finalCapture.rows)) { + return 'unverifiable' + } + const finalLive = matchingSnapshotRows( + snapshot, + finalCapture.rows, + deps.requireIdentityBeforeSignal + ) + if (finalLive.length > 0) { + return 'live' + } + return deps.requireIdentityBeforeSignal && missingObservations < 2 ? 'unverifiable' : 'exited' } diff --git a/src/main/pty-descendant-termination.test.ts b/src/main/pty-descendant-termination.test.ts index e1255a678d8..0c4bea81306 100644 --- a/src/main/pty-descendant-termination.test.ts +++ b/src/main/pty-descendant-termination.test.ts @@ -15,7 +15,10 @@ import { type ProcessTableCapture, type ProcessTableRow } from './pty-descendant-termination' -import { terminateDescendantSnapshotAndWait } from './pty-descendant-exit-verification' +import { + terminateDescendantSnapshotAndWait, + terminateDescendantSnapshotWithVerdict +} from './pty-descendant-exit-verification' const CAPTURED_AT_MS = Date.parse('Tue Jul 14 12:00:00 2026') @@ -53,7 +56,14 @@ function snapshot( rootPgid: number | null = 10, capturedAtMs = CAPTURED_AT_MS ) { - return { rootPgid, descendants, capturedAtMs } + return { + ...(rootPgid === null ? {} : { root: { pid: 10, startedAt: 'Mon Jul 13 12:54:47 2026' } }), + rootPgid, + descendants, + capturedAtMs, + // Everything a walk returns was re-derived by it. + ...(rootPgid === null ? {} : { reDerivedPids: new Set(descendants.map((row) => row.pid)) }) + } } describe('parseProcessTable', () => { @@ -298,6 +308,32 @@ describe('terminateDescendantSnapshot', () => { expect(sendSignal).not.toHaveBeenCalled() expect(vi.getTimerCount()).toBe(0) }) + + it("uses each row's capture boundary when escalating a merged snapshot", async () => { + const oldBoundary = CAPTURED_AT_MS + 900 + const refreshBoundary = CAPTURED_AT_MS + 2_100 + const retained = row(20, 10, 20, 'Tue Jul 14 12:00:00 2026') + const fresh = row(30, 10, 30, 'Tue Jul 14 12:00:01 2026') + const sendSignal = vi.fn() + terminateDescendantSnapshot( + { + ...snapshot([retained, fresh], 10, refreshBoundary), + capturedAtMsByPid: { '20': oldBoundary, '30': refreshBoundary } + }, + { + sendSignal, + readTable: vi.fn().mockResolvedValue(tableCapture([retained, fresh])) + } + ) + sendSignal.mockClear() + + await vi.advanceTimersByTimeAsync(DESCENDANT_KILL_GRACE_MS) + + // PID 20 was retained from the earlier capture and is still in its + // capture second; PID 30 was newly observed by the refresh and is old + // enough for a bounded forced cleanup. + expect(sendSignal.mock.calls).toEqual([[30, 'SIGKILL']]) + }) }) describe('terminateDescendantSnapshotAndWait', () => { @@ -333,14 +369,114 @@ describe('terminateDescendantSnapshotAndWait', () => { it('does not claim exit when the verification table is unavailable', async () => { const sendSignal = vi.fn() - const result = await terminateDescendantSnapshotAndWait(snapshot([row(20, 10, 20)]), { + const pending = terminateDescendantSnapshotAndWait(snapshot([row(20, 10, 20)]), { sendSignal, - readTable: vi.fn().mockRejectedValue(new Error('ps exploded')) + readTable: vi.fn().mockRejectedValue(new Error('ps exploded')), + verifyMs: 200 }) + await vi.advanceTimersByTimeAsync(400) - expect(result).toBe(false) + await expect(pending).resolves.toBe(false) expect(sendSignal).toHaveBeenCalledWith(20, 'SIGTERM') }) + + it('keeps polling past a read that missed its deadline rather than surrendering', async () => { + const survivor = row(20, 10, 20) + const readTable = vi + .fn() + // A loaded host can miss one read's deadline with the window still open. + .mockRejectedValueOnce(new Error('ps timed out')) + .mockResolvedValueOnce(tableCapture([survivor])) + .mockResolvedValueOnce(tableCapture([])) + const sendSignal = vi.fn() + + const pending = terminateDescendantSnapshotWithVerdict(snapshot([survivor]), { + sendSignal, + readTable, + graceMs: 0, + verifyMs: 2_000 + }) + await vi.advanceTimersByTimeAsync(500) + + await expect(pending).resolves.toBe('exited') + expect(sendSignal.mock.calls).toEqual([ + [20, 'SIGTERM'], + [20, 'SIGKILL'] + ]) + }) + + it('names a survivor seen at the deadline live, never unverifiable', async () => { + const survivor = row(20, 10, 20) + const pending = terminateDescendantSnapshotWithVerdict(snapshot([survivor]), { + sendSignal: vi.fn(), + readTable: vi.fn().mockResolvedValue(tableCapture([survivor])), + graceMs: 0, + verifyMs: 100 + }) + await vi.advanceTimersByTimeAsync(200) + + await expect(pending).resolves.toBe('live') + }) + + it('names an unreadable verification table unverifiable', async () => { + const pending = terminateDescendantSnapshotWithVerdict(snapshot([row(20, 10, 20)]), { + sendSignal: vi.fn(), + readTable: vi.fn().mockRejectedValue(new Error('ps exploded')), + verifyMs: 200 + }) + await vi.advanceTimersByTimeAsync(400) + + await expect(pending).resolves.toBe('unverifiable') + }) + + it('does not signal a recycled descendant when identity validation is required', async () => { + const sendSignal = vi.fn() + const recycled = row(20, 10, 20, 'Tue Jul 14 13:00:00 2026') + const pending = terminateDescendantSnapshotWithVerdict( + snapshot([row(20, 10, 20, 'Tue Jul 14 12:00:00 2026')]), + { + sendSignal, + readTable: vi.fn().mockResolvedValue(tableCapture([recycled])), + requireIdentityBeforeSignal: true, + verifyMs: 100 + } + ) + + await vi.advanceTimersByTimeAsync(200) + await expect(pending).resolves.toBe('exited') + expect(sendSignal).not.toHaveBeenCalled() + }) + + it('uses row-scoped boundaries for forced cleanup in the exit verifier', async () => { + const oldBoundary = CAPTURED_AT_MS + 900 + const refreshBoundary = CAPTURED_AT_MS + 2_100 + const retained = row(20, 10, 20, 'Tue Jul 14 12:00:00 2026') + const fresh = row(30, 10, 30, 'Tue Jul 14 12:00:01 2026') + const sendSignal = vi.fn() + const pending = terminateDescendantSnapshotWithVerdict( + { + ...snapshot([retained, fresh], 10, refreshBoundary), + capturedAtMsByPid: { '20': oldBoundary, '30': refreshBoundary }, + // What a merge produces: only the refresh re-derived 30; 20 is retained. + reDerivedPids: new Set([30]) + }, + { + sendSignal, + readTable: vi.fn().mockResolvedValue(tableCapture([retained, fresh])), + requireIdentityBeforeSignal: true, + graceMs: 0, + verifyMs: 100 + } + ) + await vi.advanceTimersByTimeAsync(200) + + await expect(pending).resolves.toBe('live') + expect(sendSignal.mock.calls).toEqual([ + [20, 'SIGTERM'], + [30, 'SIGTERM'], + [30, 'SIGKILL'] + ]) + }) }) describe('createProcessTableSnapshotReader', () => { diff --git a/src/main/pty-descendant-termination.ts b/src/main/pty-descendant-termination.ts index bf254d03b56..4f56c3e977b 100644 --- a/src/main/pty-descendant-termination.ts +++ b/src/main/pty-descendant-termination.ts @@ -21,12 +21,25 @@ export type ProcessTableRow = { startedAt: string } +export type PosixProcessIdentity = Pick + export type DescendantSnapshot = { + /** Identity of the root observed in the same process-table capture. */ + root?: PosixProcessIdentity rootPgid: number | null descendants: ProcessTableRow[] - /** Wall-clock boundary for deciding whether ps's second-resolution lstart - * can safely distinguish this process from a later PID reuse. */ + /** Wall-clock boundary for an unmerged snapshot (or legacy callers). */ capturedAtMs: number + /** Per-PID identity boundaries for merged captures. */ + capturedAtMsByPid?: Readonly> + /** + * PIDs this walk re-derived from a live root. A ppid walk only reaches what + * the root actually parents, so membership is proof of ownership that owes + * nothing to `lstart`'s one-second resolution: a stranger would have to have + * been forked into our own tree, and then it is not a stranger. Rows a merge + * retained from an earlier walk are absent, and still answer to start time. + */ + reDerivedPids?: ReadonlySet } export type ProcessTableCapture = { @@ -156,9 +169,13 @@ export function collectDescendantRows( ): DescendantSnapshot { const childrenByPpid = new Map() let rootRow: ProcessTableRow | null = null + let duplicateRoot = false for (const row of table) { if (row.pid === rootPid) { - rootRow = row + // A non-atomic process-table read can contain both an old and a recycled + // root row. There is no safe identity to retain in that case. + duplicateRoot = rootRow !== null + rootRow ??= row continue } const siblings = childrenByPpid.get(row.ppid) @@ -172,7 +189,7 @@ export function collectDescendantRows( // An absent root has already exited — its real descendants reparent to pid 1 and // become unreachable by ppid, so any rows still pointing at the vacated PID are a // PID-reuse coincidence. Sweeping them could signal an unrelated process, so bail. - if (!rootRow) { + if (!rootRow || duplicateRoot) { return { rootPgid: null, descendants: [], capturedAtMs } } const descendants: ProcessTableRow[] = [] @@ -191,7 +208,13 @@ export function collectDescendantRows( queue.push(child.pid) } } - return { rootPgid: rootRow.pgid, descendants, capturedAtMs } + return { + root: { pid: rootRow.pid, startedAt: rootRow.startedAt }, + rootPgid: rootRow.pgid, + descendants, + capturedAtMs, + reDerivedPids: new Set(descendants.map((row) => row.pid)) + } } type SnapshotDeps = { @@ -316,7 +339,11 @@ export type TerminateDeps = { } export function hasUnambiguousStartIdentity(row: ProcessTableRow, capturedAtMs: number): boolean { - const startedAtMs = Date.parse(row.startedAt) + return hasUnambiguousStartTime(row.startedAt, capturedAtMs) +} + +export function hasUnambiguousStartTime(startedAt: string, capturedAtMs: number): boolean { + const startedAtMs = Date.parse(startedAt) if (!Number.isFinite(startedAtMs)) { return false } @@ -364,7 +391,10 @@ export function terminateDescendantSnapshot( for (const row of snapshot.descendants) { const live = liveTargets.get(row.pid) if ( - hasUnambiguousStartIdentity(row, snapshot.capturedAtMs) && + hasUnambiguousStartIdentity( + row, + snapshot.capturedAtMsByPid?.[String(row.pid)] ?? snapshot.capturedAtMs + ) && live?.startedAt === row.startedAt && live.pgid === row.pgid ) { diff --git a/src/main/runtime/agent-session-acquisition-failure-settlement.ts b/src/main/runtime/agent-session-acquisition-failure-settlement.ts index 7ad20397813..c790a149f31 100644 --- a/src/main/runtime/agent-session-acquisition-failure-settlement.ts +++ b/src/main/runtime/agent-session-acquisition-failure-settlement.ts @@ -4,10 +4,28 @@ import { type AgentSessionOperationOutcome } from '../../shared/agent-session-operation-ledger' import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' -import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { + AgentSessionDeathEvidence, + AgentSessionRecord +} from '../../shared/agent-session-record' import { assertFence, withLease } from './agent-session-lease-transitions' import type { AgentSessionStoreState } from './agent-session-record-store-file' +/** + * How the failed attempt's provider process was accounted for. + * - `exit-proven`: cleanup observed the whole tree gone. + * - `root-exit-observed`: the owner root's exit was observed first-hand, so the + * identity this lease is keyed on is dead, but its descendants could not be + * verified. Releases the lease and says exactly that, claiming nothing more. + * - `processless`: the attempt failed before a process existed. + * - `unproven`: nothing about the process was observed; the reservation latches. + */ +export type AgentSessionAcquisitionExitProof = + | 'exit-proven' + | 'root-exit-observed' + | 'processless' + | 'unproven' + export type AgentSessionFailedAcquisitionSettlement = { sessionId: string fence: number @@ -15,7 +33,7 @@ export type AgentSessionFailedAcquisitionSettlement = { callerKey: string operationId: string outcome: Extract - exitProof: 'exit-proven' | 'processless' | 'unproven' + exitProof: AgentSessionAcquisitionExitProof now: number } @@ -83,11 +101,18 @@ export function settleFailedAgentSessionPostAcquisitionAttachment( claimStatus: 'released', lastRenewedAt: args.now, handoffOperationId: null, - deathEvidence: { - kind: 'exit-observed', - detail: 'post-acquisition cleanup proved no provider child remains', - observedAt: args.now - } + deathEvidence: + args.exitProof === 'root-exit-observed' + ? { + kind: 'exit-observed', + detail: 'the provider process exited; its descendants were not verifiable', + observedAt: args.now + } + : { + kind: 'exit-observed', + detail: 'post-acquisition cleanup proved no provider child remains', + observedAt: args.now + } }) state.records.set(args.sessionId, next) state.operations = settleAgentSessionOperation(state.operations, args) @@ -127,18 +152,29 @@ function settleFailedLease( claimStatus: 'released', lastRenewedAt: args.now, handoffOperationId: null, - deathEvidence: - args.exitProof === 'processless' - ? { - kind: 'pid-absent', - detail: 'reservation failed before spawn', - observedAt: args.now - } - : { - // Cleanup proved no child of this attempt remains; it may never have spawned. - kind: 'exit-observed', - detail: 'acquisition cleanup proved no provider child remains', - observedAt: args.now - } + deathEvidence: acquisitionDeathEvidence(args.exitProof, args.now) }) } + +/** Records only what was observed: never a tree claim the cleanup did not make. */ +function acquisitionDeathEvidence( + exitProof: AgentSessionAcquisitionExitProof, + observedAt: number +): AgentSessionDeathEvidence { + if (exitProof === 'processless') { + return { kind: 'pid-absent', detail: 'reservation failed before spawn', observedAt } + } + if (exitProof === 'root-exit-observed') { + return { + kind: 'exit-observed', + detail: 'the provider process exited; its descendants were not verifiable', + observedAt + } + } + // Cleanup proved no child of this attempt remains; it may never have spawned. + return { + kind: 'exit-observed', + detail: 'acquisition cleanup proved no provider child remains', + observedAt + } +} diff --git a/src/main/runtime/agent-session-launch-env-backfill.test.ts b/src/main/runtime/agent-session-launch-env-backfill.test.ts new file mode 100644 index 00000000000..c95705f2aa5 --- /dev/null +++ b/src/main/runtime/agent-session-launch-env-backfill.test.ts @@ -0,0 +1,90 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AgentSessionRecordStore } from './agent-session-record-store' +import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-launch-env' +let directory: string + +function request(overrides: Partial = {}): AgentSessionReserveRequest { + return { + sessionId: SESSION, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'client-1', + operationId: `${NOW}-00000000000000000000000000000001`, + fingerprint: 'fp-1' + }, + now: NOW, + ...overrides + } +} + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-launch-env-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +describe('legacy agent session launch environment', () => { + it('durably pins the first environment resolved by a current reservation', async () => { + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + await store.reserveOwner(request()) + await store.reserveOwner( + request({ + expectedFence: 1, + spawnToken: 'spawn-b', + launchEnv: { ANTHROPIC_AUTH_TOKEN: 'pinned-token' }, + operation: { + callerKey: 'client-1', + operationId: `${NOW}-00000000000000000000000000000002`, + fingerprint: 'fp-2' + } + }) + ) + + const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + expect( + (reopened.getRecord(SESSION) as { launchEnv?: Record } | null)?.launchEnv + ).toBeUndefined() + }) + + it('rejects an environment that could not be reloaded before writing it', async () => { + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const launchEnv = Object.fromEntries( + Array.from({ length: 257 }, (_, index) => [`KEY_${index}`, 'value']) + ) + + await expect(store.reserveOwner(request({ launchEnv }))).rejects.toThrow( + 'agent_session_launch_env_invalid' + ) + expect(store.getRecord(SESSION)).toBeNull() + }) + + it('rejects an overlong environment key before writing it', async () => { + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + + await expect( + store.reserveOwner(request({ launchEnv: { ['K'.repeat(513)]: 'value' } })) + ).rejects.toThrow('agent_session_launch_env_invalid') + expect(store.getRecord(SESSION)).toBeNull() + }) +}) diff --git a/src/main/runtime/agent-session-record-options.test.ts b/src/main/runtime/agent-session-record-options.test.ts index a1dfb9ccdef..5795763d96c 100644 --- a/src/main/runtime/agent-session-record-options.test.ts +++ b/src/main/runtime/agent-session-record-options.test.ts @@ -31,6 +31,20 @@ it('fails option hydration before ownership can be proved', async () => { ).rejects.toThrow('model list unavailable') }) +it('drops provider-rejected persisted options before the next owner proof', async () => { + await expect( + readNativeSessionOptions({ + adapter: { + readOptions: async () => ({ models: [], current: { model: 'provider-model' } }), + readOptionRestoreFailures: () => ['permissionMode'] + }, + sessionId: SESSION, + fence: 2, + priorOptions: { permissionMode: 'retired-mode', other: 'keep' } + }) + ).resolves.toEqual({ model: 'provider-model', other: 'keep' }) +}) + it('persists resumed provider options atomically with owner proof', async () => { const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) const reserved = await store.reserveOwner({ diff --git a/src/main/runtime/agent-session-resume-args.test.ts b/src/main/runtime/agent-session-resume-args.test.ts new file mode 100644 index 00000000000..db4d0b07b8d --- /dev/null +++ b/src/main/runtime/agent-session-resume-args.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { resolveAgentSessionResumeArgs } from './agent-session-resume-args' + +describe('agent session resume arguments', () => { + it('keeps the session creation arguments after mutable defaults change', () => { + expect( + resolveAgentSessionResumeArgs({ + persistedArgs: ['--model', 'claude-created'], + defaultArgs: '--model claude-current', + shell: 'posix' + }) + ).toBe("'--model' 'claude-created'") + }) + + it('keeps an explicit empty snapshot when defaults are toggled off', () => { + expect( + resolveAgentSessionResumeArgs({ + persistedArgs: [], + defaultArgs: '--dangerously-skip-permissions', + shell: 'posix' + }) + ).toBe('') + }) + + it('uses current defaults for legacy records without a snapshot', () => { + expect( + resolveAgentSessionResumeArgs({ + defaultArgs: '--dangerously-skip-permissions', + shell: 'posix' + }) + ).toBe('--dangerously-skip-permissions') + }) +}) diff --git a/src/main/runtime/agent-session-resume-args.ts b/src/main/runtime/agent-session-resume-args.ts new file mode 100644 index 00000000000..dc276726dc6 --- /dev/null +++ b/src/main/runtime/agent-session-resume-args.ts @@ -0,0 +1,17 @@ +import type { AgentSessionLaunchArgs } from '../../shared/agent-session-record' +import { quoteStartupArg, type AgentStartupShell } from '../../shared/tui-agent-startup-shell' + +export function resolveAgentSessionResumeArgs(input: { + requestArgs?: string | null + persistedArgs?: AgentSessionLaunchArgs + defaultArgs?: string | null + shell: AgentStartupShell +}): string | null | undefined { + if (input.requestArgs !== undefined) { + return input.requestArgs + } + if (input.persistedArgs !== undefined) { + return input.persistedArgs.map((arg) => quoteStartupArg(arg, input.shell)).join(' ') + } + return input.defaultArgs +} diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts new file mode 100644 index 00000000000..712543d0428 --- /dev/null +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -0,0 +1,747 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../shared/agent-session-wire' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import type { + ClaudeStreamJsonConnection, + ClaudeStreamJsonConnectionHandlers, + ClaudeStreamJsonLaunch, + openClaudeStreamJsonConnection +} from '../claude/claude-stream-json-connection' +import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' +import { + CLAUDE_SPAWN_TOKEN_ENV, + claudeProviderHandleLink +} from '../claude/claude-structured-owner-identity' +import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type { + StructuredAgentSessionHandoffTransport, + StructuredTuiOwner +} from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' +import type { OrcaRuntimeService } from './orca-runtime' +import type { RpcRequest, RpcResponse } from './rpc/core' +import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' +import { RpcDispatcher } from './rpc/dispatcher' +import { STRUCTURED_AGENT_SESSION_METHODS } from './rpc/methods/structured-agent-session' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const SESSION = 'claude-integration-1' +const PROVIDER_SESSION = claudeSessionIdForOrcaSession(SESSION) +const WORKSPACE = 'workspace-claude' +// Why 'runtime': this file exercises the Claude structured integration over agentSession.*, not the +// mobile surface — nothing here asserts anything mobile-specific, and its sibling integration +// suites use 'runtime' too. Mobile additionally requires the experimental structured-chat setting, +// which structured-agent-session.test.ts pins in both its satisfied and refused states. +const CLIENT = { + clientKind: 'runtime' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] +} + +const { readClaudeTranscriptLeafUuid, resolveSessionFilePath } = vi.hoisted(() => ({ + readClaudeTranscriptLeafUuid: vi.fn(), + resolveSessionFilePath: vi.fn() +})) + +vi.mock('../native-chat/session-file-resolver', () => ({ + readClaudeTranscriptLeafUuid, + resolveSessionFilePath +})) + +type FakeClaudeConnection = Omit & { + closed: boolean + exitVerdict: ClaudeStreamJsonConnection['exitVerdict'] + launch: ClaudeStreamJsonLaunch + handlers: ClaudeStreamJsonConnectionHandlers + calls: { subtype: string; params?: Record }[] + sent: Record[] +} + +function fakeClaude() { + const connections: FakeClaudeConnection[] = [] + let initializeAccount: unknown + /** A child that dies during start, with the close verdict its ladder observed. */ + let selfExit: { message: string; exitVerdict: ClaudeStreamJsonConnection['exitVerdict'] } | null = + null + const openConnection = (async (launch, handlers = {}) => { + const connection: FakeClaudeConnection = { + launch, + handlers, + calls: [], + sent: [], + pid: 4321 + connections.length, + closed: false, + initializationResult: async () => { + connection.calls.push({ subtype: 'initialize' }) + if (selfExit) { + handlers.onExit?.(new Error(selfExit.message)) + return { models: [] } + } + handlers.onMessage?.({ + type: 'system', + subtype: 'init', + session_id: PROVIDER_SESSION, + ...(connections.length === 0 ? { uuid: 'init-leaf' } : {}), + model: 'claude-sonnet-5', + apiKeySource: 'none' + }) + return { + models: [{ value: 'sonnet', displayName: 'Sonnet' }], + ...(initializeAccount === undefined ? {} : { account: initializeAccount }) + } + }, + getSettings: async () => { + connection.calls.push({ subtype: 'get_settings' }) + return { env: {} } + }, + supportedModels: async () => { + connection.calls.push({ subtype: 'list_models' }) + return [{ value: 'sonnet', displayName: 'Sonnet' }] + }, + setModel: async (model) => { + connection.calls.push({ subtype: 'set_model', params: { model } }) + }, + setPermissionMode: async (mode) => { + connection.calls.push({ subtype: 'set_permission_mode', params: { mode } }) + }, + applyFlagSettings: async (settings) => { + connection.calls.push({ subtype: 'apply_flag_settings', params: { settings } }) + }, + interrupt: async () => { + connection.calls.push({ subtype: 'interrupt', params: {} }) + return undefined + }, + cancelAsyncMessage: async () => {}, + send: async (message) => { + connection.sent.push(message) + if (message.type === 'user') { + handlers.onMessage?.({ ...message, uuid: 'user-1' }) + } + }, + exitVerdict: selfExit?.exitVerdict ?? { root: 'live', tree: 'unverifiable' }, + close: async () => { + connection.closed = true + return selfExit === null + } + } + connections.push(connection) + return connection + }) as typeof openClaudeStreamJsonConnection + const live = (): FakeClaudeConnection => { + const connection = connections.at(-1) + if (!connection) { + throw new Error('no Claude connection') + } + return connection + } + return { + connections, + openConnection, + live, + setInitializeAccount: (account: unknown) => { + initializeAccount = account + }, + setSelfExit: (exit: typeof selfExit) => { + selfExit = exit + } + } +} + +let operations = 0 +// Keep IDs unique without making each assertion depend on a wall-clock tick. +const TEST_OPERATION_TIMESTAMP = Date.now().toString() + +function operationId(): string { + operations += 1 + return `${TEST_OPERATION_TIMESTAMP}-${operations.toString(16).padStart(32, '0')}` +} + +function envelope(method: string, fields: Record, fence: number | null) { + return { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +function createIntentParams() { + const worktree = `id:${WORKSPACE}` + const fields = { worktree, agent: 'claude' } + return { envelope: envelope('agentSession.create', fields, null), ...fields } +} + +function ensureParams(fence: number) { + const params = { + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE, + workspaceKind: 'git-worktree' as const + }, + provider: 'claude' as const, + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR' as const, path: join(root, 'claude-home') }, + runtimeKind: 'native' as const, + providerHandle: { + kind: 'claude' as const, + sessionId: PROVIDER_SESSION, + leafUuid: 'assistant-leaf' + } + } + const base = { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: '' + } + return { + ...params, + envelope: { + ...base, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields({ ...params, envelope: base } as never) + }) + } + } +} + +function leaseOf(sessionId: string): { + claimStatus: string + runtimeFence: number + handoffStage: string | null + deathEvidence: { kind: string; detail: string } | null +} { + const host = getStructuredAgentSessionHost() as unknown as { + deps: { store: { getRecord: (id: string) => { lease: ReturnType } } } + } + return host.deps.store.getRecord(sessionId).lease +} + +function handoffParams(direction: 'to-native' | 'to-tui', fence: number) { + const fields = { direction, mode: 'now' as const, action: 'start' as const } + return { + envelope: envelope('agentSession.requestHandoff', fields, fence), + ...fields + } +} + +let claude: ReturnType +let root: string +let dispatcher: RpcDispatcher +let cleanups: Map void> +let tuiOwner: StructuredTuiOwner | null +let transcriptPath: string +/** Managed-account state and configured overlay this host installs, per test. */ +let claudeAuthPolicy: ClaudeStructuredAuthPolicy +let claudeLaunchEnv: Record + +async function call(method: string, params: unknown): Promise { + const replies: RpcResponse[] = [] + const request: RpcRequest = { id: `req-${operations}`, authToken: 'token', method, params } + await dispatcher.dispatchStreaming(request, (raw) => replies.push(JSON.parse(raw)), CLIENT) + if (!replies[0]) { + throw new Error(`no reply for ${method}`) + } + return replies[0] +} + +async function ok(method: string, params: unknown): Promise { + const response = await call(method, params) + expect(response, JSON.stringify(response)).toMatchObject({ ok: true }) + const result = (response as { result: { ok: boolean; value?: T } }).result + expect(result).toMatchObject({ ok: true }) + return result.value as T +} + +async function subscribe(): Promise { + const frames: AgentSessionSubscribeEvent[] = [] + await dispatcher.dispatchStreaming( + { + id: 'subscribe-1', + authToken: 'token', + method: 'agentSession.subscribe', + params: { sessionId: SESSION } + }, + (raw) => { + const response = JSON.parse(raw) as { ok: boolean; result?: AgentSessionSubscribeEvent } + if (response.ok && response.result) { + frames.push(response.result) + } + }, + CLIENT + ) + return frames +} + +function itemsOf(frames: AgentSessionSubscribeEvent[]): AgentJournalRenderItem[] { + const items = new Map() + for (const frame of frames) { + const rows = + frame.type === 'snapshot' || frame.type === 'reset' + ? frame.page.items + : frame.type === 'batch' + ? frame.batch.items + : [] + for (const row of rows) { + items.set(row.itemId, row) + } + } + return [...items.values()] +} + +function textOf(item: AgentJournalRenderItem): string { + return item.body?.kind === 'message' + ? item.body.blocks.map((block) => (block.type === 'text' ? block.text : '')).join('') + : '' +} + +beforeEach(async () => { + operations = 0 + claudeAuthPolicy = { stripAuthEnv: false } + claudeLaunchEnv = { + ANTHROPIC_AUTH_TOKEN: 'configured-token', + ANTHROPIC_BASE_URL: 'https://gateway.example.test' + } + root = await mkdtemp(join(tmpdir(), 'orca-claude-structured-integration-')) + transcriptPath = join(root, 'claude-home', 'projects', 'workspace', `${PROVIDER_SESSION}.jsonl`) + await mkdir(join(root, 'claude-home', 'projects', 'workspace'), { recursive: true }) + resolveSessionFilePath.mockResolvedValue(transcriptPath) + // The production branch proof returns the latest descendant of the prior + // cursor; mirror that contract so structured close does not regress to a + // stale mocked head. + readClaudeTranscriptLeafUuid.mockImplementation( + async (_path: string, _providerSessionId: string, previousLeafUuid?: string | null) => + previousLeafUuid ?? 'init-leaf' + ) + claude = fakeClaude() + tuiOwner = null + cleanups = new Map() + const handoffTransport: StructuredAgentSessionHandoffTransport = { + hostLabel: 'Scripted Claude host', + launchTui: async ({ record, fence, spawnToken }) => { + const head = record.providerHandleChain.at(-1)?.handle + tuiOwner = { + terminal: { + handle: 'term-claude-tui', + tabId: 'tab-claude-tui', + paneKey: 'tab-claude-tui:leaf-claude-tui', + ptyId: 'pty-claude-tui' + }, + process: { + hostId: 'local', + pid: 7331, + processStartTimeMs: 100, + spawnToken + }, + link: claudeProviderHandleLink({ + sessionId: PROVIDER_SESSION, + leafUuid: head?.provider === 'claude' ? head.leafUuid : null, + resumed: true, + fence, + observedAt: 1 + }), + transcriptPath + } + return tuiOwner + }, + reproveTuiOwner: async ({ owner }) => { + if (owner.link.handle.provider !== 'claude' || !owner.transcriptPath) { + return owner + } + return { + ...owner, + link: claudeProviderHandleLink({ + sessionId: owner.link.handle.sessionId, + leafUuid: await readClaudeTranscriptLeafUuid(owner.transcriptPath), + resumed: true, + fence: owner.link.mintedAtFence, + observedAt: 1 + }) + } + }, + recoverTuiOwner: async () => { + if (!tuiOwner) { + throw new Error('scripted TUI owner missing') + } + return tuiOwner + }, + stopRecoveredOwner: async () => {}, + waitForTuiExit: async (owner) => ({ transcriptPath: owner.transcriptPath }), + waitForTuiIdleOrExit: async () => 'idle', + tuiStatus: () => 'idle', + stopFailedTuiLaunch: async () => {} + } + const runtime = { + getRuntimeId: () => 'runtime-1', + getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + resolveStructuredAgentSessionCreateIntent: async (input: { envelope: unknown }) => ({ + ...ensureParams(1), + envelope: input.envelope, + providerHandle: undefined + }), + publishStructuredAgentSessionTab: vi.fn(), + ensureStructuredAgentSessionHost: () => + ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, + resolveCodexCommand: () => '/usr/local/bin/codex', + resolveClaudeCommand: () => '/usr/local/bin/claude', + readProcessStartTime: async (pid: number) => pid * 10, + resolveClaudeLaunchEnv: () => claudeLaunchEnv, + resolveClaudeAuthPolicy: () => claudeAuthPolicy, + openClaudeConnection: claude.openConnection, + handoffTransport + }).then(() => undefined), + registerSubscriptionCleanup: (id: string, dispose: () => void) => cleanups.set(id, dispose), + cleanupSubscription: (id: string) => cleanups.get(id)?.(), + cleanupSubscriptionsByPrefix: () => {} + } + dispatcher = new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: STRUCTURED_AGENT_SESSION_METHODS + }) +}) + +afterEach(async () => { + vi.unstubAllEnvs() + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) +}) + +describe('a structured Claude session over agentSession.*', () => { + it('strips ambient Anthropic auth from the child once a managed account is pinned', async () => { + claudeAuthPolicy = { stripAuthEnv: true } + claudeLaunchEnv = { ANTHROPIC_BASE_URL: 'https://gateway.example.test' } + vi.stubEnv('ANTHROPIC_API_KEY', 'sk-ant-SHELL-LEAK') + vi.stubEnv('ANTHROPIC_AUTH_TOKEN', 'tok-SHELL-LEAK') + + await ok<{ fence: number }>('agentSession.create', createIntentParams()) + + const env = claude.live().launch.env + expect(env).not.toHaveProperty('ANTHROPIC_API_KEY') + expect(env).not.toHaveProperty('ANTHROPIC_AUTH_TOKEN') + expect(env).toMatchObject({ + ANTHROPIC_BASE_URL: 'https://gateway.example.test', + CLAUDE_CONFIG_DIR: join(root, 'claude-home') + }) + }) + + it('refuses a create whose configured env overrides the pinned managed account auth', async () => { + claudeAuthPolicy = { stripAuthEnv: true } + // The default overlay carries ANTHROPIC_AUTH_TOKEN, which the terminal path + // refuses at spawn-env.ts:25 rather than letting it beat the pinned account. + const refused = await call('agentSession.create', createIntentParams()) + + expect(JSON.stringify(refused)).toContain('explicit Anthropic auth environment') + // Refused before spawn: no provider child was ever opened. + expect(claude.connections).toHaveLength(0) + }) + + it('durably returns actionable sign-in guidance when initialization has no credentials', async () => { + claude.setInitializeAccount({ apiProvider: 'firstParty', tokenSource: 'none' }) + const params = createIntentParams() + + const first = await call('agentSession.create', params) + const retry = await call('agentSession.create', params) + + expect(first).toMatchObject({ + ok: true, + result: { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: expect.stringMatching(/not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s) + } + } + }) + expect((retry as { result: unknown }).result).toEqual((first as { result: unknown }).result) + expect(claude.connections).toHaveLength(1) + }) + + it('releases a session whose CLI self-exited during create, with its diagnostic intact', async () => { + claude.setSelfExit({ + message: 'claude stream-json exited (code 1): claude: not signed in', + // The root's death is first-hand; its descendants were never snapshottable. + exitVerdict: { root: 'exited', tree: 'unverifiable' } + }) + + const failed = await call('agentSession.create', createIntentParams()) + + expect(JSON.stringify(failed)).toContain('claude: not signed in') + const lease = leaseOf(SESSION) + // Latching here would refuse every later attach with agent_session_ownership_unknown, + // wedging a user who only needs to sign in. + expect(lease).toMatchObject({ claimStatus: 'released', handoffStage: null }) + expect(lease.deathEvidence).toMatchObject({ + kind: 'exit-observed', + detail: 'the provider process exited; its descendants were not verifiable' + }) + + claude.setSelfExit(null) + // Signing in and reopening the chat works: the reservation was not latched. + await ok<{ fence: number }>('agentSession.ensure', ensureParams(lease.runtimeFence)) + }) + + it('keeps a session reserved when a descendant of the failed start was seen alive', async () => { + claude.setSelfExit({ + message: 'claude stream-json exited (code 1): claude: not signed in', + exitVerdict: { root: 'exited', tree: 'live' } + }) + + await call('agentSession.create', createIntentParams()) + + // A live descendant still holds the provider session: releasing would hand a + // second writer to it. + expect(leaseOf(SESSION)).toMatchObject({ + claimStatus: 'reserved', + handoffStage: 'manual-recovery' + }) + claude.setSelfExit(null) + }) + + it('routes a published Claude first-hand exit through fenced host reconciliation', async () => { + await ok<{ fence: number }>('agentSession.create', createIntentParams()) + const connection = claude.live() + connection.exitVerdict = { root: 'exited', tree: 'unverifiable' } + connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) + + for ( + let attempt = 0; + attempt < 20 && leaseOf(SESSION).claimStatus !== 'released'; + attempt += 1 + ) { + await new Promise((resolve) => setTimeout(resolve, 5)) + } + expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) + }) + + it('creates, sends, streams, approves, interrupts, and resumes from the chain head', async () => { + vi.stubEnv('ANTHROPIC_API_KEY', 'sk-ant-SHELL-LEAK') + const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + expect(claude.live().launch.options).toMatchObject({ sessionId: PROVIDER_SESSION }) + expect(claude.live().launch.options.resume).toBeUndefined() + expect(claude.live().launch.env).toMatchObject({ + ANTHROPIC_AUTH_TOKEN: 'configured-token', + ANTHROPIC_BASE_URL: 'https://gateway.example.test', + CLAUDE_CONFIG_DIR: join(root, 'claude-home'), + [CLAUDE_SPAWN_TOKEN_ENV]: expect.any(String) + }) + // System auth: the user's own shell key is their sign-in, exactly as on the + // terminal path, and the configured overlay still wins over it. + expect(claude.live().launch.env).toMatchObject({ ANTHROPIC_API_KEY: 'sk-ant-SHELL-LEAK' }) + expect(claude.live().launch.env?.PATH ?? claude.live().launch.env?.Path).toBeTruthy() + const history = await call('agentSession.history', { + sessionId: SESSION, + direction: 'tail', + limit: 1 + }) + expect(history).toMatchObject({ + ok: true, + result: { providerSession: { key: 'session_id', id: PROVIDER_SESSION } } + }) + const stream = await subscribe() + + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'List files' }] } + const sent = await ok<{ + submission: { dispatchState: string; providerItemId: string | null } + }>('agentSession.send', { + envelope: envelope('agentSession.send', { body }, created.fence), + body + }) + expect(sent.submission).toMatchObject({ + dispatchState: 'accepted', + providerItemId: `claude:${PROVIDER_SESSION}:user-1` + }) + + claude.live().handlers.onMessage?.({ + type: 'stream_event', + session_id: PROVIDER_SESSION, + uuid: 'assistant-leaf', + event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Two files.' } } + }) + claude.live().handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION, + uuid: 'assistant-leaf', + parent_tool_use_id: null, + message: { role: 'assistant', content: [{ type: 'text', text: 'Two files.' }] } + }) + claude.live().handlers.onMessage?.({ + type: 'result', + subtype: 'success', + session_id: PROVIDER_SESSION, + uuid: 'result-frame-uuid' + }) + claude.live().handlers.onMessage?.({ + type: 'stream_event', + session_id: PROVIDER_SESSION, + uuid: 'stream-event-frame-uuid', + event: { type: 'message_stop' } + }) + await getStructuredAgentSessionHost()?.flushStreamedEvents(SESSION) + expect(itemsOf(stream).find((item) => textOf(item) === 'Two files.')?.itemId).toBe( + `claude:${PROVIDER_SESSION}:assistant-leaf` + ) + + const answeredPermission = Promise.resolve( + claude.live().handlers.canUseTool?.('Bash', { command: 'ls' }, { + requestId: 'permission-1', + toolUseID: 'tool-1', + signal: new AbortController().signal + } as never) + ) + await getStructuredAgentSessionHost()?.flushStreamedEvents(SESSION) + const approval = itemsOf(stream).find((item) => item.body?.kind === 'approval') + expect(approval?.body).toMatchObject({ title: 'Allow Bash?', detail: '{"command":"ls"}' }) + await ok('agentSession.respondToApproval', { + envelope: envelope( + 'agentSession.respondTo:approval', + { + itemId: approval?.itemId, + expectedRevision: approval?.revision, + optionId: 'allow' + }, + created.fence + ), + itemId: approval?.itemId, + expectedRevision: approval?.revision, + optionId: 'allow' + }) + // Answering resolves the SDK's own canUseTool callback with the allow decision. + await expect(answeredPermission).resolves.toMatchObject({ + behavior: 'allow', + toolUseID: 'tool-1' + }) + + await expect( + ok('agentSession.cancel', { + envelope: envelope('agentSession.cancel', { turnId: 'user-1' }, created.fence), + turnId: 'user-1' + }) + ).resolves.toMatchObject({ turnId: 'user-1', cancelled: true }) + expect(claude.live().calls.at(-1)).toMatchObject({ subtype: 'interrupt' }) + + const host = getStructuredAgentSessionHost() as unknown as { + deps: { + store: { + getRecord: (sessionId: string) => { + providerHandleChain: { handle: { provider: string; leafUuid?: string | null } }[] + } + } + } + } + expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)?.handle).toMatchObject({ + provider: 'claude', + leafUuid: null + }) + const old = claude.live() + const resumed = await ok<{ fence: number }>('agentSession.ensure', ensureParams(created.fence)) + expect(resumed.fence).toBe(created.fence + 1) + expect(old.closed).toBe(true) + expect(resolveSessionFilePath).toHaveBeenCalledWith('claude', PROVIDER_SESSION, { + claudeProjectsDir: join(root, 'claude-home', 'projects') + }) + expect(claude.live().launch.options).toMatchObject({ + resume: PROVIDER_SESSION, + resumeSessionAt: 'assistant-leaf' + }) + expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)).toMatchObject({ + handle: { + provider: 'claude', + sessionId: PROVIDER_SESSION, + leafUuid: 'assistant-leaf' + }, + origin: 'resumed' + }) + }) + + it('completes a scripted native to TUI to native cycle with provider-history rehydration', async () => { + const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + await writeFile( + transcriptPath, + [ + { + type: 'user', + uuid: 'native-user', + message: { role: 'user', content: [{ type: 'text', text: 'NATIVE_USER' }] } + }, + { + type: 'assistant', + uuid: 'native-assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'NATIVE_ASSISTANT' }] } + }, + { + type: 'user', + uuid: 'tui-user', + message: { role: 'user', content: [{ type: 'text', text: 'TUI_USER' }] } + }, + { + type: 'assistant', + uuid: 'tui-assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'TUI_ASSISTANT' }] } + }, + { type: 'last-prompt', leafUuid: 'tui-assistant' } + ] + .map((entry) => JSON.stringify(entry)) + .join('\n') + ) + + await ok('agentSession.requestHandoff', handoffParams('to-tui', created.fence)) + const host = getStructuredAgentSessionHost()! + await vi.waitFor(async () => + expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'tui', phase: 'idle' }) + ) + expect(claude.connections[0]?.closed).toBe(true) + + const tuiFence = ( + host as unknown as { + deps: { store: { getRecord: (id: string) => { lease: { runtimeFence: number } } } } + } + ).deps.store.getRecord(SESSION).lease.runtimeFence + readClaudeTranscriptLeafUuid.mockResolvedValueOnce('tui-assistant') + await ok('agentSession.requestHandoff', handoffParams('to-native', tuiFence)) + await vi.waitFor(async () => + expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'native', phase: 'idle' }) + ) + + const frames = await subscribe() + const texts = itemsOf(frames).map(textOf).filter(Boolean) + expect(texts).toEqual( + expect.arrayContaining(['NATIVE_USER', 'NATIVE_ASSISTANT', 'TUI_USER', 'TUI_ASSISTANT']) + ) + expect(new Set(texts).size).toBe(texts.length) + expect(claude.connections).toHaveLength(2) + expect(claude.live().launch.options).toMatchObject({ resume: PROVIDER_SESSION }) + const record = ( + host as unknown as { + deps: { + store: { + getRecord: (id: string) => { + providerHandleChain: { handle: { provider: string; leafUuid?: string | null } }[] + } + } + } + } + ).deps.store.getRecord(SESSION) + expect(record.providerHandleChain.at(-1)?.handle).toMatchObject({ + provider: 'claude', + leafUuid: 'tui-assistant' + }) + }) +}) diff --git a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts index afbb70fc286..f70cf033718 100644 --- a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts +++ b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts @@ -17,6 +17,9 @@ import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import type { AgentSessionLaunchArgs } from '../../shared/agent-session-record' +import { resolveStartupShell } from '../../shared/tui-agent-startup-shell' +import { resolveAgentSessionResumeArgs } from './agent-session-resume-args' export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntimeWithResolveWorktreeRemovalTarget { protected getAgentSessionExecutionNamespace( @@ -88,7 +91,12 @@ export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntim async ensureAgentSession( request: RuntimeEnsureAgentSessionRequest, _caller: RuntimeAgentSessionRpcCaller = {}, - handoffAuthority?: { spawnToken: string; providerRoot: string; sessionId: string } + handoffAuthority?: { + spawnToken: string + providerRoot: string + sessionId: string + launchArgs?: AgentSessionLaunchArgs + } ): Promise { if (request.kind === 'automatic') { // Legacy renderer sleep records are migration evidence, not host authority. @@ -134,10 +142,12 @@ export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntim agent: request.agent, providerSession: identity.providerSession, cmdOverrides: settings.agentCmdOverrides ?? {}, - agentArgs: - request.agentArgs !== undefined - ? request.agentArgs - : resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs), + agentArgs: resolveAgentSessionResumeArgs({ + requestArgs: request.agentArgs, + persistedArgs: handoffAuthority?.launchArgs, + defaultArgs: resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs), + shell: resolveStartupShell(platform, shell) + }), agentEnv: { ...resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv), ...(handoffAuthority && request.agent === 'codex' @@ -148,6 +158,7 @@ export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntim }, ompResumeFilePath: request.ompResumeFilePath, sessionOptions: this.toAgentSessionOptions(request.launchPreferences), + sessionOptionsOverrideAgentArgs: Boolean(request.launchPreferences), platform, shell, isRemote diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 42c9c7ff6d3..06391e2ae83 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -10,6 +10,7 @@ import { } from './runtime-worktree-ps-activity' import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' import { compareWorktreePs } from './runtime-worktree-status-projection' +import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { Repo } from '../../shared/repo-types' import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment' import { ensureStructuredAgentSessionHost as installStructuredAgentSessionHost } from './structured-agent-session-runtime' @@ -21,9 +22,11 @@ import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' +import { resolveStartupShell, tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' import { resolveCodexStructuredAppServerArgs } from '../codex/codex-structured-app-server-args' import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' import { hostname } from 'node:os' +import { claudeStructuredAuthPolicyForSettings } from '../claude-accounts/claude-structured-auth-policy' import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe' import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' @@ -144,13 +147,47 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent // in a plain folder lands in the folder rather than failing to resolve. resolveWorkspacePath: async (workspaceId) => (await this.resolveRuntimeFileTarget(`id:${workspaceId}`)).worktree.path, - resolveLaunchArgs: () => this.resolveConfiguredCodexStructuredArgs(), + resolveLaunchArgs: (provider) => this.resolveConfiguredStructuredLaunchArgs(provider), resolveLaunchEnvOverlay: () => resolveTuiAgentLaunchEnv('codex', this.requireStore().getSettings().agentDefaultEnv), + resolveClaudeLaunchEnv: () => + resolveTuiAgentLaunchEnv('claude', this.requireStore().getSettings().agentDefaultEnv), + resolveClaudeAuthPolicy: () => + claudeStructuredAuthPolicyForSettings(this.requireStore().getSettings()), + // Same gate and same settings as agentSession.createSupport, re-read on every acquisition. + getClaudeManagedAccountGateSettings: () => this.requireStore().getSettings(), handoffTransport: this.createStructuredAgentSessionHandoffTransport() }) } + // Why the provider is honoured rather than assumed: Codex app-server flags are not + // Claude CLI flags, and prepending them to `claude` makes it exit on an unknown option. + protected resolveConfiguredStructuredLaunchArgs( + provider: AgentSessionRecord['provider'] + ): string[] { + if (provider === 'claude') { + return this.resolveConfiguredClaudeStructuredArgs() + } + return this.resolveConfiguredCodexStructuredArgs() + } + + protected resolveConfiguredClaudeStructuredArgs(): string[] { + const settings = this.requireStore().getSettings() + const shell = resolveStartupShell( + process.platform, + resolveLocalWindowsAgentStartupShell({ + platform: process.platform, + isRemote: false, + terminalWindowsShell: settings.terminalWindowsShell + }) + ) + const tokenized = tokenizeStartupCommand( + resolveTuiAgentLaunchArgs('claude', settings.agentDefaultArgs), + shell + ) + return tokenized.ok ? tokenized.tokens : [] + } + protected resolveConfiguredCodexStructuredArgs(): string[] { const settings = this.requireStore().getSettings() const shell = resolveLocalWindowsAgentStartupShell({ @@ -195,7 +232,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent tuiStatus: (owner) => this.structuredTuiStatus(owner), closeTuiOwner: (owner) => this.closeStructuredTuiOwner(owner), revealNativeSession: async ({ workspaceId, sessionId, agent = 'codex', adoptedTerminal }) => { - if (adoptedTerminal || agent !== 'codex') { + if (adoptedTerminal || (agent !== 'codex' && agent !== 'claude')) { return } await this.publishStructuredAgentSessionTab({ diff --git a/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts b/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts index 5700b71d9a5..cfb8b421966 100644 --- a/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts +++ b/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts @@ -4,6 +4,7 @@ import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-a import { agentSessionOwnerBindingsEqual } from '../../shared/claimed-agent-pty-owner-snapshot' import { resolvePinnedCodexRolloutProof } from '../codex/codex-tui-rollout-proof' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { resolveStructuredAgentSessionCreateSupport } from '../native-chat/structured-agent-session-create-support' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' @@ -12,6 +13,8 @@ import { getSystemCodexHomePath } from '../codex/codex-home-paths' import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from './structured-agent-session-runtime' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' +import { homedir } from 'node:os' +import { join } from 'node:path' export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends OrcaRuntimeWithStopStructuredSessionProcess { protected async resolveRecoveredStructuredTuiTranscript(input: { @@ -45,22 +48,18 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca async getStructuredAgentSessionCreateSupport( worktreeSelector: string, - agent: 'codex' + agent: 'claude' | 'codex' ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> { const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector) await this.ensureStructuredAgentSessionHost() - if (getStructuredAgentSessionHost()?.supportsCreate(location, agent)) { - return { supported: true } - } - return { - supported: false, - reason: - location.executionHostId !== LOCAL_EXECUTION_HOST_ID - ? 'remote' - : location.wslDistro - ? 'wsl' - : 'agent' - } + // The verdict lives in a typechecked module; this file is @ts-nocheck. + return resolveStructuredAgentSessionCreateSupport({ + agent, + location, + adapterSupportsCreate: + getStructuredAgentSessionHost()?.supportsCreate(location, agent) === true, + getSettings: () => this.requireStore().getSettings() + }) } protected hasProviderSessionObservationSource(): boolean { @@ -108,8 +107,23 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca async resolveStructuredAgentSessionCreateIntent(input: { envelope: { sessionId: string; clientOperationId: string } worktree: string - agent: 'codex' + agent: 'claude' | 'codex' }): Promise { + if (input.agent === 'claude') { + return this.resolveStructuredAgentSessionIntent(input, async ({ launchEnv, location }) => { + return ( + launchEnv.CLAUDE_CONFIG_DIR?.trim() || + this.accounts + .getClaudeConfigDirectory( + location.wslDistro + ? { runtime: 'wsl', wslDistro: location.wslDistro } + : { runtime: 'host' } + ) + ?.trim() || + join(homedir(), '.claude') + ) + }) + } return this.resolveStructuredAgentSessionIntent(input, async ({ workspacePath, launchEnv }) => { // A create has no process yet, so the current selection is what it must follow. const preparedHome = await this.prepareCodexStructuredLaunchFn?.({ workspacePath, launchEnv }) @@ -126,11 +140,17 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca input: { envelope: { sessionId: string; clientOperationId: string } worktree: string - agent: 'codex' + agent: 'claude' | 'codex' }, resolveAccountHomePath: (context: { workspacePath: string launchEnv: NodeJS.ProcessEnv + location: { + executionHostId: string + wslDistro: string | null + workspaceId: string + workspaceKind: 'folder' | 'git-worktree' + } }) => string | Promise ): Promise { const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent) @@ -152,8 +172,8 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca provider: input.agent, agent: input.agent, accountHome: { - variable: 'CODEX_HOME', - path: await resolveAccountHomePath({ workspacePath, launchEnv }) + variable: input.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', + path: await resolveAccountHomePath({ workspacePath, launchEnv, location }) }, runtimeKind: 'native' } diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 4466594b0dc..5b2c160f2c6 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -45,7 +45,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu } this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() for (const session of host?.listSessionTabs() ?? []) { - if (session.agent !== 'codex') { + if (session.agent !== 'codex' && session.agent !== 'claude') { continue } let sessionId = session.sessionId @@ -54,7 +54,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu } await this.publishStructuredAgentSessionTab({ ...session, - agent: 'codex', + agent: session.agent, sessionId, activate: false, notify: false @@ -65,7 +65,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu async publishStructuredAgentSessionTab(input: { workspaceId: string sessionId: string - agent: 'codex' + agent: 'claude' | 'codex' activate: boolean notify?: boolean }): Promise { @@ -105,7 +105,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu const tab: RuntimeMobileSessionAgentTab = { type: 'agent-session', id, - title: 'Codex Chat', + title: input.agent === 'claude' ? 'Claude Chat' : 'Codex Chat', sessionId: input.sessionId, agent: input.agent, isActive: input.activate diff --git a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts index 083c677e39f..af1fbc20372 100644 --- a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts @@ -52,4 +52,108 @@ describe('structured agent-session create intent', () => { path: '/accounts/selected/home' }) }) + + it('pins the configured Claude launch home without Codex launch preparation', async () => { + const prepareCodexStructuredLaunch = vi.fn() + const runtime = new OrcaRuntimeService( + { + getSettings: () => ({ + agentDefaultEnv: { + claude: { CLAUDE_CONFIG_DIR: '/configured/claude-home' } + } + }) + } as never, + undefined, + { prepareCodexStructuredLaunch } + ) + vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({ + supported: true + }) + const internal = runtime as unknown as { + resolveStructuredAgentSessionLocation: (selector: string) => Promise<{ + executionHostId: string + wslDistro: null + workspaceId: string + workspaceKind: 'git-worktree' + }> + resolveRuntimeFileTarget: (selector: string) => Promise<{ + worktree: { path: string } + }> + } + internal.resolveStructuredAgentSessionLocation = vi.fn(async () => ({ + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' as const + })) + internal.resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { path: '/repos/workspace-1' } + })) + + const intent = await runtime.resolveStructuredAgentSessionCreateIntent({ + envelope: { sessionId: 'session-1', clientOperationId: 'operation-1' }, + worktree: 'id:workspace-1', + agent: 'claude' + }) + + expect(prepareCodexStructuredLaunch).not.toHaveBeenCalled() + expect(intent.accountHome).toEqual({ + variable: 'CLAUDE_CONFIG_DIR', + path: '/configured/claude-home' + }) + }) + + it('uses the managed Claude launch home before falling back to ~/.claude', async () => { + const prepareCodexStructuredLaunch = vi.fn() + const getRuntimeConfigDir = vi.fn(() => '/accounts/managed/claude-home') + const runtime = new OrcaRuntimeService( + { + getSettings: () => ({ + agentDefaultEnv: { claude: {} } + }) + } as never, + undefined, + { prepareCodexStructuredLaunch } + ) + runtime.setAccountServices({ + claudeAccounts: { getRuntimeConfigDir } as never, + codexAccounts: {} as never, + rateLimits: {} as never + }) + vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({ + supported: true + }) + const internal = runtime as unknown as { + resolveStructuredAgentSessionLocation: (selector: string) => Promise<{ + executionHostId: string + wslDistro: null + workspaceId: string + workspaceKind: 'git-worktree' + }> + resolveRuntimeFileTarget: (selector: string) => Promise<{ + worktree: { path: string } + }> + } + internal.resolveStructuredAgentSessionLocation = vi.fn(async () => ({ + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' as const + })) + internal.resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { path: '/repos/workspace-1' } + })) + + const intent = await runtime.resolveStructuredAgentSessionCreateIntent({ + envelope: { sessionId: 'session-1', clientOperationId: 'operation-1' }, + worktree: 'id:workspace-1', + agent: 'claude' + }) + + expect(getRuntimeConfigDir).toHaveBeenCalledTimes(1) + expect(intent.accountHome).toEqual({ + variable: 'CLAUDE_CONFIG_DIR', + path: '/accounts/managed/claude-home' + }) + }) }) diff --git a/src/main/runtime/orca-runtime-structured-agent-session-launch-args.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-launch-args.test.ts new file mode 100644 index 00000000000..c4333fd0a4d --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-agent-session-launch-args.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +type InstalledDeps = { + resolveLaunchArgs: (provider: 'claude' | 'codex') => Promise | string[] + resolveLaunchEnvOverlay: () => Record + resolveClaudeLaunchEnv?: () => Record +} + +const { installStructuredAgentSessionHost } = vi.hoisted(() => ({ + installStructuredAgentSessionHost: vi.fn(async (_deps: unknown) => ({}) as never) +})) + +vi.mock('./structured-agent-session-runtime', async (importOriginal) => ({ + ...(await importOriginal()), + ensureStructuredAgentSessionHost: installStructuredAgentSessionHost +})) + +function runtimeWith(settings: Record): OrcaRuntimeService { + return new OrcaRuntimeService({ getSettings: () => settings } as never) +} + +async function installedDeps(settings: Record): Promise { + installStructuredAgentSessionHost.mockClear() + await runtimeWith(settings).ensureStructuredAgentSessionHost() + return installStructuredAgentSessionHost.mock.calls[0]?.[0] as InstalledDeps +} + +describe('structured agent-session launch args wiring', () => { + it('resolves Claude launch args from the Claude agent defaults, not Codex flags', async () => { + const deps = await installedDeps({ + agentDefaultArgs: { + claude: '--dangerously-skip-permissions --model opus', + codex: '--dangerously-bypass-approvals-and-sandbox' + }, + agentDefaultEnv: {} + }) + + expect(await deps.resolveLaunchArgs('claude')).toEqual([ + '--dangerously-skip-permissions', + '--model', + 'opus' + ]) + }) + + it('still resolves Codex app-server args for a Codex session', async () => { + const deps = await installedDeps({ + agentDefaultArgs: { + claude: '--dangerously-skip-permissions', + codex: '--dangerously-bypass-approvals-and-sandbox' + }, + agentDefaultEnv: {} + }) + + const codexArgs = await deps.resolveLaunchArgs('codex') + expect(codexArgs).not.toContain('--dangerously-skip-permissions') + expect(codexArgs.length).toBeGreaterThan(0) + }) + + it('never lets a broken Codex args configuration block a Claude session', async () => { + const deps = await installedDeps({ + agentDefaultArgs: { claude: '--model opus', codex: '--not-a-real-codex-flag' }, + agentDefaultEnv: {} + }) + + expect(await deps.resolveLaunchArgs('claude')).toEqual(['--model', 'opus']) + expect(() => deps.resolveLaunchArgs('codex')).toThrow() + }) + + it('supplies the Claude env overlay so the launch resolver does not fall back to process.env', async () => { + const deps = await installedDeps({ + agentDefaultArgs: {}, + agentDefaultEnv: { + claude: { ORCA_CLAUDE_OVERLAY: 'claude-value' }, + codex: { ORCA_CODEX_OVERLAY: 'codex-value' } + } + }) + + expect(deps.resolveClaudeLaunchEnv).toBeTypeOf('function') + expect(deps.resolveClaudeLaunchEnv?.()).toMatchObject({ + ORCA_CLAUDE_OVERLAY: 'claude-value' + }) + expect(deps.resolveClaudeLaunchEnv?.()).not.toHaveProperty('ORCA_CODEX_OVERLAY') + expect(deps.resolveLaunchEnvOverlay()).toMatchObject({ ORCA_CODEX_OVERLAY: 'codex-value' }) + }) +}) diff --git a/src/main/runtime/orca-runtime-structured-agent-session-launch-tui.ts b/src/main/runtime/orca-runtime-structured-agent-session-launch-tui.ts index 9e70602f91b..89836d1e027 100644 --- a/src/main/runtime/orca-runtime-structured-agent-session-launch-tui.ts +++ b/src/main/runtime/orca-runtime-structured-agent-session-launch-tui.ts @@ -28,7 +28,12 @@ export class OrcaRuntimeWithStructuredAgentSessionLaunchTui extends OrcaRuntimeW presentation: 'background' }, {}, - { spawnToken, providerRoot: record.accountHome.path, sessionId: record.sessionId } + { + spawnToken, + providerRoot: record.accountHome.path, + sessionId: record.sessionId, + ...(record.launchArgs !== undefined ? { launchArgs: record.launchArgs } : {}) + } ) const terminal = launched.terminal let spawnedOwner: StructuredTuiOwner | null = null diff --git a/src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts b/src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts new file mode 100644 index 00000000000..64b451e9ea9 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') { + return { + id, + email: `${id}@example.com`, + managedAuthPath: `/managed/${id}`, + managedAuthRuntime, + authMethod: 'subscription-oauth' as const, + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +const WSL_ONLY: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('wsl-1', 'wsl')], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } +} + +/** Registered Claude accounts with none selected: ambient auth, and the UI names no host identity, + * so this must reach structured rather than silently falling back to a terminal session. */ +const ACCOUNTS_PRESENT_NONE_ACTIVE: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('host-1', 'host'), managedAccount('host-2', 'host')], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } +} + +const HOST_SELECTED: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('host-1', 'host')], + activeClaudeManagedAccountId: 'host-1', + activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } +} + +function runtimeWithAccounts(claude: ClaudeManagedAccountGateSettings | null): OrcaRuntimeService { + // No store at all is the unreadable-settings case the gate must fail closed on. + const runtime = claude + ? new OrcaRuntimeService({ getSettings: () => claude } as never) + : new OrcaRuntimeService() + const internal = runtime as unknown as { + resolveStructuredAgentSessionLocation: (selector: string) => Promise + ensureStructuredAgentSessionHost: () => Promise + } + internal.resolveStructuredAgentSessionLocation = vi.fn(async () => ({ + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' as const + })) + // The adapter's own location answer is irrelevant here; pin it supported so only the account + // gate can refuse. + internal.ensureStructuredAgentSessionHost = vi.fn(async () => {}) + setStructuredAgentSessionHost({ + supportsCreate: () => true + } as unknown as StructuredAgentSessionHost) + return runtime +} + +afterEach(() => { + setStructuredAgentSessionHost(null) +}) + +describe('structured Claude managed-account gate', () => { + it('refuses Claude under a WSL-only managed account', async () => { + const runtime = runtimeWithAccounts(WSL_ONLY) + await expect( + runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'claude') + ).resolves.toMatchObject({ supported: false }) + }) + + it('supports Claude when accounts are registered but none is selected', async () => { + const runtime = runtimeWithAccounts(ACCOUNTS_PRESENT_NONE_ACTIVE) + await expect( + runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'claude') + ).resolves.toMatchObject({ supported: true }) + }) + + it('still supports Claude under a selected host managed account', async () => { + const runtime = runtimeWithAccounts(HOST_SELECTED) + await expect( + runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'claude') + ).resolves.toMatchObject({ supported: true }) + }) + + it('fails closed for Claude when the account runtime cannot be determined', async () => { + const runtime = runtimeWithAccounts(null) + await expect( + runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'claude') + ).resolves.toMatchObject({ supported: false }) + }) + + /** The gate is Claude's alone: Codex resolves its account separately and this lane must not + * change any Codex answer. */ + it('leaves Codex supported under the same WSL-only Claude account', async () => { + const runtime = runtimeWithAccounts(WSL_ONLY) + await expect( + runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'codex') + ).resolves.toMatchObject({ supported: true }) + }) +}) diff --git a/src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts b/src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts new file mode 100644 index 00000000000..0d9b12f7c52 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' + +const installed = vi.hoisted(() => ({ deps: null as Record | null })) + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +vi.mock('./structured-agent-session-runtime', () => ({ + ensureStructuredAgentSessionHost: vi.fn(async (deps: Record) => { + installed.deps = deps + }) +})) + +import { OrcaRuntimeService } from './orca-runtime' +import { + readClaudeManagedAccountGateSettings, + type ClaudeManagedAccountGateSettings +} from '../native-chat/claude-structured-managed-account-support' + +const SETTINGS = { + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null, + agentDefaultEnv: {}, + agentDefaultArgs: {} +} as unknown as ClaudeManagedAccountGateSettings + +function gateSettingsGetter(): (() => ClaudeManagedAccountGateSettings) | undefined { + const deps: Record = installed.deps ?? {} + const get = deps['getClaudeManagedAccountGateSettings'] + return typeof get === 'function' ? (get as () => ClaudeManagedAccountGateSettings) : undefined +} + +/** The runtime class this wiring lives on does not typecheck its own `this` calls, so a broken or + * missing gate hookup compiles clean. Pin it behaviourally instead. */ +describe('structured Claude managed-account gate wiring', () => { + it('hands the host a gate reader that resolves the live settings', async () => { + installed.deps = null + const runtime = new OrcaRuntimeService({ getSettings: () => SETTINGS } as never) + + await runtime.ensureStructuredAgentSessionHost() + + const get = gateSettingsGetter() + expect(typeof get).toBe('function') + expect(get?.()).toBe(SETTINGS) + }) + + /** The installer composes this getter with the fail-closed reader, which is the shape the + * resolver consumes; pin that composition end to end. */ + it('composes into a null answer instead of throwing when settings cannot be read', async () => { + installed.deps = null + const runtime = new OrcaRuntimeService() + + await runtime.ensureStructuredAgentSessionHost() + + const get = gateSettingsGetter() + expect(typeof get).toBe('function') + expect(() => get?.()).toThrow() + expect(readClaudeManagedAccountGateSettings(get!)).toBeNull() + }) +}) diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 9e752330445..d6ec1e22782 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -325,6 +325,56 @@ describe('structured session cold restoration', () => { expect(closed.tabGroups?.[0]?.tabOrder).toEqual(['terminal-tab']) }) + it('publishes restored Claude tabs with the Claude title', async () => { + const runtime = new OrcaRuntimeService() + const publish = vi.spyOn(runtime, 'publishStructuredAgentSessionTab') + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + getKnownWorkspaceSessionWorktreeIds(): Set + hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set + refreshMobileSessionPtyRecords(): Promise | null> + ensureStructuredAgentSessionHost(): Promise + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.getKnownWorkspaceSessionWorktreeIds = () => new Set() + internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() + internal.refreshMobileSessionPtyRecords = async () => new Set() + internal.ensureStructuredAgentSessionHost = async () => undefined + setStructuredAgentSessionHost({ + reconcileRestartLeases: async () => undefined, + restoreReadableSessions: async () => undefined, + listSessionTabs: () => [ + { + sessionId: 'agent-session:agent-session:restored-claude', + workspaceId: 'workspace-1', + agent: 'claude' + } + ] + } as never) + + await runtime.restoreStructuredAgentSessionTabs() + + expect(publish).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + sessionId: 'restored-claude', + agent: 'claude', + activate: false, + notify: false + }) + + const restored = await runtime.listMobileSessionTabs('id:workspace-1') + expect(restored.tabs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'agent-session', + id: 'agent-session:restored-claude', + title: 'Claude Chat', + agent: 'claude' + }) + ]) + ) + }) + it('commits the host close when the renderer already removed the structured tab', async () => { const runtime = new OrcaRuntimeService() runtime.setNotifier({ diff --git a/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts new file mode 100644 index 00000000000..d15f754b244 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts @@ -0,0 +1,730 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' +import { createEphemeralAgentSessionClaimSigner } from './agent-session-claim-identity' +import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' +import { OrcaRuntimeService } from './orca-runtime' + +const { + probeAgentSessionProcessIdentity, + proveCodexTuiRollout, + readClaudeTranscriptLeafUuid, + readStructuredTuiProcessIdentity, + resolveSessionFilePath, + resolvePinnedCodexRolloutProof +} = vi.hoisted(() => ({ + probeAgentSessionProcessIdentity: vi.fn(), + proveCodexTuiRollout: vi.fn(), + readClaudeTranscriptLeafUuid: vi.fn(), + readStructuredTuiProcessIdentity: vi.fn(), + resolveSessionFilePath: vi.fn(), + resolvePinnedCodexRolloutProof: vi.fn() +})) + +vi.mock('./structured-tui-process-identity', () => ({ readStructuredTuiProcessIdentity })) +vi.mock('../codex/codex-tui-rollout-proof', () => ({ + proveCodexTuiRollout, + resolvePinnedCodexRolloutProof +})) +vi.mock('../native-chat/session-file-resolver', () => ({ + readClaudeTranscriptLeafUuid, + resolveSessionFilePath +})) +vi.mock('./agent-session-process-identity-probe', async (importOriginal) => ({ + ...(await importOriginal()), + probeAgentSessionProcessIdentity +})) + +const WORKTREE_ID = 'repo-1::/tmp/structured-handoff' + +function notifier(revealTerminalSession: ReturnType) { + return { + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + } +} + +describe('structured TUI launch tab binding', () => { + it('recovers a live TUI from durable owner inventory in a fresh runtime', async () => { + const namespace = { + machine: 'native:test', + principal: 'uid:1', + container: 'native', + providerRoot: '/tmp/codex-home' + } + const signer = createEphemeralAgentSessionClaimSigner('profile-test') + const claim = signer.createClaim({ + namespace, + identity: { agent: 'codex', providerSession: { key: 'session_id', id: 'thread-1' } }, + canonicalWorktreeId: WORKTREE_ID + }) + const terminalHandle = 'term_cold_owner' + const leafId = '23013912-13f8-44e5-818f-d40a1ff4e8c5' + resolvePinnedCodexRolloutProof.mockResolvedValue('/tmp/codex-home/sessions/thread-1.jsonl') + const writeAgentSessionProof = vi.fn(() => false) + const runtime = new OrcaRuntimeService(undefined, undefined, { + agentSessionClaimSigner: signer + }) + runtime.setPtyController({ + listProcesses: vi.fn(async () => [ + { + id: 'pty-cold-owner', + incarnationId: 'incarnation-1', + cwd: '/tmp/structured-handoff', + title: 'codex', + worktreeId: WORKTREE_ID, + terminalHandle, + agentSessionOwners: [ + { + claim, + generation: 'generation-1', + phase: 'live' as const, + ptyId: 'pty-cold-owner', + surface: { + worktreeId: WORKTREE_ID, + tabId: 'tab-cold-owner', + leafId, + terminalHandle + } + } + ] + } + ]), + write: () => true, + kill: () => true, + writeAgentSessionProof, + getForegroundProcess: async () => null + }) + const internal = runtime as unknown as { + createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport + refreshMobileSessionPtyRecords(): Promise | null> + listResolvedWorktrees(): Promise + resolveTerminalWorkspaceLaunchScope(): Promise<{ + id: string + path: string + connectionId: null + repo: null + folderWorkspace: null + }> + getAgentSessionExecutionNamespace(): typeof namespace + ptysById: Map< + string, + { + launchToken: string | null + launchAgent: string | null + agentSessionOwners: unknown[] + tabId?: string | null + paneKey?: string | null + } + > + } + internal.listResolvedWorktrees = vi.fn(async () => [ + { id: WORKTREE_ID, repoId: 'repo-1', path: '/tmp/structured-handoff' } + ]) + internal.resolveTerminalWorkspaceLaunchScope = vi.fn(async () => ({ + id: WORKTREE_ID, + path: '/tmp/structured-handoff', + connectionId: null, + repo: null, + folderWorkspace: null + })) + internal.getAgentSessionExecutionNamespace = () => namespace + proveCodexTuiRollout.mockResolvedValueOnce({ + transcriptPath: '/tmp/codex-home/sessions/thread-1.jsonl' + }) + probeAgentSessionProcessIdentity.mockResolvedValue({ + outcome: 'identity-matched', + matchedOn: ['process-start-time'] + }) + + await internal.refreshMobileSessionPtyRecords() + const coldPty = internal.ptysById.get('pty-cold-owner')! + expect(coldPty).toMatchObject({ launchToken: null, launchAgent: null }) + expect(coldPty.agentSessionOwners).toHaveLength(1) + const runtimeId = (runtime as unknown as { runtimeId: string }).runtimeId + ;( + runtime as unknown as { + handles: Map< + string, + { + handle: string + runtimeId: string + rendererGraphEpoch: number + worktreeId: string + tabId: string + leafId: string + ptyId: string + ptyGeneration: number + } + > + } + ).handles.set(terminalHandle, { + handle: terminalHandle, + runtimeId, + rendererGraphEpoch: 0, + worktreeId: WORKTREE_ID, + tabId: 'pty:pty-cold-owner', + leafId: 'pty:pty-cold-owner', + ptyId: 'pty-cold-owner', + ptyGeneration: 0 + }) + coldPty.tabId = 'tab-cold-owner' + coldPty.paneKey = `tab-cold-owner:${leafId}` + coldPty.launchToken = 'spawn-token' + coldPty.launchAgent = 'codex' + + const owner = await internal.createStructuredAgentSessionHandoffTransport().recoverTuiOwner({ + sessionId: 'session-1', + location: { workspaceId: WORKTREE_ID, executionHostId: 'local' }, + accountHome: { variable: 'CODEX_HOME', path: namespace.providerRoot }, + providerHandleChain: [{ handle: { provider: 'codex', threadId: 'thread-1' }, observedAt: 1 }], + lease: { + ownerProcess: { + hostId: 'local', + pid: 4243, + processStartTimeMs: 10, + spawnToken: 'spawn-token' + }, + runtimeFence: 3 + } + } as never) + + expect(owner.terminal).toEqual({ + handle: terminalHandle, + tabId: 'tab-cold-owner', + paneKey: `tab-cold-owner:${leafId}`, + ptyId: 'pty-cold-owner' + }) + expect(proveCodexTuiRollout).toHaveBeenCalledWith( + expect.objectContaining({ + codexHome: namespace.providerRoot, + threadId: 'thread-1', + readOutput: expect.any(Function), + write: expect.any(Function) + }) + ) + expect(resolvePinnedCodexRolloutProof).not.toHaveBeenCalled() + expect(writeAgentSessionProof).not.toHaveBeenCalled() + expect(agentSessionPtyWriteGate.boundSessionId('pty-cold-owner')).toBe('session-1') + agentSessionPtyWriteGate.unbindPty('pty-cold-owner') + }) + + it('rebuilds a Claude proving link from current launch-token-bound hook evidence', async () => { + const paneKey = 'tab-claude:leaf-claude' + const spawnToken = 'claude-restart-token' + const sessionId = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' + const transcriptPath = '/tmp/claude-home/projects/worktree/session.jsonl' + const attestAgentHookCompatibilityAuthority = vi.fn(() => ({ + paneKey, + source: 'hydrated_commitment' as const + })) + const runtime = new OrcaRuntimeService(null, undefined, { + attestAgentHookCompatibilityAuthority, + getAgentProviderSessionRowsForPane: () => [ + { + paneKey, + connectionId: null, + state: 'done', + prompt: '', + agentType: 'claude', + receivedAt: Date.now() + 1000, + stateStartedAt: 10, + providerSession: { key: 'session_id', id: sessionId, transcriptPath } + } + ] + }) + const internal = runtime as unknown as { + createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport + ptysById: Map + restoredOrchestrationAuthorityByPtyId: Map + } + internal.ptysById.set('pty-claude', { + ptyId: 'pty-claude', + worktreeId: WORKTREE_ID, + connectionId: null, + tabId: 'tab-claude', + paneKey, + launchToken: spawnToken, + launchAgent: 'claude', + connected: true + }) + resolveSessionFilePath.mockResolvedValue('/tmp/claude-home/projects/worktree/session.jsonl') + readClaudeTranscriptLeafUuid.mockResolvedValue('leaf-before-resume') + const record = { + sessionId: 'session-1', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/tmp/claude-home' }, + providerHandleChain: [ + { + linkId: 'claude-old', + handle: { provider: 'claude', sessionId, leafUuid: 'leaf-before-resume' }, + origin: 'created', + mintedAtFence: 1, + observedAt: 1 + } + ], + lease: { + runtimeFence: 3, + ownerProcess: { + hostId: 'local', + pid: 4343, + processStartTimeMs: 20, + spawnToken + }, + provenHandleLinkId: null + } + } as never + probeAgentSessionProcessIdentity.mockResolvedValue({ + outcome: 'identity-matched', + matchedOn: ['process-start-time'] + }) + + const transport = internal.createStructuredAgentSessionHandoffTransport() + const recovered = await transport.recoverTuiOwner(record) + expect(recovered).toMatchObject({ + transcriptPath, + link: { + handle: { provider: 'claude', sessionId, leafUuid: 'leaf-before-resume' }, + origin: 'resumed', + mintedAtFence: 3 + } + }) + expect(recovered.link.linkId).not.toBe('claude-old') + + const pty = internal.ptysById.get('pty-claude') as { + launchToken: string | null + } + pty.launchToken = null + const dispatchAuthority = runtime.getOrchestrationDispatchAuthority(recovered.terminal.handle)! + internal.restoredOrchestrationAuthorityByPtyId.set('pty-claude', { + ptyId: 'pty-claude', + worktreeId: WORKTREE_ID, + terminalHandle: recovered.terminal.handle, + paneKey: recovered.terminal.paneKey, + processIncarnation: dispatchAuthority.processIncarnation, + hostScope: dispatchAuthority.hostScope + }) + + expect( + runtime.verifyOrchestrationCompatibilityCaller({ + terminalHandle: recovered.terminal.handle, + paneKey, + launchToken: spawnToken + }) + ).toMatchObject({ + paneKey, + terminalHandle: recovered.terminal.handle, + processIncarnation: dispatchAuthority.processIncarnation + }) + expect(attestAgentHookCompatibilityAuthority).toHaveBeenCalledWith({ + paneKey, + launchTokenHash: createHash('sha256').update(spawnToken).digest('hex'), + connectionId: null, + terminalProvenance: 'restored' + }) + attestAgentHookCompatibilityAuthority.mockReturnValueOnce(null as never) + expect( + runtime.verifyOrchestrationCompatibilityCaller({ + terminalHandle: recovered.terminal.handle, + paneKey, + launchToken: spawnToken + }) + ).toBeNull() + expect(agentSessionPtyWriteGate.boundSessionId('pty-claude')).toBe('session-1') + agentSessionPtyWriteGate.unbindPty('pty-claude') + }) + + it('requires restored hook attestation after the runtime restarts', async () => { + const paneKey = 'tab-restored:leaf-restored' + const spawnToken = 'restored-token' + const sessionId = '019fd532-7c11-7a90-b6de-4e1a2c3d5f62' + const transcriptPath = '/tmp/claude-home/projects/worktree/restored.jsonl' + const attestAgentHookCompatibilityAuthority = vi.fn(() => ({ + paneKey, + source: 'hydrated_commitment' as const + })) + const runtime = new OrcaRuntimeService(null, undefined, { + attestAgentHookCompatibilityAuthority, + getAgentProviderSessionRowsForPane: () => [ + { + paneKey, + connectionId: null, + state: 'done', + prompt: '', + agentType: 'claude', + receivedAt: Date.now() + 1000, + stateStartedAt: 10, + providerSession: { key: 'session_id', id: sessionId, transcriptPath } + } + ] + }) + const internal = runtime as unknown as { + createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport + ptysById: Map + restoredOrchestrationAuthorityByPtyId: Map + } + internal.ptysById.set('pty-restored', { + ptyId: 'pty-restored', + worktreeId: WORKTREE_ID, + connectionId: null, + tabId: 'tab-restored', + paneKey, + launchToken: spawnToken, + launchAgent: 'claude', + connected: true + }) + resolveSessionFilePath.mockResolvedValue('/tmp/claude-home/projects/worktree/restored.jsonl') + readClaudeTranscriptLeafUuid.mockResolvedValue('leaf-restored') + const record = { + sessionId: 'session-restored', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/tmp/claude-home' }, + providerHandleChain: [ + { + linkId: 'claude-restored', + handle: { provider: 'claude', sessionId, leafUuid: 'leaf-restored' }, + origin: 'created', + mintedAtFence: 1, + observedAt: 1 + } + ], + lease: { + runtimeFence: 4, + ownerProcess: { + hostId: 'local', + pid: 4545, + processStartTimeMs: 30, + spawnToken + }, + provenHandleLinkId: null + } + } as never + + const recovered = await internal + .createStructuredAgentSessionHandoffTransport() + .recoverTuiOwner(record) + const restoredPty = internal.ptysById.get('pty-restored') as { + launchToken: string | null + } + restoredPty.launchToken = null + const dispatchAuthority = runtime.getOrchestrationDispatchAuthority(recovered.terminal.handle)! + internal.restoredOrchestrationAuthorityByPtyId.set('pty-restored', { + ptyId: 'pty-restored', + worktreeId: WORKTREE_ID, + terminalHandle: recovered.terminal.handle, + paneKey: recovered.terminal.paneKey, + processIncarnation: dispatchAuthority.processIncarnation, + hostScope: dispatchAuthority.hostScope + }) + + expect( + runtime.verifyOrchestrationCompatibilityCaller({ + terminalHandle: recovered.terminal.handle, + paneKey, + launchToken: spawnToken + }) + ).toMatchObject({ + paneKey, + terminalHandle: recovered.terminal.handle, + processIncarnation: dispatchAuthority.processIncarnation + }) + expect(attestAgentHookCompatibilityAuthority).toHaveBeenCalledWith({ + paneKey, + launchTokenHash: createHash('sha256').update(spawnToken).digest('hex'), + connectionId: null, + terminalProvenance: 'restored' + }) + attestAgentHookCompatibilityAuthority.mockReturnValueOnce(null as never) + await expect( + runtime.verifyOrchestrationCompatibilityCaller({ + terminalHandle: recovered.terminal.handle, + paneKey, + launchToken: spawnToken + }) + ).toBeNull() + agentSessionPtyWriteGate.unbindPty('pty-restored') + }) + + it('proves the published launch tab before returning its revealed renderer binding', async () => { + let explicitStatus: { + state: 'working' | 'done' + prompt: string + receivedAt: number + stateStartedAt: number + paneKey: string + terminalHandle: string + } | null = null + const revealTerminalSession = vi.fn( + (_worktreeId: string, _options: { tabId?: string; leafId?: string; ptyId?: string }) => + Promise.resolve({ tabId: 'tab-renderer' }) + ) + const runtime = new OrcaRuntimeService( + { + getSettings: () => ({ + disabledTuiAgents: [], + agentCmdOverrides: {}, + agentDefaultArgs: { + codex: '-m gpt-5.6-sol -c model_reasoning_effort=high' + }, + agentDefaultEnv: {} + }) + } as never, + undefined, + { + getAgentStatusSnapshot: () => (explicitStatus ? [explicitStatus as never] : []) + } + ) + runtime.setNotifier(notifier(revealTerminalSession) as never) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-structured', pid: 4242 }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + const internal = runtime as unknown as { + createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport + resolveTerminalWorkspaceLaunchScope(): Promise<{ + id: string + path: string + connectionId: null + repo: null + folderWorkspace: null + }> + markLocalWorkspaceTrustedForAgent(): void + waitForTerminal(): Promise + waitForAdoptedStructuredTuiProof(): Promise<{ transcriptPath?: string }> + waitForStructuredTuiPtyExit(): Promise + closeTerminal(handle: string): Promise + handles: Map< + string, + { + rendererGraphEpoch: number + tabId: string + leafId: string + } + > + graphStatus: 'ready' + } + internal.resolveTerminalWorkspaceLaunchScope = vi.fn(async () => ({ + id: WORKTREE_ID, + path: '/tmp/structured-handoff', + connectionId: null, + repo: null, + folderWorkspace: null + })) + internal.markLocalWorkspaceTrustedForAgent = vi.fn() + const waitForTerminal = vi.fn(async () => ({})) + internal.waitForTerminal = waitForTerminal + const waitForAdoptedStructuredTuiProof = vi.fn(async () => { + const snapshot = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + expect(snapshot.tabs).toContainEqual( + expect.objectContaining({ + type: 'terminal', + parentTabId: expect.any(String), + leafId: expect.any(String), + ptyId: 'pty-structured', + terminal: expect.any(String) + }) + ) + expect(revealTerminalSession).not.toHaveBeenCalled() + return { transcriptPath: '/tmp/rollout.jsonl' } + }) + internal.waitForAdoptedStructuredTuiProof = waitForAdoptedStructuredTuiProof + const waitForStructuredTuiPtyExit = vi.fn(async () => {}) + internal.waitForStructuredTuiPtyExit = waitForStructuredTuiPtyExit + const closeTerminal = vi.fn(async () => undefined) + internal.closeTerminal = closeTerminal + readStructuredTuiProcessIdentity.mockResolvedValue({ + hostId: 'local', + pid: 4243, + processStartTimeMs: 10, + spawnToken: 'spawn-token' + }) + probeAgentSessionProcessIdentity.mockResolvedValue({ + outcome: 'identity-matched', + matchedOn: ['process-start-time'] + }) + + const transport = internal.createStructuredAgentSessionHandoffTransport() + const onSpawned = vi.fn(async () => {}) + const owner = await transport.launchTui({ + record: { + sessionId: 'session-1', + location: { workspaceId: WORKTREE_ID, executionHostId: 'local' }, + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, + launchArgs: ['--search'], + options: { model: 'gpt-5.6-terra', effort: 'medium' }, + providerHandleChain: [ + { handle: { provider: 'codex', threadId: 'thread-1' }, observedAt: 1 } + ] + } as never, + fence: 3, + spawnToken: 'spawn-token', + onSpawned + }) + + const reveal = revealTerminalSession.mock.calls[0]?.[1] as { + tabId: string + leafId: string + } + expect(owner.terminal).toMatchObject({ + tabId: 'tab-renderer', + paneKey: `${reveal.tabId}:${reveal.leafId}`, + ptyId: 'pty-structured' + }) + expect(waitForTerminal).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ condition: 'tui-idle' }) + ) + expect(waitForAdoptedStructuredTuiProof).toHaveBeenCalledOnce() + expect(onSpawned).toHaveBeenCalledWith( + expect.objectContaining({ + terminal: expect.objectContaining({ ptyId: 'pty-structured' }), + process: expect.objectContaining({ spawnToken: 'spawn-token' }) + }) + ) + expect(onSpawned.mock.invocationCallOrder[0]).toBeLessThan( + waitForTerminal.mock.invocationCallOrder[0]! + ) + expect(waitForAdoptedStructuredTuiProof.mock.invocationCallOrder[0]).toBeLessThan( + revealTerminalSession.mock.invocationCallOrder[0]! + ) + const launchCommand = spawn.mock.calls[0]?.[0]?.command + expect(launchCommand).toContain("'-m' 'gpt-5.6-terra'") + expect(launchCommand).toContain("'-c' 'model_reasoning_effort=medium'") + expect(launchCommand).toContain("'--search'") + expect(launchCommand).not.toContain('gpt-5.6-sol') + expect(launchCommand).not.toContain('model_reasoning_effort=high') + + Object.assign(internal.handles.get(owner.terminal.handle)!, { + rendererGraphEpoch: -1, + tabId: 'tab-retired', + leafId: 'leaf-retired' + }) + internal.graphStatus = 'ready' + + explicitStatus = { + state: 'working', + prompt: '', + receivedAt: Date.now(), + stateStartedAt: Date.now(), + paneKey: owner.terminal.paneKey, + terminalHandle: owner.terminal.handle + } + expect(transport.tuiStatus(owner)).toBe('busy') + await expect( + transport.waitForTuiIdleOrExit(owner, new AbortController().signal) + ).resolves.toBeNull() + + explicitStatus = { ...explicitStatus, state: 'done', receivedAt: Date.now() } + expect(transport.tuiStatus(owner)).toBe('idle') + await expect(transport.waitForTuiIdleOrExit(owner, new AbortController().signal)).resolves.toBe( + 'idle' + ) + + explicitStatus = null + const livePty = ( + runtime as unknown as { + ptysById: Map< + string, + { + tailBuffer: string[] + tailPartialLine: string + preview: string + lastAgentStatus: null + lastAgentStatusObservedLive: boolean + } + > + } + ).ptysById.get('pty-structured')! + Object.assign(livePty, { + tailBuffer: [ + 'OpenAI Codex (v0.147.0)', + 'model: gpt-5.6-terra', + 'directory: /tmp/structured-handoff' + ], + tailPartialLine: '', + preview: '', + lastAgentStatus: null, + lastAgentStatusObservedLive: false + }) + expect(transport.tuiStatus(owner)).toBe('idle') + await expect(transport.waitForTuiIdleOrExit(owner, new AbortController().signal)).resolves.toBe( + 'idle' + ) + + const pty = ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get('pty-structured')! + pty.launchToken = null + const persistedRecord = { + sessionId: 'session-1', + providerHandleChain: [{ handle: { provider: 'codex', threadId: 'thread-1' }, observedAt: 1 }], + lease: { ownerProcess: owner.process, provenHandleLinkId: owner.link.linkId } + } as never + + const rebound = await transport.reproveTuiOwner({ record: persistedRecord, owner }) + expect(rebound.terminal).toMatchObject({ + ptyId: 'pty-structured', + tabId: owner.terminal.tabId, + paneKey: owner.terminal.paneKey + }) + expect(rebound.terminal.handle).not.toBe(owner.terminal.handle) + await transport.waitForTuiExit(rebound) + expect(waitForStructuredTuiPtyExit).toHaveBeenCalledWith('pty-structured') + expect(waitForAdoptedStructuredTuiProof).toHaveBeenCalledOnce() + + await expect(transport.closeTuiOwner?.(rebound)).resolves.toEqual({ + transcriptPath: '/tmp/rollout.jsonl' + }) + expect(closeTerminal).toHaveBeenCalledWith(rebound.terminal.handle) + + explicitStatus = null + pty.connected = false + await expect( + transport.waitForTuiIdleOrExit(rebound, new AbortController().signal) + ).resolves.toBe('exited') + await expect(transport.stopFailedTuiLaunch?.(rebound)).resolves.toBeUndefined() + }) + + it('reveals Claude structured native sessions into the mobile graph', async () => { + const runtime = new OrcaRuntimeService() + const publish = vi.spyOn(runtime, 'publishStructuredAgentSessionTab') + const focusEditorTab = vi.fn() + runtime.setNotifier({ focusEditorTab } as never) + const internal = runtime as unknown as { + createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport + } + + await internal.createStructuredAgentSessionHandoffTransport().revealNativeSession?.({ + workspaceId: WORKTREE_ID, + sessionId: 'session-claude', + agent: 'claude' + }) + + expect(publish).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: WORKTREE_ID, + sessionId: 'session-claude', + agent: 'claude', + activate: false + }) + ) + expect(focusEditorTab).toHaveBeenCalledWith( + 'structured-agent-session-session-claude', + WORKTREE_ID + ) + }) +}) diff --git a/src/main/runtime/rpc/e2ee-channel-v2.test.ts b/src/main/runtime/rpc/e2ee-channel-v2.test.ts index f9e602ced41..b26b057abaf 100644 --- a/src/main/runtime/rpc/e2ee-channel-v2.test.ts +++ b/src/main/runtime/rpc/e2ee-channel-v2.test.ts @@ -156,6 +156,25 @@ describe('E2EEChannel v2', () => { }) }) + it('forwards post-auth capability-shaped frames without mutating authenticated capabilities', () => { + const ctx = setup() + const { schedule } = startV2(ctx) + const onMessage = vi.fn() + ctx.channel.onMessage(onMessage) + authenticate(ctx, schedule) + + const capabilityFrame = JSON.stringify({ + type: 'e2ee_client_capabilities', + v: 1, + clientCapabilities: ['agent-session.structured.v1'] + }) + ctx.channel.handleRawMessage(clientText(capabilityFrame, schedule, 1n)) + + expect(ctx.channel.clientCapabilities).toEqual([]) + expect(onMessage).toHaveBeenCalledOnce() + expect(onMessage.mock.calls[0]?.[0]).toBe(capabilityFrame) + }) + it('rejects legacy downgrade and runtime-only capability metadata when mobile v2 is required', () => { const legacy = setup() legacy.channel.handleRawMessage( diff --git a/src/main/runtime/rpc/methods/clipboard.test.ts b/src/main/runtime/rpc/methods/clipboard.test.ts index 118b21c766a..c0d224b85c6 100644 --- a/src/main/runtime/rpc/methods/clipboard.test.ts +++ b/src/main/runtime/rpc/methods/clipboard.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' -import type { RpcRequest } from '../core' +import type { RpcRequest, RpcResponse } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' import { CLIPBOARD_IMAGE_MAX_BASE64_CHARS, @@ -21,6 +21,10 @@ import { CLIPBOARD_METHODS, resetClipboardImageUploadsForTest } from './clipboard' +import { + hasMobileClipboardImagePath, + resetMobileClipboardImageProvenanceForTest +} from '../mobile-clipboard-image-provenance' function makeRequest(method: string, params?: unknown): RpcRequest { return { id: 'req-1', authToken: 'tok', method, params } @@ -31,15 +35,36 @@ function makeDispatcher(): RpcDispatcher { return new RpcDispatcher({ runtime, methods: CLIPBOARD_METHODS }) } +async function callMobile( + dispatcher: RpcDispatcher, + method: string, + params: unknown, + clientId = 'device-a' +): Promise { + const replies: RpcResponse[] = [] + await dispatcher.dispatchStreaming( + makeRequest(method, params), + (raw) => replies.push(JSON.parse(raw) as RpcResponse), + { clientKind: 'mobile', clientId } + ) + const response = replies[0] + if (!response) { + throw new Error(`no reply for ${method}`) + } + return response +} + describe('clipboard RPC methods', () => { beforeEach(() => { saveClipboardImageBufferAsTempFile.mockReset() resetClipboardImageUploadsForTest() + resetMobileClipboardImageProvenanceForTest() }) afterEach(() => { vi.useRealTimers() resetClipboardImageUploadsForTest() + resetMobileClipboardImageProvenanceForTest() }) it('saves browser-provided clipboard image bytes on the runtime host', async () => { @@ -64,6 +89,37 @@ describe('clipboard RPC methods', () => { }) }) + it('records a successful direct mobile upload for only the authenticated client', async () => { + const path = '/tmp/orca-paste-image.png' + saveClipboardImageBufferAsTempFile.mockResolvedValue(path) + const dispatcher = makeDispatcher() + + await expect( + callMobile(dispatcher, 'clipboard.saveImageAsTempFile', { + contentBase64: Buffer.from('png-bytes').toString('base64'), + connectionId: null + }) + ).resolves.toMatchObject({ ok: true, result: path }) + + expect(hasMobileClipboardImagePath('device-a', path)).toBe(true) + expect(hasMobileClipboardImagePath('device-b', path)).toBe(false) + }) + + it('does not authorize a remote-host clipboard path for local structured delivery', async () => { + const path = '/tmp/orca-paste-image.png' + saveClipboardImageBufferAsTempFile.mockResolvedValue(path) + const dispatcher = makeDispatcher() + + await expect( + callMobile(dispatcher, 'clipboard.saveImageAsTempFile', { + contentBase64: Buffer.from('png-bytes').toString('base64'), + connectionId: 'ssh-1' + }) + ).resolves.toMatchObject({ ok: true, result: path }) + + expect(hasMobileClipboardImagePath('device-a', path)).toBe(false) + }) + it('rejects non-base64 clipboard image payloads', async () => { const dispatcher = makeDispatcher() @@ -140,6 +196,48 @@ describe('clipboard RPC methods', () => { expect(saveClipboardImageBufferAsTempFile).toHaveBeenCalledWith(Buffer.from('png-bytes'), { connectionId: 'ssh-1' }) + expect(hasMobileClipboardImagePath('device-a', '/tmp/orca-paste-image.png')).toBe(false) + }) + + it('binds chunk mutation and provenance to the mobile client that started the upload', async () => { + saveClipboardImageBufferAsTempFile.mockResolvedValue('/tmp/orca-paste-image.png') + const dispatcher = makeDispatcher() + const contentBase64 = Buffer.from('png-bytes').toString('base64') + const start = await callMobile(dispatcher, 'clipboard.startImageUpload', { + expectedBase64Length: contentBase64.length, + connectionId: null + }) + const uploadId = (start.ok ? start.result : null) as { uploadId: string } + + for (const method of [ + 'clipboard.appendImageUploadChunk', + 'clipboard.commitImageUpload', + 'clipboard.abortImageUpload' + ]) { + const params = + method === 'clipboard.appendImageUploadChunk' + ? { uploadId: uploadId.uploadId, offset: 0, contentBase64 } + : { uploadId: uploadId.uploadId } + await expect(callMobile(dispatcher, method, params, 'device-b')).resolves.toMatchObject({ + ok: false + }) + } + + await expect( + callMobile(dispatcher, 'clipboard.appendImageUploadChunk', { + uploadId: uploadId.uploadId, + offset: 0, + contentBase64 + }) + ).resolves.toMatchObject({ + ok: true, + result: { receivedBase64Length: contentBase64.length } + }) + await expect( + callMobile(dispatcher, 'clipboard.commitImageUpload', { uploadId: uploadId.uploadId }) + ).resolves.toMatchObject({ ok: true, result: '/tmp/orca-paste-image.png' }) + expect(hasMobileClipboardImagePath('device-a', '/tmp/orca-paste-image.png')).toBe(true) + expect(hasMobileClipboardImagePath('device-b', '/tmp/orca-paste-image.png')).toBe(false) }) it('rejects out-of-order chunk offsets', async () => { diff --git a/src/main/runtime/rpc/methods/clipboard.ts b/src/main/runtime/rpc/methods/clipboard.ts index 3d5212c7a52..e6b487d7761 100644 --- a/src/main/runtime/rpc/methods/clipboard.ts +++ b/src/main/runtime/rpc/methods/clipboard.ts @@ -1,11 +1,12 @@ import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod, type RpcContext, type RpcMethod } from '../core' import { saveClipboardImageBufferAsTempFile } from '../../../window/clipboard-image-temp-file' import { randomUUID } from 'node:crypto' import { CLIPBOARD_IMAGE_MAX_BASE64_CHARS, CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../../shared/clipboard-image' +import { recordMobileClipboardImagePath } from '../mobile-clipboard-image-provenance' const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024 @@ -16,6 +17,7 @@ const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ type ClipboardImageUpload = { expectedBase64Length: number connectionId?: string | null + mobileClientId?: string chunks: string[] receivedBase64Length: number expiresAt: number @@ -69,6 +71,28 @@ function getUpload(uploadId: string): ClipboardImageUpload { return upload } +function mobileClientId(ctx: RpcContext): string | undefined { + if (ctx.clientKind !== 'mobile') { + return undefined + } + const clientId = ctx.clientId?.trim() + if (!clientId) { + throw new Error('Clipboard image upload requires an authenticated mobile client') + } + return clientId +} + +function assertMobileUploadOwner( + upload: ClipboardImageUpload, + ctx: RpcContext +): string | undefined { + const clientId = mobileClientId(ctx) + if (clientId && upload.mobileClientId !== clientId) { + throw new Error('Clipboard image upload was not found') + } + return clientId +} + function assertValidBase64Content(value: string): void { if (!isValidBase64(value)) { throw new Error('Clipboard image content must be base64') @@ -131,15 +155,24 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [ defineMethod({ name: 'clipboard.saveImageAsTempFile', params: SaveImageAsTempFile, - handler: async (params) => - saveClipboardImageBufferAsTempFile(Buffer.from(params.contentBase64, 'base64'), { - connectionId: params.connectionId - }) + handler: async (params, ctx) => { + const clientId = mobileClientId(ctx) + const path = await saveClipboardImageBufferAsTempFile( + Buffer.from(params.contentBase64, 'base64'), + { + connectionId: params.connectionId + } + ) + if (clientId && !params.connectionId) { + recordMobileClipboardImagePath(clientId, path) + } + return path + } }), defineMethod({ name: 'clipboard.startImageUpload', params: StartImageUpload, - handler: (params) => { + handler: (params, ctx) => { pruneExpiredUploads() if (clipboardImageUploads.size >= CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT) { throw new Error('Too many clipboard image uploads are in progress') @@ -148,6 +181,7 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [ clipboardImageUploads.set(uploadId, { expectedBase64Length: params.expectedBase64Length, connectionId: params.connectionId, + mobileClientId: mobileClientId(ctx), chunks: [], receivedBase64Length: 0, expiresAt: Date.now() + CLIPBOARD_IMAGE_UPLOAD_TTL_MS, @@ -159,8 +193,9 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [ defineMethod({ name: 'clipboard.appendImageUploadChunk', params: AppendImageUploadChunk, - handler: (params) => { + handler: (params, ctx) => { const upload = getUpload(params.uploadId) + assertMobileUploadOwner(upload, ctx) if (params.offset !== upload.receivedBase64Length) { throw new Error('Clipboard image chunk offset is out of order') } @@ -177,17 +212,25 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [ defineMethod({ name: 'clipboard.commitImageUpload', params: CommitImageUpload, - handler: async (params) => { + handler: async (params, ctx) => { const upload = getUpload(params.uploadId) + const clientId = assertMobileUploadOwner(upload, ctx) try { if (upload.receivedBase64Length !== upload.expectedBase64Length) { throw new Error('Clipboard image upload is incomplete') } const contentBase64 = upload.chunks.join('') assertValidBase64Content(contentBase64) - return await saveClipboardImageBufferAsTempFile(Buffer.from(contentBase64, 'base64'), { - connectionId: upload.connectionId - }) + const path = await saveClipboardImageBufferAsTempFile( + Buffer.from(contentBase64, 'base64'), + { + connectionId: upload.connectionId + } + ) + if (clientId && !upload.connectionId) { + recordMobileClipboardImagePath(clientId, path) + } + return path } finally { // Why: failed SSH or filesystem commits must not leave bounded upload // memory pinned until TTL cleanup. @@ -198,7 +241,12 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [ defineMethod({ name: 'clipboard.abortImageUpload', params: AbortImageUpload, - handler: (params) => { + handler: (params, ctx) => { + pruneExpiredUploads() + const upload = clipboardImageUploads.get(params.uploadId) + if (upload) { + assertMobileUploadOwner(upload, ctx) + } deleteUpload(params.uploadId) return { aborted: true } } diff --git a/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts b/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts new file mode 100644 index 00000000000..dcba8b7b64e --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts @@ -0,0 +1,22 @@ +import { defineMethod, type RpcAnyMethod } from '../core' +import { ActivateTab, SaveMarkdownTab } from './session-tabs-schemas' + +export const MOBILE_MARKDOWN_TAB_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'markdown.readTab', + params: ActivateTab, + handler: async (params, { runtime }) => + runtime.readMobileMarkdownTab(params.worktree, params.tabId) + }), + defineMethod({ + name: 'markdown.saveTab', + params: SaveMarkdownTab, + handler: async (params, { runtime }) => + runtime.saveMobileMarkdownTab( + params.worktree, + params.tabId, + params.baseVersion, + params.content + ) + }) +] diff --git a/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts index 226277f6ebd..a2a93533b6f 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../../shared/protocol-version' @@ -67,13 +68,24 @@ describe('session tab structured capability mutations', () => { expect(fixture.calls[method.runtimeMethod]).toHaveBeenCalledOnce() }) - it(`rejects ${method.name} for a legacy Claude row`, async () => { + it(`rejects ${method.name} on a Claude row the client never negotiated`, async () => { const fixture = createFixture([STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]) const response = await fixture.dispatch(method.name, method.params('claude-session')) expect(response.ok).toBe(false) expect(fixture.calls[method.runtimeMethod]).not.toHaveBeenCalled() }) + + it(`allows ${method.name} for a client that negotiated Claude rows`, async () => { + const fixture = createFixture([ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]) + const response = await fixture.dispatch(method.name, method.params('claude-session')) + + expect(response.ok).toBe(true) + expect(fixture.calls[method.runtimeMethod]).toHaveBeenCalledOnce() + }) } it.each(['session.tabs.close', 'session.tabs.closeLifecycle'] as const)( diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts index 4a61a99bc20..7128f756d6e 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types' @@ -119,44 +120,105 @@ describe('projectSessionTabAgentStatus', () => { expect(capable).toBe(snapshot) }) - it('withholds legacy Claude rows from paired structured clients', () => { - const snapshot = { - ...makeSnapshot(false), - tabs: [ - { - type: 'agent-session', - id: 'agent-session:codex', - title: 'Codex Chat', - sessionId: 'codex', - agent: 'codex', - isActive: true - }, - { - type: 'agent-session', - id: 'agent-session:claude', - title: 'Claude Chat', - sessionId: 'claude', - agent: 'claude', - isActive: false - } - ], - activeTabId: 'agent-session:codex', - activeTabType: 'agent-session' + const claudeSnapshot = { + ...makeSnapshot(false), + tabs: [ + { + type: 'agent-session', + id: 'agent-session:codex', + title: 'Codex Chat', + sessionId: 'codex', + agent: 'codex', + isActive: true + }, + { + type: 'agent-session', + id: 'agent-session:claude', + title: 'Claude Chat', + sessionId: 'claude', + agent: 'claude', + isActive: false + } + ], + activeGroupId: 'group-a', + activeTabId: 'agent-session:codex', + activeTabType: 'agent-session', + tabGroups: [ + { id: 'group-a', activeTabId: 'agent-session:codex', tabOrder: ['agent-session:codex'] }, + { id: 'group-b', activeTabId: 'agent-session:claude', tabOrder: ['agent-session:claude'] } + ], + tabGroupLayout: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'group-a' }, + second: { type: 'leaf', groupId: 'group-b' } + } + } as unknown as RuntimeMobileSessionTabsSnapshot + + const structuredMobile = [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ] + + it.each([ + ['mobile', 'mobile' as const, [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]], + ['runtime', 'runtime' as const, [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]] + ])( + 'withholds Claude rows from a paired %s client that never negotiated them', + (_name, clientKind, capabilities) => { + const projected = projectSessionTabAgentStatus(claudeSnapshot, clientKind, capabilities, true) + + expect(projected.tabs.map((tab) => tab.id)).toEqual(['agent-session:codex']) + // A row pruned from `tabs` but left in the layout is its own dead tab. + expect(projected.tabGroups?.map((group) => group.id)).toEqual(['group-a']) + expect(projected.tabGroupLayout).toEqual({ type: 'leaf', groupId: 'group-a' }) + expect(projected.activeGroupId).toBe('group-a') + expect(projected.activeTabId).toBe('agent-session:codex') + expect(projected.activeTabType).toBe('agent-session') + } + ) + + it.each([ + ['mobile', 'mobile' as const, structuredMobile], + [ + 'runtime', + 'runtime' as const, + [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ] + ] + ])( + 'publishes Claude rows to a paired %s client that negotiated them', + (_name, clientKind, capabilities) => { + const projected = projectSessionTabAgentStatus(claudeSnapshot, clientKind, capabilities, true) + + expect(projected).toBe(claudeSnapshot) + expect(projected.tabGroupLayout).toEqual(claudeSnapshot.tabGroupLayout) + } + ) + + it('keeps Claude rows on the local renderer, which negotiates nothing', () => { + expect(projectSessionTabAgentStatus(claudeSnapshot, undefined, undefined)).toBe(claudeSnapshot) + expect(projectSessionTabAgentStatus(claudeSnapshot, undefined, [])).toBe(claudeSnapshot) + }) + + it('leaves Codex rows untouched whether or not the Claude capability is present', () => { + const codexOnly = { + ...claudeSnapshot, + tabs: claudeSnapshot.tabs.filter((tab) => tab.id !== 'agent-session:claude'), + tabGroups: claudeSnapshot.tabGroups?.filter((group) => group.id !== 'group-b'), + tabGroupLayout: { type: 'leaf', groupId: 'group-a' } } as unknown as RuntimeMobileSessionTabsSnapshot - expect( - projectSessionTabAgentStatus(snapshot, 'runtime', [ - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY - ]).tabs.map((tab) => tab.id) - ).toEqual(['agent-session:codex']) - expect( - projectSessionTabAgentStatus( - snapshot, - 'mobile', - [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], - true - ).tabs.map((tab) => tab.id) - ).toEqual(['agent-session:codex']) + for (const capabilities of [[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], structuredMobile]) { + for (const clientKind of ['mobile', 'runtime'] as const) { + expect(projectSessionTabAgentStatus(codexOnly, clientKind, capabilities, true)).toBe( + codexOnly + ) + } + } + expect(projectSessionTabAgentStatus(codexOnly, undefined, undefined)).toBe(codexOnly) }) it('withholds session boundaries from legacy paired clients', () => { diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts index 375b3b499d5..0e0d9c716a5 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts @@ -1,5 +1,6 @@ import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../../shared/protocol-version' import type { @@ -24,7 +25,14 @@ export function projectSessionTabAgentStatus true) - if (structuredVisible && clientKind !== undefined) { + // Why: a paired client renders only codex structured tabs unless it says otherwise + // (mobile's resolveMobileNativeChat returns null for every other agent), so an + // ungated row would list and select into a pane that shows neither chat nor terminal. + if ( + structuredVisible && + clientKind !== undefined && + !clientCapabilities?.includes(CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + ) { projected = projectAgentSessionTabsOut(projected, (tab) => tab.agent !== 'codex') } // Why: only paired runtimes have legacy `done` completion side effects; mobile must keep its row without changing the exact v2 auth shape. diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 6a9372ed2c1..4f7c1b20cc3 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -98,7 +98,7 @@ export const CreateIntentParams = z .object({ envelope: MutationEnvelope, worktree: Identifier('Invalid worktree selector'), - agent: z.literal('codex') + agent: z.enum(['claude', 'codex']) }) .strict() @@ -107,7 +107,7 @@ export const CreateParams = z.union([AttachParams, CreateIntentParams]) export const CreateSupportParams = z .object({ worktree: Identifier('Invalid worktree selector'), - agent: z.literal('codex') + agent: z.enum(['claude', 'codex']) }) .strict() @@ -170,6 +170,15 @@ export const SetOptionParams = z }) .strict() +export const HandoffParams = z + .object({ + envelope: MutationEnvelope, + direction: z.enum(['to-tui', 'to-native']), + mode: z.enum(['now', 'after-turn', 'stop-turn']), + action: z.enum(['start', 'cancel-queued', 'retry', 'recover']).optional() + }) + .strict() + export const OptionsParams = z.object({ sessionId: SessionId }).strict() /** One surface's claim on one session. The id names the surface, not the client: two chat views diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index b65e6eff825..155d0aa6768 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -99,6 +99,22 @@ function hostStub(): StructuredAgentSessionHost { setSessionTabVisibility: vi.fn(async () => undefined), respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })), setOption: vi.fn(async () => ({ ok: true, replayed: false })), + requestHandoff: vi.fn(async () => ({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-a', sequence: 0 }, + value: { + status: { + owner: 'native', + direction: null, + phase: 'idle', + stage: null, + operationId: null + } + } + })), + supportsCreate: vi.fn(() => true), handoffStatus: vi.fn(async () => ({ owner: 'native' })), readOptions: vi.fn(async () => ({ models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }], @@ -122,9 +138,12 @@ function dispatcher(runtimeOverrides: Record = {}): RpcDispatch workspaceId: 'workspace-1', workspaceKind: 'git-worktree' }, - provider: 'codex', - agent: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' }, + provider: params.agent, + agent: params.agent, + accountHome: { + variable: params.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', + path: params.agent === 'claude' ? '/host/.claude' : '/host/.codex' + }, runtimeKind: 'native' })), publishStructuredAgentSessionTab: vi.fn() @@ -221,7 +240,7 @@ describe('capability gating', () => { } // Bump deliberately: the whole agentSession.* surface is behind the structured capability, // so an additive method is invisible to old clients and needs no protocol bump. - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(16) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(17) }) it('hides the surface from a declared client that did not advertise it', async () => { @@ -329,6 +348,49 @@ describe('method routing', () => { ) }) + it('routes Claude create support and create through the provider-aware runtime', async () => { + const worktree = 'id:workspace-1' + const support = await call( + 'agentSession.createSupport', + { worktree, agent: 'claude' }, + STRUCTURED_CLIENT + ) + expect(support).toMatchObject({ ok: true, result: { supported: true } }) + expect(runtimeCalls.getStructuredAgentSessionCreateSupport).toHaveBeenCalledWith( + worktree, + 'claude' + ) + + const params = { + envelope: envelope({ + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: SESSION, + fields: { worktree, agent: 'claude' } + }) + }), + worktree, + agent: 'claude' + } + const created = await call('agentSession.create', params, STRUCTURED_CLIENT) + expect(created).toMatchObject({ ok: true, result: { ok: true } }) + expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith(params) + expect(hostCalls.attach).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/host/.claude' } + }) + ) + expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: SESSION, + activate: true, + agent: 'claude' + }) + ) + }) + it('reports an unknown create outcome when attach commits before tab publication fails', async () => { const worktree = 'id:workspace-1' const params = { @@ -371,6 +433,25 @@ describe('method routing', () => { expect(ensured).toMatchObject({ ok: true }) }) + /** A client-supplied location skips the worktree-resolving support check, so both attach-shaped + * entries must ask the executing host directly or a host that cannot fence a provider child + * would create one anyway. */ + it.each(['agentSession.create', 'agentSession.ensure'])( + 'refuses %s for a client-supplied location the executing host does not support', + async (method) => { + hostCalls.supportsCreate.mockReturnValue(false) + + const refused = await call(method, attachParams()) + + expect(refused).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + }) + expect(hostCalls.attach).not.toHaveBeenCalled() + expect(hostCalls.supportsCreate).toHaveBeenCalledWith(attachParams().location, 'codex') + } + ) + it('tags the prompt kind from the method name, not from the client', async () => { const params = { envelope: envelope(), @@ -386,7 +467,7 @@ describe('method routing', () => { ]) }) - it('does not register the structured handoff mutation', async () => { + it('routes the structured handoff mutation through the host', async () => { const response = await call('agentSession.requestHandoff', { envelope: envelope(), direction: 'to-tui', @@ -394,7 +475,11 @@ describe('method routing', () => { action: 'start' }) - expect(response).toMatchObject({ ok: false, error: { code: 'method_not_found' } }) + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.requestHandoff).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ direction: 'to-tui', mode: 'now', action: 'start' }) + ) }) }) @@ -434,25 +519,6 @@ describe('parameter validation', () => { ) }) - it('rejects Claude structured create shapes', async () => { - await rejects('agentSession.createSupport', { - worktree: 'id:workspace-1', - agent: 'claude' - }) - const fields = { worktree: 'id:workspace-1', agent: 'claude' } - await rejects('agentSession.create', { - envelope: envelope({ - expectedRuntimeFence: null, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.create', - sessionId: SESSION, - fields - }) - }), - ...fields - }) - }) - it('requires a sha256 fingerprint and a positive fence', async () => { await rejects( 'agentSession.send', diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index ffd23499a3e..3b18f6b0ef1 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -10,6 +10,7 @@ import { agentSessionFingerprintConflict, computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' +import type { z } from 'zod' import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' import { ensureStructuredHostInstalled as ensureHostInstalled, @@ -18,6 +19,7 @@ import { structuredCallerFor as callerFor, supportsStructuredSessions } from './structured-agent-session-gate' +import type { AgentSessionAttachParams } from '../../../native-chat/agent-session-wire/structured-agent-session-attach' import { STRUCTURED_AGENT_SESSION_HOLD_METHODS } from './structured-agent-session-hold' import { AttachParams, @@ -25,6 +27,7 @@ import { CreateParams, CreateSupportParams, HistoryParams, + HandoffParams, HandoffStatusParams, OptionsParams, RespondParams, @@ -43,6 +46,29 @@ function subscriptionIdFor(ctx: RpcContext, sessionId: string): string { return ctx.requestId ? `${base}:${ctx.requestId}` : base } +/** + * The attach-shaped entries take the location from the client instead of resolving it from a + * worktree, so they never reach the worktree-resolving create-support check. Ask the executing + * host the same question directly: the answer includes host-measured facts the client cannot see + * or forge, such as whether this machine can read a provider child's process start time. + */ +async function attachClientSuppliedLocation( + params: z.infer, + ctx: RpcContext +): Promise { + await ensureHostInstalled(ctx) + const host = requireHost(ctx) + if (!host.supportsCreate(params.location, params.agent)) { + throw new Error('structured_agent_session_unsupported') + } + const { agent: _attachAgent, provider: _attachProvider, ...attachWithoutAgent } = params + return host.attach(callerFor(ctx), { + ...attachWithoutAgent, + provider: params.provider as 'claude' | 'codex', + agent: params.agent as 'claude' | 'codex' + } as AgentSessionAttachParams) +} + export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'agentSession.createSupport', @@ -86,16 +112,20 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ } }) await ensureHostInstalled(ctx) - const result = await requireHost(ctx).attach(callerFor(ctx), { - ...resolved, + const { agent: _resolvedAgent, provider: _resolvedProvider, ...resolvedAttach } = resolved + const attachParams: AgentSessionAttachParams = { + ...resolvedAttach, + provider: resolved.provider as 'claude' | 'codex', + agent: resolved.agent as 'claude' | 'codex', envelope: { ...params.envelope, payloadFingerprint: hostFingerprint } - }) - if (result.ok && resolved.agent === 'codex') { + } + const result = await requireHost(ctx).attach(callerFor(ctx), attachParams) + if (result.ok) { try { await ctx.runtime.publishStructuredAgentSessionTab({ workspaceId: resolved.location.workspaceId, sessionId: result.value.sessionId, - agent: 'codex', + agent: resolved.agent as 'claude' | 'codex', activate: true }) } catch (error) { @@ -104,24 +134,20 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ ok: false, refusal: { code: 'agent_session_operation_unknown', - message: 'The Codex chat may have been created, but its tab could not be confirmed.' + message: 'The chat may have been created, but its tab could not be confirmed.' } } } } return result } - await ensureHostInstalled(ctx) - return requireHost(ctx).attach(callerFor(ctx), params) + return attachClientSuppliedLocation(params, ctx) } }), defineMethod({ name: 'agentSession.ensure', params: AttachParams, - handler: async (params, ctx) => { - await ensureHostInstalled(ctx) - return requireHost(ctx).attach(callerFor(ctx), params) - } + handler: async (params, ctx) => attachClientSuppliedLocation(params, ctx) }), defineMethod({ name: 'agentSession.send', @@ -165,6 +191,11 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ params: SetOptionParams, handler: async (params, ctx) => requireHost(ctx).setOption(callerFor(ctx), params) }), + defineMethod({ + name: 'agentSession.requestHandoff', + params: HandoffParams, + handler: async (params, ctx) => requireHost(ctx).requestHandoff(callerFor(ctx), params) + }), defineMethod({ name: 'agentSession.handoffStatus', params: HandoffStatusParams, diff --git a/src/main/runtime/rpc/mobile-clipboard-image-provenance.test.ts b/src/main/runtime/rpc/mobile-clipboard-image-provenance.test.ts new file mode 100644 index 00000000000..aa1709fcecb --- /dev/null +++ b/src/main/runtime/rpc/mobile-clipboard-image-provenance.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + hasMobileClipboardImagePath, + MOBILE_CLIPBOARD_IMAGE_PROVENANCE_MAX_ENTRIES, + MOBILE_CLIPBOARD_IMAGE_PROVENANCE_TTL_MS, + mobileClipboardImageProvenanceSizeForTest, + recordMobileClipboardImagePath, + resetMobileClipboardImageProvenanceForTest +} from './mobile-clipboard-image-provenance' + +describe('mobile clipboard image provenance', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + resetMobileClipboardImageProvenanceForTest() + }) + + afterEach(() => { + resetMobileClipboardImageProvenanceForTest() + vi.useRealTimers() + }) + + it('expires records without consuming them on repeated checks', () => { + recordMobileClipboardImagePath('device-a', '/tmp/image.png') + + expect(hasMobileClipboardImagePath('device-a', '/tmp/image.png')).toBe(true) + expect(hasMobileClipboardImagePath('device-a', '/tmp/image.png')).toBe(true) + vi.advanceTimersByTime(MOBILE_CLIPBOARD_IMAGE_PROVENANCE_TTL_MS + 1) + expect(hasMobileClipboardImagePath('device-a', '/tmp/image.png')).toBe(false) + expect(mobileClipboardImageProvenanceSizeForTest()).toBe(0) + }) + + it('evicts the oldest record at the global bound and supports test cleanup', () => { + for (let index = 0; index <= MOBILE_CLIPBOARD_IMAGE_PROVENANCE_MAX_ENTRIES; index++) { + recordMobileClipboardImagePath(`device-${index}`, `/tmp/image-${index}.png`) + vi.advanceTimersByTime(1) + } + + expect(mobileClipboardImageProvenanceSizeForTest()).toBe( + MOBILE_CLIPBOARD_IMAGE_PROVENANCE_MAX_ENTRIES + ) + expect(hasMobileClipboardImagePath('device-0', '/tmp/image-0.png')).toBe(false) + expect( + hasMobileClipboardImagePath( + `device-${MOBILE_CLIPBOARD_IMAGE_PROVENANCE_MAX_ENTRIES}`, + `/tmp/image-${MOBILE_CLIPBOARD_IMAGE_PROVENANCE_MAX_ENTRIES}.png` + ) + ).toBe(true) + + resetMobileClipboardImageProvenanceForTest() + expect(mobileClipboardImageProvenanceSizeForTest()).toBe(0) + }) +}) diff --git a/src/main/runtime/rpc/mobile-clipboard-image-provenance.ts b/src/main/runtime/rpc/mobile-clipboard-image-provenance.ts new file mode 100644 index 00000000000..117455593c9 --- /dev/null +++ b/src/main/runtime/rpc/mobile-clipboard-image-provenance.ts @@ -0,0 +1,90 @@ +export const MOBILE_CLIPBOARD_IMAGE_PROVENANCE_TTL_MS = 60 * 60 * 1000 +export const MOBILE_CLIPBOARD_IMAGE_PROVENANCE_MAX_ENTRIES = 256 +const MOBILE_CLIPBOARD_IMAGE_PROVENANCE_MAX_PER_CLIENT = 64 + +const pathsByClient = new Map>() +let entryCount = 0 + +function deletePath(clientId: string, path: string): void { + const paths = pathsByClient.get(clientId) + if (!paths?.delete(path)) { + return + } + entryCount-- + if (paths.size === 0) { + pathsByClient.delete(clientId) + } +} + +function pruneExpired(now: number): void { + for (const [clientId, paths] of pathsByClient) { + for (const [path, expiresAt] of paths) { + if (expiresAt <= now) { + deletePath(clientId, path) + } + } + } +} + +function deleteOldestEntry(): void { + let oldest: { clientId: string; path: string; expiresAt: number } | null = null + for (const [clientId, paths] of pathsByClient) { + for (const [path, expiresAt] of paths) { + if (!oldest || expiresAt < oldest.expiresAt) { + oldest = { clientId, path, expiresAt } + } + } + } + if (oldest) { + deletePath(oldest.clientId, oldest.path) + } +} + +export function recordMobileClipboardImagePath(clientId: string | undefined, path: string): void { + const owner = clientId?.trim() + if (!owner) { + return + } + const now = Date.now() + pruneExpired(now) + let paths = pathsByClient.get(owner) + if (!paths) { + paths = new Map() + pathsByClient.set(owner, paths) + } + if (paths.delete(path)) { + entryCount-- + } + while (paths.size >= MOBILE_CLIPBOARD_IMAGE_PROVENANCE_MAX_PER_CLIENT) { + const oldestPath = paths.keys().next().value + if (typeof oldestPath !== 'string') { + break + } + deletePath(owner, oldestPath) + } + while (entryCount >= MOBILE_CLIPBOARD_IMAGE_PROVENANCE_MAX_ENTRIES) { + deleteOldestEntry() + } + paths = pathsByClient.get(owner) ?? new Map() + pathsByClient.set(owner, paths) + paths.set(path, now + MOBILE_CLIPBOARD_IMAGE_PROVENANCE_TTL_MS) + entryCount++ +} + +export function hasMobileClipboardImagePath(clientId: string | undefined, path: string): boolean { + const owner = clientId?.trim() + if (!owner) { + return false + } + pruneExpired(Date.now()) + return pathsByClient.get(owner)?.has(path) ?? false +} + +export function resetMobileClipboardImageProvenanceForTest(): void { + pathsByClient.clear() + entryCount = 0 +} + +export function mobileClipboardImageProvenanceSizeForTest(): number { + return entryCount +} diff --git a/src/main/runtime/rpc/mobile-e2ee-v2-client-capabilities.ts b/src/main/runtime/rpc/mobile-e2ee-v2-client-capabilities.ts new file mode 100644 index 00000000000..3834a417ddb --- /dev/null +++ b/src/main/runtime/rpc/mobile-e2ee-v2-client-capabilities.ts @@ -0,0 +1,21 @@ +import type { RuntimeCapability } from '../../../shared/protocol-version' +import { parseRemoteRuntimeJsonText } from '../../../shared/remote-runtime-request-frames' +import { parseRuntimeClientCapabilities } from './runtime-client-capabilities' + +export function parseMobileE2EEV2ClientCapabilities( + plaintext: string +): readonly RuntimeCapability[] | null { + try { + const message = parseRemoteRuntimeJsonText(plaintext) as Record + if ( + Object.keys(message).sort().join(',') !== 'clientCapabilities,type,v' || + message.type !== 'e2ee_client_capabilities' || + message.v !== 1 + ) { + return null + } + return parseRuntimeClientCapabilities(message.clientCapabilities) + } catch { + return null + } +} diff --git a/src/main/runtime/rpc/mobile-socket-wiring.test.ts b/src/main/runtime/rpc/mobile-socket-wiring.test.ts index 505cf63feb6..14d5beb8cd0 100644 --- a/src/main/runtime/rpc/mobile-socket-wiring.test.ts +++ b/src/main/runtime/rpc/mobile-socket-wiring.test.ts @@ -348,4 +348,86 @@ describe('MobileSocketWiring', () => { expect(transport.setClientId).not.toHaveBeenCalled() expect(ws.close).toHaveBeenCalledWith(4001, 'Unauthorized') }) + + it('keeps post-auth v2 capability-shaped frames on the RPC path', () => { + const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1)) + const phone = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2)) + const ws = new FakeSocket() + const transport = new FakeTransport() + const onText = vi.fn() + const metadata: MobileSocketTransportMetadata = { + transport: 'relay', + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId: 'device-1', + basisConnId: 'connection-1', + credentialKind: 'resume' + } + const wiring = new MobileSocketWiring({ + deviceRegistry: registryFor('device-1', 'valid-token'), + e2eeKeypair: { + publicKey: desktop.publicKey, + secretKey: desktop.secretKey, + publicKeyB64: Buffer.from(desktop.publicKey).toString('base64') + }, + onText, + onBinary: vi.fn(), + onClose: vi.fn() + }) + wiring.attachTransport(transport, () => metadata) + const hello: MobileE2EEV2Hello = { + type: 'e2ee_hello', + v: 2, + clientPublicKeyB64: Buffer.from(phone.publicKey).toString('base64'), + clientNonceB64: Buffer.from(new Uint8Array(32).fill(3)).toString('base64'), + capabilities: { framing: [2], payloadKinds: ['text', 'binary'] }, + context: { + protocol: 'orca-mobile-e2ee', + initiator: 'mobile', + responder: 'desktop', + transport: 'relay', + relayHostId: metadata.relayHostId + } + } + transport.receive(ws, JSON.stringify(hello)) + const ready = JSON.parse(ws.sent[0]!.toString()) as MobileE2EEV2Ready + const handshake = validateMobileE2EEV2Handshake(hello, ready)! + const schedule = deriveMobileE2EEV2KeySchedule({ + sharedSecret: deriveSharedKey(phone.secretKey, desktop.publicKey), + transcript: encodeMobileE2EEV2Transcript(handshake), + clientNonce: handshake.clientNonce, + desktopNonce: handshake.desktopNonce + }) + const send = (value: unknown, counter: bigint): void => { + const frame = sealMobileE2EEV2Frame({ + payload: new TextEncoder().encode(JSON.stringify(value)), + key: schedule.mobileToDesktopKey, + sessionId: schedule.sessionId, + direction: 'mobile-to-desktop', + payloadKind: 'text', + counter + }) + transport.receive(ws, Buffer.from(frame).toString('base64')) + } + send( + { + type: 'e2ee_auth', + v: 2, + transcriptHashB64: Buffer.from(schedule.transcriptHash).toString('base64'), + deviceToken: 'valid-token' + }, + 0n + ) + const capabilityFrame = { + type: 'e2ee_client_capabilities', + v: 1, + clientCapabilities: ['agent-session.structured.v1'] + } + send(capabilityFrame, 1n) + send({ id: 'rpc-1', method: 'agentSession.history', params: {} }, 2n) + + expect(onText).toHaveBeenCalledTimes(2) + expect(onText.mock.calls[0]?.[0].clientCapabilities).toEqual([]) + expect(JSON.parse(onText.mock.calls[0]?.[1] ?? '')).toEqual(capabilityFrame) + expect(onText.mock.calls[1]?.[0].clientCapabilities).toEqual([]) + }) }) diff --git a/src/main/runtime/rpc/mobile-socket-wiring.ts b/src/main/runtime/rpc/mobile-socket-wiring.ts index 43004be4582..384f403e9de 100644 --- a/src/main/runtime/rpc/mobile-socket-wiring.ts +++ b/src/main/runtime/rpc/mobile-socket-wiring.ts @@ -165,7 +165,9 @@ export class MobileSocketWiring { ws, connectionId, device, - clientCapabilities: channel.clientCapabilities, + get clientCapabilities() { + return channel.clientCapabilities + }, transport: metadata } this.authenticatedSockets.set(ws, socket) diff --git a/src/main/runtime/structured-agent-session-integration-replay.test.ts b/src/main/runtime/structured-agent-session-integration-replay.test.ts index 4edec30499b..e5baa032341 100644 --- a/src/main/runtime/structured-agent-session-integration-replay.test.ts +++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts @@ -259,6 +259,7 @@ beforeEach(async () => { claimKeyId: 'key-1', resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, resolveCodexCommand: () => '/usr/local/bin/codex', + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveEnvironment: async () => { bootEnvironmentReads += 1 return { @@ -320,6 +321,7 @@ describe('a structured codex session over agentSession.*', () => { claimKeyId: 'key-1', resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, resolveCodexCommand: () => '/usr/local/bin/codex', + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), openCodexConnection: codex.openConnection, readProcessStartTime: async () => 1_700_000_000_000 }) diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index aa5f819e1fb..2982a6530b2 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -307,6 +307,7 @@ beforeEach(async () => { claimKeyId: 'key-1', resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, resolveCodexCommand: () => '/usr/local/bin/codex', + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveEnvironment: async () => { bootEnvironmentReads += 1 return { diff --git a/src/main/runtime/structured-agent-session-owner-probe.ts b/src/main/runtime/structured-agent-session-owner-probe.ts new file mode 100644 index 00000000000..47f92a08ca6 --- /dev/null +++ b/src/main/runtime/structured-agent-session-owner-probe.ts @@ -0,0 +1,108 @@ +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { + probeAgentSessionProcessIdentities, + probeAgentSessionProcessIdentity, + probeAgentSessionReservation +} from './agent-session-process-identity-probe' +import { findAgentSessionSpawnTokenProcesses } from './agent-session-spawn-token-process-scan' +import { readEchoedAgentSessionSpawnToken } from './agent-session-spawn-token-readback' + +/** + * The lease's only source of truth about a previous owner. Everything it cannot + * answer PID-reuse-safely reports `indeterminate`. An exact owner stays fenced in `recovering`; + * an ownerless, unattributable reservation enters `manual-recovery`. + */ +export function createStructuredAgentSessionOwnerProbe( + hostId: string, + probe = probeAgentSessionProcessIdentity, + findSpawnTokenProcesses = findAgentSessionSpawnTokenProcesses +): (record: AgentSessionRecord) => Promise { + return async (record) => { + const owner = record.lease.ownerProcess + if (!owner) { + if (record.lease.processlessAt !== undefined && record.lease.processlessAt !== null) { + return { outcome: 'reservation-unused' } + } + const spawnToken = record.lease.reservedSpawnToken + if (spawnToken === null) { + if (record.lease.claimStatus === 'reserved') { + return { + outcome: 'indeterminate', + reason: 'reservation recorded no spawn token to scan for' + } + } + // The token is minted before the child and is the only thing a child could be carrying. + // No owner and no token means nothing on any host can be holding this lease — answering + // `indeterminate` here is what latches an already-free record into recovery forever. + return { outcome: 'reservation-unused' } + } + // Freeing a reservation needs positive proof that nothing spawned under its token. The scan + // answers null where the platform cannot read another process's environment. + return probeAgentSessionReservation({ + spawnToken, + findProcessesWithSpawnToken: (token) => findSpawnTokenProcesses(token), + hasProviderActivitySinceReservation: async () => + agentSessionReservationTouchedProvider(record) + }) + } + if (owner.hostId !== hostId) { + // Checking a remote host's pid against this machine's process table is + // exactly how a live owner gets declared dead. + return { + outcome: 'indeterminate', + reason: `owner runs on ${owner.hostId}, which this host cannot probe` + } + } + // The env read-back answers on hosts that expose it and null elsewhere, giving the + // probe a PID-reuse-safe element even when no start time was recorded. + return probe({ + identity: owner, + deps: { readEchoedSpawnToken: readEchoedAgentSessionSpawnToken } + }) + } +} + +export function createStructuredAgentSessionOwnerProbes( + hostId: string, + probeMany: typeof probeAgentSessionProcessIdentities = probeAgentSessionProcessIdentities, + probeOne = createStructuredAgentSessionOwnerProbe(hostId) +): (records: readonly AgentSessionRecord[]) => Promise> { + return async (records) => { + const results = new Map() + const localOwners: { + record: AgentSessionRecord + owner: NonNullable + }[] = [] + for (const record of records) { + const owner = record.lease.ownerProcess + if (owner?.hostId === hostId) { + localOwners.push({ record, owner }) + } else { + results.set(record.sessionId, await probeOne(record)) + } + } + const probes = await probeMany({ + identities: localOwners.map(({ owner }) => owner), + deps: { readEchoedSpawnToken: readEchoedAgentSessionSpawnToken } + }) + for (const [index, { record }] of localOwners.entries()) { + results.set( + record.sessionId, + probes[index] ?? { outcome: 'indeterminate', reason: 'owner probe returned no result' } + ) + } + return results + } +} + +/** + * The only provider-side trace a reservation can leave in its own record: a handle link minted at + * this fence. `proveAgentSessionOwner` refuses to append one before an identity is committed, so a + * link at the reservation's fence means a child got far enough to resume the provider thread. It + * cannot see activity the child produced without proving a handle, which is why it is paired with + * the token scan rather than trusted alone. + */ +function agentSessionReservationTouchedProvider(record: AgentSessionRecord): boolean { + return record.providerHandleChain.at(-1)?.mintedAtFence === record.lease.runtimeFence +} diff --git a/src/main/runtime/structured-agent-session-runtime-exit.test.ts b/src/main/runtime/structured-agent-session-runtime-exit.test.ts index a8419176357..5c6e43c2bc0 100644 --- a/src/main/runtime/structured-agent-session-runtime-exit.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-exit.test.ts @@ -84,6 +84,7 @@ describe('structured session runtime provider-exit wiring', () => { hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root!, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, @@ -180,6 +181,7 @@ describe('structured session runtime provider-exit wiring', () => { hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root!, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, @@ -260,6 +262,7 @@ describe('structured session runtime provider-exit wiring', () => { hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root!, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index b03d17cdb3f..3b69a0a4be3 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -13,7 +13,9 @@ import type { } from '../../shared/agent-session-record' import { createStructuredAgentSessionOwnerProbe, - createStructuredAgentSessionOwnerProbes, + createStructuredAgentSessionOwnerProbes +} from './structured-agent-session-owner-probe' +import { ensureStructuredAgentSessionHost, hasPersistedStructuredAgentSessionStore, stopStructuredAgentSessionRuntime @@ -226,6 +228,7 @@ describe('structured agent-session runtime install', () => { hostId: HOST_ID, claimKeyId: 'key-1', resolveWorkspacePath: async () => stateDirectory!, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveEnvironment: async () => ({}), reapOrphanChildren, onError @@ -252,6 +255,7 @@ describe('structured agent-session runtime install', () => { hostId: HOST_ID, claimKeyId: 'key-1', resolveWorkspacePath: async () => stateDirectory!, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveEnvironment: async () => ({}), reapOrphanChildren: async () => { throw failure @@ -301,7 +305,8 @@ describe('a teardown that fails is retried by the next stop', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => directory!, resolveEnvironment: async () => ({}), - reapOrphanChildren: async () => [] + reapOrphanChildren: async () => [], + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) }) const journalDir = join(directory, 'stubborn-journal') diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 52a3bd81818..923d5627f72 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -9,29 +9,33 @@ import { existsSync } from 'node:fs' import { join } from 'node:path' -import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { createCodexStructuredLaunchResolver } from '../codex/codex-structured-launch-resolution' import { CodexStructuredSessionAdapter, type CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter' +import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter' import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { + readClaudeManagedAccountGateSettings, + type ClaudeManagedAccountGateSettings +} from '../native-chat/claude-structured-managed-account-support' import { AgentSessionRecordStore } from './agent-session-record-store' import { agentSessionStorePath } from './agent-session-record-store-file' import { stopOrphanAgentSessionChildren } from './agent-session-orphan-child-reaper' import { - probeAgentSessionProcessIdentities, - probeAgentSessionProcessIdentity, - probeAgentSessionReservation -} from './agent-session-process-identity-probe' -import { findAgentSessionSpawnTokenProcesses } from './agent-session-spawn-token-process-scan' -import { readEchoedAgentSessionSpawnToken } from './agent-session-spawn-token-readback' + createStructuredAgentSessionOwnerProbe, + createStructuredAgentSessionOwnerProbes +} from './structured-agent-session-owner-probe' import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' import { recordAgentSessionProviderHandle } from './agent-session-provider-handle-transition' +import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' +import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtime-adapter' /** Sibling of the journal tree rather than inside it: one file adjudicates every * session's lease, while a journal is per session. */ @@ -55,13 +59,20 @@ export type StructuredAgentSessionRuntimeDeps = { claimKeyId: string resolveWorkspacePath: (workspaceId: string) => Promise resolveCodexCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string + resolveClaudeCommand?: () => string /** Provider transports are overridden only to drive the runtime against scripted children. */ openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] + openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] /** Scripted app-servers carry fake pids the real start-time read cannot answer for. */ readProcessStartTime?: CodexStructuredSessionAdapterDeps['readProcessStartTime'] resolveLaunchArgs?: (provider: AgentSessionRecord['provider']) => Promise | string[] resolveLaunchEnv?: () => Promise resolveLaunchEnvOverlay?: () => Promise> | Record + resolveClaudeLaunchEnv?: () => Promise> | Record + /** Required, and asserted at install time — an absent policy must not degrade to a guess. */ + resolveClaudeAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy + /** Raw settings getter; the reader that fails closed around it is built here, in checked code. */ + getClaudeManagedAccountGateSettings?: () => ClaudeManagedAccountGateSettings resolveEnvironment?: () => Promise resolveCodexOverrides?: () => NodeJS.ProcessEnv onError?: (input: { scope: string; error: unknown }) => void @@ -71,13 +82,17 @@ export type StructuredAgentSessionRuntimeDeps = { type InstalledRuntime = { host: StructuredAgentSessionHost - adapter: CodexStructuredSessionAdapter + adapter: { closeAll(): Promise } /** Resolves after every adapter-exit recovery callback has settled. */ waitForRecovery: () => Promise } let installing: Promise | null = null +/** Thrown when the host is installed without a Claude auth policy resolver. */ +export const CLAUDE_STRUCTURED_AUTH_POLICY_REQUIRED = + 'structured agent-session host requires a Claude auth policy resolver' + /** * Runtimes whose teardown did not finish. `installing` is cleared regardless so * nothing new attaches, but dropping the runtime as well would strand every @@ -147,8 +162,14 @@ async function tearDownRuntime(installed: InstalledRuntime): Promise { } async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { + // Why thrown rather than defaulted: the caller is `@ts-nocheck`, so a dropped + // field arrives here as `undefined`. Refusing to install is loud; guessing a + // policy is the silent under-strip this assertion exists to prevent. + if (typeof deps.resolveClaudeAuthPolicy !== 'function') { + throw new Error(CLAUDE_STRUCTURED_AUTH_POLICY_REQUIRED) + } const bootEnvironment = (deps.resolveEnvironment ?? resolveLoginShellEnvironment)() - const resolveEnvironment = async (): Promise => ({ + const resolveCodexEnvironment = async (): Promise => ({ ...(await bootEnvironment), ...(await deps.resolveLaunchEnv?.()), ...(await deps.resolveLaunchEnvOverlay?.()), @@ -181,7 +202,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise + readClaudeManagedAccountGateSettings(deps.getClaudeManagedAccountGateSettings!) + } + : {}), + onUnexpectedExit: (event) => { + recoveryChain = recoveryChain.then(async () => { + try { + await host?.handleAdapterEvent(event) + } catch (error) { + deps.onError?.({ scope: `structured-agent-session-exit:${event.sessionId}`, error }) + } + }) + }, + ...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}), + ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}) + }) + const adapter = new StructuredAgentSessionAdapterRouter({ codex, claude }, async () => { + await Promise.all([codex.closeAll(), claude.closeAll()]) + }) host = new StructuredAgentSessionHost({ store, adapter, @@ -246,102 +295,3 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise Promise { - return async (record) => { - const owner = record.lease.ownerProcess - if (!owner) { - if (record.lease.processlessAt !== undefined && record.lease.processlessAt !== null) { - return { outcome: 'reservation-unused' } - } - const spawnToken = record.lease.reservedSpawnToken - if (spawnToken === null) { - if (record.lease.claimStatus === 'reserved') { - return { - outcome: 'indeterminate', - reason: 'reservation recorded no spawn token to scan for' - } - } - // The token is minted before the child and is the only thing a child could be carrying. - // No owner and no token means nothing on any host can be holding this lease — answering - // `indeterminate` here is what latches an already-free record into recovery forever. - return { outcome: 'reservation-unused' } - } - // Freeing a reservation needs positive proof that nothing spawned under its token. The scan - // answers null where the platform cannot read another process's environment. - return probeAgentSessionReservation({ - spawnToken, - findProcessesWithSpawnToken: (token) => findSpawnTokenProcesses(token), - hasProviderActivitySinceReservation: async () => - agentSessionReservationTouchedProvider(record) - }) - } - if (owner.hostId !== hostId) { - // Checking a remote host's pid against this machine's process table is - // exactly how a live owner gets declared dead. - return { - outcome: 'indeterminate', - reason: `owner runs on ${owner.hostId}, which this host cannot probe` - } - } - // The env read-back answers on hosts that expose it and null elsewhere, giving the - // probe a PID-reuse-safe element even when no start time was recorded. - return probe({ - identity: owner, - deps: { readEchoedSpawnToken: readEchoedAgentSessionSpawnToken } - }) - } -} - -export function createStructuredAgentSessionOwnerProbes( - hostId: string, - probeMany: typeof probeAgentSessionProcessIdentities = probeAgentSessionProcessIdentities, - probeOne = createStructuredAgentSessionOwnerProbe(hostId) -): (records: readonly AgentSessionRecord[]) => Promise> { - return async (records) => { - const results = new Map() - const localOwners: { - record: AgentSessionRecord - owner: NonNullable - }[] = [] - for (const record of records) { - const owner = record.lease.ownerProcess - if (owner?.hostId === hostId) { - localOwners.push({ record, owner }) - } else { - results.set(record.sessionId, await probeOne(record)) - } - } - const probes = await probeMany({ - identities: localOwners.map(({ owner }) => owner), - deps: { readEchoedSpawnToken: readEchoedAgentSessionSpawnToken } - }) - for (const [index, { record }] of localOwners.entries()) { - results.set( - record.sessionId, - probes[index] ?? { outcome: 'indeterminate', reason: 'owner probe returned no result' } - ) - } - return results - } -} - -/** - * The only provider-side trace a reservation can leave in its own record: a handle link minted at - * this fence. `proveAgentSessionOwner` refuses to append one before an identity is committed, so a - * link at the reservation's fence means a child got far enough to resume the provider thread. It - * cannot see activity the child produced without proving a handle, which is why it is paired with - * the token scan rather than trusted alone. - */ -function agentSessionReservationTouchedProvider(record: AgentSessionRecord): boolean { - return record.providerHandleChain.at(-1)?.mintedAtFence === record.lease.runtimeFence -} diff --git a/src/main/runtime/structured-claude-auth-policy-wiring.test.ts b/src/main/runtime/structured-claude-auth-policy-wiring.test.ts new file mode 100644 index 00000000000..f018dfd30da --- /dev/null +++ b/src/main/runtime/structured-claude-auth-policy-wiring.test.ts @@ -0,0 +1,59 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it } from 'vitest' +import { + CLAUDE_STRUCTURED_AUTH_POLICY_REQUIRED, + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +/** + * The structured host's Claude auth policy has exactly one production wiring, and it + * lives in `orca-runtime-get-worktree-ps.ts` — a `@ts-nocheck` file, so neither the + * compiler nor a type test can see the field disappear. Deleting that wiring used to + * leave ~1000 tests green while every `ANTHROPIC_*` variable in the shell reached the + * child, because `stripAuthEnv` silently fell back to `false`. + * + * Two independent guards replace that silence, and this file pins both. + */ +describe('structured Claude auth policy wiring', () => { + // The behavioural version of this assertion — importing the runtime class and + // capturing the installed deps — costs 35s of module transform for the whole + // OrcaRuntime chain (measured), so the wiring itself is pinned by source and the + // policy's meaning by claude-structured-auth-policy.test.ts. + it('passes a settings-derived Claude auth policy to the host installer', () => { + const source = readFileSync(join(__dirname, 'orca-runtime-get-worktree-ps.ts'), 'utf8') + + expect(source).toContain('claudeStructuredAuthPolicyForSettings') + expect(source).toMatch( + /resolveClaudeAuthPolicy:\s*\(\)\s*=>\s*\n?\s*claudeStructuredAuthPolicyForSettings\(/ + ) + }) + + describe('installing without one', () => { + let stateDirectory: string | null = null + + afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (stateDirectory) { + await rm(stateDirectory, { recursive: true, force: true }) + stateDirectory = null + } + }) + + it('refuses loudly rather than defaulting to a guess', async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-auth-policy-wiring-')) + + await expect( + ensureStructuredAgentSessionHost({ + stateDirectory, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => stateDirectory as string + } as unknown as Parameters[0]) + ).rejects.toThrow(CLAUDE_STRUCTURED_AUTH_POLICY_REQUIRED) + }) + }) +}) diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts new file mode 100644 index 00000000000..398288562b9 --- /dev/null +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -0,0 +1,98 @@ +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { join } from 'node:path' +import { resolveClaudeCommand } from '../codex-cli/command' +import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' +import { createClaudeStructuredLaunchResolver } from '../claude/claude-structured-launch-resolution' +import { + ClaudeStructuredSessionAdapter, + type ClaudeStructuredSessionAdapterDeps +} from '../claude/claude-structured-session-adapter' +import { claudeProviderHandleLink } from '../claude/claude-structured-owner-identity' +import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + readClaudeTranscriptLeafUuid, + resolveSessionFilePath +} from '../native-chat/session-file-resolver' +import { recordAgentSessionProviderHandle } from './agent-session-provider-handle-transition' +import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' +import type { AgentSessionRecordStore } from './agent-session-record-store' + +export type StructuredClaudeRuntimeAdapterDeps = { + store: AgentSessionRecordStore + resolveWorkspacePath: (workspaceId: string) => Promise + resolveClaudeCommand?: () => string + resolveClaudeLaunchEnv?: () => Promise> | Record + /** Managed-account auth state for a Claude launch, mirroring the terminal preflight. + * Required: an absent policy is what silently under-strips. */ + resolveClaudeAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy + readClaudeManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null + openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] + readProcessStartTime?: ClaudeStructuredSessionAdapterDeps['readProcessStartTime'] + onUnexpectedExit: (event: StructuredAgentSessionLifecycleEvent) => void +} + +export function createStructuredClaudeRuntimeAdapter( + deps: StructuredClaudeRuntimeAdapterDeps +): ClaudeStructuredSessionAdapter { + const { store } = deps + return new ClaudeStructuredSessionAdapter({ + resolveLaunch: createClaudeStructuredLaunchResolver({ + store, + resolveWorkspacePath: deps.resolveWorkspacePath, + resolveCommand: deps.resolveClaudeCommand ?? resolveClaudeCommand, + ...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {}), + resolveAuthPolicy: deps.resolveClaudeAuthPolicy, + ...(deps.readClaudeManagedAccountGate + ? { readManagedAccountGate: deps.readClaudeManagedAccountGate } + : {}) + }), + persistHandle: async ({ sessionId, providerSessionId, leafUuid, fence }) => { + const currentFence = store.getRecord(sessionId)?.lease.runtimeFence ?? fence + const observedAt = Date.now() + await store.transitionHandoff(sessionId, (record: AgentSessionRecord) => + recordAgentSessionProviderHandle({ + record, + fence: currentFence, + link: claudeProviderHandleLink({ + sessionId: providerSessionId, + leafUuid, + resumed: true, + fence: currentFence, + observedAt + }), + now: observedAt + }) + ) + }, + readTranscriptLeaf: async ({ providerSessionId, previousLeafUuid, claudeConfigDir }) => { + const transcriptPath = await resolveSessionFilePath('claude', providerSessionId, { + claudeProjectsDir: join(claudeConfigDir, 'projects') + }) + return transcriptPath + ? await readClaudeTranscriptLeafUuid(transcriptPath, providerSessionId, previousLeafUuid) + : null + }, + onEvent: (event) => { + if ( + event.type === 'ended' && + event.cause === 'unexpected-exit' && + event.fence !== undefined && + event.acquisitionGeneration + ) { + deps.onUnexpectedExit({ + type: 'ended', + sessionId: event.sessionId, + reason: event.reason, + cause: event.cause, + fence: event.fence, + acquisitionGeneration: event.acquisitionGeneration, + ...(event.settlementRetryRequired + ? { settlementRetryRequired: event.settlementRetryRequired } + : {}) + }) + } + }, + ...(deps.openClaudeConnection ? { openConnection: deps.openClaudeConnection } : {}), + ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}) + }) +} diff --git a/src/main/runtime/structured-tui-process-identity.test.ts b/src/main/runtime/structured-tui-process-identity.test.ts index 4324f8c5a26..fb5919824e5 100644 --- a/src/main/runtime/structured-tui-process-identity.test.ts +++ b/src/main/runtime/structured-tui-process-identity.test.ts @@ -238,6 +238,41 @@ describe('structured TUI process identity', () => { } }) + it('does not call a child absent after a single look that outlasted the budget', async () => { + // Measured on a 2,085-process host under load: one whole-machine `ps` took 6.2s while the + // shell-delivered child landed at ~3.5s. `ps` reads the table when it STARTS, so that one + // capture reported a t=0 machine and returned with the 5s budget already spent -- the loop + // answered "no exact child" without ever looking again. + let clockMs = 0 + let captures = 0 + await expect( + readStructuredTuiProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-slow-ps', + agent: 'claude', + platform: 'darwin', + readPosixRows: async () => { + captures += 1 + const observedAtMs = clockMs + clockMs += 6_200 + return [ + { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, + ...(observedAtMs >= 3_500 + ? [{ pid: 101, ppid: 100, stat: 'S+', command: 'claude --resume session-1' }] + : []) + ] + }, + readStartTime: async () => 1_700_000_000_000, + now: () => clockMs, + sleep: async (delayMs) => { + clockMs += delayMs + } + }) + ).resolves.toMatchObject({ pid: 101, spawnToken: 'spawn-slow-ps' }) + expect(captures).toBe(2) + }) + it('fails closed when the process snapshot omitted the PTY root', async () => { await expect( readStructuredTuiProcessIdentity({ diff --git a/src/main/runtime/structured-tui-process-identity.ts b/src/main/runtime/structured-tui-process-identity.ts index 0014351d781..f5ee019882b 100644 --- a/src/main/runtime/structured-tui-process-identity.ts +++ b/src/main/runtime/structured-tui-process-identity.ts @@ -20,6 +20,12 @@ const STRUCTURED_TUI_PROCESS_POLL_MS = 50 // window the added latency is bounded by one interval. const STRUCTURED_TUI_PROCESS_FAST_POLL_WINDOW_MS = 1_000 const STRUCTURED_TUI_PROCESS_MAX_POLL_MS = 500 +// Why a floor and not just the deadline: the first capture races the spawn it is looking for, +// so a null from it is absence of the child's arrival, not evidence the child is missing. The +// budget above assumes a look is nearly free, but one whole-machine `ps` measured 6.2s on a +// 2,085-process host under load -- long enough to spend the entire budget before the child +// (observed landing at ~3.5s) could exist, and answer "no exact child" after a single look. +const STRUCTURED_TUI_PROCESS_MIN_CAPTURES = 2 function descendants(rows: ProcessRow[], rootPid: number): (ProcessRow & { depth: number })[] { const children = new Map() @@ -175,6 +181,7 @@ export async function readStructuredTuiProcessIdentity(input: { const startedAtMs = now() const deadline = startedAtMs + (input.timeoutMs ?? STRUCTURED_TUI_PROCESS_WAIT_MS) let pollDelayMs = input.pollIntervalMs ?? STRUCTURED_TUI_PROCESS_POLL_MS + let captures = 0 while (true) { const rows: ProcessRow[] = @@ -186,6 +193,7 @@ export async function readStructuredTuiProcessIdentity(input: { foreground: false })) : posixRows(await (input.readPosixRows ?? getFreshProcessTableSnapshot)()) + captures += 1 let rootPresent = false for (const row of rows) { if (row.pid === input.rootPid) { @@ -218,11 +226,11 @@ export async function readStructuredTuiProcessIdentity(input: { } } const remainingMs = deadline - now() - if (remainingMs <= 0) { + if (remainingMs <= 0 && captures >= STRUCTURED_TUI_PROCESS_MIN_CAPTURES) { const label = input.agent === 'codex' ? 'Codex' : 'Claude' throw new Error(`The resumed terminal did not expose one exact ${label} child process.`) } - await sleep(Math.min(pollDelayMs, remainingMs)) + await sleep(Math.max(0, Math.min(pollDelayMs, remainingMs))) if (now() - startedAtMs >= STRUCTURED_TUI_PROCESS_FAST_POLL_WINDOW_MS) { // Never below the caller's interval, so an explicitly slow poll stays slow. pollDelayMs = Math.max( diff --git a/src/main/windows-descendant-exit-verification.test.ts b/src/main/windows-descendant-exit-verification.test.ts new file mode 100644 index 00000000000..392c44399e7 --- /dev/null +++ b/src/main/windows-descendant-exit-verification.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi } from 'vitest' +import { + captureWindowsDescendantSnapshot, + terminateIdentifiedWindowsProcessTree, + verifyWindowsDescendantSnapshotExit, + type WindowsDescendantSnapshot +} from './windows-descendant-exit-verification' + +function snapshot( + descendants: { pid: number; creationTimeMs: number }[], + unidentifiedCount = 0 +): WindowsDescendantSnapshot { + return { + root: { pid: 100, creationTimeMs: 5 }, + descendants, + unidentifiedCount, + capturedAtMs: 1_700_000_000_000 + } +} + +describe('captureWindowsDescendantSnapshot', () => { + it('walks the whole subtree and keeps only rows a later read can re-identify', async () => { + const captured = await captureWindowsDescendantSnapshot(100, { + // 400 is a grandchild; 300 denied a creation-time query, so no later read + // could tell it from a recycled pid and signalling it would risk a stranger. + readTable: vi.fn(async () => [ + { pid: 100, ppid: 1, creationTimeMs: 5 }, + { pid: 200, ppid: 100, creationTimeMs: 7 }, + { pid: 300, ppid: 100 }, + { pid: 400, ppid: 200, creationTimeMs: 9 }, + { pid: 500, ppid: 1, creationTimeMs: 11 } + ]), + now: () => 42 + }) + + expect(captured).toEqual({ + root: { pid: 100, creationTimeMs: 5 }, + descendants: [ + { pid: 400, creationTimeMs: 9 }, + { pid: 200, creationTimeMs: 7 } + ], + // Seen but not re-identifiable: counted, so no later read can prove it gone. + unidentifiedCount: 1, + capturedAtMs: 42 + }) + }) + + it('reports an unreadable or rootless table as no snapshot rather than an empty one', async () => { + await expect( + captureWindowsDescendantSnapshot(100, { + readTable: vi.fn(async () => { + throw new Error('table unavailable') + }) + }) + ).resolves.toBeNull() + // A snapshot without the root is stale or filtered; only an observed root + // can authoritatively have no descendants. + await expect( + captureWindowsDescendantSnapshot(100, { + readTable: vi.fn(async () => [{ pid: 999, ppid: 1, creationTimeMs: 5 }]) + }) + ).resolves.toBeNull() + }) + + it('refuses an invalid root pid', async () => { + const readTable = vi.fn() + await expect(captureWindowsDescendantSnapshot(0, { readTable })).resolves.toBeNull() + expect(readTable).not.toHaveBeenCalled() + }) +}) + +describe('verifyWindowsDescendantSnapshotExit', () => { + it('proves an empty tree without reading the table', async () => { + const readTable = vi.fn() + await expect(verifyWindowsDescendantSnapshotExit(snapshot([]), { readTable })).resolves.toBe( + 'exited' + ) + expect(readTable).not.toHaveBeenCalled() + }) + + it('never proves a tree that held a descendant it could not identify', async () => { + // A descendant that denied the creation-time query was seen in the table; + // being unable to re-identify it is "could not look", never "it is gone". + const readTable = vi.fn() + await expect(verifyWindowsDescendantSnapshotExit(snapshot([], 1), { readTable })).resolves.toBe( + 'unverifiable' + ) + expect(readTable).not.toHaveBeenCalled() + + // The identified sibling leaving proves nothing about the unidentified one. + await expect( + verifyWindowsDescendantSnapshotExit(snapshot([{ pid: 200, creationTimeMs: 7 }], 1), { + readTable: vi.fn(async () => []), + wait: async () => {}, + now: vi.fn().mockReturnValueOnce(0).mockReturnValue(1) + }) + ).resolves.toBe('unverifiable') + }) + + it('reports exited once no identity-matched row remains', async () => { + const readTable = vi + .fn() + .mockResolvedValueOnce([{ pid: 200, ppid: 100, creationTimeMs: 7 }]) + // The pid came back on a different process; that is a recycle, not a survivor. + .mockResolvedValueOnce([{ pid: 200, ppid: 100, creationTimeMs: 99 }]) + + await expect( + verifyWindowsDescendantSnapshotExit(snapshot([{ pid: 200, creationTimeMs: 7 }]), { + readTable, + wait: async () => {}, + now: vi.fn().mockReturnValueOnce(0).mockReturnValue(1) + }) + ).resolves.toBe('exited') + expect(readTable).toHaveBeenCalledTimes(2) + }) + + it('reports live for a descendant still matched at the deadline', async () => { + let clock = 0 + await expect( + verifyWindowsDescendantSnapshotExit(snapshot([{ pid: 200, creationTimeMs: 7 }]), { + readTable: vi.fn(async () => [{ pid: 200, ppid: 100, creationTimeMs: 7 }]), + wait: async () => { + clock += 100 + }, + now: () => clock, + verifyMs: 250 + }) + ).resolves.toBe('live') + }) + + it('reports unverifiable when the table cannot be read at the deadline', async () => { + await expect( + verifyWindowsDescendantSnapshotExit(snapshot([{ pid: 200, creationTimeMs: 7 }]), { + readTable: vi.fn(async () => { + throw new Error('table unavailable') + }), + wait: async () => {}, + now: vi.fn().mockReturnValueOnce(0).mockReturnValue(9_999) + }) + ).resolves.toBe('unverifiable') + }) +}) + +describe('terminateIdentifiedWindowsProcessTree', () => { + it('never taskkills a replacement that reused the captured root pid', async () => { + const terminateTree = vi.fn(async () => {}) + + await expect( + terminateIdentifiedWindowsProcessTree( + { pid: 100, creationTimeMs: 5 }, + { + readTable: vi.fn(async () => [{ pid: 100, ppid: 1, creationTimeMs: 99 }]), + terminateTree + } + ) + ).resolves.toBe(false) + expect(terminateTree).not.toHaveBeenCalled() + }) + + it('rechecks retained-child ownership after the identity read settles', async () => { + const terminateTree = vi.fn(async () => {}) + + await expect( + terminateIdentifiedWindowsProcessTree( + { pid: 100, creationTimeMs: 5 }, + { + readTable: vi.fn(async () => [{ pid: 100, ppid: 1, creationTimeMs: 5 }]), + ownsRoot: () => false, + terminateTree + } + ) + ).resolves.toBe(false) + expect(terminateTree).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/windows-descendant-exit-verification.ts b/src/main/windows-descendant-exit-verification.ts new file mode 100644 index 00000000000..079833a2bd6 --- /dev/null +++ b/src/main/windows-descendant-exit-verification.ts @@ -0,0 +1,156 @@ +import type { DescendantTreeVerdict } from './pty-descendant-exit-verification' +import { windowsDescendantsFromRows } from './providers/windows-foreground-process-rows' +import { readWindowsProcessTableFresh } from './windows/windows-process-table' +import { terminateWindowsProcessTree } from './windows-process-tree-kill' + +export const WINDOWS_DESCENDANT_KILL_VERIFY_MS = 3_500 +const WINDOWS_DESCENDANT_POLL_MS = 100 + +/** + * A Windows descendant tree captured while its root was alive, with the + * PID-reuse guard the POSIX snapshot gets from ps lstart: a row only counts as + * the same process when its creation time still matches. Rows without a + * creation time are never signalled, because a bare pid cannot be re-identified, + * but they are counted: a descendant that was seen and denied identification + * is one no later read can prove gone. + */ +export type WindowsProcessIdentity = { pid: number; creationTimeMs: number } + +export type WindowsDescendantSnapshot = { + root: WindowsProcessIdentity + descendants: WindowsProcessIdentity[] + /** Descendants seen in the walk that denied the creation-time query. */ + unidentifiedCount: number + capturedAtMs: number + /** Per-PID boundaries retained when close refreshes merge snapshots. */ + capturedAtMsByPid?: Readonly> +} + +export type WindowsDescendantVerificationDeps = { + readTable?: () => Promise<{ pid: number; ppid: number; creationTimeMs?: number }[]> + now?: () => number + wait?: (ms: number) => Promise + verifyMs?: number +} + +/** Revalidate a Windows PID/creation-time identity immediately before a kill. */ +export async function verifyWindowsProcessIdentity( + target: WindowsProcessIdentity, + deps: Pick = {} +): Promise { + if (!Number.isInteger(target.pid) || target.pid <= 0 || !Number.isFinite(target.creationTimeMs)) { + return false + } + const table = await (deps.readTable ?? readWindowsProcessTableFresh)().catch(() => null) + const current = table?.filter((row) => row.pid === target.pid) ?? [] + return current.length === 1 && current[0]?.creationTimeMs === target.creationTimeMs +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms) + timer.unref?.() + }) +} + +/** + * Snapshot a Windows root's descendants while it is still alive. Resolves null + * (never rejects) when the table is unreadable or the root is absent — the same + * contract as the POSIX walk, because "cannot see" is never "nothing is there". + */ +export async function captureWindowsDescendantSnapshot( + rootPid: number, + deps: WindowsDescendantVerificationDeps = {} +): Promise { + if (!Number.isInteger(rootPid) || rootPid <= 0) { + return null + } + const capturedAtMs = (deps.now ?? Date.now)() + // One table read, not a walk plus an identity read: each is bounded in + // seconds, and this runs inside the close ladder's budget. + const table = await (deps.readTable ?? readWindowsProcessTableFresh)().catch(() => null) + const descendants = table && windowsDescendantsFromRows(table, rootPid) + const root = table?.find((row) => row.pid === rootPid) + if (!descendants || typeof root?.creationTimeMs !== 'number') { + return null + } + return { + root: { pid: root.pid, creationTimeMs: root.creationTimeMs }, + descendants: descendants.flatMap((row) => + // A descendant that denied a creation-time query cannot be told from a + // recycled pid later, so it is never signalled on a bare pid. + typeof row.creationTimeMs === 'number' + ? [{ pid: row.pid, creationTimeMs: row.creationTimeMs }] + : [] + ), + unidentifiedCount: descendants.filter((row) => typeof row.creationTimeMs !== 'number').length, + capturedAtMs + } +} + +export type IdentifiedWindowsTreeTerminationDeps = { + readTable?: WindowsDescendantVerificationDeps['readTable'] + terminateTree?: (target: WindowsProcessIdentity) => Promise + ownsRoot?: () => boolean +} + +/** Revalidate the captured root at the last async boundary before taskkill. */ +export async function terminateIdentifiedWindowsProcessTree( + target: WindowsProcessIdentity, + deps: IdentifiedWindowsTreeTerminationDeps = {} +): Promise { + if (!(await verifyWindowsProcessIdentity(target, { readTable: deps.readTable }))) { + return false + } + if (deps.ownsRoot?.() === false) { + return false + } + await ( + deps.terminateTree ?? + ((identified: WindowsProcessIdentity) => terminateWindowsProcessTree(identified.pid)) + )(target) + return true +} + +/** + * Whether a snapshotted Windows tree is gone, polled to a bounded deadline. + * + * Why a verification pass at all: `taskkill /T /F` resolves the same way on a + * timeout, an access denial and a recycled root as it does on a successful + * kill, so its completion is never evidence. Only a table read that no longer + * shows an identity-matched row is. + */ +export async function verifyWindowsDescendantSnapshotExit( + snapshot: WindowsDescendantSnapshot, + deps: WindowsDescendantVerificationDeps = {} +): Promise { + // The most a read can prove: a descendant that denied identification was seen + // and can never be matched gone, so "could not look" caps the verdict. + const proven: DescendantTreeVerdict = snapshot.unidentifiedCount > 0 ? 'unverifiable' : 'exited' + if (snapshot.descendants.length === 0) { + return proven + } + const now = deps.now ?? Date.now + const readTable = deps.readTable ?? readWindowsProcessTableFresh + const deadline = now() + (deps.verifyMs ?? WINDOWS_DESCENDANT_KILL_VERIFY_MS) + let verdict: DescendantTreeVerdict = 'unverifiable' + do { + const table = await readTable().catch(() => null) + if (!table) { + verdict = 'unverifiable' + } else { + const live = new Map(table.map((row) => [row.pid, row.creationTimeMs])) + verdict = snapshot.descendants.some((row) => live.get(row.pid) === row.creationTimeMs) + ? 'live' + : proven + if (verdict === proven) { + return verdict + } + } + if (now() >= deadline) { + return verdict + } + await (deps.wait ?? delay)(WINDOWS_DESCENDANT_POLL_MS) + } while (now() < deadline) + return verdict +} diff --git a/src/relay/pty-handler-ownership-attestation.test.ts b/src/relay/pty-handler-ownership-attestation.test.ts index 94f462c46d2..ee1c144df09 100644 --- a/src/relay/pty-handler-ownership-attestation.test.ts +++ b/src/relay/pty-handler-ownership-attestation.test.ts @@ -33,7 +33,8 @@ import { endPtyHandlerTest, type MockDispatcher } from './pty-handler-test-harness' -import { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS } from '../shared/process-table-snapshot-reader' +import * as processTableSnapshotReader from '../shared/process-table-snapshot-reader' +import { RELAY_PTY_SWEEP_MAX_EVIDENCE_AGE_MS } from '../shared/ssh-relay-pty-ownership-proof' const PANE_KEY = 'tab-agent:22222222-2222-4222-8222-222222222222' @@ -139,16 +140,41 @@ describe('PtyHandler publishes host-attested PTY ownership', () => { expect(entry?.ownerClientInstanceId).toBe('client-A') }) - it('dates the foreground observation instead of stamping it fresh', async () => { - // `capturedAgeMs` used to be a hardcoded 0 with no reader anywhere, so the one field that - // exists to bound staleness asserted the evidence was never stale. It now carries the - // actual age of the TTL-shared capture the record was derived from. - const { id } = await spawnFrom(7, { env: { ORCA_PANE_KEY: PANE_KEY } }) + it('publishes the age the capture reported, rather than restamping it fresh', async () => { + // This assertion used to read `capturedAgeMs <= PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS`, which + // could not fail: `beginPtyHandlerTest` installs fake timers, so `Date.now()` is frozen, the + // real reader reports exactly +0, and `0 <= 500` held identically for a hardcoded zero, for + // completion-stamping and for start-stamping. The one test guarding this field was blind to + // every change to it, while the real reader on a 2,002-process host returns thousands of ms. + // + // So drive a real age in from the reader. That the reader MEASURES the age correctly is + // pinned separately, against a controllable clock, by process-table-snapshot.test.ts; what + // belongs here is that the handler publishes what it was given instead of restamping. + const capturedAgeMs = 6_140 + const snapshot = vi + .spyOn(processTableSnapshotReader, 'getStrictProcessTableSnapshotWithAge') + .mockResolvedValue({ rows: [], capturedAgeMs }) + const { id } = await spawnFrom(7, { env: { ORCA_PANE_KEY: PANE_KEY } }) const entry = (await listProcesses()).find((process) => process.id === id) - expect(entry?.foregroundProcessEvidence?.capturedAgeMs).toBeLessThanOrEqual( - PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS + expect(snapshot).toHaveBeenCalled() + expect(entry?.foregroundProcessEvidence?.capturedAgeMs).toBe(capturedAgeMs) + }) + + it('publishes an age a destructive consumer will refuse, rather than one it will trust', async () => { + // The point of the field, stated as the consumer sees it: an observation this old cannot + // authorize a stop, and the whole bug was that it used to arrive claiming it could. + const snapshot = vi + .spyOn(processTableSnapshotReader, 'getStrictProcessTableSnapshotWithAge') + .mockResolvedValue({ rows: [], capturedAgeMs: 6_140 }) + + const { id } = await spawnFrom(7, { env: { ORCA_PANE_KEY: PANE_KEY } }) + const entry = (await listProcesses()).find((process) => process.id === id) + + expect(snapshot).toHaveBeenCalled() + expect(entry?.foregroundProcessEvidence?.capturedAgeMs).toBeGreaterThan( + RELAY_PTY_SWEEP_MAX_EVIDENCE_AGE_MS ) }) }) diff --git a/src/relay/pty-handler-spawn-admission.test.ts b/src/relay/pty-handler-spawn-admission.test.ts index 045ee2e412d..07fe8e9d88f 100644 --- a/src/relay/pty-handler-spawn-admission.test.ts +++ b/src/relay/pty-handler-spawn-admission.test.ts @@ -126,6 +126,46 @@ describe('PtyHandler', () => { expect(hasChildren).toHaveBeenLastCalledWith(mockPtyInstance.pid, { fresh: true }) }) + it('does not re-enter the shared capture after the evidence read gave up on it', async () => { + // The budget is worthless if the compatibility fields answer by joining the very capture the + // evidence read just abandoned: `inspectPtyChildProcesses` and `getForegroundProcessName` + // read the same TTL-shared table with no budget of their own, so on a slow host this call + // would still block for the whole capture -- once, then once per managed PTY in the listing. + const snapshot = vi + .spyOn(processTableSnapshotReader, 'getStrictProcessTableSnapshotWithAge') + .mockRejectedValue(new Error('process table unreadable: capture_over_budget')) + const hasChildren = vi.spyOn(ptyChildProcessInspection, 'inspectPtyChildProcesses') + const foregroundName = vi.spyOn(ptyShellUtils, 'getForegroundProcessName') + + const { id } = (await spawnPty({ cols: 80, rows: 24 })) as { id: string } + hasChildren.mockClear() + foregroundName.mockClear() + + const inspection = (await dispatcher.callRequest('pty.inspectProcess', { id })) as { + hasChildProcesses: boolean + childProcessEvidence?: string + foregroundProcessEvidence?: { verdict: string; reason?: string } + } + + expect(snapshot).toHaveBeenCalled() + expect(hasChildren).not.toHaveBeenCalled() + // The verdict the gates already handle, reached promptly instead of late. + expect(inspection.foregroundProcessEvidence?.verdict).toBe('unverifiable') + expect(inspection.foregroundProcessEvidence?.reason).toBe('process_table_unreadable') + // The honest verdict rather than a fabricated negative, reached without the wait. The + // compatibility boolean still spells `unverifiable` as `false` for older clients. + expect(inspection.childProcessEvidence).toBe('unverifiable') + expect(inspection.hasChildProcesses).toBe(false) + + const listing = (await dispatcher.callRequest('pty.listProcesses', {})) as { + id: string + title: string + }[] + + expect(foregroundName).not.toHaveBeenCalled() + expect(listing.find((entry) => entry.id === id)?.title).toBeTruthy() + }) + it('rejects strict process inspection for a missing relay PTY', async () => { await expect(dispatcher.callRequest('pty.inspectProcess', { id: 'missing' })).rejects.toThrow( 'terminal_gone' diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 190bcabb3f9..4b7c6dac2d6 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -2663,6 +2663,9 @@ export class PtyHandler { } } let rows: readonly ProcessTableRow[] | null = null + // Set only when the budgeted evidence read gave up, so the compatibility fields below do not + // turn around and ask the same unreadable table again with no budget at all. + let tableUnavailable = false let evidence: RemoteForegroundEvidence | undefined if (process.platform === 'win32') { // Why SSH-to-Windows is always unverifiable: POSIX has a real foreground primitive @@ -2701,6 +2704,7 @@ export class PtyHandler { rows ) } catch { + tableUnavailable = true evidence = { authorityGeneration: this.ptyIdMintEpoch, observationEpoch: ++this.foregroundEvidenceEpoch, @@ -2727,13 +2731,19 @@ export class PtyHandler { // 1.36s CIM scan, and polling that would reinstate exactly the fork storm the shared table // exists to prevent (#15209, #15036). Close and cleanup decisions ask for the scan by name; // a poll gets the honest `unverifiable` instead of a fabricated negative. + // Why `tableUnavailable` first: it means the budgeted evidence read already gave up. Without + // this arm `inspectPtyChildProcesses` re-enters `getProcessTableSnapshot()` and joins the very + // capture this call just abandoned, blocking for all of it and spending the whole latency the + // budget exists to avoid. The destructive `pty.hasChildProcesses` RPC keeps its fresh probe. const childProcessEvidence: PtyChildProcessVerdict = rows ? rows.some((row) => row.ppid === managed.pty.pid) ? 'children' : 'no-children' - : process.platform === 'win32' && params.scanChildProcesses !== true + : tableUnavailable ? 'unverifiable' - : await inspectPtyChildProcesses(managed.pty.pid) + : process.platform === 'win32' && params.scanChildProcesses !== true + ? 'unverifiable' + : await inspectPtyChildProcesses(managed.pty.pid) return { foregroundProcess, // `unverifiable` keeps spelling itself `false` on the compatibility field, which is what @@ -2758,6 +2768,10 @@ export class PtyHandler { // process-table work on the host. const includeForegroundProcessEvidence = params.includeForegroundProcessEvidence !== false let evidenceRows: readonly ProcessTableRow[] | null = null + // Same reason as `inspectProcess`: once the budgeted read has given up, the per-PTY title + // fallback below must not re-enter the same capture without a budget -- and here it would do + // so once per managed PTY. + let evidenceTableUnavailable = false let evidenceResults: BatchedForegroundProcessResult[] = [] const evidenceEpoch = ++this.foregroundEvidenceEpoch // Worst-case capture time for the snapshot below, not the instant its await settled: the @@ -2783,6 +2797,7 @@ export class PtyHandler { } catch { // An unreadable capture is represented as unverifiable evidence below; // existing inventory fields remain available for old clients. + evidenceTableUnavailable = true } } for (const [entryIndex, [id, managed]] of managedEntries.entries()) { @@ -2797,7 +2812,7 @@ export class PtyHandler { const title = (evidenceRows ? (evidenceResults[entryIndex]?.processName ?? managed.pty.process ?? null) - : includeForegroundProcessEvidence + : includeForegroundProcessEvidence && !evidenceTableUnavailable ? await getForegroundProcessName(managed.pty.pid, managed.pty.process || null) : managed.pty.process || null) || 'shell' const foregroundProcessEvidence = diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx index b6748fe7e82..9b1a2967682 100644 --- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx @@ -28,7 +28,7 @@ afterEach(() => { function render( prompt: AskPrompt, onAnswer: (s: AskAnswerSelection[]) => void, - allowOther = true + allowOther: boolean | readonly boolean[] = true ): void { act(() => { root.render( @@ -155,4 +155,71 @@ describe('NativeChatQuestionCard', () => { expect(container.querySelector('input')).toBeNull() expect(container.textContent).not.toContain('Type your answer') }) + + it('applies free-text capability per question in a grouped prompt', () => { + render( + { + questions: [ + { + header: 'Listed', + question: 'Pick a listed value', + multiSelect: false, + options: [{ label: 'One' }] + }, + { + header: 'Custom', + question: 'Provide a custom value', + multiSelect: false, + options: [] + } + ] + }, + vi.fn(), + [false, true] + ) + + expect(container.querySelector('input')).toBeNull() + clickAction('Skip') + expect(container.querySelector('input')).not.toBeNull() + }) + + it('submits grouped multi-select and free-text answers together', () => { + const onAnswer = vi.fn() + render( + { + questions: [ + { + header: 'Targets', + question: 'Which targets?', + multiSelect: true, + options: [{ label: 'Web' }, { label: 'Mobile' }] + }, + { + header: 'Notes', + question: 'Anything else?', + multiSelect: false, + options: [] + } + ] + }, + onAnswer, + [false, true] + ) + + clickOption('Web') + clickOption('Mobile') + clickAction('Next') + const input = container.querySelector('input')! + act(() => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set! + setter.call(input, 'SSH host') + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + clickAction('Submit') + + expect(onAnswer).toHaveBeenCalledWith([ + { indices: [0, 1], other: '' }, + { indices: [], other: 'SSH host' } + ]) + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx index 4bc881ee3e1..1b1ce5a3547 100644 --- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx @@ -10,7 +10,7 @@ export type NativeChatQuestionCardProps = { isSubmitting?: boolean /** Deliver the chosen answer (per-question option indices + free text). */ onAnswer: (selections: AskAnswerSelection[]) => void - allowOther?: boolean + allowOther?: boolean | readonly boolean[] /** Dismiss the prompt (sends Escape to the agent). */ onCancel: () => void /** Exposes the free-text row so pane-level Paste can target it while the @@ -42,6 +42,7 @@ export function NativeChatQuestionCard({ const total = prompt.questions.length const isLast = index === total - 1 const q = prompt.questions[index]! + const questionAllowsOther = Array.isArray(allowOther) ? (allowOther[index] ?? false) : allowOther const setOther = (qi: number, value: string): void => { setOtherText((prev) => { @@ -186,7 +187,7 @@ export function NativeChatQuestionCard({ /> ))}
- {allowOther ? ( + {questionAllowsOther ? ( <> diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx index d53e8f536d2..031ce4bcd15 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx @@ -165,6 +165,7 @@ function model(overrides: Partial = {}): SessionOptionD ] }, valueSource: 'applied', + transport: 'catalog', settable: true, ...overrides } @@ -183,6 +184,7 @@ const effort: SessionOptionDescriptor = { ] }, valueSource: 'applied', + transport: 'catalog', settable: true } @@ -192,6 +194,7 @@ const fast: SessionOptionDescriptor = { category: 'mode', kind: { type: 'boolean', currentValue: true }, valueSource: 'applied', + transport: 'catalog', settable: true } @@ -342,17 +345,48 @@ describe('NativeChatSessionOptionPickers', () => { expect(screen.queryByRole('button', { name: /^Effort/ })).toBeNull() }) - it('shows the unconfirmed hint for dispatched values', () => { + // The terminal transport typed the value at the agent and has not read it back, + // so the pill says so; the structured transport's own per-turn report is the + // confirmation, which makes the same hedge transient noise there. + it('hedges a dispatched value the terminal transport produced', () => { render( ) - expect(screen.getByText('Sent to the agent — not confirmed')).not.toBeNull() + expect(screen.getByText('Model')).not.toBeNull() + expect(screen.getAllByText('Sent to the agent — not confirmed').length).toBeGreaterThan(0) }) + it('does not hedge a dispatched value the structured transport produced', () => { + render( + + ) + expect(screen.getByText('Model')).not.toBeNull() + expect(screen.queryByText(/not confirmed/)).toBeNull() + }) + + it.each(['catalog', 'agent-session'] as const)( + 'does not hedge a reported value on the %s transport', + (transport) => { + render( + + ) + expect(screen.getByText('Model')).not.toBeNull() + expect(screen.queryByText(/not confirmed/)).toBeNull() + } + ) + it('renders agent-picker routes as one action instead of radio choices', async () => { const invokeAction = vi.fn().mockResolvedValue({ snapshot: [] }) const liveSurface = { ...surface, invokeAction } @@ -449,6 +483,7 @@ describe('NativeChatSessionOptionPickers', () => { category: 'mode', kind: { type: 'boolean' }, valueSource: 'unknown', + transport: 'catalog', settable: true } ]} @@ -463,26 +498,7 @@ describe('NativeChatSessionOptionPickers', () => { await waitFor(() => expect(setOption).toHaveBeenCalledWith('thinking', false)) }) - it('does not show unconfirmed for applied flip-only booleans', () => { - render( - - ) - expect(screen.queryByText('Sent to the agent — not confirmed')).toBeNull() - }) - - it('shows unconfirmed for confirmable dispatched booleans', () => { + it('tooltips a dispatched option pill with the category alone', () => { render( { category: 'mode', kind: { type: 'boolean', currentValue: true }, valueSource: 'dispatched', + transport: 'catalog', settable: true } ]} isWorking={false} /> ) - expect(screen.getByText('Sent to the agent — not confirmed')).not.toBeNull() + expect(screen.getAllByText('Thinking').length).toBeGreaterThan(0) + expect(screen.getAllByText('Sent to the agent — not confirmed').length).toBeGreaterThan(0) }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx index 87a860662d2..31ff2cbdc4e 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx @@ -15,10 +15,11 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { sortNativeChatSessionOptions } from '../../../../shared/native-chat-session-option-snapshot' -import type { - SessionOptionDescriptor, - SessionOptionsSurface, - SessionOptionValue +import { + sessionOptionDispatchUnconfirmed, + type SessionOptionDescriptor, + type SessionOptionsSurface, + type SessionOptionValue } from '../../../../shared/native-chat-session-options' import { nativeChatModelPillLabel, @@ -250,7 +251,7 @@ function NativeChatSessionOptionPickersInner({ tooltipLabel={optionsTooltip} disabled={isWorking || pendingId !== null} disabledReason={optionsReason} - dispatched={options.some((descriptor) => descriptor.valueSource === 'dispatched')} + dispatched={options.some(sessionOptionDispatchUnconfirmed)} /> {options.map((descriptor, index) => { @@ -283,7 +284,7 @@ function NativeChatSessionOptionPickersInner({ tooltipLabel={modelTooltip} disabled={isWorking || pendingId !== null} disabledReason={modelReason} - dispatched={model.valueSource === 'dispatched'} + dispatched={sessionOptionDispatchUnconfirmed(model)} /> {modelReason && !model.settable ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index bfb3dd1ef52..d3c13a48ef1 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -3,6 +3,9 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import React, { forwardRef, useImperativeHandle } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { decodeAgentSessionQuestionAnswers } from '../../../../shared/agent-session-question-answer' +import type { NativeChatQuestionCardProps } from './NativeChatQuestionCard' const mocks = vi.hoisted(() => ({ call: vi.fn(), @@ -13,6 +16,9 @@ const mocks = vi.hoisted(() => ({ onLinkClick?: (...args: unknown[]) => void }, composerProps: null as null | { structuredTransport?: Record }, + questionCardProps: null as NativeChatQuestionCardProps | null, + promptItems: [] as AgentJournalRenderItem[], + respond: vi.fn(), handlePasteEvent: vi.fn(), pasteFromClipboard: vi.fn(), submissions: [] as unknown[] @@ -53,7 +59,7 @@ vi.mock('./use-structured-agent-session', async () => { hasOlder: false, loadingOlder: false, loadOlder: vi.fn(), - prompts: [], + prompts: mocks.promptItems, outbox: outbox.outbox, blockedClientMessageId: outbox.blockedClientMessageId, send: outbox.send, @@ -61,7 +67,7 @@ vi.mock('./use-structured-agent-session', async () => { isWorking: false, turnId: null, cancel: vi.fn(), - respond: vi.fn(), + respond: mocks.respond, optionSnapshot: [ { id: 'model', @@ -125,7 +131,12 @@ vi.mock('./NativeChatComposer', () => ({ })) vi.mock('./NativeChatEmptyState', () => ({ NativeChatEmptyState: () => null })) vi.mock('./NativeChatApprovalCard', () => ({ NativeChatApprovalCard: () => null })) -vi.mock('./NativeChatQuestionCard', () => ({ NativeChatQuestionCard: () => null })) +vi.mock('./NativeChatQuestionCard', () => ({ + NativeChatQuestionCard: (props: NativeChatQuestionCardProps) => { + mocks.questionCardProps = props + return null + } +})) import { NativeChatStructuredSession } from './NativeChatStructuredSession' @@ -136,6 +147,9 @@ describe('NativeChatStructuredSession', () => { mocks.mode = 'static' mocks.messageListProps = null mocks.composerProps = null + mocks.questionCardProps = null + mocks.promptItems = [] + mocks.respond.mockReset() mocks.handlePasteEvent.mockReset() mocks.pasteFromClipboard.mockReset() mocks.submissions = [] @@ -546,4 +560,131 @@ describe('NativeChatStructuredSession', () => { vi.useRealTimers() } }, 30000) + + it('passes Claude grouped questions and one shared answer through the card', () => { + mocks.promptItems = [ + { + itemId: 'question-item', + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'question', + question: '2 grouped questions from Claude', + options: [], + questions: [ + { + id: 'q1', + header: 'Targets', + question: 'Which targets?', + multiSelect: true, + options: [ + { id: 'target-web', label: 'Web' }, + { id: 'target-mobile', label: 'Mobile' } + ], + freeTextQuestionId: 'q1' + }, + { + id: 'q2', + header: 'Host', + question: 'Where should it run?', + multiSelect: false, + options: [], + freeTextQuestionId: 'q2' + } + ], + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } + } + ] + + render( + + ) + + const card = mocks.questionCardProps + if (!card) { + throw new Error('question card was not rendered') + } + expect(card.prompt.questions).toHaveLength(2) + expect(card.prompt.questions[0]).toMatchObject({ + question: 'Which targets?', + multiSelect: true, + options: [{ label: 'Web' }, { label: 'Mobile' }] + }) + expect(card.allowOther).toEqual([true, true]) + + card.onAnswer([ + { indices: [0, 1], other: '' }, + { indices: [], other: 'SSH host' } + ]) + const encoded = mocks.respond.mock.calls[0]?.[1] + expect(decodeAgentSessionQuestionAnswers(encoded)).toEqual([ + { questionId: 'q1', optionIds: ['target-web', 'target-mobile'] }, + { questionId: 'q2', optionIds: [], other: 'SSH host' } + ]) + }) + + it('keeps legacy single-question option ids and free text behavior', () => { + mocks.promptItems = [ + { + itemId: 'legacy-question-item', + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'question', + question: 'Pick a library', + options: [ + { id: 'q1:choice-1', label: 'React' }, + { id: 'q1:choice-2', label: 'Vue' } + ], + freeTextQuestionId: 'q1', + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } + } + ] + + render( + + ) + + const card = mocks.questionCardProps + if (!card) { + throw new Error('question card was not rendered') + } + expect(card.prompt.questions).toEqual([ + { + question: 'Pick a library', + multiSelect: false, + options: [{ label: 'React' }, { label: 'Vue' }] + } + ]) + card.onAnswer([{ indices: [1], other: '' }]) + expect(mocks.respond).toHaveBeenCalledWith(mocks.promptItems[0], 'q1:choice-2') + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index f7d2fd62663..d6464c27760 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -4,6 +4,7 @@ import type { AgentStatusOrchestrationContext, AgentType } from '../../../../shared/agent-status-types' +import { encodeAgentSessionQuestionAnswers } from '../../../../shared/agent-session-question-answer' import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import type { NativeChatLiveSession } from './use-native-chat-live-session' @@ -85,6 +86,21 @@ export function NativeChatStructuredSession(props: { const fileLinkClick = useNativeChatFileLinkClick(props.allowFileUriLinks ? fileLinkContext : null) const prompt = controller.prompts[0] ?? null const questionBody = prompt?.body.kind === 'question' ? prompt.body : null + const questions = + questionBody?.questions ?? + (questionBody + ? [ + { + id: questionBody.freeTextQuestionId ?? 'q1', + question: questionBody.question, + options: questionBody.options, + multiSelect: false, + ...(questionBody.freeTextQuestionId + ? { freeTextQuestionId: questionBody.freeTextQuestionId } + : {}) + } + ] + : []) const retryableOutboxEntry = controller.outbox.find((entry) => entry.state === 'unconfirmed') ?? controller.outbox.find( @@ -166,17 +182,39 @@ export function NativeChatStructuredSession(props: { ) : null} {prompt && questionBody ? ( ({ label: option.label })) - } - ] + questions: questions.map((question) => ({ + question: question.question, + ...(question.header ? { header: question.header } : {}), + multiSelect: question.multiSelect, + options: question.options.map((option) => ({ + label: option.label, + ...(option.description ? { description: option.description } : {}) + })) + })) }} - allowOther={Boolean(questionBody.freeTextQuestionId)} + allowOther={questions.map((question) => Boolean(question.freeTextQuestionId))} onAnswer={(answers) => { + if (questionBody.questions) { + const grouped = questions.map((question, questionIndex) => { + const answer = answers[questionIndex] + const other = answer?.other?.trim() + const optionIds = (answer?.indices ?? []).flatMap((optionIndex) => { + const optionId = question.options[optionIndex]?.id + return optionId ? [optionId] : [] + }) + return { + questionId: question.id, + optionIds: question.multiSelect || !other ? optionIds : [], + ...(other ? { other } : {}) + } + }) + if (grouped.every((answer) => answer.optionIds.length > 0 || answer.other)) { + void controller.respond(prompt, encodeAgentSessionQuestionAnswers(grouped)) + } + return + } const index = answers[0]?.indices[0] const other = answers[0]?.other?.trim() const optionId = diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.test.tsx new file mode 100644 index 00000000000..4a185a3d630 --- /dev/null +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionHandoffStatus } from '../../../../shared/agent-session-wire' +import { StructuredAgentSessionHandoffChrome } from './StructuredAgentSessionHandoffChrome' + +const IDLE_NATIVE: AgentSessionHandoffStatus = { + owner: 'native', + direction: null, + phase: 'idle', + stage: null, + operationId: null +} + +afterEach(cleanup) + +describe('StructuredAgentSessionHandoffChrome', () => { + it('uses queued-safe admission when the native view still appears idle', () => { + const onRequest = vi.fn() + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Open agent TUI' })) + + expect(onRequest).toHaveBeenCalledWith('to-tui', 'after-turn') + }) + + it('offers one Retry action for a recoverable dead TUI owner', () => { + const onRequest = vi.fn() + render( + + ) + + expect(screen.queryByRole('button', { name: 'Return to chat' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(onRequest).toHaveBeenCalledWith('to-native', 'now', 'retry') + }) +}) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx new file mode 100644 index 00000000000..840039564d8 --- /dev/null +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx @@ -0,0 +1,225 @@ +import type { + AgentSessionHandoffDirection, + AgentSessionHandoffMode, + AgentSessionHandoffStatus +} from '../../../../shared/agent-session-wire' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { translate } from '@/i18n/i18n' + +type Props = { + status: AgentSessionHandoffStatus | null + isWorking: boolean + onRequest: ( + direction: AgentSessionHandoffDirection, + mode: AgentSessionHandoffMode, + action?: 'start' | 'cancel-queued' | 'retry' | 'recover' + ) => void +} + +function handoffStageCopy(status: AgentSessionHandoffStatus): string { + if (status.stage === 'preparing') { + return status.direction === 'to-tui' + ? translate('components.native-chat.handoff.stage.finishingChat', 'Finishing chat session…') + : translate( + 'components.native-chat.handoff.stage.finishingTerminal', + 'Finishing agent terminal…' + ) + } + if (status.stage === 'old-owner-stopped') { + return status.direction === 'to-tui' + ? translate('components.native-chat.handoff.stage.openingTerminal', 'Opening agent terminal…') + : translate('components.native-chat.handoff.stage.resumingChat', 'Resuming chat session…') + } + if (status.stage === 'new-owner-proving') { + return status.direction === 'to-tui' + ? translate( + 'components.native-chat.handoff.stage.verifyingTerminal', + 'Verifying agent terminal…' + ) + : translate('components.native-chat.handoff.stage.verifyingChat', 'Verifying chat session…') + } + if (status.stage === 'recovering') { + return translate('components.native-chat.handoff.stage.recovering', 'Recovering agent session…') + } + if (status.stage === 'manual-recovery') { + return translate( + 'components.native-chat.handoff.stage.manualRecovery', + 'Agent session needs recovery' + ) + } + return translate('components.native-chat.handoff.switchingOwner', 'Switching session owner…') +} + +export function StructuredAgentSessionHandoffChrome({ + status, + isWorking, + onRequest +}: Props): React.JSX.Element | null { + if (!status) { + return null + } + const owner = status?.owner ?? 'native' + const phase = status?.phase ?? 'idle' + const switching = phase === 'switching' || phase === 'waiting-for-exit' + return ( + <> +
+ + {switching + ? translate('components.native-chat.handoff.mode.switching', 'Switching') + : owner === 'tui' + ? translate('components.native-chat.handoff.mode.terminal', 'Terminal') + : translate('components.native-chat.handoff.mode.chat', 'Chat')} + +
+ {phase === 'queued' && status?.direction ? ( + <> + + {status.direction === 'to-tui' + ? translate( + 'components.native-chat.handoff.switchingAfterTurn', + 'Switching after this turn' + ) + : translate( + 'components.native-chat.handoff.returningAfterTurn', + 'Returning after this turn' + )} + + + + ) : owner === 'native' && phase === 'idle' ? ( + isWorking ? ( + <> + + + + ) : ( + + ) + ) : owner === 'tui' && phase === 'idle' ? ( + + ) : null} +
+
+ {owner === 'tui' && phase === 'idle' ? ( +
+ + {status?.hostLabel + ? translate( + 'components.native-chat.handoff.agentOpenOnHost', + 'Agent is open in terminal on {{value0}}.', + { value0: status.hostLabel } + ) + : translate('components.native-chat.handoff.agentOpen', 'Agent is open in terminal.')} + + +
+ ) : null} + {switching ? ( +
+ {phase === 'waiting-for-exit' + ? translate( + 'components.native-chat.handoff.exitTerminal', + 'Exit the agent terminal to continue in chat.' + ) + : status?.stage + ? handoffStageCopy(status) + : translate( + 'components.native-chat.handoff.switchingOwner', + 'Switching session owner…' + )} +
+ ) : null} + {phase === 'failed' && status?.error ? ( +
+
+ {status.error.message} + {status.direction && status.error.canRetryProof ? ( + + ) : status.direction && status.error.recoverableOwner !== 'none' ? ( + + ) : null} +
+ {status.error.details ? ( +
+ {translate('components.native-chat.handoff.details', 'Details')} +

{status.error.details}

+
+ ) : null} +
+ ) : null} + + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts index 9dae22da197..8cf82b69ca1 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts @@ -117,6 +117,7 @@ describe('native chat PTY session options', () => { expect(effortResult.snapshot.map(({ id }) => id)).toEqual(['model', 'effort', 'fastMode']) expect(effortResult.snapshot.find(({ id }) => id === 'effort')).toMatchObject({ valueSource: 'dispatched', + transport: 'catalog', kind: { currentValue: 'high' } }) expect(listener).toHaveBeenCalledOnce() diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts index aa7562b5944..3e3587e68d1 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts @@ -98,7 +98,8 @@ export function createNativeChatPtySessionOptions( catalog, models: activeModels(), record, - mode: args.mode + mode: args.mode, + liveTransport: 'catalog' }) const listeners = new Set<(value: SessionOptionDescriptor[]) => void>() @@ -108,7 +109,8 @@ export function createNativeChatPtySessionOptions( catalog, models: activeModels(), record, - mode: args.mode + mode: args.mode, + liveTransport: 'catalog' }) for (const listener of listeners) { listener(snapshot) diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-labels.test.ts b/src/renderer/src/components/native-chat/native-chat-session-option-labels.test.ts index 60d0ffe6aba..7cdf621b272 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-labels.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-labels.test.ts @@ -18,6 +18,7 @@ function modelDescriptor( id: 'model', label: 'Model', valueSource, + transport: 'catalog', settable: true, kind: { type: 'select', diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts b/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts index 9010aa66f27..4acb1a1932a 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts @@ -7,6 +7,7 @@ import { buildNativeChatSessionOptionSnapshot as buildSharedSnapshot, resolveEffectiveNativeChatModelId, withTrackedNativeChatModel, + type NativeChatLiveOptionTransport, type NativeChatSessionOptionMode } from '../../../../shared/native-chat-session-option-snapshot' import { @@ -15,7 +16,7 @@ import { } from '../../../../shared/native-chat-session-option-state' import { translate } from '@/i18n/i18n' -export type { NativeChatSessionOptionMode } +export type { NativeChatLiveOptionTransport, NativeChatSessionOptionMode } export { flattenNativeChatSessionOptionRecord, resolveEffectiveNativeChatModelId, @@ -27,6 +28,7 @@ export function buildNativeChatSessionOptionSnapshot(args: { models: readonly CatalogModel[] record: NativeChatSessionOptionRecord mode: NativeChatSessionOptionMode + liveTransport: NativeChatLiveOptionTransport }): SessionOptionDescriptor[] { return buildSharedSnapshot({ ...args, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 5bea1af8c50..4f8c37116af 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -136,6 +136,11 @@ export function useStructuredAgentSession(args: { [sessionId, target] ) + // Turns are what confirm an option: the provider names the model it is running + // on the frame that opens each one, so re-read the options as a turn changes + // rather than leaving the last write unconfirmed for the life of the session. + const turnId = activeStructuredAgentSessionTurnId(state.items) + useEffect(() => { if (!isVisible || !optionCatalog) { return @@ -157,7 +162,7 @@ export function useStructuredAgentSession(args: { return () => { stale = true } - }, [isVisible, optionCatalog, sessionId, state.fence, target]) + }, [isVisible, optionCatalog, sessionId, state.fence, target, turnId]) const optionSnapshot = useMemo( () => structuredAgentSessionOptionSnapshot(optionState), @@ -219,7 +224,6 @@ export function useStructuredAgentSession(args: { (item.body.kind === 'approval' || item.body.kind === 'question') && item.body.resolution.state === 'pending' ) - const turnId = activeStructuredAgentSessionTurnId(state.items) return { messages: projectStructuredAgentSessionMessages( state.items, diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index b421b77bfc2..ba8e1518921 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -258,8 +258,12 @@ describe('ExperimentalPane', () => { }) expect(container.textContent).toContain('Use updated structured native chat') + // The one opt-in gates both providers, so its copy must not name only Codex. expect(container.textContent).toContain( - 'Local macOS and Linux sessions only for now. Windows, WSL, and remote execution hosts (including SSH) continue to use terminal chat.' + 'Opt in to the host-owned structured chat runtime for Codex and Claude.' + ) + expect(container.textContent).toContain( + 'Local sessions only for now. WSL and remote execution hosts (including SSH) continue to use terminal chat, and Windows falls back to it unless Orca can read process start times.' ) expect(container.textContent).toContain('Default view') root.unmount() diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index 93c4b1c899d..85d27dae2e3 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -126,13 +126,13 @@ export function NativeChatExperimentalSetting({

{translate( 'auto.components.settings.ExperimentalPane.nativeChat.structuredCopy', - 'Opt in to the host-owned structured Codex runtime. Off keeps the existing terminal-backed chat path.' + 'Opt in to the host-owned structured chat runtime for Codex and Claude. Off keeps the existing terminal-backed chat path.' )}

{translate( 'auto.components.settings.ExperimentalPane.nativeChat.structuredScope', - 'Local macOS and Linux sessions only for now. Windows, WSL, and remote execution hosts (including SSH) continue to use terminal chat.' + 'Local sessions only for now. WSL and remote execution hosts (including SSH) continue to use terminal chat, and Windows falls back to it unless Orca can read process start times.' )}

diff --git a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx index 5709dc87f90..13f3a0c883d 100644 --- a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx @@ -17,8 +17,9 @@ import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' import { translate } from '@/i18n/i18n' import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert' import { worktreeRefreshOptions } from './add-repo-runtime-owner' -import { startStructuredCodexLaunch } from '@/lib/structured-agent-session-launch' -import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-codex-session' +import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' const NonGitFolderDialog = React.memo(function NonGitFolderDialog() { const activeModal = useAppStore((s) => s.activeModal) @@ -101,8 +102,11 @@ const NonGitFolderDialog = React.memo(function NonGitFolderDialog() { ...(launch.startup ? { startup: launch.startup } : {}), ...(launch.route === 'structured-native-chat' ? { providesInitialSurface: true } : {}) }) - if (launch.route === 'structured-native-chat' && launch.agent === 'codex') { - const structured = startStructuredCodexLaunch(folderWorktree.id) + if ( + launch.route === 'structured-native-chat' && + isAgentSessionHandleProvider(launch.agent) + ) { + const structured = startStructuredAgentLaunch(folderWorktree.id, launch.agent) const fallback = structured.claimDefinitiveRefusalFallback(() => { activateAndRevealWorktree(folderWorktree.id, { sidebarRevealBehavior: 'auto', diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 661bf623880..b6f1a1654f3 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -28,8 +28,9 @@ import { resolveAgentLaunchRoute } from '@/lib/agent-launch-routing' import { readLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities' -import { startStructuredCodexLaunch } from '@/lib/structured-agent-session-launch' -import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-codex-session' +import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' import { useAppStore } from '@/store' import { buildFolderWorkspaceLinkedStartupPlan, @@ -232,8 +233,8 @@ export async function submitFolderWorkspaceCreate({ runtimeEnvironmentId }) let structuredLaunchAccepted = structuredLaunch - if (structuredLaunch && quickAgent === 'codex') { - const launch = startStructuredCodexLaunch(folderWorkspaceKey(workspace.id), { + if (structuredLaunch && isAgentSessionHandleProvider(quickAgent)) { + const launch = startStructuredAgentLaunch(folderWorkspaceKey(workspace.id), quickAgent, { prompt: launchDraftPrompt ?? note }) const refusalFallback = launch.claimDefinitiveRefusalFallback(async () => { diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 85d978e3bf6..6d5b523c2af 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -8,6 +8,7 @@ import { useAgentDetectionTargetForWorktree } from '@/hooks/useAgentDetectionTar import { useDetectedAgents } from '@/hooks/useDetectedAgents' import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' import type { TuiAgent } from '../../../../shared/tui-agent' import type { LaunchSource } from '../../../../shared/telemetry-events' import { @@ -15,7 +16,7 @@ import { filterEnabledTuiAgents } from '../../../../shared/tui-agent-selection' import { translate } from '@/i18n/i18n' -import { useStructuredCodexLaunchStatus } from '@/lib/structured-agent-session-launch' +import { useStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch' export type QuickLaunchAgentMenuItemsProps = { worktreeId: string @@ -117,7 +118,12 @@ function QuickLaunchAgentMenuItemsInner({ const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent') - const structuredCodexLaunchStatus = useStructuredCodexLaunchStatus(worktreeId) + // One hook per structured provider: the launch registry is keyed by agent, and hooks cannot run + // inside the agent list's render loop. + const structuredLaunchStatusByAgent = { + claude: useStructuredAgentLaunchStatus(worktreeId, 'claude'), + codex: useStructuredAgentLaunchStatus(worktreeId, 'codex') + } const openAgentSettings = useCallback(() => { openSettingsTarget({ pane: 'agents', repoId: null }) @@ -199,26 +205,33 @@ function QuickLaunchAgentMenuItemsInner({ {agents.map((agent) => { const entry = getCatalogEntry(agent) const label = entry?.label ?? agent - const isStructuredCodexPending = - agent === 'codex' && structuredCodexLaunchStatus === 'pending' - const menuLabel = isStructuredCodexPending ? 'Starting Codex chat…' : label + const isStructuredLaunchPending = + isAgentSessionHandleProvider(agent) && structuredLaunchStatusByAgent[agent] === 'pending' + const pendingLabel = translate( + 'components.native-chat.structuredSessionLaunchPending', + 'Starting {{value0}} chat…', + { value0: label } + ) + const menuLabel = isStructuredLaunchPending ? pendingLabel : label const showsDefaultAgentShortcut = newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent return ( runLaunch(agent)} className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" - title={translate( - 'auto.components.tab.bar.QuickLaunchButton.ec2adf093e', - isStructuredCodexPending - ? 'Starting Codex chat…' - : 'Launch {{value0}} in a new terminal', - isStructuredCodexPending ? undefined : { value0: label } - )} + title={ + isStructuredLaunchPending + ? pendingLabel + : translate( + 'auto.components.tab.bar.QuickLaunchButton.ec2adf093e', + 'Launch {{value0}} in a new terminal', + { value0: label } + ) + } > - {isStructuredCodexPending ? ( + {isStructuredLaunchPending ? (