From 2dc9f1506a2be73ae2358b260916321c17f0bfaa Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sun, 6 Sep 2026 04:06:09 -0400 Subject: [PATCH 1/5] feat: adopt personal provider sessions Adopt eligible personal Codex and Claude sessions, including exact Codex Desktop heartbeat targets, with native session authority, approvals, autorespond, privacy fencing, recovery, and acceptance coverage. --- CHANGELOG.md | 21 +- PRIVACY.md | 4 +- README.md | 27 +- app/AGENTS.md | 1 + app/src/data/registry.test.ts | 49 + app/src/hra/cloud.ts | 2 + app/src/model/scheduled-tasks.test.ts | 11 +- app/src/model/settings-view.test.ts | 38 +- app/src/model/settings-view.ts | 27 +- .../screens/notification-hours-form.test.tsx | 1 + .../screens/settings-screen.devin.test.tsx | 8 + app/src/screens/settings-screen.tsx | 35 + docs/beta-release-notes.md | 14 +- docs/providers/claude.md | 6 +- docs/session-adoption.md | 128 + kb/index.md | 3 + kb/notes/codex-schedules.md | 119 +- kb/plans/AGENTS.md | 1 + kb/plans/session-adoption.md | 247 + scripts/live-acceptance-installation.ts | 7 +- scripts/live-acceptance-worker.ts | 149 +- scripts/live-acceptance.test.ts | 425 + scripts/live-acceptance.ts | 71 +- scripts/security-primitives.json | 20 +- site/content.test.ts | 7 + site/content.ts | 43 +- src/claude/AGENTS.md | 4 +- src/claude/account.test.ts | 222 + src/claude/account.ts | 324 + src/claude/assembler.ts | 4 + src/claude/client.test.ts | 179 +- src/claude/client.ts | 192 +- src/claude/index.ts | 20 + src/claude/process.test.ts | 254 + src/claude/process.ts | 468 +- src/claude/runtime.test.ts | 52 + src/claude/runtime.ts | 45 +- src/cli.test.ts | 566 +- src/cli.ts | 361 +- src/cli/parser.test.ts | 52 + src/cli/parser.ts | 58 + src/cli/render.test.ts | 368 +- src/cli/render.ts | 340 +- src/cloud/AGENTS.md | 2 +- src/cloud/daemon-adapters.test.ts | 761 +- src/cloud/daemon-adapters.ts | 242 +- src/cloud/payloads.test.ts | 108 +- src/cloud/payloads.ts | 82 +- src/codex/automations.test.ts | 916 +- src/codex/automations.ts | 918 +- src/codex/client.test.ts | 559 + src/codex/client.ts | 508 +- src/codex/index.ts | 12 +- src/codex/protocol.test.ts | 53 + src/codex/protocol.ts | 31 +- src/codex/runtime.test.ts | 63 + src/codex/runtime.ts | 3 + src/daemon/claude-runtime-adapter.test.ts | 798 +- src/daemon/claude-runtime-adapter.ts | 540 +- src/daemon/claude-session-facts.ts | 8 + src/daemon/claude-session.test.ts | 63 +- src/daemon/codex-runtime-adapter.test.ts | 1882 +- src/daemon/codex-runtime-adapter.ts | 794 +- src/daemon/local-transport.test.ts | 58 + src/daemon/personal-session-discovery.test.ts | 1664 ++ src/daemon/personal-session-discovery.ts | 1495 ++ src/daemon/ports.ts | 124 +- src/daemon/provider-switch.test.ts | 1253 +- src/daemon/service.test.ts | 8203 +++++++- src/daemon/service.ts | 16522 +++++++++++----- src/daemon/session-event-cursor.test.ts | 139 +- src/daemon/session-event-cursor.ts | 132 +- src/daemon/streaming-redaction.test.ts | 62 + src/daemon/streaming-redaction.ts | 56 +- src/daemon/work-service.test.ts | 2 + src/domain/codex-heartbeat-envelope.ts | 34 + src/domain/contracts.ts | 17 +- src/domain/presets.ts | 4 + src/domain/runtime-profile.test.ts | 100 + src/domain/runtime-profile.ts | 97 +- src/install-normalizer.ts | 2 +- src/install-preflight-runtime.ts | 4 +- src/install-preflight.ts | 2 +- src/installation.test.ts | 14 +- src/installation.ts | 9 +- src/storage/paths.ts | 20 + src/storage/session-task-store.test.ts | 245 +- src/storage/session-task-store.ts | 65 +- src/storage/state-store.test.ts | 8571 +++++++- src/storage/state-store.ts | 13832 +++++++++---- src/storage/work-store.test.ts | 676 +- src/storage/work-store.ts | 583 +- 92 files changed, 56436 insertions(+), 10865 deletions(-) create mode 100644 docs/session-adoption.md create mode 100644 kb/plans/session-adoption.md create mode 100644 src/claude/account.test.ts create mode 100644 src/claude/account.ts create mode 100644 src/claude/process.test.ts create mode 100644 src/daemon/personal-session-discovery.test.ts create mode 100644 src/daemon/personal-session-discovery.ts create mode 100644 src/domain/codex-heartbeat-envelope.ts create mode 100644 src/domain/runtime-profile.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ce16ad..46f70d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,25 +4,36 @@ Every entry names the release or the plan wave it belongs to. Unreleased work si ## v0.6.0 -HRA Web v1 wave 5: session portability. HRA owns a provider-neutral transcript, a session can move between Codex and Claude Code while it runs on Linux, and a session can be exported in the letta-ai trajectory v1 shape. New Claude provider effects fail closed on macOS until an authenticated isolated-Keychain and detached-read acceptance passes there. +HRA Web v1 wave 5: session portability. HRA owns a provider-neutral transcript, a session can move among Codex, Claude Code, and Devin when the selected providers support the platform, and a session can be exported in the letta-ai trajectory v1 shape. New Claude provider effects fail closed on macOS until an authenticated isolated-Keychain and detached-read acceptance passes there. - `auto:workspace` now fails closed for every command and permission grant until a provider adapter can attest complete private authority against the bound project root; sanitised command classes and category labels are never treated as authority. File-change approvals likewise remain pending in every mode while exact affected paths are unavailable, so autorespond makes no provider call it cannot review. Notification hours remain notification-only and do not widen approval categories or budgets. - Codex `high` and the default `ultra` preset now resolve to Astra at `max` and `ultra` reasoning. New HRA sessions and explicit preset selections use that mapping, while a versioned session and Work binding keeps pre-cutover and provider-imported Sol sessions on their exact established runtime through metadata changes, queued work, and crash recovery. Historical routing-evaluation schema 1 remains byte-stable with its Sol baseline; schema 2 admits Astra without relabelling old evidence. -- Devin is now a full local provider through the official CLI's ACP v1 server. HRA admits exact Devin CLI `3000.6.14`, launches every session as `devin acp --model gpt-6-astra`, defaults Devin to the new `astra` preset, supports foreground provider-owned sign-in, new and capability-gated resumed sessions, turns, stop, one-time permission decisions, provider switching, and provider-supplied context and optional cumulative-cost facts on macOS and Linux. Each account gets distinct private HOME and XDG roots; HRA never reads the provider credential and reports only signed-in readiness. ACP thought chunks are dropped as raw reasoning, Fast and active-turn steering are refused, and account allowance remains explicitly unknown because the documented machine-readable surfaces expose no quota or reset operation. Store schema v39 adds Devin authority without rewriting the legacy provider column and refuses a Devin session under the pre-Astra preset contract. -- Claude Code sessions now run end to end. Durable session-start, turn-start, and queue-dispatch evidence carries either provider's reviewed profile, and the daemon routes session start, turn start, steering, interrupt, and interaction resolution to the port the session binds. Claude Code facts reduce to the same provider-neutral timeline as Codex facts. Provider-side session listing, renaming, resume, and protected turn inspection remain unavailable for Claude Code, and a Claude session cannot survive the daemon that started it. +- Devin is now a full local session provider through the official CLI's ACP v1 server. HRA admits exact Devin CLI `3000.6.14`, launches every session as `devin acp --model gpt-6-astra`, defaults Devin to the new `astra` preset, supports foreground provider-owned sign-in, new and capability-gated resumed sessions, turns, stop, one-time permission decisions, provider switching, and provider-supplied context and optional cumulative-cost facts on macOS and Linux. Each account gets distinct private HOME and XDG roots; HRA never reads the provider credential and reports only signed-in readiness. ACP thought chunks are dropped as raw reasoning, Fast and active-turn steering are refused, and account allowance remains explicitly unknown because the documented machine-readable surfaces expose no quota or reset operation. Devin sessions can participate in Work coordination and provider-neutral signals, but cannot own or execute a Work attempt; Work attempt routes remain Codex-only. Store schema v39 adds Devin authority without rewriting the legacy provider column and refuses a Devin session under the pre-Astra preset contract. +- Claude Code sessions now run end to end. Durable session-start, turn-start, and queue-dispatch evidence carries either provider's reviewed profile, and the daemon routes session start, turn start, steering, interrupt, and interaction resolution to the port the session binds. Claude Code facts reduce to the same provider-neutral timeline as Codex facts. Claude Code exposes no admitted provider-side session listing or read-only observation, provider-side rename, or protected turn inspection. HRA can recover an exact Claude conversation with `--resume` only after prior-process exit or an already-completed exact process release is proven. Ambiguous custody stays fenced in recovery and never triggers a speculative resume. - Remote interaction authority is tightened behind nested policy version 2. Another device may decline an offered command, permission, or file-change request, while `answer` is limited to complete non-secret closed-choice user questions whose provider adapter proves lossless metadata and exact response translation. Every grant or accept, cancel, session scope, free-text or Other response, and MCP answer stays on the execution machine; nested policy version 1 and unknown policies expose no control. - HRA owns the conversation. A `user_message` session event records exactly what HRA sent and the actor that authored it (`human`, `autorespond`, or `provider_switch`), written after the provider accepted it and bounded with an exact omitted-character count. `item_started` and `item_completed` gain a stable opaque `callId`, so a tool result links to its call, and a bounded `summary` assembled only from labels the protocol layer had already reduced: the item kind, the MCP server and tool names, and the closed-vocabulary command class. Raw tool arguments and raw tool output are still never stored. - `src/domain/transcript.ts` reads those events back into an ordered, bounded, paged, provider-neutral conversation with a digest over its canonical serialization. It asks no provider, so a session whose provider thread is gone still has a readable conversation, and it is the single artifact the switch and the export both consume. -- `hra session switch --provider codex|claude [--preset ] [--account ]` moves a live session through an evidence-first crash protocol: immutable evidence fences both account generations and the admitting daemon generation; HRA starts and receipts the target; persists the exact seed intent before its one provider effect and the returned turn after it; releases and receipts the source; then atomically commits the provider, account, preset, provider thread, reviewed runtime profiles, conversation-automation row, `provider_switched` boundary, seed `user_message`, and replay receipt. Store schema v35 adds the append-only target, seed-intent, seed-result, source-release, target-release, and authority-rebind records. Provider-switch effect admission and Work claiming now form a bidirectional SQLite interlock, so an existing worker refuses the switch before target start and a begun switch refuses a new claim until it settles. Recovery advances only from durable evidence and complete provider projections, never repeats an unproven target-start or seed effect, and preserves a seeded target if source release is uncertain. Because Claude session custody is process-local, a daemon-generation change with an unreleased Claude side makes automatic recovery refuse with `RECOVERY_REQUIRED` and no provider effects; explicit abandonment terminalizes with provider-state-unknown evidence and touches only addressable non-Claude sides. A durable Claude source-release receipt still permits adoption of a seeded Codex target, while durable rows alone never claim a seeded Claude target remains live after restart. A switch during an active turn, on a quarantined or terminal session, or to a preset the target cannot run is refused with no effect. A target that refuses to start leaves the session exactly where it was. A switch into Claude is supported only when the custodian daemon runs on Linux; macOS refuses before starting the target. +- `hra session switch --provider codex|claude|devin [--preset ] [--account ]` moves a live session through an evidence-first crash protocol: immutable evidence fences both account generations and the admitting daemon generation; HRA starts and receipts the target; persists the exact seed intent before its one provider effect and the returned turn after it; releases and receipts the source; then atomically commits the provider, account, preset, provider thread, reviewed runtime profiles, conversation-automation row, `provider_switched` boundary, seed `user_message`, and replay receipt. Store schema v35 adds the append-only target, seed-intent, seed-result, source-release, target-release, and authority-rebind records. Provider-switch effect admission and Work claiming now form a bidirectional SQLite interlock, so an existing worker refuses the switch before target start and a begun switch refuses a new claim until it settles. Recovery advances only from durable evidence and complete provider projections, never repeats an unproven target-start or seed effect, and preserves a seeded target if source release is uncertain. Because Claude session custody is process-local, a daemon-generation change with an unreleased Claude side makes automatic recovery refuse with `RECOVERY_REQUIRED` and no provider effects; explicit abandonment terminalizes with provider-state-unknown evidence and touches only addressable non-Claude sides. A durable Claude source-release receipt still permits adoption of a seeded Codex or Devin target, while durable rows alone never claim a seeded Claude target remains live after restart. A switch during an active turn, on a quarantined or terminal session, or to a preset the target cannot run is refused with no effect. A target that refuses to start leaves the session exactly where it was. A switch into Claude is supported only when the custodian daemon runs on Linux; macOS refuses before starting the target. - `set_provider {provider, preset?}` joins `set_model` in the hosted command union and runs on the ordinary execution path under the same lease as a turn, because a provider switch is a provider effect rather than local state. It carries no account: account selection stays on the machine that holds the credentials. `hra remote provider` is the CLI form. - `hra session export [--format trajectory|json] [--out ]` writes the neutral transcript, by default in the letta-ai trajectory v1 shape. That format is import-oriented upstream, so HRA emits it rather than depending on it, and a tool call's `arguments` is a stringified object stating that HRA retained none rather than inventing input. - A session's account is no longer fixed for life, so session-event continuity follows the account across a switch instead of failing closed on it. One event page still never mixes two account identities. - `docs/providers/portability.md` states what a switch preserves, what it cannot (the provider's hidden state, its native thread, cached context), and the seeding rule. -- Public README, site, social-card, and `llms.txt` copy now describe Codex and Claude Code together, with provider-aware commands and the provider-specific authentication, account, usage, plugin, desktop, and resume boundaries kept adjacent. Documentation commands use the shared design-system syntax highlighter, inline-code styling no longer collides with classified marketing code, and dark-mode code blocks have their own high-contrast semantic roles. +- Public README, site, social-card, and `llms.txt` copy now describe Codex, Claude Code, and Devin together, with provider-aware commands and the provider-specific authentication, account, usage, plugin, desktop, resume, adoption, and Work-execution boundaries kept adjacent. Documentation commands use the shared design-system syntax highlighter, inline-code styling no longer collides with classified marketing code, and dark-mode code blocks have their own high-contrast semantic roles. - Codex account linking from the web now negotiates app-server's device-code mode with a versioned request. The machine requires the bounded HTTPS verification URL and separate user code together, account-key encrypts the complete handoff, and returns it to the requesting browser through the existing single-use result. The hosted settlement time owns its five-minute deadline, clears expired ciphertext without release, and makes machine clock skew irrelevant. The browser displays the code for manual selection before opening the provider URL, does not write it to browser clipboard or persistence APIs, locks that account row until the result is consumed, expires, or terminalizes without a handoff, and never lets a later action supersede an outstanding result. Once safely consumed and displayed, the code clears on expiry or a later action. HRA never relays a loopback browser-mode callback. Older daemons reject the versioned request before starting login, while current daemons preserve the legacy unversioned behavior for old clients. - Claude authentication is now a real provider-owned foreground path on Linux. `hra account login --provider claude` runs a realpath-resolved Claude executable after its exact self-reported version matches HRA's compatibility pin inside that profile's isolated `CLAUDE_CONFIG_DIR`; the version assertion does not authenticate the executable's package bytes. HRA observes terminal cancellation, joins the child, and then asks Claude for status before reporting success. `hra account show --provider claude [--json]` runs the bounded status probe and projects only `signedIn`. A durable one-child grant survives daemon restart; exact completion can settle it later, while an operator who has confirmed the original child exited can use the status-provided acknowledged `login-cancel` recovery command to release only the local fence. When the account is signed out, login locally releases and terminalizes only quiescent idle Claude sessions under the same profile; it does not delete their provider threads. Active turns, queued work, pending interactions, recovery, and unsettled provider authority still refuse login. HRA never opens or copies a Claude credential, and Claude exposes no HRA device-code, handoff-file, web-login, or ordinary background cancellation flow. macOS refuses new Claude provider effects pending authenticated isolated-Keychain and detached-read acceptance. - On Linux, new Claude sessions and provider switches prove Claude's own sign-in status immediately before durable effect admission. Codex continues to use its existing durable profile state. A Claude proof cannot bypass Codex admission, Codex state does not stand in for Claude authentication, and established Claude sessions are not blocked merely because the same profile's Codex account is signed out. +Personal-home session adoption: + +- `hra session adoption enable --provider codex|claude` opts one signed-in HRA account into bounded discovery from the user's ordinary provider home. This command family is closed to Codex and Claude Code; Devin has no personal-home adoption surface. Policy is off by default, candidates and provenance remain private local authority, and disabling discovery never silently detaches an already admitted session. +- Codex admission reads a bounded app-server session page and uses the accepted inactivity inference: active or recently updated rows remain pending, while an idle row older than ten minutes but no older than fifteen minutes may be resumed and rechecked. Resume proves the exact thread and HRA connection, not a provider-wide exclusive lease. +- A present valid Codex Desktop heartbeat automation also makes its exact target thread discoverable outside the fifteen-minute window. Active and paused records both qualify until deletion or retargeting. HRA locally parses each bounded automation TOML record but selects and retains only authority fields; prompt and working-directory fields are ignored and never logged, returned, or projected. HRA rechecks the association around claim; it waives only age, never account, project, liveness, quiescence, or exact-thread proof. Bounded schedule discovery keeps a live cursor and rotates its starting page across daemon generations so a restart does not pin every scan to the first records. Desktop automation data remains private and never becomes an HRA schedule, a synced scheduled-task row, or a public origin marker. If Desktop's exact provider-generated heartbeat envelope later appears in the thread, HRA replaces the whole envelope, including an exact assistant or reasoning-summary echo, with generic `[protected]` text before projecting messages, details, titles, live deltas, or name facts; its automation ID, firing time, and instructions are never uploaded. +- Claude admission reads only bounded, allowlisted registry scalars for the exact pinned version and retains the exact PID domain, PID, and process-start identity privately. The old process must be proven dead before HRA launches `--resume` for that exact session ID; incomplete discovery or uncertain liveness stays pending. +- Once admitted, a conversation is an ordinary HRA session. It has the same public shape, send/queue/steer/stop surface, scheduled work, autorespond defaults and overrides, and one-shot approval authority as an HRA-created session. There is no adopted badge or reduced-capability tier in the CLI, encrypted session projection, or browser grid. +- Personal Codex and Claude runtimes are separate from managed account runtimes and use the current user's canonical provider homes without copying or parsing credentials. Personal-home Claude admission transiently reads bounded account, email, and organization identity metadata and retains only a one-way local authority key. Raw identity fields, adoption candidate identities and records, runtime bindings, process identities, schedule-source metadata, provider-home provenance, and provider-account authority hashes are never publicly returned, projected, or uploaded; optional cloud sync carries only the encrypted Codex and Claude Code provider-level enablement and bounded pending, adopted, and fenced counts. Live acceptance injects fixture-owned personal homes, so validation cannot read or mutate the operator's real provider state. Account-generation, sign-out, provider-account replacement, provider-switch, delayed-disconnect, and daemon-restart fences retain exact controller authority and durably recover incomplete release. Account loss moves affected HRA-created and adopted sessions through the same visible fail-closed recovery; no origin-specific session command is required for login, logout, or replacement. +- A completed personal-home Codex account revocation remains fenced until daemon restart creates a fresh runtime generation; status reports that requirement and re-enable fails closed instead of relaunching through an ordinary account read. `docs/session-adoption.md` documents the liveness limits, restart fence, and residual external-resume race. +- The v0.5-to-v0.6 migration does not infer missing provider-account authority. Every nonterminal v0.5 session without immutable proof enters `recovery_required`: pending or prepared effects are cancelled, begun effects remain uncertain, scheduled work pauses, pending interactions expire while begun responses become resolution-unknown, and associated Work execution is retired or fenced. Provider threads and local records are not deleted. This is not a generic automatic-recovery state; inspect the session and use explicit abandonment only when accepting terminalization and provider-state uncertainty. + ## v0.5.0 HRA Web v1 wave 3: interaction detail, wider remote decisions, subagent activity, the Claude Code provider seam, and device commands. Admitted by the immutable release workflow on 2026-09-04. diff --git a/PRIVACY.md b/PRIVACY.md index 4eeb0f1..bd4b7dc 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -8,7 +8,8 @@ Cloud sync is optional. Local provider profiles, Codex credentials, Claude Code - User messages and final assistant display text. - Session names, notes, queued messages, and steering input. -- Codex account labels and observed provider email and plan metadata when cloud sync is enabled. Claude Code account identity and usage are not projected. HRA validates one bounded Claude Code authentication-status response transiently, reduces it to signedIn, and never retains, returns, projects, or uploads the identity or usage fields; it never opens or parses a Claude credential file. Devin account identity and allowance are not projected. HRA reports only local signed-in readiness and records provider-supplied session context and cost facts in the neutral session stream. +- Codex account labels and observed provider email and plan metadata when cloud sync is enabled. Claude Code account identity and usage are not projected. For managed profiles, HRA validates one bounded Claude Code authentication-status response transiently, reduces it to signedIn, and never retains, returns, projects, or uploads the identity or usage fields. Personal-home Claude adoption transiently reads bounded account, email, and organization identity metadata and retains only a one-way local authority key. Raw Claude identity fields and that private authority key are never publicly returned, projected, or uploaded; HRA never opens or parses a Claude credential file. Devin account identity and allowance are not projected. HRA reports only local signed-in readiness and records provider-supplied session context and cost facts in the neutral session stream. +- Codex and Claude Code personal-session adoption status: whether discovery is enabled and bounded pending, adopted, and fenced counts. Candidate identities and records are never included. Devin has no personal-home adoption surface. - Turn timing, observed model and tier, and provider usage summaries. - Bounded observed file and Git metadata, without unbounded filesystem paths. - Observation-only interaction IDs, kinds, states, revisions, blocking status, and bounded safe summaries. @@ -19,6 +20,7 @@ Cloud sync is optional. Local provider profiles, Codex credentials, Claude Code - Codex, Claude Code, or Devin credentials; provider profile or configuration files; plugin credentials; OAuth access or refresh tokens; authorization codes; PKCE verifiers; provider cookies; or the private device code. - Raw Codex app-server, Claude Code stream, or Devin ACP requests or responses. +- Personal-home adoption candidate identities or records, personal-runtime bindings, process identities, schedule-source metadata, provider-home provenance, provider-account authority hashes, or the automation id, firing time, and instructions from an exact Codex Desktop heartbeat envelope. Such an envelope is replaced with generic protected text before session content is projected. - Raw reasoning, hidden chain of thought, or approval secrets. - Provider-internal login and request IDs, permission values, MCP field contracts, protected answers, or response digests. - Environment variables, arbitrary command output, or unbounded filesystem paths. diff --git a/README.md b/README.md index 71c085d..78bd739 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ HRA runs Codex, Claude Code, and Devin sessions side by side, keeps them alive i Status: public beta. The local CLI v0.6.0 is release-ready; current source runs Codex and Devin on macOS and Linux and Claude Code on Linux; hosted sync is live as an open beta. ```sh -test "$(curl -fsSL --connect-timeout 10 --max-time 60 --retry 3 --retry-delay 1 --retry-max-time 60 --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/hraness/hra/v0.6.0/src/install-preflight-runtime.ts | bun -e 'const[a,h]=process.argv.slice(1);const b=await Bun.stdin.bytes();const d=new Bun.CryptoHasher("sha256").update(b).digest("hex");if(d!==h)throw new Error("The tagged HRA preflight digest is invalid.");const j=new Bun.Transpiler({loader:"ts",target:"bun"}).transformSync(b);const u=URL.createObjectURL(new Blob([j],{type:"text/javascript"}));try{const m=await import(u);await m.installHraRelease(a);process.stdout.write(`${m.HRA_INSTALL_SUCCESS}\n`);}finally{URL.revokeObjectURL(u)}' -- https://github.com/hraness/hra/releases/download/v0.6.0/hraness-hra-0.6.0.tgz 61dcd716e2272c4c3d94d565ac1a9aab3dc99780813316a822f594938cf456e2)" = hra-install-safe +test "$(curl -fsSL --connect-timeout 10 --max-time 60 --retry 3 --retry-delay 1 --retry-max-time 60 --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/hraness/hra/v0.6.0/src/install-preflight-runtime.ts | bun -e 'const[a,h]=process.argv.slice(1);const b=await Bun.stdin.bytes();const d=new Bun.CryptoHasher("sha256").update(b).digest("hex");if(d!==h)throw new Error("The tagged HRA preflight digest is invalid.");const j=new Bun.Transpiler({loader:"ts",target:"bun"}).transformSync(b);const u=URL.createObjectURL(new Blob([j],{type:"text/javascript"}));try{const m=await import(u);await m.installHraRelease(a);process.stdout.write(`${m.HRA_INSTALL_SUCCESS}\n`);}finally{URL.revokeObjectURL(u)}' -- https://github.com/hraness/hra/releases/download/v0.6.0/hraness-hra-0.6.0.tgz 1941cd63d43d06c29c0d1df8cff90c0a469cb14e3df524f5d537f6b49257eb05)" = hra-install-safe ``` ```sh @@ -43,7 +43,7 @@ HRA requires Bun 1.3.14 plus curl with HTTPS and TLS 1.2 support. The CLI and lo ```text bun --version -test "$(curl -fsSL --connect-timeout 10 --max-time 60 --retry 3 --retry-delay 1 --retry-max-time 60 --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/hraness/hra/v0.6.0/src/install-preflight-runtime.ts | bun -e 'const[a,h]=process.argv.slice(1);const b=await Bun.stdin.bytes();const d=new Bun.CryptoHasher("sha256").update(b).digest("hex");if(d!==h)throw new Error("The tagged HRA preflight digest is invalid.");const j=new Bun.Transpiler({loader:"ts",target:"bun"}).transformSync(b);const u=URL.createObjectURL(new Blob([j],{type:"text/javascript"}));try{const m=await import(u);await m.installHraRelease(a);process.stdout.write(`${m.HRA_INSTALL_SUCCESS}\n`);}finally{URL.revokeObjectURL(u)}' -- https://github.com/hraness/hra/releases/download/v0.6.0/hraness-hra-0.6.0.tgz 61dcd716e2272c4c3d94d565ac1a9aab3dc99780813316a822f594938cf456e2)" = hra-install-safe +test "$(curl -fsSL --connect-timeout 10 --max-time 60 --retry 3 --retry-delay 1 --retry-max-time 60 --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/hraness/hra/v0.6.0/src/install-preflight-runtime.ts | bun -e 'const[a,h]=process.argv.slice(1);const b=await Bun.stdin.bytes();const d=new Bun.CryptoHasher("sha256").update(b).digest("hex");if(d!==h)throw new Error("The tagged HRA preflight digest is invalid.");const j=new Bun.Transpiler({loader:"ts",target:"bun"}).transformSync(b);const u=URL.createObjectURL(new Blob([j],{type:"text/javascript"}));try{const m=await import(u);await m.installHraRelease(a);process.stdout.write(`${m.HRA_INSTALL_SUCCESS}\n`);}finally{URL.revokeObjectURL(u)}' -- https://github.com/hraness/hra/releases/download/v0.6.0/hraness-hra-0.6.0.tgz 1941cd63d43d06c29c0d1df8cff90c0a469cb14e3df524f5d537f6b49257eb05)" = hra-install-safe hra --version hra doctor --offline ``` @@ -55,12 +55,14 @@ Before replacing the installed binary, stop the persistent daemon and confirm th ```text hra daemon stop hra daemon status --json -test "$(curl -fsSL --connect-timeout 10 --max-time 60 --retry 3 --retry-delay 1 --retry-max-time 60 --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/hraness/hra/v0.6.0/src/install-preflight-runtime.ts | bun -e 'const[a,h]=process.argv.slice(1);const b=await Bun.stdin.bytes();const d=new Bun.CryptoHasher("sha256").update(b).digest("hex");if(d!==h)throw new Error("The tagged HRA preflight digest is invalid.");const j=new Bun.Transpiler({loader:"ts",target:"bun"}).transformSync(b);const u=URL.createObjectURL(new Blob([j],{type:"text/javascript"}));try{const m=await import(u);await m.installHraRelease(a);process.stdout.write(`${m.HRA_INSTALL_SUCCESS}\n`);}finally{URL.revokeObjectURL(u)}' -- https://github.com/hraness/hra/releases/download/v0.6.0/hraness-hra-0.6.0.tgz 61dcd716e2272c4c3d94d565ac1a9aab3dc99780813316a822f594938cf456e2)" = hra-install-safe +test "$(curl -fsSL --connect-timeout 10 --max-time 60 --retry 3 --retry-delay 1 --retry-max-time 60 --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/hraness/hra/v0.6.0/src/install-preflight-runtime.ts | bun -e 'const[a,h]=process.argv.slice(1);const b=await Bun.stdin.bytes();const d=new Bun.CryptoHasher("sha256").update(b).digest("hex");if(d!==h)throw new Error("The tagged HRA preflight digest is invalid.");const j=new Bun.Transpiler({loader:"ts",target:"bun"}).transformSync(b);const u=URL.createObjectURL(new Blob([j],{type:"text/javascript"}));try{const m=await import(u);await m.installHraRelease(a);process.stdout.write(`${m.HRA_INSTALL_SUCCESS}\n`);}finally{URL.revokeObjectURL(u)}' -- https://github.com/hraness/hra/releases/download/v0.6.0/hraness-hra-0.6.0.tgz 1941cd63d43d06c29c0d1df8cff90c0a469cb14e3df524f5d537f6b49257eb05)" = hra-install-safe hra --version hra doctor --offline hra daemon start ``` +> **v0.5 upgrade quarantine.** The first daemon start after a v0.5-to-v0.6 upgrade migrates local state but never infers provider-account authority that v0.5 did not record immutably. Every affected nonterminal session enters recovery_required: pending or prepared effects are cancelled, begun effects remain uncertain, scheduled work pauses, pending interactions expire while begun responses become resolution-unknown, and associated Work execution is retired or fenced. Provider threads and local records are not deleted. This is not a generic automatic-recovery state. Inspect the session first; use `hra session abandon ` only when you accept terminalizing HRA's local session with provider state still unknown. + ### Optional full local-data removal Full local-data removal is a separate destructive operation. While HRA remains installed, complete `hra auth delete --acknowledge-erasure` if `hra auth status` says you are signed in, then wait for `hra auth status` to report terminal deletion. Run `hra account list`, then run `hra account logout ` for every Codex profile. HRA does not sign Claude Code out; use Claude Code's own authentication flow inside every isolated `CLAUDE_CONFIG_DIR` whose credential should be removed. Stop the daemon, require a successful `hra daemon status --json` result whose `data.running` is `false` before touching local data. @@ -148,7 +150,7 @@ If the event stream reports a blocking interaction, read its exact ID and revisi ### Claude Code, Devin, and provider switching -Start directly with Claude Code or Devin by selecting its provider and reviewed preset, or move an idle session among providers. A switch preserves HRA's provider-neutral conversation record but starts a fresh provider-native runtime; it refuses an active turn, an unsettled provider effect, an unsigned target profile, or a preset that belongs to another provider. Claude Code sessions cannot be resumed after the daemon that started them exits. Devin uses exact ACP v1 session loading after restart only when its initialization advertised that capability. +Start directly with Claude Code or Devin by selecting its provider and reviewed preset, or move an idle session among providers. A switch preserves HRA's provider-neutral conversation record but starts a fresh provider-native runtime; it refuses an active turn, an unsettled provider effect, an unsigned target profile, or a preset that belongs to another provider. If a Claude controller is no longer available, HRA can recover the exact conversation with `--resume` only after prior-process exit or an already-completed exact process release is proven. Ambiguous custody stays fenced in recovery without launching another process. Devin uses exact ACP v1 session loading after restart only when its initialization advertised that capability. ```text hra session start personal --provider claude --preset fable-max --json @@ -178,6 +180,8 @@ hra session task delete --revision The frozen source contract defines a narrow local coordination kernel for agents operating several already-existing provider sessions. It records six bounded objects: work, tasks, attempts, submissions, reviews, and signals. Codex, Claude Code, and Devin still own their provider-native execution, turns, tools, context, and approvals. HRA does not add a second model loop or a generic executable workflow engine. +A Devin session can participate in Work coordination records and provider-neutral signal delivery, but it cannot own or execute a Work attempt. Work attempt routes remain Codex-only. + ```text hra work protocol [--operation |--type |--topic ] hra work apply --input-stdin @@ -230,7 +234,7 @@ hra sync status Each login reads exactly one JSON document. Request a code for an existing identity with `{"email":"you@example.com"}`, create a new identity with `{"email":"you@example.com","invite":""}`, or verify a requested code with `{"email":"you@example.com","code":"12345678"}`. No other keys or combinations are accepted. A TTY prompt hides the document; agents should pass a private descriptor with `--input-fd `. The document is never an argument. -The CLI stores HRA's revocable device credential, workspace encryption key, and local signing authority as immutable generations below its private state root. Custody directories are current-user-owned mode-0700 directories, values are single-link mode-0600 files, and reads use bounded no-follow descriptors. The detached Bun daemon never opens a Keychain prompt. HRA forces both pinned Codex credential stores to file mode and verifies their effective settings, so Codex credentials remain separately owned by each profile's isolated `CODEX_HOME`. Claude Code receives that profile's isolated `CLAUDE_CONFIG_DIR`; HRA treats the whole directory as Claude's authentication boundary and never reads, copies, or forwards its credentials. Devin receives distinct private `HOME` and four XDG roots; HRA passes those paths to the CLI but never opens, copies, or forwards Devin's credential. Provider-managed credential storage remains owned by the provider runtime. +The CLI stores HRA's revocable device credential, workspace encryption key, and local signing authority as immutable generations below its private state root. Custody directories are current-user-owned mode-0700 directories, values are single-link mode-0600 files, and reads use bounded no-follow descriptors. The detached Bun daemon never opens a Keychain prompt. HRA forces both pinned Codex credential stores to file mode and verifies their effective settings. Managed Codex accounts keep credentials in each profile's isolated `CODEX_HOME`. Claude Code receives that profile's isolated `CLAUDE_CONFIG_DIR`; HRA treats the whole directory as Claude's authentication boundary and never reads, copies, or forwards its credentials. Devin receives distinct private `HOME` and four XDG roots; HRA passes those paths to the CLI but never opens, copies, or forwards Devin's credential. Explicitly adopted Codex and Claude Code personal sessions use credentials already owned by the user's personal provider home without copying or parsing them. Provider-managed credential storage remains owned by the provider runtime. After successful email verification, the daemon automatically registers the current installation before it reads cloud data. The first registered device becomes active and creates the client-side encryption key. A later verified installation is registered as pending and may report presence, but it has no synchronized data, execution, or key authority. @@ -260,7 +264,8 @@ Cloud-account erasure is an explicit and irreversible fallback, not the default - Isolated provider profiles: each named profile has its own user-only `CODEX_HOME` for Codex and `CLAUDE_CONFIG_DIR` for Claude Code, plus distinct private `HOME` and XDG roots for Devin. Each provider owns its authentication state; HRA never copies or parses provider credentials. - Usage with provenance: account identity, quota, rate-limit, and token snapshots include their provider source time and freshness. A bounded source-ordered 24-hour ledger supports safe human and JSON pagination without returning raw provider payloads. - Compact sessions: list sessions, read user and final assistant messages, inspect elapsed time plus bounded observed file and Git actions, then open one turn for full provider-visible detail. -- Durable controls: send, queue, steer, stop, rename, and keep one editable note per session. Provider and desktop effects use exact authority, idempotency keys, and process-generation fencing. +- Personal-home adoption: opt in to discover recent Codex and Claude Code sessions, plus older Codex threads targeted by present Desktop heartbeat automations, then admit them after bounded account, project, liveness, and exact-resume checks. Active and paused automation records both count until deletion or retargeting. HRA locally parses a bounded automation record but ignores and retains no prompt or working-directory field, keeps later records reachable across daemon restarts, and replaces Desktop's exact fired heartbeat envelope with generic protected text before projection. Account-filtered session lists include admitted rows, which use the same provider-supported public commands, autorespond policy, and approval authority as every HRA session. Provider-specific limits are identical for native and adopted sessions, and provider APIs do not supply a global lease against every later external resume. Read [the session-adoption guide](https://github.com/hraness/hra/blob/main/docs/session-adoption.md). +- Durable controls: send, queue, steer, and stop through either provider; rename Codex sessions; and keep one editable note per session. Provider and desktop effects use exact authority, idempotency keys, and process-generation fencing. - Named projects: a project is a canonical directory that may contain several repositories. Changing it affects future turns only. - Agent work coordination: the frozen beta contract specifies bounded local task graphs, fenced attempts, structured submissions, independent reviews, signals, and a resumable work event stream for exact existing sessions. - Optional encrypted sync: paired devices share a bounded session projection and submit commands to the one machine holding the execution lease. @@ -280,7 +285,7 @@ hra status --json ### Session observation -`hra session status --json` returns status version 2. HRA produces one typed provider-observation result, attempting the bound provider's reviewed observation path only when the current local state makes one applicable, then reads the session, event cut, interactions, and queue from one local SQLite transaction. Codex supports a native app-server observation read. Claude Code and Devin status use HRA's live provider-neutral projection; Claude exposes no admitted provider-side session listing or resume, while Devin reloads an exact native session only when its ACP initialization advertises that capability. Execution, attention, provider, and queue remain separate axes, so a headline state cannot hide a recovery condition, pending interaction, response in flight, or queued work. Pending and response-in-flight counts are exact. The result includes at most 10 bounded safe summaries for pending interactions and excludes the session note and private provider thread binding. Every provider turn and item identifier becomes a secret-keyed opaque public alias before status, event, or interaction output. Public observation schemas accept only that exact alias form. The same local installation key keeps aliases coherent across surfaces and daemon restarts without making low-entropy provider IDs guessable from public output. If an existing installation loses that key, HRA refuses to replace it and directs the operator to restore the original local secret. +`hra session status --json` returns status version 2. HRA produces one typed provider-observation result, attempting the bound provider's reviewed observation path only when the current local state makes one applicable, then reads the session, event cut, interactions, and queue from one local SQLite transaction. Codex supports a native app-server observation read. Claude Code uses its live provider-neutral projection while the exact controller is present. If that controller is absent, HRA may establish `--resume` for the exact conversation only after prior-process exit or an already-completed exact process release is proven; ambiguous custody fails closed as recovery required. Devin status also uses HRA's live provider-neutral projection and reloads an exact native session only when its ACP initialization advertised that capability. Execution, attention, provider, and queue remain separate axes, so a headline state cannot hide a recovery condition, pending interaction, response in flight, or queued work. Pending and response-in-flight counts are exact. The result includes at most 10 bounded safe summaries for pending interactions and excludes the session note and private provider thread binding. Every provider turn and item identifier becomes a secret-keyed opaque public alias before status, event, or interaction output. Public observation schemas accept only that exact alias form. The same local installation key keeps aliases coherent across surfaces and daemon restarts without making low-entropy provider IDs guessable from public output. If an existing installation loses that key, HRA refuses to replace it and directs the operator to restore the original local secret. `hra session state --json` returns the daemon's latest classification of who must act next: working, needs approval, needs an answer, needs a human action, done, done with followups, done with caveats, or aborted, with an attention flag, a short reason, and a monotonic revision. The daemon classifies the final assistant text of every completed turn with ordered lexical rules in which human-action cues beat approval cues, so a login or a code from email never reads as consent, and it reclassifies when a provider interaction is requested or resolved. The same classification is appended to the session event stream as a `session_state` event. @@ -398,7 +403,8 @@ Cloud sync is optional. Local provider profiles, Codex credentials, Claude Code - User messages and final assistant display text. - Session names, notes, queued messages, and steering input. -- Codex account labels and observed provider email and plan metadata when cloud sync is enabled. Claude Code account identity and usage are not projected. HRA validates one bounded Claude Code authentication-status response transiently, reduces it to signedIn, and never retains, returns, projects, or uploads the identity or usage fields; it never opens or parses a Claude credential file. Devin account identity and allowance are not projected. HRA reports only local signed-in readiness and records provider-supplied session context and cost facts in the neutral session stream. +- Codex account labels and observed provider email and plan metadata when cloud sync is enabled. Claude Code account identity and usage are not projected. For managed profiles, HRA validates one bounded Claude Code authentication-status response transiently, reduces it to signedIn, and never retains, returns, projects, or uploads the identity or usage fields. Personal-home Claude adoption transiently reads bounded account, email, and organization identity metadata and retains only a one-way local authority key. Raw Claude identity fields and that private authority key are never publicly returned, projected, or uploaded; HRA never opens or parses a Claude credential file. Devin account identity and allowance are not projected. HRA reports only local signed-in readiness and records provider-supplied session context and cost facts in the neutral session stream. +- Codex and Claude Code personal-session adoption status: whether discovery is enabled and bounded pending, adopted, and fenced counts. Candidate identities and records are never included. Devin has no personal-home adoption surface. - Turn timing, observed model and tier, and provider usage summaries. - Bounded observed file and Git metadata, without unbounded filesystem paths. - Observation-only interaction IDs, kinds, states, revisions, blocking status, and bounded safe summaries. @@ -409,6 +415,7 @@ Cloud sync is optional. Local provider profiles, Codex credentials, Claude Code - Codex, Claude Code, or Devin credentials; provider profile or configuration files; plugin credentials; OAuth access or refresh tokens; authorization codes; PKCE verifiers; provider cookies; or the private device code. - Raw Codex app-server, Claude Code stream, or Devin ACP requests or responses. +- Personal-home adoption candidate identities or records, personal-runtime bindings, process identities, schedule-source metadata, provider-home provenance, provider-account authority hashes, or the automation id, firing time, and instructions from an exact Codex Desktop heartbeat envelope. Such an envelope is replaced with generic protected text before session content is projected. - Raw reasoning, hidden chain of thought, or approval secrets. - Provider-internal login and request IDs, permission values, MCP field contracts, protected answers, or response digests. - Environment variables, arbitrary command output, or unbounded filesystem paths. @@ -467,6 +474,10 @@ hra project add --path [--name ] hra project list hra project use hra session list [--account ] [--archived] [--limit <1..100>] [--cursor ] +hra session adoption status [--provider ] +hra session adoption enable --provider +hra session adoption disable --provider +hra session discover [--provider ] hra session show [--detail] hra session status [--json] hra session watch [--cursor ] [--jsonl] diff --git a/app/AGENTS.md b/app/AGENTS.md index 1f5714d..13d5029 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -28,6 +28,7 @@ - Never persist plaintext projection text, an authentication token, or an unwrapped account key. Tokens live in the in-memory storage adapter and the account key lives in the custody context only. - Keep local storage to the one key `app/src/data/card-order.ts` owns, holding a bounded list of opaque session public ids for the reader's own grid arrangement. `app/src/auth/no-persistent-storage.test.ts` allowlists that module by name; a second entry needs the same argument, and nothing else in the app may name `localStorage`, `sessionStorage`, or `document.cookie`. - Show a schedule; never offer to change one. The scheduled-task badge and the settings list read the projected device registries and expose no create, edit, or delete anywhere. +- Show personal-session adoption only as per-machine provider aggregates with exact local CLI hints. Never add an adopted-session badge or let the browser grant access to a personal provider home. - Persist only non-extractable `CryptoKey` objects, and only in IndexedDB. A private key must never be exportable. - Drop the account key on idle, on `Ctrl+L`, and on the first authority error from Convex. - A browser device is never the first device on an account and never approves another device. diff --git a/app/src/data/registry.test.ts b/app/src/data/registry.test.ts index 16cfed0..43e6216 100644 --- a/app/src/data/registry.test.ts +++ b/app/src/data/registry.test.ts @@ -199,6 +199,55 @@ describe("notification policy registry compatibility", () => { }); }); + test("drops legacy Codex Desktop automation metadata before it reaches the app", async () => { + const key = randomKeyBytes(); + const authority = { devicePublicId, keyVersion: 1, userPublicId } as const; + const hraTask = { + cadence: "every 60 minutes", + id: "stask_public_hra_task", + kind: "hra_conversation", + label: "Public HRA task", + nextRunAt: 1_760_000_060_000, + sessionPublicId: "sess_public_hra_session", + } as const; + const privateAutomation = { + cadence: "FREQ=WEEKLY;BYDAY=MO", + id: "desktop-private-automation-id", + kind: "codex_automation", + label: "Desktop private automation label", + nextRunAt: null, + sessionPublicId: "sess_private_target_correlation", + } as const; + // Encrypt the legacy wire shape directly so this exercises an old daemon's + // existing row rather than the current writer, which already strips it. + const envelope = await encryptedJson({ + ...registryPayload(), + scheduledTasks: [hraTask, privateAutomation], + }, key, registryAad(authority)); + const projection = await decryptRegistryProjection({ + key, + row: parseRow({ + devicePublicId, + envelope, + keyVersion: 1, + revision: 1, + updatedAt: 1, + }), + userPublicId, + }); + + expect(projection.registry.scheduledTasks).toEqual([hraTask]); + const appProjection = JSON.stringify(projection); + for (const privateValue of [ + privateAutomation.id, + privateAutomation.label, + privateAutomation.cadence, + privateAutomation.sessionPublicId, + ]) { + expect(appProjection).not.toContain(privateValue); + } + }); + test("shows email consent only when the composite revision is current", async () => { const key = randomKeyBytes(); const authority = { devicePublicId, keyVersion: 1, userPublicId } as const; diff --git a/app/src/hra/cloud.ts b/app/src/hra/cloud.ts index e1fa518..545c95a 100644 --- a/app/src/hra/cloud.ts +++ b/app/src/hra/cloud.ts @@ -105,6 +105,8 @@ export { type DeviceRegistryPayload, type DeviceRegistryProject, type DeviceRegistryScheduledTask, + type DeviceRegistrySessionAdoption, + type DeviceRegistrySessionAdoptionStatus, type RemoteCommandPayload, type SessionMetadataPayload, } from "../../../src/cloud/payloads"; diff --git a/app/src/model/scheduled-tasks.test.ts b/app/src/model/scheduled-tasks.test.ts index c4c9ab0..414b7c4 100644 --- a/app/src/model/scheduled-tasks.test.ts +++ b/app/src/model/scheduled-tasks.test.ts @@ -15,8 +15,8 @@ function task(overrides: Partial = {}): ScheduledTaskView { return { cadence: "every day at 09:00", id: "task-1", - kind: "codex_automation", - kindLabel: "Codex", + kind: "hra_conversation", + kindLabel: "HRA", label: "Morning sweep", machineLabel: "workshop", nextRunAt: now + hour, @@ -50,6 +50,7 @@ function machine( proseAutorespondConfigured: false, revision: 1, scheduledTasks, + sessionAdoption: null, showThinkingDefault: false, updatedAt: now, }; @@ -62,10 +63,8 @@ describe("formatting", () => { expect(scheduledTaskNextRun(null, now)).toBe("not scheduled"); }); - test("the line names the provider, the cadence, and the next run", () => { + test("the line names HRA, the cadence, and the next run", () => { expect(scheduledTaskLine(task(), now)) - .toBe("Codex · every day at 09:00 · next run in 1 hour"); - expect(scheduledTaskLine(task({ kind: "hra_conversation", kindLabel: "HRA" }), now)) .toBe("HRA · every day at 09:00 · next run in 1 hour"); }); @@ -126,7 +125,7 @@ describe("sessionScheduledTasks", () => { "session-a", now, ); - expect(view.rows[0]?.line).toBe("Codex · hourly · next run in 2 hours"); + expect(view.rows[0]?.line).toBe("HRA · hourly · next run in 2 hours"); }); test("no machine, no match, and no session all render nothing", () => { diff --git a/app/src/model/settings-view.test.ts b/app/src/model/settings-view.test.ts index 8402c01..9ddf184 100644 --- a/app/src/model/settings-view.test.ts +++ b/app/src/model/settings-view.test.ts @@ -10,6 +10,7 @@ import { commandTargetForMachine, isMachineOnline, machineLabelsByDevice, + personalSessionAdoptionCommand, registryHeartbeatToleranceMs, scheduledTaskKindLabel, shortSessionId, @@ -44,7 +45,7 @@ function registry(overrides: Partial = {}): DeviceRegistr { cadence: "every day at 09:00", id: "task_one", - kind: "codex_automation", + kind: "hra_conversation", label: "morning sweep", nextRunAt: now + 3 * minute, sessionPublicId: "sess_one", @@ -138,6 +139,7 @@ describe("toMachineView", () => { expect(view.defaultPreset).toBe("ultra"); expect(view.showThinkingDefault).toBe(false); expect(view.proseAutorespondConfigured).toBe(true); + expect(view.sessionAdoption).toBeNull(); expect(view.devicePublicId).toBe("dev_one"); expect(view.deviceStatus).toBe("active"); expect(view.notificationHours).toEqual(notificationHours); @@ -155,6 +157,33 @@ describe("toMachineView", () => { expect(view.projects.map((project) => project.label)).toEqual(["hra"]); }); + test("renders personal-home consent as a local command in both directions", () => { + expect(personalSessionAdoptionCommand("codex", false)) + .toBe("hra session adoption enable --provider codex"); + expect(personalSessionAdoptionCommand("claude", true)) + .toBe("hra session adoption disable --provider claude"); + }); + + test("carries exact provider aggregates and never guesses an older daemon's opt-in", () => { + const view = toMachineView({ + device: { online: true, status: "active" }, + devicePublicId: "dev_one", + now, + payload: registry({ + sessionAdoption: { + claude: { adopted: 1, enabled: false, fenced: 2, pending: 3 }, + codex: { adopted: 4, enabled: true, fenced: 5, pending: 6 }, + }, + }), + revision: 7, + updatedAt: now - minute, + }); + expect(view.sessionAdoption).toEqual({ + claude: { adopted: 1, enabled: false, fenced: 2, pending: 3 }, + codex: { adopted: 4, enabled: true, fenced: 5, pending: 6 }, + }); + }); + test("defaults an older registry to no displayable email consent", () => { const view = toMachineView({ device: { online: true, status: "active" }, @@ -179,14 +208,13 @@ describe("toMachineView", () => { updatedAt: now, }); expect(view.scheduledTasks.map((task) => [task.label, task.kindLabel])).toEqual([ - ["morning sweep", "Codex"], + ["morning sweep", "HRA"], ["weekly review", "HRA"], ]); for (const task of view.scheduledTasks) expect(task.machineLabel).toBe("studio"); }); - test("names both scheduled task kinds", () => { - expect(scheduledTaskKindLabel("codex_automation")).toBe("Codex"); + test("names the public HRA conversation task kind", () => { expect(scheduledTaskKindLabel("hra_conversation")).toBe("HRA"); }); }); @@ -263,7 +291,7 @@ describe("machine and task ordering", () => { scheduledTasks: [{ cadence: "hourly", id: "task_three", - kind: "codex_automation", + kind: "hra_conversation", label: "hourly sweep", nextRunAt: now + minute, sessionPublicId: null, diff --git a/app/src/model/settings-view.ts b/app/src/model/settings-view.ts index c26b757..0eae6db 100644 --- a/app/src/model/settings-view.ts +++ b/app/src/model/settings-view.ts @@ -13,6 +13,7 @@ import type { DeviceRegistryPayload, DeviceRegistryProject, DeviceRegistryScheduledTask, + DeviceRegistrySessionAdoption, NotificationHoursPolicy, } from "../hra/cloud"; import type { ApprovalMode, PresetChoice } from "./settings-commands"; @@ -26,6 +27,21 @@ export const registryHeartbeatIntervalMs = 60_000; /** Three missed registry heartbeats before a machine reads as offline. */ export const registryHeartbeatToleranceMs = 3 * registryHeartbeatIntervalMs; +export type SessionAdoptionProvider = keyof DeviceRegistrySessionAdoption; + +/** + * Personal-home access is a machine-local consent boundary. Settings shows + * the exact local command instead of manufacturing a browser mutation. + */ +export function personalSessionAdoptionCommand( + provider: SessionAdoptionProvider, + enabled: boolean, +): string { + return enabled + ? `hra session adoption disable --provider ${provider}` + : `hra session adoption enable --provider ${provider}`; +} + export type MachineDeviceState = Readonly<{ online: boolean; status: "pending" | "active" | "revoked"; @@ -52,12 +68,16 @@ export function isMachineOnline(input: MachineOnlineInput): boolean { return now - heartbeatAt <= registryHeartbeatToleranceMs; } -export type ScheduledTaskKindLabel = "Codex" | "HRA"; +export type ScheduledTaskKindLabel = "HRA"; + +const scheduledTaskKindLabels: Readonly< + Record +> = { hra_conversation: "HRA" }; export function scheduledTaskKindLabel( kind: DeviceRegistryScheduledTask["kind"], ): ScheduledTaskKindLabel { - return kind === "codex_automation" ? "Codex" : "HRA"; + return scheduledTaskKindLabels[kind]; } export type ScheduledTaskView = Readonly<{ @@ -97,6 +117,8 @@ export type MachineView = Readonly<{ proseAutorespondConfigured: boolean; revision: number; scheduledTasks: readonly ScheduledTaskView[]; + /** Null means the daemon predates this optional registry projection. */ + sessionAdoption: DeviceRegistrySessionAdoption | null; showThinkingDefault: boolean; updatedAt: number; }>; @@ -147,6 +169,7 @@ export function toMachineView(input: MachineViewInput): MachineView { nextRunAt: task.nextRunAt, sessionPublicId: task.sessionPublicId, })), + sessionAdoption: payload.sessionAdoption ?? null, showThinkingDefault: payload.showThinkingDefault, notificationHours: input.notificationHours ?? null, notificationHoursStatus: input.notificationHoursStatus diff --git a/app/src/screens/notification-hours-form.test.tsx b/app/src/screens/notification-hours-form.test.tsx index 14a3619..e7284f6 100644 --- a/app/src/screens/notification-hours-form.test.tsx +++ b/app/src/screens/notification-hours-form.test.tsx @@ -105,6 +105,7 @@ const machine: MachineView = { proseAutorespondConfigured: false, revision: 8, scheduledTasks: [], + sessionAdoption: null, showThinkingDefault: false, updatedAt: 1_760_000_000_000, }; diff --git a/app/src/screens/settings-screen.devin.test.tsx b/app/src/screens/settings-screen.devin.test.tsx index e0d6161..b446bb1 100644 --- a/app/src/screens/settings-screen.devin.test.tsx +++ b/app/src/screens/settings-screen.devin.test.tsx @@ -74,6 +74,10 @@ await mock.module("../data/registry", () => ({ proseAutorespondConfigured: false, revision: 1, scheduledTasks: [], + sessionAdoption: { + claude: { adopted: 0, enabled: false, fenced: 0, pending: 0 }, + codex: { adopted: 0, enabled: false, fenced: 0, pending: 0 }, + }, showThinkingDefault: false, updatedAt: 1_760_000_000_000, } satisfies MachineView], @@ -100,5 +104,9 @@ describe("Devin account settings", () => { expect(markup).toContain("--manual-token-flow"); expect(markup).not.toContain("Link here"); expect(markup).not.toContain("Check status"); + expect(markup).toContain("Codex personal sessions"); + expect(markup).toContain("Claude Code personal sessions"); + expect(markup).not.toContain("Devin personal sessions"); + expect(markup).not.toContain("session adoption enable <account> --provider devin"); }); }); diff --git a/app/src/screens/settings-screen.tsx b/app/src/screens/settings-screen.tsx index 2b480f0..c628d04 100644 --- a/app/src/screens/settings-screen.tsx +++ b/app/src/screens/settings-screen.tsx @@ -75,6 +75,7 @@ import { attentionEmailPresentation, commandTargetForMachine, machineLabelsByDevice, + personalSessionAdoptionCommand, shortSessionId, type AccountRowView, type ArchivedSessionView, @@ -173,6 +174,11 @@ function openSession(sessionPublicId: string): void { location.hash = `#/session/${sessionPublicId}`; } +const personalSessionProviders = Object.freeze([ + { label: "Codex", provider: "codex" }, + { label: "Claude Code", provider: "claude" }, +] as const); + /** * The gateway key entry. * @@ -465,6 +471,35 @@ function MachineCard({ /> )} + {machine.sessionAdoption === null ? ( + unavailable} + description="Update HRA on this machine to publish its local adoption status." + title="Personal sessions" + /> + ) : personalSessionProviders.map(({ label, provider }) => { + const adoption = machine.sessionAdoption?.[provider]; + if (adoption === undefined) return null; + const command = personalSessionAdoptionCommand(provider, adoption.enabled); + return ( + + {adoption.enabled ? "enabled" : "off"} + + )} + description={`${adoption.pending} pending, ${adoption.adopted} adopted, and ${adoption.fenced} fenced. ${adoption.enabled ? "New discovery is enabled." : "Disabling discovery does not change sessions already under HRA control."}`} + key={provider} + title={`${label} personal sessions`} + > +

+ Personal-home access can be changed only from this machine. +

+ {command} +
+ ); + })} + diff --git a/docs/beta-release-notes.md b/docs/beta-release-notes.md index f98edea..1c1c9c2 100644 --- a/docs/beta-release-notes.md +++ b/docs/beta-release-notes.md @@ -7,7 +7,7 @@ HRA is a persistent multi-provider CLI for isolated accounts and live local sess Install the immutable beta tag with Bun 1.3.14: ```sh -test "$(curl -fsSL --connect-timeout 10 --max-time 60 --retry 3 --retry-delay 1 --retry-max-time 60 --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/hraness/hra/v0.6.0/src/install-preflight-runtime.ts | bun -e 'const[a,h]=process.argv.slice(1);const b=await Bun.stdin.bytes();const d=new Bun.CryptoHasher("sha256").update(b).digest("hex");if(d!==h)throw new Error("The tagged HRA preflight digest is invalid.");const j=new Bun.Transpiler({loader:"ts",target:"bun"}).transformSync(b);const u=URL.createObjectURL(new Blob([j],{type:"text/javascript"}));try{const m=await import(u);await m.installHraRelease(a);process.stdout.write(`${m.HRA_INSTALL_SUCCESS}\n`);}finally{URL.revokeObjectURL(u)}' -- https://github.com/hraness/hra/releases/download/v0.6.0/hraness-hra-0.6.0.tgz 61dcd716e2272c4c3d94d565ac1a9aab3dc99780813316a822f594938cf456e2)" = hra-install-safe +test "$(curl -fsSL --connect-timeout 10 --max-time 60 --retry 3 --retry-delay 1 --retry-max-time 60 --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/hraness/hra/v0.6.0/src/install-preflight-runtime.ts | bun -e 'const[a,h]=process.argv.slice(1);const b=await Bun.stdin.bytes();const d=new Bun.CryptoHasher("sha256").update(b).digest("hex");if(d!==h)throw new Error("The tagged HRA preflight digest is invalid.");const j=new Bun.Transpiler({loader:"ts",target:"bun"}).transformSync(b);const u=URL.createObjectURL(new Blob([j],{type:"text/javascript"}));try{const m=await import(u);await m.installHraRelease(a);process.stdout.write(`${m.HRA_INSTALL_SUCCESS}\n`);}finally{URL.revokeObjectURL(u)}' -- https://github.com/hraness/hra/releases/download/v0.6.0/hraness-hra-0.6.0.tgz 1941cd63d43d06c29c0d1df8cff90c0a469cb14e3df524f5d537f6b49257eb05)" = hra-install-safe hra --version hra doctor --offline hra init --yes @@ -19,15 +19,16 @@ The local CLI is public. Hosted sync is an open beta: sign-up no longer needs an ## Included -- The official local Devin CLI is now a full provider through ACP v1. HRA admits exact CLI `3000.6.14`, launches `devin acp --model gpt-6-astra`, supports new and capability-gated resumed sessions, turns, stop, one-time permission decisions, provider switching, and provider-supplied context and optional cumulative-cost facts. Each account receives five private HOME/XDG roots; Devin owns its credential, and HRA reduces `devin auth status` to `signedIn` only. ACP thought chunks are dropped as raw reasoning. Devin exposes no machine-readable account allowance or reset operation, so HRA reports that allowance as unknown and never applies a Codex reset credit. +- The official local Devin CLI is now a full session provider through ACP v1. HRA admits exact CLI `3000.6.14`, launches `devin acp --model gpt-6-astra`, supports new and capability-gated resumed sessions, turns, stop, one-time permission decisions, provider switching, and provider-supplied context and optional cumulative-cost facts. Each account receives five private HOME/XDG roots; Devin owns its credential, and HRA reduces `devin auth status` to `signedIn` only. ACP thought chunks are dropped as raw reasoning. Devin exposes no machine-readable account allowance or reset operation, so HRA reports that allowance as unknown and never applies a Codex reset credit. Devin sessions may participate in Work coordination records and provider-neutral signals, but they cannot own or execute Work attempts; Work attempt routes remain Codex-only. - Codex `high` and the default `ultra` preset now use exact `gpt-6-astra` at `max` and `ultra` reasoning. Durable preset contracts keep existing and provider-imported Sol sessions on their established route while new sessions and explicit selections use Astra. - HRA now owns a bounded provider-neutral transcript. It records accepted user messages and safe tool-call summaries without raw arguments or output, keeps the conversation readable when a provider thread is unavailable, and exports it as HRA JSON or a letta-ai trajectory v1 document. -- `hra session switch` moves a quiescent session between Codex and Claude Code on Linux. An evidence-first crash protocol fences both account generations and the daemon generation, starts and seeds the target once, releases the source, and commits the new provider binding atomically. Recovery advances only from durable evidence and complete provider projections; it never repeats an unproven target-start or seed effect. If a daemon restart leaves an unreleased process-local Claude side, automatic recovery refuses without provider effects. Explicit abandonment terminalizes with provider-state-unknown evidence and touches only addressable non-Claude sides; a seeded Claude target is never declared live from durable rows alone. +- `hra session switch` moves a quiescent session among Codex, Claude Code, and Devin when both providers support the selected platform and preset. An evidence-first crash protocol fences both account generations and the daemon generation, starts and seeds the target once, releases the source, and commits the new provider binding atomically. Recovery advances only from durable evidence and complete provider projections; it never repeats an unproven target-start or seed effect. If a daemon restart leaves an unreleased process-local Claude side, automatic recovery refuses without provider effects. Explicit abandonment terminalizes with provider-state-unknown evidence and touches only addressable non-Claude sides; a seeded Claude target is never declared live from durable rows alone. - Codex account linking from the web always requests app-server device-code mode. HRA accepts only the pinned app-server's exact `https://auth.openai.com/codex/device` verification URL and a separate closed user code, encrypts the complete handoff to the requesting account key, exposes it once, and expires it against hosted time after five minutes. Browser-mode loopback callbacks and unversioned requests are refused before a local login effect. - Claude authentication is a provider-owned foreground path on Linux. `hra account login --provider claude` launches a realpath-resolved Claude executable after its exact self-reported version matches HRA's compatibility pin inside the profile's isolated `CLAUDE_CONFIG_DIR`; this does not authenticate the executable's package bytes. `hra account show --provider claude` reports only `signedIn`. HRA never opens or copies a Claude credential. One-child grants, exact completion after restart, acknowledged local-fence recovery, bounded status probes, and quiescent idle-session retirement prevent duplicate login children and shared-home races. -- Claude sessions and provider switches prove Claude's own sign-in state immediately before their effect. Codex keeps its existing app-server authority, and neither provider's authentication can stand in for the other's. +- Claude Code and Devin sessions and provider switches prove their own sign-in state immediately before their effect. Codex keeps its existing app-server authority, and no provider's authentication can stand in for another's. +- Personal-home session adoption is opt-in only for Codex and Claude Code. HRA discovers recent conversations and also considers an older Codex thread targeted by a present active or paused Desktop heartbeat automation. That Desktop input is private age-gate authority only: the association waives only age and never becomes an HRA schedule, a synced scheduled-task row, or a public origin marker. Admission still requires bounded account, project, liveness, quiescence, and exact-thread checks. Personal-home Claude account proof transiently reads bounded identity metadata and retains only a one-way local authority key. Raw identity fields, candidate identities and records, runtime bindings, process identities, schedule-source metadata, provider-home provenance, and provider-account authority hashes are never publicly returned, projected, or uploaded. Optional cloud sync carries only encrypted Codex and Claude Code provider-level enablement plus bounded pending, adopted, and fenced counts. Once admitted, the conversation uses the same provider-supported commands, scheduled work, autorespond policy, and approval authority as an HRA-created session. Provider-specific limits remain identical for both origins. Devin has no personal-home adoption surface. - Remote `set_provider` and `hra remote provider` use the ordinary execution lease for a provider switch. Account selection stays on the machine holding the credentials. -- Everything from `v0.5.0`: isolated Codex accounts and Linux Claude sessions, durable provider interactions and remote decisions, subagent activity, live hosted session projection, device commands, conversation-bound scheduled tasks, agent-safe JSON and JSONL output, reversible macOS ChatGPT account switching, and one checksummed npm tarball published as the same bytes on npm and the immutable GitHub Release with `SHA256SUMS`. +- Everything from `v0.5.0`: isolated Codex accounts and the Claude Code provider seam, durable provider interactions and remote decisions, subagent activity, live hosted session projection, device commands, conversation-bound scheduled tasks, agent-safe JSON and JSONL output, reversible macOS ChatGPT account switching, and one checksummed npm tarball published as the same bytes on npm and the immutable GitHub Release with `SHA256SUMS`. v0.5 refused before launching a Claude session; end-to-end Linux Claude sessions are new in v0.6. ## Known limits @@ -35,9 +36,10 @@ The local CLI is public. Hosted sync is an open beta: sign-up no longer needs an - Claude login, status, session, and switch effects are supported on Linux only. HRA refuses new Claude provider effects on macOS pending authenticated isolated-Keychain custody and detached-daemon read acceptance. Real authenticated acceptance for the exact Claude Code 2.1.260 pin remains pending because the available qualification host has 2.1.261. - Web account linking is Codex-only and requires local account-linking opt-in on the target machine. Claude and Devin login stay on that machine's foreground terminal; neither CLI exposes an HRA device-code or web-linking protocol. - Devin ACP exposes session context and optional cumulative cost, not a machine-readable account allowance, balance, reset time, or credit mutation. A zero-token compatibility smoke does not claim live turn, tool, permission, cancellation, or usage-update acceptance; that proof still requires a bounded paid turn. -- Provider-native threads do not move between providers. A switch creates one target thread and seeds it from HRA's bounded transcript. Claude exposes no admitted provider-side session listing or resume path, so a Claude session remains tied to the daemon process that started it. +- Provider-native threads do not move between providers. A switch creates one target thread and seeds it from HRA's bounded transcript. Claude Code exposes no admitted provider-side session listing or read-only observation. HRA can recover an exact Claude conversation with `--resume` only after prior-process exit or an already-completed exact process release is proven; ambiguous custody remains fenced and produces no resume effect. - Plugin and connector discovery is read-only. HRA does not install, enable, authorize, or open OAuth flows. - Desktop account switching is macOS-only in this release. - Upgrading over an older install leaves the local state schema pending until the first `hra daemon start`. Until that runs, `hra status` and `hra doctor --offline` report the pending migration and name both schema versions rather than migrating. +- On a v0.5-to-v0.6 upgrade, that first daemon migration does not infer provider-account authority that v0.5 never recorded immutably. Every affected nonterminal session enters `recovery_required`: pending or prepared effects are cancelled, begun effects remain uncertain, scheduled work pauses, pending interactions expire while begun responses become resolution-unknown, and associated Work execution is retired or fenced. Provider threads and local records are not deleted, no provider effect is replayed, and this is not generic automatic recovery. Inspect each affected session; use `hra session abandon ` only when accepting local terminalization with provider state unknown. Read the [v0.6.0 README](https://github.com/hraness/hra/tree/v0.6.0#readme), [privacy notice](https://github.com/hraness/hra/blob/v0.6.0/PRIVACY.md), and [security policy](https://github.com/hraness/hra/blob/v0.6.0/SECURITY.md) before installation or use. Report defects through [GitHub issues](https://github.com/hraness/hra/issues) and security concerns through the private process in the security policy. diff --git a/docs/providers/claude.md b/docs/providers/claude.md index b2a479f..8403ad1 100644 --- a/docs/providers/claude.md +++ b/docs/providers/claude.md @@ -1,6 +1,6 @@ # Claude provider notes -Status: the notes below are the W1 spike that the W3-C adapter was built from. The adapter now exists in `src/claude/` (pin, runtime discovery, process, protocol, delta assembler, client) with `src/daemon/claude-runtime-adapter.ts` implementing `ClaudeRuntimePort`. Every mapped shape below is covered by a fixture-driven test in `src/claude/`; nothing shells out to `claude` in tests. On Linux, the daemon starts a Claude session end to end and the local CLI has a deterministic foreground sign-in and bounded status path. New Claude provider effects are refused on macOS until authenticated testing proves that an isolated `CLAUDE_CONFIG_DIR` has isolated Keychain custody and that a detached daemon can read it without a prompt. Provider-side session listing and resume are still absent, so a Claude session lives only as long as the daemon that started it. Real authenticated acceptance against the exact pin remains pending because the available host has Claude Code 2.1.261 while HRA admits only 2.1.260. There is no Claude account-linking flow in the web app. +Status: the notes below are the W1 spike that the W3-C adapter was built from. The adapter now exists in `src/claude/` (pin, runtime discovery, process, protocol, delta assembler, client) with `src/daemon/claude-runtime-adapter.ts` implementing `ClaudeRuntimePort`. Every mapped shape below is covered by a fixture-driven test in `src/claude/`; nothing shells out to `claude` in tests. On Linux, the daemon starts a managed Claude session end to end and the local CLI has a deterministic foreground sign-in and bounded status path. New managed Claude provider effects are refused on macOS until authenticated testing proves that an isolated `CLAUDE_CONFIG_DIR` has isolated Keychain custody and that a detached daemon can read it without a prompt. Separately, opt-in personal-home discovery can adopt a session only after exact pinned registry and process evidence proves that its prior controller is no longer live. Real authenticated managed-profile acceptance against the exact pin remains pending because the available host has Claude Code 2.1.261 while HRA admits only 2.1.260. There is no Claude account-linking flow in the web app. See [Adopt sessions from personal provider homes](../session-adoption.md). ## Account isolation, sign-in, and status @@ -38,9 +38,11 @@ The status path runs the same version-admitted executable path with `auth status Account selection stays user-directed. Claude profiles default to a per-account cap of two concurrent sessions; swarm-scale traffic may be judged non-ordinary by the provider, and users raise the cap knowingly. +Personal-home adoption is a separate local, opt-in boundary. Discovery reads a bounded allowlist of scalar live-session registry fields and accepts only records naming the exact Claude Code pin. It never runs a discovery prompt or reads the registry key, socket, credential, or transcript. A complete bounded registry snapshot plus a matching PID domain, PID, and host process-start token are required to classify a process as live. A previously captured PID absent from the process table, or a captured PID whose start token now differs, is not live; a registry record missing an exact PID identity and any incomplete, conflicting, or unreadable evidence remain unknown. HRA privately retains that bounded tuple for re-probes if a registry row disappears. The `ps lstart` token has one-second wall-clock granularity, so a rare alias conservatively retains custody rather than authorizing adoption. This is a bounded liveness inference, not a provider-wide lease against another process resuming later. A recent session must also report a registered HRA project. The resumed runtime independently proves the installed pin and exact personal Claude account; the HRA profile's separate Codex account may remain signed out. Once resumed, it has the same stdin, autorespond, and approval authority as a Claude session HRA started. Account revocation and recovery also use the same provider- and runtime-scoped contract. Controller provenance remains private and does not add a session badge or require detach solely for login, logout, or provider-account replacement. + ## macOS Keychain probe (plan item D2) -Release decision: Claude ships Linux-first. HRA refuses new Claude login, provider-status, session, and switch effects on macOS until a detached-daemon Keychain acceptance answers the question below positively. +Release decision: managed Claude profiles ship Linux-first. HRA refuses managed Claude login, provider-status, new-session, and switch-to-Claude effects on macOS until a detached-daemon Keychain acceptance answers the question below positively. Personal-home adoption is a separate boundary: it uses the user's existing personal Claude home and exact process-liveness proof, and does not claim that an isolated managed Keychain has been accepted. Question: after an interactive sign-in under one isolated profile, does a detached daemon spawning the runtime under that profile's `CLAUDE_CONFIG_DIR` read only its directory-keyed Keychain item without prompting? diff --git a/docs/session-adoption.md b/docs/session-adoption.md new file mode 100644 index 0000000..132888f --- /dev/null +++ b/docs/session-adoption.md @@ -0,0 +1,128 @@ +# Adopt sessions from personal provider homes + +HRA can discover recent Codex and Claude Code sessions in your personal provider homes and bring them into the local daemon. A personal Codex thread targeted by a present Codex Desktop scheduled task is also discoverable even when the thread is older. Adoption is opt-in for each provider. After provider-specific admission succeeds, the conversation is an ordinary HRA session with the same provider-supported public commands, autorespond policy, and approval authority as every other HRA session. + +Discovery does not create a public or reduced-capability session tier. Candidates stay in private local state until HRA can claim them. The session list and normal session interface expose no separate source badge or mode. + +## What discovery reads + +The default personal provider homes are `~/.codex` and `~/.claude` for the current OS user. + +- Codex discovery asks the pinned personal-home app-server for bounded session-list pages. It also cycles fairly through bounded pages of Codex Desktop `automation.toml` records to find exact heartbeat `target_thread_id` values, then obtains those threads through metadata-only `thread/read` requests. A running daemon continues from its private scan cursor; after a daemon restart, the bounded starting page rotates with the daemon generation and wraps over the current raw directory cardinality instead of using a fixed first-page ring. Seeking remains constant-memory and shares the scan's absolute deadline; an unusually large or slow directory yields no authority for that pass rather than overrunning the bound. Before and after a claim, HRA reopens the exact automation sources that granted an age waiver. HRA opens and locally parses each bounded TOML record, but selects and retains only the authority fields; it ignores the prompt and working-directory fields and never logs, returns, or projects them. It never reads a Codex transcript, session index, or Desktop SQLite cache, and it does not resume a thread during discovery. +- Claude discovery reads only allowlisted scalar fields from the local live-session registry, accepts only records naming HRA's exact pinned Claude Code version, and never invokes Claude merely to discover sessions. The bounded registry snapshot must reach a proven end before a dead-process result can authorize admission; truncation, a read failure, or conflicting duplicate metadata leaves the candidate unknown and pending. +- Claude registry discovery never opens a registry key file or an advertised socket. Process liveness is inferred from the PID domain, PID, and the host's bounded process-start token. +- Claude account admission runs the pinned provider's bounded `auth status --json` read and sandwiches it between two bounded, no-follow reads of the non-secret account identity metadata in `~/.claude.json`. HRA transiently normalizes the account, email, and organization identifiers, derives a one-way local authority key, and discards the raw identity fields. It never opens or copies a Claude credential. + +During discovery and account admission, HRA requests only read operations from the provider binary or app-server, which may maintain its own home internals while servicing those requests. HRA directly opens only allowlisted Codex automation records, Claude registry records, and the Claude account identity metadata described above, using bounded no-follow reads. Adoption candidate identities and records, personal-runtime bindings, process identities, schedule-source metadata, provider-account authority hashes, liveness evidence, and controller provenance remain private local state. Raw Claude identity fields and the derived private authority key are not publicly returned, projected, or uploaded. When cloud sync is enabled, HRA encrypts and uploads only per-provider adoption status: whether discovery is enabled and bounded pending, adopted, and fenced counts. Once adopted, the session's ordinary fields follow the same optional encrypted-sync rules as any other HRA session. + +## Before you enable adoption + +You need an existing HRA account profile to own the adopted session. The account selector records HRA ownership and routing; it does not copy or move credentials from the personal provider home. Codex adoption additionally requires that profile's isolated Codex account to be signed in with an identifiable address matching the personal Codex home. Claude adoption instead proves the personal Claude account directly and does not require the profile's independent Codex account to be signed in. + +Register every project root that HRA may adopt: + +```text +hra project add --path /absolute/project/path --name project-name +``` + +The discovered provider project root must resolve to that exact registered directory. A candidate with no usable project root, or one whose root is not registered, stays private and pending rather than becoming a session. + +Finish any active turn in the original provider controller. For Claude, exit the old process before adoption. For Codex, leave the conversation inactive long enough to cross the quiet-time threshold described below. HRA does not terminate a foreign provider process. + +## Enable and inspect adoption + +Enable adoption separately for each provider you want HRA to scan: + +```text +hra session adoption enable personal --provider codex +hra session adoption enable personal --provider claude +``` + +Enabling a provider starts one bounded scan immediately. While the daemon is running, it repeats discovery for enabled providers. You can request another bounded scan and read aggregate status at any time: + +```text +hra session discover +hra session discover --provider claude +hra session adoption status +hra session adoption status --provider codex +``` + +Use `--json` for versioned machine-readable output. Adoption status reports whether each provider is enabled, the owning account ID, and bounded `pending`, `adopted`, and `fenced` counts. It does not reveal private candidate identifiers. + +Use `hra session list --account ` to list the account's ordinary HRA sessions. Once admission commits, a personal-home conversation appears there with the same public session fields as any other session. The account-filtered listing pages every locally known native and adopted session together in one source-neutral order before it discovers previously unknown provider rows. Pass its returned opaque `--cursor` unchanged when more than one page is available. + +## Which sessions can be adopted + +HRA normally admits only bounded, nonterminal observations from the last 15 minutes. A valid Codex Desktop heartbeat automation with an exact target thread waives only that target's 15-minute age limit. Both active and paused automation records count while present; deleting the record or retargeting it removes the waiver. Rows without a usable timestamp remain ineligible. Claude registry rows still need a recent timestamp and the exact pinned version because Claude exposes no equivalent schedule source. + +Every candidate must also meet these conditions: + +- Its provider is enabled and bound to one current HRA account profile. Codex also requires that profile's exact signed-in Codex identity; Claude requires the exact personal Claude identity and remains independent of the profile's Codex sign-in state. +- Its reported project root matches a registered HRA project. +- No other HRA session or account already owns its provider thread. +- HRA can resume the exact provider thread and recheck its identity, project, and idle state. + +Discovery and admission are separate checks. A scheduled-task association is re-read around the Codex claim and never substitutes for account identity, project, liveness, quiescence, or exact-thread proof. A candidate may remain pending until every provider-specific admission condition succeeds. + +HRA permits only one active HRA binding for each provider thread. Provider APIs do not expose a global lock against every external controller, so adoption cannot prove that a terminal or another app will never resume the same conversation later. The private controller binding tells HRA where to route and release effects; it does not create a public session kind. + +### Codex custody + +Codex does not expose an exclusive handoff operation. HRA therefore uses an inactivity inference that is part of the opt-in policy. A row is treated as live while the provider reports an active state or active turn, and an idle row remains live for 10 minutes after its last update. An idle row more than 10 minutes old can be admitted while it is still inside the 15-minute discovery window or while a present valid heartbeat automation targets it. Missing or unusable timestamps remain unknown and pending. + +For an eligible row, HRA resumes the exact thread through the pinned personal-home app-server with a policy-neutral resume. Before durable admission commits, that resume changes no provider turn policy; it establishes the exact thread, connection, and quiescent observation. After commit, every HRA-owned turn applies a fresh reviewed model, workspace permission profile, `on-request` approval policy, and `auto_review` reviewer immediately before provider dispatch, then records the effective response under the session's authority. + +The pinned Codex protocol explicitly rejoins an already-running thread. Resume therefore proves identity and connection, but not exclusive ownership. A terminal or another app that resumes the thread after the inactivity check can become a concurrent controller. HRA cannot prevent that provider-level race. Choose one controller for subsequent writes, and do not resume the provider conversation elsewhere while HRA controls it. Disable adoption when you want to prevent future discovery and adoption. + +### Claude custody + +Claude adoption uses bounded process evidence because resuming a conversation starts a new process with stdin and approval authority. HRA treats a process as live only while its PID domain, PID, and captured host process-start token all match. A previously captured PID absent from the process table, or a captured PID whose start token now differs, is treated as not live. A registry record missing an exact PID identity, an inaccessible process table, an unsupported PID domain, or otherwise incomplete evidence remains unknown. On supported hosts the token comes from `ps lstart`, whose one-second wall-clock granularity cannot distinguish the extremely narrow case of PID reuse with the same rendered start time. Such an alias fails toward retaining custody and refusing handoff, not toward adopting an ambiguously live conversation. + +HRA resumes a Claude candidate only after the old process probe reports not live. Live and unknown candidates stay pending. HRA privately retains a bounded candidate's PID-domain, PID, and source-process start token so that, if its registry row disappears after the first scan, a later scan can re-probe the same tuple rather than treating disappearance as proof of exit. The retained identity is useful only for that exact candidate revision and is never exposed in the public session shape. The pinned runtime starts `--resume` for the exact session ID and requires the resulting initialization event to confirm that same ID before adoption commits. + +This prevents HRA from attaching to the exact old Claude process. It is a bounded liveness inference, not a provider-wide lease: another user process can still start a separate resume after HRA's check. Choose one controller for subsequent writes and avoid resuming the same Claude conversation elsewhere while HRA controls it. + +The same fence applies after adoption. A clean HRA shutdown records release of the exact Claude process authority before a later daemon launches `--resume`. After an unclean loss, recovery resumes only after the stored process identity is proven not live and the exact authority is durably released. If exit or release cannot be proven, HRA leaves the session in recovery and starts no replacement controller. + +## After adoption + +An admitted session uses the same provider-supported HRA session commands as an HRA-created session. This includes send, queue, steer, stop, show, status, state, watch, session notes, scheduled work, and normal provider-compatible preset and autorespond controls. Provider-specific limits are also identical: `hra session rename` and protected turn inspection are Codex-only because Claude exposes neither provider operation. The same default `auto:all` approval mode and per-session autorespond overrides apply. Provider approval callbacks arrive on HRA's resumed connection or process, and HRA answers them with the same authority and evidence rules used for every HRA session. + +HRA cannot answer an approval that was delivered only to a prior controller, and it does not backfill a complete local event history from before adoption. Existing Codex history remains in the provider home and can appear through ordinary bounded app-server reads. When Codex Desktop fires a task, its exact provider-generated heartbeat envelope can enter that history; HRA replaces the whole user envelope with generic `[protected]` text at the provider-projection boundary. Preview-derived titles and live thread-name facts also fail closed from the canonical heartbeat prefix because the provider can truncate them before the envelope closes. The automation ID, firing timestamp, instructions, and a schedule-specific origin marker are not projected or uploaded. An exact envelope echoed by the assistant or a reasoning summary is protected too; plausible live prefixes remain bounded and private until the item boundary proves or rejects the whole envelope. Near-matching ordinary user, assistant, or reasoning-summary text is not reclassified. Claude Code exposes no admitted provider-side listing or read-only session observation, so HRA's Claude projection begins with the events it observes while controlling the resumed process. + +One pinned Codex protocol limitation is narrower than the public session surface: `thread/resume` can replace approval, reviewer, model, and workspace policy, but it cannot add a thread-creation-only dynamic tool to a conversation that never had it. CLI and app scheduling still work, and HRA enables its automation handler if the resumed thread already knows that tool, but the model in an arbitrary pre-existing Codex thread may be unable to originate an automation change itself. A Codex Desktop scheduled task remains owned by Codex Desktop; HRA adopts its target conversation as an ordinary session rather than converting that task into a separate HRA conversation task. + +## Upgrading from v0.5 + +The first `hra daemon start` after a v0.5-to-v0.6 upgrade migrates the local store. v0.5 did not retain the immutable provider-account proof that v0.6 requires before an effect, so the migration never guesses that authority. Every nonterminal session without that proof enters `recovery_required`. Pending queue entries and prepared mutations are cancelled; dispatching queue entries and mutations whose provider effect began remain ambiguous; active scheduled work pauses; pending interactions expire, while prepared or written responses become resolution-unknown; and associated Work execution is retired or fenced. + +The migration does not delete provider threads or HRA's local session records, and it does not replay a provider effect. This quarantine is not a generic automatic-recovery state: missing historical authority cannot be reconstructed by retrying. Inspect each affected session with `hra session status `. Run `hra session abandon ` only if you accept terminalizing HRA's local session and preserving provider-state-unknown evidence; abandonment does not retry or delete provider state. + +## Account changes and recovery + +Session origin does not change account authority or recovery behavior. HRA binds each controlled session to the exact provider account and runtime scope it admitted and rechecks that identity around provider effects. If that provider scope signs out, exposes a replacement account, or no longer exposes a provable identity, HRA first marks every affected nonterminal session `recovery_required`. It cancels effects that have not started, preserves in-flight effects as ambiguous or unknown, pauses scheduled work, and releases the exact native and personal-home controllers held by the prior authority. An isolated Codex login change fences Codex sessions; a managed Claude login change fences managed Claude sessions without taking custody from a separate personal Claude home. Account and session status keep recovery visible, and daemon restart continues any incomplete release. + +No origin-specific command is required when you explicitly log in, log out, or replace the provider account. Those changes use the same fail-closed revocation path for HRA-created and adopted sessions, which can make them temporarily unavailable while prior controller authority is released. Establish the intended provider identity and resolve the reported recovery before sending another mutation. + +After HRA completes a personal-home Codex account revocation, it deliberately refuses to relaunch that same account generation. Adoption status reports `restartRequired: true`, and an enable attempt returns `RECOVERY_REQUIRED` with restart guidance. Restart the daemon to create a fresh runtime generation, verify the intended provider identity, and then enable Codex adoption again. This fence never permits an ordinary account read to silently recreate the released controller. + +## Disable future adoption + +```text +hra session adoption disable --provider codex +hra session adoption disable --provider claude +``` + +Disabling a provider stops future discovery and adoption for that provider. It does not change the capabilities or lifecycle of sessions HRA already controls. + +## If a candidate stays pending + +Check these conditions: + +- The provider session was updated within the last 15 minutes, or it is the exact target of a present valid Codex Desktop heartbeat automation, and it is not terminal. +- The session reports the exact root of a registered HRA project. +- Codex has been idle for more than 10 minutes, or the prior Claude process probe reports not live. +- Claude Code matches HRA's pinned version, and the old exact process identity is no longer live. +- The selected HRA account profile is current and the daemon is running. For Codex, its isolated account must still be signed in under the admitted identity; for Claude, the personal Claude home must still expose the admitted identity. + +Run `hra session discover --provider ` after correcting the condition. If enabling adoption reports discovery state `unavailable`, the daemon could not complete the bounded provider read or admission check. The candidate remains private and is not adopted speculatively. diff --git a/kb/index.md b/kb/index.md index 1acd28d..902dae0 100644 --- a/kb/index.md +++ b/kb/index.md @@ -8,6 +8,7 @@ type: index ## Plans - [HRA v1](plans/hra-v1.md) +- [Personal-home session adoption](plans/session-adoption.md) - [HRA v2](plans/hra-v2.md) - [Model routing and bounded autonomy](plans/model-routing-autonomy.md) @@ -30,6 +31,8 @@ type: index - [[plans/hra-v1|HRA v1]] — Status: in-progress. Release plan for a persistent Codex control plane for humans and agents, with isolated accounts, bounded work coordination, durable interactions and event streams, device… +- [[plans/session-adoption|Personal-home session adoption]] — Status: in-progress. Delivery plan for adopting personal Codex and Claude Code sessions with ordinary HRA authority and fail-closed account recovery. + - [[plans/hra-v2|HRA v2]] — Status: proposed. Provider-neutral control plane for humans and agent swarms: CLI contract v2, web surface, decomposition, typed routing, Claude provider, public surface rewrite. - [[plans/model-routing-autonomy|Model routing and bounded autonomy]] - Status: in-progress. Ultra defaults, conservative shadow routing, notification timing, shared remote-action policy, and evidence-gated autonomy. diff --git a/kb/notes/codex-schedules.md b/kb/notes/codex-schedules.md index 37baa2c..bea7aac 100644 --- a/kb/notes/codex-schedules.md +++ b/kb/notes/codex-schedules.md @@ -1,6 +1,6 @@ --- title: Codex scheduled tasks (automations) ground truth -description: Where Codex Desktop stores recurring "automations" on disk and in local SQLite, how a fired automation lands in a session, and what the app-server protocol does and does not expose for a read-only scheduled-tasks projection. +description: Where Codex Desktop stores recurring automations, how they land in sessions, and why their metadata stays private except for a narrow local adoption age gate. type: note status: current area: hra @@ -15,7 +15,7 @@ relations: # Codex scheduled tasks (automations) ground truth -Spike for the "Scheduled tasks (read-only, Codex only)" prerequisite in [HRA Web v1](../plans/hra-web-v1.md#web-app-w2). Measured on this machine: pinned HRA Codex CLI `0.149.0` (`@openai/codex` in `package.json`); the interactively-driven Codex Desktop app on this machine is a newer build (`cli_version` seen in session metadata: `0.151.0-alpha.7.2`). The app-server JSON schema below was generated from the pinned `0.149.0` binary via `codex app-server generate-json-schema --experimental`. +Ground truth for the local adoption age-gate integration. The original protocol probe used HRA's then-pinned Codex CLI `0.149.0` and generated its app-server JSON schema with `codex app-server generate-json-schema --experimental`; current compatibility remains governed by the repository's reviewed pin and schema digests. Every task name, prompt, thread id, path, and timestamp below is synthetic and carries no operator data. ## What exists: "automations" (kind `heartbeat`) @@ -25,20 +25,20 @@ Codex Desktop calls its scheduled-task feature an **automation**. Nothing in thi ```toml version = 1 -id = "upload-codex-and-claude-usage-to-tokscale" +id = "weekly-project-maintenance" kind = "heartbeat" -name = "Upload Codex and Claude usage to Tokscale" -prompt = "Upload this machine's local Codex and Claude Code usage data to the saved Tokscale account. Run `...`. Report ..." +name = "Weekly project maintenance" +prompt = "Review the registered project and report any maintenance work." status = "ACTIVE" # or "PAUSED" rrule = "FREQ=WEEKLY;BYDAY=MO,WE,FR;BYHOUR=22;BYMINUTE=0" # RFC 5545 RRULE; sometimes prefixed "RRULE:", sometimes not -target_thread_id = "01a06277-c3f2-7360-ab88-e5cdc7aa1504" # links the automation to a Codex session/thread id -created_at = 1788358694391 # epoch ms -updated_at = 1788358694391 # epoch ms +target_thread_id = "00000000-0000-4000-8000-000000000001" # links the automation to a Codex session/thread id +created_at = 1700000000000 # synthetic epoch ms +updated_at = 1700000000000 # synthetic epoch ms ``` -Three automations were observed on this machine, all `kind = "heartbeat"`; no other `kind` value has been seen, so the schema for non-heartbeat kinds is unknown. `id` is a user/app-chosen slug (same string as the directory name), not a UUID, and can contain anything the automation was named from. `~/.codex/automations/.run-jitter-salt` also exists (a small opaque text file) and is presumably used to jitter fire times across a population of installs; its format was not decoded and it carries no automation-specific data. +Only `kind = "heartbeat"` has been verified; the schema for any other kind remains unknown. `id` is a user/app-chosen slug (the same string as the directory name), not necessarily a UUID, and can derive from the automation's name. Desktop also maintains a small opaque `.run-jitter-salt` beside the automation directories; its format was not decoded and it carries no automation-specific data. -**Local SQLite cache: `~/.codex/sqlite/codex-dev.db`** (this machine's active desktop build) and `~/.codex/sqlite/codex.db` (a second, currently-empty copy of the same schema, likely a different build channel) each have: +**Local SQLite cache:** inspected Desktop builds have used `~/.codex/sqlite/codex-dev.db` or `~/.codex/sqlite/codex.db` with this relevant schema: ```sql CREATE TABLE automations ( @@ -66,7 +66,7 @@ CREATE TABLE inbox_items ( ); ``` -`automations` rows mirror the TOML files and add `next_run_at`/`last_run_at`, which the TOML does not carry. On this machine `automation_runs` and `inbox_items` are both empty (0 rows) even though automations have fired (`last_run_at` is populated), which means the observed `heartbeat` kind does not go through `automation_runs`/`inbox_items` at all: it fires straight into the existing `target_thread_id` as another turn (see below). The `automation_runs`/`inbox_items` schema (keyed by a fresh `thread_id` per run, with an inbox title/summary) looks built for a different, unobserved automation mode that spawns a **new** thread per firing rather than continuing one target thread; the empty `target_type`/`project_id` columns on every `automations` row are consistent with that second mode existing but unused here. Treat this as inferred, not confirmed. +`automations` rows mirror the TOML files and add `next_run_at`/`last_run_at`, which the TOML does not carry. Verified `heartbeat` behavior fires straight into the existing `target_thread_id` as another turn (see below), without requiring an `automation_runs` or `inbox_items` row. The latter tables are keyed by a fresh `thread_id` per run and carry inbox fields, so they appear intended for a different, unverified mode that creates a new thread per firing. Treat that interpretation as an inference, not a contract. ## How a fired automation lands in a session @@ -74,10 +74,10 @@ When a `heartbeat` automation fires, Codex Desktop appends a normal `user_messag ``` - finish-linkedin-contacts-import - 2026-08-16T02:31:21.454Z + weekly-project-maintenance + 2030-01-02T03:04:05.678Z - ...the automation's prompt, verbatim... + Review the registered project and report any maintenance work. ``` @@ -96,29 +96,68 @@ Searched the generated schema (`codex app-server generate-json-schema --out ... - There **is** a different, non-overlapping `ScheduledTask*` type family (`ScheduledTaskSummary { key, name, prompt, schedule }`, `ScheduledTaskSchedule` = one of `HourlyScheduledTaskSchedule { intervalHours, days? }` / `DailyScheduledTaskSchedule { time }` / `WeekdaysScheduledTaskSchedule { time }` / `WeeklyScheduledTaskSchedule { days, time }`, `ScheduledTaskWeekday` = `MO..SU`), but it appears only as `PluginDetail.scheduledTasks` inside the response of `plugin/read`. This describes scheduled tasks a **plugin manifest declares it wants to register** (no thread/session id field at all), not a live per-user automation. It is a lookalike name, not the same feature; do not build the projection from it. - The only reachable RPC surface adjacent to "recurring work" is `plugin/list`, `plugin/read`, `plugin/search`, `plugin/install`, `plugin/installed`, `plugin/uninstall`, none of which return the user's actual automations. -`~/Library/Application Support/Codex` and `~/Library/Application Support/com.openai.codex` were checked and contain only ordinary Chromium/Electron app-shell state (caches, cookies, crash reporting, component updater data); nothing schedule-related lives there. No `LaunchAgents`/launchd plist drives automation firing; it is presumably timed by the running Desktop app process itself (consistent with the jitter-salt file). - -## Recommended read-only projection - -```ts -type ScheduledTaskProjection = { - id: string; // automations.id / automation.toml `id` (user-chosen slug, not a UUID) - label: string; // automation.toml `name` - cadence: string; // automation.toml / automations.rrule, raw RFC 5545 RRULE, passed through unparsed - nextRunAt?: number; // automations.next_run_at (epoch ms) from the local SQLite cache; absent for PAUSED or if the cache is missing/stale - lastRunAt?: number; // automations.last_run_at (epoch ms) from the same cache, OR derived by scanning the target session for the newest `` tag - sessionPublicId?: string; // automation.toml `target_thread_id`, mapped through the daemon's existing Codex session/thread id -> HRA session public id table - source: "codex"; -}; -``` - -Can be derived reliably: -- `id`, `label`, `cadence` (as a raw RRULE string), `status` (ACTIVE/PAUSED), `sessionPublicId`: read directly from `~/.codex/automations/*/automation.toml`. This needs no undocumented SQLite access and is stable as long as the TOML shape holds. -- A firing history for a given automation: scan the target session's rollout files for `` user messages (works even without the SQLite cache). - -Cannot be derived without touching the unstable local SQLite cache, or not derivable at all: -- `nextRunAt` / `lastRunAt` as single numeric fields: only present in `~/.codex/sqlite/codex-dev.db` (or `codex.db`) `automations.next_run_at`/`last_run_at`; the TOML has no equivalent. This is an internal cache with no version guarantee (two near-identical DB files were found, one active and one empty, suggesting the file name/location can shift across Desktop builds); treat any dependency on it as best-effort and fail closed (omit the field) rather than guess. -- A human-readable cadence: rendering "every Monday at 1pm" from the raw RRULE needs an RRULE evaluator; only three concrete shapes have been observed (`FREQ=HOURLY;INTERVAL=n`, `FREQ=WEEKLY;BYDAY=...;BYHOUR=..;BYMINUTE=..`, `FREQ=MONTHLY;BYDAY=..;BYSETPOS=..;BYHOUR=..;BYMINUTE=..;BYSECOND=..`, the last sometimes prefixed `RRULE:`), not the full grammar; scope a real RRULE parser as separate work rather than hand-rolling one from these three samples. -- Any automation whose `kind` is not `heartbeat`, or a "new thread per run" mode: the `automation_runs`/`inbox_items` schema suggests one exists, but no example was observed to confirm its shape or how it would populate `sessionPublicId` (a run's thread would presumably rotate per firing rather than staying fixed). -- A live "currently executing" state for an automation: there is no such field anywhere; it would have to be inferred from whether the target session currently has an active turn. -- Any app-server-native way to do the above: as established, the automations feature is not on the app-server RPC surface at all in the pinned `0.149.0` schema, so a projection built from app-server alone cannot list automations; it must read the local TOML files (and, best-effort, the SQLite cache) directly, the same way HRA already treats other undocumented Codex local state. Pin the exact file/column shapes read and fail closed on drift, per the plan's existing rule for unpublished Codex/Claude contracts. +The inspected Desktop build kept schedule authority under the Codex home rather than its ordinary Chromium/Electron app-support state, and no launchd plist drove firing. Timing therefore appears owned by the running Desktop process, consistent with the jitter-salt file; that mechanism is an inference and not part of HRA's authority contract. + +## Session-adoption trigger + +Personal-home session adoption uses one additional narrow consequence of this +mapping: a present, valid `heartbeat` record with an exact nonblank +`target_thread_id` makes that Codex thread discoverable even when it falls +outside the ordinary recent-session window. Both `ACTIVE` and `PAUSED` records +count because pausing does not delete the task or its conversation binding; +deletion or retargeting removes the trigger. HRA opens and locally parses the +bounded TOML document, but it never selects, retains, logs, returns, or projects +`prompt`, `cwds`, or another ignored field. It reads no transcript for this +decision. + +Authority discovery advances through a private live directory cursor. If that +cursor expires, its safe raw position reconstructs the reader without granting +authority until the reconstructed cursor is consumed. After a daemon restart, +the first bounded page rotates by the unbounded daemon generation; an offset +beyond the current directory wraps over its raw cardinality in constant memory +and under the same absolute deadline instead of falling back to a fixed first +page ring. If an unusually large directory cannot reach its offset or EOF before +that deadline, the pass yields no authority and a later pass retries. Exact-source +rechecks still decide whether an individual target has authority, so directory +churn can delay discovery but cannot turn a stale offset into a positive claim. + +The automation is only an age-gate hint. HRA obtains the exact target through +metadata-only `thread/read`, without resuming it during discovery, and then +requires the ordinary account, registered-project, timestamp, liveness, +quiescence, collision, and exact-resume proofs. It re-reads the association +around claim. The Codex Desktop task remains owned by Codex Desktop; HRA adopts +its target conversation as an ordinary HRA session and does not convert the +record into an HRA conversation task. Claude has no equivalent schedule source. + +## Privacy and sync boundary + +Codex Desktop automation data is private provider-home input. HRA may read the +minimum TOML association needed to waive only the recent-session age limit +during personal-session discovery, then it rechecks that association around +claim. The automation does not become an HRA schedule, and adoption does not +create a public schedule origin marker. + +The encrypted device registry and app-facing scheduled-task list contain only +ordinary HRA conversation tasks from HRA's session-task store. They never +contain a Desktop automation's id, name, RRULE, status, target thread, mapped +session correlation, firing history, or SQLite timing fields. This remains true +when the target conversation is adopted: native and adopted HRA task rows have +the same public shape and no provider-home source field. + +After Desktop fires a task, its exact provider-generated heartbeat user +envelope may appear in the ordinary Codex transcript. The Codex projection +boundary replaces that whole envelope with generic `[protected]` text in +compact messages, detailed items, preview-derived titles, and live thread-name +facts. The automation id, firing timestamp, instructions, and any +schedule-specific origin marker therefore never enter public or cloud state; +an exact envelope echoed by the assistant or a reasoning summary is protected +too. Plausible live prefixes stay in bounded local staging until the item +boundary proves or rejects the whole envelope, while near-matching user, +assistant, or reasoning-summary text retains its ordinary semantics. + +Readers discard the legacy `codex_automation` registry rows emitted by earlier +builds. Writers canonicalize the registry before encryption so even a stale +in-process caller cannot re-sync those fields. The only adoption data allowed in +that encrypted registry is the exact Codex and Claude Code provider-level +`enabled`, `pending`, `adopted`, and `fenced` aggregate. Devin has no adoption +key, and candidate records and identities stay local. diff --git a/kb/plans/AGENTS.md b/kb/plans/AGENTS.md index 3c13aed..e1a93e9 100644 --- a/kb/plans/AGENTS.md +++ b/kb/plans/AGENTS.md @@ -1,6 +1,7 @@ # Contents - `hra-v1.md` is the active product, implementation, verification, migration, and release plan. +- `session-adoption.md` is the active authority and delivery plan for adopting sessions from personal Codex and Claude Code homes. - `hra-v2.md` is the proposed provider-neutral, swarm-coordination, web-surface, and documentation plan built from the 2026-09-02 audits. It is not adopted until its open questions are closed. - `delivery-autonomy.md` is the active cross-repository plan for reducing routine agent, npm, and GitHub approval interruptions without weakening required gates. - `model-routing-autonomy.md` is the active phased plan for Ultra defaults, shadow routing, notification timing, remote action authority, and evidence-gated autonomy. Its Phase 8 authority decisions supersede blind remote-approval proposals in older plans. diff --git a/kb/plans/session-adoption.md b/kb/plans/session-adoption.md new file mode 100644 index 0000000..d930e23 --- /dev/null +++ b/kb/plans/session-adoption.md @@ -0,0 +1,247 @@ +--- +title: Personal-home session adoption +description: Delivery plan for automatically adopting Codex and Claude Code sessions from the owner's normal provider homes without weakening HRA session authority. +type: plan +status: in-progress +area: hra +tags: + - codex + - claude + - sessions + - authority +--- + +# Personal-home session adoption + +## Outcome + +After the owner explicitly enables personal-home discovery for an HRA account, +HRA discovers recently active Codex and Claude Code conversations from the +owner's normal provider homes. A discovered conversation becomes an HRA +session only after provider-specific admission succeeds. From that point onward +it has the ordinary session shape, commands, approval authority, autorespond +policy, queue, cloud projection, and grid presentation. There is no observed, +reduced-capability, or visibly adopted session tier. + +## Adversarial decisions + +The source proposal correctly found the isolated-home discovery boundary and +the imported-session provider bug. The following parts are rejected: + +- No observed/attached tiers, adopted badge, quieter default, or approval + downgrade. Those would contradict parity and duplicate controls that already + work for any normal session row. +- No origin-specific account-change prerequisite or public detach command. + Login, logout, provider replacement, and unprovable identity use one + fail-closed recovery contract for HRA-created and adopted sessions. +- No fabricated transcript seed and no `foreign_turn` transport-gap event. + Provider history is projected from the provider where supported; HRA's local + event ledger begins when HRA gains custody. +- No claim of a provider-wide exclusive lease. HRA enforces one local binding, + but Codex exposes no exclusive handoff and Claude cannot prevent a later + external resume. Admission must state and test the narrower guarantees it + actually has. +- No direct edits to provider state. Once admission succeeds, the pinned + provider process may perform its ordinary writes in its own home. + +Codex admission is attempted with the pinned private app-server and exact +`thread/resume`. The pinned protocol explicitly rejoins a running thread, so +that call is a policy-neutral identity, connection, and quiescence proof, not +an exclusivity proof. It must not change provider turn policy before the +durable adoption commit. Codex admission uses the user's accepted inactivity +inference: active rows and idle rows updated within 10 minutes remain pending; +idle rows older than 10 minutes are eligible inside the 15-minute discovery +window or when a present valid Codex Desktop heartbeat automation names the +exact target thread. Active and paused records both count until deletion or +retargeting; the association waives only age and is re-read around claim. After +commit, every HRA-owned turn applies a fresh reviewed +model, workspace permission profile, `on-request` approval routing, and +`auto_review` reviewer immediately before dispatch. + +Claude's private peer surface does not carry tool-approval authority, so HRA +does not use its private key or socket. An active or uncertain Claude process +remains a candidate; after exact PID-domain, PID, and process-start evidence +proves the old process exited, HRA resumes the same session through the pinned +stream-JSON bridge and accepts it only when `system/init` proves the requested +session ID. The new process is held under durable process authority. + +## Authority model + +- Personal-home discovery is opt in, scoped to one provider and one existing + HRA account, and defaults off. +- A provider home can be bound to at most one HRA account on one daemon. +- Runtime-home provenance and pending discovery state remain private SQLite + authority. They never enter `SessionRecord`, Convex, or app session heads. +- Personal-home Claude account proof transiently reads bounded account, email, + and organization identity metadata, retains only a one-way local authority + key, and discards the raw fields. Raw identity fields, candidate identities + and records, runtime bindings, process identities, schedule-source metadata, + and provider-account authority hashes are never publicly returned, + projected, or uploaded. +- Optional cloud sync carries only encrypted provider-level discovery + enablement and bounded pending, adopted, and fenced counts. +- The profile state machine records the isolated Codex account, so its + `signed_in` prerequisite applies only to Codex. Every session also carries + exact provider-specific account authority: Claude sessions remain usable + when the profile's independent Codex account is signed out, while managed + and personal Claude runtime scopes are fenced independently. +- Every admitted session operation resolves both the personal runtime port and + the personal provider home from its private binding. Account login, logout, + usage, plugins, and Desktop switching continue to use only isolated homes. +- The installation boundary injects personal provider homes. Production uses + the current user's canonical homes; live acceptance uses only fixture-owned + homes and therefore cannot read or mutate the operator's provider state. +- Account authority loss is controller-neutral. Login generation advance, + explicit logout, externally observed sign-out, provider-account replacement, + and unprovable identity first move every affected nonterminal session into + durable `recovery_required` state. Undispatched work is cancelled, uncertain + work remains ambiguous, scheduled work pauses, and every exact native or + personal-home controller held by the prior authority is released. Restart + resumes an incomplete release, and the account and session status surfaces + keep it visible. +- Account-authority changes require no origin-specific session action. Internal + release and revocation machinery closes the exact provider connection or + process while preserving provider history and foreign processes. +- A completed personal-home Codex account revocation leaves the released + account generation fenced. Adoption status reports `restartRequired`, and + re-enable returns `RECOVERY_REQUIRED` until daemon restart creates a fresh + runtime generation; ordinary account reads never bypass that fence. +- Released v0.5 sessions carry no immutable provider-account authority that + v0.6 can safely reconstruct. The upgrade migration therefore quarantines + every proofless nonterminal session as `recovery_required`, cancels pending + or prepared effects, leaves begun effects uncertain, pauses scheduled work, + fails pending or begun interactions closed, and retires or fences associated + Work execution. It preserves provider threads and local records and performs + no provider effect. Generic recovery cannot invent the missing proof; + abandonment is available only as an explicit acceptance of local + terminalization with provider state unknown. + +## Delivery phases + +### A. Durable policy and truthful import + +Status: implemented; final validation in progress. + +- Add an append-only schema migration for provider-scoped personal-home policy, + pending candidates, and session runtime bindings. +- Require `provider`, provider-valid next-turn `preset`, and `fastEnabled` in + `upsertProviderSession`; reject a provider collision. +- Add CLI commands for policy status/enable/disable and bounded discovery. + Enabling performs an immediate discovery pass; there is no origin-specific + public session command. + +Acceptance: released v0.5 schema v33, the canonical pre-notification +provider-switch v35 layout, the exact known pre-release adoption-v35 and +adoption-v36 layouts, upstream provider schema v39, and the exact pre-release +adoption-only v39 layout migrate restart-idempotently to the combined adoption +schema v40 without confusing their colliding version numbers; proofless +nonterminal legacy sessions enter the audited fail-closed quarantine; the +isolation boundary defaults closed; repeated discovery and import are +idempotent; public session schemas do not change. + +### B. Codex discovery and reviewed admission + +Status: implemented; final validation in progress. + +- Run a second pinned Codex runtime against the canonical personal Codex home. +- Read bounded recent provider pages plus exact metadata for the bounded set of + threads targeted by present Codex Desktop heartbeat automations. Cycle + fairly through bounded automation-directory pages, retain a live private + cursor, rotate the bounded starting page across daemon generations, retain + only exact source directory identities privately, and reopen those sources + around admission. + Parse only bounded local TOML records, select and retain no prompt, + working-directory, or unknown field, and never log, return, or project those + ignored values. Never read a transcript, session index, or Desktop SQLite + cache, and never resume a thread during discovery. Infer inactivity from the + accepted quiet-time threshold, then use exact `thread/resume` as + policy-neutral identity and quiescence admission. +- Replace the exact provider-generated Desktop heartbeat user envelope with + generic protected text at the common Codex projection boundary, including + provider-derived titles, live name facts, and an exact assistant or + reasoning-summary echo. Hold a plausible live prefix in bounded local + staging until its item boundary. Never project the automation id, firing + timestamp, instructions, or a schedule-origin marker; do not reclassify + near-matching ordinary user, assistant, or reasoning-summary text. +- Persist a normal session only after exact thread ID, current connection, + quiescent state, and project are proven. Apply the reviewed model, workspace + permissions, approval policy, and approval reviewer on every owned turn. + Existing pre-effect observation, + mutation journaling, turn IDs, interaction authority, and ambiguity rules + remain authoritative. + +Acceptance: a quiet eligible external thread becomes a normal session; an +active, recently updated, or unknown thread remains pending without a public + session row; a later poll can adopt it; every HRA session uses the same + approval and autorespond integration path. A stale idle Codex thread with a + valid active or paused heartbeat target is discovered by exact metadata read + and admitted only while that association, account, project, liveness, and + quiescence all remain proven. + +### C. Claude durable identity and resume takeover + +Status: implemented; final validation in progress. + +- Fix HRA-created Claude sessions to use and validate one real provider session ID. +- Discover sessions only through bounded scalar live-session registry metadata + that names the exact pinned version. Do not invoke an unverified discovery + command. Prove liveness with PID domain plus exact process start, never + registry status or socket existence alone. +- When the source process is dead, launch the normal stream-JSON bridge with + `--resume ` and accept custody only after `system/init` matches. + +Acceptance: an HRA-created Claude session survives daemon restart; a dead external session is +resumed under the same ID; a live, unknown, copied, or mismatched candidate is +not admitted as a public session or granted runtime authority; future turns +expose the normal interaction and autorespond path. + +### D. Projection, settings, and operations + +Status: implemented; final validation in progress. + +- Route daemon and cloud reads through the session's private runtime binding. +- Poll once at daemon admission and on a bounded interval with single-flight, + backoff, and pathless diagnostics. +- Put the opt-in control and pending counts in settings without adding any + adopted marker to session cards. +- Document discovery limits, nonexclusive handoff, liveness confidence, + account-revocation restart fencing, the v0.5 upgrade quarantine, the private + candidate and authority boundary, and the pre-adoption local-event-history + boundary. + +Acceptance: the grid and public command surfaces cannot distinguish an admitted +session by capability; disabling discovery stops new claims but does not +degrade existing sessions; account loss applies the same visible fail-closed +recovery to both controller sources; daemon shutdown drains both runtime sets. + +### E. Verification and delivery + +Status: in progress. + +- Run focused storage, parser, provider-runtime, service, cloud, and app tests. +- Run independent adversarial review over the converged diff. +- Run the repository final gate through the host scheduler on the exact tree, + then follow the repository PR, merge, release, deployment, and production + verification workflow that applies. +- Deploy the app-side optional aggregate-status reader before any daemon release + that can upload the new optional registry field, because the previous app + parser rejects unknown exact keys. + +## Explicit residual boundary + +Neither provider exposes a global lease against all later external resumes. +Codex adoption relies on the accepted 10-minute inactivity inference, and exact +resume cannot detect a still-open but quiet terminal. Claude proves that the +specific old process exited, but another process can race by resuming later. +HRA therefore promises one HRA binding plus exact resumed-channel authority, +not universal exclusion. The operator chooses one controller for subsequent +writes and must not resume the provider conversation elsewhere while HRA +controls it. + +HRA does not answer an approval already delivered only to another controller +and does not fabricate that authority. Pre-adoption content can appear through +a real bounded provider projection, but HRA's local event export begins at +admission. The pinned Codex resume method also cannot add a thread-creation-only +dynamic tool to a thread that never had it. That limits model-originated +automation changes in arbitrary existing Codex threads, not the public HRA +session commands, scheduler, approval authority, or autorespond path. diff --git a/scripts/live-acceptance-installation.ts b/scripts/live-acceptance-installation.ts index b62cdab..235998c 100644 --- a/scripts/live-acceptance-installation.ts +++ b/scripts/live-acceptance-installation.ts @@ -14,7 +14,11 @@ import { z } from "zod"; import { canonicalCloudDeploymentUrl } from "../src/cloud/identity-custody"; import type { HraInstallation } from "../src/installation"; -import { ensurePrivateDirectory, resolveStatePaths } from "../src/storage/paths"; +import { + ensurePrivateDirectory, + personalProviderPaths, + resolveStatePaths, +} from "../src/storage/paths"; import { FileSecretBackend, GenerationalSecretCustody, @@ -235,6 +239,7 @@ export function createAcceptanceInstallation( expectedHomeDirectory, kind: "live_acceptance", paths, + personalProviderHomes: personalProviderPaths(join(paths.root, "personal-home")), prepareCodexHome: prepareAcceptanceCodexHome, }; } diff --git a/scripts/live-acceptance-worker.ts b/scripts/live-acceptance-worker.ts index f4a87fb..f6492f4 100644 --- a/scripts/live-acceptance-worker.ts +++ b/scripts/live-acceptance-worker.ts @@ -5,7 +5,7 @@ import { isatty } from "node:tty"; import { callLocalDaemon } from "../src/daemon/local-transport"; import { waitForDaemonReady, type DaemonIdentity } from "../src/daemon/daemon-startup"; -import { main as cliMain, runDaemon } from "../src/cli"; +import { initialize, main as cliMain, runDaemon } from "../src/cli"; import type { Output } from "../src/cli/render"; import type { CommandResponse } from "../src/domain/contracts"; import { @@ -195,22 +195,41 @@ const responseRequiresRestart = (response: CommandResponse): boolean => type GenerationStopReason = "parent_closed" | "restart" | "stop" | "suspend"; type DaemonGeneration = { + controller: AbortController; expectedStop: GenerationStopReason | null; identity?: DaemonIdentity; promise: Promise; }; +type DaemonSupervisorDependencies = Readonly<{ + runDaemon?: typeof runDaemon; + waitForDaemonReady?: typeof waitForDaemonReady; +}>; + +type WorkerDependencies = DaemonSupervisorDependencies & Readonly<{ + initializeWorkerInstallation?: ( + descriptor: AcceptanceInstallationDescriptor, + ) => Promise; +}>; + class DaemonSupervisor { readonly #descriptor: AcceptanceInstallationDescriptor; readonly #installation: ReturnType; readonly #failure = deferred(); + readonly #runDaemon: typeof runDaemon; + readonly #waitForDaemonReady: typeof waitForDaemonReady; #failureError: Error | undefined; #generation: DaemonGeneration | undefined; #suspended = false; - constructor(descriptor: AcceptanceInstallationDescriptor) { + constructor( + descriptor: AcceptanceInstallationDescriptor, + dependencies: DaemonSupervisorDependencies = {}, + ) { this.#descriptor = descriptor; this.#installation = createAcceptanceInstallation(descriptor); + this.#runDaemon = dependencies.runDaemon ?? runDaemon; + this.#waitForDaemonReady = dependencies.waitForDaemonReady ?? waitForDaemonReady; void this.#failure.promise.catch(() => undefined); } @@ -222,9 +241,11 @@ class DaemonSupervisor { if (this.#generation !== undefined || this.#suspended) { throw new WorkerFailure("daemon_failed"); } + const controller = new AbortController(); const generation: DaemonGeneration = { + controller, expectedStop: null, - promise: runDaemon(this.#installation), + promise: this.#runDaemon(this.#installation, { stopSignal: controller.signal }), }; this.#generation = generation; void generation.promise.then( @@ -237,7 +258,7 @@ class DaemonSupervisor { ); try { generation.identity = await Promise.race([ - waitForDaemonReady({ + this.#waitForDaemonReady({ deadlineMs: 30_000, paths: this.#installation.paths, queryStatus: async () => await callLocalDaemon({ @@ -251,9 +272,9 @@ class DaemonSupervisor { ]); } catch (error: unknown) { generation.expectedStop ??= "stop"; - signalDaemon(); + generation.controller.abort(new Error("Live-acceptance daemon readiness failed.")); await beforeDeadline(generation.promise, 30_000).catch(() => undefined); - throw error; + throw error instanceof WorkerFailure ? error : new WorkerFailure("daemon_failed"); } if (process.env.HOME !== this.#descriptor.expectedHomeDirectory) { throw new WorkerFailure("home_changed"); @@ -328,10 +349,18 @@ class DaemonSupervisor { if (generation === undefined) return; if (generation.expectedStop === null) { generation.expectedStop = "parent_closed"; - signalDaemon(); + generation.controller.abort(new Error("The live-acceptance parent closed.")); } } + async stopAfterFailure(): Promise { + const generation = this.#generation; + if (generation === undefined) return; + generation.expectedStop ??= "stop"; + generation.controller.abort(new Error("The live-acceptance worker failed.")); + await beforeDeadline(generation.promise, 30_000).catch(() => undefined); + } + async #stopGeneration( reason: GenerationStopReason, signal: AbortSignal, @@ -352,7 +381,7 @@ class DaemonSupervisor { }); if (!response.ok) throw new WorkerFailure("daemon_failed"); } else { - signalDaemon(); + generation.controller.abort(new Error("The live-acceptance daemon was asked to stop.")); } await this.#awaitStoppedGeneration(generation); } @@ -409,6 +438,35 @@ class CapturedCliOutput implements Output { } } +const discardedCliOutput: Output = { + writeStderr: () => undefined, + writeStdout: () => undefined, +}; + +async function initializeWorkerInstallation( + descriptor: AcceptanceInstallationDescriptor, +): Promise { + // Codex resolves its account-level credential-store policy from the app-server + // startup directory. Bind that base config to the same isolated project used by + // every config/read preflight, without changing HOME or carrying the path in argv. + process.chdir(descriptor.documentsDirectory); + if (process.cwd() !== descriptor.documentsDirectory) { + throw new WorkerFailure("layout_invalid"); + } + const installation = createAcceptanceInstallation(descriptor); + try { + const exitCode = await initialize(true, false, discardedCliOutput, { + documentsDirectory: descriptor.documentsDirectory, + paths: installation.paths, + }); + if (exitCode !== 0) throw new WorkerFailure("initialization_failed"); + } catch { + // The worker protocol reports a bounded stage, never a potentially + // path-bearing SQLite or filesystem diagnostic. + throw new WorkerFailure("initialization_failed"); + } +} + async function executeCliControl( control: Extract, { type: "cli" }>, descriptor: AcceptanceInstallationDescriptor, @@ -576,41 +634,27 @@ async function consumeControl( } } -const signalDaemon = (): void => { - try { - process.kill(process.pid, "SIGTERM"); - } catch { - // The daemon may already have completed its bounded shutdown. - } -}; - -async function workerMain(): Promise { +async function workerMain( + dependencies: WorkerDependencies = {}, +): Promise { let status: StatusWriter | undefined; let input: WorkerInput | undefined; let descriptor: AcceptanceInstallationDescriptor | undefined; + let supervisor: DaemonSupervisor | undefined; try { status = new StatusWriter(); input = new WorkerInput(); - descriptor = await assertAcceptanceDescriptorLayout(await readDescriptor(input)); + try { + descriptor = await assertAcceptanceDescriptorLayout(await readDescriptor(input)); + } catch (error: unknown) { + if (error instanceof WorkerFailure) throw error; + throw new WorkerFailure("descriptor_invalid"); + } if (process.env.HOME !== descriptor.expectedHomeDirectory) { throw new WorkerFailure("home_changed"); } - // Codex resolves its account-level credential-store policy from the app-server - // startup directory. Bind that base config to the same isolated project used by - // every config/read preflight, without changing HOME or carrying the path in argv. - process.chdir(descriptor.documentsDirectory); - if (process.cwd() !== descriptor.documentsDirectory) { - throw new WorkerFailure("layout_invalid"); - } - const installation = createAcceptanceInstallation(descriptor); - const initializationOutput = new CapturedCliOutput(); - const initializationExitCode = await cliMain( - ["init", "--yes"], - initializationOutput, - { installation, interactive: false }, - ); - if (initializationExitCode !== 0) throw new WorkerFailure("daemon_failed"); - const supervisor = new DaemonSupervisor(descriptor); + await (dependencies.initializeWorkerInstallation ?? initializeWorkerInstallation)(descriptor); + supervisor = new DaemonSupervisor(descriptor, dependencies); await supervisor.start(); if (process.env.HOME !== descriptor.expectedHomeDirectory) { throw new WorkerFailure("home_changed"); @@ -636,7 +680,7 @@ async function workerMain(): Promise { await status.close(); return 0; } catch (error: unknown) { - signalDaemon(); + await supervisor?.stopAfterFailure().catch(() => undefined); const code = error instanceof WorkerFailure ? error.code : error instanceof Error && error.message === "home_changed" @@ -657,4 +701,39 @@ async function workerMain(): Promise { } } +type LiveAcceptanceWorkerSupervisorTestInput = + | Readonly<{ + descriptor: AcceptanceInstallationDescriptor; + kind: "start"; + runDaemon: typeof runDaemon; + waitForDaemonReady: typeof waitForDaemonReady; + }> + | Readonly<{ + initializeWorkerInstallation?: typeof initializeWorkerInstallation; + kind: "worker_main"; + runDaemon: typeof runDaemon; + waitForDaemonReady: typeof waitForDaemonReady; + }>; + +export function runLiveAcceptanceWorkerSupervisorForTest( + input: Extract, +): Promise; +export function runLiveAcceptanceWorkerSupervisorForTest( + input: Extract, +): Promise; +export async function runLiveAcceptanceWorkerSupervisorForTest( + input: LiveAcceptanceWorkerSupervisorTestInput, +): Promise { + const dependencies = { + ...(input.kind === "worker_main" && input.initializeWorkerInstallation !== undefined + ? { initializeWorkerInstallation: input.initializeWorkerInstallation } + : {}), + runDaemon: input.runDaemon, + waitForDaemonReady: input.waitForDaemonReady, + }; + if (input.kind === "worker_main") return await workerMain(dependencies); + const supervisor = new DaemonSupervisor(input.descriptor, dependencies); + await supervisor.start(); +} + if (import.meta.main) process.exitCode = await workerMain(); diff --git a/scripts/live-acceptance.test.ts b/scripts/live-acceptance.test.ts index 7b89afd..11cd9ba 100644 --- a/scripts/live-acceptance.test.ts +++ b/scripts/live-acceptance.test.ts @@ -45,6 +45,7 @@ import { readLiveRuntimeAttestation, resumeLiveAcceptanceCleanup, sourceGitOutput, + startLiveAcceptanceProcessWorkerForTesting, startLiveAcceptanceRun, type LiveAcceptanceDeviceName, type LiveAcceptanceWorker, @@ -110,6 +111,31 @@ async function removeOwnedTestBase(root: string): Promise { } } +const startSyntheticProcessWorker = async ( + descriptor: AcceptanceInstallationDescriptor, + body: readonly string[], +): Promise => { + const defaultLaunch = liveAcceptanceWorkerLaunch(descriptor); + const harness = [ + 'import { createInterface } from "node:readline";', + "const lines = createInterface({ input: process.stdin })[Symbol.asyncIterator]();", + "const descriptorFrame = await lines.next();", + 'if (descriptorFrame.done) throw new Error("missing descriptor");', + "const descriptor = JSON.parse(descriptorFrame.value);", + "const writeStatus = async (value) => await new Promise((resolve, reject) => {", + ' process.stdout.write(JSON.stringify(value) + "\\n", (error) => {', + " if (error === undefined || error === null) resolve();", + " else reject(error);", + " });", + "});", + ...body, + ].join("\n"); + return await startLiveAcceptanceProcessWorkerForTesting(descriptor, { + ...defaultLaunch, + arguments: ["--no-env-file", "-e", harness], + }); +}; + const response = (data: unknown): CommandResponse => ({ data, ok: true, @@ -592,6 +618,94 @@ describe("source-only live acceptance isolation", () => { } }); + test("initializes in the scrubbed environment before running an abort-aware daemon", async () => { + const base = await privateTestBase(); + let child: ReturnType | undefined; + let childClosed: Promise> | undefined; + let runRoot: string | undefined; + try { + const layout = await createLiveAcceptanceLayout({ temporaryBaseDirectory: base }); + runRoot = layout.runRoot.path; + const descriptor = layout.descriptors.a; + const launch = liveAcceptanceWorkerLaunch(descriptor); + const workerModule = new URL("./live-acceptance-worker.ts", import.meta.url).href; + const harness = [ + `import { runLiveAcceptanceWorkerSupervisorForTest } from ${JSON.stringify(workerModule)};`, + "const exitCode = await runLiveAcceptanceWorkerSupervisorForTest({", + ' kind: "worker_main",', + " runDaemon: async (_installation, options) => {", + " const signal = options.stopSignal;", + ' if (signal === undefined) throw new Error("missing generation stop signal");', + " await new Promise((resolve) => {", + " if (signal.aborted) resolve();", + ' else signal.addEventListener("abort", resolve, { once: true });', + " });", + " return 0;", + " },", + " waitForDaemonReady: async () => ({", + ' bootId: "boot_00000000000000000000000000000001",', + " generation: 1,", + ' nonce: "00000000-0000-4000-8000-000000000001",', + " pid: process.pid,", + ' protocol: "hra-control-plane-local-v2",', + " }),", + "});", + "process.exitCode = exitCode;", + ].join("\n"); + child = spawn(launch.executable, ["--no-env-file", "-e", harness], { + cwd: launch.cwd, + env: launch.environment, + stdio: [...LIVE_ACCEPTANCE_WORKER_STDIO], + }); + childClosed = new Promise>( + (resolvePromise) => child!.once("close", (code, signal) => { + resolvePromise({ code, signal }); + }), + ); + const lines = createInterface({ input: child.stdout! })[Symbol.asyncIterator](); + + child.stdin!.write(`${JSON.stringify(descriptor)}\n`); + const readyLine = await lines.next(); + expect(readyLine.done).toBe(false); + expect(liveAcceptanceWorkerStatusSchema.parse( + JSON.parse(readyLine.value!) as unknown, + )).toMatchObject({ + device: descriptor.device, + runId: descriptor.runId, + type: "ready", + }); + + child.stdin!.end(); + const stoppedLine = await lines.next(); + expect(stoppedLine.done).toBe(false); + expect(liveAcceptanceWorkerStatusSchema.parse( + JSON.parse(stoppedLine.value!) as unknown, + )).toEqual({ + device: descriptor.device, + runId: descriptor.runId, + type: "stopped", + version: 1, + }); + expect(await lines.next()).toMatchObject({ done: true }); + + expect(await childClosed).toEqual({ code: 0, signal: null }); + child = undefined; + childClosed = undefined; + } finally { + if (child !== undefined) { + child.stdin?.destroy(); + child.stdout?.destroy(); + child.kill("SIGTERM"); + await childClosed?.catch(() => undefined); + } + if (runRoot !== undefined) await rm(runRoot, { force: false, recursive: true }).catch(() => undefined); + await removeOwnedTestBase(base); + } + }, 60_000); + test("prepares many private credential homes concurrently", async () => { const base = await privateTestBase(); let runRoot: string | undefined; @@ -631,6 +745,112 @@ describe("source-only live acceptance isolation", () => { } }); + test("rejects a failure status attributed to another worker identity", async () => { + const base = await privateTestBase(); + let runRoot: string | undefined; + let worker: LiveAcceptanceWorker | undefined; + try { + const layout = await createLiveAcceptanceLayout({ temporaryBaseDirectory: base }); + runRoot = layout.runRoot.path; + const descriptor = layout.descriptors.a; + worker = await startSyntheticProcessWorker(descriptor, [ + "await writeStatus({", + ' code: "daemon_failed",', + ' device: descriptor.device === "a" ? "b" : "a",', + " runId: descriptor.runId,", + ' type: "failed",', + " version: 1,", + "});", + ]); + + await expect(worker.ready()).rejects.toThrow("worker_protocol_invalid"); + } finally { + await worker?.preserve(); + if (runRoot !== undefined) await rm(runRoot, { force: false, recursive: true }).catch(() => undefined); + await removeOwnedTestBase(base); + } + }, 60_000); + + test("rejects a stopped status while a control request is pending", async () => { + const base = await privateTestBase(); + let runRoot: string | undefined; + let worker: LiveAcceptanceWorker | undefined; + try { + const layout = await createLiveAcceptanceLayout({ temporaryBaseDirectory: base }); + runRoot = layout.runRoot.path; + const descriptor = layout.descriptors.a; + worker = await startSyntheticProcessWorker(descriptor, [ + "await writeStatus({", + " device: descriptor.device,", + " pid: process.pid,", + " runId: descriptor.runId,", + ' type: "ready",', + " version: 1,", + "});", + "const controlFrame = await lines.next();", + 'if (controlFrame.done) throw new Error("missing control");', + "const control = JSON.parse(controlFrame.value);", + "await writeStatus({", + " device: descriptor.device,", + " runId: descriptor.runId,", + ' type: "stopped",', + " version: 1,", + "});", + "await writeStatus({", + " requestId: control.requestId,", + " response: { data: null, ok: true, requestId: control.requestId, version: 1 },", + ' type: "command_result",', + " version: 1,", + "});", + ]); + await worker.ready(); + + await expect(worker.command({ kind: "daemon.status" })) + .rejects.toThrow("worker_protocol_invalid"); + await expect(worker.lifetime()).rejects.toThrow("worker_protocol_invalid"); + } finally { + await worker?.preserve(); + if (runRoot !== undefined) await rm(runRoot, { force: false, recursive: true }).catch(() => undefined); + await removeOwnedTestBase(base); + } + }, 60_000); + + test("rejects an unsolicited clean worker shutdown", async () => { + const base = await privateTestBase(); + let runRoot: string | undefined; + let worker: LiveAcceptanceWorker | undefined; + try { + const layout = await createLiveAcceptanceLayout({ temporaryBaseDirectory: base }); + runRoot = layout.runRoot.path; + const descriptor = layout.descriptors.a; + worker = await startSyntheticProcessWorker(descriptor, [ + "await writeStatus({", + " device: descriptor.device,", + " pid: process.pid,", + " runId: descriptor.runId,", + ' type: "ready",', + " version: 1,", + "});", + "await new Promise((resolve) => setImmediate(resolve));", + "await writeStatus({", + " device: descriptor.device,", + " runId: descriptor.runId,", + ' type: "stopped",', + " version: 1,", + "});", + ]); + await worker.ready().catch((error: unknown) => { + expect(error).toMatchObject({ message: "worker_protocol_invalid" }); + }); + + await expect(worker.lifetime()).rejects.toThrow("worker_protocol_invalid"); + } finally { + await worker?.preserve(); + if (runRoot !== undefined) await rm(runRoot, { force: false, recursive: true }).catch(() => undefined); + await removeOwnedTestBase(base); + } + }, 60_000); + test("keeps state, sockets, and capabilities out of worker argv and environment", async () => { const base = await privateTestBase(); let runRoot: string | undefined; @@ -658,6 +878,211 @@ describe("source-only live acceptance isolation", () => { } }); + test("publishes descriptor failures without terminating itself by signal", async () => { + const base = await privateTestBase(); + let child: ReturnType | undefined; + let childClosed: Promise> | undefined; + let runRoot: string | undefined; + try { + const layout = await createLiveAcceptanceLayout({ temporaryBaseDirectory: base }); + runRoot = layout.runRoot.path; + const launch = liveAcceptanceWorkerLaunch(layout.descriptors.a); + child = spawn(launch.executable, [...launch.arguments], { + cwd: launch.cwd, + env: launch.environment, + stdio: [...LIVE_ACCEPTANCE_WORKER_STDIO], + }); + childClosed = new Promise>( + (resolvePromise) => child!.once("close", (code, signal) => { + resolvePromise({ code, signal }); + }), + ); + const lines = createInterface({ input: child.stdout! })[Symbol.asyncIterator](); + + child.stdin!.end("{}\n"); + const failedLine = await lines.next(); + expect(failedLine.done).toBe(false); + expect(liveAcceptanceWorkerStatusSchema.parse( + JSON.parse(failedLine.value!) as unknown, + )).toEqual({ + code: "descriptor_invalid", + type: "failed", + version: 1, + }); + expect(await lines.next()).toMatchObject({ done: true }); + expect(await childClosed).toEqual({ code: 1, signal: null }); + child = undefined; + childClosed = undefined; + } finally { + if (child !== undefined) { + child.stdin?.destroy(); + child.stdout?.destroy(); + child.kill("SIGTERM"); + await childClosed?.catch(() => undefined); + } + if (runRoot !== undefined) await rm(runRoot, { force: false, recursive: true }).catch(() => undefined); + await removeOwnedTestBase(base); + } + }, 60_000); + + test("waits for unsettled daemon cleanup after readiness rejects", async () => { + const base = await privateTestBase(); + let child: ReturnType | undefined; + let childClosed: Promise> | undefined; + let runRoot: string | undefined; + try { + const layout = await createLiveAcceptanceLayout({ temporaryBaseDirectory: base }); + runRoot = layout.runRoot.path; + const descriptor = layout.descriptors.a; + const launch = liveAcceptanceWorkerLaunch(descriptor); + const workerModule = new URL("./live-acceptance-worker.ts", import.meta.url).href; + const harness = [ + `import { runLiveAcceptanceWorkerSupervisorForTest } from ${JSON.stringify(workerModule)};`, + "const descriptor = JSON.parse(Bun.argv[1]);", + "let finishCleanup;", + "const cleanup = new Promise((resolve) => { finishCleanup = resolve; });", + "let observeAbort;", + "const aborted = new Promise((resolve) => { observeAbort = resolve; });", + "const starting = runLiveAcceptanceWorkerSupervisorForTest({", + ' kind: "start",', + " descriptor,", + " runDaemon: async (_installation, options) => {", + " const signal = options.stopSignal;", + ' if (signal === undefined) throw new Error("missing generation stop signal");', + " if (signal.aborted) observeAbort();", + ' else signal.addEventListener("abort", observeAbort, { once: true });', + " await aborted;", + " return await cleanup;", + " },", + ' waitForDaemonReady: async () => { throw new Error("synthetic readiness rejection"); },', + "});", + "let startSettled = false;", + "void starting.then(() => { startSettled = true; }, () => { startSettled = true; });", + "await aborted;", + "await Promise.resolve();", + 'if (startSettled) throw new Error("start settled before daemon cleanup");', + "finishCleanup(0);", + "const failure = await starting.then(() => null, (error) => error);", + 'if (!(failure instanceof Error) || failure.message !== "daemon_failed") {', + ' throw new Error("readiness rejection was not normalized");', + "}", + 'process.stdout.write("ok\\n");', + ].join("\n"); + child = spawn(launch.executable, [ + "--no-env-file", + "-e", + harness, + JSON.stringify(descriptor), + ], { + cwd: launch.cwd, + env: launch.environment, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout!.setEncoding("utf8"); + child.stderr!.setEncoding("utf8"); + child.stdout!.on("data", (chunk: string) => { stdout += chunk; }); + child.stderr!.on("data", (chunk: string) => { stderr += chunk; }); + childClosed = new Promise>( + (resolvePromise) => child!.once("close", (code, signal) => { + resolvePromise({ code, signal }); + }), + ); + + expect(await childClosed).toEqual({ code: 0, signal: null }); + expect(stdout).toBe("ok\n"); + expect(stderr).toBe(""); + child = undefined; + childClosed = undefined; + } finally { + if (child !== undefined) { + child.stdout?.destroy(); + child.stderr?.destroy(); + child.kill("SIGTERM"); + await childClosed?.catch(() => undefined); + } + if (runRoot !== undefined) await rm(runRoot, { force: false, recursive: true }).catch(() => undefined); + await removeOwnedTestBase(base); + } + }, 60_000); + + test("publishes one daemon failure for a valid descriptor and rejected daemon start", async () => { + const base = await privateTestBase(); + let child: ReturnType | undefined; + let childClosed: Promise> | undefined; + let runRoot: string | undefined; + try { + const layout = await createLiveAcceptanceLayout({ temporaryBaseDirectory: base }); + runRoot = layout.runRoot.path; + const descriptor = layout.descriptors.a; + const launch = liveAcceptanceWorkerLaunch(descriptor); + const workerModule = new URL("./live-acceptance-worker.ts", import.meta.url).href; + const harness = [ + `import { runLiveAcceptanceWorkerSupervisorForTest } from ${JSON.stringify(workerModule)};`, + "let daemonStarts = 0;", + "let initializations = 0;", + "const neverReady = new Promise(() => undefined);", + "const exitCode = await runLiveAcceptanceWorkerSupervisorForTest({", + ' kind: "worker_main",', + " initializeWorkerInstallation: async () => { initializations += 1; },", + " runDaemon: async () => {", + " daemonStarts += 1;", + ' throw new Error("synthetic daemon-start rejection");', + " },", + " waitForDaemonReady: async () => await neverReady,", + "});", + "process.exitCode = initializations === 1 && daemonStarts === 1 ? exitCode : 99;", + ].join("\n"); + child = spawn(launch.executable, ["--no-env-file", "-e", harness], { + cwd: launch.cwd, + env: launch.environment, + stdio: [...LIVE_ACCEPTANCE_WORKER_STDIO], + }); + childClosed = new Promise>( + (resolvePromise) => child!.once("close", (code, signal) => { + resolvePromise({ code, signal }); + }), + ); + const lines = createInterface({ input: child.stdout! })[Symbol.asyncIterator](); + + child.stdin!.end(`${JSON.stringify(descriptor)}\n`); + const failedLine = await lines.next(); + expect(failedLine.done).toBe(false); + expect(liveAcceptanceWorkerStatusSchema.parse( + JSON.parse(failedLine.value!) as unknown, + )).toEqual({ + code: "daemon_failed", + device: descriptor.device, + runId: descriptor.runId, + type: "failed", + version: 1, + }); + expect(await lines.next()).toMatchObject({ done: true }); + expect(await childClosed).toEqual({ code: 1, signal: null }); + child = undefined; + childClosed = undefined; + } finally { + if (child !== undefined) { + child.stdin?.destroy(); + child.stdout?.destroy(); + child.kill("SIGTERM"); + await childClosed?.catch(() => undefined); + } + if (runRoot !== undefined) await rm(runRoot, { force: false, recursive: true }).catch(() => undefined); + await removeOwnedTestBase(base); + } + }, 60_000); + test("carries descriptor and fatal controls on stdin with status only on stdout", async () => { const base = await privateTestBase(); let child: ReturnType | undefined; diff --git a/scripts/live-acceptance.ts b/scripts/live-acceptance.ts index 61702c1..5a1edab 100644 --- a/scripts/live-acceptance.ts +++ b/scripts/live-acceptance.ts @@ -187,6 +187,7 @@ export const liveAcceptanceWorkerStatusSchema = z.discriminatedUnion("type", [ "daemon_failed", "descriptor_invalid", "home_changed", + "initialization_failed", "internal_failure", "layout_invalid", "status_unavailable", @@ -905,6 +906,13 @@ export type LiveAcceptanceWorkerLaunch = Readonly<{ executable: string; }>; +type ProcessWorkerLaunch = Readonly<{ + arguments: readonly string[]; + cwd: string; + environment: NodeJS.ProcessEnv; + executable: string; +}>; + export function liveAcceptanceWorkerLaunch( descriptorInput: AcceptanceInstallationDescriptor, ): LiveAcceptanceWorkerLaunch { @@ -976,6 +984,7 @@ class ProcessWorker implements LiveAcceptanceWorker { #statusBuffer = Buffer.alloc(0); #receivedStopped = false; #receivedReady = false; + #shutdownRequested = false; #statusEnded = false; #terminalError: Error | undefined; #stopOperation: Promise | undefined; @@ -1025,9 +1034,12 @@ class ProcessWorker implements LiveAcceptanceWorker { }); } - static async start(descriptorInput: AcceptanceInstallationDescriptor): Promise { + static async start( + descriptorInput: AcceptanceInstallationDescriptor, + launchInput?: ProcessWorkerLaunch, + ): Promise { const descriptor = acceptanceInstallationDescriptorSchema.parse(descriptorInput); - const launch = liveAcceptanceWorkerLaunch(descriptor); + const launch = launchInput ?? liveAcceptanceWorkerLaunch(descriptor); const child = spawn(launch.executable, [...launch.arguments], { cwd: launch.cwd, env: launch.environment, @@ -1082,7 +1094,7 @@ class ProcessWorker implements LiveAcceptanceWorker { async command(commandInput: LocalCommand): Promise { await this.ready(); - this.#assertHealthy(); + this.#assertControlAvailable(); const command = localCommandSchema.parse(commandInput); const requestId = randomUUID(); const result = deferred(); @@ -1101,7 +1113,7 @@ class ProcessWorker implements LiveAcceptanceWorker { options: Readonly<{ protectedDocument?: unknown }> = {}, ): Promise { await this.ready(); - this.#assertHealthy(); + this.#assertControlAvailable(); const requestId = randomUUID(); const result = deferred(); const hasProtectedDocument = Object.hasOwn(options, "protectedDocument"); @@ -1148,6 +1160,7 @@ class ProcessWorker implements LiveAcceptanceWorker { async stop(): Promise { if (this.#stopOperation !== undefined) return await this.#stopOperation; + this.#shutdownRequested = true; this.#stopOperation = (async () => { this.#assertHealthy(); const requestId = randomUUID(); @@ -1161,6 +1174,7 @@ class ProcessWorker implements LiveAcceptanceWorker { } async preserve(): Promise { + this.#shutdownRequested = true; if (this.#child.exitCode === null && this.#child.signalCode === null) { this.#control.end(); } @@ -1173,7 +1187,7 @@ class ProcessWorker implements LiveAcceptanceWorker { async #workerAction(action: "resume" | "suspend"): Promise { await this.ready(); - this.#assertHealthy(); + this.#assertControlAvailable(); const requestId = randomUUID(); const result = deferredSignal(); this.#pending.set(requestId, { action, kind: "ack", result }); @@ -1224,6 +1238,9 @@ class ProcessWorker implements LiveAcceptanceWorker { } #acceptStatus(frame: LiveAcceptanceWorkerStatus): void { + if (this.#terminalError !== undefined) { + throw new LiveAcceptanceError("worker_protocol_invalid"); + } if (frame.type === "ready") { if ( this.#receivedReady @@ -1236,6 +1253,22 @@ class ProcessWorker implements LiveAcceptanceWorker { this.#ready.resolve(); return; } + if (frame.type === "failed") { + const hasDevice = frame.device !== undefined; + const hasRunId = frame.runId !== undefined; + if ( + this.#receivedStopped + || hasDevice !== hasRunId + || (this.#receivedReady && !hasDevice) + || (hasDevice && frame.device !== this.device) + || (hasRunId && frame.runId !== this.#descriptor.runId) + ) throw new LiveAcceptanceError("worker_protocol_invalid"); + this.#fail(new LiveAcceptanceError("worker_failed")); + return; + } + if (!this.#receivedReady || this.#receivedStopped) { + throw new LiveAcceptanceError("worker_protocol_invalid"); + } if (frame.type === "command_result") { const pending = this.#pending.get(frame.requestId); if (pending?.kind !== "command") throw new LiveAcceptanceError("worker_protocol_invalid"); @@ -1259,16 +1292,14 @@ class ProcessWorker implements LiveAcceptanceWorker { pending.result.resolve(); return; } - if (frame.type === "stopped") { - if (frame.device !== this.device || frame.runId !== this.#descriptor.runId) { - throw new LiveAcceptanceError("worker_protocol_invalid"); - } - if (this.#receivedStopped) throw new LiveAcceptanceError("worker_protocol_invalid"); - this.#receivedStopped = true; - this.#stopped.resolve(); - return; + if (frame.device !== this.device || frame.runId !== this.#descriptor.runId) { + throw new LiveAcceptanceError("worker_protocol_invalid"); + } + if (!this.#shutdownRequested || this.#pending.size !== 0) { + throw new LiveAcceptanceError("worker_protocol_invalid"); } - this.#fail(new LiveAcceptanceError("worker_failed")); + this.#receivedStopped = true; + this.#stopped.resolve(); } #fail(error: Error): void { @@ -1288,8 +1319,20 @@ class ProcessWorker implements LiveAcceptanceWorker { throw new LiveAcceptanceError("worker_failed"); } } + + #assertControlAvailable(): void { + this.#assertHealthy(); + if (this.#shutdownRequested || this.#receivedStopped) { + throw new LiveAcceptanceError("worker_failed"); + } + } } +export const startLiveAcceptanceProcessWorkerForTesting = async ( + descriptor: AcceptanceInstallationDescriptor, + launch: ProcessWorkerLaunch, +): Promise => await ProcessWorker.start(descriptor, launch); + const initialReceipt = ( layout: LiveAcceptanceLayout, workers: readonly LiveAcceptanceWorker[] = [], diff --git a/scripts/security-primitives.json b/scripts/security-primitives.json index d154fa7..2bc308b 100644 --- a/scripts/security-primitives.json +++ b/scripts/security-primitives.json @@ -1,4 +1,7 @@ { + "src/claude/account.ts": { + "O_NOFOLLOW": 1 + }, "src/claude/process.ts": { "safeEnvironmentKeys": 2 }, @@ -17,7 +20,7 @@ "src/cli.ts": { "mode0600": 3, "mode0700": 3, - "assertCurrent": 7, + "assertCurrent": 1, "redactCompleteSensitiveText": 1 }, "src/cloud/daemon-adapters.ts": { @@ -39,6 +42,9 @@ "src/cloud/local-control.ts": { "assertCurrent": 20 }, + "src/codex/automations.ts": { + "O_NOFOLLOW": 1 + }, "src/codex/index.ts": { "safeEnvironmentKeys": 1 }, @@ -52,7 +58,7 @@ "O_NOFOLLOW": 1 }, "src/daemon/claude-runtime-adapter.ts": { - "assertCurrent": 5 + "assertCurrent": 8 }, "src/daemon/codex-runtime-adapter.ts": { "redactCompleteSensitiveText": 1 @@ -71,8 +77,12 @@ "mode0600": 6, "validateOwnedFile": 6 }, + "src/daemon/personal-session-discovery.ts": { + "O_NOFOLLOW": 1, + "redactCompleteSensitiveText": 1 + }, "src/daemon/service.ts": { - "assertCurrent": 62 + "assertCurrent": 94 }, "src/daemon/session-event-cursor.ts": { "timingSafeEqual": 1 @@ -150,7 +160,7 @@ "src/storage/state-store.ts": { "O_NOFOLLOW": 2, "mode0600": 3, - "immediateTransaction": 93, + "immediateTransaction": 119, "secureDelete": 2, "synchronousFull": 1, "redactCompleteSensitiveText": 2 @@ -163,7 +173,7 @@ }, "src/storage/work-store.ts": { "assertFence": 6, - "immediateTransaction": 7 + "immediateTransaction": 8 }, "src/streaming-sensitive-text.ts": { "redactCompleteSensitiveText": 2 diff --git a/site/content.test.ts b/site/content.test.ts index e2f9f8f..184391f 100644 --- a/site/content.test.ts +++ b/site/content.test.ts @@ -426,6 +426,9 @@ describe("public content contract", () => { "never applies a Codex reset credit to Devin", "ACP v1 has no in-turn steer method", "HRA never sends concurrent prompts to one Devin session", + "A Devin session can participate in Work coordination records and provider-neutral signal delivery", + "it cannot own or execute a Work attempt", + "Work attempt routes remain Codex-only", ]; for (const claim of claims) { expect(markdown).toContain(claim); @@ -577,6 +580,9 @@ describe("public content contract", () => { "Claude Code account identity and usage are not projected.", "validates one bounded Claude Code authentication-status response transiently", "never retains, returns, projects, or uploads the identity or usage fields", + "Codex and Claude Code personal-session adoption status: whether discovery is enabled and bounded pending, adopted, and fenced counts.", + "Candidate identities and records are never included.", + "Devin has no personal-home adoption surface.", "Devin account identity and allowance are not projected.", "provider-supplied session context and cost facts in the neutral session stream", "For an explicitly requested Codex web login, the provider HTTPS verification URL and separate one-time user code.", @@ -584,6 +590,7 @@ describe("public content contract", () => { "deletes the hosted handoff on that read or after five minutes", "OAuth access or refresh tokens; authorization codes; PKCE verifiers; provider cookies; or the private device code.", "Raw Codex app-server, Claude Code stream, or Devin ACP requests or responses.", + "Personal-home adoption candidate identities or records, personal-runtime bindings, process identities, schedule-source metadata, provider-home provenance, provider-account authority hashes, or the automation id, firing time, and instructions from an exact Codex Desktop heartbeat envelope. Such an envelope is replaced with generic protected text before session content is projected.", "Raw reasoning, hidden chain of thought, or approval secrets.", "Observation-only interaction IDs, kinds, states, revisions, blocking status, and bounded safe summaries.", "Provider-internal login and request IDs, permission values, MCP field contracts, protected answers, or response digests.", diff --git a/site/content.ts b/site/content.ts index d89ecb9..67bfda9 100644 --- a/site/content.ts +++ b/site/content.ts @@ -206,7 +206,8 @@ const privacyBlocks: readonly ContentBlock[] = [ list( [text("User messages and final assistant display text.")], [text("Session names, notes, queued messages, and steering input.")], - [text("Codex account labels and observed provider email and plan metadata when cloud sync is enabled. Claude Code account identity and usage are not projected. HRA validates one bounded Claude Code authentication-status response transiently, reduces it to signedIn, and never retains, returns, projects, or uploads the identity or usage fields; it never opens or parses a Claude credential file. Devin account identity and allowance are not projected. HRA reports only local signed-in readiness and records provider-supplied session context and cost facts in the neutral session stream.")], + [text("Codex account labels and observed provider email and plan metadata when cloud sync is enabled. Claude Code account identity and usage are not projected. For managed profiles, HRA validates one bounded Claude Code authentication-status response transiently, reduces it to signedIn, and never retains, returns, projects, or uploads the identity or usage fields. Personal-home Claude adoption transiently reads bounded account, email, and organization identity metadata and retains only a one-way local authority key. Raw Claude identity fields and that private authority key are never publicly returned, projected, or uploaded; HRA never opens or parses a Claude credential file. Devin account identity and allowance are not projected. HRA reports only local signed-in readiness and records provider-supplied session context and cost facts in the neutral session stream.")], + [text("Codex and Claude Code personal-session adoption status: whether discovery is enabled and bounded pending, adopted, and fenced counts. Candidate identities and records are never included. Devin has no personal-home adoption surface.")], [text("Turn timing, observed model and tier, and provider usage summaries.")], [text("Bounded observed file and Git metadata, without unbounded filesystem paths.")], [text("Observation-only interaction IDs, kinds, states, revisions, blocking status, and bounded safe summaries.")], @@ -217,6 +218,7 @@ const privacyBlocks: readonly ContentBlock[] = [ list( [text("Codex, Claude Code, or Devin credentials; provider profile or configuration files; plugin credentials; OAuth access or refresh tokens; authorization codes; PKCE verifiers; provider cookies; or the private device code.")], [text("Raw Codex app-server, Claude Code stream, or Devin ACP requests or responses.")], + [text("Personal-home adoption candidate identities or records, personal-runtime bindings, process identities, schedule-source metadata, provider-home provenance, provider-account authority hashes, or the automation id, firing time, and instructions from an exact Codex Desktop heartbeat envelope. Such an envelope is replaced with generic protected text before session content is projected.")], [text("Raw reasoning, hidden chain of thought, or approval secrets.")], [text("Provider-internal login and request IDs, permission values, MCP field contracts, protected answers, or response digests.")], [text("Environment variables, arbitrary command output, or unbounded filesystem paths.")], @@ -434,7 +436,7 @@ export const publicContent: PublicContent = { trust: [ { label: "Your provider credentials stay with the provider", - detail: "HRA launches each provider inside its isolated profile. It never reads, copies, or forwards Claude or Devin credentials, and Codex state stays inside its isolated profile.", + detail: "HRA launches managed Codex, Claude Code, and Devin sessions inside isolated profiles. Opted-in Codex and Claude Code personal-session adoption uses existing provider credentials without copying or parsing them. HRA never reads, copies, or forwards a Claude or Devin credential.", }, { label: "Local by default", @@ -468,7 +470,7 @@ export const publicContent: PublicContent = { }, { question: "What if the terminal closes?", - answer: [text("The local daemon keeps the session alive. Reopen the shell, select the account and session, and continue. Claude Code sessions cannot be resumed after the daemon that started them exits.")], + answer: [text("The local daemon keeps the session alive. Reopen the shell, select the account and session, and continue. If the daemon restarts or loses a Claude controller, HRA can recover the exact conversation with "), code("--resume"), text(" only after prior-process exit or an already-completed exact process release is proven. Ambiguous custody stays fenced in recovery without launching another process.")], }, { question: "Which platforms are supported?", @@ -548,6 +550,15 @@ export const publicContent: PublicContent = { "hra daemon start", ], }, + { + kind: "notice", + label: "v0.5 upgrade quarantine", + content: [ + text("The first daemon start after a v0.5-to-v0.6 upgrade migrates local state but never infers provider-account authority that v0.5 did not record immutably. Every affected nonterminal session enters recovery_required: pending or prepared effects are cancelled, begun effects remain uncertain, scheduled work pauses, pending interactions expire while begun responses become resolution-unknown, and associated Work execution is retired or fenced. Provider threads and local records are not deleted. This is not a generic automatic-recovery state. Inspect the session first; use "), + code("hra session abandon "), + text(" only when you accept terminalizing HRA's local session with provider state still unknown."), + ], + }, { kind: "subheading", text: "Optional full local-data removal" }, paragraph( text("Full local-data removal is a separate destructive operation. While HRA remains installed, complete "), @@ -757,7 +768,9 @@ export const publicContent: PublicContent = { ), { kind: "subheading", text: "Claude Code, Devin, and provider switching" }, paragraph( - text("Start directly with Claude Code or Devin by selecting its provider and reviewed preset, or move an idle session among providers. A switch preserves HRA's provider-neutral conversation record but starts a fresh provider-native runtime; it refuses an active turn, an unsettled provider effect, an unsigned target profile, or a preset that belongs to another provider. Claude Code sessions cannot be resumed after the daemon that started them exits. Devin uses exact ACP v1 session loading after restart only when its initialization advertised that capability."), + text("Start directly with Claude Code or Devin by selecting its provider and reviewed preset, or move an idle session among providers. A switch preserves HRA's provider-neutral conversation record but starts a fresh provider-native runtime; it refuses an active turn, an unsettled provider effect, an unsigned target profile, or a preset that belongs to another provider. If a Claude controller is no longer available, HRA can recover the exact conversation with "), + code("--resume"), + text(" only after prior-process exit or an already-completed exact process release is proven. Ambiguous custody stays fenced in recovery without launching another process. Devin uses exact ACP v1 session loading after restart only when its initialization advertised that capability."), ), { kind: "commands", @@ -810,6 +823,9 @@ export const publicContent: PublicContent = { paragraph( text("The frozen source contract defines a narrow local coordination kernel for agents operating several already-existing provider sessions. It records six bounded objects: work, tasks, attempts, submissions, reviews, and signals. Codex, Claude Code, and Devin still own their provider-native execution, turns, tools, context, and approvals. HRA does not add a second model loop or a generic executable workflow engine."), ), + paragraph( + text("A Devin session can participate in Work coordination records and provider-neutral signal delivery, but it cannot own or execute a Work attempt. Work attempt routes remain Codex-only."), + ), { kind: "commands", commands: [ @@ -932,13 +948,13 @@ export const publicContent: PublicContent = { text(". The document is never an argument."), ), paragraph( - text("The CLI stores HRA's revocable device credential, workspace encryption key, and local signing authority as immutable generations below its private state root. Custody directories are current-user-owned mode-0700 directories, values are single-link mode-0600 files, and reads use bounded no-follow descriptors. The detached Bun daemon never opens a Keychain prompt. HRA forces both pinned Codex credential stores to file mode and verifies their effective settings, so Codex credentials remain separately owned by each profile's isolated "), + text("The CLI stores HRA's revocable device credential, workspace encryption key, and local signing authority as immutable generations below its private state root. Custody directories are current-user-owned mode-0700 directories, values are single-link mode-0600 files, and reads use bounded no-follow descriptors. The detached Bun daemon never opens a Keychain prompt. HRA forces both pinned Codex credential stores to file mode and verifies their effective settings. Managed Codex accounts keep credentials in each profile's isolated "), code("CODEX_HOME"), text(". Claude Code receives that profile's isolated "), code("CLAUDE_CONFIG_DIR"), text("; HRA treats the whole directory as Claude's authentication boundary and never reads, copies, or forwards its credentials. Devin receives distinct private "), code("HOME"), - text(" and four XDG roots; HRA passes those paths to the CLI but never opens, copies, or forwards Devin's credential. Provider-managed credential storage remains owned by the provider runtime."), + text(" and four XDG roots; HRA passes those paths to the CLI but never opens, copies, or forwards Devin's credential. Explicitly adopted Codex and Claude Code personal sessions use credentials already owned by the user's personal provider home without copying or parsing them. Provider-managed credential storage remains owned by the provider runtime."), ), paragraph( text("After successful email verification, the daemon automatically registers the current installation before it reads cloud data. The first registered device becomes active and creates the client-side encryption key. A later verified installation is registered as pending and may report presence, but it has no synchronized data, execution, or key authority."), @@ -1020,7 +1036,12 @@ export const publicContent: PublicContent = { text("Compact sessions: list sessions, read user and final assistant messages, inspect elapsed time plus bounded observed file and Git actions, then open one turn for full provider-visible detail."), ], [ - text("Durable controls: send, queue, steer, stop, rename, and keep one editable note per session. Provider and desktop effects use exact authority, idempotency keys, and process-generation fencing."), + text("Personal-home adoption: opt in to discover recent Codex and Claude Code sessions, plus older Codex threads targeted by present Desktop heartbeat automations, then admit them after bounded account, project, liveness, and exact-resume checks. Active and paused automation records both count until deletion or retargeting. HRA locally parses a bounded automation record but ignores and retains no prompt or working-directory field, keeps later records reachable across daemon restarts, and replaces Desktop's exact fired heartbeat envelope with generic protected text before projection. Account-filtered session lists include admitted rows, which use the same provider-supported public commands, autorespond policy, and approval authority as every HRA session. Provider-specific limits are identical for native and adopted sessions, and provider APIs do not supply a global lease against every later external resume. Read "), + link("the session-adoption guide", "https://github.com/hraness/hra/blob/main/docs/session-adoption.md"), + text("."), + ], + [ + text("Durable controls: send, queue, steer, and stop through either provider; rename Codex sessions; and keep one editable note per session. Provider and desktop effects use exact authority, idempotency keys, and process-generation fencing."), ], [ text("Named projects: a project is a canonical directory that may contain several repositories. Changing it affects future turns only."), @@ -1062,7 +1083,9 @@ export const publicContent: PublicContent = { { kind: "subheading", text: "Session observation" }, paragraph( code("hra session status --json"), - text(" returns status version 2. HRA produces one typed provider-observation result, attempting the bound provider's reviewed observation path only when the current local state makes one applicable, then reads the session, event cut, interactions, and queue from one local SQLite transaction. Codex supports a native app-server observation read. Claude Code and Devin status use HRA's live provider-neutral projection; Claude exposes no admitted provider-side session listing or resume, while Devin reloads an exact native session only when its ACP initialization advertises that capability. Execution, attention, provider, and queue remain separate axes, so a headline state cannot hide a recovery condition, pending interaction, response in flight, or queued work. Pending and response-in-flight counts are exact. The result includes at most 10 bounded safe summaries for pending interactions and excludes the session note and private provider thread binding. Every provider turn and item identifier becomes a secret-keyed opaque public alias before status, event, or interaction output. Public observation schemas accept only that exact alias form. The same local installation key keeps aliases coherent across surfaces and daemon restarts without making low-entropy provider IDs guessable from public output. If an existing installation loses that key, HRA refuses to replace it and directs the operator to restore the original local secret."), + text(" returns status version 2. HRA produces one typed provider-observation result, attempting the bound provider's reviewed observation path only when the current local state makes one applicable, then reads the session, event cut, interactions, and queue from one local SQLite transaction. Codex supports a native app-server observation read. Claude Code uses its live provider-neutral projection while the exact controller is present. If that controller is absent, HRA may establish "), + code("--resume"), + text(" for the exact conversation only after prior-process exit or an already-completed exact process release is proven; ambiguous custody fails closed as recovery required. Devin status also uses HRA's live provider-neutral projection and reloads an exact native session only when its ACP initialization advertised that capability. Execution, attention, provider, and queue remain separate axes, so a headline state cannot hide a recovery condition, pending interaction, response in flight, or queued work. Pending and response-in-flight counts are exact. The result includes at most 10 bounded safe summaries for pending interactions and excludes the session note and private provider thread binding. Every provider turn and item identifier becomes a secret-keyed opaque public alias before status, event, or interaction output. Public observation schemas accept only that exact alias form. The same local installation key keeps aliases coherent across surfaces and daemon restarts without making low-entropy provider IDs guessable from public output. If an existing installation loses that key, HRA refuses to replace it and directs the operator to restore the original local secret."), ), paragraph( code("hra session state --json"), @@ -1363,6 +1386,10 @@ export const publicContent: PublicContent = { "hra project list", "hra project use ", "hra session list [--account ] [--archived] [--limit <1..100>] [--cursor ]", + "hra session adoption status [--provider ]", + "hra session adoption enable --provider ", + "hra session adoption disable --provider ", + "hra session discover [--provider ]", "hra session show [--detail]", "hra session status [--json]", "hra session watch [--cursor ] [--jsonl]", diff --git a/src/claude/AGENTS.md b/src/claude/AGENTS.md index ac15f89..66bd8e4 100644 --- a/src/claude/AGENTS.md +++ b/src/claude/AGENTS.md @@ -2,7 +2,7 @@ - Pin holds the one exact Claude Code version, Fable model id, reasoning effort, and reviewed stream-json matrix digests. - Runtime discovery locates and version-admits the pinned `claude` executable and builds its exact argv. -- Process spawns that executable under an isolated absolute `CLAUDE_CONFIG_DIR` with the same environment allowlist Codex uses. +- Process spawns that executable with the same environment allowlist Codex uses. Managed profiles set a reviewed absolute `CLAUDE_CONFIG_DIR`; the explicitly bound personal profile omits that variable so Claude resolves its canonical default home itself. The runtime profile records which mode was used. - Protocol parses every stream-json line from `unknown` into a closed union and maps `can_use_tool` onto HRA interaction kinds. - Assembler is the delta assembler: it owns turn, item, and subagent identity and emits the closed fact vocabulary the runtime consumes. - Client owns one process, is the only writer of its stdin, and brokers control responses, steering, and interrupts. @@ -13,5 +13,5 @@ - Keep the split the bridge captures: this layer knows Claude's dialect, the daemon knows the timeline. Never leak a Claude wire shape past the fact vocabulary. - Parse every foreign value from `unknown` with explicit bounds. An unrecognised event becomes a bounded protocol notice, never a silent accept and never a thrown fault on a live session. - Answer a `can_use_tool` request only by echoing its own `input`. Never send `permission_suggestions`, so HRA can grant nothing beyond `once`. -- Never read, copy, or forward a Claude credential. The isolated `CLAUDE_CONFIG_DIR` is the whole authentication boundary. +- Never read, copy, or forward a Claude credential. The authentication boundary is the reviewed isolated `CLAUDE_CONFIG_DIR` for managed profiles or Claude's canonical default-home resolution for the explicitly bound personal profile. - Sanitize every provider string that can reach a display, projection, or log: absolute paths reduced, credential-shaped runs replaced, unsafe terminal scalars folded. diff --git a/src/claude/account.test.ts b/src/claude/account.test.ts new file mode 100644 index 0000000..b9840fa --- /dev/null +++ b/src/claude/account.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, test } from "bun:test"; +import { constants } from "node:fs"; +import { chmod, mkdir, mkdtemp, open, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + claudeAccountDocumentPath, + readClaudeAccountProjection, +} from "./account"; +import { CLAUDE_PIN, CLAUDE_PIN_EFFORT, CLAUDE_PIN_MODEL } from "./pin"; +import type { PinnedClaudeRuntime } from "./runtime"; + +const runtime: PinnedClaudeRuntime = Object.freeze({ + argv: ["/synthetic/bin/claude"] as const, + effort: CLAUDE_PIN_EFFORT, + executablePath: "/synthetic/bin/claude", + model: CLAUDE_PIN_MODEL, + version: CLAUDE_PIN, +}); + +const accountMetadata = (identity: Readonly> = {}) => ({ + oauthAccount: { + accountUuid: " Account-A ", + emailAddress: " Account-A@Example.test ", + organizationUuid: " Organization-A ", + ...identity, + }, +}); + +function fifoTestsSupported(): boolean { + return process.platform !== "win32" && Bun.which("mkfifo") !== null; +} + +function makeFifo(path: string): void { + const created = Bun.spawnSync({ cmd: ["mkfifo", path] }); + if (created.exitCode !== 0) { + throw new Error(`mkfifo failed: ${new TextDecoder().decode(created.stderr)}`); + } +} + +async function rejectBeforeFifoWriter(pending: Promise, fifo: string): Promise { + type Outcome = + | Readonly<{ kind: "blocked" }> + | Readonly<{ kind: "rejected"; error: unknown }> + | Readonly<{ kind: "resolved" }>; + const completion: Promise = pending.then( + () => ({ kind: "resolved" }), + (error: unknown) => ({ kind: "rejected", error }), + ); + let timer: ReturnType | undefined; + const blocked = new Promise((resolve) => { + timer = setTimeout(() => resolve({ kind: "blocked" }), 1_000); + }); + const outcome = await Promise.race([completion, blocked]); + if (timer !== undefined) clearTimeout(timer); + if (outcome.kind === "rejected") { + expect(outcome.error).toMatchObject({ code: "AUTHORITY_STALE" }); + return; + } + if (outcome.kind === "resolved") { + throw new Error("Claude account FIFO was accepted as metadata."); + } + + const writer = await open(fifo, constants.O_WRONLY | constants.O_NONBLOCK); + await writer.close(); + await completion; + throw new Error("Claude account FIFO open blocked instead of failing promptly."); +} + +describe("Claude account projection", () => { + test("uses the documented personal and isolated account document paths", () => { + expect(claudeAccountDocumentPath("/synthetic/.claude", "personal")) + .toBe("/synthetic/.claude.json"); + expect(claudeAccountDocumentPath("/synthetic/profiles/work", "isolated")) + .toBe("/synthetic/profiles/work/.claude.json"); + expect(() => claudeAccountDocumentPath("relative", "isolated")) + .toThrow("must be absolute"); + }); + + test("fences a stable pre-status-post identity and projects account organization metadata", async () => { + const calls: string[] = []; + const statusInputs: unknown[] = []; + const projection = await readClaudeAccountProjection({ + configDir: "/synthetic/profiles/work", + configHome: "isolated", + runtime, + signal: new AbortController().signal, + readMetadata: async (path) => { + calls.push(`metadata:${path}`); + return accountMetadata(); + }, + probeAuthStatus: async (input) => { + calls.push("status"); + statusInputs.push(input); + return { loggedIn: true }; + }, + }); + + expect(calls).toEqual([ + "metadata:/synthetic/profiles/work/.claude.json", + "status", + "metadata:/synthetic/profiles/work/.claude.json", + ]); + expect(statusInputs).toEqual([{ + configDir: "/synthetic/profiles/work", + configHome: "isolated", + runtime, + signal: expect.any(AbortSignal), + }]); + expect(projection).toEqual({ + accountId: "account-a", + email: "account-a@example.test", + organizationId: "organization-a", + signedIn: true, + }); + }); + + test("does not project metadata when the current status is signed out", async () => { + const projection = await readClaudeAccountProjection({ + configDir: "/synthetic/.claude", + configHome: "personal", + runtime, + signal: new AbortController().signal, + readMetadata: async () => accountMetadata(), + probeAuthStatus: async () => ({ loggedIn: false }), + }); + + expect(projection).toEqual({ signedIn: false }); + }); + + test("rejects an identity swap across the protected status read", async () => { + let reads = 0; + const projection = readClaudeAccountProjection({ + configDir: "/synthetic/.claude", + configHome: "personal", + runtime, + signal: new AbortController().signal, + readMetadata: async () => { + reads += 1; + return accountMetadata({ accountUuid: reads === 1 ? "account-a" : "account-b" }); + }, + probeAuthStatus: async () => ({ loggedIn: true }), + }); + + await expect(projection).rejects.toMatchObject({ code: "AUTHORITY_STALE" }); + }); + + test("rejects bounded and unsafe metadata before trusting status", async () => { + for (const accountUuid of ["x".repeat(321), "account\u0000-a"]) { + let probed = false; + const projection = readClaudeAccountProjection({ + configDir: "/synthetic/.claude", + configHome: "personal", + runtime, + signal: new AbortController().signal, + readMetadata: async () => accountMetadata({ accountUuid }), + probeAuthStatus: async () => { + probed = true; + return { loggedIn: true }; + }, + }); + + await expect(projection).rejects.toMatchObject({ code: "PROTOCOL_ERROR" }); + expect(probed).toBeFalse(); + } + }); + + test("rejects a metadata document that fails its custody mode check", async () => { + const root = await mkdtemp(join(tmpdir(), "hra-claude-account-")); + const configDir = join(root, "config"); + const accountPath = claudeAccountDocumentPath(configDir, "isolated"); + try { + await mkdir(configDir, { recursive: true, mode: 0o700 }); + await writeFile(accountPath, JSON.stringify(accountMetadata()), { mode: 0o644 }); + await chmod(accountPath, 0o644); + let probed = false; + const projection = readClaudeAccountProjection({ + configDir, + configHome: "isolated", + runtime, + signal: new AbortController().signal, + probeAuthStatus: async () => { + probed = true; + return { loggedIn: true }; + }, + }); + + await expect(projection).rejects.toMatchObject({ code: "AUTHORITY_STALE" }); + expect(probed).toBeFalse(); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + test("refuses a swapped account FIFO without waiting for a writer", async () => { + if (!fifoTestsSupported()) return; + const root = await mkdtemp(join(tmpdir(), "hra-claude-account-fifo-")); + const configDir = join(root, "config"); + const accountPath = claudeAccountDocumentPath(configDir, "isolated"); + try { + await mkdir(configDir, { recursive: true, mode: 0o700 }); + makeFifo(accountPath); + let probed = false; + const projection = readClaudeAccountProjection({ + configDir, + configHome: "isolated", + runtime, + signal: new AbortController().signal, + probeAuthStatus: async () => { + probed = true; + return { loggedIn: true }; + }, + }); + + await rejectBeforeFifoWriter(projection, accountPath); + expect(probed).toBeFalse(); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); +}); diff --git a/src/claude/account.ts b/src/claude/account.ts new file mode 100644 index 0000000..6b2c013 --- /dev/null +++ b/src/claude/account.ts @@ -0,0 +1,324 @@ +import { constants } from "node:fs"; +import { open } from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; + +import { ClaudeError } from "./errors.ts"; +import { allowlistedEnvironment } from "./process.ts"; +import type { PinnedClaudeRuntime } from "./runtime.ts"; + +export type ClaudeConfigurationHome = "isolated" | "personal"; + +export type ClaudeAccountProjection = Readonly<{ + signedIn: boolean; + accountId?: string; + email?: string; + organizationId?: string; +}>; + +export type ClaudeAccountMetadataReader = (path: string) => Promise; + +export type ClaudeAuthStatusProbe = (input: Readonly<{ + configDir: string; + configHome: ClaudeConfigurationHome; + runtime: PinnedClaudeRuntime; + signal: AbortSignal; +}>) => Promise; + +const ACCOUNT_DOCUMENT_MAX_BYTES = 128 * 1_024; +const AUTH_STATUS_MAX_BYTES = 16 * 1_024; +const AUTH_STATUS_TIMEOUT_MS = 3_000; +const ACCOUNT_IDENTITY_MAX_BYTES = 320; +const encoder = new TextEncoder(); + +type ClaudeAccountIdentity = Readonly<{ + accountUuid: string | null; + email: string | null; + organizationUuid: string | null; +}>; + +/** + * Claude's default home is asymmetric: sessions live in `~/.claude`, while + * the non-secret account metadata document lives at `~/.claude.json`. + * Explicit `CLAUDE_CONFIG_DIR` homes place that document inside the selected + * directory. Keep this distinction closed and testable instead of guessing at + * call sites. + */ +export function claudeAccountDocumentPath( + configDir: string, + configHome: ClaudeConfigurationHome, +): string { + if (!isAbsolute(configDir)) { + throw new ClaudeError("INVALID_INPUT", "The Claude configuration home must be absolute."); + } + return configHome === "personal" + ? `${configDir}.json` + : join(configDir, ".claude.json"); +} + +/** + * Proves a currently authenticated Claude account without reading a token. + * `claude auth status --json` supplies current sign-in state; two no-follow + * reads of the scalar-only account metadata fence an identity change across + * that status probe. API-key and provider modes remain signed in but carry no + * stable email, so the daemon can refuse to grant session authority to them. + */ +export async function readClaudeAccountProjection(input: Readonly<{ + configDir: string; + configHome: ClaudeConfigurationHome; + runtime: PinnedClaudeRuntime; + signal: AbortSignal; + readMetadata?: ClaudeAccountMetadataReader; + probeAuthStatus?: ClaudeAuthStatusProbe; +}>): Promise { + input.signal.throwIfAborted(); + const accountPath = claudeAccountDocumentPath(input.configDir, input.configHome); + const readMetadata = input.readMetadata ?? readAccountMetadataDocument; + const probeAuthStatus = input.probeAuthStatus ?? spawnClaudeAuthStatusProbe; + const before = parseAccountIdentity(await readMetadata(accountPath)); + const status = parseAuthStatus(await probeAuthStatus({ + configDir: input.configDir, + configHome: input.configHome, + runtime: input.runtime, + signal: input.signal, + })); + input.signal.throwIfAborted(); + const after = parseAccountIdentity(await readMetadata(accountPath)); + if (!sameAccountIdentity(before, after)) { + throw new ClaudeError( + "AUTHORITY_STALE", + "Claude account identity changed during its protected status read.", + ); + } + if (!status.signedIn) return Object.freeze({ signedIn: false }); + if (before === null) return Object.freeze({ signedIn: true }); + return Object.freeze({ + signedIn: true, + ...(before.accountUuid === null ? {} : { accountId: before.accountUuid }), + ...(before.email === null ? {} : { email: before.email }), + ...(before.organizationUuid === null + ? {} + : { organizationId: before.organizationUuid }), + }); +} + +export const spawnClaudeAuthStatusProbe: ClaudeAuthStatusProbe = async (input) => { + input.signal.throwIfAborted(); + const env = allowlistedEnvironment(process.env); + if (input.configHome === "isolated") env.CLAUDE_CONFIG_DIR = input.configDir; + env.NO_COLOR = "1"; + const child = Bun.spawn([ + input.runtime.executablePath, + "auth", + "status", + "--json", + ], { + env, + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + }); + const stdout = collectBoundedStdout(child.stdout, AUTH_STATUS_MAX_BYTES); + const completion = Promise.all([child.exited, stdout]); + void completion.catch(() => undefined); + let rejectBoundary!: (reason: unknown) => void; + const boundary = new Promise((_resolve, reject) => { + rejectBoundary = reject; + }); + const stop = (reason: unknown): void => { + try { + child.kill("SIGKILL"); + } catch { + // The closed failure below remains authoritative. + } + rejectBoundary(reason); + }; + const onAbort = (): void => stop(input.signal.reason); + input.signal.addEventListener("abort", onAbort, { once: true }); + const timer = setTimeout( + () => stop(new ClaudeError("TIMEOUT", "Claude account status did not settle in time.")), + AUTH_STATUS_TIMEOUT_MS, + ); + timer.unref(); + try { + const [exitCode, text] = await Promise.race([completion, boundary]); + if (exitCode !== 0) { + throw new ClaudeError("AUTHORITY_STALE", "Claude account status was unavailable."); + } + try { + return JSON.parse(text) as unknown; + } catch (cause: unknown) { + throw new ClaudeError("PROTOCOL_ERROR", "Claude account status was invalid.", { cause }); + } + } finally { + clearTimeout(timer); + input.signal.removeEventListener("abort", onAbort); + } +}; + +async function readAccountMetadataDocument(path: string): Promise { + let handle: FileHandle; + try { + // The personal-home path is user-controlled and can change between scans. + // Open nonblocking so a FIFO swapped in before stat cannot stall daemon + // admission, then keep every byte read beneath the reviewed bound even if + // a regular file grows after the first descriptor check. + handle = await open( + path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ); + } catch (error: unknown) { + if (errorCode(error) === "ENOENT") return null; + throw new ClaudeError("AUTHORITY_STALE", "Claude account metadata was unavailable.", { + cause: error, + }); + } + try { + const metadata = await handle.stat(); + const uid = process.getuid?.(); + if ( + !metadata.isFile() + || metadata.nlink !== 1 + || metadata.size < 2 + || metadata.size > ACCOUNT_DOCUMENT_MAX_BYTES + || (metadata.mode & 0o077) !== 0 + || (uid !== undefined && metadata.uid !== uid) + ) { + throw new ClaudeError("AUTHORITY_STALE", "Claude account metadata failed its custody checks."); + } + const bytes = new Uint8Array(ACCOUNT_DOCUMENT_MAX_BYTES + 1); + let filled = 0; + while (filled < bytes.length) { + const read = await handle.read(bytes, filled, bytes.length - filled, filled); + if (read.bytesRead === 0) break; + filled += read.bytesRead; + } + const settled = await handle.stat(); + if (filled > ACCOUNT_DOCUMENT_MAX_BYTES) { + throw new ClaudeError("AUTHORITY_STALE", "Claude account metadata exceeded its size bound."); + } + if ( + settled.dev !== metadata.dev + || settled.ino !== metadata.ino + || settled.size !== metadata.size + || settled.mtimeMs !== metadata.mtimeMs + || settled.ctimeMs !== metadata.ctimeMs + ) { + throw new ClaudeError( + "AUTHORITY_STALE", + "Claude account metadata changed during its bounded read.", + ); + } + try { + return JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, filled)), + ) as unknown; + } catch (cause: unknown) { + throw new ClaudeError("PROTOCOL_ERROR", "Claude account metadata was invalid.", { cause }); + } + } finally { + await handle.close().catch(() => undefined); + } +} + +function parseAuthStatus(value: unknown): Readonly<{ signedIn: boolean }> { + if (!isRecord(value) || typeof value.loggedIn !== "boolean") { + throw new ClaudeError("PROTOCOL_ERROR", "Claude account status omitted its sign-in state."); + } + return Object.freeze({ signedIn: value.loggedIn }); +} + +function parseAccountIdentity(value: unknown): ClaudeAccountIdentity | null { + if (value === null) return null; + if (!isRecord(value)) { + throw new ClaudeError("PROTOCOL_ERROR", "Claude account metadata was not an object."); + } + if (value.oauthAccount === undefined) return null; + if (!isRecord(value.oauthAccount)) { + throw new ClaudeError("PROTOCOL_ERROR", "Claude account metadata contained an invalid identity."); + } + const email = optionalIdentityScalar(value.oauthAccount.emailAddress, true); + const accountUuid = optionalIdentityScalar(value.oauthAccount.accountUuid, false); + const organizationUuid = optionalIdentityScalar(value.oauthAccount.organizationUuid, false); + return Object.freeze({ accountUuid, email, organizationUuid }); +} + +function optionalIdentityScalar(value: unknown, email: boolean): string | null { + if (value === undefined || value === null) return null; + if (typeof value !== "string") { + throw new ClaudeError("PROTOCOL_ERROR", "Claude account metadata contained an invalid scalar."); + } + const normalized = value.trim().toLowerCase(); + if ( + normalized.length === 0 + || encoder.encode(normalized).byteLength > ACCOUNT_IDENTITY_MAX_BYTES + || hasAsciiControlCharacter(normalized) + || (email && !/^[^@\s]+@[^@\s]+$/u.test(normalized)) + ) { + throw new ClaudeError("PROTOCOL_ERROR", "Claude account metadata contained an invalid scalar."); + } + return normalized; +} + +function hasAsciiControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +function sameAccountIdentity( + left: ClaudeAccountIdentity | null, + right: ClaudeAccountIdentity | null, +): boolean { + return left?.accountUuid === right?.accountUuid + && left?.email === right?.email + && left?.organizationUuid === right?.organizationUuid; +} + +async function collectBoundedStdout( + stream: ReadableStream | number | undefined, + maximumBytes: number, +): Promise { + if (stream === undefined || typeof stream === "number") { + throw new ClaudeError("PROCESS_EXITED", "Claude account status exposed no stdout stream."); + } + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const next = await reader.read(); + if (next.done) break; + total += next.value.byteLength; + if (total > maximumBytes) { + throw new ClaudeError("PROTOCOL_LIMIT", "Claude account status exceeded its output bound."); + } + if (next.value.byteLength > 0) chunks.push(next.value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (cause: unknown) { + throw new ClaudeError("PROTOCOL_ERROR", "Claude account status was not UTF-8.", { cause }); + } +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function errorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error + ? String((error as { code?: unknown }).code) + : undefined; +} diff --git a/src/claude/assembler.ts b/src/claude/assembler.ts index b428375..5ef92e5 100644 --- a/src/claude/assembler.ts +++ b/src/claude/assembler.ts @@ -24,6 +24,10 @@ export type ClaudeFact = readonly permissionMode: string; readonly claudeVersion: string; } + | { + readonly type: "providerDisconnected"; + readonly reason: "eof" | "process_exit" | "protocol_fault"; + } | { readonly type: "turnStarted"; readonly turnId: string } | { readonly type: "assistantDelta"; diff --git a/src/claude/client.test.ts b/src/claude/client.test.ts index 1fa36d9..406a053 100644 --- a/src/claude/client.test.ts +++ b/src/claude/client.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "bun:test"; import type { ClaudeFact } from "./assembler"; import { ClaudeStreamClient } from "./client"; import { ClaudeError } from "./errors"; -import type { ClaudeProcess } from "./process"; +import type { ClaudeProcess, ClaudeProcessIdentity } from "./process"; const CONFIG_DIR = "/var/hra/profiles/acct/claude"; @@ -13,17 +13,39 @@ const CONFIG_DIR = "/var/hra/profiles/acct/claude"; * these tests run identically on a machine with no Claude Code installed. */ class FakeClaudeProcess implements ClaudeProcess { + readonly identity: Promise = Promise.resolve(Object.freeze({ + pid: 8_123, + pidDomain: "darwin", + procStart: "Fri Sep 4 12:00:00 2026", + })); readonly written: string[] = []; + readonly signals: string[] = []; + onWrite: (() => void) | undefined; + writeSettlementGate: Promise | undefined; terminated = false; + readonly #ignoreTerm: boolean; + readonly #ignoreKill: boolean; + readonly #leaveStreamsOpenAfterKill: boolean; #resolveExit: ((code: number) => void) | undefined; + #rejectExit: ((error: Error) => void) | undefined; #push: ((chunk: Uint8Array) => void) | undefined; #finish: (() => void) | undefined; readonly exited: Promise; readonly stdout: AsyncIterable; readonly stderr: AsyncIterable = { async *[Symbol.asyncIterator]() { /* silent */ } }; - constructor() { - this.exited = new Promise((resolve) => { this.#resolveExit = resolve; }); + constructor(options: Readonly<{ + ignoreTerm?: boolean; + ignoreKill?: boolean; + leaveStreamsOpenAfterKill?: boolean; + }> = {}) { + this.#ignoreTerm = options.ignoreTerm ?? false; + this.#ignoreKill = options.ignoreKill ?? false; + this.#leaveStreamsOpenAfterKill = options.leaveStreamsOpenAfterKill ?? false; + this.exited = new Promise((resolve, reject) => { + this.#resolveExit = resolve; + this.#rejectExit = reject; + }); const queue: Uint8Array[] = []; let waiter: (() => void) | undefined; let done = false; @@ -51,17 +73,30 @@ class FakeClaudeProcess implements ClaudeProcess { this.#resolveExit?.(0); } + failExitProof(): void { + this.#rejectExit?.(new Error("exit proof unavailable")); + } + async write(bytes: Uint8Array): Promise { this.written.push(new TextDecoder().decode(bytes)); + this.onWrite?.(); + const gate = this.writeSettlementGate; + this.writeSettlementGate = undefined; + if (gate !== undefined) await gate; } terminate(): void { this.terminated = true; - this.end(); + this.signals.push("SIGTERM"); + if (!this.#ignoreTerm) this.end(); } forceTerminate(): void { - this.terminate(); + this.terminated = true; + this.signals.push("SIGKILL"); + if (this.#ignoreKill) return; + if (this.#leaveStreamsOpenAfterKill) this.#resolveExit?.(137); + else this.end(); } } @@ -90,6 +125,48 @@ const writtenLines = (process: FakeClaudeProcess): readonly unknown[] => chunk.split("\n").filter((line) => line.length > 0).map((line) => JSON.parse(line) as unknown)); describe("Claude stream client", () => { + test("waits for one bounded, abortable initialization identity", async () => { + const { client, process } = open(); + const initialization = client.waitForInitialization({ + signal: new AbortController().signal, + timeoutMs: 1_000, + }); + process.emit({ + claude_code_version: "2.1.260", + model: "claude-fable-5-1", + permissionMode: "default", + session_id: "726b1b3d-ed97-4b55-9904-e58fa7d7eb45", + subtype: "init", + tools: [], + type: "system", + }); + await expect(initialization).resolves.toEqual({ + claudeVersion: "2.1.260", + model: "claude-fable-5-1", + permissionMode: "default", + providerSessionId: "726b1b3d-ed97-4b55-9904-e58fa7d7eb45", + }); + await client.close(); + }); + + test("refuses a silent or aborted initialization wait", async () => { + const silent = open(); + await expect(silent.client.waitForInitialization({ + signal: new AbortController().signal, + timeoutMs: 1, + })).rejects.toMatchObject({ code: "TIMEOUT" }); + await silent.client.close(); + + const aborted = open(); + const controller = new AbortController(); + controller.abort(new Error("caller stopped")); + await expect(aborted.client.waitForInitialization({ + signal: controller.signal, + timeoutMs: 1_000, + })).rejects.toThrow("caller stopped"); + await aborted.client.close(); + }); + test("drives one full turn: user line in, deltas and a result out", async () => { const { client, facts, process } = open(); await client.startTurn({ message: "say ok", turnId: "turn-1" }); @@ -309,12 +386,13 @@ describe("Claude stream client", () => { "turnStarted", "providerError", "turnCompleted", + "providerDisconnected", ]); - expect(facts.at(-1)).toMatchObject({ status: "failed" }); + expect(facts.at(-2)).toMatchObject({ status: "failed" }); await client.close(); }); - test("requires an absolute isolated config directory", () => { + test("requires an absolute reviewed config directory", () => { expect(() => new ClaudeStreamClient({ configDir: "relative/home", onFact: () => undefined, @@ -328,6 +406,93 @@ describe("Claude stream client", () => { await expect(client.startTurn({ message: "x", turnId: "t" })).rejects.toThrow(ClaudeError); }); + test("requires exact exit settlement after KILL and permits an exact close retry", async () => { + const process = new FakeClaudeProcess({ + ignoreKill: true, + ignoreTerm: true, + leaveStreamsOpenAfterKill: true, + }); + const diagnostics: string[] = []; + const client = new ClaudeStreamClient({ + configDir: CONFIG_DIR, + onFact: () => undefined, + onSafeDiagnostic: (message) => { diagnostics.push(message); }, + process, + shutdownSettlementMs: 5, + shutdownTermGraceMs: 5, + }); + await expect(Promise.all([client.close(), client.close()])).rejects.toMatchObject({ + code: "TIMEOUT", + }); + expect(process.signals).toEqual(["SIGTERM", "SIGKILL"]); + + process.end(); + await expect(client.close()).resolves.toBeUndefined(); + expect(process.signals).toEqual(["SIGTERM", "SIGKILL", "SIGTERM"]); + }); + + test("fences writes when process exit settlement becomes indeterminate", async () => { + const process = new FakeClaudeProcess(); + const diagnostics: string[] = []; + const client = new ClaudeStreamClient({ + configDir: CONFIG_DIR, + onFact: () => undefined, + onSafeDiagnostic: (message) => { diagnostics.push(message); }, + process, + shutdownSettlementMs: 5, + shutdownTermGraceMs: 5, + }); + process.failExitProof(); + await settle(); + + await expect(client.startTurn({ message: "must not write", turnId: "turn-1" })) + .rejects.toMatchObject({ code: "PROCESS_EXITED" }); + expect(process.written).toHaveLength(0); + expect(diagnostics).toContain("Claude process exit settlement was indeterminate"); + await expect(client.close()).rejects.toMatchObject({ code: "TIMEOUT" }); + }); + + test("refuses a queued frame when the process authority is fenced before its write begins", async () => { + const process = new FakeClaudeProcess(); + const client = new ClaudeStreamClient({ + configDir: CONFIG_DIR, + onFact: () => undefined, + process, + shutdownSettlementMs: 5, + shutdownTermGraceMs: 5, + }); + let releaseFirstWrite!: () => void; + let markFirstWriteStarted!: () => void; + const firstWriteGate = new Promise((resolve) => { releaseFirstWrite = resolve; }); + const firstWriteStarted = new Promise((resolve) => { markFirstWriteStarted = resolve; }); + process.writeSettlementGate = firstWriteGate; + process.onWrite = markFirstWriteStarted; + + const first = client.startTurn({ message: "first", turnId: "turn-1" }); + await firstWriteStarted; + process.onWrite = undefined; + const queued = client.steer("must stay fenced").then( + () => null, + (error: unknown) => error, + ); + process.failExitProof(); + await settle(); + releaseFirstWrite(); + + await expect(first).resolves.toBeUndefined(); + expect(await queued).toMatchObject({ code: "PROCESS_EXITED" }); + expect(writtenLines(process)).toEqual([ + { + message: { + content: [{ text: "first", type: "text" }], + role: "user", + }, + type: "user", + }, + ]); + await expect(client.close()).rejects.toMatchObject({ code: "TIMEOUT" }); + }); + test("force-terminates and exactly joins a session child that ignores TERM", async () => { const process = new FakeClaudeProcess(); let forceTerminations = 0; diff --git a/src/claude/client.ts b/src/claude/client.ts index ea9bb87..8bb392f 100644 --- a/src/claude/client.ts +++ b/src/claude/client.ts @@ -24,7 +24,7 @@ export type ClaudeInteractionDecision = export interface ClaudeStreamClientOptions { readonly process: ClaudeProcess; - /** Absolute isolated home the process was spawned with. Fences every write. */ + /** Absolute reviewed logical home that owns this process. Fences every write. */ readonly configDir: string; readonly onFact: (fact: ClaudeFact) => void | Promise; readonly onSafeDiagnostic?: (message: string) => void; @@ -33,6 +33,13 @@ export interface ClaudeStreamClientOptions { readonly shutdownSettlementMs?: number; } +export type ClaudeStreamInitialization = Readonly<{ + providerSessionId: string; + model: string; + permissionMode: string; + claudeVersion: string; +}>; + type PendingInteraction = { readonly requestId: string; readonly request: ClaudeCanUseTool; @@ -103,11 +110,18 @@ export class ClaudeStreamClient { readonly #readTask: Promise; readonly #stderrTask: Promise; readonly #exitTask: Promise; + readonly #exitWatchTask: Promise; + readonly #initialization: Promise; + readonly #resolveInitialization: (value: ClaudeStreamInitialization) => void; + readonly #rejectInitialization: (reason: unknown) => void; readonly #shutdownTermGraceMs: number; readonly #shutdownSettlementMs: number; + #initializationSettled = false; + #initializationValue: ClaudeStreamInitialization | undefined; #exitResolved = false; #closeTask: Promise | null = null; - #state: "open" | "closing" | "closed" = "open"; + #state: "open" | "closing" | "closed" | "failed" = "open"; + #disconnectEmitted = false; #writeChain: Promise = Promise.resolve(); constructor(options: ClaudeStreamClientOptions) { @@ -129,13 +143,27 @@ export class ClaudeStreamClient { this.#decoder = new ClaudeJsonLineDecoder( options.maxJsonLineBytes === undefined ? {} : { maxLineBytes: options.maxJsonLineBytes }, ); + let resolveInitialization!: (value: ClaudeStreamInitialization) => void; + let rejectInitialization!: (reason: unknown) => void; + this.#initialization = new Promise((resolve, reject) => { + resolveInitialization = resolve; + rejectInitialization = reject; + }); + this.#resolveInitialization = resolveInitialization; + this.#rejectInitialization = rejectInitialization; + // A process can fail before its owner reaches the wait call. Keep that + // deterministic rejection owned while preserving it for the later await. + void this.#initialization.catch(() => undefined); this.#exitTask = this.#process.exited.then((code) => { this.#exitResolved = true; return code; }); void this.#exitTask.catch(() => undefined); this.#readTask = this.#readStdout(); + void this.#readTask.catch(() => undefined); this.#stderrTask = this.#drainStderr(); + this.#exitWatchTask = this.#watchProcessExit(); + void this.#exitWatchTask.catch(() => undefined); } get configDir(): string { @@ -150,6 +178,42 @@ export class ClaudeStreamClient { return this.#assembler.activeTurnId; } + get state(): "open" | "closing" | "closed" | "failed" { + return this.#state; + } + + /** + * Waits for the one `system/init` identity that makes this process usable. + * The caller supplies both an abort fence and a bounded deadline; neither a + * silent binary nor a dead stream can hold session admission indefinitely. + */ + async waitForInitialization(input: Readonly<{ + signal: AbortSignal; + timeoutMs: number; + }>): Promise { + if (!Number.isSafeInteger(input.timeoutMs) || input.timeoutMs < 1 || input.timeoutMs > 60_000) { + throw new ClaudeError("INVALID_INPUT", "Claude initialization timeout must be 1 to 60000 ms."); + } + input.signal.throwIfAborted(); + let timer: ReturnType | undefined; + let removeAbort = (): void => undefined; + const boundary = new Promise((_resolve, reject) => { + const onAbort = (): void => { reject(input.signal.reason); }; + input.signal.addEventListener("abort", onAbort, { once: true }); + removeAbort = (): void => { input.signal.removeEventListener("abort", onAbort); }; + timer = setTimeout(() => { + reject(new ClaudeError("TIMEOUT", "Claude did not publish its initialization identity in time.")); + }, input.timeoutMs); + timer.unref(); + }); + try { + return await Promise.race([this.#initialization, boundary]); + } finally { + removeAbort(); + if (timer !== undefined) clearTimeout(timer); + } + } + /** Starts a turn: HRA mints the turn id, then writes the turn's `user` line. */ async startTurn(input: Readonly<{ turnId: string; @@ -232,6 +296,9 @@ export class ClaudeStreamClient { async #close(): Promise { if (this.#state === "closed") return; this.#state = "closing"; + this.#failInitialization( + new ClaudeError("PROCESS_EXITED", "The Claude runtime closed before initialization."), + ); if (!this.#exitResolved) { try { this.#process.terminate(); @@ -282,31 +349,111 @@ export class ClaudeStreamClient { #write(line: string): Promise { const bytes = this.#encoder.encode(line); - const chained = this.#writeChain.then(() => this.#process.write(bytes)); + const chained = this.#writeChain.then(async () => { + // Admission can change while this frame waits behind an earlier write. + // Recheck at the actual provider boundary so close, disconnect, and + // account revocation fence every frame that has not begun writing yet. + this.#assertOpen(); + await this.#process.write(bytes); + }); this.#writeChain = chained.catch(() => undefined); return chained; } async #readStdout(): Promise { + let disconnectReason: "eof" | "protocol_fault" = "eof"; try { for await (const chunk of this.#process.stdout) { for (const value of this.#decoder.push(chunk)) await this.#dispatch(value); } for (const value of this.#decoder.finish()) await this.#dispatch(value); } catch (error: unknown) { + disconnectReason = "protocol_fault"; + this.#failInitialization( + error instanceof ClaudeError + ? error + : new ClaudeError("PROTOCOL_ERROR", "Claude initialization could not be parsed."), + ); this.#onSafeDiagnostic?.( error instanceof ClaudeError ? `claude stream fault: ${error.code}` : "claude stream fault: unknown", ); } - if (this.#state === "open") { - for (const fact of this.#assembler.abandonTurn("the Claude stream ended")) { + await this.#handleUnexpectedDisconnect(disconnectReason); + } + + async #watchProcessExit(): Promise { + try { + await this.#exitTask; + } catch { + // A rejected exit promise is not proof of termination. The manager + // retains the failed client so an exact close can be retried, but no + // further write may cross this now-ambiguous process boundary. + await this.#fenceAmbiguousProcessExit(); + return; + } + await this.#handleUnexpectedDisconnect("process_exit"); + } + + async #fenceAmbiguousProcessExit(): Promise { + if (this.#state !== "open") return; + this.#state = "failed"; + this.#pending.clear(); + this.#failInitialization( + new ClaudeError("PROCESS_EXITED", "Claude process settlement became indeterminate."), + ); + this.#onSafeDiagnostic?.("Claude process exit settlement was indeterminate"); + try { + this.#process.forceTerminate(); + } catch { + this.#onSafeDiagnostic?.("Claude force termination failed after indeterminate exit"); + } + for (const fact of this.#assembler.abandonTurn("the Claude runtime became indeterminate")) { + try { await this.#onFact(fact); + } catch { + this.#onSafeDiagnostic?.("HRA fact delivery failed during Claude disconnection"); } } } + async #handleUnexpectedDisconnect( + reason: "eof" | "process_exit" | "protocol_fault", + ): Promise { + if (this.#state !== "open" || this.#disconnectEmitted) return; + // Fence writes synchronously before any observer callback can re-enter. + this.#state = "failed"; + this.#pending.clear(); + this.#failInitialization( + new ClaudeError("PROCESS_EXITED", "The Claude runtime ended before admission completed."), + ); + for (const fact of this.#assembler.abandonTurn("the Claude runtime disconnected")) { + try { + await this.#onFact(fact); + } catch { + this.#onSafeDiagnostic?.("HRA fact delivery failed during Claude disconnection"); + } + } + if (reason !== "process_exit") { + try { + this.#process.forceTerminate(); + } catch { + this.#onSafeDiagnostic?.("Claude force termination failed after stream loss"); + } + const exitSettled = await resolvesWithin( + this.#exitTask, + this.#shutdownSettlementMs, + ); + if (!exitSettled) { + this.#onSafeDiagnostic?.("Claude process exit did not settle after stream loss"); + return; + } + } + this.#disconnectEmitted = true; + await this.#onFact({ type: "providerDisconnected", reason }); + } + async #dispatch(value: unknown): Promise { const event = parseClaudeStreamLine(value); for (const fact of this.#assembler.apply(event)) { @@ -319,9 +466,44 @@ export class ClaudeStreamClient { } if (fact.type === "interactionCanceled") this.#pending.delete(fact.requestId); await this.#onFact(fact); + if (fact.type === "sessionBootstrapped") { + this.#settleInitialization({ + claudeVersion: fact.claudeVersion, + model: fact.model, + permissionMode: fact.permissionMode, + providerSessionId: fact.providerSessionId, + }); + } } } + #settleInitialization(initialization: ClaudeStreamInitialization): void { + if (this.#initializationValue !== undefined) { + if ( + initialization.providerSessionId !== this.#initializationValue.providerSessionId + || initialization.claudeVersion !== this.#initializationValue.claudeVersion + || initialization.model !== this.#initializationValue.model + || initialization.permissionMode !== this.#initializationValue.permissionMode + ) { + throw new ClaudeError( + "PROTOCOL_ERROR", + "Claude published conflicting initialization identities on one stream.", + ); + } + return; + } + if (this.#initializationSettled) return; + this.#initializationSettled = true; + this.#initializationValue = initialization; + this.#resolveInitialization(initialization); + } + + #failInitialization(error: unknown): void { + if (this.#initializationSettled) return; + this.#initializationSettled = true; + this.#rejectInitialization(error); + } + async #drainStderr(): Promise { let observed = 0; try { diff --git a/src/claude/index.ts b/src/claude/index.ts index 64a658e..e2d7817 100644 --- a/src/claude/index.ts +++ b/src/claude/index.ts @@ -1,4 +1,13 @@ export { ClaudeDeltaAssembler, type ClaudeFact } from "./assembler.ts"; +export { + claudeAccountDocumentPath, + readClaudeAccountProjection, + spawnClaudeAuthStatusProbe, + type ClaudeAccountMetadataReader, + type ClaudeAccountProjection, + type ClaudeAuthStatusProbe, + type ClaudeConfigurationHome, +} from "./account.ts"; export { parseClaudeAuthStatus, readClaudeAuthStatus, @@ -20,6 +29,7 @@ export { export { ClaudeStreamClient, type ClaudeInteractionDecision, + type ClaudeStreamInitialization, type ClaudeStreamClientOptions, } from "./client.ts"; export { ClaudeError, type ClaudeFailureCode } from "./errors.ts"; @@ -35,8 +45,16 @@ export { } from "./pin.ts"; export { allowlistedEnvironment, + ClaudeLaunchIntentLivenessProbe, + inspectSpawnedClaudeProcessIdentity, + parseClaudeProcessIdentity, spawnBunClaudeProcess, + type ClaudeLaunchIntentLiveness, + type ClaudeLaunchIntentProbeOptions, type ClaudeProcess, + type ClaudeProcessIdentity, + type ClaudeProcessIdentityInspection, + type ClaudeProcessIdentityInspectionSpawner, type SpawnClaudeProcessOptions, } from "./process.ts"; export { @@ -66,9 +84,11 @@ export { type ClaudeUsage, } from "./protocol.ts"; export { + claudeSessionArgv, locateClaudeExecutable, resolvePinnedClaudeRuntime, spawnClaudeVersionProbe, + type ClaudeSessionLaunch, type ClaudeVersionProbe, type ClaudeVersionProbeProcess, type ClaudeVersionProbeProcessFactory, diff --git a/src/claude/process.test.ts b/src/claude/process.test.ts new file mode 100644 index 0000000..3fff14c --- /dev/null +++ b/src/claude/process.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, test } from "bun:test"; + +import { + ClaudeLaunchIntentLivenessProbe, + inspectSpawnedClaudeProcessIdentity, + spawnBunClaudeProcess, + type ClaudeProcessIdentityInspectionSpawner, +} from "./process"; + +const processOutput = (...chunks: readonly Uint8Array[]): AsyncIterable => ({ + async *[Symbol.asyncIterator]() { + for (const chunk of chunks) yield chunk; + }, +}); + +const encodedProcessOutput = (value: string): AsyncIterable => + processOutput(new TextEncoder().encode(value)); + +describe("Claude child process identity", () => { + test("reads a bounded process-start token through the fixed non-shell ps command", async () => { + const calls: unknown[] = []; + const identity = await inspectSpawnedClaudeProcessIdentity(8_123, { + platform: "darwin", + spawn: (input) => { + calls.push(input); + return { + exited: Promise.resolve(0), + forceTerminate: () => undefined, + stdout: { + async *[Symbol.asyncIterator]() { + yield new TextEncoder().encode(" Fri Sep 4 12:00:00 2026 \n"); + }, + }, + }; + }, + }); + expect(identity).toEqual({ + pid: 8_123, + pidDomain: "darwin", + procStart: "Fri Sep 4 12:00:00 2026", + }); + expect(calls).toEqual([{ + argv: ["/bin/ps", "-p", "8123", "-o", "lstart="], + environment: { + LANG: "C", + LC_ALL: "C", + PATH: "/usr/bin:/bin", + TZ: "UTC", + }, + }]); + }); + + test("takes one fixed process snapshot and matches only direct launch-id argv pairs", async () => { + const resumed = "11111111-1111-4111-8111-111111111111"; + const started = "22222222-2222-4222-8222-222222222222"; + const absent = "33333333-3333-4333-8333-333333333333"; + const calls: unknown[] = []; + const probe = new ClaudeLaunchIntentLivenessProbe({ + platform: "darwin", + now: () => 1_000, + spawn: (input) => { + calls.push(input); + return { + exited: Promise.resolve(0), + forceTerminate: () => undefined, + stdout: encodedProcessOutput([ + `/opt/claude --resume ${resumed} --verbose`, + `/opt/claude --session-id ${started}`, + `/opt/other --resume=${absent}`, + `/opt/other prefix--resume ${absent}`, + `/opt/other --resume ${absent}-suffix`, + ].join("\n")), + }; + }, + }); + const options = { + deadlineAt: 2_000, + signal: new AbortController().signal, + }; + + expect(await probe.probe(resumed, options)).toBe("live"); + expect(await probe.probe(started, options)).toBe("live"); + expect(await probe.probe(absent, options)).toBe("not_live"); + expect(calls).toEqual([{ + argv: ["/bin/ps", "-axww", "-o", "command="], + environment: { + LANG: "C", + LC_ALL: "C", + PATH: "/usr/bin:/bin", + TZ: "UTC", + }, + }]); + }); + + test("returns unknown without inspection for invalid ids, deadlines, and platforms", async () => { + let spawned = 0; + const spawn = () => { + spawned += 1; + throw new Error("must not inspect"); + }; + const probe = new ClaudeLaunchIntentLivenessProbe({ + platform: "darwin", + now: () => 1_000, + spawn, + }); + const liveOptions = { + deadlineAt: 2_000, + signal: new AbortController().signal, + }; + expect(await probe.probe("contains whitespace", liveOptions)).toBe("unknown"); + expect(await probe.probe("x".repeat(201), liveOptions)).toBe("unknown"); + expect(await probe.probe("valid-id", { + ...liveOptions, + deadlineAt: 1_000, + })).toBe("unknown"); + const aborted = new AbortController(); + aborted.abort(); + expect(await probe.probe("valid-id", { + deadlineAt: 2_000, + signal: aborted.signal, + })).toBe("unknown"); + expect(await probe.probe("valid-id", undefined as never)).toBe("unknown"); + expect(await probe.probe("valid-id", { + deadlineAt: 2_000, + signal: {} as never, + })).toBe("unknown"); + expect(spawned).toBe(0); + + const unsupported = new ClaudeLaunchIntentLivenessProbe({ + platform: "win32", + now: () => 1_000, + spawn, + }); + expect(await unsupported.probe("valid-id", liveOptions)).toBe("unknown"); + expect(spawned).toBe(0); + }); + + test("maps spawn, exit, read, parse, and truncation ambiguity to unknown", async () => { + const options = { + deadlineAt: 2_000, + signal: new AbortController().signal, + }; + const resultFor = async ( + spawn: ClaudeProcessIdentityInspectionSpawner, + ) => await new ClaudeLaunchIntentLivenessProbe({ + platform: "linux", + now: () => 1_000, + spawn, + }).probe("44444444-4444-4444-8444-444444444444", options); + + expect(await resultFor(() => { throw new Error("private process table"); })) + .toBe("unknown"); + expect(await resultFor(() => null as never)).toBe("unknown"); + expect(await resultFor(() => ({ + exited: Promise.resolve(1), + forceTerminate: () => undefined, + stdout: encodedProcessOutput("/opt/claude --resume 44444444-4444-4444-8444-444444444444"), + }))).toBe("unknown"); + expect(await resultFor(() => ({ + exited: Promise.resolve(0), + forceTerminate: () => undefined, + stdout: { + [Symbol.asyncIterator]() { + return { + next: () => Promise.reject(new Error("PRIVATE_COMMAND_LINE")), + }; + }, + }, + }))).toBe("unknown"); + expect(await resultFor(() => ({ + exited: Promise.resolve(0), + forceTerminate: () => undefined, + stdout: processOutput(new Uint8Array([0xff])), + }))).toBe("unknown"); + expect(await resultFor(() => ({ + exited: Promise.resolve(0), + forceTerminate: () => undefined, + stdout: encodedProcessOutput("/opt/claude --resume"), + }))).toBe("unknown"); + expect(await resultFor(() => ({ + exited: Promise.resolve(0), + forceTerminate: () => undefined, + stdout: processOutput(new Uint8Array(4 * 1_024 * 1_024 + 1)), + }))).toBe("unknown"); + }); + + test("returns unknown and terminates a process snapshot on deadline or abort", async () => { + let forceTerminations = 0; + const hangingInspection = () => ({ + exited: new Promise(() => undefined), + forceTerminate: () => { forceTerminations += 1; }, + stdout: { + [Symbol.asyncIterator]() { + return { + next: () => new Promise>(() => undefined), + }; + }, + }, + }); + const deadlineProbe = new ClaudeLaunchIntentLivenessProbe({ + platform: "darwin", + now: () => 0, + spawn: hangingInspection, + }); + + expect(await deadlineProbe.probe("55555555-5555-4555-8555-555555555555", { + deadlineAt: 1, + signal: new AbortController().signal, + })).toBe("unknown"); + expect(forceTerminations).toBe(1); + + const abort = new AbortController(); + const abortProbe = new ClaudeLaunchIntentLivenessProbe({ + platform: "linux", + now: () => 0, + spawn: hangingInspection, + }); + const result = abortProbe.probe("66666666-6666-4666-8666-666666666666", { + deadlineAt: 1_000, + signal: abort.signal, + }); + abort.abort(); + expect(await result).toBe("unknown"); + expect(forceTerminations).toBe(2); + }); + + test("exposes the spawned child identity and reaps an unprovable child", async () => { + const child = spawnBunClaudeProcess({ + argv: [process.execPath, "-e", "setInterval(() => undefined, 1000)"], + configDir: "/tmp/hra-claude-process-identity-test", + inspectIdentity: async (pid) => Object.freeze({ + pid, + pidDomain: "darwin", + procStart: "Fri Sep 4 12:00:00 2026", + }), + }); + try { + const identity = await child.identity; + expect(identity.pid).not.toBe(process.pid); + expect(identity.procStart).toMatch(/^[\x20-\x7e]{1,128}$/u); + } finally { + child.forceTerminate(); + await child.exited; + } + + const unprovable = spawnBunClaudeProcess({ + argv: [process.execPath, "-e", "setInterval(() => undefined, 1000)"], + configDir: "/tmp/hra-claude-process-identity-test", + inspectIdentity: async () => { throw new Error("inspection unavailable"); }, + }); + await expect(unprovable.identity).rejects.toThrow("identity could not be proven"); + await expect(unprovable.exited).resolves.not.toBe(0); + }); +}); diff --git a/src/claude/process.ts b/src/claude/process.ts index 37d236b..2d14edb 100644 --- a/src/claude/process.ts +++ b/src/claude/process.ts @@ -1,6 +1,34 @@ import { ClaudeError } from "./errors.ts"; +export type ClaudeProcessIdentity = Readonly<{ + readonly pid: number; + readonly pidDomain: "darwin" | "linux"; + /** Exact trimmed output of `ps -p -o lstart=` under the C/UTC locale. */ + readonly procStart: string; +}>; + +export type ClaudeProcessIdentityInspection = Readonly<{ + exited: Promise; + stdout: AsyncIterable; + forceTerminate(): void; +}>; + +export type ClaudeProcessIdentityInspectionSpawner = (input: Readonly<{ + argv: readonly [string, ...string[]]; + environment: Readonly>; +}>) => ClaudeProcessIdentityInspection; + +export type ClaudeLaunchIntentLiveness = "live" | "not_live" | "unknown"; + +export type ClaudeLaunchIntentProbeOptions = Readonly<{ + /** Absolute wall-clock deadline for this observation. */ + deadlineAt: number; + signal: AbortSignal; +}>; + export interface ClaudeProcess { + /** Exact child identity, proven from the local process table after spawn. */ + readonly identity: Promise; readonly stdout: AsyncIterable; readonly stderr: AsyncIterable; readonly exited: Promise; @@ -11,12 +39,33 @@ export interface ClaudeProcess { export interface SpawnClaudeProcessOptions { readonly argv: readonly [string, ...string[]]; - /** Absolute. Becomes the child's whole configuration, session, and credential home. */ + /** Absolute reviewed location of the Claude configuration/session home. */ readonly configDir: string; + /** + * Isolated homes are selected explicitly with `CLAUDE_CONFIG_DIR`. The + * current user's personal home must use Claude's default-home semantics: + * exporting `CLAUDE_CONFIG_DIR=~/.claude` would instead select the nested + * and unrelated `~/.claude/.claude.json` account document. + */ + readonly configHome?: "isolated" | "personal"; readonly projectRoot?: string; readonly environment?: Readonly>; + /** Test seam. Production always uses the bounded non-shell `ps` inspector. */ + readonly inspectIdentity?: (pid: number) => Promise; } +const PROCESS_IDENTITY_TIMEOUT_MS = 1_000; +const PROCESS_IDENTITY_STDOUT_BYTES = 256; +const PROCESS_START_BYTES = 128; +const LAUNCH_INTENT_TIMEOUT_MS = 1_000; +const LAUNCH_INTENT_STDOUT_BYTES = 4 * 1_024 * 1_024; +const LAUNCH_INTENT_LINE_BYTES = 256 * 1_024; +const LAUNCH_INTENT_ID_BYTES = 200; +const LAUNCH_INTENT_ID_LIMIT = 4_096; +const encoder = new TextEncoder(); + +type ClaudeLaunchIntentSnapshot = ReadonlySet | null; + /** * The same allowlist the Codex spawner uses. Nothing else from the parent * environment crosses the boundary, so no ambient API key, proxy, or provider @@ -47,12 +96,124 @@ export function allowlistedEnvironment( return env; } +/** + * Takes at most one bounded local process-table snapshot and answers every + * session lookup from the redacted set of launch ids extracted from it. + * Raw command lines are never retained, returned, or included in an error. + */ +export class ClaudeLaunchIntentLivenessProbe { + readonly #platform: NodeJS.Platform; + readonly #spawn: ClaudeProcessIdentityInspectionSpawner; + readonly #now: () => number; + #snapshot: Promise | undefined; + + constructor(options: Readonly<{ + platform?: NodeJS.Platform; + spawn?: ClaudeProcessIdentityInspectionSpawner; + now?: () => number; + }> = {}) { + this.#platform = options.platform ?? process.platform; + this.#spawn = options.spawn ?? spawnProcessIdentityInspection; + this.#now = options.now ?? Date.now; + } + + async probe( + providerThreadId: string, + options: ClaudeLaunchIntentProbeOptions, + ): Promise { + if (!validLaunchIntentId(providerThreadId) || !validProbeOptions(options, this.#now)) { + return "unknown"; + } + this.#snapshot ??= this.#captureSnapshot(options).catch(() => null); + try { + const snapshot = await awaitSnapshot(this.#snapshot, options, this.#now); + if (snapshot === null) return "unknown"; + return snapshot.has(providerThreadId) ? "live" : "not_live"; + } catch { + return "unknown"; + } + } + + async #captureSnapshot( + options: ClaudeLaunchIntentProbeOptions, + ): Promise { + if (this.#platform !== "darwin" && this.#platform !== "linux") return null; + let remaining: number; + try { + remaining = options.deadlineAt - this.#now(); + } catch { + return null; + } + if (!Number.isFinite(remaining) || remaining <= 0 || options.signal.aborted) return null; + + let inspection: ClaudeProcessIdentityInspection; + try { + inspection = this.#spawn({ + argv: ["/bin/ps", "-axww", "-o", "command="], + environment: { + LANG: "C", + LC_ALL: "C", + PATH: "/usr/bin:/bin", + TZ: "UTC", + }, + }); + } catch { + return null; + } + + const output = readBoundedLaunchIntentOutput(inspection.stdout); + const completed = Promise.all([inspection.exited, output]); + // An abort/timeout can win the race before a malformed stream rejects. + // Own that later rejection without exposing process-table content. + void completed.catch(() => undefined); + let timer: ReturnType | undefined; + let stop!: () => void; + let stoppedOnce = false; + const stopped = new Promise((resolve) => { + stop = () => { + if (stoppedOnce) return; + stoppedOnce = true; + try { + inspection.forceTerminate(); + } catch { + // The conservative unknown result remains authoritative. + } + resolve(null); + }; + options.signal.addEventListener("abort", stop, { once: true }); + timer = setTimeout(stop, Math.min(LAUNCH_INTENT_TIMEOUT_MS, Math.max(1, remaining))); + timer.unref(); + if (options.signal.aborted) stop(); + }); + + try { + const completedOrStopped = await Promise.race([completed, stopped]); + if (completedOrStopped === null) return null; + const [exitCode, stdout] = completedOrStopped; + if (exitCode !== 0) return null; + return parseLaunchIntentSnapshot(stdout); + } catch { + try { + inspection.forceTerminate(); + } catch { + // The inspection failure still maps to one closed unknown result. + } + return null; + } finally { + if (timer !== undefined) clearTimeout(timer); + options.signal.removeEventListener("abort", stop); + } + } +} + export function spawnBunClaudeProcess(options: SpawnClaudeProcessOptions): ClaudeProcess { if (!options.configDir.startsWith("/")) { throw new ClaudeError("INVALID_INPUT", "CLAUDE_CONFIG_DIR must be an absolute path"); } const env = allowlistedEnvironment(options.environment ?? process.env); - env.CLAUDE_CONFIG_DIR = options.configDir; + if ((options.configHome ?? "isolated") === "isolated") { + env.CLAUDE_CONFIG_DIR = options.configDir; + } env.NO_COLOR = "1"; const child = Bun.spawn([...options.argv], { @@ -69,12 +230,35 @@ export function spawnBunClaudeProcess(options: SpawnClaudeProcessOptions): Claud } const stdin = child.stdin; + const identity = Promise.resolve() + .then(async () => await (options.inspectIdentity ?? inspectSpawnedClaudeProcessIdentity)(child.pid)) + .catch((cause: unknown) => { + // A child whose exact process identity is unknown can never be admitted + // as HRA's exclusive writer. Reap it even if a caller forgets to await + // the identity promise itself. + try { + child.kill("SIGKILL"); + } catch { + // The manager still performs and verifies its own bounded close. + } + throw cause instanceof ClaudeError + ? cause + : new ClaudeError( + "AUTHORITY_STALE", + "Claude child process identity could not be proven.", + { cause }, + ); + }); + // The manager awaits this during admission. Own an earlier rejection so a + // very short-lived child cannot create an unhandled promise in the gap. + void identity.catch(() => undefined); return { exited: child.exited, forceTerminate(): void { child.kill("SIGKILL"); }, stderr: readableStreamChunks(child.stderr), + identity, stdout: readableStreamChunks(child.stdout), terminate(): void { child.kill("SIGTERM"); @@ -91,6 +275,286 @@ export function spawnBunClaudeProcess(options: SpawnClaudeProcessOptions): Claud }; } +/** + * Read one bounded host process-start token for the newly spawned child. The + * command is fixed, non-shell, C-locale, UTC, byte bounded, and deadline + * bounded so authority admission cannot hang on process inspection. + */ +export async function inspectSpawnedClaudeProcessIdentity( + pid: number, + options: Readonly<{ + platform?: NodeJS.Platform; + spawn?: ClaudeProcessIdentityInspectionSpawner; + }> = {}, +): Promise { + if (!Number.isSafeInteger(pid) || pid <= 0) { + throw new ClaudeError("AUTHORITY_STALE", "Claude returned an invalid child process id."); + } + const platform = options.platform ?? process.platform; + const pidDomain = platform === "darwin" + ? "darwin" + : platform === "linux" + ? "linux" + : null; + if (pidDomain === null) { + throw new ClaudeError( + "AUTHORITY_STALE", + "Claude child process identity is unsupported on this platform.", + ); + } + const inspection = (options.spawn ?? spawnProcessIdentityInspection)({ + argv: ["/bin/ps", "-p", String(pid), "-o", "lstart="], + environment: { + LANG: "C", + LC_ALL: "C", + PATH: "/usr/bin:/bin", + TZ: "UTC", + }, + }); + const output = readBoundedInspectionOutput(inspection.stdout); + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + try { + inspection.forceTerminate(); + } catch { + // The timeout rejection is the authority result; a failed best-effort + // inspector kill must not escape the timer callback. + } + reject(new ClaudeError("TIMEOUT", "Claude child process identity inspection timed out.")); + }, PROCESS_IDENTITY_TIMEOUT_MS); + timer.unref(); + }); + try { + const [exitCode, stdout] = await Promise.race([ + Promise.all([inspection.exited, output]), + timeout, + ]); + if (exitCode !== 0) { + throw new ClaudeError( + "AUTHORITY_STALE", + "Claude child process identity was absent from the local process table.", + ); + } + const lines = stdout + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const procStart = lines[0]; + if ( + lines.length !== 1 + || procStart === undefined + || procStart.length === 0 + || encoder.encode(procStart).byteLength > PROCESS_START_BYTES + || !/^[\x20-\x7e]+$/u.test(procStart) + ) { + throw new ClaudeError( + "AUTHORITY_STALE", + "Claude child process start identity was malformed.", + ); + } + return Object.freeze({ pid, pidDomain, procStart }); + } catch (error: unknown) { + try { + inspection.forceTerminate(); + } catch { + // Preserve the inspection failure as the actionable authority result. + } + throw error; + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +function spawnProcessIdentityInspection( + input: Parameters[0], +): ClaudeProcessIdentityInspection { + const child = Bun.spawn([...input.argv], { + env: { ...input.environment }, + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + }); + return { + exited: child.exited, + forceTerminate: () => { child.kill("SIGKILL"); }, + stdout: readableStreamChunks(child.stdout), + }; +} + +export function parseClaudeProcessIdentity(value: unknown): ClaudeProcessIdentity { + if (typeof value !== "object" || value === null) { + throw new ClaudeError("AUTHORITY_STALE", "Claude child process identity was missing."); + } + const candidate = value as Readonly>; + const pid = candidate.pid; + const pidDomain = candidate.pidDomain; + const procStart = candidate.procStart; + if ( + typeof pid !== "number" + || !Number.isSafeInteger(pid) + || pid <= 0 + || (pidDomain !== "darwin" && pidDomain !== "linux") + || typeof procStart !== "string" + || procStart.length === 0 + || encoder.encode(procStart).byteLength > PROCESS_START_BYTES + || !/^[\x20-\x7e]+$/u.test(procStart) + ) { + throw new ClaudeError("AUTHORITY_STALE", "Claude child process identity was malformed."); + } + return Object.freeze({ pid, pidDomain, procStart }); +} + +function validLaunchIntentId(value: unknown): value is string { + return typeof value === "string" + && encoder.encode(value).byteLength >= 1 + && encoder.encode(value).byteLength <= LAUNCH_INTENT_ID_BYTES + && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/u.test(value); +} + +function validProbeOptions( + options: unknown, + now: () => number, +): options is ClaudeLaunchIntentProbeOptions { + if (typeof options !== "object" || options === null) return false; + const candidate = options as Readonly>; + const deadlineAt = candidate.deadlineAt; + const signal = candidate.signal; + if ( + typeof deadlineAt !== "number" + || typeof signal !== "object" + || signal === null + ) return false; + const abortSignal = signal as Readonly>; + if ( + typeof abortSignal.aborted !== "boolean" + || typeof abortSignal.addEventListener !== "function" + || typeof abortSignal.removeEventListener !== "function" + ) return false; + try { + return Number.isSafeInteger(deadlineAt) + && deadlineAt >= 0 + && deadlineAt > now() + && !abortSignal.aborted; + } catch { + return false; + } +} + +async function awaitSnapshot( + snapshot: Promise, + options: ClaudeLaunchIntentProbeOptions, + now: () => number, +): Promise { + let remaining: number; + try { + remaining = options.deadlineAt - now(); + } catch { + return null; + } + if (!Number.isFinite(remaining) || remaining <= 0 || options.signal.aborted) return null; + let timer: ReturnType | undefined; + let stop!: () => void; + const stopped = new Promise((resolve) => { + stop = () => { resolve(null); }; + options.signal.addEventListener("abort", stop, { once: true }); + timer = setTimeout( + stop, + Math.min(LAUNCH_INTENT_TIMEOUT_MS, Math.max(1, remaining)), + ); + timer.unref(); + if (options.signal.aborted) stop(); + }); + try { + return await Promise.race([snapshot, stopped]); + } finally { + if (timer !== undefined) clearTimeout(timer); + options.signal.removeEventListener("abort", stop); + } +} + +function parseLaunchIntentSnapshot(output: string): ReadonlySet | null { + const ids = new Set(); + for (const rawLine of output.split("\n")) { + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + if ( + encoder.encode(line).byteLength > LAUNCH_INTENT_LINE_BYTES + || containsUnsafeProcessControl(line) + ) return null; + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + const argv = trimmed.split(/[ \t]+/u); + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token !== "--session-id" && token !== "--resume") continue; + const providerThreadId = argv[index + 1]; + if (!validLaunchIntentId(providerThreadId)) return null; + ids.add(providerThreadId); + if (ids.size > LAUNCH_INTENT_ID_LIMIT) return null; + index += 1; + } + } + return ids; +} + +function containsUnsafeProcessControl(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 8 || (code >= 11 && code <= 31) || code === 127) return true; + } + return false; +} + +async function readBoundedLaunchIntentOutput( + stream: AsyncIterable, +): Promise { + const chunks: Uint8Array[] = []; + let observed = 0; + for await (const chunk of stream) { + observed += chunk.byteLength; + if (observed > LAUNCH_INTENT_STDOUT_BYTES) { + throw new Error("Claude launch-intent process snapshot exceeded its byte bound."); + } + chunks.push(chunk); + } + const bytes = new Uint8Array(observed); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new Error("Claude launch-intent process snapshot was malformed."); + } +} + +async function readBoundedInspectionOutput( + stream: AsyncIterable, +): Promise { + const chunks: Uint8Array[] = []; + let observed = 0; + for await (const chunk of stream) { + observed += chunk.byteLength; + if (observed > PROCESS_IDENTITY_STDOUT_BYTES) { + throw new ClaudeError("PROTOCOL_LIMIT", "Process identity output exceeded its bound."); + } + chunks.push(chunk); + } + const bytes = new Uint8Array(observed); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (cause: unknown) { + throw new ClaudeError("AUTHORITY_STALE", "Process identity output was not UTF-8.", { cause }); + } +} + async function* readableStreamChunks( stream: ReadableStream | number | undefined, ): AsyncIterable { diff --git a/src/claude/runtime.test.ts b/src/claude/runtime.test.ts index dda0d5a..9ee5d3e 100644 --- a/src/claude/runtime.test.ts +++ b/src/claude/runtime.test.ts @@ -7,6 +7,7 @@ import { ClaudeError } from "./errors"; import { CLAUDE_PIN, CLAUDE_PIN_EFFORT, CLAUDE_PIN_MODEL } from "./pin"; import { presetRequirements } from "../domain/presets"; import { + claudeSessionArgv, locateClaudeExecutable, resolvePinnedClaudeRuntime, spawnClaudeVersionProbe, @@ -89,6 +90,24 @@ describe("pinned Claude runtime", () => { } }); + test("binds creation and resume to one canonical durable session id", async () => { + const { configDir, path } = await fakeExecutable(); + const runtime = await resolvePinnedClaudeRuntime({ + configDir, + executablePath: path, + probeVersion: async () => CLAUDE_PIN, + }); + const providerThreadId = "726b1b3d-ed97-4b55-9904-e58fa7d7eb45"; + expect(claudeSessionArgv(runtime, { kind: "create", providerThreadId }).slice(-2)) + .toEqual(["--session-id", providerThreadId]); + expect(claudeSessionArgv(runtime, { kind: "resume", providerThreadId }).slice(-2)) + .toEqual(["--resume", providerThreadId]); + expect(() => claudeSessionArgv(runtime, { + kind: "resume", + providerThreadId: "not-a-session", + })).toThrow("canonical lowercase UUID"); + }); + test("requires an absolute config directory and an absolute executable", async () => { const { configDir, path } = await fakeExecutable(); await expect(resolvePinnedClaudeRuntime({ @@ -143,6 +162,7 @@ describe("pinned Claude runtime", () => { }; await expect(spawnClaudeVersionProbe({ configDir: "/tmp/claude-config", + configHome: "isolated", deadlineMs: 1_000, environment: { PATH: "/usr/bin:/bin" }, executablePath: "/test/claude", @@ -171,6 +191,7 @@ describe("pinned Claude runtime", () => { }; await expect(spawnClaudeVersionProbe({ configDir: "/tmp/claude-config", + configHome: "isolated", deadlineMs: 1, environment: { PATH: "/usr/bin:/bin" }, executablePath: "/test/claude", @@ -191,6 +212,7 @@ describe("pinned Claude runtime", () => { }; await expect(spawnClaudeVersionProbe({ configDir: "/tmp/claude-config", + configHome: "isolated", deadlineMs: 1_000, environment: { PATH: "/usr/bin:/bin" }, executablePath: "/test/claude", @@ -199,4 +221,34 @@ describe("pinned Claude runtime", () => { })).rejects.toThrow("could not be joined after forced termination"); expect(trace).toEqual(["terminate", "force"]); }); + + test("exports the reviewed home only for isolated probes", async () => { + const environments: Array>> = []; + const processFactory = (input: Readonly<{ + argv: readonly [string, "--version"]; + environment: Readonly>; + }>): ClaudeVersionProbeProcess => { + environments.push(input.environment); + return { + exited: Promise.resolve(0), + stdout: (async function* () { yield new TextEncoder().encode(CLAUDE_PIN); })(), + stderr: (async function* () { yield* []; })(), + terminate: () => undefined, + forceTerminate: () => undefined, + }; + }; + for (const configHome of ["isolated", "personal"] as const) { + await spawnClaudeVersionProbe({ + configDir: "/tmp/reviewed-claude-home", + configHome, + deadlineMs: 1_000, + environment: { PATH: "/usr/bin:/bin" }, + executablePath: "/test/claude", + processFactory, + signal: new AbortController().signal, + }); + } + expect(environments[0]?.CLAUDE_CONFIG_DIR).toBe("/tmp/reviewed-claude-home"); + expect(environments[1]?.CLAUDE_CONFIG_DIR).toBeUndefined(); + }); }); diff --git a/src/claude/runtime.ts b/src/claude/runtime.ts index b41f0c3..75e7242 100644 --- a/src/claude/runtime.ts +++ b/src/claude/runtime.ts @@ -14,6 +14,11 @@ export interface PinnedClaudeRuntime { readonly argv: readonly [string, ...string[]]; } +export type ClaudeSessionLaunch = Readonly<{ + kind: "create" | "resume"; + providerThreadId: string; +}>; + export interface ClaudeVersionProbeProcess { readonly exited: Promise; readonly stdout: AsyncIterable; @@ -30,6 +35,7 @@ export type ClaudeVersionProbeProcessFactory = (input: Readonly<{ export type ClaudeVersionProbe = (input: { readonly executablePath: string; readonly configDir: string; + readonly configHome: "isolated" | "personal"; readonly environment: Readonly>; readonly signal: AbortSignal; readonly deadlineMs: number; @@ -39,8 +45,10 @@ export type ClaudeVersionProbe = (input: { export interface ResolvePinnedClaudeRuntimeOptions { /** Absolute path to the `claude` executable. Located on PATH when omitted. */ readonly executablePath?: string; - /** Absolute, isolated `CLAUDE_CONFIG_DIR` for this profile. */ + /** Absolute reviewed home; exported as `CLAUDE_CONFIG_DIR` only in isolated mode. */ readonly configDir: string; + /** Defaults to an explicitly selected isolated home. */ + readonly configHome?: "isolated" | "personal"; readonly environment?: Readonly>; readonly probeVersion?: ClaudeVersionProbe; readonly signal?: AbortSignal; @@ -58,6 +66,8 @@ const VERSION_PROBE_STDOUT_MAX_BYTES = 512; const VERSION_PROBE_STDERR_MAX_BYTES = 4 * 1024; const VERSION_PROBE_TERMINATION_GRACE_MS = 250; const VERSION_PROBE_FORCE_JOIN_MS = 1_000; +const sessionIdPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; const chunks = async function* ( stream: ReadableStream | number | undefined, @@ -146,10 +156,33 @@ const stopVersionProbe = async ( if (!drained) throw new ClaudeError("PROCESS_EXITED", "Claude version probe output could not be drained after termination."); }; -/** Reads `claude --version` inside the isolated home, bounded and non-interactive. */ +/** + * Binds one process invocation to one durable Claude session. Creation and + * resume are deliberately distinct flags: a typo must never make Claude + * allocate a new conversation while HRA believes it reclaimed an old one. + */ +export function claudeSessionArgv( + runtime: PinnedClaudeRuntime, + launch: ClaudeSessionLaunch, +): readonly [string, ...string[]] { + if (!sessionIdPattern.test(launch.providerThreadId)) { + throw new ClaudeError( + "INVALID_INPUT", + "A Claude provider session id must be a canonical lowercase UUID.", + ); + } + return [ + ...runtime.argv, + launch.kind === "create" ? "--session-id" : "--resume", + launch.providerThreadId, + ]; +} + +/** Reads `claude --version` inside the reviewed home, bounded and non-interactive. */ export const spawnClaudeVersionProbe: ClaudeVersionProbe = async (input) => { + input.signal.throwIfAborted(); const env = allowlistedEnvironment(input.environment); - env.CLAUDE_CONFIG_DIR = input.configDir; + if (input.configHome === "isolated") env.CLAUDE_CONFIG_DIR = input.configDir; env.NO_COLOR = "1"; let child: ClaudeVersionProbeProcess; try { @@ -227,6 +260,8 @@ export async function locateClaudeExecutable( export async function resolvePinnedClaudeRuntime( options: ResolvePinnedClaudeRuntimeOptions, ): Promise { + const signal = options.signal ?? new AbortController().signal; + signal.throwIfAborted(); assertPinnedClaudeMatrices(); if (!isAbsolute(options.configDir)) { throw new ClaudeError("INVALID_INPUT", "CLAUDE_CONFIG_DIR must be an absolute path"); @@ -253,14 +288,16 @@ export async function resolvePinnedClaudeRuntime( } const reported = await probe({ configDir: options.configDir, + configHome: options.configHome ?? "isolated", environment, executablePath, - signal: options.signal ?? new AbortController().signal, + signal, deadlineMs, ...(options.versionProbeProcessFactory === undefined ? {} : { processFactory: options.versionProbeProcessFactory, }), }); + signal.throwIfAborted(); const version = versionOutputPattern.exec(reported)?.[1]; if (version === undefined) { throw new ClaudeError("RUNTIME_MISMATCH", "the Claude Code executable reported no exact version"); diff --git a/src/cli.test.ts b/src/cli.test.ts index 527e25f..c76b2f2 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -14,17 +14,22 @@ import { HUMAN_SESSION_WATCH_BOOTSTRAP_MAXIMUM_BYTES, initialize, main, + personalClaudeConfigHomeForInstallation, protectedTerminalControlLibrariesForPlatform, protectedTerminalInputQueueForPlatform, readHiddenProtectedLineFromTerminal, + releaseProvenDeadClaudeAuthoritiesBeforeDaemonGeneration, renderRemoteSuccess, resolveDaemonCloudStartup, resolveSessionEventCursorCodec, + runDaemon, selectDaemonCloudControl, stopDaemonWithExactAuthority, withProtectedTerminalLifecycle, type DaemonStopDependencies, } from "./cli"; +import { readClaudeAccountProjection } from "./claude/account"; +import { spawnBunClaudeProcess } from "./claude/process"; import { allowlistedEnvironment, SAFE_ENVIRONMENT_KEYS } from "./codex/index"; import { ClaudeError, @@ -73,6 +78,7 @@ import { LocalDaemonUnavailableError, } from "./daemon/local-transport"; import { createAcceptanceInstallation } from "../scripts/live-acceptance-installation"; +import { createProductionInstallation } from "./installation"; import { initializeStatePaths, resolveStatePaths } from "./storage/paths"; import { FileSecretBackend, GenerationalSecretCustody } from "./storage/secret-custody"; import { StateStore } from "./storage/state-store"; @@ -119,19 +125,75 @@ class CliClaudeLoginSignalSource implements ClaudeLoginSignalSource { } } -// An install written by the released main build: the notification migrations are -// absent and `user_version` still names the provider-switch schema it stamped. -// A writable open must carry that v35 authority through the routing and Devin -// migrations to v39. +const sessionAdoptionTableNames = [ + "session_claude_process_authorities", + "session_claude_process_launch_intents", + "session_personal_runtime_bindings", + "session_adoption_candidates", + "session_adoption_policies", + "session_adoption_profile_generation_permits", + "profile_personal_authority_revocations", + "provider_runtime_account_revocations", + "session_provider_account_authorities", + "session_account_authorities", +] as const; + +// An install written by the released provider-switch build: notification, +// preset-contract, Devin-provider, and personal-session-adoption migrations are +// absent and `user_version` still names schema v35. A writable open must carry +// that authority through both later feature families to v40 without pretending +// either newer private authority surface already existed. Released v0.5 stores +// use schema v33 and are covered by storage migration tests. const downgradeStateSchema = (databasePath: string): void => { const database = new Database(databasePath, { create: false, strict: true }); try { + database.exec("PRAGMA foreign_keys=OFF"); + const laterSchemaObjects = database.query(` + SELECT name,type FROM sqlite_master + WHERE name NOT LIKE 'sqlite_%' AND ( + (type='index' AND ( + tbl_name IN (${sessionAdoptionTableNames.map(() => "?").join(",")}) + OR name='sessions_profile_created' + OR sql LIKE '%provider_v39%' + OR sql LIKE '%preset_contract%' + )) + OR (type='trigger' AND ( + sql LIKE '%session_account_authorit%' + OR sql LIKE '%provider_runtime_account_revocation%' + OR sql LIKE '%session_adoption_%' + OR sql LIKE '%session_personal_runtime_%' + OR sql LIKE '%session_claude_process_%' + OR sql LIKE '%profile_personal_authority_%' + OR sql LIKE '%codex_account_key%' + OR sql LIKE '%provider_v39%' + OR sql LIKE '%preset_contract%' + )) + ) + ORDER BY CASE type WHEN 'trigger' THEN 0 ELSE 1 END,name + `).all(...sessionAdoptionTableNames) as Array<{ + name: string; + type: "index" | "trigger"; + }>; + for (const object of laterSchemaObjects) { + if (!/^[a-z0-9_]+$/u.test(object.name) || !["index", "trigger"].includes(object.type)) { + throw new Error("Unexpected session-adoption schema object."); + } + database.exec(`DROP ${object.type.toUpperCase()} IF EXISTS "${object.name}"`); + } + for (const table of sessionAdoptionTableNames) { + database.exec(`DROP TABLE IF EXISTS "${table}"`); + } database.exec(` - DROP TABLE session_mutation_authority_rebinds_v39; - DROP TABLE attention_email_policy; - DROP TABLE notification_hours; + DROP TABLE IF EXISTS session_mutation_authority_rebinds_v39; + ALTER TABLE profiles DROP COLUMN codex_account_key; + ALTER TABLE sessions DROP COLUMN provider_v39; + ALTER TABLE sessions DROP COLUMN preset_contract; + ALTER TABLE works DROP COLUMN preset_contract; + DROP TABLE IF EXISTS attention_email_policy; + DROP TABLE IF EXISTS notification_hours; DELETE FROM migrations WHERE version>=36; PRAGMA user_version=35; + PRAGMA foreign_keys=ON; `); } finally { database.close(false); @@ -143,7 +205,7 @@ const downgradeStateSchema = (databasePath: string): void => { const advanceStateSchema = (databasePath: string): void => { const database = new Database(databasePath, { create: false, strict: true }); try { - database.exec("PRAGMA user_version=40"); + database.exec("PRAGMA user_version=41"); } finally { database.close(false); } @@ -177,6 +239,115 @@ const upgradeFixture = async ( }; }; +const stagedClaudeStartupRecoveryFixture = async ( + name: string, + pid: number, +) => { + const runRoot = await realpath(await mkdtemp(join(tmpdir(), `hra-claude-startup-${name}-`))); + const paths = resolveStatePaths({ homeDirectory: runRoot, platform: "darwin" }); + await initializeStatePaths(paths); + const store = new StateStore(paths, { now: (() => { + let value = 1_000; + return () => value++; + })() }); + try { + const created = store.createProfile(`Claude startup ${name}`); + const generation = store.nextProfileGeneration(created.id); + if (!store.setProfileState( + generation.id, + generation.processGeneration, + "signed_in", + { email: `claude-startup-${name}@example.com`, plan: "Plus" }, + )) throw new Error("Expected the startup recovery profile to sign in."); + const profile = store.requireProfileById(generation.id); + const claimed = store.recordClaimedClaudeProcessAuthority({ + providerThreadId: `claude-startup-${name}`, + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "managed", + identity: { + pid, + pidDomain: "darwin", + procStart: `claude-startup-process-${name}`, + }, + }); + const authority = store.beginClaudeProcessAuthorityRelease({ + providerThreadId: claimed.providerThreadId, + profileId: claimed.profileId, + runtimeScope: claimed.runtimeScope, + expectedRevision: claimed.revision, + identity: claimed.identity, + }); + const revocation = store.stageProfilePersonalAuthorityRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + }); + if (!store.setProfileState( + profile.id, + profile.processGeneration, + "recovery_required", + { email: `claude-startup-${name}@example.com`, plan: "Plus" }, + )) throw new Error("Expected the startup recovery profile to enter recovery."); + return { + authority, + profile: store.requireProfileById(profile.id), + revocation, + runRoot, + store, + }; + } catch (error: unknown) { + store.close(); + await rm(runRoot, { force: true, recursive: true }); + throw error; + } +}; + +const stagedClaudeLaunchIntentStartupFixture = async (name: string) => { + const runRoot = await realpath(await mkdtemp(join(tmpdir(), `hra-claude-launch-${name}-`))); + const paths = resolveStatePaths({ homeDirectory: runRoot, platform: "darwin" }); + await initializeStatePaths(paths); + const store = new StateStore(paths, { now: (() => { + let value = 2_000; + return () => value++; + })() }); + try { + const created = store.createProfile(`Claude launch ${name}`); + const generation = store.nextProfileGeneration(created.id); + if (!store.setProfileState( + generation.id, + generation.processGeneration, + "signed_in", + { email: `claude-launch-${name}@example.com`, plan: "Plus" }, + )) throw new Error("Expected the launch-intent profile to sign in."); + const profile = store.requireProfileById(generation.id); + const providerThreadId = `claude-launch-${name}`; + const providerAccountKey = `v1:claude:${"a".repeat(64)}`; + const session = store.upsertProviderSession({ + profileId: profile.id, + provider: "claude", + providerThreadId, + title: `Claude launch ${name}`, + preset: "fable-max", + fastEnabled: false, + state: "idle", + providerAccountKey, + }); + const intent = store.stageClaudeProcessLaunchIntent({ + providerThreadId, + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "managed", + providerAccountKey, + sessionId: session.id, + }); + return { intent, profile, runRoot, session, store }; + } catch (error: unknown) { + store.close(); + await rm(runRoot, { force: true, recursive: true }); + throw error; + } +}; + const cursorWireSignature = "A".repeat(43); const cursorWire = (label: string): string => `hra1.${Buffer.from(`fixture:${label}`).toString("base64url")}.${cursorWireSignature}`; @@ -368,6 +539,321 @@ const exactStopDependencies = ( }); describe("CLI entry point", () => { + test("redirects acceptance personal Claude account reads and processes into fixture custody", async () => { + const value = await upgradeFixture("personal-claude-home"); + try { + const configDir = value.installation.personalProviderHomes.claudeConfigDir; + const configHome = personalClaudeConfigHomeForInstallation(value.installation); + expect(configHome).toBe("isolated"); + expect(personalClaudeConfigHomeForInstallation(createProductionInstallation())) + .toBe("personal"); + + const runtime = Object.freeze({ + argv: [process.execPath] as const, + effort: CLAUDE_PIN_EFFORT, + executablePath: process.execPath, + model: CLAUDE_PIN_MODEL, + version: CLAUDE_PIN, + }); + const metadataPaths: string[] = []; + const account = await readClaudeAccountProjection({ + configDir, + configHome, + runtime, + signal: new AbortController().signal, + readMetadata: (path) => { + metadataPaths.push(path); + return Promise.resolve({ + oauthAccount: { + accountUuid: "acceptance-account", + emailAddress: "acceptance@example.test", + organizationUuid: "acceptance-organization", + }, + }); + }, + probeAuthStatus: (input) => { + expect(input.configDir).toBe(configDir); + expect(input.configHome).toBe("isolated"); + return Promise.resolve({ loggedIn: true }); + }, + }); + expect(account).toMatchObject({ + accountId: "acceptance-account", + email: "acceptance@example.test", + signedIn: true, + }); + expect(metadataPaths).toEqual([ + join(configDir, ".claude.json"), + join(configDir, ".claude.json"), + ]); + + const child = spawnBunClaudeProcess({ + argv: [ + process.execPath, + "-e", + "process.stdout.write(process.env.CLAUDE_CONFIG_DIR ?? 'missing')", + ], + configDir, + configHome, + inspectIdentity: (pid) => Promise.resolve({ + pid, + pidDomain: "darwin", + procStart: "Fri Sep 4 12:00:00 2026", + }), + }); + const output: Uint8Array[] = []; + for await (const chunk of child.stdout) output.push(chunk); + expect(await child.exited).toBe(0); + expect(new TextDecoder().decode(Buffer.concat(output))).toBe(configDir); + } finally { + await rm(value.runRoot, { force: true, recursive: true }); + } + }); + + test("startup preserves live or unknown Claude launch intent authority and generation", async () => { + for (const [index, liveness] of (["live", "unknown"] as const).entries()) { + const value = await stagedClaudeLaunchIntentStartupFixture(liveness); + const deadlineAt = 34_567 + index; + const controller = new AbortController(); + let probes = 0; + try { + await expect(releaseProvenDeadClaudeAuthoritiesBeforeDaemonGeneration( + value.store, + { + deadlineAt, + signal: controller.signal, + launchIntentProbe: { + probe: (providerThreadId, input) => { + probes += 1; + expect(providerThreadId).toBe(value.intent.providerThreadId); + expect(input.deadlineAt).toBe(deadlineAt); + expect(input.signal).toBe(controller.signal); + return Promise.resolve(liveness); + }, + }, + probe: () => { + throw new Error("Exact process liveness must not run for a launch intent."); + }, + }, + )).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + expect(probes).toBe(1); + expect(value.store.readClaudeProcessLaunchIntent({ + providerThreadId: value.intent.providerThreadId, + profileId: value.intent.profileId, + runtimeScope: value.intent.runtimeScope, + })).toEqual(value.intent); + expect(value.store.requireProfileById(value.profile.id)).toEqual(value.profile); + + expect(() => value.store.nextDaemonGeneration( + `boot_${index === 0 ? "d".repeat(32) : "e".repeat(32)}`, + )).toThrow("live session controllers must release before account generation changes"); + expect(value.store.readClaudeProcessLaunchIntent({ + providerThreadId: value.intent.providerThreadId, + profileId: value.intent.profileId, + runtimeScope: value.intent.runtimeScope, + })).toEqual(value.intent); + expect(value.store.requireProfileById(value.profile.id)).toEqual(value.profile); + } finally { + value.store.close(); + await rm(value.runRoot, { force: true, recursive: true }); + } + } + }); + + test("startup cancels a proven-dead Claude launch intent before generation advance", async () => { + const value = await stagedClaudeLaunchIntentStartupFixture("not-live"); + const deadlineAt = 45_678; + const controller = new AbortController(); + let probes = 0; + try { + await releaseProvenDeadClaudeAuthoritiesBeforeDaemonGeneration( + value.store, + { + deadlineAt, + signal: controller.signal, + launchIntentProbe: { + probe: (providerThreadId, input) => { + probes += 1; + expect(providerThreadId).toBe(value.intent.providerThreadId); + expect(input.deadlineAt).toBe(deadlineAt); + expect(input.signal).toBe(controller.signal); + return Promise.resolve("not_live"); + }, + }, + probe: () => { + throw new Error("Exact process liveness must not run for a launch intent."); + }, + }, + ); + expect(probes).toBe(1); + expect(value.store.readClaudeProcessLaunchIntent({ + providerThreadId: value.intent.providerThreadId, + profileId: value.intent.profileId, + runtimeScope: value.intent.runtimeScope, + })).toBeNull(); + expect(value.store.requireProfileById(value.profile.id)).toEqual(value.profile); + + expect(value.store.nextDaemonGeneration(`boot_${"f".repeat(32)}`)).toBe(1); + expect(value.store.requireProfileById(value.profile.id)).toMatchObject({ + processGeneration: value.profile.processGeneration + 1, + state: "signed_in", + }); + } finally { + value.store.close(); + await rm(value.runRoot, { force: true, recursive: true }); + } + }); + + test("startup cannot cancel a restaged Claude launch intent through ABA", async () => { + const value = await stagedClaudeLaunchIntentStartupFixture("aba"); + let replacement: typeof value.intent | undefined; + try { + await expect(releaseProvenDeadClaudeAuthoritiesBeforeDaemonGeneration( + value.store, + { + launchIntentProbe: { + probe: () => { + value.store.cancelClaudeProcessLaunchIntent({ + providerThreadId: value.intent.providerThreadId, + profileId: value.intent.profileId, + profileGeneration: value.intent.profileGeneration, + runtimeScope: value.intent.runtimeScope, + intentId: value.intent.intentId, + expectedRevision: value.intent.revision, + }); + replacement = value.store.stageClaudeProcessLaunchIntent({ + providerThreadId: value.intent.providerThreadId, + profileId: value.intent.profileId, + profileGeneration: value.intent.profileGeneration, + runtimeScope: value.intent.runtimeScope, + providerAccountKey: value.intent.providerAccountKey + ?? `v1:claude:${"a".repeat(64)}`, + sessionId: value.session.id, + }); + return Promise.resolve("not_live"); + }, + }, + }, + )).rejects.toThrow("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_CONFLICT"); + if (replacement === undefined) throw new Error("Expected the launch intent to be restaged."); + expect(replacement.intentId).not.toBe(value.intent.intentId); + expect(replacement.revision).toBe(value.intent.revision); + expect(value.store.readClaudeProcessLaunchIntent({ + providerThreadId: value.intent.providerThreadId, + profileId: value.intent.profileId, + runtimeScope: value.intent.runtimeScope, + })).toEqual(replacement); + expect(value.store.requireProfileById(value.profile.id)).toEqual(value.profile); + } finally { + value.store.close(); + await rm(value.runRoot, { force: true, recursive: true }); + } + }); + + test("startup refuses live or unknown Claude custody during a staged revocation", async () => { + for (const [index, liveness] of (["live", "unknown"] as const).entries()) { + const value = await stagedClaudeStartupRecoveryFixture( + liveness, + 61_001 + index, + ); + const deadlineAt = 12_345; + const controller = new AbortController(); + let probes = 0; + try { + await expect(releaseProvenDeadClaudeAuthoritiesBeforeDaemonGeneration( + value.store, + { + deadlineAt, + signal: controller.signal, + probe: (identity, input) => { + probes += 1; + expect(identity).toEqual(value.authority.identity); + expect(input.deadlineAt).toBe(deadlineAt); + expect(input.signal).toBe(controller.signal); + return Promise.resolve(liveness); + }, + }, + )).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + expect(probes).toBe(1); + expect(value.store.requireProfileById(value.profile.id)).toEqual(value.profile); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId: value.authority.providerThreadId, + profileId: value.authority.profileId, + runtimeScope: value.authority.runtimeScope, + })).toEqual(value.authority); + expect(value.store.readProfilePersonalAuthorityRevocation(value.profile.id)) + .toEqual(value.revocation); + + expect(() => value.store.nextDaemonGeneration( + `boot_${index === 0 ? "a".repeat(32) : "b".repeat(32)}`, + )).toThrow("live session controllers must release before account generation changes"); + expect(value.store.requireProfileById(value.profile.id)).toEqual(value.profile); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId: value.authority.providerThreadId, + profileId: value.authority.profileId, + runtimeScope: value.authority.runtimeScope, + })).toEqual(value.authority); + expect(value.store.readProfilePersonalAuthorityRevocation(value.profile.id)) + .toEqual(value.revocation); + } finally { + value.store.close(); + await rm(value.runRoot, { force: true, recursive: true }); + } + } + }); + + test("startup releases proven-dead Claude custody before advancing daemon generation", async () => { + const value = await stagedClaudeStartupRecoveryFixture("not-live", 61_003); + const deadlineAt = 23_456; + const controller = new AbortController(); + let probes = 0; + try { + await releaseProvenDeadClaudeAuthoritiesBeforeDaemonGeneration( + value.store, + { + deadlineAt, + signal: controller.signal, + probe: (identity, input) => { + probes += 1; + expect(identity).toEqual(value.authority.identity); + expect(input.deadlineAt).toBe(deadlineAt); + expect(input.signal).toBe(controller.signal); + return Promise.resolve("not_live"); + }, + }, + ); + expect(probes).toBe(1); + const released = value.store.readClaudeProcessAuthority({ + providerThreadId: value.authority.providerThreadId, + profileId: value.authority.profileId, + runtimeScope: value.authority.runtimeScope, + }); + expect(released).toMatchObject({ + identity: value.authority.identity, + profileGeneration: value.profile.processGeneration, + revision: value.authority.revision + 1, + state: "released", + }); + expect(value.store.requireProfileById(value.profile.id)).toEqual(value.profile); + expect(value.store.readProfilePersonalAuthorityRevocation(value.profile.id)) + .toEqual(value.revocation); + + expect(value.store.nextDaemonGeneration(`boot_${"c".repeat(32)}`)).toBe(1); + expect(value.store.requireProfileById(value.profile.id)).toMatchObject({ + processGeneration: value.profile.processGeneration + 1, + state: "recovery_required", + }); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId: value.authority.providerThreadId, + profileId: value.authority.profileId, + runtimeScope: value.authority.runtimeScope, + })).toEqual(released); + } finally { + value.store.close(); + await rm(value.runRoot, { force: true, recursive: true }); + } + }); + test("reads root status locally without daemon autostart or transport", async () => { const captured = capture(); let daemonCalls = 0; @@ -5699,6 +6185,58 @@ describe("CLI entry point", () => { }); }); + test("latches an already-aborted daemon stop until lifecycle delivery is installed", async () => { + const { installation, runRoot } = await upgradeFixture("daemon-stop-latched-before-lifecycle"); + try { + const initialized = capture(); + expect(await main(["init", "--yes", "--json"], initialized.output, { installation })).toBe(0); + await mkdir(installation.paths.socket, { mode: 0o700 }); + const before = await readDaemonAuthorityReceipt(installation.paths); + expect(before?.state).toBe("stopped"); + + const controller = new AbortController(); + controller.abort(new Error("Stop before daemon lifecycle delivery exists.")); + + expect(await runDaemon(installation, { stopSignal: controller.signal })).toBe(0); + const after = await readDaemonAuthorityReceipt(installation.paths); + expect(after).toMatchObject({ state: "stopped" }); + expect(after?.nonce).not.toBe(before?.nonce); + expect(after?.generation).toBeUndefined(); + expect((await lstat(installation.paths.socket)).isDirectory()).toBe(true); + } finally { + await rm(runRoot, { force: true, recursive: true }); + } + }); + + test("delivers an abort during early daemon boot before transport exists", async () => { + const { installation: baseInstallation, runRoot } = await upgradeFixture("daemon-stop-during-early-boot"); + try { + const initialized = capture(); + expect(await main(["init", "--yes", "--json"], initialized.output, { + installation: baseInstallation, + })).toBe(0); + await mkdir(baseInstallation.paths.socket, { mode: 0o700 }); + + const controller = new AbortController(); + let secretCustodyCreations = 0; + const installation = { + ...baseInstallation, + createSecretCustody: () => { + secretCustodyCreations += 1; + controller.abort(new Error("Stop during early daemon boot.")); + return baseInstallation.createSecretCustody(); + }, + }; + + expect(await runDaemon(installation, { stopSignal: controller.signal })).toBe(0); + expect(secretCustodyCreations).toBe(1); + expect(await readDaemonAuthorityReceipt(installation.paths)).toMatchObject({ state: "stopped" }); + expect((await lstat(installation.paths.socket)).isDirectory()).toBe(true); + } finally { + await rm(runRoot, { force: true, recursive: true }); + } + }); + test("daemon start renders the starter's verified identity without a second status request", async () => { const runId = "018f1f55-3f10-7c1a-8f7b-c6dc608bcd4d"; const runRoot = await realpath( @@ -5766,7 +6304,7 @@ describe("CLI entry point", () => { }); expect(started.read().stderr).toBe(""); expect(daemonStarts).toBe(1); - expect(stateSchemaVersion(installation.paths.database)).toBe(39); + expect(stateSchemaVersion(installation.paths.database)).toBe(40); } finally { await rm(runRoot, { force: true, recursive: true }); } @@ -5790,7 +6328,7 @@ describe("CLI entry point", () => { error: { code: "RECOVERY_REQUIRED", details: { nextCommand: "hra daemon start" }, - message: "The local state schema needs a migration (35 to 39); start the daemon to migrate it.", + message: "The local state schema needs a migration (35 to 40); start the daemon to migrate it.", }, ok: false, version: 1, @@ -5822,14 +6360,14 @@ describe("CLI entry point", () => { expect(JSON.parse(captured.read().stdout)).toEqual({ error: { code: "RECOVERY_REQUIRED", - message: "This HRA build is older than the local state schema (40 vs 39); install the newer HRA.", + message: "This HRA build is older than the local state schema (41 vs 40); install the newer HRA.", }, ok: false, version: 1, }); expect(captured.read().stderr).toBe(""); expect(daemonStarts).toBe(0); - expect(stateSchemaVersion(installation.paths.database)).toBe(40); + expect(stateSchemaVersion(installation.paths.database)).toBe(41); } finally { await rm(runRoot, { force: true, recursive: true }); } @@ -5851,7 +6389,7 @@ describe("CLI entry point", () => { error: { code: "UNHEALTHY", message: "HRA checks found 1 problem." }, data: { healthy: false, - problems: ["The local state schema needs a migration (35 to 39). Run `hra daemon start` to migrate it."], + problems: ["The local state schema needs a migration (35 to 40). Run `hra daemon start` to migrate it."], state: { database: "invalid", initialized: false }, }, }); @@ -5878,7 +6416,7 @@ describe("CLI entry point", () => { error: { code: "UNHEALTHY", message: "HRA checks found 1 problem." }, data: { healthy: false, - problems: ["This HRA build is older than the local state schema (40 vs 39). Install the newer HRA."], + problems: ["This HRA build is older than the local state schema (41 vs 40). Install the newer HRA."], state: { database: "invalid", initialized: false }, }, }); diff --git a/src/cli.ts b/src/cli.ts index cc080af..29ba63a 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -92,7 +92,12 @@ import { type CloudProjectionRecoveryStatus, type CloudSecretCustodyPort, } from "./cloud/index"; -import { allowlistedEnvironment, resolvePinnedCodexRuntime } from "./codex/index"; +import { + allowlistedEnvironment, + readCodexAutomationAuthority, + resolvePinnedCodexRuntime, + type CodexAutomationAuthorityRequest, +} from "./codex/index"; import { createClaudeLoginSignalCustody, resolvePinnedClaudeRuntime, @@ -115,7 +120,7 @@ import { type ResolvePinnedDevinRuntimeOptions, } from "./devin/index"; import { localCommandSchema, type CommandResponse, type LocalCommand } from "./domain/contracts"; -import { providerSchema, type Provider } from "./domain/presets"; +import { adoptableProviderSchema, providerSchema, type Provider } from "./domain/presets"; import { digestTranscriptRecords, sessionTranscriptSchema, @@ -177,8 +182,14 @@ import { import { PinnedClaudeRuntimeManager } from "./daemon/claude-runtime-adapter"; import { PinnedCodexRuntimeManager } from "./daemon/codex-runtime-adapter"; import { PinnedDevinRuntimeManager } from "./daemon/devin-runtime-adapter"; +import { + BoundedPersonalSessionDiscovery, + createLocalClaudeProcessLivenessProbe, + createPersonalClaudeDiscoveryAdapters, + type ClaudeProcessLivenessProbe, +} from "./daemon/personal-session-discovery"; import { HraFactsMemoryLifecycle } from "./daemon/facts-memory-lifecycle"; -import { UnavailableCloudControl, type ClaudeRuntimePort, type CloudControlPort, type CodexRuntimePort, type CompactProjectionRecoveryBlocker, type DevinRuntimePort } from "./daemon/ports"; +import { UnavailableCloudControl, type CloudControlPort, type CompactProjectionRecoveryBlocker } from "./daemon/ports"; import { SessionEventCursorCodec } from "./daemon/session-event-cursor"; import { CommandFailure, HraService } from "./daemon/service"; import { AccountUsagePoller } from "./daemon/usage-poller"; @@ -206,6 +217,8 @@ import type { GenerationalSecretCustody } from "./storage/secret-custody"; import { StateStore } from "./storage/state-store"; import { WorkCapabilityCodec } from "./storage/work-capability"; import { HRA_VERSION } from "./version"; +import { CLAUDE_PIN } from "./claude/pin"; +import { ClaudeLaunchIntentLivenessProbe } from "./claude/process"; const writeProcessStdoutAsync = (value: string, signal: AbortSignal): Promise => new Promise((resolve, reject) => { @@ -3183,8 +3196,99 @@ async function joinBeforeDeadline(operation: string, promise: Promise, dea } } -export async function runDaemon( - installation: HraInstallation = createProductionInstallation(), +/** + * Live acceptance redirects its synthetic "personal" provider home under the + * fixture root. Claude must therefore select that directory explicitly even + * though service authority still classifies the controller as personal. + * Production preserves Claude's real default-home semantics. + */ +export function personalClaudeConfigHomeForInstallation( + installation: Pick, +): "isolated" | "personal" { + return installation.kind === "live_acceptance" ? "isolated" : "personal"; +} + +export async function releaseProvenDeadClaudeAuthoritiesBeforeDaemonGeneration( + store: StateStore, + options: Readonly<{ + probe?: ClaudeProcessLivenessProbe; + launchIntentProbe?: Pick; + deadlineAt?: number; + signal?: AbortSignal; + }> = {}, +): Promise { + const probe = options.probe ?? createLocalClaudeProcessLivenessProbe(); + const launchIntentProbe = options.launchIntentProbe + ?? new ClaudeLaunchIntentLivenessProbe(); + const signal = options.signal ?? new AbortController().signal; + const deadlineAt = options.deadlineAt ?? Date.now() + 3_000; + for (;;) { + const intents = store.listClaudeProcessLaunchIntents(); + if (intents.length === 0) break; + for (const intent of intents) { + const liveness = await launchIntentProbe.probe(intent.providerThreadId, { + deadlineAt, + signal, + }); + if (liveness !== "not_live") { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "A prior Claude launch did not finish recording exact process custody. Exit any Claude process for that session, then retry `hra daemon start`; HRA will not advance account authority around it.", + ); + } + store.cancelClaudeProcessLaunchIntent({ + providerThreadId: intent.providerThreadId, + profileId: intent.profileId, + profileGeneration: intent.profileGeneration, + runtimeScope: intent.runtimeScope, + intentId: intent.intentId, + expectedRevision: intent.revision, + }); + } + } + for (;;) { + const authorities = store.listUnreleasedClaudeProcessAuthorities(); + if (authorities.length === 0) return; + for (const authority of authorities) { + const liveness = await probe(authority.identity, { deadlineAt, signal }); + if (liveness !== "not_live") { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "A prior HRA-owned Claude controller is still live or cannot be proven stopped. Exit it, then retry `hra daemon start`; HRA will not advance account authority around it.", + ); + } + const releasing = authority.state === "releasing" + ? authority + : store.beginClaudeProcessAuthorityRelease({ + providerThreadId: authority.providerThreadId, + profileId: authority.profileId, + runtimeScope: authority.runtimeScope, + expectedRevision: authority.revision, + identity: authority.identity, + }); + store.completeClaudeProcessAuthorityRelease({ + providerThreadId: releasing.providerThreadId, + profileId: releasing.profileId, + runtimeScope: releasing.runtimeScope, + expectedRevision: releasing.revision, + identity: releasing.identity, + }); + } + } +} + +type DaemonStopLatch = { + deliver: (() => void) | undefined; + requested: boolean; +}; + +export type RunDaemonOptions = Readonly<{ + stopSignal?: AbortSignal; +}>; + +async function runDaemonLifecycle( + installation: HraInstallation, + stopLatch: DaemonStopLatch, ): Promise { assertInstallationHome(installation); const paths = installation.paths; @@ -3196,6 +3300,8 @@ export async function runDaemon( let factsMemoryControl: FactsMemoryControlStore | undefined; let codex: PinnedCodexRuntimeManager | undefined; let claude: PinnedClaudeRuntimeManager | undefined; + let personalCodex: PinnedCodexRuntimeManager | undefined; + let personalClaude: PinnedClaudeRuntimeManager | undefined; let devin: PinnedDevinRuntimeManager | undefined; let service: HraService | undefined; let server: LocalDaemonServer | undefined; @@ -3203,6 +3309,8 @@ export async function runDaemon( let cloudLifecycle: CloudDaemonLifecycle | undefined; let usagePoller: AccountUsagePoller | undefined; let usagePollerShutdown: Promise | undefined; + let adoptionPoller: AccountUsagePoller | undefined; + let adoptionPollerShutdown: Promise | undefined; let cloudRequestController: AbortController | undefined; let daemonAuthority: DaemonAuthorityFence | undefined; let serviceShutdown: Promise | undefined; @@ -3215,11 +3323,14 @@ export async function runDaemon( if (stopRequested) return; stopRequested = true; if (usagePoller !== undefined) usagePollerShutdown ??= usagePoller.close(); + if (adoptionPoller !== undefined) adoptionPollerShutdown ??= adoptionPoller.close(); if (service !== undefined) serviceShutdown = service.close(); else daemonAuthority?.close(); server?.beginShutdown(new Error("Daemon shutdown was requested.")); resolveStop(); }; + stopLatch.deliver = requestStop; + if (stopLatch.requested) requestStop(); const onSignal = () => requestStop(); process.once("SIGINT", onSignal); process.once("SIGTERM", onSignal); @@ -3260,6 +3371,7 @@ export async function runDaemon( activeStore.configurePublicProviderIdentifierProjector( (value) => eventCursors.projectPublicProviderIdentifier(value), ); + await releaseProvenDeadClaudeAuthoritiesBeforeDaemonGeneration(activeStore); bootId = `boot_${randomUUID().replaceAll("-", "")}`; generation = activeStore.nextDaemonGeneration(bootId); await daemonLock.publish({ state: "booting", generation, bootId }); @@ -3268,6 +3380,7 @@ export async function runDaemon( checkpointBoot(); const serviceReference: { current?: HraService } = {}; codex = new PinnedCodexRuntimeManager({ + allowSameGenerationRelaunchAfterProviderDisconnect: true, ...(installation.kind === "live_acceptance" ? { codexEnvironment: installation.codexEnvironment, @@ -3308,6 +3421,7 @@ export async function runDaemon( // so a machine without it pays nothing and is refused with one exact, // actionable message at `session start --provider claude`. claude = new PinnedClaudeRuntimeManager({ + configHome: "isolated", configDirFor: async (authority) => await ensurePrivateDirectory( profilePaths(paths, authority.id).claudeConfigDir, ), @@ -3325,6 +3439,121 @@ export async function runDaemon( }, }, }); + const personalHomes = installation.personalProviderHomes; + personalCodex = new PinnedCodexRuntimeManager({ + allowSameGenerationRelaunchAfterProviderDisconnect: true, + ...(installation.kind === "live_acceptance" + ? { codexEnvironment: installation.codexEnvironment } + : {}), + credentialStorePreflight: { + ...installation.credentialStorePreflight, + // Bootstrap against an HRA-owned neutral directory. Project-scoped + // operations perform their own effective-config preflight later. + cwd: installation.paths.root, + }, + isCurrent: (authority) => { + try { + const profile = activeStore.requireProfile(authority.id); + return profile.processGeneration === authority.generation && profile.state !== "removed"; + } catch { + return false; + } + }, + observer: { + // Personal-home identity never mutates the selected isolated login; + // the service compares it and durably revokes controllers on drift. + account: async (authority, account) => { + await serviceReference.current?.observePersonalCodexAccount(authority, account); + }, + conversationAutomation: async (authority, call) => { + const current = serviceReference.current; + if (current === undefined) { + throw new Error("The HRA service is unavailable during conversation automation."); + } + return await current.handleConversationAutomationToolCall(authority, call, "personal"); + }, + conversationAutomationResponseWritten: (authority, call) => { + serviceReference.current?.notifyConversationAutomationToolResponseWritten( + authority, + call, + "personal", + ); + }, + fact: async (authority, fact) => { + await serviceReference.current?.observePersonalCodexFact(authority, fact); + }, + }, + }); + personalClaude = new PinnedClaudeRuntimeManager({ + configHome: personalClaudeConfigHomeForInstallation(installation), + configDirFor: () => personalHomes.claudeConfigDir, + isCurrent: (authority) => { + try { + const profile = activeStore.requireProfile(authority.id); + return profile.processGeneration === authority.generation && profile.state !== "removed"; + } catch { + return false; + } + }, + observer: { + fact: async (authority, fact) => { + await serviceReference.current?.observePersonalClaudeFact(authority, fact); + }, + }, + }); + const activePersonalCodex = personalCodex; + const personalCodexAutomationsDirectory = join( + personalHomes.codexHome, + "automations", + ); + const readPersonalCodexAutomationAuthority = async ( + request: CodexAutomationAuthorityRequest, + ) => await readCodexAutomationAuthority({ + ...request, + automationsDirectory: personalCodexAutomationsDirectory, + }); + const personalClaudeDiscovery = createPersonalClaudeDiscoveryAdapters({ + configDir: personalHomes.claudeConfigDir, + pinnedVersion: CLAUDE_PIN, + }); + const personalDiscovery = new BoundedPersonalSessionDiscovery({ + codexListPage: async ({ cursor, limit, signal }) => { + const policy = activeStore.readSessionAdoptionPolicy("codex"); + if (policy === null || !policy.enabled || policy.profileId === null) { + return { sessions: [], nextCursor: null }; + } + const profile = activeStore.requireProfileById(policy.profileId); + const isolated = profilePaths(paths, profile.id); + return await activePersonalCodex.listSessions({ + authority: { + id: profile.id, + generation: profile.processGeneration, + codexHome: personalHomes.codexHome, + desktopUserData: isolated.desktopUserData, + }, + limit, + ...(cursor === undefined ? {} : { cursor }), + signal, + }); + }, + codexReadSession: async ({ providerThreadId, signal }) => { + const policy = activeStore.readSessionAdoptionPolicy("codex"); + if (policy === null || !policy.enabled || policy.profileId === null) return null; + const profile = activeStore.requireProfileById(policy.profileId); + const isolated = profilePaths(paths, profile.id); + return await activePersonalCodex.readSessionMetadata( + { + id: profile.id, + generation: profile.processGeneration, + codexHome: personalHomes.codexHome, + desktopUserData: isolated.desktopUserData, + }, + providerThreadId, + signal, + ); + }, + ...personalClaudeDiscovery, + }); // Devin owns its credentials and native sessions. HRA gives the pinned ACP // process a per-account HOME plus all four XDG roots and observes only the // provider-neutral facts emitted by the manager. @@ -3407,59 +3636,21 @@ export async function runDaemon( "Cloud deployment authority changed during daemon startup.", ); } - const cloudCodex = new Proxy(codex, { - get(target, property) { - const value = Reflect.get(target, property, target) as unknown; - if (typeof value !== "function") return value; - if (property === "close") { - return (...args: unknown[]): unknown => Reflect.apply(value, target, args) as unknown; - } - return async (...args: unknown[]) => { - await activeDaemonAuthority.assertCurrent(); - const result = await Reflect.apply(value, target, args) as unknown; - await activeDaemonAuthority.assertCurrent(); - return result; - }; - }, - }) as CodexRuntimePort; - // The same authority fence around the Claude seam: cloud projection - // reads a Claude session through its own port. - const cloudClaude = new Proxy(claude, { - get(target, property) { - const value = Reflect.get(target, property, target) as unknown; - if (typeof value !== "function") return value; - if (property === "close" || property === "interactionAuthority" - || property === "pinnedVersion") { - return (...args: unknown[]): unknown => Reflect.apply(value, target, args) as unknown; + candidateAdapter = new StateBackedCloudDaemonAdapter({ + readSessionProjectionForCloud: async (sessionId, signal) => { + const current = serviceReference.current; + if (current === undefined) { + throw new Error("The local command service is not ready for a cloud projection read."); } - return async (...args: unknown[]) => { - await activeDaemonAuthority.assertCurrent(); - const result = await Reflect.apply(value, target, args) as unknown; - await activeDaemonAuthority.assertCurrent(); - return result; - }; + return await current.readSessionProjectionForCloud(sessionId, signal); }, - }) as ClaudeRuntimePort; - const cloudDevin = new Proxy(devin, { - get(target, property) { - const value = Reflect.get(target, property, target) as unknown; - if (typeof value !== "function") return value; - if (property === "close" || property === "interactionAuthority" - || property === "pinnedVersion") { - return (...args: unknown[]): unknown => Reflect.apply(value, target, args) as unknown; + readProviderAccountProjectionForCloud: async (input) => { + const current = serviceReference.current; + if (current === undefined) { + throw new Error("The local command service is not ready for a cloud account read."); } - return async (...args: unknown[]) => { - await activeDaemonAuthority.assertCurrent(); - const result = await Reflect.apply(value, target, args) as unknown; - await activeDaemonAuthority.assertCurrent(); - return result; - }; + return await current.readProviderAccountProjectionForCloud(input); }, - }) as DevinRuntimePort; - candidateAdapter = new StateBackedCloudDaemonAdapter({ - claude: cloudClaude, - codex: cloudCodex, - devin: cloudDevin, // Device commands run ordinary local commands, so they go through the // same admitted service path a person's CLI uses, with the same // idempotency, quarantine, and authority checks. @@ -3568,6 +3759,12 @@ export async function runDaemon( codex, claude, devin, + personalCodex, + personalClaude, + personalCodexHome: personalHomes.codexHome, + readPersonalCodexAutomations: readPersonalCodexAutomationAuthority, + personalDiscovery, + claudeProcessLiveness: personalClaudeDiscovery.claudeProcessLiveness, cloud, daemonAuthority: activeDaemonAuthority, daemonGeneration: generation, @@ -3613,6 +3810,21 @@ export async function runDaemon( }, }); usagePoller.start(); + adoptionPoller = new AccountUsagePoller({ + listAccountIds: () => activeStore.listSessionAdoptionPolicies() + .filter((policy) => policy.enabled) + .map((policy) => policy.provider), + poll: async (provider, signal) => { + await activeService.discoverPersonalSessions(adoptableProviderSchema.parse(provider), signal); + }, + onFailure: (_provider, error) => { + activeService.recordBackgroundDiagnostic("session_adoption_failed", error); + }, + onTickFailure: (error) => { + activeService.recordBackgroundDiagnostic("session_adoption_failed", error); + }, + }); + adoptionPoller.start(); cloudLifecycle?.start(); server = await LocalDaemonServer.start({ paths, @@ -3644,8 +3856,10 @@ export async function runDaemon( } catch (error: unknown) { if (!(error instanceof DaemonBootInterruptedError)) runError = error; } finally { + if (stopLatch.deliver === requestStop) stopLatch.deliver = undefined; runError ??= unhandledRejectionError; if (usagePoller !== undefined) usagePollerShutdown ??= usagePoller.close(); + if (adoptionPoller !== undefined) adoptionPollerShutdown ??= adoptionPoller.close(); if (service !== undefined) serviceShutdown ??= service.close(); else daemonAuthority?.close(); server?.beginShutdown(new Error("Daemon lifetime ended.")); @@ -3668,6 +3882,12 @@ export async function runDaemon( else cleanupErrors.push(error); } } + if (!(runError instanceof DaemonJoinDeadlineError) && !(runError instanceof LocalDaemonShutdownTimeoutError) && adoptionPollerShutdown !== undefined) { + try { await joinBeforeDeadline("Session adoption poller shutdown", adoptionPollerShutdown); } catch (error: unknown) { + if (error instanceof DaemonJoinDeadlineError) runError = error; + else cleanupErrors.push(error); + } + } if (!(runError instanceof DaemonJoinDeadlineError) && !(runError instanceof LocalDaemonShutdownTimeoutError) && cloudLifecycle !== undefined) { try { await joinBeforeDeadline("Cloud daemon shutdown", cloudLifecycle.close()); } catch (error: unknown) { if (error instanceof DaemonJoinDeadlineError) runError = error; @@ -3688,7 +3908,7 @@ export async function runDaemon( else { const runtimes: Readonly<{ close: () => Promise; - provider: "codex" | "claude" | "devin"; + provider: "codex" | "claude" | "devin" | "personal_codex" | "personal_claude"; }>[] = []; if (codex !== undefined) { const runtime = codex; @@ -3702,6 +3922,14 @@ export async function runDaemon( const runtime = devin; runtimes.push({ close: async () => await runtime.close(), provider: "devin" }); } + if (personalCodex !== undefined) { + const runtime = personalCodex; + runtimes.push({ close: async () => await runtime.close(), provider: "personal_codex" }); + } + if (personalClaude !== undefined) { + const runtime = personalClaude; + runtimes.push({ close: async () => await runtime.close(), provider: "personal_claude" }); + } const closed = await joinBeforeDeadline( "Provider runtime shutdown", Promise.allSettled(runtimes.map(async (runtime) => await runtime.close())), @@ -3760,6 +3988,29 @@ export async function runDaemon( return 0; } +export async function runDaemon( + installation: HraInstallation = createProductionInstallation(), + options: RunDaemonOptions = {}, +): Promise { + const stopLatch: DaemonStopLatch = { deliver: undefined, requested: false }; + const requestLatchedStop = () => { + if (stopLatch.requested) return; + stopLatch.requested = true; + stopLatch.deliver?.(); + }; + const stopSignal = options.stopSignal; + stopSignal?.addEventListener("abort", requestLatchedStop, { once: true }); + // Adding an abort listener to an already-aborted signal does not dispatch an + // event. Check after registration so no stop can be lost around this edge. + if (stopSignal?.aborted === true) requestLatchedStop(); + try { + return await runDaemonLifecycle(installation, stopLatch); + } finally { + stopLatch.deliver = undefined; + stopSignal?.removeEventListener("abort", requestLatchedStop); + } +} + const commandCaller = ( input: CliMainInput, ): ((command: LocalCommand, signal?: AbortSignal) => Promise) => { diff --git a/src/cli/parser.test.ts b/src/cli/parser.test.ts index e60c73b..9ac9d95 100644 --- a/src/cli/parser.test.ts +++ b/src/cli/parser.test.ts @@ -596,6 +596,58 @@ describe("CLI parser", () => { ]) expect(() => parseCli(argv)).toThrow(CliUsageError); }); + test("parses personal-home session adoption controls", () => { + expect(parseCli(["session", "adoption", "status"])) + .toEqual({ kind: "command", command: { kind: "session.adoption.status" }, json: false }); + expect(parseCli(["session", "adoption", "status", "--provider", "claude", "--json"])) + .toEqual({ + kind: "command", + command: { kind: "session.adoption.status", provider: "claude" }, + json: true, + }); + expect(parseCli(["session", "adoption", "enable", "personal", "--provider", "codex"])) + .toMatchObject({ + command: { + account: "personal", + enabled: true, + kind: "session.adoption.set", + provider: "codex", + }, + }); + expect(parseCli(["session", "adoption", "disable", "--provider", "claude"])) + .toMatchObject({ + command: { enabled: false, kind: "session.adoption.set", provider: "claude" }, + }); + expect(parseCli(["session", "discover", "--provider", "codex"])) + .toMatchObject({ command: { kind: "session.adoption.discover", provider: "codex" } }); + + for (const argv of [ + ["session", "adoption", "enable", "personal"], + ["session", "adoption", "disable"], + ["session", "adoption", "status", "--provider", "other"], + ["session", "discover", "--provider", "other"], + ["session", "detach", "working-session"], + ["session", "detach"], + ]) expect(() => parseCli(argv)).toThrow(CliUsageError); + }); + + test("keeps Devin outside every personal-home adoption command and transport shape", () => { + for (const argv of [ + ["session", "adoption", "status", "--provider", "devin"], + ["session", "adoption", "enable", "personal", "--provider", "devin"], + ["session", "adoption", "disable", "--provider", "devin"], + ["session", "discover", "--provider", "devin"], + ]) { + expect(() => parseCli(argv)).toThrow("Provider must be one of: `codex`, `claude`."); + } + + for (const command of [ + { kind: "session.adoption.status", provider: "devin" }, + { account: "personal", enabled: true, kind: "session.adoption.set", provider: "devin" }, + { kind: "session.adoption.discover", provider: "devin" }, + ]) expect(localCommandSchema.safeParse(command).success).toBe(false); + }); + test("maps cloud session reads and the closed remote command set", () => { const idempotencyKey = "018bcfe5-6800-7000-8000-000000000001"; expect(parseCli(["remote", "list", "--limit", "25", "--json"])).toEqual({ diff --git a/src/cli/parser.ts b/src/cli/parser.ts index cbef05b..1988d64 100644 --- a/src/cli/parser.ts +++ b/src/cli/parser.ts @@ -7,10 +7,12 @@ import type { LocalCommand } from "../domain/contracts"; import { localCommandSchema } from "../domain/contracts"; import { canonicalizeNotificationTimeZone } from "../domain/notification-hours"; import { + adoptableProviderSchema, DEFAULT_PROVIDER, defaultPresetForProvider, presetSchema, providerSchema, + type AdoptableProvider, type Preset, type Provider, } from "../domain/presets"; @@ -237,6 +239,10 @@ Usage: hra plugin show [--project ] [--refresh] hra project add|list|use hra session list|show|status|watch|start|send|queue|steer|stop + hra session adoption status [--provider codex|claude] + hra session adoption enable --provider codex|claude + hra session adoption disable --provider codex|claude + hra session discover [--provider codex|claude] hra session task list|show|create|edit|delete hra session events [--cursor ] [--limit <1..200>] [--wait-ms <0..30000>] [--json|--jsonl|--follow] hra session watch [--cursor ] [--jsonl] @@ -406,6 +412,10 @@ Usage: hra session send|queue|steer [--attach ]... hra session stop|recover|abandon hra session archive|unarchive + hra session adoption status [--provider ] + hra session adoption enable --provider + hra session adoption disable --provider + hra session discover [--provider ] hra session rename hra session note get|edit|clear hra session note set @@ -423,6 +433,8 @@ Usage: Examples: hra session start personal --project jungle hra session start personal --provider claude --preset fable-max + hra session adoption enable personal --provider codex + hra session discover --provider codex hra session start personal --provider devin --preset astra hra session switch my-session --provider claude hra session export my-session --format trajectory --out ./trajectory.json @@ -737,6 +749,17 @@ const selectedProvider = (value: string | undefined): Provider => { return parsed.data; }; +const selectedAdoptableProvider = (value: string | undefined): AdoptableProvider => { + if (value === undefined) throw new CliUsageError("Missing value for --provider."); + const parsed = adoptableProviderSchema.safeParse(value); + if (!parsed.success) { + throw new CliUsageError( + `Provider must be one of: ${adoptableProviderSchema.options.map((entry) => `\`${entry}\``).join(", ")}.`, + ); + } + return parsed.data; +}; + const selectedPreset = (value: string): Preset => { const parsed = presetSchema.safeParse(value); if (!parsed.success) { @@ -1481,6 +1504,41 @@ const parseSession = ( case "rename": { const session = take(cursor, "session"); return command({ kind: "session.rename", session, name: remainder(cursor, "name") }); } case "archive": { const session = take(cursor, "session"); finish(cursor); return { kind: "session.archive", session, archived: true }; } case "unarchive": { const session = take(cursor, "session"); finish(cursor); return { kind: "session.archive", session, archived: false }; } + case "adoption": { + const adoptionAction = take(cursor, "session adoption action"); + const providerValue = option(cursor, "--provider"); + const provider = providerValue === undefined + ? undefined + : selectedAdoptableProvider(providerValue); + if (adoptionAction === "status") { + finish(cursor); + return command({ kind: "session.adoption.status", provider }); + } + if (adoptionAction === "enable") { + if (provider === undefined) { + throw new CliUsageError("Session adoption enable requires --provider codex|claude."); + } + const account = take(cursor, "account"); + finish(cursor); + return command({ kind: "session.adoption.set", provider, enabled: true, account }); + } + if (adoptionAction === "disable") { + if (provider === undefined) { + throw new CliUsageError("Session adoption disable requires --provider codex|claude."); + } + finish(cursor); + return command({ kind: "session.adoption.set", provider, enabled: false }); + } + throw new CliUsageError("Unknown session adoption action. Use `status`, `enable`, or `disable`."); + } + case "discover": { + const providerValue = option(cursor, "--provider"); + const provider = providerValue === undefined + ? undefined + : selectedAdoptableProvider(providerValue); + finish(cursor); + return command({ kind: "session.adoption.discover", provider }); + } case "recover": { const session = take(cursor, "session"); finish(cursor); return { kind: "session.recover", session }; } case "abandon": { const session = take(cursor, "session"); finish(cursor); return { kind: "session.abandon", session }; } case "note": return parseSessionNote(cursor); diff --git a/src/cli/render.test.ts b/src/cli/render.test.ts index 35ab873..72f1fab 100644 --- a/src/cli/render.test.ts +++ b/src/cli/render.test.ts @@ -26,11 +26,31 @@ const capture = (): { output: Output; stdout: string[]; stderr: string[] } => { }; }; -const command = { kind: "session.show", session: "session-1", detail: false } as const; +const primarySessionId = `sess_${"1".repeat(32)}`; +const primaryProfileId = `acct_${"0".repeat(32)}`; +const primaryProjectId = `proj_${"2".repeat(32)}`; +const primaryTurnId = publicProviderId("turn-1"); +const command = { kind: "session.show", session: primarySessionId, detail: false } as const; const data = { - session: { id: "session-1", title: "Local title", state: "idle" }, + session: { + activeTurnId: "raw-active-turn", + createdAt: 1_000, + fastEnabled: false, + id: primarySessionId, + note: "private local note", + preset: "high", + profileId: primaryProfileId, + projectId: primaryProjectId, + provider: "codex", + providerThreadId: "thread-1", + providerUpdatedAt: 1_500, + revision: 2, + state: "idle", + title: "Local title", + updatedAt: 2_000, + }, effectiveRuntimeProfile: { - profileId: "acct_00000000000000000000000000000000", + profileId: primaryProfileId, processGeneration: 3, observedAt: 2_000, preset: "high", @@ -51,11 +71,11 @@ const data = { status: "idle", projectRoot: "/workspace/project", messages: [ - { role: "user", text: "please fix it", turnId: "turn-1" }, - { role: "assistant", text: "fixed\nverified", turnId: "turn-1", omission: { originalUtf8Bytes: 18, returnedUtf8Bytes: 14, omittedUtf8Bytes: 4 } }, + { role: "user", text: "please fix it", turnId: primaryTurnId }, + { role: "assistant", text: "fixed\nverified", turnId: primaryTurnId, omission: { originalUtf8Bytes: 18, returnedUtf8Bytes: 14, omittedUtf8Bytes: 4 } }, ], turnSummaries: [ - { id: "turn-1", status: "completed", runtimeMs: 1_234, files: ["src/index.ts"], actions: ["git status", "bun test"], omittedFiles: 0, omittedActions: 0 }, + { id: primaryTurnId, status: "completed", runtimeMs: 1_234, files: ["src/index.ts"], actions: ["git status", "bun test"], omittedFiles: 0, omittedActions: 0 }, ], omission: { hasMoreOlderTurns: true, returnedTurns: 1, turnLimit: 24, omittedMessages: 2, truncatedMessages: 1, unreadItemTurnIds: [], incompleteTurnIds: [] }, }, @@ -324,14 +344,29 @@ describe("CLI rendering", () => { }); test("renders a Claude session's reviewed runtime profile, not the Codex one", () => { + const claudeSession = { + createdAt: 1_000, + fastEnabled: false, + id: `sess_${"c".repeat(32)}`, + note: "private Claude note", + preset: "fable-max", + profileId: primaryProfileId, + projectId: primaryProjectId, + provider: "claude", + providerThreadId: "thread-claude", + revision: 2, + state: "idle", + title: "Claude work", + updatedAt: 2_000, + } as const; const shown = capture(); renderSuccess( { detail: true, kind: "session.show", session: "claude-session" }, { effectiveRuntimeProfile: { claudeVersion: "2.1.260", + configHome: "isolated", inputFormat: "stream-json", - isolatedConfigDir: true, model: "claude-fable-5-1", observedAt: 2_000, outputFormat: "stream-json", @@ -349,7 +384,7 @@ describe("CLI rendering", () => { title: "Claude work", turnSummaries: [], }, - session: { id: "sess-claude", state: "idle", title: "Claude work" }, + session: claudeSession, }, false, shown.output, @@ -359,13 +394,47 @@ describe("CLI rendering", () => { expect(rendered).toContain("preset: fable-max"); expect(rendered).toContain("model: claude-fable-5-1"); expect(rendered).toContain("permission mode: default"); - expect(rendered).toContain("isolated profile: enabled"); + expect(rendered).not.toContain("config home"); + expect(rendered).not.toContain("isolatedConfigDir"); expect(rendered).toContain("stream: stream-json in, stream-json out"); // No Codex-only row is invented for a provider that has none of them. expect(rendered).not.toContain("service tier"); expect(rendered).not.toContain("Fast:"); expect(rendered).not.toContain("plugin capability"); expect(rendered).not.toContain("enabled apps"); + + const json = capture(); + renderSuccess( + { detail: false, kind: "session.show", session: "claude-session" }, + { + effectiveRuntimeProfile: { + claudeVersion: "2.1.260", + configHome: "personal", + inputFormat: "stream-json", + model: "claude-fable-5-1", + observedAt: 2_000, + outputFormat: "stream-json", + permissionMode: "default", + preset: "fable-max", + processGeneration: 3, + profileId: "acct_00000000000000000000000000000000", + reasoningEffort: "max", + }, + projection: { + providerThreadId: "thread-claude", + status: "idle", + title: "Claude work", + }, + session: claudeSession, + }, + true, + json.output, + ); + const document = JSON.parse(json.stdout.join("")) as { + data: { effectiveRuntimeProfile: Record }; + }; + expect(document.data.effectiveRuntimeProfile).not.toHaveProperty("configHome"); + expect(document.data.effectiveRuntimeProfile).not.toHaveProperty("isolatedConfigDir"); }); test("renders bounded local root status with closed recovery commands", () => { @@ -1506,16 +1575,16 @@ describe("CLI rendering", () => { "History: 2 messages omitted", "", "Messages", - "You turn-1", + `You ${primaryTurnId}`, " please fix it", "", - "Codex turn-1", + `Codex ${primaryTurnId}`, " fixed", " verified", " … [4 UTF-8 bytes omitted]", "", "Turns", - "turn-1 completed 1.2s", + `${primaryTurnId} completed 1.2s`, " files: src/index.ts", " actions: git status, bun test", "", @@ -1531,11 +1600,232 @@ describe("CLI rendering", () => { ok: true, version: 1, command: "session.show", - data, + data: { + effectiveRuntimeProfile: data.effectiveRuntimeProfile, + projection: { + messages: data.projection.messages, + omission: data.projection.omission, + projectRoot: data.projection.projectRoot, + status: data.projection.status, + title: data.projection.title, + turnSummaries: data.projection.turnSummaries, + }, + session: { + createdAt: 1_000, + fastEnabled: false, + id: primarySessionId, + preset: "high", + profileId: primaryProfileId, + projectId: primaryProjectId, + provider: "codex", + revision: 2, + state: "idle", + title: "Local title", + updatedAt: 2_000, + }, + }, }); expect(target.stderr).toEqual([]); }); + test("strips private session and provider identifiers from show, start, and send", () => { + const privateSentinel = "PRIVATE-SESSION-PROVENANCE-SENTINEL"; + const sessionId = `sess_${"7".repeat(32)}`; + const publicTurnId = publicProviderId("already-public-turn"); + const session = { + activeTurnId: privateSentinel, + archivedAt: 900, + createdAt: 100, + fastEnabled: false, + id: sessionId, + note: privateSentinel, + preset: "high", + profileId: `acct_${"8".repeat(32)}`, + provider: "codex", + providerThreadId: privateSentinel, + providerUpdatedAt: 800, + revision: 3, + state: "idle", + title: "Public session", + updatedAt: 200, + } as const; + const idempotencyKey = "00000000-0000-4000-8000-000000000731"; + const cases = [ + { + command: { detail: false, kind: "session.show", session: sessionId } as const, + response: { + effectiveRuntimeProfile: null, + privateBinding: privateSentinel, + projection: { + activeTurnId: privateSentinel, + messages: [ + { clientId: privateSentinel, role: "user", text: "hello", turnId: privateSentinel }, + { role: "assistant", text: "hi", turnId: publicTurnId }, + ], + omission: { + hasMoreOlderTurns: false, + incompleteTurnIds: [privateSentinel, publicTurnId], + omittedMessages: 0, + returnedTurns: 2, + truncatedMessages: 0, + turnLimit: 24, + unreadItemTurnIds: [privateSentinel, publicTurnId], + }, + providerThreadId: privateSentinel, + providerUpdatedAt: 800, + status: "idle", + title: "Public session", + turnSummaries: [{ + actions: [], + files: [], + id: privateSentinel, + itemId: privateSentinel, + omittedActions: 0, + omittedFiles: 0, + status: "completed", + }], + turns: [{ itemId: privateSentinel, turnId: privateSentinel }], + }, + session, + }, + }, + { + command: { + account: session.profileId, + fast: false, + idempotencyKey, + kind: "session.start", + preset: "high", + provider: "codex", + } as const, + response: { + effectiveRuntimeProfile: null, + idempotencyKey, + privateBinding: privateSentinel, + session, + }, + }, + { + command: { + idempotencyKey, + kind: "session.send", + message: "hello", + session: sessionId, + } as const, + response: { + effectiveRuntimeProfile: null, + idempotencyKey, + privateBinding: privateSentinel, + session, + turnId: privateSentinel, + }, + }, + ]; + + for (const testCase of cases) { + for (const json of [false, true]) { + const target = capture(); + renderSuccess(testCase.command, testCase.response, json, target.output); + const rendered = target.stdout.join(""); + expect(rendered).not.toContain(privateSentinel); + if (testCase.command.kind === "session.show") expect(rendered).toContain(publicTurnId); + } + } + }); + + test("gives native and adopted show, start, and send results identical public key sets", () => { + const sessionId = `sess_${"9".repeat(32)}`; + const profileId = `acct_${"a".repeat(32)}`; + const idempotencyKey = "00000000-0000-4000-8000-000000000732"; + const publicTurnId = publicProviderId("shared-public-turn"); + const session = { + createdAt: 100, + fastEnabled: false, + id: sessionId, + preset: "high", + profileId, + provider: "codex", + revision: 3, + state: "idle", + title: "Same public session", + updatedAt: 200, + } as const; + const commands = [ + { detail: false, kind: "session.show", session: sessionId } as const, + { + account: profileId, + fast: false, + idempotencyKey, + kind: "session.start", + preset: "high", + provider: "codex", + } as const, + { idempotencyKey, kind: "session.send", message: "hello", session: sessionId } as const, + ]; + const publicData = ( + command: (typeof commands)[number], + origin: "native" | "adopted", + ): Record => { + const rawProviderId = `${origin}-private-provider-thread`; + const rawSession = { + ...session, + activeTurnId: `${origin}-private-active-turn`, + note: `${origin}-private-note`, + providerThreadId: rawProviderId, + providerUpdatedAt: 300, + }; + const response = command.kind === "session.show" + ? { + effectiveRuntimeProfile: null, + origin, + projection: { + activeTurnId: `${origin}-private-active-turn`, + messages: [{ role: "assistant", text: "same", turnId: publicTurnId }], + providerThreadId: rawProviderId, + status: "idle", + title: "Same public session", + turns: [{ itemId: `${origin}-private-item`, turnId: `${origin}-private-turn` }], + }, + session: rawSession, + } + : command.kind === "session.start" + ? { + effectiveRuntimeProfile: null, + idempotencyKey, + origin, + session: rawSession, + } + : { + effectiveRuntimeProfile: null, + idempotencyKey, + origin, + session: rawSession, + turnId: `${origin}-private-turn`, + }; + const target = capture(); + renderSuccess(command, response, true, target.output); + return (JSON.parse(target.stdout.join("")) as { data: Record }).data; + }; + + for (const sessionCommand of commands) { + const native = publicData(sessionCommand, "native"); + const adopted = publicData(sessionCommand, "adopted"); + expect(adopted).toEqual(native); + expect(Object.keys(adopted.session as Record).sort()).toEqual([ + "createdAt", + "fastEnabled", + "id", + "preset", + "profileId", + "provider", + "revision", + "state", + "title", + "updatedAt", + ]); + } + }); + test("renders strict conversation-bound session task records without list prompt leakage", () => { const sessionId = `sess_${"1".repeat(32)}`; const taskId = `stask_${"2".repeat(32)}`; @@ -1723,7 +2013,19 @@ describe("CLI rendering", () => { expect(jsonText).not.toContain("\u0007"); expect(jsonText).not.toContain("\u202e"); expect(jsonText).toContain("\\u202e"); - expect(JSON.parse(jsonText)).toEqual({ ok: true, version: 1, command: command.kind, data: attacked }); + expect(JSON.parse(jsonText)).toMatchObject({ + command: command.kind, + data: { + projection: { + messages: [{ role: "assistant", text: `${attack}\nvisible` }], + title: attack, + turnSummaries: [{ files: [attack], status: "completed" }], + }, + session: { id: primarySessionId }, + }, + ok: true, + version: 1, + }); }); test("renders desktop switch recovery outcomes without exposing evidence internals", () => { @@ -2378,6 +2680,7 @@ describe("CLI rendering", () => { listing: { accountSelector: accountId, accountState: "signed_out", + provider: "codex", scope: "local_only", freshness: "stale", localCompleteness: "partial", @@ -2396,6 +2699,17 @@ describe("CLI rendering", () => { revision: 4, createdAt: 1_700_000_000_000, updatedAt: 1_700_000_000_001, + }, { + id: "sess_11111111111111111111111111111111", + profileId: accountId, + title: "Live Claude thread", + state: "idle", + provider: "claude", + preset: "fable-max", + fastEnabled: false, + revision: 2, + createdAt: 1_700_000_000_002, + updatedAt: 1_700_000_000_003, }], nextCursor: cursor, }; @@ -2406,13 +2720,17 @@ describe("CLI rendering", () => { false, human.output, ); - expect(human.stdout.join("")).toContain(`Scope: local-only cache for ${accountId}`); - expect(human.stdout.join("")).toContain("Freshness: stale; provider not contacted"); + expect(human.stdout.join("")).toContain(`Codex scope: local-only cache for ${accountId}`); + expect(human.stdout.join("")).toContain("Codex freshness: stale; Codex provider not contacted"); expect(human.stdout.join("")).toContain( - "Completeness: partial local cache; more pages available; provider completeness unknown", + "Codex completeness: partial local cache; more pages available; Codex provider completeness unknown", ); - expect(human.stdout.join("")).toContain(`Sign in to refresh: hra account login ${accountId}`); + expect(human.stdout.join("")).toContain(`Sign in to refresh Codex: hra account login ${accountId}`); expect(human.stdout.join("")).toContain("Older imported thread"); + expect(human.stdout.join("")).toContain("Live Claude thread"); + expect(human.stdout.join("")).not.toContain("Claude freshness"); + expect(human.stdout.join("")).not.toContain("Claude provider not contacted"); + expect(human.stdout.join("")).not.toContain("Sign in to refresh Claude"); expect(human.stdout.join("")).toContain( `Continue: hra session list --account ${accountId} --limit 37 --cursor ${cursor}\n`, ); @@ -2505,6 +2823,20 @@ describe("CLI rendering", () => { unsafeMetadata.output, )).toThrow(InvalidCommandResponseError); expect(unsafeMetadata.stdout.join("")).not.toContain("touch /tmp/unsafe"); + + for (const invalidListing of [ + { ...listing.listing, provider: "claude" }, + Object.fromEntries(Object.entries(listing.listing).filter(([key]) => key !== "provider")), + ]) { + const invalidProvider = capture(); + expect(() => renderSuccess( + { kind: "session.list", account: accountId, archived: false, limit: 37 }, + { ...listing, listing: invalidListing }, + false, + invalidProvider.output, + )).toThrow(InvalidCommandResponseError); + expect(invalidProvider.stdout).toEqual([]); + } }); test("renders brokered MCP form input as protected", () => { diff --git a/src/cli/render.ts b/src/cli/render.ts index 12d081e..1b0c5d0 100644 --- a/src/cli/render.ts +++ b/src/cli/render.ts @@ -1,7 +1,11 @@ +import { z } from "zod"; + import { formatAttachmentSize } from "../domain/attachments"; +import { attachmentReferenceListSchema } from "../domain/attachment-schemas"; import { notificationEmailCommandResultSchema, notificationHoursCommandResultSchema, + publicSessionListItemSchema, publicSessionListPageSchema, signedOutSessionListMetadataSchema, type LocalCommand, @@ -31,12 +35,18 @@ import { type SessionEvent, type SessionEventPage, } from "../domain/session-events"; +import { + projectPublicReviewedRuntimeProfile, + publicReviewedRuntimeProfileSchema, + reviewedRuntimeProfileSchema, +} from "../domain/runtime-profile"; import { accountUsageHistoryPageSchema, automaticRateLimitResetStatusSchema, } from "../domain/usage-metrics"; import { sessionStateReportSchema } from "../domain/session-state"; -import { profileIdSchema, sessionIdSchema } from "../domain/values"; +import { profileIdSchema, projectIdSchema, sessionIdSchema } from "../domain/values"; +import { publicProviderIdentifierSchema } from "../public-provider-identifier"; import { sessionTaskDeleteResultSchema, sessionTaskListSchema, @@ -264,10 +274,11 @@ const stringArray = (value: unknown): readonly string[] => const renderTurnSummary = (value: unknown): string | null => { const turn = object(value); - if (turn === null || typeof turn.id !== "string" || typeof turn.status !== "string") return null; + if (turn === null || typeof turn.status !== "string") return null; const files = stringArray(turn.files); const actions = stringArray(turn.actions); - const rows = [`${line(turn.id)} ${line(turn.status)} ${duration(turn.runtimeMs)}`]; + const turnLabel = typeof turn.id === "string" ? line(turn.id) : "Turn"; + const rows = [`${turnLabel} ${line(turn.status)} ${duration(turn.runtimeMs)}`]; if (files.length > 0) rows.push(` files: ${files.map(line).join(", ")}`); if (actions.length > 0) rows.push(` actions: ${actions.map(line).join(", ")}`); if (typeof turn.omittedFiles === "number" && turn.omittedFiles > 0) { @@ -283,9 +294,9 @@ const renderEffectiveRuntimeProfile = (value: unknown): readonly string[] => { const profile = object(value); if (profile === null) return []; // The two providers review different documents. Claude Code owns its own - // permission engine, so its profile names the pinned CLI version, the - // interactive permission mode, and the isolated runtime home instead of the - // Codex approval, review, and app capabilities. + // permission engine, so its public profile names the pinned CLI version and + // interactive permission mode instead of Codex approval/review capability. + // Private config-home provenance is intentionally never rendered. if (typeof profile.claudeVersion === "string") { return [ "Runtime", @@ -295,7 +306,6 @@ const renderEffectiveRuntimeProfile = (value: unknown): readonly string[] => { ` model: ${line(profile.model)}`, ` reasoning effort: ${line(profile.reasoningEffort)}`, ` permission mode: ${line(profile.permissionMode)}`, - ` isolated profile: ${profile.isolatedConfigDir === true ? "enabled" : "unavailable"}`, ` stream: ${line(profile.inputFormat)} in, ${line(profile.outputFormat)} out`, ` observed at: ${line(profile.observedAt)}`, ]; @@ -619,6 +629,290 @@ const invalidCommandResponse = (command: LocalCommand): never => { throw new InvalidCommandResponseError(command.kind); }; +const publicSessionCommandRecordSchema = publicSessionListItemSchema.extend({ + activeTurnId: publicProviderIdentifierSchema.optional(), +}).strict(); + +const publicProjectionTextOmissionSchema = z.object({ + originalUtf8Bytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + returnedUtf8Bytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + omittedUtf8Bytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), +}).strict(); + +const publicProjectedMessageSchema = z.object({ + role: z.enum(["user", "assistant"]), + text: z.string(), + turnId: publicProviderIdentifierSchema.optional(), + omission: publicProjectionTextOmissionSchema.optional(), + attachments: attachmentReferenceListSchema.optional(), +}).strict(); + +const publicTurnSummarySchema = z.object({ + id: publicProviderIdentifierSchema.optional(), + status: z.enum(["completed", "interrupted", "failed", "inProgress"]), + startedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(), + completedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(), + runtimeMs: z.number().nonnegative().finite().optional(), + files: z.array(z.string()).max(128), + actions: z.array(z.string()).max(128), + omittedFiles: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + omittedActions: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), +}).strict(); + +const publicProjectionOmissionSchema = z.object({ + hasMoreOlderTurns: z.boolean(), + returnedTurns: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + turnLimit: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + omittedMessages: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + truncatedMessages: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + unreadItemTurnIds: z.array(publicProviderIdentifierSchema).max(100), + incompleteTurnIds: z.array(publicProviderIdentifierSchema).max(100), +}).strict(); + +const publicSessionProjectionSchema = z.object({ + title: z.string(), + status: z.enum(["active", "idle", "terminal"]), + projectRoot: z.string().max(4_096).optional(), + activeTurnId: publicProviderIdentifierSchema.optional(), + messages: z.array(publicProjectedMessageSchema).max(100).optional(), + turnSummaries: z.array(publicTurnSummarySchema).max(100).optional(), + omission: publicProjectionOmissionSchema.optional(), +}).strict(); + +const publicSessionRecoverySchema = z.object({ + required: z.literal(true), + cleared: z.literal(false), +}).strict(); + +const publicSessionShowResultSchema = z.object({ + session: publicSessionCommandRecordSchema, + effectiveRuntimeProfile: publicReviewedRuntimeProfileSchema.nullable(), + projection: publicSessionProjectionSchema.optional(), + recovery: publicSessionRecoverySchema.optional(), +}).strict(); + +const publicSessionStartResultSchema = z.object({ + session: publicSessionCommandRecordSchema, + effectiveRuntimeProfile: publicReviewedRuntimeProfileSchema.nullable(), + idempotencyKey: z.string().uuid(), +}).strict(); + +const publicSessionSendResultSchema = z.object({ + session: publicSessionCommandRecordSchema, + effectiveRuntimeProfile: publicReviewedRuntimeProfileSchema.nullable(), + idempotencyKey: z.string().uuid(), + turnId: publicProviderIdentifierSchema.optional(), + attachments: attachmentReferenceListSchema.optional(), +}).strict(); + +type PublicSessionCommand = Extract< + LocalCommand, + { kind: "session.show" | "session.start" | "session.send" } +>; + +const projectPublicProviderAlias = (value: unknown): string | undefined => { + const parsed = publicProviderIdentifierSchema.safeParse(value); + return parsed.success ? parsed.data : undefined; +}; + +const projectPublicSessionRecord = ( + value: unknown, +): z.infer | null => { + const parsed = publicSessionListItemSchema.safeParse(value); + if (!parsed.success) return null; + const record = object(value); + const activeTurnId = projectPublicProviderAlias(record?.activeTurnId); + const projected = publicSessionCommandRecordSchema.safeParse({ + ...parsed.data, + ...(activeTurnId === undefined ? {} : { activeTurnId }), + }); + return projected.success ? projected.data : null; +}; + +const projectPublicMessage = ( + value: unknown, +): z.infer | null => { + const message = object(value); + if (message === null) return null; + const turnId = projectPublicProviderAlias(message.turnId); + const projected = publicProjectedMessageSchema.safeParse({ + role: message.role, + text: message.text, + ...(turnId === undefined ? {} : { turnId }), + ...(message.omission === undefined ? {} : { omission: message.omission }), + ...(message.attachments === undefined ? {} : { attachments: message.attachments }), + }); + return projected.success ? projected.data : null; +}; + +const projectPublicTurnSummary = ( + value: unknown, +): z.infer | null => { + const summary = object(value); + if (summary === null) return null; + const id = projectPublicProviderAlias(summary.id); + const projected = publicTurnSummarySchema.safeParse({ + ...(id === undefined ? {} : { id }), + status: summary.status, + ...(summary.startedAt === undefined ? {} : { startedAt: summary.startedAt }), + ...(summary.completedAt === undefined ? {} : { completedAt: summary.completedAt }), + ...(summary.runtimeMs === undefined ? {} : { runtimeMs: summary.runtimeMs }), + files: summary.files, + actions: summary.actions, + omittedFiles: summary.omittedFiles, + omittedActions: summary.omittedActions, + }); + return projected.success ? projected.data : null; +}; + +const projectPublicProviderAliases = (value: unknown): readonly string[] | null => { + if (!Array.isArray(value)) return null; + return value.flatMap((entry) => { + const alias = projectPublicProviderAlias(entry); + return alias === undefined ? [] : [alias]; + }); +}; + +const projectPublicSessionProjection = ( + value: unknown, +): z.infer | null => { + const projection = object(value); + if (projection === null) return null; + const messages = projection.messages === undefined + ? undefined + : Array.isArray(projection.messages) + ? projection.messages.map(projectPublicMessage) + : null; + const turnSummaries = projection.turnSummaries === undefined + ? undefined + : Array.isArray(projection.turnSummaries) + ? projection.turnSummaries.map(projectPublicTurnSummary) + : null; + if (messages === null || messages?.some((message) => message === null) === true) return null; + if (turnSummaries === null || turnSummaries?.some((summary) => summary === null) === true) return null; + const omission = projection.omission === undefined + ? undefined + : object(projection.omission); + const unreadItemTurnIds = omission === undefined + ? undefined + : projectPublicProviderAliases(omission?.unreadItemTurnIds); + const incompleteTurnIds = omission === undefined + ? undefined + : projectPublicProviderAliases(omission?.incompleteTurnIds); + if ( + omission === null + || unreadItemTurnIds === null + || incompleteTurnIds === null + ) return null; + const activeTurnId = projectPublicProviderAlias(projection.activeTurnId); + const projected = publicSessionProjectionSchema.safeParse({ + title: projection.title, + status: projection.status, + ...(projection.projectRoot === undefined ? {} : { projectRoot: projection.projectRoot }), + ...(activeTurnId === undefined ? {} : { activeTurnId }), + ...(messages === undefined ? {} : { messages }), + ...(turnSummaries === undefined ? {} : { turnSummaries }), + ...(omission === undefined + ? {} + : { + omission: { + hasMoreOlderTurns: omission.hasMoreOlderTurns, + returnedTurns: omission.returnedTurns, + turnLimit: omission.turnLimit, + omittedMessages: omission.omittedMessages, + truncatedMessages: omission.truncatedMessages, + unreadItemTurnIds, + incompleteTurnIds, + }, + }), + }); + return projected.success ? projected.data : null; +}; + +const projectPublicRuntimeProfile = ( + value: unknown, +): z.infer | null | undefined => { + if (value === null) return null; + const reviewed = reviewedRuntimeProfileSchema.safeParse(value); + if (!reviewed.success) return undefined; + const projected = publicReviewedRuntimeProfileSchema.safeParse( + projectPublicReviewedRuntimeProfile(reviewed.data), + ); + return projected.success ? projected.data : undefined; +}; + +const projectPublicSessionCommandData = ( + command: PublicSessionCommand, + data: unknown, +): z.infer + | z.infer + | z.infer + | null => { + const root = object(data); + if (root === null) return null; + const session = projectPublicSessionRecord(root.session); + const effectiveRuntimeProfile = projectPublicRuntimeProfile(root.effectiveRuntimeProfile); + if (session === null || effectiveRuntimeProfile === undefined) return null; + if (command.kind === "session.show") { + const requestedSession = sessionIdSchema.safeParse(command.session); + if (requestedSession.success && session.id !== requestedSession.data) return null; + const projection = root.projection === undefined + ? undefined + : projectPublicSessionProjection(root.projection); + if (projection === null) return null; + const recoveryRecord = root.recovery === undefined ? undefined : object(root.recovery); + if (recoveryRecord === null) return null; + const recovery = recoveryRecord === undefined + ? undefined + : publicSessionRecoverySchema.safeParse({ + required: recoveryRecord.required, + cleared: recoveryRecord.cleared, + }); + if (recovery !== undefined && !recovery.success) return null; + const parsed = publicSessionShowResultSchema.safeParse({ + session, + effectiveRuntimeProfile, + ...(projection === undefined ? {} : { projection }), + ...(recovery === undefined ? {} : { recovery: recovery.data }), + }); + return parsed.success ? parsed.data : null; + } + if (command.kind === "session.start") { + const requestedAccount = profileIdSchema.safeParse(command.account); + const requestedProject = command.project === undefined + ? null + : projectIdSchema.safeParse(command.project); + if ( + (requestedAccount.success && session.profileId !== requestedAccount.data) + || (requestedProject?.success === true && session.projectId !== requestedProject.data) + || session.provider !== (command.provider ?? "codex") + || session.preset !== command.preset + || session.fastEnabled !== command.fast + || (command.idempotencyKey !== undefined && root.idempotencyKey !== command.idempotencyKey) + ) return null; + const parsed = publicSessionStartResultSchema.safeParse({ + session, + effectiveRuntimeProfile, + idempotencyKey: root.idempotencyKey, + }); + return parsed.success ? parsed.data : null; + } + const requestedSession = sessionIdSchema.safeParse(command.session); + if ( + (requestedSession.success && session.id !== requestedSession.data) + || (command.idempotencyKey !== undefined && root.idempotencyKey !== command.idempotencyKey) + ) return null; + const turnId = projectPublicProviderAlias(root.turnId); + const parsed = publicSessionSendResultSchema.safeParse({ + session, + effectiveRuntimeProfile, + idempotencyKey: root.idempotencyKey, + ...(turnId === undefined ? {} : { turnId }), + ...(root.attachments === undefined ? {} : { attachments: root.attachments }), + }); + return parsed.success ? parsed.data : null; +}; + const hasOnlyValidInteractions = (value: unknown): boolean => Array.isArray(value) && value.every((entry) => publicInteractionSchema.safeParse(entry).success); @@ -1127,6 +1421,14 @@ const publicInteractionData = (command: LocalCommand, data: unknown): unknown => nextCursor: parsed.data.nextCursor === null ? null : nextCursor ?? null, }; } + if ( + command.kind === "session.show" + || command.kind === "session.start" + || command.kind === "session.send" + ) { + return projectPublicSessionCommandData(command, data) + ?? invalidCommandResponse(command); + } if (command.kind === "interaction.show") { const record = interactionRecord(data); return { interaction: record }; @@ -1205,10 +1507,10 @@ const renderSessionList = ( && accountId.success && metadata.data.accountSelector === accountId.data ? [ - `Scope: local-only cache for ${accountId.data}`, - "Freshness: stale; provider not contacted", - `Completeness: ${metadata.data.localCompleteness === "complete" ? "complete local cache" : "partial local cache; more pages available"}; provider completeness unknown`, - `Sign in to refresh: ${metadata.data.nextCommand}`, + `Codex scope: local-only cache for ${accountId.data}`, + "Codex freshness: stale; Codex provider not contacted", + `Codex completeness: ${metadata.data.localCompleteness === "complete" ? "complete local cache" : "partial local cache; more pages available"}; Codex provider completeness unknown`, + `Sign in to refresh Codex: ${metadata.data.nextCommand}`, ] : []; const tableListing = table(sessions, ["title", "state", "preset", "fastEnabled", "id"]); @@ -2398,6 +2700,10 @@ const renderSyncStatus = (data: unknown): string => { export function renderSuccess(command: LocalCommand, data: unknown, json: boolean, output: Output): void { assertCommandSuccessData(command, data); + // Both render modes consume this exact projection. JSON cannot bypass the + // human renderer's privacy boundary, and human fallback output cannot dump + // an internal daemon record that JSON would have stripped. + const publicData = publicInteractionData(command, data); if (json) { if (command.kind === "work.apply") { output.writeStdout(`${safeJson(workAgentProtocolResponseSchema.parse({ @@ -2405,7 +2711,7 @@ export function renderSuccess(command: LocalCommand, data: unknown, json: boolea version: WORK_PROTOCOL_VERSION, requestId: command.requestId, ok: true, - result: publicInteractionData(command, data), + result: publicData, }))}\n`); return; } @@ -2415,7 +2721,6 @@ export function renderSuccess(command: LocalCommand, data: unknown, json: boolea || command.kind === "work.poll" || command.kind === "work.events" ) { - const publicData = publicInteractionData(command, data); const line = workReadSuccessWireDocument(command.kind, publicData); const maximum = command.kind === "work.snapshot" ? WORK_SNAPSHOT_MAX_BYTES @@ -2434,13 +2739,10 @@ export function renderSuccess(command: LocalCommand, data: unknown, json: boolea ok: true, version: 1, command: command.kind, - data: publicInteractionData(command, data), + data: publicData, })}\n`); return; } - const publicData = command.kind === "account.login" || command.kind === "session.list" - ? publicInteractionData(command, data) - : data; const value = publicData as Record; if (command.kind === "doctor") { output.writeStdout(`${renderDoctor(data)}\n`); @@ -2484,7 +2786,7 @@ export function renderSuccess(command: LocalCommand, data: unknown, json: boolea `Deleted conversation task ${line(deleted.taskId)} from ${line(deleted.sessionId)} at ${instant(deleted.deletedAt)} (revision ${String(deleted.revision)}).\n`, ); } else if (command.kind === "session.show") { - output.writeStdout(`${renderSession(data)}\n`); + output.writeStdout(`${renderSession(publicData)}\n`); } else if (command.kind === "session.status") { output.writeStdout(`${renderSessionStatus(data)}\n`); } else if (command.kind === "session.state") { @@ -2617,7 +2919,7 @@ export function renderSuccess(command: LocalCommand, data: unknown, json: boolea } else if (command.kind === "sync.status") { output.writeStdout(`${renderSyncStatus(data)}\n`); } else { - output.writeStdout(`${safeJson(data, 2)}\n`); + output.writeStdout(`${safeJson(publicData, 2)}\n`); } } diff --git a/src/cloud/AGENTS.md b/src/cloud/AGENTS.md index 3623088..7a74962 100644 --- a/src/cloud/AGENTS.md +++ b/src/cloud/AGENTS.md @@ -3,7 +3,7 @@ - Auth implements verified-email HRA identity and device credentials. - Sync encrypts and uploads bounded local projections. - Remote control claims commands under one execution lease. -- The device registry projects this machine's settings, accounts, projects, and scheduled tasks as labels only. +- The encrypted device registry projects this machine's settings, accounts, projects, and scheduled tasks as labels, plus provider-level personal-session adoption opt-in and aggregate counts. Candidate identity, content, liveness, paths, and provenance stay local. # Guidelines diff --git a/src/cloud/daemon-adapters.test.ts b/src/cloud/daemon-adapters.test.ts index 9f56c83..84f1a66 100644 --- a/src/cloud/daemon-adapters.test.ts +++ b/src/cloud/daemon-adapters.test.ts @@ -22,6 +22,8 @@ import { join } from "node:path"; import { Database } from "bun:sqlite"; import type { LocalCommand } from "../domain/contracts"; +import { presetRequirements } from "../domain/presets"; +import { effectiveRuntimeProfileSchema } from "../domain/runtime-profile"; import type { SessionId } from "../domain/values"; import { createStoredAccountUsageSnapshot, @@ -64,6 +66,8 @@ function sha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } +const codexProviderAccountKey = `v1:codex:${sha256("person@example.com")}`; + class FakeCodex implements CodexRuntimePort { readonly provider = "codex" as const; discardRuntimeReview(): void {} @@ -113,6 +117,15 @@ class FakeCodex implements CodexRuntimePort { endSession(): Promise { return Promise.resolve(); } + readonly readSessionProjectionForCloud = async ( + _sessionPublicId: string, + signal: AbortSignal, + ): Promise => { + signal.throwIfAborted(); + this.readSessionCalls += 1; + return this.projection; + }; + readSession(input: { authority: ProfileAuthority; providerThreadId: string; detail: boolean; signal: AbortSignal }): Promise { this.readSessionCalls += 1; expect(input.providerThreadId).toBe("thread_0001"); @@ -205,18 +218,16 @@ async function fixture(): Promise>, + providerThreadId: string, +) { + const profile = value.store.requireProfileById( + value.store.requireSession(value.sessionId).profileId, + ); + value.store.setSessionAdoptionPolicy({ provider: "codex", profileId: profile.id }); + const candidate = value.store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId, + title: `Adopted ${providerThreadId}`, + state: "idle", + liveness: "not_live", + }); + const claiming = value.store.fenceSessionAdoptionCandidateForClaim({ + provider: "codex", + providerThreadId, + expectedRevision: candidate.revision, + }); + return value.store.adoptSessionCandidate({ + expectedCandidateRevision: claiming.revision, + fastEnabled: false, + preset: "high", + requirement: presetRequirements.high, + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeProfile: { + approvalPolicy: "on-request", + computerUse: true, + enabledApps: [], + fast: false, + model: "gpt-6-astra", + observedAt: 2_000, + permissionProfile: ":workspace", + pluginCapability: true, + preset: "high", + processGeneration: profile.processGeneration, + profileId: profile.id, + reasoningEffort: "max", + reviewMode: "auto_review", + serviceTier: null, + }, + providerAccountKey: codexProviderAccountKey, + provider: "codex", + providerThreadId, + }); +} + function beginTurnProfileBinding(value: Awaited>, input: Readonly<{ fast: boolean; preset: "low" | "high" | "ultra"; }>): Readonly<{ attemptId: `attempt_${string}`; profile: Parameters[0]["profile"] }> { const session = value.store.requireSession(value.sessionId); const profile = value.store.requireProfileById(session.profileId); - const runtime = { + const presetSelection = value.store.requireSessionPresetRequirement(session.id); + if (presetSelection.preset !== input.preset) { + throw new Error("Expected the fixture preset to match the session preset."); + } + const runtime = effectiveRuntimeProfileSchema.parse({ profileId: profile.id, processGeneration: profile.processGeneration, observedAt: 2_000, preset: input.preset, - model: input.preset === "low" ? "gpt-5.6-luna" : "gpt-6-astra", - reasoningEffort: input.preset === "ultra" ? "ultra" as const : "max" as const, + model: presetSelection.requirement.model, + reasoningEffort: presetSelection.requirement.effort, serviceTier: input.fast ? "priority" as const : null, fast: input.fast, approvalPolicy: "on-request" as const, @@ -269,7 +333,7 @@ function beginTurnProfileBinding(value: Awaited>, inp computerUse: true as const, pluginCapability: true as const, enabledApps: [], - }; + }); const attempt = value.store.prepareMutation({ authorityGeneration: profile.processGeneration, authorityId: session.id, @@ -324,23 +388,24 @@ async function materializeScheduledTaskQueue( if (occurrence === undefined) throw new Error("Expected a scheduled task queue occurrence."); const current = value.store.requireSession(value.sessionId); const profile = value.store.requireProfileById(current.profileId); + const presetSelection = value.store.requireSessionPresetRequirement(current.id); if (current.providerThreadId === undefined) throw new Error("Expected a bound session."); - const runtime = { + const runtime = effectiveRuntimeProfileSchema.parse({ profileId: profile.id, processGeneration: profile.processGeneration, observedAt: 2_000, - preset: "high" as const, - model: "gpt-6-astra", - reasoningEffort: "max" as const, - serviceTier: "priority" as const, - fast: true, + preset: presetSelection.preset, + model: presetSelection.requirement.model, + reasoningEffort: presetSelection.requirement.effort, + serviceTier: current.fastEnabled ? "priority" as const : null, + fast: current.fastEnabled, approvalPolicy: "on-request" as const, reviewMode: "auto_review" as const, permissionProfile: ":workspace" as const, computerUse: true as const, pluginCapability: true as const, enabledApps: [], - }; + }); const evidence = value.store.beginQueueEffect({ queueId: occurrence.queue.id, sessionId: current.id, @@ -468,6 +533,10 @@ class FakeClaude implements ClaudeRuntimePort { } reviewSessionStart(): Promise { return Promise.reject(this.#unused()); } startSession(): Promise { return Promise.reject(this.#unused()); } + claimSession(): Promise { return Promise.reject(this.#unused()); } + readSessionProcessIdentity(): ReturnType { + return Promise.reject(this.#unused()); + } observeSession(): ReturnType { return Promise.reject(this.#unused()); } reviewTurnStart(): Promise { return Promise.reject(this.#unused()); } startTurn(): Promise { return Promise.reject(this.#unused()); } @@ -543,29 +612,48 @@ class FakeDevin implements DevinRuntimePort { } describe("state-backed cloud daemon adapter", () => { - test("projects a Claude session through its own port exactly like a Codex one", async () => { + test("routes list and recovery projection reads through the service-owned exact seam", async () => { const value = await fixture(); - const profile = value.store.requireProfileById( - value.store.requireSession(value.sessionId).profileId, - ); - expect(value.store.setProfileState( - profile.id, - profile.processGeneration, - "signed_out", - )).toBe(true); - const starting = value.store.createSession({ + const projectedSessionIds: string[] = []; + const adapter = new StateBackedCloudDaemonAdapter({ + readSessionProjectionForCloud: (sessionPublicId, signal) => { + signal.throwIfAborted(); + projectedSessionIds.push(sessionPublicId); + return Promise.resolve(value.codex.projection); + }, + executeRemote: () => Promise.resolve({}), + paths: value.paths, + store: value.store, + }); + try { + const signal = new AbortController().signal; + await adapter.listSessions({ limit: 25, signal }); + await adapter.planCompactProjectionRecovery({ + idempotencyKey: "00000000-0000-7000-8000-00000000072a", + sessionPublicId: value.sessionId, + signal, + }); + expect(projectedSessionIds).toEqual([value.sessionId, value.sessionId]); + } finally { + await adapter.close(); + value.store.close(); + } + }); + + test("projects a Claude session through the service-owned exact reader", async () => { + const value = await fixture(); + const profile = value.store.createProfile("Signed-out Codex, managed Claude"); + expect(profile).toMatchObject({ processGeneration: 0, state: "signed_out" }); + const bound = value.store.upsertProviderSession({ fastEnabled: false, preset: "fable-max", profileId: profile.id, provider: "claude", title: "Claude work", - }); - const bound = value.store.bindSession({ - expectedRevision: starting.revision, providerThreadId: "thread_claude_0001", - sessionId: starting.id, state: "idle", providerUpdatedAt: 1_000, + providerAccountKey: `v1:claude:${sha256("managed-claude-account")}`, }); // Bind the turn's reviewed Claude profile the way a dispatched queue entry // does, so the compact turn summary can name its model. @@ -613,8 +701,13 @@ describe("state-backed cloud daemon adapter", () => { const claude = new FakeClaude(); const commands: LocalCommand[] = []; const adapter = new StateBackedCloudDaemonAdapter({ - claude, - codex: value.codex, + readSessionProjectionForCloud: async (sessionPublicId, signal) => { + if (sessionPublicId === bound.id) { + signal.throwIfAborted(); + return await claude.readSession(); + } + return await value.codex.readSessionProjectionForCloud(sessionPublicId, signal); + }, executeRemote: (command) => { commands.push(command); return Promise.resolve({}); }, paths: value.paths, platform: "linux", @@ -681,28 +774,31 @@ describe("state-backed cloud daemon adapter", () => { } }); - test("does not read or authorize an established Claude session on Darwin", async () => { + test("does not read or authorize a managed Claude session on Darwin", async () => { const value = await fixture(); const profile = value.store.requireProfileById( value.store.requireSession(value.sessionId).profileId, ); - const created = value.store.createSession({ + const bound = value.store.upsertProviderSession({ fastEnabled: false, preset: "fable-max", profileId: profile.id, provider: "claude", title: "Durable Claude work", - }); - const bound = value.store.bindSession({ - expectedRevision: created.revision, providerThreadId: "thread_claude_darwin", - sessionId: created.id, state: "idle", + providerAccountKey: `v1:claude:${sha256("managed-claude-darwin-account")}`, }); const claude = new FakeClaude(); + const projectedSessionIds: string[] = []; const adapter = new StateBackedCloudDaemonAdapter({ - claude, - codex: value.codex, + readSessionProjectionForCloud: async (sessionPublicId, signal) => { + signal.throwIfAborted(); + projectedSessionIds.push(sessionPublicId); + return sessionPublicId === bound.id + ? await claude.readSession() + : await value.codex.readSessionProjectionForCloud(sessionPublicId, signal); + }, executeRemote: () => Promise.reject(new Error("remote effect was not expected")), paths: value.paths, platform: "darwin", @@ -712,6 +808,7 @@ describe("state-backed cloud daemon adapter", () => { const signal = new AbortController().signal; const projected = await adapter.listSessions({ limit: 25, signal }); expect(projected.sessions.map((session) => session.publicId)).not.toContain(bound.id); + expect(projectedSessionIds).not.toContain(bound.id); expect(claude.readSessionCalls).toBe(0); await expect(adapter.resolveCommandAuthority({ sessionPublicId: bound.id, @@ -794,8 +891,14 @@ describe("state-backed cloud daemon adapter", () => { const devin = new FakeDevin(); const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, - devin, + readSessionProjectionForCloud: async (sessionPublicId, signal) => { + signal.throwIfAborted(); + if (sessionPublicId === bound.id) { + devin.readSessionCalls += 1; + return devin.projection; + } + return await value.codex.readSessionProjectionForCloud(sessionPublicId, signal); + }, executeRemote: () => Promise.resolve({}), paths: value.paths, platform: "darwin", @@ -839,11 +942,156 @@ describe("state-backed cloud daemon adapter", () => { } }); + test("projects and authorizes an actively bound personal Claude session on Darwin", async () => { + const value = await fixture(); + const profile = value.store.createProfile("Signed-out Codex, personal Claude"); + expect(profile).toMatchObject({ processGeneration: 0, state: "signed_out" }); + value.store.setSessionAdoptionPolicy({ provider: "claude", profileId: profile.id }); + const candidate = value.store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: "thread_personal_claude_darwin", + title: "Personal Claude work", + state: "idle", + providerUpdatedAt: 1_000, + liveness: "not_live", + }); + value.store.fenceSessionAdoptionCandidateForClaim({ + provider: "claude", + providerThreadId: candidate.providerThreadId, + expectedRevision: candidate.revision, + }); + const processIdentity = { + pid: 42_701, + pidDomain: "darwin" as const, + procStart: "Fri Sep 4 12:00:00 2026", + }; + value.store.recordClaimedClaudeProcessAuthority({ + providerThreadId: candidate.providerThreadId, + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "personal", + identity: processIdentity, + }); + const claimed = value.store.listSessionAdoptionCandidates({ provider: "claude" }) + .find((entry) => entry.providerThreadId === candidate.providerThreadId); + if (claimed === undefined) throw new Error("Expected the claimed personal Claude candidate."); + const adopted = value.store.adoptSessionCandidate({ + provider: "claude", + providerThreadId: candidate.providerThreadId, + expectedCandidateRevision: claimed.revision, + profileId: profile.id, + profileGeneration: profile.processGeneration, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + fastEnabled: false, + runtimeProfile: { + claudeVersion: "2.1.260", + configHome: "personal", + inputFormat: "stream-json", + model: "claude-fable-5-1", + observedAt: 2_100, + outputFormat: "stream-json", + permissionMode: "default", + preset: "fable-max", + processGeneration: profile.processGeneration, + profileId: profile.id, + reasoningEffort: "max", + }, + providerAccountKey: `v1:claude:${sha256("personal-claude-account")}`, + claudeProcessIdentity: processIdentity, + }); + const claude = new FakeClaude(); + const projectedSessionIds: string[] = []; + const commands: LocalCommand[] = []; + const adapter = new StateBackedCloudDaemonAdapter({ + readSessionProjectionForCloud: async (sessionPublicId, signal) => { + signal.throwIfAborted(); + projectedSessionIds.push(sessionPublicId); + if (sessionPublicId !== adopted.session.id) { + return await value.codex.readSessionProjectionForCloud(sessionPublicId, signal); + } + return { + ...await claude.readSession(), + providerThreadId: candidate.providerThreadId, + title: candidate.title, + }; + }, + executeRemote: (command) => { + commands.push(command); + return Promise.resolve({}); + }, + paths: value.paths, + platform: "darwin", + store: value.store, + }); + try { + const signal = new AbortController().signal; + const projected = await adapter.listSessions({ limit: 25, signal }); + const head = projected.sessions.find((session) => session.publicId === adopted.session.id); + if (head === undefined) throw new Error("Expected the adopted Claude session projection."); + expect(head).not.toHaveProperty("adopted"); + expect(head).not.toHaveProperty("origin"); + expect(head).not.toHaveProperty("runtimeScope"); + expect(projectedSessionIds).toContain(adopted.session.id); + + await expect(adapter.planCompactProjectionRecovery({ + idempotencyKey: "018bcfe5-6800-7000-8000-0000000000c5", + sessionPublicId: adopted.session.id, + signal, + })).resolves.toMatchObject({ + localAuthority: { + profileGeneration: profile.processGeneration, + profileId: profile.id, + providerThreadId: candidate.providerThreadId, + }, + }); + const authority = await adapter.resolveCommandAuthority({ + sessionPublicId: adopted.session.id, + signal, + }); + expect(authority).not.toBeNull(); + await expect(adapter.execute({ + authority: authority as CloudLocalCommandAuthority, + idempotencyKey: "00000000-0000-7000-8000-0000000000c6", + leaseAuthority: { bootGeneration: 1, bootId: "boot_00000001", fence: 1 }, + payload: { kind: "send", message: "Continue" }, + sessionPublicId: adopted.session.id, + signal, + })).resolves.toEqual({ code: "APPLIED", state: "applied" }); + expect(commands.map((command) => command.kind)).toEqual(["session.send"]); + + value.store.beginPersonalSessionDetach({ sessionId: adopted.session.id }); + const readsBeforeDetachingChecks = claude.readSessionCalls; + await expect(adapter.resolveCommandAuthority({ + sessionPublicId: adopted.session.id, + signal, + })).resolves.toBeNull(); + await expect(adapter.planCompactProjectionRecovery({ + idempotencyKey: "018bcfe5-6800-7000-8000-0000000000c7", + sessionPublicId: adopted.session.id, + signal, + })).rejects.toThrow("local authority changed"); + await adapter.listSessions({ limit: 25, signal }); + expect(claude.readSessionCalls).toBe(readsBeforeDetachingChecks); + await expect(adapter.execute({ + authority: authority as CloudLocalCommandAuthority, + idempotencyKey: "00000000-0000-7000-8000-0000000000c8", + leaseAuthority: { bootGeneration: 1, bootId: "boot_00000001", fence: 2 }, + payload: { kind: "stop" }, + sessionPublicId: adopted.session.id, + signal, + })).resolves.toEqual({ code: "LOCAL_AUTHORITY_CHANGED", state: "failed" }); + } finally { + await adapter.close(); + value.store.close(); + } + }); + test("persists bounded compact sequences and projects polled usage without exporting local paths", async () => { const value = await fixture(); const commands: LocalCommand[] = []; let adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: (command) => { commands.push(command); return Promise.resolve({}); }, now: () => 5_000, paths: value.paths, @@ -903,7 +1151,7 @@ describe("state-backed cloud daemon adapter", () => { await adapter.close(); adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: (command) => { commands.push(command); return Promise.resolve({}); }, paths: value.paths, store: value.store, @@ -961,7 +1209,7 @@ describe("state-backed cloud daemon adapter", () => { expect(value.store.latestUsage(profile.id)).toMatchObject({ sourceRevision: 3 }); const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), now: value.now, paths: value.paths, @@ -1050,7 +1298,7 @@ describe("state-backed cloud daemon adapter", () => { ], }; const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), now: value.now, paths: value.paths, @@ -1095,22 +1343,20 @@ describe("state-backed cloud daemon adapter", () => { const value = await fixture(); const profileId = value.store.requireSession(value.sessionId).profileId; for (let index = 0; index < 30; index += 1) { - const created = value.store.createSession({ + value.store.upsertProviderSession({ fastEnabled: false, preset: "high", profileId, - title: `Paged ${index}`, - }); - value.store.bindSession({ - expectedRevision: created.revision, + provider: "codex", providerThreadId: `thread_page_${index.toString().padStart(4, "0")}`, providerUpdatedAt: 2_000 + index, - sessionId: created.id, + providerAccountKey: `v1:codex:${sha256("person@example.com")}`, state: "idle", + title: `Paged ${index}`, }); } const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), now: value.now, paths: value.paths, @@ -1183,7 +1429,7 @@ describe("state-backed cloud daemon adapter", () => { sessionId: value.sessionId as `sess_${string}`, }); const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), now: value.now, paths: value.paths, @@ -1267,7 +1513,7 @@ describe("state-backed cloud daemon adapter", () => { "80000000-0000-4000-8000-000000000201", ); const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), now: value.now, paths: value.paths, @@ -1375,7 +1621,7 @@ describe("state-backed cloud daemon adapter", () => { "8fffffff-ffff-4fff-8fff-ffffffffffff", ); const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -1465,7 +1711,7 @@ describe("state-backed cloud daemon adapter", () => { "80000000-0000-4000-8002-000000000001", ); let adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), now: value.now, paths: value.paths, @@ -1519,7 +1765,7 @@ describe("state-backed cloud daemon adapter", () => { )).toEqual([value.sessionId]); await adapter.close(); adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), now: value.now, paths: value.paths, @@ -1561,7 +1807,7 @@ describe("state-backed cloud daemon adapter", () => { value.store.expireInteraction({ id: terminalId, expectedRevision: 1 }); let adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -1587,7 +1833,7 @@ describe("state-backed cloud daemon adapter", () => { ); value.store.expireInteraction({ id: newestTerminalId, expectedRevision: 1 }); adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -1629,7 +1875,7 @@ describe("state-backed cloud daemon adapter", () => { "8fffffff-ffff-4fff-8ffd-ffffffffffff", ); let adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -1690,7 +1936,7 @@ describe("state-backed cloud daemon adapter", () => { await adapter.close(); adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -1728,7 +1974,7 @@ describe("state-backed cloud daemon adapter", () => { const value = await fixture(); const cachePath = join(value.paths.root, "cloud-projection.sqlite"); let adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -1804,7 +2050,7 @@ describe("state-backed cloud daemon adapter", () => { "signed_out", )).toBe(true); adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -1836,7 +2082,7 @@ describe("state-backed cloud daemon adapter", () => { const value = await fixture(); const cachePath = join(value.paths.root, "cloud-projection.sqlite"); let adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -1873,7 +2119,7 @@ describe("state-backed cloud daemon adapter", () => { } database.close(false); adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -1900,7 +2146,7 @@ describe("state-backed cloud daemon adapter", () => { const value = await fixture(); const cachePath = join(value.paths.root, "cloud-projection.sqlite"); const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -1949,7 +2195,7 @@ describe("state-backed cloud daemon adapter", () => { ); const cachePath = join(value.paths.root, "cloud-projection.sqlite"); const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -1990,7 +2236,7 @@ describe("state-backed cloud daemon adapter", () => { "80000000-0000-4000-8003-000000000001", ); let adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -2017,7 +2263,7 @@ describe("state-backed cloud daemon adapter", () => { "signed_out", )).toBe(true); adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -2057,7 +2303,7 @@ describe("state-backed cloud daemon adapter", () => { })); } const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -2114,7 +2360,7 @@ describe("state-backed cloud daemon adapter", () => { sessionId: current.id, }); const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -2147,7 +2393,7 @@ describe("state-backed cloud daemon adapter", () => { message.role === "user" ? { ...message, clientId: binding.attemptId } : message), }; const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -2190,7 +2436,7 @@ describe("state-backed cloud daemon adapter", () => { }, }; const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -2224,7 +2470,7 @@ describe("state-backed cloud daemon adapter", () => { const value = await fixture(); const cachePath = join(value.paths.root, "cloud-projection.sqlite"); const initial = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -2253,7 +2499,7 @@ describe("state-backed cloud daemon adapter", () => { } database.close(false); const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -2309,7 +2555,7 @@ describe("state-backed cloud daemon adapter", () => { const value = await fixture(); const commands: LocalCommand[] = []; const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: (command) => { commands.push(command); return Promise.resolve({}); }, now: value.now, paths: value.paths, @@ -2363,7 +2609,7 @@ describe("state-backed cloud daemon adapter", () => { const value = await fixture(); const cachePath = join(value.paths.root, "cloud-projection.sqlite"); const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -2423,7 +2669,7 @@ describe("state-backed cloud daemon adapter", () => { test("recovers at the global remote head and omits later scheduled-task prompts", async () => { const value = await fixture(); const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -2431,12 +2677,14 @@ describe("state-backed cloud daemon adapter", () => { try { const signal = new AbortController().signal; await adapter.listSessions({ limit: 25, signal }); + expect(value.codex.readSessionCalls).toBe(1); const idempotencyKey = "00000000-0000-7000-8000-000000000701"; const plan = await adapter.planCompactProjectionRecovery({ idempotencyKey, sessionPublicId: value.sessionId, signal, }); + expect(value.codex.readSessionCalls).toBe(2); expect(plan.baselineCompletedTurns).toHaveLength(1); const installation = { ...plan, @@ -2560,7 +2808,7 @@ describe("state-backed cloud daemon adapter", () => { sessionId: value.sessionId as `sess_${string}`, }); let adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), now: value.now, paths: value.paths, @@ -2610,7 +2858,7 @@ describe("state-backed cloud daemon adapter", () => { await adapter.close(); adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), now: value.now, paths: value.paths, @@ -2643,7 +2891,7 @@ describe("state-backed cloud daemon adapter", () => { await adapter.close(); adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -2690,7 +2938,7 @@ describe("state-backed cloud daemon adapter", () => { }, }; const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -2770,7 +3018,7 @@ describe("state-backed cloud daemon adapter", () => { ], }; const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -2794,7 +3042,7 @@ describe("state-backed cloud daemon adapter", () => { const corruptBytes = "corrupt projection sentinel"; await writeFile(cachePath, corruptBytes, { mode: 0o600 }); const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -2846,7 +3094,7 @@ describe("state-backed cloud daemon adapter", () => { const cachePath = join(value.paths.root, "cloud-projection.sqlite"); await writeFile(cachePath, "corrupt projection sentinel", { mode: 0o600 }); const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -2893,7 +3141,7 @@ describe("state-backed cloud daemon adapter", () => { const cachePath = join(value.paths.root, "cloud-projection.sqlite"); const movedPath = join(value.paths.root, "cloud-projection-original.sqlite"); const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -2947,7 +3195,7 @@ describe("state-backed cloud daemon adapter", () => { const cachePath = join(value.paths.root, "cloud-projection.sqlite"); const movedPath = join(value.paths.root, "cloud-projection-installed.sqlite"); const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -2992,7 +3240,7 @@ describe("state-backed cloud daemon adapter", () => { const cachePath = join(value.paths.root, "cloud-projection.sqlite"); const originalPath = join(value.paths.root, "cloud-projection-source.sqlite"); let adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -3026,7 +3274,7 @@ describe("state-backed cloud daemon adapter", () => { await rm(cachePath); await rename(originalPath, cachePath); adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -3097,7 +3345,7 @@ describe("state-backed cloud daemon adapter", () => { expect(before[0]).toMatchObject({ path: cachePath, present: true }); const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -3134,7 +3382,7 @@ describe("state-backed cloud daemon adapter", () => { await chmod(cachePath, 0o600); } const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -3233,7 +3481,7 @@ describe("state-backed cloud daemon adapter", () => { database.close(false); await chmod(cachePath, 0o600); const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -3327,7 +3575,7 @@ describe("state-backed cloud daemon adapter", () => { await chmod(cachePath, 0o600); const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -3363,7 +3611,7 @@ describe("state-backed cloud daemon adapter", () => { const value = await fixture(); const cachePath = join(value.paths.root, "cloud-projection.sqlite"); let adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -3383,7 +3631,7 @@ describe("state-backed cloud daemon adapter", () => { previous.close(false); adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -3412,7 +3660,7 @@ describe("state-backed cloud daemon adapter", () => { ); const cachePath = join(value.paths.root, "cloud-projection.sqlite"); let adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -3431,7 +3679,7 @@ describe("state-backed cloud daemon adapter", () => { previous.close(false); adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -3457,7 +3705,7 @@ describe("state-backed cloud daemon adapter", () => { test("reconciles an exact committed compact upload after its response is lost", async () => { const value = await fixture(); const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -3510,7 +3758,7 @@ describe("state-backed cloud daemon adapter", () => { } const commands: LocalCommand[] = []; const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: (command) => { commands.push(command); return Promise.resolve({}); }, paths: value.paths, store: value.store, @@ -3546,7 +3794,7 @@ describe("state-backed cloud daemon adapter", () => { const value = await fixture(); const commands: LocalCommand[] = []; const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: (command) => { commands.push(command); return Promise.resolve({}); }, paths: value.paths, store: value.store, @@ -3614,7 +3862,7 @@ describe("state-backed cloud daemon adapter", () => { const value = await fixture(); const commands: LocalCommand[] = []; const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: (command) => { commands.push(command); return Promise.resolve({}); }, paths: value.paths, store: value.store, @@ -3852,7 +4100,7 @@ describe("remote decisions at the custodian", () => { const value = await fixture(); const commands: LocalCommand[] = []; const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: (command) => { commands.push(command); return Promise.resolve({}); }, paths: value.paths, store: value.store, @@ -3942,7 +4190,7 @@ describe("remote interaction detail and the decisions it licenses", () => { }>> { const commands: LocalCommand[] = []; const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: (command) => { commands.push(command); return Promise.resolve({}); }, now: value.now, paths: value.paths, @@ -4821,7 +5069,7 @@ describe("settings commands and the device registry", () => { const commands: LocalCommand[] = []; const gatewayKeys: string[] = []; const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: (command) => { commands.push(command); return Promise.resolve({}); }, gatewayKeyCustody: { hasKey: () => Promise.resolve(gatewayKeys.length > 0), @@ -4904,7 +5152,7 @@ describe("settings commands and the device registry", () => { test("live projection honours the stored show-thinking setting", async () => { const value = await fixture(); const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -4938,38 +5186,121 @@ describe("settings commands and the device registry", () => { } }); - test("projects labels only, merges Codex automations, and marks archived sessions", async () => { + test("projects labels and adoption aggregates without private candidate detail", async () => { const value = await fixture(); + const profileId = value.store.requireSession(value.sessionId).profileId; + const profileGeneration = value.store.requireProfileById(profileId).processGeneration; + const adoptionRuntimeProfile = { + approvalPolicy: "on-request" as const, + computerUse: true as const, + enabledApps: [], + fast: false, + model: "gpt-6-astra", + observedAt: 2_000, + permissionProfile: ":workspace" as const, + pluginCapability: true as const, + preset: "high" as const, + processGeneration: profileGeneration, + profileId, + reasoningEffort: "max" as const, + reviewMode: "auto_review" as const, + serviceTier: null, + }; + value.store.setSessionAdoptionPolicy({ provider: "codex", profileId }); + const pending = value.store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: "private-pending-thread", + title: "PRIVATE PENDING TITLE", + state: "idle", + liveness: "unknown", + }); + const adopted = value.store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: "private-adopted-thread", + title: "PRIVATE ADOPTED TITLE", + state: "idle", + liveness: "not_live", + }); + const claimedAdopted = value.store.fenceSessionAdoptionCandidateForClaim({ + provider: "codex", + providerThreadId: adopted.providerThreadId, + expectedRevision: adopted.revision, + }); + const activeAdoption = value.store.adoptSessionCandidate({ + expectedCandidateRevision: claimedAdopted.revision, + fastEnabled: false, + preset: "high", + requirement: presetRequirements.high, + profileId, + profileGeneration, + runtimeProfile: adoptionRuntimeProfile, + providerAccountKey: codexProviderAccountKey, + provider: "codex", + providerThreadId: adopted.providerThreadId, + }); + const fenced = value.store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: "private-fenced-thread", + title: "PRIVATE FENCED TITLE", + state: "idle", + liveness: "not_live", + }); + const claimedFenced = value.store.fenceSessionAdoptionCandidateForClaim({ + provider: "codex", + providerThreadId: fenced.providerThreadId, + expectedRevision: fenced.revision, + }); + const fencedAdoption = value.store.adoptSessionCandidate({ + expectedCandidateRevision: claimedFenced.revision, + fastEnabled: false, + preset: "high", + requirement: presetRequirements.high, + profileId, + profileGeneration, + runtimeProfile: adoptionRuntimeProfile, + providerAccountKey: codexProviderAccountKey, + provider: "codex", + providerThreadId: fenced.providerThreadId, + }); + value.store.detachPersonalSession({ sessionId: fencedAdoption.session.id }); + expect(pending.status).toBe("pending"); + const hraTask = value.store.createSessionTaskStore().create({ + idempotencyKey: "00000000-0000-4000-8000-000000000711", + minutes: 60, + name: "Public HRA conversation task", + prompt: "Continue the ordinary HRA conversation.", + sessionId: activeAdoption.session.id, + status: "paused", + }); + const projectedSessionIds: string[] = []; const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: (sessionPublicId, signal) => { + signal.throwIfAborted(); + projectedSessionIds.push(sessionPublicId); + const session = value.store.requireSession(sessionPublicId); + if (session.providerThreadId === undefined) { + throw new Error("missing provider binding"); + } + return Promise.resolve({ + ...value.codex.projection, + providerThreadId: session.providerThreadId, + title: session.title, + }); + }, executeRemote: () => Promise.resolve({}), gatewayKeyCustody: { hasKey: () => Promise.resolve(true), setKey: () => Promise.resolve() }, machineLabel: "Studio", paths: value.paths, - readCodexAutomations: () => Promise.resolve([ - { - cadence: "FREQ=WEEKLY;BYDAY=MO", - id: "upload-usage", - kind: "heartbeat", - label: "Upload usage", - status: "active" as const, - targetThreadId: "thread_0001", - updatedAt: 1_000, - }, - { - cadence: "FREQ=HOURLY;INTERVAL=6", - id: "unknown-thread", - kind: "heartbeat", - label: "Other machine automation", - status: "paused" as const, - targetThreadId: "thread_9999", - updatedAt: 1_000, - }, - ]), store: value.store, }); try { const signal = new AbortController().signal; + const sessions = await adapter.listSessions({ limit: 25, signal }); + expect(sessions.sessions.map((session) => session.publicId)).toContain( + activeAdoption.session.id, + ); + expect(projectedSessionIds).toContain(value.sessionId); + expect(projectedSessionIds).toContain(activeAdoption.session.id); const registry = await adapter.readDeviceRegistry({ signal }); expect(parseDeviceRegistryPayload(registry)).toEqual(registry); expect(registry).toMatchObject({ @@ -4977,29 +5308,34 @@ describe("settings commands and the device registry", () => { defaultPreset: "ultra", machineLabel: "Studio", proseAutorespondConfigured: true, + sessionAdoption: { + claude: { adopted: 0, enabled: false, fenced: 0, pending: 0 }, + codex: { adopted: 1, enabled: true, fenced: 1, pending: 1 }, + }, showThinkingDefault: false, version: 1, }); // The fixture account and session labels embed a private path; the // projection replaces them rather than leaking a filesystem location. expect(JSON.stringify(registry)).not.toContain(privateRootFixture); + expect(JSON.stringify(registry)).not.toContain("private-pending-thread"); + expect(JSON.stringify(registry)).not.toContain("PRIVATE PENDING TITLE"); + expect(JSON.stringify(registry)).not.toContain("upload-usage"); + expect(JSON.stringify(registry)).not.toContain("FREQ=WEEKLY;BYDAY=MO"); + expect(JSON.stringify(registry)).not.toContain(adopted.providerThreadId); + expect(JSON.stringify(registry)).not.toContain(fenced.providerThreadId); expect(registry.accounts).toEqual([ expect.objectContaining({ provider: "codex", publicId: expect.any(String), status: "signed_in" }), ]); expect(registry.scheduledTasks).toEqual([ - expect.objectContaining({ - cadence: "FREQ=WEEKLY;BYDAY=MO", - id: "upload-usage", - kind: "codex_automation", - label: "Upload usage", + { + cadence: "every 60 minutes", + id: hraTask.id, + kind: "hra_conversation", + label: "Public HRA conversation task", nextRunAt: null, - sessionPublicId: value.sessionId, - }), - expect.objectContaining({ - id: "unknown-thread", - kind: "codex_automation", - sessionPublicId: null, - }), + sessionPublicId: activeAdoption.session.id, + }, ]); const notificationProjection = await adapter.readDeviceRegistryProjection({ signal }); expect(notificationProjection).toMatchObject({ @@ -5064,10 +5400,92 @@ describe("settings commands and the device registry", () => { } }); + test("projects native and adopted HRA tasks with the same public shape", async () => { + const value = await fixture(); + const nativeSession = value.store.requireSession(value.sessionId); + const adopted = adoptPersonalCodexSession(value, "old-personal-codex-thread"); + const privateDesktopAutomation = { + cadence: "FREQ=WEEKLY;BYDAY=MO", + id: "desktop-private-automation-id", + label: "Desktop private automation label", + sessionPublicId: "sess_private_target_correlation", + } as const; + let desktopAutomationReads = 0; + const taskStore = value.store.createSessionTaskStore(); + const nativeTask = taskStore.create({ + idempotencyKey: "00000000-0000-4000-8000-000000000721", + minutes: 30, + name: "Native public task", + prompt: "Continue the native conversation.", + sessionId: nativeSession.id, + status: "paused", + }); + const adoptedTask = taskStore.create({ + idempotencyKey: "00000000-0000-4000-8000-000000000722", + minutes: 30, + name: "Adopted public task", + prompt: "Continue the adopted conversation.", + sessionId: adopted.session.id, + status: "paused", + }); + const adapterOptions = { + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, + executeRemote: () => Promise.resolve({}), + paths: value.paths, + // Deliberately model the retired adapter option. Even if a caller still + // carries it, Desktop automations remain private age-gate authority and + // cannot become registry schedules or an origin marker. + readCodexAutomations: () => { + desktopAutomationReads += 1; + return Promise.resolve([privateDesktopAutomation]); + }, + store: value.store, + }; + const adapter = new StateBackedCloudDaemonAdapter( + adapterOptions as unknown as ConstructorParameters[0], + ); + try { + const signal = new AbortController().signal; + const registry = await adapter.readDeviceRegistry({ signal }); + const nativeRow = registry.scheduledTasks.find((task) => task.id === nativeTask.id); + const adoptedRow = registry.scheduledTasks.find((task) => task.id === adoptedTask.id); + expect(nativeRow).toEqual({ + cadence: "every 30 minutes", + id: nativeTask.id, + kind: "hra_conversation", + label: "Native public task", + nextRunAt: null, + sessionPublicId: nativeSession.id, + }); + expect(adoptedRow).toEqual({ + cadence: "every 30 minutes", + id: adoptedTask.id, + kind: "hra_conversation", + label: "Adopted public task", + nextRunAt: null, + sessionPublicId: adopted.session.id, + }); + expect(Object.keys(nativeRow ?? {}).sort()).toEqual(Object.keys(adoptedRow ?? {}).sort()); + expect({ ...nativeRow, id: "task", label: "task", sessionPublicId: "session" }) + .toEqual({ ...adoptedRow, id: "task", label: "task", sessionPublicId: "session" }); + const publicRegistry = JSON.stringify(registry); + expect(desktopAutomationReads).toBe(0); + expect(publicRegistry).not.toContain("codex_automation"); + expect(publicRegistry).not.toContain("thread_0001"); + expect(publicRegistry).not.toContain("old-personal-codex-thread"); + for (const privateValue of Object.values(privateDesktopAutomation)) { + expect(publicRegistry).not.toContain(privateValue); + } + } finally { + await adapter.close(); + value.store.close(); + } + }); + test("uploads the archived flag in session metadata only while archived", async () => { const value = await fixture(); const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeRemote: () => Promise.resolve({}), paths: value.paths, store: value.store, @@ -5087,6 +5505,37 @@ describe("settings commands and the device registry", () => { value.store.close(); } }); + + test("publishes an archived cloud head after personal-session detach without reopening provider authority", async () => { + const value = await fixture(); + const adopted = adoptPersonalCodexSession(value, "detached-cloud-head-thread"); + value.store.detachPersonalSession({ sessionId: adopted.session.id }); + const projectedSessionIds: string[] = []; + const adapter = new StateBackedCloudDaemonAdapter({ + readSessionProjectionForCloud: (sessionPublicId) => { + projectedSessionIds.push(sessionPublicId); + return Promise.reject(new Error("detached provider authority must stay closed")); + }, + executeRemote: () => Promise.resolve({}), + paths: value.paths, + store: value.store, + }); + try { + const page = await adapter.listSessions({ + limit: 100, + signal: new AbortController().signal, + }); + const detachedHead = page.sessions.find((session) => session.publicId === adopted.session.id); + expect(detachedHead).toMatchObject({ + publicId: adopted.session.id, + metadata: { archived: true }, + }); + expect(projectedSessionIds).not.toContain(adopted.session.id); + } finally { + await adapter.close(); + value.store.close(); + } + }); }); /* @@ -5110,9 +5559,28 @@ async function deviceCommandFixture(options: Readonly<{ const executed: LocalCommand[] = []; const notices: string[] = []; const adapter = new StateBackedCloudDaemonAdapter({ - ...(options.claude === undefined ? {} : { claude: options.claude }), - codex: value.codex, - ...(options.devin === undefined ? {} : { devin: options.devin }), + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, + ...((options.claude === undefined && options.devin === undefined) ? {} : { + readProviderAccountProjectionForCloud: async (input: Readonly<{ + processGeneration: number; + profileId: ProfileAuthority["id"]; + provider: "claude" | "devin"; + signal: AbortSignal; + }>) => { + const runtime = input.provider === "claude" ? options.claude : options.devin; + if (runtime === undefined) throw new Error("runtime unavailable"); + const projection = await runtime.readAccount({ + authority: { + codexHome: join(value.paths.root, "private-provider-home"), + desktopUserData: join(value.paths.root, "private-provider-data"), + generation: input.processGeneration, + id: input.profileId, + }, + signal: input.signal, + }); + return { signedIn: projection.signedIn }; + }, + }), executeLocal: (command) => { executed.push(command); if (command.kind === "session.start") { @@ -5132,7 +5600,6 @@ async function deviceCommandFixture(options: Readonly<{ executeRemote: () => Promise.resolve({}), notifyOperator: (input) => { notices.push(input.title); return Promise.resolve(); }, now: options.now ?? (() => 1_760_000_000_000), - readCodexAutomations: async () => [], paths: value.paths, store: value.store, }); @@ -5624,7 +6091,7 @@ describe("device command execution", () => { const account = value.store.listProfiles()[0]; if (account === undefined) throw new Error("missing account fixture"); const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeLocal: (command) => command.kind === "session.start" ? Promise.resolve({ session: { id: value.sessionId } }) : Promise.reject(new Error("the provider connection dropped")), @@ -5790,7 +6257,7 @@ describe("device command execution", () => { value.store.setAccountLinkingAllowed(true); const executed: LocalCommand[] = []; const adapter = new StateBackedCloudDaemonAdapter({ - codex: value.codex, + readSessionProjectionForCloud: value.codex.readSessionProjectionForCloud, executeLocal: (command) => { executed.push(command); return Promise.resolve({ diff --git a/src/cloud/daemon-adapters.ts b/src/cloud/daemon-adapters.ts index 71cbc5c..fe1637b 100644 --- a/src/cloud/daemon-adapters.ts +++ b/src/cloud/daemon-adapters.ts @@ -19,7 +19,6 @@ import { join } from "node:path"; import { Database } from "bun:sqlite"; -import { readCodexAutomations, type CodexAutomation } from "../codex/automations"; import { parseAccountUsage, parseRateLimits, type RateLimitSnapshot } from "../codex/protocol"; import type { LocalCommand, @@ -58,19 +57,15 @@ import { providerUsagePayload } from "../domain/usage-metrics"; import type { SessionEvent } from "../domain/session-events"; import { profileIdSchema, queueIdSchema, sessionIdSchema, type ProfileId } from "../domain/values"; import type { - ClaudeRuntimePort, - CodexRuntimePort, CodexSessionProjection, CloudControlPort, - DevinRuntimePort, - ProfileAuthority, } from "../daemon/ports"; import { isAttachmentImageMediaType, type AttachmentReference, } from "../domain/attachments"; import { AttachmentBlobStore } from "../storage/attachment-store"; -import { profilePaths, type StatePaths } from "../storage/paths"; +import type { StatePaths } from "../storage/paths"; import { HRA_VERSION } from "../version"; import type { InteractionListPosition, @@ -618,16 +613,6 @@ export async function materializeRemoteAttachments( return { kind: "materialized", values }; } -function authorityFor(paths: StatePaths, profileId: Parameters[1], generation: number): ProfileAuthority { - const owned = profilePaths(paths, profileId); - return { - id: profileId, - generation, - codexHome: owned.codexHome, - desktopUserData: owned.desktopUserData, - }; -} - export type DeviceRegistryAccountAddress = Readonly<{ profileId: ProfileId; provider: Provider; @@ -670,7 +655,6 @@ export function deviceRegistryAccountAddress(input: } return null; } - function sha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } @@ -1027,20 +1011,28 @@ function terminalSessionState(session: SessionRecord): "active" | "idle" | "term } /** - * A profile's durable state is Codex authentication state. Provider-owned - * runtimes keep authentication in their isolated homes, so an established - * Claude or Devin session remains authoritative while that Codex projection - * is signed out (including generation zero). + * Every provider session remains subordinate to its exact durable provider + * account authority. Managed Claude sessions also use the accepted platform + * boundary, while an adopted session can use its exact active personal-runtime + * binding. Devin has no personal-home route and is admitted only through its + * native managed-session authority. A detaching or detached binding never + * reopens provider authority. */ function profileAllowsEstablishedSession( + store: StateStore, profile: ProfileRecord, session: SessionRecord, platform: NodeJS.Platform, ): boolean { - if (profile.state === "removed") return false; + const personalBinding = store.readSessionPersonalRuntimeBinding(session.id); + const usesPersonalRuntime = personalBinding !== null + && personalBinding.state === "active" + && personalBinding.provider === session.provider + && personalBinding.providerThreadId === session.providerThreadId; + if (!store.sessionAccountAuthorityMatches(session.id, profile.id)) return false; switch (session.provider) { - case "codex": return profile.state === "signed_in" && profile.processGeneration >= 1; - case "claude": return platform === "linux"; + case "codex": return true; + case "claude": return platform === "linux" || usesPersonalRuntime; case "devin": return true; } } @@ -2527,17 +2519,33 @@ export type CloudGatewayKeyCustody = Readonly<{ setKey(key: string): Promise; }>; +export type CloudProviderAccountProjectionReader = ( + input: Readonly<{ + processGeneration: number; + profileId: ProfileRecord["id"]; + provider: "claude" | "devin"; + signal: AbortSignal; + }>, +) => Promise>; + export type StateBackedCloudDaemonAdapterOptions = Readonly<{ cloudIdentityNamespace?: string | null; - codex: CodexRuntimePort; /** - * The Claude seam, when this daemon composes one. Cloud projection reads a - * session through the port its own provider binds, so a Claude session - * projects exactly like a Codex one. + * Service-owned exact session reader. It serializes against session/account + * authority and performs the same fresh provider-account check before and + * after every provider read. The cloud adapter deliberately owns no runtime + * port, provider-home path, or account-key derivation. */ - claude?: ClaudeRuntimePort; - /** Optional Devin ACP seam for projecting Devin-owned sessions. */ - devin?: DevinRuntimePort; + readSessionProjectionForCloud( + sessionPublicId: SessionRecord["id"], + signal: AbortSignal, + ): Promise; + /** + * Service-owned provider account reader. It returns only a signed-in bit + * after serializing and rechecking exact profile/provider authority; the + * cloud adapter never receives a runtime port, provider home, or account key. + */ + readProviderAccountProjectionForCloud?: CloudProviderAccountProjectionReader; /** Local custody for the responder gateway key (default: none; `set_gateway_key` is refused). */ gatewayKeyCustody?: CloudGatewayKeyCustody; executeRemote: LocalExecuteRemote; @@ -2552,8 +2560,6 @@ export type StateBackedCloudDaemonAdapterOptions = Readonly<{ now?: () => number; platform?: NodeJS.Platform; paths: StatePaths; - /** Read-only Codex Desktop automations source for the scheduled-task registry. */ - readCodexAutomations?: () => Promise; store: StateStore; }>; @@ -2616,9 +2622,10 @@ implements CloudDaemonLocalSourcePort, CloudCommandExecutorPort, CloudDeviceComm readonly #cachePath: string; readonly #cacheFileName: string; #cacheStatus: CloudProjectionCacheStatus; - readonly #codex: CodexRuntimePort; - readonly #claude: ClaudeRuntimePort | undefined; - readonly #devin: DevinRuntimePort | undefined; + readonly #readSessionProjectionForCloud: StateBackedCloudDaemonAdapterOptions[ + "readSessionProjectionForCloud" + ]; + readonly #readProviderAccountProjectionForCloud: CloudProviderAccountProjectionReader | undefined; readonly #executeRemote: LocalExecuteRemote; readonly #executeLocal: LocalExecuteCommand; readonly #notifyOperator: (input: Readonly<{ body: string; title: string }>) => Promise; @@ -2630,7 +2637,6 @@ implements CloudDaemonLocalSourcePort, CloudCommandExecutorPort, CloudDeviceComm readonly #liveThinking: boolean; readonly #gatewayKeyCustody: CloudGatewayKeyCustody; readonly #machineLabel: string; - readonly #readCodexAutomations: () => Promise; readonly #registryNow: () => number; readonly #platform: NodeJS.Platform; @@ -2646,8 +2652,6 @@ implements CloudDaemonLocalSourcePort, CloudCommandExecutorPort, CloudDeviceComm hasKey: async () => false, setKey: async () => { throw new Error("Gateway key custody is not available."); }, }; - this.#readCodexAutomations = options.readCodexAutomations - ?? (async () => (await readCodexAutomations()).automations); if ( options.cloudIdentityNamespace !== undefined && options.cloudIdentityNamespace !== null @@ -2678,9 +2682,8 @@ implements CloudDaemonLocalSourcePort, CloudCommandExecutorPort, CloudDeviceComm this.#cache = null; this.#cacheStatus = projectionCacheFailure(error); } - this.#codex = options.codex; - this.#claude = options.claude; - this.#devin = options.devin; + this.#readSessionProjectionForCloud = options.readSessionProjectionForCloud; + this.#readProviderAccountProjectionForCloud = options.readProviderAccountProjectionForCloud; this.#executeRemote = options.executeRemote; // Without an injected local executor no device command can reach the // provider, so the adapter refuses every one of them rather than pretending @@ -2698,29 +2701,6 @@ implements CloudDaemonLocalSourcePort, CloudCommandExecutorPort, CloudDeviceComm this.#attachmentBlobStore = AttachmentBlobStore.forStatePaths(options.paths); } - /** The provider port that owns one session's live projection reads. */ - #sessionRuntime(session: SessionRecord): { - readSession: CodexRuntimePort["readSession"]; - } { - switch (session.provider) { - case "codex": return this.#codex; - case "claude": { - const claude = this.#claude; - if (claude === undefined) { - throw new Error("This daemon composes no Claude Code runtime for that session."); - } - return claude; - } - case "devin": { - const devin = this.#devin; - if (devin === undefined) { - throw new Error("This daemon composes no Devin runtime for that session."); - } - return devin; - } - } - } - close(): Promise { if (this.#closeTask !== null) return this.#closeTask; this.#accountObservationsClosed = true; @@ -2804,12 +2784,10 @@ implements CloudDaemonLocalSourcePort, CloudCommandExecutorPort, CloudDeviceComm || entry.state === "dispatching" || entry.state === "ambiguous") ) throw new Error("Cloud projection recovery requires settled local session effects."); - const projection = await this.#sessionRuntime(session).readSession({ - authority: authorityFor(this.#paths, profile.id, profile.processGeneration), - providerThreadId: session.providerThreadId, - detail: false, - signal: input.signal, - }); + const projection = await this.#readSessionProjectionForCloud( + session.id, + input.signal, + ); throwIfAborted(input.signal); const current = this.#requireRecoverySession(input.sessionPublicId, { profileGeneration: profile.processGeneration, @@ -3162,7 +3140,7 @@ implements CloudDaemonLocalSourcePort, CloudCommandExecutorPort, CloudDeviceComm session.id !== sessionPublicId || session.providerThreadId === undefined || session.state !== "idle" - || !profileAllowsEstablishedSession(profile, session, this.#platform) + || !profileAllowsEstablishedSession(this.#store, profile, session, this.#platform) || (expected !== undefined && ( profile.id !== expected.profileId || profile.processGeneration !== expected.profileGeneration @@ -3195,13 +3173,26 @@ implements CloudDaemonLocalSourcePort, CloudCommandExecutorPort, CloudDeviceComm if (state === null || session.providerThreadId === undefined) continue; const profile = this.#store.requireProfileById(session.profileId); if (profile.state === "removed") continue; + const personalBinding = this.#store.readSessionPersonalRuntimeBinding( + session.id, + true, + ); + const detachedArchiveHead = session.archivedAt !== undefined + && personalBinding?.state === "detached" + && personalBinding.provider === session.provider + && personalBinding.providerThreadId === session.providerThreadId; const canReadProvider = profileAllowsEstablishedSession( + this.#store, profile, session, this.#platform, ); - if (profile.state === "signed_in" && !canReadProvider) continue; - let includeHead = canReadProvider; + if (profile.state === "signed_in" && !canReadProvider && !detachedArchiveHead) continue; + // Detach retires provider authority before it archives the local row. The + // archived head is the cloud tombstone for a session that was projected + // before detach, so it must remain publishable without reopening that + // retired provider authority. + let includeHead = canReadProvider || detachedArchiveHead; if (cache !== null) { if (!canReadProvider) { try { @@ -3211,7 +3202,7 @@ implements CloudDaemonLocalSourcePort, CloudCommandExecutorPort, CloudDeviceComm } } this.#projectionErrors.delete(session.id); - includeHead = cache.hasUncommittedEvents(session.id); + includeHead = includeHead || cache.hasUncommittedEvents(session.id); } catch (error: unknown) { if (error instanceof ProjectionStreamRecoveryError) { this.#projectionRecoveryErrors.add(session.id); @@ -3224,12 +3215,10 @@ implements CloudDaemonLocalSourcePort, CloudCommandExecutorPort, CloudDeviceComm } else { let projectionError: Error | undefined; try { - const projection = await this.#sessionRuntime(session).readSession({ - authority: authorityFor(this.#paths, profile.id, profile.processGeneration), - providerThreadId: session.providerThreadId, - detail: false, - signal: input.signal, - }); + const projection = await this.#readSessionProjectionForCloud( + session.id, + input.signal, + ); throwIfAborted(input.signal); if (projection.providerThreadId !== session.providerThreadId) { throw new Error("The provider runtime returned a session under different authority."); @@ -3385,19 +3374,20 @@ implements CloudDaemonLocalSourcePort, CloudCommandExecutorPort, CloudDeviceComm /** * The device settings projection: machine, daemon defaults, accounts, - * projects, and scheduled tasks, as labels only. Codex Desktop automations - * are read from disk read-only; a malformed automation is skipped rather - * than failing the whole registry, and a Codex thread that is not one of - * this daemon's sessions projects a null session id. + * projects, HRA conversation tasks, and provider-level personal-session + * adoption aggregates. Codex Desktop automation metadata is private input + * to the adoption age gate and never enters this projection. Candidate + * detail and runtime provenance stay private too. */ #startAccountObservation( profile: ProfileRecord, provider: "claude" | "devin", - runtime: ClaudeRuntimePort | DevinRuntimePort, signal: AbortSignal, ): void { + const readProjection = this.#readProviderAccountProjectionForCloud; if ( - this.#accountObservationsClosed + readProjection === undefined + || this.#accountObservationsClosed || this.#accountObservationTasks.has(provider) || this.#accountObservationCleanupFailures.has(provider) ) return; @@ -3418,8 +3408,10 @@ implements CloudDaemonLocalSourcePort, CloudCommandExecutorPort, CloudDeviceComm timer.unref(); const task = Promise.resolve().then(async () => { throwIfAborted(controller.signal); - const projection = await runtime.readAccount({ - authority: authorityFor(this.#paths, profile.id, profile.processGeneration), + const projection = await readProjection({ + processGeneration: profile.processGeneration, + profileId: profile.id, + provider, signal: controller.signal, }); if (this.#accountObservationsClosed || controller.signal.aborted) return; @@ -3457,12 +3449,12 @@ implements CloudDaemonLocalSourcePort, CloudCommandExecutorPort, CloudDeviceComm .filter((profile) => profile.state !== "removed") .slice(0, deviceRegistryLimits.accounts); const accounts: DeviceRegistryAccount[] = []; - const providers = [ - { provider: "claude" as const, runtime: this.#claude }, - { provider: "devin" as const, runtime: this.#devin }, - ]; + const providers: readonly ("claude" | "devin")[] = + this.#readProviderAccountProjectionForCloud === undefined + ? [] + : ["claude", "devin"]; const currentKeys = new Set(profiles.flatMap((profile) => - providers.map(({ provider }) => `${provider}_${profile.id}`))); + providers.map((provider) => `${provider}_${profile.id}`))); for (const key of this.#accountObservations.keys()) { if (!currentKeys.has(key)) this.#accountObservations.delete(key); } @@ -3494,9 +3486,8 @@ implements CloudDaemonLocalSourcePort, CloudCommandExecutorPort, CloudDeviceComm publicId: codexAddress.publicId, status: profile.state, }]; - for (const entry of providers) { - if (entry.runtime === undefined) continue; - const key = `${entry.provider}_${profile.id}`; + for (const provider of providers) { + const key = `${provider}_${profile.id}`; const observed = this.#accountObservations.get(key); if ( observed === undefined @@ -3504,7 +3495,7 @@ implements CloudDaemonLocalSourcePort, CloudCommandExecutorPort, CloudDeviceComm || this.#registryNow() - observed.observedAt >= 60_000 ) { this.#accountObservations.delete(key); - const pending = this.#accountObservationTasks.get(entry.provider); + const pending = this.#accountObservationTasks.get(provider); if (pending?.key === key && observed?.generation !== profile.processGeneration) { pending.controller.abort(new Error("The observed provider authority changed.")); } @@ -3514,7 +3505,7 @@ implements CloudDaemonLocalSourcePort, CloudCommandExecutorPort, CloudDeviceComm const address = deviceRegistryAccountAddress({ kind: "local", profileId: profile.id, - provider: entry.provider, + provider, }); if (address === null) continue; profileAccounts.push({ @@ -3528,8 +3519,8 @@ implements CloudDaemonLocalSourcePort, CloudCommandExecutorPort, CloudDeviceComm accounts.push(...profileAccounts); } if (refresh && profiles.length > 0) { - for (const { provider, runtime } of providers) { - if (runtime === undefined || this.#accountObservationTasks.has(provider)) continue; + for (const provider of providers) { + if (this.#accountObservationTasks.has(provider)) continue; const cursor = this.#accountObservationCursors.get(provider); const start = (profiles.findIndex((profile) => profile.id === cursor) + 1) % profiles.length; // A bounded round robin gives later profiles a turn even when earlier @@ -3543,7 +3534,7 @@ implements CloudDaemonLocalSourcePort, CloudCommandExecutorPort, CloudDeviceComm && observed.generation === profile.processGeneration && this.#registryNow() - observed.observedAt < 60_000 ) continue; - this.#startAccountObservation(profile, provider, runtime, signal); + this.#startAccountObservation(profile, provider, signal); break; } } @@ -3555,13 +3546,6 @@ implements CloudDaemonLocalSourcePort, CloudCommandExecutorPort, CloudDeviceComm input: Readonly<{ signal: AbortSignal }>, ): Promise { if (input.signal.aborted) throw input.signal.reason; - const sessions = this.#store.listSessions(100, undefined, true); - const sessionByProviderThread = new Map(); - for (const session of sessions) { - if (session.providerThreadId !== undefined) { - sessionByProviderThread.set(session.providerThreadId, session.id); - } - } const accounts = this.#deviceRegistryAccounts(input.signal); const projects = this.#store.listProjects() .slice(0, deviceRegistryLimits.projects) @@ -3579,32 +3563,21 @@ implements CloudDaemonLocalSourcePort, CloudCommandExecutorPort, CloudDeviceComm nextRunAt: task.nextDueAt, sessionPublicId: task.sessionId, })); - let automations: readonly CodexAutomation[] = []; - try { - automations = await this.#readCodexAutomations(); - } catch { - // A Codex Desktop that is absent, unreadable, or mid-write must not - // block the rest of the settings projection. - automations = []; - } - throwIfAborted(input.signal); - for (const automation of automations) { - if (scheduledTasks.length >= deviceRegistryLimits.scheduledTasks) break; - const sessionPublicId = automation.targetThreadId === null - ? null - : sessionByProviderThread.get(automation.targetThreadId) ?? null; - scheduledTasks.push({ - cadence: registryLabel(automation.cadence, "unknown", deviceRegistryLimits.cadenceCharacters), - id: registryLabel(automation.id, "automation", deviceRegistryLimits.scheduledTaskIdCharacters), - kind: "codex_automation", - label: registryLabel(automation.label, "Codex automation"), - nextRunAt: null, - sessionPublicId, - }); - } const proseAutorespondConfigured = await this.#gatewayKeyCustody.hasKey(); throwIfAborted(input.signal); const deviceCommandPolicy = this.#store.readDeviceCommandPolicy(); + const codexAdoption = this.#store.readSessionAdoptionCounts("codex"); + const claudeAdoption = this.#store.readSessionAdoptionCounts("claude"); + const sessionAdoption = { + claude: { + ...claudeAdoption, + enabled: this.#store.readSessionAdoptionPolicy("claude")?.enabled ?? false, + }, + codex: { + ...codexAdoption, + enabled: this.#store.readSessionAdoptionPolicy("codex")?.enabled ?? false, + }, + } as const; // These synchronous reads each validate the composite row, and there is no // await between them. Comparing the shared revision makes a commit by a // second local process between the reads fail closed instead of publishing @@ -3626,6 +3599,7 @@ implements CloudDaemonLocalSourcePort, CloudCommandExecutorPort, CloudDeviceComm projects, proseAutorespondConfigured, scheduledTasks, + sessionAdoption, showThinkingDefault: this.#store.readDefaultShowThinking(), version: 1, } satisfies DeviceRegistryPayload; @@ -3809,7 +3783,7 @@ implements CloudDaemonLocalSourcePort, CloudCommandExecutorPort, CloudDeviceComm || session.providerThreadId === undefined || session.state === "starting" || session.state === "recovery_required" - || !profileAllowsEstablishedSession(profile, session, this.#platform) + || !profileAllowsEstablishedSession(this.#store, profile, session, this.#platform) ) return Promise.resolve(null); return Promise.resolve({ localSessionId: session.id, @@ -3843,7 +3817,7 @@ implements CloudDaemonLocalSourcePort, CloudCommandExecutorPort, CloudDeviceComm || session.providerThreadId !== input.authority.providerThreadId || session.state === "starting" || session.state === "recovery_required" - || !profileAllowsEstablishedSession(profile, session, this.#platform) + || !profileAllowsEstablishedSession(this.#store, profile, session, this.#platform) ) return { code: "LOCAL_AUTHORITY_CHANGED", state: "failed" }; // Hosted attachments are materialized into the same local diff --git a/src/cloud/payloads.test.ts b/src/cloud/payloads.test.ts index cab859e..d4fbbb4 100644 --- a/src/cloud/payloads.test.ts +++ b/src/cloud/payloads.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { randomKeyBytes } from "./crypto"; +import { decryptBytes, randomKeyBytes } from "./crypto"; import { + cloudPayloadAad, decryptDeviceCommand, decryptDeviceCommandResult, decryptDeviceRegistry, @@ -24,6 +25,7 @@ import { parseDeviceRegistryPayload, parseRemoteCommandPayload, parseSessionMetadataPayload, + type DeviceRegistryPayload, } from "./payloads"; import { USAGE_CLOUD_ENVELOPE_MAX_CIPHERTEXT_CHARACTERS, @@ -423,14 +425,6 @@ function registryFixture() { nextRunAt: 1_700_000_060_000, sessionPublicId: "sess_00000000000000000000000000000001", }, - { - cadence: "FREQ=WEEKLY;BYDAY=MO", - id: "upload-usage", - kind: "codex_automation", - label: "Upload usage", - nextRunAt: null, - sessionPublicId: null, - }, ], showThinkingDefault: false, version: 1, @@ -460,6 +454,61 @@ describe("device registry payloads", () => { })); }); + test("removes legacy Codex Desktop automation metadata before encrypting the public registry", async () => { + const privateAutomation = { + cadence: "FREQ=WEEKLY;BYDAY=MO", + id: "desktop-private-automation-id", + kind: "codex_automation", + label: "Desktop private automation label", + nextRunAt: null, + sessionPublicId: "sess_private_target_correlation", + } as const; + const legacyRegistry = { + ...registry, + scheduledTasks: [...registry.scheduledTasks, privateAutomation], + }; + const parsed = parseDeviceRegistryPayload(legacyRegistry); + expect(parsed?.scheduledTasks).toEqual(registry.scheduledTasks); + + const key = randomKeyBytes(); + const envelope = await encryptDeviceRegistry( + legacyRegistry as unknown as DeviceRegistryPayload, + key, + authority, + ); + const plaintext = new TextDecoder().decode(await decryptBytes( + envelope, + key, + cloudPayloadAad(authority), + )); + expect(JSON.parse(plaintext)).toEqual(parsed); + for (const privateValue of [ + privateAutomation.id, + privateAutomation.label, + privateAutomation.cadence, + privateAutomation.sessionPublicId, + ]) { + expect(plaintext).not.toContain(privateValue); + } + }); + + test("accepts additive exact personal-session adoption aggregates without candidate detail", async () => { + const withAdoption = { + ...registry, + sessionAdoption: { + claude: { adopted: 1, enabled: false, fenced: 2, pending: 3 }, + codex: { adopted: 4, enabled: true, fenced: 5, pending: 6 }, + }, + } as const; + const parsed = parseDeviceRegistryPayload(withAdoption); + expect(parsed).toEqual(withAdoption); + expect(JSON.stringify(parsed)).not.toContain("providerThreadId"); + + const key = randomKeyBytes(); + const envelope = await encryptDeviceRegistry(withAdoption, key, authority); + expect(await decryptDeviceRegistry(envelope, key, authority)).toEqual(withAdoption); + }); + test("parses and encrypts a registry carrying a Devin account and Astra default", async () => { const providerRegistry = { ...registry, @@ -494,11 +543,11 @@ describe("device registry payloads", () => { })).toBeNull(); expect(parseDeviceRegistryPayload({ ...registry, - scheduledTasks: [{ ...registry.scheduledTasks[1], label: absolutePath }], + scheduledTasks: [{ ...registry.scheduledTasks[0], label: absolutePath }], })).toBeNull(); expect(parseDeviceRegistryPayload({ ...registry, - scheduledTasks: [{ ...registry.scheduledTasks[1], cadence: absolutePath }], + scheduledTasks: [{ ...registry.scheduledTasks[0], cadence: absolutePath }], })).toBeNull(); await expectPromiseToReject(encryptDeviceRegistry( { ...registry, machineLabel: absolutePath }, @@ -538,6 +587,43 @@ describe("device registry payloads", () => { ...registry, projects: Array.from({ length: 201 }, () => registry.projects[0]), })).toBeNull(); + const adoption = { + claude: { adopted: 1, enabled: false, fenced: 2, pending: 3 }, + codex: { adopted: 4, enabled: true, fenced: 5, pending: 6 }, + } as const; + expect(parseDeviceRegistryPayload({ + ...registry, + sessionAdoption: { ...adoption, codex: { ...adoption.codex, pending: -1 } }, + })).toBeNull(); + expect(parseDeviceRegistryPayload({ + ...registry, + sessionAdoption: { ...adoption, claude: { ...adoption.claude, enabled: "yes" } }, + })).toBeNull(); + expect(parseDeviceRegistryPayload({ + ...registry, + sessionAdoption: { ...adoption, codex: { ...adoption.codex, title: "private" } }, + })).toBeNull(); + expect(parseDeviceRegistryPayload({ + ...registry, + sessionAdoption: { codex: adoption.codex }, + })).toBeNull(); + expect(parseDeviceRegistryPayload({ + ...registry, + sessionAdoption: { + ...adoption, + devin: { adopted: 7, enabled: true, fenced: 8, pending: 9 }, + }, + })).toBeNull(); + for (const privateExtra of [ + { candidateIds: ["candidate_private"] }, + { providerHome: "/private/provider/home" }, + { providerThreadId: "provider_thread_private" }, + { sourceProcessIdentity: { pid: 42 } }, + { authorityHash: "authority_hash_private" }, + ]) { + expect(parseDeviceRegistryPayload({ ...registry, ...privateExtra, sessionAdoption: adoption })) + .toBeNull(); + } }); test("takes one accessor-free snapshot before validating a registry", () => { diff --git a/src/cloud/payloads.ts b/src/cloud/payloads.ts index 6b828cd..dc8219d 100644 --- a/src/cloud/payloads.ts +++ b/src/cloud/payloads.ts @@ -35,7 +35,12 @@ import { type NotificationHoursPolicy, } from "../domain/notification-hours-contract"; import { isModelPreset, type ModelPreset } from "./projection"; -import { presetProviders, providerSchema, type Provider } from "../domain/presets"; +import { + presetProviders, + providerSchema, + type AdoptableProvider, + type Provider, +} from "../domain/presets"; import { parseUsageEncryptedEnvelope, parseUsageProjection, @@ -510,12 +515,28 @@ export type DeviceRegistryProject = Readonly<{ label: string; publicId: string } export type DeviceRegistryScheduledTask = Readonly<{ cadence: string; id: string; - kind: "codex_automation" | "hra_conversation"; + kind: "hra_conversation"; label: string; nextRunAt: number | null; sessionPublicId: string | null; }>; +/** + * Provider-level personal-home adoption state. Candidate identity, content, + * liveness, project paths, and runtime provenance remain local; only these + * exact aggregates enter the encrypted device registry. + */ +export type DeviceRegistrySessionAdoptionStatus = Readonly<{ + adopted: number; + enabled: boolean; + fenced: number; + pending: number; +}>; + +export type DeviceRegistrySessionAdoption = Readonly< + Record +>; + /** * One device's settings projection: what the web settings screen needs to * render machines, accounts, projects, scheduled tasks, and the daemon's @@ -539,6 +560,9 @@ export type DeviceRegistryPayload = Readonly<{ projects: readonly DeviceRegistryProject[]; proseAutorespondConfigured: boolean; scheduledTasks: readonly DeviceRegistryScheduledTask[]; + // Additive and optional so a registry published by an older daemon remains + // readable. Absence means unsupported/unknown, never disabled. + sessionAdoption?: DeviceRegistrySessionAdoption; showThinkingDefault: boolean; version: 1; }>; @@ -783,6 +807,10 @@ function isRegistryTimestamp(value: unknown): value is number { return Number.isSafeInteger(value) && (value as number) >= 0; } +function isRegistryCount(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + function parseRegistryAccounts(value: unknown): readonly DeviceRegistryAccount[] | null { if (!Array.isArray(value) || value.length > deviceRegistryLimits.accounts) return null; const accounts: DeviceRegistryAccount[] = []; @@ -832,9 +860,15 @@ function parseRegistryScheduledTasks( if ( !isRecord(entry) || !hasExactKeys(entry, ["cadence", "id", "kind", "label", "nextRunAt", "sessionPublicId"]) - || !isRegistryLabel(entry.cadence, deviceRegistryLimits.cadenceCharacters) + ) return null; + // Old daemons could publish Codex Desktop automation rows. They are + // private age-gate inputs, not public HRA schedules, so readers discard + // those legacy rows without retaining any of their metadata. + if (entry.kind === "codex_automation") continue; + if ( + !isRegistryLabel(entry.cadence, deviceRegistryLimits.cadenceCharacters) || !isRegistryLabel(entry.id, deviceRegistryLimits.scheduledTaskIdCharacters) - || (entry.kind !== "codex_automation" && entry.kind !== "hra_conversation") + || entry.kind !== "hra_conversation" || !isRegistryLabel(entry.label) || (entry.nextRunAt !== null && !isRegistryTimestamp(entry.nextRunAt)) || (entry.sessionPublicId !== null && !isOpaqueIdentifier(entry.sessionPublicId)) @@ -851,13 +885,44 @@ function parseRegistryScheduledTasks( return tasks; } +function parseRegistrySessionAdoptionStatus( + value: unknown, +): DeviceRegistrySessionAdoptionStatus | null { + if ( + !isRecord(value) + || !hasExactKeys(value, ["adopted", "enabled", "fenced", "pending"]) + || !isRegistryCount(value.adopted) + || typeof value.enabled !== "boolean" + || !isRegistryCount(value.fenced) + || !isRegistryCount(value.pending) + ) return null; + return { + adopted: value.adopted, + enabled: value.enabled, + fenced: value.fenced, + pending: value.pending, + }; +} + +function parseRegistrySessionAdoption(value: unknown): DeviceRegistrySessionAdoption | null { + if (!isRecord(value) || !hasExactKeys(value, ["claude", "codex"])) return null; + const claude = parseRegistrySessionAdoptionStatus(value.claude); + const codex = parseRegistrySessionAdoptionStatus(value.codex); + return claude === null || codex === null ? null : { claude, codex }; +} + function parseDeviceRegistryPayloadUnchecked(value: unknown): DeviceRegistryPayload | null { if (!isRecord(value)) return null; const hasAccountLinking = Object.hasOwn(value, "accountLinkingAllowed"); const hasDeviceCommands = Object.hasOwn(value, "deviceCommandsAllowed"); + const hasSessionAdoption = Object.hasOwn(value, "sessionAdoption"); + const sessionAdoption = hasSessionAdoption + ? parseRegistrySessionAdoption(value.sessionAdoption) + : null; if ( (hasAccountLinking && typeof value.accountLinkingAllowed !== "boolean") || (hasDeviceCommands && typeof value.deviceCommandsAllowed !== "boolean") + || (hasSessionAdoption && sessionAdoption === null) ) return null; if ( !hasExactKeys(value, [ @@ -872,6 +937,7 @@ function parseDeviceRegistryPayloadUnchecked(value: unknown): DeviceRegistryPayl "projects", "proseAutorespondConfigured", "scheduledTasks", + ...(hasSessionAdoption ? ["sessionAdoption"] : []), "showThinkingDefault", "version", ]) @@ -906,6 +972,9 @@ function parseDeviceRegistryPayloadUnchecked(value: unknown): DeviceRegistryPayl projects, proseAutorespondConfigured: value.proseAutorespondConfigured, scheduledTasks, + ...(hasSessionAdoption + ? { sessionAdoption: sessionAdoption as DeviceRegistrySessionAdoption } + : {}), showThinkingDefault: value.showThinkingDefault, version: 1, }; @@ -1053,10 +1122,11 @@ export async function encryptDeviceRegistry( key: Uint8Array, authority: CloudPayloadAuthority, ): Promise { - if (authority.kind !== "device_registry" || parseDeviceRegistryPayload(payload) === null) { + const parsed = parseDeviceRegistryPayload(payload); + if (authority.kind !== "device_registry" || parsed === null) { throw new Error("Invalid device registry payload."); } - const envelope = await encryptJson(payload, key, authority); + const envelope = await encryptJson(parsed, key, authority); if (envelope.ciphertext.length > cloudLimits.registryCiphertextCharacters) { throw new Error("Encrypted device registry exceeds its closed envelope bound."); } diff --git a/src/codex/automations.test.ts b/src/codex/automations.test.ts index 158e254..a17904b 100644 --- a/src/codex/automations.test.ts +++ b/src/codex/automations.test.ts @@ -1,28 +1,32 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { constants, Dir } from "node:fs"; +import { mkdir, mkdtemp, open, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + CODEX_AUTOMATION_AUTHORITY_PAGE_ENTRY_LIMIT, parseCodexAutomationToml, + readCodexAutomationAuthority, readCodexAutomations, type CodexAutomation, } from "./automations.ts"; -// The exact shape observed on disk (kb/notes/codex-schedules.md), including the -// `prompt` and `version` keys this reader must never surface. -const OBSERVED_PROMPT = "Upload this machine's local usage data to the saved account."; +// A synthetic fixture with the exact field shape documented in +// kb/notes/codex-schedules.md, including `prompt` and `version` keys this reader +// must never surface. +const SYNTHETIC_PROMPT = "Review the registered project and report maintenance work."; const WELL_FORMED = [ "version = 1", - 'id = "upload-usage-to-tokscale"', + 'id = "weekly-project-maintenance"', 'kind = "heartbeat"', - 'name = "Upload Codex and Claude usage to Tokscale"', - `prompt = "${OBSERVED_PROMPT}"`, + 'name = "Weekly project maintenance"', + `prompt = "${SYNTHETIC_PROMPT}"`, 'status = "ACTIVE"', 'rrule = "FREQ=WEEKLY;BYDAY=MO,WE,FR;BYHOUR=22;BYMINUTE=0"', - 'target_thread_id = "01a06277-c3f2-7360-ab88-e5cdc7aa1504"', - "created_at = 1788358694391", - "updated_at = 1788358694391", + 'target_thread_id = "00000000-0000-4000-8000-000000000001"', + "created_at = 1700000000000", + "updated_at = 1700000000000", "", ].join("\n"); @@ -30,12 +34,12 @@ const MALFORMED = 'id = "broken"\nkind = "heartbeat\nname = '; const EXPECTED: CodexAutomation = { cadence: "FREQ=WEEKLY;BYDAY=MO,WE,FR;BYHOUR=22;BYMINUTE=0", - id: "upload-usage-to-tokscale", + id: "weekly-project-maintenance", kind: "heartbeat", - label: "Upload Codex and Claude usage to Tokscale", + label: "Weekly project maintenance", status: "active", - targetThreadId: "01a06277-c3f2-7360-ab88-e5cdc7aa1504", - updatedAt: 1788358694391, + targetThreadId: "00000000-0000-4000-8000-000000000001", + updatedAt: 1700000000000, }; const roots: string[] = []; @@ -52,6 +56,41 @@ async function automationsRoot(): Promise { return directory; } +function fifoTestsSupported(): boolean { + return process.platform !== "win32" && Bun.which("mkfifo") !== null; +} + +function makeFifo(path: string): void { + const created = Bun.spawnSync({ cmd: ["mkfifo", path] }); + if (created.exitCode !== 0) { + throw new Error(`mkfifo failed: ${new TextDecoder().decode(created.stderr)}`); + } +} + +async function settleBeforeFifoWriter(pending: Promise, fifo: string): Promise { + type Outcome = + | Readonly<{ kind: "blocked" }> + | Readonly<{ kind: "rejected"; error: unknown }> + | Readonly<{ kind: "resolved"; value: T }>; + const completion: Promise = pending.then( + (value) => ({ kind: "resolved", value }), + (error: unknown) => ({ kind: "rejected", error }), + ); + let timer: ReturnType | undefined; + const blocked = new Promise((resolve) => { + timer = setTimeout(() => resolve({ kind: "blocked" }), 1_000); + }); + const outcome = await Promise.race([completion, blocked]); + if (timer !== undefined) clearTimeout(timer); + if (outcome.kind === "resolved") return outcome.value; + if (outcome.kind === "rejected") throw outcome.error; + + const writer = await open(fifo, constants.O_WRONLY | constants.O_NONBLOCK); + await writer.close(); + await completion; + throw new Error("Automation FIFO open blocked instead of failing promptly."); +} + async function writeAutomation( directory: string, name: string, @@ -76,7 +115,7 @@ describe("parseCodexAutomationToml", () => { ]); const serialized = JSON.stringify(automation); expect(serialized).not.toContain("prompt"); - expect(serialized).not.toContain(OBSERVED_PROMPT); + expect(serialized).not.toContain(SYNTHETIC_PROMPT); expect(serialized).not.toContain("version"); expect(serialized).not.toContain("created"); }); @@ -89,7 +128,7 @@ describe("parseCodexAutomationToml", () => { 'project_id = "p-1"', 'model = "gpt-x"', 'reasoning_effort = "high"', - "next_run_at = 1788358694391", + "next_run_at = 1700000000000", "", "[unknown_table]", 'anything = "at all"', @@ -103,13 +142,13 @@ describe("parseCodexAutomationToml", () => { test("falls back to the directory name and tolerates absent optional fields", () => { const automation = parseCodexAutomationToml( ['kind = "heartbeat"', 'name = " "', ""].join("\n"), - "finish-contacts-import", + "weekly-project-maintenance", ); expect(automation).toEqual({ cadence: "", - id: "finish-contacts-import", + id: "weekly-project-maintenance", kind: "heartbeat", - label: "finish-contacts-import", + label: "weekly-project-maintenance", // An absent status means "running": only an explicit PAUSED pauses. status: "active", targetThreadId: null, @@ -229,6 +268,21 @@ describe("parseCodexAutomationToml", () => { parseCodexAutomationToml(['id = "a"', 'name = "A"', ""].join("\n"), "a"), ).toBeNull(); }); + + test("never trims authority-bearing heartbeat kind or target identity", () => { + expect( + parseCodexAutomationToml( + ['id = "a"', 'kind = " heartbeat "', 'target_thread_id = "thread-a"', ""].join("\n"), + "a", + ), + ).toBeNull(); + expect( + parseCodexAutomationToml( + ['id = "a"', 'kind = "heartbeat"', 'target_thread_id = " thread-a "', ""].join("\n"), + "a", + ), + ).toBeNull(); + }); }); describe("readCodexAutomations", () => { @@ -286,7 +340,7 @@ describe("readCodexAutomations", () => { const scan = await readCodexAutomations({ automationsDirectory: join(directory, "not-installed"), }); - expect(scan).toEqual({ automations: [], diagnostics: [] }); + expect(scan).toEqual({ automations: [], complete: true, diagnostics: [] }); }); test("honours the limit over directories sorted by name", async () => { @@ -300,12 +354,14 @@ describe("readCodexAutomations", () => { } const limited = await readCodexAutomations({ automationsDirectory: directory, limit: 2 }); + expect(limited.complete).toBe(false); expect(limited.automations.map((automation) => automation.id)).toEqual([ "a-first", "b-second", ]); const all = await readCodexAutomations({ automationsDirectory: directory }); + expect(all.complete).toBe(true); expect(all.automations.map((automation) => automation.id)).toEqual([ "a-first", "b-second", @@ -313,7 +369,7 @@ describe("readCodexAutomations", () => { ]); const none = await readCodexAutomations({ automationsDirectory: directory, limit: 0 }); - expect(none).toEqual({ automations: [], diagnostics: [] }); + expect(none).toEqual({ automations: [], complete: true, diagnostics: [] }); }); test("ignores dot directories and plain files beside the automations", async () => { @@ -339,4 +395,820 @@ describe("readCodexAutomations", () => { expect(scan.automations).toEqual([]); expect(scan.diagnostics).toEqual([{ automationId: "a-huge", reason: "unreadable" }]); }); + + test("rejects malformed UTF-8 instead of replacing bytes before parsing", async () => { + const directory = await automationsRoot(); + await writeAutomation(directory, "invalid-utf8", null); + const prefix = new TextEncoder().encode([ + 'kind = "heartbeat"', + 'target_thread_id = "scheduled-thread"', + "# ", + ].join("\n")); + await writeFile( + join(directory, "invalid-utf8", "automation.toml"), + Uint8Array.from([...prefix, 0xff, 0x0a]), + ); + + await expect(readCodexAutomations({ automationsDirectory: directory })).resolves.toEqual({ + automations: [], + complete: true, + diagnostics: [{ automationId: "invalid-utf8", reason: "unreadable" }], + }); + await expect(readCodexAutomationAuthority({ + automationsDirectory: directory, + kind: "sources", + sourceDirectoryNames: ["invalid-utf8"], + })).resolves.toEqual({ + complete: true, + diagnostics: [{ automationId: "invalid-utf8", reason: "unreadable" }], + entries: [], + nextCursor: null, + }); + }); + + test("never follows an automation.toml symlink", async () => { + const directory = await automationsRoot(); + const outside = join(directory, "outside.toml"); + await writeFile(outside, WELL_FORMED); + await writeAutomation(directory, "a-linked", null); + await symlink(outside, join(directory, "a-linked", "automation.toml")); + + const scan = await readCodexAutomations({ automationsDirectory: directory }); + expect(scan.automations).toEqual([]); + expect(scan.diagnostics).toEqual([{ automationId: "a-linked", reason: "unreadable" }]); + }); + + test("fails the public projection promptly when automation.toml is a FIFO", async () => { + if (!fifoTestsSupported()) return; + const directory = await automationsRoot(); + await writeAutomation(directory, "a-fifo", null); + const fifo = join(directory, "a-fifo", "automation.toml"); + makeFifo(fifo); + + const scan = await settleBeforeFifoWriter( + readCodexAutomations({ automationsDirectory: directory }), + fifo, + ); + expect(scan).toEqual({ + automations: [], + complete: true, + diagnostics: [{ automationId: "a-fifo", reason: "unreadable" }], + }); + }); +}); + +describe("readCodexAutomationAuthority", () => { + test("honors cancellation and an already-expired deadline", async () => { + const directory = await automationsRoot(); + const controller = new AbortController(); + controller.abort(new Error("authority read cancelled")); + await expect(readCodexAutomationAuthority({ + automationsDirectory: directory, + kind: "page", + signal: controller.signal, + })).rejects.toThrow("authority read cancelled"); + + await expect(readCodexAutomationAuthority({ + automationsDirectory: directory, + deadlineAt: Date.now() - 1, + kind: "page", + })).resolves.toEqual({ + complete: false, + diagnostics: [], + entries: [], + nextCursor: null, + }); + }); + + test("fairly pages beyond 200 directories without an unbounded page", async () => { + const directory = await automationsRoot(); + const expected = Array.from( + { length: 205 }, + (_, index) => `automation-${String(index).padStart(3, "0")}`, + ); + for (const name of expected) { + await writeAutomation( + directory, + name, + [ + 'kind = "heartbeat"', + `target_thread_id = "thread-${name}"`, + "", + ].join("\n"), + ); + } + + const observed: string[] = []; + let after: string | null = null; + let pages = 0; + do { + const scan = await readCodexAutomationAuthority({ + after, + automationsDirectory: directory, + kind: "page", + limit: 50, + }); + pages += 1; + expect(scan.entries.length).toBeLessThanOrEqual(50); + expect(scan.diagnostics).toEqual([]); + observed.push(...scan.entries.map((entry) => entry.sourceDirectoryName)); + after = scan.nextCursor; + if (scan.complete) expect(after).toBeNull(); + else expect(after).not.toBeNull(); + } while (after !== null); + + expect(pages).toBe(5); + expect(observed.toSorted()).toEqual(expected); + }); + + test("bounds examined entries per page and reaches a valid entry across hostile prefixes", async () => { + const directory = await automationsRoot(); + const hiddenDirectoryCount = CODEX_AUTOMATION_AUTHORITY_PAGE_ENTRY_LIMIT * 2; + for (let index = 0; index < hiddenDirectoryCount; index += 1) { + await mkdir(join(directory, `.ignored-${String(index).padStart(4, "0")}`)); + } + await writeAutomation( + directory, + "eventual-target", + ['kind = "heartbeat"', 'target_thread_id = "thread-eventual"', ""].join("\n"), + ); + + const observed: string[] = []; + let after: string | null = null; + let pages = 0; + do { + const scan = await readCodexAutomationAuthority({ + after, + automationsDirectory: directory, + kind: "page", + limit: 50, + }); + pages += 1; + if (pages < 3) { + expect(scan.complete).toBe(false); + expect(scan.nextCursor).not.toBeNull(); + } + observed.push(...scan.entries.map((entry) => entry.sourceDirectoryName)); + after = scan.nextCursor; + } while (after !== null && pages < 4); + + expect(pages).toBe(3); + expect(after).toBeNull(); + expect(observed).toEqual(["eventual-target"]); + }); + + test("fails closed on concurrent cursor use and recovers an unknown cursor without evidence", async () => { + const directory = await automationsRoot(); + const expected = Array.from( + { length: 10 }, + (_, index) => `concurrent-${String(index).padStart(2, "0")}`, + ); + for (const name of expected) { + await writeAutomation( + directory, + name, + ['kind = "heartbeat"', `target_thread_id = "thread-${name}"`, ""].join("\n"), + ); + } + + const first = await readCodexAutomationAuthority({ + automationsDirectory: directory, + kind: "page", + limit: 1, + }); + expect(first).toMatchObject({ complete: false }); + if (first.nextCursor === null) throw new Error("Expected a retained authority cursor."); + const staleCursor = first.nextCursor; + const concurrent = await Promise.all([ + readCodexAutomationAuthority({ + after: staleCursor, + automationsDirectory: directory, + kind: "page", + limit: 1, + }), + readCodexAutomationAuthority({ + after: staleCursor, + automationsDirectory: directory, + kind: "page", + limit: 1, + }), + ]); + const continued = concurrent.filter((scan) => scan.entries.length === 1); + const refused = concurrent.filter((scan) => scan.entries.length === 0); + expect(refused).toHaveLength(1); + expect(continued).toHaveLength(1); + expect(refused[0]).toMatchObject({ complete: false, diagnostics: [] }); + expect(refused[0]?.nextCursor).not.toBe(staleCursor); + expect(continued[0]?.nextCursor).not.toBe(staleCursor); + + const observed = [ + ...first.entries.map((entry) => entry.sourceDirectoryName), + ...continued.flatMap((scan) => scan.entries.map((entry) => entry.sourceDirectoryName)), + ]; + let after: string | null = continued[0]?.nextCursor ?? null; + while (after !== null) { + const scan = await readCodexAutomationAuthority({ + after, + automationsDirectory: directory, + kind: "page", + limit: 1, + }); + observed.push(...scan.entries.map((entry) => entry.sourceDirectoryName)); + after = scan.nextCursor; + } + expect(observed.toSorted()).toEqual(expected); + + let refusedCursor: string | null = refused[0]?.nextCursor ?? null; + while (refusedCursor !== null) { + const scan = await readCodexAutomationAuthority({ + after: refusedCursor, + automationsDirectory: directory, + kind: "page", + limit: 50, + }); + refusedCursor = scan.nextCursor; + } + + const unknownCursor = "authority_scan_0_00000000-0000-4000-8000-000000000000"; + const recovered = await readCodexAutomationAuthority({ + after: unknownCursor, + automationsDirectory: directory, + kind: "page", + limit: 1, + }); + expect(recovered).toMatchObject({ + complete: false, + diagnostics: [], + entries: [], + }); + expect(recovered.nextCursor).not.toBeNull(); + expect(recovered.nextCursor).not.toBe(unknownCursor); + + let recoveryCursor: string | null = recovered.nextCursor; + while (recoveryCursor !== null) { + const scan = await readCodexAutomationAuthority({ + after: recoveryCursor, + automationsDirectory: directory, + kind: "page", + limit: 50, + }); + recoveryCursor = scan.nextCursor; + } + }); + + test("recovers a safe position after reader expiry and rotates a fresh daemon page", async () => { + const directory = await automationsRoot(); + const expected = Array.from( + { length: 60 }, + (_, index) => `restart-${String(index).padStart(3, "0")}`, + ); + for (const name of expected) { + await writeAutomation( + directory, + name, + ['kind = "heartbeat"', `target_thread_id = "thread-${name}"`, ""].join("\n"), + ); + } + + const first = await readCodexAutomationAuthority({ + automationsDirectory: directory, + kind: "page", + limit: 50, + }); + expect(first).toMatchObject({ complete: false }); + const firstNames = first.entries.map((entry) => entry.sourceDirectoryName); + expect(firstNames).toHaveLength(50); + const remaining = expected.filter((name) => !firstNames.includes(name)); + expect(remaining).toHaveLength(10); + + // A syntactically valid cursor absent from this process models a reader + // recreation or TTL expiry. The first read restores no authority; its live + // successor resumes from the safe raw position. + const expired = await readCodexAutomationAuthority({ + after: "authority_scan_50_10000000-0000-4000-8000-000000000000", + automationsDirectory: directory, + kind: "page", + limit: 50, + }); + expect(expired).toMatchObject({ complete: false, diagnostics: [], entries: [] }); + if (expired.nextCursor === null) throw new Error("Expected a rebuilt live cursor."); + const resumed = await readCodexAutomationAuthority({ + after: expired.nextCursor, + automationsDirectory: directory, + kind: "page", + limit: 50, + }); + expect(resumed).toMatchObject({ complete: true, diagnostics: [], nextCursor: null }); + expect(resumed.entries.map((entry) => entry.sourceDirectoryName).toSorted()) + .toEqual(remaining.toSorted()); + + const restarted = await readCodexAutomationAuthority({ + automationsDirectory: directory, + kind: "page", + limit: 50, + restartPage: 1, + }); + expect(restarted).toMatchObject({ complete: true, diagnostics: [], nextCursor: null }); + expect(restarted.entries.map((entry) => entry.sourceDirectoryName).toSorted()) + .toEqual(remaining.toSorted()); + + const ordered = [...firstNames, ...resumed.entries.map((entry) => entry.sourceDirectoryName)]; + const hugeRestartPage = Number.MAX_SAFE_INTEGER; + const hugeOffset = Number( + (BigInt(hugeRestartPage) * 50n) % BigInt(ordered.length), + ); + const hugeRestart = await readCodexAutomationAuthority({ + automationsDirectory: directory, + kind: "page", + limit: 50, + restartPage: hugeRestartPage, + }); + expect(hugeRestart.entries.map((entry) => entry.sourceDirectoryName)) + .toEqual(ordered.slice(hugeOffset, hugeOffset + 50)); + }); + + test("wraps a restart offset equal to the raw directory cardinality", async () => { + const directory = await automationsRoot(); + for (let index = 0; index < 50; index += 1) { + const name = `exact-cardinality-${String(index).padStart(3, "0")}`; + await writeAutomation( + directory, + name, + ['kind = "heartbeat"', `target_thread_id = "thread-${String(index)}"`, ""].join("\n"), + ); + } + + const baseline = await readCodexAutomationAuthority({ + automationsDirectory: directory, + kind: "page", + limit: 50, + }); + expect(baseline.entries).toHaveLength(50); + if (baseline.nextCursor === null) throw new Error("Expected an exact-cardinality cursor."); + await expect(readCodexAutomationAuthority({ + after: baseline.nextCursor, + automationsDirectory: directory, + kind: "page", + limit: 50, + })).resolves.toMatchObject({ complete: true, entries: [], nextCursor: null }); + + const recovered = await readCodexAutomationAuthority({ + after: "authority_scan_50_40000000-0000-4000-8000-000000000000", + automationsDirectory: directory, + kind: "page", + limit: 50, + }); + expect(recovered).toMatchObject({ complete: false, entries: [] }); + if (recovered.nextCursor === null) throw new Error("Expected an exact-end recovery cursor."); + await expect(readCodexAutomationAuthority({ + after: recovered.nextCursor, + automationsDirectory: directory, + kind: "page", + limit: 50, + })).resolves.toMatchObject({ complete: true, entries: [], nextCursor: null }); + + const restarted = await readCodexAutomationAuthority({ + automationsDirectory: directory, + kind: "page", + limit: 50, + restartPage: 1, + }); + expect(restarted.entries.map((entry) => entry.sourceDirectoryName)) + .toEqual(baseline.entries.map((entry) => entry.sourceDirectoryName)); + }); + + test("refuses invalid restart hints and safely wraps a large recovered cursor position", async () => { + const directory = await automationsRoot(); + await writeAutomation( + directory, + "first", + ['kind = "heartbeat"', 'target_thread_id = "thread-first"', ""].join("\n"), + ); + + for (const restartPage of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1]) { + await expect(readCodexAutomationAuthority({ + automationsDirectory: directory, + kind: "page", + restartPage, + })).resolves.toEqual({ + complete: false, + diagnostics: [], + entries: [], + nextCursor: null, + }); + } + await expect(readCodexAutomationAuthority({ + after: "authority_scan_9007199254740992_20000000-0000-4000-8000-000000000000", + automationsDirectory: directory, + kind: "page", + })).resolves.toEqual({ + complete: false, + diagnostics: [], + entries: [], + nextCursor: null, + }); + + const huge = await readCodexAutomationAuthority({ + after: "authority_scan_9007199254740991_30000000-0000-4000-8000-000000000000", + automationsDirectory: directory, + kind: "page", + }); + expect(huge).toMatchObject({ complete: false, diagnostics: [], entries: [] }); + if (huge.nextCursor === null) throw new Error("Expected a wrapped recovery cursor."); + await expect(readCodexAutomationAuthority({ + after: huge.nextCursor, + automationsDirectory: directory, + kind: "page", + })).resolves.toMatchObject({ + complete: true, + diagnostics: [], + entries: [{ sourceDirectoryName: "first" }], + nextCursor: null, + }); + }); + + test("wraps a maximum safe restart page over an empty directory", async () => { + const directory = await automationsRoot(); + await expect(readCodexAutomationAuthority({ + automationsDirectory: directory, + kind: "page", + limit: 50, + restartPage: Number.MAX_SAFE_INTEGER, + })).resolves.toEqual({ + complete: true, + diagnostics: [], + entries: [], + nextCursor: null, + }); + }); + + test("closes retained directory handles at EOF and when a missing root resets a scan", async () => { + const closeSpy = spyOn(Dir.prototype, "close"); + try { + const directory = await automationsRoot(); + for (const name of ["a", "b", "c"]) { + await writeAutomation( + directory, + name, + ['kind = "heartbeat"', `target_thread_id = "thread-${name}"`, ""].join("\n"), + ); + } + + let cursor: string | null = null; + do { + const scan = await readCodexAutomationAuthority({ + after: cursor, + automationsDirectory: directory, + kind: "page", + limit: 1, + }); + cursor = scan.nextCursor; + } while (cursor !== null); + expect(closeSpy).toHaveBeenCalledTimes(1); + + const retained = await readCodexAutomationAuthority({ + automationsDirectory: directory, + kind: "page", + limit: 1, + }); + if (retained.nextCursor === null) throw new Error("Expected a retained authority cursor."); + await rm(directory, { force: true, recursive: true }); + const reset = await readCodexAutomationAuthority({ + after: retained.nextCursor, + automationsDirectory: directory, + kind: "page", + limit: 1, + }); + expect(reset).toEqual({ + complete: true, + diagnostics: [], + entries: [], + nextCursor: null, + }); + expect(closeSpy).toHaveBeenCalledTimes(2); + } finally { + closeSpy.mockRestore(); + } + }); + + test("returns no authority when directory-handle cleanup initially fails", async () => { + const closeSpy = spyOn(Dir.prototype, "close"); + closeSpy.mockImplementationOnce(() => { + throw new Error("Synthetic directory close failure."); + }); + try { + const directory = await automationsRoot(); + await writeAutomation( + directory, + "scheduled", + ['kind = "heartbeat"', 'target_thread_id = "thread-scheduled"', ""].join("\n"), + ); + + const scan = await readCodexAutomationAuthority({ + automationsDirectory: directory, + kind: "page", + limit: 50, + }); + expect(scan).toEqual({ + complete: false, + diagnostics: [], + entries: [], + nextCursor: null, + }); + expect(closeSpy).toHaveBeenCalledTimes(2); + } finally { + closeSpy.mockRestore(); + } + }); + + test("retries synchronous handle cleanup when a restart seek fails", async () => { + const readSpy = spyOn(Dir.prototype, "read"); + const closeSpy = spyOn(Dir.prototype, "close"); + readSpy.mockImplementationOnce(() => { + throw new Error("Synthetic restart seek failure."); + }); + closeSpy.mockImplementationOnce(() => { + throw new Error("Synthetic restart handle close failure."); + }); + try { + const directory = await automationsRoot(); + await writeAutomation( + directory, + "scheduled", + ['kind = "heartbeat"', 'target_thread_id = "thread-scheduled"', ""].join("\n"), + ); + + await expect(readCodexAutomationAuthority({ + automationsDirectory: directory, + kind: "page", + limit: 1, + restartPage: 1, + })).resolves.toEqual({ + complete: false, + diagnostics: [], + entries: [], + nextCursor: null, + }); + expect(closeSpy).toHaveBeenCalledTimes(2); + } finally { + readSpy.mockRestore(); + closeSpy.mockRestore(); + } + }); + + test("revalidates exact source directories independently of page movement", async () => { + const directory = await automationsRoot(); + await writeAutomation( + directory, + "scheduled-source", + ['kind = "heartbeat"', 'target_thread_id = "thread-original"', ""].join("\n"), + ); + + const page = await readCodexAutomationAuthority({ + automationsDirectory: directory, + kind: "page", + limit: 50, + }); + expect(page).toMatchObject({ complete: true, nextCursor: null }); + expect(page.entries).toHaveLength(1); + expect(page.entries[0]).toMatchObject({ + sourceDirectoryName: "scheduled-source", + automation: { kind: "heartbeat", targetThreadId: "thread-original" }, + }); + expect(JSON.stringify(page.entries[0])).not.toContain("prompt"); + + await writeFile( + join(directory, "scheduled-source", "automation.toml"), + ['kind = "heartbeat"', 'target_thread_id = "thread-retargeted"', ""].join("\n"), + ); + const exact = await readCodexAutomationAuthority({ + automationsDirectory: directory, + kind: "sources", + sourceDirectoryNames: ["scheduled-source"], + }); + expect(exact).toMatchObject({ complete: true, nextCursor: null }); + expect(exact.entries).toHaveLength(1); + expect(exact.entries[0]?.automation.targetThreadId).toBe("thread-retargeted"); + }); + + test("derives minimal authority independently of rejected display fields", async () => { + const directory = await automationsRoot(); + await writeAutomation( + directory, + "path-bearing-name", + [ + 'id = "path-bearing-name"', + 'kind = "heartbeat"', + 'name = "Sweep /srv/private\\u202e"', + 'rrule = "FREQ=HOURLY"', + 'status = "PAUSED"', + 'target_thread_id = "thread-path-name"', + "updated_at = 10", + "", + ].join("\n"), + ); + await writeAutomation( + directory, + "sanitized-display-field", + [ + 'id = "sanitized-display-field"', + 'kind = "heartbeat"', + 'name = "Safe display label"', + 'status = "ACTIVE"', + 'target_thread_id = "thread-sanitized-display"', + "updated_at = -1", + "", + ].join("\n"), + ); + await writeAutomation( + directory, + "unusable-display-fields", + [ + "id = 42", + 'kind = "heartbeat"', + 'name = ["not", "display text"]', + "rrule = 17", + 'target_thread_id = "thread-unusable-display"', + 'updated_at = "not-a-timestamp"', + "", + ].join("\n"), + ); + + const projection = await readCodexAutomations({ automationsDirectory: directory }); + expect(projection.automations).toEqual([ + { + cadence: "", + id: "sanitized-display-field", + kind: "heartbeat", + label: "Safe display label", + status: "active", + targetThreadId: "thread-sanitized-display", + updatedAt: null, + }, + ]); + expect(projection.diagnostics).toEqual([ + { automationId: "path-bearing-name", reason: "invalid_fields" }, + { automationId: "unusable-display-fields", reason: "invalid_fields" }, + ]); + + const authority = await readCodexAutomationAuthority({ + automationsDirectory: directory, + kind: "sources", + sourceDirectoryNames: [ + "path-bearing-name", + "sanitized-display-field", + "unusable-display-fields", + ], + }); + expect(authority).toMatchObject({ complete: true, diagnostics: [], nextCursor: null }); + expect(authority.entries).toEqual([ + { + automation: { + kind: "heartbeat", + status: "paused", + targetThreadId: "thread-path-name", + }, + sourceDirectoryName: "path-bearing-name", + }, + { + automation: { + kind: "heartbeat", + status: "active", + targetThreadId: "thread-sanitized-display", + }, + sourceDirectoryName: "sanitized-display-field", + }, + { + automation: { + kind: "heartbeat", + status: "active", + targetThreadId: "thread-unusable-display", + }, + sourceDirectoryName: "unusable-display-fields", + }, + ]); + expect(Object.keys(authority.entries[0]?.automation ?? {})).toEqual([ + "kind", + "status", + "targetThreadId", + ]); + const serialized = JSON.stringify(authority.entries); + expect(serialized).not.toContain("/srv/private"); + expect(serialized).not.toContain("FREQ=HOURLY"); + expect(serialized).not.toContain("not-a-timestamp"); + expect(serialized).not.toContain("display text"); + expect(serialized).not.toContain("Safe display label"); + }); + + test("accepts only exact authority fields and documented status forms", async () => { + const directory = await automationsRoot(); + const records = new Map([ + ["accepted-active", ['kind = "heartbeat"', 'status = "ACTIVE"', 'target_thread_id = "thread-active"']], + ["accepted-blank", ['kind = "heartbeat"', 'status = " "', 'target_thread_id = "thread-blank"']], + ["accepted-missing", ['kind = "heartbeat"', 'target_thread_id = "thread-missing"']], + ["accepted-paused", ['kind = "heartbeat"', 'status = "PAUSED"', 'target_thread_id = "thread-paused"']], + ["drift-kind", ['kind = "Heartbeat"', 'status = "ACTIVE"', 'target_thread_id = "thread-kind"']], + ["drift-status-case", ['kind = "heartbeat"', 'status = "active"', 'target_thread_id = "thread-status-case"']], + ["drift-status-padding", ['kind = "heartbeat"', 'status = " ACTIVE "', 'target_thread_id = "thread-status-padding"']], + ["drift-status-type", ['kind = "heartbeat"', "status = 1", 'target_thread_id = "thread-status-type"']], + ["drift-target-blank", ['kind = "heartbeat"', 'status = "ACTIVE"', 'target_thread_id = ""']], + ["drift-target-padding", ['kind = "heartbeat"', 'status = "ACTIVE"', 'target_thread_id = " thread-target "']], + ]); + for (const [name, lines] of records) { + await writeAutomation(directory, name, [...lines, ""].join("\n")); + } + + const authority = await readCodexAutomationAuthority({ + automationsDirectory: directory, + kind: "sources", + sourceDirectoryNames: [...records.keys()], + }); + expect(authority.entries).toEqual([ + { + automation: { kind: "heartbeat", status: "active", targetThreadId: "thread-active" }, + sourceDirectoryName: "accepted-active", + }, + { + automation: { kind: "heartbeat", status: "active", targetThreadId: "thread-blank" }, + sourceDirectoryName: "accepted-blank", + }, + { + automation: { kind: "heartbeat", status: "active", targetThreadId: "thread-missing" }, + sourceDirectoryName: "accepted-missing", + }, + { + automation: { kind: "heartbeat", status: "paused", targetThreadId: "thread-paused" }, + sourceDirectoryName: "accepted-paused", + }, + ]); + expect(authority.diagnostics).toEqual([ + { automationId: "drift-kind", reason: "invalid_fields" }, + { automationId: "drift-status-case", reason: "invalid_fields" }, + { automationId: "drift-status-padding", reason: "invalid_fields" }, + { automationId: "drift-status-type", reason: "invalid_fields" }, + { automationId: "drift-target-blank", reason: "invalid_fields" }, + { automationId: "drift-target-padding", reason: "invalid_fields" }, + ]); + }); + + test("refuses unsafe exact source identities", async () => { + const directory = await automationsRoot(); + const scan = await readCodexAutomationAuthority({ + automationsDirectory: directory, + kind: "sources", + sourceDirectoryNames: ["../outside"], + }); + expect(scan).toEqual({ + complete: false, + diagnostics: [], + entries: [], + nextCursor: null, + }); + }); + + test("never accepts authority through a replaced source-directory symlink", async () => { + const directory = await automationsRoot(); + const outside = join(directory, "..", "outside-source"); + await mkdir(outside); + await writeFile( + join(outside, "automation.toml"), + ['kind = "heartbeat"', 'target_thread_id = "outside-thread"', ""].join("\n"), + ); + await symlink(outside, join(directory, "scheduled-source")); + + const scan = await readCodexAutomationAuthority({ + automationsDirectory: directory, + kind: "sources", + sourceDirectoryNames: ["scheduled-source"], + }); + expect(scan).toEqual({ + complete: true, + diagnostics: [{ automationId: "scheduled-source", reason: "unreadable" }], + entries: [], + nextCursor: null, + }); + }); + + test("fails private authority promptly when automation.toml is a FIFO", async () => { + if (!fifoTestsSupported()) return; + const directory = await automationsRoot(); + await writeAutomation(directory, "scheduled-fifo", null); + const fifo = join(directory, "scheduled-fifo", "automation.toml"); + makeFifo(fifo); + + const scan = await settleBeforeFifoWriter( + readCodexAutomationAuthority({ + automationsDirectory: directory, + kind: "sources", + sourceDirectoryNames: ["scheduled-fifo"], + }), + fifo, + ); + expect(scan).toEqual({ + complete: true, + diagnostics: [{ automationId: "scheduled-fifo", reason: "unreadable" }], + entries: [], + nextCursor: null, + }); + }); }); diff --git a/src/codex/automations.ts b/src/codex/automations.ts index 8ed8b80..6e501c9 100644 --- a/src/codex/automations.ts +++ b/src/codex/automations.ts @@ -8,17 +8,20 @@ // it never opens the Desktop SQLite caches (`sqlite/codex-dev.db` and friends), // whose file names and columns shift between Desktop builds. // -// It is also deliberately narrow: the automation `prompt`, its `cwds`, and -// every other filesystem-shaped value are never read, logged, or returned. The -// two free-text fields that do reach a projection - `label` and `cadence` - are -// refused outright when they carry an absolute path or a control scalar, -// because these values are projected to the cloud. +// It is also deliberately narrow: the bounded TOML document is parsed locally, +// but the automation `prompt`, its `cwds`, and every other filesystem-shaped +// value are never selected, retained, logged, or returned. The two free-text +// fields that do reach a projection - `label` and `cadence` - are refused +// outright when they carry an absolute path or a control scalar, because these +// values are projected to the cloud. // // Every failure is tolerated. Codex adds TOML keys across builds, so unknown // keys are ignored, and one unreadable or malformed directory yields a // diagnostic instead of failing the whole scan. -import { open, readdir } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { constants, type Dir, type Dirent } from "node:fs"; +import { open, opendir, realpath } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -45,6 +48,8 @@ export type CodexAutomationDiagnostic = Readonly<{ export type CodexAutomationScan = Readonly<{ automations: readonly CodexAutomation[]; + /** True when every visible automation directory fit inside the requested bound. */ + complete: boolean; diagnostics: readonly CodexAutomationDiagnostic[]; }>; @@ -53,7 +58,62 @@ export type ReadCodexAutomationsInput = Readonly<{ limit?: number; }>; -/** Every bound is fixed here; nothing in this module reads an unbounded value. */ +/** + * Private filesystem evidence used only when a scheduled-task association can + * grant an otherwise-stale thread adoption authority. The directory name is + * intentionally kept out of `CodexAutomation`, which is cloud projected. + */ +export type CodexAutomationAuthorityEntry = Readonly<{ + automation: Readonly>; + sourceDirectoryName: string; +}>; + +type CodexAutomationAuthorityReadControl = Readonly<{ + /** Cooperative cancellation for the daemon operation that consumes authority. */ + signal?: AbortSignal; + /** Absolute wall-clock deadline. Readers also enforce their own maximum. */ + deadlineAt?: number; +}>; + +export type CodexAutomationAuthorityRequest = ( + | Readonly<{ + after?: string | null; + kind: "page"; + limit?: number; + /** + * Private restart hint used only when no live cursor exists. Page zero is + * the ordinary start. Later pages seek to `restartPage * limit`, wrapping + * that raw offset over the current directory cardinality under the same + * absolute deadline. + */ + restartPage?: number; + }> + | Readonly<{ + kind: "sources"; + sourceDirectoryNames: readonly string[]; + }>) & CodexAutomationAuthorityReadControl; + +export type ReadCodexAutomationAuthorityInput = CodexAutomationAuthorityRequest & Readonly<{ + automationsDirectory?: string; +}>; + +export type CodexAutomationAuthorityScan = Readonly<{ + /** True only when this exact request was completely inspected. */ + complete: boolean; + diagnostics: readonly CodexAutomationDiagnostic[]; + entries: readonly CodexAutomationAuthorityEntry[]; + /** Opaque private scan cursor for the next fair page, or null at wrap. */ + nextCursor: string | null; +}>; + +/** + * Returned collections and retained filesystem values have fixed bounds. The + * compatibility projection streams a whole directory to preserve its atomic, + * lexicographically stable snapshot. Adoption authority bounds each returned + * page and ordinary cursor advance by entry count; reconstructing a cursor or + * restart offset is instead constant-memory and bounded by an absolute + * deadline so an unbounded daemon generation cannot become unbounded work. + */ const MAX_AUTOMATION_FILE_BYTES = 64 * 1_024; const MAX_ID_LENGTH = 200; const MAX_KIND_LENGTH = 200; @@ -62,6 +122,16 @@ const MAX_RRULE_LENGTH = 512; const MAX_TARGET_THREAD_ID_LENGTH = 200; const MAX_AUTOMATION_DIRECTORIES = 200; const DEFAULT_AUTOMATION_LIMIT = 200; +const MAX_AUTOMATION_DIRECTORY_NAME_LENGTH = 255; +/** Maximum directory entries consumed by one authority-page request. */ +export const CODEX_AUTOMATION_AUTHORITY_PAGE_ENTRY_LIMIT = 256; +const MAX_AUTOMATION_AUTHORITY_PAGE_CURSORS = 16; +const AUTOMATION_AUTHORITY_PAGE_CURSOR_TTL_MS = 5 * 60_000; +const AUTOMATION_AUTHORITY_DEFAULT_DEADLINE_MS = 3_000; +const AUTOMATION_AUTHORITY_MAX_DEADLINE_MS = 10_000; +const AUTOMATION_AUTHORITY_PAGE_CURSOR_PREFIX = "authority_scan_"; +const automationAuthorityPageCursorPattern = + /^authority_scan_(0|[1-9][0-9]{0,15})_([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/u; const AUTOMATION_FILE_NAME = "automation.toml"; const RRULE_PREFIX = "RRULE:"; @@ -71,13 +141,50 @@ const unsafeScalarPattern = /[\p{Cc}\p{Cf}\p{Cs}]/gu; const EMPTY_SCAN: CodexAutomationScan = Object.freeze({ automations: Object.freeze([]), + complete: true, + diagnostics: Object.freeze([]), +}); + +const UNAVAILABLE_SCAN: CodexAutomationScan = Object.freeze({ + automations: Object.freeze([]), + complete: false, diagnostics: Object.freeze([]), }); +const UNAVAILABLE_AUTHORITY_SCAN: CodexAutomationAuthorityScan = Object.freeze({ + complete: false, + diagnostics: Object.freeze([]), + entries: Object.freeze([]), + nextCursor: null, +}); + +type AutomationAuthorityPageState = { + readonly canonicalDirectory: string; + readonly directory: string; + readonly handle: Dir; + token: string; + busy: boolean; + closeTask?: Promise; + expiresAt: number; + expiryTimer?: ReturnType; + pendingEntry?: Dirent; + position: number; +}; + +const automationAuthorityPageStates = new Map(); +let automationAuthorityPageCreationTail = Promise.resolve(); + type AutomationParseOutcome = | Readonly<{ ok: true; automation: CodexAutomation }> | Readonly<{ ok: false; reason: "invalid_toml" | "invalid_fields" }>; +type AutomationAuthorityParseOutcome = + | Readonly<{ + ok: true; + automation: CodexAutomationAuthorityEntry["automation"]; + }> + | Readonly<{ ok: false; reason: "invalid_toml" | "invalid_fields" }>; + const INVALID_TOML: AutomationParseOutcome = Object.freeze({ ok: false, reason: "invalid_toml", @@ -88,6 +195,16 @@ const INVALID_FIELDS: AutomationParseOutcome = Object.freeze({ reason: "invalid_fields", } as const); +const INVALID_AUTHORITY_TOML: AutomationAuthorityParseOutcome = Object.freeze({ + ok: false, + reason: "invalid_toml", +} as const); + +const INVALID_AUTHORITY_FIELDS: AutomationAuthorityParseOutcome = Object.freeze({ + ok: false, + reason: "invalid_fields", +} as const); + /** * Parse one `automation.toml` body. Returns `null` for malformed TOML and for * any value that fails a bound or a safety check; the caller decides whether @@ -115,24 +232,27 @@ export async function readCodexAutomations( const limit = boundedLimit(input.limit); if (limit === 0) return EMPTY_SCAN; - let entries: readonly string[]; + const canonicalDirectory = await resolveCanonicalAutomationDirectory(directory); + if (!canonicalDirectory.ok) { + return canonicalDirectory.missing ? EMPTY_SCAN : UNAVAILABLE_SCAN; + } + + let selected: Readonly<{ complete: boolean; names: readonly string[] }>; try { - // A directory that cannot be listed at all carries no automation identity, - // so there is nothing to attribute a diagnostic to: report an empty scan. - const listed = await readdir(directory, { withFileTypes: true }); - entries = listed - .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) - .map((entry) => entry.name); - } catch { - return EMPTY_SCAN; + selected = await selectAutomationProjectionNames(directory, limit); + } catch (error) { + const code = fileSystemErrorCode(error); + return code === "ENOENT" || code === "ENOTDIR" ? EMPTY_SCAN : UNAVAILABLE_SCAN; } - const names = [...entries].sort().slice(0, limit); const automations: CodexAutomation[] = []; const diagnostics: CodexAutomationDiagnostic[] = []; - for (const name of names) { - const read = await readBoundedText(join(directory, name, AUTOMATION_FILE_NAME)); + for (const name of selected.names) { + const read = await readBoundedText( + join(directory, name, AUTOMATION_FILE_NAME), + join(canonicalDirectory.path, name, AUTOMATION_FILE_NAME), + ); if (!read.ok) { // A directory without `automation.toml` is not an automation at all // (Codex keeps other state beside them), so it is skipped silently. @@ -147,10 +267,558 @@ export async function readCodexAutomations( return Object.freeze({ automations: Object.freeze(automations), + complete: selected.complete, diagnostics: Object.freeze(diagnostics), }); } +async function selectAutomationProjectionNames( + directory: string, + limit: number, +): Promise> { + // Keep only the lexicographically first bounded candidates while streaming. + // This preserves the original public snapshot semantics without `readdir`'s + // directory-cardinality-sized allocation. Unlike adoption authority, this + // compatibility projection is not a source of session-control authority. + const candidates: string[] = []; + let complete = true; + const handle = await opendir(directory); + for await (const entry of handle) { + if (!entry.isDirectory() || entry.name.startsWith(".")) continue; + if (candidates.length === limit) complete = false; + let low = 0; + let high = candidates.length; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if ((candidates[middle] ?? "") < entry.name) low = middle + 1; + else high = middle; + } + candidates.splice(low, 0, entry.name); + if (candidates.length > limit) candidates.pop(); + } + return Object.freeze({ complete, names: Object.freeze(candidates) }); +} + +/** + * Read fair, bounded pages of automation authority or re-read an exact set of + * source directories. Positive age-waiver evidence comes from a page entry; + * pre-claim and pre-commit checks use `kind: "sources"` so unrelated directory + * insertion or deletion cannot shift the evidence across a page boundary. + * + * This deliberately does not cache records. Every exact-source request opens + * and validates the current file again, so deletion, retargeting, pausing + * drift, malformed TOML, symlinks, and replacement all fail closed. + */ +export async function readCodexAutomationAuthority( + input: ReadCodexAutomationAuthorityInput, +): Promise { + const callerSignal = input.signal; + callerSignal?.throwIfAborted(); + const controller = new AbortController(); + const now = Date.now(); + const requestedDeadline = input.deadlineAt; + const deadlineAt = Math.min( + typeof requestedDeadline === "number" && Number.isFinite(requestedDeadline) + ? requestedDeadline + : now + AUTOMATION_AUTHORITY_DEFAULT_DEADLINE_MS, + now + AUTOMATION_AUTHORITY_MAX_DEADLINE_MS, + ); + if (deadlineAt <= now) return UNAVAILABLE_AUTHORITY_SCAN; + const forwardAbort = (): void => { + controller.abort(signalReason(callerSignal)); + }; + callerSignal?.addEventListener("abort", forwardAbort, { once: true }); + if (callerSignal?.aborted === true) forwardAbort(); + const aborted = new Promise((_resolve, reject) => { + controller.signal.addEventListener("abort", () => { + reject(signalReason(controller.signal)); + }, { once: true }); + }); + const timer = setTimeout(() => { + controller.abort(new AutomationAuthorityDeadlineError()); + }, Math.max(1, deadlineAt - now)); + timer.unref(); + try { + return await Promise.race([ + readCodexAutomationAuthorityBeforeDeadline(input, { + deadlineAt, + signal: controller.signal, + }), + aborted, + ]); + } catch (error: unknown) { + if (callerSignal?.aborted === true) throw signalReason(callerSignal); + if (error instanceof AutomationAuthorityDeadlineError) { + return UNAVAILABLE_AUTHORITY_SCAN; + } + throw error; + } finally { + clearTimeout(timer); + callerSignal?.removeEventListener("abort", forwardAbort); + } +} + +async function readCodexAutomationAuthorityBeforeDeadline( + input: ReadCodexAutomationAuthorityInput, + control: Readonly<{ deadlineAt: number; signal: AbortSignal }>, +): Promise { + assertAutomationAuthorityReadCurrent(control); + const directory = input.automationsDirectory ?? defaultAutomationsDirectory(); + const canonicalDirectory = await resolveCanonicalAutomationDirectory(directory); + assertAutomationAuthorityReadCurrent(control); + if (!canonicalDirectory.ok) { + if (input.kind === "page" && input.after !== undefined && input.after !== null) { + await discardAutomationAuthorityPageCursor(input.after, directory); + } + if (!canonicalDirectory.missing) return UNAVAILABLE_AUTHORITY_SCAN; + return Object.freeze({ + complete: true, + diagnostics: Object.freeze([]), + entries: Object.freeze([]), + nextCursor: null, + }); + } + if (input.kind === "sources") { + const names = [...new Set(input.sourceDirectoryNames)]; + if ( + names.length > MAX_AUTOMATION_DIRECTORIES + || names.some((name) => !safeAutomationDirectoryName(name)) + ) return UNAVAILABLE_AUTHORITY_SCAN; + const read = await readAutomationAuthorityEntries( + directory, + canonicalDirectory.path, + names.sort(), + control, + ); + return Object.freeze({ + complete: true, + diagnostics: read.diagnostics, + entries: read.entries, + nextCursor: null, + }); + } + + const after = input.after ?? null; + if (after !== null && !safeAutomationAuthorityPageCursor(after)) { + return UNAVAILABLE_AUTHORITY_SCAN; + } + const limit = boundedLimit(input.limit); + if (limit === 0) return UNAVAILABLE_AUTHORITY_SCAN; + const restartPage = boundedAutomationAuthorityRestartPage(input.restartPage); + if (restartPage === null) return UNAVAILABLE_AUTHORITY_SCAN; + + let page: readonly string[]; + let complete: boolean; + let nextCursor: string | null; + try { + const selected = await selectAutomationAuthorityPage( + directory, + canonicalDirectory.path, + after, + limit, + restartPage, + control, + ); + page = selected.names; + complete = selected.complete; + nextCursor = selected.nextCursor; + } catch (error) { + const code = fileSystemErrorCode(error); + if (code === "ENOENT" || code === "ENOTDIR") { + return Object.freeze({ + complete: true, + diagnostics: Object.freeze([]), + entries: Object.freeze([]), + nextCursor: null, + }); + } + return UNAVAILABLE_AUTHORITY_SCAN; + } + if (!complete && nextCursor === null) return UNAVAILABLE_AUTHORITY_SCAN; + const read = await readAutomationAuthorityEntries( + directory, + canonicalDirectory.path, + page, + control, + ); + return Object.freeze({ + complete, + diagnostics: read.diagnostics, + entries: read.entries, + nextCursor, + }); +} + +async function selectAutomationAuthorityPage( + directory: string, + canonicalDirectory: string, + after: string | null, + limit: number, + restartPage: number, + control: Readonly<{ deadlineAt: number; signal: AbortSignal }>, +): Promise> { + let state: AutomationAuthorityPageState | null; + if (after === null) { + state = await createAutomationAuthorityPageState( + directory, + canonicalDirectory, + automationAuthorityRestartPosition(restartPage, limit), + restartPage > 0, + control, + ); + if (state === null) throw new Error("Automation authority cursor capacity is unavailable."); + } else { + const current = automationAuthorityPageStates.get(after); + if (current === undefined) { + // Cursor handles are process-local and intentionally expire, but their + // safe raw position is self-contained. Rebuild the handle at that + // position without returning authority on the recovery request itself; + // the single owner immediately proves the new live cursor on its retry. + const recoveryPosition = automationAuthorityPageCursorPosition(after); + state = await createAutomationAuthorityPageState( + directory, + canonicalDirectory, + BigInt(recoveryPosition ?? 0), + false, + control, + ); + if (state === null) throw new Error("Automation authority cursor capacity is unavailable."); + return Object.freeze({ + complete: false, + names: Object.freeze([]), + nextCursor: state.token, + }); + } + if ( + current.directory !== directory + || current.canonicalDirectory !== canonicalDirectory + || current.expiresAt <= Date.now() + ) { + if (current.directory !== directory) { + throw new Error("Automation authority cursor belongs to another directory."); + } + await closeAutomationAuthorityPageState(current); + const recoveryPosition = automationAuthorityPageCursorPosition(after); + state = await createAutomationAuthorityPageState( + directory, + canonicalDirectory, + BigInt(recoveryPosition ?? 0), + false, + control, + ); + if (state === null) throw new Error("Automation authority cursor capacity is unavailable."); + return Object.freeze({ + complete: false, + names: Object.freeze([]), + nextCursor: state.token, + }); + } + if (current.busy || current.closeTask !== undefined) { + throw new Error("Automation authority cursor is already in use."); + } + state = current; + } + + state.busy = true; + armAutomationAuthorityPageExpiry(state); + const names: string[] = []; + let complete = false; + let examined = 0; + try { + while ( + names.length < limit + && examined < CODEX_AUTOMATION_AUTHORITY_PAGE_ENTRY_LIMIT + ) { + assertAutomationAuthorityReadCurrent(control); + const entry = state.pendingEntry ?? await state.handle.read(); + delete state.pendingEntry; + assertAutomationAuthorityReadCurrent(control); + if (entry === null) { + complete = true; + break; + } + examined += 1; + if (state.position >= Number.MAX_SAFE_INTEGER) { + throw new Error("Automation authority directory position is too large."); + } + state.position += 1; + if (entry.isDirectory() && safeAutomationDirectoryName(entry.name)) { + names.push(entry.name); + } + } + if (complete) { + await closeAutomationAuthorityPageState(state); + } else { + rotateAutomationAuthorityPageCursor(state); + } + return Object.freeze({ + complete, + names: Object.freeze(names), + nextCursor: complete ? null : state.token, + }); + } catch (error: unknown) { + // A failed close does not prove the descriptor was released. Retry it once + // immediately; if that also fails, retained state and its expiry timer keep + // later cleanup possible. In every case this page returns no authority. + await closeAutomationAuthorityPageState(state).catch(() => undefined); + throw error; + } finally { + state.busy = false; + if ( + automationAuthorityPageStates.get(state.token) === state + && state.closeTask === undefined + ) { + armAutomationAuthorityPageExpiry(state); + } + } +} + +async function createAutomationAuthorityPageState( + directory: string, + canonicalDirectory: string, + requestedPosition: bigint, + wrapExactEnd: boolean, + control: Readonly<{ deadlineAt: number; signal: AbortSignal }>, +): Promise { + let releaseCreation!: () => void; + const priorCreation = automationAuthorityPageCreationTail; + automationAuthorityPageCreationTail = new Promise((resolve) => { + releaseCreation = resolve; + }); + await priorCreation; + try { + const now = Date.now(); + for (const state of [...automationAuthorityPageStates.values()]) { + if (!state.busy && state.closeTask === undefined && state.expiresAt <= now) { + await closeAutomationAuthorityPageState(state); + } + } + if (automationAuthorityPageStates.size >= MAX_AUTOMATION_AUTHORITY_PAGE_CURSORS) { + const evictable = [...automationAuthorityPageStates.values()] + .filter((state) => !state.busy && state.closeTask === undefined) + .sort((left, right) => left.expiresAt - right.expiresAt)[0]; + if (evictable === undefined) return null; + await closeAutomationAuthorityPageState(evictable); + } + if (requestedPosition < 0n) { + throw new Error("Automation authority recovery position is invalid."); + } + let requested = requestedPosition; + let handle = await opendir(directory); + let pendingEntry: Dirent | undefined; + let position = 0; + let wrapped = false; + try { + while (BigInt(position) < requested) { + assertAutomationAuthorityReadCurrent(control); + const entry = await handle.read(); + assertAutomationAuthorityReadCurrent(control); + if (entry === null) { + if (position === 0 || wrapped) break; + // Restart pages can carry an unbounded generation-derived offset, + // and recovered cursor positions can outlive directory shrinkage. + // Learn the current raw cardinality in one pass, then reopen once at + // the equivalent offset. Work remains memory-constant and under the + // caller's absolute deadline even for a very large position. + requested %= BigInt(position); + await handle.close(); + handle = await opendir(directory); + position = 0; + wrapped = true; + continue; + } + if (position >= Number.MAX_SAFE_INTEGER) { + throw new Error("Automation authority directory position is too large."); + } + position += 1; + } + if ( + wrapExactEnd + && !wrapped + && requestedPosition > 0n + && BigInt(position) === requested + ) { + // Prove the exact-cardinality edge. Without this bounded lookahead, a + // restart offset equal to the directory size would park at EOF and + // return an empty page instead of wrapping to the ordinary start. + assertAutomationAuthorityReadCurrent(control); + const next = await handle.read(); + assertAutomationAuthorityReadCurrent(control); + if (next === null && position > 0) { + await handle.close(); + handle = await opendir(directory); + position = 0; + } else if (next !== null) { + // The directory handle has consumed the lookahead, but the logical + // cursor has not. Retain it in memory and advance the safe raw + // position only when the page reader actually consumes the entry. + pendingEntry = next; + } + } + assertAutomationAuthorityReadCurrent(control); + } catch (error: unknown) { + await closeUnregisteredAutomationAuthorityHandle(handle); + throw error; + } + const token = automationAuthorityPageCursor(position); + if (automationAuthorityPageStates.has(token)) { + await handle.close(); + return null; + } + const state: AutomationAuthorityPageState = { + busy: false, + canonicalDirectory, + directory, + expiresAt: now + AUTOMATION_AUTHORITY_PAGE_CURSOR_TTL_MS, + handle, + ...(pendingEntry === undefined ? {} : { pendingEntry }), + position, + token, + }; + automationAuthorityPageStates.set(token, state); + armAutomationAuthorityPageExpiry(state); + return state; + } finally { + releaseCreation(); + } +} + +async function closeUnregisteredAutomationAuthorityHandle(handle: Dir): Promise { + try { + await handle.close(); + return; + } catch { + // A transient close failure gets one immediate bounded retry. Bun 1.3 can + // return synchronous `undefined` from this promise-typed API, so keep the + // retry inside an ordinary try/await boundary rather than chaining catch. + } + try { + await handle.close(); + } catch { + // The handle was never published as authority. The caller still fails + // closed; there is no safe operation to perform through this descriptor. + } +} + +function armAutomationAuthorityPageExpiry(state: AutomationAuthorityPageState): void { + if (state.closeTask !== undefined) return; + if (state.expiryTimer !== undefined) clearTimeout(state.expiryTimer); + state.expiresAt = Date.now() + AUTOMATION_AUTHORITY_PAGE_CURSOR_TTL_MS; + state.expiryTimer = setTimeout(() => { + if (automationAuthorityPageStates.get(state.token) !== state) return; + if (state.busy || state.closeTask !== undefined) { + armAutomationAuthorityPageExpiry(state); + return; + } + void closeAutomationAuthorityPageState(state).catch(() => undefined); + }, AUTOMATION_AUTHORITY_PAGE_CURSOR_TTL_MS); + state.expiryTimer.unref(); +} + +async function closeAutomationAuthorityPageState( + state: AutomationAuthorityPageState, +): Promise { + if (state.closeTask !== undefined) return state.closeTask; + if (automationAuthorityPageStates.get(state.token) !== state) return; + if (state.expiryTimer !== undefined) { + clearTimeout(state.expiryTimer); + delete state.expiryTimer; + } + // Bun 1.3 implements this Node API as a synchronous `undefined` return even + // though its declaration promises `Promise`. Normalise both runtimes + // and synchronous throws into one tracked close task. + const closeTask = Promise.resolve().then(() => state.handle.close()).then( + () => { + if (automationAuthorityPageStates.get(state.token) === state) { + automationAuthorityPageStates.delete(state.token); + } + }, + (error: unknown) => { + delete state.closeTask; + if ( + automationAuthorityPageStates.get(state.token) === state + && !state.busy + ) { + armAutomationAuthorityPageExpiry(state); + } + throw error; + }, + ); + state.closeTask = closeTask; + return closeTask; +} + +function rotateAutomationAuthorityPageCursor(state: AutomationAuthorityPageState): void { + if (automationAuthorityPageStates.get(state.token) !== state) { + throw new Error("Automation authority cursor state was retired during use."); + } + const nextToken = automationAuthorityPageCursor(state.position); + if (automationAuthorityPageStates.has(nextToken)) { + throw new Error("Automation authority cursor identity collided."); + } + automationAuthorityPageStates.delete(state.token); + state.token = nextToken; + automationAuthorityPageStates.set(nextToken, state); +} + +async function discardAutomationAuthorityPageCursor( + token: string, + directory: string, +): Promise { + if (!safeAutomationAuthorityPageCursor(token)) return; + const state = automationAuthorityPageStates.get(token); + if ( + state === undefined + || state.directory !== directory + || state.busy + || state.closeTask !== undefined + ) return; + await closeAutomationAuthorityPageState(state).catch(() => undefined); +} + +async function readAutomationAuthorityEntries( + directory: string, + canonicalDirectory: string, + names: readonly string[], + control: Readonly<{ deadlineAt: number; signal: AbortSignal }>, +): Promise> { + const entries: CodexAutomationAuthorityEntry[] = []; + const diagnostics: CodexAutomationDiagnostic[] = []; + for (const name of names) { + assertAutomationAuthorityReadCurrent(control); + const read = await readBoundedText( + join(directory, name, AUTOMATION_FILE_NAME), + join(canonicalDirectory, name, AUTOMATION_FILE_NAME), + control, + ); + assertAutomationAuthorityReadCurrent(control); + if (!read.ok) { + if (!read.missing) diagnostics.push(diagnostic(name, "unreadable")); + continue; + } + const outcome = parseAutomationAuthorityDocument(read.text); + if (outcome.ok) { + entries.push(Object.freeze({ + automation: outcome.automation, + sourceDirectoryName: name, + })); + } else { + diagnostics.push(diagnostic(name, outcome.reason)); + } + } + return Object.freeze({ + diagnostics: Object.freeze(diagnostics), + entries: Object.freeze(entries), + }); +} + function defaultAutomationsDirectory(): string { return join(homedir(), ".codex", "automations"); } @@ -163,15 +831,20 @@ function parseAutomationDocument(text: string, fallbackId: string): AutomationPa return INVALID_TOML; } if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return INVALID_TOML; - // Unknown keys are ignored on purpose: Codex adds fields across builds, and - // `prompt`/`cwds` are never read even though they sit in the same table. + // Unknown keys are ignored on purpose: Codex adds fields across builds. The + // bounded TOML parser necessarily materializes its document, but this reader + // never selects, retains, logs, or returns `prompt`, `cwds`, or another + // unknown value from the parsed table. const document = parsed as Record; const rawId = document.id; const id = safeField(rawId === undefined || rawId === null ? fallbackId : rawId, MAX_ID_LENGTH); if (id === null || id.length === 0) return INVALID_FIELDS; - const kind = safeField(document.kind, MAX_KIND_LENGTH); + // `kind` and `target_thread_id` decide adoption authority. Unlike display + // fields, their bytes must already be canonical; trimming must never turn a + // malformed record into an exact heartbeat association. + const kind = safeExactField(document.kind, MAX_KIND_LENGTH); if (kind === null || kind.length === 0) return INVALID_FIELDS; const rawName = document.name; @@ -213,7 +886,7 @@ function parseAutomationDocument(text: string, fallbackId: string): AutomationPa const rawTarget = document.target_thread_id; let targetThreadId: string | null = null; if (rawTarget !== undefined && rawTarget !== null) { - const target = safeField(rawTarget, MAX_TARGET_THREAD_ID_LENGTH); + const target = safeExactField(rawTarget, MAX_TARGET_THREAD_ID_LENGTH); if (target === null) return INVALID_FIELDS; if (target.length > 0) targetThreadId = target; } @@ -240,6 +913,53 @@ function parseAutomationDocument(text: string, fallbackId: string): AutomationPa } as const); } +/** + * Parse only the fields that can grant scheduled-target adoption authority. + * Display and projection fields are deliberately not read: an unsafe name or + * unusable id/rrule must not revoke an otherwise exact Desktop-owned binding, + * and none of that free text belongs in the private authority result. + */ +function parseAutomationAuthorityDocument(text: string): AutomationAuthorityParseOutcome { + let parsed: unknown; + try { + parsed = Bun.TOML.parse(text); + } catch { + return INVALID_AUTHORITY_TOML; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return INVALID_AUTHORITY_TOML; + } + const document = parsed as Record; + + if (document.kind !== "heartbeat") return INVALID_AUTHORITY_FIELDS; + + const targetThreadId = safeExactField( + document.target_thread_id, + MAX_TARGET_THREAD_ID_LENGTH, + ); + if (targetThreadId === null || targetThreadId.length === 0) { + return INVALID_AUTHORITY_FIELDS; + } + + const rawStatus = document.status; + let status: "active" | "paused" = "active"; + if (rawStatus !== undefined) { + if (typeof rawStatus !== "string") return INVALID_AUTHORITY_FIELDS; + if (rawStatus.trim().length === 0 || rawStatus === "ACTIVE") status = "active"; + else if (rawStatus === "PAUSED") status = "paused"; + else return INVALID_AUTHORITY_FIELDS; + } + + return Object.freeze({ + ok: true, + automation: Object.freeze({ + kind: "heartbeat", + status, + targetThreadId, + }), + } as const); +} + /** * A string field that is within its bound and carries neither a control scalar * nor an absolute path. Returns `null` when the value is not usable; an @@ -253,6 +973,54 @@ function safeField(value: unknown, max: number): string | null { return value.trim(); } +function safeExactField(value: unknown, max: number): string | null { + const field = safeField(value, max); + return field !== null && field === value ? field : null; +} + +function safeAutomationDirectoryName(value: unknown): value is string { + return typeof value === "string" + && value.length > 0 + && value.length <= MAX_AUTOMATION_DIRECTORY_NAME_LENGTH + && value !== "." + && value !== ".." + && !value.startsWith(".") + && !value.includes("/") + && !value.includes("\\") + && !containsUnsafeTerminalScalar(value); +} + +function safeAutomationAuthorityPageCursor(value: unknown): value is string { + return automationAuthorityPageCursorPosition(value) !== null; +} + +function automationAuthorityPageCursorPosition(value: unknown): number | null { + if (typeof value !== "string") return null; + const match = automationAuthorityPageCursorPattern.exec(value); + if (match === null) return null; + const position = Number(match[1]); + return Number.isSafeInteger(position) && position >= 0 ? position : null; +} + +function automationAuthorityPageCursor(position: number): string { + if (!Number.isSafeInteger(position) || position < 0) { + throw new Error("Automation authority cursor position is invalid."); + } + return `${AUTOMATION_AUTHORITY_PAGE_CURSOR_PREFIX}${String(position)}_${randomUUID()}`; +} + +function boundedAutomationAuthorityRestartPage(value: number | undefined): number | null { + if (value === undefined) return 0; + return Number.isSafeInteger(value) + && value >= 0 + ? value + : null; +} + +function automationAuthorityRestartPosition(page: number, limit: number): bigint { + return BigInt(page) * BigInt(limit); +} + function boundedLimit(limit: number | undefined): number { if (limit === undefined || !Number.isFinite(limit)) return DEFAULT_AUTOMATION_LIMIT; const floored = Math.floor(limit); @@ -276,28 +1044,108 @@ type BoundedRead = const MISSING_FILE: BoundedRead = Object.freeze({ ok: false, missing: true } as const); const UNREADABLE_FILE: BoundedRead = Object.freeze({ ok: false, missing: false } as const); +const exposedCloseOnExecFlag: unknown = Reflect.get(constants, "O_CLOEXEC"); +// Node-compatible runtimes open descriptors close-on-exec internally, but do +// not expose O_CLOEXEC in `fs.constants` on every supported platform. Include +// the native flag when it is exposed without making those platforms unreadable. +const CLOSE_ON_EXEC_OPEN_FLAG = typeof exposedCloseOnExecFlag === "number" + ? exposedCloseOnExecFlag + : 0; + +type CanonicalAutomationDirectory = + | Readonly<{ ok: true; path: string }> + | Readonly<{ ok: false; missing: boolean }>; + +async function resolveCanonicalAutomationDirectory( + directory: string, +): Promise { + try { + return Object.freeze({ ok: true, path: await realpath(directory) }); + } catch (error) { + const code = fileSystemErrorCode(error); + return code === "ENOENT" || code === "ENOTDIR" + ? Object.freeze({ ok: false, missing: true }) + : Object.freeze({ ok: false, missing: false }); + } +} + +/** + * Resolve the already-open descriptor without trusting any path component + * used to reach it. Darwin exposes descriptors through `/dev/fd`; Linux uses + * `/proc/self/fd`. A platform without either proof refuses the file. + */ +async function canonicalPathForOpenHandle(handle: FileHandle): Promise { + const candidates = process.platform === "linux" + ? [`/proc/self/fd/${handle.fd}`, `/dev/fd/${handle.fd}`] + : [`/dev/fd/${handle.fd}`, `/proc/self/fd/${handle.fd}`]; + for (const candidate of candidates) { + try { + return await realpath(candidate); + } catch { + // Try the other supported descriptor filesystem. + } + } + return null; +} /** Read at most `MAX_AUTOMATION_FILE_BYTES`; a larger file is unreadable. */ -async function readBoundedText(path: string): Promise { +async function readBoundedText( + path: string, + expectedCanonicalPath: string, + control?: Readonly<{ deadlineAt: number; signal: AbortSignal }>, +): Promise { + if (control !== undefined) assertAutomationAuthorityReadCurrent(control); let handle: FileHandle; try { - handle = await open(path, "r"); + handle = await open( + path, + constants.O_RDONLY + | CLOSE_ON_EXEC_OPEN_FLAG + | constants.O_NOFOLLOW + | constants.O_NONBLOCK, + ); } catch (error) { const code = fileSystemErrorCode(error); return code === "ENOENT" || code === "ENOTDIR" ? MISSING_FILE : UNREADABLE_FILE; } try { + if (control !== undefined) assertAutomationAuthorityReadCurrent(control); + const openedPath = await canonicalPathForOpenHandle(handle); + if (openedPath !== expectedCanonicalPath) return UNREADABLE_FILE; + const before = await handle.stat(); + const currentUid = typeof process.getuid === "function" ? process.getuid() : null; + if ( + !before.isFile() + || before.nlink !== 1 + || before.size > MAX_AUTOMATION_FILE_BYTES + || (currentUid !== null && before.uid !== currentUid) + || (before.mode & 0o022) !== 0 + ) return UNREADABLE_FILE; const buffer = new Uint8Array(MAX_AUTOMATION_FILE_BYTES + 1); let filled = 0; while (filled < buffer.length) { + if (control !== undefined) assertAutomationAuthorityReadCurrent(control); const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, filled); if (bytesRead === 0) break; filled += bytesRead; } if (filled > MAX_AUTOMATION_FILE_BYTES) return UNREADABLE_FILE; + const after = await handle.stat(); + const settledPath = await canonicalPathForOpenHandle(handle); + if ( + (control !== undefined + && (control.signal.aborted || control.deadlineAt <= Date.now())) + || + settledPath !== openedPath + || after.dev !== before.dev + || after.ino !== before.ino + || after.size !== before.size + || after.mtimeMs !== before.mtimeMs + || after.ctimeMs !== before.ctimeMs + ) return UNREADABLE_FILE; return Object.freeze({ ok: true, - text: new TextDecoder().decode(buffer.subarray(0, filled)), + text: new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, filled)), } as const); } catch { return UNREADABLE_FILE; @@ -306,6 +1154,24 @@ async function readBoundedText(path: string): Promise { } } +class AutomationAuthorityDeadlineError extends Error { + constructor() { + super("Codex automation authority read reached its deadline."); + this.name = "AutomationAuthorityDeadlineError"; + } +} + +function assertAutomationAuthorityReadCurrent( + control: Readonly<{ deadlineAt: number; signal: AbortSignal }>, +): void { + control.signal.throwIfAborted(); + if (control.deadlineAt <= Date.now()) throw new AutomationAuthorityDeadlineError(); +} + +function signalReason(signal: AbortSignal | undefined): Error { + return signal?.reason instanceof Error ? signal.reason : new Error("The operation was aborted."); +} + function fileSystemErrorCode(error: unknown): string | null { if (typeof error !== "object" || error === null) return null; const code = (error as { readonly code?: unknown }).code; diff --git a/src/codex/client.test.ts b/src/codex/client.test.ts index f048f30..686fc3a 100644 --- a/src/codex/client.test.ts +++ b/src/codex/client.test.ts @@ -116,6 +116,7 @@ class FakeProcess implements CodexProcess { readonly onWrite: (message: Record, process: FakeProcess) => void, readonly shutdown: { readonly autoCredentialStorePreflight?: boolean; + readonly ignoreKill?: boolean; readonly ignoreTerm?: boolean; readonly leaveStreamsOpenAfterKill?: boolean; } = {}, @@ -171,12 +172,17 @@ class FakeProcess implements CodexProcess { forceTerminate(): void { this.signals.push("SIGKILL"); + if (this.shutdown.ignoreKill === true) return; if (this.shutdown.leaveStreamsOpenAfterKill !== true) { this.stdoutQueue.close(); this.stderrQueue.close(); } this.#resolveExit(137); } + + settleExit(code = 137): void { + this.#resolveExit(code); + } } function successfulFake(codexHome: string, userAgent = "codex-cli/0.153.2"): FakeProcess { @@ -626,6 +632,262 @@ describe("CodexAppServerClient", () => { await client.close(); }); + test("raises account authority before its first await and lets only the refresh read pass blocked frames", async () => { + const codexHome = "/tmp/hra-control-plane/profile-a/codex-home"; + const barrier = deferred(); + const events: string[] = []; + let signaled = false; + const process = successfulFake(codexHome); + const client = createClient({ + process, + authority: { profileId: "profile-a", processGeneration: 7 }, + expectedCodexHome: codexHome, + isAuthorityCurrent: () => { + if (signaled) events.push("authority-check"); + return true; + }, + connectionId: CONNECTION_ID, + onAccountAuthoritySignal: () => { + events.push("signal"); + signaled = true; + return barrier.promise; + }, + }); + await client.initialize(); + const writesBeforeSignal = process.writes.length; + + process.stdoutQueue.push([ + JSON.stringify({ + method: "account/updated", + params: { authMode: "chatgpt", planType: "pro" }, + }), + JSON.stringify({ id: 91, method: "unsupported/request", params: {} }), + "", + ].join("\n")); + await waitFor(() => events.includes("authority-check")); + expect(events.slice(0, 2)).toEqual(["signal", "authority-check"]); + + const ordinaryRead = client.accountRead(); + const refreshRead = client.refreshAccountAuthority(); + await refreshRead; + expect(process.writes.slice(writesBeforeSignal)).toContainEqual({ + id: expect.any(Number), + method: "account/read", + params: { refreshToken: true }, + }); + expect(process.writes.slice(writesBeforeSignal)).not.toContainEqual({ + id: expect.any(Number), + method: "account/read", + params: { refreshToken: false }, + }); + expect(process.writes.slice(writesBeforeSignal)).not.toContainEqual({ + id: 91, + error: expect.any(Object), + }); + + barrier.resolve(undefined); + await ordinaryRead; + await waitFor(() => process.writes.some((frame) => + (frame as Record).id === 91)); + expect(process.writes.slice(writesBeforeSignal)).toContainEqual({ + id: expect.any(Number), + method: "account/read", + params: { refreshToken: false }, + }); + await client.close(); + }); + + test("holds a pre-admitted queued provider write behind a later account signal", async () => { + const codexHome = "/tmp/hra-control-plane/profile-a/codex-home"; + const firstWriteGate = deferred(); + const accountBarrier = deferred(); + let signaled = false; + const process = successfulFake(codexHome); + const client = createClient({ + process, + authority: { profileId: "profile-a", processGeneration: 7 }, + expectedCodexHome: codexHome, + isAuthorityCurrent: () => true, + connectionId: CONNECTION_ID, + onAccountAuthoritySignal: () => { + signaled = true; + return accountBarrier.promise; + }, + }); + await client.initialize(); + const writesBeforeReads = process.writes.length; + process.writeSettlementGate = firstWriteGate.promise; + const first = client.accountRead(); + await waitFor(() => process.writes.slice(writesBeforeReads).some((frame) => + (frame as Record).method === "account/read")); + const second = client.accountRead(); + + process.respond({ + method: "account/updated", + params: { authMode: "chatgpt", planType: "pro" }, + }); + await waitFor(() => signaled); + const refresh = client.refreshAccountAuthority(); + firstWriteGate.resolve(undefined); + await Promise.all([first, refresh]); + expect(process.writes.slice(writesBeforeReads).filter((frame) => + (frame as Record).method === "account/read")) + .toEqual([ + expect.objectContaining({ params: { refreshToken: false } }), + expect.objectContaining({ params: { refreshToken: true } }), + ]); + + accountBarrier.resolve(undefined); + await second; + expect(process.writes.slice(writesBeforeReads).filter((frame) => + (frame as Record).method === "account/read")) + .toEqual([ + expect.objectContaining({ params: { refreshToken: false } }), + expect.objectContaining({ params: { refreshToken: true } }), + expect.objectContaining({ params: { refreshToken: false } }), + ]); + await client.close(); + }); + + test("defers an already-dispatched response without blocking the refresh response read", async () => { + const codexHome = "/tmp/hra-control-plane/profile-a/codex-home"; + const accountBarrier = deferred(); + let ordinaryRequestId: number | undefined; + let signaled = false; + const process = new FakeProcess((message, runtime) => { + if (message.method === "initialize") { + runtime.respond({ + id: message.id, + result: { + userAgent: "codex-cli/0.153.2", + codexHome, + platformFamily: "unix", + platformOs: "macos", + }, + }); + } else if (message.method === "account/read") { + const params = message.params as { refreshToken?: boolean }; + if (params.refreshToken === true) { + runtime.respond({ + id: message.id, + result: { + account: { type: "chatgpt", email: "person@example.com", planType: "pro" }, + requiresOpenaiAuth: true, + }, + }); + } else { + ordinaryRequestId = message.id as number; + } + } + }); + const client = createClient({ + process, + authority: { profileId: "profile-a", processGeneration: 7 }, + expectedCodexHome: codexHome, + isAuthorityCurrent: () => true, + connectionId: CONNECTION_ID, + onAccountAuthoritySignal: () => { + signaled = true; + return accountBarrier.promise; + }, + }); + await client.initialize(); + const ordinary = client.accountRead(); + await waitFor(() => ordinaryRequestId !== undefined); + process.stdoutQueue.push([ + JSON.stringify({ + method: "account/updated", + params: { authMode: "chatgpt", planType: "pro" }, + }), + JSON.stringify({ + id: ordinaryRequestId, + result: { + account: { type: "chatgpt", email: "old@example.com", planType: "pro" }, + requiresOpenaiAuth: true, + }, + }), + JSON.stringify({ id: 92, method: "unsupported/request", params: {} }), + "", + ].join("\n")); + await waitFor(() => signaled); + let ordinarySettled = false; + void ordinary.then(() => { ordinarySettled = true; }); + + await client.refreshAccountAuthority(); + await Promise.resolve(); + expect(ordinarySettled).toBe(false); + expect(process.writes).not.toContainEqual({ id: 92, error: expect.any(Object) }); + + accountBarrier.resolve(undefined); + await ordinary; + await waitFor(() => process.writes.some((frame) => + (frame as Record).id === 92)); + expect(ordinarySettled).toBe(true); + await client.close(); + }); + + test("close rejects a barrier-deferred response and permanently cancels queued frames", async () => { + const codexHome = "/tmp/hra-control-plane/profile-a/codex-home"; + const accountBarrier = deferred(); + let ordinaryRequestId: number | undefined; + let signaled = false; + const process = new FakeProcess((message, runtime) => { + if (message.method === "initialize") { + runtime.respond({ + id: message.id, + result: { + userAgent: "codex-cli/0.153.2", + codexHome, + platformFamily: "unix", + platformOs: "macos", + }, + }); + } else if (message.method === "account/read") { + ordinaryRequestId = message.id as number; + } + }); + const client = createClient({ + process, + authority: { profileId: "profile-a", processGeneration: 7 }, + expectedCodexHome: codexHome, + isAuthorityCurrent: () => true, + connectionId: CONNECTION_ID, + shutdownSettlementMs: 5, + onAccountAuthoritySignal: () => { + signaled = true; + return accountBarrier.promise; + }, + }); + await client.initialize(); + const ordinary = client.accountRead(); + const ordinaryError = ordinary.catch((error: unknown) => error); + await waitFor(() => ordinaryRequestId !== undefined); + process.stdoutQueue.push([ + JSON.stringify({ + method: "account/updated", + params: { authMode: "chatgpt", planType: "pro" }, + }), + JSON.stringify({ + id: ordinaryRequestId, + result: { + account: { type: "chatgpt", email: "old@example.com", planType: "pro" }, + requiresOpenaiAuth: true, + }, + }), + JSON.stringify({ id: 93, method: "unsupported/request", params: {} }), + "", + ].join("\n")); + await waitFor(() => signaled); + await client.close(); + expect(await ordinaryError).toMatchObject({ code: "PROCESS_EXITED" }); + const writesAtClose = process.writes.length; + + accountBarrier.resolve(undefined); + await Bun.sleep(2); + expect(process.writes).toHaveLength(writesAtClose); + expect(process.writes).not.toContainEqual({ id: 93, error: expect.any(Object) }); + }); + test("emits no connection facts when authority becomes stale at the activation commit", async () => { const codexHome = "/tmp/hra-control-plane/profile-a/codex-home"; const activationGate = deferred(); @@ -1335,6 +1597,75 @@ describe("CodexAppServerClient", () => { await client.close(); }); + test("lets an account refresh leapfrog a dynamic response paused in its async pre-write check", async () => { + const codexHome = "/tmp/hra-control-plane/profile-a/codex-home"; + const preWriteGate = deferred(); + const accountBarrier = deferred(); + let markPreWriteStarted!: () => void; + const preWriteStarted = new Promise((resolve) => { markPreWriteStarted = resolve; }); + let authorityPhase = 0; + let providerConnected = false; + let accountSignaled = false; + let postWriteCalls = 0; + const process = successfulFake(codexHome); + const client = createClient({ + process, + authority: { profileId: "profile-a", processGeneration: 1 }, + expectedCodexHome: codexHome, + experimentalApi: true, + isAuthorityCurrent: () => { + if (authorityPhase === 1) { + authorityPhase = 2; + return true; + } + if (authorityPhase === 2) { + authorityPhase = 3; + markPreWriteStarted(); + return preWriteGate.promise; + } + return true; + }, + connectionId: CONNECTION_ID, + onAccountAuthoritySignal: () => { + accountSignaled = true; + return accountBarrier.promise; + }, + onConversationAutomationToolCall: () => { + authorityPhase = 1; + return { scope: "conversation" }; + }, + onConversationAutomationToolResponseWritten: () => { postWriteCalls += 1; }, + onFact: ({ value }) => { + if (value.type === "providerConnected") providerConnected = true; + }, + }); + await client.initialize(); + await waitFor(() => providerConnected); + process.respond({ + id: "tool-account-race", + method: "item/tool/call", + params: conversationAutomationParams(), + }); + await preWriteStarted; + + process.respond({ + method: "account/updated", + params: { authMode: "chatgpt", planType: "pro" }, + }); + await waitFor(() => accountSignaled); + const refresh = client.refreshAccountAuthority(); + preWriteGate.resolve(true); + await refresh; + expect(process.writes.some((frame) => + (frame as Record).id === "tool-account-race")).toBe(false); + + accountBarrier.resolve(undefined); + await waitFor(() => process.writes.some((frame) => + (frame as Record).id === "tool-account-race")); + expect(postWriteCalls).toBe(1); + await client.close(); + }); + test("rejects dynamic-tool calls when the experimental API was not negotiated", async () => { const process = successfulFake("/tmp/hra-control-plane/profile-a/codex-home"); let calls = 0; @@ -2344,6 +2675,59 @@ describe("CodexAppServerClient", () => { await client.close(); }); + test("does not replay a barrier-blocked approval after the provider resolves it", async () => { + const process = successfulFake("/tmp/hra-control-plane/profile-a/codex-home"); + const barrier = deferred(); + const facts: CodexFact[] = []; + const client = createClient({ + process, + authority: { profileId: "profile-a", processGeneration: 1 }, + expectedCodexHome: "/tmp/hra-control-plane/profile-a/codex-home", + isAuthorityCurrent: () => true, + connectionId: CONNECTION_ID, + onAccountAuthoritySignal: () => barrier.promise, + onFact: ({ value }) => { facts.push(value); }, + }); + await client.initialize(); + const params = commandApprovalParams(); + process.respond({ id: 70, method: "item/commandExecution/requestApproval", params }); + await waitFor(() => facts.some((fact) => fact.type === "interactionRequested")); + const requested = facts.find((fact) => fact.type === "interactionRequested"); + if (requested?.type !== "interactionRequested") throw new Error("Missing interaction."); + await client.resolveInteraction({ + provider: requested.provider, + kind: requested.kind, + deadlineAt: requested.deadlineAt ?? Number.NaN, + resolution: { kind: "approval_decision", decision: "once" }, + }); + expect(process.writes.filter((frame) => + (frame as Record).id === 70)).toHaveLength(1); + + process.stdoutQueue.push([ + JSON.stringify({ + method: "account/updated", + params: { authMode: "chatgpt", planType: "pro" }, + }), + JSON.stringify({ + id: 70, + method: "item/commandExecution/requestApproval", + params, + }), + JSON.stringify({ + method: "serverRequest/resolved", + params: { threadId: "thread-1", requestId: 70 }, + }), + "", + ].join("\n")); + await waitFor(() => facts.some((fact) => fact.type === "interactionResolved")); + barrier.resolve(undefined); + await Bun.sleep(2); + expect(process.writes.filter((frame) => + (frame as Record).id === 70)).toHaveLength(1); + expect(client.state).toBe("ready"); + await client.close(); + }); + test("binds feature and app discovery to the exact existing thread", async () => { const codexHome = "/tmp/hra-control-plane/profile-a/codex-home"; const process = new FakeProcess((message, target) => { @@ -3365,6 +3749,138 @@ describe("CodexAppServerClient", () => { await client.close(); }); + test("applies and verifies native approval authority when claiming a resumed thread", async () => { + const codexHome = "/tmp/hra-control-plane/profile-a/codex-home"; + const thread = { + id: "thread-adopted", + sessionId: "thread-adopted", + preview: "", + ephemeral: false, + historyMode: "paginated", + modelProvider: "openai", + createdAt: 1, + updatedAt: 1, + status: { type: "idle" }, + cwd: "/workspace/project", + name: null, + turns: [], + }; + const process = new FakeProcess((message, target) => { + if (message.method === "initialize") { + target.respond({ + id: message.id, + result: { + userAgent: "codex-cli/0.153.2", + codexHome, + platformFamily: "unix", + platformOs: "macos", + }, + }); + } else if (message.method === "thread/resume") { + target.respond({ + id: message.id, + result: { + thread, + cwd: "/workspace/project", + model: "gpt-5.6-sol", + modelProvider: "openai", + reasoningEffort: "max", + serviceTier: "default", + approvalPolicy: "on-request", + approvalsReviewer: "auto_review", + sandbox: { + type: "workspaceWrite", + writableRoots: ["/workspace/project"], + networkAccess: false, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false, + }, + activePermissionProfile: { id: ":workspace", extends: null }, + runtimeWorkspaceRoots: ["/workspace/project"], + }, + }); + } + }); + const client = createClient({ + process, + authority: { profileId: "profile-a", processGeneration: 1 }, + expectedCodexHome: codexHome, + isAuthorityCurrent: () => true, + }); + await client.initialize(); + await client.resumeThreadWithPolicy({ + threadId: "thread-adopted", + cwd: "/workspace/project", + preset: { + alias: "high", + model: "gpt-5.6-sol", + effort: "max", + serviceTier: null, + fast: false, + }, + policy: { + review: "auto_review", + permissionProfile: ":workspace", + writableRoots: ["/workspace/project"], + }, + }); + expect(process.writes.at(-1)).toEqual({ + id: 3, + method: "thread/resume", + params: { + threadId: "thread-adopted", + model: "gpt-5.6-sol", + serviceTier: null, + cwd: "/workspace/project", + permissions: ":workspace", + runtimeWorkspaceRoots: ["/workspace/project"], + approvalPolicy: "on-request", + approvalsReviewer: "auto_review", + config: { model_reasoning_effort: "max" }, + excludeTurns: true, + }, + }); + expect(JSON.stringify(process.writes.at(-1))).not.toContain("dynamicTools"); + await client.close(); + }); + + test("sends the exact pinned thread unsubscribe request and returns its closed status", async () => { + const codexHome = "/tmp/hra-control-plane/profile-a/codex-home"; + const process = new FakeProcess((message, target) => { + if (message.method === "initialize") { + target.respond({ + id: message.id, + result: { + userAgent: "codex-cli/0.153.2", + codexHome, + platformFamily: "unix", + platformOs: "macos", + }, + }); + } else if (message.method === "thread/unsubscribe") { + target.respond({ id: message.id, result: { status: "unsubscribed" } }); + } + }); + const client = createClient({ + process, + authority: { profileId: "profile-a", processGeneration: 1 }, + expectedCodexHome: codexHome, + isAuthorityCurrent: () => true, + }); + await client.initialize(); + + await expect(client.unsubscribeThread("thread-adopted")).resolves.toMatchObject({ + authority: { profileId: "profile-a", processGeneration: 1 }, + value: { status: "unsubscribed" }, + }); + expect(process.writes.at(-1)).toEqual({ + id: 3, + method: "thread/unsubscribe", + params: { threadId: "thread-adopted" }, + }); + await client.close(); + }); + test("bounds shutdown when TERM and stdout settlement are ignored", async () => { const codexHome = "/tmp/hra-control-plane/profile-a/codex-home"; const process = new FakeProcess( @@ -3415,4 +3931,47 @@ describe("CodexAppServerClient", () => { }); expect(diagnostics).toContain("Codex stdout did not settle after termination"); }); + + test("requires exact process-exit settlement and permits the exact close owner to retry", async () => { + const codexHome = "/tmp/hra-control-plane/profile-a/codex-home"; + const process = new FakeProcess( + (message, target) => { + if (message.method === "initialize") { + target.respond({ + id: message.id, + result: { + userAgent: "codex-cli/0.153.2", + codexHome, + platformFamily: "unix", + platformOs: "macos", + }, + }); + } + }, + { ignoreKill: true, ignoreTerm: true, leaveStreamsOpenAfterKill: true }, + ); + const diagnostics: string[] = []; + const client = createClient({ + process, + authority: { profileId: "profile-a", processGeneration: 3 }, + expectedCodexHome: codexHome, + isAuthorityCurrent: () => true, + onSafeDiagnostic: (message) => diagnostics.push(message), + shutdownTermGraceMs: 5, + shutdownSettlementMs: 5, + }); + await client.initialize(); + + await expect(Promise.all([client.close(), client.close()])).rejects.toMatchObject({ + code: "PROCESS_EXITED", + }); + expect(client.state).toBe("closing"); + expect(process.signals).toEqual(["SIGTERM", "SIGKILL"]); + expect(diagnostics).toContain("Codex process exit did not settle after termination"); + + process.settleExit(); + await expect(client.close()).resolves.toBeUndefined(); + expect(client.state).toBe("closed"); + expect(process.signals).toEqual(["SIGTERM", "SIGKILL", "SIGTERM"]); + }); }); diff --git a/src/codex/client.ts b/src/codex/client.ts index 08fa206..7b82e30 100644 --- a/src/codex/client.ts +++ b/src/codex/client.ts @@ -56,6 +56,7 @@ import { parseThreadItemsPage, parseThreadPage, parseThreadRead, + parseThreadUnsubscribe, parseThreadTurnsPage, parseTurnStart, providerRequestIdKey, @@ -89,6 +90,7 @@ import { type ResolvedPreset, type ThreadPage, type ThreadStartResult, + type ThreadUnsubscribeResult, type ThreadItemPage, type TurnPage, type TurnStartResult, @@ -117,12 +119,14 @@ const CAPABILITY_DISCOVERY_MAX_DEADLINE_MS = 40_000; interface PendingRequest { readonly id: number; readonly descriptor: CodexOperationDescriptor; + readonly bypassAccountAuthorityBarrier: boolean; readonly parseAndResolve: (value: unknown, authority: CodexAuthority) => void; readonly reject: (reason: unknown) => void; readonly timeout: ReturnType; readonly signal?: AbortSignal; readonly onAbort?: () => void; dispatched: boolean; + responseReceived: boolean; } type ServerRequestState = "pending" | "writing" | "responded" | "resolved" | "resolution_unknown"; @@ -140,6 +144,19 @@ interface PendingServerRequest { >; } +interface PendingFrameWrite { + readonly bytes: Uint8Array; + readonly bypassAccountAuthorityBarrier: boolean; + readonly beforeWrite?: () => void; + readonly beforeWriteAsync?: () => Promise; + readonly resolve: () => void; + readonly reject: (reason?: unknown) => void; +} + +interface AccountAuthorityWriteBarrier { + readonly task: Promise; +} + class CodexFrameRejectedBeforeWriteError extends Error { constructor(readonly rejection: CodexError) { super(rejection.message, { cause: rejection }); @@ -158,6 +175,14 @@ export interface CodexAppServerClientOptions { }>; readonly experimentalApi?: boolean; readonly isAuthorityCurrent: (authority: CodexAuthority) => boolean | Promise; + /** + * Synchronous edge raised before an account-change notification enters the + * asynchronous fact tail. Runtime owners use it to close admission until an + * authoritative account read has settled. + */ + readonly onAccountAuthoritySignal?: ( + authority: CodexAuthority, + ) => void | Promise; readonly onFact?: (fact: FencedCodexValue) => void | Promise; /** Local-only host service for the one conversation-bound dynamic tool. */ readonly onConversationAutomationToolCall?: ( @@ -221,6 +246,10 @@ export interface StartThreadInput { readonly policy: ThreadPolicy; } +export interface ResumeThreadInput extends StartThreadInput { + readonly threadId: string; +} + export interface StartTurnInput { readonly threadId: string; readonly clientMessageId: string; @@ -261,6 +290,9 @@ export class CodexAppServerClient { readonly #credentialStorePreflight: CodexAppServerClientOptions["credentialStorePreflight"]; readonly #experimentalApi: boolean; readonly #isAuthorityCurrent: CodexAppServerClientOptions["isAuthorityCurrent"]; + readonly #onAccountAuthoritySignal: + | CodexAppServerClientOptions["onAccountAuthoritySignal"] + | undefined; readonly #onFact: NonNullable; readonly #onConversationAutomationToolCall: | CodexAppServerClientOptions["onConversationAutomationToolCall"] @@ -280,8 +312,14 @@ export class CodexAppServerClient { readonly #serverRequests = new Map(); readonly #dynamicRequestDigests = new Map(); readonly #inboundDynamicRequests = new Set>(); + readonly #inboundServerRequests = new Set>(); + readonly #inboundResponseSettlements = new Set>(); #factTail: Promise = Promise.resolve(); #writeTail: Promise = Promise.resolve(); + readonly #writeQueue: PendingFrameWrite[] = []; + #writeDrainActive = false; + #writeBarrierWake: (() => void) | null = null; + #accountAuthorityWriteBarrier: AccountAuthorityWriteBarrier | null = null; #disconnectEmitted = false; #connectionAnnounced = false; #preReadyFactBytes = 0; @@ -311,6 +349,7 @@ export class CodexAppServerClient { }; this.#experimentalApi = options.experimentalApi ?? false; this.#isAuthorityCurrent = options.isAuthorityCurrent; + this.#onAccountAuthoritySignal = options.onAccountAuthoritySignal; this.#onFact = options.onFact ?? (() => undefined); if ( (options.onConversationAutomationToolCall === undefined) @@ -426,6 +465,7 @@ export class CodexAppServerClient { this.#preReadyFacts.length = 0; this.#preReadyFactBytes = 0; this.#state = "failed"; + this.#wakeWriteBarrier(); try { this.#process.terminate(); } catch (cleanupError: unknown) { @@ -443,6 +483,17 @@ export class CodexAppServerClient { return this.#closedRequest("account/read", { refreshToken }, parseAccountRead); } + /** Runtime-owner-only read used to settle an account-authority barrier. */ + async refreshAccountAuthority(): Promise> { + return this.#closedRequest( + "account/read", + { refreshToken: true }, + parseAccountRead, + undefined, + true, + ); + } + /** Rechecks project-layer effective custody before a project-scoped effect. */ async assertCredentialStores(cwd: string, signal?: AbortSignal): Promise { await this.#assertCredentialStores(cwd, "ready", signal); @@ -738,6 +789,48 @@ export class CodexAppServerClient { ); } + /** + * Resume an existing thread while replacing its next-turn policy with the + * same reviewed authority HRA applies at native thread creation. The pinned + * resume contract cannot add dynamic tools retroactively, so this method + * deliberately sends only fields the provider documents for resume. + */ + async resumeThreadWithPolicy( + input: ResumeThreadInput, + ): Promise> { + const cwd = canonicalAbsolute(input.cwd, "cwd"); + const policy = compileThreadPolicy(input.policy); + return this.#closedRequest( + "thread/resume", + { + threadId: boundedIdentifier(input.threadId, "thread id"), + model: input.preset.model, + serviceTier: input.preset.serviceTier, + cwd, + permissions: input.policy.permissionProfile, + runtimeWorkspaceRoots: policy.runtimeWorkspaceRoots, + approvalPolicy: "on-request", + approvalsReviewer: input.policy.review, + config: { model_reasoning_effort: input.preset.effort }, + excludeTurns: true, + }, + (value) => validateThreadStartResult( + parseThreadStart(value), + input, + policy.runtimeWorkspaceRoots, + cwd, + ), + ); + } + + async unsubscribeThread(threadId: string): Promise> { + return this.#closedRequest( + "thread/unsubscribe", + { threadId: boundedIdentifier(threadId, "thread id") }, + parseThreadUnsubscribe, + ); + } + async renameThread(threadId: string, name: string): Promise>>> { return this.#closedRequest( "thread/name/set", @@ -1063,13 +1156,26 @@ export class CodexAppServerClient { } async close(): Promise { - if (this.#closeTask === null) this.#closeTask = this.#close(); + if (this.#closeTask === null) { + const task = this.#close(); + this.#closeTask = task; + try { + await task; + } catch (error: unknown) { + // A failed close retains process custody. Let the exact owner retry the + // TERM/KILL/exit proof instead of caching a falsely completed release. + if (this.#closeTask === task) this.#closeTask = null; + throw error; + } + return; + } await this.#closeTask; } async #close(): Promise { if (this.#state === "closed") return; this.#state = "closing"; + this.#wakeWriteBarrier(); this.#failPending(new CodexError("PROCESS_EXITED", "Codex is shutting down")); this.#emitDisconnected("closed"); @@ -1089,7 +1195,17 @@ export class CodexAppServerClient { } const inboundDynamicRequests = Promise.all([...this.#inboundDynamicRequests]); - const [exitSettled, readSettled, factsSettled, writesSettled, inboundSettled] = await Promise.all([ + const inboundServerRequests = Promise.all([...this.#inboundServerRequests]); + const inboundResponseSettlements = Promise.all([...this.#inboundResponseSettlements]); + const [ + exitSettled, + readSettled, + factsSettled, + writesSettled, + inboundSettled, + serverRequestsSettled, + responsesSettled, + ] = await Promise.all([ resolvesWithin(this.#process.exited, this.#shutdownSettlementMs), this.#readTask === null ? Promise.resolve(true) @@ -1097,6 +1213,8 @@ export class CodexAppServerClient { settlesWithin(this.#factTail, this.#shutdownSettlementMs), settlesWithin(this.#writeTail, this.#shutdownSettlementMs), settlesWithin(inboundDynamicRequests, this.#shutdownSettlementMs), + settlesWithin(inboundServerRequests, this.#shutdownSettlementMs), + settlesWithin(inboundResponseSettlements, this.#shutdownSettlementMs), ]); if (!exitSettled) this.#onSafeDiagnostic("Codex process exit did not settle after termination"); if (!readSettled) this.#onSafeDiagnostic("Codex stdout did not settle after termination"); @@ -1105,6 +1223,18 @@ export class CodexAppServerClient { if (!inboundSettled) { this.#onSafeDiagnostic("HRA dynamic-tool handling did not settle after Codex termination"); } + if (!serverRequestsSettled) { + this.#onSafeDiagnostic("Codex server-request handling did not settle after termination"); + } + if (!responsesSettled) { + this.#onSafeDiagnostic("Codex response settlement did not finish after termination"); + } + if (!exitSettled) { + throw new CodexError( + "PROCESS_EXITED", + "Codex process exit could not be proven after force termination", + ); + } this.#state = "closed"; } @@ -1113,6 +1243,7 @@ export class CodexAppServerClient { params: unknown, parse: (value: unknown) => T, signal?: AbortSignal, + bypassAccountAuthorityBarrier = false, ): Promise> { if (this.#state !== "ready") { throw new CodexError("PROTOCOL_ERROR", "Codex app-server is not ready"); @@ -1124,7 +1255,14 @@ export class CodexAppServerClient { `${method} requires the pinned experimental API capability`, ); } - return this.#request(descriptor, params, parse, "ready", signal); + return this.#request( + descriptor, + params, + parse, + "ready", + signal, + bypassAccountAuthorityBarrier, + ); } async #request( @@ -1133,6 +1271,7 @@ export class CodexAppServerClient { parse: (value: unknown) => T, requestState: "initializing" | "preflighting" | "ready", signal?: AbortSignal, + bypassAccountAuthorityBarrier = false, ): Promise> { if (this.#state !== requestState) { throw new CodexError( @@ -1175,6 +1314,7 @@ export class CodexAppServerClient { exactPending = { id, descriptor, + bypassAccountAuthorityBarrier, parseAndResolve: (value, authority) => { resolvePromise({ authority, value: parse(value) }); }, @@ -1183,6 +1323,7 @@ export class CodexAppServerClient { ...(signal === undefined ? {} : { signal }), ...(onAbort === undefined ? {} : { onAbort }), dispatched: false, + responseReceived: false, }; this.#pending.set(id, exactPending); if (signal !== undefined && onAbort !== undefined) { @@ -1195,6 +1336,10 @@ export class CodexAppServerClient { const write = this.#writeFrame( { id, method: descriptor.method, params }, { + // The authoritative read is what resolves an account-change + // barrier. It must be able to pass queued writes that the same + // barrier is holding, or the read and write tails deadlock. + bypassAccountAuthorityBarrier, beforeWrite: () => { throwIfAborted(signal); if (exactPending === undefined || this.#pending.get(id) !== exactPending) { @@ -1314,12 +1459,16 @@ export class CodexAppServerClient { } async #handleParsedFact(fact: CodexFact): Promise { + const accountAuthoritySignaled = this.#signalAccountAuthority(fact); if (!(await this.#authorityIsCurrent())) return; if (fact.type === "serverRequestResolved") { await this.#handleServerRequestResolved(fact); return; } - void this.#enqueueFact({ ...fact, connectionId: this.#connectionId }); + void this.#enqueueFact( + { ...fact, connectionId: this.#connectionId }, + accountAuthoritySignaled, + ); } async #readLoop(): Promise { @@ -1331,6 +1480,7 @@ export class CodexAppServerClient { if (this.#state !== "closing" && this.#state !== "closed") { const error = new CodexError("PROCESS_EXITED", "Codex stdout reached EOF"); this.#state = "failed"; + this.#wakeWriteBarrier(); this.#failPending(error); this.#emitDisconnected("eof"); } @@ -1338,6 +1488,7 @@ export class CodexAppServerClient { this.#failPending(error); if (this.#state !== "closing" && this.#state !== "closed") { this.#state = "failed"; + this.#wakeWriteBarrier(); this.#emitDisconnected("protocol_fault"); } } @@ -1357,6 +1508,7 @@ export class CodexAppServerClient { const exitCode = await this.#process.exited.catch(() => -1); if (this.#state === "closing" || this.#state === "closed") return; this.#state = "failed"; + this.#wakeWriteBarrier(); this.#failPending( new CodexError("PROCESS_EXITED", `Codex exited with status ${String(exitCode)}`), ); @@ -1391,7 +1543,13 @@ export class CodexAppServerClient { ); return; } - await this.#handleServerRequest(message.id, method, message.params ?? {}); + if (this.#inboundServerRequests.size >= INBOUND_DYNAMIC_REQUEST_LIMIT) { + this.#quarantineConnection("Codex exceeded the bounded server-request handling limit"); + return; + } + this.#trackInboundServerRequest( + this.#handleServerRequest(message.id, method, message.params ?? {}), + ); return; } const fact = parseFact(method, message.params ?? {}); @@ -1427,14 +1585,47 @@ export class CodexAppServerClient { this.#quarantineConnection("Codex emitted a response before HRA dispatched its request"); return; } - this.#takePending(id); - if (!(await this.#authorityIsCurrent())) { - const stale = new CodexError("AUTHORITY_STALE", "Codex response belongs to a stale generation"); - pending.reject(pending.descriptor.lostResponse === "reconcile" - ? new IndeterminateCodexEffectError(pending.descriptor.method, id, stale) - : stale); + if (pending.responseReceived) return; + pending.responseReceived = true; + const deferForAccountAuthority = !pending.bypassAccountAuthorityBarrier + && this.#accountAuthorityWriteBarrier !== null; + const settlement = this.#settleResponse(message, pending, id); + if (deferForAccountAuthority) { + this.#trackInboundResponseSettlement(settlement); return; } + await settlement; + } + + async #settleResponse( + message: Record, + pending: PendingRequest, + id: number, + ): Promise { + if (!pending.bypassAccountAuthorityBarrier) { + while (this.#accountAuthorityWriteBarrier !== null) { + const accepted = await this.#awaitAccountAuthorityForResponse(pending, id); + if (!accepted) return; + } + } + let authorityIsCurrent = false; + try { + authorityIsCurrent = await this.#authorityIsCurrent(); + } catch (error: unknown) { + this.#rejectResponseForAuthority(pending, id, error); + return; + } + if (!authorityIsCurrent) { + this.#rejectResponseForAuthority(pending, id); + return; + } + if (!pending.bypassAccountAuthorityBarrier) { + while (this.#accountAuthorityWriteBarrier !== null) { + const accepted = await this.#awaitAccountAuthorityForResponse(pending, id); + if (!accepted) return; + } + } + if (this.#takePending(id) !== pending) return; if (message.error !== undefined) { const remote = record(message.error, "JSON-RPC error"); pending.reject(new CodexRemoteError(safeInteger(remote.code, "JSON-RPC error code"), "request failed")); @@ -1456,6 +1647,66 @@ export class CodexAppServerClient { } } + async #awaitAccountAuthorityForResponse( + pending: PendingRequest, + id: number, + ): Promise { + for (;;) { + const barrier = this.#accountAuthorityWriteBarrier; + if (barrier === null) return true; + try { + await barrier.task; + } catch (error: unknown) { + this.#rejectResponseForAuthority(pending, id, error); + return false; + } + if (this.#accountAuthorityWriteBarrier === barrier) { + this.#accountAuthorityWriteBarrier = null; + } + } + } + + #rejectResponseForAuthority( + pending: PendingRequest, + id: number, + cause?: unknown, + ): void { + if (this.#takePending(id) !== pending) return; + const stale = cause === undefined + ? new CodexError( + "AUTHORITY_STALE", + "Codex response was not admitted under refreshed account authority", + ) + : new CodexError( + "AUTHORITY_STALE", + "Codex response was not admitted under refreshed account authority", + { cause }, + ); + pending.reject( + pending.descriptor.lostResponse === "reconcile" && pending.dispatched + ? new IndeterminateCodexEffectError(pending.descriptor.method, id, stale) + : stale, + ); + } + + #trackInboundResponseSettlement(task: Promise): void { + const tracked = task.then( + () => undefined, + (error: unknown) => { + if (this.#state !== "ready") return; + this.#onSafeDiagnostic( + error instanceof Error + ? `Codex response settlement failed: ${error.name}` + : "Codex response settlement failed", + ); + }, + ); + this.#inboundResponseSettlements.add(tracked); + void tracked.then(() => { + this.#inboundResponseSettlements.delete(tracked); + }); + } + async #handleConversationAutomationToolCall( idValue: unknown, params: unknown, @@ -1583,6 +1834,24 @@ export class CodexAppServerClient { }); } + #trackInboundServerRequest(task: Promise): void { + const tracked = task.then( + () => undefined, + () => { + if (this.#state !== "ready") return; + try { + this.#quarantineConnection("Codex server-request handling failed"); + } catch { + this.#onSafeDiagnostic("HRA could not terminate a failed server-request connection"); + } + }, + ); + this.#inboundServerRequests.add(tracked); + void tracked.then(() => { + this.#inboundServerRequests.delete(tracked); + }); + } + async #conversationAutomationAuthorityIsCurrent(): Promise { let current = false; try { @@ -1678,7 +1947,30 @@ export class CodexAppServerClient { return; } if (existing.state === "responded" && existing.responseFrame !== undefined) { - await this.#writeFrame(existing.responseFrame); + const responseFrame = existing.responseFrame; + try { + await this.#writeFrame(responseFrame, { + beforeWrite: () => { + if ( + this.#state !== "ready" + || this.#serverRequests.get(key) !== existing + || existing.state !== "responded" + || existing.responseFrame !== responseFrame + ) { + throw new CodexError( + "AUTHORITY_STALE", + "the cached interaction response became stale before replay", + ); + } + }, + }); + } catch (error: unknown) { + if ( + error instanceof CodexFrameRejectedBeforeWriteError + && error.rejection.code === "AUTHORITY_STALE" + ) return; + throw error; + } } else if (existing.state === "resolved") { await this.#writeFrame({ id: rawProviderRequestId(requestId), @@ -1760,7 +2052,8 @@ export class CodexAppServerClient { else void pending.admissionTask.then(emitResolved, () => undefined); } - #enqueueFact(fact: CodexFact): Promise { + #enqueueFact(fact: CodexFact, accountAuthoritySignaled = false): Promise { + if (!accountAuthoritySignaled) this.#signalAccountAuthority(fact); const task = this.#factTail.then(async () => { if (!(await this.#authorityIsCurrent())) return; await this.#onFact({ authority: this.#authority, value: fact }); @@ -1775,6 +2068,29 @@ export class CodexAppServerClient { return task; } + #signalAccountAuthority(fact: CodexFact): boolean { + if ( + fact.type !== "accountUpdated" + && (fact.type !== "loginCompleted" || !fact.success) + ) return false; + // This callback is deliberately invoked in the same run-to-completion + // turn that parses a ready notification, before any authority lookup or + // fact-tail await. Runtime owners can therefore close both external + // admission and this client's outbound write boundary immediately. + const task = this.#onAccountAuthoritySignal?.(this.#authority); + if (task !== undefined) { + const barrier: AccountAuthorityWriteBarrier = { + task: Promise.resolve(task), + }; + // The adapter also observes this rejection. The local handler prevents + // an unhandled-rejection report when no outbound write is waiting yet. + void barrier.task.catch(() => undefined); + this.#accountAuthorityWriteBarrier = barrier; + this.#wakeWriteBarrier(); + } + return true; + } + #emitDisconnected(reason: "eof" | "process_exit" | "closed" | "protocol_fault"): void { if (!this.#connectionAnnounced || this.#disconnectEmitted) return; this.#disconnectEmitted = true; @@ -1788,6 +2104,7 @@ export class CodexAppServerClient { #quarantineConnection(message: string): void { if (this.#state === "closing" || this.#state === "closed" || this.#state === "failed") return; this.#state = "failed"; + this.#wakeWriteBarrier(); this.#onSafeDiagnostic(message); this.#failPending(new CodexError("PROTOCOL_ERROR", "Codex provider connection was quarantined")); this.#emitDisconnected("protocol_fault"); @@ -1797,6 +2114,7 @@ export class CodexAppServerClient { async #writeFrame( value: unknown, options: Readonly<{ + bypassAccountAuthorityBarrier?: boolean; beforeWrite?: () => void; beforeWriteAsync?: () => Promise; }> = {}, @@ -1806,24 +2124,160 @@ export class CodexAppServerClient { throw new CodexError("PROTOCOL_LIMIT", "outbound Codex frame exceeded its byte limit"); } const bytes = this.#encoder.encode(`${serialized}\n`); - const write = this.#writeTail.then(async () => { - if (options.beforeWriteAsync !== undefined || options.beforeWrite !== undefined) { + let resolveWrite!: () => void; + let rejectWrite!: (reason?: unknown) => void; + const write = new Promise((resolve, reject) => { + resolveWrite = resolve; + rejectWrite = reject; + }); + const settled = write.catch(() => undefined); + this.#writeTail = Promise.all([this.#writeTail, settled]).then(() => undefined); + this.#writeQueue.push({ + bytes, + bypassAccountAuthorityBarrier: + options.bypassAccountAuthorityBarrier ?? false, + ...(options.beforeWrite === undefined ? {} : { beforeWrite: options.beforeWrite }), + ...(options.beforeWriteAsync === undefined + ? {} + : { beforeWriteAsync: options.beforeWriteAsync }), + resolve: resolveWrite, + reject: rejectWrite, + }); + this.#wakeWriteBarrier(); + this.#startWriteDrain(); + await write; + } + + #startWriteDrain(): void { + if (this.#writeDrainActive) return; + this.#writeDrainActive = true; + void this.#drainWriteQueue(); + } + + async #drainWriteQueue(): Promise { + try { + while (this.#writeQueue.length > 0) { + let index = 0; + let rejectedBarrier: AccountAuthorityWriteBarrier | null = null; + if ( + !this.#writeStateIsTerminal() + && this.#accountAuthorityWriteBarrier !== null + && this.#writeQueue[0]?.bypassAccountAuthorityBarrier !== true + ) { + index = this.#writeQueue.findIndex( + (entry) => entry.bypassAccountAuthorityBarrier, + ); + if (index < 0) { + rejectedBarrier = await this.#waitForAccountAuthorityOrPriorityWrite(); + if (rejectedBarrier === null) continue; + index = 0; + } + } + const entry = this.#writeQueue.splice(index, 1)[0]; + if (entry === undefined) continue; try { - if (options.beforeWriteAsync !== undefined) { - await options.beforeWriteAsync(); + const written = await this.#performFrameWrite(entry, rejectedBarrier); + if (!written) { + this.#writeQueue.unshift(entry); + continue; } - options.beforeWrite?.(); + entry.resolve(); } catch (error: unknown) { - if (error instanceof CodexError) { - throw new CodexFrameRejectedBeforeWriteError(error); - } - throw error; + entry.reject(error); } } - await this.#process.write(bytes); - }); - this.#writeTail = write.catch(() => undefined); - await write; + } finally { + this.#writeDrainActive = false; + if (this.#writeQueue.length > 0) this.#startWriteDrain(); + } + } + + async #waitForAccountAuthorityOrPriorityWrite(): Promise { + const barrier = this.#accountAuthorityWriteBarrier; + if (barrier === null) return null; + let wake!: () => void; + const priorityWrite = new Promise((resolve) => { wake = resolve; }); + this.#writeBarrierWake = wake; + let winner: "barrier" | "priority"; + try { + winner = await Promise.race([ + barrier.task.then( + () => "barrier" as const, + () => "barrier" as const, + ), + priorityWrite.then(() => "priority" as const), + ]); + } finally { + if (this.#writeBarrierWake === wake) this.#writeBarrierWake = null; + } + if (winner === "priority") return null; + if (this.#accountAuthorityWriteBarrier === barrier) { + try { + await barrier.task; + if (this.#accountAuthorityWriteBarrier === barrier) { + this.#accountAuthorityWriteBarrier = null; + } + } catch { + // Keep a rejected barrier installed. Each non-bypass write will be + // rejected before dispatch while the owning runtime retires the + // client; an account/read remains available only to break a refresh + // deadlock. + return barrier; + } + } + return null; + } + + async #performFrameWrite( + entry: PendingFrameWrite, + rejectedBarrier: AccountAuthorityWriteBarrier | null, + ): Promise { + try { + if (this.#writeStateIsTerminal()) { + throw new CodexError("PROCESS_EXITED", "Codex is shutting down"); + } + if (rejectedBarrier !== null && !entry.bypassAccountAuthorityBarrier) { + try { + await rejectedBarrier.task; + } catch (error: unknown) { + throw new CodexError( + "AUTHORITY_STALE", + "Codex account authority refresh failed before the provider write", + { cause: error }, + ); + } + } + if (entry.beforeWriteAsync !== undefined) await entry.beforeWriteAsync(); + if (this.#writeStateIsTerminal()) { + throw new CodexError("PROCESS_EXITED", "Codex is shutting down"); + } + if (!entry.bypassAccountAuthorityBarrier) { + // A signal can arrive while an asynchronous pre-write authority check + // is pending. Yield this normal frame back to the queue instead of + // waiting in-place, so the barrier's refresh read can leapfrog it. + if (this.#accountAuthorityWriteBarrier !== null) return false; + } + entry.beforeWrite?.(); + } catch (error: unknown) { + if (error instanceof CodexError) { + throw new CodexFrameRejectedBeforeWriteError(error); + } + throw error; + } + await this.#process.write(entry.bytes); + return true; + } + + #writeStateIsTerminal(): boolean { + return this.#state === "closing" + || this.#state === "closed" + || this.#state === "failed"; + } + + #wakeWriteBarrier(): void { + const wake = this.#writeBarrierWake; + this.#writeBarrierWake = null; + wake?.(); } #allocateRequestId(): number { @@ -1993,7 +2447,7 @@ function validateThreadStartResult( ) { throw new CodexError( "PROTOCOL_ERROR", - "Codex did not apply the requested model, permissions, or workspace policy to the new thread", + "Codex did not apply the requested model, permissions, or workspace policy to the thread", ); } return value; diff --git a/src/codex/index.ts b/src/codex/index.ts index 9b71c5e..6752604 100644 --- a/src/codex/index.ts +++ b/src/codex/index.ts @@ -1,14 +1,23 @@ -export { parseCodexAutomationToml, readCodexAutomations } from "./automations.ts"; +export { + parseCodexAutomationToml, + readCodexAutomationAuthority, + readCodexAutomations, +} from "./automations.ts"; export type { CodexAutomation, + CodexAutomationAuthorityEntry, + CodexAutomationAuthorityRequest, + CodexAutomationAuthorityScan, CodexAutomationDiagnostic, CodexAutomationScan, + ReadCodexAutomationAuthorityInput, ReadCodexAutomationsInput, } from "./automations.ts"; export { CodexAppServerClient } from "./client.ts"; export type { CodexAppServerClientOptions, DiscoverCapabilitiesOptions, + ResumeThreadInput, StartThreadInput, StartTurnInput, ThreadListOptions, @@ -88,6 +97,7 @@ export type { ResolvedPreset, ThreadPage, ThreadStartResult, + ThreadUnsubscribeResult, ThreadItemPage, TurnPage, TurnStartResult, diff --git a/src/codex/protocol.test.ts b/src/codex/protocol.test.ts index 47f54a9..5b6416f 100644 --- a/src/codex/protocol.test.ts +++ b/src/codex/protocol.test.ts @@ -41,6 +41,8 @@ import { parseRateLimitResetCreditConsumption, parseThreadItemsPage, parseThreadMetadataRead, + parseThreadPage, + parseThreadUnsubscribe, parseThreadTurnsPage, resolvePreset, safeLiveAcceptanceCommandDigest, @@ -201,6 +203,21 @@ describe("pinned server requests and safe notifications", () => { }); }); + test("declares and parses the closed pinned thread unsubscribe operation", () => { + expect(OPERATIONS["thread/unsubscribe"]).toEqual({ + method: "thread/unsubscribe", + effect: "thread-mutation", + deadlineMs: 15_000, + lostResponse: "reconcile", + experimental: false, + }); + for (const status of ["notLoaded", "notSubscribed", "unsubscribed"] as const) { + expect(parseThreadUnsubscribe({ status })).toEqual({ status }); + } + expect(() => parseThreadUnsubscribe({ status: "stillSubscribed" })).toThrow(CodexError); + expect(() => parseThreadUnsubscribe({})).toThrow(CodexError); + }); + test("projects only the safe reset-credit count from account rate limits", () => { const resetCreditId = "RESET_CREDIT_ID_SENTINEL"; const rateLimits = { @@ -1740,6 +1757,42 @@ describe("runtime capability resolution", () => { }); }); + test("normalizes pinned Codex thread epoch seconds to milliseconds", () => { + const createdAtSeconds = 1_900_000_000; + const updatedAtSeconds = 1_900_000_123; + const providerThread = { + id: "thread-1", + sessionId: "thread-1", + preview: "hello", + ephemeral: false, + historyMode: "paginated", + modelProvider: "openai", + createdAt: createdAtSeconds, + updatedAt: updatedAtSeconds, + status: { type: "idle" }, + cwd: "/workspace", + name: null, + turns: [], + }; + const page = parseThreadPage({ + data: [providerThread], + nextCursor: null, + backwardsCursor: null, + }); + + expect(page.data[0]).toMatchObject({ + createdAt: createdAtSeconds * 1_000, + updatedAt: updatedAtSeconds * 1_000, + }); + for (const invalidCreatedAt of [1.5, Math.floor(Number.MAX_SAFE_INTEGER / 1_000) + 1]) { + expect(() => parseThreadPage({ + data: [{ ...providerThread, createdAt: invalidCreatedAt }], + nextCursor: null, + backwardsCursor: null, + })).toThrow(CodexError); + } + }); + test("rejects provider turns at the metadata-only thread boundary", () => { expect(() => parseThreadMetadataRead({ thread: { turns: [{}] } })).toThrow(CodexError); }); diff --git a/src/codex/protocol.ts b/src/codex/protocol.ts index c88d913..6761d1a 100644 --- a/src/codex/protocol.ts +++ b/src/codex/protocol.ts @@ -435,6 +435,7 @@ export type CodexMethod = | "thread/read" | "thread/resume" | "thread/start" + | "thread/unsubscribe" | "thread/turns/list" | "turn/interrupt" | "turn/start" @@ -482,6 +483,7 @@ export const OPERATIONS: Readonly> "thread/turns/list": operation("thread/turns/list", "read", 20_000, "retry-read", true), "thread/start": operation("thread/start", "thread-mutation", 30_000, "reconcile"), "thread/resume": operation("thread/resume", "thread-mutation", 30_000, "reconcile"), + "thread/unsubscribe": operation("thread/unsubscribe", "thread-mutation", 15_000, "reconcile"), "thread/name/set": operation("thread/name/set", "thread-mutation", 15_000, "reconcile"), "turn/start": operation("turn/start", "turn-mutation", 30_000, "reconcile"), "turn/steer": operation("turn/steer", "turn-mutation", 20_000, "reconcile"), @@ -1554,6 +1556,22 @@ export function parseThreadMutation(value: unknown): CodexThread { return parseThread(record(value, "thread mutation result").thread, 0); } +export type ThreadUnsubscribeResult = Readonly<{ + status: "notLoaded" | "notSubscribed" | "unsubscribed"; +}>; + +/** Exact pinned (`CODEX_PIN`) `thread/unsubscribe` response. */ +export function parseThreadUnsubscribe(value: unknown): ThreadUnsubscribeResult { + const root = record(value, "thread/unsubscribe result"); + return { + status: oneOf( + root.status, + "thread/unsubscribe status", + ["notLoaded", "notSubscribed", "unsubscribed"] as const, + ), + }; +} + /** Exact pinned (`CODEX_PIN`) `thread/start` response, including effective policy. */ export function parseThreadStart(value: unknown): ThreadStartResult { const root = record(value, "thread/start result"); @@ -1655,8 +1673,8 @@ function parseThread(value: unknown, index: number): CodexThread { ["legacy", "paginated"] as const, ), modelProvider: identifier(root.modelProvider, "thread.modelProvider"), - createdAt: nonnegativeNumber(root.createdAt, "thread.createdAt"), - updatedAt: nonnegativeNumber(root.updatedAt, "thread.updatedAt"), + createdAt: unixSecondsToMilliseconds(root.createdAt, "thread.createdAt"), + updatedAt: unixSecondsToMilliseconds(root.updatedAt, "thread.updatedAt"), status: parseThreadStatus(root.status), cwd: string(root.cwd, "thread.cwd", { min: 1, max: 16_384 }), name: nullableString(root.name, "thread.name", 1_024), @@ -3458,6 +3476,15 @@ function nonnegativeNumber(value: unknown, label: string): number { return parsed; } +function unixSecondsToMilliseconds(value: unknown, label: string): number { + const seconds = nonnegativeInteger(value, label); + const milliseconds = seconds * 1_000; + if (!Number.isSafeInteger(milliseconds)) { + throw protocol(`${label} exceeds the safe Unix-millisecond range`); + } + return milliseconds; +} + export function validateAuthority(authority: CodexAuthority): CodexAuthority { const profileId = identifier(authority.profileId, "profile id"); if (!Number.isSafeInteger(authority.processGeneration) || authority.processGeneration < 1) { diff --git a/src/codex/runtime.test.ts b/src/codex/runtime.test.ts index af33ed3..3e6dabd 100644 --- a/src/codex/runtime.test.ts +++ b/src/codex/runtime.test.ts @@ -189,6 +189,69 @@ describe("pinned Codex runtime", () => { expect(child.signals).toEqual(["SIGTERM"]); }); + test("forwards the synchronous account-authority signal to the client", async () => { + const packageJsonPath = await fakePackage("0.153.2"); + const codexHome = "/tmp/hra-control-plane/profile-a/codex-home"; + const events: string[] = []; + const child = new TrackedProcess({ + onWrite: (message, target) => { + if (message.method === "initialize") { + target.respond({ + id: message.id, + result: { + userAgent: "codex-cli/0.153.2", + codexHome, + platformFamily: "unix", + platformOs: "macos", + }, + }); + } else if (message.method === "config/read") { + target.respond({ + id: message.id, + result: { + config: { + cli_auth_credentials_store: "file", + mcp_oauth_credentials_store: "file", + }, + origins: {}, + }, + }); + } + }, + }); + const client = await launchPinnedCodexAppServer({ + packageJsonPath, + bunExecutable: process.execPath, + processFactory: () => child, + authority: { profileId: "profile-a", processGeneration: 1 }, + credentialStorePreflight: CREDENTIAL_STORE_PREFLIGHT, + expectedCodexHome: codexHome, + isAuthorityCurrent: () => true, + onAccountAuthoritySignal: () => { events.push("signal"); }, + onFact: ({ value }) => { events.push(`fact:${value.type}`); }, + }); + child.respond({ + method: "account/updated", + params: { authMode: "chatgpt", planType: "pro" }, + }); + child.respond({ + method: "account/login/completed", + params: { loginId: "login-exact", success: true }, + }); + for (let attempt = 0; attempt < 200 && events.length < 5; attempt += 1) { + await Bun.sleep(1); + } + + expect(events.filter((event) => event === "signal")).toHaveLength(2); + const firstAccountFact = events.indexOf("fact:accountUpdated"); + const loginFact = events.indexOf("fact:loginCompleted"); + expect(events.slice(0, firstAccountFact).filter((event) => event === "signal")) + .not.toHaveLength(0); + expect(events.slice(0, loginFact).filter((event) => event === "signal")) + .toHaveLength(2); + await client.close(); + }); + test("reaps the exact spawned process when client construction fails", async () => { const packageJsonPath = await fakePackage("0.153.2"); const child = new TrackedProcess({ ignoreTerm: true }); diff --git a/src/codex/runtime.ts b/src/codex/runtime.ts index f191eec..8ee350c 100644 --- a/src/codex/runtime.ts +++ b/src/codex/runtime.ts @@ -235,6 +235,9 @@ export async function launchPinnedCodexAppServer( onConversationAutomationToolResponseWritten: options.onConversationAutomationToolResponseWritten, }), + ...(options.onAccountAuthoritySignal === undefined + ? {} + : { onAccountAuthoritySignal: options.onAccountAuthoritySignal }), ...(options.onFact === undefined ? {} : { onFact: options.onFact }), ...(options.onSafeDiagnostic === undefined ? {} diff --git a/src/daemon/claude-runtime-adapter.test.ts b/src/daemon/claude-runtime-adapter.test.ts index 364d4dd..8944773 100644 --- a/src/daemon/claude-runtime-adapter.test.ts +++ b/src/daemon/claude-runtime-adapter.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { lstatSync } from "node:fs"; import { chmod, lstat, mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -7,20 +7,34 @@ import { join } from "node:path"; import type { ClaudeAuthStatusReader, ClaudeProcess, + ClaudeProcessIdentity, PinnedClaudeRuntime, } from "../claude/index"; +import { ClaudeDeltaAssembler } from "../claude/assembler"; import { CLAUDE_PIN, CLAUDE_PIN_EFFORT, CLAUDE_PIN_MODEL } from "../claude/pin"; -import { PresetProviderMismatchError } from "../domain/presets"; +import { PresetProviderMismatchError, presetRequirements } from "../domain/presets"; import { effectiveClaudeRuntimeProfileSchema } from "../domain/runtime-profile"; import { ensurePrivateDirectory } from "../storage/paths"; import { PinnedClaudeRuntimeManager, + type ClaudeProcessFactory, type ClaudeSessionFact, } from "./claude-runtime-adapter"; -import type { ProfileAuthority } from "./ports"; +import { + ClaudeProcessExitUnprovenError, + ClaudeSessionObservationError, + type ProfileAuthority, +} from "./ports"; const CONFIG_DIR = "/var/hra/profiles/acct/claude"; const PROJECT_ROOT = "/var/hra/projects/demo"; +const ADOPTED_PROVIDER_THREAD_ID = "726b1b3d-ed97-4b55-9904-e58fa7d7eb45"; +const ADOPTED_TITLE = "Existing Claude conversation"; +const PROCESS_IDENTITY: ClaudeProcessIdentity = Object.freeze({ + pid: 8_123, + pidDomain: "darwin", + procStart: "Fri Sep 4 12:00:00 2026", +}); const authority: ProfileAuthority = { codexHome: "/var/hra/profiles/acct/codex", @@ -31,6 +45,11 @@ const authority: ProfileAuthority = { class FakeClaudeProcess implements ClaudeProcess { readonly written: string[] = []; + readonly signals: string[] = []; + terminated = false; + readonly identity: Promise; + readonly #ignoreTerm: boolean; + readonly #ignoreKill: boolean; #push: ((chunk: Uint8Array) => void) | undefined; #finish: (() => void) | undefined; #resolveExit: ((code: number) => void) | undefined; @@ -38,7 +57,14 @@ class FakeClaudeProcess implements ClaudeProcess { readonly stdout: AsyncIterable; readonly stderr: AsyncIterable = { async *[Symbol.asyncIterator]() { /* silent */ } }; - constructor() { + constructor(options: Readonly<{ + identity?: Promise; + ignoreKill?: boolean; + ignoreTerm?: boolean; + }> = {}) { + this.identity = options.identity ?? Promise.resolve(PROCESS_IDENTITY); + this.#ignoreKill = options.ignoreKill ?? false; + this.#ignoreTerm = options.ignoreTerm ?? false; this.exited = new Promise((resolve) => { this.#resolveExit = resolve; }); const queue: Uint8Array[] = []; let waiter: (() => void) | undefined; @@ -66,11 +92,22 @@ class FakeClaudeProcess implements ClaudeProcess { } terminate(): void { + this.terminated = true; + this.signals.push("SIGTERM"); + if (this.#ignoreTerm) return; + this.end(); + } + + end(): void { this.#finish?.(); this.#resolveExit?.(0); } - forceTerminate(): void { this.terminate(); } + forceTerminate(): void { + this.terminated = true; + this.signals.push("SIGKILL"); + if (!this.#ignoreKill) this.end(); + } } const runtime: PinnedClaudeRuntime = { @@ -86,18 +123,38 @@ const settle = async (): Promise => { await new Promise((resolve) => { setTimeout(resolve, 1); }); }; +type InitializationOverride = Readonly<{ + model?: string; + permissionMode?: string; + providerThreadId?: string; + version?: string; +}>; + const harness = (options: { clientShutdownSettlementMs?: number; clientShutdownTermGraceMs?: number; configDirFor?: ConstructorParameters[0]["configDirFor"]; - isCurrent?: (authority: ProfileAuthority) => boolean; + configHome?: "isolated" | "personal"; + exitAfterInitialization?: boolean; + initialization?: InitializationOverride | "silent"; + initializationTimeoutMs?: number; + isCurrent?: ConstructorParameters[0]["isCurrent"]; + onFact?: ( + authority: ProfileAuthority, + fact: ClaudeSessionFact, + ) => void | Promise; + processIdentity?: ClaudeProcessIdentity | "reject"; + processIgnoresKill?: boolean; + processIgnoresTerm?: boolean; processFactory?: ConstructorParameters[0]["processFactory"]; readAuthStatus?: ClaudeAuthStatusReader; resolveRuntime?: ConstructorParameters[0]["resolveRuntime"]; } = {}) => { const facts: ClaudeSessionFact[] = []; const processes: FakeClaudeProcess[] = []; + const launches: Parameters[0][] = []; const manager = new PinnedClaudeRuntimeManager({ + configHome: options.configHome ?? "isolated", configDirFor: options.configDirFor ?? (() => CONFIG_DIR), ...(options.clientShutdownSettlementMs === undefined ? {} @@ -105,18 +162,55 @@ const harness = (options: { ...(options.clientShutdownTermGraceMs === undefined ? {} : { clientShutdownTermGraceMs: options.clientShutdownTermGraceMs }), + ...(options.initializationTimeoutMs === undefined + ? {} + : { initializationTimeoutMs: options.initializationTimeoutMs }), isCurrent: options.isCurrent ?? (() => true), now: () => 1_700_000_000_000, - observer: { fact: (_authority, fact) => { facts.push(fact); } }, - processFactory: options.processFactory ?? (() => { - const process = new FakeClaudeProcess(); + observer: { + fact: (factAuthority, fact) => { + facts.push(fact); + return options.onFact?.(factAuthority, fact); + }, + }, + processFactory: (launch) => { + launches.push(launch); + const process = options.processFactory?.(launch) ?? new FakeClaudeProcess({ + identity: options.processIdentity === "reject" + ? Promise.reject(new Error("identity unavailable")) + : Promise.resolve(options.processIdentity ?? PROCESS_IDENTITY), + ...(options.processIgnoresKill === undefined + ? {} + : { ignoreKill: options.processIgnoresKill }), + ...(options.processIgnoresTerm === undefined + ? {} + : { ignoreTerm: options.processIgnoresTerm }), + }); + if (!(process instanceof FakeClaudeProcess)) return process; processes.push(process); + if (options.initialization !== "silent") { + const override = options.initialization ?? {}; + const requestedId = launch.argv.at(-1); + if (requestedId === undefined) throw new Error("expected a session-bound Claude argv"); + queueMicrotask(() => { + process.emit({ + claude_code_version: override.version ?? CLAUDE_PIN, + model: override.model ?? CLAUDE_PIN_MODEL, + permissionMode: override.permissionMode ?? "default", + session_id: override.providerThreadId ?? requestedId, + subtype: "init", + tools: ["Bash"], + type: "system", + }); + if (options.exitAfterInitialization === true) process.end(); + }); + } return process; - }), + }, ...(options.readAuthStatus === undefined ? {} : { readAuthStatus: options.readAuthStatus }), resolveRuntime: options.resolveRuntime ?? (async () => runtime), }); - return { facts, manager, processes }; + return { facts, launches, manager, processes }; }; const signal = (): AbortSignal => new AbortController().signal; @@ -128,6 +222,7 @@ const startSession = async ( authority, fast: false, preset: "fable-max", + requirement: presetRequirements["fable-max"], projectRoot: PROJECT_ROOT, signal: signal(), }); @@ -144,6 +239,7 @@ const startTurn = async ( authority, fast: false, preset: "fable-max", + requirement: presetRequirements["fable-max"], projectRoot: PROJECT_ROOT, providerThreadId, signal: signal(), @@ -191,6 +287,7 @@ describe("pinned Claude runtime manager", () => { authority, fast: false, preset: "fable-max" as const, + requirement: presetRequirements["fable-max"], projectRoot: PROJECT_ROOT, signal: controller.signal, }; @@ -268,6 +365,7 @@ describe("pinned Claude runtime manager", () => { authority, fast: false, preset: "fable-max", + requirement: presetRequirements["fable-max"], projectRoot: PROJECT_ROOT, signal: signal(), })).rejects.toThrow(/regular directory|symbolic link/u); @@ -310,6 +408,7 @@ describe("pinned Claude runtime manager", () => { authority, fast: false, preset: "fable-max", + requirement: presetRequirements["fable-max"], projectRoot: PROJECT_ROOT, signal: signal(), }); @@ -346,6 +445,7 @@ describe("pinned Claude runtime manager", () => { authority, fast: false, preset: "fable-max", + requirement: presetRequirements["fable-max"], projectRoot: PROJECT_ROOT, signal: signal(), })).rejects.toThrow(); @@ -391,6 +491,7 @@ describe("pinned Claude runtime manager", () => { authority, fast: false, preset: "fable-max", + requirement: presetRequirements["fable-max"], projectRoot: PROJECT_ROOT, signal: signal(), }); @@ -419,6 +520,7 @@ describe("pinned Claude runtime manager", () => { authority, fast: false, preset: "fable-max", + requirement: presetRequirements["fable-max"], projectRoot: PROJECT_ROOT, signal: signal(), }); @@ -459,6 +561,7 @@ describe("pinned Claude runtime manager", () => { authority, fast: false, preset: "fable-max", + requirement: presetRequirements["fable-max"], projectRoot: PROJECT_ROOT, signal: signal(), }); @@ -509,6 +612,7 @@ describe("pinned Claude runtime manager", () => { authority: alternateAuthority, fast: false, preset: "fable-max", + requirement: presetRequirements["fable-max"], projectRoot: PROJECT_ROOT, signal: signal(), }); @@ -516,12 +620,13 @@ describe("pinned Claude runtime manager", () => { authority, fast: false, preset: "fable-max", + requirement: presetRequirements["fable-max"], projectRoot: PROJECT_ROOT, signal: signal(), }); await expect(manager.startSession({ authority, review, signal: signal() })) - .rejects.toThrow("child cleanup was incomplete"); + .rejects.toBeInstanceOf(ClaudeProcessExitUnprovenError); expect(launches).toBe(1); expect(terminations).toEqual(["terminate", "force"]); @@ -529,6 +634,7 @@ describe("pinned Claude runtime manager", () => { authority: alternateAuthority, fast: false, preset: "fable-max", + requirement: presetRequirements["fable-max"], projectRoot: PROJECT_ROOT, signal: signal(), })).rejects.toThrow("still unjoined"); @@ -702,6 +808,7 @@ describe("pinned Claude runtime manager", () => { authority, fast: false, preset: "fable-max", + requirement: presetRequirements["fable-max"], projectRoot: PROJECT_ROOT, signal: signal(), }); @@ -713,19 +820,20 @@ describe("pinned Claude runtime manager", () => { }); test("reviews, starts, runs, and completes one full turn", async () => { - const { facts, manager, processes } = harness(); + const { facts, launches, manager, processes } = harness(); const review = await manager.reviewSessionStart({ authority, fast: false, preset: "fable-max", + requirement: presetRequirements["fable-max"], projectRoot: PROJECT_ROOT, signal: signal(), }); expect(review.kind).toBe("session_start"); expect(effectiveClaudeRuntimeProfileSchema.parse(review.effectiveRuntimeProfile)).toEqual({ claudeVersion: CLAUDE_PIN, + configHome: "isolated", inputFormat: "stream-json", - isolatedConfigDir: true, model: CLAUDE_PIN_MODEL, observedAt: 1_700_000_000_000, outputFormat: "stream-json", @@ -740,6 +848,13 @@ describe("pinned Claude runtime manager", () => { const started = await manager.startSession({ authority, review, signal: signal() }); expect(started.status).toBe("idle"); expect(started.projectRoot).toBe(PROJECT_ROOT); + expect(launches[0]?.launch).toBe("create"); + expect(launches[0]?.argv.slice(-2)).toEqual(["--session-id", started.providerThreadId]); + await expect(manager.readSessionProcessIdentity({ + authority, + providerThreadId: started.providerThreadId, + signal: signal(), + })).resolves.toEqual(PROCESS_IDENTITY); const turnId = await startTurn(manager, started.providerThreadId, "say ok"); const process = processes[0]; @@ -756,15 +871,6 @@ describe("pinned Claude runtime manager", () => { })).activeTurnId).toBe(turnId); process.emit( - { - claude_code_version: CLAUDE_PIN, - model: CLAUDE_PIN_MODEL, - permissionMode: "default", - session_id: "sess", - subtype: "init", - tools: ["Bash"], - type: "system", - }, { message: { content: [{ text: "ok", type: "text" }], @@ -774,7 +880,7 @@ describe("pinned Claude runtime manager", () => { type: "message", }, parent_tool_use_id: null, - session_id: "sess", + session_id: started.providerThreadId, type: "assistant", }, { @@ -782,7 +888,7 @@ describe("pinned Claude runtime manager", () => { is_error: false, num_turns: 1, result: "ok", - session_id: "sess", + session_id: started.providerThreadId, stop_reason: "end_turn", terminal_reason: "completed", type: "result", @@ -793,7 +899,6 @@ describe("pinned Claude runtime manager", () => { expect(facts.map((fact) => fact.type)).toEqual([ "turnStarted", - "sessionBootstrapped", "assistantDelta", "tokenUsageUpdated", "turnCompleted", @@ -810,12 +915,200 @@ describe("pinned Claude runtime manager", () => { await manager.close(); }); + test("passes a reserved session id directly to Claude before process admission", async () => { + const { launches, manager } = harness(); + const review = await manager.reviewSessionStart({ + authority, + fast: false, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + projectRoot: PROJECT_ROOT, + signal: signal(), + }); + const admittedIdentities: ClaudeProcessIdentity[] = []; + const started = await manager.startSession({ + authority, + admitProcessIdentity: (identity) => { + admittedIdentities.push(identity); + return Promise.resolve(); + }, + providerThreadId: ADOPTED_PROVIDER_THREAD_ID, + review, + signal: signal(), + }); + + expect(started.providerThreadId).toBe(ADOPTED_PROVIDER_THREAD_ID); + expect(launches).toHaveLength(1); + expect(launches[0]?.argv).toEqual([ + ...runtime.argv, + "--session-id", + ADOPTED_PROVIDER_THREAD_ID, + ]); + expect(admittedIdentities).toEqual([PROCESS_IDENTITY]); + await manager.close(); + }); + + test("keeps the initialized child unobservable while process admission is pending", async () => { + const { manager, processes } = harness(); + const review = await manager.reviewSessionStart({ + authority, + fast: false, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + projectRoot: PROJECT_ROOT, + signal: signal(), + }); + let markCallbackEntered!: () => void; + const callbackEntered = new Promise((resolve) => { + markCallbackEntered = resolve; + }); + let releaseCallback!: () => void; + const callbackRelease = new Promise((resolve) => { + releaseCallback = resolve; + }); + let callbackIdentity: ClaudeProcessIdentity | undefined; + const starting = manager.startSession({ + authority, + admitProcessIdentity: async (identity) => { + callbackIdentity = identity; + markCallbackEntered(); + await callbackRelease; + }, + providerThreadId: ADOPTED_PROVIDER_THREAD_ID, + review, + signal: signal(), + }); + + await callbackEntered; + let startSettled = false; + void starting.then( + () => { startSettled = true; }, + () => { startSettled = true; }, + ); + await Promise.resolve(); + try { + expect(callbackIdentity).toEqual(PROCESS_IDENTITY); + expect(startSettled).toBe(false); + expect(processes).toHaveLength(1); + expect(processes[0]?.terminated).toBe(false); + await expect(manager.observeSession({ + authority, + providerThreadId: ADOPTED_PROVIDER_THREAD_ID, + signal: signal(), + })).rejects.toMatchObject({ reason: "not_running" }); + } finally { + releaseCallback(); + } + + const started = await starting; + expect(started.providerThreadId).toBe(ADOPTED_PROVIDER_THREAD_ID); + await expect(manager.observeSession({ + authority, + providerThreadId: ADOPTED_PROVIDER_THREAD_ID, + signal: signal(), + })).resolves.toMatchObject({ + projection: { providerThreadId: ADOPTED_PROVIDER_THREAD_ID }, + }); + await manager.close(); + }); + + test("rejects process admission failure only after proving child cleanup", async () => { + const { manager, processes } = harness(); + const review = await manager.reviewSessionStart({ + authority, + fast: false, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + projectRoot: PROJECT_ROOT, + signal: signal(), + }); + const admissionFailure = new Error("durable process custody rejected"); + await expect(manager.startSession({ + authority, + admitProcessIdentity: () => Promise.reject(admissionFailure), + providerThreadId: ADOPTED_PROVIDER_THREAD_ID, + review, + signal: signal(), + })).rejects.toBe(admissionFailure); + + const rejectedProcess = processes[0]; + if (rejectedProcess === undefined) throw new Error("expected a rejected child process"); + expect(rejectedProcess.signals).toEqual(["SIGTERM"]); + await expect(rejectedProcess.exited).resolves.toBe(0); + await expect(manager.observeSession({ + authority, + providerThreadId: ADOPTED_PROVIDER_THREAD_ID, + signal: signal(), + })).rejects.toMatchObject({ reason: "not_running" }); + + const retryReview = await manager.reviewSessionStart({ + authority, + fast: false, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + projectRoot: PROJECT_ROOT, + signal: signal(), + }); + await expect(manager.startSession({ + authority, + providerThreadId: ADOPTED_PROVIDER_THREAD_ID, + review: retryReview, + signal: signal(), + })).resolves.toMatchObject({ providerThreadId: ADOPTED_PROVIDER_THREAD_ID }); + expect(processes).toHaveLength(2); + await manager.close(); + }); + + test("retains typed unproven-exit custody when admission cleanup cannot prove exit", async () => { + const { manager, processes } = harness({ + processIgnoresKill: true, + processIgnoresTerm: true, + clientShutdownSettlementMs: 5, + clientShutdownTermGraceMs: 5, + }); + const review = await manager.reviewSessionStart({ + authority, + fast: false, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + projectRoot: PROJECT_ROOT, + signal: signal(), + }); + await expect(manager.startSession({ + authority, + admitProcessIdentity: () => Promise.reject(new Error("durable process custody rejected")), + providerThreadId: ADOPTED_PROVIDER_THREAD_ID, + review, + signal: signal(), + })).rejects.toBeInstanceOf(ClaudeProcessExitUnprovenError); + + expect(processes[0]?.signals).toEqual(["SIGTERM", "SIGKILL"]); + await expect(manager.observeSession({ + authority, + providerThreadId: ADOPTED_PROVIDER_THREAD_ID, + signal: signal(), + })).rejects.toMatchObject({ reason: "not_running" }); + await expect(manager.reviewSessionStart({ + authority, + fast: false, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + projectRoot: PROJECT_ROOT, + signal: signal(), + })).rejects.toThrow("still unjoined"); + expect(processes).toHaveLength(1); + + processes[0]?.end(); + await expect(manager.close()).resolves.toBeUndefined(); + }); + test("refuses a preset the Claude provider does not support", async () => { const { manager } = harness(); await expect(manager.reviewSessionStart({ authority, fast: false, preset: "ultra", + requirement: presetRequirements.ultra, projectRoot: PROJECT_ROOT, signal: signal(), })).rejects.toThrow(PresetProviderMismatchError); @@ -823,6 +1116,7 @@ describe("pinned Claude runtime manager", () => { authority, fast: false, preset: "low", + requirement: presetRequirements.low, projectRoot: PROJECT_ROOT, signal: signal(), })).rejects.toThrow("does not support the `low` model preset"); @@ -831,12 +1125,42 @@ describe("pinned Claude runtime manager", () => { authority, fast: true, preset: "fable-max", + requirement: presetRequirements["fable-max"], projectRoot: PROJECT_ROOT, signal: signal(), })).rejects.toThrow("no HRA fast mode"); await manager.close(); }); + test("requires the passed exact preset tuple before admitting a Claude runtime", async () => { + const unadmitted = harness(); + await expect(unadmitted.manager.reviewSessionStart({ + authority, + fast: false, + preset: "fable-max", + requirement: { model: CLAUDE_PIN_MODEL, effort: "ultra" }, + projectRoot: PROJECT_ROOT, + signal: signal(), + })).rejects.toMatchObject({ code: "UNSUPPORTED_CAPABILITY" }); + await unadmitted.manager.close(); + + const mismatched = harness({ + resolveRuntime: async () => ({ + ...runtime, + model: "claude-other-model", + }) as unknown as PinnedClaudeRuntime, + }); + await expect(mismatched.manager.reviewSessionStart({ + authority, + fast: false, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + projectRoot: PROJECT_ROOT, + signal: signal(), + })).rejects.toMatchObject({ code: "UNSUPPORTED_CAPABILITY" }); + await mismatched.manager.close(); + }); + test("maps every can_use_tool request onto its HRA interaction kind", async () => { const { facts, manager, processes } = harness(); const providerThreadId = await startSession(manager); @@ -1043,12 +1367,433 @@ describe("pinned Claude runtime manager", () => { await manager.close(); }); + test("a stale generation cannot release a replacement session controller", async () => { + const { manager, processes } = harness(); + const providerThreadId = await startSession(manager); + const process = processes[0]; + if (process === undefined) throw new Error("expected one spawned process"); + + await expect(manager.endSession({ + authority: { ...authority, generation: authority.generation - 1 }, + providerThreadId, + signal: signal(), + })).rejects.toMatchObject({ code: "AUTHORITY_STALE" }); + + await expect(manager.observeSession({ + authority, + providerThreadId, + signal: signal(), + })).resolves.toMatchObject({ projection: { providerThreadId } }); + expect(process.signals).toEqual([]); + await manager.close(); + }); + + test("claims one non-live durable session with full runtime authority", async () => { + const { launches, manager, processes } = harness({ configHome: "personal" }); + const claimed = await manager.claimSession({ + authority, + fast: false, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + projectRoot: PROJECT_ROOT, + providerThreadId: ADOPTED_PROVIDER_THREAD_ID, + signal: signal(), + sourceLiveness: "not_live", + title: ADOPTED_TITLE, + }); + expect(claimed).toMatchObject({ + effectiveRuntimeProfile: { configHome: "personal", permissionMode: "default" }, + providerThreadId: ADOPTED_PROVIDER_THREAD_ID, + status: "idle", + title: ADOPTED_TITLE, + }); + expect(launches[0]?.launch).toBe("resume"); + expect(launches[0]?.argv.slice(-2)).toEqual(["--resume", ADOPTED_PROVIDER_THREAD_ID]); + await expect(manager.observeSession({ + authority, + providerThreadId: ADOPTED_PROVIDER_THREAD_ID, + signal: signal(), + })).resolves.toMatchObject({ resumed: true }); + + const turnId = await startTurn(manager, ADOPTED_PROVIDER_THREAD_ID, "continue"); + const process = processes[0]; + if (process === undefined) throw new Error("expected resumed process"); + expect(typeof turnId).toBe("string"); + expect(JSON.parse(process.written[0] ?? "") as unknown).toMatchObject({ type: "user" }); + await expect(manager.readSession({ + authority, + detail: false, + providerThreadId: ADOPTED_PROVIDER_THREAD_ID, + signal: signal(), + })).resolves.toMatchObject({ title: ADOPTED_TITLE }); + + // A second local writer is refused instead of spawning a duplicate copy. + await expect(manager.claimSession({ + authority, + fast: false, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + projectRoot: PROJECT_ROOT, + providerThreadId: ADOPTED_PROVIDER_THREAD_ID, + signal: signal(), + sourceLiveness: "not_live", + title: ADOPTED_TITLE, + })).rejects.toThrow("already has a runtime owner"); + expect(processes).toHaveLength(1); + await manager.close(); + }); + + test("validates and bounds the durable title before resuming Claude", async () => { + const invalid = harness(); + await expect(invalid.manager.claimSession({ + authority, + fast: false, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + projectRoot: PROJECT_ROOT, + providerThreadId: ADOPTED_PROVIDER_THREAD_ID, + signal: signal(), + sourceLiveness: "not_live", + title: 42 as never, + })).rejects.toMatchObject({ code: "INVALID_INPUT" }); + expect(invalid.processes).toHaveLength(0); + await invalid.manager.close(); + + const bounded = harness(); + const claimed = await bounded.manager.claimSession({ + authority, + fast: false, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + projectRoot: PROJECT_ROOT, + providerThreadId: ADOPTED_PROVIDER_THREAD_ID, + signal: signal(), + sourceLiveness: "not_live", + title: `Existing ${"é".repeat(100)}`, + }); + expect(claimed.title.startsWith("Existing ")).toBe(true); + expect(new TextEncoder().encode(claimed.title).byteLength).toBeLessThanOrEqual(120); + await bounded.manager.close(); + }); + + test("rejects the former daemon-fence shortcut without spawning a writer", async () => { + const prior = harness(); + const providerThreadId = await startSession(prior.manager); + await prior.manager.close(); + + const replacement = harness(); + await expect(replacement.manager.observeSession({ + authority, + providerThreadId, + signal: signal(), + })).rejects.toBeInstanceOf(ClaudeSessionObservationError); + await expect(replacement.manager.claimSession({ + authority, + fast: false, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + projectRoot: PROJECT_ROOT, + providerThreadId, + signal: signal(), + sourceLiveness: "prior_hra_daemon_fenced" as never, + title: ADOPTED_TITLE, + })).rejects.toThrow("source process is not live"); + expect(replacement.launches).toHaveLength(0); + await replacement.manager.close(); + }); + + test("refuses a claim without the closed non-live proof", async () => { + const { manager, processes } = harness(); + await expect(manager.claimSession({ + authority, + fast: false, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + projectRoot: PROJECT_ROOT, + providerThreadId: ADOPTED_PROVIDER_THREAD_ID, + signal: signal(), + // Exercises the runtime boundary against an untyped or stale caller. + sourceLiveness: "live" as never, + title: ADOPTED_TITLE, + })).rejects.toThrow("source process is not live"); + expect(processes).toHaveLength(0); + await manager.close(); + }); + + test("fails closed and cleans up every mismatched initialization identity", async () => { + const mismatches: readonly InitializationOverride[] = [ + { providerThreadId: "069d2fc6-b09a-4ced-8932-14b1229cc043" }, + { version: "2.1.259" }, + { model: "claude-fable-5" }, + { permissionMode: "bypassPermissions" }, + ]; + for (const initialization of mismatches) { + const { manager, processes } = harness({ initialization }); + await expect(manager.claimSession({ + authority, + fast: false, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + projectRoot: PROJECT_ROOT, + providerThreadId: ADOPTED_PROVIDER_THREAD_ID, + signal: signal(), + sourceLiveness: "not_live", + title: ADOPTED_TITLE, + })).rejects.toBeInstanceOf(Error); + expect(processes[0]?.terminated).toBe(true); + await expect(manager.observeSession({ + authority, + providerThreadId: ADOPTED_PROVIDER_THREAD_ID, + signal: signal(), + })).rejects.toMatchObject({ reason: "not_running" }); + await manager.close(); + } + }); + + test("fails closed and reaps a child whose exact process identity is unavailable", async () => { + const { manager, processes } = harness({ processIdentity: "reject" }); + await expect(manager.claimSession({ + authority, + fast: false, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + projectRoot: PROJECT_ROOT, + providerThreadId: ADOPTED_PROVIDER_THREAD_ID, + signal: signal(), + sourceLiveness: "not_live", + title: ADOPTED_TITLE, + })).rejects.toThrow("identity unavailable"); + expect(processes[0]?.terminated).toBe(true); + await expect(manager.observeSession({ + authority, + providerThreadId: ADOPTED_PROVIDER_THREAD_ID, + signal: signal(), + })).rejects.toBeInstanceOf(ClaudeSessionObservationError); + await manager.close(); + }); + + test("retains an unproven admission cleanup fence until manager close can retry", async () => { + const { manager, processes } = harness({ + processIdentity: "reject", + processIgnoresKill: true, + processIgnoresTerm: true, + clientShutdownSettlementMs: 5, + clientShutdownTermGraceMs: 5, + }); + const claim = (): ReturnType => + manager.claimSession({ + authority, + fast: false, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + projectRoot: PROJECT_ROOT, + providerThreadId: ADOPTED_PROVIDER_THREAD_ID, + signal: signal(), + sourceLiveness: "not_live", + title: ADOPTED_TITLE, + }); + await expect(claim()).rejects.toThrow("exit could not be proven"); + await expect(claim()).rejects.toThrow("still unjoined"); + expect(processes).toHaveLength(1); + + processes[0]?.end(); + await expect(manager.close()).resolves.toBeUndefined(); + }); + + test("does not admit a process that reaches EOF immediately after initialization", async () => { + const { manager, processes } = harness({ exitAfterInitialization: true }); + await expect(startSession(manager)).rejects.toMatchObject({ code: "PROCESS_EXITED" }); + await expect(processes[0]?.exited).resolves.toBe(0); + await manager.close(); + }); + + test("evicts and reports one committed process that exits", async () => { + const { facts, manager, processes } = harness(); + const providerThreadId = await startSession(manager); + const process = processes[0]; + if (process === undefined) throw new Error("expected one spawned process"); + process.end(); + await settle(); + + await expect(manager.observeSession({ + authority, + providerThreadId, + signal: signal(), + })).rejects.toBeInstanceOf(ClaudeSessionObservationError); + await expect(manager.readSessionProcessIdentity({ + authority, + providerThreadId, + signal: signal(), + })).rejects.toThrow(/no longer live|cleanup is unresolved/u); + expect(facts.filter((fact) => fact.type === "providerDisconnected")).toHaveLength(1); + await manager.close(); + }); + + test("drops late disconnect and turn facts from a replaced Claude connection", async () => { + const evictionSentinel = "hra_test_evict_old_claude_connection"; + // The spy delegates with `.call(this, ...)`, preserving the exact assembler instance. + // eslint-disable-next-line @typescript-eslint/unbound-method + const originalApply = ClaudeDeltaAssembler.prototype.apply; + const applySpy = spyOn(ClaudeDeltaAssembler.prototype, "apply").mockImplementation(function ( + this: ClaudeDeltaAssembler, + event: Parameters[0], + ): ReturnType { + if (event.type === "protocol_notice" && event.event === evictionSentinel) { + return [{ type: "providerDisconnected", reason: "process_exit" }]; + } + return originalApply.call(this, event); + }); + let resolveInitialDisconnect!: () => void; + const initialDisconnect = new Promise((resolve) => { + resolveInitialDisconnect = resolve; + }); + const value = harness({ + onFact: (_factAuthority, fact) => { + if (fact.type === "providerDisconnected") resolveInitialDisconnect(); + }, + }); + let oldProcess: FakeClaudeProcess | undefined; + try { + const providerThreadId = await startSession(value.manager); + const oldObservation = await value.manager.observeSession({ + authority, + providerThreadId, + signal: signal(), + }); + await startTurn(value.manager, providerThreadId, "old connection turn"); + oldProcess = value.processes[0]; + if (oldProcess === undefined) throw new Error("expected the old Claude process"); + + // This test-only sentinel reaches the manager through the real stream + // client callback and models the disconnect that first evicts the old + // runtime. Its process stays readable so later old-client callbacks can + // race a newly admitted owner for the same provider thread. + oldProcess.emit({ type: evictionSentinel }); + await initialDisconnect; + await expect(value.manager.observeSession({ + authority, + providerThreadId, + signal: signal(), + })).rejects.toBeInstanceOf(ClaudeSessionObservationError); + + // Disconnect fences provider effects immediately, but the exact old + // client remains owned until its process and output drains are joined. + await value.manager.endSession({ authority, providerThreadId, signal: signal() }); + + await value.manager.claimSession({ + authority, + fast: false, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + projectRoot: PROJECT_ROOT, + providerThreadId, + signal: signal(), + sourceLiveness: "not_live", + title: ADOPTED_TITLE, + }); + const replacement = await value.manager.observeSession({ + authority, + providerThreadId, + signal: signal(), + }); + expect(replacement.connectionId).not.toBe(oldObservation.connectionId); + const factsBeforeLateCallbacks = [...value.facts]; + + oldProcess.emit({ + message: { + content: [{ text: "stale output from the old connection", type: "text" }], + id: "stale-old-connection-message", + role: "assistant", + type: "message", + }, + parent_tool_use_id: null, + session_id: providerThreadId, + type: "assistant", + }); + // The old client's natural exit publishes abandoned-turn mutations and + // then a second providerDisconnected through its captured connection. + oldProcess.end(); + await settle(); + + await expect(value.manager.observeSession({ + authority, + providerThreadId, + signal: signal(), + })).resolves.toMatchObject({ + connectionId: replacement.connectionId, + projection: { + messages: [], + status: "idle", + title: ADOPTED_TITLE, + }, + resumed: true, + }); + expect(value.facts).toEqual(factsBeforeLateCallbacks); + expect(value.facts.filter((fact) => fact.type === "providerDisconnected")) + .toHaveLength(1); + expect(value.processes).toHaveLength(2); + } finally { + oldProcess?.end(); + await value.manager.close(); + applySpy.mockRestore(); + } + }); + + test("retains ambiguous close custody and permits an exact release retry", async () => { + const { manager, processes } = harness({ + processIgnoresKill: true, + processIgnoresTerm: true, + clientShutdownSettlementMs: 5, + clientShutdownTermGraceMs: 5, + }); + const providerThreadId = await startSession(manager); + const process = processes[0]; + if (process === undefined) throw new Error("expected one spawned process"); + + await expect(manager.endSession({ authority, providerThreadId, signal: signal() })) + .rejects.toMatchObject({ code: "TIMEOUT" }); + await expect(manager.readSessionProcessIdentity({ + authority, + providerThreadId, + signal: signal(), + })).rejects.toThrow("cleanup is unresolved"); + + process.end(); + await expect(manager.endSession({ authority, providerThreadId, signal: signal() })) + .resolves.toBeUndefined(); + await expect(manager.observeSession({ authority, providerThreadId, signal: signal() })) + .rejects.toBeInstanceOf(ClaudeSessionObservationError); + expect(process.signals).toEqual(["SIGTERM", "SIGKILL", "SIGTERM"]); + await manager.close(); + }); + + test("times out and cleans up a resumed process that never initializes", async () => { + const { manager, processes } = harness({ + initialization: "silent", + initializationTimeoutMs: 1, + }); + await expect(manager.claimSession({ + authority, + fast: false, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + projectRoot: PROJECT_ROOT, + providerThreadId: ADOPTED_PROVIDER_THREAD_ID, + signal: signal(), + sourceLiveness: "not_live", + title: ADOPTED_TITLE, + })).rejects.toMatchObject({ code: "TIMEOUT" }); + expect(processes[0]?.terminated).toBe(true); + await manager.close(); + }); + test("refuses a review that names no project directory", async () => { const { manager } = harness(); await expect(manager.reviewSessionStart({ authority, fast: false, preset: "fable-max", + requirement: presetRequirements["fable-max"], signal: signal(), })).rejects.toThrow("requires a project directory"); await manager.close(); @@ -1060,6 +1805,7 @@ describe("pinned Claude runtime manager", () => { authority, fast: false, preset: "fable-max", + requirement: presetRequirements["fable-max"], projectRoot: PROJECT_ROOT, signal: signal(), }); diff --git a/src/daemon/claude-runtime-adapter.ts b/src/daemon/claude-runtime-adapter.ts index 39ee9e3..ef9e575 100644 --- a/src/daemon/claude-runtime-adapter.ts +++ b/src/daemon/claude-runtime-adapter.ts @@ -6,15 +6,20 @@ import { ClaudeError, ClaudeStreamClient, boundClaudeText, + claudeSessionArgv, readClaudeAuthStatus, sanitizeClaudeText, spawnBunClaudeProcess, + parseClaudeProcessIdentity, + readClaudeAccountProjection, resolvePinnedClaudeRuntime, type ClaudeAuthStatusReader, type ClaudeCanUseTool, type ClaudeFact, type ClaudeInteractionDecision, type ClaudeProcess, + type ClaudeProcessIdentity, + type ClaudeStreamInitialization, type PinnedClaudeRuntime, type ResolvePinnedClaudeRuntimeOptions, } from "../claude/index"; @@ -27,16 +32,25 @@ import type { } from "../domain/interactions"; import { assertPresetSupportedByProvider, + isAdmittedPresetRequirement, type Preset, + type PresetRequirement, } from "../domain/presets"; import { + claudeConfigHomeSchema, effectiveClaudeRuntimeProfileSchema, + type ClaudeConfigHome, type EffectiveClaudeRuntimeProfile, } from "../domain/runtime-profile"; import type { ClaudeSessionFact } from "./claude-session-facts"; +import { + ClaudeProcessExitUnprovenError, + ClaudeSessionObservationError, +} from "./ports"; import type { ClaudeRuntimePort, ClaudeRuntimeStartReview, + ClaudeSessionClaimProof, CodexAccountProjection, CodexProjectedMessage, CodexSessionObservation, @@ -50,10 +64,40 @@ const PROJECTED_MESSAGE_LIMIT = 256; const PROJECTED_TURN_LIMIT = 128; const PROJECTED_MESSAGE_BYTES = 16 * 1024; const PROJECTED_TITLE_BYTES = 120; +const PROCESS_CONSTRUCTOR_FAILURE_SETTLEMENT_MS = 1_000; const CLOSED_SESSION_PROOF_LIMIT = 1_024; const encoder = new TextEncoder(); +const processExitSettledWithin = async ( + process: ClaudeProcess, + milliseconds: number, +): Promise => { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + process.exited.then(() => true, () => false), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), milliseconds); + timer.unref(); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +}; + +/** Validates and bounds the durable title supplied when resuming a conversation. */ +const projectedTitle = (value: unknown): string => { + if (typeof value !== "string") { + throw new ClaudeError("INVALID_INPUT", "A resumed Claude session requires a valid title."); + } + return boundClaudeText( + sanitizeClaudeText(value), + PROJECTED_TITLE_BYTES, + ) || "Untitled session"; +}; + const optionalClientShutdownDuration = ( value: number | undefined, label: string, @@ -93,7 +137,9 @@ type RunningSession = { readonly connectionId: string; readonly providerThreadId: string; readonly profile: EffectiveClaudeRuntimeProfile; + readonly processIdentity: ClaudeProcessIdentity; readonly projectRoot: string; + readonly resumed: boolean; status: "active" | "idle" | "terminal"; activeTurnId: string | undefined; title: string; @@ -130,11 +176,16 @@ type PendingClaudeReview = { export type ClaudeProcessFactory = (input: { readonly runtime: PinnedClaudeRuntime; + readonly argv: readonly [string, ...string[]]; readonly configDir: string; + readonly configHome: ClaudeConfigHome; readonly projectRoot: string; + readonly launch: "create" | "resume"; }) => ClaudeProcess; const METHOD = "claude/control_request/can_use_tool"; +const DEFAULT_INITIALIZATION_TIMEOUT_MS = 10_000; +const INITIALIZATION_FACT_LIMIT = 16; const requestDigestOf = (requestId: string, request: ClaudeCanUseTool): string => createHash("sha256") @@ -188,9 +239,10 @@ const decisionFor = ( /** * The Claude Code implementation of the provider-neutral session seam. It - * owns one pinned `claude` process per session under an isolated - * `CLAUDE_CONFIG_DIR`, and it translates only through `src/claude`'s fact - * vocabulary: no Claude wire shape leaves this file. + * owns one pinned `claude` process per session under a reviewed configuration + * home. Isolated mode exports `CLAUDE_CONFIG_DIR`; personal mode deliberately + * uses Claude's default-home resolution. It translates only through + * `src/claude`'s fact vocabulary: no Claude wire shape leaves this file. */ export class PinnedClaudeRuntimeManager implements ClaudeRuntimePort { readonly provider = "claude" as const; @@ -201,6 +253,8 @@ export class PinnedClaudeRuntimeManager implements ClaudeRuntimePort { readonly #resolveRuntime: typeof resolvePinnedClaudeRuntime; readonly #processFactory: ClaudeProcessFactory; readonly #now: () => number; + readonly #configHome: ClaudeConfigHome; + readonly #initializationTimeoutMs: number; readonly #clientShutdownTermGraceMs: number | undefined; readonly #clientShutdownSettlementMs: number | undefined; readonly #sessions = new Map(); @@ -213,13 +267,15 @@ export class PinnedClaudeRuntimeManager implements ClaudeRuntimePort { /** Same-daemon idempotency only; a restart deliberately has no exit proof. */ readonly #closedSessionProofs = new Map(); readonly #reviews = new Map(); + readonly #startingSessionIds = new Set(); + readonly #initializingClients = new Map(); #resolvedRuntime: PinnedClaudeRuntime | undefined; #state: "open" | "closed" = "open"; constructor(input: { isCurrent: (authority: ProfileAuthority) => boolean; observer: ClaudeRuntimeObserver; - /** The isolated, absolute `CLAUDE_CONFIG_DIR` for one HRA account. */ + /** The reviewed absolute home used by the selected configuration-home mode. */ configDirFor: (authority: ProfileAuthority) => string | Promise; readAuthStatus?: ClaudeAuthStatusReader; resolveRuntime?: typeof resolvePinnedClaudeRuntime; @@ -228,6 +284,9 @@ export class PinnedClaudeRuntimeManager implements ClaudeRuntimePort { clientShutdownTermGraceMs?: number; clientShutdownSettlementMs?: number; now?: () => number; + initializationTimeoutMs?: number; + /** Truthful authority classification recorded in every runtime review. */ + configHome: ClaudeConfigHome; }) { this.#isCurrent = input.isCurrent; this.#observer = input.observer; @@ -236,8 +295,9 @@ export class PinnedClaudeRuntimeManager implements ClaudeRuntimePort { this.#resolveRuntime = input.resolveRuntime ?? resolvePinnedClaudeRuntime; this.#processFactory = input.processFactory ?? ((launch) => spawnBunClaudeProcess({ - argv: launch.runtime.argv, + argv: launch.argv, configDir: launch.configDir, + configHome: launch.configHome, projectRoot: launch.projectRoot, })); this.#clientShutdownTermGraceMs = optionalClientShutdownDuration( @@ -249,6 +309,19 @@ export class PinnedClaudeRuntimeManager implements ClaudeRuntimePort { "Claude shutdown settlement", ); this.#now = input.now ?? Date.now; + this.#configHome = claudeConfigHomeSchema.parse(input.configHome); + this.#initializationTimeoutMs = input.initializationTimeoutMs + ?? DEFAULT_INITIALIZATION_TIMEOUT_MS; + if ( + !Number.isSafeInteger(this.#initializationTimeoutMs) + || this.#initializationTimeoutMs < 1 + || this.#initializationTimeoutMs > 60_000 + ) { + throw new ClaudeError( + "INVALID_INPUT", + "Claude initialization timeout must be between 1 and 60000 milliseconds", + ); + } } pinnedVersion(): string { @@ -310,27 +383,31 @@ export class PinnedClaudeRuntimeManager implements ClaudeRuntimePort { signal: AbortSignal; }): Promise { this.#assertLaunchAuthority(input.authority, input.signal); - // Claude's own non-interactive status command reads the isolated home and - // projects only this boolean. HRA never opens a credential file or copies - // any identity-bearing status fields into its account model. const configDir = await this.#configDirFor(input.authority); this.#assertLaunchAuthority(input.authority, input.signal); const runtime = await this.#admitRuntime(configDir, input.signal); this.#assertLaunchAuthority(input.authority, input.signal); this.#resolvedRuntime = runtime; - const account = await this.#readAuthStatus({ - configDir, - runtime, - signal: input.signal, - }); + // Managed homes retain the hardened, strictly parsed auth-status reader. + // Personal adoption must use Claude's default-home semantics: exporting + // CLAUDE_CONFIG_DIR there redirects `.claude.json` into the wrong home. + const account = this.#configHome === "personal" + ? await readClaudeAccountProjection({ + configDir, + configHome: this.#configHome, + runtime, + signal: input.signal, + }) + : await this.#readAuthStatus({ configDir, runtime, signal: input.signal }); this.#assertLaunchAuthority(input.authority, input.signal); - return { signedIn: account.signedIn }; + return account; } async reviewSessionStart(input: { authority: ProfileAuthority; projectRoot?: string; preset: Preset; + requirement: PresetRequirement; fast: boolean; signal: AbortSignal; }): Promise { @@ -347,6 +424,7 @@ export class PinnedClaudeRuntimeManager implements ClaudeRuntimePort { providerThreadId: string; projectRoot?: string; preset: Preset; + requirement: PresetRequirement; fast: boolean; signal: AbortSignal; }): Promise { @@ -355,84 +433,101 @@ export class PinnedClaudeRuntimeManager implements ClaudeRuntimePort { async startSession(input: { authority: ProfileAuthority; + admitProcessIdentity?: (identity: ClaudeProcessIdentity) => Promise; projectRoot?: string; + providerThreadId?: string; review: ClaudeRuntimeStartReview; signal: AbortSignal; }): Promise { this.#assertLaunchAuthority(input.authority, input.signal); this.#assertNoUnboundSessionChild(); const pending = this.#consumeReview(input.review, "session_start"); - const connectionId = randomUUID(); - const providerThreadId = randomUUID(); - const configDir = await this.#configDirFor(input.authority); + if (!this.#sameAuthority(pending.authority, input.authority)) { + throw new ClaudeError("AUTHORITY_STALE", "That Claude runtime review belongs to another authority."); + } this.#assertLaunchAuthority(input.authority, input.signal); - this.#assertNoUnboundSessionChild(); - const process = this.#processFactory({ - configDir, + const providerThreadId = input.providerThreadId ?? randomUUID(); + const session = await this.#startPinnedSession({ + authority: input.authority, + ...(input.admitProcessIdentity === undefined + ? {} + : { admitProcessIdentity: input.admitProcessIdentity }), + launch: "create", + profile: pending.review.effectiveRuntimeProfile, projectRoot: pending.projectRoot, + providerThreadId, runtime: pending.runtime, + signal: input.signal, + title: "Untitled session", }); - const client = new ClaudeStreamClient({ - configDir, - onFact: (fact) => this.#onFact(providerThreadId, connectionId, fact), - process, - ...(this.#clientShutdownTermGraceMs === undefined - ? {} - : { shutdownTermGraceMs: this.#clientShutdownTermGraceMs }), - ...(this.#clientShutdownSettlementMs === undefined + return { + effectiveRuntimeProfile: session.profile, + providerThreadId, + providerUpdatedAt: session.updatedAt, + status: "idle", + title: session.title, + projectRoot: session.projectRoot, + }; + } + + /** + * Reclaims a durable Claude conversation only after its external process is + * proven gone. The new pinned stream owns stdin and therefore has the same + * interaction and autorespond authority as a session HRA created itself. + */ + async claimSession(input: { + authority: ProfileAuthority; + admitProcessIdentity?: (identity: ClaudeProcessIdentity) => Promise; + providerThreadId: string; + projectRoot: string; + title: string; + preset: Preset; + requirement: PresetRequirement; + fast: boolean; + sourceLiveness: ClaudeSessionClaimProof; + signal: AbortSignal; + }): Promise { + this.#assertOpen(); + input.signal.throwIfAborted(); + const sourceLiveness: unknown = input.sourceLiveness; + if (sourceLiveness !== "not_live") { + throw new ClaudeError( + "AUTHORITY_STALE", + "Claude session takeover requires proof that the source process is not live.", + ); + } + const title = projectedTitle(input.title); + const review = await this.#review({ + authority: input.authority, + fast: input.fast, + kind: "session_start", + preset: input.preset, + requirement: input.requirement, + projectRoot: input.projectRoot, + signal: input.signal, + }); + const pending = this.#consumeReview(review, "session_start"); + const session = await this.#startPinnedSession({ + authority: input.authority, + ...(input.admitProcessIdentity === undefined ? {} - : { shutdownSettlementMs: this.#clientShutdownSettlementMs }), + : { admitProcessIdentity: input.admitProcessIdentity }), + launch: "resume", + profile: pending.review.effectiveRuntimeProfile, + projectRoot: pending.projectRoot, + providerThreadId: input.providerThreadId, + runtime: pending.runtime, + signal: input.signal, + title, }); - // Own the child before the first post-spawn authority check. If that check - // fails and bounded cleanup cannot yet join the process, manager shutdown - // must still be able to retry this exact client rather than losing custody. - this.#unboundClients.add(client); - try { - this.#assertLaunchAuthority(input.authority, input.signal); - const session: RunningSession = { - activeTurnId: undefined, - assistantItems: new Map(), - authority: input.authority, - client, - closeState: "open", - connectionId, - droppedMessages: 0, - droppedTurns: 0, - messages: [], - profile: pending.review.effectiveRuntimeProfile, - projectRoot: pending.projectRoot, - providerThreadId, - status: "idle", - title: "Untitled session", - truncatedMessages: 0, - turnSummaries: [], - updatedAt: this.#now(), - }; - this.#assertLaunchAuthority(input.authority, input.signal); - this.#closedSessionProofs.delete(providerThreadId); - this.#sessions.set(providerThreadId, session); - this.#unboundClients.delete(client); - return { - effectiveRuntimeProfile: session.profile, - providerThreadId, - providerUpdatedAt: session.updatedAt, - status: "idle", - title: session.title, - projectRoot: session.projectRoot, - }; - } catch (error: unknown) { - try { - await client.close(); - this.#unboundClients.delete(client); - } catch (cleanupError: unknown) { - throw new AggregateError( - [error, cleanupError], - "Claude session launch authority failed and its child cleanup was incomplete.", - { cause: error }, - ); - } - throw error; - } + return { + effectiveRuntimeProfile: session.profile, + providerThreadId: session.providerThreadId, + providerUpdatedAt: session.updatedAt, + projectRoot: session.projectRoot, + status: "idle", + title: session.title, + }; } async startTurn(input: { @@ -451,6 +546,12 @@ export class PinnedClaudeRuntimeManager implements ClaudeRuntimePort { }> { this.#assertOpen(); const pending = this.#consumeReview(input.review, "turn_start"); + if ( + !this.#sameAuthority(pending.authority, input.authority) + || pending.providerThreadId !== input.providerThreadId + ) { + throw new ClaudeError("AUTHORITY_STALE", "That Claude runtime review belongs to another session authority."); + } const session = this.#requireSession(input.authority, input.providerThreadId); if (session.activeTurnId !== undefined) { throw new ClaudeError("INVALID_INPUT", "The Claude session already has an active turn."); @@ -510,11 +611,19 @@ export class PinnedClaudeRuntimeManager implements ClaudeRuntimePort { providerThreadId: string; signal: AbortSignal; }): Promise { + const current = this.#sessions.get(input.providerThreadId); + if ( + current === undefined + || current.client.state !== "open" + || current.closeState !== "open" + ) { + throw new ClaudeSessionObservationError(); + } const session = this.#requireSession(input.authority, input.providerThreadId); return { connectionId: session.connectionId, projection: this.#projection(session), - resumed: false, + resumed: session.resumed, }; } @@ -527,6 +636,15 @@ export class PinnedClaudeRuntimeManager implements ClaudeRuntimePort { return this.#projection(this.#requireSession(input.authority, input.providerThreadId)); } + async readSessionProcessIdentity(input: { + authority: ProfileAuthority; + providerThreadId: string; + signal: AbortSignal; + }): Promise { + input.signal.throwIfAborted(); + return this.#requireSession(input.authority, input.providerThreadId).processIdentity; + } + /** * Stop the pinned Claude Code process that served one session and forget it. * A Claude session is one live process, so leaving a switched-away session @@ -561,14 +679,18 @@ export class PinnedClaudeRuntimeManager implements ClaudeRuntimePort { /** Widens the runtime-resolution failure into one actionable instruction. */ async #admitRuntime(configDir: string, signal: AbortSignal): Promise { try { - return await this.#resolveRuntime({ configDir, signal } satisfies ResolvePinnedClaudeRuntimeOptions); + return await this.#resolveRuntime({ + configDir, + configHome: this.#configHome, + signal, + } satisfies ResolvePinnedClaudeRuntimeOptions); } catch (error: unknown) { const detail = error instanceof ClaudeError ? error.message : "it could not be admitted"; throw new ClaudeError( "RUNTIME_MISMATCH", `HRA cannot start a Claude Code session on this machine: ${detail}. ` + `Install Claude Code ${CLAUDE_PIN} exactly, put \`claude\` on this daemon's PATH, ` - + "then sign in inside the account's isolated Claude profile and retry.", + + "then sign in inside the configured Claude profile and retry.", { cause: error }, ); } @@ -704,6 +826,9 @@ export class PinnedClaudeRuntimeManager implements ClaudeRuntimePort { ...[...this.#unboundClients].map(async (client) => { await client.close(); this.#unboundClients.delete(client); + const providerThreadId = this.#initializingClients.get(client); + this.#initializingClients.delete(client); + if (providerThreadId !== undefined) this.#startingSessionIds.delete(providerThreadId); }), ], ); @@ -747,6 +872,7 @@ export class PinnedClaudeRuntimeManager implements ClaudeRuntimePort { projectRoot?: string; providerThreadId?: string; preset: Preset; + requirement: PresetRequirement; fast: boolean; signal: AbortSignal; }): Promise { @@ -754,6 +880,12 @@ export class PinnedClaudeRuntimeManager implements ClaudeRuntimePort { if (input.kind === "session_start") this.#assertNoUnboundSessionChild(); // Refuse another provider's preset before touching the runtime at all. assertPresetSupportedByProvider("claude", input.preset); + if (!isAdmittedPresetRequirement(input.preset, input.requirement)) { + throw new ClaudeError( + "UNSUPPORTED_CAPABILITY", + `${input.preset} requested an unadmitted exact HRA model and reasoning tuple`, + ); + } if (input.fast) { throw new ClaudeError( "UNSUPPORTED_CAPABILITY", @@ -770,11 +902,20 @@ export class PinnedClaudeRuntimeManager implements ClaudeRuntimePort { const runtime = await this.#admitRuntime(configDir, input.signal); this.#assertLaunchAuthority(input.authority, input.signal); if (input.kind === "session_start") this.#assertNoUnboundSessionChild(); + if ( + runtime.model !== input.requirement.model + || runtime.effort !== input.requirement.effort + ) { + throw new ClaudeError( + "UNSUPPORTED_CAPABILITY", + `${input.preset} requires exactly ${input.requirement.model} with ${input.requirement.effort} reasoning for this account generation`, + ); + } this.#resolvedRuntime = runtime; const profile = effectiveClaudeRuntimeProfileSchema.parse({ claudeVersion: runtime.version, inputFormat: "stream-json", - isolatedConfigDir: true, + configHome: this.#configHome, model: runtime.model, observedAt: this.#now(), outputFormat: "stream-json", @@ -832,6 +973,209 @@ export class PinnedClaudeRuntimeManager implements ClaudeRuntimePort { return pending; } + async #startPinnedSession(input: Readonly<{ + authority: ProfileAuthority; + admitProcessIdentity?: (identity: ClaudeProcessIdentity) => Promise; + launch: "create" | "resume"; + profile: EffectiveClaudeRuntimeProfile; + projectRoot: string; + providerThreadId: string; + runtime: PinnedClaudeRuntime; + signal: AbortSignal; + title: string; + }>): Promise { + this.#assertOpen(); + input.signal.throwIfAborted(); + this.#assertCurrent(input.authority); + if ( + this.#sessions.has(input.providerThreadId) + || this.#startingSessionIds.has(input.providerThreadId) + ) { + throw new ClaudeError( + "AUTHORITY_STALE", + "That Claude session already has a runtime owner on this daemon.", + ); + } + this.#startingSessionIds.add(input.providerThreadId); + + const connectionId = randomUUID(); + const configDir = await this.#configDirFor(input.authority); + this.#assertLaunchAuthority(input.authority, input.signal); + this.#assertNoUnboundSessionChild(); + let ready = false; + const initializationFacts: ClaudeFact[] = []; + let process: ClaudeProcess | undefined; + let client: ClaudeStreamClient | undefined; + let admitted = false; + try { + process = this.#processFactory({ + argv: claudeSessionArgv(input.runtime, { + kind: input.launch, + providerThreadId: input.providerThreadId, + }), + configDir, + configHome: this.#configHome, + launch: input.launch, + projectRoot: input.projectRoot, + runtime: input.runtime, + }); + client = new ClaudeStreamClient({ + configDir, + onFact: (fact) => { + if (ready) return this.#onFact(input.providerThreadId, connectionId, fact); + if (initializationFacts.length >= INITIALIZATION_FACT_LIMIT) { + throw new ClaudeError( + "PROTOCOL_LIMIT", + "Claude published too many facts before initialization completed.", + ); + } + initializationFacts.push(fact); + }, + process, + ...(this.#clientShutdownSettlementMs === undefined + ? {} + : { shutdownSettlementMs: this.#clientShutdownSettlementMs }), + ...(this.#clientShutdownTermGraceMs === undefined + ? {} + : { shutdownTermGraceMs: this.#clientShutdownTermGraceMs }), + }); + // Establish custody before any post-spawn await. Failed admission keeps + // this exact client here until its process exit and output drains prove + // clean, so another session cannot launch across an ambiguous child. + this.#unboundClients.add(client); + this.#initializingClients.set(client, input.providerThreadId); + const [initialization, processIdentity] = await Promise.all([ + client.waitForInitialization({ + signal: input.signal, + timeoutMs: this.#initializationTimeoutMs, + }), + process.identity.then((value) => parseClaudeProcessIdentity(value)), + ]); + this.#assertInitialization(input.providerThreadId, input.runtime, initialization); + // Let an EOF already queued behind `system/init` fence this admission + // before the session enters the live map. A later exit is handled by + // the same client's provider-disconnect path and evicted immediately. + await Promise.resolve(); + input.signal.throwIfAborted(); + this.#assertOpen(); + this.#assertCurrent(input.authority); + if (client.state !== "open") { + throw new ClaudeError( + "PROCESS_EXITED", + "Claude exited before its runtime authority could be admitted.", + ); + } + const unexpected = initializationFacts.find( + (fact) => fact.type !== "sessionBootstrapped" && fact.type !== "protocolNotice", + ); + if (unexpected !== undefined) { + throw new ClaudeError( + "PROTOCOL_ERROR", + "Claude published session activity before its initialization identity was admitted.", + ); + } + if (this.#sessions.has(input.providerThreadId)) { + throw new ClaudeError( + "AUTHORITY_STALE", + "That Claude session acquired another runtime owner during initialization.", + ); + } + // A caller that owns durable process custody commits the exact PID/start + // identity before this child becomes addressable in the live session + // map. If that commit fails, the catch path proves the child closed. + await input.admitProcessIdentity?.(processIdentity); + input.signal.throwIfAborted(); + this.#assertOpen(); + this.#assertCurrent(input.authority); + if (!isClaudeClientOpen(client) || this.#sessions.has(input.providerThreadId)) { + throw new ClaudeError( + "AUTHORITY_STALE", + "Claude authority changed while its exact process identity was admitted.", + ); + } + const session: RunningSession = { + activeTurnId: undefined, + assistantItems: new Map(), + authority: input.authority, + client, + closeState: "open", + connectionId, + droppedMessages: 0, + droppedTurns: 0, + messages: [], + profile: input.profile, + processIdentity, + projectRoot: input.projectRoot, + providerThreadId: input.providerThreadId, + resumed: input.launch === "resume", + status: "idle", + title: input.title, + truncatedMessages: 0, + turnSummaries: [], + updatedAt: this.#now(), + }; + this.#closedSessionProofs.delete(input.providerThreadId); + this.#sessions.set(input.providerThreadId, session); + this.#unboundClients.delete(client); + this.#initializingClients.delete(client); + admitted = true; + ready = true; + return session; + } catch (error: unknown) { + if (client !== undefined) { + try { + await client.close(); + this.#unboundClients.delete(client); + this.#initializingClients.delete(client); + } catch (cause: unknown) { + throw new ClaudeProcessExitUnprovenError({ + cause: new AggregateError([error, cause], "Claude admission and cleanup both failed."), + }); + } + } else if (process !== undefined) { + try { + process.forceTerminate(); + } catch { + // Settlement below is the authority result. + } + const settled = await processExitSettledWithin( + process, + this.#clientShutdownSettlementMs ?? PROCESS_CONSTRUCTOR_FAILURE_SETTLEMENT_MS, + ); + if (!settled) throw new ClaudeProcessExitUnprovenError({ cause: error }); + } + throw error; + } finally { + if (client === undefined || admitted || client.state === "closed") { + this.#startingSessionIds.delete(input.providerThreadId); + if (client !== undefined) this.#initializingClients.delete(client); + } + } + } + + #assertInitialization( + providerThreadId: string, + runtime: PinnedClaudeRuntime, + initialization: ClaudeStreamInitialization, + ): void { + if (initialization.providerSessionId !== providerThreadId) { + throw new ClaudeError( + "PROTOCOL_ERROR", + "Claude initialized a different provider session than HRA requested.", + ); + } + if ( + initialization.claudeVersion !== runtime.version + || initialization.model !== runtime.model + || initialization.permissionMode !== "default" + ) { + throw new ClaudeError( + "RUNTIME_MISMATCH", + "Claude initialized with a different version, model, or permission mode than HRA reviewed.", + ); + } + } + #requireSession(authority: ProfileAuthority, providerThreadId: string): RunningSession { const session = this.#sessions.get(providerThreadId); if (session === undefined) { @@ -850,6 +1194,9 @@ export class PinnedClaudeRuntimeManager implements ClaudeRuntimePort { "That Claude session's process cleanup is unresolved on this daemon.", ); } + if (session.client.state !== "open") { + throw new ClaudeError("PROCESS_EXITED", "The Claude session process is no longer live."); + } return session; } @@ -944,11 +1291,8 @@ export class PinnedClaudeRuntimeManager implements ClaudeRuntimePort { message: string, clientMessageId: string, ): void { - if (session.messages.length === 0) { - session.title = boundClaudeText( - sanitizeClaudeText(message), - PROJECTED_TITLE_BYTES, - ) || "Untitled session"; + if (!session.resumed && session.messages.length === 0) { + session.title = projectedTitle(message); } this.#pushMessage(session, { clientId: clientMessageId, @@ -1020,7 +1364,18 @@ export class PinnedClaudeRuntimeManager implements ClaudeRuntimePort { fact: ClaudeFact, ): Promise { const session = this.#sessions.get(providerThreadId); - if (session === undefined) return; + if (session === undefined || session.connectionId !== connectionId) return; + if (fact.type === "providerDisconnected") { + session.activeTurnId = undefined; + session.status = "terminal"; + session.updatedAt = this.#now(); + // Retain the exact client until endSession/manager shutdown proves its + // process joined and both output streams drained. The disconnect fact + // fences effects, but is not itself complete release proof. + session.closeState = "failed"; + await this.#observer.fact(session.authority, { ...fact, connectionId, providerThreadId }); + return; + } if (fact.type === "assistantDelta") { this.#appendAssistantDelta(session, fact.turnId, fact.itemId, fact.text); } @@ -1047,3 +1402,10 @@ export class PinnedClaudeRuntimeManager implements ClaudeRuntimePort { } } } + +// Claude's stream can close while an awaited durable-custody callback runs. +// Keep the state read behind a call boundary so TypeScript does not reuse its +// pre-await narrowing as though no asynchronous callback could have changed it. +function isClaudeClientOpen(client: ClaudeStreamClient): boolean { + return client.state === "open"; +} diff --git a/src/daemon/claude-session-facts.ts b/src/daemon/claude-session-facts.ts index a2fe66a..afd19c4 100644 --- a/src/daemon/claude-session-facts.ts +++ b/src/daemon/claude-session-facts.ts @@ -137,6 +137,14 @@ export class ClaudeSessionFactTranslator { // of that, so nothing is projected. case "sessionBootstrapped": return null; + case "providerDisconnected": + this.forgetSession(threadId); + this.#openItems.delete(threadId); + return { + connectionId, + reason: fact.reason, + type: "providerDisconnected", + }; case "turnStarted": return { connectionId, diff --git a/src/daemon/claude-session.test.ts b/src/daemon/claude-session.test.ts index 527f580..3128b96 100644 --- a/src/daemon/claude-session.test.ts +++ b/src/daemon/claude-session.test.ts @@ -3,7 +3,11 @@ import { mkdtemp, mkdir, realpath, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { ClaudeProcess, PinnedClaudeRuntime } from "../claude/index"; +import type { + ClaudeProcess, + ClaudeProcessIdentity, + PinnedClaudeRuntime, +} from "../claude/index"; import { CLAUDE_PIN, CLAUDE_PIN_EFFORT, CLAUDE_PIN_MODEL } from "../claude/pin"; import { LiveBatcher } from "../cloud/live-uploader"; import type { SessionEvent } from "../domain/session-events"; @@ -131,6 +135,11 @@ const pinnedRuntime: PinnedClaudeRuntime = { }; class FakeClaudeProcess implements ClaudeProcess { + readonly identity: Promise = Promise.resolve(Object.freeze({ + pid: 8_123, + pidDomain: "darwin", + procStart: "Fri Sep 4 12:00:00 2026", + })); readonly written: string[] = []; readonly exited: Promise; readonly stdout: AsyncIterable; @@ -193,6 +202,7 @@ class SignInOnlyCodex implements CodexRuntimePort { async readAccount(): Promise { return { email: "person@example.com", signedIn: true }; } + async releaseOwnedAuthority(): Promise {} async logout(): Promise {} async close(): Promise {} cancelLogin(): Promise { return Promise.reject(this.#unsupported()); } @@ -266,6 +276,7 @@ async function claudeFixture( const processes: FakeClaudeProcess[] = []; const reference: { current?: HraService } = {}; const claude = new PinnedClaudeRuntimeManager({ + configHome: "isolated", configDirFor: () => join(home, "claude-config"), isCurrent: (authority) => { try { @@ -280,12 +291,22 @@ async function claudeFixture( await reference.current?.observeClaudeFact(authority, fact); }, }, - processFactory: () => { + processFactory: (launch) => { const process = new FakeClaudeProcess(); processes.push(process); + const providerThreadId = launch.argv.at(-1); + if (providerThreadId === undefined) throw new Error("Expected a session-bound Claude argv."); + queueMicrotask(() => { process.emit({ ...initLine, session_id: providerThreadId }); }); return process; }, - readAuthStatus: async () => ({ signedIn: options.claudeSignedIn ?? true }), + readAuthStatus: async () => options.claudeSignedIn === false + ? { signedIn: false } + : { + accountId: "claude-test-account", + email: "claude-test@example.com", + organizationId: "claude-test-organization", + signedIn: true, + }, resolveRuntime: options.resolveRuntime ?? (async () => pinnedRuntime), }); const cloud = new OfflineCloud(); @@ -385,7 +406,6 @@ describe("Claude sessions on the local authority", () => { }, { signal }) as { session: { id: `sess_${string}` } }; const process = value.processes[0]; if (process === undefined) throw new Error("Expected one pinned Claude process."); - process.emit(initLine); await settle(); const before = value.store.requireProfileById(account); @@ -403,7 +423,7 @@ describe("Claude sessions on the local authority", () => { state: "signed_in", }); expect(value.processes).toEqual([process]); - expect(process.terminated).toBe(false); + expect(process.terminated).toBe(true); const afterLoginBodies = await eventBodies(value, started.session.id); expect(afterLoginBodies).not.toContainEqual( expect.objectContaining({ type: "gap", reason: "provider_disconnect" }), @@ -423,10 +443,13 @@ describe("Claude sessions on the local authority", () => { kind: "session.stop", session: started.session.id, }, { signal }) as { stopped: boolean }; + const resumedProcess = value.processes[1]; + if (resumedProcess === undefined) throw new Error("Expected Claude to resume the session."); expect(stopped.stopped).toBe(true); - expect(process.written.join("\n")).toContain("Keep working after the Codex login"); - expect(process.written.some((line) => line.includes("interrupt"))).toBe(true); - expect(process.terminated).toBe(false); + expect(resumedProcess.written.join("\n")).toContain("Keep working after the Codex login"); + expect(resumedProcess.written.some((line) => line.includes("interrupt"))).toBe(true); + expect(process.terminated).toBe(true); + expect(resumedProcess.terminated).toBe(false); }); test("refuses a Codex login before rotating an in-flight Claude authority", async () => { @@ -441,7 +464,6 @@ describe("Claude sessions on the local authority", () => { }, { signal }) as { session: { id: `sess_${string}` } }; const process = value.processes[0]; if (process === undefined) throw new Error("Expected one pinned Claude process."); - process.emit(initLine); await settle(); await value.service.execute({ idempotencyKey: crypto.randomUUID(), @@ -485,7 +507,7 @@ describe("Claude sessions on the local authority", () => { }, { signal })).resolves.toMatchObject({ account: { processGeneration: before.processGeneration + 1 }, }); - expect(process.terminated).toBe(false); + expect(process.terminated).toBe(true); }); test("keeps an idle Claude session usable when the sibling Codex runtime disconnects", async () => { @@ -500,7 +522,6 @@ describe("Claude sessions on the local authority", () => { }, { signal }) as { session: { id: `sess_${string}` } }; const process = value.processes[0]; if (process === undefined) throw new Error("Expected one pinned Claude process."); - process.emit(initLine); await settle(); const before = value.store.requireProfileById(account); @@ -516,7 +537,7 @@ describe("Claude sessions on the local authority", () => { }); expect(value.store.requireProfileById(account).processGeneration) - .toBe(before.processGeneration + 1); + .toBe(before.processGeneration); expect(process.terminated).toBe(false); await value.service.execute({ idempotencyKey: crypto.randomUUID(), @@ -609,24 +630,25 @@ describe("Claude sessions on the local authority", () => { expect(started.effectiveRuntimeProfile).toMatchObject({ claudeVersion: CLAUDE_PIN, inputFormat: "stream-json", - isolatedConfigDir: true, model: CLAUDE_PIN_MODEL, outputFormat: "stream-json", permissionMode: "default", preset: "fable-max", reasoningEffort: "max", }); + expect(started.effectiveRuntimeProfile).not.toHaveProperty("configHome"); expect(value.store.latestSessionRuntimeProfile(sessionId)).toMatchObject({ - profile: { claudeVersion: CLAUDE_PIN, preset: "fable-max" }, + profile: { + claudeVersion: CLAUDE_PIN, + configHome: "isolated", + preset: "fable-max", + }, revision: 1, sourceKind: "session_start", }); const process = value.processes[0]; if (process === undefined) throw new Error("Expected one pinned Claude process."); - process.emit(initLine); - await settle(); - const sent = await value.service.execute({ idempotencyKey: crypto.randomUUID(), kind: "session.send", @@ -752,8 +774,6 @@ describe("Claude sessions on the local authority", () => { const sessionId = started.session.id; const process = value.processes[0]; if (process === undefined) throw new Error("Expected one pinned Claude process."); - process.emit(initLine); - await settle(); await value.service.execute({ idempotencyKey: crypto.randomUUID(), kind: "session.send", @@ -811,9 +831,6 @@ describe("Claude sessions on the local authority", () => { }, { signal }) as { session: { id: `sess_${string}` } }; const process = value.processes[0]; if (process === undefined) throw new Error("Expected one pinned Claude process."); - process.emit(initLine); - await settle(); - await value.service.execute({ idempotencyKey: crypto.randomUUID(), kind: "session.send", @@ -887,8 +904,6 @@ describe("Claude sessions on the local authority", () => { }, { signal }) as { session: { id: `sess_${string}` } }; const process = value.processes[0]; if (process === undefined) throw new Error("Expected one pinned Claude process."); - process.emit(initLine); - await settle(); const sent = await value.service.execute({ idempotencyKey: crypto.randomUUID(), kind: "session.send", diff --git a/src/daemon/codex-runtime-adapter.test.ts b/src/daemon/codex-runtime-adapter.test.ts index 3e5bee8..58d9fe0 100644 --- a/src/daemon/codex-runtime-adapter.test.ts +++ b/src/daemon/codex-runtime-adapter.test.ts @@ -22,6 +22,7 @@ import { projectBoundedThread, projectUtf8Text, } from "./codex-runtime-adapter"; +import { CodexClaimReleaseUnprovenError } from "./ports"; import type { CodexAccountProjection, CodexSessionObservationError, ProfileAuthority } from "./ports"; const authority = { @@ -54,8 +55,12 @@ function createRuntimeManager(input: TestRuntimeManagerOptions): PinnedCodexRunt launchClient: async (options) => { const client = await launchClient(options); const mutable = client as unknown as { + assertCredentialStores?: CodexAppServerClient["assertCredentialStores"]; connectionId?: string; + discoverCapabilities?: CodexAppServerClient["discoverCapabilities"]; resumeThread?: CodexAppServerClient["resumeThread"]; + resumeThreadWithPolicy?: CodexAppServerClient["resumeThreadWithPolicy"]; + resolvePreset?: CodexAppServerClient["resolvePreset"]; }; if (typeof mutable.connectionId !== "string") { fakeConnectionSequence += 1; @@ -71,6 +76,55 @@ function createRuntimeManager(input: TestRuntimeManagerOptions): PinnedCodexRunt }, value: makeThread([], threadId), }); + mutable.assertCredentialStores ??= async () => undefined; + mutable.discoverCapabilities ??= async () => ({ + authority: { + profileId: options.authority.profileId, + processGeneration: options.authority.processGeneration, + }, + value: makeCapabilitySnapshot(), + }); + mutable.resolvePreset ??= (_capabilities, alias, requirement, fast) => { + if (requirement.effort === "provider-default") { + throw new Error("Codex cannot resolve a provider-default reasoning effort."); + } + return { + alias, + model: requirement.model, + effort: requirement.effort, + serviceTier: fast ? "priority" : null, + fast, + }; + }; + mutable.resumeThreadWithPolicy ??= async (reviewed) => { + const resumed = await mutable.resumeThread?.(reviewed.threadId); + if (resumed === undefined) throw new Error("missing fake resume implementation"); + return { + authority: resumed.authority, + value: { + thread: resumed.value, + cwd: reviewed.cwd, + model: reviewed.preset.model, + modelProvider: "openai", + reasoningEffort: reviewed.preset.effort, + serviceTier: reviewed.preset.serviceTier, + approvalPolicy: "on-request", + approvalsReviewer: reviewed.policy.review, + sandbox: { + type: "workspaceWrite", + writableRoots: [...reviewed.policy.writableRoots], + networkAccess: false, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false, + }, + activePermissionProfile: { + id: reviewed.policy.permissionProfile, + extends: null, + }, + runtimeWorkspaceRoots: [...reviewed.policy.writableRoots], + }, + }; + }; return client; }, }), @@ -105,6 +159,29 @@ const makeThread = (turns: readonly CodexTurn[], id = "thread-1"): CodexThread = turns, }); +function makeCapabilitySnapshot(): CodexCapabilitySnapshot { + return { + models: [{ + id: "gpt-5.6-sol", + model: "gpt-5.6-sol", + displayName: "GPT-5.6 Sol", + hidden: false, + supportedReasoningEfforts: ["max", "ultra"], + defaultReasoningEffort: "max", + serviceTiers: [{ id: "priority", name: "Fast", description: "Faster" }], + defaultServiceTier: null, + isDefault: true, + }], + features: [ + { name: "computer_use", stage: "stable", enabled: true, defaultEnabled: true }, + { name: "plugins", stage: "stable", enabled: true, defaultEnabled: true }, + ], + permissionProfiles: [{ id: ":workspace", description: null, allowed: true }], + apps: [], + pluginLifecycle: "unsupported-under-development", + }; +} + describe("PinnedCodexRuntimeManager", () => { test("single-flights an exact resumed thread observation by generation and connection", async () => { let resumeCalls = 0; @@ -279,109 +356,915 @@ describe("PinnedCodexRuntimeManager", () => { projection: { providerThreadId, status: "idle" }, resumed: true, }); - expect(resumeCalls).toBe(1); - expect(readCalls).toBe(2); + expect(resumeCalls).toBe(1); + expect(readCalls).toBe(2); + await manager.close(); + }); + + test("keeps an indeterminate resume retired under the same generation even when deterministic relaunch is enabled", async () => { + let resumeCalls = 0; + let closeCalls = 0; + const fake = { + state: "ready", + connectionId: "71000000-0000-4000-8000-000000000002", + resumeThread: async () => { + resumeCalls += 1; + throw new IndeterminateCodexEffectError("thread/resume", 17); + }, + close: async () => { closeCalls += 1; }, + } as unknown as CodexAppServerClient; + const manager = createRuntimeManager({ + allowSameGenerationRelaunchAfterProviderDisconnect: true, + isCurrent: () => true, + observer: { account: () => undefined, fact: () => undefined }, + launchClient: async () => fake, + }); + + await expect(manager.observeSession({ + authority, + providerThreadId: "thread-resume", + signal: new AbortController().signal, + })).rejects.toMatchObject({ + name: "CodexSessionObservationError", + reason: "resume_unavailable", + } satisfies Partial); + await expect(manager.observeSession({ + authority, + providerThreadId: "thread-resume", + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: "AUTHORITY_STALE" }); + expect(resumeCalls).toBe(1); + expect(closeCalls).toBe(1); + await manager.close(); + }); + + test("reports unproven claim release when an indeterminate resume cannot retire its connection", async () => { + const resumeFailure = new IndeterminateCodexEffectError("thread/resume", 18); + const closeFailure = new Error("Synthetic failed retirement after indeterminate resume."); + let closeCalls = 0; + const fake = { + state: "ready", + connectionId: "71000000-0000-4000-8000-000000000018", + resumeThread: async () => { throw resumeFailure; }, + close: async () => { + closeCalls += 1; + if (closeCalls === 1) throw closeFailure; + }, + } as unknown as CodexAppServerClient; + const manager = createRuntimeManager({ + allowSameGenerationRelaunchAfterProviderDisconnect: true, + isCurrent: () => true, + observer: { account: () => undefined, fact: () => undefined }, + launchClient: async () => fake, + }); + + const rejection = await manager.claimSession({ + authority, + providerThreadId: "thread-resume-release-unproven", + projectRoot: "/workspace/project", + preset: "high", + requirement: presetRequirements.high, + fast: false, + signal: new AbortController().signal, + }).catch((error: unknown) => error); + expect(rejection).toBeInstanceOf(CodexClaimReleaseUnprovenError); + expect((rejection as Error).cause).toBeInstanceOf(AggregateError); + expect(closeCalls).toBe(1); + + await manager.close(); + expect(closeCalls).toBe(2); + }); + + test("caches a determinate resume rejection without retiring or retrying the client", async () => { + let resumeCalls = 0; + let readCalls = 0; + let closeCalls = 0; + let onFact: LaunchPinnedCodexOptions["onFact"]; + const connectionId = "71000000-0000-4000-8000-000000000003"; + const providerThreadId = "thread-resume"; + const fake = { + state: "ready", + connectionId, + resumeThread: async () => { + resumeCalls += 1; + throw new CodexError("REMOTE_ERROR", "Codex rejected thread/resume."); + }, + readThread: async () => { + readCalls += 1; + return { + authority: { profileId: authority.id, processGeneration: authority.generation }, + value: makeThread([], providerThreadId), + }; + }, + close: async () => { closeCalls += 1; }, + } as unknown as CodexAppServerClient; + const manager = createRuntimeManager({ + isCurrent: () => true, + observer: { account: () => undefined, fact: () => undefined }, + launchClient: async (options) => { + onFact = options.onFact; + return fake; + }, + }); + + for (let attempt = 0; attempt < 2; attempt += 1) { + await expect(manager.observeSession({ + authority, + providerThreadId, + signal: new AbortController().signal, + })).rejects.toMatchObject({ + name: "CodexSessionObservationError", + reason: "resume_unavailable", + }); + } + expect(resumeCalls).toBe(1); + await onFact?.({ + authority: { profileId: authority.id, processGeneration: authority.generation }, + value: { + type: "threadNameUpdated", + threadId: providerThreadId, + name: "Provider evidence", + connectionId, + }, + }); + await expect(manager.observeSession({ + authority, + providerThreadId, + signal: new AbortController().signal, + })).resolves.toMatchObject({ connectionId, resumed: false }); + expect(resumeCalls).toBe(1); + expect(readCalls).toBe(1); + expect(closeCalls).toBe(0); + await manager.close(); + expect(closeCalls).toBe(1); + }); + + test("claim retries one retained determinate resume rejection on the same connection", async () => { + let resumeCalls = 0; + let policyfulResumeCalls = 0; + let readCalls = 0; + const connectionId = "71000000-0000-4000-8000-000000000006"; + const providerThreadId = "thread-personal-claim"; + const providerAuthority = { + profileId: authority.id, + processGeneration: authority.generation, + }; + const fake = { + state: "ready", + connectionId, + resumeThread: async (threadId: string) => { + resumeCalls += 1; + expect(threadId).toBe(providerThreadId); + if (resumeCalls === 1) { + throw new CodexError("REMOTE_ERROR", "Codex temporarily rejected thread/resume."); + } + return { authority: providerAuthority, value: makeThread([], threadId) }; + }, + resumeThreadWithPolicy: async () => { + policyfulResumeCalls += 1; + throw new Error("claim must not mutate provider policy before durable adoption"); + }, + readThread: async (threadId: string, includeTurns: boolean) => { + readCalls += 1; + expect(threadId).toBe(providerThreadId); + expect(includeTurns).toBe(false); + return { authority: providerAuthority, value: makeThread([], threadId) }; + }, + close: async () => undefined, + } as unknown as CodexAppServerClient; + const manager = createRuntimeManager({ + isCurrent: () => true, + observer: { account: () => undefined, fact: () => undefined }, + launchClient: async () => fake, + }); + + for (let attempt = 0; attempt < 2; attempt += 1) { + await expect(manager.observeSession({ + authority, + providerThreadId, + signal: new AbortController().signal, + })).rejects.toMatchObject({ + name: "CodexSessionObservationError", + reason: "resume_unavailable", + }); + } + expect(resumeCalls).toBe(1); + + const claims = await Promise.all([ + manager.claimSession({ + authority, + providerThreadId, + projectRoot: "/workspace/project", + preset: "high", + requirement: presetRequirements.high, + fast: false, + signal: new AbortController().signal, + }), + manager.claimSession({ + authority, + providerThreadId, + projectRoot: "/workspace/project", + preset: "high", + requirement: presetRequirements.high, + fast: false, + signal: new AbortController().signal, + }), + ]); + expect(claims).toHaveLength(2); + for (const claim of claims) { + expect(claim).toMatchObject({ + connectionId, + effectiveRuntimeProfile: { + model: presetRequirements.high.model, + reasoningEffort: presetRequirements.high.effort, + }, + projection: { providerThreadId, status: "idle" }, + resumed: true, + }); + } + expect({ policyfulResumeCalls, readCalls, resumeCalls }).toEqual({ + policyfulResumeCalls: 0, + readCalls: 2, + resumeCalls: 2, + }); + await expect(manager.observeSession({ + authority, + providerThreadId, + signal: new AbortController().signal, + })).resolves.toMatchObject({ connectionId, projection: { providerThreadId } }); + expect(resumeCalls).toBe(2); + await manager.close(); + }); + + test("claim rejects a resumed thread mismatch instead of accepting foreign custody", async () => { + const requestedThreadId = "thread-personal-requested"; + const resumedThreadId = "thread-personal-other"; + const unsubscribeCalls: string[] = []; + let closeCalls = 0; + const fake = { + state: "ready", + connectionId: "71000000-0000-4000-8000-000000000007", + resumeThread: async () => ({ + authority: { profileId: authority.id, processGeneration: authority.generation }, + value: makeThread([], resumedThreadId), + }), + unsubscribeThread: async (threadId: string) => { + unsubscribeCalls.push(threadId); + return { + authority: { profileId: authority.id, processGeneration: authority.generation }, + value: { status: "unsubscribed" as const }, + }; + }, + close: async () => { closeCalls += 1; }, + } as unknown as CodexAppServerClient; + const manager = createRuntimeManager({ + isCurrent: () => true, + observer: { account: () => undefined, fact: () => undefined }, + launchClient: async () => fake, + }); + + await expect(manager.claimSession({ + authority, + providerThreadId: requestedThreadId, + projectRoot: "/workspace/project", + preset: "high", + requirement: presetRequirements.high, + fast: false, + signal: new AbortController().signal, + })).rejects.toMatchObject({ + name: "CodexSessionObservationError", + reason: "thread_mismatch", + }); + expect(unsubscribeCalls).toEqual([]); + expect(closeCalls).toBe(1); + await expect(manager.readAccount({ + authority, + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: "AUTHORITY_STALE" }); + await manager.close(); + }); + + test("reports unproven claim release when a mismatched resume cannot retire its connection", async () => { + let closeCalls = 0; + const fake = { + state: "ready", + connectionId: "71000000-0000-4000-8000-000000000076", + resumeThread: async () => ({ + authority: { profileId: authority.id, processGeneration: authority.generation }, + value: makeThread([], "thread-personal-mismatch-foreign"), + }), + close: async () => { + closeCalls += 1; + if (closeCalls === 1) throw new Error("Synthetic failed mismatch retirement."); + }, + } as unknown as CodexAppServerClient; + const manager = createRuntimeManager({ + isCurrent: () => true, + observer: { account: () => undefined, fact: () => undefined }, + launchClient: async () => fake, + }); + + const rejection = await manager.claimSession({ + authority, + providerThreadId: "thread-personal-mismatch-requested", + projectRoot: "/workspace/project", + preset: "high", + requirement: presetRequirements.high, + fast: false, + signal: new AbortController().signal, + }).catch((error: unknown) => error); + expect(rejection).toBeInstanceOf(CodexClaimReleaseUnprovenError); + expect((rejection as Error).cause).toBeInstanceOf(AggregateError); + expect(closeCalls).toBe(1); + + await manager.close(); + expect(closeCalls).toBe(2); + }); + + test("claim immediately unsubscribes a resumed thread when its metadata read fails", async () => { + const providerThreadId = "thread-personal-claim-read-failure"; + const providerAuthority = { + profileId: authority.id, + processGeneration: authority.generation, + }; + const readFailure = new CodexError("PROTOCOL_ERROR", "Synthetic metadata read failure."); + const events: string[] = []; + const fake = { + state: "ready", + connectionId: "71000000-0000-4000-8000-000000000071", + resumeThread: async (threadId: string) => { + events.push(`resume:${threadId}`); + return { authority: providerAuthority, value: makeThread([], threadId) }; + }, + readThread: async (threadId: string) => { + events.push(`read:${threadId}`); + throw readFailure; + }, + unsubscribeThread: async (threadId: string) => { + events.push(`unsubscribe:${threadId}`); + return { authority: providerAuthority, value: { status: "unsubscribed" as const } }; + }, + close: async () => undefined, + } as unknown as CodexAppServerClient; + const manager = createRuntimeManager({ + isCurrent: () => true, + observer: { account: () => undefined, fact: () => undefined }, + launchClient: async () => fake, + }); + + await expect(manager.claimSession({ + authority, + providerThreadId, + projectRoot: "/workspace/project", + preset: "high", + requirement: presetRequirements.high, + fast: false, + signal: new AbortController().signal, + })).rejects.toBe(readFailure); + expect(events).toEqual([ + `resume:${providerThreadId}`, + `read:${providerThreadId}`, + `unsubscribe:${providerThreadId}`, + ]); + await manager.close(); + }); + + test("claim retires the connection when resumed-thread metadata names another thread", async () => { + const providerThreadId = "thread-personal-claim-metadata-mismatch"; + const providerAuthority = { + profileId: authority.id, + processGeneration: authority.generation, + }; + const unsubscribeCalls: string[] = []; + let closeCalls = 0; + const fake = { + state: "ready", + connectionId: "71000000-0000-4000-8000-000000000072", + resumeThread: async (threadId: string) => ({ + authority: providerAuthority, + value: makeThread([], threadId), + }), + readThread: async () => ({ + authority: providerAuthority, + value: makeThread([], "thread-personal-claim-metadata-other"), + }), + unsubscribeThread: async (threadId: string) => { + unsubscribeCalls.push(threadId); + return { authority: providerAuthority, value: { status: "unsubscribed" as const } }; + }, + close: async () => { closeCalls += 1; }, + } as unknown as CodexAppServerClient; + const manager = createRuntimeManager({ + isCurrent: () => true, + observer: { account: () => undefined, fact: () => undefined }, + launchClient: async () => fake, + }); + + await expect(manager.claimSession({ + authority, + providerThreadId, + projectRoot: "/workspace/project", + preset: "high", + requirement: presetRequirements.high, + fast: false, + signal: new AbortController().signal, + })).rejects.toMatchObject({ + name: "CodexSessionObservationError", + reason: "thread_mismatch", + }); + expect(unsubscribeCalls).toEqual([]); + expect(closeCalls).toBe(1); + await expect(manager.readAccount({ + authority, + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: "AUTHORITY_STALE" }); + await manager.close(); + }); + + test("claim immediately unsubscribes a resumed thread when its caller aborts after metadata", async () => { + const providerThreadId = "thread-personal-claim-aborted"; + const providerAuthority = { + profileId: authority.id, + processGeneration: authority.generation, + }; + const controller = new AbortController(); + const abortReason = new Error("Synthetic post-resume claim abort."); + const unsubscribeCalls: string[] = []; + const fake = { + state: "ready", + connectionId: "71000000-0000-4000-8000-000000000073", + resumeThread: async (threadId: string) => ({ + authority: providerAuthority, + value: makeThread([], threadId), + }), + readThread: async (threadId: string) => { + controller.abort(abortReason); + return { authority: providerAuthority, value: makeThread([], threadId) }; + }, + unsubscribeThread: async (threadId: string) => { + unsubscribeCalls.push(threadId); + return { authority: providerAuthority, value: { status: "unsubscribed" as const } }; + }, + close: async () => undefined, + } as unknown as CodexAppServerClient; + const manager = createRuntimeManager({ + isCurrent: () => true, + observer: { account: () => undefined, fact: () => undefined }, + launchClient: async () => fake, + }); + + await expect(manager.claimSession({ + authority, + providerThreadId, + projectRoot: "/workspace/project", + preset: "high", + requirement: presetRequirements.high, + fast: false, + signal: controller.signal, + })).rejects.toBe(abortReason); + expect(unsubscribeCalls).toEqual([providerThreadId]); + await manager.close(); + }); + + test("claim retires the connection when failed-claim unsubscribe is indeterminate", async () => { + const providerThreadId = "thread-personal-claim-release-unknown"; + const providerAuthority = { + profileId: authority.id, + processGeneration: authority.generation, + }; + const readFailure = new CodexError("PROTOCOL_ERROR", "Synthetic metadata read failure."); + let closeCalls = 0; + const fake = { + state: "ready", + connectionId: "71000000-0000-4000-8000-000000000074", + resumeThread: async (threadId: string) => ({ + authority: providerAuthority, + value: makeThread([], threadId), + }), + readThread: async () => { + throw readFailure; + }, + unsubscribeThread: async () => { + throw new IndeterminateCodexEffectError("thread/unsubscribe", 74); + }, + close: async () => { + closeCalls += 1; + }, + } as unknown as CodexAppServerClient; + const manager = createRuntimeManager({ + isCurrent: () => true, + observer: { account: () => undefined, fact: () => undefined }, + launchClient: async () => fake, + }); + + await expect(manager.claimSession({ + authority, + providerThreadId, + projectRoot: "/workspace/project", + preset: "high", + requirement: presetRequirements.high, + fast: false, + signal: new AbortController().signal, + })).rejects.toBe(readFailure); + expect(closeCalls).toBe(1); + await expect(manager.readAccount({ + authority, + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: "AUTHORITY_STALE" }); + await manager.close(); + }); + + test("reports unproven claim release when unsubscribe and connection retirement both fail", async () => { + const providerThreadId = "thread-personal-claim-release-failed"; + const providerAuthority = { + profileId: authority.id, + processGeneration: authority.generation, + }; + const readFailure = new CodexError("PROTOCOL_ERROR", "Synthetic metadata read failure."); + let closeCalls = 0; + const fake = { + state: "ready", + connectionId: "71000000-0000-4000-8000-000000000075", + resumeThread: async (threadId: string) => ({ + authority: providerAuthority, + value: makeThread([], threadId), + }), + readThread: async () => { throw readFailure; }, + unsubscribeThread: async () => { + throw new IndeterminateCodexEffectError("thread/unsubscribe", 75); + }, + close: async () => { + closeCalls += 1; + if (closeCalls === 1) throw new Error("Synthetic failed claim connection retirement."); + }, + } as unknown as CodexAppServerClient; + const manager = createRuntimeManager({ + isCurrent: () => true, + observer: { account: () => undefined, fact: () => undefined }, + launchClient: async () => fake, + }); + + const rejection = await manager.claimSession({ + authority, + providerThreadId, + projectRoot: "/workspace/project", + preset: "high", + requirement: presetRequirements.high, + fast: false, + signal: new AbortController().signal, + }).catch((error: unknown) => error); + expect(rejection).toBeInstanceOf(CodexClaimReleaseUnprovenError); + expect((rejection as Error).cause).toBeInstanceOf(AggregateError); + expect(closeCalls).toBe(1); + + await manager.close(); + expect(closeCalls).toBe(2); + }); + + test("endSession unsubscribes the exact thread and clears its observation proof", async () => { + let resumeCalls = 0; + const unsubscribeCalls: string[] = []; + const providerThreadId = "thread-personal-release"; + const providerAuthority = { + profileId: authority.id, + processGeneration: authority.generation, + }; + const fake = { + state: "ready", + connectionId: "71000000-0000-4000-8000-000000000008", + resumeThread: async (threadId: string) => { + resumeCalls += 1; + return { authority: providerAuthority, value: makeThread([], threadId) }; + }, + readThread: async (threadId: string) => ({ + authority: providerAuthority, + value: makeThread([], threadId), + }), + unsubscribeThread: async (threadId: string) => { + unsubscribeCalls.push(threadId); + return { authority: providerAuthority, value: { status: "unsubscribed" as const } }; + }, + close: async () => undefined, + } as unknown as CodexAppServerClient; + const manager = createRuntimeManager({ + isCurrent: () => true, + observer: { account: () => undefined, fact: () => undefined }, + launchClient: async () => fake, + }); + + await manager.observeSession({ + authority, + providerThreadId, + signal: new AbortController().signal, + }); + await manager.endSession({ + authority, + providerThreadId, + signal: new AbortController().signal, + }); + await manager.observeSession({ + authority, + providerThreadId, + signal: new AbortController().signal, + }); + expect(unsubscribeCalls).toEqual([providerThreadId]); + expect(resumeCalls).toBe(2); + await manager.close(); + }); + + test("endSession waits for an in-flight resume before releasing custody", async () => { + let releaseResume!: () => void; + let markResumeEntered!: () => void; + const resumeGate = new Promise((resolve) => { releaseResume = resolve; }); + const resumeEntered = new Promise((resolve) => { markResumeEntered = resolve; }); + const events: string[] = []; + const providerThreadId = "thread-personal-release-race"; + const providerAuthority = { + profileId: authority.id, + processGeneration: authority.generation, + }; + const fake = { + state: "ready", + connectionId: "71000000-0000-4000-8000-000000000009", + resumeThread: async (threadId: string) => { + events.push("resume-start"); + markResumeEntered(); + await resumeGate; + events.push("resume-finish"); + return { authority: providerAuthority, value: makeThread([], threadId) }; + }, + readThread: async (threadId: string) => ({ + authority: providerAuthority, + value: makeThread([], threadId), + }), + unsubscribeThread: async () => { + events.push("unsubscribe"); + return { authority: providerAuthority, value: { status: "unsubscribed" as const } }; + }, + close: async () => undefined, + } as unknown as CodexAppServerClient; + const manager = createRuntimeManager({ + isCurrent: () => true, + observer: { account: () => undefined, fact: () => undefined }, + launchClient: async () => fake, + }); + + const observing = manager.observeSession({ + authority, + providerThreadId, + signal: new AbortController().signal, + }); + await resumeEntered; + const ending = manager.endSession({ + authority, + providerThreadId, + signal: new AbortController().signal, + }); + await Promise.resolve(); + expect(events).toEqual(["resume-start"]); + releaseResume(); + await Promise.all([observing, ending]); + expect(events).toEqual(["resume-start", "resume-finish", "unsubscribe"]); + await manager.close(); + }); + + test("endSession clears stale custody proof after an indeterminate release", async () => { + let resumeCalls = 0; + const providerThreadId = "thread-personal-release-unknown"; + const providerAuthority = { + profileId: authority.id, + processGeneration: authority.generation, + }; + const fake = { + state: "ready", + connectionId: "71000000-0000-4000-8000-000000000010", + resumeThread: async (threadId: string) => { + resumeCalls += 1; + return { authority: providerAuthority, value: makeThread([], threadId) }; + }, + readThread: async (threadId: string) => ({ + authority: providerAuthority, + value: makeThread([], threadId), + }), + unsubscribeThread: async () => { + throw new IndeterminateCodexEffectError("thread/unsubscribe", 29); + }, + close: async () => undefined, + } as unknown as CodexAppServerClient; + const manager = createRuntimeManager({ + isCurrent: () => true, + observer: { account: () => undefined, fact: () => undefined }, + launchClient: async () => fake, + }); + + await manager.observeSession({ + authority, + providerThreadId, + signal: new AbortController().signal, + }); + await expect(manager.endSession({ + authority, + providerThreadId, + signal: new AbortController().signal, + })).rejects.toMatchObject({ + code: "INDETERMINATE_EFFECT", + operation: "thread/unsubscribe", + }); + await manager.observeSession({ + authority, + providerThreadId, + signal: new AbortController().signal, + }); + expect(resumeCalls).toBe(2); + await manager.close(); + }); + + test("releases only an existing exact account generation without thread RPCs or relaunch", async () => { + let launches = 0; + let closeCalls = 0; + let accountReads = 0; + let threadCalls = 0; + const providerAuthority = { + profileId: authority.id, + processGeneration: authority.generation, + }; + const fake = { + state: "ready", + accountRead: async () => { + accountReads += 1; + return { + authority: providerAuthority, + value: { account: null, requiresOpenaiAuth: true }, + }; + }, + resumeThread: async () => { + threadCalls += 1; + throw new Error("release must not resume a thread"); + }, + unsubscribeThread: async () => { + threadCalls += 1; + throw new Error("release must not unsubscribe a thread"); + }, + close: async () => { closeCalls += 1; }, + } as unknown as CodexAppServerClient; + const manager = createRuntimeManager({ + isCurrent: () => true, + observer: { account: () => undefined, fact: () => undefined }, + launchClient: async () => { + launches += 1; + return fake; + }, + }); + const signal = new AbortController().signal; + + await manager.releaseOwnedAuthority({ authority, signal }); + expect(launches).toBe(0); + await manager.readAccount({ authority, signal }); + expect(launches).toBe(1); + await manager.releaseOwnedAuthority({ + authority: { ...authority, generation: authority.generation + 1 }, + signal, + }); + expect(closeCalls).toBe(0); + await manager.releaseOwnedAuthority({ authority, signal }); + await manager.releaseOwnedAuthority({ authority, signal }); + + expect({ accountReads, closeCalls, launches, threadCalls }).toEqual({ + accountReads: 1, + closeCalls: 1, + launches: 1, + threadCalls: 0, + }); + await expect(manager.readAccount({ authority, signal })) + .rejects.toMatchObject({ code: "AUTHORITY_STALE" }); + expect(launches).toBe(1); + await manager.close(); + expect(closeCalls).toBe(1); + }); + + test("retains a failed exact authority close and retries it without relaunch", async () => { + let launches = 0; + let closeCalls = 0; + const closeFailure = new CodexError( + "PROCESS_EXITED", + "Codex process exit could not be proven after force termination", + ); + const fake = { + state: "ready", + accountRead: async () => ({ + authority: { profileId: authority.id, processGeneration: authority.generation }, + value: { account: null, requiresOpenaiAuth: true }, + }), + close: async () => { + closeCalls += 1; + if (closeCalls === 1) throw closeFailure; + }, + } as unknown as CodexAppServerClient; + const manager = createRuntimeManager({ + isCurrent: () => true, + observer: { account: () => undefined, fact: () => undefined }, + launchClient: async () => { + launches += 1; + return fake; + }, + }); + const signal = new AbortController().signal; + + await manager.readAccount({ authority, signal }); + await expect(manager.releaseOwnedAuthority({ authority, signal })).rejects.toBe(closeFailure); + await expect(manager.readAccount({ authority, signal })) + .rejects.toMatchObject({ code: "AUTHORITY_STALE" }); + await expect(manager.releaseOwnedAuthority({ authority, signal })).resolves.toBeUndefined(); + + expect({ closeCalls, launches }).toEqual({ closeCalls: 2, launches: 1 }); await manager.close(); }); - test("retires an indeterminate resume without retrying it under the same generation", async () => { - let resumeCalls = 0; + test("propagates exact process-close failure from manager shutdown and permits a retry", async () => { let closeCalls = 0; + const closeFailure = new CodexError( + "PROCESS_EXITED", + "Codex process exit could not be proven after force termination", + ); const fake = { state: "ready", - connectionId: "71000000-0000-4000-8000-000000000002", - resumeThread: async () => { - resumeCalls += 1; - throw new IndeterminateCodexEffectError("thread/resume", 17); + accountRead: async () => ({ + authority: { profileId: authority.id, processGeneration: authority.generation }, + value: { account: null, requiresOpenaiAuth: true }, + }), + close: async () => { + closeCalls += 1; + if (closeCalls === 1) throw closeFailure; }, - close: async () => { closeCalls += 1; }, } as unknown as CodexAppServerClient; const manager = createRuntimeManager({ isCurrent: () => true, observer: { account: () => undefined, fact: () => undefined }, launchClient: async () => fake, }); + const signal = new AbortController().signal; - await expect(manager.observeSession({ - authority, - providerThreadId: "thread-resume", - signal: new AbortController().signal, - })).rejects.toMatchObject({ - name: "CodexSessionObservationError", - reason: "resume_unavailable", - } satisfies Partial); - await expect(manager.observeSession({ - authority, - providerThreadId: "thread-resume", - signal: new AbortController().signal, - })).rejects.toMatchObject({ code: "AUTHORITY_STALE" }); - expect(resumeCalls).toBe(1); - expect(closeCalls).toBe(1); - await manager.close(); + await manager.readAccount({ authority, signal }); + await expect(manager.close()).rejects.toBe(closeFailure); + await expect(manager.close()).resolves.toBeUndefined(); + expect(closeCalls).toBe(2); }); - test("caches a determinate resume rejection without retiring or retrying the client", async () => { - let resumeCalls = 0; - let readCalls = 0; + test("queues a nonlaunching authority release behind an in-flight first launch", async () => { + let launches = 0; let closeCalls = 0; - let onFact: LaunchPinnedCodexOptions["onFact"]; - const connectionId = "71000000-0000-4000-8000-000000000003"; - const providerThreadId = "thread-resume"; + let threadCalls = 0; + let releaseLaunch!: () => void; + let markLaunchStarted!: () => void; + const launchGate = new Promise((resolve) => { releaseLaunch = resolve; }); + const launchStarted = new Promise((resolve) => { markLaunchStarted = resolve; }); const fake = { state: "ready", - connectionId, + accountRead: async () => ({ + authority: { profileId: authority.id, processGeneration: authority.generation }, + value: { account: null, requiresOpenaiAuth: true }, + }), resumeThread: async () => { - resumeCalls += 1; - throw new CodexError("REMOTE_ERROR", "Codex rejected thread/resume."); + threadCalls += 1; + throw new Error("release must not resume a thread"); }, - readThread: async () => { - readCalls += 1; - return { - authority: { profileId: authority.id, processGeneration: authority.generation }, - value: makeThread([], providerThreadId), - }; + unsubscribeThread: async () => { + threadCalls += 1; + throw new Error("release must not unsubscribe a thread"); }, close: async () => { closeCalls += 1; }, } as unknown as CodexAppServerClient; const manager = createRuntimeManager({ isCurrent: () => true, observer: { account: () => undefined, fact: () => undefined }, - launchClient: async (options) => { - onFact = options.onFact; + launchClient: async () => { + launches += 1; + markLaunchStarted(); + await launchGate; return fake; }, }); + const signal = new AbortController().signal; + const firstUse = manager.readAccount({ authority, signal }).catch((error: unknown) => error); + await launchStarted; + let released = false; + const release = manager.releaseOwnedAuthority({ authority, signal }) + .then(() => { released = true; }); + await Promise.resolve(); + expect(released).toBe(false); - for (let attempt = 0; attempt < 2; attempt += 1) { - await expect(manager.observeSession({ - authority, - providerThreadId, - signal: new AbortController().signal, - })).rejects.toMatchObject({ - name: "CodexSessionObservationError", - reason: "resume_unavailable", - }); - } - expect(resumeCalls).toBe(1); - await onFact?.({ - authority: { profileId: authority.id, processGeneration: authority.generation }, - value: { - type: "threadNameUpdated", - threadId: providerThreadId, - name: "Provider evidence", - connectionId, - }, + releaseLaunch(); + await release; + await firstUse; + expect({ closeCalls, launches, threadCalls }).toEqual({ + closeCalls: 1, + launches: 1, + threadCalls: 0, }); - await expect(manager.observeSession({ - authority, - providerThreadId, - signal: new AbortController().signal, - })).resolves.toMatchObject({ connectionId, resumed: false }); - expect(resumeCalls).toBe(1); - expect(readCalls).toBe(1); - expect(closeCalls).toBe(0); + await expect(manager.readAccount({ authority, signal })) + .rejects.toMatchObject({ code: "AUTHORITY_STALE" }); + expect(launches).toBe(1); await manager.close(); - expect(closeCalls).toBe(1); }); test("forwards Codex thread-list cursors and preserves the provider continuation", async () => { @@ -424,6 +1307,49 @@ describe("PinnedCodexRuntimeManager", () => { await manager.close(); }); + test("reads exact scheduled-target metadata without resuming or observing the thread", async () => { + let current = true; + let resumeCalls = 0; + const reads: Array> = []; + const providerAuthority = { + profileId: authority.id, + processGeneration: authority.generation, + }; + const fake = { + state: "ready", + readThread: async (threadId: string, includeTurns: boolean) => { + reads.push({ includeTurns, threadId }); + if (threadId === "scheduled-stale") current = false; + return { authority: providerAuthority, value: makeThread([], threadId) }; + }, + resumeThread: async () => { + resumeCalls += 1; + throw new Error("metadata discovery must not resume a thread"); + }, + close: async () => undefined, + } as unknown as CodexAppServerClient; + const manager = createRuntimeManager({ + isCurrent: () => current, + observer: { account: () => undefined, fact: () => undefined }, + launchClient: async () => fake, + }); + const requestSignal = new AbortController().signal; + + await expect(manager.readSessionMetadata(authority, "scheduled-old", requestSignal)) + .resolves.toMatchObject({ + providerThreadId: "scheduled-old", + status: "idle", + title: "Projection test", + }); + expect(reads).toEqual([{ includeTurns: false, threadId: "scheduled-old" }]); + expect(resumeCalls).toBe(0); + + await expect(manager.readSessionMetadata(authority, "scheduled-stale", requestSignal)) + .rejects.toMatchObject({ code: "AUTHORITY_STALE" }); + expect(resumeCalls).toBe(0); + await manager.close(); + }); + test("prepares each isolated Codex home and threads its bounded launch policy", async () => { const steps: string[] = []; const now = () => 12_345; @@ -622,8 +1548,10 @@ describe("PinnedCodexRuntimeManager", () => { let launches = 0; let launched: LaunchPinnedCodexOptions | undefined; const facts: CodexFact[] = []; + const connectionId = "018f1f55-3f10-7c1a-8f7b-c6dc608bcd3b"; const fake = { get state() { return state; }, + connectionId, accountRead: async () => ({ authority: { profileId: authority.id, processGeneration: authority.generation }, value: { @@ -658,13 +1586,13 @@ describe("PinnedCodexRuntimeManager", () => { authority: { profileId: authority.id, processGeneration: authority.generation }, value: { type: "providerDisconnected", - connectionId: "018f1f55-3f10-7c1a-8f7b-c6dc608bcd3b", + connectionId, reason: "process_exit", }, }); expect(facts).toEqual([{ type: "providerDisconnected", - connectionId: "018f1f55-3f10-7c1a-8f7b-c6dc608bcd3b", + connectionId, reason: "process_exit", }]); await expect(manager.readAccount({ @@ -675,6 +1603,103 @@ describe("PinnedCodexRuntimeManager", () => { await manager.close(); }); + test("single-flights a managed reconnect when its generation remains current after an exact disconnect", async () => { + const firstConnectionId = "018f1f55-3f10-7c1a-8f7b-c6dc608bcd31"; + const secondConnectionId = "018f1f55-3f10-7c1a-8f7b-c6dc608bcd32"; + const providerThreadId = "thread-managed-reconnect"; + const providerAuthority = { + profileId: authority.id, + processGeneration: authority.generation, + }; + let firstState: CodexAppServerClient["state"] = "ready"; + let firstCloseCalls = 0; + let secondCloseCalls = 0; + let launches = 0; + const launchGenerations: number[] = []; + const resumeCalls = [0, 0]; + let firstOnFact: LaunchPinnedCodexOptions["onFact"]; + const clients = [ + { + get state() { return firstState; }, + connectionId: firstConnectionId, + resumeThread: async (threadId: string) => { + resumeCalls[0] = (resumeCalls[0] ?? 0) + 1; + return { authority: providerAuthority, value: makeThread([], threadId) }; + }, + readThread: async (threadId: string) => ({ + authority: providerAuthority, + value: makeThread([], threadId), + }), + close: async () => { + firstCloseCalls += 1; + firstState = "closed"; + }, + }, + { + state: "ready", + connectionId: secondConnectionId, + resumeThread: async (threadId: string) => { + resumeCalls[1] = (resumeCalls[1] ?? 0) + 1; + return { authority: providerAuthority, value: makeThread([], threadId) }; + }, + readThread: async (threadId: string) => ({ + authority: providerAuthority, + value: makeThread([], threadId), + }), + close: async () => { secondCloseCalls += 1; }, + }, + ] as unknown as CodexAppServerClient[]; + const manager = createRuntimeManager({ + allowSameGenerationRelaunchAfterProviderDisconnect: true, + isCurrent: () => true, + observer: { account: () => undefined, fact: () => undefined }, + launchClient: async (options) => { + launchGenerations.push(options.authority.processGeneration); + const index = launches; + launches += 1; + if (index === 0) firstOnFact = options.onFact; + if (index === 1) expect(firstCloseCalls).toBe(1); + const client = clients[index]; + if (client === undefined) throw new Error("Unexpected extra Codex launch."); + return client; + }, + }); + + await expect(manager.observeSession({ + authority, + providerThreadId, + signal: new AbortController().signal, + })).resolves.toMatchObject({ connectionId: firstConnectionId, resumed: true }); + expect(resumeCalls).toEqual([1, 0]); + firstState = "failed"; + if (firstOnFact === undefined) throw new Error("Missing first connection fact observer."); + await firstOnFact({ + authority: providerAuthority, + value: { + type: "providerDisconnected", + connectionId: firstConnectionId, + reason: "process_exit", + }, + }); + + const reconnect = () => manager.observeSession({ + authority, + providerThreadId, + signal: new AbortController().signal, + }); + const observations = await Promise.all([reconnect(), reconnect()]); + expect(observations).toHaveLength(2); + expect(observations[0]).toMatchObject({ connectionId: secondConnectionId, resumed: true }); + expect(observations[1]).toMatchObject({ connectionId: secondConnectionId, resumed: true }); + expect(secondConnectionId).not.toBe(firstConnectionId); + expect(launches).toBe(2); + expect(launchGenerations).toEqual([authority.generation, authority.generation]); + expect(firstCloseCalls).toBe(1); + expect(resumeCalls).toEqual([1, 1]); + await manager.close(); + expect(secondCloseCalls).toBe(1); + }); + test("routes interaction responses only to the exact live connection and generation", async () => { const connectionId = "018f1f55-3f10-7c1a-8f7b-c6dc608bcd3b"; const calls: unknown[] = []; @@ -1288,42 +2313,281 @@ describe("PinnedCodexRuntimeManager", () => { expect(detail?.items[3]).toMatchObject({ paths: [`src/${safe}.ts`] }); }); - test("redacts complete transcript secrets and local paths before compact cloud projection", () => { - const privatePath = ["", "Users", "alice", "private", "token.txt"].join("/"); - const thread = makeThread([makeTurn("turn-private", [ - { - type: "userMessage", - id: "user-private", - clientId: "client-private", - text: [ - "Before Authorization: Bearer USER-PROJECTION-SECRET-11", - `Read ${privatePath}`, - ], - }, + test("redacts complete transcript secrets and local paths before compact cloud projection", () => { + const privatePath = ["", "Users", "alice", "private", "token.txt"].join("/"); + const thread = makeThread([makeTurn("turn-private", [ + { + type: "userMessage", + id: "user-private", + clientId: "client-private", + text: [ + "Before Authorization: Bearer USER-PROJECTION-SECRET-11", + `Read ${privatePath}`, + ], + }, + { + type: "reasoning", + id: "reason-private", + summary: ["device_code=REASONING-PROJECTION-SECRET-22 while checking"], + }, + { + type: "agentMessage", + id: "agent-private", + text: "Result api_key=ASSISTANT-PROJECTION-SECRET-33 complete", + }, + ])]); + const projection = projectBoundedThread(thread, true); + const serialized = JSON.stringify(projection); + expect(serialized).not.toContain("PROJECTION-SECRET"); + expect(serialized).not.toContain(privatePath); + expect(serialized).toContain("[protected]"); + expect(serialized).toContain("[local-path]"); + expect(projection.messages?.map((message) => message.text)).toEqual([ + "Before [protected]", + "Result [protected] complete", + ]); + const detail = (projection.turns as { items: Record[] }[])[0]; + expect(JSON.stringify(detail?.items)).not.toContain("PROJECTION-SECRET"); + expect(JSON.stringify(detail?.items)).not.toContain(privatePath); + }); + + test("replaces an exact Codex Desktop heartbeat envelope on every session projection surface", () => { + const heartbeat = [ + "", + " weekly-project-maintenance", + " 2030-01-02T03:04:05.678Z", + " ", + " Review the synthetic fixture project.", + " Report any synthetic maintenance work.", + " ", + "", + ].join("\n"); + const thread = { + ...makeThread([makeTurn("turn-private-injection", [ + { + type: "userMessage", + id: "user-private-injection", + clientId: "client-private-injection", + text: [heartbeat], + }, + { + type: "reasoning", + id: "reasoning-private-injection", + summary: [heartbeat], + }, + { + type: "agentMessage", + id: "assistant-private-injection", + text: heartbeat, + }, + ])]), + name: null, + preview: heartbeat, + }; + const truncatedHeartbeat = heartbeat.slice(0, heartbeat.indexOf("\n ")); + + const projection = projectBoundedThread(thread, true); + const marker = "[protected]"; + expect(projection.title).toBe(marker); + const protectedTitlePrefixes = [ + "", + "\n", + "\n ", + "\n \n ", + "\n weekly-project-maintenance", + heartbeat.slice(0, heartbeat.indexOf("\n ")), + truncatedHeartbeat, + heartbeat, + ]; + for (const title of protectedTitlePrefixes) { + expect(projectBoundedThread({ + ...thread, + name: title, + preview: "Ordinary preview", + }, true).title).toBe(marker); + expect(projectBoundedThread({ + ...thread, + name: null, + preview: title, + }, true).title).toBe(marker); + } + expect(projectBoundedThread({ + ...thread, + name: "\nordinary user title", + preview: "Ordinary preview", + }, true).title).toBe("�ordinary user title"); + expect(projection.messages).toEqual([ + { + role: "user", + text: marker, + turnId: "turn-private-injection", + clientId: "client-private-injection", + }, + { + role: "assistant", + text: marker, + turnId: "turn-private-injection", + }, + ]); + const detail = (projection.turns as { items: Record[] }[])[0]; + expect(detail?.items[0]).toMatchObject({ content: [{ text: marker }] }); + expect(detail?.items[1]).toMatchObject({ summary: [{ text: marker }] }); + expect(detail?.items[2]).toMatchObject({ text: marker }); + const serialized = JSON.stringify(projection); + expect(serialized).not.toContain("weekly-project-maintenance"); + expect(serialized).not.toContain("2030-01-02T03:04:05.678Z"); + expect(serialized).not.toContain("synthetic fixture project"); + expect(serialized).not.toContain("synthetic maintenance work"); + expect(serialized.toLowerCase()).not.toContain("heartbeat"); + expect(serialized.toLowerCase()).not.toContain("scheduled"); + expect(serialized.toLowerCase()).not.toContain("automation"); + }); + + test("replaces an exact Codex Desktop heartbeat envelope in live thread-name facts", async () => { + const heartbeat = [ + "", + " synthetic-live-task", + " 2030-01-02T03:04:05.678Z", + " ", + " Continue the synthetic live task.", + " ", + "", + ].join("\n"); + const truncatedHeartbeat = heartbeat.slice(0, heartbeat.indexOf("\n ")); + const facts: CodexFact[] = []; + let onFact: LaunchPinnedCodexOptions["onFact"]; + const connectionId = "71000000-0000-4000-8000-000000000099"; + const fake = { + state: "ready", + connectionId, + accountRead: async () => ({ + authority: { profileId: authority.id, processGeneration: authority.generation }, + value: { + account: { type: "chatgpt", email: "person@example.com", planType: "pro" }, + requiresOpenaiAuth: true, + }, + }), + close: async () => undefined, + } as unknown as CodexAppServerClient; + const manager = createRuntimeManager({ + isCurrent: () => true, + observer: { + account: () => undefined, + fact: (_authority, fact) => { facts.push(fact); }, + }, + launchClient: async (options) => { + onFact = options.onFact; + return fake; + }, + }); + + await manager.readAccount({ authority, signal: new AbortController().signal }); + if (onFact === undefined) throw new Error("Missing live fact observer."); + await onFact({ + authority: { profileId: authority.id, processGeneration: authority.generation }, + value: { + type: "threadNameUpdated", + threadId: "thread-live-heartbeat", + name: truncatedHeartbeat, + connectionId, + }, + }); + + expect(facts).toEqual([{ + type: "threadNameUpdated", + threadId: "thread-live-heartbeat", + name: "[protected]", + connectionId, + }]); + expect(JSON.stringify(facts)).not.toContain("synthetic-live-task"); + expect(JSON.stringify(facts)).not.toContain("synthetic live task"); + await manager.close(); + }); + + test("leaves heartbeat near-matches unchanged and protects exact envelopes for either role", () => { + const exact = [ + "", + " ordinary-task", + " 2030-01-02T03:04:05.678Z", + " ", + " Continue the ordinary review.", + " ", + "", + ].join("\n"); + const nearMatches = [ + ` ${exact}`, + `${exact}\n`, + exact.replaceAll("\n", "\r\n"), + exact.replace(" ", " "), + exact.replace(" Continue the ordinary review.", " Continue the ordinary review."), + exact.replace("", ""), + exact.replace("ordinary-task", " "), + exact.replace( + "2030-01-02T03:04:05.678Z", + "2030-01-02T03:04:05Z", + ), + exact.replace( + "2030-01-02T03:04:05.678Z", + "2030-02-30T03:04:05.678Z", + ), + exact.replace( + " ordinary-task\n 2030-01-02T03:04:05.678Z", + " 2030-01-02T03:04:05.678Z\n ordinary-task", + ), + exact.slice(0, exact.indexOf("\n ")), + ]; + const turns = nearMatches.map((text, index) => makeTurn(`turn-near-${String(index)}`, [ { - type: "reasoning", - id: "reason-private", - summary: ["device_code=REASONING-PROJECTION-SECRET-22 while checking"], + type: "userMessage" as const, + id: `user-near-${String(index)}`, + clientId: `client-near-${String(index)}`, + text: [text], }, { - type: "agentMessage", - id: "agent-private", - text: "Result api_key=ASSISTANT-PROJECTION-SECRET-33 complete", + type: "agentMessage" as const, + id: `assistant-near-${String(index)}`, + text, }, - ])]); - const projection = projectBoundedThread(thread, true); - const serialized = JSON.stringify(projection); - expect(serialized).not.toContain("PROJECTION-SECRET"); - expect(serialized).not.toContain(privatePath); - expect(serialized).toContain("[protected]"); - expect(serialized).toContain("[local-path]"); - expect(projection.messages?.map((message) => message.text)).toEqual([ - "Before [protected]", - "Result [protected] complete", - ]); - const detail = (projection.turns as { items: Record[] }[])[0]; - expect(JSON.stringify(detail?.items)).not.toContain("PROJECTION-SECRET"); - expect(JSON.stringify(detail?.items)).not.toContain(privatePath); + ])); + turns.push(makeTurn("turn-assistant-envelope", [{ + type: "agentMessage", + id: "assistant-envelope", + text: exact, + }])); + turns.push(makeTurn("turn-ordinary-user", [{ + type: "userMessage", + id: "ordinary-user", + clientId: "ordinary-client", + text: ["Please continue the ordinary review."], + }])); + + const projection = projectBoundedThread(makeThread(turns), true); + const marker = "[protected]"; + for (const [index] of nearMatches.entries()) { + const turnId = `turn-near-${String(index)}`; + const messages = projection.messages?.filter((message) => message.turnId === turnId); + expect(messages).toHaveLength(2); + expect(messages?.[0]?.text).toBe(messages?.[1]?.text); + expect(messages?.[0]?.text).not.toBe(marker); + const detail = (projection.turns as { + id: string; + items: Array<{ content?: Array<{ text: string }>; text?: string }>; + }[]).find((turn) => turn.id === turnId); + expect(detail?.items[0]?.content?.[0]?.text).toBe(detail?.items[1]?.text); + expect(detail?.items[0]?.content?.[0]?.text).not.toBe(marker); + } + const assistantEnvelope = projection.messages?.find( + (message) => message.turnId === "turn-assistant-envelope", + ); + expect(assistantEnvelope?.text).toBe(marker); + const assistantEnvelopeDetail = (projection.turns as { + id: string; + items: Array<{ text?: string }>; + }[]).find((turn) => turn.id === "turn-assistant-envelope"); + expect(assistantEnvelopeDetail?.items[0]?.text).toBe(marker); + expect(projection.messages?.find((message) => message.turnId === "turn-ordinary-user")?.text) + .toBe("Please continue the ordinary review."); }); test("UTF-8 truncation is a scalar-safe prefix with exact byte accounting", () => { @@ -2220,13 +3484,331 @@ describe("PinnedCodexRuntimeManager", () => { await manager.close(); }); - test("performs a trailing account refresh after overlapping notifications and consumes refresh failure", async () => { + test("blocks later facts, provider calls, interactions, and dynamic tools on account re-attestation", async () => { + const connectionId = "70000000-0000-4000-8000-000000000778"; + const providerAuthority = { + profileId: authority.id, + processGeneration: authority.generation, + }; + let onAccountAuthoritySignal: LaunchPinnedCodexOptions["onAccountAuthoritySignal"]; + let onFact: LaunchPinnedCodexOptions["onFact"]; + let onConversationAutomationToolCall: + | LaunchPinnedCodexOptions["onConversationAutomationToolCall"] + | undefined; + let releaseRead!: () => void; + let markReadStarted!: () => void; + const readGate = new Promise((resolve) => { releaseRead = resolve; }); + const readStarted = new Promise((resolve) => { markReadStarted = resolve; }); + let releaseObserver!: () => void; + let markObserverStarted!: () => void; + const observerGate = new Promise((resolve) => { releaseObserver = resolve; }); + const observerStarted = new Promise((resolve) => { markObserverStarted = resolve; }); + const steps: string[] = []; + const fake = { + state: "ready", + connectionId, + accountRead: async (refreshToken = false) => { + if (refreshToken) { + steps.push("account:read"); + markReadStarted(); + await readGate; + } + return { + authority: providerAuthority, + value: { + account: { type: "chatgpt", email: "person@example.com", planType: "pro" }, + requiresOpenaiAuth: true, + }, + }; + }, + refreshAccountAuthority: async () => { + steps.push("account:read"); + markReadStarted(); + await readGate; + return { + authority: providerAuthority, + value: { + account: { type: "chatgpt", email: "person@example.com", planType: "pro" }, + requiresOpenaiAuth: true, + }, + }; + }, + listThreads: async () => { + steps.push("provider:list"); + return { + authority: providerAuthority, + value: { data: [], nextCursor: null, backwardsCursor: null }, + }; + }, + inspectInteractionAuthority: async () => { + steps.push("interaction:inspect"); + return { + kind: "command_approval" as const, + command: "git status", + reason: null, + availableDecisions: ["accept", "decline", "cancel"], + workingDirectory: "/workspace", + environmentId: null, + commandActions: [], + networkApprovalContext: null, + additionalPermissions: null, + proposedExecpolicyAmendment: null, + proposedNetworkPolicyAmendments: null, + }; + }, + close: async () => undefined, + } as unknown as CodexAppServerClient; + const manager = createRuntimeManager({ + isCurrent: () => true, + observer: { + account: async () => { + steps.push("account:observer"); + markObserverStarted(); + await observerGate; + }, + conversationAutomation: () => { + steps.push("dynamic:call"); + return { scope: "conversation", task: { id: "stask_barrier" } }; + }, + fact: (_authority, fact) => { + steps.push(`fact:${fact.type}`); + }, + }, + launchClient: async (options) => { + onAccountAuthoritySignal = options.onAccountAuthoritySignal; + onFact = options.onFact; + onConversationAutomationToolCall = options.onConversationAutomationToolCall; + return fake; + }, + }); + await manager.readAccount({ authority, signal: new AbortController().signal }); + const provider = { + profileId: authority.id, + processGeneration: authority.generation, + connectionId, + requestId: { type: "number" as const, value: 73 }, + method: "item/commandExecution/requestApproval", + requestDigest: "a".repeat(64), + threadId: "thread-barrier", + turnId: "turn-barrier", + itemId: "item-barrier", + approvalId: null, + }; + const call = { + authority: providerAuthority, + connectionId, + requestId: { type: "string" as const, value: "tool-barrier" }, + requestDigest: "b".repeat(64), + threadId: "thread-barrier", + turnId: "turn-barrier", + callId: "call-barrier", + operation: { + mode: "create" as const, + name: "Continue review", + prompt: "Continue in this conversation.", + schedule: { kind: "interval_minutes" as const, minutes: 60 }, + }, + } satisfies ConversationAutomationToolCall; + if ( + onAccountAuthoritySignal === undefined + || onFact === undefined + || onConversationAutomationToolCall === undefined + ) throw new Error("Missing account-barrier launch callbacks."); + + void onAccountAuthoritySignal(providerAuthority); + const accountFact = onFact({ + authority: providerAuthority, + value: { type: "accountUpdated", authMode: "chatgpt", planType: "pro" }, + }); + const laterFact = onFact({ + authority: providerAuthority, + value: { + type: "interactionRequested", + provider, + kind: "command_approval", + blocking: true, + display: { + kind: "command_approval", + summary: "Command approval", + reason: null, + commandClass: "git status", + workingDirectory: "/workspace", + availableDecisions: ["once", "decline", "cancel"], + }, + }, + }); + const list = manager.listSessions({ + authority, + limit: 1, + signal: new AbortController().signal, + }); + const inspection = manager.inspectInteractionAuthority({ + authority, + provider, + kind: "command_approval", + signal: new AbortController().signal, + }); + const dynamic = onConversationAutomationToolCall(call); + + await readStarted; + expect(steps).toEqual(["account:read"]); + releaseRead(); + await observerStarted; + expect(steps).toEqual(["account:read", "account:observer"]); + releaseObserver(); + await Promise.all([accountFact, laterFact, list, inspection, dynamic]); + const observerIndex = steps.indexOf("account:observer"); + for (const step of [ + "fact:accountUpdated", + "fact:interactionRequested", + "provider:list", + "interaction:inspect", + "dynamic:call", + ]) expect(steps.indexOf(step)).toBeGreaterThan(observerIndex); + await manager.close(); + }); + + test("does not lose an account signal raised after refresh settlement but before cleanup", async () => { + const providerAuthority = { + profileId: authority.id, + processGeneration: authority.generation, + }; + let onAccountAuthoritySignal: LaunchPinnedCodexOptions["onAccountAuthoritySignal"]; + let refreshCalls = 0; + let observerCalls = 0; + const fake = { + state: "ready", + accountRead: async () => ({ + authority: providerAuthority, + value: { account: null, requiresOpenaiAuth: true }, + }), + refreshAccountAuthority: async () => { + refreshCalls += 1; + return { + authority: providerAuthority, + value: { + account: { type: "chatgpt", email: "person@example.com", planType: "pro" }, + requiresOpenaiAuth: true, + }, + }; + }, + close: async () => undefined, + } as unknown as CodexAppServerClient; + const manager = createRuntimeManager({ + isCurrent: () => true, + observer: { + account: () => { observerCalls += 1; }, + fact: () => undefined, + }, + launchClient: async (options) => { + onAccountAuthoritySignal = options.onAccountAuthoritySignal; + return fake; + }, + }); + await manager.readAccount({ authority, signal: new AbortController().signal }); + if (onAccountAuthoritySignal === undefined) throw new Error("Missing account signal callback."); + + const first = onAccountAuthoritySignal(providerAuthority); + if (first === undefined) throw new Error("Missing first account barrier."); + let second: Promise | void = undefined; + await first.then(() => { + // The adapter's tracked cleanup is already queued, but has not run yet. + // This is the exact settlement/cleanup window that used to drop a signal. + second = onAccountAuthoritySignal?.(providerAuthority); + }); + await second; + expect({ observerCalls, refreshCalls }).toEqual({ + observerCalls: 2, + refreshCalls: 2, + }); + await manager.close(); + }); + + test("retires a published client and rejects its barrier when an account signal finds stale authority", async () => { + const providerAuthority = { + profileId: authority.id, + processGeneration: authority.generation, + }; + let current = true; + let launches = 0; + let closeCalls = 0; + let onAccountAuthoritySignal: LaunchPinnedCodexOptions["onAccountAuthoritySignal"]; + const fake = { + state: "ready", + accountRead: async () => ({ + authority: providerAuthority, + value: { account: null, requiresOpenaiAuth: true }, + }), + close: async () => { closeCalls += 1; }, + } as unknown as CodexAppServerClient; + const manager = createRuntimeManager({ + isCurrent: () => current, + observer: { account: () => undefined, fact: () => undefined }, + launchClient: async (options) => { + launches += 1; + onAccountAuthoritySignal = options.onAccountAuthoritySignal; + return fake; + }, + }); + const signal = new AbortController().signal; + await manager.readAccount({ authority, signal }); + if (onAccountAuthoritySignal === undefined) throw new Error("Missing account signal callback."); + + current = false; + const barrier = onAccountAuthoritySignal(providerAuthority); + if (barrier === undefined) throw new Error("Stale signal did not return a rejected barrier."); + await expect(barrier).rejects.toMatchObject({ code: "AUTHORITY_STALE" }); + expect(closeCalls).toBe(1); + current = true; + await expect(manager.readAccount({ authority, signal })) + .rejects.toMatchObject({ code: "AUTHORITY_STALE" }); + expect(launches).toBe(1); + await manager.close(); + }); + + test("discards launch-time facts when the client never becomes the owned generation", async () => { + const providerAuthority = { + profileId: authority.id, + processGeneration: authority.generation, + }; + const observed: CodexFact[] = []; + let launchFact: Promise | void = undefined; + const manager = createRuntimeManager({ + isCurrent: () => true, + observer: { + account: () => undefined, + fact: (_authority, fact) => { observed.push(fact); }, + }, + launchClient: async (options) => { + launchFact = options.onFact?.({ + authority: providerAuthority, + value: { type: "protocolNotice", method: "launch/will-fail" }, + }); + throw new Error("launch failed after emitting a fact"); + }, + }); + + await expect(manager.readAccount({ + authority, + signal: new AbortController().signal, + })).rejects.toThrow("launch failed after emitting a fact"); + await launchFact; + expect(observed).toEqual([]); + await manager.close(); + }); + + test("retires an exact generation when its account observer rejects refreshed authority", async () => { + let onAccountAuthoritySignal: LaunchPinnedCodexOptions["onAccountAuthoritySignal"]; let onFact: LaunchPinnedCodexOptions["onFact"]; let refreshCalls = 0; let releaseFirst!: () => void; const firstRefreshGate = new Promise((resolve) => { releaseFirst = resolve; }); - let signalTrailing!: () => void; - const trailingStarted = new Promise((resolve) => { signalTrailing = resolve; }); + let markRefreshStarted!: () => void; + const refreshStarted = new Promise((resolve) => { markRefreshStarted = resolve; }); + let closeCalls = 0; + let releaseClose!: () => void; + const closeGate = new Promise((resolve) => { releaseClose = resolve; }); + let listCalls = 0; const observed: CodexAccountProjection[] = []; const fake = { state: "ready", @@ -2235,34 +3817,81 @@ describe("PinnedCodexRuntimeManager", () => { return { authority: { profileId: authority.id, processGeneration: 1 }, value: { account: { type: "chatgpt", email: "initial@example.com", planType: "pro" }, requiresOpenaiAuth: true } }; } refreshCalls += 1; - if (refreshCalls === 1) { - await firstRefreshGate; - throw new Error("transient refresh failure"); - } - signalTrailing(); - return { authority: { profileId: authority.id, processGeneration: 1 }, value: { account: { type: "chatgpt", email: "fresh@example.com", planType: "pro" }, requiresOpenaiAuth: true } }; + markRefreshStarted(); + await firstRefreshGate; + return { authority: { profileId: authority.id, processGeneration: 1 }, value: { account: { type: "chatgpt", email: "replacement@example.com", planType: "pro" }, requiresOpenaiAuth: true } }; + }, + refreshAccountAuthority: async () => { + refreshCalls += 1; + markRefreshStarted(); + await firstRefreshGate; + return { authority: { profileId: authority.id, processGeneration: 1 }, value: { account: { type: "chatgpt", email: "replacement@example.com", planType: "pro" }, requiresOpenaiAuth: true } }; + }, + listThreads: async () => { + listCalls += 1; + return { authority: { profileId: authority.id, processGeneration: 1 }, value: { data: [], nextCursor: null, backwardsCursor: null } }; + }, + close: async () => { + closeCalls += 1; + await closeGate; }, - close: async () => undefined, } as unknown as CodexAppServerClient; + let launches = 0; const manager = createRuntimeManager({ isCurrent: () => true, - observer: { account: (_authority, account) => { observed.push(account); }, fact: () => undefined }, + observer: { + account: (_authority, account) => { + observed.push(account); + throw new Error("account observer rejected replacement authority"); + }, + fact: () => undefined, + }, launchClient: async (options) => { + launches += 1; + onAccountAuthoritySignal = options.onAccountAuthoritySignal; onFact = options.onFact; return fake; }, }); await manager.readAccount({ authority, signal: new AbortController().signal }); const providerAuthority = { profileId: authority.id, processGeneration: 1 }; - await onFact?.({ authority: providerAuthority, value: { type: "accountUpdated", authMode: "chatgpt", planType: "pro" } }); - await Promise.resolve(); + void onAccountAuthoritySignal?.(providerAuthority); + const accountFact = onFact?.({ authority: providerAuthority, value: { type: "accountUpdated", authMode: "chatgpt", planType: "pro" } }); + const accountFactError = accountFact?.catch((error: unknown) => error); + const laterRead = manager.listSessions({ + authority, + limit: 1, + signal: new AbortController().signal, + }); + const laterReadError = laterRead.catch((error: unknown) => error); + await refreshStarted; expect(refreshCalls).toBe(1); - await onFact?.({ authority: providerAuthority, value: { type: "loginCompleted", loginId: "login", success: true } }); releaseFirst(); - await trailingStarted; + expect(await accountFactError).toMatchObject({ + message: "account observer rejected replacement authority", + }); + expect(await laterReadError).toMatchObject({ + message: "account observer rejected replacement authority", + }); + let releaseSettled = false; + const authorityRelease = manager.releaseOwnedAuthority({ + authority, + signal: new AbortController().signal, + }).then(() => { releaseSettled = true; }); + await Promise.resolve(); + expect(releaseSettled).toBe(false); + releaseClose(); + await authorityRelease; + await expect(manager.readAccount({ + authority, + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: "AUTHORITY_STALE" }); await manager.close(); - expect(refreshCalls).toBe(2); - expect(observed).toEqual([]); + expect(refreshCalls).toBe(1); + expect(launches).toBe(1); + expect(listCalls).toBe(0); + expect(closeCalls).toBe(1); + expect(observed).toEqual([{ signedIn: true, email: "replacement@example.com", plan: "pro" }]); }); test("forwards failed login completion without scheduling an account refresh", async () => { @@ -2364,6 +3993,7 @@ describe("PinnedCodexRuntimeManager", () => { }); test("joins an owned account refresh that was scheduled before close", async () => { + let onAccountAuthoritySignal: LaunchPinnedCodexOptions["onAccountAuthoritySignal"]; let onFact: LaunchPinnedCodexOptions["onFact"]; let releaseRefresh!: () => void; let signalRefreshStarted!: () => void; @@ -2381,18 +4011,25 @@ describe("PinnedCodexRuntimeManager", () => { } return { authority: providerAuthority, value: { account: { type: "chatgpt", email: "ready@example.com", planType: "pro" }, requiresOpenaiAuth: true } }; }, + refreshAccountAuthority: async () => { + signalRefreshStarted(); + await refreshGate; + return { authority: providerAuthority, value: { account: { type: "chatgpt", email: "ready@example.com", planType: "pro" }, requiresOpenaiAuth: true } }; + }, close: async () => { closeCalls += 1; }, } as unknown as CodexAppServerClient; const manager = createRuntimeManager({ isCurrent: () => true, observer: { account: (_authority, account) => { observed.push(account); }, fact: () => undefined }, launchClient: async (options) => { + onAccountAuthoritySignal = options.onAccountAuthoritySignal; onFact = options.onFact; return fake; }, }); await manager.readAccount({ authority, signal: new AbortController().signal }); - await onFact?.({ authority: providerAuthority, value: { type: "accountUpdated", authMode: "chatgpt", planType: "pro" } }); + void onAccountAuthoritySignal?.(providerAuthority); + const accountFact = onFact?.({ authority: providerAuthority, value: { type: "accountUpdated", authMode: "chatgpt", planType: "pro" } }); await refreshStarted; let closeSettled = false; @@ -2401,6 +4038,7 @@ describe("PinnedCodexRuntimeManager", () => { expect(closeSettled).toBe(false); expect(closeCalls).toBe(0); releaseRefresh(); + await accountFact; await closing; expect(closeCalls).toBe(1); expect(observed).toEqual([]); diff --git a/src/daemon/codex-runtime-adapter.ts b/src/daemon/codex-runtime-adapter.ts index 3427edd..65a5513 100644 --- a/src/daemon/codex-runtime-adapter.ts +++ b/src/daemon/codex-runtime-adapter.ts @@ -19,6 +19,11 @@ import { type ResolvedPreset, type ThreadStartResult, } from "../codex/index"; +import { + PROTECTED_PROVIDER_TEXT_MARKER, + replaceCodexDesktopHeartbeatEnvelope, + replaceCodexDesktopHeartbeatTitle, +} from "../domain/codex-heartbeat-envelope"; import type { PreparedAttachment } from "../domain/attachments"; import { assertPresetSupportedByProvider, @@ -35,6 +40,7 @@ import type { ProviderInteractionAuthority, } from "../domain/interactions"; import { + CodexClaimReleaseUnprovenError, CodexSessionObservationError, type CodexAccountProjection, type CodexLoginOutcome, @@ -76,6 +82,14 @@ type SessionObservationEntry = { readonly startProjection?: CodexSessionProjection; readonly task: Promise; }; +class ResumedThreadMismatchObservationError extends CodexSessionObservationError { + readonly resumedThreadId: string; + + constructor(resumedThreadId: string) { + super("thread_mismatch"); + this.resumedThreadId = resumedThreadId; + } +} type PendingRuntimeReview = { readonly review: RuntimeStartReview; readonly running: RunningClient; @@ -84,8 +98,18 @@ type PendingRuntimeReview = { readonly preset: ResolvedPreset; readonly createdAt: number; }; +type DeterministicallyDisconnectedClient = { + readonly connectionId: string; + readonly generation: number; + readonly running: RunningClient; +}; +type AccountAuthorityBarrier = { + readonly authority: ProfileAuthority; + readonly task: Promise; + readonly settled: boolean; +}; -const assertReviewedThreadStart = ( +const assertReviewedThreadRuntime = ( value: ThreadStartResult, profile: EffectiveRuntimeProfile, projectRoot: string, @@ -115,7 +139,7 @@ const assertReviewedThreadStart = ( ) { throw new CodexError( "PROTOCOL_ERROR", - "Codex did not apply the reviewed model, permissions, or workspace policy to the new thread.", + "Codex did not apply the reviewed model, permissions, or workspace policy to the thread.", ); } }; @@ -157,7 +181,7 @@ const sanitizeProviderText = (input: string, preserveLineFeeds: boolean): string ? redactAbsolutePaths(input) : input; const protectedInput = sensitiveProviderTextHint.test(pathReduced) - ? redactCompleteSensitiveText(pathReduced, "[protected]") + ? redactCompleteSensitiveText(pathReduced, PROTECTED_PROVIDER_TEXT_MARKER) : pathReduced; let output = ""; for (const scalar of protectedInput) { @@ -170,6 +194,14 @@ const sanitizeProviderText = (input: string, preserveLineFeeds: boolean): string return output; }; +const projectTranscriptText = ( + input: string, + preserveLineFeeds: boolean, +): string => sanitizeProviderText( + replaceCodexDesktopHeartbeatEnvelope(input), + preserveLineFeeds, +); + const binaryCompare = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0; @@ -340,7 +372,8 @@ const safeRelative = (root: string, path: string): string | null => { const projectItem = (item: CodexThreadItem, root: string): unknown => { if (item.type === "userMessage") { - const content = item.text.slice(0, 16).map((text) => projectUtf8Text(sanitizeProviderText(text, true), DETAIL_TEXT_BYTES)); + const content = item.text.slice(0, 16).map((text) => + projectUtf8Text(projectTranscriptText(text, true), DETAIL_TEXT_BYTES)); return { type: item.type, id: item.id, @@ -350,10 +383,15 @@ const projectItem = (item: CodexThreadItem, root: string): unknown => { }; } if (item.type === "agentMessage") { - return { type: item.type, id: item.id, ...projectUtf8Text(sanitizeProviderText(item.text, true), DETAIL_TEXT_BYTES) }; + return { + type: item.type, + id: item.id, + ...projectUtf8Text(projectTranscriptText(item.text, true), DETAIL_TEXT_BYTES), + }; } if (item.type === "reasoning") { - const summary = item.summary.slice(0, 16).map((text) => projectUtf8Text(sanitizeProviderText(text, true), DETAIL_TEXT_BYTES)); + const summary = item.summary.slice(0, 16).map((text) => + projectUtf8Text(projectTranscriptText(text, true), DETAIL_TEXT_BYTES)); return { type: item.type, id: item.id, @@ -555,7 +593,7 @@ const projectCompactMessage = ( turnId: string, clientId?: string, ): CodexProjectedMessage => { - const projected = projectUtf8Text(sanitizeProviderText(text, true), MESSAGE_TEXT_BYTES); + const projected = projectUtf8Text(projectTranscriptText(text, true), MESSAGE_TEXT_BYTES); return { role, text: projected.text, @@ -691,7 +729,9 @@ export const projectBoundedThread = ( const turnId = activeTurn(thread); return assertTransportSafeProjection({ providerThreadId: thread.id, - title: normalizeProviderTitle(thread.name ?? thread.preview), + title: normalizeProviderTitle( + replaceCodexDesktopHeartbeatTitle(thread.name ?? thread.preview), + ), status: threadStatus(thread), projectRoot: thread.cwd, providerUpdatedAt: thread.updatedAt, @@ -726,9 +766,15 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { readonly #clients = new Map(); readonly #lifecycleTails = new Map>(); readonly #background = new Set>(); - readonly #accountRefreshes = new Map>(); + readonly #accountRefreshes = new Map(); readonly #accountRefreshDirty = new Set(); + readonly #authorityCloseClients = new Map(); + readonly #authorityCloseTasks = new Map>(); readonly #endedGenerationByProfile = new Map(); + readonly #deterministicallyDisconnectedByProfile = new Map< + string, + DeterministicallyDisconnectedClient + >(); readonly #runtimeReviews = new Map(); readonly #sessionObservations = new Map(); readonly #operations = new Set>(); @@ -744,6 +790,7 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { readonly cwd: string; readonly mcpOauth: "file"; }>; + readonly #allowSameGenerationRelaunchAfterProviderDisconnect: boolean; readonly #now: () => number; #usageRevision = Date.now(); #state: "open" | "closing" | "closed" = "open"; @@ -762,6 +809,7 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { readonly cwd: string; readonly mcpOauth: "file"; }>; + allowSameGenerationRelaunchAfterProviderDisconnect?: boolean; now?: () => number; }) { this.#isCurrent = input.isCurrent; @@ -770,6 +818,8 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { this.#prepareCodexHome = input.prepareCodexHome; this.#codexEnvironment = input.codexEnvironment; this.#credentialStorePreflight = input.credentialStorePreflight; + this.#allowSameGenerationRelaunchAfterProviderDisconnect = + input.allowSameGenerationRelaunchAfterProviderDisconnect ?? false; this.#now = input.now ?? Date.now; } @@ -801,6 +851,23 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { }); } + async releaseOwnedAuthority(input: { + authority: ProfileAuthority; + signal: AbortSignal; + }): Promise { + input.signal.throwIfAborted(); + await this.#serializeLifecycle(input.authority.id, async () => { + input.signal.throwIfAborted(); + // Serializing the nonlaunching lookup closes a client whose first launch + // was already in flight when revocation began. This path deliberately + // never calls #running/#client and never consults provider threads. + const close = this.#retireExactClient(input.authority) + ?? this.#retryExactClientClose(input.authority); + if (close !== undefined) await close; + }); + input.signal.throwIfAborted(); + } + async readAccount(input: { authority: ProfileAuthority; signal: AbortSignal }): Promise { return await this.#admit(async () => { if (input.signal.aborted) throw input.signal.reason; @@ -871,6 +938,28 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { }); } + async readSessionMetadata( + authority: ProfileAuthority, + providerThreadId: string, + signal: AbortSignal, + ): Promise { + return await this.#admit(async () => { + signal.throwIfAborted(); + const running = await this.#running(authority); + signal.throwIfAborted(); + const metadata = await running.client.readThread(providerThreadId, false); + signal.throwIfAborted(); + this.#assertObservedClientCurrent(running); + if (metadata.value.id !== providerThreadId) { + throw new CodexError( + "PROTOCOL_ERROR", + "thread/read returned metadata for a different Codex thread", + ); + } + return projectBoundedThread(metadata.value, false); + }); + } + async reviewSessionStart(input: { authority: ProfileAuthority; projectRoot?: string; preset: Preset; requirement: PresetRequirement; fast: boolean; signal: AbortSignal }): Promise { return await this.#admit(async () => { if (input.projectRoot === undefined) throw new Error("A project directory is required before starting a session."); @@ -909,7 +998,7 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { const observationFactSequence = running.sessionObservationFactSequence; const started = (await running.client.startThread({ cwd: input.projectRoot, preset, policy: this.#policy(input.projectRoot) })).value; try { - assertReviewedThreadStart(started, reviewed.review.effectiveRuntimeProfile, input.projectRoot); + assertReviewedThreadRuntime(started, reviewed.review.effectiveRuntimeProfile, input.projectRoot); const contextual = await this.#reviewedPreset( running, preset.alias, @@ -962,13 +1051,92 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { }); } - /** - * A no-op for Codex. The app-server owns thread lifetime and one process - * serves every thread of an account, so there is nothing per-thread to - * release; the thread itself is deliberately left intact. - */ + async claimSession(input: { + authority: ProfileAuthority; + providerThreadId: string; + projectRoot: string; + preset: Preset; + requirement: PresetRequirement; + fast: boolean; + signal: AbortSignal; + }): Promise { + return await this.#admit(async () => { + if (input.signal.aborted) throw input.signal.reason; + const running = await this.#running(input.authority); + input.signal.throwIfAborted(); + const reviewed = await this.#reviewedPreset( + running, + input.preset, + input.requirement, + input.fast, + input.projectRoot, + input.providerThreadId, + input.signal, + ); + // Claiming is deliberately non-mutating. HRA applies its reviewed + // approval and permission policy on every turn after the durable + // adoption commit, so a failed commit cannot leave provider policy + // changed on a thread HRA does not own. + let resumedThreadId: string | undefined; + try { + await this.#invalidateRetainedResumeUnavailable(running, input.providerThreadId); + input.signal.throwIfAborted(); + const proof = await this.#ensureSessionObserved( + running, + input.providerThreadId, + (threadId) => { resumedThreadId = threadId; }, + ); + if (proof.resumed) resumedThreadId ??= input.providerThreadId; + const observation = await this.#readSessionObservation( + running, + input.providerThreadId, + proof, + ); + input.signal.throwIfAborted(); + this.#assertObservedClientCurrent(running); + if (observation.projection.providerThreadId !== input.providerThreadId) { + throw new CodexSessionObservationError("thread_mismatch"); + } + if (observation.connectionId !== running.client.connectionId) { + throw new CodexError( + "AUTHORITY_STALE", + "The claimed Codex thread belongs to another provider connection.", + ); + } + return { ...observation, effectiveRuntimeProfile: reviewed.profile }; + } catch (error: unknown) { + if (resumedThreadId === undefined && error instanceof ResumedThreadMismatchObservationError) { + resumedThreadId = error.resumedThreadId; + } + if (resumedThreadId !== undefined) { + await this.#releaseFailedSessionClaim( + running, + input.providerThreadId, + resumedThreadId, + error, + ); + } + throw error; + } + }); + } + async endSession(input: { authority: ProfileAuthority; providerThreadId: string; signal: AbortSignal }): Promise { - void input; + await this.#admit(async () => { + if (input.signal.aborted) throw input.signal.reason; + const running = await this.#running(input.authority); + await this.#settleSessionObservationForRelease(running, input.providerThreadId); + if (this.#clients.get(running.authority.id) !== running) return; + input.signal.throwIfAborted(); + try { + await running.client.unsubscribeThread(input.providerThreadId); + this.#assertObservedClientCurrent(running); + } finally { + // No observation proof survives a release attempt. A lost response is + // ambiguous, so any later operation must establish custody afresh. + this.#clearSessionObservation(running, input.providerThreadId); + } + }); } async readSession(input: { authority: ProfileAuthority; providerThreadId: string; detail: boolean; signal: AbortSignal }): Promise { @@ -1391,20 +1559,45 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { this.#runtimeReviews.clear(); this.#sessionObservations.clear(); this.#accountRefreshDirty.clear(); - this.#closeTask = this.#closeOwnedRuntime(); - return this.#closeTask; + this.#deterministicallyDisconnectedByProfile.clear(); + const task = this.#closeOwnedRuntime(); + this.#closeTask = task; + void task.catch(() => { + // Failed process-exit proof retains exact custody. Let the same manager + // retry shutdown instead of caching a falsely complete close forever. + if (this.#closeTask === task) this.#closeTask = undefined; + }); + return task; } async #closeOwnedRuntime(): Promise { await this.#drainOwnedWork(); - const clients = [...this.#clients.values()].map((entry) => entry.client); - this.#clients.clear(); - await Promise.allSettled(clients.map(async (client) => client.close())); + const closes = [...this.#clients.values()].map((running) => + this.#retireExactClient(running.authority, running)); + for (const key of this.#authorityCloseClients.keys()) { + const existing = this.#authorityCloseTasks.get(key); + if (existing !== undefined && closes.includes(existing)) continue; + const client = this.#authorityCloseClients.get(key); + if (client === undefined) continue; + closes.push(this.#closeRetiredClient(key, client)); + } + const outcomes = await Promise.allSettled(closes.filter( + (close): close is Promise => close !== undefined, + )); await this.#drainOwnedWork(); + const failures = outcomes.flatMap((outcome) => + outcome.status === "rejected" ? [outcome.reason as unknown] : []); + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) { + throw new AggregateError(failures, "Multiple Codex processes failed exact shutdown."); + } this.#accountRefreshes.clear(); this.#accountRefreshDirty.clear(); + this.#authorityCloseClients.clear(); + this.#authorityCloseTasks.clear(); this.#runtimeReviews.clear(); this.#sessionObservations.clear(); + this.#deterministicallyDisconnectedByProfile.clear(); this.#state = "closed"; } @@ -1425,7 +1618,7 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { if (!this.#acceptingOperations()) { throw new CodexError("AUTHORITY_STALE", "The Codex runtime is closing and no longer accepts operations."); } - const task = operation(); + const task = this.#afterAccountAuthorityBarriers(operation); const settled = task.then( () => undefined, () => undefined, @@ -1442,6 +1635,101 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { return this.#state === "open"; } + #afterAccountAuthorityBarriers(operation: () => Promise): Promise { + if (!this.#acceptingOperations()) { + return Promise.reject(new CodexError( + "AUTHORITY_STALE", + "The Codex runtime is closing and no longer accepts operations.", + )); + } + const barriers = [...this.#accountRefreshes.values()].map((entry) => entry.task); + // Calling the operation directly when admission is open is important: it + // leaves no await boundary in which an account signal can be raised after + // this check but before the provider operation begins. + if (barriers.length === 0) return operation(); + return Promise.all(barriers).then(() => this.#afterAccountAuthorityBarriers(operation)); + } + + #accountAuthorityKey(authority: ProfileAuthority): string { + return JSON.stringify([authority.id, authority.generation]); + } + + #retryExactClientClose(authority: ProfileAuthority): Promise | undefined { + const key = this.#accountAuthorityKey(authority); + const active = this.#authorityCloseTasks.get(key); + if (active !== undefined) return active; + const client = this.#authorityCloseClients.get(key); + return client === undefined ? undefined : this.#closeRetiredClient(key, client); + } + + #closeRetiredClient(key: string, client: CodexAppServerClient): Promise { + const active = this.#authorityCloseTasks.get(key); + if (active !== undefined) return active; + let close: Promise; + try { + // `close()` fences client admission synchronously before its first await. + close = client.close(); + } catch (error: unknown) { + close = Promise.reject(error); + } + this.#authorityCloseTasks.set(key, close); + const tracked = close.then( + () => { if (this.#authorityCloseClients.get(key) === client) this.#authorityCloseClients.delete(key); }, + () => undefined, + ); + this.#background.add(tracked); + void tracked.then(() => { + if (this.#authorityCloseTasks.get(key) === close) this.#authorityCloseTasks.delete(key); + this.#background.delete(tracked); + }); + return close; + } + + #retireExactClient( + authority: ProfileAuthority, + expected?: RunningClient, + ): Promise | undefined { + const running = this.#clients.get(authority.id); + if ( + running === undefined + || running.authority.generation !== authority.generation + || (expected !== undefined && running !== expected) + ) return undefined; + + // Removal and the ended-generation fence are one run-to-completion + // commit. Nothing can rediscover or relaunch this account generation + // while close drains the client's fact and request tails. + this.#clients.delete(authority.id); + this.#clearSessionObservations(running); + this.#endedGenerationByProfile.set( + authority.id, + Math.max( + this.#endedGenerationByProfile.get(authority.id) ?? 0, + authority.generation, + ), + ); + const disconnected = this.#deterministicallyDisconnectedByProfile.get(authority.id); + if (disconnected?.running === running) { + this.#deterministicallyDisconnectedByProfile.delete(authority.id); + } + for (const [reviewId, review] of this.#runtimeReviews) { + if (review.running === running) this.#runtimeReviews.delete(reviewId); + } + + const key = this.#accountAuthorityKey(authority); + const retained = this.#authorityCloseClients.get(key); + if (retained !== undefined && retained !== running.client) { + throw new CodexError( + "AUTHORITY_STALE", + "Another Codex process still retains this exact account authority.", + ); + } + this.#authorityCloseClients.set(key, running.client); + // Do not await on the account barrier: client close drains the fact tail, + // whose triggering account fact is itself waiting for that barrier. + return this.#closeRetiredClient(key, running.client); + } + async #client(authority: ProfileAuthority): Promise { return (await this.#running(authority)).client; } #observationKey(running: RunningClient, providerThreadId: string): string { @@ -1514,9 +1802,121 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { this.#sessionObservations.delete(this.#observationKey(running, providerThreadId)); } + async #invalidateRetainedResumeUnavailable( + running: RunningClient, + providerThreadId: string, + ): Promise { + const key = this.#observationKey(running, providerThreadId); + const existing = this.#sessionObservations.get(key); + if (existing === undefined) return; + try { + await existing.task; + } catch (error: unknown) { + if (this.#sessionObservations.get(key) !== existing) { + if (this.#clients.get(running.authority.id) !== running) throw error; + return; + } + this.#assertObservedClientCurrent(running); + if ( + !(error instanceof CodexSessionObservationError) + || error.reason !== "resume_unavailable" + ) throw error; + // One explicit adoption poll gets one fresh, deadline-bounded resume. + // Ordinary observation retains the refusal and therefore cannot hammer + // a thread still held by another client. + this.#sessionObservations.delete(key); + } + } + + async #settleSessionObservationForRelease( + running: RunningClient, + providerThreadId: string, + ): Promise { + const key = this.#observationKey(running, providerThreadId); + for (;;) { + const existing = this.#sessionObservations.get(key); + if (existing === undefined) return; + try { + await existing.task; + } catch (error: unknown) { + // An indeterminate resume retires and closes this whole connection, + // which itself releases every subscription it held. + if (this.#clients.get(running.authority.id) !== running) return; + if (this.#sessionObservations.get(key) !== existing) continue; + if ( + error instanceof CodexSessionObservationError + && error.reason === "resume_unavailable" + ) return; + throw error; + } + this.#assertObservedClientCurrent(running); + if (this.#sessionObservations.get(key) === existing) return; + } + } + + async #releaseFailedSessionClaim( + running: RunningClient, + observationThreadId: string, + resumedThreadId: string, + claimError: unknown, + ): Promise { + // A successful policy-neutral resume is already a provider subscription. + // If a later claim check fails, remove that controller before returning the + // original failure to the adoption service. Ordinary failures unsubscribe + // the exact resumed id; an identity mismatch retires the connection because + // the subscribed thread is no longer provable. + this.#clearSessionObservation(running, observationThreadId); + if ( + claimError instanceof CodexSessionObservationError + && claimError.reason === "thread_mismatch" + ) { + // A mismatched response leaves even the subscribed thread identity in + // doubt. Only closing the exact connection proves that neither possible + // subscription remains controlled by this failed claim. + try { + await this.#closeFailedSessionClaimConnection(running); + } catch (closeError: unknown) { + throw new CodexClaimReleaseUnprovenError({ + cause: new AggregateError( + [claimError, closeError], + "Codex claim mismatch and connection retirement both failed.", + ), + }); + } + return; + } + try { + await running.client.unsubscribeThread(resumedThreadId); + return; + } catch (unsubscribeError: unknown) { + // A rejected or lost unsubscribe response cannot prove release. Retiring + // the exact connection releases all of its subscriptions. A completed + // close restores certainty, so the caller can still receive its original + // claim error; a failed close replaces it with the cleanup failure below. + try { + await this.#closeFailedSessionClaimConnection(running); + } catch (closeError: unknown) { + throw new CodexClaimReleaseUnprovenError({ + cause: new AggregateError( + [claimError, unsubscribeError, closeError], + "Codex claim, unsubscribe, and connection retirement all failed.", + ), + }); + } + } + } + + async #closeFailedSessionClaimConnection(running: RunningClient): Promise { + const close = this.#retireExactClient(running.authority, running) + ?? this.#retryExactClientClose(running.authority) + ?? running.client.close(); + await close; + } + async #ensureSessionObserved( running: RunningClient, providerThreadId: string, + onResumed?: (providerThreadId: string) => void, ): Promise { const key = this.#observationKey(running, providerThreadId); for (;;) { @@ -1530,16 +1930,28 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { } const task = (async (): Promise => { try { - const resumed = (await running.client.resumeThread(providerThreadId)).value; - if (resumed.id !== providerThreadId) { - throw new CodexSessionObservationError("thread_mismatch"); + const resumedThread: CodexThread = ( + await running.client.resumeThread(providerThreadId) + ).value; + onResumed?.(resumedThread.id); + if (resumedThread.id !== providerThreadId) { + throw new ResumedThreadMismatchObservationError(resumedThread.id); } this.#assertObservedClientCurrent(running); return { resumed: true }; } catch (error: unknown) { if (error instanceof CodexSessionObservationError) throw error; if (error instanceof IndeterminateCodexEffectError) { - await this.#retireIndeterminateObservation(running); + try { + await this.#retireIndeterminateObservation(running); + } catch (closeError: unknown) { + throw new CodexClaimReleaseUnprovenError({ + cause: new AggregateError( + [error, closeError], + "Indeterminate Codex resume and connection retirement both failed.", + ), + }); + } throw new CodexSessionObservationError("resume_unavailable", { cause: error }); } if (error instanceof CodexError) { @@ -1638,18 +2050,9 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { async #retireIndeterminateObservation(running: RunningClient): Promise { await this.#serializeLifecycle(running.authority.id, async () => { - const current = this.#clients.get(running.authority.id); - if (current !== running) return; - this.#clients.delete(running.authority.id); - this.#clearSessionObservations(running); - this.#endedGenerationByProfile.set( - running.authority.id, - Math.max( - this.#endedGenerationByProfile.get(running.authority.id) ?? 0, - running.authority.generation, - ), - ); - await running.client.close(); + const close = this.#retireExactClient(running.authority, running) + ?? this.#retryExactClientClose(running.authority); + if (close !== undefined) await close; }); } @@ -2111,16 +2514,33 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { async #runningLocked(authority: ProfileAuthority): Promise { if (!this.#isCurrent(authority)) throw new Error("Codex account generation is stale."); + const existing = this.#clients.get(authority.id); const endedGeneration = this.#endedGenerationByProfile.get(authority.id); - if (endedGeneration !== undefined && endedGeneration >= authority.generation) { + const disconnected = this.#deterministicallyDisconnectedByProfile.get(authority.id); + const mayRelaunchDisconnectedClient = + this.#allowSameGenerationRelaunchAfterProviderDisconnect + && endedGeneration === authority.generation + && existing !== undefined + && existing.authority.generation === authority.generation + && existing.client.state !== "ready" + && disconnected?.running === existing + && disconnected.generation === authority.generation + && disconnected.connectionId === existing.client.connectionId; + if ( + endedGeneration !== undefined + && endedGeneration >= authority.generation + && !mayRelaunchDisconnectedClient + ) { throw new CodexError( "AUTHORITY_STALE", "The Codex process generation ended and cannot be relaunched under the same authority.", ); } - const existing = this.#clients.get(authority.id); - if (existing?.authority.generation === authority.generation && existing.client.state === "ready") return existing; + if (existing?.authority.generation === authority.generation && existing.client.state === "ready") { + return existing; + } if (existing?.authority.generation === authority.generation) { + if (mayRelaunchDisconnectedClient) return await this.#launch(authority, existing); throw new CodexError( "AUTHORITY_STALE", "The Codex process generation is no longer ready and cannot be reused.", @@ -2144,18 +2564,32 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { } } - async #launch(authority: ProfileAuthority): Promise { + async #launch( + authority: ProfileAuthority, + disconnectedClient?: RunningClient, + ): Promise { const existing = this.#clients.get(authority.id); + const replacingDeterministicDisconnect = disconnectedClient !== undefined + && existing === disconnectedClient + && existing.authority.generation === authority.generation; + const previousConnectionId = replacingDeterministicDisconnect + ? disconnectedClient.client.connectionId + : undefined; + // A disconnect proof authorizes exactly one cleanup-and-launch attempt. + // Consuming it up front leaves a failed cleanup or launch fenced. + this.#deterministicallyDisconnectedByProfile.delete(authority.id); if (existing !== undefined) { this.#clients.delete(authority.id); this.#clearSessionObservations(existing); - this.#endedGenerationByProfile.set( - existing.authority.id, - Math.max( - this.#endedGenerationByProfile.get(existing.authority.id) ?? 0, - existing.authority.generation, - ), - ); + if (!replacingDeterministicDisconnect) { + this.#endedGenerationByProfile.set( + existing.authority.id, + Math.max( + this.#endedGenerationByProfile.get(existing.authority.id) ?? 0, + existing.authority.generation, + ), + ); + } await existing.client.close(); } if (this.#prepareCodexHome !== undefined) { @@ -2167,7 +2601,13 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { const launchedClient: { current: CodexAppServerClient | undefined } = { current: undefined, }; - const client = await this.#launchClient({ + let publishRunning!: (running: RunningClient | null) => void; + const runningReady = new Promise((resolveRunning) => { + publishRunning = resolveRunning; + }); + let client: CodexAppServerClient; + try { + client = await this.#launchClient({ authority: { profileId: authority.id, processGeneration: authority.generation }, expectedCodexHome: authority.codexHome, ...(environment === undefined ? {} : { environment }), @@ -2175,6 +2615,27 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { experimentalApi: true, isAuthorityCurrent: () => this.#isCurrent(authority), now: this.#now, + onAccountAuthoritySignal: (signaled) => { + if ( + signaled.profileId !== authority.id + || signaled.processGeneration !== authority.generation + ) return; + const published = this.#clients.get(authority.id); + if ( + launchedClient.current !== undefined + && published?.client !== launchedClient.current + ) return; + if (!this.#isCurrent(authority) || !this.#acceptingOperations()) { + if (published !== undefined) { + void this.#retireExactClient(authority, published); + } + return Promise.reject(new CodexError( + "AUTHORITY_STALE", + "The Codex runtime authority changed before the account could be refreshed.", + )); + } + return this.#scheduleAccountRefresh(authority, runningReady); + }, onConversationAutomationToolCall: async (call) => await this.#admit(async () => { const current = this.#clients.get(authority.id); if ( @@ -2213,65 +2674,90 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { return this.#observer.conversationAutomationResponseWritten?.(authority, call); }, onFact: async (value: FencedCodexValue) => { - const factUnloadsThread = value.value.type === "threadDeleted" - || ( - value.value.type === "threadStatusChanged" - && value.value.status.type === "notLoaded" - ); - const factInvalidatesStartProjection = value.value.type === "turnStarted" - || value.value.type === "turnCompleted" - || value.value.type === "threadNameUpdated" - || ( - value.value.type === "threadStatusChanged" - && value.value.status.type !== "idle" - && value.value.status.type !== "notLoaded" - ); - if ((factUnloadsThread || factInvalidatesStartProjection) && "threadId" in value.value) { - const observed = this.#clients.get(authority.id); - if ( - observed?.authority.generation === authority.generation - && observed.client.connectionId === value.value.connectionId - ) { - observed.sessionObservationFactSequence += 1; - observed.sessionObservationFactByThread.set( - value.value.threadId, - observed.sessionObservationFactSequence, - ); - if (factUnloadsThread) { - this.#clearSessionObservation(observed, value.value.threadId); - if (value.value.type === "threadDeleted") { - observed.threadItemsListSupport.delete(value.value.threadId); - } - } else { - this.#rotateSessionObservation(observed, value.value.threadId); - } - } - } - if (value.value.type === "providerDisconnected") { - const disconnected = this.#clients.get(authority.id); - if ( - disconnected?.authority.generation === authority.generation - && disconnected.client.connectionId === value.value.connectionId - ) this.#clearSessionObservations(disconnected); - this.#endedGenerationByProfile.set( - authority.id, - Math.max( - this.#endedGenerationByProfile.get(authority.id) ?? 0, - authority.generation, - ), - ); - } if (!this.#acceptingOperations()) return; try { await this.#admit(async () => { + const owned = await runningReady; + const exactClient = this.#clients.get(authority.id); + if ( + owned === null + || launchedClient.current === undefined + || owned.client !== launchedClient.current + || exactClient !== owned + || exactClient.authority.generation !== authority.generation + ) return; + const factUnloadsThread = value.value.type === "threadDeleted" + || ( + value.value.type === "threadStatusChanged" + && value.value.status.type === "notLoaded" + ); + const factInvalidatesStartProjection = value.value.type === "turnStarted" + || value.value.type === "turnCompleted" + || value.value.type === "threadNameUpdated" + || ( + value.value.type === "threadStatusChanged" + && value.value.status.type !== "idle" + && value.value.status.type !== "notLoaded" + ); + if ((factUnloadsThread || factInvalidatesStartProjection) && "threadId" in value.value) { + const observed = this.#clients.get(authority.id); + if ( + observed?.authority.generation === authority.generation + && observed.client.connectionId === value.value.connectionId + ) { + observed.sessionObservationFactSequence += 1; + observed.sessionObservationFactByThread.set( + value.value.threadId, + observed.sessionObservationFactSequence, + ); + if (factUnloadsThread) { + this.#clearSessionObservation(observed, value.value.threadId); + if (value.value.type === "threadDeleted") { + observed.threadItemsListSupport.delete(value.value.threadId); + } + } else { + this.#rotateSessionObservation(observed, value.value.threadId); + } + } + } + if (value.value.type === "providerDisconnected") { + const disconnected = this.#clients.get(authority.id); + const exactDisconnectedClient = + disconnected?.authority.generation === authority.generation + && disconnected.client.connectionId === value.value.connectionId + && disconnected.client.state !== "ready" + ? disconnected + : undefined; + if (exactDisconnectedClient !== undefined) { + this.#clearSessionObservations(exactDisconnectedClient); + this.#endedGenerationByProfile.set( + authority.id, + Math.max( + this.#endedGenerationByProfile.get(authority.id) ?? 0, + authority.generation, + ), + ); + if (this.#allowSameGenerationRelaunchAfterProviderDisconnect) { + this.#deterministicallyDisconnectedByProfile.set(authority.id, { + connectionId: value.value.connectionId, + generation: authority.generation, + running: exactDisconnectedClient, + }); + } + } + } if (!this.#isCurrent(authority)) return; const fact = value.value.type === "threadNameUpdated" - ? { ...value.value, name: value.value.name === null ? null : normalizeProviderTitle(value.value.name) } + ? { + ...value.value, + name: value.value.name === null + ? null + : normalizeProviderTitle( + replaceCodexDesktopHeartbeatTitle(value.value.name), + ), + } : value.value; await this.#observer.fact(authority, fact); - if ((value.value.type === "loginCompleted" && value.value.success) || value.value.type === "accountUpdated") { - this.#scheduleAccountRefresh(authority); - } }); } catch (error: unknown) { if (!this.#acceptingOperations()) return; @@ -2279,11 +2765,30 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { } }, }); + } catch (error: unknown) { + publishRunning(null); + throw error; + } + if (previousConnectionId !== undefined && client.connectionId === previousConnectionId) { + publishRunning(null); + await client.close(); + throw new CodexError( + "PROTOCOL_ERROR", + "The replacement Codex process reused the disconnected connection identity.", + ); + } launchedClient.current = client; if (!this.#isCurrent(authority)) { + publishRunning(null); await client.close(); throw new Error("Codex account generation changed during launch."); } + if ( + replacingDeterministicDisconnect + && this.#endedGenerationByProfile.get(authority.id) === authority.generation + ) { + this.#endedGenerationByProfile.delete(authority.id); + } const running: RunningClient = { authority, client, @@ -2292,37 +2797,90 @@ export class PinnedCodexRuntimeManager implements CodexRuntimePort { sessionObservationFactByThread: new Map(), }; this.#clients.set(authority.id, running); + publishRunning(running); return running; } - #scheduleAccountRefresh(authority: ProfileAuthority): void { - if (!this.#acceptingOperations()) return; - if (this.#accountRefreshes.has(authority.id)) { - this.#accountRefreshDirty.add(authority.id); - return; + #scheduleAccountRefresh( + authority: ProfileAuthority, + runningReady: Promise, + ): Promise | undefined { + if (!this.#acceptingOperations()) return undefined; + const key = this.#accountAuthorityKey(authority); + const existing = this.#accountRefreshes.get(key); + if (existing !== undefined && !existing.settled) { + this.#accountRefreshDirty.add(key); + return existing.task; } + const barrierState = { settled: false }; const task = Promise.resolve().then(async () => { - for (;;) { - this.#accountRefreshDirty.delete(authority.id); - const entry = this.#clients.get(authority.id); - if (!this.#acceptingOperations() || entry?.authority.generation !== authority.generation || !this.#isCurrent(authority)) return; - const account = accountProjection((await entry.client.accountRead(true)).value); - if (!this.#acceptingOperations() || !this.#isCurrent(authority)) return; - await this.#observer.account(authority, account); - if (!this.#accountRefreshDirty.has(authority.id)) return; + let running: RunningClient | null = null; + try { + running = await runningReady; + if (running === null || !this.#acceptingOperations()) return; + for (;;) { + this.#accountRefreshDirty.delete(key); + this.#assertAccountRefreshCurrent(authority, running); + const account = accountProjection( + (await running.client.refreshAccountAuthority()).value, + ); + this.#assertAccountRefreshCurrent(authority, running); + await this.#observer.account(authority, account); + this.#assertAccountRefreshCurrent(authority, running); + if (!this.#accountRefreshDirty.has(key)) return; + } + } catch (error: unknown) { + // Retire synchronously, then let client close drain in the background. + // Awaiting close here deadlocks because the triggering account fact is + // itself queued behind this admission barrier. + if (running !== null) void this.#retireExactClient(authority, running); + throw error; + } finally { + // A signal cannot interleave between this synchronous flag write and + // task settlement. A later signal therefore observes `settled` and + // replaces this barrier instead of being lost during map cleanup. + barrierState.settled = true; } }); + const barrier: AccountAuthorityBarrier = { + authority, + get settled() { return barrierState.settled; }, + task, + }; const tracked = task.then( () => undefined, () => undefined, ); - this.#accountRefreshes.set(authority.id, tracked); + // Publishing the barrier happens synchronously inside the client's signal + // callback, before the triggering fact enters its asynchronous tail. + this.#accountRefreshes.set(key, barrier); this.#background.add(tracked); void tracked.then(() => { - if (this.#accountRefreshes.get(authority.id) === tracked) this.#accountRefreshes.delete(authority.id); + if (this.#accountRefreshes.get(key) === barrier) { + this.#accountRefreshes.delete(key); + this.#accountRefreshDirty.delete(key); + } this.#background.delete(tracked); - if (this.#accountRefreshDirty.delete(authority.id) && this.#acceptingOperations() && this.#isCurrent(authority)) this.#scheduleAccountRefresh(authority); }); + return task; + } + + #assertAccountRefreshCurrent( + authority: ProfileAuthority, + running: RunningClient, + ): void { + if ( + !this.#acceptingOperations() + || !this.#isCurrent(authority) + || this.#clients.get(authority.id) !== running + || running.authority.generation !== authority.generation + || running.client.state !== "ready" + ) { + throw new CodexError( + "AUTHORITY_STALE", + "The Codex account changed while its runtime authority was being refreshed.", + ); + } } async #reviewedPreset( diff --git a/src/daemon/local-transport.test.ts b/src/daemon/local-transport.test.ts index 18d3c40..7196f7a 100644 --- a/src/daemon/local-transport.test.ts +++ b/src/daemon/local-transport.test.ts @@ -506,6 +506,64 @@ describe("local daemon transport", () => { expect(JSON.stringify(response)).not.toContain(secret); }); + test("renders provider-account recovery identically without controller provenance", async () => { + const home = await realpath(await mkdtemp(join(tmpdir(), "hra-daemon-"))); + const paths = resolveStatePaths({ homeDirectory: home, platform: "darwin" }); + await initializeStatePaths(paths); + const privateDiagnostics = [ + "personal-home account mismatch at /opt/hra-fixture/personal/.codex runtimeScope=personal", + "managed account revocation pending at /opt/hra-fixture/managed/.hra runtimeScope=managed", + ]; + let calls = 0; + const server = await LocalDaemonServer.start({ + paths, + handler: () => { + const diagnostic = privateDiagnostics[calls] ?? privateDiagnostics.at(-1); + calls += 1; + throw Object.assign(new Error(diagnostic), { + [commandFailureBrand]: true as const, + code: "RECOVERY_REQUIRED" as const, + details: { + accountId: "acct_11111111111111111111111111111111", + provider: "codex", + }, + }); + }, + }); + servers.push(server); + + const native = await callLocalDaemon({ paths, command: { kind: "daemon.status" } }); + const adopted = await callLocalDaemon({ paths, command: { kind: "daemon.status" } }); + if (native.ok || adopted.ok) throw new Error("Expected closed recovery failures."); + expect(JSON.stringify(native.error)).toBe(JSON.stringify(adopted.error)); + expect(native.error).toEqual({ + code: "RECOVERY_REQUIRED", + message: "The local command requires recovery before it can continue.", + details: { + accountId: "acct_11111111111111111111111111111111", + provider: "codex", + }, + }); + + const rendered = [native.error, adopted.error].map((failure) => { + const stdout: string[] = []; + const stderr: string[] = []; + expect(renderFailure(failure, false, { + writeStdout: (value) => { stdout.push(value); }, + writeStderr: (value) => { stderr.push(value); }, + })).toBe(7); + expect(stdout).toEqual([]); + return stderr.join(""); + }); + expect(rendered[0]).toBe(rendered[1]); + const exposed = `${JSON.stringify([native.error, adopted.error])}${rendered.join("")}` + .toLowerCase(); + expect(exposed).not.toContain("personal"); + expect(exposed).not.toContain("managed"); + expect(exposed).not.toContain("runtimescope"); + expect(exposed).not.toContain("home"); + }); + test("preserves closed settled-rejection guidance across the local transport", async () => { const home = await realpath(await mkdtemp(join(tmpdir(), "hra-daemon-"))); const paths = resolveStatePaths({ homeDirectory: home, platform: "darwin" }); diff --git a/src/daemon/personal-session-discovery.test.ts b/src/daemon/personal-session-discovery.test.ts new file mode 100644 index 0000000..b2e181b --- /dev/null +++ b/src/daemon/personal-session-discovery.test.ts @@ -0,0 +1,1664 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { constants, unlinkSync } from "node:fs"; +import { chmod, link, mkdir, mkdtemp, open, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { CLAUDE_PIN } from "../claude/pin"; +import { + BoundedPersonalSessionDiscovery, + CLAUDE_REGISTRY_MAX_FILE_BYTES, + CLAUDE_REGISTRY_MAX_RECORDS, + PERSONAL_CODEX_DISCOVERY_MAX_RESULTS, + PERSONAL_SESSION_DISCOVERY_RECENCY_WINDOW_MS, + createClaudeRegistrySource, + createLocalClaudeProcessLivenessProbe, + createPersonalClaudeDiscoveryAdapters, + createPsLocalProcessInspector, + inferCodexLiveness, + type ClaudeProcessIdentity, + type CodexPersonalSessionPageRequest, + type LocalProcessInspectionResult, + type PersonalSessionDiscoveryInput, + type ReadonlyCommandProcess, + type ReadonlyCommandSpawnInput, + type ReadonlyCommandSpawner, +} from "./personal-session-discovery"; + +const roots: string[] = []; +const encoder = new TextEncoder(); +const SYNTHETIC_PROCESS_START = "Mon Jan 5 12:34:56 2026"; + +function completeClaudeSnapshot(records: readonly unknown[]): unknown { + return { records, complete: true }; +} + +function validClaudeRegistryRecord(sessionId: string, pid: number): Readonly> { + return { + sessionId, + version: CLAUDE_PIN, + pid, + pidDomain: "darwin", + procStart: `process-start-${pid}`, + }; +} + +function registryFifoTestsSupported(): boolean { + return process.platform !== "win32" && Bun.which("mkfifo") !== null; +} + +function makeRegistryFifo(path: string): void { + const created = Bun.spawnSync({ cmd: ["mkfifo", path] }); + if (created.exitCode !== 0) { + throw new Error(`mkfifo failed: ${new TextDecoder().decode(created.stderr)}`); + } +} + +async function settleRegistryBeforeFifoWriter(pending: Promise, fifo: string): Promise { + type Outcome = + | Readonly<{ kind: "blocked" }> + | Readonly<{ kind: "rejected"; error: unknown }> + | Readonly<{ kind: "resolved"; value: T }>; + const completion: Promise = pending.then( + (value) => ({ kind: "resolved", value }), + (error: unknown) => ({ kind: "rejected", error }), + ); + let timer: ReturnType | undefined; + const blocked = new Promise((resolve) => { + timer = setTimeout(() => resolve({ kind: "blocked" }), 1_000); + }); + const outcome = await Promise.race([completion, blocked]); + if (timer !== undefined) clearTimeout(timer); + if (outcome.kind === "resolved") return outcome.value; + if (outcome.kind === "rejected") throw outcome.error; + + const writer = await open(fifo, constants.O_WRONLY | constants.O_NONBLOCK); + await writer.close(); + await completion; + throw new Error("Claude registry FIFO open blocked instead of failing promptly."); +} + +type ScriptedProcess = Readonly<{ + stdout?: readonly (string | Uint8Array)[]; + stderr?: readonly (string | Uint8Array)[]; + exitCode?: number; + holdUntilTerminated?: boolean; +}>; + +function scriptedSpawner( + scripts: readonly ScriptedProcess[], + calls: ReadonlyCommandSpawnInput[], + effects: { terminated: number; forceTerminated: number; stderrChunks: number }, +): ReadonlyCommandSpawner { + let index = 0; + return (input): ReadonlyCommandProcess => { + calls.push(input); + const script = scripts[index]; + index += 1; + if (script === undefined) throw new Error("Unexpected process spawn."); + let resolveExit: ((code: number) => void) | undefined; + const exited = script.holdUntilTerminated === true + ? new Promise((resolve) => { + resolveExit = resolve; + }) + : Promise.resolve(script.exitCode ?? 0); + const chunks = async function* ( + values: readonly (string | Uint8Array)[], + stderr: boolean, + ): AsyncIterable { + for (const value of values) { + if (stderr) effects.stderrChunks += 1; + yield typeof value === "string" ? encoder.encode(value) : value; + } + if (script.holdUntilTerminated === true) await exited; + }; + return { + exited, + stdout: chunks(script.stdout ?? [], false), + stderr: chunks(script.stderr ?? [], true), + terminate(): void { + effects.terminated += 1; + resolveExit?.(143); + }, + forceTerminate(): void { + effects.forceTerminated += 1; + resolveExit?.(137); + }, + }; + }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +describe("BoundedPersonalSessionDiscovery", () => { + test("fails closed instead of routing Devin through Claude discovery", async () => { + let claudeReads = 0; + const discovery = new BoundedPersonalSessionDiscovery({ + pinnedClaudeVersion: CLAUDE_PIN, + claudeRegistry: () => { + claudeReads += 1; + return Promise.resolve(completeClaudeSnapshot([])); + }, + }); + + await expect(discovery.discover({ + provider: "devin", + } as unknown as PersonalSessionDiscoveryInput)).rejects.toThrow( + "does not support personal-session discovery", + ); + expect(claudeReads).toBe(0); + }); + + test("pages the injected Codex source, normalizes metadata, and infers liveness", async () => { + const requests: CodexPersonalSessionPageRequest[] = []; + const discovery = new BoundedPersonalSessionDiscovery({ + now: () => 1_900_000_000_000, + codexListPage: (request) => { + requests.push(request); + if (request.cursor === undefined) { + return Promise.resolve({ + sessions: [ + { + providerThreadId: "thread-idle", + title: "Idle thread", + status: "idle", + projectRoot: "/workspace/idle", + providerUpdatedAt: 1_899_999_500_000, + }, + { + providerThreadId: "thread-live", + title: "Live thread", + status: "active", + activeTurnId: "turn-1", + providerUpdatedAt: 1_899_999_200_000, + }, + ], + nextCursor: "page-2", + }); + } + return Promise.resolve({ + sessions: [ + { + providerThreadId: "thread-terminal", + title: "Terminal thread", + status: "terminal", + providerUpdatedAt: 1_899_998_000_000, + }, + ], + nextCursor: null, + }); + }, + }); + + await expect(discovery.discover({ provider: "codex", limit: 3 })).resolves.toEqual([ + { + provider: "codex", + providerThreadId: "thread-idle", + title: "Idle thread", + projectRoot: "/workspace/idle", + updatedAt: 1_899_999_500_000, + liveness: "live", + }, + { + provider: "codex", + providerThreadId: "thread-live", + title: "Live thread", + updatedAt: 1_899_999_200_000, + liveness: "live", + trustedLiveObservation: true, + }, + ]); + expect(requests.map(({ cursor, limit }) => ({ cursor, limit }))).toEqual([ + { cursor: undefined, limit: 3 }, + { cursor: "page-2", limit: 1 }, + ]); + expect(requests.every((request) => request.signal.aborted)).toBe(true); + }); + + test("scans past a full live Codex page to observe a later quiet candidate", async () => { + const now = 1_900_000_000_000; + const requests: CodexPersonalSessionPageRequest[] = []; + const discovery = new BoundedPersonalSessionDiscovery({ + now: () => now, + codexListPage: (request) => { + requests.push(request); + if (request.cursor === undefined) { + return Promise.resolve({ + sessions: Array.from({ length: 50 }, (_, index) => ({ + providerThreadId: `live-${String(index).padStart(2, "0")}`, + title: `Live ${index}`, + status: "active", + providerUpdatedAt: now - 1_000 - index, + })), + nextCursor: "page-2", + }); + } + return Promise.resolve({ + sessions: [{ + providerThreadId: "quiet-on-page-2", + title: "Quiet", + status: "idle", + providerUpdatedAt: now - 11 * 60_000, + }], + nextCursor: null, + }); + }, + }); + + const candidates = await discovery.discover({ provider: "codex", limit: 200 }); + expect(requests.map(({ cursor, limit }) => ({ cursor, limit }))).toEqual([ + { cursor: undefined, limit: 50 }, + { cursor: "page-2", limit: 50 }, + ]); + expect(candidates.find((candidate) => candidate.providerThreadId === "quiet-on-page-2")) + .toMatchObject({ liveness: "not_live" }); + }); + + test("bounds Codex pages, stops cursor loops, and never uses ids as title fallbacks", async () => { + let calls = 0; + const discovery = new BoundedPersonalSessionDiscovery({ + now: () => Date.parse("2026-01-02T03:04:06.000Z"), + codexListPage: () => { + calls += 1; + return Promise.resolve({ + sessions: [ + { + providerThreadId: "thread-private-id", + title: null, + status: "idle", + providerUpdatedAt: "2026-01-02T03:04:05.000Z", + }, + ], + nextCursor: "same-cursor", + }); + }, + }); + + const candidates = await discovery.discover({ provider: "codex", limit: 500 }); + expect(calls).toBe(2); + expect(candidates).toHaveLength(1); + expect(candidates[0]?.title).toBe("Codex session"); + expect(candidates[0]?.title).not.toContain("thread-private-id"); + expect(candidates[0]?.updatedAt).toBe(Date.parse("2026-01-02T03:04:05.000Z")); + }); + + test("sanitizes titles, omits unsafe roots, and skips malformed Codex rows", async () => { + const sensitiveLabel = ["to", "ken"].join(""); + const sensitiveValue = ["labelled", "value"].join("-"); + const now = 1_900_000_000_000; + const discovery = new BoundedPersonalSessionDiscovery({ + now: () => now, + codexListPage: () => Promise.resolve({ + sessions: [ + { + providerThreadId: "thread-safe", + title: `Inspect /private/location ${sensitiveLabel}: ${sensitiveValue}`, + status: "idle", + projectRoot: "relative/project", + providerUpdatedAt: now - 1_000, + }, + { providerThreadId: "thread-unknown-status", title: "Bad", status: "future" }, + { providerThreadId: "x".repeat(201), title: "Bad", status: "idle" }, + ], + nextCursor: null, + }), + }); + + const candidates = await discovery.discover({ provider: "codex" }); + expect(candidates).toHaveLength(1); + expect(candidates[0]).not.toHaveProperty("projectRoot"); + expect(candidates[0]?.title).toContain("[local-path]"); + expect(candidates[0]?.title).toContain("[protected]"); + expect(candidates[0]?.title).not.toContain("private/location"); + expect(candidates[0]?.title).not.toContain(sensitiveValue); + }); + + test("retains explicit stale and timestamp-less Codex activity without admitting history", async () => { + const now = 1_900_000_000_000; + const discovery = new BoundedPersonalSessionDiscovery({ + now: () => now, + codexListPage: () => Promise.resolve({ + sessions: [ + { + providerThreadId: "terminal", + title: "Terminal", + status: "terminal", + providerUpdatedAt: now - 1_000, + }, + { + providerThreadId: "stale", + title: "Stale", + status: "active", + providerUpdatedAt: now - PERSONAL_SESSION_DISCOVERY_RECENCY_WINDOW_MS - 1, + }, + { + providerThreadId: "stale-idle-history", + title: "Stale idle history", + status: "idle", + providerUpdatedAt: now - PERSONAL_SESSION_DISCOVERY_RECENCY_WINDOW_MS - 2, + }, + { providerThreadId: "missing-time", title: "Missing", status: "active" }, + { + providerThreadId: "recent", + title: "Recent", + status: "idle", + providerUpdatedAt: now - 1_000, + }, + ], + nextCursor: null, + }), + }); + + expect(await discovery.discover({ provider: "codex" })).toEqual([ + { + provider: "codex", + providerThreadId: "recent", + title: "Recent", + updatedAt: now - 1_000, + liveness: "live", + }, + { + provider: "codex", + providerThreadId: "stale", + title: "Stale", + updatedAt: now - PERSONAL_SESSION_DISCOVERY_RECENCY_WINDOW_MS - 1, + liveness: "live", + admissionEligible: false, + trustedLiveObservation: true, + }, + { + provider: "codex", + providerThreadId: "missing-time", + title: "Missing", + liveness: "live", + admissionEligible: false, + trustedLiveObservation: true, + }, + { + provider: "codex", + providerThreadId: "stale-idle-history", + title: "Stale idle history", + updatedAt: now - PERSONAL_SESSION_DISCOVERY_RECENCY_WINDOW_MS - 2, + liveness: "not_live", + admissionEligible: false, + }, + ]); + }); + + test("reads a stale Codex heartbeat target exactly without weakening metadata or liveness checks", async () => { + const now = 1_900_000_000_000; + const exactReads: string[] = []; + const discovery = new BoundedPersonalSessionDiscovery({ + now: () => now, + codexReadSession: ({ providerThreadId }) => { + exactReads.push(providerThreadId); + if (providerThreadId === "scheduled-terminal") { + return Promise.resolve({ + providerThreadId, + title: "Terminal scheduled thread", + status: "terminal", + providerUpdatedAt: now - 86_400_000, + }); + } + if (providerThreadId === "scheduled-missing-time") { + return Promise.resolve({ + providerThreadId, + title: "Timestamp-less scheduled thread", + status: "idle", + }); + } + return Promise.resolve({ + providerThreadId, + title: "Old scheduled thread", + status: "idle", + projectRoot: "/workspace/scheduled", + providerUpdatedAt: now - 86_400_000, + }); + }, + codexListPage: () => Promise.resolve({ + sessions: [{ + providerThreadId: "recent-thread", + title: "Recent thread", + status: "idle", + providerUpdatedAt: now - 1_000, + }], + nextCursor: null, + }), + }); + + await expect(discovery.discover({ + provider: "codex", + limit: 4, + codexScheduledThreadIds: [ + "scheduled-old", + "scheduled-old", + "scheduled-terminal", + "scheduled-missing-time", + ], + })).resolves.toEqual([ + { + provider: "codex", + providerThreadId: "scheduled-old", + title: "Old scheduled thread", + projectRoot: "/workspace/scheduled", + updatedAt: now - 86_400_000, + liveness: "not_live", + scheduledTaskTarget: true, + }, + { + provider: "codex", + providerThreadId: "recent-thread", + title: "Recent thread", + updatedAt: now - 1_000, + liveness: "live", + }, + ]); + expect(exactReads).toEqual([ + "scheduled-old", + "scheduled-terminal", + "scheduled-missing-time", + ]); + }); + + test("preserves 200 ordinary Codex observations beside 50 scheduled targets", async () => { + const now = 1_900_000_000_000; + const scheduledIds = Array.from( + { length: 50 }, + (_, index) => `scheduled-${String(index).padStart(2, "0")}`, + ); + const pageRequests: CodexPersonalSessionPageRequest[] = []; + const discovery = new BoundedPersonalSessionDiscovery({ + now: () => now, + codexReadSession: ({ providerThreadId }) => Promise.resolve({ + providerThreadId, + title: providerThreadId, + status: "idle", + providerUpdatedAt: now - 24 * 60 * 60_000, + }), + codexListPage: (request) => { + pageRequests.push(request); + const pageIndex = request.cursor === undefined + ? 0 + : Number(request.cursor.replace("page-", "")); + if (pageIndex < 3) { + return Promise.resolve({ + sessions: Array.from({ length: 50 }, (_, index) => ({ + providerThreadId: `recent-live-${pageIndex}-${index}`, + title: `Recent live ${pageIndex}-${index}`, + status: "active", + providerUpdatedAt: now - pageIndex * 50 - index, + })), + nextCursor: `page-${pageIndex + 1}`, + }); + } + return Promise.resolve({ + sessions: Array.from({ length: 50 }, (_, index) => index === 49 + ? { + providerThreadId: "quiet-on-fourth-page", + title: "Quiet on fourth page", + status: "idle", + providerUpdatedAt: now - 11 * 60_000, + } + : { + providerThreadId: `recent-live-${pageIndex}-${index}`, + title: `Recent live ${pageIndex}-${index}`, + status: "active", + providerUpdatedAt: now - pageIndex * 50 - index, + }), + nextCursor: null, + }); + }, + }); + + const candidates = await discovery.discover({ + provider: "codex", + codexScheduledThreadIds: scheduledIds, + limit: 200, + }); + expect(pageRequests).toHaveLength(4); + expect(pageRequests.map((request) => request.limit)).toEqual([50, 50, 50, 50]); + expect(candidates).toHaveLength(PERSONAL_CODEX_DISCOVERY_MAX_RESULTS); + expect(candidates.filter((candidate) => candidate.scheduledTaskTarget === true)) + .toHaveLength(50); + expect(candidates.filter((candidate) => candidate.scheduledTaskTarget !== true)) + .toHaveLength(200); + expect(candidates.some((candidate) => + candidate.providerThreadId === "quiet-on-fourth-page" + && candidate.liveness === "not_live")).toBe(true); + }); + + test("does not let an unavailable exact scheduled-target read suppress recent discovery", async () => { + const now = 1_900_000_000_000; + const discovery = new BoundedPersonalSessionDiscovery({ + now: () => now, + codexReadSession: () => Promise.reject(new Error("metadata unavailable")), + codexListPage: () => Promise.resolve({ + sessions: [{ + providerThreadId: "recent-after-target-failure", + title: "Recent after target failure", + status: "idle", + providerUpdatedAt: now - 1_000, + }], + nextCursor: null, + }), + }); + + await expect(discovery.discover({ + provider: "codex", + codexScheduledThreadIds: ["unreadable-scheduled-target"], + })).resolves.toEqual([{ + provider: "codex", + providerThreadId: "recent-after-target-failure", + title: "Recent after target failure", + updatedAt: now - 1_000, + liveness: "live", + }]); + }); + + test("starts every bounded scheduled read while reserving time for recent discovery", async () => { + const exactReads: string[] = []; + let recentPageCalls = 0; + const scheduled = Array.from( + { length: 50 }, + (_, index) => `slow-scheduled-${String(index).padStart(2, "0")}`, + ); + const discovery = new BoundedPersonalSessionDiscovery({ + codexReadSession: ({ providerThreadId, signal }) => { + exactReads.push(providerThreadId); + return new Promise((_resolve, reject) => { + const rejectOnAbort = (): void => { + reject(signal.reason instanceof Error ? signal.reason : new Error("aborted")); + }; + signal.addEventListener("abort", rejectOnAbort, { once: true }); + }); + }, + codexListPage: () => { + recentPageCalls += 1; + const now = Date.now(); + return Promise.resolve({ + sessions: [{ + providerThreadId: "recent-despite-slow-schedules", + title: "Recent despite slow schedules", + status: "idle", + providerUpdatedAt: now - 1, + }], + nextCursor: null, + }); + }, + }); + + const candidates = await discovery.discover({ + provider: "codex", + codexScheduledThreadIds: scheduled, + deadlineMs: 120, + limit: 100, + }); + + expect(exactReads).toEqual(scheduled); + expect(recentPageCalls).toBe(1); + expect(candidates).toEqual([{ + provider: "codex", + providerThreadId: "recent-despite-slow-schedules", + title: "Recent despite slow schedules", + updatedAt: expect.any(Number), + liveness: "live", + }]); + }); + + test("keeps completed scheduled targets when another exact read hangs", async () => { + const now = Date.now(); + const discovery = new BoundedPersonalSessionDiscovery({ + codexReadSession: ({ providerThreadId, signal }) => { + if (providerThreadId === "scheduled-hung") { + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(new Error("aborted")), { + once: true, + }); + }); + } + return Promise.resolve({ + providerThreadId, + title: `Scheduled ${providerThreadId}`, + status: "idle", + providerUpdatedAt: now - 86_400_000, + }); + }, + codexListPage: () => Promise.resolve({ + sessions: [{ + providerThreadId: "recent-alongside-partial-schedules", + title: "Recent alongside partial schedules", + status: "idle", + providerUpdatedAt: now - 1, + }], + nextCursor: null, + }), + }); + + const candidates = await discovery.discover({ + provider: "codex", + codexScheduledThreadIds: [ + "scheduled-fast-a", + "scheduled-hung", + "scheduled-fast-b", + ], + deadlineMs: 120, + limit: 10, + }); + + expect(candidates.map((candidate) => candidate.providerThreadId)).toEqual([ + "scheduled-fast-a", + "scheduled-fast-b", + "recent-alongside-partial-schedules", + ]); + expect(candidates.slice(0, 2).every( + (candidate) => candidate.scheduledTaskTarget === true, + )).toBe(true); + }); + + test("uses only pinned registry scalars and conservative process liveness", async () => { + const probed: ClaudeProcessIdentity[] = []; + const now = 1_900_000_100_000; + const discovery = new BoundedPersonalSessionDiscovery({ + pinnedClaudeVersion: CLAUDE_PIN, + now: () => now, + claudeRegistry: () => Promise.resolve(completeClaudeSnapshot([ + { + sessionId: "claude-live", + name: "Registry live", + cwd: "/workspace/live", + updatedAt: 1_900_000_000_000, + version: CLAUDE_PIN, + pid: 8123, + pidDomain: "darwin", + procStart: "live-start", + messagingSocketPath: "/ignored/socket", + privateField: "ignored", + }, + { + sessionId: "claude-old-version", + name: "Drifted", + version: "0.0.0", + pid: 8124, + pidDomain: "darwin", + procStart: 701, + }, + { + sessionId: "claude-unknown", + name: null, + version: CLAUDE_PIN, + }, + { + sessionId: "claude-stale", + name: "Stale", + updatedAt: now - PERSONAL_SESSION_DISCOVERY_RECENCY_WINDOW_MS - 1, + version: CLAUDE_PIN, + pid: 8125, + pidDomain: "darwin", + procStart: 702, + }, + { + sessionId: "claude-recent-dead", + name: "Recent stopped session", + updatedAt: 1_899_999_950_000, + version: CLAUDE_PIN, + pid: 8126, + pidDomain: "darwin", + procStart: "dead-start", + }, + ])), + claudeProcessLiveness: (identity) => { + probed.push(identity); + return Promise.resolve(identity.pid === 8126 ? "not_live" : "live"); + }, + }); + + const candidates = await discovery.discover({ provider: "claude", limit: 3 }); + expect(candidates).toEqual([ + { + provider: "claude", + providerThreadId: "claude-recent-dead", + title: "Recent stopped session", + updatedAt: 1_899_999_950_000, + liveness: "not_live", + admissionEligible: true, + sourceProcessIdentity: { + pid: 8126, + pidDomain: "darwin", + procStart: "dead-start", + }, + }, + { + provider: "claude", + providerThreadId: "claude-live", + title: "Registry live", + projectRoot: "/workspace/live", + updatedAt: 1_900_000_000_000, + liveness: "live", + admissionEligible: true, + trustedLiveObservation: true, + sourceProcessIdentity: { + pid: 8123, + pidDomain: "darwin", + procStart: "live-start", + }, + }, + { + provider: "claude", + providerThreadId: "claude-stale", + title: "Stale", + updatedAt: now - PERSONAL_SESSION_DISCOVERY_RECENCY_WINDOW_MS - 1, + liveness: "live", + sourceProcessIdentity: null, + admissionEligible: false, + }, + ]); + expect(probed).toEqual([ + { pid: 8123, pidDomain: "darwin", procStart: "live-start" }, + { pid: 8124, pidDomain: "darwin", procStart: 701 }, + { pid: 8125, pidDomain: "darwin", procStart: 702 }, + { pid: 8126, pidDomain: "darwin", procStart: "dead-start" }, + ]); + expect(JSON.stringify(candidates)).not.toContain("messagingSocketPath"); + expect(JSON.stringify(candidates)).not.toContain("privateField"); + }); + + test("merges duplicate registry rows conservatively", async () => { + const now = 1_900_000_000_000; + const discovery = new BoundedPersonalSessionDiscovery({ + pinnedClaudeVersion: CLAUDE_PIN, + now: () => now, + claudeRegistry: () => Promise.resolve(completeClaudeSnapshot([ + { + sessionId: "same", + name: "First registry title", + cwd: "/workspace/first", + updatedAt: now - 2_000, + version: CLAUDE_PIN, + pid: 9000, + pidDomain: "darwin", + procStart: "first-start", + }, + { + sessionId: "same", + name: "Conflicting registry title", + cwd: "/workspace/second", + updatedAt: now - 1_000, + version: CLAUDE_PIN, + pid: 9001, + pidDomain: "darwin", + procStart: "second-start", + }, + ])), + claudeProcessLiveness: (identity) => Promise.resolve( + identity.pid === 9000 ? "not_live" : "unknown", + ), + }); + + expect(await discovery.discover({ provider: "claude", limit: 1 })).toEqual([ + { + provider: "claude", + providerThreadId: "same", + title: "First registry title", + updatedAt: now - 1_000, + liveness: "unknown", + admissionEligible: true, + sourceProcessIdentity: null, + }, + ]); + }); + + test("retains only one exact durable identity across duplicate Claude rows", async () => { + const now = 1_900_000_000_000; + const common = { + sessionId: "same", + name: "Same process", + updatedAt: now - 1_000, + version: CLAUDE_PIN, + pid: 9000, + pidDomain: "linux", + procStart: "Mon Jan 5 12:34:56 2026", + } as const; + const discovery = new BoundedPersonalSessionDiscovery({ + pinnedClaudeVersion: CLAUDE_PIN, + now: () => now, + claudeRegistry: () => Promise.resolve(completeClaudeSnapshot([ + { ...common, cwd: "/workspace/same" }, + { ...common, cwd: "/workspace/same" }, + ])), + claudeProcessLiveness: () => Promise.resolve("live"), + }); + + expect(await discovery.discover({ provider: "claude", limit: 1 })).toEqual([{ + provider: "claude", + providerThreadId: "same", + title: "Same process", + projectRoot: "/workspace/same", + updatedAt: now - 1_000, + liveness: "live", + admissionEligible: true, + trustedLiveObservation: true, + sourceProcessIdentity: { + pid: 9000, + pidDomain: "linux", + procStart: "Mon Jan 5 12:34:56 2026", + }, + }]); + }); + + test("clears a durable identity when any duplicate Claude row is unusable", async () => { + const now = 1_900_000_000_000; + const discovery = new BoundedPersonalSessionDiscovery({ + pinnedClaudeVersion: CLAUDE_PIN, + now: () => now, + claudeRegistry: () => Promise.resolve(completeClaudeSnapshot([ + { + sessionId: "same", + name: "Exact process", + updatedAt: now - 2_000, + version: CLAUDE_PIN, + pid: 9000, + pidDomain: "darwin", + procStart: "Mon Jan 5 12:34:56 2026", + }, + { + sessionId: "same", + name: "Incomplete process", + updatedAt: now - 1_000, + version: CLAUDE_PIN, + pid: 9000, + pidDomain: "darwin", + }, + ])), + claudeProcessLiveness: () => Promise.resolve("not_live"), + }); + + expect(await discovery.discover({ provider: "claude", limit: 1 })).toEqual([{ + provider: "claude", + providerThreadId: "same", + title: "Exact process", + updatedAt: now - 1_000, + liveness: "unknown", + admissionEligible: true, + sourceProcessIdentity: null, + }]); + }); + + test("does not retain a non-ASCII process start as storage authority", async () => { + const now = 1_900_000_000_000; + const discovery = new BoundedPersonalSessionDiscovery({ + pinnedClaudeVersion: CLAUDE_PIN, + now: () => now, + claudeRegistry: () => Promise.resolve(completeClaudeSnapshot([{ + sessionId: "non-ascii-process-start", + name: "Non-ASCII process start", + updatedAt: now - 1_000, + version: CLAUDE_PIN, + pid: 9002, + pidDomain: "darwin", + procStart: "Mon Jan 5 12:34:56 2026é", + }])), + claudeProcessLiveness: () => Promise.resolve("unknown"), + }); + + expect(await discovery.discover({ provider: "claude", limit: 1 })).toEqual([{ + provider: "claude", + providerThreadId: "non-ascii-process-start", + title: "Non-ASCII process start", + updatedAt: now - 1_000, + liveness: "unknown", + admissionEligible: true, + sourceProcessIdentity: null, + }]); + }); + + test("inspects a live duplicate beyond the former result-derived scan bound", async () => { + const now = 1_900_000_000_000; + const records = [ + { + sessionId: "same", + name: "Initially stopped", + updatedAt: now - 2_000, + version: CLAUDE_PIN, + pid: 10_001, + pidDomain: "darwin", + procStart: "first-start", + }, + ...Array.from({ length: 120 }, (_, index) => ({ + sessionId: `irrelevant-${index}`, + version: "0.0.0", + })), + { + sessionId: "same", + name: "Still live", + updatedAt: now - 1_000, + version: CLAUDE_PIN, + pid: 10_002, + pidDomain: "darwin", + procStart: "second-start", + }, + ]; + const discovery = new BoundedPersonalSessionDiscovery({ + pinnedClaudeVersion: CLAUDE_PIN, + now: () => now, + claudeRegistry: () => Promise.resolve(completeClaudeSnapshot(records)), + claudeProcessLiveness: (identity) => Promise.resolve( + identity.pid === 10_002 ? "live" : "not_live", + ), + }); + + expect(await discovery.discover({ provider: "claude", limit: 1 })).toEqual([{ + provider: "claude", + providerThreadId: "same", + title: "Initially stopped", + updatedAt: now - 1_000, + liveness: "live", + admissionEligible: true, + sourceProcessIdentity: null, + }]); + }); + + test("mismatched or missing versions poison a recent stopped duplicate", async () => { + const now = 1_900_000_000_000; + const discovery = new BoundedPersonalSessionDiscovery({ + pinnedClaudeVersion: CLAUDE_PIN, + now: () => now, + claudeRegistry: () => Promise.resolve(completeClaudeSnapshot([ + { + sessionId: "mismatched-live", + name: "Mismatched live", + updatedAt: now - 1_000, + version: CLAUDE_PIN, + pid: 11_001, + pidDomain: "darwin", + procStart: "matching-dead", + }, + { + sessionId: "mismatched-live", + updatedAt: now - 500, + version: "0.0.0", + pid: 11_002, + pidDomain: "darwin", + procStart: "mismatched-live", + }, + { + sessionId: "missing-unknown", + name: "Missing unknown", + updatedAt: now - 1_000, + version: CLAUDE_PIN, + pid: 12_001, + pidDomain: "darwin", + procStart: "matching-dead", + }, + { + sessionId: "missing-unknown", + updatedAt: now - 500, + pid: 12_002, + pidDomain: "darwin", + procStart: "missing-unknown", + }, + ])), + claudeProcessLiveness: (identity) => Promise.resolve( + identity.pid === 11_002 + ? "live" + : identity.pid === 12_002 + ? "unknown" + : "not_live", + ), + }); + + expect(await discovery.discover({ provider: "claude", limit: 2 })).toEqual([ + { + provider: "claude", + providerThreadId: "mismatched-live", + title: "Mismatched live", + updatedAt: now - 1_000, + liveness: "live", + admissionEligible: true, + sourceProcessIdentity: null, + }, + { + provider: "claude", + providerThreadId: "missing-unknown", + title: "Missing unknown", + updatedAt: now - 1_000, + liveness: "unknown", + admissionEligible: true, + sourceProcessIdentity: null, + }, + ]); + }); + + test("a stale live row poisons a recent stopped duplicate", async () => { + const now = 1_900_000_000_000; + const discovery = new BoundedPersonalSessionDiscovery({ + pinnedClaudeVersion: CLAUDE_PIN, + now: () => now, + claudeRegistry: () => Promise.resolve(completeClaudeSnapshot([ + { + sessionId: "same", + name: "Recent stopped", + updatedAt: now - 1_000, + version: CLAUDE_PIN, + pid: 13_001, + pidDomain: "darwin", + procStart: "recent-dead", + }, + { + sessionId: "same", + name: "Stale live", + updatedAt: now - PERSONAL_SESSION_DISCOVERY_RECENCY_WINDOW_MS - 1, + version: CLAUDE_PIN, + pid: 13_002, + pidDomain: "darwin", + procStart: "stale-live", + }, + ])), + claudeProcessLiveness: (identity) => Promise.resolve( + identity.pid === 13_002 ? "live" : "not_live", + ), + }); + + expect(await discovery.discover({ provider: "claude", limit: 1 })).toEqual([{ + provider: "claude", + providerThreadId: "same", + title: "Recent stopped", + updatedAt: now - 1_000, + liveness: "live", + admissionEligible: true, + sourceProcessIdentity: null, + }]); + }); + + test("emits ineligible blocker rows for wrong-pin and stale live session ids", async () => { + const now = 1_900_000_000_000; + const discovery = new BoundedPersonalSessionDiscovery({ + pinnedClaudeVersion: CLAUDE_PIN, + now: () => now, + claudeRegistry: () => Promise.resolve(completeClaudeSnapshot([ + { + sessionId: "wrong-pin-retained-id", + name: "Wrong pin blocker", + updatedAt: now - 500, + version: "0.0.0", + pid: 13_101, + pidDomain: "darwin", + procStart: "wrong-pin-live", + }, + { + sessionId: "stale-retained-id", + name: "Stale blocker", + updatedAt: now - PERSONAL_SESSION_DISCOVERY_RECENCY_WINDOW_MS - 1, + version: CLAUDE_PIN, + pid: 13_102, + pidDomain: "darwin", + procStart: "stale-live", + }, + ])), + claudeProcessLiveness: () => Promise.resolve("live"), + }); + + expect(await discovery.discover({ provider: "claude", limit: 2 })).toEqual([ + { + provider: "claude", + providerThreadId: "stale-retained-id", + title: "Stale blocker", + updatedAt: now - PERSONAL_SESSION_DISCOVERY_RECENCY_WINDOW_MS - 1, + liveness: "live", + sourceProcessIdentity: { + pid: 13_102, + pidDomain: "darwin", + procStart: "stale-live", + }, + admissionEligible: false, + trustedLiveObservation: true, + }, + { + provider: "claude", + providerThreadId: "wrong-pin-retained-id", + title: "Wrong pin blocker", + updatedAt: now - 500, + liveness: "live", + sourceProcessIdentity: null, + admissionEligible: false, + }, + ]); + }); + + test("rejects a claimed-complete snapshot that exceeds the global record cap", async () => { + const now = 1_900_000_000_000; + const records = [ + { + sessionId: "same", + name: "Stopped", + updatedAt: now - 1_000, + version: CLAUDE_PIN, + pid: 14_001, + pidDomain: "darwin", + procStart: "recent-dead", + }, + ...Array.from({ length: CLAUDE_REGISTRY_MAX_RECORDS }, (_, index) => ({ + sessionId: `overflow-${index}`, + version: "0.0.0", + })), + ]; + const discovery = new BoundedPersonalSessionDiscovery({ + pinnedClaudeVersion: CLAUDE_PIN, + now: () => now, + claudeRegistry: () => Promise.resolve({ records, complete: true }), + claudeProcessLiveness: () => Promise.resolve("not_live"), + }); + + await expect(discovery.discover({ provider: "claude", limit: 1 })) + .rejects.toThrow("snapshot is incomplete"); + }); + + test("rejects legacy bare arrays because they cannot prove a complete scan", async () => { + const now = 1_900_000_000_000; + const discovery = new BoundedPersonalSessionDiscovery({ + pinnedClaudeVersion: CLAUDE_PIN, + now: () => now, + claudeRegistry: () => Promise.resolve([{ + sessionId: "unproven", + name: "Unproven stopped process", + updatedAt: now - 1_000, + version: CLAUDE_PIN, + pid: 15_001, + pidDomain: "darwin", + procStart: "unproven-dead", + }]), + claudeProcessLiveness: () => Promise.resolve("not_live"), + }); + + await expect(discovery.discover({ provider: "claude", limit: 1 })) + .rejects.toThrow("snapshot is incomplete"); + }); + + test("rejects malformed records even when an injected source claims completeness", async () => { + const discovery = new BoundedPersonalSessionDiscovery({ + pinnedClaudeVersion: CLAUDE_PIN, + claudeRegistry: () => Promise.resolve(completeClaudeSnapshot([{}])), + }); + + await expect(discovery.discover({ provider: "claude", limit: 1 })) + .rejects.toThrow("contains an invalid record"); + }); + + test("returns partial results when a later Codex page fails", async () => { + const now = 1_900_000_000_000; + const discovery = new BoundedPersonalSessionDiscovery({ + now: () => now, + codexListPage: ({ cursor }) => cursor === undefined + ? Promise.resolve({ + sessions: [{ + providerThreadId: "first", + title: "First", + status: "idle", + providerUpdatedAt: now - 1_000, + }], + nextCursor: "next", + }) + : Promise.reject(new Error("page unavailable")), + }); + await expect(discovery.discover({ provider: "codex" })).resolves.toEqual([ + { + provider: "codex", + providerThreadId: "first", + title: "First", + updatedAt: now - 1_000, + liveness: "live", + }, + ]); + }); + + test("links a caller abort signal into an in-flight provider read", async () => { + let providerSignal: AbortSignal | undefined; + const discovery = new BoundedPersonalSessionDiscovery({ + codexListPage: (input) => { + providerSignal = input.signal; + return new Promise(() => undefined); + }, + }); + const controller = new AbortController(); + const pending = discovery.discover({ + provider: "codex", + deadlineMs: 2_000, + signal: controller.signal, + }); + for (let attempt = 0; attempt < 20 && providerSignal === undefined; attempt += 1) { + await Bun.sleep(1); + } + controller.abort(new Error("cancel discovery")); + + await expect(pending).rejects.toThrow("cancel discovery"); + expect(providerSignal?.aborted).toBe(true); + }); +}); + +describe("createClaudeRegistrySource", () => { + test("reads only bounded pid JSON files and projects a scalar allowlist", async () => { + const root = await mkdtemp(join(tmpdir(), "hra-session-registry-")); + roots.push(root); + await writeFile(join(root, "8123.json"), JSON.stringify({ + sessionId: "session-one", + name: "One", + cwd: "/workspace/one", + updatedAt: 1_900_000_000_000, + statusUpdatedAt: "2026-01-01T00:00:00Z", + version: CLAUDE_PIN, + pid: 8123, + pidDomain: "darwin", + procStart: 700, + messagingSocketPath: "/ignored/socket", + peerFeatures: ["ignored"], + })); + await writeFile(join(root, "8123.abcd.key"), "ignored"); + await writeFile(join(root, "broken.json"), "not a pid record"); + const source = createClaudeRegistrySource(root, () => 1_000); + const snapshot = await source({ + deadlineAt: 2_000, + maxFiles: 10, + maxFileBytes: CLAUDE_REGISTRY_MAX_FILE_BYTES, + signal: new AbortController().signal, + }); + expect(snapshot).toEqual({ + records: [{ + sessionId: "session-one", + name: "One", + cwd: "/workspace/one", + updatedAt: 1_900_000_000_000, + statusUpdatedAt: "2026-01-01T00:00:00Z", + version: CLAUDE_PIN, + pid: 8123, + pidDomain: "darwin", + procStart: 700, + }], + complete: true, + }); + const serialized = JSON.stringify(snapshot); + expect(serialized).not.toContain("messagingSocketPath"); + expect(serialized).not.toContain("peerFeatures"); + expect(serialized).not.toContain("ignored/socket"); + }); + + test("rejects a registry record whose filename PID does not match its process authority", async () => { + const root = await mkdtemp(join(tmpdir(), "hra-session-registry-pid-mismatch-")); + roots.push(root); + const now = 1_900_000_000_000; + await writeFile(join(root, "123.json"), JSON.stringify({ + ...validClaudeRegistryRecord("mismatched-pid", 456), + updatedAt: now - 1_000, + })); + const source = createClaudeRegistrySource(root, () => now); + const snapshot = await source({ + deadlineAt: now + 2_000, + maxFiles: CLAUDE_REGISTRY_MAX_RECORDS, + maxFileBytes: CLAUDE_REGISTRY_MAX_FILE_BYTES, + signal: new AbortController().signal, + }); + expect(snapshot).toEqual({ records: [], complete: false }); + + const inspectedPids: number[] = []; + const discovery = new BoundedPersonalSessionDiscovery({ + pinnedClaudeVersion: CLAUDE_PIN, + now: () => now, + claudeRegistry: source, + claudeProcessLiveness: createLocalClaudeProcessLivenessProbe({ + currentPidDomain: "darwin", + now: () => now, + inspectProcess: (identity) => { + inspectedPids.push(identity.pid); + return Promise.resolve({ status: "not_found" }); + }, + }), + }); + await expect(discovery.discover({ provider: "claude" })) + .rejects.toThrow("The Claude registry snapshot is incomplete."); + expect(inspectedPids).toEqual([]); + }); + + test("rejects ambiguous and non-safe decimal PID filenames", async () => { + for (const [filename, pid] of [ + ["001.json", 1], + ["9007199254740992.json", 1], + ] as const) { + const root = await mkdtemp(join(tmpdir(), "hra-session-registry-invalid-pid-name-")); + roots.push(root); + await writeFile(join(root, filename), JSON.stringify(validClaudeRegistryRecord(filename, pid))); + const source = createClaudeRegistrySource(root, () => 1_000); + await expect(source({ + deadlineAt: 2_000, + maxFiles: CLAUDE_REGISTRY_MAX_RECORDS, + maxFileBytes: CLAUDE_REGISTRY_MAX_FILE_BYTES, + signal: new AbortController().signal, + })).resolves.toEqual({ records: [], complete: false }); + } + }); + + test("honors file-count, byte, abort, and deadline bounds", async () => { + const root = await mkdtemp(join(tmpdir(), "hra-session-registry-bounds-")); + roots.push(root); + await writeFile(join(root, "1.json"), JSON.stringify(validClaudeRegistryRecord("one", 1))); + await writeFile(join(root, "2.json"), JSON.stringify(validClaudeRegistryRecord("two", 2))); + const source = createClaudeRegistrySource(root, () => 1_000); + const controller = new AbortController(); + + const bounded = await source({ + deadlineAt: 2_000, + maxFiles: 1, + maxFileBytes: CLAUDE_REGISTRY_MAX_FILE_BYTES, + signal: controller.signal, + }); + expect(bounded).toMatchObject({ complete: false }); + expect((bounded as { records: unknown[] }).records).toHaveLength(1); + + controller.abort(); + await expect(source({ + deadlineAt: 2_000, + maxFiles: 10, + maxFileBytes: CLAUDE_REGISTRY_MAX_FILE_BYTES, + signal: controller.signal, + })).resolves.toEqual({ records: [], complete: false }); + await expect(source({ + deadlineAt: 1_000, + maxFiles: 10, + maxFileBytes: CLAUDE_REGISTRY_MAX_FILE_BYTES, + signal: new AbortController().signal, + })).resolves.toEqual({ records: [], complete: false }); + }); + + test("marks malformed pid records incomplete instead of asserting EOF proof", async () => { + const root = await mkdtemp(join(tmpdir(), "hra-session-registry-malformed-")); + roots.push(root); + await writeFile(join(root, "1.json"), JSON.stringify(validClaudeRegistryRecord("one", 1))); + await writeFile(join(root, "2.json"), "{malformed"); + await mkdir(join(root, "3.json")); + await symlink(join(root, "1.json"), join(root, "4.json")); + const source = createClaudeRegistrySource(root, () => 1_000); + + await expect(source({ + deadlineAt: 2_000, + maxFiles: CLAUDE_REGISTRY_MAX_RECORDS, + maxFileBytes: CLAUDE_REGISTRY_MAX_FILE_BYTES, + signal: new AbortController().signal, + })).resolves.toMatchObject({ complete: false }); + }); + + test("rejects non-UTF-8, multiply-linked, and writable registry records", async () => { + const root = await mkdtemp(join(tmpdir(), "hra-session-registry-untrusted-file-")); + roots.push(root); + const invalidUtf8 = join(root, "1.json"); + const validJson = JSON.stringify(validClaudeRegistryRecord("invalid-utf8", 1)); + const prefix = encoder.encode(`${validJson.slice(0, -1)},"ignored":"`); + const suffix = encoder.encode('"}'); + await writeFile(invalidUtf8, Uint8Array.from([...prefix, 0xff, ...suffix])); + const source = createClaudeRegistrySource(root, () => 1_000); + const input = { + deadlineAt: 2_000, + maxFiles: CLAUDE_REGISTRY_MAX_RECORDS, + maxFileBytes: CLAUDE_REGISTRY_MAX_FILE_BYTES, + signal: new AbortController().signal, + }; + await expect(source(input)).resolves.toEqual({ records: [], complete: false }); + + await rm(invalidUtf8); + const linked = join(root, "2.json"); + await writeFile(linked, JSON.stringify(validClaudeRegistryRecord("linked", 2))); + await link(linked, join(root, "linked-copy")); + await expect(source(input)).resolves.toEqual({ records: [], complete: false }); + + await rm(join(root, "linked-copy")); + await rm(linked); + const writable = join(root, "3.json"); + await writeFile(writable, JSON.stringify(validClaudeRegistryRecord("writable", 3))); + await chmod(writable, 0o666); + await expect(source(input)).resolves.toEqual({ records: [], complete: false }); + }); + + test("fails closed promptly when a regular PID record is swapped to a FIFO", async () => { + if (!registryFifoTestsSupported()) return; + const root = await mkdtemp(join(tmpdir(), "hra-session-registry-fifo-swap-")); + roots.push(root); + const recordPath = join(root, "8123.json"); + await writeFile(recordPath, JSON.stringify(validClaudeRegistryRecord("swapped", 8123))); + let clockReads = 0; + let swapped = false; + const source = createClaudeRegistrySource(root, () => { + clockReads += 1; + if (clockReads === 3) { + unlinkSync(recordPath); + makeRegistryFifo(recordPath); + swapped = true; + } + return 1_000; + }); + + const snapshot = await settleRegistryBeforeFifoWriter(source({ + deadlineAt: 2_000, + maxFiles: CLAUDE_REGISTRY_MAX_RECORDS, + maxFileBytes: CLAUDE_REGISTRY_MAX_FILE_BYTES, + signal: new AbortController().signal, + }), recordPath); + expect(swapped).toBe(true); + expect(snapshot).toEqual({ records: [], complete: false }); + }); + + test("marks pid records with missing authority fields incomplete", async () => { + const root = await mkdtemp(join(tmpdir(), "hra-session-registry-incomplete-record-")); + roots.push(root); + await writeFile(join(root, "1.json"), JSON.stringify({ sessionId: "missing-authority" })); + const source = createClaudeRegistrySource(root, () => 1_000); + + await expect(source({ + deadlineAt: 2_000, + maxFiles: CLAUDE_REGISTRY_MAX_RECORDS, + maxFileBytes: CLAUDE_REGISTRY_MAX_FILE_BYTES, + signal: new AbortController().signal, + })).resolves.toEqual({ records: [], complete: false }); + }); + + test("reads at most 200 pid records and detects registry overflow", async () => { + const root = await mkdtemp(join(tmpdir(), "hra-session-registry-overflow-")); + roots.push(root); + await Promise.all(Array.from({ length: CLAUDE_REGISTRY_MAX_RECORDS + 1 }, async (_, index) => { + await writeFile( + join(root, `${index + 1}.json`), + JSON.stringify(validClaudeRegistryRecord(`s-${index}`, index + 1)), + ); + })); + const source = createClaudeRegistrySource(root, () => 1_000); + const snapshot = await source({ + deadlineAt: 2_000, + maxFiles: CLAUDE_REGISTRY_MAX_RECORDS, + maxFileBytes: CLAUDE_REGISTRY_MAX_FILE_BYTES, + signal: new AbortController().signal, + }); + + expect(snapshot).toMatchObject({ complete: false }); + expect((snapshot as { records: unknown[] }).records).toHaveLength(CLAUDE_REGISTRY_MAX_RECORDS); + }); +}); + +describe("inferCodexLiveness", () => { + test("uses active turns and recent updates, then admits a bounded quiet idle thread", () => { + expect(inferCodexLiveness({ status: "active", now: 20_000 })).toBe("live"); + expect(inferCodexLiveness({ status: "idle", activeTurnId: "turn", now: 20_000 })).toBe("live"); + expect(inferCodexLiveness({ status: "idle", updatedAt: 19_000, now: 20_000 })).toBe("live"); + expect(inferCodexLiveness({ status: "idle", updatedAt: 0, now: 1_000_000 })).toBe("not_live"); + expect(inferCodexLiveness({ status: "idle", now: 1_000_000 })).toBe("unknown"); + expect(inferCodexLiveness({ status: "idle", updatedAt: 2_000_000, now: 1_000_000 })).toBe("unknown"); + expect(inferCodexLiveness({ status: "terminal", updatedAt: 20_000, now: 20_000 })).toBe("not_live"); + }); +}); + +describe("createLocalClaudeProcessLivenessProbe", () => { + const identity: ClaudeProcessIdentity = { + pid: 8123, + pidDomain: "darwin", + procStart: SYNTHETIC_PROCESS_START, + }; + + test("distinguishes matching and mismatched start tokens, missing, inaccessible, and unsupported", async () => { + const input = { deadlineAt: Date.now() + 2_000, signal: new AbortController().signal }; + const withInspection = (result: LocalProcessInspectionResult) => + createLocalClaudeProcessLivenessProbe({ + currentPidDomain: "darwin", + inspectProcess: () => Promise.resolve(result), + }); + + await expect(withInspection({ status: "found", procStart: identity.procStart })(identity, input)) + .resolves.toBe("live"); + await expect(withInspection({ status: "found", procStart: "different process start" })(identity, input)) + .resolves.toBe("not_live"); + await expect(withInspection({ status: "not_found" })(identity, input)).resolves.toBe("not_live"); + await expect(withInspection({ status: "unknown" })(identity, input)).resolves.toBe("unknown"); + await expect(createLocalClaudeProcessLivenessProbe({ + currentPidDomain: "linux", + inspectProcess: () => Promise.resolve({ status: "found", procStart: identity.procStart }), + })(identity, input)).resolves.toBe("unknown"); + }); + + test("default ps inspection is non-shell and compares the captured process-start token", async () => { + const calls: ReadonlyCommandSpawnInput[] = []; + const effects = { terminated: 0, forceTerminated: 0, stderrChunks: 0 }; + const probe = createLocalClaudeProcessLivenessProbe({ + currentPidDomain: "darwin", + pidExists: () => "exists", + spawn: scriptedSpawner([ + { stdout: [` ${identity.procStart} \n`], stderr: ["ignored"] }, + ], calls, effects), + }); + + await expect(probe(identity, { + deadlineAt: Date.now() + 2_000, + signal: new AbortController().signal, + })).resolves.toBe("live"); + expect(calls.map((call) => call.argv)).toEqual([ + ["/bin/ps", "-p", "8123", "-o", "lstart="], + ]); + expect(calls[0]?.environment).toEqual({ + LANG: "C", + LC_ALL: "C", + PATH: "/usr/bin:/bin", + TZ: "UTC", + }); + expect(effects.stderrChunks).toBe(1); + }); + + test("default inspection maps a mismatched start token to not live and inaccessible lookup to unknown", async () => { + const reusedCalls: ReadonlyCommandSpawnInput[] = []; + const reusedEffects = { terminated: 0, forceTerminated: 0, stderrChunks: 0 }; + const reused = createLocalClaudeProcessLivenessProbe({ + currentPidDomain: "darwin", + pidExists: () => "exists", + spawn: scriptedSpawner([{ stdout: ["Mon Jan 5 12:34:57 2026\n"] }], reusedCalls, reusedEffects), + }); + await expect(reused(identity, { + deadlineAt: Date.now() + 2_000, + signal: new AbortController().signal, + })).resolves.toBe("not_live"); + + let spawned = false; + const inaccessible = createLocalClaudeProcessLivenessProbe({ + currentPidDomain: "darwin", + pidExists: () => "inaccessible", + spawn: () => { + spawned = true; + throw new Error("must not spawn"); + }, + }); + await expect(inaccessible(identity, { + deadlineAt: Date.now() + 2_000, + signal: new AbortController().signal, + })).resolves.toBe("unknown"); + expect(spawned).toBe(false); + }); + + test("rechecks a failed ps lookup so an exited process becomes not live", async () => { + let existenceCalls = 0; + const calls: ReadonlyCommandSpawnInput[] = []; + const effects = { terminated: 0, forceTerminated: 0, stderrChunks: 0 }; + const inspector = createPsLocalProcessInspector({ + currentPidDomain: "darwin", + pidExists: () => { + existenceCalls += 1; + return existenceCalls === 1 ? "exists" : "not_found"; + }, + spawn: scriptedSpawner([{ exitCode: 1 }], calls, effects), + }); + await expect(inspector(identity, { + deadlineAt: Date.now() + 2_000, + signal: new AbortController().signal, + })).resolves.toEqual({ status: "not_found" }); + }); + + test("force-terminates a bounded ps probe when exit settlement rejects", async () => { + let terminated = 0; + let forceTerminated = 0; + const exited = Promise.reject(new Error("exit settlement unavailable")); + void exited.catch(() => undefined); + const empty = async function* (): AsyncIterable {}; + const inspector = createPsLocalProcessInspector({ + currentPidDomain: "darwin", + pidExists: () => "exists", + spawn: () => ({ + exited, + stdout: empty(), + stderr: empty(), + terminate(): void { + terminated += 1; + }, + forceTerminate(): void { + forceTerminated += 1; + }, + }), + }); + + await expect(inspector(identity, { + deadlineAt: Date.now() + 2_000, + signal: new AbortController().signal, + })).resolves.toEqual({ status: "unknown" }); + expect(terminated).toBe(1); + expect(forceTerminated).toBe(1); + }); +}); + +describe("createPersonalClaudeDiscoveryAdapters", () => { + test("binds the registry under the supplied personal config directory", async () => { + const configDir = await mkdtemp(join(tmpdir(), "hra-personal-claude-adapters-")); + roots.push(configDir); + await mkdir(join(configDir, "sessions")); + await writeFile(join(configDir, "sessions", "8123.json"), JSON.stringify({ + sessionId: "session-one", + version: CLAUDE_PIN, + pid: 8123, + pidDomain: "darwin", + procStart: SYNTHETIC_PROCESS_START, + })); + const adapters = createPersonalClaudeDiscoveryAdapters({ + configDir, + pinnedVersion: CLAUDE_PIN, + currentPidDomain: "darwin", + inspectProcess: () => Promise.resolve({ status: "not_found" }), + }); + + expect(adapters.pinnedClaudeVersion).toBe(CLAUDE_PIN); + const snapshot = await adapters.claudeRegistry({ + deadlineAt: Date.now() + 2_000, + maxFiles: 10, + maxFileBytes: CLAUDE_REGISTRY_MAX_FILE_BYTES, + signal: new AbortController().signal, + }); + expect(snapshot).toEqual({ + records: [{ + sessionId: "session-one", + version: CLAUDE_PIN, + pid: 8123, + pidDomain: "darwin", + procStart: SYNTHETIC_PROCESS_START, + }], + complete: true, + }); + }); +}); diff --git a/src/daemon/personal-session-discovery.ts b/src/daemon/personal-session-discovery.ts new file mode 100644 index 0000000..59cbc0f --- /dev/null +++ b/src/daemon/personal-session-discovery.ts @@ -0,0 +1,1495 @@ +import { constants } from "node:fs"; +import { open, opendir, realpath } from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; + +import { containsUnsafeTerminalScalar, redactAbsolutePaths } from "../domain/text-safety"; +import type { AdoptableProvider } from "../domain/presets"; +import { redactCompleteSensitiveText } from "../sensitive-text"; + +export type PersonalSessionLiveness = "live" | "not_live" | "unknown"; + +/** Exact, storage-safe identity retained privately for a discovered Claude process. */ +export type PersonalClaudeSourceProcessIdentity = Readonly<{ + pid: number; + pidDomain: "darwin" | "linux"; + procStart: string; +}>; + +/** The only provider metadata that may leave personal-home discovery. */ +export type DiscoveredPersonalSession = Readonly<{ + provider: AdoptableProvider; + providerThreadId: string; + title: string; + projectRoot?: string; + /** Provider activity time normalized to epoch milliseconds. */ + updatedAt?: number; + liveness: PersonalSessionLiveness; + /** Private exact source identity; never part of public session projections. */ + sourceProcessIdentity?: PersonalClaudeSourceProcessIdentity | null; + /** False for a Claude row emitted only to fence retained-candidate reprobes. */ + admissionEligible?: boolean; + /** + * Private persistence hint for a source proven actively running. For Claude, + * this additionally requires the exact pinned process identity. It permits + * durable retention for a later quiet/death observation, never a claim. + */ + trustedLiveObservation?: true; + /** Private age-gate waiver for a present Codex Desktop heartbeat target. */ + scheduledTaskTarget?: true; +}>; + +export type PersonalSessionDiscoveryInput = Readonly<{ + provider: AdoptableProvider; + /** Exact personal Codex thread ids named by bounded Desktop automations. */ + codexScheduledThreadIds?: readonly string[]; + limit?: number; + deadlineMs?: number; + signal?: AbortSignal; +}>; + +export interface PersonalSessionDiscoveryPort { + discover(input: PersonalSessionDiscoveryInput): Promise; +} + +export type CodexPersonalSessionPageRequest = Readonly<{ + cursor?: string; + limit: number; + deadlineAt: number; + signal: AbortSignal; +}>; + +/** A closure can bind this source to the read-only app-server for the personal Codex home. */ +export type CodexPersonalSessionPageSource = ( + input: CodexPersonalSessionPageRequest, +) => Promise; + +export type CodexPersonalSessionReadRequest = Readonly<{ + providerThreadId: string; + deadlineAt: number; + signal: AbortSignal; +}>; + +/** Exact metadata-only read. It must not resume or subscribe to the thread. */ +export type CodexPersonalSessionReadSource = ( + input: CodexPersonalSessionReadRequest, +) => Promise; + +export type ClaudeRegistryReadRequest = Readonly<{ + deadlineAt: number; + maxFiles: number; + maxFileBytes: number; + signal: AbortSignal; +}>; + +export type ClaudeRegistrySnapshot = Readonly<{ + records: readonly unknown[]; + /** True only when the source reached directory EOF without truncation or read failure. */ + complete: boolean; +}>; + +/** Returns unknown records because every registry value is parsed again at this boundary. */ +export type ClaudeRegistrySource = (input: ClaudeRegistryReadRequest) => Promise; + +export type ClaudeProcessIdentity = Readonly<{ + pid: number; + pidDomain: string; + procStart: number | string; +}>; + +export type ClaudeProcessLivenessProbe = ( + identity: ClaudeProcessIdentity, + input: Readonly<{ deadlineAt: number; signal: AbortSignal }>, +) => Promise; + +export type PersonalSessionDiscoveryOptions = Readonly<{ + codexListPage?: CodexPersonalSessionPageSource; + codexReadSession?: CodexPersonalSessionReadSource; + claudeRegistry?: ClaudeRegistrySource; + claudeProcessLiveness?: ClaudeProcessLivenessProbe; + pinnedClaudeVersion?: string; + inferCodexLiveness?: ( + input: Readonly<{ + status: "active" | "idle" | "terminal"; + activeTurnId?: string; + updatedAt?: number; + now: number; + }>, + ) => PersonalSessionLiveness; + now?: () => number; +}>; + +export type ReadonlyCommandProcess = Readonly<{ + stdout: AsyncIterable; + stderr: AsyncIterable; + exited: Promise; + terminate(): void; + forceTerminate(): void; +}>; + +export type ReadonlyCommandSpawnInput = Readonly<{ + argv: readonly [string, ...string[]]; + environment: Readonly>; +}>; + +export type ReadonlyCommandSpawner = ( + input: ReadonlyCommandSpawnInput, +) => ReadonlyCommandProcess; + +export type LocalProcessInspectionResult = + | Readonly<{ status: "found"; procStart: number | string }> + | Readonly<{ status: "not_found" | "unknown" }>; + +export type LocalProcessInspector = ( + identity: ClaudeProcessIdentity, + input: Readonly<{ deadlineAt: number; signal: AbortSignal }>, +) => Promise; + +export type PidExistence = "exists" | "not_found" | "inaccessible" | "unknown"; +export type PidExistenceProbe = (pid: number) => PidExistence; + +export type LocalClaudeProcessLivenessOptions = Readonly<{ + currentPidDomain?: string | null; + inspectProcess?: LocalProcessInspector; + pidExists?: PidExistenceProbe; + psPath?: string; + spawn?: ReadonlyCommandSpawner; + now?: () => number; +}>; + +export type PersonalClaudeDiscoveryAdapterOptions = Readonly<{ + configDir: string; + pinnedVersion: string; +}> & LocalClaudeProcessLivenessOptions; + +export type PersonalClaudeDiscoveryAdapters = Readonly<{ + pinnedClaudeVersion: string; + claudeRegistry: ClaudeRegistrySource; + claudeProcessLiveness: ClaudeProcessLivenessProbe; +}>; + +export const PERSONAL_SESSION_DISCOVERY_MAX_RESULTS = 200; +/** Extra exact Codex automation targets retained beside the ordinary list budget. */ +export const PERSONAL_CODEX_SCHEDULED_EXACT_READ_MAX = 50; +/** Absolute Codex discovery bound: 200 listed rows plus 50 scheduled-only targets. */ +export const PERSONAL_CODEX_DISCOVERY_MAX_RESULTS = + PERSONAL_SESSION_DISCOVERY_MAX_RESULTS + PERSONAL_CODEX_SCHEDULED_EXACT_READ_MAX; +export const PERSONAL_SESSION_DISCOVERY_DEFAULT_DEADLINE_MS = 3_000; +export const PERSONAL_SESSION_DISCOVERY_MAX_DEADLINE_MS = 10_000; +export const PERSONAL_SESSION_DISCOVERY_RECENCY_WINDOW_MS = 15 * 60 * 1_000; +export const CLAUDE_REGISTRY_MAX_FILE_BYTES = 32 * 1_024; +export const CLAUDE_REGISTRY_MAX_RECORDS = 200; + +const DEFAULT_RESULT_LIMIT = 100; +const CODEX_PAGE_LIMIT = 50; +const CODEX_MAX_PAGES = 4; +const CODEX_RECENT_LIVENESS_WINDOW_MS = 10 * 60 * 1_000; +const CODEX_CLOCK_SKEW_MS = 5 * 60 * 1_000; +const PROVIDER_ID_MAX_BYTES = 200; +const TITLE_MAX_BYTES = 320; +const PROJECT_ROOT_MAX_BYTES = 8 * 1_024; +const CURSOR_MAX_BYTES = 4 * 1_024; +const CLAUDE_VERSION_MAX_BYTES = 64; +const PID_DOMAIN_MAX_BYTES = 64; +const PROC_START_MAX_BYTES = 128; +const CLAUDE_REGISTRY_MAX_DIRECTORY_ENTRIES = CLAUDE_REGISTRY_MAX_RECORDS * 4; +const PS_STDOUT_MAX_BYTES = 256; +const COMMAND_TERMINATION_GRACE_MS = 100; +const ABSOLUTE_PATH_MAX_BYTES = 8 * 1_024; +const exposedCloseOnExecFlag: unknown = Reflect.get(constants, "O_CLOEXEC"); +const CLOSE_ON_EXEC_OPEN_FLAG = typeof exposedCloseOnExecFlag === "number" + ? exposedCloseOnExecFlag + : 0; +const exactClaudeVersionPattern = /^\d{1,5}\.\d{1,5}\.\d{1,5}$/u; +const textEncoder = new TextEncoder(); + +type ParsedCodexPage = Readonly<{ + sessions: readonly ParsedCodexSession[]; + nextCursor: string | null; +}>; + +type ParsedCodexSession = Readonly<{ + providerThreadId: string; + title: string; + projectRoot?: string; + updatedAt?: number; + status: "active" | "idle" | "terminal"; + activeTurnId?: string; +}>; + +type ParsedClaudeSession = Readonly<{ + providerThreadId: string; + title: string; + projectRoot?: string; + updatedAt?: number; + matchesPinnedVersion: boolean; + identity?: ClaudeProcessIdentity; + sourceProcessIdentity: PersonalClaudeSourceProcessIdentity | null; +}>; + +type ClaudeCandidateAccumulator = { + hasRecentExactPin: boolean; + hasLiveExactPin: boolean; + hasPreferredMetadata: boolean; + title: string; + projectRoot: string | undefined; + projectRootConflicted: boolean; + updatedAt: number | undefined; + liveness: PersonalSessionLiveness | undefined; + sourceProcessIdentity: PersonalClaudeSourceProcessIdentity | null | undefined; +}; + +export class BoundedPersonalSessionDiscovery implements PersonalSessionDiscoveryPort { + readonly #options: PersonalSessionDiscoveryOptions; + readonly #now: () => number; + + constructor(options: PersonalSessionDiscoveryOptions) { + this.#options = options; + this.#now = options.now ?? Date.now; + } + + async discover( + input: PersonalSessionDiscoveryInput, + ): Promise { + const callerSignal = input.signal; + callerSignal?.throwIfAborted(); + const limit = boundedResultLimit(input.limit); + if (limit === 0) return Object.freeze([]); + const controller = new AbortController(); + const forwardAbort = (): void => { + if (callerSignal !== undefined) controller.abort(signalReason(callerSignal)); + }; + callerSignal?.addEventListener("abort", forwardAbort, { once: true }); + if (callerSignal?.aborted === true) forwardAbort(); + const deadlineAt = this.#now() + boundedDeadlineMs(input.deadlineMs); + try { + let candidates: readonly DiscoveredPersonalSession[]; + let resultLimit: number; + switch (input.provider) { + case "codex": + candidates = await this.#discoverCodex( + limit, + deadlineAt, + controller, + input.codexScheduledThreadIds ?? [], + ); + resultLimit = limit + PERSONAL_CODEX_SCHEDULED_EXACT_READ_MAX; + break; + case "claude": + candidates = await this.#discoverClaude(deadlineAt, controller); + resultLimit = limit; + break; + default: + throw new Error("That provider does not support personal-session discovery."); + } + callerSignal?.throwIfAborted(); + // `limit` owns the ordinary provider-list budget. Exact scheduled-task + // targets are an independently bounded source: slicing the merged set + // back to `limit` would let a stable scheduled prefix permanently evict + // the same ordinary recent/active observations on every poll. + return Object.freeze(sortCandidates(candidates).slice(0, resultLimit)); + } finally { + callerSignal?.removeEventListener("abort", forwardAbort); + controller.abort(new Error("Personal session discovery settled.")); + } + } + + async #discoverCodex( + limit: number, + deadlineAt: number, + controller: AbortController, + scheduledThreadIds: readonly string[], + ): Promise { + const source = this.#options.codexListPage; + const exactSource = this.#options.codexReadSession; + if (source === undefined && exactSource === undefined) return []; + const candidates = new Map(); + const scheduledIds = new Set(); + for (const rawId of scheduledThreadIds) { + const providerThreadId = safeProviderId(rawId); + if (providerThreadId === null) continue; + scheduledIds.add(providerThreadId); + if (scheduledIds.size >= PERSONAL_CODEX_SCHEDULED_EXACT_READ_MAX) break; + } + + // Recency-sorted pages cannot reliably reach an old automation target. + // Read each bounded target directly through metadata-only thread/read; + // controller resume remains reserved for the later durable claim. + if (exactSource !== undefined && scheduledIds.size > 0) { + const startedAt = this.#now(); + const remainingMs = Math.max(1, deadlineAt - startedAt); + // A scheduled target can be arbitrarily old, but it must never consume + // the whole discovery deadline and suppress ordinary recent sessions. + // Start the entire bounded target batch together, then reserve the final + // third of the budget for the recency-sorted page source. + const recentReserveMs = source === undefined + ? 0 + : Math.max(1, Math.floor(remainingMs / 3)); + const scheduledDeadlineAt = deadlineAt - recentReserveMs; + const scheduledController = new AbortController(); + const forwardScheduledAbort = (): void => { + scheduledController.abort(signalReason(controller.signal)); + }; + controller.signal.addEventListener("abort", forwardScheduledAbort, { once: true }); + if (controller.signal.aborted) forwardScheduledAbort(); + let timer: ReturnType | undefined; + let onScheduledAbort: (() => void) | undefined; + let acceptResults = true; + const completedCandidates: DiscoveredPersonalSession[] = []; + try { + const reads = [...scheduledIds].map(async (providerThreadId) => { + try { + const raw = await exactSource({ + providerThreadId, + deadlineAt: scheduledDeadlineAt, + signal: scheduledController.signal, + }); + const session = parseCodexSession(raw); + if ( + !acceptResults + || session === null + || session.providerThreadId !== providerThreadId + || session.status === "terminal" + || session.updatedAt === undefined + ) return null; + const liveness = (this.#options.inferCodexLiveness ?? inferCodexLiveness)({ + status: session.status, + ...(session.activeTurnId === undefined + ? {} + : { activeTurnId: session.activeTurnId }), + updatedAt: session.updatedAt, + now: this.#now(), + }); + const candidate = freezeCandidate({ + provider: "codex", + providerThreadId, + title: session.title, + ...(session.projectRoot === undefined + ? {} + : { projectRoot: session.projectRoot }), + updatedAt: session.updatedAt, + liveness: validLiveness(liveness), + ...(session.status === "active" || session.activeTurnId !== undefined + ? { trustedLiveObservation: true } + : {}), + scheduledTaskTarget: true, + }); + completedCandidates.push(candidate); + return; + } catch { + // An exact target read is only an eligibility hint. Its failure + // must not suppress independently verified recent candidates. + return; + } + }); + const timeout = new Promise((resolve) => { + timer = setTimeout(() => { + scheduledController.abort( + new Error("Scheduled-target discovery reached its reserved deadline."), + ); + resolve(null); + }, Math.max(1, scheduledDeadlineAt - this.#now())); + }); + const aborted = new Promise((resolve) => { + onScheduledAbort = () => resolve(null); + scheduledController.signal.addEventListener( + "abort", + onScheduledAbort, + { once: true }, + ); + }); + await Promise.race([Promise.all(reads), timeout, aborted]); + acceptResults = false; + for (const candidate of completedCandidates) { + candidates.set(candidate.providerThreadId, candidate); + } + } finally { + acceptResults = false; + if (timer !== undefined) clearTimeout(timer); + if (onScheduledAbort !== undefined) { + scheduledController.signal.removeEventListener("abort", onScheduledAbort); + } + controller.signal.removeEventListener("abort", forwardScheduledAbort); + scheduledController.abort(new Error("Scheduled-target discovery settled.")); + } + } + + if (source === undefined) return [...candidates.values()]; + const seenCursors = new Set(); + const listedCandidateIds = new Set(); + let cursor: string | undefined; + for ( + let pageIndex = 0; + pageIndex < CODEX_MAX_PAGES && listedCandidateIds.size < limit; + pageIndex += 1 + ) { + if (controller.signal.aborted) break; + const remainingMs = deadlineAt - this.#now(); + if (remainingMs <= 0) break; + let raw: unknown; + try { + raw = await settleBeforeDeadline( + source({ + ...(cursor === undefined ? {} : { cursor }), + limit: Math.min(CODEX_PAGE_LIMIT, limit - listedCandidateIds.size), + deadlineAt, + signal: controller.signal, + }), + remainingMs, + controller, + ); + } catch { + break; + } + const page = parseCodexPage(raw); + if (page === null) break; + for (const session of page.sessions) { + const scheduledTaskTarget = scheduledIds.has(session.providerThreadId); + if (session.status === "terminal") continue; + const liveness = (this.#options.inferCodexLiveness ?? inferCodexLiveness)({ + status: session.status, + ...(session.activeTurnId === undefined ? {} : { activeTurnId: session.activeTurnId }), + ...(session.updatedAt === undefined ? {} : { updatedAt: session.updatedAt }), + now: this.#now(), + }); + const recent = withinDiscoveryRecency(session.updatedAt, this.#now()); + const explicitlyLive = session.status === "active" + || session.activeTurnId !== undefined; + // Scheduled authority still requires a timestamp; it waives only age. + // Unscheduled stale/timestamp-less rows are emitted as blockers. An + // explicit active row is retained, while arbitrary stale idle history + // remains ineligible unless service observes a recent live->quiet edge. + if (scheduledTaskTarget && session.updatedAt === undefined) continue; + const admissionEligible = scheduledTaskTarget || recent; + candidates.set(session.providerThreadId, freezeCandidate({ + provider: "codex", + providerThreadId: session.providerThreadId, + title: session.title, + ...(session.projectRoot === undefined ? {} : { projectRoot: session.projectRoot }), + ...(session.updatedAt === undefined ? {} : { updatedAt: session.updatedAt }), + liveness: validLiveness(liveness), + ...(!admissionEligible ? { admissionEligible: false } : {}), + ...(explicitlyLive ? { trustedLiveObservation: true } : {}), + ...(scheduledTaskTarget ? { scheduledTaskTarget: true } : {}), + })); + listedCandidateIds.add(session.providerThreadId); + if (listedCandidateIds.size >= limit) break; + } + if (page.nextCursor === null || seenCursors.has(page.nextCursor)) break; + seenCursors.add(page.nextCursor); + cursor = page.nextCursor; + } + return [...candidates.values()]; + } + + async #discoverClaude( + deadlineAt: number, + controller: AbortController, + ): Promise { + const expectedVersion = safeBoundedString( + this.#options.pinnedClaudeVersion, + CLAUDE_VERSION_MAX_BYTES, + ); + if (expectedVersion === null || !exactClaudeVersionPattern.test(expectedVersion)) return []; + const candidates = new Map(); + + const registry = this.#options.claudeRegistry; + if (registry === undefined || deadlineAt <= this.#now()) { + throw new Error("A complete Claude registry snapshot is unavailable."); + } + const rawRegistry = await settleBeforeDeadline( + registry({ + deadlineAt, + maxFiles: CLAUDE_REGISTRY_MAX_RECORDS, + maxFileBytes: CLAUDE_REGISTRY_MAX_FILE_BYTES, + signal: controller.signal, + }), + deadlineAt - this.#now(), + controller, + ); + const snapshot = normalizeClaudeRegistrySnapshot(rawRegistry); + if (!snapshot.complete) { + throw new Error("The Claude registry snapshot is incomplete."); + } + for (let index = 0; index < snapshot.records.length; index += 1) { + if (controller.signal.aborted || deadlineAt <= this.#now()) { + throw new Error("The Claude registry snapshot was not inspected completely."); + } + const session = parseClaudeSession(snapshot.records[index], "Claude session", expectedVersion); + if (session === null) { + throw new Error("The Claude registry snapshot contains an invalid record."); + } + let liveness: PersonalSessionLiveness = "unknown"; + if (session.identity !== undefined && this.#options.claudeProcessLiveness !== undefined) { + try { + liveness = validLiveness(await settleBeforeDeadline( + this.#options.claudeProcessLiveness(session.identity, { + deadlineAt, + signal: controller.signal, + }), + deadlineAt - this.#now(), + controller, + )); + } catch { + liveness = "unknown"; + } + } + const recentExactPin = session.matchesPinnedVersion + && withinDiscoveryRecency(session.updatedAt, this.#now()); + const exactPinnedIdentity = session.matchesPinnedVersion + && session.sourceProcessIdentity !== null; + const liveExactPin = exactPinnedIdentity && liveness === "live"; + const contribution = liveness === "live" + ? "live" + : exactPinnedIdentity + ? liveness + : "unknown"; + const existing: ClaudeCandidateAccumulator = candidates.get(session.providerThreadId) ?? { + hasRecentExactPin: false, + hasLiveExactPin: false, + hasPreferredMetadata: false, + projectRootConflicted: false, + title: session.title, + projectRoot: undefined, + updatedAt: session.updatedAt, + liveness: undefined, + sourceProcessIdentity: undefined, + }; + existing.liveness = mergeClaudeLiveness(existing.liveness, contribution); + existing.sourceProcessIdentity = mergeClaudeSourceProcessIdentity( + existing.sourceProcessIdentity, + exactPinnedIdentity ? session.sourceProcessIdentity : null, + ); + if ( + !recentExactPin + && !existing.hasRecentExactPin + && session.updatedAt !== undefined + ) { + existing.updatedAt = existing.updatedAt === undefined + ? session.updatedAt + : Math.max(existing.updatedAt, session.updatedAt); + } + if (recentExactPin || liveExactPin) { + // Recent exact rows remain the authoritative metadata choice. A stale + // exact live row is retained only when no recent exact row exists, so + // its process identity can be re-probed after the source exits. + if (!existing.hasPreferredMetadata || (recentExactPin && !existing.hasRecentExactPin)) { + existing.title = session.title; + existing.projectRoot = session.projectRoot; + existing.projectRootConflicted = false; + existing.updatedAt = session.updatedAt; + existing.hasPreferredMetadata = true; + } else if (recentExactPin === existing.hasRecentExactPin) { + if (existing.projectRoot !== session.projectRoot) { + existing.projectRoot = undefined; + existing.projectRootConflicted = true; + } + if (existing.updatedAt === undefined) { + existing.updatedAt = session.updatedAt; + } else if (session.updatedAt !== undefined) { + existing.updatedAt = Math.max(existing.updatedAt, session.updatedAt); + } + } + } + if (recentExactPin) existing.hasRecentExactPin = true; + if (liveExactPin) existing.hasLiveExactPin = true; + candidates.set(session.providerThreadId, existing); + if (abortRequested(controller.signal) && index + 1 < snapshot.records.length) { + throw new Error("The Claude registry snapshot was not inspected completely."); + } + } + const output: DiscoveredPersonalSession[] = []; + for (const [providerThreadId, candidate] of candidates) { + const sourceProcessIdentity = candidate.sourceProcessIdentity ?? null; + const trustedLiveObservation = candidate.hasLiveExactPin + && sourceProcessIdentity !== null + && candidate.liveness === "live"; + const retentionEligible = !candidate.hasRecentExactPin && trustedLiveObservation; + if (!candidate.hasRecentExactPin && !retentionEligible) { + output.push(freezeCandidate({ + provider: "claude", + providerThreadId, + title: candidate.title, + ...(candidate.updatedAt === undefined ? {} : { updatedAt: candidate.updatedAt }), + liveness: candidate.liveness === "live" ? "live" : "unknown", + sourceProcessIdentity: null, + admissionEligible: false, + })); + continue; + } + const mergedLiveness = candidate.liveness ?? "unknown"; + const liveness = mergedLiveness === "live" + ? "live" + : sourceProcessIdentity !== null + ? mergedLiveness + : "unknown"; + output.push(freezeCandidate({ + provider: "claude", + providerThreadId, + title: candidate.title, + ...( + candidate.projectRoot === undefined || candidate.projectRootConflicted + ? {} + : { projectRoot: candidate.projectRoot } + ), + ...(candidate.updatedAt === undefined ? {} : { updatedAt: candidate.updatedAt }), + liveness, + sourceProcessIdentity, + admissionEligible: candidate.hasRecentExactPin, + ...(trustedLiveObservation ? { trustedLiveObservation: true } : {}), + })); + } + return output; + } +} + +/** + * Compose the registry and process-liveness adapters for one explicitly + * supplied personal Claude configuration directory. No Claude command is used + * for discovery: the CLI has no reviewed read-only session-list operation. + */ +export function createPersonalClaudeDiscoveryAdapters( + options: PersonalClaudeDiscoveryAdapterOptions, +): PersonalClaudeDiscoveryAdapters { + if (!safeAbsolutePath(options.configDir)) { + throw new Error("The personal Claude configuration directory must be absolute."); + } + if (!exactClaudeVersionPattern.test(options.pinnedVersion)) { + throw new Error("The pinned Claude version is invalid."); + } + return Object.freeze({ + pinnedClaudeVersion: options.pinnedVersion, + claudeRegistry: createClaudeRegistrySource(join(options.configDir, "sessions"), options.now), + claudeProcessLiveness: createLocalClaudeProcessLivenessProbe({ + ...(options.currentPidDomain === undefined + ? {} + : { currentPidDomain: options.currentPidDomain }), + ...(options.inspectProcess === undefined ? {} : { inspectProcess: options.inspectProcess }), + ...(options.pidExists === undefined ? {} : { pidExists: options.pidExists }), + ...(options.psPath === undefined ? {} : { psPath: options.psPath }), + ...(options.spawn === undefined ? {} : { spawn: options.spawn }), + ...(options.now === undefined ? {} : { now: options.now }), + }), + }); +} + +/** + * Compare a Claude registry identity with the local process table. A matching + * host start token is conservatively treated as live; a missing process or a + * token mismatch is not live. Unsupported domain, inaccessible metadata, + * cancellation, and inspection failure stay unknown. + */ +export function createLocalClaudeProcessLivenessProbe( + options: LocalClaudeProcessLivenessOptions = {}, +): ClaudeProcessLivenessProbe { + const currentPidDomain = options.currentPidDomain === undefined + ? pidDomainForPlatform(process.platform) + : options.currentPidDomain; + const inspect = options.inspectProcess ?? createPsLocalProcessInspector(options); + return async (identity, input) => { + if (currentPidDomain === null || identity.pidDomain !== currentPidDomain) return "unknown"; + if (input.signal.aborted) return "unknown"; + let inspection: LocalProcessInspectionResult; + try { + inspection = await inspect(identity, input); + } catch { + return "unknown"; + } + switch (inspection.status) { + case "found": + return inspection.procStart === identity.procStart ? "live" : "not_live"; + case "not_found": + return "not_live"; + case "unknown": + return "unknown"; + } + }; +} + +/** A non-shell `ps` process inspector, fenced first by a PID-existence probe. */ +export function createPsLocalProcessInspector( + options: Omit = {}, +): LocalProcessInspector { + const currentPidDomain = options.currentPidDomain === undefined + ? pidDomainForPlatform(process.platform) + : options.currentPidDomain; + const psPath = options.psPath ?? "/bin/ps"; + const spawn = options.spawn ?? spawnReadonlyBunCommand; + const pidExists = options.pidExists ?? probeLocalPidExistence; + const now = options.now ?? Date.now; + return async (identity, input) => { + if ( + currentPidDomain === null + || identity.pidDomain !== currentPidDomain + || typeof identity.procStart !== "string" + || !isAbsolute(psPath) + ) return Object.freeze({ status: "unknown" } as const); + const before = pidExists(identity.pid); + if (before === "not_found") return Object.freeze({ status: "not_found" } as const); + if (before !== "exists") return Object.freeze({ status: "unknown" } as const); + let result: Readonly<{ exitCode: number; stdout: string }>; + try { + result = await runBoundedReadonlyCommand({ + argv: [psPath, "-p", String(identity.pid), "-o", "lstart="], + environment: Object.freeze({ + LANG: "C", + LC_ALL: "C", + PATH: "/usr/bin:/bin", + // Claude records `procStart` from `ps lstart` in UTC. + TZ: "UTC", + }), + maxStdoutBytes: PS_STDOUT_MAX_BYTES, + deadlineAt: input.deadlineAt, + signal: input.signal, + spawn, + now, + }); + } catch { + return Object.freeze({ status: "unknown" } as const); + } + if (result.exitCode !== 0) { + return pidExists(identity.pid) === "not_found" + ? Object.freeze({ status: "not_found" } as const) + : Object.freeze({ status: "unknown" } as const); + } + const lines = result.stdout + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + if (lines.length !== 1 || textEncoder.encode(lines[0] ?? "").byteLength > PROC_START_MAX_BYTES) { + return Object.freeze({ status: "unknown" } as const); + } + return Object.freeze({ status: "found", procStart: lines[0] ?? "" } as const); + }; +} + +export const probeLocalPidExistence: PidExistenceProbe = (pid) => { + try { + process.kill(pid, 0); + return "exists"; + } catch (error) { + const code = errorCode(error); + if (code === "ESRCH") return "not_found"; + if (code === "EPERM" || code === "EACCES") return "inaccessible"; + return "unknown"; + } +}; + +export function spawnReadonlyBunCommand(input: ReadonlyCommandSpawnInput): ReadonlyCommandProcess { + const child = Bun.spawn([...input.argv], { + env: { ...input.environment }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + return Object.freeze({ + exited: child.exited, + stdout: readableStreamBytes(child.stdout), + stderr: readableStreamBytes(child.stderr), + terminate(): void { + child.kill("SIGTERM"); + }, + forceTerminate(): void { + child.kill("SIGKILL"); + }, + }); +} + +/** + * Read the scalar allowlist from a Claude registry directory. Entries that are + * not canonical `.json` candidates are ignored unless their decimal stem + * is ambiguous or outside the safe PID range. A filename/content PID mismatch, + * PID-named symlink, non-file, oversized or malformed document makes the whole + * bounded snapshot incomplete, because it could hide a conflicting live + * controller. Sibling key files and advertised socket paths are never opened + * or returned. + */ +export function createClaudeRegistrySource( + registryDirectory: string, + now: () => number = Date.now, +): ClaudeRegistrySource { + return async (input) => { + const records: Readonly>[] = []; + const maxFiles = boundedRegistryFileLimit(input.maxFiles); + const snapshot = (complete: boolean): ClaudeRegistrySnapshot => Object.freeze({ + records: Object.freeze(records), + complete, + }); + if (maxFiles === 0 || input.signal.aborted || input.deadlineAt <= now()) { + return snapshot(false); + } + let canonicalRegistryDirectory: string; + try { + canonicalRegistryDirectory = await realpath(registryDirectory); + } catch { + return snapshot(false); + } + let directory; + try { + directory = await opendir(registryDirectory); + } catch { + return snapshot(false); + } + let examined = 0; + let pidNamedRecords = 0; + let reachedEof = false; + let complete = true; + try { + for (;;) { + if (abortRequested(input.signal) || input.deadlineAt <= now()) { + complete = false; + break; + } + const entry = await directory.read(); + if (entry === null) { + reachedEof = true; + break; + } + examined += 1; + if (examined > CLAUDE_REGISTRY_MAX_DIRECTORY_ENTRIES) { + complete = false; + break; + } + const pidFilename = /^(\d+)\.json$/u.exec(entry.name); + if (pidFilename === null) continue; + pidNamedRecords += 1; + if (pidNamedRecords > maxFiles) { + complete = false; + break; + } + const filenamePidText = pidFilename[1]; + const filenamePid = filenamePidText === undefined + ? Number.NaN + : Number(filenamePidText); + if ( + !Number.isSafeInteger(filenamePid) + || filenamePid <= 0 + || String(filenamePid) !== filenamePidText + ) { + complete = false; + break; + } + if (!entry.isFile()) { + complete = false; + break; + } + // Recheck immediately before the final untrusted path open. The open + // itself is nonblocking because this Dirent can be stale after a swap. + if (abortRequested(input.signal) || input.deadlineAt <= now()) { + complete = false; + break; + } + const document = await readBoundedJsonFile( + join(registryDirectory, entry.name), + join(canonicalRegistryDirectory, entry.name), + Math.min(input.maxFileBytes, CLAUDE_REGISTRY_MAX_FILE_BYTES), + input.deadlineAt, + input.signal, + now, + ); + const scalars = selectClaudeRegistryScalars(document); + if (scalars === null || scalars.pid !== filenamePid) { + complete = false; + break; + } + records.push(scalars); + } + } catch { + // A disappearing registry is normal, but it makes this snapshot unproven. + complete = false; + } finally { + try { + await directory.close(); + } catch { + // The async iterator closes the directory after natural exhaustion. + } + } + return snapshot( + complete + && reachedEof + && !input.signal.aborted, + ); + }; +} + +export const inferCodexLiveness = ( + input: Readonly<{ + status: "active" | "idle" | "terminal"; + activeTurnId?: string; + updatedAt?: number; + now: number; + }>, +): PersonalSessionLiveness => { + if (input.status === "terminal") return "not_live"; + if (input.status === "active" || input.activeTurnId !== undefined) return "live"; + if ( + input.updatedAt !== undefined + && input.updatedAt <= input.now + CODEX_CLOCK_SKEW_MS + ) { + return input.now - input.updatedAt <= CODEX_RECENT_LIVENESS_WINDOW_MS + ? "live" + : "not_live"; + } + return "unknown"; +}; + +function withinDiscoveryRecency(updatedAt: number | undefined, now: number): boolean { + return updatedAt !== undefined + && updatedAt <= now + CODEX_CLOCK_SKEW_MS + && now - updatedAt <= PERSONAL_SESSION_DISCOVERY_RECENCY_WINDOW_MS; +} + +function normalizeClaudeRegistrySnapshot(value: unknown): ClaudeRegistrySnapshot { + if (Array.isArray(value)) { + return Object.freeze({ + records: Object.freeze(value.slice(0, CLAUDE_REGISTRY_MAX_RECORDS)), + // Legacy/injected arrays cannot prove that their producer reached EOF. + complete: false, + }); + } + if (!isRecord(value) || !Array.isArray(value.records)) { + return Object.freeze({ records: Object.freeze([]), complete: false }); + } + const withinBound = value.records.length <= CLAUDE_REGISTRY_MAX_RECORDS; + return Object.freeze({ + records: Object.freeze(value.records.slice(0, CLAUDE_REGISTRY_MAX_RECORDS)), + complete: value.complete === true && withinBound, + }); +} + +function parseCodexPage(value: unknown): ParsedCodexPage | null { + if (!isRecord(value) || !Array.isArray(value.sessions)) return null; + if (value.sessions.length > CODEX_PAGE_LIMIT) return null; + const rawNextCursor = value.nextCursor; + let nextCursor: string | null = null; + if (rawNextCursor !== null && rawNextCursor !== undefined) { + const parsedCursor = safeBoundedString(rawNextCursor, CURSOR_MAX_BYTES); + if (parsedCursor === null || parsedCursor.length === 0) return null; + nextCursor = parsedCursor; + } + const sessions: ParsedCodexSession[] = []; + for (const item of value.sessions) { + const session = parseCodexSession(item); + if (session !== null) sessions.push(session); + } + return Object.freeze({ + sessions: Object.freeze(sessions), + nextCursor, + }); +} + +function parseCodexSession(value: unknown): ParsedCodexSession | null { + if (!isRecord(value)) return null; + const providerThreadId = safeProviderId(value.providerThreadId); + if (providerThreadId === null) return null; + const status = value.status; + if (status !== "active" && status !== "idle" && status !== "terminal") return null; + let activeTurnId: string | undefined; + if (value.activeTurnId !== undefined) { + const parsedActiveTurnId = safeProviderId(value.activeTurnId); + if (parsedActiveTurnId === null) return null; + activeTurnId = parsedActiveTurnId; + } + const projectRoot = safeProjectRoot(value.projectRoot); + const updatedAt = normalizedTimestamp(value.providerUpdatedAt); + return Object.freeze({ + providerThreadId, + title: safeTitle(value.title, "Codex session"), + ...(projectRoot === undefined ? {} : { projectRoot }), + ...(updatedAt === undefined ? {} : { updatedAt }), + status, + ...(activeTurnId === undefined ? {} : { activeTurnId }), + }); +} + +function parseClaudeSession( + value: unknown, + fallbackTitle: string, + expectedVersion: string, +): ParsedClaudeSession | null { + if (!isRecord(value)) return null; + const providerThreadId = safeProviderId(value.sessionId); + if (providerThreadId === null) return null; + const projectRoot = safeProjectRoot(value.cwd); + const updatedAt = normalizedTimestamp(value.updatedAt ?? value.statusUpdatedAt); + const identity = parseClaudeProcessIdentity(value); + const sourceProcessIdentity = storageSafeClaudeProcessIdentity(identity); + return Object.freeze({ + providerThreadId, + title: safeTitle(value.name, fallbackTitle), + ...(projectRoot === undefined ? {} : { projectRoot }), + ...(updatedAt === undefined ? {} : { updatedAt }), + matchesPinnedVersion: value.version === expectedVersion, + ...(identity === undefined ? {} : { identity }), + sourceProcessIdentity, + }); +} + +function parseClaudeProcessIdentity(value: Readonly>): ClaudeProcessIdentity | undefined { + const pid = value.pid; + const pidDomain = safeBoundedString(value.pidDomain, PID_DOMAIN_MAX_BYTES); + const procStart = value.procStart; + if ( + typeof pid !== "number" + || !Number.isSafeInteger(pid) + || pid <= 0 + || pidDomain === null + || pidDomain.length === 0 + ) return undefined; + if ( + !( + (typeof procStart === "number" && Number.isSafeInteger(procStart) && procStart >= 0) + || ( + typeof procStart === "string" + && procStart.length > 0 + && textEncoder.encode(procStart).byteLength <= PROC_START_MAX_BYTES + && !containsUnsafeTerminalScalar(procStart) + ) + ) + ) return undefined; + return Object.freeze({ pid, pidDomain, procStart }); +} + +function storageSafeClaudeProcessIdentity( + identity: ClaudeProcessIdentity | undefined, +): PersonalClaudeSourceProcessIdentity | null { + if ( + identity === undefined + || (identity.pidDomain !== "darwin" && identity.pidDomain !== "linux") + || typeof identity.procStart !== "string" + || !/^[\x20-\x7e]+$/u.test(identity.procStart) + ) return null; + return Object.freeze({ + pid: identity.pid, + pidDomain: identity.pidDomain, + procStart: identity.procStart, + }); +} + +function selectClaudeRegistryScalars(value: unknown): Readonly> | null { + if (!isRecord(value)) return null; + const sessionId = safeProviderId(value.sessionId); + const version = safeBoundedString(value.version, CLAUDE_VERSION_MAX_BYTES); + if ( + sessionId === null + || version === null + || version.length === 0 + || parseClaudeProcessIdentity(value) === undefined + ) return null; + const output: Record = {}; + for (const key of [ + "sessionId", + "name", + "cwd", + "updatedAt", + "statusUpdatedAt", + "version", + "pid", + "pidDomain", + "procStart", + ] as const) { + const scalar = value[key]; + if (typeof scalar === "string" || typeof scalar === "number") output[key] = scalar; + } + return Object.freeze(output); +} + +async function readBoundedJsonFile( + path: string, + expectedCanonicalPath: string, + maxBytes: number, + deadlineAt: number, + signal: AbortSignal, + now: () => number, +): Promise { + if ( + !Number.isSafeInteger(maxBytes) + || maxBytes <= 0 + || abortRequested(signal) + || deadlineAt <= now() + ) return null; + let handle: FileHandle; + try { + handle = await open( + path, + constants.O_RDONLY + | CLOSE_ON_EXEC_OPEN_FLAG + | constants.O_NOFOLLOW + | constants.O_NONBLOCK, + ); + } catch { + return null; + } + try { + if (abortRequested(signal) || deadlineAt <= now()) return null; + const openedPath = await canonicalPathForOpenHandle(handle); + if (openedPath !== expectedCanonicalPath) return null; + const before = await handle.stat(); + const currentUid = typeof process.getuid === "function" ? process.getuid() : null; + if ( + !before.isFile() + || before.nlink !== 1 + || before.size > maxBytes + || (currentUid !== null && before.uid !== currentUid) + || (before.mode & 0o022) !== 0 + ) return null; + const bytes = new Uint8Array(maxBytes + 1); + let filled = 0; + while (filled < bytes.length) { + if (abortRequested(signal) || deadlineAt <= now()) return null; + const result = await handle.read(bytes, filled, bytes.length - filled, filled); + if (result.bytesRead === 0) break; + filled += result.bytesRead; + } + if (filled > maxBytes) return null; + const after = await handle.stat(); + const settledPath = await canonicalPathForOpenHandle(handle); + if ( + abortRequested(signal) + || deadlineAt <= now() + || settledPath !== openedPath + || after.dev !== before.dev + || after.ino !== before.ino + || after.size !== before.size + || after.mtimeMs !== before.mtimeMs + || after.ctimeMs !== before.ctimeMs + ) return null; + return JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, filled)), + ) as unknown; + } catch { + return null; + } finally { + await handle.close().catch(() => undefined); + } +} + +/** Resolve an already-open descriptor without trusting its original path. */ +async function canonicalPathForOpenHandle(handle: FileHandle): Promise { + const candidates = process.platform === "linux" + ? [`/proc/self/fd/${handle.fd}`, `/dev/fd/${handle.fd}`] + : [`/dev/fd/${handle.fd}`, `/proc/self/fd/${handle.fd}`]; + for (const candidate of candidates) { + try { + return await realpath(candidate); + } catch { + // Try the other supported descriptor filesystem. + } + } + return null; +} + +async function runBoundedReadonlyCommand(input: Readonly<{ + argv: readonly [string, ...string[]]; + environment: Readonly>; + maxStdoutBytes: number; + deadlineAt: number; + signal: AbortSignal; + spawn: ReadonlyCommandSpawner; + now: () => number; +}>): Promise> { + input.signal.throwIfAborted(); + const remainingMs = input.deadlineAt - input.now(); + if (remainingMs <= 0) throw new Error("The read-only command reached its deadline."); + const child = input.spawn({ argv: input.argv, environment: input.environment }); + let rejectCancellation: ((reason: Error) => void) | undefined; + const cancellation = new Promise((_resolve, reject) => { + rejectCancellation = reject; + }); + let cancellationReason: Error | undefined; + const cancel = (reason: Error): void => { + if (cancellationReason !== undefined) return; + cancellationReason = reason; + try { + child.terminate(); + } catch { + // Force termination below remains available even if SIGTERM fails. + } + rejectCancellation?.(reason); + }; + const onAbort = (): void => cancel(signalReason(input.signal)); + input.signal.addEventListener("abort", onAbort, { once: true }); + const timer = setTimeout( + () => cancel(new Error("The read-only command reached its deadline.")), + remainingMs, + ); + const stdoutTask = collectBoundedUtf8(child.stdout, input.maxStdoutBytes) + .catch((error: unknown) => { + const bounded = error instanceof Error ? error : new Error("The command output was invalid."); + cancel(bounded); + throw bounded; + }); + const stderrTask = drainBytes(child.stderr); + const completion = Promise.all([child.exited, stdoutTask, stderrTask]); + try { + const [exitCode, stdout] = await Promise.race([completion, cancellation]); + if (cancellationReason !== undefined) throw cancellationReason; + return Object.freeze({ exitCode, stdout }); + } catch (error) { + await terminateReadonlyCommand(child); + void completion.catch(() => undefined); + throw error; + } finally { + clearTimeout(timer); + input.signal.removeEventListener("abort", onAbort); + } +} + +async function collectBoundedUtf8( + chunks: AsyncIterable, + maximumBytes: number, +): Promise { + const retained: Uint8Array[] = []; + let total = 0; + for await (const chunk of chunks) { + if (!(chunk instanceof Uint8Array)) throw new Error("The command emitted an invalid byte stream."); + total += chunk.byteLength; + if (total > maximumBytes) throw new Error("The command exceeded its stdout byte bound."); + if (chunk.byteLength > 0) retained.push(chunk); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of retained) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new Error("The command emitted invalid UTF-8."); + } +} + +async function drainBytes(chunks: AsyncIterable): Promise { + for await (const chunk of chunks) { + if (!(chunk instanceof Uint8Array)) throw new Error("The command emitted an invalid byte stream."); + // Deliberately retain no bytes while keeping the child pipe drained. + } +} + +async function terminateReadonlyCommand(child: ReadonlyCommandProcess): Promise { + try { + child.terminate(); + } catch { + // Continue to the force-termination path. + } + if (await resolvesWithin(child.exited, COMMAND_TERMINATION_GRACE_MS)) return; + try { + child.forceTerminate(); + } catch { + return; + } + await resolvesWithin(child.exited, COMMAND_TERMINATION_GRACE_MS); +} + +async function resolvesWithin(promise: Promise, milliseconds: number): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise.then(() => true, () => false), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), milliseconds); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +async function* readableStreamBytes( + stream: ReadableStream | number | undefined, +): AsyncIterable { + if (stream === undefined || typeof stream === "number") return; + const reader = stream.getReader(); + try { + let result = await reader.read(); + while (!result.done) { + if (result.value.byteLength > 0) yield result.value; + result = await reader.read(); + } + } finally { + reader.releaseLock(); + } +} + +function signalReason(signal: AbortSignal): Error { + return signal.reason instanceof Error ? signal.reason : new Error("The command was aborted."); +} + +function pidDomainForPlatform(platform: NodeJS.Platform): string | null { + if (platform === "darwin") return "darwin"; + if (platform === "linux") return "linux"; + return null; +} + +async function settleBeforeDeadline( + promise: Promise, + remainingMs: number, + controller: AbortController, +): Promise { + controller.signal.throwIfAborted(); + if (remainingMs <= 0) throw new Error("Personal session discovery reached its deadline."); + let timer: ReturnType | undefined; + let onAbort: (() => void) | undefined; + const onAbortPromise = new Promise((_resolve, reject) => { + onAbort = () => reject(signalReason(controller.signal)); + controller.signal.addEventListener("abort", onAbort, { once: true }); + }); + const deadline = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + const error = new Error("Personal session discovery reached its deadline."); + controller.abort(error); + reject(error); + }, remainingMs); + }); + try { + return await Promise.race([promise, deadline, onAbortPromise]); + } finally { + if (timer !== undefined) clearTimeout(timer); + if (onAbort !== undefined) controller.signal.removeEventListener("abort", onAbort); + } +} + +function safeTitle(value: unknown, fallback: string): string { + if (typeof value !== "string") return fallback; + const redacted = redactCompleteSensitiveText(redactAbsolutePaths(value), "[protected]"); + let output = ""; + let used = 0; + for (const scalar of redacted.trim()) { + const safeScalar = containsUnsafeTerminalScalar(scalar) ? "�" : scalar; + const bytes = textEncoder.encode(safeScalar).byteLength; + if (used + bytes > TITLE_MAX_BYTES) break; + output += safeScalar; + used += bytes; + } + return output.trim() || fallback; +} + +function safeProjectRoot(value: unknown): string | undefined { + if ( + typeof value !== "string" + || !isAbsolute(value) + || textEncoder.encode(value).byteLength > PROJECT_ROOT_MAX_BYTES + || containsUnsafeTerminalScalar(value) + ) return undefined; + return value; +} + +function safeAbsolutePath(value: string): boolean { + return isAbsolute(value) + && textEncoder.encode(value).byteLength <= ABSOLUTE_PATH_MAX_BYTES + && !containsUnsafeTerminalScalar(value); +} + +function safeProviderId(value: unknown): string | null { + const id = safeBoundedString(value, PROVIDER_ID_MAX_BYTES); + return id === null || id.length === 0 ? null : id; +} + +function safeBoundedString(value: unknown, maxBytes: number): string | null { + if ( + typeof value !== "string" + || textEncoder.encode(value).byteLength > maxBytes + || containsUnsafeTerminalScalar(value) + ) return null; + return value; +} + +function normalizedTimestamp(value: unknown): number | undefined { + if (typeof value === "number") { + return Number.isSafeInteger(value) && value >= 0 ? value : undefined; + } + if (typeof value !== "string" || value.length > 128 || containsUnsafeTerminalScalar(value)) { + return undefined; + } + const parsed = Date.parse(value); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +function validLiveness(value: unknown): PersonalSessionLiveness { + return value === "live" || value === "not_live" ? value : "unknown"; +} + +function mergeClaudeLiveness( + previous: PersonalSessionLiveness | undefined, + current: PersonalSessionLiveness, +): PersonalSessionLiveness { + if (previous === undefined) return current; + if (previous === "live" || current === "live") return "live"; + if (previous === "unknown" || current === "unknown") return "unknown"; + return "not_live"; +} + +function mergeClaudeSourceProcessIdentity( + previous: PersonalClaudeSourceProcessIdentity | null | undefined, + current: PersonalClaudeSourceProcessIdentity | null, +): PersonalClaudeSourceProcessIdentity | null { + if (previous === undefined) return current; + if (previous === null || current === null) return null; + return previous.pid === current.pid + && previous.pidDomain === current.pidDomain + && previous.procStart === current.procStart + ? previous + : null; +} + +function boundedResultLimit(value: number | undefined): number { + if (value === undefined || !Number.isFinite(value)) return DEFAULT_RESULT_LIMIT; + return Math.max(0, Math.min(PERSONAL_SESSION_DISCOVERY_MAX_RESULTS, Math.floor(value))); +} + +function boundedRegistryFileLimit(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(CLAUDE_REGISTRY_MAX_RECORDS, Math.floor(value))); +} + +function boundedDeadlineMs(value: number | undefined): number { + if (value === undefined || !Number.isFinite(value)) { + return PERSONAL_SESSION_DISCOVERY_DEFAULT_DEADLINE_MS; + } + return Math.max(1, Math.min(PERSONAL_SESSION_DISCOVERY_MAX_DEADLINE_MS, Math.floor(value))); +} + +function freezeCandidate(candidate: DiscoveredPersonalSession): DiscoveredPersonalSession { + return Object.freeze(candidate); +} + +function sortCandidates( + candidates: readonly DiscoveredPersonalSession[], +): DiscoveredPersonalSession[] { + return [...candidates].sort((left, right) => { + const byScheduledTarget = Number(right.scheduledTaskTarget === true) + - Number(left.scheduledTaskTarget === true); + if (byScheduledTarget !== 0) return byScheduledTarget; + const byAdmissionPriority = candidateAdmissionPriority(left) + - candidateAdmissionPriority(right); + if (byAdmissionPriority !== 0) return byAdmissionPriority; + const byUpdated = (right.updatedAt ?? -1) - (left.updatedAt ?? -1); + if (byUpdated !== 0) return byUpdated; + return left.providerThreadId < right.providerThreadId + ? -1 + : left.providerThreadId > right.providerThreadId + ? 1 + : 0; + }); +} + +function candidateAdmissionPriority(candidate: DiscoveredPersonalSession): number { + if (candidate.admissionEligible !== false) { + return candidate.liveness === "not_live" ? 0 : 1; + } + return candidate.trustedLiveObservation === true ? 2 : 3; +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function errorCode(error: unknown): string | null { + if (!isRecord(error)) return null; + return typeof error.code === "string" ? error.code : null; +} + +function abortRequested(signal: AbortSignal): boolean { + return signal.aborted; +} diff --git a/src/daemon/ports.ts b/src/daemon/ports.ts index cdd244a..4077598 100644 --- a/src/daemon/ports.ts +++ b/src/daemon/ports.ts @@ -16,6 +16,9 @@ import type { ProviderInteractionAuthority, } from "../domain/interactions"; import type { ProfileId, ProjectId, SessionId } from "../domain/values"; +import type { ClaudeProcessIdentity } from "../claude/process"; + +export type { ClaudeProcessIdentity } from "../claude/process"; export type ProfileAuthority = { id: ProfileId; @@ -43,7 +46,11 @@ export type DevinRuntimeStartReview = RuntimeStartReviewOf { reviewSessionStart(input: { authority: ProfileAuthority; projectRoot?: string; preset: Preset; requirement: PresetRequirement; fast: boolean; signal: AbortSignal }): Promise>; /** Releases a review that never reached its matching start effect. */ discardRuntimeReview(review: RuntimeStartReviewOf): void; - startSession(input: { authority: ProfileAuthority; projectRoot?: string; review: RuntimeStartReviewOf; signal: AbortSignal }): Promise; + startSession(input: { + authority: ProfileAuthority; + /** Persists exact Claude child custody before that child is admitted. */ + admitProcessIdentity?: (identity: ClaudeProcessIdentity) => Promise; + projectRoot?: string; + /** + * An HRA-reserved provider identity supplied before a Claude child is + * launched, so crash recovery can recognize an indeterminate launch. + * Codex allocates its own thread identity and therefore ignores this. + */ + providerThreadId?: string; + review: RuntimeStartReviewOf; + signal: AbortSignal; + }): Promise; observeSession(input: { authority: ProfileAuthority; providerThreadId: string; signal: AbortSignal }): Promise; readSession(input: { authority: ProfileAuthority; providerThreadId: string; detail: boolean; signal: AbortSignal }): Promise; /** @@ -216,6 +277,31 @@ export interface SessionRuntimePort { */ export interface ClaudeRuntimePort extends SessionRuntimePort { readonly provider: "claude"; + /** + * Reclaims an existing Claude conversation under this runtime's full stdin + * and interaction authority. Callers may supply the closed liveness proof + * only after the prior process is known not to be live. + */ + claimSession(input: { + authority: ProfileAuthority; + /** Persists exact child custody before the resumed process is admitted. */ + admitProcessIdentity?: (identity: ClaudeProcessIdentity) => Promise; + providerThreadId: string; + projectRoot: string; + /** Durable display title retained across takeover and daemon restart. */ + title: string; + preset: Preset; + requirement: PresetRequirement; + fast: boolean; + sourceLiveness: ClaudeSessionClaimProof; + signal: AbortSignal; + }): Promise; + /** Exact live child authority retained for durable restart liveness fencing. */ + readSessionProcessIdentity(input: { + authority: ProfileAuthority; + providerThreadId: string; + signal: AbortSignal; + }): Promise; readAccount(input: { authority: ProfileAuthority; signal: AbortSignal }): Promise; /** * Rebinds live, quiescent Claude processes when only the sibling Codex @@ -255,9 +341,31 @@ export interface DevinRuntimePort extends SessionRuntimePort { readonly provider: "codex"; + /** + * Explicitly retries custody of a personal-home thread. Unlike ordinary + * observation, one retained determinate resume refusal may be invalidated + * so a later adoption poll can acquire a thread its prior controller freed. + */ + claimSession?(input: { + authority: ProfileAuthority; + providerThreadId: string; + projectRoot: string; + preset: Preset; + requirement: PresetRequirement; + fast: boolean; + signal: AbortSignal; + }): Promise; login(input: { authority: ProfileAuthority; method: "browser" | "device_code"; signal: AbortSignal }): Promise; cancelLogin(input: { authority: ProfileAuthority; loginId: string; signal: AbortSignal }): Promise<{ status: "canceled" | "not_found" }>; logout(input: { authority: ProfileAuthority; signal: AbortSignal }): Promise; + /** + * Retire an already-running exact account generation without discovering, + * launching, resuming, or releasing any individual provider thread. + */ + releaseOwnedAuthority?(input: { + authority: ProfileAuthority; + signal: AbortSignal; + }): Promise; readAccount(input: { authority: ProfileAuthority; signal: AbortSignal }): Promise; readUsage(input: { authority: ProfileAuthority; signal: AbortSignal }): Promise<{ revision: number; observedAt: number; payload: unknown }>; consumeRateLimitReset(input: { @@ -272,6 +380,16 @@ export interface CodexRuntimePort extends SessionRuntimePort; + /** + * Reads one exact thread's bounded metadata without resuming, subscribing, + * or creating session observation authority. Personal discovery uses this + * only to reach scheduled-task targets outside recency-sorted list pages. + */ + readSessionMetadata?( + authority: ProfileAuthority, + providerThreadId: string, + signal: AbortSignal, + ): Promise; rename(input: { authority: ProfileAuthority; providerThreadId: string; name: string; signal: AbortSignal }): Promise; inspectTurn(input: { authority: ProfileAuthority; providerThreadId: string; turnId: string; signal: AbortSignal }): Promise; inspectInteractionAuthority(input: { @@ -364,11 +482,13 @@ export class UnavailableCodexRuntime implements CodexRuntimePort { login(): Promise { return Promise.reject(this.#unavailable()); } cancelLogin(): Promise { return Promise.reject(this.#unavailable()); } logout(): Promise { return Promise.reject(this.#unavailable()); } + async releaseOwnedAuthority(): Promise {} readAccount(): Promise { return Promise.reject(this.#unavailable()); } readUsage(): Promise { return Promise.reject(this.#unavailable()); } consumeRateLimitReset(): Promise { return Promise.reject(this.#unavailable()); } listPlugins(): Promise { return Promise.reject(this.#unavailable()); } listSessions(): Promise { return Promise.reject(this.#unavailable()); } + claimSession(): Promise { return Promise.reject(this.#unavailable()); } reviewSessionStart(): Promise { return Promise.reject(this.#unavailable()); } discardRuntimeReview(): void {} startSession(): Promise { return Promise.reject(this.#unavailable()); } @@ -424,6 +544,8 @@ export class UnavailableClaudeRuntime implements ClaudeRuntimePort { } interactionAuthority(): ProviderInteractionAuthority { return this.#unavailable(); } pinnedVersion(): string { return this.#unavailable(); } + claimSession(): Promise { return Promise.reject(this.#unavailable()); } + readSessionProcessIdentity(): Promise { return Promise.reject(this.#unavailable()); } readAccount(): Promise { return Promise.reject(this.#unavailable()); } rebindProfileAuthority(): void {} reviewSessionStart(): Promise { return Promise.reject(this.#unavailable()); } diff --git a/src/daemon/provider-switch.test.ts b/src/daemon/provider-switch.test.ts index 0279581..b00d563 100644 --- a/src/daemon/provider-switch.test.ts +++ b/src/daemon/provider-switch.test.ts @@ -1,9 +1,12 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { createHash } from "node:crypto"; import { mkdtemp, mkdir, realpath, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { CLAUDE_PIN, CLAUDE_PIN_MODEL } from "../claude/pin"; +import { IndeterminateCodexEffectError } from "../codex"; import type { Preset } from "../domain/presets"; import type { EffectiveClaudeRuntimeProfile, @@ -21,7 +24,11 @@ import { import { initializeStatePaths, resolveStatePaths } from "../storage/paths"; import { StateStore } from "../storage/state-store"; import { + ClaudeProcessExitUnprovenError, + ClaudeSessionObservationError, + CodexSessionObservationError, UnavailableCloudControl, + type ClaudeProcessIdentity, type ClaudeRuntimePort, type ClaudeRuntimeStartReview, type CodexAccountProjection, @@ -78,8 +85,15 @@ class SwitchFakeCodex implements CodexRuntimePort { discardRuntimeReview(): void {} readonly calls: string[] = []; readonly endedThreads: string[] = []; + beforeEndSessionReturn?: () => Promise | void; + beforeLogoutReturn?: () => Promise | void; endSessionError?: Error; + readAccountCalls = 0; turnStatus: "completed" | "inProgress" = "completed"; + accountProjection: CodexAccountProjection = { + signedIn: true, + email: "person@example.com", + }; #turns = 0; projection: CodexSessionProjection = { providerThreadId: "codex-thread-1", @@ -92,9 +106,14 @@ class SwitchFakeCodex implements CodexRuntimePort { return { status: "signed_in", account: { signedIn: true, email: "person@example.com" } }; } async readAccount(): Promise { - return { signedIn: true, email: "person@example.com" }; + this.readAccountCalls += 1; + return this.accountProjection; + } + async releaseOwnedAuthority(): Promise {} + async logout(): Promise { + this.calls.push("logout"); + await this.beforeLogoutReturn?.(); } - async logout(): Promise {} async close(): Promise {} async reviewSessionStart( input: Parameters[0], @@ -130,6 +149,7 @@ class SwitchFakeCodex implements CodexRuntimePort { this.calls.push("end-session"); if (this.endSessionError !== undefined) throw this.endSessionError; this.endedThreads.push(input.providerThreadId); + await this.beforeEndSessionReturn?.(); } async reviewTurnStart( input: Parameters[0], @@ -192,15 +212,40 @@ class SwitchFakeClaude implements ClaudeRuntimePort { this.pendingReviewIds.delete(review.reviewId); } readonly calls: string[] = []; - readonly endedThreads: string[] = []; + readonly claimRequests: Array[0]> = []; + readonly endRequests: Array[0]> = []; + readonly endedProcessIdentities: ClaudeProcessIdentity[] = []; + readonly identityRequests: Array< + Parameters[0] + > = []; + readonly observeRequests: Array[0]> = []; + readonly startSessionRequests: Array[0]> = []; readonly seededMessages: string[] = []; + beforeStartSessionAdmission?: ( + input: Parameters[0], + ) => Promise | void; + beforeStartSessionReturn?: () => Promise | void; + beforeStartTurnReturn?: () => Promise | void; + connectionId = "30000000-0000-4000-8000-000000000002"; + connectionIdOnClaim?: string; + controllerLive = true; + disconnectOnObserveRequest?: number; + processIdentity: ClaudeProcessIdentity = { + pid: 64_001, + pidDomain: "darwin", + procStart: "switch-claude-initial", + }; + processIdentityOnClaim?: ClaudeProcessIdentity; + readonly endedThreads: string[] = []; accountSignedIn = true; readonly accountSignedInResults: boolean[] = []; beforeReadAccountReturn?: () => Promise; + beforeClaimSessionReturn?: () => Promise | void; readAccountError?: Error; observeError?: Error; endSessionError?: Error; reviewProfileGenerationOffset = 0; + omitProcessIdentityOnClaim = false; startSessionError?: Error; #turns = 0; projection: CodexSessionProjection = { @@ -218,7 +263,12 @@ class SwitchFakeClaude implements ClaudeRuntimePort { if (this.readAccountError !== undefined) throw this.readAccountError; const signedIn = this.accountSignedInResults.shift() ?? this.accountSignedIn; return signedIn - ? { signedIn: true, email: "person@example.com" } + ? { + signedIn: true, + accountId: "claude-account", + organizationId: "claude-organization", + email: "person@example.com", + } : { signedIn: false }; } async close(): Promise {} @@ -241,17 +291,69 @@ class SwitchFakeClaude implements ClaudeRuntimePort { input: Parameters[0], ): Promise { this.calls.push("start-session"); + this.startSessionRequests.push(input); this.pendingReviewIds.delete(input.review.reviewId); if (this.startSessionError !== undefined) throw this.startSessionError; + if (input.providerThreadId !== undefined) { + this.projection = { ...this.projection, providerThreadId: input.providerThreadId }; + } + await this.beforeStartSessionAdmission?.(input); + await input.admitProcessIdentity?.(this.processIdentity); + await this.beforeStartSessionReturn?.(); + this.controllerLive = true; return { ...this.projection, effectiveRuntimeProfile: input.review.effectiveRuntimeProfile }; } + async claimSession( + input: Parameters[0], + ): ReturnType { + this.calls.push("claim-session"); + this.claimRequests.push(input); + this.controllerLive = true; + if (this.processIdentityOnClaim !== undefined) { + this.processIdentity = this.processIdentityOnClaim; + delete this.processIdentityOnClaim; + } + if (this.connectionIdOnClaim !== undefined) { + this.connectionId = this.connectionIdOnClaim; + delete this.connectionIdOnClaim; + } + if (!this.omitProcessIdentityOnClaim) { + await input.admitProcessIdentity?.(this.processIdentity); + } + await this.beforeClaimSessionReturn?.(); + this.projection = { + ...this.projection, + providerThreadId: input.providerThreadId, + projectRoot: input.projectRoot, + status: "idle", + title: input.title, + }; + delete (this.projection as { activeTurnId?: string }).activeTurnId; + return { + ...this.projection, + effectiveRuntimeProfile: claudeProfile(input.authority), + }; + } + async readSessionProcessIdentity( + input: Parameters[0], + ): ReturnType { + this.calls.push("read-identity"); + this.identityRequests.push(input); + return this.processIdentity; + } async observeSession( input: Parameters[0], ): Promise { this.calls.push("observe"); + this.observeRequests.push(input); if (this.observeError !== undefined) throw this.observeError; + if (this.disconnectOnObserveRequest === this.observeRequests.length) { + this.controllerLive = false; + throw new ClaudeSessionObservationError(); + } + if (!this.controllerLive) throw new ClaudeSessionObservationError(); return { - connectionId: "30000000-0000-4000-8000-000000000002", + connectionId: this.connectionId, projection: { ...this.projection, providerThreadId: input.providerThreadId }, resumed: false, }; @@ -260,10 +362,15 @@ class SwitchFakeClaude implements ClaudeRuntimePort { this.calls.push("read"); return this.projection; } - async endSession(input: Parameters[0]): Promise { + async endSession( + input: Parameters[0], + ): Promise { this.calls.push("end-session"); if (this.endSessionError !== undefined) throw this.endSessionError; + this.endRequests.push(input); this.endedThreads.push(input.providerThreadId); + this.endedProcessIdentities.push(this.processIdentity); + this.controllerLive = false; } async reviewTurnStart( input: Parameters[0], @@ -286,6 +393,7 @@ class SwitchFakeClaude implements ClaudeRuntimePort { }> { this.calls.push("start-turn"); this.pendingReviewIds.delete(input.review.reviewId); + await this.beforeStartTurnReturn?.(); this.seededMessages.push(input.message); this.#turns += 1; return { @@ -542,6 +650,30 @@ const leaveFinalSwitchCommitUnsettled = async ( } }; +const removeTargetAccountAuthorityFromSwitchEvidence = ( + value: Fixture, + idempotencyKey: string, +): NonNullable> => { + const attempt = value.store.readMutation(idempotencyKey); + if (attempt?.evidence?.evidence.kind !== "session.switch") { + throw new Error("Expected immutable provider-switch evidence."); + } + const direct = new Database(value.store.paths.database, { create: false, strict: true }); + try { + direct.exec("DROP TRIGGER mutation_effect_evidence_immutable_update"); + const legacyEvidence = { ...attempt.evidence.evidence } as Record; + delete legacyEvidence.targetProviderAccountKey; + const legacyEvidenceJson = JSON.stringify(legacyEvidence); + const legacyEvidenceDigest = createHash("sha256").update(legacyEvidenceJson).digest("hex"); + direct.query( + "UPDATE mutation_effect_evidence SET evidence_json=?,evidence_digest=? WHERE attempt_id=?", + ).run(legacyEvidenceJson, legacyEvidenceDigest, attempt.id); + } finally { + direct.close(); + } + return attempt; +}; + const expectCurrentSwitchSuccessors = ( value: Fixture, idempotencyKey: string, @@ -600,10 +732,12 @@ describe("provider portability", () => { { signal }, ) as { session: { id: `sess_${string}` } }; - expect(value.claude.calls.slice(0, 2)).toEqual([ - "read-account", - "review-session", - ]); + const review = value.claude.calls.indexOf("review-session"); + const start = value.claude.calls.indexOf("start-session"); + expect(review).toBeGreaterThan(value.claude.calls.indexOf("read-account")); + expect(start).toBeGreaterThan(review); + expect(value.claude.calls.filter((call) => call === "read-account").length) + .toBeGreaterThanOrEqual(3); expect(value.store.requireProfileById(added.account.id).state).toBe("signed_out"); await value.service.execute( { @@ -621,6 +755,8 @@ describe("provider portability", () => { test("releases an idle Claude session before granting login for an expired account", async () => { const value = await fixture(); const { accountId, sessionId } = await claudeSession(value); + const providerThreadId = value.store.requireSession(sessionId).providerThreadId; + if (providerThreadId === undefined) throw new Error("Expected a bound Claude session."); value.claude.accountSignedIn = false; const loginKey = crypto.randomUUID(); @@ -638,7 +774,7 @@ describe("provider portability", () => { provider: "claude", state: "terminal", }); - expect(value.claude.endedThreads).toEqual(["claude-thread-1"]); + expect(value.claude.endedThreads).toEqual([providerThreadId]); expect(value.store.readMutation(loginKey)).toMatchObject({ authorityId: accountId, kind: "account.claude-login", @@ -685,7 +821,7 @@ describe("provider portability", () => { test("reports an ordinary Claude observation failure as bounded unavailable", async () => { const value = await fixture(); const { sessionId } = await claudeSession(value); - value.claude.observeError = new Error("Claude runtime exited"); + value.claude.observeError = new CodexSessionObservationError("resume_unavailable"); await expect(value.service.execute({ kind: "session.status", @@ -793,7 +929,10 @@ describe("provider portability", () => { expect(value.store.readMutation(idempotencyKey)).toMatchObject({ state: "prepared" }); expect(value.store.listUnsettledMutations({ authorityId: added.account.id })).toEqual([]); expect(value.claude.pendingReviewIds.size).toBe(0); - expect(value.claude.calls).toEqual(["read-account", "review-session"]); + expect(value.claude.calls.filter((call) => call === "review-session")).toHaveLength(1); + expect(value.claude.calls.filter((call) => call === "start-session")).toHaveLength(0); + expect(value.claude.calls.filter((call) => call === "read-account").length) + .toBeGreaterThanOrEqual(2); value.claude.reviewProfileGenerationOffset = 0; await expect(value.service.execute({ @@ -978,6 +1117,7 @@ describe("provider portability", () => { status: "completed", type: "itemCompleted", }); + await value.service.settled(); const transcript = await transcriptOf(value, sessionId); const kinds = transcript.records.map((record) => record.kind); @@ -1026,7 +1166,7 @@ describe("provider portability", () => { const session = value.store.requireSession(sessionId); expect(session.provider).toBe("claude"); expect(session.preset).toBe("fable-max"); - expect(session.providerThreadId).toBe("claude-thread-1"); + expect(session.providerThreadId).toMatch(/^[0-9a-f-]{36}$/u); // The outgoing provider was released, and its thread was not deleted. expect(value.codex.endedThreads).toEqual(["codex-thread-1"]); @@ -1061,6 +1201,165 @@ describe("provider portability", () => { expect(value.claude.seededMessages.at(-1)).toBe("carry on"); }); + test("holds the destination account lock through a cross-account provider switch", async () => { + const value = await fixture(); + const { sessionId } = await codexSession(value); + const target = await value.service.execute( + { kind: "account.add", label: "Target" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute( + { account: target.account.id, deviceCode: false, kind: "account.login" }, + { signal }, + ); + const targetGeneration = value.store + .requireProfileById(target.account.id).processGeneration; + + const events: string[] = []; + let targetStartEntered!: () => void; + const targetStarted = new Promise((resolve) => { targetStartEntered = resolve; }); + let releaseTargetStart!: () => void; + const targetStartGate = new Promise((resolve) => { releaseTargetStart = resolve; }); + value.claude.beforeStartSessionReturn = async () => { + events.push("target-start"); + targetStartEntered(); + await targetStartGate; + }; + value.codex.beforeEndSessionReturn = () => { events.push("source-release"); }; + value.claude.beforeStartTurnReturn = () => { events.push("seed"); }; + value.codex.beforeLogoutReturn = () => { events.push("logout"); }; + + let switching: Promise | undefined; + let logout: Promise | undefined; + try { + switching = value.service.execute({ + account: target.account.id, + idempotencyKey: crypto.randomUUID(), + kind: "session.switch", + provider: "claude", + session: sessionId, + }, { signal }); + await targetStarted; + + let logoutSettled = false; + logout = value.service.execute({ + account: target.account.id, + idempotencyKey: crypto.randomUUID(), + kind: "account.logout", + }, { signal }).finally(() => { logoutSettled = true; }); + await Bun.sleep(0); + + expect(logoutSettled).toBe(false); + expect(value.codex.calls.filter((call) => call === "logout")).toHaveLength(0); + expect(value.store.requireProfileById(target.account.id)).toMatchObject({ + processGeneration: targetGeneration, + state: "signed_in", + }); + + releaseTargetStart(); + const switched = await switching as { + to: { account: `acct_${string}`; preset: string; provider: string }; + }; + await logout; + + expect(switched.to).toEqual({ + account: target.account.id, + preset: "fable-max", + provider: "claude", + }); + expect(events).toEqual(["target-start", "seed", "source-release", "logout"]); + expect(value.codex.endedThreads).toEqual(["codex-thread-1"]); + const switchedSession = value.store.requireSession(sessionId); + expect(switchedSession).toMatchObject({ + profileId: target.account.id, + provider: "claude", + }); + expect(switchedSession.providerThreadId).toMatch(/^[0-9a-f-]{36}$/u); + expect(value.store.latestSessionRuntimeProfile(sessionId)?.profile).toMatchObject({ + processGeneration: targetGeneration, + profileId: target.account.id, + }); + } finally { + releaseTargetStart(); + await Promise.allSettled([ + ...(switching === undefined ? [] : [switching]), + ...(logout === undefined ? [] : [logout]), + ]); + } + }); + + test("releases and resumes a switched-in Claude child lost after seeding", async () => { + const value = await fixture(); + const { accountId, sessionId } = await codexSession(value); + await value.service.execute( + { + idempotencyKey: crypto.randomUUID(), + kind: "session.send", + message: "preserve this context", + session: sessionId, + }, + { signal }, + ); + const initialIdentity = value.claude.processIdentity; + const replacementIdentity: ClaudeProcessIdentity = { + pid: 64_002, + pidDomain: "darwin", + procStart: "switch-claude-replacement", + }; + const replacementConnectionId = "30000000-0000-4000-8000-000000000003"; + value.claude.disconnectOnObserveRequest = 1; + value.claude.processIdentityOnClaim = replacementIdentity; + value.claude.connectionIdOnClaim = replacementConnectionId; + + const switched = await value.service.execute( + { + idempotencyKey: crypto.randomUUID(), + kind: "session.switch", + provider: "claude", + session: sessionId, + }, + { signal }, + ) as { seed: { delivered: boolean } }; + + const switchedSession = value.store.requireSession(sessionId); + const claudeThreadId = switchedSession.providerThreadId; + if (claudeThreadId === undefined) throw new Error("Expected a bound Claude session."); + expect(claudeThreadId).toMatch(/^[0-9a-f-]{36}$/u); + expect(switched.seed.delivered).toBe(true); + expect(value.claude.endRequests).toHaveLength(1); + expect(value.claude.endRequests[0]?.providerThreadId).toBe(claudeThreadId); + expect(value.claude.endedProcessIdentities).toEqual([initialIdentity]); + expect(value.claude.claimRequests).toHaveLength(1); + expect(value.claude.claimRequests[0]).toMatchObject({ + providerThreadId: claudeThreadId, + sourceLiveness: "not_live", + title: "New session", + }); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId: claudeThreadId, + profileId: accountId, + runtimeScope: "managed", + })).toMatchObject({ + identity: replacementIdentity, + sessionId, + state: "bound", + }); + + const seedDelivery = value.claude.calls.indexOf("start-turn"); + const firstObservation = value.claude.calls.indexOf("observe", seedDelivery + 1); + const release = value.claude.calls.indexOf("end-session", firstObservation + 1); + const claim = value.claude.calls.indexOf("claim-session", release + 1); + const replacementObservation = value.claude.calls.indexOf("observe", claim + 1); + expect(seedDelivery).toBeGreaterThanOrEqual(0); + expect(firstObservation).toBeGreaterThanOrEqual(0); + expect(firstObservation).toBeGreaterThan(seedDelivery); + expect(release).toBeGreaterThan(firstObservation); + expect(claim).toBeGreaterThan(release); + expect(replacementObservation).toBeGreaterThan(claim); + expect(value.claude.seededMessages).toHaveLength(1); + expect(value.claude.seededMessages[0]).toContain("[HRA provider handoff]"); + }); + test("replays a committed provider switch after response loss without repeating provider effects", async () => { const value = await fixture(); const { sessionId } = await codexSession(value); @@ -1128,10 +1427,12 @@ describe("provider portability", () => { const first = await value.service.execute(firstCommand, { signal }) as { session: { provider: string; providerThreadId: string; revision: number }; }; + const firstProviderThreadId = first.session.providerThreadId; expect(first.session).toMatchObject({ provider: "claude", - providerThreadId: "claude-thread-1", + providerThreadId: firstProviderThreadId, }); + expect(firstProviderThreadId).toMatch(/^[0-9a-f-]{36}$/u); await value.service.execute({ idempotencyKey: crypto.randomUUID(), @@ -1155,7 +1456,7 @@ describe("provider portability", () => { }; expect(replayed.session).toMatchObject({ provider: "claude", - providerThreadId: "claude-thread-1", + providerThreadId: firstProviderThreadId, revision: first.session.revision, }); expect(value.store.requireSession(sessionId).provider).toBe("codex"); @@ -1198,14 +1499,15 @@ describe("provider portability", () => { kind: "session.switch", state: "ambiguous", }); - expect(value.store.readSessionProviderSwitchProgress( + const seededProgress = value.store.readSessionProviderSwitchProgress( value.store.readMutation(idempotencyKey)!.id, - )).toMatchObject({ + ); + expect(seededProgress).toMatchObject({ seedTurnId: "claude-turn-1", sourceReleased: false, - targetProviderThreadId: "claude-thread-1", targetReleased: false, }); + expect(seededProgress.targetProviderThreadId).toMatch(/^[0-9a-f-]{36}$/u); expect(value.claude.seededMessages).toHaveLength(1); expect(value.claude.endedThreads).toEqual([]); }); @@ -1223,9 +1525,9 @@ describe("provider portability", () => { expect(progressBeforeRestart.seed).toBeUndefined(); expect(progressBeforeRestart).toMatchObject({ sourceReleased: false, - targetProviderThreadId: "claude-thread-1", targetReleased: false, }); + expect(progressBeforeRestart.targetProviderThreadId).toMatch(/^[0-9a-f-]{36}$/u); const restarted = await reopenFixture(value); expectCurrentSwitchSuccessors(restarted, idempotencyKey); @@ -1288,18 +1590,23 @@ describe("provider portability", () => { }); const attemptBeforeRestart = value.store.readMutation(idempotencyKey); if (attemptBeforeRestart === null) throw new Error("Expected an unsettled provider switch."); - expect(value.store.readSessionProviderSwitchProgress(attemptBeforeRestart.id)).toMatchObject({ + const progressBeforeRestart = value.store.readSessionProviderSwitchProgress( + attemptBeforeRestart.id, + ); + const targetProviderThreadId = progressBeforeRestart.targetProviderThreadId; + if (targetProviderThreadId === undefined) throw new Error("Expected a Claude target receipt."); + expect(progressBeforeRestart).toMatchObject({ seedTurnId: "claude-turn-1", sourceReleased: true, - targetProviderThreadId: "claude-thread-1", targetReleased: false, }); + expect(targetProviderThreadId).toMatch(/^[0-9a-f-]{36}$/u); const restarted = await reopenFixture(value); expectCurrentSwitchSuccessors(restarted, idempotencyKey); expect(restarted.store.requireSession(sessionId)).toMatchObject({ provider: "claude", - providerThreadId: "claude-thread-1", + providerThreadId: targetProviderThreadId, state: "recovery_required", }); await expect(restarted.service.execute({ @@ -1391,125 +1698,608 @@ describe("provider portability", () => { }); }); - test("recovers a seeded Codex target when the Claude source release receipt survived restart", async () => { + test("abandons a restarted legacy Codex target without provider access", async () => { const value = await fixture(); const { sessionId } = await claudeSession(value); - const targetAccountId = await signedInCodexAccount(value, "Recoverable Codex target"); + const targetAccountId = await signedInCodexAccount(value, "Legacy restarted Codex target"); const idempotencyKey = crypto.randomUUID(); - await leaveFinalSwitchCommitUnsettled(value, { + value.claude.endSessionError = new Error("source release did not settle"); + await expect(value.service.execute({ account: targetAccountId, idempotencyKey, + kind: "session.switch", provider: "codex", session: sessionId, - }); - const attemptBeforeRestart = value.store.readMutation(idempotencyKey); - if (attemptBeforeRestart === null) throw new Error("Expected an unsettled provider switch."); - expect(value.store.readSessionProviderSwitchProgress(attemptBeforeRestart.id)).toMatchObject({ + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + const attempt = value.store.readMutation(idempotencyKey); + if (attempt === null) throw new Error("Expected an unsettled provider switch."); + expect(value.store.readSessionProviderSwitchProgress(attempt.id)).toMatchObject({ seedTurnId: "codex-turn-1", - sourceReleased: true, + sourceReleased: false, targetProviderThreadId: "codex-thread-1", targetReleased: false, }); + removeTargetAccountAuthorityFromSwitchEvidence(value, idempotencyKey); const restarted = await reopenFixture(value); expectCurrentSwitchSuccessors(restarted, idempotencyKey); - expect(await restarted.service.execute({ + const codexCallsBefore = [...restarted.codex.calls]; + const codexAccountReadsBefore = restarted.codex.readAccountCalls; + const claudeCallsBefore = [...restarted.claude.calls]; + await expect(restarted.service.execute({ kind: "session.recover", session: sessionId, - }, { signal })).toMatchObject({ + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + + await expect(restarted.service.execute({ + kind: "session.abandon", + session: sessionId, + }, { signal })).resolves.toMatchObject({ recovery: { providerEffectRetried: false, - resolution: "proven_applied", + providerStateDeleted: false, + providerStateUnknown: true, + resolution: "abandoned", + sourceObserved: false, + sourceReleased: false, + sourceStateUnknown: true, + targetAddressable: true, + targetReleased: false, + targetStateUnknown: true, + unaddressableTargetMayExist: false, }, - session: { - profileId: targetAccountId, - provider: "codex", - providerThreadId: "codex-thread-1", - state: "idle", + session: { state: "terminal" }, + }); + expect(restarted.codex.calls).toEqual(codexCallsBefore); + expect(restarted.codex.readAccountCalls).toBe(codexAccountReadsBefore); + expect(restarted.codex.endedThreads).toEqual([]); + expect(restarted.claude.calls).toEqual(claudeCallsBefore); + expect(restarted.store.readMutation(idempotencyKey)).toMatchObject({ + resolution: { + evidence: { providerStateDeleted: false, providerStateUnknown: true }, + kind: "abandoned", }, + state: "reconciled", }); - expect(restarted.codex.calls).toEqual(["read"]); - expect(restarted.claude.calls).toEqual([]); + }); + + test("does not target a replacement Codex account while abandoning a recovered switch", async () => { + const value = await fixture(); + const { sessionId } = await claudeSession(value); + const targetAccountId = await signedInCodexAccount(value, "Replaced Codex target"); + const idempotencyKey = crypto.randomUUID(); + value.claude.endSessionError = new Error("source release did not settle"); + await expect(value.service.execute({ + account: targetAccountId, + idempotencyKey, + kind: "session.switch", + provider: "codex", + session: sessionId, + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + const attemptBeforeRestart = value.store.readMutation(idempotencyKey); + if (attemptBeforeRestart?.evidence?.evidence.kind !== "session.switch") { + throw new Error("Expected an unsettled provider switch."); + } + expect(attemptBeforeRestart.evidence.evidence.targetProviderAccountKey).toBeString(); + + const restarted = await reopenFixture(value); + restarted.codex.accountProjection = { + signedIn: true, + email: "replacement@example.com", + }; + expect(await restarted.service.execute({ + kind: "session.abandon", + session: sessionId, + }, { signal })).toMatchObject({ + recovery: { + providerStateDeleted: false, + providerStateUnknown: true, + sourceReleased: false, + sourceStateUnknown: true, + targetReleased: false, + targetStateUnknown: true, + }, + session: { state: "terminal" }, + }); + expect(restarted.codex.calls).not.toContain("end-session"); + expect(restarted.codex.endedThreads).toEqual([]); expect(restarted.store.readMutation(idempotencyKey)).toMatchObject({ - resolution: { kind: "proven_applied" }, + resolution: { + evidence: { + providerStateDeleted: false, + providerStateUnknown: true, + targetReleased: false, + targetStateUnknown: true, + }, + kind: "abandoned", + }, state: "reconciled", }); - const transcript = await transcriptOf(restarted, sessionId); - expect(transcript.records.filter((record) => record.kind === "provider_switch")) - .toHaveLength(1); - expect(transcript.records.filter( - (record) => record.kind === "user" && record.actor === "provider_switch", - )).toHaveLength(1); }); - test("does not accept one visible seed match from an incomplete recovery projection", async () => { + test("keeps a lost target start ambiguous when the target account changes before the response", async () => { const value = await fixture(); - const { sessionId } = await codexSession(value); + const { sessionId } = await claudeSession(value); + const targetAccountId = await signedInCodexAccount(value, "Lost-response Codex target"); + const idempotencyKey = crypto.randomUUID(); + const startTarget = value.codex.startSession.bind(value.codex); + Object.defineProperty(value.codex, "startSession", { + configurable: true, + value: async (input: Parameters[0]) => { + await startTarget(input); + value.codex.accountProjection = { + signedIn: true, + email: "replacement@example.com", + }; + throw new IndeterminateCodexEffectError("thread/start", 71); + }, + }); + + await expect(value.service.execute({ + account: targetAccountId, + idempotencyKey, + kind: "session.switch", + provider: "codex", + session: sessionId, + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + + expect(value.codex.calls.filter((call) => call === "start-session")).toHaveLength(1); + expect(value.codex.calls.filter((call) => call === "end-session")).toHaveLength(0); + expect(value.codex.endedThreads).toEqual([]); + expect(value.store.readMutation(idempotencyKey)).toMatchObject({ state: "ambiguous" }); + expect(value.store.requireSession(sessionId)).toMatchObject({ + provider: "claude", + state: "recovery_required", + }); + await value.service.settled(); + await expect(value.service.close()).resolves.toBeUndefined(); + }); + + test("runs the forced target-account proof after a failed recovery read", async () => { + const value = await fixture(); + const { sessionId } = await claudeSession(value); + const targetAccountId = await signedInCodexAccount(value, "Read-race Codex target"); const idempotencyKey = crypto.randomUUID(); const recordSeedResult = value.store.recordSessionProviderSwitchSeedResult.bind(value.store); - const endTarget = value.claude.endSession.bind(value.claude); + value.codex.endSessionError = new Error("simulated target cleanup failure"); Object.defineProperty(value.store, "recordSessionProviderSwitchSeedResult", { configurable: true, value: () => { throw new Error("simulated seed-result receipt failure"); }, }); - Object.defineProperty(value.claude, "endSession", { - configurable: true, - value: async () => { throw new Error("simulated target cleanup failure"); }, - }); try { await expect(value.service.execute({ + account: targetAccountId, idempotencyKey, kind: "session.switch", - provider: "claude", + provider: "codex", session: sessionId, }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); } finally { + delete value.codex.endSessionError; Object.defineProperty(value.store, "recordSessionProviderSwitchSeedResult", { configurable: true, value: recordSeedResult, }); - Object.defineProperty(value.claude, "endSession", { - configurable: true, - value: endTarget, - }); } - const attempt = value.store.readMutation(idempotencyKey); - if (attempt === null) throw new Error("Expected an unsettled provider switch."); - value.claude.projection = { - ...value.claude.projection, - messages: [{ - clientId: attempt.id, - role: "user", - text: "provider handoff", - turnId: "claude-turn-1", - }], - omission: { - hasMoreOlderTurns: false, - incompleteTurnIds: [], - omittedMessages: 0, - returnedTurns: 1, - truncatedMessages: 1, - turnLimit: 20, - unreadItemTurnIds: [], + const readsBefore = value.codex.calls.filter((call) => call === "read").length; + const endsBefore = value.codex.calls.filter((call) => call === "end-session").length; + Object.defineProperty(value.codex, "readSession", { + configurable: true, + value: async () => { + value.codex.calls.push("read"); + value.codex.accountProjection = { + signedIn: true, + email: "replacement@example.com", + }; + throw new Error("simulated target read failure"); }, - turnSummaries: [{ - actions: [], - files: [], - id: "claude-turn-1", - omittedActions: 0, - omittedFiles: 0, - status: "completed", - }], - }; + }); - await expect(value.service.execute({ + const failure = await value.service.execute({ kind: "session.recover", session: sessionId, - }, { signal })).rejects.toMatchObject({ - code: "RECOVERY_REQUIRED", - message: expect.stringContaining("cannot prove that the seed match is unique"), - }); - expect(value.store.readSessionProviderSwitchProgress(attempt.id).seedTurnId).toBeUndefined(); + }, { signal }).catch((error: unknown) => error); + expect(failure).toMatchObject({ name: "IndeterminateLocalCommitError" }); + expect(value.codex.calls.filter((call) => call === "read")).toHaveLength(readsBefore + 1); + expect(value.codex.calls.filter((call) => call === "end-session")).toHaveLength(endsBefore); + expect(value.store.readMutation(idempotencyKey)).toMatchObject({ state: "ambiguous" }); + }); + + test("runs the forced target-account proof when post-switch observation fails", async () => { + const value = await fixture(); + const { sessionId } = await claudeSession(value); + const targetAccountId = await signedInCodexAccount(value, "Observation-race Codex target"); + const idempotencyKey = crypto.randomUUID(); + Object.defineProperty(value.codex, "observeSession", { + configurable: true, + value: async () => { + value.codex.calls.push("observe"); + value.codex.accountProjection = { + signedIn: true, + email: "replacement@example.com", + }; + throw new CodexSessionObservationError("resume_unavailable"); + }, + }); + + const failure = await value.service.execute({ + account: targetAccountId, + idempotencyKey, + kind: "session.switch", + provider: "codex", + session: sessionId, + }, { signal }).catch((error: unknown) => error); + + expect(failure).toMatchObject({ name: "IndeterminateLocalCommitError" }); + expect(value.codex.calls.filter((call) => call === "observe")).toHaveLength(1); + expect(value.store.readMutation(idempotencyKey)).toMatchObject({ state: "applied" }); + expect(value.store.requireSession(sessionId)).toMatchObject({ + profileId: targetAccountId, + provider: "codex", + state: "recovery_required", + }); + }); + + test("retains a launch fence and never target-ends a resumed Claude controller without PID custody", async () => { + const value = await fixture(); + const { accountId, sessionId } = await codexSession(value); + const idempotencyKey = crypto.randomUUID(); + value.claude.disconnectOnObserveRequest = 1; + value.claude.omitProcessIdentityOnClaim = true; + value.claude.beforeClaimSessionReturn = () => { + value.claude.accountSignedIn = false; + }; + + const failure = await value.service.execute({ + idempotencyKey, + kind: "session.switch", + provider: "claude", + session: sessionId, + }, { signal }).catch((error: unknown) => error); + + expect(failure).toMatchObject({ name: "IndeterminateLocalCommitError" }); + expect(value.store.readMutation(idempotencyKey)).toMatchObject({ state: "applied" }); + expect(value.claude.claimRequests).toHaveLength(1); + // The first end releases the exact process that failed observation. The + // replacement claim omitted PID/start custody, so it must never receive a + // thread-targeted end even though its account changed during the call. + expect(value.claude.endedThreads).toHaveLength(1); + const providerThreadId = value.claude.claimRequests[0]?.providerThreadId; + if (providerThreadId === undefined) throw new Error("Expected the resumed Claude target id."); + expect(value.store.readClaudeProcessLaunchIntent({ + providerThreadId, + profileId: accountId, + runtimeScope: "managed", + })).toMatchObject({ + profileId: accountId, + providerAccountKey: expect.any(String), + sessionId, + }); + expect(value.store.requireSession(sessionId)).toMatchObject({ + provider: "claude", + state: "recovery_required", + }); + }); + + test("does not record target release when cleanup fails across an account change", async () => { + const value = await fixture(); + const { sessionId } = await codexSession(value); + const idempotencyKey = crypto.randomUUID(); + await leaveUnseededTargetUnsettled(value, sessionId, idempotencyKey); + const endTarget = value.claude.endSession.bind(value.claude); + const targetEndsBefore = value.claude.endedThreads.length; + Object.defineProperty(value.claude, "endSession", { + configurable: true, + value: async (input: Parameters[0]) => { + await endTarget(input); + value.claude.accountSignedIn = false; + throw new Error("simulated cleanup response loss"); + }, + }); + + const result = await value.service.execute({ + kind: "session.abandon", + session: sessionId, + }, { signal }) as { session: { state: string } }; + expect(result.session.state).toBe("terminal"); + expect(value.claude.endedThreads).toHaveLength(targetEndsBefore + 1); + const attempt = value.store.readMutation(idempotencyKey); + if (attempt === null) throw new Error("Expected the ambiguous provider switch."); + expect(value.store.readSessionProviderSwitchProgress(attempt.id).targetReleased).toBe(false); + expect(attempt).toMatchObject({ + resolution: { kind: "abandoned" }, + state: "reconciled", + }); + }); + + test("never reads or ends a legacy target that aliases the source and lacks an account key", async () => { + const value = await fixture(); + const { sessionId } = await codexSession(value); + const idempotencyKey = crypto.randomUUID(); + value.codex.projection = { + ...value.codex.projection, + providerThreadId: "codex-thread-2", + }; + const recordSeedIntent = value.store.recordSessionProviderSwitchSeedIntent.bind(value.store); + value.codex.endSessionError = new Error("simulated target cleanup failure"); + Object.defineProperty(value.store, "recordSessionProviderSwitchSeedIntent", { + configurable: true, + value: () => { throw new Error("simulated seed-intent receipt failure"); }, + }); + try { + await expect(value.service.execute({ + idempotencyKey, + kind: "session.switch", + preset: "low", + provider: "codex", + session: sessionId, + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + } finally { + delete value.codex.endSessionError; + Object.defineProperty(value.store, "recordSessionProviderSwitchSeedIntent", { + configurable: true, + value: recordSeedIntent, + }); + } + const attempt = removeTargetAccountAuthorityFromSwitchEvidence(value, idempotencyKey); + const direct = new Database(value.store.paths.database, { create: false, strict: true }); + try { + direct.exec("DROP TRIGGER session_provider_switch_targets_immutable_update"); + direct.query( + "UPDATE session_provider_switch_targets SET provider_thread_id=? WHERE attempt_id=?", + ).run("codex-thread-1", attempt.id); + } finally { + direct.close(); + } + const callsBefore = [...value.codex.calls]; + const accountReadsBefore = value.codex.readAccountCalls; + + await expect(value.service.execute({ + kind: "session.abandon", + session: sessionId, + }, { signal })).rejects.toThrow("SESSION_PROVIDER_SWITCH_TARGET_ALIASES_SOURCE"); + expect(value.codex.readAccountCalls).toBe(accountReadsBefore); + expect(value.codex.calls).toEqual(callsBefore); + expect(value.store.readMutation(idempotencyKey)).toMatchObject({ state: "ambiguous" }); + }); + + test("never reads or ends a distinct legacy target without durable account authority", async () => { + const value = await fixture(); + const { sessionId } = await codexSession(value); + const idempotencyKey = crypto.randomUUID(); + value.codex.projection = { + ...value.codex.projection, + providerThreadId: "codex-thread-2", + }; + const recordSeedIntent = value.store.recordSessionProviderSwitchSeedIntent.bind(value.store); + value.codex.endSessionError = new Error("simulated target cleanup failure"); + Object.defineProperty(value.store, "recordSessionProviderSwitchSeedIntent", { + configurable: true, + value: () => { throw new Error("simulated seed-intent receipt failure"); }, + }); + try { + await expect(value.service.execute({ + idempotencyKey, + kind: "session.switch", + preset: "low", + provider: "codex", + session: sessionId, + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + } finally { + delete value.codex.endSessionError; + Object.defineProperty(value.store, "recordSessionProviderSwitchSeedIntent", { + configurable: true, + value: recordSeedIntent, + }); + } + removeTargetAccountAuthorityFromSwitchEvidence(value, idempotencyKey); + const callsBefore = [...value.codex.calls]; + const accountReadsBefore = value.codex.readAccountCalls; + + await expect(value.service.execute({ + kind: "session.recover", + session: sessionId, + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + + await expect(value.service.execute({ + kind: "session.abandon", + session: sessionId, + }, { signal })).resolves.toMatchObject({ + recovery: { + providerStateDeleted: false, + targetAddressable: true, + }, + session: { state: "terminal" }, + }); + expect(value.codex.readAccountCalls).toBe(accountReadsBefore); + expect(value.codex.calls).toEqual(callsBefore); + expect(value.store.readMutation(idempotencyKey)).toMatchObject({ + resolution: { kind: "abandoned" }, + state: "reconciled", + }); + }); + + test("abandons a target-bound legacy switch without provider access", async () => { + const value = await fixture(); + const { sessionId } = await claudeSession(value); + const targetAccountId = await signedInCodexAccount(value, "Legacy Codex target"); + const idempotencyKey = crypto.randomUUID(); + await leaveFinalSwitchCommitUnsettled(value, { + account: targetAccountId, + idempotencyKey, + provider: "codex", + session: sessionId, + }); + const attempt = value.store.readMutation(idempotencyKey); + if (attempt === null) throw new Error("Expected an unsettled provider switch."); + expect(value.store.readSessionProviderSwitchProgress(attempt.id)).toMatchObject({ + seedTurnId: "codex-turn-1", + sourceReleased: true, + targetProviderThreadId: "codex-thread-1", + targetReleased: false, + }); + expect(value.store.requireSession(sessionId)).toMatchObject({ + profileId: targetAccountId, + provider: "codex", + providerThreadId: "codex-thread-1", + state: "recovery_required", + }); + removeTargetAccountAuthorityFromSwitchEvidence(value, idempotencyKey); + + const restarted = await reopenFixture(value); + expectCurrentSwitchSuccessors(restarted, idempotencyKey); + const codexCallsBefore = [...restarted.codex.calls]; + const codexAccountReadsBefore = restarted.codex.readAccountCalls; + const claudeCallsBefore = [...restarted.claude.calls]; + await expect(restarted.service.execute({ + kind: "session.recover", + session: sessionId, + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + + await expect(restarted.service.execute({ + kind: "session.abandon", + session: sessionId, + }, { signal })).resolves.toMatchObject({ + recovery: { + providerEffectRetried: false, + providerStateDeleted: false, + resolution: "abandoned", + targetAddressable: true, + unaddressableTargetMayExist: false, + }, + session: { state: "terminal" }, + }); + expect(restarted.codex.calls).toEqual(codexCallsBefore); + expect(restarted.codex.readAccountCalls).toBe(codexAccountReadsBefore); + expect(restarted.claude.calls).toEqual(claudeCallsBefore); + expect(restarted.store.readMutation(idempotencyKey)).toMatchObject({ + resolution: { kind: "abandoned" }, + state: "reconciled", + }); + }); + + test("recovers a seeded Codex target when the Claude source release receipt survived restart", async () => { + const value = await fixture(); + const { sessionId } = await claudeSession(value); + const targetAccountId = await signedInCodexAccount(value, "Recoverable Codex target"); + const idempotencyKey = crypto.randomUUID(); + await leaveFinalSwitchCommitUnsettled(value, { + account: targetAccountId, + idempotencyKey, + provider: "codex", + session: sessionId, + }); + const attemptBeforeRestart = value.store.readMutation(idempotencyKey); + if (attemptBeforeRestart === null) throw new Error("Expected an unsettled provider switch."); + expect(value.store.readSessionProviderSwitchProgress(attemptBeforeRestart.id)).toMatchObject({ + seedTurnId: "codex-turn-1", + sourceReleased: true, + targetProviderThreadId: "codex-thread-1", + targetReleased: false, + }); + + const restarted = await reopenFixture(value); + expectCurrentSwitchSuccessors(restarted, idempotencyKey); + expect(await restarted.service.execute({ + kind: "session.recover", + session: sessionId, + }, { signal })).toMatchObject({ + recovery: { + providerEffectRetried: false, + resolution: "proven_applied", + }, + session: { + profileId: targetAccountId, + provider: "codex", + providerThreadId: "codex-thread-1", + state: "idle", + }, + }); + expect(restarted.codex.calls).toEqual(["read", "read"]); + expect(restarted.claude.calls).toEqual([]); + expect(restarted.store.readMutation(idempotencyKey)).toMatchObject({ + resolution: { kind: "proven_applied" }, + state: "reconciled", + }); + const transcript = await transcriptOf(restarted, sessionId); + expect(transcript.records.filter((record) => record.kind === "provider_switch")) + .toHaveLength(1); + expect(transcript.records.filter( + (record) => record.kind === "user" && record.actor === "provider_switch", + )).toHaveLength(1); + }); + + test("does not accept one visible seed match from an incomplete recovery projection", async () => { + const value = await fixture(); + const { sessionId } = await codexSession(value); + const idempotencyKey = crypto.randomUUID(); + const recordSeedResult = value.store.recordSessionProviderSwitchSeedResult.bind(value.store); + const beginTargetRelease = value.store.beginClaudeProcessAuthorityRelease.bind(value.store); + Object.defineProperty(value.store, "recordSessionProviderSwitchSeedResult", { + configurable: true, + value: () => { throw new Error("simulated seed-result receipt failure"); }, + }); + Object.defineProperty(value.store, "beginClaudeProcessAuthorityRelease", { + configurable: true, + value: () => { throw new Error("simulated target cleanup failure"); }, + }); + try { + await expect(value.service.execute({ + idempotencyKey, + kind: "session.switch", + provider: "claude", + session: sessionId, + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + } finally { + Object.defineProperty(value.store, "recordSessionProviderSwitchSeedResult", { + configurable: true, + value: recordSeedResult, + }); + Object.defineProperty(value.store, "beginClaudeProcessAuthorityRelease", { + configurable: true, + value: beginTargetRelease, + }); + } + const attempt = value.store.readMutation(idempotencyKey); + if (attempt === null) throw new Error("Expected an unsettled provider switch."); + const targetProviderThreadId = value.store + .readSessionProviderSwitchProgress(attempt.id).targetProviderThreadId; + if (targetProviderThreadId === undefined) throw new Error("Expected a Claude target receipt."); + value.claude.projection = { + ...value.claude.projection, + messages: [{ + clientId: attempt.id, + role: "user", + text: "provider handoff", + turnId: "claude-turn-1", + }], + omission: { + hasMoreOlderTurns: false, + incompleteTurnIds: [], + omittedMessages: 0, + returnedTurns: 1, + truncatedMessages: 1, + turnLimit: 20, + unreadItemTurnIds: [], + }, + turnSummaries: [{ + actions: [], + files: [], + id: "claude-turn-1", + omittedActions: 0, + omittedFiles: 0, + status: "completed", + }], + }; + + await expect(value.service.execute({ + kind: "session.recover", + session: sessionId, + }, { signal })).rejects.toMatchObject({ + code: "RECOVERY_REQUIRED", + message: expect.stringContaining("cannot prove that the seed match is unique"), + }); + expect(value.store.readSessionProviderSwitchProgress(attempt.id).seedTurnId).toBeUndefined(); expect(value.codex.endedThreads).toEqual([]); expect(value.claude.endedThreads).toEqual([]); @@ -1541,7 +2331,7 @@ describe("provider portability", () => { state: "idle", }, }); - expect(value.claude.endedThreads).toEqual(["claude-thread-1"]); + expect(value.claude.endedThreads).toEqual([targetProviderThreadId]); expect(value.codex.endedThreads).toEqual([]); }); @@ -1550,6 +2340,13 @@ describe("provider portability", () => { const { sessionId } = await codexSession(value); const idempotencyKey = crypto.randomUUID(); await leaveUnseededTargetUnsettled(value, sessionId, idempotencyKey); + const attempt = value.store.readMutation(idempotencyKey); + if (attempt === null) throw new Error("Expected an unsettled provider switch."); + const targetProviderThreadId = value.store + .readSessionProviderSwitchProgress(attempt.id).targetProviderThreadId; + if (targetProviderThreadId === undefined) { + throw new Error("Expected an exact provider-switch target receipt."); + } expect(await value.service.execute({ kind: "session.recover", @@ -1565,7 +2362,7 @@ describe("provider portability", () => { state: "idle", }, }); - expect(value.claude.endedThreads).toEqual(["claude-thread-1"]); + expect(value.claude.endedThreads).toEqual([targetProviderThreadId]); expect(value.codex.endedThreads).toEqual([]); }); @@ -1648,6 +2445,99 @@ describe("provider portability", () => { expect(value.store.readSessionProviderSwitchProgress(attempt.id).targetReleased).toBe(true); }); + test("leaves the switch effect unsettled after daemon authority is lost during target cleanup", async () => { + let stale = false; + const value = await fixture({ + assertCurrent: async () => { + if (stale) throw new DaemonAuthoritySafetyError("simulated stale daemon authority"); + }, + close: () => {}, + }); + const { sessionId } = await codexSession(value); + const idempotencyKey = crypto.randomUUID(); + const recordSeedIntent = value.store.recordSessionProviderSwitchSeedIntent.bind(value.store); + const endTarget = value.claude.endSession.bind(value.claude); + Object.defineProperty(value.store, "recordSessionProviderSwitchSeedIntent", { + configurable: true, + value: () => { throw new Error("simulated seed-intent receipt failure"); }, + }); + Object.defineProperty(value.claude, "endSession", { + configurable: true, + value: async (input: Parameters[0]) => { + await endTarget(input); + stale = true; + }, + }); + try { + await expect(value.service.execute({ + idempotencyKey, + kind: "session.switch", + provider: "claude", + session: sessionId, + }, { signal })).rejects.toBeInstanceOf(DaemonAuthoritySafetyError); + } finally { + stale = false; + Object.defineProperty(value.store, "recordSessionProviderSwitchSeedIntent", { + configurable: true, + value: recordSeedIntent, + }); + Object.defineProperty(value.claude, "endSession", { + configurable: true, + value: endTarget, + }); + } + + expect(value.store.readMutation(idempotencyKey)).toMatchObject({ state: "effect_started" }); + expect(value.store.requireSession(sessionId)).toMatchObject({ + provider: "codex", + providerThreadId: "codex-thread-1", + state: "idle", + }); + }); + + test("does not settle a source release after its forced account proof loses daemon authority", async () => { + let stale = false; + const value = await fixture({ + assertCurrent: async () => { + if (stale) throw new DaemonAuthoritySafetyError("simulated stale daemon authority"); + }, + close: () => {}, + }); + const { sessionId } = await codexSession(value); + const idempotencyKey = crypto.randomUUID(); + const readAccount = value.codex.readAccount.bind(value.codex); + Object.defineProperty(value.codex, "readAccount", { + configurable: true, + value: async () => { + const account = await readAccount(); + if (value.codex.calls.includes("end-session")) stale = true; + return account; + }, + }); + try { + await expect(value.service.execute({ + idempotencyKey, + kind: "session.switch", + provider: "claude", + session: sessionId, + }, { signal })).rejects.toBeInstanceOf(DaemonAuthoritySafetyError); + } finally { + stale = false; + Object.defineProperty(value.codex, "readAccount", { + configurable: true, + value: readAccount, + }); + } + + expect(value.codex.endedThreads).toEqual(["codex-thread-1"]); + expect(value.store.readMutation(idempotencyKey)).toMatchObject({ state: "effect_started" }); + expect(value.store.requireSession(sessionId)).toMatchObject({ + provider: "codex", + providerThreadId: "codex-thread-1", + state: "idle", + }); + }); + test("serializes a local cross-account switch before target Claude login admission", async () => { const value = await fixture(); const { sessionId } = await codexSession(value); @@ -1659,7 +2549,6 @@ describe("provider portability", () => { let release!: () => void; const enteredRead = new Promise((resolve) => { entered = resolve; }); const holdRead = new Promise((resolve) => { release = resolve; }); - value.claude.accountSignedInResults.push(true, false); value.claude.beforeReadAccountReturn = async () => { entered(); await holdRead; @@ -1693,18 +2582,18 @@ describe("provider portability", () => { const loginOutcome = await login; expect(loginOutcome).toMatchObject({ result: { - authentication: { provider: "claude", signedIn: false }, - login: { status: "launch_granted" }, + authentication: { provider: "claude", signedIn: true }, + login: { status: "signed_in" }, }, status: "fulfilled", }); - expect(value.store.readMutation(loginKey)).toMatchObject({ state: "effect_started" }); + expect(value.store.readMutation(loginKey)).toBeNull(); expect(value.store.requireSession(sessionId)).toMatchObject({ profileId: target.account.id, provider: "claude", - state: "terminal", + state: "idle", }); - expect(value.claude.endedThreads).toEqual(["claude-thread-1"]); + expect(value.claude.endedThreads).toEqual([]); }); test("serializes a remote cross-account switch before target Claude login admission", async () => { @@ -1721,7 +2610,6 @@ describe("provider portability", () => { let release!: () => void; const enteredRead = new Promise((resolve) => { entered = resolve; }); const holdRead = new Promise((resolve) => { release = resolve; }); - value.claude.accountSignedInResults.push(true, false); value.claude.beforeReadAccountReturn = async () => { entered(); await holdRead; @@ -1759,18 +2647,18 @@ describe("provider portability", () => { }); expect(await login).toMatchObject({ result: { - authentication: { provider: "claude", signedIn: false }, - login: { status: "launch_granted" }, + authentication: { provider: "claude", signedIn: true }, + login: { status: "signed_in" }, }, status: "fulfilled", }); - expect(value.store.readMutation(loginKey)).toMatchObject({ state: "effect_started" }); + expect(value.store.readMutation(loginKey)).toBeNull(); expect(value.store.requireSession(sessionId)).toMatchObject({ profileId: target.account.id, provider: "claude", - state: "terminal", + state: "idle", }); - expect(value.claude.endedThreads).toEqual(["claude-thread-1"]); + expect(value.claude.endedThreads).toEqual([]); }); test("replays a remote provider switch after its original authority changed", async () => { @@ -1857,6 +2745,131 @@ describe("provider portability", () => { expect(value.codex.endedThreads).toEqual(["codex-thread-1"]); }); + test("stages target Claude launch authority before admission and binds it atomically", async () => { + const value = await fixture(); + const { accountId, sessionId } = await codexSession(value); + let stagedProviderThreadId: string | undefined; + let stagedIntentId: string | undefined; + value.claude.beforeStartSessionAdmission = (input) => { + stagedProviderThreadId = input.providerThreadId; + if (stagedProviderThreadId === undefined) { + throw new Error("Expected HRA to reserve the Claude provider identity before launch."); + } + const intent = value.store.readClaudeProcessLaunchIntent({ + providerThreadId: stagedProviderThreadId, + profileId: accountId, + runtimeScope: "managed", + }); + if (intent === null) throw new Error("Expected durable pre-admission launch authority."); + stagedIntentId = intent.intentId; + expect(intent).toMatchObject({ + profileId: accountId, + runtimeScope: "managed", + sessionId, + }); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId: stagedProviderThreadId, + profileId: accountId, + runtimeScope: "managed", + })).toBeNull(); + }; + + await expect(value.service.execute({ + idempotencyKey: crypto.randomUUID(), + kind: "session.switch", + provider: "claude", + session: sessionId, + }, { signal })).resolves.toMatchObject({ + to: { provider: "claude" }, + }); + + if (stagedProviderThreadId === undefined || stagedIntentId === undefined) { + throw new Error("Expected the Claude launch-intent callback to run."); + } + expect(stagedProviderThreadId).toMatch(/^[0-9a-f-]{36}$/u); + expect(value.store.readClaudeProcessLaunchIntent({ + providerThreadId: stagedProviderThreadId, + profileId: accountId, + runtimeScope: "managed", + })).toBeNull(); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId: stagedProviderThreadId, + profileId: accountId, + runtimeScope: "managed", + })).toMatchObject({ + identity: value.claude.processIdentity, + sessionId, + state: "bound", + }); + expect(value.store.requireSession(sessionId)).toMatchObject({ + provider: "claude", + providerThreadId: stagedProviderThreadId, + }); + }); + + test("preserves the source session when a Claude child exit is unproven and never respawns it", async () => { + const value = await fixture(); + const { accountId, sessionId } = await codexSession(value); + const idempotencyKey = crypto.randomUUID(); + value.claude.startSessionError = new ClaudeProcessExitUnprovenError(); + + const first = await value.service.execute({ + idempotencyKey, + kind: "session.switch", + provider: "claude", + session: sessionId, + }, { signal }).catch((error: unknown) => error); + expect(first).toBeInstanceOf(CommandFailure); + expect((first as CommandFailure).code).toBe("RECOVERY_REQUIRED"); + expect(value.claude.startSessionRequests).toHaveLength(1); + const providerThreadId = value.claude.startSessionRequests[0]?.providerThreadId; + if (providerThreadId === undefined) throw new Error("Expected a reserved Claude identity."); + expect(value.store.readClaudeProcessLaunchIntent({ + providerThreadId, + profileId: accountId, + runtimeScope: "managed", + })).toMatchObject({ + profileId: accountId, + runtimeScope: "managed", + sessionId, + }); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId, + profileId: accountId, + runtimeScope: "managed", + })).toBeNull(); + expect(value.store.requireSession(sessionId)).toMatchObject({ + provider: "codex", + providerThreadId: "codex-thread-1", + state: "idle", + }); + expect(value.store.readMutation(idempotencyKey)).toMatchObject({ state: "failed" }); + + const replay = await value.service.execute({ + idempotencyKey, + kind: "session.switch", + provider: "claude", + session: sessionId, + }, { signal }).catch((error: unknown) => error); + expect(replay).toBeInstanceOf(CommandFailure); + expect((replay as CommandFailure).code).toBe("CONFLICT"); + const freshAttempt = await value.service.execute({ + idempotencyKey: crypto.randomUUID(), + kind: "session.switch", + provider: "claude", + session: sessionId, + }, { signal }).catch((error: unknown) => error); + expect(freshAttempt).toBeInstanceOf(CommandFailure); + expect((freshAttempt as CommandFailure).code).toBe("RECOVERY_REQUIRED"); + expect(value.claude.startSessionRequests).toHaveLength(1); + expect(value.codex.endedThreads).toEqual([]); + expect(value.store.requireSession(sessionId)).toMatchObject({ + provider: "codex", + providerThreadId: "codex-thread-1", + state: "idle", + }); + }); + test("refuses a switch to the provider the session already runs", async () => { const value = await fixture(); const { sessionId } = await codexSession(value); @@ -1868,6 +2881,32 @@ describe("provider portability", () => { expect((refusal as CommandFailure).code).toBe("INVALID_INPUT"); }); + test("quarantines a same-provider preset switch when its target aliases the source thread", async () => { + const value = await fixture(); + const { sessionId } = await codexSession(value); + const startsBefore = value.codex.calls.filter((call) => call === "start-session").length; + const turnsBefore = value.codex.calls.filter((call) => call === "start-turn").length; + + await expect(value.service.execute({ + idempotencyKey: crypto.randomUUID(), + kind: "session.switch", + preset: "low", + provider: "codex", + session: sessionId, + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + + expect(value.codex.calls.filter((call) => call === "start-session")) + .toHaveLength(startsBefore + 1); + expect(value.codex.calls.filter((call) => call === "start-turn")) + .toHaveLength(turnsBefore); + expect(value.codex.endedThreads).toEqual([]); + expect(value.store.requireSession(sessionId)).toMatchObject({ + provider: "codex", + providerThreadId: "codex-thread-1", + state: "recovery_required", + }); + }); + test("exports the neutral transcript as a letta-ai trajectory v1 document", async () => { const value = await fixture(); const { accountId, sessionId } = await codexSession(value); diff --git a/src/daemon/service.test.ts b/src/daemon/service.test.ts index a64595a..5f0f86b 100644 --- a/src/daemon/service.test.ts +++ b/src/daemon/service.test.ts @@ -10,11 +10,14 @@ import { CodexError, CodexRemoteError, IndeterminateCodexEffectError, + readCodexAutomationAuthority, + type CodexAutomationAuthorityRequest, + type CodexAutomationAuthorityScan, type CodexFact, type CodexPluginCatalog, } from "../codex"; import { parseFact } from "../codex/protocol"; -import { CLAUDE_PIN } from "../claude/pin"; +import { CLAUDE_PIN, CLAUDE_PIN_MODEL } from "../claude/pin"; import { DEVIN_PIN } from "../devin/pin"; import { CloudProjectionRecoveryAdmissionError } from "../cloud/contracts"; import { AccountKeyLossPreconditionError } from "../cloud/local-control"; @@ -38,13 +41,19 @@ import { publicInteractionSchema, type InteractionRecord, type InteractionResolution, + type ProviderInteractionAuthority, type PublicInteraction, } from "../domain/interactions"; import { sessionStatusSchema, type SessionStatus } from "../domain/observation"; import type { PreparedAttachment } from "../domain/attachments"; import { AttachmentBlobStore } from "../storage/attachment-store"; import { ingestAttachments } from "./attachment-ingest"; -import type { EffectiveRuntimeProfile } from "../domain/runtime-profile"; +import { presetRequirements } from "../domain/presets"; +import type { + EffectiveClaudeRuntimeProfile, + EffectiveDevinRuntimeProfile, + EffectiveRuntimeProfile, +} from "../domain/runtime-profile"; import { SESSION_EVENT_RETAIN_AGE_MS, sessionEventPageSchema, @@ -58,7 +67,7 @@ import { InMemoryGatewayKeyStore, type GatewayKeyPort, } from "../storage/gateway-key-custody"; -import { StateStore } from "../storage/state-store"; +import { StateStore, type SessionRecord } from "../storage/state-store"; import { DeterministicProseResponder, PROSE_APPROVAL_REPLY, @@ -69,12 +78,26 @@ import type { HraFactsMemoryLifecyclePort, HraFactsMemoryLifecycleReceipt, } from "./facts-memory-lifecycle"; -import { CodexSessionObservationError, UnavailableCloudControl, type ClaudeRuntimePort, type CloudControlPort, type CodexAccountProjection, type CodexLoginOutcome, type CodexRuntimePort, type CodexSessionProjection, type CompactProjectionRecoveryBlocker, type DesktopSwitchPort, type DevinRuntimePort, type ProfileAuthority, type RuntimeStartReview } from "./ports"; +import { ClaudeProcessExitUnprovenError, ClaudeSessionObservationError, CodexClaimReleaseUnprovenError, CodexSessionObservationError, UnavailableCloudControl, type ClaudeProcessIdentity, type ClaudeRuntimePort, type ClaudeRuntimeStartReview, type CloudControlPort, type CodexAccountProjection, type CodexLoginOutcome, type CodexRuntimePort, type CodexSessionObservation, type CodexSessionProjection, type CompactProjectionRecoveryBlocker, type DesktopSwitchPort, type DevinRuntimePort, type ProfileAuthority, type RuntimeStartReview } from "./ports"; +import { + BoundedPersonalSessionDiscovery, + CLAUDE_REGISTRY_MAX_RECORDS, + PERSONAL_CODEX_DISCOVERY_MAX_RESULTS, + PERSONAL_SESSION_DISCOVERY_MAX_RESULTS, + PERSONAL_SESSION_DISCOVERY_RECENCY_WINDOW_MS, + type ClaudeProcessLivenessProbe, + type DiscoveredPersonalSession, + type PersonalSessionDiscoveryPort, +} from "./personal-session-discovery"; import { SessionEventCursorCodec } from "./session-event-cursor"; import { CommandFailure, FACTS_MEMORY_SESSION_TTL_MS, HraService } from "./service"; import { USAGE_HISTORY_CURSOR_TTL_MS } from "./usage-history-cursor"; const privatePathRoot = ["", "Users", "private"].join("/"); +const codexProviderAccountKey = (email = "person@example.com"): string => + `v1:codex:${createHash("sha256").update(email.trim().toLowerCase()).digest("hex")}`; +const claudeProviderAccountKey = (identity = "claude-test-account"): string => + `v1:claude:${createHash("sha256").update(identity).digest("hex")}`; const runtimeProfile = (authority: ProfileAuthority): EffectiveRuntimeProfile => ({ profileId: authority.id, @@ -97,6 +120,8 @@ class FakeCodex implements CodexRuntimePort { readonly provider = "codex" as const; discardRuntimeReview(): void {} readonly calls: string[] = []; + readonly releasedAuthorities: ProfileAuthority[] = []; + readonly retiredAuthorityKeys = new Set(); readonly observedThreads: string[] = []; readonly freshThreads = new Set(); readonly turnEffectTrace: string[] = []; @@ -118,6 +143,7 @@ class FakeCodex implements CodexRuntimePort { reviewTurnErrorOnce?: Error; beforeLogoutReturn?: () => Promise; logoutError?: Error; + beforeReleaseOwnedAuthorityReturn?: () => Promise; startSessionError?: Error; startTurnError?: Error; startTurnErrorOnce?: Error; @@ -132,9 +158,12 @@ class FakeCodex implements CodexRuntimePort { activeStartTurns = 0; maximumConcurrentStartTurns = 0; runtimeProfileOverride?: EffectiveRuntimeProfile; + claimRuntimeProfileOverride?: EffectiveRuntimeProfile; + closeError?: Error; closeCalls = 0; resolveInteractionError?: Error; beforeResolveInteractionReturn?: () => Promise; + beforeTimeoutInteractionReturn?: () => Promise; validateInteractionResolutionError?: Error; beforeValidateInteractionResolutionReturn?: () => Promise; validateInteractionTimeoutError?: Error; @@ -144,6 +173,7 @@ class FakeCodex implements CodexRuntimePort { readonly validatedInteractionTimeouts: Array[0]> = []; readonly timedOutInteractions: Array[0]> = []; readonly inspectedInteractions: Array[0]> = []; + beforeInspectInteractionReturn?: () => Promise; interactionAuthority: Awaited> = { kind: "command_approval", command: "git status --short", @@ -205,6 +235,17 @@ class FakeCodex implements CodexRuntimePort { listedProjections: readonly CodexSessionProjection[] = []; listedNextCursor: string | null = null; readonly sessionListRequests: Array[0]> = []; + readonly metadataReadRequests: Array> = []; + beforeListSessionsReturn?: () => Promise | void; + claimError?: Error; + claimErrorForProviderThreadId?: (providerThreadId: string) => Error | undefined; + readonly claimRequests: Array< + Parameters>[0] + > = []; + beforeClaimSessionReturnOnce?: () => Promise; readonly sessionReviewRequests: Array[0]> = []; readonly turnReviewRequests: Array[0]> = []; loginResult: CodexLoginOutcome = { status: "signed_in", account: { signedIn: true, email: "person@example.com", plan: "Plus" } }; @@ -212,10 +253,50 @@ class FakeCodex implements CodexRuntimePort { beforeLoginReturn?: (input: { authority: ProfileAuthority; method: "browser" | "device_code" }) => Promise; beforeCancelLoginReturn: (() => Promise) | undefined = undefined; beforeReadAccountReturn?: () => Promise; + #authorityKey(authority: ProfileAuthority): string { + return `${authority.id}:${String(authority.generation)}`; + } + retireAuthority(authority: ProfileAuthority): void { + this.retiredAuthorityKeys.add(this.#authorityKey(authority)); + } async login(input: { authority: ProfileAuthority; method: "browser" | "device_code" }): Promise { this.calls.push(`login:${input.authority.id}:${input.authority.generation}:${input.method}`); await this.beforeLoginReturn?.(input); return this.loginResult; } async cancelLogin(input: { authority: ProfileAuthority; loginId: string }): Promise<{ status: "canceled" | "not_found" }> { this.calls.push(`login-cancel:${input.authority.id}:${input.authority.generation}:${input.loginId}`); await this.beforeCancelLoginReturn?.(); return this.cancelLoginResult; } - async logout(): Promise { this.calls.push("logout"); await this.beforeLogoutReturn?.(); if (this.logoutError !== undefined) throw this.logoutError; } - async readAccount(): Promise { this.calls.push("readAccount"); await this.beforeReadAccountReturn?.(); return this.accountProjection; } + async logout(input: { + authority: ProfileAuthority; + signal: AbortSignal; + }): Promise { + input.signal.throwIfAborted(); + this.calls.push("logout"); + if (this.retiredAuthorityKeys.has(this.#authorityKey(input.authority))) { + throw new Error("AUTHORITY_STALE"); + } + await this.beforeLogoutReturn?.(); + input.signal.throwIfAborted(); + if (this.logoutError !== undefined) throw this.logoutError; + } + async releaseOwnedAuthority(input: { + authority: ProfileAuthority; + signal: AbortSignal; + }): Promise { + input.signal.throwIfAborted(); + this.releasedAuthorities.push(input.authority); + this.retireAuthority(input.authority); + await this.beforeReleaseOwnedAuthorityReturn?.(); + input.signal.throwIfAborted(); + } + async readAccount(input: { + authority: ProfileAuthority; + signal: AbortSignal; + }): Promise { + input.signal.throwIfAborted(); + this.calls.push("readAccount"); + if (this.retiredAuthorityKeys.has(this.#authorityKey(input.authority))) { + throw new Error("AUTHORITY_STALE"); + } + const observed = this.accountProjection; + await this.beforeReadAccountReturn?.(); + return observed; + } async listPlugins(input: Parameters[0]): Promise { this.calls.push("plugins"); this.pluginRequests.push(input); @@ -239,8 +320,52 @@ class FakeCodex implements CodexRuntimePort { async listSessions(input: Parameters[0]): ReturnType { this.calls.push("list"); this.sessionListRequests.push(input); + await this.beforeListSessionsReturn?.(); return { sessions: this.listedProjections, nextCursor: this.listedNextCursor }; } + async readSessionMetadata( + authority: ProfileAuthority, + providerThreadId: string, + signal: AbortSignal, + ): Promise { + signal.throwIfAborted(); + this.calls.push("metadata"); + this.metadataReadRequests.push({ authority, providerThreadId }); + return { ...this.readProjection, providerThreadId }; + } + async claimSession( + input: Parameters>[0], + ): ReturnType> { + this.calls.push("claim"); + this.claimRequests.push(input); + const scopedClaimError = this.claimErrorForProviderThreadId?.(input.providerThreadId); + if (scopedClaimError !== undefined) throw scopedClaimError; + if (this.claimError !== undefined) throw this.claimError; + const beforeReturn = this.beforeClaimSessionReturnOnce; + delete this.beforeClaimSessionReturnOnce; + await beforeReturn?.(); + if (input.requirement.effort === "provider-default") { + throw new Error("not a Codex requirement"); + } + const base = runtimeProfile(input.authority); + const effectiveRuntimeProfile = this.claimRuntimeProfileOverride ?? { + ...base, + preset: input.preset, + fast: input.fast, + serviceTier: input.fast ? "priority" as const : null, + model: input.requirement.model, + reasoningEffort: input.requirement.effort, + }; + return { + connectionId: this.observationConnectionId, + effectiveRuntimeProfile, + projection: { + ...this.readProjection, + providerThreadId: input.providerThreadId, + }, + resumed: true, + }; + } async reviewSessionStart(input: Parameters[0]): Promise { this.calls.push("review-session"); this.sessionReviewRequests.push(input); @@ -378,6 +503,7 @@ class FakeCodex implements CodexRuntimePort { input: Parameters[0], ): ReturnType { this.inspectedInteractions.push(input); + await this.beforeInspectInteractionReturn?.(); return this.interactionAuthority; } async resolveInteraction( @@ -409,10 +535,333 @@ class FakeCodex implements CodexRuntimePort { input: Parameters[0], ): Promise<{ responseWritten: true }> { this.timedOutInteractions.push(input); + await this.beforeTimeoutInteractionReturn?.(); if (this.timeoutInteractionError !== undefined) throw this.timeoutInteractionError; return { responseWritten: true }; } - async close(): Promise { this.closeCalls += 1; } + async close(): Promise { + this.closeCalls += 1; + if (this.closeError !== undefined) throw this.closeError; + } +} + +const claudeRuntimeProfile = ( + authority: ProfileAuthority, + configHome: "isolated" | "personal", +): EffectiveClaudeRuntimeProfile => ({ + profileId: authority.id, + processGeneration: authority.generation, + observedAt: personalAdoptionNow, + preset: "fable-max", + model: CLAUDE_PIN_MODEL, + reasoningEffort: "max", + claudeVersion: CLAUDE_PIN, + permissionMode: "default", + configHome, + outputFormat: "stream-json", + inputFormat: "stream-json", +}); + +/** Minimal Claude seam that preserves which provider home received each effect. */ +class FakeClaude implements ClaudeRuntimePort { + readonly provider = "claude" as const; + discardRuntimeReview(): void {} + readonly calls: string[] = []; + readonly claimRequests: Array[0]> = []; + readonly endRequests: Array[0]> = []; + readonly endedProcessIdentities: ClaudeProcessIdentity[] = []; + readonly identityRequests: Array< + Parameters[0] + > = []; + readonly observeRequests: Array[0]> = []; + readonly startSessionRequests: Array[0]> = []; + readonly resolvedInteractions: Array< + Parameters[0] + > = []; + readonly inspectedInteractions: Array< + Parameters[0] + > = []; + readonly turnRequests: Array[0]> = []; + readonly validatedInteractions: Array< + Parameters[0] + > = []; + beforeEndSessionReturn?: () => Promise; + beforeClaimSessionAdmission?: ( + input: Parameters[0], + ) => Promise | void; + beforeStartSessionAdmission?: ( + input: Parameters[0], + ) => Promise | void; + closeError?: Error; + closeCalls = 0; + claimProjectionTitle?: string; + claimRuntimeProfileOverride?: ( + authority: ProfileAuthority, + ) => EffectiveClaudeRuntimeProfile; + claimSessionError?: Error; + disconnectOnObserveRequest?: number; + observationConnectionId = "30000000-0000-4000-8000-0000000000c1"; + observationConnectionIdOnClaim?: string; + observePause?: Promise; + observePauseStarted?: () => void; + pauseOnObserveRequest?: number; + observeErrorOnce?: Error; + readIdentityErrorOnce?: Error; + processIdentity: ClaudeProcessIdentity; + processIdentityOnClaim?: ClaudeProcessIdentity; + startSessionError?: Error; + startTurnError?: Error; + controllerLive = true; + accountProjection: CodexAccountProjection = { + signedIn: true, + accountId: "claude-account", + organizationId: "claude-organization", + email: "claude@example.com", + }; + interactionAuthorityProjection: Awaited< + ReturnType + > = { + kind: "command_approval", + command: "bun test", + reason: null, + availableDecisions: ["accept", "decline", "cancel"], + workingDirectory: "/workspace", + environmentId: null, + commandActions: null, + networkApprovalContext: null, + additionalPermissions: null, + proposedExecpolicyAmendment: null, + proposedNetworkPolicyAmendments: null, + }; + projection: CodexSessionProjection = { + providerThreadId: "personal-claude-thread", + title: "Personal Claude session", + status: "idle", + providerUpdatedAt: personalAdoptionNow - 1_000, + }; + + constructor( + readonly configHome: "isolated" | "personal", + identity: ClaudeProcessIdentity, + ) { + this.processIdentity = identity; + } + + pinnedVersion(): string { return CLAUDE_PIN; } + rebindProfileAuthority(): void {} + + async readAccount(): Promise { + return this.accountProjection; + } + + async claimSession( + input: Parameters[0], + ): ReturnType { + this.calls.push("claim"); + this.claimRequests.push(input); + this.controllerLive = true; + if (this.processIdentityOnClaim !== undefined) { + this.processIdentity = this.processIdentityOnClaim; + delete this.processIdentityOnClaim; + } + if (this.observationConnectionIdOnClaim !== undefined) { + this.observationConnectionId = this.observationConnectionIdOnClaim; + delete this.observationConnectionIdOnClaim; + } + await this.beforeClaimSessionAdmission?.(input); + if (this.claimSessionError !== undefined) throw this.claimSessionError; + await input.admitProcessIdentity?.(this.processIdentity); + this.projection = { + ...this.projection, + providerThreadId: input.providerThreadId, + projectRoot: input.projectRoot, + status: "idle", + title: this.claimProjectionTitle ?? input.title, + }; + delete (this.projection as { activeTurnId?: string }).activeTurnId; + return { + ...this.projection, + effectiveRuntimeProfile: this.claimRuntimeProfileOverride?.(input.authority) + ?? claudeRuntimeProfile(input.authority, this.configHome), + }; + } + + async readSessionProcessIdentity( + input: Parameters[0], + ): ReturnType { + this.calls.push("identity"); + this.identityRequests.push(input); + const error = this.readIdentityErrorOnce; + delete this.readIdentityErrorOnce; + if (error !== undefined) throw error; + return this.processIdentity; + } + + async reviewSessionStart( + input: Parameters[0], + ): Promise { + this.calls.push("review-session"); + return { + reviewId: crypto.randomUUID(), + kind: "session_start", + effectiveRuntimeProfile: claudeRuntimeProfile(input.authority, this.configHome), + }; + } + + async startSession( + input: Parameters[0], + ): Promise { + this.calls.push("start-session"); + this.startSessionRequests.push(input); + this.controllerLive = true; + if (input.providerThreadId !== undefined) { + this.projection = { ...this.projection, providerThreadId: input.providerThreadId }; + } + await this.beforeStartSessionAdmission?.(input); + if (this.startSessionError !== undefined) throw this.startSessionError; + await input.admitProcessIdentity?.(this.processIdentity); + return { ...this.projection, effectiveRuntimeProfile: input.review.effectiveRuntimeProfile }; + } + + async observeSession( + input: Parameters[0], + ): Promise { + this.calls.push("observe"); + this.observeRequests.push(input); + if (this.disconnectOnObserveRequest === this.observeRequests.length) { + this.controllerLive = false; + throw new ClaudeSessionObservationError(); + } + if (!this.controllerLive) throw new ClaudeSessionObservationError(); + if (this.pauseOnObserveRequest === this.observeRequests.length) { + this.observePauseStarted?.(); + if (this.observePause !== undefined) await this.observePause; + } + const error = this.observeErrorOnce; + delete this.observeErrorOnce; + if (error !== undefined) throw error; + return { + connectionId: this.observationConnectionId, + projection: { ...this.projection, providerThreadId: input.providerThreadId }, + resumed: true, + }; + } + + async readSession( + input: Parameters[0], + ): Promise { + this.calls.push("read"); + return { ...this.projection, providerThreadId: input.providerThreadId }; + } + + async endSession( + input: Parameters[0], + ): Promise { + this.calls.push("end"); + this.endRequests.push(input); + this.endedProcessIdentities.push(this.processIdentity); + await this.beforeEndSessionReturn?.(); + this.controllerLive = false; + } + + async reviewTurnStart( + input: Parameters[0], + ): Promise { + this.calls.push("review-turn"); + return { + reviewId: crypto.randomUUID(), + kind: "turn_start", + effectiveRuntimeProfile: claudeRuntimeProfile(input.authority, this.configHome), + }; + } + + async startTurn( + input: Parameters[0], + ): Promise<{ + turnId: string; + status: "completed"; + effectiveRuntimeProfile: EffectiveClaudeRuntimeProfile; + }> { + this.calls.push("send"); + this.turnRequests.push(input); + const turnId = `claude-turn-${String(this.turnRequests.length)}`; + this.projection = { + ...this.projection, + status: "idle", + providerUpdatedAt: (this.projection.providerUpdatedAt ?? 0) + 1, + messages: [ + ...(this.projection.messages ?? []), + { + role: "user", + text: input.message, + turnId, + clientId: input.clientMessageId, + }, + ], + }; + if (this.startTurnError !== undefined) throw this.startTurnError; + return { + turnId, + status: "completed", + effectiveRuntimeProfile: input.review.effectiveRuntimeProfile, + }; + } + + async steer(): Promise { this.calls.push("steer"); } + async interrupt(): Promise { this.calls.push("interrupt"); } + async close(): Promise { + this.closeCalls += 1; + if (this.closeError !== undefined) throw this.closeError; + } + + #unsupported(): never { + throw new Error("This fake does not drive that Claude capability."); + } + + interactionAuthority( + providerThreadId: string, + requestId: string, + ): ProviderInteractionAuthority { + const authority = this.claimRequests.at(-1)?.authority + ?? this.startSessionRequests.at(-1)?.authority; + if (authority === undefined) return this.#unsupported(); + return { + profileId: authority.id, + processGeneration: authority.generation, + connectionId: this.observationConnectionId, + requestId: { type: "string", value: requestId }, + method: "claude/control_request/can_use_tool", + requestDigest: createHash("sha256").update(requestId).digest("hex"), + threadId: providerThreadId, + turnId: "claude-turn-approval", + itemId: "claude-item-approval", + approvalId: null, + }; + } + async inspectInteractionAuthority( + input: Parameters[0], + ): ReturnType { + this.inspectedInteractions.push(input); + return this.interactionAuthorityProjection; + } + async validateInteractionResolution( + input: Parameters[0], + ): Promise<{ responseDigest: string }> { + this.validatedInteractions.push(input); + return { + responseDigest: createHash("sha256") + .update(JSON.stringify(input.resolution)) + .digest("hex"), + }; + } + async resolveInteraction( + input: Parameters[0], + ): Promise<{ responseWritten: true }> { + this.resolvedInteractions.push(input); + return { responseWritten: true }; + } + validateInteractionTimeout(): Promise { return Promise.reject(this.#unsupported()); } + timeoutInteraction(): Promise { return Promise.reject(this.#unsupported()); } } class FakeDaemonAuthority { @@ -738,7 +1187,19 @@ async function fixture( gatewayKeys?: GatewayKeyPort; proseResponder?: ProseResponder; }> = {}, - platform: NodeJS.Platform = "linux", + adoptionOrPlatform: Readonly<{ + managedClaude?: ClaudeRuntimePort; + personalCodex?: CodexRuntimePort; + personalClaude?: ClaudeRuntimePort; + personalDiscovery?: PersonalSessionDiscoveryPort; + daemonGeneration?: number; + readPersonalCodexAutomations?: ( + request: CodexAutomationAuthorityRequest, + ) => Promise; + personalCodexHome?: string; + claudeProcessLiveness?: ClaudeProcessLivenessProbe; + }> | NodeJS.Platform = {}, + platformOverride: NodeJS.Platform = "linux", ): Promise<{ service: HraService; store: StateStore; codex: FakeCodex; cloud: FakeCloud; daemonAuthority: FakeDaemonAuthority; documents: string; eventCursors: SessionEventCursorCodec; paths: ReturnType }> { const home = await realpath(await mkdtemp(join(tmpdir(), "hra-service-"))); serviceRoots.push(home); @@ -754,7 +1215,417 @@ async function fixture( const codex = new FakeCodex(); const daemonAuthority = new FakeDaemonAuthority(); const eventCursors = new SessionEventCursorCodec(SessionEventCursorCodec.generateKey()); - return { service: new HraService({ store, paths, codex, cloud, daemonAuthority, ...(desktop === undefined ? {} : { desktop }), eventCursors, ...(factsMemory === undefined ? {} : { factsMemory }), ...(autorespond.claude === undefined ? {} : { claude: autorespond.claude }), ...(autorespond.devin === undefined ? {} : { devin: autorespond.devin }), ...(autorespond.gatewayKeys === undefined ? {} : { gatewayKeys: autorespond.gatewayKeys }), ...(autorespond.proseResponder === undefined ? {} : { proseResponder: autorespond.proseResponder }), now, platform, requestStop }), store, codex, cloud, daemonAuthority, documents, eventCursors, paths }; + const adoption = typeof adoptionOrPlatform === "string" ? {} : adoptionOrPlatform; + const platform = typeof adoptionOrPlatform === "string" + ? adoptionOrPlatform + : platformOverride; + const managedClaude = adoption.managedClaude ?? autorespond.claude; + return { + service: new HraService({ + store, + paths, + codex, + cloud, + daemonAuthority, + ...(desktop === undefined ? {} : { desktop }), + ...(managedClaude === undefined ? {} : { claude: managedClaude }), + ...(autorespond.devin === undefined ? {} : { devin: autorespond.devin }), + eventCursors, + ...(factsMemory === undefined ? {} : { factsMemory }), + ...(autorespond.gatewayKeys === undefined ? {} : { gatewayKeys: autorespond.gatewayKeys }), + ...(autorespond.proseResponder === undefined ? {} : { proseResponder: autorespond.proseResponder }), + ...(adoption.personalCodex === undefined ? {} : { personalCodex: adoption.personalCodex }), + ...(adoption.personalClaude === undefined ? {} : { personalClaude: adoption.personalClaude }), + ...(adoption.personalDiscovery === undefined ? {} : { personalDiscovery: adoption.personalDiscovery }), + ...(adoption.readPersonalCodexAutomations === undefined + ? {} + : { readPersonalCodexAutomations: adoption.readPersonalCodexAutomations }), + ...(adoption.personalCodexHome === undefined ? {} : { personalCodexHome: adoption.personalCodexHome }), + ...(adoption.claudeProcessLiveness === undefined ? {} : { claudeProcessLiveness: adoption.claudeProcessLiveness }), + ...(adoption.daemonGeneration === undefined + ? {} + : { daemonGeneration: adoption.daemonGeneration }), + now, + platform, + requestStop, + }), + store, + codex, + cloud, + daemonAuthority, + documents, + eventCursors, + paths, + }; +} + +const personalAdoptionNow = 1_900_000_000_000; +const personalCodexHome = join(privatePathRoot, "personal-codex-home"); + +const personalCodexAutomationScan = ( + targets: readonly Readonly<{ + id?: string; + kind?: string; + status?: "active" | "paused"; + targetThreadId: string | null; + }>[], + request: CodexAutomationAuthorityRequest, + complete = true, +): CodexAutomationAuthorityScan => { + if (!complete) { + return { complete: false, diagnostics: [], entries: [], nextCursor: null }; + } + const entries = targets.map((target, index) => ({ + sourceDirectoryName: `automation-source-${String(index + 1).padStart(4, "0")}`, + automation: { + cadence: "FREQ=HOURLY;INTERVAL=1", + id: target.id ?? `automation-${String(index + 1)}`, + kind: target.kind ?? "heartbeat", + label: `Automation ${String(index + 1)}`, + status: target.status ?? "active", + targetThreadId: target.targetThreadId, + updatedAt: personalAdoptionNow - 1_000, + }, + })); + if (request.kind === "sources") { + const requested = new Set(request.sourceDirectoryNames); + return { + complete: true, + diagnostics: [], + entries: entries.filter((entry) => requested.has(entry.sourceDirectoryName)), + nextCursor: null, + }; + } + const limit = request.limit ?? 200; + const page = entries + .filter((entry) => request.after === undefined + || request.after === null + || entry.sourceDirectoryName > request.after) + .slice(0, limit); + const hasMore = entries.some((entry) => + entry.sourceDirectoryName > (page.at(-1)?.sourceDirectoryName ?? "") + && !page.includes(entry)); + return { + complete: !hasMore, + diagnostics: [], + entries: page, + nextCursor: hasMore ? page.at(-1)?.sourceDirectoryName ?? null : null, + }; +}; + +class FakePersonalSessionDiscovery implements PersonalSessionDiscoveryPort { + candidates: readonly DiscoveredPersonalSession[] = []; + readonly requests: Array< + Parameters[0] + > = []; + + discover( + input: Parameters[0], + ): Promise { + input.signal?.throwIfAborted(); + this.requests.push(input); + return Promise.resolve( + this.candidates + .filter((candidate) => candidate.provider === input.provider) + .slice(0, input.limit), + ); + } +} + +async function adoptedCodexFixture( + label: string, + providerThreadId: string, + factsMemory?: HraFactsMemoryLifecyclePort, +) { + const personalCodex = new FakeCodex(); + const discovery = new FakePersonalSessionDiscovery(); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + factsMemory, + {}, + { + personalCodex, + personalCodexHome, + personalDiscovery: discovery, + }, + ); + const added = await value.service.execute( + { kind: "account.add", label }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute( + { kind: "account.login", account: added.account.id, deviceCode: false }, + { signal }, + ); + await value.service.execute( + { kind: "project.add", label: `${label} project`, path: value.documents }, + { signal }, + ); + personalCodex.readProjection = { + providerThreadId, + title: `${label} personal thread`, + status: "idle", + projectRoot: value.documents, + providerUpdatedAt: personalAdoptionNow - 11 * 60_000, + messages: [{ role: "user", text: "Started outside HRA" }], + }; + discovery.candidates = [{ + provider: "codex", + providerThreadId, + title: `${label} personal thread`, + projectRoot: value.documents, + updatedAt: personalAdoptionNow - 11 * 60_000, + liveness: "not_live", + }]; + const enabled = await value.service.execute({ + kind: "session.adoption.set", + provider: "codex", + enabled: true, + account: added.account.id, + }, { signal }); + const session = value.store.findSessionByProviderThread( + added.account.id, + providerThreadId, + ); + if (session === null) throw new Error("Expected the personal Codex session to be adopted."); + return { + ...value, + accountId: added.account.id, + discovery, + enabled, + personalCodex, + session, + }; +} + +async function preparedPersonalCodexCandidate(input: Readonly<{ + daemonGeneration?: number; + label: string; + providerThreadId: string; + liveness: DiscoveredPersonalSession["liveness"]; + readPersonalCodexAutomations?: ( + request: CodexAutomationAuthorityRequest, + ) => Promise; + scheduledTaskTarget?: true; + updatedAt?: number; + now?: () => number; +}>) { + const personalCodex = new FakeCodex(); + const discovery = new FakePersonalSessionDiscovery(); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + input.now ?? (() => personalAdoptionNow), + undefined, + {}, + { + personalCodex, + personalCodexHome, + personalDiscovery: discovery, + ...(input.daemonGeneration === undefined + ? {} + : { daemonGeneration: input.daemonGeneration }), + ...(input.readPersonalCodexAutomations === undefined + ? {} + : { readPersonalCodexAutomations: input.readPersonalCodexAutomations }), + }, + ); + const added = await value.service.execute( + { kind: "account.add", label: input.label }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + await value.service.execute({ + kind: "project.add", + label: `${input.label} project`, + path: value.documents, + }, { signal }); + personalCodex.readProjection = { + providerThreadId: input.providerThreadId, + title: `${input.label} personal thread`, + status: "idle", + projectRoot: value.documents, + ...(input.updatedAt === undefined ? {} : { providerUpdatedAt: input.updatedAt }), + }; + discovery.candidates = [{ + provider: "codex", + providerThreadId: input.providerThreadId, + title: `${input.label} personal thread`, + projectRoot: value.documents, + ...(input.updatedAt === undefined ? {} : { updatedAt: input.updatedAt }), + liveness: input.liveness, + ...(input.scheduledTaskTarget === true ? { scheduledTaskTarget: true } : {}), + }]; + return { + ...value, + accountId: added.account.id, + discovery, + personalCodex, + enable: async (): Promise => await value.service.execute({ + kind: "session.adoption.set", + provider: "codex", + enabled: true, + account: added.account.id, + }, { signal }), + }; +} + +async function adoptedClaudeFixture( + label: string, + providerThreadId: string, + configure?: (runtime: FakeClaude) => void, + title = `${label} personal thread`, + platform: NodeJS.Platform = "linux", + signInCodex = true, +) { + const personalIdentity: ClaudeProcessIdentity = { + pid: 63_001, + pidDomain: "darwin", + procStart: "personal-claude-initial", + }; + const managedClaude = new FakeClaude("isolated", { + pid: 63_002, + pidDomain: "darwin", + procStart: "managed-claude", + }); + const personalClaude = new FakeClaude("personal", personalIdentity); + const discovery = new FakePersonalSessionDiscovery(); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { + managedClaude, + personalClaude, + personalCodexHome, + personalDiscovery: discovery, + }, + platform, + ); + const added = await value.service.execute( + { kind: "account.add", label }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + if (signInCodex) { + await value.service.execute( + { kind: "account.login", account: added.account.id, deviceCode: false }, + { signal }, + ); + } + await value.service.execute( + { kind: "project.add", label: `${label} project`, path: value.documents }, + { signal }, + ); + personalClaude.projection = { + providerThreadId, + title, + status: "idle", + projectRoot: value.documents, + providerUpdatedAt: personalAdoptionNow - 1_000, + }; + configure?.(personalClaude); + discovery.candidates = [{ + provider: "claude", + providerThreadId, + title, + projectRoot: value.documents, + updatedAt: personalAdoptionNow - 1_000, + liveness: "not_live", + }]; + const enabled = await value.service.execute({ + kind: "session.adoption.set", + provider: "claude", + enabled: true, + account: added.account.id, + }, { signal }); + const session = value.store.findSessionByProviderThread( + added.account.id, + providerThreadId, + ); + if (session === null) throw new Error("Expected the personal Claude session to be adopted."); + return { + ...value, + accountId: added.account.id, + discovery, + enabled, + managedClaude, + personalClaude, + personalIdentity, + session, + }; +} + +async function nativeClaudeFixture( + label: string, + providerThreadId: string, + identity: ClaudeProcessIdentity, + loginIdempotencyKey?: string, + signInCodex = true, +) { + const managedClaude = new FakeClaude("isolated", identity); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { managedClaude }, + ); + const added = await value.service.execute( + { kind: "account.add", label }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + if (signInCodex) { + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + ...(loginIdempotencyKey === undefined ? {} : { idempotencyKey: loginIdempotencyKey }), + }, { signal }); + } + await value.service.execute({ + kind: "project.add", + label: `${label} project`, + path: value.documents, + }, { signal }); + managedClaude.projection = { + providerThreadId, + title: `${label} session`, + status: "idle", + projectRoot: value.documents, + providerUpdatedAt: personalAdoptionNow - 1_000, + }; + const started = await value.service.execute({ + kind: "session.start", + account: added.account.id, + provider: "claude", + preset: "fable-max", + fast: false, + }, { signal }) as { session: { id: `sess_${string}`; providerThreadId?: string } }; + const reservedProviderThreadId = started.session.providerThreadId; + if (reservedProviderThreadId === undefined) { + throw new Error("Expected the native Claude session to receive a reserved provider identity."); + } + return { + ...value, + accountId: added.account.id, + identity, + managedClaude, + providerThreadId: reservedProviderThreadId, + session: value.store.requireSession(started.session.id), + }; } async function claudeAccountFixture( @@ -968,9 +1839,10 @@ function seedUnsettledInteractionStates( async function seedResolvableInteraction( value: Awaited>, - sessionId: `sess_${string}`, + sessionId: SessionRecord["id"], requestId: string, timing?: Readonly<{ requestedAt: number; deadlineAt: number }>, + source: "managed" | "personal" = "managed", ): Promise candidate.authority.requestId.value === requestId); + if (interaction === undefined) throw new Error("Expected a resolvable interaction."); + return { authority, interaction }; +} + +const providerMutationCalls = (codex: FakeCodex): readonly string[] => codex.calls.filter( + (call) => call.startsWith("login:") || call.startsWith("start:") || call === "logout" || call === "send" || call === "steer" || call === "stop" || call === "rename", +); + +const signal = new AbortController().signal; +const automaticResetWindowResetsAtSeconds = Math.floor(Date.now() / 1_000) + + 3 * 24 * 60 * 60; +const automaticResetWindowResetsAt = automaticResetWindowResetsAtSeconds * 1_000; + +const renderHuman = (command: LocalCommand, data: unknown): string => { + let stdout = ""; + renderSuccess(command, data, false, { + writeStdout: (value) => { stdout += value; }, + writeStderr: () => undefined, + }); + return stdout; +}; + +const renderJson = (command: LocalCommand, data: unknown): string => { + let stdout = ""; + renderSuccess(command, data, true, { + writeStdout: (value) => { stdout += value; }, + writeStderr: () => undefined, + }); + return stdout; +}; + +describe("HraService personal-session adoption", () => { + const waitFor = async (predicate: () => boolean, timeoutMs = 2_000): Promise => { + const startedAt = Date.now(); + while (!predicate()) { + if (Date.now() - startedAt > timeoutMs) { + throw new Error("Timed out waiting for adopted-session autorespond."); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + }; + + test.each([ + ["a deterministically released claim failure", new Error("claim rejected after exact release"), "pending"], + ["an unproven claim release", new CodexClaimReleaseUnprovenError(), "claiming"], + ] as const)( + "records the exact durable outcome for %s", + async (_case, claimError, expectedStatus) => { + let now = personalAdoptionNow; + const providerThreadId = `personal-codex-${expectedStatus}-claim-failure`; + const value = await preparedPersonalCodexCandidate({ + label: `Codex ${expectedStatus} claim failure`, + providerThreadId, + updatedAt: personalAdoptionNow - 11 * 60_000, + liveness: "not_live", + now: () => now, + }); + value.personalCodex.claimError = claimError; + + await expect(value.enable()).resolves.toMatchObject({ + discovery: { provider: "codex", adopted: 0, failed: 1 }, + }); + + expect(value.personalCodex.claimRequests).toHaveLength(1); + expect(value.personalCodex.calls.filter((call) => call === "end")).toHaveLength(0); + expect(value.store.listSessionAdoptionCandidates({ provider: "codex" }) + .some((candidate) => + candidate.providerThreadId === providerThreadId + && candidate.status === expectedStatus)).toBe(true); + if (expectedStatus !== "claiming") return; + + now += 1; + await expect(value.service.execute({ + kind: "session.adoption.discover", + provider: "codex", + }, { signal })).resolves.toMatchObject({ + providers: [{ provider: "codex", adopted: 0, pending: 1 }], + }); + expect(value.personalCodex.claimRequests).toHaveLength(1); + expect(value.store.listSessionAdoptionCandidates({ provider: "codex" }) + .some((candidate) => + candidate.providerThreadId === providerThreadId + && candidate.status === "claiming")).toBe(true); + + now += 1; + const daemonGeneration = value.store.nextDaemonGeneration( + `boot_${"7".repeat(32)}`, + ); + const restartedPersonalCodex = new FakeCodex(); + restartedPersonalCodex.readProjection = { + ...value.personalCodex.readProjection, + providerThreadId, + }; + const restarted = new HraService({ + store: value.store, + paths: value.paths, + codex: new FakeCodex(), + personalCodex: restartedPersonalCodex, + personalCodexHome, + personalDiscovery: value.discovery, + cloud: new FakeCloud(), + daemonAuthority: new FakeDaemonAuthority(), + daemonGeneration, + now: () => now, + requestStop: () => undefined, + }); + await expect(restarted.execute({ + kind: "session.adoption.discover", + provider: "codex", + }, { signal })).resolves.toMatchObject({ + providers: [{ provider: "codex", adopted: 1, failed: 0 }], + }); + expect(restartedPersonalCodex.claimRequests).toHaveLength(1); + expect(value.store.findSessionByProviderThread(value.accountId, providerThreadId)) + .not.toBeNull(); + }, + ); + + test.each(["disable", "reassign"] as const)( + "recovers a restarted stale Codex claim after schedule removal before policy %s", + async (action) => { + let now = personalAdoptionNow; + const providerThreadId = `removed-schedule-${action}-claim`; + let scheduledTargets: readonly Readonly<{ targetThreadId: string | null }>[] = [{ + targetThreadId: providerThreadId, + }]; + const value = await preparedPersonalCodexCandidate({ + label: `Removed schedule ${action}`, + providerThreadId, + updatedAt: personalAdoptionNow - 24 * 60 * 60_000, + liveness: "not_live", + scheduledTaskTarget: true, + now: () => now, + readPersonalCodexAutomations: (request) => Promise.resolve( + personalCodexAutomationScan(scheduledTargets, request), + ), + }); + const replacement = await value.service.execute({ + kind: "account.add", + label: `Replacement account ${action}`, + }, { signal }) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: replacement.account.id, + deviceCode: false, + }, { signal }); + value.personalCodex.claimError = new CodexClaimReleaseUnprovenError(); + await expect(value.enable()).resolves.toMatchObject({ + discovery: { provider: "codex", adopted: 0, failed: 1 }, + }); + expect(value.store.listSessionAdoptionCandidates({ + provider: "codex", + status: "claiming", + })).toHaveLength(1); + + // The granting Desktop record disappears while the daemon is down. The + // old thread is outside ordinary recency and therefore cannot re-enter + // discovery merely to settle its interrupted claim. + scheduledTargets = []; + value.discovery.candidates = []; + now += 1; + const daemonGeneration = value.store.nextDaemonGeneration( + `boot_${(action === "disable" ? "8" : "9").repeat(32)}`, + ); + const restartedPersonalCodex = new FakeCodex(); + restartedPersonalCodex.readProjection = { + providerThreadId, + title: `Removed schedule ${action} personal thread`, + projectRoot: value.documents, + status: "idle", + providerUpdatedAt: personalAdoptionNow - 24 * 60 * 60_000, + }; + const restarted = new HraService({ + store: value.store, + paths: value.paths, + codex: new FakeCodex(), + personalCodex: restartedPersonalCodex, + personalCodexHome, + personalDiscovery: value.discovery, + readPersonalCodexAutomations: (request) => Promise.resolve( + personalCodexAutomationScan(scheduledTargets, request), + ), + cloud: new FakeCloud(), + daemonAuthority: new FakeDaemonAuthority(), + daemonGeneration, + now: () => now, + requestStop: () => undefined, + }); + + if (action === "disable") { + await expect(restarted.execute({ + kind: "session.adoption.set", + provider: "codex", + enabled: false, + }, { signal })).resolves.toMatchObject({ + providers: [{ provider: "codex", enabled: false }], + }); + expect(value.store.readSessionAdoptionPolicy("codex")?.profileId).toBeNull(); + } else { + await expect(restarted.execute({ + kind: "session.adoption.set", + provider: "codex", + enabled: true, + account: replacement.account.id, + }, { signal })).resolves.toMatchObject({ + providers: [{ + provider: "codex", + accountId: replacement.account.id, + enabled: true, + }], + }); + expect(value.store.readSessionAdoptionPolicy("codex")?.profileId) + .toBe(replacement.account.id); + } + expect(restartedPersonalCodex.metadataReadRequests).toHaveLength(1); + expect(restartedPersonalCodex.metadataReadRequests[0]?.providerThreadId) + .toBe(providerThreadId); + expect(value.store.listSessionAdoptionCandidates({ + provider: "codex", + status: "claiming", + })).toEqual([]); + await restarted.close(); + }, + ); + + test("persists a native Claude launch intent before admission and consumes it into exact custody", async () => { + const managedClaude = new FakeClaude("isolated", { + pid: 62_990, + pidDomain: "darwin", + procStart: "managed-claude-pre-admission", + }); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { managedClaude }, + ); + const added = await value.service.execute( + { kind: "account.add", label: "Native Claude launch intent" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + await value.service.execute({ + kind: "project.add", + label: "Native Claude launch intent project", + path: value.documents, + }, { signal }); + let stagedProviderThreadId: string | undefined; + let stagedSessionId: string | undefined; + managedClaude.beforeStartSessionAdmission = (input) => { + stagedProviderThreadId = input.providerThreadId; + if (stagedProviderThreadId === undefined) { + throw new Error("Expected a reserved Claude provider identity."); + } + const intent = value.store.readClaudeProcessLaunchIntent({ + providerThreadId: stagedProviderThreadId, + profileId: added.account.id, + runtimeScope: "managed", + }); + if (intent?.sessionId === null || intent === null) { + throw new Error("Expected a session-bound Claude launch intent."); + } + stagedSessionId = intent.sessionId; + expect(intent.profileGeneration).toBe( + value.store.requireProfileById(added.account.id).processGeneration, + ); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId: stagedProviderThreadId, + profileId: added.account.id, + runtimeScope: "managed", + })).toBeNull(); + }; + + const started = await value.service.execute({ + kind: "session.start", + account: added.account.id, + provider: "claude", + preset: "fable-max", + fast: false, + }, { signal }) as { session: { id: `sess_${string}`; providerThreadId?: string } }; + if (stagedProviderThreadId === undefined || stagedSessionId === undefined) { + throw new Error("Expected the pre-admission hook to observe launch authority."); + } + expect(started.session).toMatchObject({ + id: stagedSessionId, + providerThreadId: stagedProviderThreadId, + }); + expect(value.store.readClaudeProcessLaunchIntent({ + providerThreadId: stagedProviderThreadId, + profileId: added.account.id, + runtimeScope: "managed", + })).toBeNull(); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId: stagedProviderThreadId, + profileId: added.account.id, + runtimeScope: "managed", + })).toMatchObject({ + identity: managedClaude.processIdentity, + sessionId: started.session.id, + state: "bound", + }); + await value.service.close(); + }); + + test("retains native Claude launch authority and suppresses replay when child exit is unproven", async () => { + const managedClaude = new FakeClaude("isolated", { + pid: 62_991, + pidDomain: "darwin", + procStart: "managed-claude-unproven-start", + }); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { managedClaude }, + ); + const added = await value.service.execute( + { kind: "account.add", label: "Native Claude unproven launch" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + await value.service.execute({ + kind: "project.add", + label: "Native Claude unproven launch project", + path: value.documents, + }, { signal }); + managedClaude.startSessionError = new ClaudeProcessExitUnprovenError(); + const idempotencyKey = crypto.randomUUID(); + + const failure = await value.service.execute({ + kind: "session.start", + account: added.account.id, + provider: "claude", + preset: "fable-max", + fast: false, + idempotencyKey, + }, { signal }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(CommandFailure); + expect((failure as CommandFailure).code).toBe("RECOVERY_REQUIRED"); + expect(managedClaude.startSessionRequests).toHaveLength(1); + const providerThreadId = managedClaude.startSessionRequests[0]?.providerThreadId; + if (providerThreadId === undefined) throw new Error("Expected a reserved Claude identity."); + const intent = value.store.readClaudeProcessLaunchIntent({ + providerThreadId, + profileId: added.account.id, + runtimeScope: "managed", + }); + if (intent?.sessionId === null || intent === null) { + throw new Error("Expected retained session-bound launch authority."); + } + expect(value.store.requireSession(intent.sessionId).state).toBe("recovery_required"); + expect(value.store.readMutation(idempotencyKey)).toMatchObject({ state: "ambiguous" }); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId, + profileId: added.account.id, + runtimeScope: "managed", + })).toBeNull(); + + const replay = await value.service.execute({ + kind: "session.start", + account: added.account.id, + provider: "claude", + preset: "fable-max", + fast: false, + idempotencyKey, + }, { signal }).catch((error: unknown) => error); + expect(replay).toBeInstanceOf(CommandFailure); + expect((replay as CommandFailure).code).toBe("RECOVERY_REQUIRED"); + expect(managedClaude.startSessionRequests).toHaveLength(1); + await value.service.close(); + }); + + test("retains adopted Claude launch authority and never reclaims after an unproven child exit", async () => { + const managedClaude = new FakeClaude("isolated", { + pid: 62_993, + pidDomain: "darwin", + procStart: "managed-claude-adoption-control", + }); + const personalClaude = new FakeClaude("personal", { + pid: 62_994, + pidDomain: "darwin", + procStart: "personal-claude-unproven-adoption", + }); + personalClaude.claimSessionError = new ClaudeProcessExitUnprovenError(); + const discovery = new FakePersonalSessionDiscovery(); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { + managedClaude, + personalClaude, + personalCodexHome, + personalDiscovery: discovery, + }, + ); + const added = await value.service.execute( + { kind: "account.add", label: "Adopted Claude unproven launch" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + await value.service.execute({ + kind: "project.add", + label: "Adopted Claude unproven launch project", + path: value.documents, + }, { signal }); + const providerThreadId = "personal-claude-unproven-adoption"; + discovery.candidates = [{ + provider: "claude", + providerThreadId, + title: "Personal Claude unproven adoption", + projectRoot: value.documents, + updatedAt: personalAdoptionNow - 1_000, + liveness: "not_live", + }]; + + await expect(value.service.execute({ + kind: "session.adoption.set", + provider: "claude", + enabled: true, + account: added.account.id, + }, { signal })).resolves.toMatchObject({ + discovery: { state: "ready", adopted: 0, failed: 1 }, + providers: [{ pending: 1 }], + }); + expect(personalClaude.claimRequests).toHaveLength(1); + expect(value.store.findSessionByProviderThread(added.account.id, providerThreadId)) + .toBeNull(); + expect(value.store.listSessionAdoptionCandidates({ provider: "claude" })[0]) + .toMatchObject({ providerThreadId, status: "claiming" }); + expect(value.store.readClaudeProcessLaunchIntent({ + providerThreadId, + profileId: added.account.id, + runtimeScope: "personal", + })).toMatchObject({ + profileId: added.account.id, + runtimeScope: "personal", + sessionId: null, + }); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId, + profileId: added.account.id, + runtimeScope: "personal", + })).toBeNull(); + + await expect(value.service.execute({ + kind: "session.adoption.discover", + provider: "claude", + }, { signal })).resolves.toMatchObject({ + providers: [{ adopted: 0, pending: 1 }], + }); + expect(personalClaude.claimRequests).toHaveLength(1); + expect(value.store.readClaudeProcessLaunchIntent({ + providerThreadId, + profileId: added.account.id, + runtimeScope: "personal", + })).not.toBeNull(); + await value.service.close(); + }); + + test("quarantines Claude resume when the replacement child exit is unproven", async () => { + const value = await nativeClaudeFixture( + "Native Claude unproven resume", + "native-claude-unproven-resume", + { + pid: 62_992, + pidDomain: "darwin", + procStart: "managed-claude-unproven-resume", + }, + ); + const failedObservation = value.managedClaude.observeRequests.length + 1; + value.managedClaude.disconnectOnObserveRequest = failedObservation; + value.managedClaude.claimSessionError = new ClaudeProcessExitUnprovenError(); + + const failure = await value.service.execute({ + kind: "session.status", + session: value.session.id, + }, { signal }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(CommandFailure); + expect((failure as CommandFailure).code).toBe("RECOVERY_REQUIRED"); + expect(value.managedClaude.claimRequests).toHaveLength(1); + expect(value.store.requireSession(value.session.id).state).toBe("recovery_required"); + expect(value.store.readClaudeProcessLaunchIntent({ + providerThreadId: value.providerThreadId, + profileId: value.accountId, + runtimeScope: "managed", + })).toMatchObject({ + sessionId: value.session.id, + }); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId: value.providerThreadId, + profileId: value.accountId, + runtimeScope: "managed", + })?.state).toBe("released"); + + await expect(value.service.execute({ + kind: "session.status", + session: value.session.id, + }, { signal })).resolves.toMatchObject({ + providerObservation: { state: "recovery_required" }, + }); + expect(value.managedClaude.claimRequests).toHaveLength(1); + await value.service.close(); + }); + + test("retires an exact Claude resume launch intent after an ordinary pre-admission claim failure", async () => { + const value = await nativeClaudeFixture( + "Native Claude rejected resume", + "native-claude-rejected-resume", + { + pid: 62_995, + pidDomain: "darwin", + procStart: "managed-claude-rejected-resume", + }, + ); + const claimFailure = new Error("replacement claim rejected before process admission"); + value.managedClaude.disconnectOnObserveRequest = value.managedClaude.observeRequests.length + 1; + value.managedClaude.claimSessionError = claimFailure; + + const failure = await value.service.execute({ + kind: "session.status", + session: value.session.id, + }, { signal }).catch((error: unknown) => error); + + expect(failure).toBe(claimFailure); + expect(value.managedClaude.claimRequests).toHaveLength(1); + expect(value.store.readClaudeProcessLaunchIntent({ + providerThreadId: value.providerThreadId, + profileId: value.accountId, + runtimeScope: "managed", + })).toBeNull(); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId: value.providerThreadId, + profileId: value.accountId, + runtimeScope: "managed", + })?.state).toBe("released"); + expect(value.store.requireSession(value.session.id).state).toBe("idle"); + await value.service.close(); + }); + + test("quarantines an exact Claude resume when its ordinary-failure launch intent cannot be retired", async () => { + const value = await nativeClaudeFixture( + "Native Claude conflicted resume cleanup", + "native-claude-conflicted-resume-cleanup", + { + pid: 62_996, + pidDomain: "darwin", + procStart: "managed-claude-conflicted-resume-cleanup", + }, + ); + const claimFailure = new Error("replacement claim rejected before process admission"); + let replacementIntentId: string | undefined; + value.managedClaude.disconnectOnObserveRequest = value.managedClaude.observeRequests.length + 1; + value.managedClaude.beforeClaimSessionAdmission = () => { + const original = value.store.readClaudeProcessLaunchIntent({ + providerThreadId: value.providerThreadId, + profileId: value.accountId, + runtimeScope: "managed", + }); + if (original === null || original.providerAccountKey === null) { + throw new Error("Expected the account-bound exact Claude resume launch intent."); + } + value.store.cancelClaudeProcessLaunchIntent({ + providerThreadId: original.providerThreadId, + profileId: original.profileId, + profileGeneration: original.profileGeneration, + runtimeScope: original.runtimeScope, + intentId: original.intentId, + expectedRevision: original.revision, + }); + replacementIntentId = value.store.stageClaudeProcessLaunchIntent({ + providerThreadId: original.providerThreadId, + profileId: original.profileId, + profileGeneration: original.profileGeneration, + runtimeScope: original.runtimeScope, + providerAccountKey: original.providerAccountKey, + sessionId: value.session.id, + }).intentId; + }; + value.managedClaude.claimSessionError = claimFailure; + + const failure = await value.service.execute({ + kind: "session.status", + session: value.session.id, + }, { signal }).catch((error: unknown) => error); + + expect(failure).toMatchObject({ + name: "IndeterminateLocalCommitError", + message: "Claude rejected exact-process resume, but its launch intent could not be retired.", + }); + expect(value.store.requireSession(value.session.id).state).toBe("recovery_required"); + expect(value.store.readClaudeProcessLaunchIntent({ + providerThreadId: value.providerThreadId, + profileId: value.accountId, + runtimeScope: "managed", + })?.intentId).toBe(replacementIntentId); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId: value.providerThreadId, + profileId: value.accountId, + runtimeScope: "managed", + })?.state).toBe("released"); + await value.service.close(); + }); + + test("keeps native Claude config-home provenance private across start, show, and send", async () => { + const managedClaude = new FakeClaude("isolated", { + pid: 63_000, + pidDomain: "darwin", + procStart: "managed-claude-public-profile", + }); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { managedClaude }, + ); + const added = await value.service.execute( + { kind: "account.add", label: "Native Claude profile" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + await value.service.execute({ + kind: "project.add", + label: "Native Claude project", + path: value.documents, + }, { signal }); + + const started = await value.service.execute({ + kind: "session.start", + account: added.account.id, + provider: "claude", + preset: "fable-max", + fast: false, + }, { signal }) as { + effectiveRuntimeProfile: Record; + session: { id: `sess_${string}` }; + }; + expect(started.effectiveRuntimeProfile).not.toHaveProperty("configHome"); + expect(started.effectiveRuntimeProfile).not.toHaveProperty("isolatedConfigDir"); + expect(value.store.latestSessionRuntimeProfile(started.session.id)) + .toMatchObject({ profile: { configHome: "isolated" } }); + + const shown = await value.service.execute({ + kind: "session.show", + session: started.session.id, + detail: false, + }, { signal }) as { + effectiveRuntimeProfile: Record; + }; + expect(shown.effectiveRuntimeProfile).not.toHaveProperty("configHome"); + expect(shown.effectiveRuntimeProfile).not.toHaveProperty("isolatedConfigDir"); + + const sent = await value.service.execute({ + kind: "session.send", + session: started.session.id, + message: "Continue native Claude work", + }, { signal }) as { + effectiveRuntimeProfile: Record; + turnId: string; + }; + expect(sent.effectiveRuntimeProfile).not.toHaveProperty("configHome"); + expect(sent.effectiveRuntimeProfile).not.toHaveProperty("isolatedConfigDir"); + expect(value.store.runtimeProfileForTurn(started.session.id, sent.turnId)) + .toMatchObject({ configHome: "isolated" }); + await value.service.close(); + }); + + test.each(["current", "legacy"] as const)( + "keeps a stored %s Claude config-home private when managed control is platform-unavailable", + async (profileShape) => { + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + "darwin", + ); + const added = await value.service.execute( + { kind: "account.add", label: `Darwin ${profileShape} Claude profile` }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + const profile = value.store.requireProfileById(added.account.id); + const session = value.store.upsertProviderSession({ + profileId: profile.id, + provider: "claude", + providerThreadId: `darwin-${profileShape}-claude-profile`, + title: `Darwin ${profileShape} Claude profile`, + preset: "fable-max", + fastEnabled: false, + state: "idle", + providerAccountKey: claudeProviderAccountKey(), + }); + const authority: ProfileAuthority = { + id: profile.id, + generation: profile.processGeneration, + codexHome: "unused", + desktopUserData: "unused", + }; + const currentProfile = claudeRuntimeProfile(authority, "isolated"); + const storedProfile: EffectiveClaudeRuntimeProfile = profileShape === "current" + ? currentProfile + : { + profileId: currentProfile.profileId, + processGeneration: currentProfile.processGeneration, + observedAt: currentProfile.observedAt, + preset: currentProfile.preset, + model: currentProfile.model, + reasoningEffort: currentProfile.reasoningEffort, + claudeVersion: currentProfile.claudeVersion, + permissionMode: currentProfile.permissionMode, + isolatedConfigDir: true, + outputFormat: currentProfile.outputFormat, + inputFormat: currentProfile.inputFormat, + }; + value.store.recordSessionRuntimeProfile({ + sessionId: session.id, + sourceKind: "session_start", + sourceId: `darwin-${profileShape}-stored-profile`, + profile: storedProfile, + }); + + const shown = await value.service.execute({ + kind: "session.show", + session: session.id, + detail: false, + }, { signal }) as { + effectiveRuntimeProfile: Record; + providerObservation: { state: string }; + }; + + expect(value.store.latestSessionRuntimeProfile(session.id)?.profile) + .toHaveProperty(profileShape === "current" ? "configHome" : "isolatedConfigDir"); + expect(shown.providerObservation.state).toBe("unavailable"); + expect(shown.effectiveRuntimeProfile).not.toHaveProperty("configHome"); + expect(shown.effectiveRuntimeProfile).not.toHaveProperty("isolatedConfigDir"); + await value.service.close(); + }, + ); + + test("releases and resumes a native Claude child lost at its first post-commit observation", async () => { + const initialIdentity: ClaudeProcessIdentity = { + pid: 63_010, + pidDomain: "darwin", + procStart: "native-claude-observation-gap-initial", + }; + const replacementIdentity: ClaudeProcessIdentity = { + pid: 63_011, + pidDomain: "darwin", + procStart: "native-claude-observation-gap-replacement", + }; + const replacementConnectionId = "30000000-0000-4000-8000-0000000000c2"; + const managedClaude = new FakeClaude("isolated", initialIdentity); + managedClaude.disconnectOnObserveRequest = 1; + managedClaude.processIdentityOnClaim = replacementIdentity; + managedClaude.observationConnectionIdOnClaim = replacementConnectionId; + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { managedClaude }, + ); + const added = await value.service.execute( + { kind: "account.add", label: "Native Claude observation gap" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + await value.service.execute({ + kind: "project.add", + label: "Native Claude observation gap project", + path: value.documents, + }, { signal }); + managedClaude.projection = { + providerThreadId: "native-claude-observation-gap", + title: "Native Claude observation gap", + status: "idle", + projectRoot: value.documents, + providerUpdatedAt: personalAdoptionNow - 1_000, + }; + + const started = await value.service.execute({ + kind: "session.start", + account: added.account.id, + provider: "claude", + preset: "fable-max", + fast: false, + }, { signal }) as { session: { id: `sess_${string}`; providerThreadId?: string } }; + + const providerThreadId = started.session.providerThreadId; + if (providerThreadId === undefined) throw new Error("Expected a bound native Claude session."); + expect(providerThreadId).toMatch(/^[0-9a-f-]{36}$/u); + expect(managedClaude.endRequests).toHaveLength(1); + expect(managedClaude.endRequests[0]?.providerThreadId) + .toBe(providerThreadId); + expect(managedClaude.endedProcessIdentities).toEqual([initialIdentity]); + expect(managedClaude.claimRequests).toHaveLength(1); + expect(managedClaude.claimRequests[0]).toMatchObject({ + providerThreadId, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + sourceLiveness: "not_live", + title: "Untitled session", + }); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId, + profileId: added.account.id, + runtimeScope: "managed", + })).toMatchObject({ + identity: replacementIdentity, + sessionId: started.session.id, + state: "bound", + }); + await expect(value.service.execute({ + kind: "session.status", + session: started.session.id, + }, { signal })).resolves.toMatchObject({ + providerObservation: { + connectionId: replacementConnectionId, + state: "live", + }, + }); + expect(managedClaude.endRequests).toHaveLength(1); + expect(managedClaude.claimRequests).toHaveLength(1); + await value.service.close(); + }); + + test("releases a resumed native Claude child whose claimed profile violates the durable preset", async () => { + const initialIdentity: ClaudeProcessIdentity = { + pid: 63_014, + pidDomain: "darwin", + procStart: "native-claude-profile-mismatch-initial", + }; + const replacementIdentity: ClaudeProcessIdentity = { + pid: 63_015, + pidDomain: "darwin", + procStart: "native-claude-profile-mismatch-replacement", + }; + const managedClaude = new FakeClaude("isolated", initialIdentity); + managedClaude.disconnectOnObserveRequest = 1; + managedClaude.processIdentityOnClaim = replacementIdentity; + managedClaude.claimRuntimeProfileOverride = (authority) => ({ + ...claudeRuntimeProfile(authority, "isolated"), + model: "claude-other-model", + }); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { managedClaude }, + ); + const added = await value.service.execute( + { kind: "account.add", label: "Native Claude profile mismatch" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + await value.service.execute({ + kind: "project.add", + label: "Native Claude profile mismatch project", + path: value.documents, + }, { signal }); + managedClaude.projection = { + providerThreadId: "native-claude-profile-mismatch", + title: "Native Claude profile mismatch", + status: "idle", + projectRoot: value.documents, + providerUpdatedAt: personalAdoptionNow - 1_000, + }; + + await expect(value.service.execute({ + kind: "session.start", + account: added.account.id, + provider: "claude", + preset: "fable-max", + fast: false, + }, { signal })).rejects.toThrow("SESSION_CLAIM_RUNTIME_PROFILE_MISMATCH"); + + const providerThreadId = managedClaude.claimRequests[0]?.providerThreadId; + if (providerThreadId === undefined) throw new Error("Expected a replacement Claude claim."); + expect(managedClaude.endRequests).toHaveLength(2); + expect(managedClaude.endedProcessIdentities).toEqual([ + initialIdentity, + replacementIdentity, + ]); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId, + profileId: added.account.id, + runtimeScope: "managed", + })).toMatchObject({ + identity: replacementIdentity, + state: "released", + }); + expect(value.store.readClaudeProcessLaunchIntent({ + providerThreadId, + profileId: added.account.id, + runtimeScope: "managed", + })).toBeNull(); + await value.service.close(); + }); + + test("retains an adopted Claude replacement when its old disconnect arrives before connection remapping", async () => { + const value = await adoptedClaudeFixture( + "Adopted Claude delayed disconnect", + "personal-claude-delayed-disconnect", + ); + await value.service.settled(); + const initialIdentity = value.personalClaude.processIdentity; + const replacementIdentity: ClaudeProcessIdentity = { + pid: 63_013, + pidDomain: "darwin", + procStart: "personal-claude-delayed-disconnect-replacement", + }; + const initialConnectionId = value.personalClaude.observationConnectionId; + const replacementConnectionId = "30000000-0000-4000-8000-0000000000c4"; + + let markReplacementObservationStarted!: () => void; + const replacementObservationStarted = new Promise((resolve) => { + markReplacementObservationStarted = resolve; + }); + let releaseReplacementObservation!: () => void; + const replacementObservationPause = new Promise((resolve) => { + releaseReplacementObservation = resolve; + }); + const claimsBeforeRecovery = value.personalClaude.claimRequests.length; + const failedObservationRequest = value.personalClaude.observeRequests.length + 1; + value.personalClaude.disconnectOnObserveRequest = failedObservationRequest; + value.personalClaude.processIdentityOnClaim = replacementIdentity; + value.personalClaude.observationConnectionIdOnClaim = replacementConnectionId; + value.personalClaude.pauseOnObserveRequest = failedObservationRequest + 1; + value.personalClaude.observePauseStarted = markReplacementObservationStarted; + value.personalClaude.observePause = replacementObservationPause; + + const foregroundRecovery = value.service.execute({ + kind: "session.status", + session: value.session.id, + }, { signal }); + await replacementObservationStarted; + expect(value.store.readClaudeProcessAuthority({ + providerThreadId: "personal-claude-delayed-disconnect", + profileId: value.accountId, + runtimeScope: "personal", + })).toMatchObject({ + identity: replacementIdentity, + sessionId: value.session.id, + state: "bound", + }); + expect(value.personalClaude.claimRequests).toHaveLength(claimsBeforeRecovery + 1); + const replacementAuthority = value.personalClaude.claimRequests.at(-1)?.authority; + if (replacementAuthority === undefined) { + throw new Error("Expected the replacement Claude claim authority."); + } + await value.service.observePersonalClaudeFact(replacementAuthority, { + type: "providerDisconnected", + connectionId: initialConnectionId, + providerThreadId: "personal-claude-delayed-disconnect", + reason: "process_exit", + }); + expect(value.personalClaude.endRequests).toHaveLength(1); + + releaseReplacementObservation(); + await expect(foregroundRecovery).resolves.toMatchObject({ + providerObservation: { + connectionId: replacementConnectionId, + state: "live", + }, + }); + await value.service.settled(); + expect(value.personalClaude.observeRequests) + .toHaveLength(failedObservationRequest + 2); + expect(value.personalClaude.endRequests).toHaveLength(1); + expect(value.personalClaude.endedProcessIdentities).toEqual([initialIdentity]); + expect(value.personalClaude.claimRequests).toHaveLength(claimsBeforeRecovery + 1); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId: "personal-claude-delayed-disconnect", + profileId: value.accountId, + runtimeScope: "personal", + })).toMatchObject({ + identity: replacementIdentity, + sessionId: value.session.id, + state: "bound", + }); + expect(value.store.readSessionPersonalRuntimeBinding(value.session.id)) + .toMatchObject({ state: "active" }); + + await expect(value.service.execute({ + kind: "session.status", + session: value.session.id, + }, { signal })).resolves.toMatchObject({ + providerObservation: { + connectionId: replacementConnectionId, + state: "live", + }, + }); + + await expect(value.service.execute({ + kind: "session.send", + session: value.session.id, + message: "Continue on the replacement controller", + idempotencyKey: "00000000-0000-4000-8000-00000000c004", + }, { signal })).resolves.toMatchObject({ + session: { id: value.session.id, state: "idle" }, + }); + expect(value.personalClaude.turnRequests).toHaveLength(1); + expect(value.managedClaude.turnRequests).toHaveLength(0); + await value.service.close(); + }); + + test("holds account authority across a personal Codex adoption claim and managed disconnect", async () => { + const personalCodex = new FakeCodex(); + const discovery = new FakePersonalSessionDiscovery(); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { personalCodex, personalCodexHome, personalDiscovery: discovery }, + ); + const added = await value.service.execute( + { kind: "account.add", label: "Codex adoption account lock" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + await value.service.execute({ + kind: "project.add", + label: "Codex adoption account lock project", + path: value.documents, + }, { signal }); + + const providerThreadId = "personal-thread-account-lock"; + personalCodex.readProjection = { + providerThreadId, + title: "Personal thread protected by the account lock", + status: "idle", + projectRoot: value.documents, + providerUpdatedAt: personalAdoptionNow - 11 * 60_000, + }; + discovery.candidates = [{ + provider: "codex", + providerThreadId, + title: "Personal thread protected by the account lock", + projectRoot: value.documents, + updatedAt: personalAdoptionNow - 11 * 60_000, + liveness: "not_live", + }]; + + let markClaimStarted!: () => void; + let releaseClaim!: () => void; + const claimStarted = new Promise((resolve) => { + markClaimStarted = resolve; + }); + const claimGate = new Promise((resolve) => { + releaseClaim = resolve; + }); + personalCodex.beforeClaimSessionReturnOnce = async () => { + markClaimStarted(); + await claimGate; + }; + + const adoption = value.service.execute({ + kind: "session.adoption.set", + provider: "codex", + enabled: true, + account: added.account.id, + }, { signal }); + await claimStarted; + const authority = personalCodex.claimRequests[0]?.authority; + if (authority === undefined) throw new Error("Expected a paused personal Codex claim."); + const generationDuringClaim = value.store + .requireProfileById(added.account.id).processGeneration; + expect(authority.generation).toBe(generationDuringClaim); + expect(value.store.listSessionAdoptionCandidates({ provider: "codex" })[0]) + .toMatchObject({ providerThreadId, status: "claiming" }); + + await value.service.observeCodexFact(authority, { + type: "providerDisconnected", + connectionId: value.codex.observationConnectionId, + reason: "process_exit", + }); + const generationAfterDisconnectDuringClaim = value.store + .requireProfileById(added.account.id).processGeneration; + releaseClaim(); + const adoptionResult = await adoption; + await value.service.settled(); + + expect(generationAfterDisconnectDuringClaim).toBe(generationDuringClaim); + expect(adoptionResult).toMatchObject({ + discovery: { provider: "codex", state: "ready", adopted: 1, failed: 0 }, + }); + + expect(value.store.requireProfileById(added.account.id).processGeneration) + .toBe(generationDuringClaim); + expect(value.store.listSessionAdoptionCandidates({ provider: "codex" })[0]) + .toMatchObject({ providerThreadId, status: "adopted" }); + const session = value.store.findSessionByProviderThread(added.account.id, providerThreadId); + if (session === null) throw new Error("Expected the claimed Codex thread to stay adopted."); + expect(session).toMatchObject({ + profileId: added.account.id, + provider: "codex", + providerThreadId, + state: "idle", + }); + expect(session.archivedAt).toBeUndefined(); + expect(value.store.readSessionPersonalRuntimeBinding(session.id)).toMatchObject({ + provider: "codex", + providerThreadId, + state: "active", + }); + expect(personalCodex.calls.filter((call) => call === "end")).toHaveLength(0); + }); + + test("starts a causally fresh account read when a post-claim fence overlaps an older check", async () => { + const value = await adoptedCodexFixture( + "Fresh post-claim identity", + "personal-thread-before-fresh-post-claim", + ); + const providerThreadId = "personal-thread-fresh-post-claim"; + value.personalCodex.readProjection = { + providerThreadId, + title: "Fresh post-claim personal thread", + status: "idle", + projectRoot: value.documents, + providerUpdatedAt: personalAdoptionNow - 11 * 60_000, + }; + value.discovery.candidates = [{ + provider: "codex", + providerThreadId, + title: "Fresh post-claim personal thread", + projectRoot: value.documents, + updatedAt: personalAdoptionNow - 11 * 60_000, + liveness: "not_live", + }]; + const seeded = await seedResolvableInteraction( + value, + value.session.id, + "fresh-post-claim-overlap", + undefined, + "personal", + ); + + let releaseClaim!: () => void; + let claimReturned!: () => void; + const claimGate = new Promise((resolve) => { + releaseClaim = resolve; + }); + const claimApplied = new Promise((resolve) => { + claimReturned = resolve; + }); + value.personalCodex.beforeClaimSessionReturnOnce = async () => { + claimReturned(); + await claimGate; + }; + value.personalCodex.validateInteractionResolutionError = new CodexError( + "UNSUPPORTED_CAPABILITY", + "Stop after the account precheck.", + ); + // Admit an older account read before discovery takes the account tail. + // Once it finishes, discovery must perform its own pre-claim read and a + // distinct causally fresh post-claim read rather than reusing either one. + let releaseOlderRead!: () => void; + let olderReadStarted!: () => void; + const olderReadGate = new Promise((resolve) => { + releaseOlderRead = resolve; + }); + const olderReadAdmitted = new Promise((resolve) => { + olderReadStarted = resolve; + }); + value.personalCodex.beforeReadAccountReturn = async () => { + delete value.personalCodex.beforeReadAccountReturn; + olderReadStarted(); + await olderReadGate; + }; + const resolving = value.service.execute({ + kind: "interaction.resolve", + interaction: seeded.interaction.publicId, + expectedRevision: seeded.interaction.revision, + resolution: { kind: "approval_decision", decision: "once" }, + }, { signal }); + await olderReadAdmitted; + const claimsBeforeDiscovery = value.personalCodex.claimRequests.length; + const discovery = value.service.discoverPersonalSessions("codex", signal); + await Bun.sleep(0); + expect(value.personalCodex.claimRequests).toHaveLength(claimsBeforeDiscovery); + releaseOlderRead(); + await expect(resolving).rejects.toMatchObject({ code: "CONFLICT" }); + const admission = await Promise.race([ + claimApplied.then(() => ({ kind: "claim" as const })), + discovery.then( + (result) => ({ kind: "settled" as const, result }), + (error: unknown) => ({ error, kind: "failed" as const }), + ), + ]); + if (admission.kind !== "claim") { + throw new Error("Expected personal Codex discovery to pause inside the claim.", { + cause: admission.kind === "failed" ? admission.error : admission.result, + }); + } + const claimAuthority = value.personalCodex.claimRequests.at(-1)?.authority; + if (claimAuthority === undefined) throw new Error("Expected a paused personal Codex claim."); + expect(claimAuthority).toMatchObject({ + id: seeded.authority.id, + generation: seeded.authority.generation, + }); + const readsBeforeSwap = value.personalCodex.calls + .filter((call) => call === "readAccount").length; + + value.personalCodex.accountProjection = { + signedIn: true, + email: "replacement@example.com", + plan: "Plus", + }; + releaseClaim(); + await expect(discovery).resolves.toMatchObject({ + providers: [{ + adopted: 0, + failed: 1, + provider: "codex", + state: "ready", + }], + }); + expect(value.personalCodex.calls.filter((call) => call === "readAccount").length) + .toBeGreaterThan(readsBeforeSwap); + expect(value.store.findSessionByProviderThread(value.accountId, providerThreadId)) + .toBeNull(); + expect(value.store.readSessionAdoptionPolicy("codex")).toMatchObject({ + enabled: false, + profileId: null, + }); + await value.service.settled(); + }); + + test("completes personal Codex account revocation after the observer already retired its generation", async () => { + const value = await adoptedCodexFixture( + "Retired personal Codex account authority", + "personal-thread-retired-account-authority", + ); + const authority = value.personalCodex.claimRequests[0]?.authority; + if (authority === undefined) throw new Error("Expected personal Codex claim authority."); + const readsBeforeMismatch = value.personalCodex.calls + .filter((call) => call === "readAccount").length; + let releaseStarted!: () => void; + const releaseAdmission = new Promise((resolve) => { + releaseStarted = resolve; + }); + let finishRelease!: () => void; + const releaseGate = new Promise((resolve) => { + finishRelease = resolve; + }); + value.personalCodex.beforeReleaseOwnedAuthorityReturn = async () => { + releaseStarted(); + await releaseGate; + }; + + // CodexRuntimeAdapter retires and removes the exact client before it + // delivers the observer callback. Ordinary reads at this generation are + // therefore impossible, while releaseOwnedAuthority can still await its + // retained close task without launching anything. + value.personalCodex.retireAuthority(authority); + await expect(value.personalCodex.readAccount({ authority, signal })) + .rejects.toThrow("AUTHORITY_STALE"); + + const replacementB: CodexAccountProjection = { + signedIn: true, + email: "replacement-retired-b@example.com", + plan: "Plus", + }; + await expect(value.service.observePersonalCodexAccount(authority, replacementB)) + .rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + await releaseAdmission; + + const replacementC: CodexAccountProjection = { + signedIn: true, + email: "replacement-retired-c@example.com", + plan: "Plus", + }; + await expect(value.service.observePersonalCodexAccount(authority, replacementC)) + .rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + finishRelease(); + await value.service.settled(); + + expect(value.personalCodex.calls.filter((call) => call === "readAccount")) + .toHaveLength(readsBeforeMismatch + 1); + expect(value.personalCodex.releasedAuthorities).toEqual([authority]); + expect(value.store.readProviderRuntimeAccountRevocation({ + profileId: value.accountId, + provider: "codex", + runtimeScope: "personal", + })).toMatchObject({ + currentAccountKey: codexProviderAccountKey("replacement-retired-c@example.com"), + profileGeneration: authority.generation, + state: "completed", + }); + expect(value.store.readSessionPersonalRuntimeBinding(value.session.id, true)) + .toMatchObject({ state: "detached" }); + expect(value.store.requireSession(value.session.id).archivedAt).toBeUndefined(); + await expect(value.service.execute({ + kind: "session.adoption.status", + provider: "codex", + }, { signal })).resolves.toMatchObject({ + providers: [{ provider: "codex", restartRequired: true }], + }); + }); + + test.each(["active", "paused"] as const)( + "adopts a stale Codex thread targeted by a present %s heartbeat without exposing its source", + async (status) => { + const providerThreadId = `scheduled-${status}-heartbeat-thread`; + let scanCalls = 0; + const authorityRequests: CodexAutomationAuthorityRequest[] = []; + const value = await preparedPersonalCodexCandidate({ + label: `Scheduled ${status} heartbeat`, + providerThreadId, + updatedAt: personalAdoptionNow - 24 * 60 * 60_000, + liveness: "not_live", + scheduledTaskTarget: true, + readPersonalCodexAutomations: (request) => { + scanCalls += 1; + authorityRequests.push(request); + return Promise.resolve(personalCodexAutomationScan([{ + status, + targetThreadId: providerThreadId, + }], request)); + }, + }); + + await expect(value.enable()).resolves.toMatchObject({ + discovery: { provider: "codex", adopted: 1, failed: 0 }, + }); + expect(scanCalls).toBe(3); + expect(authorityRequests.every((request) => request.signal === signal)).toBe(true); + expect(authorityRequests.map((request) => request.deadlineAt)).toEqual([ + personalAdoptionNow + 5_000, + personalAdoptionNow + 5_000, + personalAdoptionNow + 5_000, + ]); + expect(value.discovery.requests).toHaveLength(1); + expect(value.discovery.requests[0]?.codexScheduledThreadIds).toEqual([ + providerThreadId, + ]); + expect(value.personalCodex.claimRequests).toHaveLength(1); + const session = value.store.findSessionByProviderThread( + value.accountId, + providerThreadId, + ); + if (session === null) throw new Error("Expected the scheduled target to be adopted."); + const listed = await value.service.execute({ + kind: "session.list", + archived: false, + account: value.accountId, + limit: 10, + }, { signal }) as { sessions: readonly Record[] }; + const publicSession = listed.sessions.find((candidate) => candidate.id === session.id); + expect(publicSession).toBeDefined(); + expect(publicSession).not.toHaveProperty("scheduledTaskTarget"); + expect(publicSession).not.toHaveProperty("automation"); + }, + ); + + test("reasserts daemon authority after an exact scheduled-target read", async () => { + const providerThreadId = "scheduled-authority-reassertion"; + let authorityReads = 0; + const context: { + value?: Awaited>; + } = {}; + const value = await preparedPersonalCodexCandidate({ + label: "Scheduled authority reassertion", + providerThreadId, + updatedAt: personalAdoptionNow - 24 * 60 * 60_000, + liveness: "not_live", + scheduledTaskTarget: true, + readPersonalCodexAutomations: (request) => { + authorityReads += 1; + if (authorityReads === 2) context.value?.daemonAuthority.invalidate(); + return Promise.resolve(personalCodexAutomationScan([{ + targetThreadId: providerThreadId, + }], request)); + }, + }); + context.value = value; + + await expect(value.enable()).rejects.toBeInstanceOf(DaemonAuthoritySafetyError); + expect(authorityReads).toBe(2); + expect(value.personalCodex.claimRequests).toHaveLength(0); + expect(value.store.readSessionAdoptionCandidate("codex", providerThreadId)) + .toMatchObject({ status: "pending" }); + }); + + test("reasserts daemon authority after the final scheduled-target read before commit", async () => { + const providerThreadId = "scheduled-final-authority-reassertion"; + let authorityReads = 0; + const context: { + value?: Awaited>; + } = {}; + const value = await preparedPersonalCodexCandidate({ + label: "Scheduled final authority reassertion", + providerThreadId, + updatedAt: personalAdoptionNow - 24 * 60 * 60_000, + liveness: "not_live", + scheduledTaskTarget: true, + readPersonalCodexAutomations: (request) => { + authorityReads += 1; + if (authorityReads === 3) context.value?.daemonAuthority.invalidate(); + return Promise.resolve(personalCodexAutomationScan([{ + targetThreadId: providerThreadId, + }], request)); + }, + }); + context.value = value; + + await expect(value.enable()).rejects.toBeInstanceOf(DaemonAuthoritySafetyError); + expect(authorityReads).toBe(3); + expect(value.personalCodex.claimRequests).toHaveLength(1); + expect(value.personalCodex.calls.filter((call) => call === "end")).toHaveLength(1); + expect(value.store.findSessionByProviderThread(value.accountId, providerThreadId)).toBeNull(); + }); + + test("rotates bounded scheduled-target batches so a nonclaimable prefix cannot starve later threads", async () => { + const providerThreadIds = Array.from( + { length: 60 }, + (_, index) => `scheduled-rotation-${String(index).padStart(3, "0")}`, + ); + const laterProviderThreadId = providerThreadIds[55]; + if (laterProviderThreadId === undefined) throw new Error("Expected a later target id."); + let projectRoot = privatePathRoot; + const discoveryRequests: Array< + Parameters[0] + > = []; + const discovery: PersonalSessionDiscoveryPort = { + discover: (input) => { + discoveryRequests.push(input); + return Promise.resolve((input.codexScheduledThreadIds ?? []).map((providerThreadId) => ({ + provider: "codex" as const, + providerThreadId, + title: `Scheduled ${providerThreadId}`, + projectRoot, + updatedAt: personalAdoptionNow - 24 * 60 * 60_000, + liveness: providerThreadId === laterProviderThreadId + ? "not_live" as const + : "live" as const, + scheduledTaskTarget: true as const, + }))); + }, + }; + const personalCodex = new FakeCodex(); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { + personalCodex, + personalCodexHome, + personalDiscovery: discovery, + readPersonalCodexAutomations: (request) => Promise.resolve(personalCodexAutomationScan( + providerThreadIds.map((targetThreadId) => ({ targetThreadId })), + request, + )), + }, + ); + projectRoot = value.documents; + personalCodex.readProjection = { + providerThreadId: laterProviderThreadId, + title: "Later scheduled target", + projectRoot, + status: "idle", + providerUpdatedAt: personalAdoptionNow - 24 * 60 * 60_000, + }; + const added = await value.service.execute( + { kind: "account.add", label: "Scheduled target rotation" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + await value.service.execute({ + kind: "project.add", + label: "Scheduled target rotation project", + path: projectRoot, + }, { signal }); + + await expect(value.service.execute({ + kind: "session.adoption.set", + provider: "codex", + enabled: true, + account: added.account.id, + }, { signal })).resolves.toMatchObject({ + discovery: { discovered: 50, adopted: 0, pending: 50 }, + }); + expect(discoveryRequests[0]?.codexScheduledThreadIds).toHaveLength(50); + expect(discoveryRequests[0]?.codexScheduledThreadIds).not.toContain(laterProviderThreadId); + + await expect(value.service.execute({ + kind: "session.adoption.discover", + provider: "codex", + }, { signal })).resolves.toMatchObject({ + providers: [{ discovered: 10, adopted: 1, pending: 9 }], + }); + expect(discoveryRequests[1]?.codexScheduledThreadIds).toHaveLength(10); + expect(discoveryRequests[1]?.codexScheduledThreadIds).toContain(laterProviderThreadId); + expect(value.store.findSessionByProviderThread( + added.account.id, + laterProviderThreadId, + )).not.toBeNull(); + }); + + test("rotates the first scheduled-target page by daemon generation across reader restarts", async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), "hra-automation-restart-"))); + serviceRoots.push(root); + const automationsDirectory = join(root, "automations"); + await mkdir(automationsDirectory); + const providerThreadIds = Array.from( + { length: 60 }, + (_, index) => `scheduled-restart-${String(index).padStart(3, "0")}`, + ); + for (const [index, providerThreadId] of providerThreadIds.entries()) { + const source = `restart-source-${String(index).padStart(3, "0")}`; + await mkdir(join(automationsDirectory, source)); + await writeFile( + join(automationsDirectory, source, "automation.toml"), + [ + 'kind = "heartbeat"', + 'status = "ACTIVE"', + `target_thread_id = "${providerThreadId}"`, + "", + ].join("\n"), + ); + } + + let projectRoot = privatePathRoot; + let laterProviderThreadId: string | null = null; + const discoveryRequests: Array< + Parameters[0] + > = []; + const discovery: PersonalSessionDiscoveryPort = { + discover: (input) => { + discoveryRequests.push(input); + if ( + laterProviderThreadId === null + || !input.codexScheduledThreadIds?.includes(laterProviderThreadId) + ) return Promise.resolve([]); + return Promise.resolve([{ + provider: "codex" as const, + providerThreadId: laterProviderThreadId, + title: "Restart-rotated scheduled target", + projectRoot, + updatedAt: personalAdoptionNow - 24 * 60 * 60_000, + liveness: "not_live" as const, + scheduledTaskTarget: true as const, + }]); + }, + }; + const firstPersonalCodex = new FakeCodex(); + const firstReader = (request: CodexAutomationAuthorityRequest) => + readCodexAutomationAuthority({ ...request, automationsDirectory }); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { + daemonGeneration: 1, + personalCodex: firstPersonalCodex, + personalCodexHome, + personalDiscovery: discovery, + readPersonalCodexAutomations: firstReader, + }, + ); + projectRoot = value.documents; + const added = await value.service.execute( + { kind: "account.add", label: "Restart-rotated schedules" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + await value.service.execute({ + kind: "project.add", + label: "Restart-rotated schedule project", + path: projectRoot, + }, { signal }); + + await expect(value.service.execute({ + kind: "session.adoption.set", + provider: "codex", + enabled: true, + account: added.account.id, + }, { signal })).resolves.toMatchObject({ + discovery: { discovered: 0, adopted: 0 }, + }); + const firstPage = discoveryRequests[0]?.codexScheduledThreadIds ?? []; + expect(firstPage).toHaveLength(50); + laterProviderThreadId = providerThreadIds.find((id) => !firstPage.includes(id)) ?? null; + if (laterProviderThreadId === null) throw new Error("Expected a target after page one."); + await value.service.close(); + + const restartedPersonalCodex = new FakeCodex(); + restartedPersonalCodex.readProjection = { + providerThreadId: laterProviderThreadId, + title: "Restart-rotated scheduled target", + projectRoot, + status: "idle", + providerUpdatedAt: personalAdoptionNow - 24 * 60 * 60_000, + }; + // Recreate both the service and its reader closure. Generation two starts + // at the next dense page instead of returning to the first fifty forever. + const restartedReader = (request: CodexAutomationAuthorityRequest) => + readCodexAutomationAuthority({ ...request, automationsDirectory }); + const restarted = new HraService({ + store: value.store, + paths: value.paths, + codex: new FakeCodex(), + personalCodex: restartedPersonalCodex, + personalCodexHome, + personalDiscovery: discovery, + readPersonalCodexAutomations: restartedReader, + cloud: new FakeCloud(), + daemonAuthority: new FakeDaemonAuthority(), + daemonGeneration: 42, + eventCursors: value.eventCursors, + now: () => personalAdoptionNow, + requestStop: () => undefined, + }); + + await expect(restarted.execute({ + kind: "session.adoption.discover", + provider: "codex", + }, { signal })).resolves.toMatchObject({ + providers: [{ discovered: 1, adopted: 1, failed: 0 }], + }); + expect(discoveryRequests[1]?.codexScheduledThreadIds).toContain(laterProviderThreadId); + expect(discoveryRequests[1]?.codexScheduledThreadIds).toHaveLength(50); + expect(value.store.findSessionByProviderThread( + added.account.id, + laterProviderThreadId, + )).not.toBeNull(); + }); + + test("falls back to ordinary schedule traversal after a restart seek fails", async () => { + const providerThreadId = "scheduled-after-restart-seek-failure"; + const restartPages: number[] = []; + const value = await preparedPersonalCodexCandidate({ + daemonGeneration: 42, + label: "Restart seek fallback", + providerThreadId, + updatedAt: personalAdoptionNow - 24 * 60 * 60_000, + liveness: "not_live", + scheduledTaskTarget: true, + readPersonalCodexAutomations: (request) => { + if (request.kind === "sources") { + return Promise.resolve(personalCodexAutomationScan( + [{ targetThreadId: providerThreadId }], + request, + )); + } + restartPages.push(request.restartPage ?? 0); + if (restartPages.length === 1) { + return Promise.reject(new Error("restart seek deadline exhausted")); + } + return Promise.resolve(personalCodexAutomationScan( + [{ targetThreadId: providerThreadId }], + request, + )); + }, + }); + + await expect(value.enable()).resolves.toMatchObject({ + discovery: { adopted: 0, failed: 1 }, + }); + await expect(value.service.execute({ + kind: "session.adoption.discover", + provider: "codex", + }, { signal })).resolves.toMatchObject({ + providers: [{ discovered: 1, adopted: 1, failed: 0 }], + }); + expect(restartPages).toEqual([41, 0]); + expect(value.store.findSessionByProviderThread( + value.accountId, + providerThreadId, + )).not.toBeNull(); + }); + + test("immediately consumes a rebuilt scheduled-target cursor after reader expiry", async () => { + const providerThreadId = "scheduled-after-expired-reader"; + const prefix = Array.from( + { length: 50 }, + (_, index) => ({ targetThreadId: `scheduled-expired-prefix-${String(index)}` }), + ); + let now = personalAdoptionNow; + let projectRoot = privatePathRoot; + let pageReads = 0; + const pageDeadlines: number[] = []; + const pageCursors: Array = []; + const discoveryRequests: Array< + Parameters[0] + > = []; + const discovery: PersonalSessionDiscoveryPort = { + discover: (input) => { + discoveryRequests.push(input); + if (!input.codexScheduledThreadIds?.includes(providerThreadId)) { + return Promise.resolve([]); + } + return Promise.resolve([{ + provider: "codex" as const, + providerThreadId, + title: "Scheduled after expired reader", + projectRoot, + updatedAt: personalAdoptionNow - 24 * 60 * 60_000, + liveness: "not_live" as const, + scheduledTaskTarget: true as const, + }]); + }, + }; + const personalCodex = new FakeCodex(); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => now, + undefined, + {}, + { + personalCodex, + personalCodexHome, + personalDiscovery: discovery, + readPersonalCodexAutomations: (request) => { + if (request.kind === "sources") { + return Promise.resolve(personalCodexAutomationScan( + [{ targetThreadId: providerThreadId }], + request, + )); + } + pageReads += 1; + pageCursors.push(request.after ?? null); + if (request.deadlineAt === undefined) throw new Error("Expected a page deadline."); + pageDeadlines.push(request.deadlineAt); + if (pageReads === 1) { + const scan = personalCodexAutomationScan(prefix, request); + return Promise.resolve({ ...scan, complete: false, nextCursor: "expired-cursor" }); + } + if (pageReads === 2) { + now += 60_000; + return Promise.resolve({ + complete: false, + diagnostics: [], + entries: [], + nextCursor: "rebuilt-live-cursor", + }); + } + if (pageReads === 3) { + return Promise.resolve({ + complete: false, + diagnostics: [], + entries: [], + nextCursor: null, + }); + } + return Promise.resolve(personalCodexAutomationScan( + [{ targetThreadId: providerThreadId }], + { ...request, after: null }, + )); + }, + }, + ); + projectRoot = value.documents; + const added = await value.service.execute( + { kind: "account.add", label: "Expired scheduled reader" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + await value.service.execute({ + kind: "project.add", + label: "Expired scheduled reader project", + path: projectRoot, + }, { signal }); + personalCodex.readProjection = { + providerThreadId, + title: "Scheduled after expired reader", + projectRoot, + status: "idle", + providerUpdatedAt: personalAdoptionNow - 24 * 60 * 60_000, + }; + + await expect(value.service.execute({ + kind: "session.adoption.set", + provider: "codex", + enabled: true, + account: added.account.id, + }, { signal })).resolves.toMatchObject({ + discovery: { discovered: 0, adopted: 0 }, + }); + await expect(value.service.execute({ + kind: "session.adoption.discover", + provider: "codex", + }, { signal })).resolves.toMatchObject({ + providers: [{ discovered: 0, adopted: 0, failed: 0 }], + }); + await expect(value.service.execute({ + kind: "session.adoption.discover", + provider: "codex", + }, { signal })).resolves.toMatchObject({ + providers: [{ discovered: 1, adopted: 1, failed: 0 }], + }); + expect(pageReads).toBe(4); + expect(pageDeadlines[2]).toBe(pageDeadlines[1]); + expect(pageCursors).toEqual([ + null, + "expired-cursor", + "rebuilt-live-cursor", + "rebuilt-live-cursor", + ]); + expect(discoveryRequests).toHaveLength(3); + expect(discoveryRequests[2]?.codexScheduledThreadIds).toEqual([providerThreadId]); + }); + + test("bounds failing claims on nonfinal schedule pages so authority rotation still advances", async () => { + let now = personalAdoptionNow; + const providerThreadIds = Array.from( + { length: 110 }, + (_, index) => `scheduled-failing-rotation-${String(index).padStart(3, "0")}`, + ); + let projectRoot = privatePathRoot; + const discoveryRequests: Array< + Parameters[0] + > = []; + const discovery: PersonalSessionDiscoveryPort = { + discover: (input) => { + discoveryRequests.push(input); + return Promise.resolve((input.codexScheduledThreadIds ?? []).map((providerThreadId) => ({ + provider: "codex" as const, + providerThreadId, + title: `Failing scheduled ${providerThreadId}`, + projectRoot, + updatedAt: personalAdoptionNow - 24 * 60 * 60_000, + liveness: "not_live" as const, + scheduledTaskTarget: true as const, + }))); + }, + }; + const personalCodex = new FakeCodex(); + personalCodex.claimErrorForProviderThreadId = () => { + // Model a slow, but individually deadline-bounded provider claim. Fifty + // serial attempts would expire Desktop's five-minute continuation; the + // poll budget must leave it usable for the next page. + now += 2 * 60_000; + return new Error("bounded scheduled claim failure"); + }; + let cursorState: Readonly<{ expiresAt: number; position: number; token: string }> | null = null; + let cursorSequence = 0; + const readPersonalCodexAutomations = ( + request: CodexAutomationAuthorityRequest, + ): Promise => { + if (request.kind === "sources") { + return Promise.resolve(personalCodexAutomationScan( + providerThreadIds.map((targetThreadId) => ({ targetThreadId })), + request, + )); + } + let position = 0; + if (request.after !== undefined && request.after !== null) { + if ( + cursorState === null + || cursorState.token !== request.after + || cursorState.expiresAt <= now + ) { + cursorSequence += 1; + cursorState = { + expiresAt: now + 5 * 60_000, + position: 0, + token: `authority-scan-${String(cursorSequence)}`, + }; + return Promise.resolve({ + complete: false, + diagnostics: [], + entries: [], + nextCursor: cursorState.token, + }); + } + position = cursorState.position; + } + const limit = request.limit ?? 200; + const targets = providerThreadIds.map((targetThreadId) => ({ targetThreadId })); + const page = personalCodexAutomationScan(targets, { + kind: "page", + after: position === 0 + ? null + : `automation-source-${String(position).padStart(4, "0")}`, + limit, + }); + if (page.nextCursor === null) { + cursorState = null; + return Promise.resolve(page); + } + cursorSequence += 1; + cursorState = { + expiresAt: now + 5 * 60_000, + position: position + page.entries.length, + token: `authority-scan-${String(cursorSequence)}`, + }; + return Promise.resolve({ ...page, nextCursor: cursorState.token }); + }; + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => now, + undefined, + {}, + { + personalCodex, + personalCodexHome, + personalDiscovery: discovery, + readPersonalCodexAutomations, + }, + ); + projectRoot = value.documents; + const added = await value.service.execute( + { kind: "account.add", label: "Failing scheduled rotation" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + await value.service.execute({ + kind: "project.add", + label: "Failing scheduled rotation project", + path: projectRoot, + }, { signal }); + + await expect(value.service.execute({ + kind: "session.adoption.set", + provider: "codex", + enabled: true, + account: added.account.id, + }, { signal })).resolves.toMatchObject({ + discovery: { discovered: 50, adopted: 0, failed: 2, pending: 48 }, + }); + expect(personalCodex.claimRequests).toHaveLength(2); + expect(discoveryRequests[0]?.codexScheduledThreadIds).toEqual( + providerThreadIds.slice(0, 50), + ); + + await expect(value.service.execute({ + kind: "session.adoption.discover", + provider: "codex", + }, { signal })).resolves.toMatchObject({ + providers: [{ discovered: 50, adopted: 0, failed: 2, pending: 48 }], + }); + expect(personalCodex.claimRequests).toHaveLength(4); + expect(discoveryRequests[1]?.codexScheduledThreadIds).toEqual( + providerThreadIds.slice(50, 100), + ); + + await expect(value.service.execute({ + kind: "session.adoption.discover", + provider: "codex", + }, { signal })).resolves.toMatchObject({ + providers: [{ discovered: 10, adopted: 0, failed: 2, pending: 8 }], + }); + expect(personalCodex.claimRequests).toHaveLength(6); + expect(discoveryRequests[2]?.codexScheduledThreadIds).toEqual( + providerThreadIds.slice(100), + ); + }); + + test("bounds rejected scheduled preflights and durably rotates later candidates", async () => { + const providerThreadIds = Array.from( + { length: 5 }, + (_, index) => `scheduled-rejected-preflight-${String(index).padStart(2, "0")}`, + ); + let projectRoot = privatePathRoot; + const sourceRechecks: string[][] = []; + const discovery: PersonalSessionDiscoveryPort = { + discover: (input) => Promise.resolve((input.codexScheduledThreadIds ?? []).map( + (providerThreadId) => ({ + provider: "codex" as const, + providerThreadId, + title: `Scheduled ${providerThreadId}`, + projectRoot, + updatedAt: personalAdoptionNow - 24 * 60 * 60_000, + liveness: "not_live" as const, + scheduledTaskTarget: true as const, + }), + )), + }; + const personalCodex = new FakeCodex(); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { + personalCodex, + personalCodexHome, + personalDiscovery: discovery, + readPersonalCodexAutomations: (request) => { + if (request.kind === "sources") { + sourceRechecks.push([...request.sourceDirectoryNames]); + // The source vanished after the complete page supplied its exact + // target. This is a preflight rejection with no provider effect. + return Promise.resolve({ + complete: true, + diagnostics: [], + entries: [], + nextCursor: null, + }); + } + return Promise.resolve(personalCodexAutomationScan( + providerThreadIds.map((targetThreadId) => ({ targetThreadId })), + request, + )); + }, + }, + ); + projectRoot = value.documents; + const added = await value.service.execute( + { kind: "account.add", label: "Rejected scheduled preflight fairness" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + await value.service.execute({ + kind: "project.add", + label: "Rejected scheduled preflight project", + path: projectRoot, + }, { signal }); + + await expect(value.service.execute({ + kind: "session.adoption.set", + provider: "codex", + enabled: true, + account: added.account.id, + }, { signal })).resolves.toMatchObject({ + discovery: { discovered: 5, adopted: 0, failed: 2, pending: 3 }, + }); + expect(sourceRechecks).toEqual([ + ["automation-source-0001"], + ["automation-source-0002"], + ]); + expect(personalCodex.claimRequests).toHaveLength(0); + const firstPoll = new Map(value.store.listSessionAdoptionCandidates({ + provider: "codex", + limit: 10, + }).map((candidate) => [candidate.providerThreadId, candidate])); + expect(providerThreadIds.slice(0, 2).every((id) => firstPoll.get(id)?.lastAttemptAt !== null)) + .toBe(true); + expect(providerThreadIds.slice(2).every((id) => firstPoll.get(id)?.lastAttemptAt === null)) + .toBe(true); + + await expect(value.service.execute({ + kind: "session.adoption.discover", + provider: "codex", + }, { signal })).resolves.toMatchObject({ + providers: [{ discovered: 5, adopted: 0, failed: 2, pending: 3 }], + }); + expect(sourceRechecks).toEqual([ + ["automation-source-0001"], + ["automation-source-0002"], + ["automation-source-0003"], + ["automation-source-0004"], + ]); + expect(personalCodex.claimRequests).toHaveLength(0); + }); + + test("bounds failed project canonicalization and reaches a later valid row next poll", async () => { + const personalCodex = new FakeCodex(); + const discovery = new FakePersonalSessionDiscovery(); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { + personalCodex, + personalCodexHome, + personalDiscovery: discovery, + }, + ); + const validProviderThreadId = "valid-after-unusable-project-prefix"; + discovery.candidates = [ + { + provider: "codex", + providerThreadId: "unusable-project-prefix-one", + title: "Unusable project one", + projectRoot: join(value.documents, "missing-project-one"), + updatedAt: personalAdoptionNow - 13 * 60_000, + liveness: "not_live", + }, + { + provider: "codex", + providerThreadId: "unusable-project-prefix-two", + title: "Unusable project two", + projectRoot: join(value.documents, "missing-project-two"), + updatedAt: personalAdoptionNow - 12 * 60_000, + liveness: "not_live", + }, + { + provider: "codex", + providerThreadId: validProviderThreadId, + title: "Valid project after rejected prefix", + projectRoot: value.documents, + updatedAt: personalAdoptionNow - 11 * 60_000, + liveness: "not_live", + }, + ]; + personalCodex.readProjection = { + providerThreadId: validProviderThreadId, + title: "Valid project after rejected prefix", + projectRoot: value.documents, + status: "idle", + providerUpdatedAt: personalAdoptionNow - 11 * 60_000, + }; + const added = await value.service.execute( + { kind: "account.add", label: "Project preflight fairness" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + await value.service.execute({ + kind: "project.add", + label: "Project preflight fairness root", + path: value.documents, + }, { signal }); + + await expect(value.service.execute({ + kind: "session.adoption.set", + provider: "codex", + enabled: true, + account: added.account.id, + }, { signal })).resolves.toMatchObject({ + discovery: { discovered: 3, adopted: 0, failed: 2, pending: 1 }, + }); + expect(personalCodex.claimRequests).toHaveLength(0); + const firstPoll = new Map(value.store.listSessionAdoptionCandidates({ + provider: "codex", + limit: 10, + }).map((candidate) => [candidate.providerThreadId, candidate])); + expect(firstPoll.get("unusable-project-prefix-one")?.lastAttemptAt).not.toBeNull(); + expect(firstPoll.get("unusable-project-prefix-two")?.lastAttemptAt).not.toBeNull(); + expect(firstPoll.get(validProviderThreadId)?.lastAttemptAt).toBeNull(); + + await expect(value.service.execute({ + kind: "session.adoption.discover", + provider: "codex", + }, { signal })).resolves.toMatchObject({ + providers: [{ discovered: 3, adopted: 1 }], + }); + expect(personalCodex.claimRequests.map((request) => request.providerThreadId)) + .toEqual([validProviderThreadId]); + expect(value.store.findSessionByProviderThread( + added.account.id, + validProviderThreadId, + )).not.toBeNull(); + }); + + test("gives both recent and scheduled Codex claims bounded progress across polls", async () => { + const scheduledProviderThreadIds = Array.from( + { length: 60 }, + (_, index) => `scheduled-before-recent-${String(index).padStart(2, "0")}`, + ); + const recentProviderThreadIds = [ + "expiring-recent-beside-failing-schedules-one", + "expiring-recent-beside-failing-schedules-two", + ] as const; + const [firstRecentProviderThreadId, secondRecentProviderThreadId] = recentProviderThreadIds; + const firstScheduledProviderThreadId = scheduledProviderThreadIds[0]; + const secondPageScheduledProviderThreadId = scheduledProviderThreadIds[50]; + if ( + firstScheduledProviderThreadId === undefined + || secondPageScheduledProviderThreadId === undefined + ) throw new Error("Expected scheduled candidates on both authority pages."); + let projectRoot = privatePathRoot; + let discoveryCall = 0; + const discovery: PersonalSessionDiscoveryPort = { + discover: (input) => { + const recentProviderThreadId = recentProviderThreadIds[ + Math.min(discoveryCall, recentProviderThreadIds.length - 1) + ] ?? secondRecentProviderThreadId; + discoveryCall += 1; + return Promise.resolve([ + ...(input.codexScheduledThreadIds ?? []).map((providerThreadId) => ({ + provider: "codex" as const, + providerThreadId, + title: `Failing scheduled ${providerThreadId}`, + projectRoot, + updatedAt: personalAdoptionNow - 24 * 60 * 60_000, + liveness: "not_live" as const, + scheduledTaskTarget: true as const, + })), + { + provider: "codex" as const, + providerThreadId: recentProviderThreadId, + title: "Expiring recent candidate", + projectRoot, + updatedAt: personalAdoptionNow - 14 * 60_000, + liveness: "not_live" as const, + }, + ]); + }, + }; + const personalCodex = new FakeCodex(); + personalCodex.claimErrorForProviderThreadId = (providerThreadId) => + scheduledProviderThreadIds.includes(providerThreadId) + ? new Error("slow scheduled prefix failure") + : undefined; + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { + personalCodex, + personalCodexHome, + personalDiscovery: discovery, + readPersonalCodexAutomations: (request) => Promise.resolve(personalCodexAutomationScan( + scheduledProviderThreadIds.map((targetThreadId) => ({ targetThreadId })), + request, + )), + }, + ); + projectRoot = value.documents; + personalCodex.readProjection = { + providerThreadId: firstRecentProviderThreadId, + title: "Expiring recent candidate", + projectRoot, + status: "idle", + providerUpdatedAt: personalAdoptionNow - 14 * 60_000, + }; + const added = await value.service.execute( + { kind: "account.add", label: "Recent before scheduled" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + await value.service.execute({ + kind: "project.add", + label: "Recent before scheduled project", + path: projectRoot, + }, { signal }); + + await expect(value.service.execute({ + kind: "session.adoption.set", + provider: "codex", + enabled: true, + account: added.account.id, + }, { signal })).resolves.toMatchObject({ + discovery: { adopted: 1, failed: 1, pending: 49 }, + }); + expect(personalCodex.claimRequests.map((request) => request.providerThreadId)).toEqual([ + firstRecentProviderThreadId, + firstScheduledProviderThreadId, + ]); + expect(value.store.findSessionByProviderThread( + added.account.id, + firstRecentProviderThreadId, + )).not.toBeNull(); + + await expect(value.service.execute({ + kind: "session.adoption.discover", + provider: "codex", + }, { signal })).resolves.toMatchObject({ + providers: [{ adopted: 1, failed: 1, pending: 9 }], + }); + expect(personalCodex.claimRequests.map((request) => request.providerThreadId)).toEqual([ + firstRecentProviderThreadId, + firstScheduledProviderThreadId, + secondPageScheduledProviderThreadId, + secondRecentProviderThreadId, + ]); + expect(value.store.findSessionByProviderThread( + added.account.id, + secondRecentProviderThreadId, + )).not.toBeNull(); + }); + + test("persists 200 ordinary observations beside a full scheduled-target batch", async () => { + const scheduledProviderThreadIds = Array.from( + { length: 50 }, + (_, index) => `scheduled-coexistence-${String(index).padStart(2, "0")}`, + ); + const ordinaryProviderThreadIds = Array.from( + { length: PERSONAL_SESSION_DISCOVERY_MAX_RESULTS }, + (_, index) => `ordinary-with-scheduled-batch-${String(index).padStart(3, "0")}`, + ); + const recentProviderThreadId = ordinaryProviderThreadIds.at(-1); + if (recentProviderThreadId === undefined) throw new Error("Expected an ordinary candidate."); + let projectRoot = privatePathRoot; + const exactReads: string[] = []; + const discovery = new BoundedPersonalSessionDiscovery({ + now: () => personalAdoptionNow, + codexReadSession: ({ providerThreadId }) => { + exactReads.push(providerThreadId); + return Promise.resolve({ + providerThreadId, + title: `Live scheduled ${providerThreadId}`, + projectRoot, + status: "active", + activeTurnId: `turn-${providerThreadId}`, + providerUpdatedAt: personalAdoptionNow - 24 * 60 * 60_000, + }); + }, + codexListPage: (request) => { + const offset = request.cursor === undefined ? 0 : Number(request.cursor); + const ids = ordinaryProviderThreadIds.slice(offset, offset + request.limit); + const nextOffset = offset + ids.length; + return Promise.resolve({ + sessions: ids.map((providerThreadId) => providerThreadId === recentProviderThreadId + ? { + providerThreadId, + title: "Ordinary recent candidate", + projectRoot, + status: "idle", + providerUpdatedAt: personalAdoptionNow - 11 * 60_000, + } + : { + providerThreadId, + title: `Ordinary live ${providerThreadId}`, + projectRoot, + status: "active", + activeTurnId: `turn-${providerThreadId}`, + providerUpdatedAt: personalAdoptionNow - 1_000, + }), + nextCursor: nextOffset < ordinaryProviderThreadIds.length + ? String(nextOffset) + : null, + }); + }, + }); + const personalCodex = new FakeCodex(); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { + personalCodex, + personalCodexHome, + personalDiscovery: discovery, + readPersonalCodexAutomations: (request) => Promise.resolve(personalCodexAutomationScan( + scheduledProviderThreadIds.map((targetThreadId) => ({ targetThreadId })), + request, + )), + }, + ); + projectRoot = value.documents; + personalCodex.readProjection = { + providerThreadId: recentProviderThreadId, + title: "Ordinary recent candidate", + projectRoot, + status: "idle", + providerUpdatedAt: personalAdoptionNow - 11 * 60_000, + }; + const added = await value.service.execute( + { kind: "account.add", label: "Scheduled and recent coexistence" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + await value.service.execute({ + kind: "project.add", + label: "Scheduled and recent coexistence project", + path: projectRoot, + }, { signal }); + + await expect(value.service.execute({ + kind: "session.adoption.set", + provider: "codex", + enabled: true, + account: added.account.id, + }, { signal })).resolves.toMatchObject({ + discovery: { + discovered: PERSONAL_CODEX_DISCOVERY_MAX_RESULTS, + adopted: 1, + pending: PERSONAL_CODEX_DISCOVERY_MAX_RESULTS - 1, + }, + }); + expect(exactReads).toHaveLength(50); + const persistedIds = new Set(value.store.listSessionAdoptionCandidates({ + provider: "codex", + limit: 500, + }).map((candidate) => candidate.providerThreadId)); + expect(persistedIds.size).toBe(PERSONAL_CODEX_DISCOVERY_MAX_RESULTS); + expect(ordinaryProviderThreadIds.every((providerThreadId) => + persistedIds.has(providerThreadId))).toBe(true); + expect(value.store.findSessionByProviderThread( + added.account.id, + recentProviderThreadId, + )).not.toBeNull(); + expect(personalCodex.claimRequests).toHaveLength(1); + }); + + test("persists a full bounded Codex observation before a later quiet claim", async () => { + const quietProviderThreadId = "quiet-after-live-observation-prefix"; + let projectRoot = privatePathRoot; + const discovery = new FakePersonalSessionDiscovery(); + discovery.candidates = [ + ...Array.from({ length: 59 }, (_, index) => ({ + provider: "codex" as const, + providerThreadId: `live-observation-prefix-${String(index).padStart(2, "0")}`, + title: `Live observation ${index}`, + projectRoot, + updatedAt: personalAdoptionNow - 1_000 - index, + liveness: "live" as const, + })), + { + provider: "codex", + providerThreadId: quietProviderThreadId, + title: "Quiet after live prefix", + projectRoot, + updatedAt: personalAdoptionNow - 11 * 60_000, + liveness: "not_live", + }, + ]; + const personalCodex = new FakeCodex(); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { + personalCodex, + personalCodexHome, + personalDiscovery: discovery, + }, + ); + projectRoot = value.documents; + discovery.candidates = discovery.candidates.map((candidate) => ({ + ...candidate, + projectRoot, + })); + personalCodex.readProjection = { + providerThreadId: quietProviderThreadId, + title: "Quiet after live prefix", + projectRoot, + status: "idle", + providerUpdatedAt: personalAdoptionNow - 11 * 60_000, + }; + const added = await value.service.execute( + { kind: "account.add", label: "Bounded Codex observation" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + await value.service.execute({ + kind: "project.add", + label: "Bounded Codex observation project", + path: projectRoot, + }, { signal }); + + await expect(value.service.execute({ + kind: "session.adoption.set", + provider: "codex", + enabled: true, + account: added.account.id, + }, { signal })).resolves.toMatchObject({ + discovery: { discovered: 60, adopted: 1, pending: 59, failed: 0 }, + }); + expect(discovery.requests[0]?.limit).toBe(PERSONAL_SESSION_DISCOVERY_MAX_RESULTS); + expect(value.store.listSessionAdoptionCandidates({ provider: "codex", limit: 100 })) + .toHaveLength(60); + expect(personalCodex.claimRequests.map((request) => request.providerThreadId)) + .toEqual([quietProviderThreadId]); + }); + + test("retains stale active Codex work without claiming until a recent live-to-quiet edge", async () => { + const providerThreadId = "stale-active-to-quiet-codex"; + const value = await preparedPersonalCodexCandidate({ + label: "Stale active Codex retention", + providerThreadId, + updatedAt: personalAdoptionNow - 24 * 60 * 60_000, + liveness: "live", + }); + const active = value.discovery.candidates[0]; + if (active === undefined) throw new Error("Expected the active candidate."); + value.discovery.candidates = [{ + ...active, + admissionEligible: false, + trustedLiveObservation: true, + }]; + + await expect(value.enable()).resolves.toMatchObject({ + discovery: { discovered: 1, adopted: 0, pending: 1, failed: 0 }, + }); + expect(value.personalCodex.claimRequests).toHaveLength(0); + expect(value.store.readSessionAdoptionCandidate("codex", providerThreadId)) + .toMatchObject({ + lastLiveObservedAt: personalAdoptionNow, + liveness: "live", + status: "pending", + }); + + value.discovery.candidates = [{ + ...active, + liveness: "not_live", + admissionEligible: false, + }]; + await expect(value.service.execute({ + kind: "session.adoption.discover", + provider: "codex", + }, { signal })).resolves.toMatchObject({ + providers: [{ discovered: 1, adopted: 1, pending: 0, failed: 0 }], + }); + expect(value.personalCodex.claimRequests.map((request) => request.providerThreadId)) + .toEqual([providerThreadId]); + }); + + test("never admits stale quiet Codex history without a trusted live observation", async () => { + const providerThreadId = "stale-quiet-without-live-codex"; + const value = await preparedPersonalCodexCandidate({ + label: "Stale quiet Codex history", + providerThreadId, + updatedAt: personalAdoptionNow - 24 * 60 * 60_000, + liveness: "not_live", + }); + const quiet = value.discovery.candidates[0]; + if (quiet === undefined) throw new Error("Expected the quiet candidate."); + value.discovery.candidates = [{ ...quiet, admissionEligible: false }]; + + await expect(value.enable()).resolves.toMatchObject({ + discovery: { discovered: 0, adopted: 0, pending: 0, failed: 0 }, + }); + expect(value.personalCodex.claimRequests).toHaveLength(0); + expect(value.store.readSessionAdoptionCandidate("codex", providerThreadId)).toBeNull(); + }); + + test("expires retained Codex live authority before a stale quiet observation", async () => { + let now = personalAdoptionNow; + const providerThreadId = "expired-active-to-quiet-codex"; + const value = await preparedPersonalCodexCandidate({ + label: "Expired active Codex retention", + providerThreadId, + updatedAt: personalAdoptionNow - 24 * 60 * 60_000, + liveness: "live", + now: () => now, + }); + const active = value.discovery.candidates[0]; + if (active === undefined) throw new Error("Expected the active candidate."); + value.discovery.candidates = [{ + ...active, + admissionEligible: false, + trustedLiveObservation: true, + }]; + await value.enable(); + now += PERSONAL_SESSION_DISCOVERY_RECENCY_WINDOW_MS + 1; + value.discovery.candidates = [{ + ...active, + liveness: "not_live", + admissionEligible: false, + }]; + + await expect(value.service.execute({ + kind: "session.adoption.discover", + provider: "codex", + }, { signal })).resolves.toMatchObject({ + providers: [{ discovered: 0, adopted: 0, pending: 0, failed: 0 }], + }); + expect(value.personalCodex.claimRequests).toHaveLength(0); + expect(value.store.readSessionAdoptionCandidate("codex", providerThreadId)) + .toMatchObject({ liveness: "live", status: "pending" }); + }); + + test("caps Claude controller claims at two after persisting every observation", async () => { + let projectRoot = privatePathRoot; + const discovery = new FakePersonalSessionDiscovery(); + const managedClaude = new FakeClaude("isolated", { + pid: 61_000, + pidDomain: "darwin", + procStart: "managed-claim-cap", + }); + const personalClaude = new FakeClaude("personal", { + pid: 61_001, + pidDomain: "darwin", + procStart: "personal-claim-cap", + }); + personalClaude.claimSessionError = new Error("bounded Claude claim failure"); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { + managedClaude, + personalClaude, + personalCodexHome, + personalDiscovery: discovery, + }, + ); + projectRoot = value.documents; + discovery.candidates = Array.from({ length: 5 }, (_, index) => ({ + provider: "claude" as const, + providerThreadId: `bounded-claude-claim-${index}`, + title: `Bounded Claude claim ${index}`, + projectRoot, + updatedAt: personalAdoptionNow - 11 * 60_000 - index, + liveness: "not_live" as const, + sourceProcessIdentity: { + pid: 61_100 + index, + pidDomain: "darwin" as const, + procStart: `source-claim-cap-${index}`, + }, + })); + const added = await value.service.execute( + { kind: "account.add", label: "Bounded Claude claims" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + await value.service.execute({ + kind: "project.add", + label: "Bounded Claude claims project", + path: projectRoot, + }, { signal }); + + await expect(value.service.execute({ + kind: "session.adoption.set", + provider: "claude", + enabled: true, + account: added.account.id, + }, { signal })).resolves.toMatchObject({ + discovery: { discovered: 5, adopted: 0, pending: 3, failed: 2 }, + }); + expect(personalClaude.claimRequests).toHaveLength(2); + expect(value.store.listSessionAdoptionCandidates({ provider: "claude", limit: 100 })) + .toHaveLength(5); + }); + + test.each([ + ["removed", [], true], + ["retargeted", [{ targetThreadId: "replacement-thread" }], true], + ["invalid", [{ + kind: "cron", + targetThreadId: "scheduled-racy-heartbeat-thread", + }], true], + ["incomplete", [{ + targetThreadId: "scheduled-racy-heartbeat-thread", + }], false], + ] as const)( + "releases a claimed stale Codex target when its heartbeat becomes %s before commit", + async (_change, changedTargets, changedComplete) => { + const providerThreadId = "scheduled-racy-heartbeat-thread"; + let currentTargets: readonly Readonly<{ + kind?: string; + targetThreadId: string | null; + }>[] = [{ targetThreadId: providerThreadId }]; + let currentComplete = true; + let scanCalls = 0; + const value = await preparedPersonalCodexCandidate({ + label: "Scheduled heartbeat claim race", + providerThreadId, + updatedAt: personalAdoptionNow - 24 * 60 * 60_000, + liveness: "not_live", + scheduledTaskTarget: true, + readPersonalCodexAutomations: (request) => { + scanCalls += 1; + return Promise.resolve(personalCodexAutomationScan( + currentTargets, + request, + currentComplete, + )); + }, + }); + value.personalCodex.beforeClaimSessionReturnOnce = () => { + currentTargets = changedTargets; + currentComplete = changedComplete; + return Promise.resolve(); + }; + + await expect(value.enable()).resolves.toMatchObject({ + discovery: { provider: "codex", adopted: 0, failed: 1 }, + }); + expect(scanCalls).toBe(3); + expect(value.personalCodex.claimRequests).toHaveLength(1); + expect(value.personalCodex.calls.filter((call) => call === "end")).toHaveLength(1); + expect(value.store.findSessionByProviderThread( + value.accountId, + providerThreadId, + )).toBeNull(); + expect(value.store.listSessionAdoptionCandidates({ provider: "codex" }) + .some((candidate) => + candidate.providerThreadId === providerThreadId + && candidate.status === "pending")) + .toBe(true); + }, + ); + + test("keeps active, timestamp-less, and terminal scheduled Codex targets ineligible", async () => { + const providerThreadIds = [ + "scheduled-active-thread", + "scheduled-missing-time-thread", + "scheduled-terminal-thread", + ] as const; + let projectRoot = privatePathRoot; + const discovery = new BoundedPersonalSessionDiscovery({ + now: () => personalAdoptionNow, + codexReadSession: ({ providerThreadId }) => { + if (providerThreadId === providerThreadIds[0]) { + return Promise.resolve({ + providerThreadId, + title: "Scheduled active thread", + projectRoot, + status: "active", + activeTurnId: "turn-active-elsewhere", + providerUpdatedAt: personalAdoptionNow - 24 * 60 * 60_000, + }); + } + if (providerThreadId === providerThreadIds[1]) { + return Promise.resolve({ + providerThreadId, + title: "Scheduled timestamp-less thread", + projectRoot, + status: "idle", + }); + } + return Promise.resolve({ + providerThreadId, + title: "Scheduled terminal thread", + projectRoot, + status: "terminal", + providerUpdatedAt: personalAdoptionNow - 24 * 60 * 60_000, + }); + }, + }); + const personalCodex = new FakeCodex(); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { + personalCodex, + personalCodexHome, + personalDiscovery: discovery, + readPersonalCodexAutomations: (request) => Promise.resolve(personalCodexAutomationScan( + providerThreadIds.map((targetThreadId) => ({ targetThreadId })), + request, + )), + }, + ); + projectRoot = value.documents; + const added = await value.service.execute( + { kind: "account.add", label: "Scheduled ineligible targets" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + await value.service.execute({ + kind: "project.add", + label: "Scheduled ineligible targets project", + path: value.documents, + }, { signal }); + + await expect(value.service.execute({ + kind: "session.adoption.set", + provider: "codex", + enabled: true, + account: added.account.id, + }, { signal })).resolves.toMatchObject({ + discovery: { provider: "codex", discovered: 1, adopted: 0, pending: 1 }, + }); + expect(personalCodex.claimRequests).toHaveLength(0); + expect(value.store.listSessionAdoptionCandidates({ provider: "codex" })) + .toEqual([expect.objectContaining({ + providerThreadId: providerThreadIds[0], + liveness: "live", + status: "pending", + })]); + }); + + test("continues recent Codex discovery when the automation scan fails", async () => { + let scanCalls = 0; + const value = await preparedPersonalCodexCandidate({ + label: "Recent discovery after automation failure", + providerThreadId: "recent-after-automation-failure", + updatedAt: personalAdoptionNow - 11 * 60_000, + liveness: "not_live", + readPersonalCodexAutomations: () => { + scanCalls += 1; + return Promise.reject(new Error("automation directory unavailable")); + }, + }); + + await expect(value.enable()).resolves.toMatchObject({ + discovery: { provider: "codex", adopted: 1, failed: 0 }, + }); + expect(scanCalls).toBe(1); + expect(value.discovery.requests[0]?.codexScheduledThreadIds).toEqual([]); + expect(value.personalCodex.claimRequests).toHaveLength(1); + }); + + test("adopts a recent registered-project Codex thread as an ordinary session and routes its turns to personal custody", async () => { + const value = await adoptedCodexFixture( + "Adopted routing", + "personal-thread-routing", + ); + + expect(value.enabled).toMatchObject({ + providers: [{ provider: "codex", enabled: true, adopted: 1 }], + discovery: { provider: "codex", state: "ready", discovered: 1, adopted: 1 }, + }); + expect(value.personalCodex.claimRequests).toHaveLength(1); + expect(value.personalCodex.claimRequests[0]).toMatchObject({ + authority: { + id: value.accountId, + codexHome: personalCodexHome, + }, + providerThreadId: "personal-thread-routing", + projectRoot: value.documents, + preset: "ultra", + requirement: presetRequirements.ultra, + fast: false, + }); + expect(value.store.requireSessionPresetRequirement(value.session.id)).toEqual({ + preset: "ultra", + requirement: presetRequirements.ultra, + }); + expect(value.store.latestSessionRuntimeProfile(value.session.id)).toMatchObject({ + sourceKind: "session_start", + profile: { + preset: "ultra", + approvalPolicy: "on-request", + reviewMode: "auto_review", + permissionProfile: ":workspace", + }, + }); + expect(value.store.isConversationAutomationEnabled( + value.session.id, + "personal-thread-routing", + )).toBe(true); + expect(value.store.readSessionPersonalRuntimeBinding(value.session.id)).toMatchObject({ + provider: "codex", + providerThreadId: "personal-thread-routing", + state: "active", + }); + + const listed = await value.service.execute({ + kind: "session.list", + archived: false, + limit: 100, + }, { signal }) as { sessions: readonly Record[] }; + const publicSession = listed.sessions.find((session) => session.id === value.session.id); + expect(publicSession).toMatchObject({ + id: value.session.id, + profileId: value.accountId, + provider: "codex", + providerThreadId: "personal-thread-routing", + state: "idle", + }); + expect(publicSession).not.toHaveProperty("origin"); + expect(publicSession).not.toHaveProperty("adopted"); + expect(publicSession).not.toHaveProperty("observationTier"); + + const accountListed = await value.service.execute({ + kind: "session.list", + archived: false, + account: value.accountId, + limit: 1, + }, { signal }) as { + sessions: readonly { id: string; providerThreadId?: string }[]; + nextCursor: string | null; + }; + expect(accountListed.sessions).toEqual([expect.objectContaining({ + id: value.session.id, + providerThreadId: "personal-thread-routing", + })]); + expect(accountListed.nextCursor).not.toBeNull(); + + // A managed projection may expose the same opaque provider thread id. The + // source/session identity must keep this row from being emitted twice. + value.codex.listedProjections = [{ + providerThreadId: "personal-thread-routing", + title: "Managed collision", + status: "active", + activeTurnId: "managed-collision-turn", + providerUpdatedAt: personalAdoptionNow + 1_000, + }]; + const collisionListed = await value.service.execute({ + kind: "session.list", + archived: false, + account: value.accountId, + limit: 2, + }, { signal }) as { sessions: readonly { id: string }[]; nextCursor: string | null }; + expect(collisionListed.sessions.map((session) => session.id)).toEqual([value.session.id]); + expect(collisionListed.nextCursor).not.toBeNull(); + await expect(value.service.execute({ + kind: "session.list", + archived: false, + account: value.accountId, + limit: 2, + cursor: collisionListed.nextCursor ?? undefined, + }, { signal })).resolves.toMatchObject({ sessions: [], nextCursor: null }); + expect(value.store.requireSession(value.session.id)).toMatchObject({ + title: "Adopted routing personal thread", + state: "idle", + }); + expect(value.store.readSessionPersonalRuntimeBinding(value.session.id)) + .toMatchObject({ state: "active" }); + + const foreign = await value.service.execute( + { kind: "account.add", label: "Foreign collision account" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: foreign.account.id, + deviceCode: false, + }, { signal }); + const foreignCollision = await value.service.execute({ + kind: "session.list", + archived: false, + account: foreign.account.id, + limit: 2, + }, { signal }) as { sessions: readonly { id: string }[] }; + expect(foreignCollision.sessions).toEqual([]); + expect(value.store.findSessionByProviderThread( + foreign.account.id, + "personal-thread-routing", + )).toBeNull(); + expect(value.store.requireSession(value.session.id)).toMatchObject({ + profileId: value.accountId, + title: "Adopted routing personal thread", + state: "idle", + }); + + const nativeSendsBefore = value.codex.calls.filter((call) => call === "send").length; + const personalSendsBefore = value.personalCodex.calls + .filter((call) => call === "send").length; + await expect(value.service.execute({ + kind: "session.send", + session: value.session.id, + message: "Continue under HRA", + idempotencyKey: "00000000-0000-4000-8000-00000000a001", + }, { signal })).resolves.toMatchObject({ + session: { id: value.session.id, state: "active" }, + }); + expect(value.personalCodex.calls.filter((call) => call === "send")).toHaveLength( + personalSendsBefore + 1, + ); + expect(value.codex.calls.filter((call) => call === "send")).toHaveLength( + nativeSendsBefore, + ); + }); + + test("releases a Codex claim whose fresh projection revoked the quiet-window inference", async () => { + const personalCodex = new FakeCodex(); + const discovery = new FakePersonalSessionDiscovery(); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { personalCodex, personalCodexHome, personalDiscovery: discovery }, + ); + const added = await value.service.execute( + { kind: "account.add", label: "Codex liveness race" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + await value.service.execute({ + kind: "project.add", + label: "Codex liveness race project", + path: value.documents, + }, { signal }); + const providerThreadId = "personal-thread-liveness-race"; + personalCodex.readProjection = { + providerThreadId, + title: "Freshly active elsewhere", + status: "idle", + projectRoot: value.documents, + providerUpdatedAt: personalAdoptionNow - 1_000, + }; + discovery.candidates = [{ + provider: "codex", + providerThreadId, + title: "Previously quiet", + projectRoot: value.documents, + updatedAt: personalAdoptionNow - 11 * 60_000, + liveness: "not_live", + }]; + + await expect(value.service.execute({ + kind: "session.adoption.set", + provider: "codex", + enabled: true, + account: added.account.id, + }, { signal })).resolves.toMatchObject({ + discovery: { adopted: 0, failed: 1 }, + }); + expect(personalCodex.claimRequests).toHaveLength(1); + expect(personalCodex.calls.filter((call) => call === "end")).toHaveLength(1); + expect(value.store.findSessionByProviderThread(added.account.id, providerThreadId)).toBeNull(); + expect(value.store.listSessionAdoptionCandidates({ provider: "codex" })[0]) + .toMatchObject({ status: "pending" }); + }); + + test("paginates native and adopted sessions together before unknown provider discovery", async () => { + const personalCodex = new FakeCodex(); + const managedClaude = new FakeClaude("isolated", { + pid: 63_100, + pidDomain: "darwin", + procStart: "managed-claude-pagination", + }); + const discovery = new FakePersonalSessionDiscovery(); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { managedClaude, personalCodex, personalCodexHome, personalDiscovery: discovery }, + ); + const added = await value.service.execute( + { kind: "account.add", label: "Adopted pagination" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute( + { kind: "account.login", account: added.account.id, deviceCode: false }, + { signal }, + ); + await value.service.execute( + { kind: "project.add", label: "Adopted pagination project", path: value.documents }, + { signal }, + ); + managedClaude.projection = { + providerThreadId: "managed-claude-pagination", + title: "Managed Claude pagination", + status: "idle", + projectRoot: value.documents, + providerUpdatedAt: personalAdoptionNow - 500, + }; + const nativeClaude = await value.service.execute({ + kind: "session.start", + account: added.account.id, + provider: "claude", + preset: "fable-max", + fast: false, + }, { signal }) as { session: { id: string; providerThreadId?: string } }; + expect(nativeClaude.session.providerThreadId).toMatch(/^[0-9a-f-]{36}$/u); + personalCodex.readProjection = { + providerThreadId: "adopted-pagination-template", + title: "Adopted pagination thread", + status: "idle", + projectRoot: value.documents, + providerUpdatedAt: personalAdoptionNow - 11 * 60_000, + }; + discovery.candidates = [1, 2, 3].map((index) => ({ + provider: "codex" as const, + providerThreadId: `adopted-pagination-${String(index)}`, + title: `Adopted pagination ${String(index)}`, + projectRoot: value.documents, + updatedAt: personalAdoptionNow - 11 * 60_000 - index, + liveness: "not_live" as const, + })); + await expect(value.service.execute({ + kind: "session.adoption.set", + provider: "codex", + enabled: true, + account: added.account.id, + }, { signal })).resolves.toMatchObject({ + discovery: { adopted: 2, failed: 0, pending: 1 }, + }); + await expect(value.service.execute({ + kind: "session.adoption.discover", + provider: "codex", + }, { signal })).resolves.toMatchObject({ + providers: [{ adopted: 1, failed: 0 }], + }); + const adoptedIds = new Set(value.store.listSessionPersonalRuntimeBindings() + .map((binding) => binding.sessionId)); + expect(adoptedIds.size).toBe(3); + const expectedLocalIds = new Set([...adoptedIds, nativeClaude.session.id]); + + value.codex.listedProjections = [{ + providerThreadId: "managed-pagination-1", + title: "Managed first page", + status: "idle", + }]; + value.codex.listedNextCursor = "provider-page-2"; + const first = await value.service.execute({ + kind: "session.list", + archived: false, + account: added.account.id, + limit: 2, + }, { signal }) as { + sessions: readonly { id: string; providerThreadId?: string }[]; + nextCursor: string | null; + }; + expect(first.sessions).toHaveLength(2); + expect(first.sessions.every((session) => expectedLocalIds.has(session.id))).toBe(true); + expect(first.nextCursor).not.toBeNull(); + + const second = await value.service.execute({ + kind: "session.list", + archived: false, + account: added.account.id, + limit: 2, + cursor: first.nextCursor ?? undefined, + }, { signal }) as { + sessions: readonly { id: string; providerThreadId?: string }[]; + nextCursor: string | null; + }; + expect(second.sessions).toHaveLength(2); + expect(second.nextCursor).not.toBeNull(); + + const third = await value.service.execute({ + kind: "session.list", + archived: false, + account: added.account.id, + limit: 2, + cursor: second.nextCursor ?? undefined, + }, { signal }) as { + sessions: readonly { id: string; providerThreadId?: string }[]; + nextCursor: string | null; + }; + expect(third.sessions.map((session) => session.providerThreadId)) + .toEqual(["managed-pagination-1"]); + expect(third.nextCursor).not.toBeNull(); + + const replayedThird = await value.service.execute({ + kind: "session.list", + archived: false, + account: added.account.id, + limit: 2, + cursor: second.nextCursor ?? undefined, + }, { signal }) as { + sessions: readonly { id: string; providerThreadId?: string }[]; + nextCursor: string | null; + }; + expect(replayedThird.sessions.map((session) => session.id)) + .toEqual(third.sessions.map((session) => session.id)); + expect(replayedThird.sessions.map((session) => session.providerThreadId)) + .toEqual(["managed-pagination-1"]); + expect(replayedThird.nextCursor).toBe(third.nextCursor); + + value.codex.listedProjections = [{ + providerThreadId: "managed-pagination-2", + title: "Managed terminal page", + status: "idle", + }]; + value.codex.listedNextCursor = null; + const fourth = await value.service.execute({ + kind: "session.list", + archived: false, + account: added.account.id, + limit: 2, + cursor: third.nextCursor ?? undefined, + }, { signal }) as { + sessions: readonly { id: string; providerThreadId?: string }[]; + nextCursor: string | null; + }; + expect(fourth.sessions.map((session) => session.providerThreadId)) + .toEqual(["managed-pagination-2"]); + expect(fourth.nextCursor).toBeNull(); + const replayedFourth = await value.service.execute({ + kind: "session.list", + archived: false, + account: added.account.id, + limit: 2, + cursor: third.nextCursor ?? undefined, + }, { signal }) as { + sessions: readonly { id: string; providerThreadId?: string }[]; + nextCursor: string | null; + }; + expect(replayedFourth.sessions.map((session) => session.id)) + .toEqual(fourth.sessions.map((session) => session.id)); + expect(replayedFourth.sessions.map((session) => session.providerThreadId)) + .toEqual(["managed-pagination-2"]); + expect(replayedFourth.nextCursor).toBeNull(); + const localSessions = [...first.sessions, ...second.sessions]; + expect(new Set(localSessions.map((session) => session.id))).toEqual(expectedLocalIds); + const listedNativeClaude = localSessions.find( + (session) => session.id === nativeClaude.session.id, + ); + expect(listedNativeClaude?.providerThreadId).toBe(nativeClaude.session.providerThreadId); + const allIds = [...first.sessions, ...second.sessions, ...third.sessions, ...fourth.sessions] + .map((session) => session.id); + expect(new Set(allIds).size).toBe(allIds.length); + expect(value.codex.sessionListRequests.map((request) => request.cursor ?? null)) + .toEqual([null, null, "provider-page-2", "provider-page-2"]); + }); + + test("re-proves the managed account identity before importing a provider list page", async () => { + const value = await fixture(); + const added = await value.service.execute( + { kind: "account.add", label: "List identity race" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + value.codex.listedProjections = [{ + providerThreadId: "replacement-account-thread", + title: "Must not cross the account fence", + status: "idle", + }]; + value.codex.beforeListSessionsReturn = () => { + value.codex.accountProjection = { + signedIn: true, + email: "replacement-list-owner@example.com", + plan: "Plus", + }; + }; + + await expect(value.service.execute({ + kind: "session.list", + archived: false, + account: added.account.id, + limit: 10, + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + expect(value.codex.sessionListRequests).toHaveLength(1); + expect(value.store.findSessionByProviderThread( + added.account.id, + "replacement-account-thread", + )).toBeNull(); + await value.service.settled(); + }); + + test("denies provider discovery for a signed-in runtime without an identifiable account", async () => { + const value = await fixture(); + value.codex.loginResult = { + status: "signed_in", + account: { signedIn: true }, + }; + value.codex.accountProjection = { signedIn: true }; + const added = await value.service.execute( + { kind: "account.add", label: "Unidentifiable list account" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + value.codex.listedProjections = [{ + providerThreadId: "unidentifiable-provider-thread", + title: "Must not import", + status: "idle", + }]; + + await expect(value.service.execute({ + kind: "session.list", + archived: false, + account: added.account.id, + limit: 10, + }, { signal })).rejects.toMatchObject({ code: "UNAVAILABLE" }); + expect(value.codex.sessionListRequests).toEqual([]); + expect(value.store.findSessionByProviderThread( + added.account.id, + "unidentifiable-provider-thread", + )).toBeNull(); + }); + + test("pages every authorized local session while provider listing is recovery-blocked", async () => { + const cloud = new FakeCloud(); + const value = await fixture(undefined, cloud); + const added = await value.service.execute( + { kind: "account.add", label: "Recovery local pagination" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + const expectedIds = new Set(Array.from({ length: 3 }, (_, index) => { + const session = value.store.upsertProviderSession({ + profileId: added.account.id, + provider: "codex", + providerThreadId: `recovery-local-${String(index)}`, + title: `Recovery local ${String(index)}`, + preset: "high", + fastEnabled: false, + state: "idle", + providerAccountKey: codexProviderAccountKey(), + }); + return session.id; + })); + cloud.unsettledProjectionProfiles.add(added.account.id); + value.codex.listedProjections = [{ + providerThreadId: "recovery-blocked-provider-thread", + title: "Must remain undiscovered", + status: "idle", + }]; + + const listedIds: string[] = []; + let cursor: string | undefined; + for (let pageIndex = 0; pageIndex < 3; pageIndex += 1) { + const page = await value.service.execute({ + kind: "session.list", + archived: false, + account: added.account.id, + limit: 1, + ...(cursor === undefined ? {} : { cursor }), + }, { signal }) as { + sessions: readonly { id: string }[]; + nextCursor: string | null; + recovery: { required: boolean }; + }; + expect(page.sessions).toHaveLength(1); + expect(page.recovery.required).toBe(true); + listedIds.push(page.sessions[0]?.id ?? "missing-session"); + if (pageIndex < 2) { + expect(page.nextCursor).not.toBeNull(); + cursor = page.nextCursor ?? undefined; + } else { + expect(page.nextCursor).toBeNull(); + } + } + + expect(new Set(listedIds)).toEqual(expectedIds); + expect(listedIds).toHaveLength(expectedIds.size); + expect(value.codex.sessionListRequests).toEqual([]); + expect(value.store.findSessionByProviderThread( + added.account.id, + "recovery-blocked-provider-thread", + )).toBeNull(); + }); + + test("does not let a managed Codex projection mutate a native Claude collision", async () => { + const managedClaude = new FakeClaude("isolated", { + pid: 63_101, + pidDomain: "darwin", + procStart: "managed-claude-provider-collision", + }); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { managedClaude }, + ); + const added = await value.service.execute( + { kind: "account.add", label: "Provider collision" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute( + { kind: "account.login", account: added.account.id, deviceCode: false }, + { signal }, + ); + await value.service.execute( + { kind: "project.add", label: "Provider collision project", path: value.documents }, + { signal }, + ); + managedClaude.projection = { + providerThreadId: "cross-provider-thread-collision", + title: "Native Claude collision", + status: "idle", + projectRoot: value.documents, + providerUpdatedAt: personalAdoptionNow - 1_000, + }; + const started = await value.service.execute({ + kind: "session.start", + account: added.account.id, + provider: "claude", + preset: "fable-max", + fast: false, + }, { signal }) as { session: { id: `sess_${string}` } }; + const before = value.store.requireSession(started.session.id); + if (before.providerThreadId === undefined) throw new Error("Expected a bound Claude session."); + + value.codex.listedProjections = [{ + providerThreadId: before.providerThreadId, + title: "Codex must not overwrite this row", + status: "active", + activeTurnId: "wrong-provider-turn", + providerUpdatedAt: personalAdoptionNow + 1_000, + }]; + value.codex.listedNextCursor = null; + const listed = await value.service.execute({ + kind: "session.list", + archived: false, + account: added.account.id, + limit: 10, + }, { signal }) as { + sessions: readonly { id: string; provider: string }[]; + nextCursor: string | null; + }; + + const collisions = listed.sessions.filter((session) => session.id === started.session.id); + expect(collisions).toHaveLength(1); + expect(collisions[0]?.provider).toBe("claude"); + expect(listed.nextCursor).not.toBeNull(); + await expect(value.service.execute({ + kind: "session.list", + archived: false, + account: added.account.id, + limit: 10, + cursor: listed.nextCursor ?? undefined, + }, { signal })).resolves.toMatchObject({ sessions: [], nextCursor: null }); + expect(value.store.requireSession(started.session.id)).toEqual(before); + }); + + test("retains a stale live Claude process with a noncanonical project root and adopts it after the exact source disappears", async () => { + const sourceProcessIdentity: ClaudeProcessIdentity = { + pid: 62_901, + pidDomain: "darwin", + procStart: "retained-source-process", + }; + const livenessChecks: Array[0]> = []; + const managedClaude = new FakeClaude("isolated", { + pid: 62_902, + pidDomain: "darwin", + procStart: "retained-managed-process", + }); + const personalClaude = new FakeClaude("personal", { + pid: 62_903, + pidDomain: "darwin", + procStart: "retained-adopted-process", + }); + const discovery = new FakePersonalSessionDiscovery(); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { + managedClaude, + personalClaude, + personalCodexHome, + personalDiscovery: discovery, + claudeProcessLiveness: (identity) => { + livenessChecks.push(identity); + return Promise.resolve("not_live"); + }, + }, + ); + const added = await value.service.execute( + { kind: "account.add", label: "Retained Claude adoption" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + const project = await value.service.execute({ + kind: "project.add", + label: "Retained Claude project", + path: value.documents, + }, { signal }) as { project: { id: `proj_${string}` } }; + const providerProjectRoot = `${value.documents}/provider-alias/..`; + personalClaude.projection = { + providerThreadId: "retained-claude-thread", + title: "Retained Claude thread", + status: "idle", + projectRoot: providerProjectRoot, + providerUpdatedAt: personalAdoptionNow - 1_000, + }; + discovery.candidates = [{ + provider: "claude", + providerThreadId: "retained-claude-thread", + title: "Retained Claude thread", + projectRoot: providerProjectRoot, + updatedAt: personalAdoptionNow - 24 * 60 * 60_000, + liveness: "live", + sourceProcessIdentity, + admissionEligible: false, + trustedLiveObservation: true, + }]; + + await expect(value.service.execute({ + kind: "session.adoption.set", + provider: "claude", + enabled: true, + account: added.account.id, + }, { signal })).resolves.toMatchObject({ + discovery: { provider: "claude", discovered: 1, adopted: 0, pending: 1 }, + }); + expect(livenessChecks).toHaveLength(0); + expect(value.store.listSessionAdoptionCandidates({ provider: "claude" })[0]) + .toMatchObject({ + projectId: null, + providerProjectRoot, + liveness: "live", + status: "pending", + sourceProcessIdentity, + }); + expect(value.store.findSessionByProviderThread( + added.account.id, + "retained-claude-thread", + )).toBeNull(); + + // Every structurally observed Claude id must fence retained reprobes, + // even when the target sorts outside the ordinary admission bound and + // its current row is ineligible (for example, a wrong pinned version). + discovery.candidates = [ + ...Array.from({ length: 50 }, (_, index) => ({ + provider: "claude" as const, + providerThreadId: `newer-ineligible-claude-${String(index)}`, + title: `Newer ineligible Claude ${String(index)}`, + updatedAt: personalAdoptionNow - index, + liveness: "unknown" as const, + sourceProcessIdentity: null, + admissionEligible: false, + })), + { + provider: "claude", + providerThreadId: "retained-claude-thread", + title: "Current incompatible Claude row", + updatedAt: personalAdoptionNow - 100, + liveness: "live", + sourceProcessIdentity: null, + admissionEligible: false, + }, + ]; + await expect(value.service.execute({ + kind: "session.adoption.discover", + provider: "claude", + }, { signal })).resolves.toMatchObject({ + providers: [{ discovered: 0, adopted: 0 }], + }); + expect(discovery.requests.at(-1)?.limit).toBe(CLAUDE_REGISTRY_MAX_RECORDS); + expect(livenessChecks).toHaveLength(0); + expect(personalClaude.claimRequests).toHaveLength(0); + expect(value.store.listSessionAdoptionCandidates({ provider: "claude" })[0]) + .toMatchObject({ liveness: "live", status: "pending", sourceProcessIdentity }); + + // Once a later proven-complete snapshot contains no row for the id, HRA + // can prove the retained PID/start tuple is gone and admit the candidate. + discovery.candidates = []; + await expect(value.service.execute({ + kind: "session.adoption.discover", + provider: "claude", + }, { signal })).resolves.toMatchObject({ + providers: [{ + provider: "claude", + state: "ready", + discovered: 0, + adopted: 1, + failed: 0, + }], + }); + expect(livenessChecks).toEqual([sourceProcessIdentity]); + expect(personalClaude.claimRequests).toHaveLength(1); + expect(value.store.findSessionByProviderThread( + added.account.id, + "retained-claude-thread", + )).toMatchObject({ + profileId: added.account.id, + projectId: project.project.id, + provider: "claude", + }); + expect(value.store.readSessionAdoptionCandidate( + "claude", + "retained-claude-thread", + )).toMatchObject({ + projectId: project.project.id, + providerProjectRoot: value.documents, + status: "adopted", + }); + }); + + test("excludes a full current Claude snapshot before bounding retained reprobes", async () => { + const targetProviderThreadId = "z-retained-after-current-prefix"; + const targetIdentity: ClaudeProcessIdentity = { + pid: 63_999, + pidDomain: "darwin", + procStart: "retained-after-current-prefix", + }; + const probed: Array[0]> = []; + const discovery = new FakePersonalSessionDiscovery(); + const managedClaude = new FakeClaude("isolated", { + pid: 63_000, + pidDomain: "darwin", + procStart: "managed-retained-prefix", + }); + const personalClaude = new FakeClaude("personal", { + pid: 63_001, + pidDomain: "darwin", + procStart: "personal-retained-prefix", + }); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { + managedClaude, + personalClaude, + personalCodexHome, + personalDiscovery: discovery, + claudeProcessLiveness: (identity) => { + probed.push(identity); + return Promise.resolve("not_live"); + }, + }, + ); + const added = await value.service.execute( + { kind: "account.add", label: "Retained query fairness" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + const project = await value.service.execute({ + kind: "project.add", + label: "Retained query fairness project", + path: value.documents, + }, { signal }) as { project: { id: `proj_${string}` } }; + const currentProviderThreadIds = Array.from( + { length: 100 }, + (_, index) => `a-current-retained-prefix-${String(index).padStart(3, "0")}`, + ); + for (const [index, providerThreadId] of currentProviderThreadIds.entries()) { + value.store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId, + projectId: project.project.id, + title: `Current retained prefix ${index}`, + state: "active", + providerUpdatedAt: personalAdoptionNow - 1_000, + liveness: "live", + trustedLiveObservation: true, + sourceProcessIdentity: { + pid: 64_000 + index, + pidDomain: "darwin", + procStart: `current-retained-prefix-${index}`, + }, + }); + } + value.store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: targetProviderThreadId, + projectId: project.project.id, + title: "Retained after current prefix", + state: "active", + providerUpdatedAt: personalAdoptionNow - 24 * 60 * 60_000, + liveness: "live", + trustedLiveObservation: true, + sourceProcessIdentity: targetIdentity, + }); + discovery.candidates = currentProviderThreadIds.map((providerThreadId, index) => ({ + provider: "claude" as const, + providerThreadId, + title: `Ineligible current prefix ${index}`, + updatedAt: personalAdoptionNow - index, + liveness: "unknown" as const, + sourceProcessIdentity: null, + admissionEligible: false, + })); + personalClaude.projection = { + providerThreadId: targetProviderThreadId, + title: "Retained after current prefix", + projectRoot: value.documents, + status: "idle", + providerUpdatedAt: personalAdoptionNow - 24 * 60 * 60_000, + }; + + await expect(value.service.execute({ + kind: "session.adoption.set", + provider: "claude", + enabled: true, + account: added.account.id, + }, { signal })).resolves.toMatchObject({ + discovery: { discovered: 0, adopted: 1, failed: 0 }, + }); + expect(probed).toEqual([targetIdentity]); + expect(personalClaude.claimRequests.map((request) => request.providerThreadId)) + .toEqual([targetProviderThreadId]); + }); + + test("does not adopt a retained Claude candidate on unknown liveness or a stale exact probe", async () => { + const sourceProcessIdentity: ClaudeProcessIdentity = { + pid: 62_911, + pidDomain: "darwin", + procStart: "retained-fenced-source", + }; + const replacementIdentity: ClaudeProcessIdentity = { + pid: 62_912, + pidDomain: "darwin", + procStart: "retained-fenced-replacement", + }; + let probeResult: "not_live" | "unknown" = "unknown"; + const raceContext: { store?: StateStore } = {}; + const personalClaude = new FakeClaude("personal", { + pid: 62_913, + pidDomain: "darwin", + procStart: "retained-fenced-adopted", + }); + const discovery = new FakePersonalSessionDiscovery(); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { + managedClaude: new FakeClaude("isolated", { + pid: 62_914, + pidDomain: "darwin", + procStart: "retained-fenced-managed", + }), + personalClaude, + personalCodexHome, + personalDiscovery: discovery, + claudeProcessLiveness: () => { + if (probeResult === "not_live") { + const store = raceContext.store; + if (store === undefined) throw new Error("Expected the race store."); + const current = store.listSessionAdoptionCandidates({ provider: "claude" })[0]; + if (current === undefined) throw new Error("Expected the retained candidate."); + store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: current.providerThreadId, + ...(current.projectId === null ? {} : { projectId: current.projectId }), + title: current.title, + state: "active", + ...(current.providerUpdatedAt === null + ? {} + : { providerUpdatedAt: current.providerUpdatedAt }), + liveness: "live", + trustedLiveObservation: true, + sourceProcessIdentity: replacementIdentity, + }); + } + return Promise.resolve(probeResult); + }, + }, + ); + raceContext.store = value.store; + const added = await value.service.execute( + { kind: "account.add", label: "Retained Claude fence" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + await value.service.execute({ + kind: "project.add", + label: "Retained Claude fence project", + path: value.documents, + }, { signal }); + personalClaude.projection = { + providerThreadId: "retained-claude-fenced-thread", + title: "Retained Claude fenced thread", + status: "idle", + projectRoot: value.documents, + providerUpdatedAt: personalAdoptionNow - 1_000, + }; + discovery.candidates = [{ + provider: "claude", + providerThreadId: "retained-claude-fenced-thread", + title: "Retained Claude fenced thread", + projectRoot: value.documents, + updatedAt: personalAdoptionNow - 1_000, + liveness: "live", + trustedLiveObservation: true, + sourceProcessIdentity, + }]; + await value.service.execute({ + kind: "session.adoption.set", + provider: "claude", + enabled: true, + account: added.account.id, + }, { signal }); + + discovery.candidates = []; + await expect(value.service.execute({ + kind: "session.adoption.discover", + provider: "claude", + }, { signal })).resolves.toMatchObject({ + providers: [{ discovered: 0, adopted: 0, pending: 1 }], + }); + expect(personalClaude.claimRequests).toHaveLength(0); + expect(value.store.listSessionAdoptionCandidates({ provider: "claude" })[0]) + .toMatchObject({ liveness: "unknown", status: "pending", sourceProcessIdentity }); + + probeResult = "not_live"; + await expect(value.service.execute({ + kind: "session.adoption.discover", + provider: "claude", + }, { signal })).resolves.toMatchObject({ + providers: [{ discovered: 0, adopted: 0 }], + }); + expect(personalClaude.claimRequests).toHaveLength(0); + expect(value.store.findSessionByProviderThread( + added.account.id, + "retained-claude-fenced-thread", + )).toBeNull(); + expect(value.store.listSessionAdoptionCandidates({ provider: "claude" })[0]) + .toMatchObject({ + liveness: "live", + status: "pending", + sourceProcessIdentity: replacementIdentity, + }); + }); + + test("preserves an adopted Claude title beyond the bounded runtime projection", async () => { + const durableTitle = `Personal Claude ${"界".repeat(80)}`; + const boundedProjectionTitle = `Personal Claude ${"界".repeat(34)}`; + expect(new TextEncoder().encode(durableTitle).byteLength).toBeGreaterThan(120); + expect(new TextEncoder().encode(boundedProjectionTitle).byteLength) + .toBeLessThanOrEqual(120); + const value = await adoptedClaudeFixture( + "Adopted Claude long title", + "personal-claude-long-title", + (runtime) => { + runtime.claimProjectionTitle = boundedProjectionTitle; + }, + durableTitle, + ); + await value.service.settled(); + + expect(value.personalClaude.claimRequests[0]?.title).toBe(durableTitle); + expect(value.personalClaude.projection.title).toBe(boundedProjectionTitle); + expect(value.store.requireSession(value.session.id).title).toBe(durableTitle); + expect(value.store.listSessionAdoptionCandidates({ provider: "claude" })[0]?.title) + .toBe(durableTitle); + await expect(value.service.execute({ + kind: "session.show", + session: value.session.id, + detail: false, + }, { signal })).resolves.toMatchObject({ + projection: { title: boundedProjectionTitle }, + session: { title: durableTitle }, + }); + expect(value.store.requireSession(value.session.id).title).toBe(durableTitle); + }); + + test("preserves an adopted Claude title across controller restart observation", async () => { + const durableTitle = `Restarted Claude ${"界".repeat(80)}`; + const boundedProjectionTitle = `Restarted Claude ${"界".repeat(33)}`; + expect(new TextEncoder().encode(durableTitle).byteLength).toBeGreaterThan(120); + expect(new TextEncoder().encode(boundedProjectionTitle).byteLength) + .toBeLessThanOrEqual(120); + const value = await adoptedClaudeFixture( + "Adopted Claude restart title", + "personal-claude-restart-title", + (runtime) => { + runtime.claimProjectionTitle = boundedProjectionTitle; + }, + durableTitle, + ); + await value.service.settled(); + const initialIdentity = value.personalClaude.processIdentity; + const replacementIdentity: ClaudeProcessIdentity = { + pid: 63_024, + pidDomain: "darwin", + procStart: "personal-claude-restart-title-replacement", + }; + const replacementConnectionId = "30000000-0000-4000-8000-0000000000c5"; + value.personalClaude.disconnectOnObserveRequest = + value.personalClaude.observeRequests.length + 1; + value.personalClaude.processIdentityOnClaim = replacementIdentity; + value.personalClaude.observationConnectionIdOnClaim = replacementConnectionId; + + await expect(value.service.execute({ + kind: "session.status", + session: value.session.id, + }, { signal })).resolves.toMatchObject({ + providerObservation: { + connectionId: replacementConnectionId, + state: "live", + }, + }); + + expect(value.personalClaude.endedProcessIdentities).toEqual([initialIdentity]); + expect(value.personalClaude.claimRequests).toHaveLength(2); + expect(value.personalClaude.claimRequests[1]?.title).toBe(durableTitle); + expect(value.personalClaude.projection.title).toBe(boundedProjectionTitle); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId: "personal-claude-restart-title", + profileId: value.accountId, + runtimeScope: "personal", + })).toMatchObject({ + identity: replacementIdentity, + sessionId: value.session.id, + state: "bound", + }); + expect(value.store.requireSession(value.session.id).title).toBe(durableTitle); + await expect(value.service.execute({ + kind: "session.show", + session: value.session.id, + detail: false, + }, { signal })).resolves.toMatchObject({ + projection: { title: boundedProjectionTitle }, + session: { title: durableTitle }, + }); + expect(value.store.requireSession(value.session.id).title).toBe(durableTitle); + }); + + test("preserves an adopted Claude title across status, mutation, and queue recovery", async () => { + const durableTitle = `Recovered Claude ${"界".repeat(80)}`; + const boundedProjectionTitle = `Recovered Claude ${"界".repeat(33)}`; + expect(new TextEncoder().encode(durableTitle).byteLength).toBeGreaterThan(120); + expect(new TextEncoder().encode(boundedProjectionTitle).byteLength) + .toBeLessThanOrEqual(120); + const value = await adoptedClaudeFixture( + "Adopted Claude recovered title", + "personal-claude-recovered-title", + (runtime) => { + runtime.claimProjectionTitle = boundedProjectionTitle; + }, + durableTitle, + ); + await value.service.settled(); + + expect(value.store.quarantineSession(value.session.id)) + .toMatchObject({ state: "recovery_required", title: durableTitle }); + await expect(value.service.execute({ + kind: "session.recover", + session: value.session.id, + }, { signal })).resolves.toMatchObject({ + session: { state: "idle", title: durableTitle }, + projection: { title: boundedProjectionTitle }, + recovery: { resolution: "provider_state_reconciled" }, + }); + + const mutationKey = "00000000-0000-4000-8000-00000000c101"; + value.personalClaude.startTurnError = new IndeterminateCodexEffectError( + "claude/turn", + 48, + ); + await expect(value.service.execute({ + kind: "session.send", + session: value.session.id, + message: "Recover this exact mutation", + idempotencyKey: mutationKey, + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + expect(value.store.readMutation(mutationKey)).toMatchObject({ state: "ambiguous" }); + delete value.personalClaude.startTurnError; + await expect(value.service.execute({ + kind: "session.recover", + session: value.session.id, + }, { signal })).resolves.toMatchObject({ + session: { title: durableTitle }, + projection: { title: boundedProjectionTitle }, + recovery: { resolution: "proven_applied", providerEffectRetried: false }, + }); + expect(value.store.requireSession(value.session.id).title).toBe(durableTitle); + + value.personalClaude.startTurnError = new IndeterminateCodexEffectError( + "claude/turn", + 49, + ); + const queued = await value.service.execute({ + kind: "session.queue", + session: value.session.id, + message: "Recover this exact queue effect", + }, { signal }) as { queued: { id: `queue_${string}` } }; + await value.service.settled(); + expect(value.store.requireQueue(queued.queued.id)).toMatchObject({ state: "ambiguous" }); + expect(value.store.requireSession(value.session.id)) + .toMatchObject({ state: "recovery_required", title: durableTitle }); + delete value.personalClaude.startTurnError; + await expect(value.service.execute({ + kind: "session.recover", + session: value.session.id, + }, { signal })).resolves.toMatchObject({ + queueId: queued.queued.id, + session: { title: durableTitle }, + projection: { title: boundedProjectionTitle }, + recovery: { resolution: "proven_applied", providerEffectRetried: false }, + }); + expect(value.store.requireSession(value.session.id).title).toBe(durableTitle); + }); + + test("refuses a fresh Codex adoption whose claimed runtime profile uses a legacy preset contract", async () => { + const providerThreadId = "personal-thread-legacy-runtime-profile"; + const value = await preparedPersonalCodexCandidate({ + label: "Legacy runtime profile", + providerThreadId, + updatedAt: personalAdoptionNow - 11 * 60_000, + liveness: "not_live", + }); + const profile = value.store.requireProfile(value.accountId); + const preset = value.store.readDefaultPreset("codex"); + value.personalCodex.claimRuntimeProfileOverride = { + ...runtimeProfile({ + id: profile.id, + generation: profile.processGeneration, + codexHome: personalCodexHome, + desktopUserData: join(privatePathRoot, "personal-codex-desktop"), + }), + preset, + model: "gpt-5.6-sol", + reasoningEffort: preset === "ultra" ? "ultra" : "max", + }; + + await expect(value.enable()).resolves.toMatchObject({ + discovery: { adopted: 0, failed: 1, provider: "codex" }, + }); + expect(value.personalCodex.claimRequests[0]).toMatchObject({ + preset, + requirement: presetRequirements[preset], + }); + expect(value.store.findSessionByProviderThread(value.accountId, providerThreadId)).toBeNull(); + }); + + test("keeps account-authority failures source-neutral for native and adopted controllers", async () => { + const shape = (error: unknown) => { + const failure = error as CommandFailure; + return { code: failure.code, message: failure.message, details: failure.details }; + }; + const withoutAccountIdentity = (error: unknown) => { + const failure = shape(error); + const details = failure.details as { accountId: string; provider: string }; + return { ...failure, details: { ...details, accountId: "" } }; + }; + + const adopted = await adoptedCodexFixture( + "Source-neutral adopted authority", + "source-neutral-adopted-codex", + ); + const adoptedAuthority = adopted.personalCodex.claimRequests[0]?.authority; + if (adoptedAuthority === undefined) throw new Error("Expected adopted authority."); + const replacementAccount = { + signedIn: true as const, + email: "replacement-source-neutral@example.com", + plan: "Plus", + }; + const adoptedMismatch = await adopted.service.observePersonalCodexAccount( + adoptedAuthority, + replacementAccount, + ).catch((error: unknown) => error); + + const native = await fixture(); + const { sessionId } = await createIdleSession(native, "Source-neutral native authority"); + const nativeProfile = native.store.requireProfileById( + native.store.requireSession(sessionId).profileId, + ); + const nativePaths = profilePaths(native.paths, nativeProfile.id); + const nativeMismatch = await native.service.observeCodexAccount({ + id: nativeProfile.id, + generation: nativeProfile.processGeneration, + codexHome: nativePaths.codexHome, + desktopUserData: nativePaths.desktopUserData, + }, replacementAccount).catch((error: unknown) => error); + await Promise.all([adopted.service.settled(), native.service.settled()]); + + expect(adoptedMismatch).toBeInstanceOf(CommandFailure); + expect(nativeMismatch).toBeInstanceOf(CommandFailure); + expect(withoutAccountIdentity(adoptedMismatch)).toEqual( + withoutAccountIdentity(nativeMismatch), + ); + expect(withoutAccountIdentity(adoptedMismatch)).toEqual({ + code: "RECOVERY_REQUIRED", + message: "The provider account changed. HRA refused stale controller authority and is releasing the affected sessions.", + details: { accountId: "", provider: "codex" }, + }); + + const pending = await adoptedClaudeFixture( + "Source-neutral pending authority", + "source-neutral-pending-adopted-claude", + ); + pending.managedClaude.projection = { + providerThreadId: "source-neutral-pending-native-claude", + title: "Source-neutral pending native Claude", + status: "idle", + projectRoot: pending.documents, + providerUpdatedAt: personalAdoptionNow, + }; + await pending.service.execute({ + kind: "session.start", + account: pending.accountId, + provider: "claude", + preset: "fable-max", + fast: false, + }, { signal }); + const pendingProfile = pending.store.requireProfileById(pending.accountId); + const direct = new Database(pending.paths.database, { create: false, strict: true }); + try { + for (const runtimeScope of ["personal", "managed"] as const) { + direct.query( + `INSERT INTO provider_runtime_account_revocations( + profile_id,profile_generation,provider,runtime_scope,current_account_key, + state,revision,created_at,updated_at,completed_at + ) VALUES (?,?,'claude',?,?, 'releasing',1,?,?,NULL)`, + ).run( + pendingProfile.id, + pendingProfile.processGeneration, + runtimeScope, + claudeProviderAccountKey(), + personalAdoptionNow, + personalAdoptionNow, + ); + } + } finally { + direct.close(); + } + + const adoptedPending = await pending.service.discoverPersonalSessions( + "claude", + signal, + ).catch((error: unknown) => error); + const nativePending = await pending.service.execute({ + kind: "session.start", + account: pending.accountId, + provider: "claude", + preset: "fable-max", + fast: false, + }, { signal }).catch((error: unknown) => error); + expect(adoptedPending).toBeInstanceOf(CommandFailure); + expect(nativePending).toBeInstanceOf(CommandFailure); + expect(shape(adoptedPending)).toEqual(shape(nativePending)); + expect(shape(adoptedPending)).toEqual({ + code: "RECOVERY_REQUIRED", + message: "The provider account authority is being released.", + details: { accountId: pending.accountId, provider: "claude" }, + }); + + for (const failure of [adoptedMismatch, nativeMismatch, adoptedPending, nativePending]) { + const serialized = JSON.stringify(shape(failure)).toLowerCase(); + expect(serialized).not.toContain("personal"); + expect(serialized).not.toContain("managed"); + expect(serialized).not.toContain("runtimescope"); + expect(serialized).not.toContain("home"); + } + }); + + test("routes an adopted Claude session on Darwin only through personal custody", async () => { + const value = await adoptedClaudeFixture( + "Adopted Claude routing", + "same-opaque-claude-thread", + undefined, + undefined, + "darwin", + ); + + expect(value.enabled).toMatchObject({ + providers: [{ provider: "claude", enabled: true, adopted: 1 }], + discovery: { provider: "claude", state: "ready", adopted: 1 }, + }); + expect(value.personalClaude.claimRequests[0]).toMatchObject({ + authority: { id: value.accountId, codexHome: personalCodexHome }, + providerThreadId: "same-opaque-claude-thread", + preset: "fable-max", + requirement: presetRequirements["fable-max"], + fast: false, + sourceLiveness: "not_live", + title: "Adopted Claude routing personal thread", + }); + expect(value.store.requireSessionPresetRequirement(value.session.id)).toEqual({ + preset: "fable-max", + requirement: presetRequirements["fable-max"], + }); + expect(value.store.latestSessionRuntimeProfile(value.session.id)).toMatchObject({ + profile: { + configHome: "personal", + preset: "fable-max", + }, + }); + const shown = await value.service.execute({ + kind: "session.show", + session: value.session.id, + detail: false, + }, { signal }) as { + effectiveRuntimeProfile: Record; + projection: { title: string }; + session: { title: string }; + }; + expect(shown).toMatchObject({ + projection: { title: "Adopted Claude routing personal thread" }, + session: { title: "Adopted Claude routing personal thread" }, + }); + expect(shown.effectiveRuntimeProfile).not.toHaveProperty("configHome"); + expect(shown.effectiveRuntimeProfile).not.toHaveProperty("isolatedConfigDir"); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId: "same-opaque-claude-thread", + profileId: value.accountId, + runtimeScope: "personal", + })).toMatchObject({ + sessionId: value.session.id, + state: "bound", + identity: value.personalIdentity, + }); + + const listed = await value.service.execute({ + kind: "session.list", + archived: false, + account: value.accountId, + limit: 1, + }, { signal }) as { sessions: readonly Record[] }; + expect(listed.sessions).toEqual([expect.objectContaining({ + id: value.session.id, + provider: "claude", + providerThreadId: "same-opaque-claude-thread", + })]); + expect(listed.sessions[0]).not.toHaveProperty("adopted"); + expect(listed.sessions[0]).not.toHaveProperty("origin"); + + value.store.recordClaimedClaudeProcessAuthority({ + providerThreadId: "same-opaque-claude-thread", + profileId: value.accountId, + profileGeneration: value.store.requireProfileById(value.accountId).processGeneration, + runtimeScope: "managed", + identity: value.managedClaude.processIdentity, + }); + const sent = await value.service.execute({ + kind: "session.send", + session: value.session.id, + message: "Continue the adopted Claude conversation", + idempotencyKey: "00000000-0000-4000-8000-00000000c001", + }, { signal }) as { + effectiveRuntimeProfile: Record; + session: { id: string; state: string; title: string }; + }; + expect(sent).toMatchObject({ + session: { id: value.session.id, state: "idle" }, + }); + expect(sent.session.title).toBe("Adopted Claude routing personal thread"); + expect(sent.effectiveRuntimeProfile).not.toHaveProperty("configHome"); + expect(sent.effectiveRuntimeProfile).not.toHaveProperty("isolatedConfigDir"); + expect(value.personalClaude.turnRequests).toHaveLength(1); + expect(value.personalClaude.turnRequests[0]?.authority.codexHome).toBe(personalCodexHome); + expect(value.managedClaude.turnRequests).toHaveLength(0); + + }); + + test("autoresponds on Darwin to adopted Claude approval authority through its personal controller", async () => { + const value = await adoptedClaudeFixture( + "Adopted Claude approval", + "personal-claude-approval", + undefined, + undefined, + "darwin", + ); + value.store.setDefaultApprovalMode("auto:all"); + const authority = value.personalClaude.claimRequests[0]?.authority; + if (authority === undefined) throw new Error("Expected personal Claude authority."); + const requestId = "adopted-claude-approval-request"; + + await value.service.observePersonalClaudeFact(authority, { + providerThreadId: "personal-claude-approval", + connectionId: value.personalClaude.observationConnectionId, + type: "interactionRequested", + requestId, + turnId: "claude-turn-approval", + itemId: "claude-item-approval", + kind: "command_approval", + blocking: true, + display: { + kind: "command_approval", + summary: "Run the adopted Claude test", + reason: null, + commandClass: "bun test", + workingDirectory: null, + availableDecisions: ["once", "session", "decline", "cancel"], + }, + request: { + blockedPath: null, + decisionReasonType: null, + description: "Run the adopted Claude test", + displayName: "Bash", + input: { command: "bun test" }, + permissionSuggestionCount: 0, + questions: null, + requiresUserInteraction: false, + subtype: "can_use_tool", + toolName: "Bash", + toolUseId: "claude-item-approval", + }, + }); + + await waitFor(() => value.personalClaude.resolvedInteractions.length === 1); + expect(value.personalClaude.validatedInteractions).toHaveLength(1); + expect(value.personalClaude.resolvedInteractions[0]).toMatchObject({ + authority: { id: value.accountId, codexHome: personalCodexHome }, + kind: "command_approval", + resolution: { kind: "approval_decision", decision: "once" }, + }); + expect(value.managedClaude.validatedInteractions).toHaveLength(0); + expect(value.managedClaude.resolvedInteractions).toHaveLength(0); + const interaction = value.store.listInteractions({ + sessionId: value.session.id, + limit: 10, + }).find((candidate) => candidate.authority.requestId.value === requestId); + expect(interaction).toMatchObject({ + resolvedBy: "autorespond", + sessionId: value.session.id, + }); + expect(interaction?.state).not.toBe("pending"); + }); + + test("adopts, operates, and autoresponds on Darwin through personal Claude while Codex is signed out", async () => { + const value = await adoptedClaudeFixture( + "Signed-out adopted Claude parity", + "signed-out-personal-claude-approval", + undefined, + undefined, + "darwin", + false, + ); + expect(value.store.requireProfileById(value.accountId).state).toBe("signed_out"); + value.store.setDefaultApprovalMode("auto:all"); + + await expect(value.service.execute({ + kind: "session.send", + session: value.session.id, + message: "Continue with provider-specific Claude authority", + idempotencyKey: "00000000-0000-4000-8000-00000000c002", + }, { signal })).resolves.toMatchObject({ + session: { id: value.session.id }, + }); + + const authority = value.personalClaude.claimRequests[0]?.authority; + if (authority === undefined) throw new Error("Expected personal Claude authority."); + const requestId = "signed-out-adopted-claude-approval"; + await value.service.observePersonalClaudeFact(authority, { + providerThreadId: "signed-out-personal-claude-approval", + connectionId: value.personalClaude.observationConnectionId, + type: "interactionRequested", + requestId, + turnId: "signed-out-adopted-turn", + itemId: "signed-out-adopted-item", + kind: "command_approval", + blocking: true, + display: { + kind: "command_approval", + summary: "Run the signed-out adopted Claude test", + reason: null, + commandClass: "bun test", + workingDirectory: null, + availableDecisions: ["once", "session", "decline", "cancel"], + }, + request: { + blockedPath: null, + decisionReasonType: null, + description: "Run the signed-out adopted Claude test", + displayName: "Bash", + input: { command: "bun test" }, + permissionSuggestionCount: 0, + questions: null, + requiresUserInteraction: false, + subtype: "can_use_tool", + toolName: "Bash", + toolUseId: "signed-out-adopted-item", + }, + }); + + await waitFor(() => value.personalClaude.resolvedInteractions.length === 1); + expect(value.personalClaude.resolvedInteractions[0]).toMatchObject({ + authority: { id: value.accountId, codexHome: personalCodexHome }, + resolution: { kind: "approval_decision", decision: "once" }, + }); + expect(value.managedClaude.resolvedInteractions).toHaveLength(0); + expect(value.store.listInteractions({ + sessionId: value.session.id, + limit: 10, + }).some((interaction) => + interaction.resolvedBy === "autorespond" + && interaction.state === "response_written")).toBe(true); + value.store.setDefaultApprovalMode("manual"); + const manualRequestId = "signed-out-adopted-claude-manual"; + await value.service.observePersonalClaudeFact(authority, { + providerThreadId: "signed-out-personal-claude-approval", + connectionId: value.personalClaude.observationConnectionId, + type: "interactionRequested", + requestId: manualRequestId, + turnId: "signed-out-adopted-manual-turn", + itemId: "signed-out-adopted-manual-item", + kind: "command_approval", + blocking: true, + display: { + kind: "command_approval", + summary: "Inspect and resolve signed-out adopted Claude authority", + reason: null, + commandClass: "bun test", + workingDirectory: null, + availableDecisions: ["once", "session", "decline", "cancel"], + }, + request: { + blockedPath: null, + decisionReasonType: null, + description: "Inspect and resolve signed-out adopted Claude authority", + displayName: "Bash", + input: { command: "bun test" }, + permissionSuggestionCount: 0, + questions: null, + requiresUserInteraction: false, + subtype: "can_use_tool", + toolName: "Bash", + toolUseId: "signed-out-adopted-manual-item", + }, + }); + const manualInteraction = value.store.listInteractions({ + sessionId: value.session.id, + pendingOnly: true, + limit: 10, + }).find((candidate) => candidate.authority.requestId.value === manualRequestId); + if (manualInteraction === undefined) { + throw new Error("Expected a manual adopted Claude interaction."); + } + await expect(value.service.execute({ + kind: "interaction.inspect", + interaction: manualInteraction.publicId, + expectedRevision: manualInteraction.revision, + }, { signal })).resolves.toMatchObject({ + binding: { interactionId: manualInteraction.publicId }, + authority: { kind: "command_approval", command: "bun test" }, + }); + await expect(value.service.execute({ + kind: "interaction.resolve", + interaction: manualInteraction.publicId, + expectedRevision: manualInteraction.revision, + resolution: { kind: "approval_decision", decision: "once" }, + }, { signal })).resolves.toMatchObject({ + responseWritten: true, + interaction: { state: "response_written" }, + }); + expect(value.personalClaude.inspectedInteractions).toHaveLength(1); + expect(value.personalClaude.resolvedInteractions).toHaveLength(2); + await value.service.observePersonalClaudeFact(authority, { + providerThreadId: "signed-out-personal-claude-approval", + connectionId: value.personalClaude.observationConnectionId, + type: "interactionCanceled", + requestId: manualRequestId, + }); + expect(value.store.requireInteraction(manualInteraction.publicId).state).toBe("resolved"); + expect(value.store.requireProfileById(value.accountId).state).toBe("signed_out"); + }); + + test("starts, operates, and autoresponds through managed Claude while Codex is signed out", async () => { + const value = await nativeClaudeFixture( + "Signed-out native Claude parity", + "signed-out-native-claude", + { + pid: 63_028, + pidDomain: "darwin", + procStart: "signed-out-native-claude-process", + }, + undefined, + false, + ); + const profile = value.store.requireProfileById(value.accountId); + expect(profile.state).toBe("signed_out"); + value.store.setDefaultApprovalMode("auto:all"); + + await expect(value.service.execute({ + kind: "session.send", + session: value.session.id, + message: "Continue with managed Claude authority", + idempotencyKey: "00000000-0000-4000-8000-00000000c003", + }, { signal })).resolves.toMatchObject({ + session: { id: value.session.id }, + }); + + const authority: ProfileAuthority = { + id: profile.id, + generation: profile.processGeneration, + codexHome: "unused", + desktopUserData: "unused", + }; + const requestId = "signed-out-native-claude-approval"; + await value.service.observeClaudeFact(authority, { + providerThreadId: value.providerThreadId, + connectionId: value.managedClaude.observationConnectionId, + type: "interactionRequested", + requestId, + turnId: "signed-out-native-turn", + itemId: "signed-out-native-item", + kind: "command_approval", + blocking: true, + display: { + kind: "command_approval", + summary: "Run the signed-out native Claude test", + reason: null, + commandClass: "bun test", + workingDirectory: null, + availableDecisions: ["once", "session", "decline", "cancel"], + }, + request: { + blockedPath: null, + decisionReasonType: null, + description: "Run the signed-out native Claude test", + displayName: "Bash", + input: { command: "bun test" }, + permissionSuggestionCount: 0, + questions: null, + requiresUserInteraction: false, + subtype: "can_use_tool", + toolName: "Bash", + toolUseId: "signed-out-native-item", + }, + }); + + await waitFor(() => value.managedClaude.resolvedInteractions.length === 1); + expect(value.managedClaude.resolvedInteractions[0]).toMatchObject({ + authority: { id: value.accountId }, + resolution: { kind: "approval_decision", decision: "once" }, + }); + expect(value.store.listInteractions({ + sessionId: value.session.id, + limit: 10, + }).some((interaction) => + interaction.resolvedBy === "autorespond" + && interaction.state === "response_written")).toBe(true); + value.store.setDefaultApprovalMode("manual"); + const manualRequestId = "signed-out-native-claude-manual"; + await value.service.observeClaudeFact(authority, { + providerThreadId: value.providerThreadId, + connectionId: value.managedClaude.observationConnectionId, + type: "interactionRequested", + requestId: manualRequestId, + turnId: "signed-out-native-manual-turn", + itemId: "signed-out-native-manual-item", + kind: "command_approval", + blocking: true, + display: { + kind: "command_approval", + summary: "Inspect and resolve signed-out Claude authority", + reason: null, + commandClass: "bun test", + workingDirectory: null, + availableDecisions: ["once", "session", "decline", "cancel"], + }, + request: { + blockedPath: null, + decisionReasonType: null, + description: "Inspect and resolve signed-out Claude authority", + displayName: "Bash", + input: { command: "bun test" }, + permissionSuggestionCount: 0, + questions: null, + requiresUserInteraction: false, + subtype: "can_use_tool", + toolName: "Bash", + toolUseId: "signed-out-native-manual-item", + }, + }); + const manualInteraction = value.store.listInteractions({ + sessionId: value.session.id, + pendingOnly: true, + limit: 10, + }).find((candidate) => candidate.authority.requestId.value === manualRequestId); + if (manualInteraction === undefined) throw new Error("Expected a manual Claude interaction."); + await expect(value.service.execute({ + kind: "interaction.inspect", + interaction: manualInteraction.publicId, + expectedRevision: manualInteraction.revision, + }, { signal })).resolves.toMatchObject({ + binding: { interactionId: manualInteraction.publicId }, + authority: { kind: "command_approval", command: "bun test" }, + }); + await expect(value.service.execute({ + kind: "interaction.resolve", + interaction: manualInteraction.publicId, + expectedRevision: manualInteraction.revision, + resolution: { kind: "approval_decision", decision: "once" }, + }, { signal })).resolves.toMatchObject({ + responseWritten: true, + interaction: { state: "response_written" }, + }); + expect(value.managedClaude.inspectedInteractions).toHaveLength(1); + expect(value.managedClaude.resolvedInteractions).toHaveLength(2); + await value.service.observeClaudeFact(authority, { + providerThreadId: value.providerThreadId, + connectionId: value.managedClaude.observationConnectionId, + type: "interactionCanceled", + requestId: manualRequestId, + }); + expect(value.store.requireInteraction(manualInteraction.publicId).state).toBe("resolved"); + const observationsBeforeDisconnect = value.managedClaude.observeRequests.length; + await value.service.observeClaudeFact(authority, { + type: "providerDisconnected", + providerThreadId: value.providerThreadId, + connectionId: value.managedClaude.observationConnectionId, + reason: "eof", + }); + await waitFor(() => + value.managedClaude.observeRequests.length > observationsBeforeDisconnect); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId: value.providerThreadId, + profileId: value.accountId, + runtimeScope: "managed", + })).toMatchObject({ state: "bound", sessionId: value.session.id }); + expect(value.store.requireProfileById(value.accountId).state).toBe("signed_out"); + }); + + test("recovers a target-bound switch whose source was an adopted personal Claude session", async () => { + const value = await adoptedClaudeFixture( + "Adopted Claude switch recovery", + "adopted-claude-switch-source", + ); + const idempotencyKey = crypto.randomUUID(); + const complete = value.store.completeSessionProviderSwitch.bind(value.store); + Object.defineProperty(value.store, "completeSessionProviderSwitch", { + configurable: true, + value: () => { + throw new Error("simulated final switch commit failure"); + }, + }); + try { + await expect(value.service.execute({ + idempotencyKey, + kind: "session.switch", + provider: "codex", + session: value.session.id, + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + } finally { + Object.defineProperty(value.store, "completeSessionProviderSwitch", { + configurable: true, + value: complete, + }); + } + + const unsettled = value.store.readMutation(idempotencyKey); + if (unsettled?.evidence?.evidence.kind !== "session.switch") { + throw new Error("Expected an unsettled provider switch."); + } + expect(unsettled.evidence.evidence.targetProviderAccountKey).toBeString(); + expect(value.store.requireSession(value.session.id)).toMatchObject({ + profileId: value.accountId, + provider: "codex", + providerThreadId: "provider-thread", + state: "recovery_required", + }); + expect(value.store.readSessionPersonalRuntimeBinding( + value.session.id, + true, + )).toMatchObject({ + provider: "claude", + providerThreadId: "adopted-claude-switch-source", + state: "detached", + }); + + await expect(value.service.execute({ + kind: "session.recover", + session: value.session.id, + }, { signal })).resolves.toMatchObject({ + recovery: { + providerEffectRetried: false, + resolution: "proven_applied", + }, + session: { + profileId: value.accountId, + provider: "codex", + providerThreadId: "provider-thread", + }, + }); + expect(value.store.readMutation(idempotencyKey)).toMatchObject({ + resolution: { kind: "proven_applied" }, + state: "reconciled", + }); + }); + + test("recovers an adopted Claude session after clean restart from released process custody", async () => { + const value = await adoptedClaudeFixture( + "Adopted Claude restart", + "personal-claude-restart", + ); + const oldAuthority = value.store.readClaudeProcessAuthority({ + providerThreadId: "personal-claude-restart", + profileId: value.accountId, + runtimeScope: "personal", + }); + if (oldAuthority === null) throw new Error("Expected initial Claude process authority."); + const oldGeneration = value.store.requireProfileById(value.accountId).processGeneration; + await value.service.close(); + expect(value.store.requireProfileById(value.accountId).processGeneration) + .toBe(oldGeneration + 1); + expect(value.store.readSessionPersonalRuntimeBinding(value.session.id)) + .toMatchObject({ state: "active" }); + + const restartedPersonal = new FakeClaude("personal", { + pid: 63_003, + pidDomain: "darwin", + procStart: "personal-claude-restarted", + }); + restartedPersonal.projection = { + ...value.personalClaude.projection, + providerThreadId: "personal-claude-restart", + status: "idle", + }; + restartedPersonal.readIdentityErrorOnce = new Error("prior child is gone"); + restartedPersonal.observeErrorOnce = new ClaudeSessionObservationError(); + const restartedManaged = new FakeClaude("isolated", { + pid: 63_004, + pidDomain: "darwin", + procStart: "managed-claude-restarted", + }); + const livenessChecks: Array[0]> = []; + const restarted = new HraService({ + store: value.store, + paths: value.paths, + codex: new FakeCodex(), + claude: restartedManaged, + personalClaude: restartedPersonal, + personalCodexHome, + claudeProcessLiveness: (identity) => { + livenessChecks.push(identity); + return Promise.resolve("not_live"); + }, + cloud: new FakeCloud(), + daemonAuthority: new FakeDaemonAuthority(), + eventCursors: value.eventCursors, + now: () => personalAdoptionNow, + requestStop: () => undefined, + }); + await expect(restarted.recover()).resolves.toBeUndefined(); + await restarted.settled(); + + // The prior service closed the exact child successfully and durably + // released its process row before generation rollover. Restart therefore + // needs no second OS liveness inference for that already-proven exit. + expect(livenessChecks).toEqual([]); + expect(restartedPersonal.endRequests).toHaveLength(0); + expect(restartedManaged.calls).toEqual([]); + expect(restartedPersonal.claimRequests).toHaveLength(1); + expect(restartedPersonal.claimRequests[0]).toMatchObject({ + authority: { + id: value.accountId, + generation: oldGeneration + 1, + codexHome: personalCodexHome, + }, + providerThreadId: "personal-claude-restart", + sourceLiveness: "not_live", + title: "Adopted Claude restart personal thread", + }); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId: "personal-claude-restart", + profileId: value.accountId, + runtimeScope: "personal", + })).toMatchObject({ + identity: restartedPersonal.processIdentity, + profileGeneration: oldGeneration + 1, + sessionId: value.session.id, + state: "bound", + }); + expect(value.store.readSessionPersonalRuntimeBinding(value.session.id)) + .toMatchObject({ state: "active" }); + const shownAfterRestart = await restarted.execute({ + kind: "session.show", + session: value.session.id, + detail: false, + }, { signal }) as { + effectiveRuntimeProfile: Record; + projection: { title: string }; + session: { title: string }; + }; + expect(shownAfterRestart).toMatchObject({ + projection: { title: "Adopted Claude restart personal thread" }, + session: { title: "Adopted Claude restart personal thread" }, + }); + expect(shownAfterRestart.effectiveRuntimeProfile).not.toHaveProperty("configHome"); + + await expect(restarted.execute({ + kind: "session.send", + session: value.session.id, + message: "Continue after restart", + idempotencyKey: "00000000-0000-4000-8000-00000000c002", + }, { signal })).resolves.toMatchObject({ + session: { id: value.session.id, state: "idle" }, + }); + expect(restartedPersonal.turnRequests).toHaveLength(1); + expect(restartedManaged.turnRequests).toHaveLength(0); + await restarted.close(); + }); + + test("clean shutdown retires generation-zero adopted Claude without advancing a pristine account", async () => { + const value = await adoptedClaudeFixture( + "Generation-zero adopted Claude shutdown", + "generation-zero-personal-claude-shutdown", + undefined, + undefined, + "darwin", + false, + ); + const pristine = await value.service.execute({ + kind: "account.add", + label: "Pristine generation-zero account", + }, { signal }) as { account: { id: `acct_${string}` } }; + expect(value.store.requireProfileById(value.accountId)).toMatchObject({ + processGeneration: 0, + state: "signed_out", + }); + expect(value.store.requireProfileById(pristine.account.id)).toMatchObject({ + processGeneration: 0, + state: "signed_out", + }); + + await expect(value.service.close()).resolves.toBeUndefined(); + + expect(value.store.requireProfileById(value.accountId)).toMatchObject({ + processGeneration: 1, + state: "signed_out", + }); + expect(value.store.requireProfileById(pristine.account.id)).toMatchObject({ + processGeneration: 0, + state: "signed_out", + }); + expect(value.store.readSessionPersonalRuntimeBinding(value.session.id)).toMatchObject({ + state: "active", + }); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId: "generation-zero-personal-claude-shutdown", + profileId: value.accountId, + runtimeScope: "personal", + })).toMatchObject({ + profileGeneration: 0, + sessionId: value.session.id, + state: "released", + }); + + const restartedPersonal = new FakeClaude("personal", { + pid: 63_029, + pidDomain: "darwin", + procStart: "generation-one-personal-claude", + }); + restartedPersonal.projection = { + ...value.personalClaude.projection, + providerThreadId: "generation-zero-personal-claude-shutdown", + status: "idle", + }; + restartedPersonal.readIdentityErrorOnce = new Error("prior child is gone"); + restartedPersonal.observeErrorOnce = new ClaudeSessionObservationError(); + const restarted = new HraService({ + store: value.store, + paths: value.paths, + codex: new FakeCodex(), + claude: new FakeClaude("isolated", { + pid: 63_030, + pidDomain: "darwin", + procStart: "generation-one-managed-claude", + }), + personalClaude: restartedPersonal, + personalCodexHome, + claudeProcessLiveness: () => Promise.resolve("not_live"), + cloud: new FakeCloud(), + daemonAuthority: new FakeDaemonAuthority(), + eventCursors: value.eventCursors, + now: () => personalAdoptionNow, + requestStop: () => undefined, + }); + await expect(restarted.recover()).resolves.toBeUndefined(); + await restarted.settled(); + + expect(restartedPersonal.claimRequests).toHaveLength(1); + expect(restartedPersonal.claimRequests[0]).toMatchObject({ + authority: { id: value.accountId, generation: 1, codexHome: personalCodexHome }, + providerThreadId: "generation-zero-personal-claude-shutdown", + }); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId: "generation-zero-personal-claude-shutdown", + profileId: value.accountId, + runtimeScope: "personal", + })).toMatchObject({ + identity: restartedPersonal.processIdentity, + profileGeneration: 1, + sessionId: value.session.id, + state: "bound", + }); + await restarted.close(); + }); + + test("releases and resumes an adopted Claude controller lost between claim and commit", async () => { + const value = await adoptedClaudeFixture( + "Adopted Claude admission race", + "personal-claude-admission-race", + (runtime) => { runtime.disconnectOnObserveRequest = 2; }, + ); + await value.service.settled(); + + expect(value.personalClaude.endRequests).toHaveLength(1); + expect(value.personalClaude.claimRequests).toHaveLength(2); + expect(value.personalClaude.claimRequests[1]).toMatchObject({ + providerThreadId: "personal-claude-admission-race", + sourceLiveness: "not_live", + title: "Adopted Claude admission race personal thread", + }); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId: "personal-claude-admission-race", + profileId: value.accountId, + runtimeScope: "personal", + })).toMatchObject({ + sessionId: value.session.id, + state: "bound", + }); + expect(value.store.readSessionPersonalRuntimeBinding(value.session.id)) + .toMatchObject({ state: "active" }); + + await expect(value.service.execute({ + kind: "session.send", + session: value.session.id, + message: "Continue after admission recovery", + idempotencyKey: "00000000-0000-4000-8000-00000000c003", + }, { signal })).resolves.toMatchObject({ + session: { + id: value.session.id, + state: "idle", + title: "Adopted Claude admission race personal thread", + }, + }); + await value.service.close(); + }); + + test("retires a daemon generation without detaching adopted sessions", async () => { + const value = await adoptedCodexFixture( + "Adopted restart", + "personal-thread-restart", + ); + const before = value.store.requireProfileById(value.accountId).processGeneration; + + await expect(value.service.close()).resolves.toBeUndefined(); + + expect(value.store.requireProfileById(value.accountId).processGeneration).toBe(before + 1); + expect(value.store.readSessionPersonalRuntimeBinding(value.session.id)).toMatchObject({ + state: "active", + }); + }); + + test("keeps committed adoption authority and retries failed memory initialization", async () => { + const factsMemory = new FakeFactsMemoryLifecycle(); + factsMemory.ensureErrorOnce = new Error("lost adoption memory receipt"); + const value = await adoptedCodexFixture( + "Adopted initialization retry", + "personal-thread-initialization-retry", + factsMemory, + ); + + expect(value.store.readSessionPersonalRuntimeBinding(value.session.id)).toMatchObject({ + state: "active", + }); + expect(value.store.latestSessionRuntimeProfile(value.session.id)).toMatchObject({ + sourceKind: "session_start", + profile: { profileId: value.accountId, preset: "ultra" }, + }); + await waitFor(() => factsMemory.ensures.filter( + ({ sessionId }) => sessionId === value.session.id, + ).length >= 2); + expect(factsMemory.states.get(value.session.id)).toBe("active"); + expect(value.personalCodex.calls.filter((call) => call === "end")).toHaveLength(0); + }); + + test("admits and autoresponds to an adopted session approval through personal custody", async () => { + const value = await adoptedCodexFixture( + "Adopted approval", + "personal-thread-approval", + ); + value.store.setDefaultApprovalMode("auto:all"); + const authority = value.personalCodex.claimRequests[0]?.authority; + if (authority === undefined) throw new Error("Expected personal claim authority."); + const requestId = "adopted-approval-request"; + const connectionId = value.personalCodex.observationConnectionId; + + await value.service.observePersonalCodexFact(authority, { + type: "interactionRequested", + connectionId, + provider: { + profileId: value.session.profileId, + processGeneration: authority.generation, + connectionId, + requestId: { type: "string", value: requestId }, + method: "item/commandExecution/requestApproval", + requestDigest: createHash("sha256").update(requestId).digest("hex"), + threadId: "personal-thread-approval", + turnId: "turn-adopted-approval", + itemId: "item-adopted-approval", + approvalId: null, + }, + kind: "command_approval", + blocking: true, + display: { + kind: "command_approval", + summary: "Run the adopted session test", + reason: null, + commandClass: "bun test", + workingDirectory: null, + availableDecisions: ["once", "session", "decline", "cancel"], + }, + }); + + await waitFor(() => value.personalCodex.resolvedInteractions.length === 1); + expect(value.personalCodex.resolvedInteractions[0]).toMatchObject({ + kind: "command_approval", + resolution: { kind: "approval_decision", decision: "once" }, + }); + expect(value.codex.resolvedInteractions).toHaveLength(0); + await waitFor(() => value.store.listInteractions({ + sessionId: value.session.id, + limit: 10, + }).some((candidate) => + candidate.authority.requestId.value === requestId + && candidate.resolvedBy === "autorespond")); + const interaction = value.store.listInteractions({ + sessionId: value.session.id, + limit: 10, + }).find((candidate) => candidate.authority.requestId.value === requestId); + expect(interaction).toMatchObject({ + resolvedBy: "autorespond", + sessionId: value.session.id, + }); + expect(interaction?.state).not.toBe("pending"); + await waitFor(() => value.store.listAutorespondEvidence({ + sessionId: value.session.id, + }).length === 1); + expect(value.store.listAutorespondEvidence({ sessionId: value.session.id })[0]) + .toMatchObject({ + decision: "once", + kind: "command_approval", + mode: "auto:all", + outcome: "accepted", + }); + }); + + test("refuses protected approval inspection when the adopted Codex account changes in flight", async () => { + const value = await adoptedCodexFixture( + "Adopted inspection account race", + "personal-thread-inspection-account-race", + ); + value.store.setDefaultApprovalMode("manual"); + const seeded = await seedResolvableInteraction( + value, + value.session.id, + "adopted-inspection-account-race", + undefined, + "personal", + ); + value.personalCodex.interactionAuthority = { + kind: "command_approval", + command: "PRIVATE-OLD-ACCOUNT-COMMAND", + reason: null, + availableDecisions: ["accept", "decline", "cancel"], + workingDirectory: "/workspace", + environmentId: null, + commandActions: null, + networkApprovalContext: null, + additionalPermissions: null, + proposedExecpolicyAmendment: null, + proposedNetworkPolicyAmendments: null, + }; + let markInspectionStarted!: () => void; + const inspectionStarted = new Promise((resolve) => { + markInspectionStarted = resolve; + }); + let releaseInspection!: () => void; + const inspectionGate = new Promise((resolve) => { + releaseInspection = resolve; + }); + value.personalCodex.beforeInspectInteractionReturn = async () => { + markInspectionStarted(); + await inspectionGate; + }; + + const inspecting = value.service.execute({ + kind: "interaction.inspect", + interaction: seeded.interaction.publicId, + expectedRevision: seeded.interaction.revision, + }, { signal }); + await inspectionStarted; + value.personalCodex.accountProjection = { + signedIn: true, + email: "replacement-during-inspection@example.com", + plan: "Plus", + }; + releaseInspection(); + + await expect(inspecting).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + expect(value.personalCodex.inspectedInteractions).toHaveLength(1); + expect(value.store.requireInteraction(seeded.interaction.publicId)) + .toMatchObject({ state: "expired" }); + expect(value.store.requireSession(value.session.id)) + .toMatchObject({ state: "recovery_required" }); + await value.service.settled(); + expect(value.store.readSessionPersonalRuntimeBinding(value.session.id, true)) + .toMatchObject({ state: "detached" }); + }); + + test("replaying an applied account login leaves a newer managed Claude controller untouched", async () => { + let providerThreadId = "native-claude-login-replay"; + const loginIdempotencyKey = "00000000-0000-4000-8000-00000000c100"; + const identity: ClaudeProcessIdentity = { + pid: 63_019, + pidDomain: "darwin", + procStart: "native-claude-login-replay-process", + }; + const value = await nativeClaudeFixture( + "Native Claude login replay", + providerThreadId, + identity, + loginIdempotencyKey, + ); + providerThreadId = value.providerThreadId; + const profile = value.store.requireProfileById(value.accountId); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId, + profileId: value.accountId, + runtimeScope: "managed", + })).toMatchObject({ + identity, + profileGeneration: profile.processGeneration, + sessionId: value.session.id, + state: "bound", + }); + + await expect(value.service.execute({ + kind: "account.login", + account: value.accountId, + deviceCode: false, + idempotencyKey: loginIdempotencyKey, + }, { signal })).resolves.toMatchObject({ + account: { + id: value.accountId, + processGeneration: profile.processGeneration, + state: "signed_in", + }, + login: { status: "signed_in" }, + }); + + expect(value.codex.calls.filter((call) => call.startsWith("login:"))) + .toHaveLength(1); + expect(value.managedClaude.endRequests).toEqual([]); + expect(value.managedClaude.endedProcessIdentities).toEqual([]); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId, + profileId: value.accountId, + runtimeScope: "managed", + })).toMatchObject({ + identity, + profileGeneration: profile.processGeneration, + sessionId: value.session.id, + state: "bound", + }); + expect(value.store.requireProfileById(value.accountId)).toMatchObject({ + processGeneration: profile.processGeneration, + state: "signed_in", + }); + }); + + test("preserves exact native Claude process authority across explicit Codex logout", async () => { + let providerThreadId = "native-claude-explicit-logout"; + const identity: ClaudeProcessIdentity = { + pid: 63_020, + pidDomain: "darwin", + procStart: "native-claude-explicit-logout-process", + }; + const value = await nativeClaudeFixture( + "Native Claude explicit logout", + providerThreadId, + identity, + ); + providerThreadId = value.providerThreadId; + const profile = value.store.requireProfileById(value.accountId); + expect(value.store.listSessionPersonalRuntimeBindings()).toEqual([]); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId, + profileId: value.accountId, + runtimeScope: "managed", + })).toMatchObject({ + identity, + profileGeneration: profile.processGeneration, + sessionId: value.session.id, + state: "bound", + }); + + await expect(value.service.execute({ + kind: "account.logout", + account: value.accountId, + idempotencyKey: "00000000-0000-4000-8000-00000000c101", + }, { signal })).resolves.toMatchObject({ + account: { id: value.accountId, state: "signed_out" }, + }); + + expect(value.codex.calls.filter((call) => call === "logout")).toHaveLength(1); + expect(value.managedClaude.endRequests).toEqual([]); + expect(value.managedClaude.endedProcessIdentities).toEqual([]); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId, + profileId: value.accountId, + runtimeScope: "managed", + })).toMatchObject({ + identity, + profileGeneration: profile.processGeneration, + sessionId: value.session.id, + state: "bound", + }); + expect(value.store.requireProfileById(value.accountId).state).toBe("signed_out"); + + await expect(value.service.execute({ + kind: "session.send", + session: value.session.id, + message: "Continue under independent Claude authority", + idempotencyKey: "00000000-0000-4000-8000-00000000c102", + }, { signal })).resolves.toMatchObject({ + session: { id: value.session.id }, + }); + expect(value.managedClaude.turnRequests).toHaveLength(1); + }); + + test("durably revokes native Claude process authority after provider-observed signout", async () => { + let providerThreadId = "native-claude-observed-signout"; + const identity: ClaudeProcessIdentity = { + pid: 63_021, + pidDomain: "darwin", + procStart: "native-claude-observed-signout-process", + }; + const value = await nativeClaudeFixture( + "Native Claude observed signout", + providerThreadId, + identity, + ); + providerThreadId = value.providerThreadId; + const profile = value.store.requireProfileById(value.accountId); + let markReleaseStarted!: () => void; + const releaseStarted = new Promise((resolve) => { + markReleaseStarted = resolve; + }); + let finishRelease!: () => void; + const releaseGate = new Promise((resolve) => { + finishRelease = resolve; + }); + value.managedClaude.beforeEndSessionReturn = async () => { + markReleaseStarted(); + await releaseGate; + }; + + await expect(value.service.observeCodexAccount({ + id: profile.id, + generation: profile.processGeneration, + codexHome: "unused", + desktopUserData: "unused", + }, { signedIn: false })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + await releaseStarted; + try { + expect(value.store.readProfilePersonalAuthorityRevocation(value.accountId)) + .toMatchObject({ + profileGeneration: profile.processGeneration, + state: "releasing", + }); + expect(value.store.requireProfileById(value.accountId).state) + .toBe("recovery_required"); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId, + profileId: value.accountId, + runtimeScope: "managed", + })).toMatchObject({ + identity, + profileGeneration: profile.processGeneration, + sessionId: value.session.id, + state: "releasing", + }); + expect(value.store.listSessionPersonalRuntimeBindings()).toEqual([]); + } finally { + finishRelease(); + } + await value.service.settled(); + + expect(value.managedClaude.endRequests).toHaveLength(1); + expect(value.managedClaude.endedProcessIdentities).toEqual([identity]); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId, + profileId: value.accountId, + runtimeScope: "managed", + })).toMatchObject({ + identity, + profileGeneration: profile.processGeneration, + sessionId: value.session.id, + state: "released", + }); + expect(value.store.readProfilePersonalAuthorityRevocation(value.accountId)) + .toMatchObject({ + profileGeneration: profile.processGeneration, + state: "completed", + }); + expect(value.store.requireProfileById(value.accountId).state).toBe("signed_out"); + }); + + test("revokes managed Claude authority before accepting replaced or missing account identity", async () => { + const observations: readonly CodexAccountProjection[] = [ + { signedIn: true, email: "replacement@example.com", plan: "Plus" }, + { signedIn: true, plan: "Plus" }, + ]; + for (const [index, observation] of observations.entries()) { + let providerThreadId = `native-claude-account-identity-${String(index)}`; + const identity: ClaudeProcessIdentity = { + pid: 63_030 + index, + pidDomain: "darwin", + procStart: `native-claude-account-identity-${String(index)}`, + }; + const value = await nativeClaudeFixture( + `Native Claude account identity ${String(index)}`, + providerThreadId, + identity, + ); + providerThreadId = value.providerThreadId; + const profile = value.store.requireProfileById(value.accountId); + expect(profile.providerEmail).toBe("person@example.com"); + let markReleaseStarted!: () => void; + const releaseStarted = new Promise((resolve) => { + markReleaseStarted = resolve; + }); + let finishRelease!: () => void; + const releaseGate = new Promise((resolve) => { + finishRelease = resolve; + }); + value.managedClaude.beforeEndSessionReturn = async () => { + markReleaseStarted(); + await releaseGate; + }; + + await expect(value.service.observeCodexAccount({ + id: profile.id, + generation: profile.processGeneration, + codexHome: "unused", + desktopUserData: "unused", + }, observation)).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + await releaseStarted; + try { + expect(value.store.requireProfileById(value.accountId)).toMatchObject({ + providerEmail: "person@example.com", + state: "recovery_required", + }); + expect(value.store.readProfilePersonalAuthorityRevocation(value.accountId)) + .toMatchObject({ + profileGeneration: profile.processGeneration, + state: "releasing", + }); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId, + profileId: value.accountId, + runtimeScope: "managed", + })).toMatchObject({ + identity, + state: "releasing", + }); + } finally { + finishRelease(); + } + await value.service.settled(); + + const retired = value.store.requireProfileById(value.accountId); + expect(retired.state).toBe("signed_out"); + expect(retired.providerEmail).toBeUndefined(); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId, + profileId: value.accountId, + runtimeScope: "managed", + })?.state).toBe("released"); + await value.service.close(); + } + }); + + test("keeps a dormant native session bound to its original account across replacement", async () => { + const value = await fixture(); + const { sessionId } = await createIdleSession(value, "Native account identity fence"); + const session = value.store.requireSession(sessionId); + const original = value.store.requireProfileById(session.profileId); + expect(value.store.sessionAccountAuthorityMatches(session.id, original.id)).toBe(true); + + value.codex.accountProjection = { + signedIn: true, + email: "replacement-native-owner@example.com", + plan: "Plus", + }; + await expect(value.service.execute({ + kind: "account.show", + account: original.id, + }, { signal })).resolves.toMatchObject({ + account: { providerEmail: "person@example.com", state: "signed_in" }, + providerProjection: { email: "replacement-native-owner@example.com", signedIn: true }, + recovery: { required: true }, + }); + await value.service.settled(); + + expect(value.store.requireProfileById(original.id)).toMatchObject({ + processGeneration: original.processGeneration + 1, + state: "signed_out", + }); + expect(value.store.requireSession(session.id)).toMatchObject({ + providerThreadId: session.providerThreadId, + state: "recovery_required", + }); + expect(value.store.sessionAccountAuthorityMatches(session.id, original.id)).toBe(false); + + value.codex.loginResult = { + status: "signed_in", + account: { + signedIn: true, + email: "replacement-native-owner@example.com", + plan: "Plus", + }, + }; + await value.service.execute({ + kind: "account.login", + account: original.id, + deviceCode: false, + }, { signal }); + expect(value.store.sessionAccountAuthorityMatches(session.id, original.id)).toBe(false); + await expect(value.service.execute({ + kind: "session.list", + account: original.id, + archived: false, + limit: 10, + }, { signal })).resolves.toMatchObject({ sessions: [], nextCursor: null }); + await expect(value.service.execute({ + kind: "session.note.set", + session: session.id, + note: "replacement must not change original metadata", + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + await expect(value.service.execute({ + kind: "autorespond.set", + session: session.id, + mode: "auto:all", + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + await expect(value.service.execute({ + kind: "session.archive", + session: session.id, + archived: true, + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + const turnsBefore = value.codex.committedStartTurns; + await expect(value.service.execute({ + kind: "session.send", + session: session.id, + message: "must stay with the original identity", + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + expect(value.codex.committedStartTurns).toBe(turnsBefore); + }); + + test("account read revokes an adopted controller before exposing a replacement identity", async () => { + const value = await adoptedCodexFixture( + "Adopted account replacement", + "personal-thread-account-replacement", + ); + value.codex.accountProjection = { + signedIn: true, + email: "replacement@example.com", + plan: "Plus", + }; + + const shown = await value.service.execute({ + kind: "account.show", + account: value.accountId, + }, { signal }) as { + account: { providerEmail?: string; state: string }; + providerProjection: CodexAccountProjection; + recovery: { required: boolean }; + }; + expect(shown).toMatchObject({ + account: { + providerEmail: "person@example.com", + state: "signed_in", + }, + providerProjection: { signedIn: true, email: "replacement@example.com" }, + recovery: { required: true }, + }); + await value.service.settled(); + + const retired = value.store.requireProfileById(value.accountId); + expect(retired.state).toBe("signed_out"); + expect(retired.providerEmail).toBeUndefined(); + expect(value.store.readSessionPersonalRuntimeBinding(value.session.id, true)) + .toMatchObject({ state: "detached" }); + expect(value.store.readProfilePersonalAuthorityRevocation(value.accountId)) + .toMatchObject({ state: "completed" }); + await value.service.close(); + }); + + test("account read requires explicit login before binding a controller-free replacement identity", async () => { + const value = await fixture(); + const added = await value.service.execute( + { kind: "account.add", label: "Controller-free account replacement" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + expect(value.store.requireProfileById(added.account.id).providerEmail) + .toBe("person@example.com"); + value.codex.accountProjection = { + signedIn: true, + email: "replacement@example.com", + plan: "Plus", + }; + + await expect(value.service.execute({ + kind: "account.show", + account: added.account.id, + }, { signal })).resolves.toMatchObject({ + account: { + providerEmail: "person@example.com", + state: "signed_in", + }, + providerProjection: { email: "replacement@example.com", signedIn: true }, + recovery: { required: true }, + }); + await value.service.settled(); + expect(value.store.requireProfileById(added.account.id)).toMatchObject({ + processGeneration: 2, + state: "signed_out", + }); + expect(value.store.readProfilePersonalAuthorityRevocation(added.account.id)) + .toMatchObject({ profileGeneration: 1, state: "completed" }); + await value.service.close(); + }); + + test("supersedes completed non-null scoped Codex fences before account login", async () => { + const value = await fixture(); + const added = await value.service.execute( + { kind: "account.add", label: "Completed scoped Codex fences" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + const profile = value.store.requireProfileById(added.account.id); + const priorAccountKey = codexProviderAccountKey("prior-owner@example.com"); + const direct = new Database(value.paths.database, { create: false, strict: true }); + try { + for (const runtimeScope of ["personal", "managed"] as const) { + direct.query( + `INSERT INTO provider_runtime_account_revocations( + profile_id,profile_generation,provider,runtime_scope,current_account_key, + state,revision,created_at,updated_at,completed_at + ) VALUES (?,?,'codex',?,?, 'completed',1,?,?,?)`, + ).run( + profile.id, + profile.processGeneration, + runtimeScope, + priorAccountKey, + 1_000, + 1_000, + 1_000, + ); + } + } finally { + direct.close(); + } + + await expect(value.service.execute({ + account: profile.id, + deviceCode: false, + kind: "account.login", + }, { signal })).resolves.toMatchObject({ + account: { id: profile.id, state: "signed_in" }, + login: { status: "signed_in" }, + }); + + for (const runtimeScope of ["personal", "managed"] as const) { + expect(value.store.readProviderRuntimeAccountRevocation({ + profileId: profile.id, + provider: "codex", + runtimeScope, + })).toMatchObject({ + currentAccountKey: null, + profileGeneration: profile.processGeneration, + state: "completed", + }); + } + expect(value.codex.releasedAuthorities).toContainEqual({ + id: profile.id, + generation: profile.processGeneration, + codexHome: profilePaths(value.paths, profile.id).codexHome, + desktopUserData: profilePaths(value.paths, profile.id).desktopUserData, + }); + }); + + test("failed managed Claude shutdown preserves the live generation and exact process authority", async () => { + let providerThreadId = "native-claude-failed-shutdown"; + const identity: ClaudeProcessIdentity = { + pid: 63_022, + pidDomain: "darwin", + procStart: "native-claude-failed-shutdown-process", + }; + const value = await nativeClaudeFixture( + "Native Claude failed shutdown", + providerThreadId, + identity, + ); + providerThreadId = value.providerThreadId; + const profileBefore = value.store.requireProfileById(value.accountId); + const processBefore = value.store.readClaudeProcessAuthority({ + providerThreadId, + profileId: value.accountId, + runtimeScope: "managed", + }); + if (processBefore === null) throw new Error("Expected native Claude process authority."); + expect(processBefore).toMatchObject({ + identity, + profileGeneration: profileBefore.processGeneration, + sessionId: value.session.id, + state: "bound", + }); + value.managedClaude.closeError = new Error( + "managed Claude child exit could not be proved", + ); + + await expect(value.service.close()).rejects.toBeInstanceOf(Error); + + expect(value.codex.closeCalls).toBe(1); + expect(value.managedClaude.closeCalls).toBe(1); + expect(value.store.requireProfileById(value.accountId)).toMatchObject({ + processGeneration: profileBefore.processGeneration, + state: profileBefore.state, + }); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId, + profileId: value.accountId, + runtimeScope: "managed", + })).toEqual(processBefore); + }); + + test("successful managed Claude shutdown releases exact process authority before generation retirement", async () => { + let providerThreadId = "native-claude-clean-shutdown"; + const identity: ClaudeProcessIdentity = { + pid: 63_023, + pidDomain: "darwin", + procStart: "native-claude-clean-shutdown-process", + }; + const value = await nativeClaudeFixture( + "Native Claude clean shutdown", + providerThreadId, + identity, + ); + providerThreadId = value.providerThreadId; + const profileBefore = value.store.requireProfileById(value.accountId); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId, + profileId: value.accountId, + runtimeScope: "managed", + })).toMatchObject({ + identity, + profileGeneration: profileBefore.processGeneration, + sessionId: value.session.id, + state: "bound", + }); + + await expect(value.service.close()).resolves.toBeUndefined(); + + expect(value.codex.closeCalls).toBe(1); + expect(value.managedClaude.closeCalls).toBe(1); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId, + profileId: value.accountId, + runtimeScope: "managed", + })).toMatchObject({ + identity, + profileGeneration: profileBefore.processGeneration, + sessionId: value.session.id, + state: "released", + }); + expect(value.store.requireProfileById(value.accountId)).toMatchObject({ + processGeneration: profileBefore.processGeneration + 1, + state: profileBefore.state, + }); + }); + + test("releases adopted controllers before authoritative account sign-out completes", async () => { + const value = await adoptedCodexFixture( + "Adopted sign-out", + "personal-thread-sign-out", + ); + const authority = value.personalCodex.claimRequests[0]?.authority; + if (authority === undefined) throw new Error("Expected personal claim authority."); + const requestId = "adopted-sign-out-approval"; + await value.service.observePersonalCodexFact(authority, { + type: "interactionRequested", + connectionId: value.personalCodex.observationConnectionId, + provider: { + profileId: value.accountId, + processGeneration: authority.generation, + connectionId: value.personalCodex.observationConnectionId, + requestId: { type: "string", value: requestId }, + method: "item/commandExecution/requestApproval", + requestDigest: createHash("sha256").update(requestId).digest("hex"), + threadId: value.session.providerThreadId ?? null, + turnId: "turn-adopted-sign-out", + itemId: "item-adopted-sign-out", + approvalId: null, + }, + kind: "command_approval", + blocking: true, + display: { + kind: "command_approval", + summary: "Do not answer after sign-out", + reason: null, + commandClass: "bun test", + workingDirectory: null, + availableDecisions: ["once", "decline", "cancel"], + }, + }); + const interaction = value.store.listInteractions({ + sessionId: value.session.id, + pendingOnly: true, + limit: 10, + })[0]; + if (interaction === undefined) throw new Error("Expected pending interaction."); + const releasesBefore = value.personalCodex.releasedAuthorities.length; + + await expect(value.service.observeCodexAccount(authority, { signedIn: false })) + .rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + await value.service.settled(); + + expect(value.personalCodex.releasedAuthorities).toHaveLength(releasesBefore + 1); + expect(value.personalCodex.releasedAuthorities.at(-1)).toEqual(authority); + expect(value.store.requireProfileById(value.accountId).state).toBe("signed_out"); + expect(value.store.readSessionPersonalRuntimeBinding(value.session.id)).toBeNull(); + expect(value.store.readSessionPersonalRuntimeBinding(value.session.id, true)) + .toMatchObject({ state: "detached" }); + expect(value.store.requireSession(value.session.id)).toMatchObject({ + state: "recovery_required", + archivedAt: expect.any(Number), + }); + expect(value.store.requireInteraction(interaction.publicId).state).toBe("expired"); + expect(value.personalCodex.resolvedInteractions).toHaveLength(0); + expect(value.store.readSessionAdoptionPolicy("codex")).toMatchObject({ + enabled: false, + profileId: null, + }); + expect(value.store.readProfilePersonalAuthorityRevocation(value.accountId)) + .toMatchObject({ state: "completed" }); }); - const interaction = value.store.listInteractions({ - sessionId, - pendingOnly: true, - limit: 10, - }).find((candidate) => candidate.authority.requestId.value === requestId); - if (interaction === undefined) throw new Error("Expected a resolvable interaction."); - return { authority, interaction }; -} -const providerMutationCalls = (codex: FakeCodex): readonly string[] => codex.calls.filter( - (call) => call.startsWith("login:") || call.startsWith("start:") || call === "logout" || call === "send" || call === "steer" || call === "stop" || call === "rename", -); + test("recovers a durably staged personal-controller revocation after daemon loss", async () => { + const value = await adoptedCodexFixture( + "Adopted sign-out recovery", + "personal-thread-sign-out-recovery", + ); + const profile = value.store.requireProfileById(value.accountId); + value.store.stageProfilePersonalAuthorityRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + }); + const restartedGeneration = profile.processGeneration + 1; + value.store.nextDaemonGeneration(crypto.randomUUID()); + expect(value.store.requireProfileById(value.accountId).processGeneration) + .toBe(restartedGeneration); + expect(value.store.readProfilePersonalAuthorityRevocation(value.accountId)) + .toMatchObject({ + profileGeneration: restartedGeneration, + state: "releasing", + }); + const releasesBefore = value.personalCodex.releasedAuthorities.length; -const signal = new AbortController().signal; -const automaticResetWindowResetsAtSeconds = Math.floor(Date.now() / 1_000) - + 3 * 24 * 60 * 60; -const automaticResetWindowResetsAt = automaticResetWindowResetsAtSeconds * 1_000; + // Simulate process loss after the sign-out fact was staged but before the + // asynchronous controller release began. Recovery must finish from the + // durable revocation row before it admits any session resubscriptions. + const restarted = new HraService({ + store: value.store, + paths: value.paths, + codex: new FakeCodex(), + personalCodex: value.personalCodex, + personalCodexHome, + cloud: new FakeCloud(), + daemonAuthority: new FakeDaemonAuthority(), + eventCursors: value.eventCursors, + now: () => personalAdoptionNow, + requestStop: () => undefined, + }); + await expect(restarted.recover()).resolves.toBeUndefined(); -const renderHuman = (command: LocalCommand, data: unknown): string => { - let stdout = ""; - renderSuccess(command, data, false, { - writeStdout: (value) => { stdout += value; }, - writeStderr: () => undefined, + expect(value.personalCodex.releasedAuthorities).toHaveLength(releasesBefore + 1); + expect(value.personalCodex.releasedAuthorities.at(-1)).toMatchObject({ + id: value.accountId, + generation: restartedGeneration, + }); + expect(value.store.requireProfileById(value.accountId).state).toBe("signed_out"); + expect(value.store.readSessionPersonalRuntimeBinding(value.session.id)).toBeNull(); + expect(value.store.readSessionPersonalRuntimeBinding(value.session.id, true)) + .toMatchObject({ state: "detached" }); + expect(value.store.requireSession(value.session.id)).toMatchObject({ + state: "recovery_required", + archivedAt: expect.any(Number), + }); + expect(value.store.readProfilePersonalAuthorityRevocation(value.accountId)) + .toMatchObject({ state: "completed" }); + await restarted.close(); }); - return stdout; -}; -const renderJson = (command: LocalCommand, data: unknown): string => { - let stdout = ""; - renderSuccess(command, data, true, { - writeStdout: (value) => { stdout += value; }, - writeStderr: () => undefined, + test("releases a claimed personal thread when durable adoption collides", async () => { + const personalCodex = new FakeCodex(); + const discovery = new FakePersonalSessionDiscovery(); + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { + personalCodex, + personalCodexHome, + personalDiscovery: discovery, + }, + ); + const existingOwner = await value.service.execute( + { kind: "account.add", label: "Existing thread owner" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: existingOwner.account.id, + deviceCode: false, + }, { signal }); + await value.service.execute({ + kind: "project.add", + label: "Adoption collision project", + path: value.documents, + }, { signal }); + const existing = await value.service.execute({ + kind: "session.start", + account: existingOwner.account.id, + preset: "high", + fast: false, + }, { signal }) as { session: { id: `sess_${string}`; providerThreadId: string } }; + const adoptingOwner = await value.service.execute( + { kind: "account.add", label: "Adopting thread owner" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: adoptingOwner.account.id, + deviceCode: false, + }, { signal }); + personalCodex.readProjection = { + providerThreadId: existing.session.providerThreadId, + title: "Colliding personal thread", + status: "idle", + projectRoot: value.documents, + providerUpdatedAt: personalAdoptionNow - 1_000, + }; + discovery.candidates = [{ + provider: "codex", + providerThreadId: existing.session.providerThreadId, + title: "Colliding personal thread", + projectRoot: value.documents, + updatedAt: personalAdoptionNow - 1_000, + liveness: "not_live", + }]; + + await expect(value.service.execute({ + kind: "session.adoption.set", + provider: "codex", + enabled: true, + account: adoptingOwner.account.id, + }, { signal })).resolves.toMatchObject({ + discovery: { provider: "codex", state: "ready", adopted: 0, failed: 1 }, + }); + expect(personalCodex.calls.filter((call) => call === "claim")).toHaveLength(1); + expect(personalCodex.calls.filter((call) => call === "end")).toHaveLength(1); + expect(value.store.findSessionByProviderThread( + adoptingOwner.account.id, + existing.session.providerThreadId, + )).toBeNull(); + expect(value.store.listSessionPersonalRuntimeBindings()).toHaveLength(0); + expect(value.store.listSessionAdoptionCandidates({ provider: "codex" })[0]) + .toMatchObject({ status: "pending" }); }); - return stdout; -}; + + test("finishes a crash-left detach fence before skipping a quarantined session", async () => { + const value = await adoptedCodexFixture( + "Adopted detach restart", + "personal-thread-detach-restart", + ); + expect(value.store.quarantineSession(value.session.id)) + .toMatchObject({ state: "recovery_required" }); + value.store.beginPersonalSessionDetach({ sessionId: value.session.id }); + await value.service.close(); + + const personalCodex = new FakeCodex(); + const restarted = new HraService({ + store: value.store, + paths: value.paths, + codex: new FakeCodex(), + personalCodex, + personalCodexHome, + cloud: new FakeCloud(), + daemonAuthority: new FakeDaemonAuthority(), + eventCursors: value.eventCursors, + now: () => personalAdoptionNow, + requestStop: () => undefined, + }); + await restarted.recover(); + await restarted.settled(); + + expect(personalCodex.claimRequests).toHaveLength(0); + expect(personalCodex.releasedAuthorities).toHaveLength(1); + expect(value.store.readSessionPersonalRuntimeBinding(value.session.id, true)) + .toMatchObject({ state: "detached" }); + await restarted.close(); + }); +}); describe("HraService", () => { test("reports and CAS-updates notification hours with the injected clock only", async () => { @@ -1948,6 +8779,43 @@ describe("HraService", () => { }); }); + test("prepares managed Claude login without releasing an adopted personal Claude controller", async () => { + const value = await adoptedClaudeFixture( + "Personal Claude survives managed login", + "personal-claude-managed-login", + ); + const beforeSession = value.store.requireSession(value.session.id); + const beforeBinding = value.store.readSessionPersonalRuntimeBinding( + value.session.id, + ); + const beforeProcess = value.store.readClaudeProcessAuthority({ + providerThreadId: "personal-claude-managed-login", + profileId: value.accountId, + runtimeScope: "personal", + }); + value.managedClaude.accountProjection = { signedIn: false }; + + await expect(value.service.execute({ + kind: "account.claude-login.prepare", + account: value.accountId, + idempotencyKey: "00000000-0000-4000-8000-000000000715", + }, { signal })).resolves.toMatchObject({ + authentication: { provider: "claude", signedIn: false }, + login: { status: "launch_granted" }, + }); + + expect(value.managedClaude.endRequests).toEqual([]); + expect(value.personalClaude.endRequests).toEqual([]); + expect(value.store.requireSession(value.session.id)).toEqual(beforeSession); + expect(value.store.readSessionPersonalRuntimeBinding(value.session.id)) + .toEqual(beforeBinding); + expect(value.store.readClaudeProcessAuthority({ + providerThreadId: "personal-claude-managed-login", + profileId: value.accountId, + runtimeScope: "personal", + })).toEqual(beforeProcess); + }); + test("gates new Claude effects on Darwin but preserves exact unresolved login recovery", async () => { const value = await claudeAccountFixture(false, "darwin"); const added = await value.service.execute( @@ -2074,18 +8942,25 @@ describe("HraService", () => { test("keeps durable Claude state readable on Darwin without resubscribe, dispatch, or effects", async () => { const value = await claudeAccountFixture(false, "darwin"); - const profile = value.store.createProfile("Darwin upgrade"); - const idleCreated = value.store.createSession({ + const added = await value.service.execute( + { kind: "account.add", label: "Darwin upgrade" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + const profile = value.store.requireProfileById(added.account.id); + const idle = value.store.upsertProviderSession({ fastEnabled: false, preset: "fable-max", profileId: profile.id, provider: "claude", - }); - const idle = value.store.bindSession({ - expectedRevision: idleCreated.revision, providerThreadId: "claude-thread-darwin-idle", - sessionId: idleCreated.id, + providerAccountKey: claudeProviderAccountKey(), state: "idle", + title: "Darwin idle session", }); value.store.appendSessionEvent({ accountId: profile.id, @@ -2101,18 +8976,16 @@ describe("HraService", () => { sessionId: idle.id, }); const queued = value.store.enqueue(idle.id, "preserve pending queue"); - const activeCreated = value.store.createSession({ + const active = value.store.upsertProviderSession({ fastEnabled: false, preset: "fable-max", profileId: profile.id, provider: "claude", - }); - const active = value.store.bindSession({ activeTurnId: "claude-turn-darwin-active", - expectedRevision: activeCreated.revision, providerThreadId: "claude-thread-darwin-active", - sessionId: activeCreated.id, + providerAccountKey: claudeProviderAccountKey(), state: "active", + title: "Darwin active session", }); await value.service.recover(); @@ -2233,17 +9106,15 @@ describe("HraService", () => { state: "pending", }); - const recoveryCreated = value.store.createSession({ + const recoveryBound = value.store.upsertProviderSession({ fastEnabled: false, preset: "fable-max", profileId: profile.id, provider: "claude", - }); - const recoveryBound = value.store.bindSession({ - expectedRevision: recoveryCreated.revision, providerThreadId: "claude-thread-darwin-recovery", - sessionId: recoveryCreated.id, + providerAccountKey: claudeProviderAccountKey(), state: "idle", + title: "Darwin recovery session", }); value.store.quarantineSession(recoveryBound.id); await expect(value.service.execute({ @@ -2387,6 +9258,101 @@ describe("HraService", () => { expect(started.session.id).toMatch(/^sess_/u); }); + test("starts a native managed Devin session with current keyless authority", async () => { + const providerThreadId = "native-managed-devin"; + const connectionId = "018f1f55-3f10-7c1a-8f7b-c6dc608bcd3d"; + const reviewed: EffectiveDevinRuntimeProfile[] = []; + const devin = { + provider: "devin" as const, + readAccount: async () => ({ signedIn: true }), + reviewSessionStart: async ( + input: Parameters[0], + ) => { + const effectiveRuntimeProfile: EffectiveDevinRuntimeProfile = { + profileId: input.authority.id, + processGeneration: input.authority.generation, + observedAt: 2_000, + preset: "astra", + model: input.requirement.model, + reasoningEffort: "provider-default", + devinVersion: DEVIN_PIN, + protocolVersion: 1, + isolatedHome: true, + }; + reviewed.push(effectiveRuntimeProfile); + return { + reviewId: crypto.randomUUID(), + kind: "session_start" as const, + effectiveRuntimeProfile, + }; + }, + discardRuntimeReview: () => undefined, + startSession: async ( + input: Parameters[0], + ) => ({ + providerThreadId, + title: "Native managed Devin", + status: "idle" as const, + providerUpdatedAt: 2_001, + effectiveRuntimeProfile: input.review.effectiveRuntimeProfile, + }), + observeSession: async () => ({ + connectionId, + projection: { + providerThreadId, + title: "Native managed Devin", + status: "idle" as const, + providerUpdatedAt: 2_001, + }, + resumed: true, + }), + endSession: async () => undefined, + close: async () => undefined, + } as unknown as DevinRuntimePort; + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + Date.now, + undefined, + { devin }, + ); + const added = await value.service.execute( + { kind: "account.add", label: "Native managed Devin" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "project.add", + label: "Native managed Devin project", + path: value.documents, + }, { signal }); + + const started = await value.service.execute({ + kind: "session.start", + account: added.account.id, + provider: "devin", + preset: "astra", + fast: false, + }, { signal }) as { + session: SessionRecord; + effectiveRuntimeProfile: Record; + }; + + expect(reviewed).toHaveLength(1); + expect(started.session).toMatchObject({ + provider: "devin", + providerThreadId, + preset: "astra", + state: "idle", + }); + expect(value.store.readSessionProviderAccountAuthority(started.session.id)).toBeNull(); + expect(value.store.requireSessionPresetRequirement(started.session.id)).toEqual({ + preset: "astra", + requirement: presetRequirements.astra, + }); + expect(started.effectiveRuntimeProfile).not.toHaveProperty("isolatedHome"); + }); + test("archives and unarchives a session and filters the default listing", async () => { const { service, documents } = await fixture(); const added = await service.execute( @@ -2482,6 +9448,7 @@ describe("HraService", () => { listing: { accountSelector: added.account.id, accountState: "signed_out", + provider: "codex", scope: "local_only", freshness: "stale", localCompleteness: "complete", @@ -2537,6 +9504,7 @@ describe("HraService", () => { expect(finalListing).toEqual({ accountSelector: added.account.id, accountState: "signed_out", + provider: "codex", scope: "local_only", freshness: "stale", localCompleteness: "complete", @@ -2672,7 +9640,10 @@ describe("HraService", () => { { kind: "account.add", label: "Project custody" }, { signal }, ) as { account: { id: `acct_${string}` } }; - expect(store.setProfileState(added.account.id, 0, "signed_in")).toBe(true); + expect(store.setProfileState(added.account.id, 0, "signed_in", { + email: "project-custody@example.com", + plan: "Plus", + })).toBe(true); await service.execute({ kind: "project.add", label: "Custody docs", @@ -2987,7 +9958,7 @@ describe("HraService", () => { expect(value.codex.sessionListRequests).toHaveLength(callsBeforeInvalid); }); - test("pages durable non-Codex sessions before Codex without duplicates or private provider reads", async () => { + test("pages every durable provider session before Codex discovery without duplicates", async () => { const value = await fixture(); const added = await value.service.execute( { kind: "account.add", label: "Mixed provider history" }, @@ -2999,32 +9970,47 @@ describe("HraService", () => { deviceCode: false, }, { signal }); - const localSessions = [ - value.store.createSession({ + const firstClaude = value.store.upsertProviderSession({ profileId: added.account.id, provider: "claude", + providerThreadId: "durable-claude-one", + providerAccountKey: claudeProviderAccountKey("durable-claude-one"), preset: "fable-max", fastEnabled: false, + state: "idle", title: "Durable Claude one", - }), - value.store.createSession({ + }); + const startingDevin = value.store.createSession({ profileId: added.account.id, provider: "devin", preset: "astra", fastEnabled: false, title: "Durable Devin", - }), - value.store.createSession({ + }); + const durableDevin = value.store.bindSession({ + sessionId: startingDevin.id, + expectedRevision: startingDevin.revision, + providerThreadId: "durable-devin", + state: "idle", + }); + const secondClaude = value.store.upsertProviderSession({ profileId: added.account.id, provider: "claude", + providerThreadId: "durable-claude-two", + providerAccountKey: claudeProviderAccountKey("durable-claude-two"), preset: "fable-max", fastEnabled: false, + state: "idle", title: "Durable Claude two", - }), - ]; + }); + const localSessions = [firstClaude, durableDevin, secondClaude]; const existingCodex = value.store.upsertProviderSession({ + fastEnabled: false, profileId: added.account.id, + preset: "high", + provider: "codex", providerThreadId: "provider-existing-codex", + providerAccountKey: codexProviderAccountKey(), state: "idle", title: "Existing Codex cache", }); @@ -3039,7 +10025,10 @@ describe("HraService", () => { nextCursor: string; }; expect(first.sessions).toHaveLength(2); - expect(first.sessions.every((session) => session.provider !== "codex")).toBe(true); + expect(first.sessions.every((session) => [ + ...localSessions.map((local) => local.id), + existingCodex.id, + ].includes(session.id))).toBe(true); expect(value.codex.sessionListRequests).toHaveLength(0); value.codex.listedProjections = [{ @@ -3059,8 +10048,20 @@ describe("HraService", () => { nextCursor: string; }; expect(second.sessions).toHaveLength(2); - expect(second.sessions.filter((session) => session.provider !== "codex")).toHaveLength(1); - expect(second.sessions.filter((session) => session.id === existingCodex.id)).toHaveLength(1); + expect(value.codex.sessionListRequests).toHaveLength(0); + + const third = await value.service.execute({ + kind: "session.list", + archived: false, + account: added.account.id, + cursor: second.nextCursor, + limit: 2, + }, { signal }) as { + sessions: readonly { id: string; provider: string }[]; + nextCursor: string; + }; + expect(third.sessions).toHaveLength(0); + expect(value.codex.sessionListRequests).toHaveLength(1); value.codex.listedProjections = [{ providerThreadId: "provider-new-codex", @@ -3068,19 +10069,19 @@ describe("HraService", () => { title: "Next Codex page", }]; value.codex.listedNextCursor = null; - const third = await value.service.execute({ + const fourth = await value.service.execute({ kind: "session.list", archived: false, account: added.account.id, - cursor: second.nextCursor, + cursor: third.nextCursor, limit: 2, }, { signal }) as { sessions: readonly { id: string; provider: string }[]; nextCursor: null; }; - const allReturned = [...first.sessions, ...second.sessions, ...third.sessions]; - const nextCodex = third.sessions[0]; + const allReturned = [...first.sessions, ...second.sessions, ...third.sessions, ...fourth.sessions]; + const nextCodex = fourth.sessions[0]; if (nextCodex === undefined) throw new Error("Expected the final Codex session page."); expect(new Set(allReturned.map((session) => session.id)).size).toBe(allReturned.length); expect(new Set(allReturned.map((session) => session.id))).toEqual(new Set([ @@ -3088,12 +10089,12 @@ describe("HraService", () => { existingCodex.id, nextCodex.id, ])); - expect(third).toMatchObject({ + expect(fourth).toMatchObject({ sessions: [{ provider: "codex" }], nextCursor: null, }); expect(value.codex.sessionListRequests.map(({ cursor, limit }) => ({ cursor, limit }))).toEqual([ - { cursor: undefined, limit: 1 }, + { cursor: undefined, limit: 2 }, { cursor: "provider-page-two", limit: 2 }, ]); }); @@ -3410,11 +10411,19 @@ describe("HraService", () => { status: "terminal", title: "List terminal memory", }]; + const localPage = await value.service.execute({ + account: session.profileId, + kind: "session.list", + archived: false, + limit: 20, + }, { signal }) as { nextCursor: string | null }; + if (localPage.nextCursor === null) throw new Error("Expected a provider-discovery continuation."); await value.service.execute({ account: session.profileId, kind: "session.list", archived: false, limit: 20, + cursor: localPage.nextCursor, }, { signal }); expect(value.store.requireSession(sessionId).state).toBe("terminal"); expect(factsMemory.cleanups).toContainEqual({ @@ -3426,16 +10435,24 @@ describe("HraService", () => { const crashFactsMemory = new FakeFactsMemoryLifecycle(); const otherSession = value.store.upsertProviderSession({ profileId: session.profileId, + provider: "codex", providerThreadId: "provider-thread-crash-terminal", + preset: "high", + fastEnabled: false, providerUpdatedAt: 1, + providerAccountKey: codexProviderAccountKey(), state: "idle", title: "Crash terminal memory", }); if (otherSession.providerThreadId === undefined) throw new Error("Expected provider binding."); value.store.upsertProviderSession({ profileId: otherSession.profileId, + provider: "codex", providerThreadId: otherSession.providerThreadId, + preset: "high", + fastEnabled: false, providerUpdatedAt: (otherSession.providerUpdatedAt ?? 0) + 1, + providerAccountKey: codexProviderAccountKey(), state: "terminal", title: otherSession.title, }); @@ -3593,11 +10610,19 @@ describe("HraService", () => { status: "idle", title: session.title, }]; + const localPage = await value.service.execute({ + account: profile.id, + kind: "session.list", + archived: false, + limit: 20, + }, { signal }) as { nextCursor: string | null }; + if (localPage.nextCursor === null) throw new Error("Expected a provider-discovery continuation."); await value.service.execute({ account: profile.id, kind: "session.list", archived: false, limit: 20, + cursor: localPage.nextCursor, }, { signal }); expect(factsMemory.ensures.at(-1)).toEqual({ expiresAt: now + FACTS_MEMORY_SESSION_TTL_MS, @@ -3649,15 +10674,23 @@ describe("HraService", () => { const poisoned = value.store.upsertProviderSession({ profileId: profile.id, + provider: "codex", providerThreadId: "provider-terminal-poisoned", + preset: "high", + fastEnabled: false, providerUpdatedAt: 1, + providerAccountKey: codexProviderAccountKey(), state: "terminal", title: "Poisoned terminal memory", }); const healthy = value.store.upsertProviderSession({ profileId: profile.id, + provider: "codex", providerThreadId: "provider-terminal-healthy", + preset: "high", + fastEnabled: false, providerUpdatedAt: 1, + providerAccountKey: codexProviderAccountKey(), state: "terminal", title: "Healthy terminal memory", }); @@ -3765,7 +10798,7 @@ describe("HraService", () => { expect(after).toMatchObject({ usage: [{ snapshot: { payload: { primary: { usedPercent: 25 } } } }] }); }); - test("reconciles a changed provider identity before reading below-threshold usage", async () => { + test("revokes a changed provider identity before reading below-threshold usage", async () => { const { service, codex, store } = await fixture(); const added = await service.execute({ kind: "account.add", @@ -3791,7 +10824,8 @@ describe("HraService", () => { kind: "account.usage", account: added.account.id, refresh: true, - }, { signal })).rejects.toMatchObject({ code: "CONFLICT" }); + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + await service.settled(); expect(codex.calls.slice(callsBefore)).toEqual(["readAccount"]); expect(store.latestUsage(added.account.id)).toBeNull(); expect(codex.resetIdempotencyKeys).toEqual([]); @@ -3806,7 +10840,7 @@ describe("HraService", () => { refresh: false, }, { signal })).resolves.toMatchObject({ usage: [{ - account: { providerEmail: "other@example.com" }, + account: { processGeneration: 2, state: "signed_out" }, automaticReset: { policy: { state: "reconciliation_required" } }, poll: { state: "never_observed" }, snapshot: null, @@ -3841,7 +10875,8 @@ describe("HraService", () => { kind: "account.usage", account: added.account.id, refresh: true, - }, { signal })).rejects.toMatchObject({ code: "CONFLICT" }); + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + await service.settled(); expect(codex.calls.slice(-3)).toEqual(["readAccount", "usage", "readAccount"]); expect(store.latestUsage(added.account.id)).toBeNull(); expect(codex.resetIdempotencyKeys).toEqual([]); @@ -4018,7 +11053,8 @@ describe("HraService", () => { kind: "account.usage", account: added.account.id, refresh: true, - }, { signal })).rejects.toMatchObject({ code: "CONFLICT" }); + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + await service.settled(); expect(identityChanged).toBe(true); expect(codex.calls.slice(callsBefore)).toEqual([ "readAccount", @@ -4028,7 +11064,8 @@ describe("HraService", () => { ]); expect(codex.resetIdempotencyKeys).toEqual([]); expect(store.requireProfileById(added.account.id)).toMatchObject({ - providerEmail: "replacement@example.com", + processGeneration: 2, + state: "signed_out", }); expect(store.requireAccountRateLimitResetPolicy(added.account.id)).toMatchObject({ state: "reconciliation_required", @@ -4045,6 +11082,19 @@ describe("HraService", () => { }], }); + codex.loginResult = { + status: "signed_in", + account: { + signedIn: true, + email: "replacement@example.com", + plan: "Plus", + }, + }; + await service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); await expect(service.execute({ kind: "account.usage", account: added.account.id, @@ -4244,6 +11294,73 @@ describe("HraService", () => { const legacy = new Database(value.paths.database, { create: false, strict: true }); try { legacy.exec("PRAGMA foreign_keys=OFF"); + const adoptionTables = [ + "session_claude_process_authorities", + "session_claude_process_launch_intents", + "session_personal_runtime_bindings", + "session_adoption_candidates", + "session_adoption_policies", + "session_adoption_profile_generation_permits", + "profile_personal_authority_revocations", + "provider_runtime_account_revocations", + "session_provider_account_authorities", + "session_account_authorities", + ] as const; + const adoptionObjects = legacy.query(` + SELECT name,type FROM sqlite_master + WHERE name NOT LIKE 'sqlite_%' AND ( + (type='index' AND ( + tbl_name IN (${adoptionTables.map(() => "?").join(",")}) + OR name='sessions_profile_created' + )) + OR (type='trigger' AND ( + sql LIKE '%session_account_authorit%' + OR sql LIKE '%provider_runtime_account_revocation%' + OR sql LIKE '%session_adoption_%' + OR sql LIKE '%session_personal_runtime_%' + OR sql LIKE '%session_claude_process_%' + OR sql LIKE '%profile_personal_authority_%' + OR sql LIKE '%codex_account_key%' + )) + ) + ORDER BY CASE type WHEN 'trigger' THEN 0 ELSE 1 END,name + `).all(...adoptionTables) as Array<{ + name: string; + type: "index" | "trigger"; + }>; + for (const object of adoptionObjects) { + if (!/^[a-z0-9_]+$/u.test(object.name) || !["index", "trigger"].includes(object.type)) { + throw new Error("Unexpected session-adoption schema object."); + } + legacy.exec(`DROP ${object.type.toUpperCase()} IF EXISTS "${object.name}"`); + } + for (const table of adoptionTables) { + legacy.exec(`DROP TABLE IF EXISTS "${table}"`); + } + const presetContractTriggers = legacy.query(` + SELECT name FROM sqlite_master + WHERE type='trigger' AND sql LIKE '%preset_contract%' + ORDER BY name + `).all() as Array<{ name: string }>; + for (const { name } of presetContractTriggers) { + if (!/^[a-z0-9_]+$/u.test(name)) { + throw new Error("Unexpected preset-contract trigger name."); + } + legacy.exec(`DROP TRIGGER IF EXISTS "${name}"`); + } + legacy.exec(` + ALTER TABLE profiles DROP COLUMN codex_account_key; + ALTER TABLE sessions DROP COLUMN preset_contract; + ALTER TABLE works DROP COLUMN preset_contract; + DROP TABLE IF EXISTS attention_email_policy; + DROP TABLE IF EXISTS notification_hours; + DROP TABLE IF EXISTS session_provider_switch_source_releases; + DROP TABLE IF EXISTS session_provider_switch_seed_results; + DROP TABLE IF EXISTS session_provider_switch_seed_intents; + DROP TABLE IF EXISTS session_provider_switch_target_releases; + DROP TABLE IF EXISTS session_provider_switch_targets; + DROP TABLE IF EXISTS session_mutation_authority_rebinds; + `); const resetTriggers = legacy.query( `SELECT name FROM sqlite_master WHERE type='trigger' AND name GLOB 'account_rate_limit_reset_*' @@ -4304,7 +11421,7 @@ describe("HraService", () => { }); const inspector = new Database(value.paths.database, { readonly: true, strict: true }); try { - expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 39 }); + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); expect(inspector.query( "SELECT version FROM migrations WHERE version>=25 ORDER BY version", ).all()).toEqual([ @@ -4323,6 +11440,7 @@ describe("HraService", () => { { version: 37 }, { version: 38 }, { version: 39 }, + { version: 40 }, ]); } finally { inspector.close(false); @@ -5166,12 +12284,12 @@ describe("HraService", () => { kind: "account.usage", account: added.account.id, refresh: true, - }, { signal })).rejects.toMatchObject({ code: "CONFLICT" }); + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + await service.settled(); expect(codex.resetIdempotencyKeys).toHaveLength(1); expect(store.requireProfileById(added.account.id)).toMatchObject({ - state: "signed_in", - providerEmail: "someone-else@example.com", - providerPlan: "Plus", + processGeneration: 2, + state: "signed_out", }); expect(store.requireAccountRateLimitResetPolicy(added.account.id)).toMatchObject({ state: "reconciliation_required", @@ -5188,7 +12306,7 @@ describe("HraService", () => { refresh: false, }, { signal })).resolves.toMatchObject({ usage: [{ - account: { providerEmail: "someone-else@example.com" }, + account: { processGeneration: 2, state: "signed_out" }, automaticReset: { lastAttempt: null, observation: { @@ -5200,6 +12318,19 @@ describe("HraService", () => { }], }); + codex.loginResult = { + status: "signed_in", + account: { + signedIn: true, + email: "someone-else@example.com", + plan: "Plus", + }, + }; + await service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); await expect(service.execute({ kind: "account.usage", account: added.account.id, @@ -5855,19 +12986,44 @@ describe("HraService", () => { expect(store.latestSessionRuntimeProfile(started.session.id)).toMatchObject({ revision: 2, sourceKind: "turn_start" }); }); - test("holds send and metadata behind the full projection recovery fence without a provider mutation", async () => { + test("lets the cloud projection reader reacquire authority while the in-flight fence rejects concurrent mutations", async () => { const cloud = new FakeCloud(); const value = await fixture(undefined, cloud); const { sessionId } = await createIdleSession(value, "Projection serialization"); const recoverySession = value.store.requireSession(sessionId); const recoveryProfile = value.store.requireProfileById(recoverySession.profileId); if (recoverySession.providerThreadId === undefined) throw new Error("Expected a bound recovery session."); + const peerCreated = value.store.createSession({ + profileId: recoveryProfile.id, + title: "Same-account recovery peer", + provider: "codex", + preset: "high", + fastEnabled: false, + }); + const peer = value.store.bindSession({ + sessionId: peerCreated.id, + expectedRevision: peerCreated.revision, + providerThreadId: "same-account-recovery-peer", + state: "idle", + }); + value.store.bindSessionProviderAccountAuthority({ + sessionId: peer.id, + provider: "codex", + runtimeScope: "managed", + accountKey: codexProviderAccountKey(), + }); const providerWritesBefore = providerMutationCalls(value.codex); let entered!: () => void; const recoveryEntered = new Promise((resolve) => { entered = resolve; }); let release!: () => void; const recoveryGate = new Promise((resolve) => { release = resolve; }); cloud.beforeProjectionRecoveryReturn = async () => { + await expect(value.service.readSessionProjectionForCloud( + sessionId, + signal, + )).resolves.toMatchObject({ + providerThreadId: recoverySession.providerThreadId, + }); await value.service.observeCodexFact({ codexHome: "unused", desktopUserData: "unused", @@ -5878,12 +13034,6 @@ describe("HraService", () => { threadId: recoverySession.providerThreadId as string, type: "threadNameUpdated", }); - await value.service.observeCodexAccount({ - codexHome: "unused", - desktopUserData: "unused", - generation: recoveryProfile.processGeneration, - id: recoveryProfile.id, - }, { signedIn: false }); entered(); await recoveryGate; }; @@ -5896,24 +13046,30 @@ describe("HraService", () => { }, { signal }); await recoveryEntered; - let sendSettled = false; - const send = value.service.execute({ + await expect(value.service.execute({ + acknowledgeGap: true, + idempotencyKey: "00000000-0000-4000-8000-000000000804", + kind: "sync.projection-recover", + session: peer.id, + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + await expect(value.service.execute({ + kind: "session.note.set", + note: "must remain fenced with its account peer", + session: peer.id, + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + await expect(value.service.execute({ idempotencyKey: "00000000-0000-4000-8000-000000000802", kind: "session.send", message: "after recovery", session: sessionId, - }, { signal }).finally(() => { sendSettled = true; }); - let metadataSettled = false; - const metadata = value.service.execute({ + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + await expect(value.service.execute({ idempotencyKey: "00000000-0000-4000-8000-000000000803", kind: "session.note.set", note: "after recovery", session: sessionId, - }, { signal }).finally(() => { metadataSettled = true; }); - await Bun.sleep(5); + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); - expect(sendSettled).toBe(false); - expect(metadataSettled).toBe(false); expect(value.store.requireSession(sessionId)).toMatchObject({ note: "", title: recoverySession.title, @@ -5929,11 +13085,107 @@ describe("HraService", () => { release(); await expect(recovery).resolves.toBe(cloud.projectionRecoveryResult); - await Promise.all([send, metadata]); + await expect(value.service.execute({ + idempotencyKey: "00000000-0000-4000-8000-000000000802", + kind: "session.send", + message: "after recovery", + session: sessionId, + }, { signal })).resolves.toBeDefined(); + await expect(value.service.execute({ + idempotencyKey: "00000000-0000-4000-8000-000000000803", + kind: "session.note.set", + note: "after recovery", + session: sessionId, + }, { signal })).resolves.toBeDefined(); expect(value.store.requireSession(sessionId).note).toBe("after recovery"); expect(value.codex.calls.filter((call) => call === "send")).toHaveLength(1); }); + test("fences manual, autorespond, and deadline interaction effects during projection recovery", async () => { + let now = 125_000; + const cloud = new FakeCloud(); + const value = await fixture( + undefined, + cloud, + () => undefined, + () => now, + ); + const { sessionId } = await createIdleSession(value, "Projection interaction fence"); + const session = value.store.requireSession(sessionId); + const profile = value.store.requireProfileById(session.profileId); + const manual = await seedResolvableInteraction( + value, + sessionId, + "projection-manual-fence", + { requestedAt: now, deadlineAt: now + 10_000 }, + ); + let entered!: () => void; + const recoveryEntered = new Promise((resolve) => { entered = resolve; }); + let release!: () => void; + const recoveryGate = new Promise((resolve) => { release = resolve; }); + cloud.beforeProjectionRecoveryReturn = async () => { + entered(); + await recoveryGate; + }; + const recovery = value.service.execute({ + acknowledgeGap: true, + idempotencyKey: "00000000-0000-4000-8000-000000000805", + kind: "sync.projection-recover", + session: sessionId, + }, { signal }); + await recoveryEntered; + + await expect(value.service.execute({ + kind: "interaction.resolve", + interaction: manual.interaction.publicId, + expectedRevision: manual.interaction.revision, + resolution: { kind: "approval_decision", decision: "once" }, + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + + const deadline = await seedResolvableInteraction( + value, + sessionId, + "projection-deadline-fence", + { requestedAt: now, deadlineAt: now + 1 }, + ); + now += 1; + await expect(value.service.maintainInteractionDeadlines()).resolves.toEqual({ + examined: 1, + failed: 1, + }); + + value.store.setSessionApprovalMode(sessionId, "auto:all"); + const automatic = await seedResolvableInteraction( + value, + sessionId, + "projection-autorespond-fence", + { requestedAt: now, deadlineAt: now + 10_000 }, + ); + const evidenceDeadline = Date.now() + 2_000; + while (value.store.listAutorespondEvidence({ sessionId }).length === 0) { + if (Date.now() >= evidenceDeadline) { + throw new Error("Timed out waiting for fenced autorespond evidence."); + } + await Bun.sleep(5); + } + + expect(value.codex.validatedInteractions).toEqual([]); + expect(value.codex.resolvedInteractions).toEqual([]); + expect(value.codex.validatedInteractionTimeouts).toEqual([]); + expect(value.codex.timedOutInteractions).toEqual([]); + expect(value.store.requireInteraction(manual.interaction.publicId).state).toBe("pending"); + expect(value.store.requireInteraction(deadline.interaction.publicId).state).toBe("pending"); + expect(value.store.requireInteraction(automatic.interaction.publicId).state).toBe("pending"); + expect(value.store.listAutorespondEvidence({ sessionId })).toMatchObject([{ + interactionId: automatic.interaction.publicId, + outcome: "refused", + }]); + expect(value.store.requireProfileById(profile.id).state).toBe("signed_in"); + + release(); + await expect(recovery).resolves.toBe(cloud.projectionRecoveryResult); + }); + test("routes same-key projection recovery replay through the same closed cloud seam", async () => { const cloud = new FakeCloud(); const value = await fixture(undefined, cloud); @@ -6022,11 +13274,6 @@ describe("HraService", () => { idempotencyKey: "00000000-0000-4000-8000-000000000812", kind: "account.logout", }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); - value.codex.accountProjection = { signedIn: false }; - await expect(value.service.execute({ - account: profile.id, - kind: "account.show", - }, { signal })).resolves.toMatchObject({ recovery: { cleared: false, required: true } }); expect(value.store.requireProfileById(profile.id).state).toBe("signed_in"); value.codex.listedProjections = [{ providerThreadId: sessionBefore.providerThreadId as string, @@ -6059,9 +13306,6 @@ describe("HraService", () => { generation: profile.processGeneration, id: profile.id, } as const; - await value.service.observeCodexAccount(observerAuthority, { - signedIn: false, - }); value.codex.readProjection = { ...value.codex.readProjection, activeTurnId: "foreign-active-turn", @@ -6069,16 +13313,12 @@ describe("HraService", () => { status: "active", }; const beforeBlockedShow = value.store.requireSession(sessionId); - const blockedShow = await value.service.execute({ + await expect(value.service.execute({ detail: false, kind: "session.show", session: sessionId, - }, { signal }); - expect(blockedShow).toMatchObject({ - recovery: { cleared: false, required: true }, - session: beforeBlockedShow, - }); - expect(blockedShow).not.toHaveProperty("projection"); + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + expect(value.store.requireSession(sessionId)).toEqual(beforeBlockedShow); await value.service.observeCodexFact(observerAuthority, { status: { type: "systemError" }, threadId: sessionBefore.providerThreadId as string, @@ -6244,7 +13484,7 @@ describe("HraService", () => { generation: profile.processGeneration, id: profile.id, }; - const connectionId = "32000000-0000-4000-8000-000000000099"; + const connectionId = value.codex.observationConnectionId; let entered!: () => void; const recoveryEntered = new Promise((resolve) => { entered = resolve; }); let release!: () => void; @@ -6801,10 +14041,14 @@ describe("HraService", () => { const project = await service.execute({ kind: "project.add", label: "Docs", path: documents }, { signal }) as { project: { id: string } }; const imported = store.upsertProviderSession({ profileId: added.account.id, + provider: "codex", providerThreadId: "provider-thread", + preset: "high", + fastEnabled: false, title: "Imported without project", state: "idle", providerUpdatedAt: 10, + providerAccountKey: codexProviderAccountKey(), }); const queued = await service.execute({ kind: "session.queue", session: imported.id, message: "run after project" }, { signal }) as { queued: { id: `queue_${string}` } }; await service.settled(); @@ -6836,7 +14080,11 @@ describe("HraService", () => { const imported = store.upsertProviderSession({ profileId: added.account.id, projectId: project.id, + provider: "codex", providerThreadId: "provider-thread-imported-send", + providerAccountKey: codexProviderAccountKey(), + preset: "high", + fastEnabled: false, title: "Imported send", state: "idle", providerUpdatedAt: 10, @@ -7465,10 +14713,11 @@ describe("HraService", () => { await service.close(); }); - test("records a determinate provider rejection as failed when the daemon fence closes during the effect", async () => { + test("leaves a logout rejection unsettled when the daemon fence closes before exact controller release", async () => { const { service, codex, store, daemonAuthority } = await fixture(); const added = await service.execute({ kind: "account.add", label: "Fence loss" }, { signal }) as { account: { id: `acct_${string}` } }; await service.execute({ kind: "account.login", account: added.account.id, deviceCode: false }, { signal }); + const releasesBeforeLogout = codex.releasedAuthorities.length; codex.logoutError = new Error("provider rejected the logout"); codex.beforeLogoutReturn = async () => { daemonAuthority.invalidate(); @@ -7479,9 +14728,17 @@ describe("HraService", () => { account: added.account.id, idempotencyKey, }, { signal })).rejects.toBeInstanceOf(DaemonAuthoritySafetyError); - expect(store.readMutation(idempotencyKey)).toMatchObject({ state: "failed", result: { code: "Error" } }); - expect(store.listUnsettledMutations({ authorityId: added.account.id })).toEqual([]); - expect(store.recoverEffectStartedMutations()).toEqual({ recovered: [], unresolved: [] }); + expect(store.readMutation(idempotencyKey)).toMatchObject({ state: "effect_started" }); + expect(store.listUnsettledMutations({ authorityId: added.account.id }) + .some((attempt) => + attempt.idempotencyKey === idempotencyKey + && attempt.state === "effect_started")).toBe(true); + expect(codex.releasedAuthorities).toHaveLength(releasesBeforeLogout); + expect(store.readProviderRuntimeAccountRevocation({ + profileId: added.account.id, + provider: "codex", + runtimeScope: "managed", + })).toMatchObject({ currentAccountKey: null, state: "releasing" }); }); test("leaves a fenced effect that lost the daemon fence to restart recovery", async () => { @@ -7781,34 +15038,61 @@ describe("HraService", () => { }); test("scopes an isolated Devin disconnect to its provider session", async () => { - const value = await fixture(); - const profile = value.store.createProfile("Provider-scoped disconnect"); - const codexCreated = value.store.createSession({ + const devinConnection = "018f1f55-3f10-7c1a-8f7b-c6dc608bcd3c"; + const devin = { + provider: "devin" as const, + rebindProfileAuthority: () => undefined, + observeSession: async (input: Parameters[0]) => ({ + connectionId: devinConnection, + projection: { + providerThreadId: input.providerThreadId, + status: "idle" as const, + title: "Devin provider-scoped session", + }, + resumed: true, + }), + close: async () => undefined, + } as unknown as DevinRuntimePort; + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + Date.now, + undefined, + { devin }, + ); + const added = await value.service.execute( + { kind: "account.add", label: "Provider-scoped disconnect" }, + { signal }, + ) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + const profile = value.store.requireProfileById(added.account.id); + const codexSession = value.store.upsertProviderSession({ fastEnabled: false, preset: "high", profileId: profile.id, provider: "codex", - }); - const codexSession = value.store.bindSession({ - expectedRevision: codexCreated.revision, providerThreadId: "codex-provider-scoped-thread", - sessionId: codexCreated.id, - state: "idle", + providerAccountKey: codexProviderAccountKey(), + state: "active", + activeTurnId: "codex-provider-scoped-turn", + title: "Codex provider-scoped session", }); - const devinCreated = value.store.createSession({ + const devinSession = value.store.upsertProviderSession({ fastEnabled: false, preset: "astra", profileId: profile.id, provider: "devin", - }); - const devinSession = value.store.bindSession({ - expectedRevision: devinCreated.revision, providerThreadId: "devin-provider-scoped-thread", - sessionId: devinCreated.id, - state: "idle", + state: "active", + activeTurnId: "devin-provider-scoped-turn", + title: "Devin provider-scoped session", }); const codexProviderThreadId = "codex-provider-scoped-thread"; - const devinProviderThreadId = "devin-provider-scoped-thread"; const authority: ProfileAuthority = { codexHome: "unused", desktopUserData: "unused", @@ -7816,19 +15100,14 @@ describe("HraService", () => { id: profile.id, }; const codexConnection = "018f1f55-3f10-7c1a-8f7b-c6dc608bcd3b"; - const devinConnection = "018f1f55-3f10-7c1a-8f7b-c6dc608bcd3c"; - await value.service.observeCodexFact(authority, { - connectionId: codexConnection, - status: { type: "idle" }, - threadId: codexProviderThreadId, - type: "threadStatusChanged", - }); - await value.service.observeDevinFact(authority, { - connectionId: devinConnection, - status: { type: "idle" }, - threadId: devinProviderThreadId, - type: "threadStatusChanged", - }); + value.codex.observationConnectionId = codexConnection; + value.codex.readProjection = { + providerThreadId: codexProviderThreadId, + status: "idle", + title: "Codex provider-scoped session", + }; + await value.service.execute({ kind: "session.status", session: codexSession.id }, { signal }); + await value.service.execute({ kind: "session.status", session: devinSession.id }, { signal }); // A provider-scoped observer may not mutate a sibling provider even when // handed its exact thread id. @@ -7871,7 +15150,7 @@ describe("HraService", () => { ]); }); - test("atomically retires an old connection while a fresh login advances the profile", async () => { + test("atomically retires an old connection across Codex logout and fresh login", async () => { const value = await fixture(); const { sessionId } = await createIdleSession(value, "Disconnect retirement"); const seeded = await seedResolvableInteraction( @@ -7920,7 +15199,15 @@ describe("HraService", () => { kind: "account.logout", account: seeded.authority.id, }, { signal }); - expect(value.store.requireInteraction(seeded.interaction.publicId).state).toBe("pending"); + expect(value.store.requireInteraction(seeded.interaction.publicId).state).toBe("expired"); + const retiredBeforeLogin = value.store.listSessionEvents({ + sessionId, + afterSequence: 0, + limit: 100, + }).events; + expect(retiredBeforeLogin.filter((event) => + event.body.type === "connection" && event.body.state === "disconnected")) + .toHaveLength(1); let releaseFreshLogin!: () => void; let freshLoginPreflightStarted!: () => void; const freshLoginGate = new Promise((resolve) => { @@ -7953,15 +15240,12 @@ describe("HraService", () => { deviceCode: false, }, { signal }); await preflightStarted; - expect(value.store.requireInteraction(seeded.interaction.publicId).state).toBe("pending"); + expect(value.store.requireInteraction(seeded.interaction.publicId).state).toBe("expired"); expect(value.store.listSessionEvents({ sessionId, afterSequence: 0, limit: 100, - }).events.map((event) => event.body)).not.toContainEqual(expect.objectContaining({ - type: "connection", - state: "disconnected", - })); + }).events).toEqual(retiredBeforeLogin); releaseFreshLogin(); await expect(freshLogin).resolves.toMatchObject({ account: { processGeneration: 2, state: "signed_in" }, @@ -8026,11 +15310,19 @@ describe("HraService", () => { status: "terminal", providerUpdatedAt: (session.providerUpdatedAt ?? 0) + 1, }]; + const localPage = await value.service.execute({ + kind: "session.list", + archived: false, + account: profile.id, + limit: 100, + }, { signal }) as { nextCursor: string | null }; + if (localPage.nextCursor === null) throw new Error("Expected a provider-discovery continuation."); await value.service.execute({ kind: "session.list", archived: false, account: profile.id, limit: 100, + cursor: localPage.nextCursor, }, { signal }); expect(value.store.requireSession(sessionId).state).toBe("terminal"); @@ -8199,6 +15491,9 @@ describe("HraService", () => { connectionId: "018f1f55-3f10-7c1a-8f7b-c6dc608bcd3b", reason: "process_exit", }); + // Login authority retirement is ordered behind the durable account tail; + // the callback itself must return before that tail can close its client. + await service.settled(); expect(store.requireProfile(added.account.id)).toMatchObject({ processGeneration: 2, state: "login_pending", @@ -8585,25 +15880,21 @@ describe("HraService", () => { path: documents, }, { signal }) as { project: { id: `proj_${string}` } }; const created = Array.from({ length: 103 }, (_, index) => { - const session = store.createSession({ + const active = index >= 100; + const session = store.upsertProviderSession({ profileId: added.account.id, projectId: project.project.id, + provider: "codex", + providerThreadId: `provider-recovery-${String(index)}`, title: `Recovery ${String(index)}`, preset: "high", fastEnabled: false, + state: active ? "active" : "idle", + ...(active ? { activeTurnId: `turn-${String(index)}` } : {}), + providerAccountKey: codexProviderAccountKey(), }); - return { index, session }; + return { active, index, session }; }).toSorted((left, right) => left.session.id.localeCompare(right.session.id)); - const active = new Set(created.slice(-3).map(({ session }) => session.id)); - for (const { index, session } of created) { - store.bindSession({ - sessionId: session.id, - expectedRevision: session.revision, - providerThreadId: `provider-recovery-${String(index)}`, - state: active.has(session.id) ? "active" : "idle", - ...(active.has(session.id) ? { activeTurnId: `turn-${String(index)}` } : {}), - }); - } codex.beforeObserveReturn = async () => await Bun.sleep(2); const readiness = await Promise.race([ @@ -8612,9 +15903,9 @@ describe("HraService", () => { ]); expect(readiness).toBe("ready"); await service.settled(); - for (const { index, session } of created.slice(-3)) { + for (const { active, index } of created.filter((entry) => entry.active)) { expect(codex.observedThreads).toContain(`provider-recovery-${String(index)}`); - expect(active.has(session.id)).toBe(true); + expect(active).toBe(true); } expect(codex.maximumConcurrentObservations).toBe(1); }); @@ -8830,45 +16121,208 @@ describe("HraService", () => { expect(store.requireSession(started.session.id)).toMatchObject({ state: "idle" }); }); - test("replays logout without contacting Codex twice", async () => { - const { service, codex } = await fixture(); - const added = await service.execute({ kind: "account.add", label: "Logout replay" }, { signal }) as { account: { id: string } }; + test("replays logout without contacting Codex twice", async () => { + const { service, codex } = await fixture(); + const added = await service.execute({ kind: "account.add", label: "Logout replay" }, { signal }) as { account: { id: string } }; + await service.execute({ kind: "account.login", account: added.account.id, deviceCode: false }, { signal }); + const command = { kind: "account.logout" as const, account: added.account.id, idempotencyKey: "00000000-0000-4000-8000-000000000109" }; + await service.execute(command, { signal }); + expect(await service.execute(command, { signal })).toMatchObject({ account: { state: "signed_out" } }); + expect(codex.calls.filter((call) => call === "logout")).toHaveLength(1); + }); + + test("retires the exact Codex client only after logout dispatch and requires a fresh generation to reconcile ambiguity", async () => { + const { service, codex, documents, store, paths } = await fixture(); + const added = await service.execute({ kind: "account.add", label: "Logout recovery" }, { signal }) as { account: { id: string } }; + await service.execute({ kind: "account.login", account: added.account.id, deviceCode: false }, { signal }); + await service.execute({ kind: "project.add", label: "Docs", path: documents }, { signal }); + const started = await service.execute({ kind: "session.start", account: added.account.id, preset: "high", fast: false }, { signal }) as { session: { id: string } }; + const releasesBeforeLogout = codex.releasedAuthorities.length; + codex.logoutError = new IndeterminateCodexEffectError("account/logout", 43); + codex.beforeLogoutReturn = async () => { + expect(codex.releasedAuthorities).toHaveLength(releasesBeforeLogout); + expect(store.readProviderRuntimeAccountRevocation({ + profileId: added.account.id, + provider: "codex", + runtimeScope: "personal", + })).toMatchObject({ currentAccountKey: null, state: "completed" }); + expect(store.readProviderRuntimeAccountRevocation({ + profileId: added.account.id, + provider: "codex", + runtimeScope: "managed", + })).toMatchObject({ currentAccountKey: null, state: "releasing" }); + }; + const command = { kind: "account.logout" as const, account: added.account.id, idempotencyKey: "00000000-0000-4000-8000-000000000501" }; + + await expect(service.execute(command, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + const quarantined = store.requireProfile(added.account.id); + expect(quarantined).toMatchObject({ state: "recovery_required", processGeneration: 1, providerEmail: "person@example.com" }); + expect(store.setProfileState(quarantined.id, quarantined.processGeneration, "signed_in", { email: "notification@example.com" })).toBe(false); + + await expect(service.execute({ kind: "session.send", session: started.session.id, message: "blocked", idempotencyKey: "00000000-0000-4000-8000-000000000502" }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + await expect(service.execute({ kind: "account.logout", account: added.account.id, idempotencyKey: "00000000-0000-4000-8000-000000000503" }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + expect(codex.calls.filter((call) => call === "send")).toHaveLength(0); + expect(codex.calls.filter((call) => call === "logout")).toHaveLength(1); + expect(codex.releasedAuthorities).toHaveLength(releasesBeforeLogout + 1); + expect(store.readProviderRuntimeAccountRevocation({ + profileId: added.account.id, + provider: "codex", + runtimeScope: "managed", + })).toMatchObject({ currentAccountKey: null, state: "completed" }); + + delete codex.logoutError; + delete codex.beforeLogoutReturn; + await expect(service.execute({ + kind: "account.show", + account: added.account.id, + }, { signal })).resolves.toMatchObject({ + account: { state: "recovery_required", processGeneration: 1 }, + recovery: { required: true, cleared: false, restartRequired: true }, + }); + + store.nextDaemonGeneration(`boot_${"9".repeat(32)}`); + const restartedCodex = new FakeCodex(); + restartedCodex.accountProjection = { + signedIn: true, + email: "person@example.com", + plan: "Pro", + }; + const restarted = new HraService({ + store, + paths, + codex: restartedCodex, + cloud: new FakeCloud(), + daemonAuthority: new FakeDaemonAuthority(), + requestStop: () => undefined, + }); + expect(await restarted.execute({ + kind: "account.show", + account: added.account.id, + }, { signal })).toMatchObject({ + account: { state: "signed_in", processGeneration: 2, providerEmail: "person@example.com" }, + providerProjection: { signedIn: true, email: "person@example.com" }, + recovery: { required: false, cleared: true, resolution: "provider_state_reconciled" }, + }); + await expect(restarted.execute(command, { signal })).rejects.toThrow("IDEMPOTENCY_CONFLICT"); + expect(store.requireProfile(added.account.id)).toMatchObject({ state: "signed_in" }); + expect(codex.calls.filter((call) => call === "logout")).toHaveLength(1); + expect(restartedCodex.calls.filter((call) => call === "logout")).toHaveLength(0); + expect(await restarted.execute({ + kind: "account.logout", + account: added.account.id, + idempotencyKey: "00000000-0000-4000-8000-000000000504", + }, { signal })).toMatchObject({ account: { state: "signed_out", processGeneration: 2 } }); + expect(restartedCodex.calls.filter((call) => call === "logout")).toHaveLength(1); + }); + + test("releases retained Codex logout custody despite request cancellation after dispatch", async () => { + const { service, codex, store } = await fixture(); + const added = await service.execute({ kind: "account.add", label: "Canceled logout" }, { signal }) as { account: { id: string } }; await service.execute({ kind: "account.login", account: added.account.id, deviceCode: false }, { signal }); - const command = { kind: "account.logout" as const, account: added.account.id, idempotencyKey: "00000000-0000-4000-8000-000000000109" }; - await service.execute(command, { signal }); - expect(await service.execute(command, { signal })).toMatchObject({ account: { state: "signed_out" } }); + const releasesBeforeLogout = codex.releasedAuthorities.length; + const cancellation = new AbortController(); + codex.beforeLogoutReturn = async () => { + expect(codex.releasedAuthorities).toHaveLength(releasesBeforeLogout); + cancellation.abort(new Error("caller canceled after dispatch")); + }; + + await expect(service.execute({ + kind: "account.logout", + account: added.account.id, + idempotencyKey: "00000000-0000-4000-8000-000000000505", + }, { signal: cancellation.signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + expect(codex.calls.filter((call) => call === "logout")).toHaveLength(1); + expect(codex.releasedAuthorities).toHaveLength(releasesBeforeLogout + 1); + expect(store.readProviderRuntimeAccountRevocation({ + profileId: added.account.id, + provider: "codex", + runtimeScope: "managed", + })).toMatchObject({ currentAccountKey: null, state: "completed" }); + expect(store.requireProfile(added.account.id)).toMatchObject({ state: "recovery_required" }); }); - test("reconciles an ambiguous logout only from an exact account read without replay", async () => { - const { service, codex, documents, store } = await fixture(); - const added = await service.execute({ kind: "account.add", label: "Logout recovery" }, { signal }) as { account: { id: string } }; + test("reconciles an ambiguous Codex logout as signed out after one real daemon generation advance", async () => { + const { service, codex, store, paths } = await fixture(); + const added = await service.execute({ kind: "account.add", label: "Applied ambiguous logout" }, { signal }) as { account: { id: string } }; await service.execute({ kind: "account.login", account: added.account.id, deviceCode: false }, { signal }); - await service.execute({ kind: "project.add", label: "Docs", path: documents }, { signal }); - const started = await service.execute({ kind: "session.start", account: added.account.id, preset: "high", fast: false }, { signal }) as { session: { id: string } }; - codex.logoutError = new IndeterminateCodexEffectError("account/logout", 43); - const command = { kind: "account.logout" as const, account: added.account.id, idempotencyKey: "00000000-0000-4000-8000-000000000501" }; - - await expect(service.execute(command, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); - const quarantined = store.requireProfile(added.account.id); - expect(quarantined).toMatchObject({ state: "recovery_required", processGeneration: 1, providerEmail: "person@example.com" }); - expect(store.setProfileState(quarantined.id, quarantined.processGeneration, "signed_in", { email: "notification@example.com" })).toBe(false); + codex.logoutError = new IndeterminateCodexEffectError("account/logout", 44); - await expect(service.execute({ kind: "session.send", session: started.session.id, message: "blocked", idempotencyKey: "00000000-0000-4000-8000-000000000502" }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); - await expect(service.execute({ kind: "account.logout", account: added.account.id, idempotencyKey: "00000000-0000-4000-8000-000000000503" }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); - expect(codex.calls.filter((call) => call === "send")).toHaveLength(0); - expect(codex.calls.filter((call) => call === "logout")).toHaveLength(1); + await expect(service.execute({ + kind: "account.logout", + account: added.account.id, + idempotencyKey: "00000000-0000-4000-8000-000000000507", + }, { signal })).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + const readsBeforeShow = codex.calls.filter((call) => call === "readAccount").length; + await expect(service.execute({ + kind: "account.show", + account: added.account.id, + }, { signal })).resolves.toMatchObject({ + account: { state: "recovery_required", processGeneration: 1 }, + recovery: { required: true, cleared: false, restartRequired: true }, + }); + expect(codex.calls.filter((call) => call === "readAccount")).toHaveLength(readsBeforeShow); - delete codex.logoutError; - codex.accountProjection = { signedIn: true, email: "reconciled@example.com", plan: "Pro" }; - expect(await service.execute({ kind: "account.show", account: added.account.id }, { signal })).toMatchObject({ - account: { state: "signed_in", processGeneration: 1, providerEmail: "reconciled@example.com" }, - providerProjection: { signedIn: true, email: "reconciled@example.com" }, + store.nextDaemonGeneration(`boot_${"8".repeat(32)}`); + const restartedCodex = new FakeCodex(); + restartedCodex.accountProjection = { signedIn: false }; + const restarted = new HraService({ + store, + paths, + codex: restartedCodex, + cloud: new FakeCloud(), + daemonAuthority: new FakeDaemonAuthority(), + requestStop: () => undefined, + }); + await expect(restarted.execute({ + kind: "account.show", + account: added.account.id, + }, { signal })).resolves.toMatchObject({ + account: { state: "signed_out", processGeneration: 2 }, + providerProjection: { signedIn: false }, recovery: { required: false, cleared: true, resolution: "provider_state_reconciled" }, }); - await expect(service.execute(command, { signal })).rejects.toMatchObject({ code: "CONFLICT" }); - expect(store.requireProfile(added.account.id)).toMatchObject({ state: "signed_in" }); - expect(codex.calls.filter((call) => call === "logout")).toHaveLength(1); + }); + + test("releases retained Codex custody when durable logout admission fails", async () => { + let stopRequests = 0; + const { service, codex, store } = await fixture( + undefined, + new FakeCloud(), + () => { stopRequests += 1; }, + ); + const added = await service.execute({ kind: "account.add", label: "Rejected logout admission" }, { signal }) as { account: { id: string } }; + await service.execute({ kind: "account.login", account: added.account.id, deviceCode: false }, { signal }); + const releasesBeforeLogout = codex.releasedAuthorities.length; + const beginAccountMutationEffect = store.beginAccountMutationEffect.bind(store); + store.beginAccountMutationEffect = ((input) => { + if (input.evidence.kind === "account.logout") { + throw new Error("injected logout admission failure"); + } + return beginAccountMutationEffect(input); + }) as StateStore["beginAccountMutationEffect"]; + + await expect(service.execute({ + kind: "account.logout", + account: added.account.id, + idempotencyKey: "00000000-0000-4000-8000-000000000506", + }, { signal })).rejects.toThrow("injected logout admission failure"); + + expect(codex.calls.filter((call) => call === "logout")).toHaveLength(0); + expect(codex.releasedAuthorities).toHaveLength(releasesBeforeLogout + 1); + expect(store.readProviderRuntimeAccountRevocation({ + profileId: added.account.id, + provider: "codex", + runtimeScope: "managed", + })).toMatchObject({ currentAccountKey: null, state: "completed" }); + expect(store.readMutation("00000000-0000-4000-8000-000000000506")) + .toMatchObject({ state: "prepared" }); + await Bun.sleep(10); + expect(stopRequests).toBe(1); + await expect(service.execute({ + kind: "account.show", + account: added.account.id, + }, { signal })).rejects.toMatchObject({ code: "UNAVAILABLE" }); }); test("does not commit a provider turn after daemon authority becomes stale post-await", async () => { @@ -8920,8 +16374,7 @@ describe("HraService", () => { codexHome: "unused", desktopUserData: "unused", }; - const connectionId = "3a000000-0000-4000-8000-000000000001"; - value.codex.observationConnectionId = connectionId; + const connectionId = value.codex.observationConnectionId; await value.service.observeCodexFact(authority, { type: "turnStarted", connectionId, @@ -8998,8 +16451,7 @@ describe("HraService", () => { codexHome: "unused", desktopUserData: "unused", }; - const connectionId = "30000000-0000-4000-8000-000000000001"; - value.codex.observationConnectionId = connectionId; + const connectionId = value.codex.observationConnectionId; await value.service.observeCodexFact(authority, { type: "itemStarted", connectionId, @@ -9343,6 +16795,19 @@ describe("HraService", () => { const { sessionId } = await createIdleSession(value, "Provider variants"); const session = value.store.requireSession(sessionId); const profile = value.store.requireProfileById(session.profileId); + // A terminal row still needs immutable provenance at creation time. Bind + // it while the selected Codex identity is established, then prove that + // terminal observation remains local after the profile signs out. + const terminalSession = value.store.upsertProviderSession({ + profileId: profile.id, + provider: "codex", + providerThreadId: "provider-terminal-observation", + preset: "high", + fastEnabled: false, + title: "Terminal provider observation", + state: "terminal", + providerAccountKey: codexProviderAccountKey(), + }); const observeCallsBeforeNonLiveStatuses = value.codex.calls.filter( (call) => call === "observe", ).length; @@ -9383,12 +16848,6 @@ describe("HraService", () => { }); expect(quarantined.advisory.attention).toBe("recovery_required"); - const terminalSession = value.store.upsertProviderSession({ - profileId: profile.id, - providerThreadId: "provider-terminal-observation", - title: "Terminal provider observation", - state: "terminal", - }); const terminal = sessionStatusSchema.parse(await value.service.execute({ kind: "session.status", session: terminalSession.id, @@ -9427,14 +16886,9 @@ describe("HraService", () => { await expect(value.service.execute({ kind: "session.status", session: sessionId, - }, { signal })).resolves.toMatchObject({ - providerObservation: { - basis: "provider_read", - code: "resume_unavailable", - profileGeneration: profile.processGeneration + 1, - state: "unavailable", - }, - }); + }, { signal })).rejects.toMatchObject({ name: "IndeterminateLocalCommitError" }); + expect(value.store.requireProfileById(profile.id).processGeneration) + .toBe(profile.processGeneration + 1); expect(value.store.listSessionEvents({ sessionId, afterSequence: 0 }).events).toEqual(before); }); @@ -9450,7 +16904,7 @@ describe("HraService", () => { codexHome: "unused", desktopUserData: "unused", }; - const connectionId = "30000000-0000-4000-8000-000000000002"; + const connectionId = value.codex.observationConnectionId; const providerArgumentsSecret = "MCP-ARGUMENT-SECRET-MUST-NOT-PERSIST"; const providerResultSecret = "MCP-RESULT-SECRET-MUST-NOT-PERSIST"; const providerItem = { @@ -9575,7 +17029,7 @@ describe("HraService", () => { codexHome: "unused", desktopUserData: "unused", }; - const connectionId = "32000000-0000-4000-8000-000000000001"; + const connectionId = value.codex.observationConnectionId; factsMemory.ensures.length = 0; await value.service.observeCodexFact(authority, { type: "turnStarted", @@ -9698,7 +17152,7 @@ describe("HraService", () => { }); await value.service.observeCodexFact(authority, { ...parsed, - connectionId: "31000000-0000-4000-8000-000000000001", + connectionId: value.codex.observationConnectionId, }); const command = { kind: "session.events", @@ -9732,7 +17186,7 @@ describe("HraService", () => { codexHome: "unused", desktopUserData: "unused", }; - const connectionId = "32000000-0000-4000-8000-000000000001"; + const connectionId = value.codex.observationConnectionId; const observe = async (fact: CodexFact): Promise => await value.service.observeCodexFact(authority, { ...fact, connectionId }); @@ -9868,6 +17322,82 @@ describe("HraService", () => { expect(durableEvents).toContain("provider_disconnect"); }); + test("never persists a split exact Codex heartbeat echo from live assistant deltas", async () => { + const value = await fixture(); + const { sessionId } = await createIdleSession(value, "Heartbeat stream confidentiality"); + const session = value.store.requireSession(sessionId); + const profile = value.store.requireProfileById(session.profileId); + if (session.providerThreadId === undefined) throw new Error("Expected a bound session."); + const authority: ProfileAuthority = { + id: profile.id, + generation: profile.processGeneration, + codexHome: "unused", + desktopUserData: "unused", + }; + const connectionId = value.codex.observationConnectionId; + const turnId = "turn-heartbeat-echo"; + const itemId = "assistant-heartbeat-echo"; + const heartbeat = [ + "", + " weekly-project-maintenance", + " 2030-01-02T03:04:05.678Z", + " ", + " Review the synthetic fixture project.", + " ", + "", + ].join("\n"); + await value.service.observeCodexFact(authority, { + type: "itemStarted", + connectionId, + threadId: session.providerThreadId, + turnId, + itemId, + itemKind: "agentMessage", + }); + for (const text of [ + heartbeat.slice(0, 7), + heartbeat.slice(7, 31), + heartbeat.slice(31, 79), + heartbeat.slice(79), + ]) { + await value.service.observeCodexFact(authority, { + type: "assistantDelta", + connectionId, + threadId: session.providerThreadId, + turnId, + itemId, + text, + }); + const durable = JSON.stringify(value.store.listSessionEvents({ + sessionId, + afterSequence: 0, + })); + expect(durable).not.toContain("weekly-project-maintenance"); + expect(durable).not.toContain("2030-01-02T03:04:05.678Z"); + expect(durable).not.toContain("synthetic fixture project"); + } + await value.service.observeCodexFact(authority, { + type: "itemCompleted", + connectionId, + threadId: session.providerThreadId, + turnId, + itemId, + itemKind: "agentMessage", + status: "completed", + }); + + const events = value.store.listSessionEvents({ + sessionId, + afterSequence: 0, + }).events; + expect(events.flatMap((event) => + event.body.type === "assistant_delta" ? [event.body.text] : [])) + .toEqual(["[protected]"]); + expect(JSON.stringify(events)).not.toContain("weekly-project-maintenance"); + expect(JSON.stringify(events)).not.toContain("2030-01-02T03:04:05.678Z"); + expect(JSON.stringify(events)).not.toContain("synthetic fixture project"); + }); + test("rejects unsafe exact interaction answer keys before durable admission", async () => { const value = await fixture(); const { sessionId } = await createIdleSession(value, "Unsafe interaction key"); @@ -9880,7 +17410,7 @@ describe("HraService", () => { codexHome: "unused", desktopUserData: "unused", }; - const connectionId = "33000000-0000-4000-8000-000000000001"; + const connectionId = value.codex.observationConnectionId; await expect(value.service.observeCodexFact(authority, { type: "interactionRequested", connectionId, @@ -10187,7 +17717,7 @@ describe("HraService", () => { const provider = { profileId: profile.id, processGeneration: profile.processGeneration, - connectionId: "40000000-0000-4000-8000-000000000001", + connectionId: value.codex.observationConnectionId, requestId: { type: "string" as const, value: "approval-request-1" }, method: "item/commandExecution/requestApproval", requestDigest: "a".repeat(64), @@ -10922,7 +18452,7 @@ describe("HraService", () => { codexHome: "unused", desktopUserData: "unused", }; - const connectionId = "42000000-0000-4000-8000-000000000001"; + const connectionId = value.codex.observationConnectionId; const requests = [ { kind: "command_approval" as const, @@ -11047,7 +18577,7 @@ describe("HraService", () => { desktopUserData: "unused", }; const providerThreadId = session.providerThreadId; - const connectionId = "43000000-0000-4000-8000-000000000001"; + const connectionId = value.codex.observationConnectionId; const cases = [ { kind: "command_approval" as const, @@ -11234,7 +18764,7 @@ describe("HraService", () => { codexHome: "unused", desktopUserData: "unused", }; - const connectionId = "44000000-0000-4000-8000-000000000001"; + const connectionId = value.codex.observationConnectionId; await value.service.observeCodexFact(authority, { type: "interactionRequested", connectionId, @@ -11295,14 +18825,14 @@ describe("HraService", () => { const now = () => { if (postValidationRead === null) return baseNow; postValidationRead += 1; - return postValidationRead === 1 ? 90_999 : 91_000; + return postValidationRead <= 2 ? 90_999 : 91_000; }; const value = await fixture(undefined, new FakeCloud(), () => undefined, now); const { sessionId } = await createIdleSession(value, "Deadline prepare edge"); const session = value.store.requireSession(sessionId); const profile = value.store.requireProfileById(session.profileId); if (session.providerThreadId === undefined) throw new Error("Expected a bound session."); - const connectionId = "45000000-0000-4000-8000-000000000001"; + const connectionId = value.codex.observationConnectionId; await value.service.observeCodexFact({ id: profile.id, generation: profile.processGeneration, @@ -11342,14 +18872,19 @@ describe("HraService", () => { value.codex.beforeValidateInteractionResolutionReturn = async () => { postValidationRead = 0; }; - await expect(value.service.execute({ + const deadlineFailure = await value.service.execute({ kind: "interaction.resolve", interaction: interaction.publicId, expectedRevision: interaction.revision, resolution: { kind: "approval_decision", decision: "once" }, - }, { signal })).rejects.toMatchObject({ - code: "CONFLICT", - details: { interaction: { state: "expired", revision: 5 } }, + }, { signal }).then( + () => null, + (error: unknown) => error, + ); + expect(deadlineFailure).toBeInstanceOf(CommandFailure); + expect(deadlineFailure).toMatchObject({ code: "CONFLICT" }); + expect((deadlineFailure as CommandFailure).details).toMatchObject({ + interaction: { state: "expired", revision: 5 }, }); expect(value.codex.validatedInteractions).toHaveLength(1); expect(value.codex.validatedInteractionTimeouts).toHaveLength(1); @@ -11364,6 +18899,154 @@ describe("HraService", () => { await value.service.close(); }); + test("settles a manual response unknown when account authority changes after the provider write", async () => { + const now = 95_000; + const value = await fixture(undefined, new FakeCloud(), () => undefined, () => now); + const { sessionId } = await createIdleSession(value, "Interaction post-write account fence"); + const seeded = await seedResolvableInteraction( + value, + sessionId, + "interaction-post-write-account-fence", + { requestedAt: now, deadlineAt: now + 1_000 }, + ); + const queued = value.store.enqueue(sessionId, "must fence with the account callback"); + let callbackFence: { + interactionState: string; + profileState: string; + queueState: string; + sessionState: string; + } | undefined; + let finishRelease!: () => void; + const releaseGate = new Promise((resolve) => { + finishRelease = resolve; + }); + value.codex.beforeReleaseOwnedAuthorityReturn = async () => await releaseGate; + value.codex.beforeResolveInteractionReturn = async () => { + value.codex.accountProjection = { + signedIn: true, + email: "replacement-after-response@example.com", + plan: "Plus", + }; + await expect(value.service.observeCodexAccount( + seeded.authority, + value.codex.accountProjection, + )).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + callbackFence = { + interactionState: value.store.requireInteraction(seeded.interaction.publicId).state, + profileState: value.store.requireProfileById(seeded.authority.id).state, + queueState: value.store.requireQueue(queued.id).state, + sessionState: value.store.requireSession(sessionId).state, + }; + }; + + await expect(value.service.execute({ + kind: "interaction.resolve", + interaction: seeded.interaction.publicId, + expectedRevision: seeded.interaction.revision, + resolution: { kind: "approval_decision", decision: "once" }, + }, { signal })).rejects.toMatchObject({ + code: "RECOVERY_REQUIRED", + details: { + interaction: { + id: seeded.interaction.publicId, + state: "resolution_unknown", + }, + }, + }); + expect(value.codex.resolvedInteractions).toHaveLength(1); + expect(value.store.requireInteraction(seeded.interaction.publicId)).toMatchObject({ + state: "resolution_unknown", + intendedTerminalState: "resolved", + }); + finishRelease(); + expect(callbackFence).toEqual({ + interactionState: "resolution_unknown", + profileState: "recovery_required", + queueState: "cancelled", + sessionState: "recovery_required", + }); + await value.service.settled(); + await value.service.close(); + }); + + test("settles a deadline timeout unknown when account authority changes after its provider write", async () => { + let now = 96_000; + const value = await fixture(undefined, new FakeCloud(), () => undefined, () => now); + const { sessionId } = await createIdleSession(value, "Deadline timeout post-write account fence"); + const seeded = await seedResolvableInteraction( + value, + sessionId, + "deadline-timeout-post-write-account-fence", + { requestedAt: now, deadlineAt: now + 1_000 }, + ); + value.codex.beforeResolveInteractionReturn = async () => { + now = 97_000; + }; + value.codex.resolveInteractionError = new CodexError( + "DEADLINE_EXPIRED", + "the final manual write reached its deadline", + ); + value.codex.beforeTimeoutInteractionReturn = async () => { + value.codex.accountProjection = { + signedIn: true, + email: "replacement-after-deadline-timeout@example.com", + plan: "Plus", + }; + }; + + await expect(value.service.execute({ + kind: "interaction.resolve", + interaction: seeded.interaction.publicId, + expectedRevision: seeded.interaction.revision, + resolution: { kind: "approval_decision", decision: "once" }, + }, { signal })).rejects.toMatchObject({ + code: "RECOVERY_REQUIRED", + details: { + interaction: { + id: seeded.interaction.publicId, + state: "resolution_unknown", + }, + }, + }); + expect(value.codex.timedOutInteractions).toHaveLength(1); + expect(value.store.requireInteraction(seeded.interaction.publicId)).toMatchObject({ + state: "resolution_unknown", + intendedTerminalState: "expired", + }); + await value.service.settled(); + await value.service.close(); + }); + + test("settles maintenance timeout unknown when account authority changes after its provider write", async () => { + let now = 98_000; + const value = await fixture(undefined, new FakeCloud(), () => undefined, () => now); + const { sessionId } = await createIdleSession(value, "Maintenance timeout post-write account fence"); + const seeded = await seedResolvableInteraction( + value, + sessionId, + "maintenance-timeout-post-write-account-fence", + { requestedAt: now, deadlineAt: now + 1_000 }, + ); + value.codex.beforeTimeoutInteractionReturn = async () => { + value.codex.accountProjection = { + signedIn: true, + email: "replacement-after-maintenance-timeout@example.com", + plan: "Plus", + }; + }; + now = 99_000; + + await expect(value.service.maintainInteractionDeadlines()) + .resolves.toEqual({ examined: 1, failed: 0 }); + expect(value.codex.timedOutInteractions).toHaveLength(1); + expect(value.store.requireInteraction(seeded.interaction.publicId)).toMatchObject({ + state: "resolution_unknown", + intendedTerminalState: "expired", + }); + await value.service.settled(); + await value.service.close(); + }); + test("turns a client final-boundary deadline rejection into one durable neutral timeout", async () => { let now = 100_000; const value = await fixture(undefined, new FakeCloud(), () => undefined, () => now); @@ -11377,7 +19060,7 @@ describe("HraService", () => { codexHome: "unused", desktopUserData: "unused", }; - const connectionId = "46000000-0000-4000-8000-000000000001"; + const connectionId = value.codex.observationConnectionId; const admit = async (requestId: string, requestedAt: number, deadlineAt: number) => { await value.service.observeCodexFact(authority, { type: "interactionRequested", @@ -11710,7 +19393,7 @@ describe("HraService", () => { "AUTHORITY_STALE", "injected stale provider", ); - const connectionId = "43000000-0000-4000-8000-000000000001"; + const connectionId = value.codex.observationConnectionId; await value.service.observeCodexFact(authority, { type: "interactionRequested", connectionId, @@ -11760,7 +19443,7 @@ describe("HraService", () => { codexHome: "unused", desktopUserData: "unused", }; - const connectionId = "45000000-0000-4000-8000-000000000001"; + const connectionId = value.codex.observationConnectionId; const request = async (requestId: string) => { const provider = { profileId: profile.id, @@ -11886,7 +19569,7 @@ describe("HraService", () => { codexHome: "unused", desktopUserData: "unused", }; - const connectionId = "50000000-0000-4000-8000-000000000001"; + const connectionId = value.codex.observationConnectionId; const request = async (requestId: string) => { const provider = { profileId: profile.id, @@ -11953,6 +19636,7 @@ describe("HraService", () => { generation: authority.generation + 1, }; const replacementConnectionId = "50000000-0000-4000-8000-000000000099"; + value.codex.observationConnectionId = replacementConnectionId; await value.service.observeCodexFact(replacementAuthority, { type: "itemStarted", connectionId: replacementConnectionId, @@ -12014,7 +19698,7 @@ describe("HraService", () => { test("clean shutdown retires every live provider generation when runtime close emits no disconnect fact", async () => { const value = await fixture(); const { sessionId } = await createIdleSession(value, "Clean interaction shutdown"); - const connectionId = "51000000-0000-4000-8000-000000000001"; + const connectionId = value.codex.observationConnectionId; const seeded = seedUnsettledInteractionStates( value, sessionId, @@ -12086,6 +19770,7 @@ describe("HraService", () => { expect(restartedCodex.resolvedInteractions).toHaveLength(0); const replacementConnectionId = "51000000-0000-4000-8000-000000000099"; + restartedCodex.observationConnectionId = replacementConnectionId; const replacementAuthority = { ...authority, generation: originalGeneration + 1, @@ -12154,6 +19839,38 @@ describe("HraService", () => { expect(value.store.requireProfileById(session.profileId).processGeneration).toBe(generation); }); + test("quarantines only the failed runtime scope when managed and personal Codex coexist", async () => { + for (const failedScope of ["managed", "personal"] as const) { + const value = await adoptedCodexFixture( + `Scoped ${failedScope} shutdown`, + `personal-thread-${failedScope}-shutdown`, + ); + const started = await value.service.execute({ + kind: "session.start", + account: value.accountId, + preset: "high", + fast: false, + }, { signal }) as { session: { id: `sess_${string}` } }; + const nativeSessionId = started.session.id; + expect(value.store.readSessionProviderAccountAuthority(nativeSessionId)) + .toMatchObject({ provider: "codex", runtimeScope: "managed" }); + expect(value.store.readSessionProviderAccountAuthority(value.session.id)) + .toMatchObject({ provider: "codex", runtimeScope: "personal" }); + const failure = new Error(`${failedScope} Codex close failure`); + if (failedScope === "managed") value.codex.closeError = failure; + else value.personalCodex.closeError = failure; + + await expect(value.service.close()).rejects.toThrow(failure.message); + + expect(value.store.requireSession(nativeSessionId).state).toBe( + failedScope === "managed" ? "recovery_required" : "idle", + ); + expect(value.store.requireSession(value.session.id).state).toBe( + failedScope === "personal" ? "recovery_required" : "idle", + ); + } + }); + test("crash restart atomically fences the old generation and terminalizes ambiguous interaction states", async () => { const value = await fixture(); const { sessionId } = await createIdleSession(value, "Crashed interaction authority"); @@ -12325,7 +20042,7 @@ describe("HraService autorespond", () => { codexHome: "unused", desktopUserData: "unused", }; - const connectionId = "46000000-0000-4000-8000-000000000001"; + const connectionId = value.codex.observationConnectionId; await value.service.observeCodexFact(authority, { type: "interactionRequested", connectionId, diff --git a/src/daemon/service.ts b/src/daemon/service.ts index 7e407bc..6539dc2 100644 --- a/src/daemon/service.ts +++ b/src/daemon/service.ts @@ -23,6 +23,8 @@ import { resolvePinnedCodexRuntime, validateMcpFormSubmission, type CodexFact, + type CodexAutomationAuthorityRequest, + type CodexAutomationAuthorityScan, type CodexPluginCatalog, type CodexPluginSummary, type ConversationAutomationToolCall, @@ -60,16 +62,20 @@ import { type SessionStatus, } from "../domain/observation"; import { + adoptableProviderSchema, currentPresetContract, isPresetSupportedByProvider, PresetProviderMismatchError, presetRequirementForContract, presetsForProvider, presetTiers, + type AdoptableProvider, type Preset, + type PresetRequirement, type Provider, } from "../domain/presets"; import { + projectPublicReviewedRuntimeProfile, reviewedRuntimeProfileSchema, type ReviewedRuntimeProfile, } from "../domain/runtime-profile"; @@ -164,7 +170,13 @@ import { type MutationEffectEvidence, type AccountRateLimitResetAttemptRecord, type AccountRateLimitResetPolicyRecord, + type ClaudeProcessAuthorityKey, + type ClaudeProcessAuthorityRecord, + type ClaudeProcessLaunchIntentRecord, type ProfileRecord, + type ProviderRuntimeAccountRevocationRecord, + type ProjectRecord, + type SessionAdoptionCandidateRecord, type SessionRecord, type StateStore, type StoredMessageAttachment, @@ -183,11 +195,15 @@ import { DaemonAuthoritySafetyError, type DaemonAuthorityFence } from "./daemon- import type { HraFactsMemoryLifecyclePort } from "./facts-memory-lifecycle"; import { commandFailureBrand } from "./local-transport"; import { + ClaudeProcessExitUnprovenError, + ClaudeSessionObservationError, + CodexClaimReleaseUnprovenError, CodexSessionObservationError, ProviderRuntimeUnavailableError, UnavailableClaudeRuntime, UnavailableDevinRuntime, type ClaudeRuntimePort, + type ClaudeProcessIdentity, type CloudControlPort, type CodexAccountProjection, type CodexLoginOutcome, @@ -204,6 +220,14 @@ import { ClaudeSessionFactTranslator, type ClaudeSessionFact, } from "./claude-session-facts"; +import { + inferCodexLiveness, + PERSONAL_SESSION_DISCOVERY_MAX_RESULTS, + PERSONAL_SESSION_DISCOVERY_RECENCY_WINDOW_MS, + type ClaudeProcessLivenessProbe, + type DiscoveredPersonalSession, + type PersonalSessionDiscoveryPort, +} from "./personal-session-discovery"; import { SessionEventCursorCodec, SessionEventCursorError, @@ -260,6 +284,20 @@ export class CommandFailure extends Error { } } +class ProviderAccountAuthorityMismatchError extends CommandFailure { + constructor( + provider: AdoptableProvider, + profile: Pick, + ) { + super( + "RECOVERY_REQUIRED", + "The provider account changed. HRA refused stale controller authority and is releasing the affected sessions.", + { accountId: profile.id, provider }, + ); + this.name = "ProviderAccountAuthorityMismatchError"; + } +} + const doctorProjectionCacheSchema = z.discriminatedUnion("state", [ z.object({ state: z.literal("ready") }).passthrough(), z.object({ @@ -357,6 +395,8 @@ const providerFailureCode = (error: unknown): string | null => providerFailure(e const providerFailureMessage = (error: unknown): string => providerFailure(error)?.message ?? "The provider refused the operation."; +type ProviderFactSource = "managed" | "personal"; + const claudeCommandFailure = (error: ClaudeError): CommandFailure => { switch (error.code) { case "AUTHORITY_STALE": @@ -686,6 +726,41 @@ const defaultPresetForProviderSwitch = (provider: Provider, current: Preset): Pr return fallback; }; +/** Projects private reviewed runtime evidence onto the ordinary public session surface. */ +const publicRuntimeProfile = ( + profile: ReviewedRuntimeProfile | null | undefined, +): ReturnType | null => + profile === null || profile === undefined + ? null + : projectPublicReviewedRuntimeProfile(profile); + +const assertClaimedRuntimeProfile = (input: Readonly<{ + authority: ProfileAuthority; + fast: boolean; + preset: Preset; + provider: AdoptableProvider; + requirement: PresetRequirement; + runtimeProfile: ReviewedRuntimeProfile; +}>): ReviewedRuntimeProfile => { + const parsed = reviewedRuntimeProfileSchema.safeParse(input.runtimeProfile); + if (!parsed.success) { + throw new Error("SESSION_CLAIM_RUNTIME_PROFILE_MISMATCH"); + } + const profile = parsed.data; + if ( + profile.profileId !== input.authority.id + || profile.processGeneration !== input.authority.generation + || profile.preset !== input.preset + || profile.model !== input.requirement.model + || profile.reasoningEffort !== input.requirement.effort + || !isPresetSupportedByProvider(input.provider, profile.preset) + || ("fast" in profile ? profile.fast !== input.fast : input.fast) + ) { + throw new Error("SESSION_CLAIM_RUNTIME_PROFILE_MISMATCH"); + } + return profile; +}; + const loginReceiptSchema = z.discriminatedUnion("status", [ z.object({ status: z.literal("pending"), @@ -773,6 +848,58 @@ const accountFingerprintForProfile = ( ): string | null => profile.providerEmail === undefined ? null : digestText(profile.providerEmail.trim().toLowerCase()); +const normalizedProviderEmail = (value: string): string => value.trim().toLowerCase(); +type RuntimeAccountScope = "managed" | "personal"; + +const boundedProviderIdentityScalar = (value: unknown): string | null => { + const parsed = z.string().min(1).max(1_024).safeParse(value); + if (!parsed.success) return null; + const normalized = parsed.data.trim(); + if (normalized.length === 0 || /\p{Cc}/u.test(normalized)) return null; + return normalized; +}; + +/** + * Credential-free provider identity captured at custody admission. Codex's + * stable authority is its normalized account email. Claude exposes distinct + * account and organization UUIDs, so its authority deliberately does not + * inherit the selected Codex profile email. + */ +const providerAccountAuthorityKey = ( + provider: AdoptableProvider, + account: CodexAccountProjection, +): string | null => { + if (!account.signedIn) return null; + switch (provider) { + case "codex": { + const email = boundedProviderIdentityScalar(account.email); + return email === null ? null : `v1:codex:${digestText(email.toLowerCase())}`; + } + case "claude": { + const accountId = boundedProviderIdentityScalar(account.accountId); + const organizationId = boundedProviderIdentityScalar(account.organizationId); + if (accountId === null || organizationId === null) return null; + return `v1:claude:${digestText(`${accountId}\0${organizationId}`)}`; + } + } +}; + +const profileCodexAccountAuthorityKey = ( + profile: Pick, +): string | null => profile.providerEmail === undefined + ? null + : providerAccountAuthorityKey("codex", { + signedIn: true, + email: profile.providerEmail, + }); +const providerAccountAuthorityChanged = ( + profile: Pick, + account: CodexAccountProjection, +): boolean => { + if (!account.signedIn) return true; + if (profile.providerEmail === undefined || account.email === undefined) return true; + return normalizedProviderEmail(account.email) !== normalizedProviderEmail(profile.providerEmail); +}; const QUEUE_PRE_EFFECT_RETRY_DELAYS_MS = [25, 100, 250] as const; export const FACTS_MEMORY_SESSION_TTL_MS = 30 * 24 * 60 * 60_000; @@ -867,14 +994,18 @@ export const BACKGROUND_DIAGNOSTIC_CODES = [ "autorespond_failed", "claude_fact_untranslatable", "prose_autorespond_failed", + "profile_authority_revocation_failed", + "provider_account_authority_revocation_failed", "queue_dispatch_failed", "queue_pre_effect_retry_failed", "recovery_observation_failed", + "session_adoption_failed", "session_state_tracking_failed", "usage_refresh_failed", "usage_poll_account_failed", "provider_switch_source_abandon_failed", "provider_switch_seed_failed", + "provider_switch_target_release_failed", "provider_switch_target_abandon_failed", "usage_poll_tick_failed", "user_message_record_failed", @@ -905,6 +1036,79 @@ const classifyBackgroundDiagnosticCause = (error: unknown): BackgroundDiagnostic /** Upper bound on remembered per-session fact epochs; oldest entries are dropped first. */ const SESSION_FACT_EPOCH_LIMIT = 4_096; +const PERSONAL_SESSION_ADOPTION_SCAN_LIMIT = 50; +/** + * A provider claim can include capability discovery, resume, observation, and + * deterministic release. Keep each poll to two provider-neutral attempts so a + * slow prefix cannot monopolize automation rotation or the other provider. + */ +const PERSONAL_SESSION_ADOPTION_CLAIM_ATTEMPT_LIMIT = 2; +const PERSONAL_SESSION_ADOPTION_RECENCY_MS = PERSONAL_SESSION_DISCOVERY_RECENCY_WINDOW_MS; +const PERSONAL_SESSION_ADOPTION_CLOCK_SKEW_MS = 5 * 60_000; +const PERSONAL_CODEX_AUTOMATION_AUTHORITY_DEADLINE_MS = 5_000; +const CLAUDE_PROCESS_LIVENESS_DEADLINE_MS = 3_000; +const CLAUDE_RETAINED_CANDIDATE_PROBE_CONCURRENCY = 8; +const PERSONAL_ACCOUNT_ATTESTATION_TTL_MS = 1_000; +const SESSION_LIST_TRAVERSAL_LIMIT = 64; +const SESSION_LIST_TRAVERSAL_IMPORT_RECEIPT_LIMIT = 10_000; + +const isEligiblePersonalCodexAutomationStatus = ( + status: unknown, +): status is "active" | "paused" => status === "active" || status === "paused"; + +type PreparedPersonalAdmissionCandidate = Readonly<{ + kind: "discovered" | "retained"; + candidate: DiscoveredPersonalSession; + durableCandidate: SessionAdoptionCandidateRecord; + project: ProjectRecord | undefined; +}>; + +type RetainedClaudeCandidateObservation = Omit< + PreparedPersonalAdmissionCandidate, + "kind" +>; + +type PersonalAccountAttestation = Readonly<{ + checkedAt: number; + accountKey: string; + generation: number; +}>; + +type PersonalCodexScheduledAuthorityBatch = Readonly<{ + providerThreadIds: readonly string[]; + sourceDirectoryNamesByProviderThreadId: ReadonlyMap; +}>; + +type PersonalCodexAdoptionClaimClass = "recent" | "scheduled"; + +/** + * In-memory capability for provider deltas. Its durable components are + * revalidated synchronously before every commit, so an ordinary delta never + * performs a provider account read and can never outlive controller custody. + */ +type SessionFactAuthority = Readonly<{ + sessionId: SessionRecord["id"]; + profileId: ProfileRecord["id"]; + profileGeneration: number; + provider: Provider; + runtimeScope: RuntimeAccountScope; + providerThreadId: string; + connectionId: string; + accountKey: string | null; + personalBindingRevision: number | null; + claudeProcess: Readonly<{ + identity: ClaudeProcessIdentity; + revision: number; + }> | null; +}>; + +type SessionListTraversalReplayState = { + readonly accountId: ProfileRecord["id"]; + readonly providerGeneration: number; + readonly importedSessionIdsByProviderPage: Map>; + readonly emittedSessionIds: Set; + importReceiptCount: number; +}; export class HraService { readonly #store: StateStore; @@ -913,7 +1117,16 @@ export class HraService { readonly #codex: CodexRuntimePort; readonly #claude: ClaudeRuntimePort; readonly #devin: DevinRuntimePort; + readonly #personalCodex: CodexRuntimePort | undefined; + readonly #personalClaude: ClaudeRuntimePort | undefined; + readonly #personalCodexHome: string | undefined; + readonly #personalDiscovery: PersonalSessionDiscoveryPort | undefined; + readonly #readPersonalCodexAutomations: (( + request: CodexAutomationAuthorityRequest, + ) => Promise) | undefined; + readonly #claudeProcessLiveness: ClaudeProcessLivenessProbe | undefined; readonly #claudeFacts: ClaudeSessionFactTranslator; + readonly #personalClaudeFacts: ClaudeSessionFactTranslator | undefined; readonly #desktop: DesktopSwitchPort | undefined; readonly #cloud: CloudControlPort; readonly #daemonAuthority: Pick; @@ -940,17 +1153,34 @@ export class HraService { readonly #scheduledAutorespondInteractions = new Set(); readonly #operations = new Set>(); readonly #projectionRecoveriesInFlight = new Set(); + /** Immediate in-memory admission fence for a durable personal-authority revocation. */ + readonly #profileAuthorityRevocationsPending = new Map(); + readonly #profileAuthorityRevocationTasks = new Map>(); + /** Provider-home replacements fence only the sessions owned by that home. */ + readonly #providerAccountRevocationTasks = new Map>(); + /** Short-lived fact-path cache; every controlling effect forces a fresh read. */ + readonly #personalAccountAttestations = new Map(); + readonly #personalAccountChecks = new Map>(); readonly #sessionFactEpochs = new Map(); readonly #backgroundDiagnostics = new Map(); #lastBackgroundDiagnostic: BackgroundDiagnostic | null = null; readonly #sessionProviderConnections = new Map(); + readonly #sessionFactAuthorities = new Map(); readonly #sessionObservationFailures = new Map(); readonly #sessionResubscriptionConnections = new Map(); readonly #sessionsAwaitingResubscription = new Set(); readonly #queuePreEffectRetryCounts = new Map(); readonly #queuePreEffectRetryScheduled = new Set(); + /** Same-daemon Codex claims whose exact controller release was not proven. */ + readonly #unprovenCodexAdoptionClaims = new Set(); readonly #usageRefreshes = new Map>(); readonly #usageRefreshDirty = new Set(); + readonly #sessionListTraversals = new Map(); + #personalCodexAutomationCursor: string | null = null; + readonly #personalCodexAutomationRestartPage: number; + #personalCodexAutomationRestartPending = true; + /** Alternate the first bounded Codex claim slot when both sources stay busy. */ + #personalCodexNextClaimClass: PersonalCodexAdoptionClaimClass = "recent"; readonly #backgroundAbort = new AbortController(); readonly #interactionDeadlineAbort = new AbortController(); #interactionDeadlineTask: Promise | undefined; @@ -972,6 +1202,15 @@ export class HraService { claude?: ClaudeRuntimePort; /** Omitted on a machine with no admitted `devin` binary. */ devin?: DevinRuntimePort; + /** Dedicated runtimes for sessions claimed from the OS user's provider homes. */ + personalCodex?: CodexRuntimePort; + personalClaude?: ClaudeRuntimePort; + personalCodexHome?: string; + personalDiscovery?: PersonalSessionDiscoveryPort; + readPersonalCodexAutomations?: ( + request: CodexAutomationAuthorityRequest, + ) => Promise; + claudeProcessLiveness?: ClaudeProcessLivenessProbe; cloud: CloudControlPort; daemonAuthority: Pick; desktop?: DesktopSwitchPort; @@ -993,11 +1232,25 @@ export class HraService { this.#codex = input.codex; this.#claude = input.claude ?? new UnavailableClaudeRuntime(CLAUDE_PIN); this.#devin = input.devin ?? new UnavailableDevinRuntime(DEVIN_PIN); + this.#personalCodex = input.personalCodex; + this.#personalClaude = input.personalClaude; + this.#personalCodexHome = input.personalCodexHome; + this.#personalDiscovery = input.personalDiscovery; + this.#readPersonalCodexAutomations = input.readPersonalCodexAutomations; + this.#claudeProcessLiveness = input.claudeProcessLiveness; this.#claudeFacts = new ClaudeSessionFactTranslator({ authorityFor: (providerThreadId, requestId) => this.#claude.interactionAuthority(providerThreadId, requestId), now: () => this.#now(), }); + this.#personalClaudeFacts = this.#personalClaude === undefined + ? undefined + : new ClaudeSessionFactTranslator({ + authorityFor: (providerThreadId, requestId) => + this.#personalClaude?.interactionAuthority(providerThreadId, requestId) + ?? this.#claude.interactionAuthority(providerThreadId, requestId), + now: () => this.#now(), + }); this.#cloud = input.cloud; this.#daemonAuthority = input.daemonAuthority; this.#eventCursors = input.eventCursors @@ -1006,6 +1259,8 @@ export class HraService { (value) => this.#eventCursors.projectPublicProviderIdentifier(value), ); this.#eventRedactor = new SessionEventStreamRedactor({ + isCodexSession: (write) => + this.#store.requireSession(write.sessionId).provider === "codex", projectPublicProviderIdentifier: (value) => this.#eventCursors.projectPublicProviderIdentifier(value), }); @@ -1018,6 +1273,7 @@ export class HraService { this.#factsMemory = input.factsMemory; this.#daemonGeneration = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) .parse(input.daemonGeneration ?? 0); + this.#personalCodexAutomationRestartPage = Math.max(0, this.#daemonGeneration - 1); this.#platform = input.platform ?? process.platform; const workCapabilities = input.workCapabilities ?? new WorkCapabilityCodec(WorkCapabilityCodec.generateKey()); @@ -1118,7 +1374,18 @@ export class HraService { } }); } - case "account.login": { const profile = this.#store.requireProfile(command.account); return await this.#serialize(`account:${profile.id}`, async () => this.#login(profile.id, command.deviceCode, command.idempotencyKey, context.signal)); } + case "account.login": { + const profile = this.#store.requireProfile(command.account); + return await this.#serialize("session-adoption:codex", async () => + await this.#serialize("session-adoption:claude", async () => + await this.#serialize(`account:${profile.id}`, async () => + await this.#login( + profile.id, + command.deviceCode, + command.idempotencyKey, + context.signal, + )))); + } case "account.claude-login.prepare": { const profile = this.#store.requireProfile(command.account); return await this.#serialize(`account:${profile.id}`, async () => this.#prepareClaudeLogin(profile.id, command.idempotencyKey, context.signal)); } case "account.claude-login.complete": { const profile = this.#store.requireProfile(command.account); return await this.#serialize(`account:${profile.id}`, async () => this.#completeClaudeLogin({ ...command, account: profile.id }, context.signal)); } case "account.claude-login.abandon": { const profile = this.#store.requireProfile(command.account); return await this.#serialize(`account:${profile.id}`, async () => this.#abandonClaudeLogin({ ...command, account: profile.id })); } @@ -1126,7 +1393,13 @@ export class HraService { case "account.devin-login.complete": { const profile = this.#store.requireProfile(command.account); return await this.#serialize(`account:${profile.id}`, async () => this.#completeDevinLogin({ ...command, account: profile.id }, context.signal)); } case "account.devin-login.abandon": { const profile = this.#store.requireProfile(command.account); return await this.#serialize(`account:${profile.id}`, async () => this.#abandonDevinLogin({ ...command, account: profile.id })); } case "account.login-cancel": { const profile = this.#store.requireProfile(command.account); return await this.#serialize(`account:${profile.id}`, async () => this.#cancelLogin(profile.id, command.idempotencyKey, context.signal)); } - case "account.logout": { const profile = this.#store.requireProfile(command.account); return await this.#serialize(`account:${profile.id}`, async () => this.#logout(profile.id, command.idempotencyKey, context.signal)); } + case "account.logout": { + const profile = this.#store.requireProfile(command.account); + return await this.#serialize("session-adoption:codex", async () => + await this.#serialize("session-adoption:claude", async () => + await this.#serialize(`account:${profile.id}`, async () => + await this.#logout(profile.id, command.idempotencyKey, context.signal)))); + } case "account.usage": { if (command.account === undefined) return await this.#usage(undefined, command.refresh, context.signal); const profile = this.#store.requireProfile(command.account); @@ -1159,6 +1432,7 @@ export class HraService { case "project.use": return { project: this.#store.setDefaultProject(this.#store.requireProject(command.project).id) }; case "session.archive": { const session = this.#store.requireSession(command.session); + this.#assertSessionAccountAuthorityIfSignedIn(session); const archived = this.#store.setSessionArchived(session.id, command.archived); return { version: 1, @@ -1167,6 +1441,15 @@ export class HraService { archivedAt: archived.archivedAt ?? null, }; } + case "session.adoption.status": return this.#sessionAdoptionStatus(command.provider); + case "session.adoption.set": return await this.#serialize( + `session-adoption:${command.provider}`, + async () => await this.#setSessionAdoption(command, context.signal), + ); + case "session.adoption.discover": return await this.discoverPersonalSessions( + command.provider, + context.signal, + ); case "session.list": { if (command.account === undefined) { return await this.#listSessions( @@ -1243,6 +1526,7 @@ export class HraService { return { version: 1, mode: command.mode, source: "default" }; } const session = this.#store.requireSession(command.session); + this.#assertSessionAccountAuthorityIfSignedIn(session); this.#store.setSessionApprovalMode(session.id, command.mode); const effective = this.#store.readSessionApprovalMode(session.id); return { version: 1, session: session.id, mode: effective.mode, source: effective.source }; @@ -1311,7 +1595,7 @@ export class HraService { } case "session.start": { const profile = this.#store.requireProfile(command.account); return await this.#serialize(`account:${profile.id}`, async () => this.#startSession({ ...command, account: profile.id }, context.signal)); } case "session.send": { const session = this.#store.requireSession(command.session); return await this.#serializeSessionAuthority(session, async () => this.#send(session.id, command.message, command.idempotencyKey, context.signal, undefined, "human", command.attachments ?? [])); } - case "session.queue": { const session = this.#store.requireSession(command.session); return await this.#serializeSessionAuthority(session, async () => this.#queue(session.id, command.message, command.idempotencyKey, undefined, command.attachments ?? [])); } + case "session.queue": { const session = this.#store.requireSession(command.session); return await this.#serializeSessionAuthority(session, async () => this.#queue(session.id, command.message, command.idempotencyKey, context.signal, undefined, command.attachments ?? [])); } case "session.steer": { const session = this.#store.requireSession(command.session); return await this.#serializeSessionAuthority(session, async () => this.#steer(session.id, command.message, command.idempotencyKey, context.signal, undefined, command.attachments ?? [])); } case "session.stop": { const session = this.#store.requireSession(command.session); return await this.#serializeSessionAuthority(session, async () => this.#stop(session.id, command.idempotencyKey, context.signal)); } case "session.rename": { const session = this.#store.requireSession(command.session); return await this.#serializeSessionAuthority(session, async () => this.#rename(session.id, command.name, command.idempotencyKey, context.signal)); } @@ -1330,11 +1614,38 @@ export class HraService { this.#sessionRecoveryProfileIds(session), async () => { const current = this.#store.requireSession(session.id); - if (current.state !== "recovery_required") { - return await this.#resolveSessionRecovery(current.id, "abandon", context.signal); + if (current.state === "recovery_required") { + await this.#cleanupFactsMemory(current, "abandon"); } - await this.#cleanupFactsMemory(current, "abandon"); - return await this.#resolveSessionRecovery(current.id, "abandon", context.signal); + const result = await this.#resolveSessionRecovery( + current.id, + "abandon", + context.signal, + ); + const terminal = this.#store.requireSession(current.id); + const binding = this.#store.readSessionPersonalRuntimeBinding(terminal.id, true); + if ( + binding !== null + && binding.state !== "detached" + && binding.provider === terminal.provider + && binding.providerThreadId === terminal.providerThreadId + ) { + if (binding.state === "active") { + this.#clearSessionFactAuthority(terminal.id); + this.#store.beginPersonalSessionDetach({ sessionId: terminal.id }); + } + this.#scheduleTerminalPersonalDetach(terminal); + } else if ( + terminal.provider === "claude" + && terminal.providerThreadId !== undefined + ) { + this.#scheduleClaudeProcessAuthorityRelease({ + providerThreadId: terminal.providerThreadId, + profileId: terminal.profileId, + runtimeScope: "managed", + }); + } + return result; }, ); } @@ -1516,39 +1827,73 @@ export class HraService { case "sync.now": return await this.#fencedEffect(async () => await this.#cloud.sync(context.signal)); case "sync.projection-recover": { const selected = this.#store.requireSession(command.session); - return await this.#serializeSessionAuthority(selected, async () => { - this.#projectionRecoveriesInFlight.add(selected.id); - try { - await this.#daemonAuthority.assertCurrent(); - const replay = await this.#cloud.readCompactProjectionRecoveryReceipt?.({ - idempotencyKey: command.idempotencyKey, - sessionPublicId: selected.id, - signal: context.signal, - }); - await this.#daemonAuthority.assertCurrent(); - if (replay !== undefined) { - if (replay.status === "conflict") { - throw new CommandFailure( - "CONFLICT", - "The projection recovery idempotency key belongs to another session.", - ); - } - if (replay.status === "found") return replay.result; + const admission = await this.#serializeSessionAuthority(selected, async () => { + if ( + this.#projectionRecoveriesInFlight.has(selected.id) + || this.#profileHasProjectionRecoveryInFlight(selected.profileId) + ) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "This session or account already has a compact-projection recovery in flight.", + ); + } + await this.#daemonAuthority.assertCurrent(); + const replay = await this.#cloud.readCompactProjectionRecoveryReceipt?.({ + idempotencyKey: command.idempotencyKey, + sessionPublicId: selected.id, + signal: context.signal, + }); + await this.#daemonAuthority.assertCurrent(); + if (replay !== undefined) { + if (replay.status === "conflict") { + throw new CommandFailure( + "CONFLICT", + "The projection recovery idempotency key belongs to another session.", + ); } - const session = this.#requireBoundSession(selected.id); - const profile = this.#store.requireProfile(session.profileId); - return await this.#recoverCompactProjection({ - acknowledgeGap: command.acknowledgeGap, - idempotencyKey: command.idempotencyKey, - processGeneration: profile.processGeneration, - profileId: profile.id, - providerThreadId: session.providerThreadId, - sessionId: session.id, - }, context.signal); - } finally { - this.#projectionRecoveriesInFlight.delete(selected.id); + if (replay.status === "found") { + return { kind: "replay", result: replay.result } as const; + } + } + const session = this.#requireBoundSession(selected.id); + if (session.profileId !== selected.profileId) { + throw new CommandFailure( + "CONFLICT", + "The session account changed before projection recovery admission.", + ); + } + const profile = this.#store.requireProfile(session.profileId); + const expected = { + acknowledgeGap: command.acknowledgeGap, + idempotencyKey: command.idempotencyKey, + processGeneration: profile.processGeneration, + profileId: profile.id, + providerThreadId: session.providerThreadId, + sessionId: session.id, + } as const; + await this.#assertCompactProjectionRecoveryReady(expected); + if (this.#profileHasProjectionRecoveryInFlight(profile.id)) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "This account already has a compact-projection recovery in flight.", + ); } + // This in-memory fence closes the pre-journal admission window. + // Release the account/session tails before calling cloud: its + // provider-read callback reacquires both tails through the public + // exact-reader seam. + this.#projectionRecoveriesInFlight.add(session.id); + return { kind: "admitted", expected } as const; }, { allowDuringProjectionRecovery: true }); + if (admission.kind === "replay") return admission.result; + try { + return await this.#recoverCompactProjection( + admission.expected, + context.signal, + ); + } finally { + this.#projectionRecoveriesInFlight.delete(admission.expected.sessionId); + } } } } catch (error: unknown) { @@ -1780,6 +2125,117 @@ export class HraService { } } + /** + * Supplies cloud reconciliation with an exact provider projection without + * letting that adapter select a runtime or provider home on its own. + */ + async readSessionProjectionForCloud( + sessionId: SessionRecord["id"], + signal: AbortSignal, + ): Promise { + const finish = this.#beginOperation(); + try { + signal.throwIfAborted(); + await this.#daemonAuthority.assertCurrent(); + const selected = this.#store.requireSession(sessionId); + return await this.#serializeSessionAuthority(selected, async () => { + signal.throwIfAborted(); + await this.#daemonAuthority.assertCurrent(); + const session = this.#store.requireSession(selected.id); + if (session.profileId !== selected.profileId) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The session account changed before cloud projection authority was acquired.", + ); + } + const bound = this.#requireBoundSession(session.id); + const profile = this.#store.requireProfileById(bound.profileId); + this.#assertEstablishedSessionAccount(profile, bound); + const projection = await this.#readExactSessionProjection( + bound, + profile, + false, + signal, + ); + signal.throwIfAborted(); + await this.#daemonAuthority.assertCurrent(); + const exactSession = this.#store.requireSession(bound.id); + const exactProfile = this.#store.requireProfileById(profile.id); + if ( + exactSession.profileId !== bound.profileId + || exactSession.provider !== bound.provider + || exactSession.providerThreadId !== bound.providerThreadId + || exactSession.state === "recovery_required" + || exactSession.state === "terminal" + || exactProfile.processGeneration !== profile.processGeneration + || !this.#profileAllowsEstablishedSession(exactProfile, exactSession) + ) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The session authority changed during the cloud projection read.", + ); + } + return projection; + }, { allowDuringProjectionRecovery: true }); + } finally { + finish(); + } + } + + /** + * Supplies cloud account reconciliation with only the bounded authentication + * bit. The cloud adapter never receives provider runtime custody or paths. + */ + async readProviderAccountProjectionForCloud(input: Readonly<{ + profileId: ProfileRecord["id"]; + provider: "claude" | "devin"; + processGeneration: number; + signal: AbortSignal; + }>): Promise> { + const finish = this.#beginOperation(); + try { + input.signal.throwIfAborted(); + await this.#daemonAuthority.assertCurrent(); + return await this.#serialize(`account:${input.profileId}`, async () => { + const assertExactProfile = (): ProfileRecord => { + const exact = this.#store.requireProfileById(input.profileId); + if ( + exact.processGeneration !== input.processGeneration + || (exact.state !== "signed_in" && exact.state !== "signed_out") + || this.#profileAuthorityRevocationIsPending( + exact.id, + exact.processGeneration, + ) + ) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The provider account authority changed during cloud projection.", + ); + } + return exact; + }; + input.signal.throwIfAborted(); + await this.#daemonAuthority.assertCurrent(); + const before = assertExactProfile(); + const account = await (async (): Promise => { + switch (input.provider) { + case "claude": + this.#assertClaudeIsolationAccepted(); + return await this.#readClaudeAccount(before, input.signal); + case "devin": + return await this.#readDevinAccount(before, input.signal); + } + })(); + input.signal.throwIfAborted(); + await this.#daemonAuthority.assertCurrent(); + assertExactProfile(); + return { signedIn: account.signedIn }; + }); + } finally { + finish(); + } + } + async #executeRemoteAdmitted( command: RemoteSessionCommand, expectedAuthority: { sessionId: SessionRecord["id"]; profileId: ProfileRecord["id"]; processGeneration: number; providerThreadId: string }, @@ -1821,7 +2277,7 @@ export class HraService { this.#assertEstablishedSessionAccount(profile, session); switch (command.kind) { case "session.send": return await this.#send(session.id, command.message, command.idempotencyKey, context.signal, undefined, "human", command.attachments ?? []); - case "session.queue": return await this.#queue(session.id, command.message, command.idempotencyKey, undefined, command.attachments ?? []); + case "session.queue": return await this.#queue(session.id, command.message, command.idempotencyKey, context.signal, undefined, command.attachments ?? []); case "session.steer": return await this.#steer(session.id, command.message, command.idempotencyKey, context.signal, undefined, command.attachments ?? []); case "session.stop": return await this.#stop(session.id, command.idempotencyKey, context.signal); case "session.rename": return await this.#rename(session.id, command.name, command.idempotencyKey, context.signal); @@ -1846,7 +2302,8 @@ export class HraService { if (interaction.sessionId !== session.id) { throw new CommandFailure("CONFLICT", "The remote decision names an interaction of another session."); } - return await this.#resolveInteraction(command, { signal: context.signal }); + return await this.#serialize(`interaction:${command.interaction}`, async () => + await this.#resolveInteractionLocked(command, { signal: context.signal })); } } }, @@ -1871,6 +2328,7 @@ export class HraService { close(): Promise { if (this.#closeTask !== undefined) return this.#closeTask; this.#state = "closing"; + this.#sessionFactAuthorities.clear(); this.#backgroundAbort.abort(new Error("HRA service is closing.")); this.#interactionDeadlineAbort.abort(new Error("HRA service is closing.")); this.#interactionDeadlineWake?.(); @@ -1894,6 +2352,14 @@ export class HraService { } async #recoverAdmitted(): Promise { + // A launch intent can span an actual child launch before PID/start + // admission. Never delete or step past it: an unidentified live child is + // a harder boundary than a pending revocation and requires exact recovery. + if (this.#store.listClaudeProcessLaunchIntents().length > 0) { + throw new Error( + "Daemon recovery cannot run while a Claude process launch intent is unresolved.", + ); + } await this.#cloud.supersedeTerminalCompactProjectionRecoveries(); await this.#daemonAuthority.assertCurrent(); const recoveredMutations = this.#store.recoverEffectStartedMutations(); @@ -1904,8 +2370,15 @@ export class HraService { if (recoveredQueue.unresolved.length > 0) { throw new Error(`Daemon recovery cannot resolve ${String(recoveredQueue.unresolved.length)} dispatching queue authorities.`); } + await this.#recoverProfilePersonalAuthorityRevocations( + this.#interactionDeadlineAbort.signal, + ); + await this.#recoverProviderRuntimeAccountRevocations( + this.#interactionDeadlineAbort.signal, + ); await this.#reconcileTerminalFactsMemory(); await this.#recoverPreparedWorkEffects(this.#interactionDeadlineAbort.signal); + await this.#recoverClaudeProcessAuthorities(this.#interactionDeadlineAbort.signal); await this.#daemonAuthority.assertCurrent(); const pendingSessions = new Set(); for (const queued of this.#store.listRecoverableQueue()) { @@ -1922,112 +2395,607 @@ export class HraService { } } let continueAfterId: string | null = null; - const activeSessions: SessionRecord[] = []; + const sessionsToReconnect: SessionRecord[] = []; for (;;) { const page = this.#store.listCloudSessionPage({ afterId: continueAfterId, limit: 100, }); for (const session of page.sessions) { - if (session.providerThreadId === undefined || session.state === "terminal") continue; + if (session.providerThreadId === undefined) continue; + const binding = this.#store.readSessionPersonalRuntimeBinding(session.id, true); + const bindingMatches = binding !== null + && binding.provider === session.provider + && binding.providerThreadId === session.providerThreadId; + if (bindingMatches && binding.state === "detaching") { + try { + switch (session.provider) { + case "codex": { + const runtime = this.#personalCodex; + if (runtime === undefined || runtime.releaseOwnedAuthority === undefined) { + throw new ProviderRuntimeUnavailableError( + "Personal-home Codex control cannot finish detach recovery.", + ); + } + const profile = this.#store.requireProfileById(session.profileId); + const authority = this.#personalAuthorityForProfile(profile); + await runtime.releaseOwnedAuthority({ + authority, + signal: new AbortController().signal, + }); + break; + } + case "claude": { + const process = this.#store.readClaudeProcessAuthority({ + providerThreadId: session.providerThreadId, + profileId: session.profileId, + runtimeScope: "personal", + }); + if (process?.state !== "released") { + throw new ProviderRuntimeUnavailableError( + "Claude detach recovery is waiting for exact process release.", + ); + } + break; + } + case "devin": + throw new ProviderRuntimeUnavailableError( + "A Devin session cannot carry personal-home detach authority.", + ); + } + this.#store.completePersonalSessionDetach({ sessionId: session.id }); + } catch (error: unknown) { + this.recordBackgroundDiagnostic("session_adoption_failed", error); + } + continue; + } + if (session.state === "terminal" || session.state === "recovery_required") continue; + if (bindingMatches && binding.state === "detached") continue; + const usesPersonalRuntime = bindingMatches && binding.state === "active"; this.#sessionsAwaitingResubscription.add(session.id); const profile = this.#store.requireProfile(session.profileId); if ( - session.state === "active" + (session.state === "active" || session.provider === "claude" || usesPersonalRuntime) && this.#profileAllowsEstablishedSession(profile, session) ) { - activeSessions.push(session); + if (session.provider === "claude") { + const process = this.#store.readClaudeProcessAuthority({ + providerThreadId: session.providerThreadId, + profileId: session.profileId, + runtimeScope: usesPersonalRuntime ? "personal" : "managed", + }); + if ( + process === null + || process.state !== "released" + || (process.sessionId !== null && process.sessionId !== session.id) + ) { + if (process === null) this.#quarantineSession(session.id); + continue; + } + } + sessionsToReconnect.push(session); } } if (page.isDone || page.continueAfterId === null) break; continueAfterId = page.continueAfterId; } - this.#scheduleRecoverySessionObservations(activeSessions); + this.#scheduleRecoverySessionObservations(sessionsToReconnect); this.#wakeInteractionDeadlinePump(); this.#wakeSessionTaskPump(); } - async #recoverPreparedWorkEffects(signal: AbortSignal): Promise { - let cursor: Parameters[0]; + async #recoverClaudeProcessAuthorities(signal: AbortSignal): Promise { + for (const process of this.#store.listUnreleasedClaudeProcessAuthorities()) { + signal.throwIfAborted(); + try { + await this.#releaseClaudeProcessAuthority(process, signal); + } catch (error: unknown) { + if (signal.aborted) throw signal.reason; + this.recordBackgroundDiagnostic("recovery_observation_failed", error); + } + } + // A crash may land after personal-session detach was durably staged but + // before its Claude child was released. The first recovery pass above now + // owns that exact release; finish the already-authorized detach in the same + // boot instead of requiring a second restart merely to observe `released`. for (;;) { - if (this.#workEffectRecoveryStopped(signal)) return; - await this.#daemonAuthority.assertCurrent(); - const page = this.#work.recoverablePreparedEffects(cursor, 32); - for (const recoverable of page.effects) { - if (this.#workEffectRecoveryStopped(signal)) return; - await this.#daemonAuthority.assertCurrent(); - this.#assertPreparedEffectBinding(recoverable.effect, recoverable.status); - - let executionError: unknown; - if (recoverable.status.state === "prepared") { - try { - await this.#performPreparedWorkEffect( - recoverable.effect, - recoverable.idempotencyKey, - signal, - ); - } catch (error: unknown) { - executionError = error; - } - } - - await this.#daemonAuthority.assertCurrent(); - let projected = this.#work.reprojectPreparedEffect(recoverable.idempotencyKey); - this.#assertPreparedEffectBinding(recoverable.effect, projected); - if (projected.state === "prepared") { - projected = this.#work.settlePreparedEffectNoEffect( - recoverable.idempotencyKey, - "startup_preflight_no_effect", - ); - this.#assertPreparedEffectBinding(recoverable.effect, projected); - } - this.#workWaiters.notify(recoverable.effect.workId); - if (executionError instanceof StateSecurityScrubRequiredError) { - throw executionError; - } + const detaching = this.#store.listSessionPersonalRuntimeBindings({ + provider: "claude", + state: "detaching", + limit: 500, + }); + if (detaching.length === 0) break; + let progressed = false; + for (const binding of detaching) { + signal.throwIfAborted(); + const session = this.#store.requireSession(binding.sessionId); + const process = this.#store.readClaudeProcessAuthority({ + providerThreadId: binding.providerThreadId, + profileId: session.profileId, + runtimeScope: "personal", + }); + if (process?.state !== "released") continue; + this.#store.completePersonalSessionDetach({ sessionId: binding.sessionId }); + progressed = true; } - if (page.nextCursor === null) return; - cursor = page.nextCursor; - // Keep each startup read and recovery batch bounded while allowing close - // and notification work to run before the next page is admitted. - await new Promise((resolveYield) => setTimeout(resolveYield, 0)); + if (!progressed) break; } } - #workEffectRecoveryStopped(signal: AbortSignal): boolean { - return this.#state !== "open" || signal.aborted; + async #recoverProfilePersonalAuthorityRevocations(signal: AbortSignal): Promise { + for (const revocation of this.#store.listReleasingProfilePersonalAuthorityRevocations()) { + this.#profileAuthorityRevocationsPending.set( + revocation.profileId, + revocation.profileGeneration, + ); + } + for (const revocation of this.#store.listReleasingProfilePersonalAuthorityRevocations()) { + signal.throwIfAborted(); + try { + await this.#runProfilePersonalAuthorityRevocation( + revocation.profileId, + revocation.profileGeneration, + signal, + ); + if ( + this.#profileAuthorityRevocationsPending.get(revocation.profileId) + === revocation.profileGeneration + ) this.#profileAuthorityRevocationsPending.delete(revocation.profileId); + } catch (error: unknown) { + if (signal.aborted) throw signal.reason; + this.recordBackgroundDiagnostic("profile_authority_revocation_failed", error); + } + } } - async settled(): Promise { - while (this.#mutationTails.size > 0 || this.#background.size > 0) { - await Promise.allSettled([...this.#mutationTails.values(), ...this.#background]); + async #recoverProviderRuntimeAccountRevocations(signal: AbortSignal): Promise { + for (const revocation of this.#store.listReleasingProviderRuntimeAccountRevocations()) { + signal.throwIfAborted(); + this.#clearProfileFactAuthorities( + revocation.profileId, + revocation.provider, + revocation.runtimeScope, + ); + await this.#serialize(`session-adoption:${revocation.provider}`, async () => + await this.#serialize(`account:${revocation.profileId}`, async () => + await this.#runProviderRuntimeAccountRevocation( + revocation, + signal, + ))); } } - /** - * Records that a background task failed. Only the closed code and a closed - * cause class are kept; error text never enters the record. - */ - recordBackgroundDiagnostic(code: BackgroundDiagnosticCode, error?: unknown): void { - if (this.#state !== "open") return; - const previous = this.#backgroundDiagnostics.get(code); - const diagnostic: BackgroundDiagnostic = { - code, - cause: classifyBackgroundDiagnosticCause(error), - count: Math.min((previous?.count ?? 0) + 1, Number.MAX_SAFE_INTEGER), - observedAt: this.#now(), - }; - this.#backgroundDiagnostics.set(code, diagnostic); - this.#lastBackgroundDiagnostic = diagnostic; + #scheduleClaudeProcessAuthorityRelease(key: ClaudeProcessAuthorityKey): void { + const task = Promise.resolve().then(async () => { + await this.#releaseClaudeProcessAuthority( + key, + this.#backgroundAbort.signal, + ); + }); + const tracked = task.catch((error: unknown) => { + if (this.#backgroundAbort.signal.aborted) return; + this.recordBackgroundDiagnostic("recovery_observation_failed", error); + }); + this.#background.add(tracked); + void tracked.then(() => this.#background.delete(tracked)); } - backgroundDiagnostics(): Readonly<{ - last: BackgroundDiagnostic | null; - byCode: readonly BackgroundDiagnostic[]; - }> { - return { - last: this.#lastBackgroundDiagnostic, - byCode: [...this.#backgroundDiagnostics.values()] + #scheduleClaudeDisconnectRecovery(sessions: readonly SessionRecord[]): void { + if (this.#state !== "open" || sessions.length === 0) return; + const task = (async () => { + for (const disconnected of sessions) { + if (this.#state !== "open") return; + await this.#serializeSessionAuthority( + disconnected, + async () => { + let current: SessionRecord; + try { + current = this.#store.requireSession(disconnected.id); + } catch (error: unknown) { + if (error instanceof SelectionError && error.code === "NOT_FOUND") return; + throw error; + } + if ( + current.profileId !== disconnected.profileId + || current.provider !== "claude" + || current.provider !== disconnected.provider + || current.providerThreadId === undefined + || current.providerThreadId !== disconnected.providerThreadId + ) return; + + const binding = this.#store.readSessionPersonalRuntimeBinding(current.id, true); + const bindingMatches = binding !== null + && binding.provider === current.provider + && binding.providerThreadId === current.providerThreadId; + if (binding?.state === "active" && !bindingMatches) { + throw new ProviderRuntimeUnavailableError( + "The personal-home session binding no longer matches its durable session identity.", + ); + } + const runtimeScope = bindingMatches ? "personal" : "managed"; + const profile = this.#store.requireProfileById(current.profileId); + if ( + current.state === "terminal" + || current.state === "recovery_required" + || !this.#profileAllowsEstablishedSession(profile, current) + || (bindingMatches && binding.state !== "active") + ) { + const process = this.#store.readClaudeProcessAuthority({ + providerThreadId: current.providerThreadId, + profileId: current.profileId, + runtimeScope, + }); + if (process !== null && process.state !== "released") { + await this.#releaseClaudeProcessAuthority( + process, + new AbortController().signal, + ); + } + return; + } + + // The disconnect callback can be delayed until a foreground + // recovery has already bound a replacement child. Re-observe the + // session under its authority tail so that healthy replacement is + // retained; the central Claude observation-recovery path releases + // only the exact currently persisted process when repair is needed. + await this.#ensureSessionObservedLocked( + current.id, + new AbortController().signal, + ); + }, + { allowDuringProjectionRecovery: true }, + ).catch((error: unknown) => { + this.recordBackgroundDiagnostic("recovery_observation_failed", error); + }); + } + })(); + const tracked = task.catch((error: unknown) => { + if (this.#backgroundAbort.signal.aborted) return; + this.recordBackgroundDiagnostic("recovery_observation_failed", error); + }); + this.#background.add(tracked); + void tracked.then(() => this.#background.delete(tracked)); + } + + #scheduleTerminalPersonalDetach(session: SessionRecord): void { + const task = this.#serialize(`session-adoption:${session.provider}`, async () => + await this.#serializeSessionAuthority( + session, + async () => { await this.#detachPersonalSession(session.id, this.#backgroundAbort.signal); }, + { allowDuringProjectionRecovery: true }, + )); + const tracked = task.catch((error: unknown) => { + if (this.#backgroundAbort.signal.aborted) return; + this.recordBackgroundDiagnostic("session_adoption_failed", error); + }); + this.#background.add(tracked); + void tracked.then(() => this.#background.delete(tracked)); + } + + #profileAuthorityRevocationIsPending( + profileId: ProfileRecord["id"], + processGeneration?: number, + ): boolean { + const pending = this.#profileAuthorityRevocationsPending.get(profileId); + return pending !== undefined + && (processGeneration === undefined || pending === processGeneration); + } + + #providerRuntimeAccountRevocationIsPending( + profileId: ProfileRecord["id"], + profileGeneration: number, + provider: AdoptableProvider, + runtimeScope: RuntimeAccountScope, + ): boolean { + const revocation = this.#store.readProviderRuntimeAccountRevocation({ + profileId, + provider, + runtimeScope, + }); + return revocation?.state === "releasing" + && revocation.profileGeneration === profileGeneration; + } + + #profileHasControllingRuntimeAuthority( + profile: Pick, + ): boolean { + return this.#store.profileHasControllingPersonalSessions(profile.id) + || this.#store.profileHasClaudeProcessLaunchIntents( + profile.id, + ) + || this.#store.profileHasUnreleasedClaudeProcessAuthorities( + profile.id, + ); + } + + async #releaseProfileClaudeControllersLocked( + profile: Pick, + signal: AbortSignal, + ): Promise { + for (const runtimeScope of ["managed", "personal"] as const) { + let afterProviderThreadId: string | null = null; + for (;;) { + const page = this.#store.listUnreleasedClaudeProcessAuthorityPage({ + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope, + afterProviderThreadId, + limit: 100, + }); + for (const process of page.authorities) { + signal.throwIfAborted(); + await this.#releaseClaudeProcessAuthority(process, signal); + } + if (page.continueAfterProviderThreadId === null) break; + afterProviderThreadId = page.continueAfterProviderThreadId; + } + } + if (this.#store.profileHasUnreleasedClaudeProcessAuthorities( + profile.id, + )) { + throw new ProviderRuntimeUnavailableError( + "Every Claude controller must be exactly released before account authority can change.", + ); + } + } + + #scheduleProfilePersonalAuthorityRevocation( + profile: Pick, + ): void { + this.#clearPersonalAccountAttestations(profile.id); + // Persist and apply the complete authority fence before yielding to the + // asynchronous controller-release task. A daemon loss or a same-tick + // command after this callback must see recovery_required sessions, closed + // interactions, and retired work rather than a merely staged row. + const begun = this.#store.beginProfilePersonalAuthorityRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + workStore: this.#work, + }); + this.#notifyAffectedWork(begun.affectedWorkIds); + for (const sessionId of begun.sessionIds) { + this.#sessionProviderConnections.delete(sessionId); + this.#clearSessionFactAuthority(sessionId); + this.#sessionObservationFailures.delete(sessionId); + this.#sessionResubscriptionConnections.delete(sessionId); + this.#sessionsAwaitingResubscription.delete(sessionId); + this.#eventWaiters.notify(sessionId); + } + for (const interaction of begun.interactions) { + if (interaction.sessionId !== null) this.#eventWaiters.notify(interaction.sessionId); + } + this.#profileAuthorityRevocationsPending.set(profile.id, profile.processGeneration); + if (this.#profileAuthorityRevocationTasks.has(profile.id)) return; + const task = Promise.resolve().then(async () => { + await this.#runProfilePersonalAuthorityRevocation( + profile.id, + profile.processGeneration, + this.#backgroundAbort.signal, + ); + if (this.#profileAuthorityRevocationsPending.get(profile.id) === profile.processGeneration) { + this.#profileAuthorityRevocationsPending.delete(profile.id); + } + }); + const tracked = task.catch((error: unknown) => { + if (this.#backgroundAbort.signal.aborted) return; + this.recordBackgroundDiagnostic("profile_authority_revocation_failed", error); + }); + this.#profileAuthorityRevocationTasks.set(profile.id, tracked); + this.#background.add(tracked); + void tracked.then(() => { + if (this.#profileAuthorityRevocationTasks.get(profile.id) === tracked) { + this.#profileAuthorityRevocationTasks.delete(profile.id); + } + this.#background.delete(tracked); + }); + } + + async #runProfilePersonalAuthorityRevocation( + profileId: ProfileRecord["id"], + expectedGeneration: number, + signal: AbortSignal, + ): Promise { + signal.throwIfAborted(); + const revoke = async (): Promise => { + const profile = this.#store.requireProfileById(profileId); + if (profile.processGeneration !== expectedGeneration) { + throw new Error("PROFILE_PERSONAL_AUTHORITY_REVOCATION_STALE"); + } + const existing = this.#store.readProfilePersonalAuthorityRevocation(profileId); + if (existing?.state === "completed") { + if (existing.profileGeneration !== expectedGeneration || profile.state !== "signed_out") { + throw new Error("PROFILE_PERSONAL_AUTHORITY_REVOCATION_CONFLICT"); + } + return; + } + const bindings = this.#store.listProfileControllingPersonalRuntimeBindings(profileId); + const interactions = this.#store.listOpenInteractionsForProfile( + profileId, + expectedGeneration, + ); + const claudeProcesses = this.#store.listUnreleasedClaudeProcessAuthoritiesForProfile( + profileId, + ); + const sessionIds = this.#nonterminalSessionIdsForProfile(profileId); + const keys = [ + ...new Set([ + ...sessionIds.map((sessionId) => `session:${sessionId}`), + ...bindings.map((binding) => `session:${binding.sessionId}`), + ...claudeProcesses.flatMap((process) => process.sessionId === null + ? [] + : [`session:${process.sessionId}`]), + ]), + ...interactions.map((interaction) => `interaction:${interaction.publicId}`), + ]; + await this.#serializeKeys(keys, async () => { + signal.throwIfAborted(); + const begun = this.#store.beginProfilePersonalAuthorityRevocation({ + profileId, + expectedGeneration, + workStore: this.#work, + }); + this.#notifyAffectedWork(begun.affectedWorkIds); + for (const sessionId of begun.sessionIds) { + this.#sessionProviderConnections.delete(sessionId); + this.#clearSessionFactAuthority(sessionId); + this.#sessionObservationFailures.delete(sessionId); + this.#sessionResubscriptionConnections.delete(sessionId); + this.#sessionsAwaitingResubscription.delete(sessionId); + this.#eventWaiters.notify(sessionId); + } + for (const interaction of begun.interactions) { + if (interaction.sessionId !== null) this.#eventWaiters.notify(interaction.sessionId); + } + + await this.#releaseProfileClaudeControllersLocked( + { id: profileId, processGeneration: expectedGeneration }, + signal, + ); + const exactProfile = this.#store.requireProfileById(profileId, { + includeRemoved: true, + }); + if (this.#codex.releaseOwnedAuthority !== undefined) { + await this.#codex.releaseOwnedAuthority({ + authority: authorityFor(this.#paths, exactProfile), + signal, + }); + } else { + await this.#codex.close(); + } + if (begun.bindings.some((binding) => binding.provider === "codex")) { + if (this.#personalCodex === undefined) { + throw new ProviderRuntimeUnavailableError( + "Personal-home Codex control is unavailable during authority release.", + ); + } + if (this.#personalCodex.releaseOwnedAuthority !== undefined) { + await this.#personalCodex.releaseOwnedAuthority({ + authority: this.#personalAuthorityForProfile(exactProfile), + signal, + }); + } else { + await this.#personalCodex.close(); + } + } + for (const binding of begun.bindings) { + signal.throwIfAborted(); + const current = this.#store.readSessionPersonalRuntimeBinding( + binding.sessionId, + true, + ); + if (current === null || current.state === "detached") continue; + this.#store.completePersonalSessionDetach({ sessionId: binding.sessionId }); + } + this.#store.completeProfilePersonalAuthorityRevocation({ + profileId, + expectedGeneration, + }); + }); + }; + await this.#serialize("session-adoption:codex", async () => + await this.#serialize("session-adoption:claude", async () => + await this.#serialize(`account:${profileId}`, revoke))); + } + + #nonterminalSessionIdsForProfile( + profileId: ProfileRecord["id"], + ): readonly SessionRecord["id"][] { + const sessionIds: SessionRecord["id"][] = []; + let afterId: string | null = null; + for (;;) { + const page = this.#store.listCloudSessionPage({ afterId, limit: 100 }); + for (const session of page.sessions) { + if (session.profileId === profileId && session.state !== "terminal") { + sessionIds.push(session.id); + } + } + if (page.isDone || page.continueAfterId === null) return sessionIds; + afterId = page.continueAfterId; + } + } + + async #recoverPreparedWorkEffects(signal: AbortSignal): Promise { + let cursor: Parameters[0]; + for (;;) { + if (this.#workEffectRecoveryStopped(signal)) return; + await this.#daemonAuthority.assertCurrent(); + const page = this.#work.recoverablePreparedEffects(cursor, 32); + for (const recoverable of page.effects) { + if (this.#workEffectRecoveryStopped(signal)) return; + await this.#daemonAuthority.assertCurrent(); + this.#assertPreparedEffectBinding(recoverable.effect, recoverable.status); + + let executionError: unknown; + if (recoverable.status.state === "prepared") { + try { + await this.#performPreparedWorkEffect( + recoverable.effect, + recoverable.idempotencyKey, + signal, + ); + } catch (error: unknown) { + executionError = error; + } + } + + await this.#daemonAuthority.assertCurrent(); + let projected = this.#work.reprojectPreparedEffect(recoverable.idempotencyKey); + this.#assertPreparedEffectBinding(recoverable.effect, projected); + if (projected.state === "prepared") { + projected = this.#work.settlePreparedEffectNoEffect( + recoverable.idempotencyKey, + "startup_preflight_no_effect", + ); + this.#assertPreparedEffectBinding(recoverable.effect, projected); + } + this.#workWaiters.notify(recoverable.effect.workId); + if (executionError instanceof StateSecurityScrubRequiredError) { + throw executionError; + } + } + if (page.nextCursor === null) return; + cursor = page.nextCursor; + // Keep each startup read and recovery batch bounded while allowing close + // and notification work to run before the next page is admitted. + await new Promise((resolveYield) => setTimeout(resolveYield, 0)); + } + } + + #workEffectRecoveryStopped(signal: AbortSignal): boolean { + return this.#state !== "open" || signal.aborted; + } + + async settled(): Promise { + while (this.#mutationTails.size > 0 || this.#background.size > 0) { + await Promise.allSettled([...this.#mutationTails.values(), ...this.#background]); + } + } + + /** + * Records that a background task failed. Only the closed code and a closed + * cause class are kept; error text never enters the record. + */ + recordBackgroundDiagnostic(code: BackgroundDiagnosticCode, error?: unknown): void { + if (this.#state !== "open") return; + const previous = this.#backgroundDiagnostics.get(code); + const diagnostic: BackgroundDiagnostic = { + code, + cause: classifyBackgroundDiagnosticCause(error), + count: Math.min((previous?.count ?? 0) + 1, Number.MAX_SAFE_INTEGER), + observedAt: this.#now(), + }; + this.#backgroundDiagnostics.set(code, diagnostic); + this.#lastBackgroundDiagnostic = diagnostic; + } + + backgroundDiagnostics(): Readonly<{ + last: BackgroundDiagnostic | null; + byCode: readonly BackgroundDiagnostic[]; + }> { + return { + last: this.#lastBackgroundDiagnostic, + byCode: [...this.#backgroundDiagnostics.values()] .sort((left, right) => left.code.localeCompare(right.code)), }; } @@ -2074,7 +3042,7 @@ export class HraService { let failed = 0; for (const interaction of due) { if (this.#interactionDeadlineMaintenanceStopped()) break; - await this.#serialize(`interaction:${interaction.publicId}`, async () => { + await this.#serializeInteractionAuthority(interaction.publicId, async () => { const current = this.#store.requireInteraction(interaction.publicId); if (current.state !== "pending" || current.deadlineAt > this.#now()) return; await this.#expireInteractionAtDeadline( @@ -2244,16 +3212,34 @@ export class HraService { signal: AbortSignal, ): Promise { const profile = this.#store.requireProfileById(current.authority.profileId); + if (!this.#interactionProfileAuthorityIsUsable(current)) { + const terminal = this.#store.expireInteraction({ + id: current.publicId, + expectedRevision: current.revision, + }); + this.#appendInteractionState(terminal); + return; + } const runtime = this.#runtimeForInteraction(current); let responseDigest: string; try { await this.#daemonAuthority.assertCurrent(); + await this.#assertPersonalInteractionAccountAuthority(current, profile, signal); const validated = await runtime.validateInteractionTimeout({ - authority: authorityFor(this.#paths, profile), + authority: this.#authorityForInteraction(current, profile), provider: current.authority, signal, }); + await this.#assertPersonalInteractionAccountAuthority(current, profile, signal); responseDigest = validated.responseDigest; + if (!this.#interactionProfileAuthorityIsUsable(current)) { + const terminal = this.#store.expireInteraction({ + id: current.publicId, + expectedRevision: current.revision, + }); + this.#appendInteractionState(terminal); + return; + } } catch (error: unknown) { if (signal.aborted) return; const latest = this.#store.requireInteraction(current.publicId); @@ -2288,16 +3274,24 @@ export class HraService { } try { await this.#daemonAuthority.assertCurrent(); + await this.#assertPersonalInteractionAccountAuthority(prepared, profile, signal); await runtime.timeoutInteraction({ - authority: authorityFor(this.#paths, profile), + authority: this.#authorityForInteraction(prepared, profile), provider: prepared.authority, signal, }); + await this.#assertInteractionAccountAuthorityAfterProviderEffect( + prepared, + profile, + signal, + ); } catch (error: unknown) { if (signal.aborted) return; const latest = this.#store.requireInteraction(prepared.publicId); if (latest.state !== "response_prepared" || latest.revision !== prepared.revision) return; - const terminal = providerFailureCode(error) === "INDETERMINATE_EFFECT" + const indeterminate = error instanceof IndeterminateLocalCommitError + || providerFailureCode(error) === "INDETERMINATE_EFFECT"; + const terminal = indeterminate ? this.#store.markInteractionResolutionUnknown({ id: latest.publicId, expectedRevision: latest.revision, @@ -2337,23 +3331,31 @@ export class HraService { } async observeCodexFact(authority: ProfileAuthority, fact: CodexFact): Promise { - await this.#observeProviderFact("codex", authority, fact); + await this.#observeProviderFact(authority, fact, "codex", "managed"); + } + + async observePersonalCodexFact( + authority: ProfileAuthority, + fact: CodexFact, + ): Promise { + await this.#observeProviderFact(authority, fact, "codex", "personal"); } /** Applies one neutral fact emitted by the isolated Devin ACP runtime. */ async observeDevinFact(authority: ProfileAuthority, fact: CodexFact): Promise { - await this.#observeProviderFact("devin", authority, fact); + await this.#observeProviderFact(authority, fact, "devin", "managed"); } async #observeProviderFact( - provider: Provider, authority: ProfileAuthority, fact: CodexFact, + provider: Provider, + source: ProviderFactSource, ): Promise { const finish = this.#beginFactOperation(); if (finish === null) return; try { - await this.#observeProviderFactAdmitted(provider, authority, fact); + await this.#observeProviderFactAdmitted(authority, fact, provider, source); } catch (error: unknown) { if (error instanceof InteractionPersistenceBoundaryError) this.#scheduleStop(); if (error instanceof StateSecurityScrubRequiredError) this.#requestStop(); @@ -2371,9 +3373,28 @@ export class HraService { * therefore provider-agnostic by construction. */ async observeClaudeFact(authority: ProfileAuthority, fact: ClaudeSessionFact): Promise { + await this.#observeTranslatedClaudeFact(this.#claudeFacts, authority, fact, "managed"); + } + + /** Facts from the dedicated personal-home Claude controller. */ + async observePersonalClaudeFact( + authority: ProfileAuthority, + fact: ClaudeSessionFact, + ): Promise { + const translator = this.#personalClaudeFacts; + if (translator === undefined) return; + await this.#observeTranslatedClaudeFact(translator, authority, fact, "personal"); + } + + async #observeTranslatedClaudeFact( + translator: ClaudeSessionFactTranslator, + authority: ProfileAuthority, + fact: ClaudeSessionFact, + source: ProviderFactSource, + ): Promise { let translated: readonly CodexFact[]; try { - translated = this.#claudeFacts.translate(fact); + translated = translator.translate(fact); } catch (error: unknown) { // A control request whose authority the runtime can no longer prove is // a dropped fact, never a fault on a live session. @@ -2381,7 +3402,7 @@ export class HraService { return; } for (const neutral of translated) { - await this.#observeProviderFact("claude", authority, neutral); + await this.#observeProviderFact(authority, neutral, "claude", source); } } @@ -2394,5341 +3415,9390 @@ export class HraService { } } - #runtimeForSession( - session: Readonly<{ provider: Provider }>, - ): SessionRuntimePort { - return this.#sessionRuntime(session.provider); - } - - /** - * The port that owns a brokered interaction. The session it belongs to is - * the authority; an interaction with no session (a provider-level request) - * is attributed by the durable method name its authority recorded. - */ - #runtimeForInteraction( - record: Readonly<{ - sessionId: SessionRecord["id"] | null; - authority: ProviderInteractionAuthority; - }>, - ): SessionRuntimePort { - if (record.sessionId !== null) { - try { - const session = this.#store.requireSession(record.sessionId); - if (session.provider === "claude") this.#assertClaudeIsolationAccepted(); - return this.#runtimeForSession(session); - } catch (error: unknown) { - if (!(error instanceof SelectionError && error.code === "NOT_FOUND")) throw error; - // Fall through to the durable method name below. - } + #personalSessionRuntime(provider: AdoptableProvider): SessionRuntimePort { + let runtime: CodexRuntimePort | ClaudeRuntimePort | undefined; + switch (provider) { + case "codex": runtime = this.#personalCodex; break; + case "claude": runtime = this.#personalClaude; break; } - const provider = this.#providerForInteractionAuthority(record.authority); - if (provider === "claude") this.#assertClaudeIsolationAccepted(); - return this.#sessionRuntime(provider); + if (runtime !== undefined) return runtime; + throw new ProviderRuntimeUnavailableError( + `Personal-home ${provider} session control is unavailable on this daemon.`, + ); } - #providerForInteractionAuthority( - authority: Pick, - ): Provider { - return authority.method.startsWith("claude/") - ? "claude" - : authority.method.startsWith("devin/") - ? "devin" - : "codex"; + #personalAccountAttestationKey( + provider: AdoptableProvider, + profileId: ProfileRecord["id"], + runtimeScope: RuntimeAccountScope = "personal", + ): string { + return `${runtimeScope}:${provider}:${profileId}`; } - /** Refuses a Codex-only capability on a session bound to another provider. */ - #requireCodexSession(session: Readonly<{ provider: Provider }>, capability: string): void { - if (session.provider === "codex") return; - throw new CommandFailure( - "INVALID_INPUT", - `The ${session.provider} provider does not support ${capability}. ` - + "It is available on Codex sessions only.", + #clearPersonalAccountAttestations(profileId: ProfileRecord["id"]): void { + this.#personalAccountAttestations.delete( + this.#personalAccountAttestationKey("codex", profileId), ); + this.#personalAccountAttestations.delete( + this.#personalAccountAttestationKey("claude", profileId), + ); + this.#personalAccountAttestations.delete( + this.#personalAccountAttestationKey("codex", profileId, "managed"), + ); + this.#personalAccountAttestations.delete( + this.#personalAccountAttestationKey("claude", profileId, "managed"), + ); + this.#clearProfileFactAuthorities(profileId); } - async handleConversationAutomationToolCall( - authority: ProfileAuthority, - call: ConversationAutomationToolCall, - ): Promise { - const finish = this.#beginOperation(); - try { - await this.#daemonAuthority.assertCurrent(); - if ( - call.authority.profileId !== authority.id - || call.authority.processGeneration !== authority.generation - ) throw new Error("CONVERSATION_AUTOMATION_AUTHORITY_MISMATCH"); - const profile = this.#store.requireProfileById(authority.id); - if ( - profile.processGeneration !== authority.generation - || profile.state !== "signed_in" - ) throw new Error("CONVERSATION_AUTOMATION_AUTHORITY_STALE"); - const session = this.#store.findSessionByProviderThread(authority.id, call.threadId); - if ( - session === null - || session.state === "terminal" - || session.state === "recovery_required" - || !this.#store.isConversationAutomationEnabled(session.id, call.threadId) - ) { - throw new Error("CONVERSATION_AUTOMATION_SESSION_UNAVAILABLE"); + #scheduleProviderRuntimeAccountRevocation( + profile: Pick, + provider: AdoptableProvider, + runtimeScope: RuntimeAccountScope, + currentAccountKey: string | null, + ): void { + this.#beginProviderRuntimeAccountRevocationFence( + profile, + provider, + runtimeScope, + currentAccountKey, + ); + const revocationKey = `${runtimeScope}:${provider}:${profile.id}`; + // Staging is deliberately above this in-memory dedupe. A second B -> C + // observation must durably advance the same job even while its B release + // worker is awaiting the provider. + if (this.#providerAccountRevocationTasks.has(revocationKey)) return; + const task = Promise.resolve().then(async () => { + await this.#serialize(`session-adoption:${provider}`, async () => + await this.#serialize(`account:${profile.id}`, async () => + await this.#runProviderRuntimeAccountRevocation({ + profileId: profile.id, + profileGeneration: profile.processGeneration, + provider, + runtimeScope, + }, this.#backgroundAbort.signal))); + }); + const tracked = task.catch((error: unknown) => { + if (this.#backgroundAbort.signal.aborted) return; + this.recordBackgroundDiagnostic( + "provider_account_authority_revocation_failed", + error, + ); + }); + this.#providerAccountRevocationTasks.set(revocationKey, tracked); + this.#background.add(tracked); + void tracked.then(() => { + if (this.#providerAccountRevocationTasks.get(revocationKey) === tracked) { + this.#providerAccountRevocationTasks.delete(revocationKey); } - const idempotencyKey = conversationAutomationIdempotencyKey(authority, call); - const result = await this.#serializeSessionAuthority( - session, - () => { - const currentProfile = this.#store.requireProfileById(authority.id); - const currentSession = this.#store.findSessionByProviderThread( - authority.id, - call.threadId, - ); - if ( - currentProfile.processGeneration !== authority.generation - || currentProfile.state !== "signed_in" - || currentSession === null - || currentSession.id !== session.id - || currentSession.state === "terminal" - || currentSession.state === "recovery_required" - || !this.#store.isConversationAutomationEnabled(currentSession.id, call.threadId) - ) throw new Error("CONVERSATION_AUTOMATION_AUTHORITY_STALE"); - switch (call.operation.mode) { - case "list": - return this.#sessionTasks.listIdempotent( - currentSession.id, - idempotencyKey, - call.requestDigest, - ); - case "view": - return summarizeSessionTask(this.#sessionTasks.requireIdempotent( - currentSession.id, - sessionTaskIdSchema.parse(call.operation.id), - idempotencyKey, - call.requestDigest, - )); - case "create": - return summarizeSessionTask(this.#sessionTasks.create({ - sessionId: currentSession.id, - name: call.operation.name, - prompt: call.operation.prompt, - minutes: call.operation.schedule.minutes, - status: call.operation.paused === true ? "paused" : "active", - idempotencyKey, - receiptDigest: call.requestDigest, - })); - case "update": { - const patch: SessionTaskPatch = { - ...(call.operation.name === undefined ? {} : { name: call.operation.name }), - ...(call.operation.prompt === undefined ? {} : { prompt: call.operation.prompt }), - ...(call.operation.schedule === undefined - ? {} - : { minutes: call.operation.schedule.minutes }), - ...(call.operation.status === undefined ? {} : { status: call.operation.status }), - }; - return summarizeSessionTask(this.#sessionTasks.edit({ - sessionId: currentSession.id, - taskId: sessionTaskIdSchema.parse(call.operation.id), - expectedRevision: call.operation.revision, - patch, - idempotencyKey, - receiptDigest: call.requestDigest, - })); - } - case "delete": - return this.#sessionTasks.delete({ - sessionId: currentSession.id, - taskId: sessionTaskIdSchema.parse(call.operation.id), - expectedRevision: call.operation.revision, - idempotencyKey, - receiptDigest: call.requestDigest, - }); + this.#background.delete(tracked); + }); + } + + #beginProviderRuntimeAccountRevocationFence( + profile: Pick, + provider: AdoptableProvider, + runtimeScope: RuntimeAccountScope, + currentAccountKey: string | null, + ): ReturnType { + const attestationKey = this.#personalAccountAttestationKey( + provider, + profile.id, + runtimeScope, + ); + this.#personalAccountAttestations.delete(attestationKey); + this.#clearProfileFactAuthorities(profile.id, provider, runtimeScope); + const begun = this.#store.beginProviderRuntimeAccountRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + provider, + runtimeScope, + currentAccountKey, + workStore: this.#work, + }); + this.#notifyAffectedWork(begun.affectedWorkIds); + for (const interaction of begun.interactions) { + if (interaction.sessionId !== null) this.#eventWaiters.notify(interaction.sessionId); + } + for (const sessionId of begun.sessionIds) { + this.#sessionProviderConnections.delete(sessionId); + this.#clearSessionFactAuthority(sessionId); + this.#sessionObservationFailures.delete(sessionId); + this.#sessionResubscriptionConnections.delete(sessionId); + this.#sessionsAwaitingResubscription.delete(sessionId); + this.#eventWaiters.notify(sessionId); + } + return begun; + } + + async #runProviderRuntimeAccountRevocation( + selector: Pick< + ProviderRuntimeAccountRevocationRecord, + "profileId" | "profileGeneration" | "provider" | "runtimeScope" + >, + signal: AbortSignal, + options: Readonly<{ allowEmptyPersonalCodexScope?: boolean }> = {}, + ): Promise { + for (;;) { + signal.throwIfAborted(); + const current = this.#store.readProviderRuntimeAccountRevocation({ + profileId: selector.profileId, + provider: selector.provider, + runtimeScope: selector.runtimeScope, + }); + if (current === null || current.state === "completed") return; + if (current.profileGeneration !== selector.profileGeneration) { + throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_STALE"); + } + const exact = this.#store.requireProfileById(selector.profileId, { + includeRemoved: true, + }); + if (exact.processGeneration !== selector.profileGeneration) { + throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_STALE"); + } + const restaged = this.#store.beginProviderRuntimeAccountRevocation({ + profileId: current.profileId, + expectedGeneration: current.profileGeneration, + provider: current.provider, + runtimeScope: current.runtimeScope, + currentAccountKey: current.currentAccountKey, + workStore: this.#work, + }); + this.#notifyAffectedWork(restaged.affectedWorkIds); + for (const interaction of restaged.interactions) { + if (interaction.sessionId !== null) this.#eventWaiters.notify(interaction.sessionId); + } + for (const sessionId of restaged.sessionIds) { + this.#sessionProviderConnections.delete(sessionId); + this.#clearSessionFactAuthority(sessionId); + this.#sessionObservationFailures.delete(sessionId); + this.#sessionResubscriptionConnections.delete(sessionId); + this.#sessionsAwaitingResubscription.delete(sessionId); + this.#eventWaiters.notify(sessionId); + } + const revision = restaged.revocation.revision; + if (current.provider === "codex") { + // The account callback durably stages the exact observed identity + // before it returns. The real Codex barrier also retires this exact + // generation before this worker can run, so any ordinary account read + // here would either fail AUTHORITY_STALE or incorrectly relaunch the + // authority we are releasing. Await only the nonlaunching close + // custody. A concurrent B -> C callback advances the durable revision + // synchronously and the completion read below covers that latest key. + const runtime = current.runtimeScope === "personal" + ? this.#personalCodex + : this.#codex; + const emptyPersonalScopeMayCloseWithoutRuntime = + options.allowEmptyPersonalCodexScope === true + && current.runtimeScope === "personal" + && restaged.bindings.length === 0; + if (!emptyPersonalScopeMayCloseWithoutRuntime) { + if (runtime?.releaseOwnedAuthority === undefined) { + throw new ProviderRuntimeUnavailableError( + `The ${current.runtimeScope} Codex runtime cannot safely release account authority.`, + ); } - }, - { allowDuringProjectionRecovery: false }, + await runtime.releaseOwnedAuthority({ + authority: current.runtimeScope === "personal" + ? this.#personalAuthorityForProfile(exact) + : authorityFor(this.#paths, exact), + signal, + }); + } + } else { + // Include unbound claimed/releasing processes: the exact PID/start + // custody record, not a session lookup, is the release authority. + let afterProviderThreadId: string | null = null; + for (;;) { + const page = this.#store.listUnreleasedClaudeProcessAuthorityPage({ + profileId: current.profileId, + profileGeneration: current.profileGeneration, + runtimeScope: current.runtimeScope, + afterProviderThreadId, + limit: 100, + }); + for (const process of page.authorities) { + signal.throwIfAborted(); + await this.#releaseClaudeProcessAuthority(process, signal); + } + if (page.continueAfterProviderThreadId === null) break; + afterProviderThreadId = page.continueAfterProviderThreadId; + } + } + if (current.runtimeScope === "personal") { + let afterSessionId: string | null = null; + for (;;) { + const page = this.#store.listProfileDetachingPersonalRuntimeBindingPage({ + profileId: current.profileId, + provider: current.provider, + afterSessionId, + limit: 500, + }); + for (const binding of page.bindings) { + this.#clearSessionFactAuthority(binding.sessionId); + this.#store.completePersonalSessionDetach({ + sessionId: binding.sessionId, + archive: false, + }); + } + if (page.continueAfterSessionId === null) break; + afterSessionId = page.continueAfterSessionId; + } + } + + if (current.provider === "codex") { + // A replacement observed while release was in flight was staged + // synchronously before its callback returned. Since release completed + // afterward, the same controller retirement covers that latest + // revision without trying to reopen a retired generation for a read. + const released = this.#store.readProviderRuntimeAccountRevocation({ + profileId: current.profileId, + provider: current.provider, + runtimeScope: current.runtimeScope, + }); + if (released === null || released.state === "completed") return; + if (released.profileGeneration !== current.profileGeneration) { + throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_STALE"); + } + this.#store.completeProviderRuntimeAccountRevocation({ + profileId: released.profileId, + expectedGeneration: released.profileGeneration, + provider: released.provider, + runtimeScope: released.runtimeScope, + expectedRevision: released.revision, + }); + return; + } + + const observedAccountKey = await this.#readClaudeRuntimeAccountKeyForRevocation( + current, + exact, + signal, ); - await this.#daemonAuthority.assertCurrent(); - return result; - } finally { - finish(); + const latest = this.#store.readProviderRuntimeAccountRevocation({ + profileId: current.profileId, + provider: current.provider, + runtimeScope: current.runtimeScope, + }); + if (latest === null || latest.state === "completed") return; + if ( + latest.revision !== revision + || latest.currentAccountKey !== current.currentAccountKey + ) continue; + if (observedAccountKey !== current.currentAccountKey) { + const advanced = this.#store.beginProviderRuntimeAccountRevocation({ + profileId: current.profileId, + expectedGeneration: current.profileGeneration, + provider: current.provider, + runtimeScope: current.runtimeScope, + currentAccountKey: observedAccountKey, + workStore: this.#work, + }); + this.#notifyAffectedWork(advanced.affectedWorkIds); + continue; + } + this.#store.completeProviderRuntimeAccountRevocation({ + profileId: current.profileId, + expectedGeneration: current.profileGeneration, + provider: current.provider, + runtimeScope: current.runtimeScope, + expectedRevision: revision, + }); + return; } } - /** Called only after Codex has received a successful dynamic-tool response frame. */ - notifyConversationAutomationToolResponseWritten( - authority: ProfileAuthority, - call: ConversationAutomationToolCall, - ): void { - if ( - this.#state !== "open" - || call.authority.profileId !== authority.id - || call.authority.processGeneration !== authority.generation - ) return; - try { - const profile = this.#store.requireProfileById(authority.id); - const session = this.#store.findSessionByProviderThread(authority.id, call.threadId); + async #releaseCodexAuthorityForAccountMutationLocked( + profile: Pick, + signal: AbortSignal, + options: Readonly<{ deferManagedRelease?: boolean }> = {}, + ): Promise { + for (const runtimeScope of ["personal", "managed"] as const) { + const existing = this.#store.readProviderRuntimeAccountRevocation({ + profileId: profile.id, + provider: "codex", + runtimeScope, + }); if ( - profile.processGeneration === authority.generation - && profile.state === "signed_in" - && session !== null - && session.state !== "terminal" - ) this.#wakeSessionTaskPump(); - } catch { - // The mutation was already committed and acknowledged; a later state change simply - // leaves the durable daemon pump or recovery path to observe it. + existing?.state === "completed" + && existing.profileGeneration === profile.processGeneration + && existing.currentAccountKey === null + ) { + if (runtimeScope === "managed" && options.deferManagedRelease === true) { + throw new ProviderRuntimeUnavailableError( + "Managed Codex authority already ended in this profile generation. Restart HRA before reconciling or retrying account logout.", + ); + } + continue; + } + if (runtimeScope === "managed" && options.deferManagedRelease === true) { + // Logout must durably fence every session before provider dispatch, + // while retaining the one exact managed client that owns account/logout. + // Unlike the ordinary scheduler, this intentionally creates no worker + // that could race and close that client before the effect begins. + this.#beginProviderRuntimeAccountRevocationFence( + profile, + "codex", + runtimeScope, + null, + ); + continue; + } + // A null replacement key is an intentional complete-scope fence: login + // and logout retire every Codex session/controller, native or adopted, + // even when its stored key names the account being changed. + this.#scheduleProviderRuntimeAccountRevocation( + profile, + "codex", + runtimeScope, + null, + ); + await this.#runProviderRuntimeAccountRevocation({ + profileId: profile.id, + profileGeneration: profile.processGeneration, + provider: "codex", + runtimeScope, + }, signal, { allowEmptyPersonalCodexScope: true }); + const completed = this.#store.readProviderRuntimeAccountRevocation({ + profileId: profile.id, + provider: "codex", + runtimeScope, + }); + if ( + completed?.state !== "completed" + || completed.profileGeneration !== profile.processGeneration + || completed.currentAccountKey !== null + ) { + throw new ProviderRuntimeUnavailableError( + `${runtimeScope === "personal" ? "Personal-home" : "Managed"} Codex authority did not finish releasing before the account mutation.`, + ); + } } } - async observeCodexAccount( - authority: ProfileAuthority, - account: CodexAccountProjection, + async #completeManagedCodexLogoutAuthorityReleaseLocked( + profile: Pick, ): Promise { - const finish = this.#beginFactOperation(); - if (finish === null) return; + // Once account/logout crossed its durable effect boundary, request + // cancellation no longer owns cleanup. Retire the exact client with an + // independent signal, while retaining daemon-fence checks around the + // nonlaunching release and its durable completion. + const releaseSignal = new AbortController().signal; + await this.#daemonAuthority.assertCurrent(); + let releaseFailure: unknown; try { - await this.#daemonAuthority.assertCurrent(); - let profile: ProfileRecord; - try { - profile = this.#store.requireProfileById(authority.id); - } catch { - return; - } - if ( - profile.processGeneration !== authority.generation - || this.#profileHasProjectionRecoveryInFlight(profile.id) - ) return; - const recoveryUnsettled = await this.#cloud - .isCompactProjectionRecoveryUnsettledForProfile(profile.id); - await this.#daemonAuthority.assertCurrent(); - if (recoveryUnsettled || this.#profileHasProjectionRecoveryInFlight(profile.id)) return; - const apply = async (): Promise => { - let current: ProfileRecord; - try { - current = this.#store.requireProfileById(profile.id); - } catch (error: unknown) { - if (error instanceof SelectionError && error.code === "NOT_FOUND") return; - throw error; - } - if ( - current.processGeneration !== authority.generation - || this.#profileHasProjectionRecoveryInFlight(profile.id) - ) return; - const blocked = await this.#cloud - .isCompactProjectionRecoveryUnsettledForProfile(profile.id); - await this.#daemonAuthority.assertCurrent(); - if (blocked || this.#profileHasProjectionRecoveryInFlight(profile.id)) return; - if (!account.signedIn && current.state === "login_pending") return; - const stateChange = this.#store.setProfileStateWithWorkRetirement( - current.id, - current.processGeneration, - account.signedIn ? "signed_in" : "signed_out", - this.#work, - { - ...(account.email === undefined ? {} : { email: account.email }), - ...(account.plan === undefined ? {} : { plan: account.plan }), - }, - ); - this.#notifyAffectedWork(stateChange.affectedWorkIds); - if (account.signedIn) this.#wakeSessionTaskPump(); - }; - const accountKey = `account:${profile.id}`; - if (!this.#mutationTails.has(accountKey)) { - await this.#serialize(accountKey, apply); - return; - } - // An account mutation holds the tail, and this callback may be awaited - // inside that mutation's own provider call, so it cannot wait its turn. - // Queue the fact behind the tail instead of applying it now: a signed-in - // fact written mid-login would move the profile out of `login_pending` - // under a commit that requires that exact state, which quarantined the - // account for a login that succeeded. - const queued = this.#serialize(accountKey, apply); - const tracked = queued.then( - () => undefined, - (error: unknown) => { - if (error instanceof StateSecurityScrubRequiredError) this.#requestStop(); - else this.recordBackgroundDiagnostic("account_fact_apply_failed", error); - }, - ); - this.#background.add(tracked); - void tracked.then(() => this.#background.delete(tracked)); + await this.#runProviderRuntimeAccountRevocation({ + profileId: profile.id, + profileGeneration: profile.processGeneration, + provider: "codex", + runtimeScope: "managed", + }, releaseSignal); } catch (error: unknown) { - if (error instanceof StateSecurityScrubRequiredError) this.#requestStop(); - throw error; - } finally { - finish(); + releaseFailure = error; + } + await this.#daemonAuthority.assertCurrent(); + if (releaseFailure !== undefined) { + throw releaseFailure instanceof Error + ? releaseFailure + : new Error("Managed Codex controller release failed.", { cause: releaseFailure }); + } + const completed = this.#store.readProviderRuntimeAccountRevocation({ + profileId: profile.id, + provider: "codex", + runtimeScope: "managed", + }); + if ( + completed?.state !== "completed" + || completed.profileGeneration !== profile.processGeneration + || completed.currentAccountKey !== null + ) { + throw new ProviderRuntimeUnavailableError( + "Managed Codex authority did not finish releasing after account logout dispatch.", + ); } } - async #observeProviderFactAdmitted( - provider: Provider, - authority: ProfileAuthority, - fact: CodexFact, - ): Promise { + async #readClaudeRuntimeAccountKeyForRevocation( + revocation: ProviderRuntimeAccountRevocationRecord, + profile: ProfileRecord, + signal: AbortSignal, + ): Promise { + if (revocation.provider !== "claude") { + throw new Error("CODEX_REVOCATION_MUST_NOT_REREAD_RETIRED_AUTHORITY"); + } + const authority = revocation.runtimeScope === "personal" + ? this.#personalAuthorityForProfile(profile) + : authorityFor(this.#paths, profile); + const runtime = revocation.runtimeScope === "personal" + ? this.#personalClaude + : this.#claude; + if (runtime === undefined) { + throw new ProviderRuntimeUnavailableError( + `The ${revocation.runtimeScope} ${revocation.provider} runtime cannot reread account identity.`, + ); + } + const account = await this.#fencedEffect(async () => + await runtime.readAccount({ authority, signal })); await this.#daemonAuthority.assertCurrent(); - let profile: ProfileRecord; - try { - profile = this.#store.requireProfileById(authority.id); - } catch { - return; + return providerAccountAuthorityKey("claude", account); + } + + async #assertProviderRuntimeAccountAuthority( + profile: ProfileRecord, + provider: AdoptableProvider, + runtimeScope: RuntimeAccountScope, + signal: AbortSignal, + force: boolean, + ): Promise { + if (provider === "codex") { + this.#assertSignedIn(profile); + this.#assertIdentifiableAccountAuthority(profile); + } else { + if (profile.state !== "signed_in" && profile.state !== "signed_out") { + throw new CommandFailure( + "RECOVERY_REQUIRED", + `The HRA profile authority for ${profile.label} is unsettled. Resolve its Codex account transition before another Claude provider operation.`, + ); + } + if (this.#profileAuthorityRevocationIsPending(profile.id, profile.processGeneration)) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + `Account authority for ${profile.label} is being revoked; wait for controller release before another provider operation.`, + ); + } + if (runtimeScope === "managed") this.#assertClaudeIsolationAccepted(); } - if (profile.processGeneration !== authority.generation || profile.state === "removed") return; - if (fact.type === "providerDisconnected") { - await this.#applyOrderedAccountFact(profile.id, () => { - let current: ProfileRecord; + if (this.#providerRuntimeAccountRevocationIsPending( + profile.id, + profile.processGeneration, + provider, + runtimeScope, + )) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The provider account authority is being released.", + { accountId: profile.id, provider }, + ); + } + const expectedCodexKey = profileCodexAccountAuthorityKey(profile); + const key = this.#personalAccountAttestationKey(provider, profile.id, runtimeScope); + const cached = this.#personalAccountAttestations.get(key); + const durableRevocation = this.#store.readProviderRuntimeAccountRevocation({ + profileId: profile.id, + provider, + runtimeScope, + }); + if ( + !force + && cached?.generation === profile.processGeneration + && (provider !== "codex" || cached.accountKey === expectedCodexKey) + && durableRevocation?.profileGeneration !== profile.processGeneration + && this.#now() - cached.checkedAt <= PERSONAL_ACCOUNT_ATTESTATION_TTL_MS + ) return cached.accountKey; + + const existing = this.#personalAccountChecks.get(key); + if (existing !== undefined && !force) return await existing; + const check = (async (): Promise => { + // A forced check is a post-effect fence. It must begin a provider read + // after every check that was already admitted when the caller crossed + // the effect boundary; joining an older read would collapse the account + // sandwich into a single pre-effect observation. Chaining onto the + // current per-account tail also makes concurrent forced checks each earn + // their own causally fresh observation. + if (existing !== undefined) { try { - current = this.#store.requireProfileById(authority.id); - } catch (error: unknown) { - if (error instanceof SelectionError && error.code === "NOT_FOUND") return; - throw error; + await existing; + } catch { + // The older caller owns its failure. This caller still needs a fresh + // observation so it can prove (or independently revoke) its effect. } - if (current.processGeneration !== authority.generation) return; - if (provider !== "codex") { - // Claude and Devin own one child process per session. Its exit retires - // only that provider connection; it must never rotate the shared - // profile generation that fences sibling provider sessions. - this.#handleProviderDisconnected( - authority, - provider, - fact.connectionId, - fact.reason, - ); - return; + signal.throwIfAborted(); + } + const authority = runtimeScope === "personal" + ? this.#personalAuthorityForProfile(profile) + : authorityFor(this.#paths, profile); + const account = await this.#fencedEffect(async () => { + if (provider === "claude") { + const runtime = runtimeScope === "personal" ? this.#personalClaude : this.#claude; + return await runtime?.readAccount({ authority, signal }); } - const isolatedProviderBlocker = this.#isolatedProviderAuthorityAdvanceBlocker(current.id); - this.#handleProviderDisconnected( - authority, - provider, - fact.connectionId, - fact.reason, + const runtime = runtimeScope === "personal" ? this.#personalCodex : this.#codex; + return await runtime?.readAccount({ authority, signal }); + }); + if (account === undefined) { + throw new ProviderRuntimeUnavailableError( + `${runtimeScope === "personal" ? "Personal-home" : "Managed"} ${provider} account identity is unavailable on this daemon.`, ); - if (isolatedProviderBlocker !== null) { - // A spontaneous Codex disconnect cannot be refused and retried like - // an explicit login. Stop the whole daemon instead of rotating a - // live provider-owned authority underneath an in-flight turn or recovery. - this.#state = "closing"; - this.#interactionDeadlineAbort.abort( - new Error( - `Codex disconnected while ${isolatedProviderBlocker.provider} authority was ${isolatedProviderBlocker.blocker}.`, - ), + } + signal.throwIfAborted(); + await this.#daemonAuthority.assertCurrent(); + const exact = this.#store.requireProfileById(profile.id); + if (exact.processGeneration !== profile.processGeneration) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The HRA account authority changed during provider identity verification.", + ); + } + if (provider === "codex") { + this.#assertSignedIn(exact); + this.#assertIdentifiableAccountAuthority(exact); + } else { + if (exact.state !== "signed_in" && exact.state !== "signed_out") { + throw new CommandFailure( + "RECOVERY_REQUIRED", + `The HRA profile authority for ${exact.label} changed during Claude identity verification.`, ); - this.#interactionDeadlineWake?.(); - this.#interactionDeadlineWake = undefined; - this.#daemonAuthority.close(); - this.#scheduleStop(); - return; } - const retirement = this.#store.advanceProfileGenerationWithWorkRetirement( - authority.id, - authority.generation, - this.#work, - { preserveSessionMutationAuthorities: true }, - ); - this.#notifyAffectedWork(retirement.affectedWorkIds); - this.#rebindIsolatedProviderAuthorities( - current.id, - authority.generation, - retirement.profile.processGeneration, - ); - this.#wakeSessionTaskPump(); - }); - return; - } - if (fact.type === "providerConnected") return; - if (fact.type === "notificationIgnored") return; - if (fact.type === "rateLimitsUpdated") { - if (provider === "codex") this.#scheduleUsageRefresh(authority); - return; - } - if (fact.type === "loginCompleted") { - if (provider !== "codex") return; - if (fact.success || fact.loginId === null) return; - const loginId = fact.loginId; - const settleFailedLogin = (): void => { - let current: ProfileRecord; - try { - current = this.#store.requireProfileById(authority.id); - } catch (error: unknown) { - if (error instanceof SelectionError && error.code === "NOT_FOUND") return; - throw error; + if (this.#profileAuthorityRevocationIsPending(exact.id, exact.processGeneration)) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + `Account authority for ${exact.label} is being revoked; wait for controller release before another provider operation.`, + ); + } + if (runtimeScope === "managed") this.#assertClaudeIsolationAccepted(); + } + const accountKey = providerAccountAuthorityKey(provider, account); + const exactExpectedCodexKey = profileCodexAccountAuthorityKey(exact); + const mismatchesSelectedCodexAccount = provider === "codex" + && (accountKey === null || accountKey !== exactExpectedCodexKey); + if (accountKey === null || mismatchesSelectedCodexAccount) { + this.#personalAccountAttestations.delete(key); + if (provider === "codex" && runtimeScope === "managed") { + this.#scheduleProfilePersonalAuthorityRevocation(exact); + } else { + this.#scheduleProviderRuntimeAccountRevocation( + exact, + provider, + runtimeScope, + accountKey, + ); } + throw new ProviderAccountAuthorityMismatchError(provider, exact); + } + const currentRevocation = this.#store.readProviderRuntimeAccountRevocation({ + profileId: exact.id, + provider, + runtimeScope, + }); + if (currentRevocation?.profileGeneration === exact.processGeneration) { if ( - current.processGeneration !== authority.generation - || current.state !== "login_pending" - ) return; - const pending = this.#store.readPendingLoginAuthority( - current.id, - current.processGeneration, - ); - if (pending?.loginId !== loginId) return; - this.#store.settlePendingLogin({ - profileId: current.id, - processGeneration: current.processGeneration, - loginId, - providerStatus: "not_found", - provider: { signedIn: false }, + currentRevocation.state !== "completed" + || currentRevocation.currentAccountKey !== accountKey + ) { + this.#scheduleProviderRuntimeAccountRevocation( + exact, + provider, + runtimeScope, + accountKey, + ); + throw new ProviderAccountAuthorityMismatchError(provider, exact); + } + this.#store.clearCompletedProviderRuntimeAccountRevocation({ + profileId: exact.id, + expectedGeneration: exact.processGeneration, + provider, + runtimeScope, + currentAccountKey: accountKey, }); - }; - await this.#applyOrderedAccountFact(profile.id, settleFailedLogin); - return; - } - if (fact.type === "interactionRequested") { - if ( - fact.provider.profileId !== authority.id - || fact.provider.processGeneration !== authority.generation - || fact.provider.connectionId !== fact.connectionId - ) throw new Error("INTERACTION_FACT_AUTHORITY_MISMATCH"); - if (this.#providerForInteractionAuthority(fact.provider) !== provider) return; - if ( - fact.kind === "mcp_elicitation" - && ( - fact.display.kind !== "mcp_elicitation" - || fact.display.mode !== "form" - || fact.display.fields === undefined - ) - ) throw new Error("MCP_FORM_DISPLAY_CONTRACT_MISSING"); - const session = fact.provider.threadId === null - ? null - : this.#store.findSessionByProviderThread(authority.id, fact.provider.threadId); - if (session !== null && session.provider !== provider) return; - if (session !== null) this.#ensureSessionProviderConnection(authority, session, fact.connectionId); - const admitted = this.#store.admitInteraction({ - publicId: randomUUID(), - sessionId: session?.id ?? null, - authority: fact.provider, - kind: fact.kind, - blocking: fact.blocking, - display: sanitizeInteractionDisplay(fact.display), - ...(fact.timeoutMs === undefined ? {} : { timeoutMs: fact.timeoutMs }), - ...(fact.requestedAt === undefined ? {} : { requestedAt: fact.requestedAt }), - ...(fact.deadlineAt === undefined ? {} : { deadlineAt: fact.deadlineAt }), + } + this.#personalAccountAttestations.set(key, { + checkedAt: this.#now(), + accountKey, + generation: exact.processGeneration, }); - if (!admitted.replayed && admitted.record.sessionId !== null) { - this.#appendSessionEvent(authority, admitted.record.sessionId, fact.connectionId, { - type: "interaction_requested", - interactionId: admitted.record.publicId, - interactionKind: admitted.record.kind, - revision: admitted.record.revision, - blocking: admitted.record.blocking, - summary: admitted.record.display.summary, - }); - this.#scheduleAutorespond(admitted.record); + return accountKey; + })(); + this.#personalAccountChecks.set(key, check); + try { + return await check; + } finally { + if (this.#personalAccountChecks.get(key) === check) { + this.#personalAccountChecks.delete(key); } - this.#wakeInteractionDeadlinePump(); - return; } - if (fact.type === "interactionResolved") { - if (this.#providerForInteractionAuthority(fact.provider) !== provider) return; - const observed = this.#store.findInteractionByAuthority(fact.provider); - if (observed === null) return; - if ( - observed.sessionId !== null - && this.#store.requireSession(observed.sessionId).provider !== provider - ) return; - await this.#serialize(`interaction:${observed.publicId}`, async () => { - const current = this.#store.findInteractionByAuthority(fact.provider); - if ( - current === null - || current.state === "resolved" - || current.state === "declined" - || current.state === "canceled" - || current.state === "expired" - || current.state === "resolution_unknown" - ) return; - try { - const settled = this.#store.settleInteraction({ - id: current.publicId, - expectedRevision: current.revision, - state: current.intendedTerminalState ?? "resolved", - authority: fact.provider, - ...(current.responseDigest === null ? {} : { responseDigest: current.responseDigest }), - }); - this.#appendInteractionState(settled); - } catch (error: unknown) { - throw this.#interactionPersistenceBoundaryError({ - cause: error, - effect: "possibly_sent", - focalInteraction: current, - ...(current.responseDigest === null - ? {} - : { responseDigest: current.responseDigest }), - }); - } - }); - return; - } - if (fact.type === "protocolNotice") { - if (fact.connectionId === undefined) return; - for (const [sessionId, connectionId] of this.#sessionProviderConnections) { - if (connectionId !== fact.connectionId) continue; - const session = this.#store.requireSession(sessionId); - if (session.profileId !== authority.id || session.provider !== provider) continue; - this.#appendSessionEvent(authority, session.id, connectionId, { - type: "protocol_incompatible", - method: fact.method, - payloadDigest: digestText(fact.method), - }); - } - return; + } + + async #assertManagedProviderRuntimeAuthority( + profile: ProfileRecord, + provider: Provider, + signal: AbortSignal, + force: boolean, + ): Promise { + if (provider !== "devin") { + return await this.#assertProviderRuntimeAccountAuthority( + profile, + provider, + "managed", + signal, + force, + ); } - if (!("threadId" in fact) || typeof fact.threadId !== "string") return; - const session = this.#store.findSessionByProviderThread(authority.id, fact.threadId); + + signal.throwIfAborted(); + await this.#daemonAuthority.assertCurrent(); + const before = this.#store.requireProfileById(profile.id); if ( - session === null - || session.provider !== provider - || (session.state === "terminal" && fact.type !== "threadDeleted") - || (session.state === "recovery_required" && fact.type !== "threadDeleted") - ) return; - this.#ensureSessionProviderConnection(authority, session, fact.connectionId); - if (fact.type === "threadDeleted") { - await this.#applyProviderThreadDeletion(authority, fact, session); - return; - } - const event = this.#eventBodyForCodexFact(fact, session); - if (event !== null) { - this.#appendSessionEvent(authority, session.id, fact.connectionId ?? null, event); + before.processGeneration !== profile.processGeneration + || this.#profileAuthorityRevocationIsPending( + before.id, + before.processGeneration, + ) + ) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The managed Devin profile authority changed before provider verification.", + ); } - const recoveryUnsettled = await this.#cloud - .isCompactProjectionRecoveryUnsettled(session.id); + await this.#assertProviderSignedIn(before, "devin", signal); + signal.throwIfAborted(); await this.#daemonAuthority.assertCurrent(); - if (recoveryUnsettled || this.#projectionRecoveriesInFlight.has(session.id)) return; - let dispatchQueue = false; - if (this.#mutationTails.has(`session:${session.id}`)) { - const priorRevision = this.#store.requireSession(session.id).revision; - dispatchQueue = this.#applyCodexFact(authority, fact, session); - const committed = this.#store.requireSession(session.id); - if (committed.revision !== priorRevision) { - await this.#reconcileCommittedSessionFactsMemory(committed); - } - } else { - try { - dispatchQueue = await this.#serializeSessionAuthority(session, async () => { - const priorRevision = this.#store.requireSession(session.id).revision; - const shouldDispatch = this.#applyCodexFact(authority, fact, session); - const committed = this.#store.requireSession(session.id); - if (committed.revision !== priorRevision) { - await this.#reconcileCommittedSessionFactsMemory(committed); - } - return shouldDispatch; - }); - } catch (error: unknown) { - if (error instanceof CommandFailure && error.code === "RECOVERY_REQUIRED") return; - throw error; - } - } - if (dispatchQueue) { - const task = this.#serializeSessionAuthority(session, async () => this.#dispatchNextQueue(session.id, authority)); - const tracked = task.then( - () => undefined, - (error: unknown) => this.recordBackgroundDiagnostic("queue_dispatch_failed", error), + const after = this.#store.requireProfileById(profile.id); + if ( + after.processGeneration !== profile.processGeneration + || (after.state !== "signed_in" && after.state !== "signed_out") + || this.#profileAuthorityRevocationIsPending( + after.id, + after.processGeneration, + ) + ) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The managed Devin profile authority changed during provider verification.", ); - this.#background.add(tracked); - void tracked.then(() => this.#background.delete(tracked)); } + return undefined; } - async #applyProviderThreadDeletion( - authority: ProfileAuthority, - fact: Extract, - expected: SessionRecord, - ): Promise { - const current = this.#store.findSessionByProviderThread(authority.id, fact.threadId); - if (current === null || current.id !== expected.id) return; - this.#persistSessionEventWrites(this.#eventRedactor.interruptSession({ - sessionId: current.id, - accountId: authority.id, - providerGeneration: authority.generation, - providerConnectionId: fact.connectionId ?? null, - })); - this.#bumpSessionFactEpoch(current.id); - const terminal = this.#store.terminalizeSessionFromProviderDeletion({ - accountId: authority.id, - providerConnectionId: fact.connectionId ?? null, - providerGeneration: authority.generation, - sessionId: current.id, - }); - if (terminal.event !== undefined) this.#eventWaiters.notify(current.id); - for (const interaction of terminal.interactions) this.#appendInteractionState(interaction); - await this.#cleanupTerminalFactsMemory(this.#store.requireSession(current.id)); - this.#sessionProviderConnections.delete(current.id); - this.#sessionObservationFailures.delete(current.id); - this.#sessionResubscriptionConnections.delete(current.id); - this.#sessionsAwaitingResubscription.delete(current.id); - await this.#cloud.supersedeCompactProjectionRecoveryForProviderDeletion(current.id); - await this.#daemonAuthority.assertCurrent(); - } - - /* - * Autorespond: answer a freshly admitted approval on behalf of the human - * when the session's approval mode allows it. Runs in the background behind - * the interaction's own serialization key; the ordinary resolve path enforces - * revision, deadline, and provider-offered decisions, and every attempt - * leaves an evidence row whether it accepted or escalated. - */ - #scheduleAutorespond(record: InteractionRecord): void { - if (record.sessionId === null) return; - if ( - record.kind !== "command_approval" - && record.kind !== "file_change_approval" - && record.kind !== "permission_approval" - ) return; - const sessionId = record.sessionId; - this.#scheduledAutorespondInteractions.add(record.publicId); - const tracked = this.#autorespondAdmitted(record, sessionId).then( - () => undefined, - (error: unknown) => { - if (error instanceof StateSecurityScrubRequiredError) this.#requestStop(); - else this.recordBackgroundDiagnostic("autorespond_failed", error); - this.#escalatePendingAutorespondInteraction(record, "autorespond_failed"); - }, + async #assertPersonalProviderAccountAuthority( + profile: ProfileRecord, + provider: AdoptableProvider, + signal: AbortSignal, + force: boolean, + ): Promise { + return await this.#assertProviderRuntimeAccountAuthority( + profile, + provider, + "personal", + signal, + force, ); - this.#background.add(tracked); - void tracked.then(() => { - this.#scheduledAutorespondInteractions.delete(record.publicId); - this.#background.delete(tracked); - }); } - async #autorespondAdmitted(record: InteractionRecord, sessionId: SessionRecord["id"]): Promise { - const startedAt = this.#now(); - const { mode } = this.#store.readSessionApprovalMode(sessionId); - const budgets = this.#store.readAutorespondBudgets(sessionId, startedAt); - const decision = decideAutorespond({ budgets, display: record.display, kind: record.kind, mode }); - const kind = record.kind as "command_approval" | "file_change_approval" | "permission_approval"; - if (decision.action === "escalate") { - this.#store.recordAutorespondEvidence({ - approvalClass: decision.approvalClass, - decision: decision.code, - interactionId: record.publicId, - kind, - latencyMs: this.#now() - startedAt, - mode, - outcome: "refused", - sessionId, - subagent: false, - }); - this.#escalatePendingAutorespondInteraction(record, `autorespond_${decision.code}`); + async #assertPersonalSessionAccountAuthority( + session: SessionRecord, + profile: ProfileRecord, + signal: AbortSignal, + force = true, + ): Promise { + if (session.provider === "devin") { + signal.throwIfAborted(); + await this.#daemonAuthority.assertCurrent(); + if (this.#sessionHasActivePersonalBinding(session)) { + this.#quarantineSession(session.id); + throw new CommandFailure( + "RECOVERY_REQUIRED", + "A Devin session cannot use personal-home runtime authority.", + { sessionId: session.id }, + ); + } + const exact = this.#store.requireSession(session.id); + const exactProfile = this.#store.requireProfileById(profile.id); + if ( + exact.profileId !== profile.id + || exact.provider !== "devin" + || exact.providerThreadId !== session.providerThreadId + || exactProfile.processGeneration !== profile.processGeneration + || this.#profileAuthorityRevocationIsPending( + exactProfile.id, + exactProfile.processGeneration, + ) + ) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The session's managed Devin authority changed during verification.", + { sessionId: session.id }, + ); + } + this.#assertEstablishedSessionAccount(exactProfile, exact); return; } - const resolution = record.kind === "permission_approval" - ? { kind: "permission_grant" as const, permissions: permissionNamesOf(record.display), scope: null } - : { kind: "approval_decision" as const, decision: decision.decision }; - let outcome: "accepted" | "refused" = "accepted"; - try { - await this.#resolveInteraction( - { - kind: "interaction.resolve", - interaction: record.publicId, - expectedRevision: record.revision, - resolution, - }, - { signal: this.#backgroundAbort.signal }, + const runtimeScope: RuntimeAccountScope = this.#sessionHasActivePersonalBinding(session) + ? "personal" + : "managed"; + const recorded = this.#store.readSessionProviderAccountAuthority(session.id); + if ( + recorded === null + || recorded.provider !== session.provider + || recorded.runtimeScope !== runtimeScope + ) { + this.#quarantineSession(session.id); + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The session has no exact provider-account authority for its current runtime.", + { sessionId: session.id }, ); - this.#store.markInteractionResolvedBy(record.publicId, "autorespond"); - this.#store.bumpAutorespondCounter(sessionId); - } catch (error: unknown) { - outcome = "refused"; - if (!(error instanceof CommandFailure)) throw error; - } finally { - this.#store.recordAutorespondEvidence({ - approvalClass: decision.approvalClass, - decision: decision.decision, - interactionId: record.publicId, - kind, - latencyMs: this.#now() - startedAt, - mode, - outcome, - sessionId, - subagent: false, - }); - if (outcome === "refused") { - this.#escalatePendingAutorespondInteraction(record, "autorespond_resolution_refused"); + } + const currentAccountKey = await this.#assertProviderRuntimeAccountAuthority( + profile, + session.provider, + runtimeScope, + signal, + force, + ); + if (recorded.accountKey !== currentAccountKey) { + if (session.provider === "codex" && runtimeScope === "managed") { + this.#scheduleProfilePersonalAuthorityRevocation(profile); + } else { + this.#scheduleProviderRuntimeAccountRevocation( + profile, + session.provider, + runtimeScope, + currentAccountKey, + ); } + throw new ProviderAccountAuthorityMismatchError( + session.provider, + profile, + ); } - } - - #requireGatewayKeys(): GatewayKeyPort { - if (this.#gatewayKeys === undefined) { + const exact = this.#store.requireSession(session.id); + const exactRecorded = this.#store.readSessionProviderAccountAuthority(session.id); + if ( + exact.profileId !== profile.id + || exact.provider !== session.provider + || exact.providerThreadId !== session.providerThreadId + || exactRecorded === null + || exactRecorded.provider !== recorded.provider + || exactRecorded.runtimeScope !== recorded.runtimeScope + || exactRecorded.accountKey !== recorded.accountKey + || (runtimeScope === "personal" && !this.#sessionHasActivePersonalBinding(exact)) + || (runtimeScope === "managed" && this.#sessionHasActivePersonalBinding(exact)) + ) { throw new CommandFailure( - "UNAVAILABLE", - "Local secret custody for the autorespond gateway key is unavailable in this daemon.", + "RECOVERY_REQUIRED", + "The session's provider-account authority changed during verification.", + { sessionId: session.id }, ); } - return this.#gatewayKeys; + const exactProfile = this.#store.requireProfileById(profile.id); + this.#assertEstablishedSessionAccount(exactProfile, exact); } - async #gatewayConfigured(): Promise { + async #assertSessionAccountAuthorityAfterProviderEffect( + session: SessionRecord, + profile: ProfileRecord, + signal: AbortSignal, + ): Promise { try { - return await this.#gatewayKeys?.isConfigured() ?? false; - } catch { - return false; + await this.#assertPersonalSessionAccountAuthority(session, profile, signal, true); + } catch (cause: unknown) { + if (cause instanceof DaemonAuthoritySafetyError) throw cause; + throw new IndeterminateLocalCommitError( + "The provider may have applied the effect while its account authority changed.", + cause, + ); } } - /* - * Prose autorespond (W2). A completed turn that classified as - * `needs_approval` through the lexical approval cue — never through a pending - * provider interaction — may be answered on the human's behalf. Everything - * below is a refusal path except the last one, and every path leaves one - * evidence row. - */ - #scheduleProseAutorespond( - sessionId: SessionRecord["id"], - turnId: string, - classification: SessionStateClassification, - ): void { - if (this.#proseResponder === undefined) return; - if (classification.state !== "needs_approval") return; - // At most one autoresponse per turn, even if the state is re-emitted. - if (this.#proseAutorespondedTurns.get(sessionId) === turnId) return; - this.#proseAutorespondedTurns.set(sessionId, turnId); - const tracked = this.#autorespondProse(sessionId, classification).then( - () => undefined, - (error: unknown) => { - if (error instanceof StateSecurityScrubRequiredError) this.#requestStop(); - else this.recordBackgroundDiagnostic("prose_autorespond_failed", error); - }, - ); - this.#background.add(tracked); - void tracked.then(() => this.#background.delete(tracked)); + async #assertPersonalInteractionAccountAuthority( + interaction: Pick, + profile: ProfileRecord, + signal: AbortSignal, + ): Promise { + if (interaction.sessionId === null) return; + const session = this.#store.requireSession(interaction.sessionId); + await this.#assertPersonalSessionAccountAuthority(session, profile, signal); } - async #autorespondProse( - sessionId: SessionRecord["id"], - classification: SessionStateClassification, + async #assertInteractionAccountAuthorityAfterProviderEffect( + interaction: Pick, + profile: ProfileRecord, + signal: AbortSignal, ): Promise { - const responder = this.#proseResponder; - if (responder === undefined) return; - const startedAt = this.#now(); - const { mode } = this.#store.readSessionApprovalMode(sessionId); - const rule = classification.matchedRule; - const finalText = this.#sessionStateTracker.finalAssistantText(sessionId); - const refuse = (code: ProseAutorespondGateFailure): void => { - this.#store.recordProseAutorespondEvidence({ - decision: "refuse", - latencyMs: this.#now() - startedAt, - mode, - model: null, - outcome: `gate_failed:${code}`, - rule, - sessionId, - }); + if (interaction.sessionId === null) return; + try { + const session = this.#store.requireSession(interaction.sessionId); + await this.#assertPersonalSessionAccountAuthority(session, profile, signal, true); + } catch (cause: unknown) { + if (cause instanceof DaemonAuthoritySafetyError) throw cause; + throw new CodexError( + "INDETERMINATE_EFFECT", + "The provider may have applied the interaction response while its account authority changed.", + { cause }, + ); + } + } + + #claudeRuntimeForScope(scope: ClaudeProcessAuthorityRecord["runtimeScope"]): ClaudeRuntimePort { + if (scope === "managed") return this.#claude; + if (this.#personalClaude !== undefined) return this.#personalClaude; + throw new ProviderRuntimeUnavailableError( + "Personal-home Claude process custody is unavailable on this daemon.", + ); + } + + #authorityForClaudeProcess(record: ClaudeProcessAuthorityRecord): ProfileAuthority { + const profile = this.#store.requireProfileById(record.profileId, { includeRemoved: true }); + const exact = { ...profile, processGeneration: record.profileGeneration }; + if (record.runtimeScope === "managed") return authorityFor(this.#paths, exact); + if (this.#personalCodexHome === undefined) { + throw new ProviderRuntimeUnavailableError( + "Personal provider-home authority is unavailable on this daemon.", + ); + } + const isolated = profilePaths(this.#paths, profile.id); + return { + id: profile.id, + generation: record.profileGeneration, + codexHome: this.#personalCodexHome, + desktopUserData: isolated.desktopUserData, }; + } - // The positive gate. Each clause must hold before a model is consulted. - if (rule !== "approval_cue") return refuse("not_an_approval_cue"); - if (this.#store.listInteractions({ sessionId, pendingOnly: true, limit: 1 }).length > 0) { - return refuse("pending_interaction"); + #sameClaudeProcessIdentity( + left: ClaudeProcessIdentity, + right: ClaudeProcessIdentity, + ): boolean { + return left.pid === right.pid + && left.pidDomain === right.pidDomain + && left.procStart === right.procStart; + } + + async #recordClaimedClaudeProcess(input: { + authority: ProfileAuthority; + providerThreadId: string; + runtimeScope: ClaudeProcessAuthorityRecord["runtimeScope"]; + sessionId?: SessionRecord["id"]; + launchIntent: ClaudeProcessLaunchIntentRecord; + identity: ClaudeProcessIdentity; + signal: AbortSignal; + }): Promise { + input.signal.throwIfAborted(); + await this.#daemonAuthority.assertCurrent(); + this.#store.recordClaimedClaudeProcessAuthority({ + providerThreadId: input.providerThreadId, + profileId: input.authority.id, + profileGeneration: input.authority.generation, + runtimeScope: input.runtimeScope, + ...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }), + identity: input.identity, + expectedLaunchIntentId: input.launchIntent.intentId, + expectedLaunchIntentRevision: input.launchIntent.revision, + }); + return input.identity; + } + + #cancelClaudeProcessLaunchIntent(intent: ClaudeProcessLaunchIntentRecord): void { + const current = this.#store.readClaudeProcessLaunchIntent({ + providerThreadId: intent.providerThreadId, + profileId: intent.profileId, + runtimeScope: intent.runtimeScope, + }); + if (current === null) return; + if (current.intentId !== intent.intentId || current.revision !== intent.revision) { + throw new Error("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_CONFLICT"); + } + this.#store.cancelClaudeProcessLaunchIntent({ + providerThreadId: intent.providerThreadId, + profileId: intent.profileId, + profileGeneration: intent.profileGeneration, + runtimeScope: intent.runtimeScope, + intentId: intent.intentId, + expectedRevision: intent.revision, + }); + } + + async #probeClaudeProcessLiveness( + record: ClaudeProcessAuthorityRecord, + signal: AbortSignal, + ): Promise<"live" | "not_live" | "unknown"> { + return await this.#probeClaudeProcessIdentityLiveness(record.identity, signal); + } + + async #probeClaudeProcessIdentityLiveness( + identity: ClaudeProcessIdentity, + signal: AbortSignal, + deadlineAt = this.#now() + CLAUDE_PROCESS_LIVENESS_DEADLINE_MS, + ): Promise<"live" | "not_live" | "unknown"> { + if (this.#claudeProcessLiveness === undefined) return "unknown"; + signal.throwIfAborted(); + return await this.#claudeProcessLiveness(identity, { + deadlineAt, + signal, + }); + } + + async #releaseClaudeProcessAuthority( + input: ClaudeProcessAuthorityKey, + signal: AbortSignal, + ): Promise { + // Callers often already hold the richer durable authority record. Narrow + // it before crossing the strict storage boundary so recovery cannot be + // defeated by structurally valid extra fields. + const key: ClaudeProcessAuthorityKey = { + providerThreadId: input.providerThreadId, + profileId: input.profileId, + runtimeScope: input.runtimeScope, + }; + return await this.#serialize( + `claude-process:${key.runtimeScope}:${key.profileId}:${key.providerThreadId}`, + async () => await this.#releaseClaudeProcessAuthorityLocked(key, signal), + ); + } + + async #releaseClaudeProcessAuthorityLocked( + key: ClaudeProcessAuthorityKey, + signal: AbortSignal, + ): Promise { + let record = this.#store.readClaudeProcessAuthority(key); + if (record === null) { + throw new ProviderRuntimeUnavailableError( + "The Claude process has no durable exact-process custody record.", + ); } - const prepared = prepareAssistantText(finalText); - // The classifier reads cues over the stripped text and, for the full - // human-action list, only over the tail. The gate is stricter on purpose: - // it scans the whole raw message, fenced code and blockquotes included, so - // a quoted login step or a destructive command inside a code block still - // hands the turn back to the human. - if ( - STRONG_HUMAN_ACTION_CUES.some((cue) => cue.test(finalText)) - || HUMAN_ACTION_CUES.some((cue) => cue.test(finalText)) - ) return refuse("human_action_cue"); - if (DENYLIST_CUES.some((cue) => cue.test(finalText))) return refuse("denylist_cue"); - if (finalText.length >= PROSE_AUTORESPOND_MAX_MESSAGE_CHARACTERS) { - return refuse("message_too_long"); + if (record.state === "released") return record; + const runtime = this.#claudeRuntimeForScope(record.runtimeScope); + const authority = this.#authorityForClaudeProcess(record); + let runtimeOwnsExactProcess = false; + let liveIdentity: ClaudeProcessIdentity | undefined; + try { + liveIdentity = await this.#fencedEffect(async () => + await runtime.readSessionProcessIdentity({ + authority, + providerThreadId: key.providerThreadId, + signal, + })); + } catch (error: unknown) { + if (error instanceof DaemonAuthoritySafetyError) throw error; + if (signal.aborted) throw signal.reason; + const liveness = await this.#probeClaudeProcessLiveness(record, signal); + if (liveness !== "not_live") throw error; } - if (!await this.#gatewayConfigured()) return refuse("gateway_key_missing"); - const verbatimLiteral = classification.verbatimRequired - ? classification.verbatimLiteral - : undefined; - if (classification.verbatimRequired && verbatimLiteral === undefined) { - return refuse("verbatim_literal_missing"); + if (liveIdentity !== undefined) { + if (!this.#sameClaudeProcessIdentity(liveIdentity, record.identity)) { + throw new ProviderRuntimeUnavailableError( + "The live Claude controller does not match its durable process authority.", + ); + } + runtimeOwnsExactProcess = true; } - const budgets = this.#store.readAutorespondBudgets(sessionId, startedAt); - const decision = decideProseAutorespond({ budgets, mode }); - if (decision.action === "escalate") return refuse(decision.code); - const durable = this.#store.readSessionState(sessionId); - let result: Awaited>; - try { - result = await responder.respond( - { - assistantTail: prepared.tail, - report: { - version: 1, - session: sessionId, - state: durable?.state ?? classification.state, - attention: durable?.attention ?? classification.attention, - reason: durable?.reason ?? classification.reason, - verbatimRequired: classification.verbatimRequired, - lastActivityAt: durable?.lastActivityAt ?? null, - revision: durable?.revision ?? 0, - }, - ...(verbatimLiteral === undefined ? {} : { verbatimLiteral }), - }, - this.#backgroundAbort.signal, - ); - } catch { - this.#store.recordProseAutorespondEvidence({ - decision: "refuse", - latencyMs: this.#now() - startedAt, - mode, - model: null, - outcome: "responder_failed", - rule, - sessionId, + if (record.state !== "releasing") { + record = this.#store.beginClaudeProcessAuthorityRelease({ + ...key, + expectedRevision: record.revision, + identity: record.identity, }); - return; } - - /* - * The responder is never trusted with free text. A verbatim ask must come - * back byte-exact from the assistant's own message; every other approval is - * answered with the one fixed sentence, whatever the model produced. - */ - let reply = PROSE_APPROVAL_REPLY; - if (verbatimLiteral !== undefined) { - if (!finalText.includes(result.reply)) { - this.#store.recordProseAutorespondEvidence({ - decision: "refuse", - latencyMs: this.#now() - startedAt, - mode, - model: result.model, - outcome: "verbatim_mismatch", - rule, - sessionId, - }); - this.#escalateSessionState(sessionId, "autorespond_verbatim_mismatch"); - return; + if (runtimeOwnsExactProcess) { + await this.#fencedEffect(async () => await runtime.endSession({ + authority, + providerThreadId: key.providerThreadId, + signal: new AbortController().signal, + })); + } else { + const liveness = await this.#probeClaudeProcessLiveness(record, signal); + if (liveness !== "not_live") { + throw new ProviderRuntimeUnavailableError( + "The exact prior Claude process is still live or cannot be proven gone.", + ); } - reply = result.reply; } + await this.#daemonAuthority.assertCurrent(); + return this.#store.completeClaudeProcessAuthorityRelease({ + ...key, + expectedRevision: record.revision, + identity: record.identity, + }); + } - let outcome: "sent" | "responder_failed" = "sent"; - try { - const session = this.#store.requireSession(sessionId); - await this.#serializeSessionAuthority(session, async () => - this.#send(session.id, reply, undefined, this.#backgroundAbort.signal, undefined, "autorespond")); - this.#store.bumpAutorespondCounter(sessionId); - } catch (error: unknown) { - outcome = "responder_failed"; - if (!(error instanceof CommandFailure) && !(error instanceof SelectionError)) throw error; - } finally { - this.#store.recordProseAutorespondEvidence({ - decision: outcome === "sent" ? "send" : "refuse", - latencyMs: this.#now() - startedAt, - mode, - model: result.model, - outcome, - rule, - sessionId, - }); + #sessionHasActivePersonalBinding(session: SessionRecord): boolean { + const binding = this.#store.readSessionPersonalRuntimeBinding(session.id, true); + if (binding === null) return false; + const matchesCurrentIdentity = binding.provider === session.provider + && binding.providerThreadId === session.providerThreadId; + if (binding.state === "active") { + if (!matchesCurrentIdentity) { + throw new ProviderRuntimeUnavailableError( + "The personal-home session binding no longer matches its durable session identity.", + ); + } + return true; } + if (matchesCurrentIdentity) { + throw new ProviderRuntimeUnavailableError( + "That session's exact provider controller is no longer available.", + ); + } + // A provider switch may retain the mismatched detached row until the old + // identity is readopted. Runtime-profile history and provider_switched + // events preserve provenance; the session's current identity is managed. + return false; } - /* - * Emit one further `session_state` revision after an autorespond outcome - * that hands the turn back to the human. A later revision always wins, so - * the browser and the CLI converge on the escalation. - */ - #escalateSessionState( - sessionId: SessionRecord["id"], - reason: string, - state: "needs_answer" | "needs_approval" = "needs_answer", - ): void { - try { - const body = this.#sessionStateTracker.escalate(sessionId, { - attention: true, - reason, - state, - }); - const snapshot = this.#sessionStateTracker.snapshot(sessionId); - if (snapshot === null) return; - this.#store.upsertSessionState({ - sessionId, - state: snapshot.state, - attention: snapshot.attention, - reason: snapshot.reason, - verbatimRequired: snapshot.verbatimRequired, - verbatimLiteral: snapshot.verbatimLiteral, - lastActivityAt: snapshot.lastActivityAt, - revision: snapshot.revision, - }); - const session = this.#store.requireSession(sessionId); - const profile = this.#store.requireProfile(session.profileId); - this.#appendSessionEvent( - authorityFor(this.#paths, profile), - sessionId, - this.#sessionProviderConnections.get(sessionId) ?? null, - body, + #sessionHasMatchingActivePersonalBinding( + session: Pick, + ): boolean { + const binding = this.#store.readSessionPersonalRuntimeBinding(session.id, true); + return binding !== null + && binding.state === "active" + && binding.provider === session.provider + && binding.providerThreadId === session.providerThreadId; + } + + #runtimeForSession( + session: SessionRecord, + ): SessionRuntimePort { + if (!this.#sessionHasActivePersonalBinding(session)) { + return this.#sessionRuntime(session.provider); + } + switch (session.provider) { + case "codex": return this.#personalSessionRuntime("codex"); + case "claude": return this.#personalSessionRuntime("claude"); + case "devin": throw new ProviderRuntimeUnavailableError( + "A Devin session cannot carry personal-home runtime authority.", ); - } catch (error: unknown) { - this.recordBackgroundDiagnostic("session_state_tracking_failed", error); } } - /* - * A provider validation refusal can leave the same approval pending, while - * connection loss or indeterminate delivery terminalizes it first. Only the - * former is actionable. Re-read the exact revision so a stale background - * decision cannot manufacture attention for a resolved or expired prompt. - */ - #escalatePendingAutorespondInteraction(record: InteractionRecord, reason: string): void { - try { - const current = this.#store.requireInteraction(record.publicId); - if ( - current.sessionId === null - || current.sessionId !== record.sessionId - || current.revision !== record.revision - || current.state !== "pending" - || this.#now() >= current.deadlineAt - ) return; - this.#escalateSessionState(current.sessionId, reason, "needs_approval"); - } catch (error: unknown) { - if (error instanceof SelectionError && error.code === "NOT_FOUND") return; - this.recordBackgroundDiagnostic("session_state_tracking_failed", error); + #authorityForSession(session: SessionRecord, profile?: ProfileRecord): ProfileAuthority { + const owner = profile ?? this.#store.requireProfileById(session.profileId); + this.#assertEstablishedSessionAccount(owner, session); + const managed = authorityFor(this.#paths, owner); + if (!this.#sessionHasActivePersonalBinding(session)) return managed; + return this.#personalAuthorityForProfile(owner); + } + + #personalAuthorityForProfile(profile: ProfileRecord): ProfileAuthority { + if (this.#personalCodexHome === undefined) { + throw new ProviderRuntimeUnavailableError( + "Personal-home session authority is unavailable on this daemon.", + ); } + return { ...authorityFor(this.#paths, profile), codexHome: this.#personalCodexHome }; } - #appendSessionEvent( - authority: ProfileAuthority, - sessionId: SessionRecord["id"], - connectionId: string | null | undefined, - body: SessionEventBody, + #assertSessionAccountAuthority( + session: Pick, + profile: Pick, ): void { - const parsedConnection = connectionId === null || connectionId === undefined - ? null - : z.string().uuid().parse(connectionId); - this.#persistSessionEventWrites(this.#eventRedactor.accept({ - sessionId, - accountId: authority.id, - providerGeneration: authority.generation, - providerConnectionId: parsedConnection, - body, - })); + if ( + session.profileId === profile.id + && this.#store.sessionAccountAuthorityMatches(session.id, profile.id) + ) return; + throw new CommandFailure( + "RECOVERY_REQUIRED", + `Session ${session.id} is bound to a different or unprovable provider account identity. Sign in to the original account for ${profile.label} before using it.`, + { sessionId: session.id, accountId: profile.id }, + ); } - #persistSessionEventWrites(writes: readonly SessionEventWrite[]): void { - for (const write of writes) { - this.#store.appendPublicSessionEvent(write); - this.#eventWaiters.notify(write.sessionId); - this.#trackSessionState(write); + #assertSessionAccountAuthorityIfSignedIn( + session: Pick, + ): void { + switch (session.provider) { + case "codex": { + const profile = this.#store.requireProfileById(session.profileId); + if (profile.state === "signed_in") this.#assertSessionAccountAuthority(session, profile); + return; + } + case "claude": + case "devin": + return; } } - /* - * Session-state attention is derived from the complete actionable pending - * set, not merely from the newest interaction's kind. Exact in-flight - * scheduler ownership is authoritative for older requests; settings and - * bounded audit history cannot retroactively claim them. The newly persisted - * request is classified synchronously because scheduling happens immediately - * after its event is tracked. If the bounded page overflows, fail closed and - * keep attention visible. + #authorityForInteraction( + record: Readonly<{ sessionId: SessionRecord["id"] | null }>, + profile: ProfileRecord, + ): ProfileAuthority { + if (record.sessionId === null) return authorityFor(this.#paths, profile); + return this.#authorityForSession(this.#store.requireSession(record.sessionId), profile); + } + + #providerForInteraction( + record: Readonly<{ + sessionId: SessionRecord["id"] | null; + authority: ProviderInteractionAuthority; + }>, + ): Provider { + const provider = this.#providerForInteractionAuthority(record.authority); + if (record.sessionId === null) return provider; + const session = this.#store.requireSession(record.sessionId); + if (session.provider !== provider) { + throw new ProviderRuntimeUnavailableError( + "The interaction provider no longer matches its durable session authority.", + ); + } + return provider; + } + + #assertProviderProfileState(profile: ProfileRecord, provider: Provider): void { + if (provider === "codex") { + this.#assertSignedIn(profile); + return; + } + if (profile.state === "signed_in" || profile.state === "signed_out") return; + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The interaction belongs to an unsettled HRA profile authority.", + ); + } + + /** + * The port that owns a brokered interaction. The session it belongs to is + * the authority; an interaction with no session (a provider-level request) + * is attributed by the durable method name its authority recorded. */ - #pendingSessionStateContext( - sessionId: SessionRecord["id"], - newlyRequestedInteractionId?: string, - ): SessionStateContext { - const page = this.#store.listInteractionPage({ - sessionId, - pendingOnly: true, - limit: 200, - }); - let representative = page.interactions[0]; - if (representative === undefined) return {}; - let autorespondWillAct = page.nextPosition === null; - for (const interaction of page.interactions) { - let willAct = interaction.state !== "pending" - || this.#scheduledAutorespondInteractions.has(interaction.publicId); - if (!willAct && interaction.publicId === newlyRequestedInteractionId) { - const { mode } = this.#store.readSessionApprovalMode(sessionId); - const budgets = this.#store.readAutorespondBudgets(sessionId, this.#now()); - willAct = decideAutorespond({ - budgets, - display: interaction.display, - kind: interaction.kind, - mode, - }).action === "accept"; - } - if (!willAct) { - representative = interaction; - autorespondWillAct = false; - break; + #runtimeForInteraction( + record: Readonly<{ + sessionId: SessionRecord["id"] | null; + authority: ProviderInteractionAuthority; + }>, + ): SessionRuntimePort { + if (record.sessionId !== null) { + try { + const session = this.#store.requireSession(record.sessionId); + if ( + session.provider === "claude" + && !this.#sessionHasActivePersonalBinding(session) + ) this.#assertClaudeIsolationAccepted(); + return this.#runtimeForSession(session); + } catch (error: unknown) { + if (!(error instanceof SelectionError && error.code === "NOT_FOUND")) throw error; + // Fall through to the durable method name below. } } - return { - pendingInteraction: { kind: representative.kind }, - autorespondWillAct, - }; + const provider = this.#providerForInteraction(record); + if (provider === "claude") this.#assertClaudeIsolationAccepted(); + return this.#sessionRuntime(provider); } - /* - * Classify the session after every persisted event. The tracker decides - * whether the state changed; a change is persisted as the session's durable - * latest state and appended as one `session_state` event. Failures here are - * background diagnostics, never a reason to drop the originating event. - */ - #trackSessionState(write: SessionEventWrite): void { - if (write.body.type === "session_state") return; + #providerForInteractionAuthority( + authority: Pick, + ): Provider { + switch (authority.method) { + case "claude/control_request/can_use_tool": + return "claude"; + case "devin/session/request_permission": + return "devin"; + case "item/commandExecution/requestApproval": + case "item/fileChange/requestApproval": + case "item/permissions/requestApproval": + case "item/tool/requestUserInput": + case "mcpServer/elicitation/request": + return "codex"; + default: + throw new ProviderRuntimeUnavailableError( + "The interaction method has no admitted provider runtime authority.", + ); + } + } + + #interactionProfileAuthorityIsUsable( + record: Pick, + ): boolean { try { - if (this.#sessionStateTracker.snapshot(write.sessionId) === null) { - const durable = this.#store.readSessionState(write.sessionId); - if (durable !== null) { - this.#sessionStateTracker.seed(write.sessionId, { - state: durable.state, - attention: durable.attention, - reason: durable.reason, - verbatimRequired: durable.verbatimRequired, - verbatimLiteral: durable.verbatimLiteral ?? undefined, - lastActivityAt: durable.lastActivityAt, - revision: durable.revision, - }); - } - } - const pendingContext = write.body.type === "interaction_requested" - || write.body.type === "interaction_state" - || write.body.type === "turn_completed" - ? this.#pendingSessionStateContext( - write.sessionId, - write.body.type === "interaction_requested" - ? write.body.interactionId - : undefined, - ) - : {}; - const body = this.#sessionStateTracker.observe(write.sessionId, write.body, pendingContext); - if (body === null) return; - const snapshot = this.#sessionStateTracker.snapshot(write.sessionId); - if (snapshot === null) return; - this.#store.upsertSessionState({ - sessionId: write.sessionId, - state: snapshot.state, - attention: snapshot.attention, - reason: snapshot.reason, - verbatimRequired: snapshot.verbatimRequired, - verbatimLiteral: snapshot.verbatimLiteral, - lastActivityAt: snapshot.lastActivityAt, - revision: snapshot.revision, - }); - this.#store.appendPublicSessionEvent({ ...write, body }); - this.#eventWaiters.notify(write.sessionId); - // A prose approval is only ever answered for a turn that just ended and - // left no pending provider interaction behind. - if ( - body.state === "needs_approval" - && write.body.type === "turn_completed" - && pendingContext.pendingInteraction === undefined - ) { - const classification = this.#sessionStateTracker.classification(write.sessionId); - if (classification !== null) { - this.#scheduleProseAutorespond(write.sessionId, write.body.turnId, classification); - } - } + return this.#profileAuthorityIsUsable( + record.authority.profileId, + record.authority.processGeneration, + this.#providerForInteractionAuthority(record.authority), + record.sessionId ?? undefined, + ); } catch (error: unknown) { - this.recordBackgroundDiagnostic("session_state_tracking_failed", error); + if (error instanceof ProviderRuntimeUnavailableError) return false; + throw error; } } - #ensureSessionProviderConnection( - authority: ProfileAuthority, + /** Refuses a Codex-only capability on a session bound to another provider. */ + #requireCodexSession(session: Readonly<{ provider: Provider }>, capability: string): void { + if (session.provider === "codex") return; + throw new CommandFailure( + "INVALID_INPUT", + `The ${session.provider} provider does not support ${capability}. ` + + "It is available on Codex sessions only.", + ); + } + + #sessionUsesFactSource( session: SessionRecord, - connectionId: string | undefined, - ): void { - if (connectionId === undefined) return; - z.string().uuid().parse(connectionId); - const previous = this.#sessionProviderConnections.get(session.id); - if (previous === connectionId) return; - if (previous !== undefined) { - const position = this.#store.eventStreamPosition(session.id); - this.#appendSessionEvent(authority, session.id, previous, { - type: "gap", - reason: "provider_restart", - fromSequence: position.observedThroughSequence + 1, - throughSequence: position.observedThroughSequence + 1, - }); - } - this.#sessionProviderConnections.set(session.id, connectionId); - const resubscribed = previous !== undefined - || this.#sessionsAwaitingResubscription.has(session.id) - || this.#lastSessionEventIsProviderGap(session.id); - this.#sessionsAwaitingResubscription.delete(session.id); - if (resubscribed) this.#sessionResubscriptionConnections.set(session.id, connectionId); - this.#appendSessionEvent(authority, session.id, connectionId, { - type: "connection", - state: resubscribed ? "resubscribed" : "connected", - }); + provider: Provider, + source: ProviderFactSource, + ): boolean { + if (session.provider !== provider) return false; + if (this.#profileAuthorityRevocationIsPending(session.profileId)) return false; + const binding = this.#store.readSessionPersonalRuntimeBinding(session.id, true); + const runtimeScope: RuntimeAccountScope = binding !== null + && binding.state === "active" + && binding.provider === provider + && binding.providerThreadId === session.providerThreadId + ? "personal" + : "managed"; + const profile = this.#store.requireProfileById(session.profileId); + if ( + provider !== "devin" + && this.#providerRuntimeAccountRevocationIsPending( + profile.id, + profile.processGeneration, + provider, + runtimeScope, + ) + ) return false; + if (binding === null) return source === "managed"; + const matchesCurrentIdentity = binding.provider === provider + && binding.providerThreadId === session.providerThreadId; + if (source === "managed") { + // Provider switching keeps a detached historical binding. It must not + // suppress facts from the session's new ordinary managed identity. + return binding.state === "detached" && !matchesCurrentIdentity; + } + return binding.state === "active" && matchesCurrentIdentity; } - #lastSessionEventIsProviderGap(sessionId: SessionRecord["id"]): boolean { - const position = this.#store.eventStreamPosition(sessionId); - if (position.observedThroughSequence === 0) return false; - const latest = this.#store.listSessionEvents({ - sessionId, - afterSequence: position.observedThroughSequence - 1, - limit: 1, - }).events[0]; - return latest?.body.type === "gap" - && (latest.body.reason === "provider_restart" || latest.body.reason === "provider_disconnect"); + #clearSessionFactAuthority(sessionId: SessionRecord["id"]): void { + this.#sessionFactAuthorities.delete(sessionId); } - #handleProviderDisconnected( + #clearProfileFactAuthorities( + profileId: ProfileRecord["id"], + provider?: Provider, + runtimeScope?: RuntimeAccountScope, + ): void { + for (const [sessionId, capability] of this.#sessionFactAuthorities) { + if ( + capability.profileId === profileId + && (provider === undefined || capability.provider === provider) + && (runtimeScope === undefined || capability.runtimeScope === runtimeScope) + ) this.#sessionFactAuthorities.delete(sessionId); + } + } + + #mintSessionFactAuthority( authority: ProfileAuthority, - provider: Provider, + session: SessionRecord, connectionId: string, - reason: "eof" | "process_exit" | "closed" | "protocol_fault", ): void { - const terminal = this.#store.expireGenerationInteractions({ - profileId: authority.id, - processGeneration: authority.generation, + z.string().uuid().parse(connectionId); + if (session.providerThreadId === undefined) { + throw new Error("SESSION_FACT_AUTHORITY_THREAD_MISSING"); + } + const profile = this.#store.requireProfileById(session.profileId); + if ( + profile.id !== authority.id + || profile.processGeneration !== authority.generation + || !this.#profileAllowsEstablishedSession(profile, session) + || session.state === "terminal" + || session.state === "recovery_required" + || ( + session.provider === "codex" + && !this.#store.sessionAccountAuthorityMatches(session.id, profile.id) + ) + ) throw new Error("SESSION_FACT_AUTHORITY_PROFILE_STALE"); + const runtimeScope: RuntimeAccountScope = this.#sessionHasActivePersonalBinding(session) + ? "personal" + : "managed"; + let accountKey: string | null = null; + if (session.provider !== "devin") { + const recorded = this.#store.readSessionProviderAccountAuthority(session.id); + const attested = this.#personalAccountAttestations.get( + this.#personalAccountAttestationKey(session.provider, profile.id, runtimeScope), + ); + if ( + recorded === null + || recorded.provider !== session.provider + || recorded.runtimeScope !== runtimeScope + || attested?.generation !== profile.processGeneration + || attested.accountKey !== recorded.accountKey + ) throw new Error("SESSION_FACT_AUTHORITY_ACCOUNT_UNATTESTED"); + accountKey = recorded.accountKey; + } else if (runtimeScope !== "managed") { + throw new Error("SESSION_FACT_AUTHORITY_DEVIN_SCOPE_STALE"); + } + const binding = this.#store.readSessionPersonalRuntimeBinding(session.id, true); + const personalBindingRevision = runtimeScope === "personal" + && binding !== null + && binding.state === "active" + && binding.provider === session.provider + && binding.providerThreadId === session.providerThreadId + ? binding.revision + : null; + if (runtimeScope === "personal" && personalBindingRevision === null) { + throw new Error("SESSION_FACT_AUTHORITY_BINDING_STALE"); + } + let claudeProcess: SessionFactAuthority["claudeProcess"] = null; + if (session.provider === "claude") { + const process = this.#store.readClaudeProcessAuthority({ + providerThreadId: session.providerThreadId, + profileId: profile.id, + runtimeScope, + }); + if ( + process === null + || process.profileGeneration !== profile.processGeneration + || process.sessionId !== session.id + || process.state !== "bound" + ) throw new Error("SESSION_FACT_AUTHORITY_CLAUDE_PROCESS_STALE"); + claudeProcess = { identity: process.identity, revision: process.revision }; + } + this.#sessionFactAuthorities.set(session.id, { + sessionId: session.id, + profileId: profile.id, + profileGeneration: profile.processGeneration, + provider: session.provider, + runtimeScope, + providerThreadId: session.providerThreadId, connectionId, + accountKey, + personalBindingRevision, + claudeProcess, }); - for (const interaction of terminal) this.#appendInteractionState(interaction); - for (const [sessionId, activeConnectionId] of [...this.#sessionProviderConnections]) { - if (activeConnectionId !== connectionId) continue; + } + + #sessionFactAuthorityIsCurrent( + sessionId: SessionRecord["id"], + authority: ProfileAuthority, + provider: Provider, + source: ProviderFactSource, + providerThreadId: string, + connectionId?: string, + options: Readonly<{ allowRecoveryRequired?: boolean }> = {}, + ): boolean { + const capability = this.#sessionFactAuthorities.get(sessionId); + if (capability === undefined) return false; + try { + if ( + capability.profileId !== authority.id + || capability.profileGeneration !== authority.generation + || capability.provider !== provider + || capability.providerThreadId !== providerThreadId + || capability.runtimeScope !== (source === "personal" ? "personal" : "managed") + || (connectionId !== undefined && capability.connectionId !== connectionId) + || this.#sessionProviderConnections.get(sessionId) !== capability.connectionId + || this.#profileAuthorityRevocationIsPending( + capability.profileId, + capability.profileGeneration, + ) + || ( + capability.provider !== "devin" + && this.#providerRuntimeAccountRevocationIsPending( + capability.profileId, + capability.profileGeneration, + capability.provider, + capability.runtimeScope, + ) + ) + ) throw new Error("SESSION_FACT_AUTHORITY_STALE"); + const profile = this.#store.requireProfileById(capability.profileId); const session = this.#store.requireSession(sessionId); - if (session.profileId !== authority.id || session.provider !== provider) continue; - this.#appendSessionEvent(authority, session.id, connectionId, { - type: "connection", - state: "disconnected", - reason, - }); - const position = this.#store.eventStreamPosition(session.id); - this.#appendSessionEvent(authority, session.id, connectionId, { - type: "gap", - reason: reason === "protocol_fault" ? "protocol_incompatible" : "provider_disconnect", - fromSequence: position.observedThroughSequence + 1, - throughSequence: position.observedThroughSequence + 1, - }); - this.#sessionProviderConnections.delete(session.id); - this.#sessionObservationFailures.delete(session.id); - this.#sessionResubscriptionConnections.delete(session.id); - this.#sessionsAwaitingResubscription.add(session.id); + const recorded = capability.provider === "devin" + ? null + : this.#store.readSessionProviderAccountAuthority(sessionId); + if ( + profile.processGeneration !== capability.profileGeneration + || !this.#profileAllowsEstablishedSession(profile, session) + || session.profileId !== capability.profileId + || session.provider !== capability.provider + || session.providerThreadId !== capability.providerThreadId + || session.state === "terminal" + || (session.state === "recovery_required" && options.allowRecoveryRequired !== true) + || ( + session.provider === "codex" + && !this.#store.sessionAccountAuthorityMatches(session.id, profile.id) + ) + || ( + capability.provider === "devin" + ? capability.runtimeScope !== "managed" || capability.accountKey !== null + : recorded === null + || recorded.provider !== capability.provider + || recorded.runtimeScope !== capability.runtimeScope + || recorded.accountKey !== capability.accountKey + ) + ) throw new Error("SESSION_FACT_AUTHORITY_STALE"); + const binding = this.#store.readSessionPersonalRuntimeBinding(sessionId, true); + if (capability.runtimeScope === "personal") { + if ( + binding === null + || binding.state !== "active" + || binding.revision !== capability.personalBindingRevision + || binding.provider !== capability.provider + || binding.providerThreadId !== capability.providerThreadId + ) throw new Error("SESSION_FACT_AUTHORITY_BINDING_STALE"); + } else if ( + binding !== null + && binding.state !== "detached" + && binding.provider === capability.provider + && binding.providerThreadId === capability.providerThreadId + ) throw new Error("SESSION_FACT_AUTHORITY_BINDING_STALE"); + if (capability.provider === "claude") { + const process = this.#store.readClaudeProcessAuthority({ + providerThreadId: capability.providerThreadId, + profileId: capability.profileId, + runtimeScope: capability.runtimeScope, + }); + if ( + process === null + || process.profileGeneration !== capability.profileGeneration + || process.sessionId !== capability.sessionId + || process.state !== "bound" + || capability.claudeProcess === null + || process.revision !== capability.claudeProcess.revision + || !this.#sameClaudeProcessIdentity(process.identity, capability.claudeProcess.identity) + ) throw new Error("SESSION_FACT_AUTHORITY_CLAUDE_PROCESS_STALE"); + } else if (capability.claudeProcess !== null) { + throw new Error("SESSION_FACT_AUTHORITY_PROVIDER_STALE"); + } + return true; + } catch { + this.#clearSessionFactAuthority(sessionId); + return false; } } /** - * Provider-owned isolated runtimes share the profile generation fence with - * Codex, but not its credential state. A Codex authority advance may rebind - * them only while each provider's own durable/live authority is quiescent. + * An unknown Codex connection is never allowed to commit its triggering + * delta. Once existing mutation tails drain, an exact provider observation + * may mint a capability for later deltas. Claude has no connection-only + * fallback: its exact process identity must already be claimed and observed. */ - #isolatedProviderAuthorityAdvanceBlocker( - profileId: ProfileRecord["id"], - ): Readonly<{ - blocker: NonNullable>; - provider: "claude" | "devin"; - }> | null { - for (const provider of ["claude", "devin"] as const) { - const blocker = this.#store.providerAuthorityAdvanceBlocker(profileId, provider); - if (blocker !== null) return { blocker, provider }; + async #warmUnknownCodexFactAuthority( + session: SessionRecord, + provider: Provider, + ): Promise { + if (provider !== "codex") return; + try { + await this.#ensureSessionObservedLocked( + session.id, + this.#backgroundAbort.signal, + ); + } catch (error: unknown) { + if (error instanceof StateSecurityScrubRequiredError) throw error; + this.recordBackgroundDiagnostic("session_state_tracking_failed", error); } - return null; } - #rebindIsolatedProviderAuthorities( - profileId: ProfileRecord["id"], - expectedGeneration: number, - nextGeneration: number, - ): void { - const input = { profileId, expectedGeneration, nextGeneration }; - this.#claude.rebindProfileAuthority(input); - this.#devin.rebindProfileAuthority(input); + async #ensureSessionFactAuthority( + session: SessionRecord, + authority: ProfileAuthority, + provider: Provider, + source: ProviderFactSource, + providerThreadId: string, + connectionId?: string, + ): Promise { + const hadCapability = this.#sessionFactAuthorities.has(session.id); + if (this.#sessionFactAuthorityIsCurrent( + session.id, + authority, + provider, + source, + providerThreadId, + connectionId, + )) return true; + if (hadCapability || provider !== "codex") return false; + await this.#warmUnknownCodexFactAuthority(session, provider); + return this.#sessionFactAuthorityIsCurrent( + session.id, + authority, + provider, + source, + providerThreadId, + connectionId, + ); } - #prepareAccountLoginProviderRetirements( + #findSessionForProviderFact( profileId: ProfileRecord["id"], - processGeneration: number, - ): readonly Readonly<{ - connectionId: string; - releasedEvents: readonly SessionEventWrite[]; - sessionId: SessionRecord["id"]; - }>[] { - const retirements: Array> = []; - for (const [sessionId, connectionId] of this.#sessionProviderConnections) { - const session = this.#store.requireSession(sessionId); - if (session.profileId !== profileId || session.provider !== "codex") continue; - retirements.push({ - connectionId, - releasedEvents: this.#eventRedactor.interruptSession({ - accountId: profileId, - providerConnectionId: connectionId, - providerGeneration: processGeneration, - sessionId, - }), - sessionId, - }); + providerThreadId: string, + provider: Provider, + source: ProviderFactSource, + ): SessionRecord | null { + const session = this.#store.findSessionByProviderThread(profileId, providerThreadId); + if (session === null || !this.#sessionUsesFactSource(session, provider, source)) { + return null; } - return retirements; + const profile = this.#store.requireProfileById(profileId); + if (!this.#profileAllowsEstablishedSession(profile, session)) return null; + if ( + session.provider === "codex" + && !this.#store.sessionAccountAuthorityMatches(session.id, profileId) + ) return null; + return session; } - #applyAccountLoginProviderRetirements( - retirements: readonly Readonly<{ - connectionId: string; - sessionId: SessionRecord["id"]; - }>[], - retiredSessionIds: readonly SessionRecord["id"][], - ): void { - for (const retirement of retirements) { - if (this.#sessionProviderConnections.get(retirement.sessionId) !== retirement.connectionId) { - throw new Error("ACCOUNT_LOGIN_RETIREMENT_CONNECTION_CHANGED"); + async handleConversationAutomationToolCall( + authority: ProfileAuthority, + call: ConversationAutomationToolCall, + source: ProviderFactSource = "managed", + ): Promise { + const finish = this.#beginOperation(); + try { + await this.#daemonAuthority.assertCurrent(); + if ( + call.authority.profileId !== authority.id + || call.authority.processGeneration !== authority.generation + ) throw new Error("CONVERSATION_AUTOMATION_AUTHORITY_MISMATCH"); + const profile = this.#store.requireProfileById(authority.id); + if ( + profile.processGeneration !== authority.generation + || profile.state !== "signed_in" + || this.#profileAuthorityRevocationIsPending(profile.id, authority.generation) + ) throw new Error("CONVERSATION_AUTOMATION_AUTHORITY_STALE"); + const session = this.#findSessionForProviderFact( + authority.id, + call.threadId, + "codex", + source, + ); + if ( + session === null + || session.state === "terminal" + || session.state === "recovery_required" + || !this.#store.isConversationAutomationEnabled(session.id, call.threadId) + ) { + throw new Error("CONVERSATION_AUTOMATION_SESSION_UNAVAILABLE"); } - this.#sessionProviderConnections.delete(retirement.sessionId); - this.#sessionObservationFailures.delete(retirement.sessionId); - this.#sessionResubscriptionConnections.delete(retirement.sessionId); - this.#sessionsAwaitingResubscription.add(retirement.sessionId); + const idempotencyKey = conversationAutomationIdempotencyKey(authority, call); + const result = await this.#serializeSessionAuthority( + session, + async () => { + const currentProfile = this.#store.requireProfileById(authority.id); + const currentSession = this.#findSessionForProviderFact( + authority.id, + call.threadId, + "codex", + source, + ); + if ( + currentProfile.processGeneration !== authority.generation + || currentProfile.state !== "signed_in" + || currentSession === null + || currentSession.id !== session.id + || currentSession.state === "terminal" + || currentSession.state === "recovery_required" + || !this.#store.isConversationAutomationEnabled(currentSession.id, call.threadId) + ) throw new Error("CONVERSATION_AUTOMATION_AUTHORITY_STALE"); + await this.#assertPersonalSessionAccountAuthority( + currentSession, + currentProfile, + this.#backgroundAbort.signal, + true, + ); + switch (call.operation.mode) { + case "list": + return this.#sessionTasks.listIdempotent( + currentSession.id, + idempotencyKey, + call.requestDigest, + ); + case "view": + return summarizeSessionTask(this.#sessionTasks.requireIdempotent( + currentSession.id, + sessionTaskIdSchema.parse(call.operation.id), + idempotencyKey, + call.requestDigest, + )); + case "create": + return summarizeSessionTask(this.#sessionTasks.create({ + sessionId: currentSession.id, + name: call.operation.name, + prompt: call.operation.prompt, + minutes: call.operation.schedule.minutes, + status: call.operation.paused === true ? "paused" : "active", + idempotencyKey, + receiptDigest: call.requestDigest, + })); + case "update": { + const patch: SessionTaskPatch = { + ...(call.operation.name === undefined ? {} : { name: call.operation.name }), + ...(call.operation.prompt === undefined ? {} : { prompt: call.operation.prompt }), + ...(call.operation.schedule === undefined + ? {} + : { minutes: call.operation.schedule.minutes }), + ...(call.operation.status === undefined ? {} : { status: call.operation.status }), + }; + return summarizeSessionTask(this.#sessionTasks.edit({ + sessionId: currentSession.id, + taskId: sessionTaskIdSchema.parse(call.operation.id), + expectedRevision: call.operation.revision, + patch, + idempotencyKey, + receiptDigest: call.requestDigest, + })); + } + case "delete": + return this.#sessionTasks.delete({ + sessionId: currentSession.id, + taskId: sessionTaskIdSchema.parse(call.operation.id), + expectedRevision: call.operation.revision, + idempotencyKey, + receiptDigest: call.requestDigest, + }); + } + }, + { allowDuringProjectionRecovery: false }, + ); + await this.#daemonAuthority.assertCurrent(); + return result; + } finally { + finish(); } - for (const sessionId of retiredSessionIds) this.#eventWaiters.notify(sessionId); } - #eventBodyForCodexFact( - fact: Exclude - & Readonly<{ threadId: string }>, - session: SessionRecord, - ): SessionEventBody | null { - switch (fact.type) { - case "turnStarted": return { type: "turn_started", turnId: fact.turn.id }; - case "turnCompleted": return { - type: "turn_completed", - turnId: fact.turn.id, - status: fact.turn.status === "inProgress" ? "failed" : fact.turn.status, - }; - case "threadStatusChanged": return { - type: "session_status", - status: fact.status.type === "notLoaded" - ? "not_loaded" - : fact.status.type === "systemError" - ? "system_error" - : fact.status.type, - activeTurnId: fact.status.type === "active" ? session.activeTurnId ?? null : null, - }; - case "threadDeleted": return null; - // A `subAgentActivity` marker item announces the same activity on both - // its started and its completed notification, so the projection is the - // same body twice at most. Every consumer folds by agent id, so the - // repeat is a no-op rather than a second subagent. - case "itemStarted": - case "itemCompleted": { - if (fact.subagent !== undefined) { - return { - type: "subagent_activity", - turnId: fact.turnId, - agentId: fact.subagent.agentThreadId, - kind: fact.subagent.kind, - }; - } - // A tool-shaped item carries the stable call identity a later result - // binds back to, plus a classified one-line summary. Both are built - // only from fields the protocol layer already reduced to safe labels. - const toolIdentity = isNeutralToolItemKind(fact.itemKind) - ? { - callId: fact.itemId, - summary: neutralToolSummary(fact), - } - : {}; - return fact.type === "itemStarted" - ? { - type: "item_started", - turnId: fact.turnId, - itemId: fact.itemId, - itemKind: fact.itemKind, - ...(fact.server === undefined ? {} : { server: fact.server }), - ...(fact.tool === undefined ? {} : { tool: fact.tool }), - ...(fact.liveAcceptanceCommandDigest === undefined - ? {} - : { liveAcceptanceCommandDigest: fact.liveAcceptanceCommandDigest }), - ...toolIdentity, - } - : { - type: "item_completed", - turnId: fact.turnId, - itemId: fact.itemId, - itemKind: fact.itemKind, - ...(fact.server === undefined ? {} : { server: fact.server }), - ...(fact.tool === undefined ? {} : { tool: fact.tool }), - ...(fact.liveAcceptanceCommandDigest === undefined - ? {} - : { liveAcceptanceCommandDigest: fact.liveAcceptanceCommandDigest }), - ...(fact.status === undefined ? {} : { status: fact.status }), - ...toolIdentity, - }; - } - // Only a spawned subagent thread reaches here, and only its bounded - // nickname, role, and depth. Without an active turn there is nothing to - // attach the activity to, so the metadata is dropped. - case "subagentThreadStarted": { - const turnId = session.activeTurnId ?? null; - if (turnId === null) return null; - return { - type: "subagent_activity", - turnId, - agentId: fact.agentThreadId, - kind: "started", - ...(fact.depth === undefined ? {} : { depth: fact.depth }), - ...(fact.nickname === undefined ? {} : { nickname: fact.nickname }), - ...(fact.role === undefined ? {} : { role: fact.role }), - }; - } - case "assistantDelta": return { - type: "assistant_delta", - turnId: fact.turnId, - itemId: fact.itemId, - text: fact.text, - }; - case "reasoningSummaryDelta": return { - type: "reasoning_summary_delta", - turnId: fact.turnId, - itemId: fact.itemId, - summaryPart: fact.summaryIndex, - text: fact.text, - }; - case "toolProgress": return { - type: "tool_progress", - turnId: fact.turnId, - itemId: fact.itemId, - toolKind: fact.toolKind, - ...(fact.status === undefined ? {} : { status: fact.status }), - ...(fact.outputBytesObserved === undefined - ? {} - : { outputBytesObserved: fact.outputBytesObserved }), - ...(fact.server === undefined ? {} : { server: fact.server }), - ...(fact.tool === undefined ? {} : { tool: fact.tool }), - }; - case "planUpdated": return { - type: "plan_updated", - turnId: fact.turnId, - steps: [...fact.steps], - ...(fact.explanation === undefined ? {} : { explanation: fact.explanation }), - }; - case "diffUpdated": return { - type: "diff_updated", - turnId: fact.turnId, - changedFiles: fact.changedFiles, - patchBytesObserved: fact.patchBytesObserved, - }; - case "tokenUsageUpdated": return { - type: "token_usage", - turnId: fact.turnId, - inputTokens: fact.inputTokens, - cachedInputTokens: fact.cachedInputTokens, - outputTokens: fact.outputTokens, - reasoningOutputTokens: fact.reasoningOutputTokens, - totalTokens: fact.totalTokens, - modelContextWindow: fact.modelContextWindow, - ...(fact.providerCost === undefined ? {} : { providerCost: fact.providerCost }), - }; - case "providerWarning": return { - type: "warning", - code: fact.code, - message: fact.message, - }; - case "providerError": return { - type: "error", - code: fact.code, - message: fact.message, - terminal: fact.terminal, - }; - case "accountUpdated": - case "rateLimitsUpdated": - case "loginCompleted": - case "serverRequestResolved": - case "notificationIgnored": - case "threadNameUpdated": - return null; - } - } - - #applyCodexFact( + /** Called only after Codex has received a successful dynamic-tool response frame. */ + notifyConversationAutomationToolResponseWritten( authority: ProfileAuthority, - fact: CodexFact & Readonly<{ threadId: string }>, - expected: SessionRecord, - ): boolean { - let profile: ProfileRecord; + call: ConversationAutomationToolCall, + source: ProviderFactSource = "managed", + ): void { + if ( + this.#state !== "open" + || call.authority.profileId !== authority.id + || call.authority.processGeneration !== authority.generation + ) return; try { - profile = this.#store.requireProfileById(authority.id); + const profile = this.#store.requireProfileById(authority.id); + const session = this.#findSessionForProviderFact( + authority.id, + call.threadId, + "codex", + source, + ); + if ( + profile.processGeneration === authority.generation + && profile.state === "signed_in" + && !this.#profileAuthorityRevocationIsPending(profile.id, authority.generation) + && session !== null + && session.state !== "terminal" + ) this.#wakeSessionTaskPump(); } catch { - return false; - } - if (profile.processGeneration !== authority.generation) return false; - const current = this.#store.findSessionByProviderThread(authority.id, fact.threadId); - if ( - current === null - || current.id !== expected.id - || !this.#profileAllowsEstablishedSession(profile, current) - || current.state === "terminal" - || (current.state === "recovery_required" && fact.type !== "threadDeleted") - || this.#projectionRecoveriesInFlight.has(current.id) - ) return false; - this.#bumpSessionFactEpoch(current.id); - if (fact.type === "threadDeleted") return false; - if (fact.type === "turnStarted") { - this.#store.reconcileSessionFromProvider({ sessionId: current.id, state: "active", activeTurnId: fact.turn.id }); - return false; - } - if (fact.type === "turnCompleted") { - for (const interaction of this.#store.expireTurnInteractions({ - sessionId: current.id, - profileId: authority.id, - processGeneration: authority.generation, - turnId: fact.turn.id, - })) this.#appendInteractionState(interaction); - this.#store.reconcileSessionFromProvider({ sessionId: current.id, state: "idle", activeTurnId: null }); - return true; - } - if (fact.type === "threadStatusChanged") { - if (fact.status.type === "systemError") { - this.#quarantineSession(current.id); - return false; - } - const state = fact.status.type === "active" ? "active" : "idle"; - this.#store.reconcileSessionFromProvider({ sessionId: current.id, state, ...(state === "active" ? {} : { activeTurnId: null }) }); - return false; - } - if (fact.type === "threadNameUpdated" && fact.name !== null) { - this.#store.reconcileSessionFromProvider({ sessionId: current.id, title: fact.name }); + // The mutation was already committed and acknowledged; a later state change simply + // leaves the durable daemon pump or recovery path to observe it. } - return false; } - async #closeAdmittedService(): Promise { - let runtimeError: unknown; - const failedProviders = new Set(); + async observeCodexAccount( + authority: ProfileAuthority, + account: CodexAccountProjection, + ): Promise { + const finish = this.#beginFactOperation(); + if (finish === null) return; try { - if (this.#interactionDeadlineTask !== undefined) { - await this.#interactionDeadlineTask.catch(() => undefined); - } - if (this.#sessionTaskPumpTask !== undefined) { - await this.#sessionTaskPumpTask.catch(() => undefined); + await this.#daemonAuthority.assertCurrent(); + let profile: ProfileRecord; + try { + profile = this.#store.requireProfileById(authority.id); + } catch { + return; } - const runtimes: readonly Readonly<{ - close: () => Promise; - provider: Provider; - }>[] = [ - { close: async () => await this.#codex.close(), provider: "codex" }, - { close: async () => await this.#claude.close(), provider: "claude" }, - { close: async () => await this.#devin.close(), provider: "devin" }, - ]; - const closed = await Promise.allSettled( - runtimes.map(async (runtime) => await runtime.close()), - ); - for (const [index, outcome] of closed.entries()) { - if (outcome.status === "rejected") { - runtimeError ??= outcome.reason; - const runtime = runtimes[index]; - if (runtime !== undefined) failedProviders.add(runtime.provider); + if ( + profile.processGeneration !== authority.generation + ) return; + this.#assertObservedCodexAccountAuthority(profile, account); + const recoveryUnsettled = await this.#cloud + .isCompactProjectionRecoveryUnsettledForProfile(profile.id); + await this.#daemonAuthority.assertCurrent(); + const afterRecoveryRead = this.#store.requireProfileById(profile.id); + if (afterRecoveryRead.processGeneration !== authority.generation) return; + this.#assertObservedCodexAccountAuthority(afterRecoveryRead, account); + if (recoveryUnsettled || this.#profileHasProjectionRecoveryInFlight(profile.id)) return; + const apply = async (): Promise => { + let current: ProfileRecord; + try { + current = this.#store.requireProfileById(profile.id); + } catch (error: unknown) { + if (error instanceof SelectionError && error.code === "NOT_FOUND") return; + throw error; } - } - } catch (error: unknown) { - runtimeError = error; - } - await this.#drainOwnedWork(); - this.#persistSessionEventWrites(this.#eventRedactor.interruptAll()); - if (runtimeError !== undefined) { - const quarantineErrors: unknown[] = []; - for (const profile of this.#store.listProfiles()) { - for (const provider of failedProviders) { - for (const session of this.#store.listNonterminalProviderSessions(profile.id, provider)) { - try { - this.#quarantineSession(session.id); - } catch (error: unknown) { - quarantineErrors.push(error); - } - } + if (current.processGeneration !== authority.generation) return; + this.#assertObservedCodexAccountAuthority(current, account); + if (this.#profileHasProjectionRecoveryInFlight(profile.id)) return; + const blocked = await this.#cloud + .isCompactProjectionRecoveryUnsettledForProfile(profile.id); + await this.#daemonAuthority.assertCurrent(); + current = this.#store.requireProfileById(profile.id); + if (current.processGeneration !== authority.generation) return; + this.#assertObservedCodexAccountAuthority(current, account); + if (blocked || this.#profileHasProjectionRecoveryInFlight(profile.id)) return; + const accountAuthorityChanged = providerAccountAuthorityChanged(current, account); + if (this.#profileAuthorityRevocationIsPending( + current.id, + current.processGeneration, + )) { + if (accountAuthorityChanged) this.#scheduleProfilePersonalAuthorityRevocation(current); + return; } - } - this.#sessionProviderConnections.clear(); - this.#sessionObservationFailures.clear(); - this.#sessionResubscriptionConnections.clear(); - this.#sessionsAwaitingResubscription.clear(); - this.#state = "closed"; - if (quarantineErrors.length > 0) { - throw new AggregateError( - [runtimeError, ...quarantineErrors], - "A provider runtime failed to close and its matching durable session quarantine was incomplete.", + if (!account.signedIn && current.state === "login_pending") return; + // Established-identity mismatches were rejected synchronously before + // any recovery wait or mutation-tail deferral above. + // Provider state discovered outside HRA is evidence, not permission to + // bind a replacement identity to dormant sessions and work. Only the + // explicit login mutation may move a signed-out profile into signed-in. + if (current.state === "signed_out") return; + const stateChange = this.#store.setProfileStateWithWorkRetirement( + current.id, + current.processGeneration, + account.signedIn ? "signed_in" : "signed_out", + this.#work, + { + ...(account.email === undefined ? {} : { email: account.email }), + ...(account.plan === undefined ? {} : { plan: account.plan }), + }, ); + this.#notifyAffectedWork(stateChange.affectedWorkIds); + if (account.signedIn) this.#wakeSessionTaskPump(); + }; + const accountKey = `account:${profile.id}`; + if (!this.#mutationTails.has(accountKey)) { + await this.#serialize(accountKey, apply); + return; } - throw runtimeError instanceof Error - ? runtimeError - : new Error("A provider runtime closed with a non-Error failure."); - } - let retirementError: unknown; - try { - this.#retireClosedRuntimeAuthorities(); + // An account mutation holds the tail, and this callback may be awaited + // inside that mutation's own provider call, so it cannot wait its turn. + // Queue the fact behind the tail instead of applying it now: a signed-in + // fact written mid-login would move the profile out of `login_pending` + // under a commit that requires that exact state, which quarantined the + // account for a login that succeeded. + const queued = this.#serialize(accountKey, apply); + const tracked = queued.then( + () => undefined, + (error: unknown) => { + if (error instanceof StateSecurityScrubRequiredError) this.#requestStop(); + else this.recordBackgroundDiagnostic("account_fact_apply_failed", error); + }, + ); + this.#background.add(tracked); + void tracked.then(() => this.#background.delete(tracked)); } catch (error: unknown) { - retirementError = error; - } - this.#state = "closed"; - if (retirementError !== undefined) { - throw retirementError instanceof Error - ? retirementError - : new Error("The Codex runtime authority retirement failed with a non-Error failure."); + if (error instanceof StateSecurityScrubRequiredError) this.#requestStop(); + throw error; + } finally { + finish(); } } - #retireClosedRuntimeAuthorities(): void { - const projectionErrors: unknown[] = []; - for (const profile of this.#store.listProfiles()) { - if (profile.processGeneration === 0) continue; - let terminal: readonly InteractionRecord[]; + #accountMutationExplainsObservedCodexTransition( + profile: Pick, + account: CodexAccountProjection, + ): boolean { + if (profile.state === "login_pending") return true; + const unsettled = this.#store.listUnsettledMutations({ authorityId: profile.id }) + .filter((attempt) => (attempt.originalState ?? attempt.state) !== "reconciled"); + const currentGeneration = unsettled.filter((attempt) => + attempt.authorityGeneration === profile.processGeneration); + const candidates = currentGeneration.length > 0 + ? currentGeneration + : profile.state === "recovery_required" && !account.signedIn + ? unsettled.filter((attempt) => { + if ( + attempt.kind !== "account.logout" + || attempt.authorityGeneration + 1 !== profile.processGeneration + ) return false; + const retired = this.#store.readProviderRuntimeAccountRevocation({ + profileId: profile.id, + provider: "codex", + runtimeScope: "managed", + }); + return retired?.state === "completed" + && retired.profileGeneration === attempt.authorityGeneration + && retired.currentAccountKey === null; + }) + : []; + if (candidates.length !== 1) return false; + const [attempt] = candidates; + if (attempt === undefined) return false; + if (attempt.kind === "account.logout") return !account.signedIn; + if (attempt.kind === "account.login") return account.signedIn; + return attempt.kind === "account.login-cancel" && !account.signedIn; + } + + /** + * Reject an unsolicited replacement before an account fact can hide behind + * projection recovery or the account mutation tail. Login and logout facts + * backed by their exact durable mutation are expected transitions, not an + * authority replacement. + */ + #assertObservedCodexAccountAuthority( + profile: ProfileRecord, + account: CodexAccountProjection, + ): void { + if (profile.state !== "signed_in" && profile.state !== "recovery_required") return; + if (!providerAccountAuthorityChanged(profile, account)) return; + if (this.#accountMutationExplainsObservedCodexTransition(profile, account)) return; + this.#scheduleProfilePersonalAuthorityRevocation(profile); + throw new ProviderAccountAuthorityMismatchError("codex", profile); + } + + /** + * A personal-home account fact is evidence only for the dedicated personal + * controller. It must never rewrite the selected isolated HRA login. A + * mismatch instead enters the existing durable controller-revocation path + * before any later personal fact or effect can be admitted. + */ + async observePersonalCodexAccount( + authority: ProfileAuthority, + account: CodexAccountProjection, + ): Promise { + const finish = this.#beginFactOperation(); + if (finish === null) return; + try { + await this.#daemonAuthority.assertCurrent(); + let profile: ProfileRecord; try { - terminal = this.#store.expireGenerationInteractions({ + profile = this.#store.requireProfileById(authority.id); + } catch { + return; + } + if ( + profile.processGeneration !== authority.generation + || (profile.state !== "signed_in" && profile.state !== "recovery_required") + ) return; + const key = this.#personalAccountAttestationKey("codex", profile.id); + const accountKey = providerAccountAuthorityKey("codex", account); + if (accountKey === null || accountKey !== profileCodexAccountAuthorityKey(profile)) { + this.#personalAccountAttestations.delete(key); + const releasing = this.#store.readProviderRuntimeAccountRevocation({ profileId: profile.id, - processGeneration: profile.processGeneration, + provider: "codex", + runtimeScope: "personal", }); - } catch (error: unknown) { - projectionErrors.push(error); - continue; - } - for (const interaction of terminal) { - try { - this.#appendInteractionState(interaction); - } catch (error: unknown) { - projectionErrors.push(error); + if ( + releasing?.state === "releasing" + && releasing.profileGeneration === profile.processGeneration + ) { + if (releasing.currentAccountKey !== accountKey) { + this.#scheduleProviderRuntimeAccountRevocation( + profile, + "codex", + "personal", + accountKey, + ); + } + // The controller is already fenced, but every replacement callback + // still fails closed. A B -> C observation advances the durable job + // before throwing, so the in-flight close can complete its newest + // revision without reopening this retired generation. + throw new ProviderAccountAuthorityMismatchError("codex", profile); } + this.#scheduleProviderRuntimeAccountRevocation( + profile, + "codex", + "personal", + accountKey, + ); + throw new ProviderAccountAuthorityMismatchError("codex", profile); } - const authority = authorityFor(this.#paths, profile); - for (const [sessionId, connectionId] of [...this.#sessionProviderConnections]) { - let session: SessionRecord; + this.#personalAccountAttestations.set(key, { + checkedAt: this.#now(), + accountKey, + generation: profile.processGeneration, + }); + } catch (error: unknown) { + if (error instanceof StateSecurityScrubRequiredError) this.#requestStop(); + throw error; + } finally { + finish(); + } + } + + async #observeProviderFactAdmitted( + authority: ProfileAuthority, + fact: CodexFact, + provider: Provider, + source: ProviderFactSource, + ): Promise { + await this.#daemonAuthority.assertCurrent(); + let profile: ProfileRecord; + try { + profile = this.#store.requireProfileById(authority.id); + } catch { + return; + } + if (profile.processGeneration !== authority.generation || profile.state === "removed") return; + if ( + fact.type !== "providerDisconnected" + && this.#profileAuthorityRevocationIsPending(profile.id, authority.generation) + ) return; + if (fact.type === "providerDisconnected") { + if (source === "personal") { + const disconnected = this.#handleProviderDisconnected( + authority, + fact.connectionId, + fact.reason, + provider, + source, + ); + if (provider === "claude") this.#scheduleClaudeDisconnectRecovery(disconnected); + return; + } + await this.#applyOrderedAccountFact(profile.id, () => { + let current: ProfileRecord; try { - session = this.#store.requireSession(sessionId); + current = this.#store.requireProfileById(authority.id); } catch (error: unknown) { - projectionErrors.push(error); - this.#sessionProviderConnections.delete(sessionId); - this.#sessionObservationFailures.delete(sessionId); - this.#sessionResubscriptionConnections.delete(sessionId); - this.#sessionsAwaitingResubscription.delete(sessionId); - continue; + if (error instanceof SelectionError && error.code === "NOT_FOUND") return; + throw error; } - if (session.profileId !== profile.id) continue; - try { - this.#appendSessionEvent(authority, session.id, connectionId, { - type: "connection", - state: "disconnected", - reason: "closed", - }); - const position = this.#store.eventStreamPosition(session.id); - this.#appendSessionEvent(authority, session.id, connectionId, { - type: "gap", - reason: "provider_disconnect", - fromSequence: position.observedThroughSequence + 1, - throughSequence: position.observedThroughSequence + 1, - }); - } catch (error: unknown) { - projectionErrors.push(error); - } finally { - this.#sessionProviderConnections.delete(sessionId); - this.#sessionObservationFailures.delete(sessionId); - this.#sessionResubscriptionConnections.delete(sessionId); - this.#sessionsAwaitingResubscription.delete(sessionId); + if (current.processGeneration !== authority.generation) return; + if (provider !== "codex") { + // Claude and Devin own one child process per session. Its exit retires + // only that provider connection; it must never rotate the shared + // profile generation that fences sibling provider sessions. + const disconnected = this.#handleProviderDisconnected( + authority, + fact.connectionId, + fact.reason, + provider, + source, + ); + if (provider === "claude") { + this.#scheduleClaudeDisconnectRecovery(disconnected); + } + return; + } + const isolatedProviderBlocker = this.#isolatedProviderAuthorityAdvanceBlocker(current.id); + this.#handleProviderDisconnected( + authority, + fact.connectionId, + fact.reason, + provider, + source, + ); + if (isolatedProviderBlocker !== null) { + // A spontaneous Codex disconnect cannot be refused and retried like + // an explicit login. Stop the whole daemon instead of rotating a + // live provider-owned authority underneath an in-flight turn or recovery. + this.#state = "closing"; + this.#interactionDeadlineAbort.abort( + new Error( + `Codex disconnected while ${isolatedProviderBlocker.provider} authority was ${isolatedProviderBlocker.blocker}.`, + ), + ); + this.#interactionDeadlineWake?.(); + this.#interactionDeadlineWake = undefined; + this.#daemonAuthority.close(); + this.#scheduleStop(); + return; + } + if (this.#profileHasControllingRuntimeAuthority(current)) { + this.#wakeSessionTaskPump(); + return; } - } - try { - const unsettledForegroundLogin = this.#store - .listUnsettledMutations({ authorityId: profile.id }) - .some((attempt) => - attempt.kind === "account.claude-login" - || attempt.kind === "account.devin-login"); - // Foreground provider children are owned by the invoking CLI rather than - // this runtime manager. Preserve its exact completion generation even - // though the daemon's managed session runtimes have already closed. - if (unsettledForegroundLogin) continue; const retirement = this.#store.advanceProfileGenerationWithWorkRetirement( - profile.id, - profile.processGeneration, + authority.id, + authority.generation, this.#work, { preserveSessionMutationAuthorities: true }, ); this.#notifyAffectedWork(retirement.affectedWorkIds); - } catch (error: unknown) { - projectionErrors.push(error); - } + this.#rebindIsolatedProviderAuthorities( + current.id, + authority.generation, + retirement.profile.processGeneration, + ); + this.#wakeSessionTaskPump(); + }); + return; } - this.#sessionProviderConnections.clear(); - this.#sessionObservationFailures.clear(); - this.#sessionResubscriptionConnections.clear(); - this.#sessionsAwaitingResubscription.clear(); - if (projectionErrors.length > 0) { - throw new AggregateError( - projectionErrors, - "The closed Codex runtime could not retire every durable provider authority.", - ); + if (fact.type === "providerConnected") return; + if (fact.type === "notificationIgnored") return; + if (fact.type === "rateLimitsUpdated") { + if (source === "personal" || provider !== "codex") return; + this.#scheduleUsageRefresh(authority); + return; } - } + if (fact.type === "loginCompleted") { + if (source === "personal" || provider !== "codex") return; + if (fact.success || fact.loginId === null) return; + const loginId = fact.loginId; + const settleFailedLogin = (): void => { + let current: ProfileRecord; + try { + current = this.#store.requireProfileById(authority.id); + } catch (error: unknown) { + if (error instanceof SelectionError && error.code === "NOT_FOUND") return; + throw error; + } + if ( + current.processGeneration !== authority.generation + || current.state !== "login_pending" + ) return; + const pending = this.#store.readPendingLoginAuthority( + current.id, + current.processGeneration, + ); + if (pending?.loginId !== loginId) return; + this.#store.settlePendingLogin({ + profileId: current.id, + processGeneration: current.processGeneration, + loginId, + providerStatus: "not_found", + provider: { signedIn: false }, + }); + }; + await this.#applyOrderedAccountFact(profile.id, settleFailedLogin); + return; + } + // Codex facts retain the selected Codex-account prerequisite. Claude facts + // are instead fenced by their exact provider account and process authority, + // so a profile whose independent Codex login is signed out remains usable. + if (provider === "codex" && profile.state !== "signed_in") return; + if (fact.type === "interactionRequested") { + if ( + fact.provider.profileId !== authority.id + || fact.provider.processGeneration !== authority.generation + || fact.provider.connectionId !== fact.connectionId + ) throw new Error("INTERACTION_FACT_AUTHORITY_MISMATCH"); + if (this.#providerForInteractionAuthority(fact.provider) !== provider) return; + if ( + fact.kind === "mcp_elicitation" + && ( + fact.display.kind !== "mcp_elicitation" + || fact.display.mode !== "form" + || fact.display.fields === undefined + ) + ) throw new Error("MCP_FORM_DISPLAY_CONTRACT_MISSING"); + const session = fact.provider.threadId === null + ? null + : this.#findSessionForProviderFact( + authority.id, + fact.provider.threadId, + provider, + source, + ); + if (session === null || fact.provider.threadId === null) return; + const providerThreadId = fact.provider.threadId; + const admit = async (): Promise => { + const currentProfile = this.#store.requireProfileById(authority.id); + if ( + currentProfile.processGeneration !== authority.generation + || (provider === "codex" && currentProfile.state !== "signed_in") + || this.#profileAuthorityRevocationIsPending( + currentProfile.id, + authority.generation, + ) + ) return; + const exact = fact.provider.threadId === null + ? null + : this.#findSessionForProviderFact( + authority.id, + fact.provider.threadId, + provider, + source, + ); + if (exact === null) return; + if (!await this.#ensureSessionFactAuthority( + exact, + authority, + provider, + source, + providerThreadId, + fact.connectionId, + )) return; + if (!this.#sessionFactAuthorityIsCurrent( + exact.id, + authority, + provider, + source, + providerThreadId, + fact.connectionId, + )) return; + const admitted = this.#store.admitInteraction({ + publicId: randomUUID(), + sessionId: exact.id, + authority: fact.provider, + kind: fact.kind, + blocking: fact.blocking, + display: sanitizeInteractionDisplay(fact.display), + ...(fact.timeoutMs === undefined ? {} : { timeoutMs: fact.timeoutMs }), + ...(fact.requestedAt === undefined ? {} : { requestedAt: fact.requestedAt }), + ...(fact.deadlineAt === undefined ? {} : { deadlineAt: fact.deadlineAt }), + }); + if (!admitted.replayed && admitted.record.sessionId !== null) { + if (!this.#sessionFactAuthorityIsCurrent( + exact.id, + authority, + provider, + source, + providerThreadId, + fact.connectionId, + )) return; + this.#appendSessionEvent(authority, admitted.record.sessionId, fact.connectionId, { + type: "interaction_requested", + interactionId: admitted.record.publicId, + interactionKind: admitted.record.kind, + revision: admitted.record.revision, + blocking: admitted.record.blocking, + summary: admitted.record.display.summary, + }); + this.#scheduleAutorespond(admitted.record); + } + this.#wakeInteractionDeadlinePump(); + }; + await this.#applyOrderedSessionFact(session, admit); + return; + } + if (fact.type === "interactionResolved") { + if (this.#providerForInteractionAuthority(fact.provider) !== provider) return; + const observed = this.#store.findInteractionByAuthority(fact.provider); + if (observed === null) return; + if ( + observed.sessionId !== null + && this.#store.requireSession(observed.sessionId).provider !== provider + ) return; + const settle = async (): Promise => { + const currentProfile = this.#store.requireProfileById(authority.id); + if ( + currentProfile.processGeneration !== authority.generation + || (provider === "codex" && currentProfile.state !== "signed_in") + || this.#profileAuthorityRevocationIsPending( + currentProfile.id, + authority.generation, + ) + ) return; + await this.#serialize(`interaction:${observed.publicId}`, async () => { + const current = this.#store.findInteractionByAuthority(fact.provider); + if ( + current === null + || current.state === "resolved" + || current.state === "declined" + || current.state === "canceled" + || current.state === "expired" + || current.state === "resolution_unknown" + ) return; + if (current.sessionId === null || fact.provider.threadId === null) return; + if (!this.#sessionFactAuthorityIsCurrent( + current.sessionId, + authority, + provider, + source, + fact.provider.threadId, + fact.provider.connectionId, + )) return; + try { + const settled = this.#store.settleInteraction({ + id: current.publicId, + expectedRevision: current.revision, + state: current.intendedTerminalState ?? "resolved", + authority: fact.provider, + ...(current.responseDigest === null ? {} : { responseDigest: current.responseDigest }), + }); + this.#appendInteractionState(settled); + } catch (error: unknown) { + throw this.#interactionPersistenceBoundaryError({ + cause: error, + effect: "possibly_sent", + focalInteraction: current, + ...(current.responseDigest === null + ? {} + : { responseDigest: current.responseDigest }), + }); + } + }); + }; + const ordered = async (): Promise => { + if (observed.sessionId === null) { + await this.#applyOrderedAccountFact(authority.id, settle); + return; + } + const session = this.#store.requireSession(observed.sessionId); + if (!this.#sessionUsesFactSource(session, provider, source)) return; + await this.#applyOrderedSessionFact(session, async () => { + const exact = this.#store.requireSession(session.id); + if (!this.#sessionUsesFactSource(exact, provider, source)) return; + if (fact.provider.threadId === null) return; + if (!await this.#ensureSessionFactAuthority( + exact, + authority, + provider, + source, + fact.provider.threadId, + fact.provider.connectionId, + )) return; + await settle(); + }); + }; + if (this.#mutationTails.has(`interaction:${observed.publicId}`)) { + const tracked = ordered().catch((error: unknown) => { + if (error instanceof StateSecurityScrubRequiredError) this.#requestStop(); + else this.recordBackgroundDiagnostic("session_state_tracking_failed", error); + }); + this.#background.add(tracked); + void tracked.then(() => this.#background.delete(tracked)); + } else { + await ordered(); + } + return; + } + if (fact.type === "protocolNotice") { + if (fact.connectionId === undefined) return; + const observations: Promise[] = []; + for (const [sessionId, connectionId] of [...this.#sessionProviderConnections]) { + if (connectionId !== fact.connectionId) continue; + const session = this.#store.requireSession(sessionId); + if ( + session.profileId !== authority.id + || !this.#sessionUsesFactSource(session, provider, source) + ) continue; + observations.push(this.#applyOrderedSessionFact(session, async () => { + const exact = this.#store.requireSession(session.id); + if ( + exact.profileId !== authority.id + || !this.#sessionUsesFactSource(exact, provider, source) + ) return; + if (exact.providerThreadId === undefined) return; + if (!await this.#ensureSessionFactAuthority( + exact, + authority, + provider, + source, + exact.providerThreadId, + connectionId, + )) return; + if (!this.#sessionFactAuthorityIsCurrent( + exact.id, + authority, + provider, + source, + exact.providerThreadId, + connectionId, + )) return; + this.#appendSessionEvent(authority, exact.id, connectionId, { + type: "protocol_incompatible", + method: fact.method, + payloadDigest: digestText(fact.method), + }); + })); + } + await Promise.all(observations); + return; + } + if (!("threadId" in fact) || typeof fact.threadId !== "string") return; + const observedSession = this.#findSessionForProviderFact( + authority.id, + fact.threadId, + provider, + source, + ); + if ( + observedSession === null + || (observedSession.state === "terminal" && fact.type !== "threadDeleted") + || (observedSession.state === "recovery_required" && fact.type !== "threadDeleted") + ) return; + // Provider deletion is the terminal authority that supersedes an + // in-flight compact-projection recovery. It must not queue behind that + // recovery's session tail, or both sides wait for the other to settle. + if (fact.type === "threadDeleted") { + if (fact.connectionId === undefined) return; + if (!this.#sessionFactAuthorityIsCurrent( + observedSession.id, + authority, + provider, + source, + fact.threadId, + fact.connectionId, + { allowRecoveryRequired: true }, + )) return; + await this.#applyProviderThreadDeletion( + authority, + fact, + observedSession, + provider, + source, + ); + return; + } + await this.#applyOrderedSessionFact(observedSession, async () => { + const currentProfile = this.#store.requireProfileById(authority.id); + if ( + currentProfile.processGeneration !== authority.generation + || (provider === "codex" && currentProfile.state !== "signed_in") + || this.#profileAuthorityRevocationIsPending( + currentProfile.id, + authority.generation, + ) + ) return; + const session = this.#findSessionForProviderFact( + authority.id, + fact.threadId, + provider, + source, + ); + if ( + session === null + || session.state === "terminal" + || session.state === "recovery_required" + ) return; + if (!await this.#ensureSessionFactAuthority( + session, + authority, + provider, + source, + fact.threadId, + fact.connectionId, + )) return; + const event = this.#eventBodyForCodexFact(fact, session); + if (event !== null) { + if (!this.#sessionFactAuthorityIsCurrent( + session.id, + authority, + provider, + source, + fact.threadId, + fact.connectionId, + )) return; + this.#appendSessionEvent(authority, session.id, fact.connectionId ?? null, event); + } + const recoveryUnsettled = await this.#cloud + .isCompactProjectionRecoveryUnsettled(session.id); + await this.#daemonAuthority.assertCurrent(); + const exact = this.#findSessionForProviderFact( + authority.id, + fact.threadId, + provider, + source, + ); + if ( + exact === null + || recoveryUnsettled + || this.#projectionRecoveriesInFlight.has(session.id) + ) return; + if (!this.#sessionFactAuthorityIsCurrent( + exact.id, + authority, + provider, + source, + fact.threadId, + fact.connectionId, + )) return; + const priorRevision = exact.revision; + const dispatchQueue = this.#applyCodexFact(authority, fact, exact); + const committed = this.#store.requireSession(exact.id); + if (committed.revision !== priorRevision) { + await this.#reconcileCommittedSessionFactsMemory(committed); + } + if (dispatchQueue) this.#scheduleIdleQueue(committed); + }); + } + + async #applyProviderThreadDeletion( + authority: ProfileAuthority, + fact: Extract, + expected: SessionRecord, + provider: Provider, + source: ProviderFactSource, + ): Promise { + const current = this.#store.findSessionByProviderThread(authority.id, fact.threadId); + if (current === null || current.id !== expected.id) return; + if (!this.#sessionFactAuthorityIsCurrent( + current.id, + authority, + provider, + source, + fact.threadId, + fact.connectionId, + { allowRecoveryRequired: true }, + )) return; + this.#persistSessionEventWrites(this.#eventRedactor.interruptSession({ + sessionId: current.id, + accountId: authority.id, + providerGeneration: authority.generation, + providerConnectionId: fact.connectionId ?? null, + })); + this.#bumpSessionFactEpoch(current.id); + if (!this.#sessionFactAuthorityIsCurrent( + current.id, + authority, + provider, + source, + fact.threadId, + fact.connectionId, + { allowRecoveryRequired: true }, + )) return; + const terminal = this.#store.terminalizeSessionFromProviderDeletion({ + accountId: authority.id, + providerConnectionId: fact.connectionId ?? null, + providerGeneration: authority.generation, + sessionId: current.id, + }); + if (terminal.event !== undefined) this.#eventWaiters.notify(current.id); + for (const interaction of terminal.interactions) this.#appendInteractionState(interaction); + const terminalSession = this.#store.requireSession(current.id); + const personalBinding = this.#store.readSessionPersonalRuntimeBinding(current.id, true); + if ( + personalBinding !== null + && personalBinding.state !== "detached" + && personalBinding.provider === terminalSession.provider + && personalBinding.providerThreadId === terminalSession.providerThreadId + ) { + if (personalBinding.state === "active") { + this.#clearSessionFactAuthority(terminalSession.id); + this.#store.beginPersonalSessionDetach({ sessionId: terminalSession.id }); + } + this.#scheduleTerminalPersonalDetach(terminalSession); + } else if ( + terminalSession.provider === "claude" + && terminalSession.providerThreadId !== undefined + ) { + this.#scheduleClaudeProcessAuthorityRelease({ + providerThreadId: terminalSession.providerThreadId, + profileId: terminalSession.profileId, + runtimeScope: "managed", + }); + } + await this.#cleanupTerminalFactsMemory(terminalSession); + this.#sessionProviderConnections.delete(current.id); + this.#clearSessionFactAuthority(current.id); + this.#sessionObservationFailures.delete(current.id); + this.#sessionResubscriptionConnections.delete(current.id); + this.#sessionsAwaitingResubscription.delete(current.id); + await this.#cloud.supersedeCompactProjectionRecoveryForProviderDeletion(current.id); + await this.#daemonAuthority.assertCurrent(); + } + + /* + * Autorespond: answer a freshly admitted approval on behalf of the human + * when the session's approval mode allows it. Runs in the background behind + * the interaction's own serialization key; the ordinary resolve path enforces + * revision, deadline, and provider-offered decisions, and every attempt + * leaves an evidence row whether it accepted or escalated. + */ + #scheduleAutorespond(record: InteractionRecord): void { + if (record.sessionId === null) return; + if ( + record.kind !== "command_approval" + && record.kind !== "file_change_approval" + && record.kind !== "permission_approval" + ) return; + const sessionId = record.sessionId; + this.#scheduledAutorespondInteractions.add(record.publicId); + const tracked = this.#autorespondAdmitted(record, sessionId).then( + () => undefined, + (error: unknown) => { + if (error instanceof StateSecurityScrubRequiredError) this.#requestStop(); + else this.recordBackgroundDiagnostic("autorespond_failed", error); + this.#escalatePendingAutorespondInteraction(record, "autorespond_failed"); + }, + ); + this.#background.add(tracked); + void tracked.then(() => { + this.#scheduledAutorespondInteractions.delete(record.publicId); + this.#background.delete(tracked); + }); + } + + async #autorespondAdmitted(record: InteractionRecord, sessionId: SessionRecord["id"]): Promise { + const startedAt = this.#now(); + const { mode } = this.#store.readSessionApprovalMode(sessionId); + const budgets = this.#store.readAutorespondBudgets(sessionId, startedAt); + const decision = decideAutorespond({ budgets, display: record.display, kind: record.kind, mode }); + const kind = record.kind as "command_approval" | "file_change_approval" | "permission_approval"; + if (decision.action === "escalate") { + this.#store.recordAutorespondEvidence({ + approvalClass: decision.approvalClass, + decision: decision.code, + interactionId: record.publicId, + kind, + latencyMs: this.#now() - startedAt, + mode, + outcome: "refused", + sessionId, + subagent: false, + }); + this.#escalatePendingAutorespondInteraction(record, `autorespond_${decision.code}`); + return; + } + const resolution = record.kind === "permission_approval" + ? { kind: "permission_grant" as const, permissions: permissionNamesOf(record.display), scope: null } + : { kind: "approval_decision" as const, decision: decision.decision }; + let outcome: "accepted" | "refused" = "accepted"; + try { + await this.#resolveInteraction( + { + kind: "interaction.resolve", + interaction: record.publicId, + expectedRevision: record.revision, + resolution, + }, + { signal: this.#backgroundAbort.signal }, + ); + this.#store.markInteractionResolvedBy(record.publicId, "autorespond"); + this.#store.bumpAutorespondCounter(sessionId); + } catch (error: unknown) { + outcome = "refused"; + if (!(error instanceof CommandFailure)) throw error; + } finally { + this.#store.recordAutorespondEvidence({ + approvalClass: decision.approvalClass, + decision: decision.decision, + interactionId: record.publicId, + kind, + latencyMs: this.#now() - startedAt, + mode, + outcome, + sessionId, + subagent: false, + }); + if (outcome === "refused") { + this.#escalatePendingAutorespondInteraction(record, "autorespond_resolution_refused"); + } + } + } + + #requireGatewayKeys(): GatewayKeyPort { + if (this.#gatewayKeys === undefined) { + throw new CommandFailure( + "UNAVAILABLE", + "Local secret custody for the autorespond gateway key is unavailable in this daemon.", + ); + } + return this.#gatewayKeys; + } + + async #gatewayConfigured(): Promise { + try { + return await this.#gatewayKeys?.isConfigured() ?? false; + } catch { + return false; + } + } + + /* + * Prose autorespond (W2). A completed turn that classified as + * `needs_approval` through the lexical approval cue — never through a pending + * provider interaction — may be answered on the human's behalf. Everything + * below is a refusal path except the last one, and every path leaves one + * evidence row. + */ + #scheduleProseAutorespond( + sessionId: SessionRecord["id"], + turnId: string, + classification: SessionStateClassification, + ): void { + if (this.#proseResponder === undefined) return; + if (classification.state !== "needs_approval") return; + // At most one autoresponse per turn, even if the state is re-emitted. + if (this.#proseAutorespondedTurns.get(sessionId) === turnId) return; + this.#proseAutorespondedTurns.set(sessionId, turnId); + const tracked = this.#autorespondProse(sessionId, classification).then( + () => undefined, + (error: unknown) => { + if (error instanceof StateSecurityScrubRequiredError) this.#requestStop(); + else this.recordBackgroundDiagnostic("prose_autorespond_failed", error); + }, + ); + this.#background.add(tracked); + void tracked.then(() => this.#background.delete(tracked)); + } + + async #autorespondProse( + sessionId: SessionRecord["id"], + classification: SessionStateClassification, + ): Promise { + const responder = this.#proseResponder; + if (responder === undefined) return; + const startedAt = this.#now(); + const { mode } = this.#store.readSessionApprovalMode(sessionId); + const rule = classification.matchedRule; + const finalText = this.#sessionStateTracker.finalAssistantText(sessionId); + const refuse = (code: ProseAutorespondGateFailure): void => { + this.#store.recordProseAutorespondEvidence({ + decision: "refuse", + latencyMs: this.#now() - startedAt, + mode, + model: null, + outcome: `gate_failed:${code}`, + rule, + sessionId, + }); + }; + + // The positive gate. Each clause must hold before a model is consulted. + if (rule !== "approval_cue") return refuse("not_an_approval_cue"); + if (this.#store.listInteractions({ sessionId, pendingOnly: true, limit: 1 }).length > 0) { + return refuse("pending_interaction"); + } + const prepared = prepareAssistantText(finalText); + // The classifier reads cues over the stripped text and, for the full + // human-action list, only over the tail. The gate is stricter on purpose: + // it scans the whole raw message, fenced code and blockquotes included, so + // a quoted login step or a destructive command inside a code block still + // hands the turn back to the human. + if ( + STRONG_HUMAN_ACTION_CUES.some((cue) => cue.test(finalText)) + || HUMAN_ACTION_CUES.some((cue) => cue.test(finalText)) + ) return refuse("human_action_cue"); + if (DENYLIST_CUES.some((cue) => cue.test(finalText))) return refuse("denylist_cue"); + if (finalText.length >= PROSE_AUTORESPOND_MAX_MESSAGE_CHARACTERS) { + return refuse("message_too_long"); + } + if (!await this.#gatewayConfigured()) return refuse("gateway_key_missing"); + const verbatimLiteral = classification.verbatimRequired + ? classification.verbatimLiteral + : undefined; + if (classification.verbatimRequired && verbatimLiteral === undefined) { + return refuse("verbatim_literal_missing"); + } + const budgets = this.#store.readAutorespondBudgets(sessionId, startedAt); + const decision = decideProseAutorespond({ budgets, mode }); + if (decision.action === "escalate") return refuse(decision.code); + + const durable = this.#store.readSessionState(sessionId); + let result: Awaited>; + try { + result = await responder.respond( + { + assistantTail: prepared.tail, + report: { + version: 1, + session: sessionId, + state: durable?.state ?? classification.state, + attention: durable?.attention ?? classification.attention, + reason: durable?.reason ?? classification.reason, + verbatimRequired: classification.verbatimRequired, + lastActivityAt: durable?.lastActivityAt ?? null, + revision: durable?.revision ?? 0, + }, + ...(verbatimLiteral === undefined ? {} : { verbatimLiteral }), + }, + this.#backgroundAbort.signal, + ); + } catch { + this.#store.recordProseAutorespondEvidence({ + decision: "refuse", + latencyMs: this.#now() - startedAt, + mode, + model: null, + outcome: "responder_failed", + rule, + sessionId, + }); + return; + } + + /* + * The responder is never trusted with free text. A verbatim ask must come + * back byte-exact from the assistant's own message; every other approval is + * answered with the one fixed sentence, whatever the model produced. + */ + let reply = PROSE_APPROVAL_REPLY; + if (verbatimLiteral !== undefined) { + if (!finalText.includes(result.reply)) { + this.#store.recordProseAutorespondEvidence({ + decision: "refuse", + latencyMs: this.#now() - startedAt, + mode, + model: result.model, + outcome: "verbatim_mismatch", + rule, + sessionId, + }); + this.#escalateSessionState(sessionId, "autorespond_verbatim_mismatch"); + return; + } + reply = result.reply; + } + + let outcome: "sent" | "responder_failed" = "sent"; + try { + const session = this.#store.requireSession(sessionId); + await this.#serializeSessionAuthority(session, async () => + this.#send(session.id, reply, undefined, this.#backgroundAbort.signal, undefined, "autorespond")); + this.#store.bumpAutorespondCounter(sessionId); + } catch (error: unknown) { + outcome = "responder_failed"; + if (!(error instanceof CommandFailure) && !(error instanceof SelectionError)) throw error; + } finally { + this.#store.recordProseAutorespondEvidence({ + decision: outcome === "sent" ? "send" : "refuse", + latencyMs: this.#now() - startedAt, + mode, + model: result.model, + outcome, + rule, + sessionId, + }); + } + } + + /* + * Emit one further `session_state` revision after an autorespond outcome + * that hands the turn back to the human. A later revision always wins, so + * the browser and the CLI converge on the escalation. + */ + #escalateSessionState( + sessionId: SessionRecord["id"], + reason: string, + state: "needs_answer" | "needs_approval" = "needs_answer", + ): void { + try { + const body = this.#sessionStateTracker.escalate(sessionId, { + attention: true, + reason, + state, + }); + const snapshot = this.#sessionStateTracker.snapshot(sessionId); + if (snapshot === null) return; + this.#store.upsertSessionState({ + sessionId, + state: snapshot.state, + attention: snapshot.attention, + reason: snapshot.reason, + verbatimRequired: snapshot.verbatimRequired, + verbatimLiteral: snapshot.verbatimLiteral, + lastActivityAt: snapshot.lastActivityAt, + revision: snapshot.revision, + }); + const session = this.#store.requireSession(sessionId); + const profile = this.#store.requireProfile(session.profileId); + this.#appendSessionEvent( + authorityFor(this.#paths, profile), + sessionId, + this.#sessionProviderConnections.get(sessionId) ?? null, + body, + ); + } catch (error: unknown) { + this.recordBackgroundDiagnostic("session_state_tracking_failed", error); + } + } + + /* + * A provider validation refusal can leave the same approval pending, while + * connection loss or indeterminate delivery terminalizes it first. Only the + * former is actionable. Re-read the exact revision so a stale background + * decision cannot manufacture attention for a resolved or expired prompt. + */ + #escalatePendingAutorespondInteraction(record: InteractionRecord, reason: string): void { + try { + const current = this.#store.requireInteraction(record.publicId); + if ( + current.sessionId === null + || current.sessionId !== record.sessionId + || current.revision !== record.revision + || current.state !== "pending" + || this.#now() >= current.deadlineAt + ) return; + this.#escalateSessionState(current.sessionId, reason, "needs_approval"); + } catch (error: unknown) { + if (error instanceof SelectionError && error.code === "NOT_FOUND") return; + this.recordBackgroundDiagnostic("session_state_tracking_failed", error); + } + } + + #appendSessionEvent( + authority: ProfileAuthority, + sessionId: SessionRecord["id"], + connectionId: string | null | undefined, + body: SessionEventBody, + ): void { + const parsedConnection = connectionId === null || connectionId === undefined + ? null + : z.string().uuid().parse(connectionId); + this.#persistSessionEventWrites(this.#eventRedactor.accept({ + sessionId, + accountId: authority.id, + providerGeneration: authority.generation, + providerConnectionId: parsedConnection, + body, + })); + } + + #persistSessionEventWrites(writes: readonly SessionEventWrite[]): void { + for (const write of writes) { + this.#store.appendPublicSessionEvent(write); + this.#eventWaiters.notify(write.sessionId); + this.#trackSessionState(write); + } + } + + /* + * Session-state attention is derived from the complete actionable pending + * set, not merely from the newest interaction's kind. Exact in-flight + * scheduler ownership is authoritative for older requests; settings and + * bounded audit history cannot retroactively claim them. The newly persisted + * request is classified synchronously because scheduling happens immediately + * after its event is tracked. If the bounded page overflows, fail closed and + * keep attention visible. + */ + #pendingSessionStateContext( + sessionId: SessionRecord["id"], + newlyRequestedInteractionId?: string, + ): SessionStateContext { + const page = this.#store.listInteractionPage({ + sessionId, + pendingOnly: true, + limit: 200, + }); + let representative = page.interactions[0]; + if (representative === undefined) return {}; + let autorespondWillAct = page.nextPosition === null; + for (const interaction of page.interactions) { + let willAct = interaction.state !== "pending" + || this.#scheduledAutorespondInteractions.has(interaction.publicId); + if (!willAct && interaction.publicId === newlyRequestedInteractionId) { + const { mode } = this.#store.readSessionApprovalMode(sessionId); + const budgets = this.#store.readAutorespondBudgets(sessionId, this.#now()); + willAct = decideAutorespond({ + budgets, + display: interaction.display, + kind: interaction.kind, + mode, + }).action === "accept"; + } + if (!willAct) { + representative = interaction; + autorespondWillAct = false; + break; + } + } + return { + pendingInteraction: { kind: representative.kind }, + autorespondWillAct, + }; + } + + /* + * Classify the session after every persisted event. The tracker decides + * whether the state changed; a change is persisted as the session's durable + * latest state and appended as one `session_state` event. Failures here are + * background diagnostics, never a reason to drop the originating event. + */ + #trackSessionState(write: SessionEventWrite): void { + if (write.body.type === "session_state") return; + try { + if (this.#sessionStateTracker.snapshot(write.sessionId) === null) { + const durable = this.#store.readSessionState(write.sessionId); + if (durable !== null) { + this.#sessionStateTracker.seed(write.sessionId, { + state: durable.state, + attention: durable.attention, + reason: durable.reason, + verbatimRequired: durable.verbatimRequired, + verbatimLiteral: durable.verbatimLiteral ?? undefined, + lastActivityAt: durable.lastActivityAt, + revision: durable.revision, + }); + } + } + const pendingContext = write.body.type === "interaction_requested" + || write.body.type === "interaction_state" + || write.body.type === "turn_completed" + ? this.#pendingSessionStateContext( + write.sessionId, + write.body.type === "interaction_requested" + ? write.body.interactionId + : undefined, + ) + : {}; + const body = this.#sessionStateTracker.observe(write.sessionId, write.body, pendingContext); + if (body === null) return; + const snapshot = this.#sessionStateTracker.snapshot(write.sessionId); + if (snapshot === null) return; + this.#store.upsertSessionState({ + sessionId: write.sessionId, + state: snapshot.state, + attention: snapshot.attention, + reason: snapshot.reason, + verbatimRequired: snapshot.verbatimRequired, + verbatimLiteral: snapshot.verbatimLiteral, + lastActivityAt: snapshot.lastActivityAt, + revision: snapshot.revision, + }); + this.#store.appendPublicSessionEvent({ ...write, body }); + this.#eventWaiters.notify(write.sessionId); + // A prose approval is only ever answered for a turn that just ended and + // left no pending provider interaction behind. + if ( + body.state === "needs_approval" + && write.body.type === "turn_completed" + && pendingContext.pendingInteraction === undefined + ) { + const classification = this.#sessionStateTracker.classification(write.sessionId); + if (classification !== null) { + this.#scheduleProseAutorespond(write.sessionId, write.body.turnId, classification); + } + } + } catch (error: unknown) { + this.recordBackgroundDiagnostic("session_state_tracking_failed", error); + } + } + + #ensureSessionProviderConnection( + authority: ProfileAuthority, + session: SessionRecord, + connectionId: string | undefined, + ): void { + if (connectionId === undefined) return; + z.string().uuid().parse(connectionId); + const previous = this.#sessionProviderConnections.get(session.id); + if (previous === connectionId) { + try { + this.#mintSessionFactAuthority(authority, session, connectionId); + } catch (error: unknown) { + this.#clearSessionFactAuthority(session.id); + throw error; + } + return; + } + if (previous !== undefined) { + const position = this.#store.eventStreamPosition(session.id); + this.#appendSessionEvent(authority, session.id, previous, { + type: "gap", + reason: "provider_restart", + fromSequence: position.observedThroughSequence + 1, + throughSequence: position.observedThroughSequence + 1, + }); + } + this.#sessionProviderConnections.set(session.id, connectionId); + try { + this.#mintSessionFactAuthority(authority, session, connectionId); + } catch (error: unknown) { + this.#sessionProviderConnections.delete(session.id); + this.#clearSessionFactAuthority(session.id); + throw error; + } + const resubscribed = previous !== undefined + || this.#sessionsAwaitingResubscription.has(session.id) + || this.#lastSessionEventIsProviderGap(session.id); + this.#sessionsAwaitingResubscription.delete(session.id); + if (resubscribed) this.#sessionResubscriptionConnections.set(session.id, connectionId); + this.#appendSessionEvent(authority, session.id, connectionId, { + type: "connection", + state: resubscribed ? "resubscribed" : "connected", + }); + } + + #lastSessionEventIsProviderGap(sessionId: SessionRecord["id"]): boolean { + const position = this.#store.eventStreamPosition(sessionId); + if (position.observedThroughSequence === 0) return false; + const latest = this.#store.listSessionEvents({ + sessionId, + afterSequence: position.observedThroughSequence - 1, + limit: 1, + }).events[0]; + return latest?.body.type === "gap" + && (latest.body.reason === "provider_restart" || latest.body.reason === "provider_disconnect"); + } + + #handleProviderDisconnected( + authority: ProfileAuthority, + connectionId: string, + reason: "eof" | "process_exit" | "closed" | "protocol_fault", + provider: Provider, + source: ProviderFactSource, + ): readonly SessionRecord[] { + const disconnected: SessionRecord[] = []; + const terminal = this.#store.expireGenerationInteractions({ + profileId: authority.id, + processGeneration: authority.generation, + connectionId, + }); + for (const interaction of terminal) this.#appendInteractionState(interaction); + for (const [sessionId, activeConnectionId] of [...this.#sessionProviderConnections]) { + if (activeConnectionId !== connectionId) continue; + const session = this.#store.requireSession(sessionId); + if ( + session.profileId !== authority.id + || !this.#sessionUsesFactSource(session, provider, source) + ) continue; + this.#appendSessionEvent(authority, session.id, connectionId, { + type: "connection", + state: "disconnected", + reason, + }); + const position = this.#store.eventStreamPosition(session.id); + this.#appendSessionEvent(authority, session.id, connectionId, { + type: "gap", + reason: reason === "protocol_fault" ? "protocol_incompatible" : "provider_disconnect", + fromSequence: position.observedThroughSequence + 1, + throughSequence: position.observedThroughSequence + 1, + }); + this.#sessionProviderConnections.delete(session.id); + this.#clearSessionFactAuthority(session.id); + this.#sessionObservationFailures.delete(session.id); + this.#sessionResubscriptionConnections.delete(session.id); + this.#sessionsAwaitingResubscription.add(session.id); + disconnected.push(session); + } + return disconnected; + } + + /** + * Provider-owned isolated runtimes share the profile generation fence with + * Codex, but not its credential state. A Codex authority advance may rebind + * them only while each provider's own durable/live authority is quiescent. + */ + #isolatedProviderAuthorityAdvanceBlocker( + profileId: ProfileRecord["id"], + ): Readonly<{ + blocker: NonNullable>; + provider: "claude" | "devin"; + }> | null { + for (const provider of ["claude", "devin"] as const) { + const blocker = this.#store.providerAuthorityAdvanceBlocker(profileId, provider); + if (blocker !== null) return { blocker, provider }; + } + return null; + } + + #rebindIsolatedProviderAuthorities( + profileId: ProfileRecord["id"], + expectedGeneration: number, + nextGeneration: number, + ): void { + const input = { profileId, expectedGeneration, nextGeneration }; + this.#claude.rebindProfileAuthority(input); + this.#devin.rebindProfileAuthority(input); + } + + #prepareAccountLoginProviderRetirements( + profileId: ProfileRecord["id"], + processGeneration: number, + ): readonly Readonly<{ + connectionId: string; + releasedEvents: readonly SessionEventWrite[]; + sessionId: SessionRecord["id"]; + }>[] { + const retirements: Array> = []; + for (const [sessionId, connectionId] of this.#sessionProviderConnections) { + const session = this.#store.requireSession(sessionId); + if (session.profileId !== profileId || session.provider !== "codex") continue; + retirements.push({ + connectionId, + releasedEvents: this.#eventRedactor.interruptSession({ + accountId: profileId, + providerConnectionId: connectionId, + providerGeneration: processGeneration, + sessionId, + }), + sessionId, + }); + } + return retirements; + } + + #applyAccountLoginProviderRetirements( + retirements: readonly Readonly<{ + connectionId: string; + sessionId: SessionRecord["id"]; + }>[], + retiredSessionIds: readonly SessionRecord["id"][], + ): void { + for (const retirement of retirements) { + const currentConnection = this.#sessionProviderConnections.get(retirement.sessionId); + if ( + currentConnection !== undefined + && currentConnection !== retirement.connectionId + ) { + throw new Error("ACCOUNT_LOGIN_RETIREMENT_CONNECTION_CHANGED"); + } + this.#sessionProviderConnections.delete(retirement.sessionId); + this.#clearSessionFactAuthority(retirement.sessionId); + this.#sessionObservationFailures.delete(retirement.sessionId); + this.#sessionResubscriptionConnections.delete(retirement.sessionId); + this.#sessionsAwaitingResubscription.add(retirement.sessionId); + } + for (const sessionId of retiredSessionIds) this.#eventWaiters.notify(sessionId); + } + + #eventBodyForCodexFact( + fact: Exclude + & Readonly<{ threadId: string }>, + session: SessionRecord, + ): SessionEventBody | null { + switch (fact.type) { + case "turnStarted": return { type: "turn_started", turnId: fact.turn.id }; + case "turnCompleted": return { + type: "turn_completed", + turnId: fact.turn.id, + status: fact.turn.status === "inProgress" ? "failed" : fact.turn.status, + }; + case "threadStatusChanged": return { + type: "session_status", + status: fact.status.type === "notLoaded" + ? "not_loaded" + : fact.status.type === "systemError" + ? "system_error" + : fact.status.type, + activeTurnId: fact.status.type === "active" ? session.activeTurnId ?? null : null, + }; + case "threadDeleted": return null; + // A `subAgentActivity` marker item announces the same activity on both + // its started and its completed notification, so the projection is the + // same body twice at most. Every consumer folds by agent id, so the + // repeat is a no-op rather than a second subagent. + case "itemStarted": + case "itemCompleted": { + if (fact.subagent !== undefined) { + return { + type: "subagent_activity", + turnId: fact.turnId, + agentId: fact.subagent.agentThreadId, + kind: fact.subagent.kind, + }; + } + // A tool-shaped item carries the stable call identity a later result + // binds back to, plus a classified one-line summary. Both are built + // only from fields the protocol layer already reduced to safe labels. + const toolIdentity = isNeutralToolItemKind(fact.itemKind) + ? { + callId: fact.itemId, + summary: neutralToolSummary(fact), + } + : {}; + return fact.type === "itemStarted" + ? { + type: "item_started", + turnId: fact.turnId, + itemId: fact.itemId, + itemKind: fact.itemKind, + ...(fact.server === undefined ? {} : { server: fact.server }), + ...(fact.tool === undefined ? {} : { tool: fact.tool }), + ...(fact.liveAcceptanceCommandDigest === undefined + ? {} + : { liveAcceptanceCommandDigest: fact.liveAcceptanceCommandDigest }), + ...toolIdentity, + } + : { + type: "item_completed", + turnId: fact.turnId, + itemId: fact.itemId, + itemKind: fact.itemKind, + ...(fact.server === undefined ? {} : { server: fact.server }), + ...(fact.tool === undefined ? {} : { tool: fact.tool }), + ...(fact.liveAcceptanceCommandDigest === undefined + ? {} + : { liveAcceptanceCommandDigest: fact.liveAcceptanceCommandDigest }), + ...(fact.status === undefined ? {} : { status: fact.status }), + ...toolIdentity, + }; + } + // Only a spawned subagent thread reaches here, and only its bounded + // nickname, role, and depth. Without an active turn there is nothing to + // attach the activity to, so the metadata is dropped. + case "subagentThreadStarted": { + const turnId = session.activeTurnId ?? null; + if (turnId === null) return null; + return { + type: "subagent_activity", + turnId, + agentId: fact.agentThreadId, + kind: "started", + ...(fact.depth === undefined ? {} : { depth: fact.depth }), + ...(fact.nickname === undefined ? {} : { nickname: fact.nickname }), + ...(fact.role === undefined ? {} : { role: fact.role }), + }; + } + case "assistantDelta": return { + type: "assistant_delta", + turnId: fact.turnId, + itemId: fact.itemId, + text: fact.text, + }; + case "reasoningSummaryDelta": return { + type: "reasoning_summary_delta", + turnId: fact.turnId, + itemId: fact.itemId, + summaryPart: fact.summaryIndex, + text: fact.text, + }; + case "toolProgress": return { + type: "tool_progress", + turnId: fact.turnId, + itemId: fact.itemId, + toolKind: fact.toolKind, + ...(fact.status === undefined ? {} : { status: fact.status }), + ...(fact.outputBytesObserved === undefined + ? {} + : { outputBytesObserved: fact.outputBytesObserved }), + ...(fact.server === undefined ? {} : { server: fact.server }), + ...(fact.tool === undefined ? {} : { tool: fact.tool }), + }; + case "planUpdated": return { + type: "plan_updated", + turnId: fact.turnId, + steps: [...fact.steps], + ...(fact.explanation === undefined ? {} : { explanation: fact.explanation }), + }; + case "diffUpdated": return { + type: "diff_updated", + turnId: fact.turnId, + changedFiles: fact.changedFiles, + patchBytesObserved: fact.patchBytesObserved, + }; + case "tokenUsageUpdated": return { + type: "token_usage", + turnId: fact.turnId, + inputTokens: fact.inputTokens, + cachedInputTokens: fact.cachedInputTokens, + outputTokens: fact.outputTokens, + reasoningOutputTokens: fact.reasoningOutputTokens, + totalTokens: fact.totalTokens, + modelContextWindow: fact.modelContextWindow, + ...(fact.providerCost === undefined ? {} : { providerCost: fact.providerCost }), + }; + case "providerWarning": return { + type: "warning", + code: fact.code, + message: fact.message, + }; + case "providerError": return { + type: "error", + code: fact.code, + message: fact.message, + terminal: fact.terminal, + }; + case "accountUpdated": + case "rateLimitsUpdated": + case "loginCompleted": + case "serverRequestResolved": + case "notificationIgnored": + case "threadNameUpdated": + return null; + } + } + + #applyCodexFact( + authority: ProfileAuthority, + fact: CodexFact & Readonly<{ threadId: string }>, + expected: SessionRecord, + ): boolean { + let profile: ProfileRecord; + try { + profile = this.#store.requireProfileById(authority.id); + } catch { + return false; + } + if (profile.processGeneration !== authority.generation) return false; + const current = this.#store.findSessionByProviderThread(authority.id, fact.threadId); + if ( + current === null + || current.id !== expected.id + || !this.#profileAllowsEstablishedSession(profile, current) + || current.state === "terminal" + || (current.state === "recovery_required" && fact.type !== "threadDeleted") + || this.#projectionRecoveriesInFlight.has(current.id) + ) return false; + this.#bumpSessionFactEpoch(current.id); + if (fact.type === "threadDeleted") return false; + if (fact.type === "turnStarted") { + this.#store.reconcileSessionFromProvider({ sessionId: current.id, state: "active", activeTurnId: fact.turn.id }); + return false; + } + if (fact.type === "turnCompleted") { + for (const interaction of this.#store.expireTurnInteractions({ + sessionId: current.id, + profileId: authority.id, + processGeneration: authority.generation, + turnId: fact.turn.id, + })) this.#appendInteractionState(interaction); + this.#store.reconcileSessionFromProvider({ sessionId: current.id, state: "idle", activeTurnId: null }); + return true; + } + if (fact.type === "threadStatusChanged") { + if (fact.status.type === "systemError") { + this.#quarantineSession(current.id); + return false; + } + const state = fact.status.type === "active" ? "active" : "idle"; + this.#store.reconcileSessionFromProvider({ sessionId: current.id, state, ...(state === "active" ? {} : { activeTurnId: null }) }); + return false; + } + if (fact.type === "threadNameUpdated" && fact.name !== null) { + this.#store.reconcileSessionFromProvider({ sessionId: current.id, title: fact.name }); + } + return false; + } + + async #closeAdmittedService(): Promise { + let runtimeError: unknown; + const failedRuntimeScopes = new Set(); + try { + if (this.#interactionDeadlineTask !== undefined) { + await this.#interactionDeadlineTask.catch(() => undefined); + } + if (this.#sessionTaskPumpTask !== undefined) { + await this.#sessionTaskPumpTask.catch(() => undefined); + } + const runtimes = new Map< + SessionRuntimePort, + Array> + >(); + const registerRuntime = ( + runtime: SessionRuntimePort, + provider: Provider, + runtimeScope: RuntimeAccountScope, + ): void => { + const authorities = runtimes.get(runtime) ?? []; + authorities.push({ provider, runtimeScope }); + runtimes.set(runtime, authorities); + }; + registerRuntime(this.#codex, "codex", "managed"); + registerRuntime(this.#claude, "claude", "managed"); + registerRuntime(this.#devin, "devin", "managed"); + if (this.#personalCodex !== undefined) { + registerRuntime(this.#personalCodex, "codex", "personal"); + } + if (this.#personalClaude !== undefined) { + registerRuntime(this.#personalClaude, "claude", "personal"); + } + const runtimeEntries = [...runtimes.entries()]; + const closed = await Promise.allSettled( + runtimeEntries.map(async ([runtime]) => await runtime.close()), + ); + for (const [index, outcome] of closed.entries()) { + if (outcome.status === "rejected") { + runtimeError ??= outcome.reason; + const entry = runtimeEntries[index]; + if (entry !== undefined) { + for (const authority of entry[1]) { + failedRuntimeScopes.add( + `${authority.provider}:${authority.runtimeScope}`, + ); + } + } + } + } + } catch (error: unknown) { + runtimeError = error; + } + await this.#drainOwnedWork(); + this.#persistSessionEventWrites(this.#eventRedactor.interruptAll()); + if (runtimeError !== undefined) { + const quarantineErrors: unknown[] = []; + for (const profile of this.#store.listProfiles()) { + for (const provider of ["codex", "claude", "devin"] as const) { + for (const session of this.#store.listNonterminalProviderSessions( + profile.id, + provider, + )) { + const recorded = provider === "devin" + ? null + : this.#store.readSessionProviderAccountAuthority(session.id); + const runtimeScope: RuntimeAccountScope = provider === "devin" + ? "managed" + : recorded !== null && recorded.provider === session.provider + ? recorded.runtimeScope + : this.#sessionHasMatchingActivePersonalBinding(session) + ? "personal" + : "managed"; + if (!failedRuntimeScopes.has(`${provider}:${runtimeScope}`)) continue; + try { + this.#quarantineSession(session.id); + } catch (error: unknown) { + quarantineErrors.push(error); + } + } + } + } + this.#sessionProviderConnections.clear(); + this.#sessionObservationFailures.clear(); + this.#sessionResubscriptionConnections.clear(); + this.#sessionsAwaitingResubscription.clear(); + this.#state = "closed"; + if (quarantineErrors.length > 0) { + throw new AggregateError( + [runtimeError, ...quarantineErrors], + "A provider runtime failed to close and its matching durable session quarantine was incomplete.", + ); + } + throw runtimeError instanceof Error + ? runtimeError + : new Error("A provider runtime closed with a non-Error failure."); + } + let retirementError: unknown; + try { + this.#settleClosedClaudeProcessAuthorities(); + this.#retireClosedRuntimeAuthorities(); + } catch (error: unknown) { + retirementError = error; + } + this.#state = "closed"; + if (retirementError !== undefined) { + throw retirementError instanceof Error + ? retirementError + : new Error("Provider runtime authority retirement failed with a non-Error failure."); + } + } + + #settleClosedClaudeProcessAuthorities(): void { + for (;;) { + const live = this.#store.listUnreleasedClaudeProcessAuthorities(); + if (live.length === 0) break; + for (const process of live) { + const releasing = process.state === "releasing" + ? process + : this.#store.beginClaudeProcessAuthorityRelease({ + providerThreadId: process.providerThreadId, + profileId: process.profileId, + runtimeScope: process.runtimeScope, + expectedRevision: process.revision, + identity: process.identity, + }); + this.#store.completeClaudeProcessAuthorityRelease({ + providerThreadId: releasing.providerThreadId, + profileId: releasing.profileId, + runtimeScope: releasing.runtimeScope, + expectedRevision: releasing.revision, + identity: releasing.identity, + }); + } + } + for (;;) { + const intents = this.#store.listClaudeProcessLaunchIntents(); + if (intents.length === 0) return; + for (const intent of intents) this.#cancelClaudeProcessLaunchIntent(intent); + } + } + + #retireClosedRuntimeAuthorities(): void { + const projectionErrors: unknown[] = []; + for (const profile of this.#store.listProfiles()) { + if ( + profile.processGeneration === 0 + && !this.#store.hasNonterminalProviderSession(profile.id, "codex") + && !this.#store.hasNonterminalProviderSession(profile.id, "claude") + && !this.#store.hasNonterminalProviderSession(profile.id, "devin") + && !this.#store.profileHasControllingPersonalSessions(profile.id) + && !this.#store.hasUnsettledSessionMutationAuthority(profile.id) + ) continue; + let terminal: readonly InteractionRecord[]; + try { + terminal = this.#store.expireGenerationInteractions({ + profileId: profile.id, + processGeneration: profile.processGeneration, + }); + } catch (error: unknown) { + projectionErrors.push(error); + continue; + } + for (const interaction of terminal) { + try { + this.#appendInteractionState(interaction); + } catch (error: unknown) { + projectionErrors.push(error); + } + } + const authority = authorityFor(this.#paths, profile); + for (const [sessionId, connectionId] of [...this.#sessionProviderConnections]) { + let session: SessionRecord; + try { + session = this.#store.requireSession(sessionId); + } catch (error: unknown) { + projectionErrors.push(error); + this.#sessionProviderConnections.delete(sessionId); + this.#clearSessionFactAuthority(sessionId); + this.#sessionObservationFailures.delete(sessionId); + this.#sessionResubscriptionConnections.delete(sessionId); + this.#sessionsAwaitingResubscription.delete(sessionId); + continue; + } + if (session.profileId !== profile.id) continue; + try { + this.#appendSessionEvent(authority, session.id, connectionId, { + type: "connection", + state: "disconnected", + reason: "closed", + }); + const position = this.#store.eventStreamPosition(session.id); + this.#appendSessionEvent(authority, session.id, connectionId, { + type: "gap", + reason: "provider_disconnect", + fromSequence: position.observedThroughSequence + 1, + throughSequence: position.observedThroughSequence + 1, + }); + } catch (error: unknown) { + projectionErrors.push(error); + } finally { + this.#sessionProviderConnections.delete(sessionId); + this.#clearSessionFactAuthority(sessionId); + this.#sessionObservationFailures.delete(sessionId); + this.#sessionResubscriptionConnections.delete(sessionId); + this.#sessionsAwaitingResubscription.delete(sessionId); + } + } + try { + const unsettledForegroundLogin = this.#store + .listUnsettledMutations({ authorityId: profile.id }) + .some((attempt) => + attempt.kind === "account.claude-login" + || attempt.kind === "account.devin-login"); + // Foreground provider children are owned by the invoking CLI rather than + // this runtime manager. Preserve its exact completion generation even + // though the daemon's managed session runtimes have already closed. + if (unsettledForegroundLogin) continue; + const retirement = this.#store.advanceProfileGenerationForDaemonShutdown( + profile.id, + profile.processGeneration, + this.#work, + { preserveSessionMutationAuthorities: true }, + ); + this.#notifyAffectedWork(retirement.affectedWorkIds); + } catch (error: unknown) { + projectionErrors.push(error); + } + } + this.#sessionProviderConnections.clear(); + this.#sessionFactAuthorities.clear(); + this.#sessionObservationFailures.clear(); + this.#sessionResubscriptionConnections.clear(); + this.#sessionsAwaitingResubscription.clear(); + if (projectionErrors.length > 0) { + throw new AggregateError( + projectionErrors, + "The closed Codex runtime could not retire every durable provider authority.", + ); + } + } + + async #drainOwnedWork(): Promise { + for (;;) { + const owned = [ + ...this.#operations, + ...this.#mutationTails.values(), + ...this.#background, + ]; + if (owned.length === 0) return; + await Promise.allSettled(owned); + await Promise.resolve(); + } + } + + #beginOperation(): () => void { + if (this.#state !== "open") { + throw new CommandFailure("UNAVAILABLE", "The daemon service is closing and no longer accepts operations."); + } + return this.#trackOperation(); + } + + #beginFactOperation(): (() => void) | null { + if (this.#state !== "open") return null; + return this.#trackOperation(); + } + + #trackOperation(): () => void { + let settle!: () => void; + const pending = new Promise((resolve) => { settle = resolve; }); + this.#operations.add(pending); + let finished = false; + return () => { + if (finished) return; + finished = true; + this.#operations.delete(pending); + settle(); + }; + } + + async #fencedEffect(operation: () => Promise): Promise { + await this.#daemonAuthority.assertCurrent(); + const result = await operation(); + await this.#daemonAuthority.assertCurrent(); + return result; + } + + async #fencedRuntimeReview( + runtime: SessionRuntimePort, + operation: () => Promise>, + ): Promise> { + await this.#daemonAuthority.assertCurrent(); + const review = await operation(); + try { + await this.#daemonAuthority.assertCurrent(); + return review; + } catch (error: unknown) { + runtime.discardRuntimeReview(review); + throw error; + } + } + + async #doctor(offline: boolean, signal: AbortSignal): Promise { + const problems: string[] = []; + const bunReady = Bun.version === "1.3.14"; + if (!bunReady) problems.push(`HRA requires Bun 1.3.14, but ${Bun.version} is running.`); + let codex: { status: "ready"; version: string } | { status: "invalid"; diagnostic: string }; + try { + const runtime = await resolvePinnedCodexRuntime(); + codex = { status: "ready", version: runtime.packageVersion }; + } catch { + const diagnostic = "The pinned Codex runtime check failed without exposing its runtime diagnostic."; + codex = { status: "invalid", diagnostic }; + problems.push(diagnostic); + } + let cloud: unknown = { configured: false, skipped: offline }; + if (!offline) { + try { + cloud = await this.#fencedEffect(async () => await this.#cloud.status(signal)); + problems.push(...cloudDoctorProblems(cloud)); + } catch (error: unknown) { + if (error instanceof DaemonAuthoritySafetyError) throw error; + const diagnostic = "Cloud status failed without exposing its runtime diagnostic."; + cloud = { configured: true, status: "unavailable", diagnostic }; + problems.push(diagnostic); + } + } + const projects = this.#store.listProjects(); + const projectReady = projects.length > 0; + if (!projectReady) { + problems.push("No project directory is configured. Stop the daemon with `hra daemon stop`, then run `hra init --yes`."); + } + if (projectReady) { + const usable = await Promise.all(projects.map(async (project) => + await resolveUsableCanonicalProjectDirectory(project.rootPath))); + if (usable.some((projectRoot) => projectRoot === null)) { + problems.push("A configured project directory is missing or unsafe. Run `hra project list`, then restore or repair every listed directory so it is readable, writable, traversable, and canonical."); + } + } + let desktopRecovery: unknown = { status: "unavailable" }; + if (this.#desktop !== undefined) { + try { + desktopRecovery = await this.#fencedEffect(async () => await this.#desktop?.currentRecovery()); + if ( + desktopRecovery !== null && + typeof desktopRecovery === "object" && + "status" in desktopRecovery && + desktopRecovery.status === "recovery_required" + ) { + problems.push("A desktop switch is unresolved. Run `hra account switch-recover`."); + } + } catch (error: unknown) { + if (error instanceof DaemonAuthoritySafetyError) throw error; + const diagnostic = "Desktop switch recovery failed without exposing its runtime diagnostic."; + desktopRecovery = { status: "invalid", diagnostic }; + problems.push(diagnostic); + } + } + return { + healthy: problems.length === 0, + offline, + runtime: { bun: Bun.version, requiredBun: "1.3.14", bunReady, codex, platform: process.platform, architecture: process.arch }, + state: { database: "ready", profiles: this.#store.listProfiles().length, projects: projects.length, unsettledMutations: this.#store.listUnsettledMutations().length }, + cloud, + desktop: { supportedPlatform: process.platform === "darwin", configured: this.#desktop !== undefined, recovery: desktopRecovery }, + problems, + }; + } + + async #addAccount(label: string): Promise { + let profile: ProfileRecord; + try { + profile = this.#store.createProfile(label); + } catch (error: unknown) { + const normalizedLabel = canonicalLabelKey(label); + const duplicate = this.#store.listProfiles().some((candidate) => + canonicalLabelKey(candidate.label) === normalizedLabel); + if (duplicate && isSqliteUniqueConstraint(error)) { + throw new CommandFailure("CONFLICT", "An active account already uses that label."); + } + throw error; + } + try { + await initializeProfilePaths(this.#paths, profile.id); + await this.#daemonAuthority.assertCurrent(); + } catch (error: unknown) { + await this.#daemonAuthority.assertCurrent(); + this.#store.removeProfile(profile.id); + throw error; + } + return { account: this.#publicProfile(profile), next: `hra account login ${profile.id}` }; + } + + async #addProject(label: string, path: string): Promise { + try { + return await this.#store.createProject( + label, + path, + this.#store.listProjects().length === 0, + ); + } catch (error: unknown) { + const normalizedLabel = canonicalLabelKey(label); + const requestedRoot = resolve(path); + const projects = this.#store.listProjects(); + const duplicateLabel = projects.some((candidate) => + canonicalLabelKey(candidate.label) === normalizedLabel); + const duplicateRoot = projects.some((candidate) => + candidate.rootPath === requestedRoot); + if (isSqliteUniqueConstraint(error) && duplicateLabel) { + throw new CommandFailure("CONFLICT", "A project already uses that label."); + } + if (isSqliteUniqueConstraint(error) && duplicateRoot) { + throw new CommandFailure("CONFLICT", "A project already uses that directory."); + } + if (error instanceof UnusableProjectRootError) { + throw new CommandFailure( + "UNAVAILABLE", + "The project directory is missing, unsafe, or not readable, writable, traversable, and canonical. Repair it or choose another directory before retrying.", + { + nextCommand: "hra doctor", + repair: "repair_or_select_project", + }, + ); + } + throw error; + } + } + + async #requireUsableProjectRoot(projectRoot: string): Promise { + const canonical = await resolveUsableCanonicalProjectDirectory(projectRoot); + if (canonical === null) { + throw new CommandFailure( + "UNAVAILABLE", + "The selected project directory is missing, unsafe, or not readable, writable, and traversable. Repair it or select another project before retrying.", + { + nextCommand: "hra doctor", + repair: "repair_or_select_project", + }, + ); + } + // Filesystem validation awaits several operations. Recheck daemon authority + // after that await boundary so the following provider call cannot escape a + // concurrent service shutdown on a formerly valid root. + await this.#daemonAuthority.assertCurrent(); + return canonical; + } + + async #readPluginCatalog( + profileId: ProfileRecord["id"], + projectSelector: string | undefined, + refresh: boolean, + signal: AbortSignal, + ): Promise> { + const profile = this.#store.requireProfile(profileId); + this.#assertSignedIn(profile); + const project = projectSelector === undefined + ? undefined + : this.#store.requireProject(projectSelector); + const catalog = await this.#fencedEffect(async () => { + const projectRoot = project === undefined + ? undefined + : await this.#requireUsableProjectRoot(project.rootPath); + return await this.#codex.listPlugins({ + authority: authorityFor(this.#paths, profile), + ...(projectRoot === undefined ? {} : { projectRoot }), + forceRefetch: refresh, + signal, + }); + }); + return { catalog, profile: this.#store.requireProfile(profile.id) }; + } + + async #listPlugins( + profileId: ProfileRecord["id"], + projectSelector: string | undefined, + refresh: boolean, + signal: AbortSignal, + ): Promise { + const { catalog, profile } = await this.#readPluginCatalog( + profileId, + projectSelector, + refresh, + signal, + ); + return { account: this.#publicProfile(profile), catalog }; + } + + async #showPlugin( + profileId: ProfileRecord["id"], + selector: string, + projectSelector: string | undefined, + refresh: boolean, + signal: AbortSignal, + ): Promise { + const { catalog, profile } = await this.#readPluginCatalog( + profileId, + projectSelector, + refresh, + signal, + ); + const entries: Array> = []; + for (const marketplace of catalog.marketplaces) { + for (const plugin of marketplace.plugins) entries.push({ marketplace, plugin }); + } + const exact = entries.filter((entry) => entry.plugin.id === selector); + const normalized = selector.toLocaleLowerCase("en-US"); + const labels = exact.length > 0 + ? exact + : entries.filter((entry) => + entry.plugin.name.toLocaleLowerCase("en-US") === normalized + || entry.plugin.displayName?.toLocaleLowerCase("en-US") === normalized); + if (labels.length !== 1) { + throw new SelectionError( + labels.length === 0 ? "NOT_FOUND" : "AMBIGUOUS", + labels.map(({ plugin }) => ({ + id: plugin.id, + label: plugin.displayName ?? plugin.name, + })), + ); + } + const selected = labels[0]; + if (selected === undefined) throw new SelectionError("NOT_FOUND"); + return { + account: this.#publicProfile(profile), + marketplace: { + name: selected.marketplace.name, + displayName: selected.marketplace.displayName, + }, + plugin: selected.plugin, + lifecycle: catalog.lifecycle, + }; + } - async #drainOwnedWork(): Promise { - for (;;) { - const owned = [ - ...this.#operations, - ...this.#mutationTails.values(), - ...this.#background, - ]; - if (owned.length === 0) return; - await Promise.allSettled(owned); - await Promise.resolve(); - } + #claudeLoginRecovery(attempt: MutationAttemptRecord): Readonly> { + const accountId = profileIdSchema.parse(attempt.authorityId); + return { + required: true, + attemptId: attempt.id, + idempotencyKey: attempt.idempotencyKey, + providerGeneration: attempt.authorityGeneration, + statusCommand: `hra account show ${accountId} --provider claude`, + sameKeyReplayCommand: `hra account login ${accountId} --provider claude --idempotency-key ${attempt.idempotencyKey}`, + abandonCommand: `hra account login-cancel ${accountId} --provider claude --attempt-id ${attempt.id} --provider-generation ${String(attempt.authorityGeneration)} --idempotency-key ${attempt.idempotencyKey} --acknowledge-child-exited`, + diagnostic: "The foreground Claude login launch was granted once. Its exact completion can settle after a daemon restart. Status may report credential presence but never proves that the child exited or grants another launch. If the original HRA parent is gone, first confirm its Claude child exited, then run the exact acknowledged local abandon command; abandon does not stop Claude or change or delete credentials.", + }; } - #beginOperation(): () => void { - if (this.#state !== "open") { - throw new CommandFailure("UNAVAILABLE", "The daemon service is closing and no longer accepts operations."); - } - return this.#trackOperation(); + #devinLoginRecovery(attempt: MutationAttemptRecord): Readonly> { + const accountId = profileIdSchema.parse(attempt.authorityId); + return { + required: true, + attemptId: attempt.id, + idempotencyKey: attempt.idempotencyKey, + providerGeneration: attempt.authorityGeneration, + statusCommand: `hra account show ${accountId} --provider devin`, + sameKeyReplayCommand: `hra account login ${accountId} --provider devin --idempotency-key ${attempt.idempotencyKey}`, + abandonCommand: `hra account login-cancel ${accountId} --provider devin --attempt-id ${attempt.id} --provider-generation ${String(attempt.authorityGeneration)} --idempotency-key ${attempt.idempotencyKey} --acknowledge-child-exited`, + diagnostic: "The foreground Devin login launch was granted once. Its exact completion can settle after a daemon restart. Status may report credential presence but never proves that the child exited or grants another launch. If the original HRA parent is gone, first confirm its Devin child exited, then run the exact acknowledged local abandon command; abandon does not stop Devin or change or delete credentials.", + }; } - #beginFactOperation(): (() => void) | null { - if (this.#state !== "open") return null; - return this.#trackOperation(); + #publicIsolatedProviderAccount(profile: ProfileRecord): Readonly<{ id: ProfileRecord["id"]; label: string }> { + return { id: profile.id, label: profile.label }; } - #trackOperation(): () => void { - let settle!: () => void; - const pending = new Promise((resolve) => { settle = resolve; }); - this.#operations.add(pending); - let finished = false; - return () => { - if (finished) return; - finished = true; - this.#operations.delete(pending); - settle(); + #assertClaudeIsolationAccepted(): void { + if (this.#platform === "linux") return; + throw new CommandFailure( + "UNAVAILABLE", + `Claude account isolation is acceptance-pending on ${this.#platform}. New Claude authentication, status, and session effects are currently supported only on Linux; run this operation against an HRA daemon on Linux.`, + { + platform: this.#platform, + provider: "claude", + reason: "claude_isolation_acceptance_pending", + retryable: false, + supportedPlatforms: ["linux"], + }, + ); + } + + #claudePlatformUnavailableObservation( + profile: ProfileRecord, + ): PublicProviderObservation { + return { + basis: "local_state", + code: "provider_platform_unavailable", + coverage: "unavailable", + freshness: "fresh", + observedAt: this.#now(), + profileGeneration: profile.processGeneration, + source: "codex_app_server", + state: "unavailable", }; } - async #fencedEffect(operation: () => Promise): Promise { - await this.#daemonAuthority.assertCurrent(); - const result = await operation(); + async #readClaudeAccount(profile: ProfileRecord, signal: AbortSignal): Promise { await this.#daemonAuthority.assertCurrent(); - return result; + return await this.#fencedEffect(async () => await this.#claude.readAccount({ + authority: authorityFor(this.#paths, profile), + signal, + })); } - async #fencedRuntimeReview( - runtime: SessionRuntimePort, - operation: () => Promise>, - ): Promise> { + async #readDevinAccount(profile: ProfileRecord, signal: AbortSignal): Promise { await this.#daemonAuthority.assertCurrent(); - const review = await operation(); - try { - await this.#daemonAuthority.assertCurrent(); - return review; - } catch (error: unknown) { - runtime.discardRuntimeReview(review); - throw error; - } + return await this.#fencedEffect(async () => await this.#devin.readAccount({ + authority: authorityFor(this.#paths, profile), + signal, + })); } - async #doctor(offline: boolean, signal: AbortSignal): Promise { - const problems: string[] = []; - const bunReady = Bun.version === "1.3.14"; - if (!bunReady) problems.push(`HRA requires Bun 1.3.14, but ${Bun.version} is running.`); - let codex: { status: "ready"; version: string } | { status: "invalid"; diagnostic: string }; - try { - const runtime = await resolvePinnedCodexRuntime(); - codex = { status: "ready", version: runtime.packageVersion }; - } catch { - const diagnostic = "The pinned Codex runtime check failed without exposing its runtime diagnostic."; - codex = { status: "invalid", diagnostic }; - problems.push(diagnostic); - } - let cloud: unknown = { configured: false, skipped: offline }; - if (!offline) { - try { - cloud = await this.#fencedEffect(async () => await this.#cloud.status(signal)); - problems.push(...cloudDoctorProblems(cloud)); - } catch (error: unknown) { - if (error instanceof DaemonAuthoritySafetyError) throw error; - const diagnostic = "Cloud status failed without exposing its runtime diagnostic."; - cloud = { configured: true, status: "unavailable", diagnostic }; - problems.push(diagnostic); - } - } - const projects = this.#store.listProjects(); - const projectReady = projects.length > 0; - if (!projectReady) { - problems.push("No project directory is configured. Stop the daemon with `hra daemon stop`, then run `hra init --yes`."); - } - if (projectReady) { - const usable = await Promise.all(projects.map(async (project) => - await resolveUsableCanonicalProjectDirectory(project.rootPath))); - if (usable.some((projectRoot) => projectRoot === null)) { - problems.push("A configured project directory is missing or unsafe. Run `hra project list`, then restore or repair every listed directory so it is readable, writable, traversable, and canonical."); - } + #unsettledClaudeLogin(profile: ProfileRecord): MutationAttemptRecord | undefined { + return this.#store.listUnsettledMutations({ authorityId: profile.id }).find((attempt) => + attempt.kind === "account.claude-login"); + } + + #unsettledDevinLogin(profile: ProfileRecord): MutationAttemptRecord | undefined { + return this.#store.listUnsettledMutations({ authorityId: profile.id }).find((attempt) => + attempt.kind === "account.devin-login"); + } + + async #showClaudeAccount(selector: string, signal: AbortSignal): Promise { + const profile = this.#store.requireProfile(selector); + const unsettled = this.#unsettledClaudeLogin(profile); + if (unsettled !== undefined) { + // The durable child fence is authoritative even when the provider + // binary is missing, drifts from the pin, or cannot answer. Do not hide + // the only exact recovery command behind a best-effort status process. + return { + account: this.#publicIsolatedProviderAccount(profile), + authentication: { provider: "claude", signedIn: null }, + providerGeneration: profile.processGeneration, + recovery: this.#claudeLoginRecovery(unsettled), + }; } - let desktopRecovery: unknown = { status: "unavailable" }; - if (this.#desktop !== undefined) { - try { - desktopRecovery = await this.#fencedEffect(async () => await this.#desktop?.currentRecovery()); - if ( - desktopRecovery !== null && - typeof desktopRecovery === "object" && - "status" in desktopRecovery && - desktopRecovery.status === "recovery_required" - ) { - problems.push("A desktop switch is unresolved. Run `hra account switch-recover`."); - } - } catch (error: unknown) { - if (error instanceof DaemonAuthoritySafetyError) throw error; - const diagnostic = "Desktop switch recovery failed without exposing its runtime diagnostic."; - desktopRecovery = { status: "invalid", diagnostic }; - problems.push(diagnostic); - } + this.#assertClaudeIsolationAccepted(); + const account = await this.#readClaudeAccount(profile, signal); + return { + account: this.#publicIsolatedProviderAccount(profile), + authentication: { provider: "claude", signedIn: account.signedIn }, + providerGeneration: profile.processGeneration, + ...(account.signedIn + ? {} + : { nextCommand: `hra account login ${profile.id} --provider claude` }), + }; + } + + async #showDevinAccount(selector: string, signal: AbortSignal): Promise { + const profile = this.#store.requireProfile(selector); + const unsettled = this.#unsettledDevinLogin(profile); + if (unsettled !== undefined) { + return { + account: this.#publicIsolatedProviderAccount(profile), + authentication: { provider: "devin", signedIn: null }, + providerGeneration: profile.processGeneration, + recovery: this.#devinLoginRecovery(unsettled), + usage: { + allowance: "unknown", + reason: "Devin ACP reports context and optional cumulative session cost, but exposes no account allowance or reset window.", + source: "devin_acp", + }, + }; } + const account = await this.#readDevinAccount(profile, signal); return { - healthy: problems.length === 0, - offline, - runtime: { bun: Bun.version, requiredBun: "1.3.14", bunReady, codex, platform: process.platform, architecture: process.arch }, - state: { database: "ready", profiles: this.#store.listProfiles().length, projects: projects.length, unsettledMutations: this.#store.listUnsettledMutations().length }, - cloud, - desktop: { supportedPlatform: process.platform === "darwin", configured: this.#desktop !== undefined, recovery: desktopRecovery }, - problems, + account: this.#publicIsolatedProviderAccount(profile), + authentication: { provider: "devin", signedIn: account.signedIn }, + providerGeneration: profile.processGeneration, + usage: { + allowance: "unknown", + reason: "Devin ACP reports context and optional cumulative session cost, but exposes no account allowance or reset window.", + source: "devin_acp", + }, + ...(account.signedIn + ? {} + : { nextCommand: `hra account login ${profile.id} --provider devin` }), }; } - async #addAccount(label: string): Promise { - let profile: ProfileRecord; - try { - profile = this.#store.createProfile(label); - } catch (error: unknown) { - const normalizedLabel = canonicalLabelKey(label); - const duplicate = this.#store.listProfiles().some((candidate) => - canonicalLabelKey(candidate.label) === normalizedLabel); - if (duplicate && isSqliteUniqueConstraint(error)) { - throw new CommandFailure("CONFLICT", "An active account already uses that label."); + async #prepareDevinLogin( + selector: string, + idempotencyKey: string, + _manualTokenFlow: boolean, + signal: AbortSignal, + ): Promise { + const profile = this.#store.requireProfile(selector); + if (profile.state === "removed") throw new CommandFailure("NOT_FOUND", "That account is removed."); + const prior = this.#store.readMutation(idempotencyKey); + if (prior !== null) { + // The manual-token choice is foreground presentation, not daemon + // authority. A same-key replay can only recover the already-granted + // child fence and can never relaunch with a different choice. + this.#store.prepareMutation({ + kind: "account.devin-login", + authorityId: profile.id, + authorityGeneration: prior.authorityGeneration, + request: { provider: "devin" }, + idempotencyKey, + }); + if (prior.state === "effect_started" || prior.state === "ambiguous") { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "This Devin login launch was already granted and will not be granted again.", + this.#devinLoginRecovery(prior), + ); } - throw error; - } - try { - await initializeProfilePaths(this.#paths, profile.id); - await this.#daemonAuthority.assertCurrent(); - } catch (error: unknown) { - await this.#daemonAuthority.assertCurrent(); - this.#store.removeProfile(profile.id); - throw error; - } - return { account: this.#publicProfile(profile), next: `hra account login ${profile.id}` }; - } - - async #addProject(label: string, path: string): Promise { - try { - return await this.#store.createProject( - label, - path, - this.#store.listProjects().length === 0, - ); - } catch (error: unknown) { - const normalizedLabel = canonicalLabelKey(label); - const requestedRoot = resolve(path); - const projects = this.#store.listProjects(); - const duplicateLabel = projects.some((candidate) => - canonicalLabelKey(candidate.label) === normalizedLabel); - const duplicateRoot = projects.some((candidate) => - candidate.rootPath === requestedRoot); - if (isSqliteUniqueConstraint(error) && duplicateLabel) { - throw new CommandFailure("CONFLICT", "A project already uses that label."); + if (prior.state === "reconciled" && prior.resolution?.kind === "abandoned") { + throw new CommandFailure( + "CONFLICT", + "This Devin login fence was explicitly abandoned. Start a fresh login with a new idempotency key.", + ); } - if (isSqliteUniqueConstraint(error) && duplicateRoot) { - throw new CommandFailure("CONFLICT", "A project already uses that directory."); + if (prior.state === "applied" || prior.state === "reconciled") { + const receipt = devinLoginTerminalReceiptSchema.safeParse(prior.result); + if ( + !receipt.success + || receipt.data.accountId !== profile.id + || receipt.data.attemptId !== prior.id + || receipt.data.idempotencyKey !== prior.idempotencyKey + || receipt.data.providerGeneration !== prior.authorityGeneration + ) throw new CommandFailure("INTERNAL", "The Devin login terminal receipt is invalid."); + if (!receipt.data.signedIn) { + throw new CommandFailure( + "INTERACTION_REQUIRED", + "This Devin login attempt settled signed out. Start a fresh login with a new idempotency key.", + ); + } + return { + account: this.#publicIsolatedProviderAccount(profile), + authentication: { provider: "devin", signedIn: true }, + login: { status: "signed_in" }, + }; } - if (error instanceof UnusableProjectRootError) { + if (prior.state === "failed" || prior.state === "cancelled") { throw new CommandFailure( - "UNAVAILABLE", - "The project directory is missing, unsafe, or not readable, writable, traversable, and canonical. Repair it or choose another directory before retrying.", - { - nextCommand: "hra doctor", - repair: "repair_or_select_project", - }, + "INTERACTION_REQUIRED", + "This Devin login attempt is terminal without sign-in. Start a fresh login with a new idempotency key.", + ); + } + if (prior.authorityGeneration !== profile.processGeneration) { + if (!this.#store.transitionMutation(prior.id, "prepared", "cancelled", { + provider: "devin", + signedIn: false, + status: "stale_no_effect", + })) throw new CommandFailure("CONFLICT", "The Devin login preparation changed concurrently."); + throw new CommandFailure( + "CONFLICT", + "This no-effect Devin login preparation belongs to an older provider generation. Start a fresh login with a new idempotency key.", ); } - throw error; } - } - - async #requireUsableProjectRoot(projectRoot: string): Promise { - const canonical = await resolveUsableCanonicalProjectDirectory(projectRoot); - if (canonical === null) { + const unsettled = this.#unsettledDevinLogin(profile); + if (unsettled !== undefined) { throw new CommandFailure( - "UNAVAILABLE", - "The selected project directory is missing, unsafe, or not readable, writable, and traversable. Repair it or select another project before retrying.", - { - nextCommand: "hra doctor", - repair: "repair_or_select_project", - }, + "RECOVERY_REQUIRED", + "A Devin login already owns this account, including across provider generations.", + this.#devinLoginRecovery(unsettled), ); } - // Filesystem validation awaits several operations. Recheck daemon authority - // after that await boundary so the following provider call cannot escape a - // concurrent service shutdown on a formerly valid root. - await this.#daemonAuthority.assertCurrent(); - return canonical; - } - - async #readPluginCatalog( - profileId: ProfileRecord["id"], - projectSelector: string | undefined, - refresh: boolean, - signal: AbortSignal, - ): Promise> { - const profile = this.#store.requireProfile(profileId); - this.#assertSignedIn(profile); - const project = projectSelector === undefined - ? undefined - : this.#store.requireProject(projectSelector); - const catalog = await this.#fencedEffect(async () => { - const projectRoot = project === undefined - ? undefined - : await this.#requireUsableProjectRoot(project.rootPath); - return await this.#codex.listPlugins({ - authority: authorityFor(this.#paths, profile), - ...(projectRoot === undefined ? {} : { projectRoot }), - forceRefetch: refresh, - signal, - }); - }); - return { catalog, profile: this.#store.requireProfile(profile.id) }; - } - - async #listPlugins( - profileId: ProfileRecord["id"], - projectSelector: string | undefined, - refresh: boolean, - signal: AbortSignal, - ): Promise { - const { catalog, profile } = await this.#readPluginCatalog( - profileId, - projectSelector, - refresh, - signal, - ); - return { account: this.#publicProfile(profile), catalog }; - } - - async #showPlugin( - profileId: ProfileRecord["id"], - selector: string, - projectSelector: string | undefined, - refresh: boolean, - signal: AbortSignal, - ): Promise { - const { catalog, profile } = await this.#readPluginCatalog( - profileId, - projectSelector, - refresh, - signal, - ); - const entries: Array> = []; - for (const marketplace of catalog.marketplaces) { - for (const plugin of marketplace.plugins) entries.push({ marketplace, plugin }); + const providerBlocker = this.#store.providerAuthorityAdvanceBlocker(profile.id, "devin"); + if (providerBlocker !== null) { + throw new CommandFailure( + providerBlocker === "active_session" ? "CONFLICT" : "RECOVERY_REQUIRED", + `Devin login cannot replace the shared isolated home while Devin session authority is ${providerBlocker.replaceAll("_", " ")}. Inspect \`hra session list --account ${profile.id}\`, stop active turns, and resolve recovery before retrying.`, + { provider: "devin", reason: providerBlocker, retryable: true }, + ); } - const exact = entries.filter((entry) => entry.plugin.id === selector); - const normalized = selector.toLocaleLowerCase("en-US"); - const labels = exact.length > 0 - ? exact - : entries.filter((entry) => - entry.plugin.name.toLocaleLowerCase("en-US") === normalized - || entry.plugin.displayName?.toLocaleLowerCase("en-US") === normalized); - if (labels.length !== 1) { - throw new SelectionError( - labels.length === 0 ? "NOT_FOUND" : "AMBIGUOUS", - labels.map(({ plugin }) => ({ - id: plugin.id, - label: plugin.displayName ?? plugin.name, - })), + const releasableSessions = this.#store.listNonterminalProviderSessions(profile.id, "devin"); + if (releasableSessions.some((session) => + session.state !== "idle" + || session.activeTurnId !== undefined + || session.providerThreadId === undefined)) { + throw new CommandFailure( + "CONFLICT", + `Devin login can release only idle, fully bound Devin sessions. Inspect \`hra session list --account ${profile.id}\`, then finish or recover every other session before retrying.`, + { provider: "devin", reason: "session_not_idle", retryable: true }, ); } - const selected = labels[0]; - if (selected === undefined) throw new SelectionError("NOT_FOUND"); - return { - account: this.#publicProfile(profile), - marketplace: { - name: selected.marketplace.name, - displayName: selected.marketplace.displayName, - }, - plugin: selected.plugin, - lifecycle: catalog.lifecycle, - }; - } - - #claudeLoginRecovery(attempt: MutationAttemptRecord): Readonly> { - const accountId = profileIdSchema.parse(attempt.authorityId); - return { - required: true, - attemptId: attempt.id, - idempotencyKey: attempt.idempotencyKey, - providerGeneration: attempt.authorityGeneration, - statusCommand: `hra account show ${accountId} --provider claude`, - sameKeyReplayCommand: `hra account login ${accountId} --provider claude --idempotency-key ${attempt.idempotencyKey}`, - abandonCommand: `hra account login-cancel ${accountId} --provider claude --attempt-id ${attempt.id} --provider-generation ${String(attempt.authorityGeneration)} --idempotency-key ${attempt.idempotencyKey} --acknowledge-child-exited`, - diagnostic: "The foreground Claude login launch was granted once. Its exact completion can settle after a daemon restart. Status may report credential presence but never proves that the child exited or grants another launch. If the original HRA parent is gone, first confirm its Claude child exited, then run the exact acknowledged local abandon command; abandon does not stop Claude or change or delete credentials.", - }; - } - - #devinLoginRecovery(attempt: MutationAttemptRecord): Readonly> { - const accountId = profileIdSchema.parse(attempt.authorityId); + const observed = await this.#readDevinAccount(profile, signal); + if (observed.signedIn) { + if (prior?.state === "prepared") { + if (!this.#store.transitionMutation(prior.id, "prepared", "cancelled", { + provider: "devin", + signedIn: true, + status: "no_effect", + })) throw new CommandFailure("CONFLICT", "The Devin login preparation changed concurrently."); + } + return { + account: this.#publicIsolatedProviderAccount(profile), + authentication: { provider: "devin", signedIn: true }, + login: { status: "signed_in" }, + }; + } + if (releasableSessions.length > 0) { + await this.#assertNoCompactProjectionRecoveryForProfile(profile.id); + for (const candidate of releasableSessions) { + await this.#serialize(`session:${candidate.id}`, async () => { + const current = this.#store.requireSession(candidate.id); + const blocker = this.#store.providerAuthorityAdvanceBlocker(profile.id, "devin"); + if ( + blocker !== null + || current.profileId !== profile.id + || current.provider !== "devin" + || current.state !== "idle" + || current.activeTurnId !== undefined + || current.providerThreadId === undefined + || !this.#store.canReleaseIdleDevinSessionForAccountLogin({ + profileId: profile.id, + profileGeneration: profile.processGeneration, + sessionId: current.id, + }) + ) { + throw new CommandFailure( + blocker === "recovery_required" || blocker === "unsettled_authority" + ? "RECOVERY_REQUIRED" + : "CONFLICT", + "Devin session authority changed before the idle session could be released for login. Inspect the session and retry after it is quiescent.", + { provider: "devin", reason: blocker ?? "session_not_idle", retryable: true }, + ); + } + const providerConnectionId = this.#sessionProviderConnections.get(current.id) ?? null; + await this.#endProviderSession( + { ...current, providerThreadId: current.providerThreadId }, + profile, + signal, + "Devin account login", + ); + const terminal = this.#store.terminalizeIdleDevinSessionForAccountLogin({ + accountId: profile.id, + providerConnectionId, + providerGeneration: profile.processGeneration, + sessionId: current.id, + }); + if (terminal.event !== undefined) this.#eventWaiters.notify(current.id); + for (const interaction of terminal.interactions) this.#appendInteractionState(interaction); + await this.#cleanupTerminalFactsMemory(terminal.session); + await this.#cloud.supersedeCompactProjectionRecoveryForProviderDeletion(current.id); + await this.#daemonAuthority.assertCurrent(); + }); + } + } + let attempt: ReturnType; + try { + attempt = this.#store.prepareMutation({ + kind: "account.devin-login", + authorityId: profile.id, + authorityGeneration: profile.processGeneration, + request: { provider: "devin" }, + idempotencyKey, + }); + this.#store.beginDevinLoginMutationEffect({ + attemptId: attempt.id, + profileId: profile.id, + profileGeneration: profile.processGeneration, + evidence: { kind: "account.devin-login", provider: "devin", baselineSignedIn: false }, + }); + } catch (error: unknown) { + if (error instanceof Error && error.message === "UNSETTLED_MUTATION_AUTHORITY") { + const blocking = this.#unsettledDevinLogin(profile); + throw new CommandFailure( + "RECOVERY_REQUIRED", + "Another mutation already owns this account generation.", + blocking === undefined ? undefined : this.#devinLoginRecovery(blocking), + ); + } + throw error; + } return { - required: true, - attemptId: attempt.id, - idempotencyKey: attempt.idempotencyKey, - providerGeneration: attempt.authorityGeneration, - statusCommand: `hra account show ${accountId} --provider devin`, - sameKeyReplayCommand: `hra account login ${accountId} --provider devin --idempotency-key ${attempt.idempotencyKey}`, - abandonCommand: `hra account login-cancel ${accountId} --provider devin --attempt-id ${attempt.id} --provider-generation ${String(attempt.authorityGeneration)} --idempotency-key ${attempt.idempotencyKey} --acknowledge-child-exited`, - diagnostic: "The foreground Devin login launch was granted once. Its exact completion can settle after a daemon restart. Status may report credential presence but never proves that the child exited or grants another launch. If the original HRA parent is gone, first confirm its Devin child exited, then run the exact acknowledged local abandon command; abandon does not stop Devin or change or delete credentials.", - }; - } - - #publicIsolatedProviderAccount(profile: ProfileRecord): Readonly<{ id: ProfileRecord["id"]; label: string }> { - return { id: profile.id, label: profile.label }; - } - - #assertClaudeIsolationAccepted(): void { - if (this.#platform === "linux") return; - throw new CommandFailure( - "UNAVAILABLE", - `Claude account isolation is acceptance-pending on ${this.#platform}. New Claude authentication, status, and session effects are currently supported only on Linux; run this operation against an HRA daemon on Linux.`, - { - platform: this.#platform, - provider: "claude", - reason: "claude_isolation_acceptance_pending", - retryable: false, - supportedPlatforms: ["linux"], + account: this.#publicIsolatedProviderAccount(profile), + authentication: { provider: "devin", signedIn: false }, + login: { + status: "launch_granted", + attemptId: attempt.id, + idempotencyKey, + providerGeneration: profile.processGeneration, }, - ); - } - - #claudePlatformUnavailableObservation( - profile: ProfileRecord, - ): PublicProviderObservation { - return { - basis: "local_state", - code: "provider_platform_unavailable", - coverage: "unavailable", - freshness: "fresh", - observedAt: this.#now(), - profileGeneration: profile.processGeneration, - source: "codex_app_server", - state: "unavailable", }; } - async #readClaudeAccount(profile: ProfileRecord, signal: AbortSignal): Promise { - await this.#daemonAuthority.assertCurrent(); - return await this.#fencedEffect(async () => await this.#claude.readAccount({ - authority: authorityFor(this.#paths, profile), - signal, - })); - } - - async #readDevinAccount(profile: ProfileRecord, signal: AbortSignal): Promise { - await this.#daemonAuthority.assertCurrent(); - return await this.#fencedEffect(async () => await this.#devin.readAccount({ - authority: authorityFor(this.#paths, profile), - signal, - })); - } - - #unsettledClaudeLogin(profile: ProfileRecord): MutationAttemptRecord | undefined { - return this.#store.listUnsettledMutations({ authorityId: profile.id }).find((attempt) => - attempt.kind === "account.claude-login"); - } - - #unsettledDevinLogin(profile: ProfileRecord): MutationAttemptRecord | undefined { - return this.#store.listUnsettledMutations({ authorityId: profile.id }).find((attempt) => - attempt.kind === "account.devin-login"); - } - - async #showClaudeAccount(selector: string, signal: AbortSignal): Promise { - const profile = this.#store.requireProfile(selector); - const unsettled = this.#unsettledClaudeLogin(profile); - if (unsettled !== undefined) { - // The durable child fence is authoritative even when the provider - // binary is missing, drifts from the pin, or cannot answer. Do not hide - // the only exact recovery command behind a best-effort status process. - return { - account: this.#publicIsolatedProviderAccount(profile), - authentication: { provider: "claude", signedIn: null }, - providerGeneration: profile.processGeneration, - recovery: this.#claudeLoginRecovery(unsettled), - }; + async #completeDevinLogin( + command: Extract, + signal: AbortSignal, + ): Promise { + const profile = this.#store.requireProfile(command.account); + const attempt = this.#store.readMutation(command.idempotencyKey); + if ( + attempt === null + || attempt.id !== command.attemptId + || attempt.kind !== "account.devin-login" + || attempt.authorityId !== profile.id + || attempt.authorityGeneration !== command.providerGeneration + ) throw new CommandFailure("CONFLICT", "The Devin login completion does not match its exact launch authority."); + if (attempt.state === "reconciled" && attempt.resolution?.kind === "abandoned") { + throw new CommandFailure( + "CONFLICT", + "This Devin login fence was explicitly abandoned. Start a fresh login with a new idempotency key.", + ); + } + const priorReceipt = attempt.state === "applied" + || attempt.state === "failed" + || attempt.state === "reconciled" + ? devinLoginTerminalReceiptSchema.safeParse(attempt.result) + : undefined; + let signedIn: boolean; + if (priorReceipt?.success === true) { + signedIn = priorReceipt.data.signedIn; + } else if (command.outcome.state === "not_started") { + signedIn = false; + } else { + signedIn = (await this.#readDevinAccount(profile, signal)).signedIn; + } + try { + this.#store.settleDevinLoginMutation({ + attemptId: command.attemptId, + idempotencyKey: command.idempotencyKey, + profileId: profile.id, + profileGeneration: command.providerGeneration, + signedIn, + outcome: command.outcome, + }); + } catch (error: unknown) { + if ( + error instanceof Error + && ( + error.message === "DEVIN_LOGIN_AUTHORITY_MISMATCH" + || error.message === "DEVIN_LOGIN_TERMINAL_OUTCOME_CONFLICT" + || error.message === "MUTATION_RECOVERY_CAS_CONFLICT" + ) + ) throw new CommandFailure("CONFLICT", "The Devin login completion conflicts with its durable terminal receipt."); + throw error; } - this.#assertClaudeIsolationAccepted(); - const account = await this.#readClaudeAccount(profile, signal); return { account: this.#publicIsolatedProviderAccount(profile), - authentication: { provider: "claude", signedIn: account.signedIn }, - providerGeneration: profile.processGeneration, - ...(account.signedIn - ? {} - : { nextCommand: `hra account login ${profile.id} --provider claude` }), + authentication: { provider: "devin", signedIn }, + login: { + status: signedIn ? "signed_in" : "signed_out", + attemptId: command.attemptId, + idempotencyKey: command.idempotencyKey, + providerGeneration: command.providerGeneration, + }, }; } - async #showDevinAccount(selector: string, signal: AbortSignal): Promise { - const profile = this.#store.requireProfile(selector); - const unsettled = this.#unsettledDevinLogin(profile); - if (unsettled !== undefined) { - return { - account: this.#publicIsolatedProviderAccount(profile), - authentication: { provider: "devin", signedIn: null }, - providerGeneration: profile.processGeneration, - recovery: this.#devinLoginRecovery(unsettled), - usage: { - allowance: "unknown", - reason: "Devin ACP reports context and optional cumulative session cost, but exposes no account allowance or reset window.", - source: "devin_acp", - }, - }; + #abandonDevinLogin( + command: Extract, + ): unknown { + const profile = this.#store.requireProfile(command.account); + const attempt = this.#store.readMutation(command.idempotencyKey); + if ( + attempt === null + || attempt.id !== command.attemptId + || attempt.kind !== "account.devin-login" + || attempt.authorityId !== profile.id + || attempt.authorityGeneration !== command.providerGeneration + ) throw new CommandFailure("CONFLICT", "The acknowledged Devin login abandon does not match its exact launch authority."); + try { + this.#store.abandonDevinLoginMutation({ + attemptId: command.attemptId, + idempotencyKey: command.idempotencyKey, + profileId: profile.id, + profileGeneration: command.providerGeneration, + acknowledgeChildExited: command.acknowledgeChildExited, + }); + } catch (error: unknown) { + if ( + error instanceof Error + && ( + error.message === "DEVIN_LOGIN_AUTHORITY_MISMATCH" + || error.message === "DEVIN_LOGIN_NOT_UNSETTLED" + || error.message === "DEVIN_LOGIN_TERMINAL_OUTCOME_CONFLICT" + || error.message === "MUTATION_RECOVERY_CAS_CONFLICT" + ) + ) throw new CommandFailure("CONFLICT", "The acknowledged Devin login abandon does not match one live unsettled launch fence."); + throw error; } - const account = await this.#readDevinAccount(profile, signal); return { account: this.#publicIsolatedProviderAccount(profile), - authentication: { provider: "devin", signedIn: account.signedIn }, - providerGeneration: profile.processGeneration, - usage: { - allowance: "unknown", - reason: "Devin ACP reports context and optional cumulative session cost, but exposes no account allowance or reset window.", - source: "devin_acp", + login: { + status: "abandoned", + attemptId: command.attemptId, + idempotencyKey: command.idempotencyKey, + providerGeneration: command.providerGeneration, + localOnly: true, + credentialAction: "none", }, - ...(account.signedIn - ? {} - : { nextCommand: `hra account login ${profile.id} --provider devin` }), }; } - async #prepareDevinLogin( + async #prepareClaudeLogin( selector: string, idempotencyKey: string, - _manualTokenFlow: boolean, signal: AbortSignal, ): Promise { const profile = this.#store.requireProfile(selector); if (profile.state === "removed") throw new CommandFailure("NOT_FOUND", "That account is removed."); const prior = this.#store.readMutation(idempotencyKey); if (prior !== null) { - // The manual-token choice is foreground presentation, not daemon - // authority. A same-key replay can only recover the already-granted - // child fence and can never relaunch with a different choice. + // Reusing an existing key must validate the canonical request digest + // before even a no-effect signed-in response may succeed. this.#store.prepareMutation({ - kind: "account.devin-login", + kind: "account.claude-login", authorityId: profile.id, authorityGeneration: prior.authorityGeneration, - request: { provider: "devin" }, + request: { provider: "claude" }, idempotencyKey, }); if (prior.state === "effect_started" || prior.state === "ambiguous") { throw new CommandFailure( "RECOVERY_REQUIRED", - "This Devin login launch was already granted and will not be granted again.", - this.#devinLoginRecovery(prior), + "This Claude login launch was already granted and will not be granted again.", + this.#claudeLoginRecovery(prior), ); } if (prior.state === "reconciled" && prior.resolution?.kind === "abandoned") { throw new CommandFailure( "CONFLICT", - "This Devin login fence was explicitly abandoned. Start a fresh login with a new idempotency key.", + "This Claude login fence was explicitly abandoned. Start a fresh login with a new idempotency key.", ); } if (prior.state === "applied" || prior.state === "reconciled") { - const receipt = devinLoginTerminalReceiptSchema.safeParse(prior.result); + const receipt = claudeLoginTerminalReceiptSchema.safeParse(prior.result); if ( !receipt.success || receipt.data.accountId !== profile.id || receipt.data.attemptId !== prior.id || receipt.data.idempotencyKey !== prior.idempotencyKey || receipt.data.providerGeneration !== prior.authorityGeneration - ) throw new CommandFailure("INTERNAL", "The Devin login terminal receipt is invalid."); + ) throw new CommandFailure("INTERNAL", "The Claude login terminal receipt is invalid."); if (!receipt.data.signedIn) { throw new CommandFailure( "INTERACTION_REQUIRED", - "This Devin login attempt settled signed out. Start a fresh login with a new idempotency key.", + "This Claude login attempt settled signed out. Start a fresh login with a new idempotency key.", ); } return { account: this.#publicIsolatedProviderAccount(profile), - authentication: { provider: "devin", signedIn: true }, + authentication: { provider: "claude", signedIn: true }, login: { status: "signed_in" }, }; } if (prior.state === "failed" || prior.state === "cancelled") { throw new CommandFailure( "INTERACTION_REQUIRED", - "This Devin login attempt is terminal without sign-in. Start a fresh login with a new idempotency key.", + "This Claude login attempt is terminal without sign-in. Start a fresh login with a new idempotency key.", ); } if (prior.authorityGeneration !== profile.processGeneration) { if (!this.#store.transitionMutation(prior.id, "prepared", "cancelled", { - provider: "devin", + provider: "claude", signedIn: false, status: "stale_no_effect", - })) throw new CommandFailure("CONFLICT", "The Devin login preparation changed concurrently."); + })) throw new CommandFailure("CONFLICT", "The Claude login preparation changed concurrently."); throw new CommandFailure( "CONFLICT", - "This no-effect Devin login preparation belongs to an older provider generation. Start a fresh login with a new idempotency key.", + "This no-effect Claude login preparation belongs to an older provider generation. Start a fresh login with a new idempotency key.", + ); + } + } + const unsettled = this.#unsettledClaudeLogin(profile); + if (unsettled !== undefined) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "A Claude login already owns this account, including across provider generations.", + this.#claudeLoginRecovery(unsettled), + ); + } + this.#assertClaudeIsolationAccepted(); + const providerBlocker = this.#store.managedClaudeLoginAuthorityBlocker( + profile.id, + ); + if (providerBlocker !== null) { + throw new CommandFailure( + providerBlocker === "active_session" ? "CONFLICT" : "RECOVERY_REQUIRED", + `Claude login cannot replace the shared isolated configuration while Claude session authority is ${providerBlocker.replaceAll("_", " ")}. Inspect \`hra session list --account ${profile.id}\`, stop active turns, and resolve recovery before retrying.`, + { provider: "claude", reason: providerBlocker, retryable: true }, + ); + } + const releasableSessions = this.#store.listNonterminalManagedClaudeSessions( + profile.id, + ); + if (releasableSessions.some((session) => + session.state !== "idle" + || session.activeTurnId !== undefined + || session.providerThreadId === undefined)) { + throw new CommandFailure( + "CONFLICT", + `Claude login can release only idle, fully bound Claude sessions. Inspect \`hra session list --account ${profile.id}\`, then finish or recover every other session before retrying.`, + { provider: "claude", reason: "session_not_idle", retryable: true }, + ); + } + const observed = await this.#readClaudeAccount(profile, signal); + if (observed.signedIn) { + if (prior?.state === "prepared") { + if (!this.#store.transitionMutation(prior.id, "prepared", "cancelled", { + provider: "claude", + signedIn: true, + status: "no_effect", + })) throw new CommandFailure("CONFLICT", "The Claude login preparation changed concurrently."); + } + return { + account: this.#publicIsolatedProviderAccount(profile), + authentication: { provider: "claude", signedIn: true }, + login: { status: "signed_in" }, + }; + } + if (releasableSessions.length > 0) { + await this.#assertNoCompactProjectionRecoveryForProfile(profile.id); + for (const candidate of releasableSessions) { + await this.#serialize(`session:${candidate.id}`, async () => { + const current = this.#store.requireSession(candidate.id); + const blocker = this.#store.managedClaudeLoginAuthorityBlocker( + profile.id, + ); + if ( + blocker !== null + || current.profileId !== profile.id + || current.provider !== "claude" + || current.state !== "idle" + || current.activeTurnId !== undefined + || current.providerThreadId === undefined + || !this.#store.canReleaseIdleManagedClaudeSessionForAccountLogin({ + profileId: profile.id, + profileGeneration: profile.processGeneration, + sessionId: current.id, + }) + ) { + throw new CommandFailure( + blocker === "recovery_required" || blocker === "unsettled_authority" + ? "RECOVERY_REQUIRED" + : "CONFLICT", + "Claude session authority changed before the idle session could be released for login. Inspect the session and retry after it is quiescent.", + { provider: "claude", reason: blocker ?? "session_not_idle", retryable: true }, + ); + } + const providerConnectionId = this.#sessionProviderConnections.get(current.id) ?? null; + // Login is specifically entered because the managed Claude account + // no longer authenticates. A normal session end requires that stale + // account key to remain current, which would make safe sign-in + // impossible. The durable PID/start record is the narrower release + // authority here: release that exact process without targeting a + // replacement account, then retire the local session below. + await this.#releaseClaudeProcessAuthority({ + providerThreadId: current.providerThreadId, + profileId: current.profileId, + runtimeScope: "managed", + }, signal); + await this.#daemonAuthority.assertCurrent(); + this.#persistSessionEventWrites(this.#eventRedactor.interruptSession({ + accountId: profile.id, + providerConnectionId, + providerGeneration: profile.processGeneration, + sessionId: current.id, + })); + this.#appendSessionEvent(authorityFor(this.#paths, profile), current.id, providerConnectionId, { + type: "connection", + state: "disconnected", + reason: "Claude account login", + }); + this.#sessionProviderConnections.delete(current.id); + this.#clearSessionFactAuthority(current.id); + this.#sessionObservationFailures.delete(current.id); + this.#sessionResubscriptionConnections.delete(current.id); + this.#sessionsAwaitingResubscription.delete(current.id); + const terminal = this.#store.terminalizeIdleClaudeSessionForAccountLogin({ + accountId: profile.id, + providerConnectionId, + providerGeneration: profile.processGeneration, + sessionId: current.id, + }); + if (terminal.event !== undefined) this.#eventWaiters.notify(current.id); + for (const interaction of terminal.interactions) this.#appendInteractionState(interaction); + await this.#cleanupTerminalFactsMemory(terminal.session); + await this.#cloud.supersedeCompactProjectionRecoveryForProviderDeletion(current.id); + await this.#daemonAuthority.assertCurrent(); + }); + } + } + let attempt: ReturnType; + try { + attempt = this.#store.prepareMutation({ + kind: "account.claude-login", + authorityId: profile.id, + authorityGeneration: profile.processGeneration, + request: { provider: "claude" }, + idempotencyKey, + }); + this.#store.beginClaudeLoginMutationEffect({ + attemptId: attempt.id, + profileId: profile.id, + profileGeneration: profile.processGeneration, + evidence: { kind: "account.claude-login", provider: "claude", baselineSignedIn: false }, + }); + } catch (error: unknown) { + if (error instanceof Error && error.message === "UNSETTLED_MUTATION_AUTHORITY") { + const blocking = this.#unsettledClaudeLogin(profile); + throw new CommandFailure( + "RECOVERY_REQUIRED", + "Another mutation already owns this account generation.", + blocking === undefined ? undefined : this.#claudeLoginRecovery(blocking), ); } + throw error; + } + return { + account: this.#publicIsolatedProviderAccount(profile), + authentication: { provider: "claude", signedIn: false }, + login: { + status: "launch_granted", + attemptId: attempt.id, + idempotencyKey, + providerGeneration: profile.processGeneration, + }, + }; + } + + async #completeClaudeLogin( + command: Extract, + signal: AbortSignal, + ): Promise { + const profile = this.#store.requireProfile(command.account); + const attempt = this.#store.readMutation(command.idempotencyKey); + if ( + attempt === null + || attempt.id !== command.attemptId + || attempt.kind !== "account.claude-login" + || attempt.authorityId !== profile.id + || attempt.authorityGeneration !== command.providerGeneration + ) throw new CommandFailure("CONFLICT", "The Claude login completion does not match its exact launch authority."); + if (attempt.state === "reconciled" && attempt.resolution?.kind === "abandoned") { + throw new CommandFailure( + "CONFLICT", + "This Claude login fence was explicitly abandoned. Start a fresh login with a new idempotency key.", + ); + } + const priorReceipt = attempt.state === "applied" + || attempt.state === "failed" + || attempt.state === "reconciled" + ? claudeLoginTerminalReceiptSchema.safeParse(attempt.result) + : undefined; + let signedIn: boolean; + if (priorReceipt?.success === true) { + signedIn = priorReceipt.data.signedIn; + } else if (command.outcome.state === "not_started") { + // The launch helper proved no child/effect existed. Settle from the + // recorded signed-out baseline without making this no-effect completion + // depend on a fallible provider status probe. + signedIn = false; + } else { + this.#assertClaudeIsolationAccepted(); + signedIn = (await this.#readClaudeAccount(profile, signal)).signedIn; + } + try { + this.#store.settleClaudeLoginMutation({ + attemptId: command.attemptId, + idempotencyKey: command.idempotencyKey, + profileId: profile.id, + profileGeneration: command.providerGeneration, + signedIn, + outcome: command.outcome, + }); + } catch (error: unknown) { + if ( + error instanceof Error + && ( + error.message === "CLAUDE_LOGIN_AUTHORITY_MISMATCH" + || error.message === "CLAUDE_LOGIN_TERMINAL_OUTCOME_CONFLICT" + || error.message === "MUTATION_RECOVERY_CAS_CONFLICT" + ) + ) throw new CommandFailure("CONFLICT", "The Claude login completion conflicts with its durable terminal receipt."); + throw error; + } + return { + account: this.#publicIsolatedProviderAccount(profile), + authentication: { provider: "claude", signedIn }, + login: { + status: signedIn ? "signed_in" : "signed_out", + attemptId: command.attemptId, + idempotencyKey: command.idempotencyKey, + providerGeneration: command.providerGeneration, + }, + }; + } + + #abandonClaudeLogin( + command: Extract, + ): unknown { + const profile = this.#store.requireProfile(command.account); + const attempt = this.#store.readMutation(command.idempotencyKey); + if ( + attempt === null + || attempt.id !== command.attemptId + || attempt.kind !== "account.claude-login" + || attempt.authorityId !== profile.id + || attempt.authorityGeneration !== command.providerGeneration + ) throw new CommandFailure("CONFLICT", "The acknowledged Claude login abandon does not match its exact launch authority."); + try { + this.#store.abandonClaudeLoginMutation({ + attemptId: command.attemptId, + idempotencyKey: command.idempotencyKey, + profileId: profile.id, + profileGeneration: command.providerGeneration, + acknowledgeChildExited: command.acknowledgeChildExited, + }); + } catch (error: unknown) { + if ( + error instanceof Error + && ( + error.message === "CLAUDE_LOGIN_AUTHORITY_MISMATCH" + || error.message === "CLAUDE_LOGIN_NOT_UNSETTLED" + || error.message === "CLAUDE_LOGIN_TERMINAL_OUTCOME_CONFLICT" + || error.message === "MUTATION_RECOVERY_CAS_CONFLICT" + ) + ) throw new CommandFailure("CONFLICT", "The acknowledged Claude login abandon does not match one live unsettled launch fence."); + throw error; } - const unsettled = this.#unsettledDevinLogin(profile); - if (unsettled !== undefined) { - throw new CommandFailure( - "RECOVERY_REQUIRED", - "A Devin login already owns this account, including across provider generations.", - this.#devinLoginRecovery(unsettled), - ); + return { + account: this.#publicIsolatedProviderAccount(profile), + login: { + status: "abandoned", + attemptId: command.attemptId, + idempotencyKey: command.idempotencyKey, + providerGeneration: command.providerGeneration, + localOnly: true, + credentialAction: "none", + }, + }; + } + + async #showAccount(selector: string, signal: AbortSignal): Promise { + const profile = this.#store.requireProfile(selector); + const revocation = this.#store.readProfilePersonalAuthorityRevocation(profile.id); + if ( + this.#profileAuthorityRevocationIsPending(profile.id, profile.processGeneration) + || (revocation?.state === "releasing" + && revocation.profileGeneration === profile.processGeneration) + ) { + return { + account: this.#publicProfile(profile), + recovery: { + required: true, + cleared: false, + diagnostic: "Provider account authority changed; HRA is releasing every session controller before completing sign-out.", + }, + }; } - const providerBlocker = this.#store.providerAuthorityAdvanceBlocker(profile.id, "devin"); - if (providerBlocker !== null) { - throw new CommandFailure( - providerBlocker === "active_session" ? "CONFLICT" : "RECOVERY_REQUIRED", - `Devin login cannot replace the shared isolated home while Devin session authority is ${providerBlocker.replaceAll("_", " ")}. Inspect \`hra session list --account ${profile.id}\`, stop active turns, and resolve recovery before retrying.`, - { provider: "devin", reason: providerBlocker, retryable: true }, - ); + const managedCodexRevocation = this.#store.readProviderRuntimeAccountRevocation({ + profileId: profile.id, + provider: "codex", + runtimeScope: "managed", + }); + const managedCodexGenerationEnded = + managedCodexRevocation?.state === "completed" + && managedCodexRevocation.profileGeneration === profile.processGeneration + && managedCodexRevocation.currentAccountKey === null; + if (managedCodexGenerationEnded) { + if (profile.state === "signed_out") { + return { account: this.#publicProfile(profile) }; + } + return { + account: this.#publicProfile(profile), + recovery: { + required: true, + cleared: false, + restartRequired: true, + diagnostic: "This Codex generation was exactly retired after account mutation dispatch. Restart HRA so a fresh generation can reread provider state without reopening the retired controller.", + }, + }; } - const releasableSessions = this.#store.listNonterminalProviderSessions(profile.id, "devin"); - if (releasableSessions.some((session) => - session.state !== "idle" - || session.activeTurnId !== undefined - || session.providerThreadId === undefined)) { + if (profile.state === "signed_out" && profile.processGeneration === 0) { + return { account: this.#publicProfile(profile) }; + } + const projectionRecoveryUnsettled = await this.#cloud + .isCompactProjectionRecoveryUnsettledForProfile(profile.id); + await this.#daemonAuthority.assertCurrent(); + const account = await this.#fencedEffect(async () => await this.#codex.readAccount({ authority: authorityFor(this.#paths, profile), signal })); + const observedProfile = this.#store.requireProfileById(profile.id); + if (observedProfile.processGeneration !== profile.processGeneration) { throw new CommandFailure( "CONFLICT", - `Devin login can release only idle, fully bound Devin sessions. Inspect \`hra session list --account ${profile.id}\`, then finish or recover every other session before retrying.`, - { provider: "devin", reason: "session_not_idle", retryable: true }, + "Account authority changed while its provider identity was read.", ); } - const observed = await this.#readDevinAccount(profile, signal); - if (observed.signedIn) { - if (prior?.state === "prepared") { - if (!this.#store.transitionMutation(prior.id, "prepared", "cancelled", { - provider: "devin", - signedIn: true, - status: "no_effect", - })) throw new CommandFailure("CONFLICT", "The Devin login preparation changed concurrently."); - } + const accountAuthorityChanged = providerAccountAuthorityChanged(observedProfile, account); + if ( + accountAuthorityChanged + && (observedProfile.state === "signed_in" || observedProfile.state === "recovery_required") + && !this.#accountMutationExplainsObservedCodexTransition(observedProfile, account) + ) { + this.#scheduleProfilePersonalAuthorityRevocation(observedProfile); return { - account: this.#publicIsolatedProviderAccount(profile), - authentication: { provider: "devin", signedIn: true }, - login: { status: "signed_in" }, + account: this.#publicProfile(observedProfile), + providerProjection: account, + recovery: { + required: true, + cleared: false, + diagnostic: "Provider account authority changed. HRA is releasing every controller owned by the prior account before accepting another identity.", + }, }; } - if (releasableSessions.length > 0) { - await this.#assertNoCompactProjectionRecoveryForProfile(profile.id); - for (const candidate of releasableSessions) { - await this.#serialize(`session:${candidate.id}`, async () => { - const current = this.#store.requireSession(candidate.id); - const blocker = this.#store.providerAuthorityAdvanceBlocker(profile.id, "devin"); - if ( - blocker !== null - || current.profileId !== profile.id - || current.provider !== "devin" - || current.state !== "idle" - || current.activeTurnId !== undefined - || current.providerThreadId === undefined - || !this.#store.canReleaseIdleDevinSessionForAccountLogin({ - profileId: profile.id, - profileGeneration: profile.processGeneration, - sessionId: current.id, - }) - ) { - throw new CommandFailure( - blocker === "recovery_required" || blocker === "unsettled_authority" - ? "RECOVERY_REQUIRED" - : "CONFLICT", - "Devin session authority changed before the idle session could be released for login. Inspect the session and retry after it is quiescent.", - { provider: "devin", reason: blocker ?? "session_not_idle", retryable: true }, - ); - } - const providerConnectionId = this.#sessionProviderConnections.get(current.id) ?? null; - await this.#endProviderSession( - { ...current, providerThreadId: current.providerThreadId }, - profile, - signal, - "Devin account login", - ); - const terminal = this.#store.terminalizeIdleDevinSessionForAccountLogin({ - accountId: profile.id, - providerConnectionId, - providerGeneration: profile.processGeneration, - sessionId: current.id, - }); - if (terminal.event !== undefined) this.#eventWaiters.notify(current.id); - for (const interaction of terminal.interactions) this.#appendInteractionState(interaction); - await this.#cleanupTerminalFactsMemory(terminal.session); - await this.#cloud.supersedeCompactProjectionRecoveryForProviderDeletion(current.id); - await this.#daemonAuthority.assertCurrent(); + if (projectionRecoveryUnsettled) { + return { + account: this.#publicProfile(observedProfile), + providerProjection: account, + recovery: { + cleared: false, + diagnostic: "Compact-projection recovery preserves this account's exact local authority; provider state was read without changing local custody.", + required: true, + }, + }; + } + if (profile.state === "signed_out") { + return { + account: this.#publicProfile(profile), + providerProjection: account, + ...(account.signedIn + ? { + login: { + status: "external_identity_unbound", + next: `hra account login ${profile.id}`, + }, + } + : {}), + }; + } + if (profile.state === "recovery_required" || profile.state === "login_pending") { + this.#resolveUnsettledLoginCancellations(profile, account); + } + if (profile.state === "recovery_required") { + const unsettled = this.#store.listUnsettledMutations({ authorityId: profile.id }) + .filter((attempt) => attempt.authorityGeneration === profile.processGeneration && (attempt.kind === "account.login" || attempt.kind === "account.logout")); + if (unsettled.length === 0) { + const reconciled = this.#store.reconcileProfileRecoveryFromAccountRead({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + provider: account, }); + return { + account: this.#publicProfile(reconciled), + providerProjection: account, + recovery: { + required: false, + cleared: true, + resolution: "provider_state_reconciled", + }, + }; + } + if (unsettled.length !== 1) { + return { account: this.#publicProfile(profile), providerProjection: account, recovery: { required: true, cleared: false, diagnostic: "No single exact account recovery authority is available." } }; + } + const attempt = unsettled[0]; + if (attempt?.evidence === undefined || (attempt.originalState ?? attempt.state) === "reconciled") { + throw new CommandFailure("RECOVERY_REQUIRED", "The account recovery evidence is incomplete."); + } + const originalState = attempt.originalState ?? attempt.state; + if (originalState !== "effect_started" && originalState !== "ambiguous") { + throw new CommandFailure("RECOVERY_REQUIRED", "The account recovery state is not resolvable."); + } + if (attempt.kind === "account.login" && !account.signedIn) { + return { account: this.#publicProfile(profile), providerProjection: account, recovery: { required: true, cleared: false, diagnostic: "The exact provider read does not prove that login completed." } }; } + const applied = attempt.kind === "account.login" || !account.signedIn; + const reconciled = this.#store.resolveAccountMutation({ + attemptId: attempt.id, + expectedOriginalState: originalState, + expectedEvidenceDigest: attempt.evidence.digest, + resolution: applied ? "proven_applied" : "provider_state_reconciled", + resolutionEvidence: { source: "account/read", signedIn: account.signedIn }, + ...(attempt.kind === "account.login" + ? { receipt: { status: "signed_in", account } } + : account.signedIn ? {} : { receipt: { loggedOut: true } }), + provider: account, + }); + return { account: this.#publicProfile(reconciled), providerProjection: account, idempotencyKey: attempt.idempotencyKey, recovery: { required: false, cleared: true, resolution: applied ? "proven_applied" : "provider_state_reconciled" } }; + } + if (profile.state === "login_pending" && !account.signedIn) { + const authority = this.#store.readPendingLoginAuthority(profile.id, profile.processGeneration); + return { + account: this.#publicProfile(profile), + providerProjection: account, + login: authority === null + ? { + status: "pending", + recoveryRequired: true, + diagnostic: "The pending login has no exact durable provider login authority.", + } + : { + status: "pending", + loginId: authority.loginId, + next: `hra account login-cancel ${profile.id}`, + }, + }; + } + const stateChange = this.#store.setProfileStateWithWorkRetirement( + profile.id, + profile.processGeneration, + account.signedIn ? "signed_in" : "signed_out", + this.#work, + { + ...(account.email === undefined ? {} : { email: account.email }), + ...(account.plan === undefined ? {} : { plan: account.plan }), + }, + ); + this.#notifyAffectedWork(stateChange.affectedWorkIds); + if (!stateChange.changed) { + throw new CommandFailure("CONFLICT", "Account generation changed during reconciliation."); } - let attempt: ReturnType; - try { - attempt = this.#store.prepareMutation({ - kind: "account.devin-login", - authorityId: profile.id, - authorityGeneration: profile.processGeneration, - request: { provider: "devin" }, - idempotencyKey, - }); - this.#store.beginDevinLoginMutationEffect({ + return { account: this.#publicProfile(this.#store.requireProfile(profile.id)) }; + } + + /** + * An indeterminate login cancellation changes no local state on its own. The + * exact account read settles it: a signed-in read proves the login finished, + * and a signed-out read leaves the pending login for a fresh cancellation. + */ + #resolveUnsettledLoginCancellations(profile: ProfileRecord, account: CodexAccountProjection): void { + for (const attempt of this.#store.listUnsettledMutations({ authorityId: profile.id })) { + if (attempt.kind !== "account.login-cancel" || attempt.authorityGeneration !== profile.processGeneration) continue; + const originalState = attempt.originalState ?? attempt.state; + if (originalState !== "effect_started" && originalState !== "ambiguous") continue; + this.#store.resolveLoginCancelMutation({ attemptId: attempt.id, - profileId: profile.id, - profileGeneration: profile.processGeneration, - evidence: { kind: "account.devin-login", provider: "devin", baselineSignedIn: false }, + expectedOriginalState: originalState, + provider: { signedIn: account.signedIn }, }); - } catch (error: unknown) { - if (error instanceof Error && error.message === "UNSETTLED_MUTATION_AUTHORITY") { - const blocking = this.#unsettledDevinLogin(profile); - throw new CommandFailure( - "RECOVERY_REQUIRED", - "Another mutation already owns this account generation.", - blocking === undefined ? undefined : this.#devinLoginRecovery(blocking), - ); - } - throw error; } - return { - account: this.#publicIsolatedProviderAccount(profile), - authentication: { provider: "devin", signedIn: false }, - login: { - status: "launch_granted", - attemptId: attempt.id, - idempotencyKey, - providerGeneration: profile.processGeneration, - }, - }; } - async #completeDevinLogin( - command: Extract, - signal: AbortSignal, - ): Promise { - const profile = this.#store.requireProfile(command.account); - const attempt = this.#store.readMutation(command.idempotencyKey); - if ( - attempt === null - || attempt.id !== command.attemptId - || attempt.kind !== "account.devin-login" - || attempt.authorityId !== profile.id - || attempt.authorityGeneration !== command.providerGeneration - ) throw new CommandFailure("CONFLICT", "The Devin login completion does not match its exact launch authority."); - if (attempt.state === "reconciled" && attempt.resolution?.kind === "abandoned") { - throw new CommandFailure( - "CONFLICT", - "This Devin login fence was explicitly abandoned. Start a fresh login with a new idempotency key.", - ); + async #login(selector: string, deviceCode: boolean, idempotencyKey: string | undefined, signal: AbortSignal): Promise { + const current = this.#store.requireProfile(selector); + await this.#assertNoCompactProjectionRecoveryForProfile(current.id); + if (current.state === "signed_in" && idempotencyKey === undefined) return { account: this.#publicProfile(current), login: { status: "signed_in" } }; + const key = idempotencyKey ?? randomUUID(); + const prior = this.#store.readMutation(key); + if (prior !== null && (prior.kind !== "account.login" || prior.authorityId !== current.id)) { + throw new CommandFailure("CONFLICT", "The idempotency key belongs to another mutation authority."); } - const priorReceipt = attempt.state === "applied" - || attempt.state === "failed" - || attempt.state === "reconciled" - ? devinLoginTerminalReceiptSchema.safeParse(attempt.result) - : undefined; - let signedIn: boolean; - if (priorReceipt?.success === true) { - signedIn = priorReceipt.data.signedIn; - } else if (command.outcome.state === "not_started") { - signedIn = false; - } else { - signedIn = (await this.#readDevinAccount(profile, signal)).signedIn; + if (current.state === "signed_in" && prior === null) { + return { account: this.#publicProfile(current), login: { status: "signed_in" } }; } - try { - this.#store.settleDevinLoginMutation({ - attemptId: command.attemptId, - idempotencyKey: command.idempotencyKey, - profileId: profile.id, - profileGeneration: command.providerGeneration, - signedIn, - outcome: command.outcome, - }); - } catch (error: unknown) { - if ( - error instanceof Error - && ( - error.message === "DEVIN_LOGIN_AUTHORITY_MISMATCH" - || error.message === "DEVIN_LOGIN_TERMINAL_OUTCOME_CONFLICT" - || error.message === "MUTATION_RECOVERY_CAS_CONFLICT" - ) - ) throw new CommandFailure("CONFLICT", "The Devin login completion conflicts with its durable terminal receipt."); - throw error; + if (prior === null && (current.state === "login_pending" || current.state === "recovery_required")) { + throw new CommandFailure("RECOVERY_REQUIRED", "This account already has an unsettled login. Reuse its idempotency key or inspect the account before starting another login."); + } + const reboundAuthority = current.state === "login_pending" + ? this.#store.readPendingLoginAuthority(current.id, current.processGeneration) + : null; + const targetGeneration = prior?.authorityGeneration ?? current.processGeneration + 1; + const canBegin = current.processGeneration + 1 === targetGeneration && (prior === null || prior.state === "prepared"); + const canReplayReboundPending = prior?.state === "applied" + && reboundAuthority?.attemptId === prior.id; + if (current.processGeneration !== targetGeneration && !canBegin && !canReplayReboundPending) { + throw new CommandFailure("CONFLICT", "The login attempt belongs to a stale account generation."); + } + const authority = { ...current, processGeneration: targetGeneration }; + if (canBegin) { + if (this.#store.hasUnsettledSessionMutationAuthority(current.id)) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "This account has an unsettled session start or provider switch. Recover or abandon that session mutation before replacing the account login authority.", + { reason: "unsettled_session_mutation", retryable: true }, + ); + } + const blocked = this.#isolatedProviderAuthorityAdvanceBlocker(current.id); + if (blocked !== null) { + throw new CommandFailure( + "CONFLICT", + `The ${blocked.provider} runtime for this account is not quiescent. Finish or stop its active work, resolve any recovery, then retry the Codex login.`, + { + provider: blocked.provider, + reason: blocked.blocker, + retryable: true, + }, + ); + } } - return { - account: this.#publicIsolatedProviderAccount(profile), - authentication: { provider: "devin", signedIn }, - login: { - status: signedIn ? "signed_in" : "signed_out", - attemptId: command.attemptId, - idempotencyKey: command.idempotencyKey, - providerGeneration: command.providerGeneration, - }, - }; - } - - #abandonDevinLogin( - command: Extract, - ): unknown { - const profile = this.#store.requireProfile(command.account); - const attempt = this.#store.readMutation(command.idempotencyKey); - if ( - attempt === null - || attempt.id !== command.attemptId - || attempt.kind !== "account.devin-login" - || attempt.authorityId !== profile.id - || attempt.authorityGeneration !== command.providerGeneration - ) throw new CommandFailure("CONFLICT", "The acknowledged Devin login abandon does not match its exact launch authority."); try { - this.#store.abandonDevinLoginMutation({ - attemptId: command.attemptId, - idempotencyKey: command.idempotencyKey, - profileId: profile.id, - profileGeneration: command.providerGeneration, - acknowledgeChildExited: command.acknowledgeChildExited, + const result = await this.#effect({ + kind: "account.login", + authorityId: current.id, + authorityGeneration: targetGeneration, + request: { deviceCode }, + idempotencyKey: key, + beginEffect: async (attemptId) => { + try { + const retirements = this.#prepareAccountLoginProviderRetirements( + current.id, + current.processGeneration, + ); + await this.#releaseCodexAuthorityForAccountMutationLocked( + current, + signal, + ); + await this.#releaseProfileClaudeControllersLocked(current, signal); + const begun = this.#store.beginAccountMutationEffect({ + attemptId, + profileId: current.id, + profileGeneration: targetGeneration, + evidence: { kind: "account.login", method: deviceCode ? "device_code" : "browser" }, + providerRetirements: retirements, + workStore: this.#work, + }); + this.#notifyAffectedWork(begun.affectedWorkIds); + this.#applyAccountLoginProviderRetirements( + retirements, + begun.retiredSessionIds, + ); + this.#rebindIsolatedProviderAuthorities( + current.id, + current.processGeneration, + targetGeneration, + ); + } catch (error: unknown) { + // Preparing the retirement drains bounded redactor custody. A + // failed atomic commit must stop this daemon so recovery exposes a + // provider gap instead of continuing from an incomplete stream. + this.#state = "closing"; + this.#interactionDeadlineAbort.abort( + new Error("Account login provider retirement did not commit exactly."), + ); + this.#interactionDeadlineWake?.(); + this.#interactionDeadlineWake = undefined; + this.#daemonAuthority.close(); + this.#scheduleStop(); + throw error; + } + }, + effect: async () => await this.#fencedEffect(async () => await this.#codex.login({ authority: authorityFor(this.#paths, authority), method: deviceCode ? "device_code" : "browser", signal })), + receipt: (value) => loginReceiptSchema.parse(value.status === "pending" + ? { status: "pending", loginId: value.loginId } + : { status: "signed_in", account: value.account }), + restore: restoreLoginReceipt, + commit: (attemptId, _value, receipt) => { + this.#store.completeAccountLoginMutation({ + attemptId, + profileId: current.id, + processGeneration: targetGeneration, + receipt: loginReceiptSchema.parse(receipt), + }); + }, }); + const observed = this.#store.requireProfile(current.id); + const replayedPendingReceipt = prior?.state === "applied" && result.status === "pending"; + const login = replayedPendingReceipt && observed.state === "signed_in" + ? { + status: "signed_in" as const, + account: { + signedIn: true as const, + ...(observed.providerEmail === undefined ? {} : { email: observed.providerEmail }), + ...(observed.providerPlan === undefined ? {} : { plan: observed.providerPlan }), + }, + } + : replayedPendingReceipt && observed.state === "signed_out" + ? { status: "settled" as const, outcome: "signed_out" as const } + : result.status === "pending" + ? { + ...result, + next: `hra account login-cancel ${current.id}`, + } + : result; + return { + account: this.#publicProfile(observed), + login, + idempotencyKey: key, + }; } catch (error: unknown) { - if ( - error instanceof Error - && ( - error.message === "DEVIN_LOGIN_AUTHORITY_MISMATCH" - || error.message === "DEVIN_LOGIN_NOT_UNSETTLED" - || error.message === "DEVIN_LOGIN_TERMINAL_OUTCOME_CONFLICT" - || error.message === "MUTATION_RECOVERY_CAS_CONFLICT" - ) - ) throw new CommandFailure("CONFLICT", "The acknowledged Devin login abandon does not match one live unsettled launch fence."); + if (error instanceof DaemonAuthoritySafetyError) throw error; + const observed = this.#store.requireProfile(current.id); + const attempt = this.#store.readMutation(key); + if (observed.processGeneration === targetGeneration) { + if (attempt?.state === "effect_started" || attempt?.state === "ambiguous") { + this.#quarantineProfile(observed); + } else if (observed.state === "login_pending") { + const stateChange = this.#store.setProfileStateWithWorkRetirement( + current.id, + targetGeneration, + "signed_out", + this.#work, + ); + this.#notifyAffectedWork(stateChange.affectedWorkIds); + } + } throw error; } - return { - account: this.#publicIsolatedProviderAccount(profile), - login: { - status: "abandoned", - attemptId: command.attemptId, - idempotencyKey: command.idempotencyKey, - providerGeneration: command.providerGeneration, - localOnly: true, - credentialAction: "none", - }, - }; } - async #prepareClaudeLogin( - selector: string, - idempotencyKey: string, - signal: AbortSignal, - ): Promise { + async #cancelLogin(selector: string, idempotencyKey: string | undefined, signal: AbortSignal): Promise { const profile = this.#store.requireProfile(selector); - if (profile.state === "removed") throw new CommandFailure("NOT_FOUND", "That account is removed."); - const prior = this.#store.readMutation(idempotencyKey); - if (prior !== null) { - // Reusing an existing key must validate the canonical request digest - // before even a no-effect signed-in response may succeed. - this.#store.prepareMutation({ - kind: "account.claude-login", - authorityId: profile.id, - authorityGeneration: prior.authorityGeneration, - request: { provider: "claude" }, - idempotencyKey, - }); - if (prior.state === "effect_started" || prior.state === "ambiguous") { - throw new CommandFailure( - "RECOVERY_REQUIRED", - "This Claude login launch was already granted and will not be granted again.", - this.#claudeLoginRecovery(prior), - ); - } - if (prior.state === "reconciled" && prior.resolution?.kind === "abandoned") { - throw new CommandFailure( - "CONFLICT", - "This Claude login fence was explicitly abandoned. Start a fresh login with a new idempotency key.", - ); - } - if (prior.state === "applied" || prior.state === "reconciled") { - const receipt = claudeLoginTerminalReceiptSchema.safeParse(prior.result); - if ( - !receipt.success - || receipt.data.accountId !== profile.id - || receipt.data.attemptId !== prior.id - || receipt.data.idempotencyKey !== prior.idempotencyKey - || receipt.data.providerGeneration !== prior.authorityGeneration - ) throw new CommandFailure("INTERNAL", "The Claude login terminal receipt is invalid."); - if (!receipt.data.signedIn) { - throw new CommandFailure( - "INTERACTION_REQUIRED", - "This Claude login attempt settled signed out. Start a fresh login with a new idempotency key.", - ); - } - return { - account: this.#publicIsolatedProviderAccount(profile), - authentication: { provider: "claude", signedIn: true }, - login: { status: "signed_in" }, - }; - } - if (prior.state === "failed" || prior.state === "cancelled") { - throw new CommandFailure( - "INTERACTION_REQUIRED", - "This Claude login attempt is terminal without sign-in. Start a fresh login with a new idempotency key.", - ); - } - if (prior.authorityGeneration !== profile.processGeneration) { - if (!this.#store.transitionMutation(prior.id, "prepared", "cancelled", { - provider: "claude", - signedIn: false, - status: "stale_no_effect", - })) throw new CommandFailure("CONFLICT", "The Claude login preparation changed concurrently."); - throw new CommandFailure( - "CONFLICT", - "This no-effect Claude login preparation belongs to an older provider generation. Start a fresh login with a new idempotency key.", - ); - } + await this.#assertNoCompactProjectionRecoveryForProfile(profile.id); + const key = idempotencyKey ?? randomUUID(); + const prior = this.#store.readMutation(key); + if (prior !== null && (prior.kind !== "account.login-cancel" || prior.authorityId !== profile.id)) { + throw new CommandFailure("CONFLICT", "The idempotency key belongs to another mutation authority."); } - const unsettled = this.#unsettledClaudeLogin(profile); - if (unsettled !== undefined) { + if (prior?.state === "applied") { + // A replay returns the recorded settlement without another provider call. + const receipt = loginCancelReceiptSchema.parse(prior.result); + return { + account: this.#publicProfile(profile), + loginId: receipt.loginId, + providerStatus: receipt.providerStatus, + status: receipt.provider.signedIn ? "signed_in" : "canceled", + idempotencyKey: key, + }; + } + if (profile.state === "recovery_required") { throw new CommandFailure( "RECOVERY_REQUIRED", - "A Claude login already owns this account, including across provider generations.", - this.#claudeLoginRecovery(unsettled), + "This login has no safely replayable cancellation authority. Inspect the account before changing provider state.", ); } - this.#assertClaudeIsolationAccepted(); - const providerBlocker = this.#store.providerAuthorityAdvanceBlocker( - profile.id, - "claude", - ); - if (providerBlocker !== null) { + if (profile.state === "signed_in") { + return { account: this.#publicProfile(profile), status: "signed_in" }; + } + if (profile.state === "signed_out") { + return { account: this.#publicProfile(profile), status: "already_settled" }; + } + if (profile.state !== "login_pending") { + throw new CommandFailure("CONFLICT", "This account cannot cancel a login in its current state."); + } + const login = this.#store.readPendingLoginAuthority(profile.id, profile.processGeneration); + if (login === null) { throw new CommandFailure( - providerBlocker === "active_session" ? "CONFLICT" : "RECOVERY_REQUIRED", - `Claude login cannot replace the shared isolated configuration while Claude session authority is ${providerBlocker.replaceAll("_", " ")}. Inspect \`hra session list --account ${profile.id}\`, stop active turns, and resolve recovery before retrying.`, - { provider: "claude", reason: providerBlocker, retryable: true }, + "RECOVERY_REQUIRED", + "The pending login has no exact durable provider login authority and cannot be canceled automatically.", ); } - const releasableSessions = this.#store.listNonterminalProviderSessions( - profile.id, - "claude", - ); - if (releasableSessions.some((session) => - session.state !== "idle" - || session.activeTurnId !== undefined - || session.providerThreadId === undefined)) { + const unsettledCancellations = this.#store.listUnsettledMutations({ authorityId: profile.id }) + .filter((attempt) => attempt.kind === "account.login-cancel" && attempt.authorityGeneration === profile.processGeneration); + if (unsettledCancellations.length > 0) { throw new CommandFailure( - "CONFLICT", - `Claude login can release only idle, fully bound Claude sessions. Inspect \`hra session list --account ${profile.id}\`, then finish or recover every other session before retrying.`, - { provider: "claude", reason: "session_not_idle", retryable: true }, + "RECOVERY_REQUIRED", + "An earlier cancellation of this login is indeterminate. Run `hra account show` to reconcile it before canceling again.", + { idempotencyKey: key }, ); } - const observed = await this.#readClaudeAccount(profile, signal); - if (observed.signedIn) { - if (prior?.state === "prepared") { - if (!this.#store.transitionMutation(prior.id, "prepared", "cancelled", { - provider: "claude", - signedIn: true, - status: "no_effect", - })) throw new CommandFailure("CONFLICT", "The Claude login preparation changed concurrently."); - } - return { - account: this.#publicIsolatedProviderAccount(profile), - authentication: { provider: "claude", signedIn: true }, - login: { status: "signed_in" }, - }; + const authority = authorityFor(this.#paths, profile); + // The attempt is recorded before the provider call, like every other Codex + // mutation, so a crash between dispatch and settlement is visible to + // restart recovery instead of leaving an unledgered cancellation. + const receipt = await this.#effect({ + kind: "account.login-cancel", + authorityId: profile.id, + authorityGeneration: profile.processGeneration, + request: { loginId: login.loginId }, + idempotencyKey: key, + beginEffect: (attemptId) => { + this.#store.beginLoginCancelMutationEffect({ + attemptId, + profileId: profile.id, + processGeneration: profile.processGeneration, + loginId: login.loginId, + }); + }, + effect: async () => { + const canceled = await this.#fencedEffect(async () => await this.#codex.cancelLogin({ + authority, + loginId: login.loginId, + signal, + })); + const provider = await this.#fencedEffect(async () => await this.#codex.readAccount({ + authority, + signal, + })); + return loginCancelReceiptSchema.parse({ + loginId: login.loginId, + providerStatus: canceled.status, + provider: { + signedIn: provider.signedIn, + ...(provider.email === undefined ? {} : { email: provider.email }), + ...(provider.plan === undefined ? {} : { plan: provider.plan }), + }, + }); + }, + receipt: (value) => loginCancelReceiptSchema.parse(value), + restore: (value) => loginCancelReceiptSchema.parse(value), + commit: (attemptId, value, recorded) => { + this.#store.settlePendingLogin({ + profileId: profile.id, + processGeneration: profile.processGeneration, + loginId: value.loginId, + providerStatus: value.providerStatus, + provider: { + signedIn: value.provider.signedIn, + ...(value.provider.email === undefined ? {} : { email: value.provider.email }), + ...(value.provider.plan === undefined ? {} : { plan: value.provider.plan }), + }, + }); + if (!this.#store.transitionMutation(attemptId, "effect_started", "applied", recorded)) { + throw new Error("LOGIN_CANCEL_MUTATION_CAS_CONFLICT"); + } + }, + }); + return { + account: this.#publicProfile(this.#store.requireProfileById(profile.id)), + loginId: receipt.loginId, + providerStatus: receipt.providerStatus, + status: receipt.provider.signedIn ? "signed_in" : "canceled", + idempotencyKey: key, + }; + } + + async #logout(selector: string, idempotencyKey: string | undefined, signal: AbortSignal): Promise { + const profile = this.#store.requireProfile(selector); + await this.#assertNoCompactProjectionRecoveryForProfile(profile.id); + this.#work.assertProfileCanChangeAuthority(profile.id, "codex"); + if (this.#store.hasUnsettledSessionMutationAuthority(profile.id, "codex")) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "This account has an unsettled Codex session start or provider switch. Recover or abandon that session mutation before signing out.", + { provider: "codex", reason: "unsettled_session_mutation", retryable: true }, + ); + } + const key = idempotencyKey ?? randomUUID(); + if (profile.state === "recovery_required") { + throw new CommandFailure("RECOVERY_REQUIRED", "This account has an indeterminate logout. Run `hra account show` to reconcile its exact provider state before another logout."); } - if (releasableSessions.length > 0) { - await this.#assertNoCompactProjectionRecoveryForProfile(profile.id); - for (const candidate of releasableSessions) { - await this.#serialize(`session:${candidate.id}`, async () => { - const current = this.#store.requireSession(candidate.id); - const blocker = this.#store.providerAuthorityAdvanceBlocker( + await this.#effect({ + kind: "account.logout", + authorityId: profile.id, + authorityGeneration: profile.processGeneration, + request: {}, + idempotencyKey: key, + beginEffect: async (attemptId) => { + try { + const retirements = this.#prepareAccountLoginProviderRetirements( profile.id, - "claude", + profile.processGeneration, + ); + await this.#releaseCodexAuthorityForAccountMutationLocked( + profile, + signal, + { deferManagedRelease: profile.state !== "signed_out" }, + ); + const begun = this.#store.beginAccountMutationEffect({ + attemptId, + profileId: profile.id, + profileGeneration: profile.processGeneration, + evidence: { kind: "account.logout", baselineSignedIn: profile.state !== "signed_out" }, + providerRetirements: retirements, + workStore: this.#work, + }); + this.#notifyAffectedWork(begun.affectedWorkIds); + this.#applyAccountLoginProviderRetirements( + retirements, + begun.retiredSessionIds, ); + } catch (error: unknown) { + let admissionFailure = error; + const managed = this.#store.readProviderRuntimeAccountRevocation({ + profileId: profile.id, + provider: "codex", + runtimeScope: "managed", + }); if ( - blocker !== null - || current.profileId !== profile.id - || current.provider !== "claude" - || current.state !== "idle" - || current.activeTurnId !== undefined - || current.providerThreadId === undefined - || !this.#store.canReleaseIdleClaudeSessionForAccountLogin({ - profileId: profile.id, - profileGeneration: profile.processGeneration, - sessionId: current.id, - }) + managed?.state === "releasing" + && managed.profileGeneration === profile.processGeneration + && managed.currentAccountKey === null ) { - throw new CommandFailure( - blocker === "recovery_required" || blocker === "unsettled_authority" - ? "RECOVERY_REQUIRED" - : "CONFLICT", - "Claude session authority changed before the idle session could be released for login. Inspect the session and retry after it is quiescent.", - { provider: "claude", reason: blocker ?? "session_not_idle", retryable: true }, - ); + try { + // No provider effect was dispatched, but the exact client was + // deliberately retained for it. Prove that custody released + // before returning the begin failure; the same generation may + // not be reopened afterward. + await this.#completeManagedCodexLogoutAuthorityReleaseLocked(profile); + } catch (cleanupError: unknown) { + admissionFailure = cleanupError instanceof DaemonAuthoritySafetyError + ? cleanupError + : new AggregateError( + [error, cleanupError], + "Codex logout admission failed and retained controller release was not proven.", + ); + } } - const providerConnectionId = this.#sessionProviderConnections.get(current.id) ?? null; - await this.#endProviderSession( - { ...current, providerThreadId: current.providerThreadId }, - profile, + // The generation is now durably fenced even when exact release + // completed. Do not let another operation in this service attempt + // to reopen it; the normal daemon close path advances authority for + // a fresh-process reconciliation. + this.#state = "closing"; + this.#interactionDeadlineAbort.abort( + new Error("Codex logout admission did not commit exactly."), + ); + this.#interactionDeadlineWake?.(); + this.#interactionDeadlineWake = undefined; + this.#daemonAuthority.close(); + this.#scheduleStop(); + throw admissionFailure; + } + }, + effect: async () => { + if (profile.state === "signed_out") return { loggedOut: true as const }; + let logoutFailure: unknown; + try { + await this.#fencedEffect(async () => await this.#codex.logout({ + authority: authorityFor(this.#paths, profile), signal, - "Claude account login", + })); + } catch (error: unknown) { + if (error instanceof DaemonAuthoritySafetyError) throw error; + logoutFailure = error; + } + let releaseFailure: unknown; + try { + // The provider call has crossed its durable effect_started boundary. + // Only now may the exact client be retired; completion is required + // before any local account settlement can make progress again. + await this.#completeManagedCodexLogoutAuthorityReleaseLocked( + profile, ); - const terminal = this.#store.terminalizeIdleClaudeSessionForAccountLogin({ - accountId: profile.id, - providerConnectionId, - providerGeneration: profile.processGeneration, - sessionId: current.id, - }); - if (terminal.event !== undefined) this.#eventWaiters.notify(current.id); - for (const interaction of terminal.interactions) this.#appendInteractionState(interaction); - await this.#cleanupTerminalFactsMemory(terminal.session); - await this.#cloud.supersedeCompactProjectionRecoveryForProviderDeletion(current.id); - await this.#daemonAuthority.assertCurrent(); - }); - } - } - let attempt: ReturnType; - try { - attempt = this.#store.prepareMutation({ - kind: "account.claude-login", - authorityId: profile.id, - authorityGeneration: profile.processGeneration, - request: { provider: "claude" }, - idempotencyKey, - }); - this.#store.beginClaudeLoginMutationEffect({ - attemptId: attempt.id, - profileId: profile.id, - profileGeneration: profile.processGeneration, - evidence: { kind: "account.claude-login", provider: "claude", baselineSignedIn: false }, - }); - } catch (error: unknown) { - if (error instanceof Error && error.message === "UNSETTLED_MUTATION_AUTHORITY") { - const blocking = this.#unsettledClaudeLogin(profile); - throw new CommandFailure( - "RECOVERY_REQUIRED", - "Another mutation already owns this account generation.", - blocking === undefined ? undefined : this.#claudeLoginRecovery(blocking), + } catch (error: unknown) { + if (error instanceof DaemonAuthoritySafetyError) throw error; + releaseFailure = error; + } + if (logoutFailure !== undefined || releaseFailure !== undefined) { + const causes = [logoutFailure, releaseFailure] + .filter((cause) => cause !== undefined); + throw new IndeterminateLocalCommitError( + "Codex logout was dispatched, but its exact account and controller settlement is not fully proven.", + causes.length === 1 + ? causes[0] + : new AggregateError(causes, "Codex logout and controller release did not settle together."), + ); + } + return { loggedOut: true as const }; + }, + receipt: (value) => logoutReceiptSchema.parse(value), + restore: (value) => logoutReceiptSchema.parse(value), + onAmbiguous: () => this.#quarantineCodexAccountMutation(profile), + }); + const current = this.#store.requireProfile(profile.id); + const stateChange = current.state === "signed_out" + ? null + : this.#store.setProfileStateWithWorkRetirement( + profile.id, + profile.processGeneration, + "signed_out", + this.#work, ); - } - throw error; + if (stateChange !== null) this.#notifyAffectedWork(stateChange.affectedWorkIds); + if (stateChange !== null && !stateChange.changed) { + this.#quarantineCodexAccountMutation(profile); + throw new CommandFailure("RECOVERY_REQUIRED", "Codex logged out, but its local account state could not be committed. Run `hra account show` to reconcile it."); } - return { - account: this.#publicIsolatedProviderAccount(profile), - authentication: { provider: "claude", signedIn: false }, - login: { - status: "launch_granted", - attemptId: attempt.id, - idempotencyKey, - providerGeneration: profile.processGeneration, - }, - }; + return { account: this.#publicProfile(this.#store.requireProfile(profile.id)), idempotencyKey: key }; } - async #completeClaudeLogin( - command: Extract, - signal: AbortSignal, - ): Promise { - const profile = this.#store.requireProfile(command.account); - const attempt = this.#store.readMutation(command.idempotencyKey); - if ( - attempt === null - || attempt.id !== command.attemptId - || attempt.kind !== "account.claude-login" - || attempt.authorityId !== profile.id - || attempt.authorityGeneration !== command.providerGeneration - ) throw new CommandFailure("CONFLICT", "The Claude login completion does not match its exact launch authority."); - if (attempt.state === "reconciled" && attempt.resolution?.kind === "abandoned") { - throw new CommandFailure( - "CONFLICT", - "This Claude login fence was explicitly abandoned. Start a fresh login with a new idempotency key.", - ); - } - const priorReceipt = attempt.state === "applied" - || attempt.state === "failed" - || attempt.state === "reconciled" - ? claudeLoginTerminalReceiptSchema.safeParse(attempt.result) - : undefined; - let signedIn: boolean; - if (priorReceipt?.success === true) { - signedIn = priorReceipt.data.signedIn; - } else if (command.outcome.state === "not_started") { - // The launch helper proved no child/effect existed. Settle from the - // recorded signed-out baseline without making this no-effect completion - // depend on a fallible provider status probe. - signedIn = false; - } else { - this.#assertClaudeIsolationAccepted(); - signedIn = (await this.#readClaudeAccount(profile, signal)).signedIn; + async #usage(selector: string | undefined, refresh: boolean, signal: AbortSignal): Promise { + if (selector === undefined && refresh) { + const usage: unknown[] = []; + for (const profile of this.#store.listProfiles()) { + const value = await this.#serialize(`account:${profile.id}`, async () => + this.#usage(profile.id, true, signal)) as { usage: unknown[] }; + usage.push(...value.usage); + } + return { usage }; } - try { - this.#store.settleClaudeLoginMutation({ - attemptId: command.attemptId, - idempotencyKey: command.idempotencyKey, + const profiles = selector === undefined ? this.#store.listProfiles() : [this.#store.requireProfile(selector)]; + const usage = []; + for (const profile of profiles) { + let automaticResetRefresh: AutomaticRateLimitResetRefreshStatus | undefined; + if (refresh) { + this.#assertSignedIn(profile); + const observed = await this.#readAndRecordUsage(profile, signal); + const refreshedProfile = this.#store.requireProfileById(profile.id); + const reset = await this.#attemptAutomaticRateLimitReset( + refreshedProfile, + observed.accountFingerprint, + observed.snapshot.payload, + signal, + ); + automaticResetRefresh = reset.refresh; + if (reset.authoritativeReread) { + // Every closed reset outcome is followed by an authoritative read. + // The provider response itself never substitutes for updated limits. + await this.#readAndRecordUsage( + this.#store.requireProfileById(profile.id), + signal, + ); + } + } + const now = this.#now(); + const currentProfile = this.#store.requireProfileById(profile.id); + const automaticResetPolicy = this.#store + .requireAccountRateLimitResetPolicy(profile.id); + const currentFingerprint = accountFingerprintForProfile(currentProfile); + const latestRecorded = currentFingerprint === null + ? null + : this.#store.latestUsageForAccount(profile.id, currentFingerprint); + const latestFailure = currentFingerprint === null + ? null + : this.#store.latestUsagePollFailure(profile.id, currentFingerprint); + const parsedLatest = latestRecorded === null + ? null + : storedAccountUsageSnapshotSchema.safeParse(latestRecorded.payload); + const latest = parsedLatest?.success === true + && currentFingerprint !== null + && parsedLatest.data.observation.accountFingerprint === currentFingerprint + ? latestRecorded + : null; + const samples = accountUsageCounterSamples(this.#store.usageRange({ profileId: profile.id, - profileGeneration: command.providerGeneration, - signedIn, - outcome: command.outcome, + fromObservedAt: Math.max(0, now - 30 * 60_000), + throughObservedAt: now, + limit: 2_000, + })).filter((sample) => sample.accountFingerprint === currentFingerprint); + const windows = ["1m", "5m", "15m"] satisfies readonly UsageVelocityWindow[]; + const velocity = Object.fromEntries(windows.map((window) => [ + window, + observedAccountTokenVelocity({ samples, window, now }), + ])); + const parsedStored = latest === null ? null : parsedLatest; + const resetObservation = latest === null + ? { available: false as const, reason: "weekly_window_unavailable" as const } + : automaticRateLimitResetObservation({ + providerPayload: providerUsagePayload(latest.payload), + now, + }); + const automaticResetLastAttempt = publicAutomaticRateLimitResetLastAttempt( + currentFingerprint === null + ? null + : this.#store.latestAccountRateLimitResetAttempt( + currentProfile.id, + currentFingerprint, + ), + ); + usage.push({ + account: this.#publicProfile(currentProfile), + automaticReset: automaticRateLimitResetStatusSchema.parse({ + policy: publicAutomaticRateLimitResetPolicy( + automaticResetPolicy, + currentFingerprint, + ), + threshold: { + remainingPercent: AUTO_RATE_LIMIT_RESET_REMAINING_PERCENT, + usedPercent: AUTO_RATE_LIMIT_RESET_USED_PERCENT, + }, + observation: resetObservation.available + ? { + state: "available", + creditsAvailable: resetObservation.creditsAvailable, + remainingPercent: Math.max(0, 100 - resetObservation.usedPercent), + usedPercent: resetObservation.usedPercent, + weeklyWindowResetsAt: resetObservation.weeklyWindowResetsAt, + } + : { state: "unavailable", reason: resetObservation.reason }, + lastAttempt: automaticResetLastAttempt, + ...(automaticResetRefresh === undefined + ? {} + : { refresh: automaticResetRefresh }), + }), + poll: latestFailure !== null + && (latest === null || latestFailure.sourceRevision > latest.sourceRevision) + ? { state: "failed", ...latestFailure } + : latest === null + ? { state: "never_observed" } + : { + observedAt: latest.observedAt, + sourceRevision: latest.sourceRevision, + state: "observed", + }, + snapshot: latest === null ? null : { + ...latest, + payload: providerUsagePayload(latest.payload), + ...(parsedStored?.success === true + ? { observation: parsedStored.data.observation } + : {}), + }, + velocity, }); - } catch (error: unknown) { - if ( - error instanceof Error - && ( - error.message === "CLAUDE_LOGIN_AUTHORITY_MISMATCH" - || error.message === "CLAUDE_LOGIN_TERMINAL_OUTCOME_CONFLICT" - || error.message === "MUTATION_RECOVERY_CAS_CONFLICT" - ) - ) throw new CommandFailure("CONFLICT", "The Claude login completion conflicts with its durable terminal receipt."); - throw error; } - return { - account: this.#publicIsolatedProviderAccount(profile), - authentication: { provider: "claude", signedIn }, - login: { - status: signedIn ? "signed_in" : "signed_out", - attemptId: command.attemptId, - idempotencyKey: command.idempotencyKey, - providerGeneration: command.providerGeneration, - }, - }; + return { usage }; } - #abandonClaudeLogin( - command: Extract, - ): unknown { - const profile = this.#store.requireProfile(command.account); - const attempt = this.#store.readMutation(command.idempotencyKey); - if ( - attempt === null - || attempt.id !== command.attemptId - || attempt.kind !== "account.claude-login" - || attempt.authorityId !== profile.id - || attempt.authorityGeneration !== command.providerGeneration - ) throw new CommandFailure("CONFLICT", "The acknowledged Claude login abandon does not match its exact launch authority."); + async #readAndRecordUsage( + profile: ProfileRecord, + signal: AbortSignal, + ): Promise>; + }>> { + let verifiedProfile = this.#store.requireProfileById(profile.id); + const expectedFingerprint = accountFingerprintForProfile(verifiedProfile); + const accountFingerprint = await this.#proveUsageAccountIdentity({ + profile: verifiedProfile, + expectedFingerprint, + signal, + }); + verifiedProfile = this.#store.requireProfileById(profile.id); + const sourceSequence = this.#store.allocateNextUsageRevision(profile.id); + let snapshot: Awaited>; try { - this.#store.abandonClaudeLoginMutation({ - attemptId: command.attemptId, - idempotencyKey: command.idempotencyKey, - profileId: profile.id, - profileGeneration: command.providerGeneration, - acknowledgeChildExited: command.acknowledgeChildExited, - }); + snapshot = await this.#fencedEffect(async () => + await this.#codex.readUsage({ + authority: authorityFor(this.#paths, verifiedProfile), + signal, + })); } catch (error: unknown) { - if ( - error instanceof Error - && ( - error.message === "CLAUDE_LOGIN_AUTHORITY_MISMATCH" - || error.message === "CLAUDE_LOGIN_NOT_UNSETTLED" - || error.message === "CLAUDE_LOGIN_TERMINAL_OUTCOME_CONFLICT" - || error.message === "MUTATION_RECOVERY_CAS_CONFLICT" - ) - ) throw new CommandFailure("CONFLICT", "The acknowledged Claude login abandon does not match one live unsettled launch fence."); + if (!signal.aborted) { + this.#store.recordUsagePollFailure( + profile.id, + accountFingerprint, + sourceSequence, + this.#now(), + "account_usage_read_failed", + ); + } throw error; } - return { - account: this.#publicIsolatedProviderAccount(profile), - login: { - status: "abandoned", - attemptId: command.attemptId, - idempotencyKey: command.idempotencyKey, - providerGeneration: command.providerGeneration, - localOnly: true, - credentialAction: "none", - }, - }; + const receivedAt = this.#now(); + const confirmedFingerprint = await this.#proveUsageAccountIdentity({ + profile: verifiedProfile, + expectedFingerprint: accountFingerprint, + signal, + }); + if (confirmedFingerprint !== accountFingerprint) { + throw new Error("ACCOUNT_USAGE_IDENTITY_PROOF_CHANGED_WITHOUT_CONFLICT"); + } + verifiedProfile = this.#store.requireProfileById(profile.id); + const previous = this.#store.latestUsageForAccount( + profile.id, + accountFingerprint, + ); + const stored = createStoredAccountUsageSnapshot({ + providerPayload: snapshot.payload, + sourceSequence, + observedAt: snapshot.observedAt, + receivedAt, + accountFingerprint, + providerGeneration: verifiedProfile.processGeneration, + daemonGeneration: this.#daemonGeneration, + previousPayload: previous?.payload ?? null, + }); + this.#store.recordUsage(profile.id, sourceSequence, snapshot.observedAt, stored); + return { accountFingerprint, snapshot }; } - async #showAccount(selector: string, signal: AbortSignal): Promise { - const profile = this.#store.requireProfile(selector); - if (profile.state === "signed_out" && profile.processGeneration === 0) { - return { account: this.#publicProfile(profile) }; - } - const projectionRecoveryUnsettled = await this.#cloud - .isCompactProjectionRecoveryUnsettledForProfile(profile.id); - await this.#daemonAuthority.assertCurrent(); - const account = await this.#fencedEffect(async () => await this.#codex.readAccount({ authority: authorityFor(this.#paths, profile), signal })); - if (projectionRecoveryUnsettled) { - return { - account: this.#publicProfile(profile), - providerProjection: account, - recovery: { - cleared: false, - diagnostic: "Compact-projection recovery preserves this account's exact local authority; provider state was read without changing local custody.", - required: true, - }, - }; - } - if (profile.state === "recovery_required" || profile.state === "login_pending") { - this.#resolveUnsettledLoginCancellations(profile, account); - } - if (profile.state === "recovery_required") { - const unsettled = this.#store.listUnsettledMutations({ authorityId: profile.id }) - .filter((attempt) => attempt.authorityGeneration === profile.processGeneration && (attempt.kind === "account.login" || attempt.kind === "account.logout")); - if (unsettled.length === 0) { - const reconciled = this.#store.reconcileProfileRecoveryFromAccountRead({ - profileId: profile.id, - expectedGeneration: profile.processGeneration, - provider: account, - }); - return { - account: this.#publicProfile(reconciled), - providerProjection: account, - recovery: { - required: false, - cleared: true, - resolution: "provider_state_reconciled", - }, - }; - } - if (unsettled.length !== 1) { - return { account: this.#publicProfile(profile), providerProjection: account, recovery: { required: true, cleared: false, diagnostic: "No single exact account recovery authority is available." } }; - } - const attempt = unsettled[0]; - if (attempt?.evidence === undefined || (attempt.originalState ?? attempt.state) === "reconciled") { - throw new CommandFailure("RECOVERY_REQUIRED", "The account recovery evidence is incomplete."); - } - const originalState = attempt.originalState ?? attempt.state; - if (originalState !== "effect_started" && originalState !== "ambiguous") { - throw new CommandFailure("RECOVERY_REQUIRED", "The account recovery state is not resolvable."); - } - if (attempt.kind === "account.login" && !account.signedIn) { - return { account: this.#publicProfile(profile), providerProjection: account, recovery: { required: true, cleared: false, diagnostic: "The exact provider read does not prove that login completed." } }; - } - const applied = attempt.kind === "account.login" || !account.signedIn; - const reconciled = this.#store.resolveAccountMutation({ - attemptId: attempt.id, - expectedOriginalState: originalState, - expectedEvidenceDigest: attempt.evidence.digest, - resolution: applied ? "proven_applied" : "provider_state_reconciled", - resolutionEvidence: { source: "account/read", signedIn: account.signedIn }, - ...(attempt.kind === "account.login" - ? { receipt: { status: "signed_in", account } } - : account.signedIn ? {} : { receipt: { loggedOut: true } }), - provider: account, - }); - return { account: this.#publicProfile(reconciled), providerProjection: account, idempotencyKey: attempt.idempotencyKey, recovery: { required: false, cleared: true, resolution: applied ? "proven_applied" : "provider_state_reconciled" } }; - } - if (profile.state === "login_pending" && !account.signedIn) { - const authority = this.#store.readPendingLoginAuthority(profile.id, profile.processGeneration); + async #attemptAutomaticRateLimitReset( + profile: ProfileRecord, + accountFingerprint: string, + providerPayload: unknown, + signal: AbortSignal, + ): Promise { + const now = this.#now(); + const observation = automaticRateLimitResetObservation({ + providerPayload, + now, + }); + const policyDecision = this.#store.authorizeAccountRateLimitResetPolicy({ + profileId: profile.id, + processGeneration: profile.processGeneration, + accountFingerprint, + weeklyWindowDurationMinutes: observation.available + ? CODEX_WEEKLY_RATE_LIMIT_WINDOW_MINUTES + : null, + weeklyWindowResetsAt: observation.available + ? observation.weeklyWindowResetsAt + : null, + }); + if (policyDecision.decision !== "allow") { + const reason = policyDecision.reason === "weekly_window_unavailable" + && policyDecision.policy.state === "reconciliation_required" + ? "reconciliation_required" as const + : policyDecision.reason; return { - account: this.#publicProfile(profile), - providerProjection: account, - login: authority === null - ? { - status: "pending", - recoveryRequired: true, - diagnostic: "The pending login has no exact durable provider login authority.", - } - : { - status: "pending", - loginId: authority.loginId, - next: `hra account login-cancel ${profile.id}`, - }, + authoritativeReread: false, + refresh: { state: "suppressed", reason }, }; } - const stateChange = this.#store.setProfileStateWithWorkRetirement( - profile.id, - profile.processGeneration, - account.signedIn ? "signed_in" : "signed_out", - this.#work, - { - ...(account.email === undefined ? {} : { email: account.email }), - ...(account.plan === undefined ? {} : { plan: account.plan }), - }, - ); - this.#notifyAffectedWork(stateChange.affectedWorkIds); - if (!stateChange.changed) { - throw new CommandFailure("CONFLICT", "Account generation changed during reconciliation."); + if (!observation.available) { + throw new Error("ACCOUNT_RATE_LIMIT_RESET_POLICY_OBSERVATION_MISMATCH"); } - return { account: this.#publicProfile(this.#store.requireProfile(profile.id)) }; - } - /** - * An indeterminate login cancellation changes no local state on its own. The - * exact account read settles it: a signed-in read proves the login finished, - * and a signed-out read leaves the pending login for a fresh cancellation. - */ - #resolveUnsettledLoginCancellations(profile: ProfileRecord, account: CodexAccountProjection): void { - for (const attempt of this.#store.listUnsettledMutations({ authorityId: profile.id })) { - if (attempt.kind !== "account.login-cancel" || attempt.authorityGeneration !== profile.processGeneration) continue; - const originalState = attempt.originalState ?? attempt.state; - if (originalState !== "effect_started" && originalState !== "ambiguous") continue; - this.#store.resolveLoginCancelMutation({ - attemptId: attempt.id, - expectedOriginalState: originalState, - provider: { signedIn: account.signedIn }, - }); + this.#store.recoverAccountRateLimitResetAttempts({ + profileId: profile.id, + processGeneration: profile.processGeneration, + accountFingerprint, + weeklyWindowResetsAt: observation.weeklyWindowResetsAt, + }); + let attempt = this.#store.readRecoverableAccountRateLimitReset( + profile.id, + accountFingerprint, + ); + if (attempt?.state === "effect_started") { + return { + authoritativeReread: false, + refresh: { state: "recovery_pending" }, + }; } - } - async #login(selector: string, deviceCode: boolean, idempotencyKey: string | undefined, signal: AbortSignal): Promise { - const current = this.#store.requireProfile(selector); - await this.#assertNoCompactProjectionRecoveryForProfile(current.id); - if (current.state === "signed_in" && idempotencyKey === undefined) return { account: this.#publicProfile(current), login: { status: "signed_in" } }; - const key = idempotencyKey ?? randomUUID(); - const prior = this.#store.readMutation(key); - if (prior !== null && (prior.kind !== "account.login" || prior.authorityId !== current.id)) { - throw new CommandFailure("CONFLICT", "The idempotency key belongs to another mutation authority."); - } - if (current.state === "signed_in" && prior === null) { - return { account: this.#publicProfile(current), login: { status: "signed_in" } }; - } - if (prior === null && (current.state === "login_pending" || current.state === "recovery_required")) { - throw new CommandFailure("RECOVERY_REQUIRED", "This account already has an unsettled login. Reuse its idempotency key or inspect the account before starting another login."); - } - const reboundAuthority = current.state === "login_pending" - ? this.#store.readPendingLoginAuthority(current.id, current.processGeneration) - : null; - const targetGeneration = prior?.authorityGeneration ?? current.processGeneration + 1; - const canBegin = current.processGeneration + 1 === targetGeneration && (prior === null || prior.state === "prepared"); - const canReplayReboundPending = prior?.state === "applied" - && reboundAuthority?.attemptId === prior.id; - if (current.processGeneration !== targetGeneration && !canBegin && !canReplayReboundPending) { - throw new CommandFailure("CONFLICT", "The login attempt belongs to a stale account generation."); - } - const authority = { ...current, processGeneration: targetGeneration }; - if (canBegin) { - if (this.#store.hasUnsettledSessionMutationAuthority(current.id)) { - throw new CommandFailure( - "RECOVERY_REQUIRED", - "This account has an unsettled session start or provider switch. Recover or abandon that session mutation before replacing the account login authority.", - { reason: "unsettled_session_mutation", retryable: true }, - ); - } - const blocked = this.#isolatedProviderAuthorityAdvanceBlocker(current.id); - if (blocked !== null) { - throw new CommandFailure( - "CONFLICT", - `The ${blocked.provider} runtime for this account is not quiescent. Finish or stop its active work, resolve any recovery, then retry the Codex login.`, - { - provider: blocked.provider, - reason: blocked.blocker, - retryable: true, - }, - ); - } - } - try { - const result = await this.#effect({ - kind: "account.login", - authorityId: current.id, - authorityGeneration: targetGeneration, - request: { deviceCode }, - idempotencyKey: key, - beginEffect: (attemptId) => { - try { - const retirements = this.#prepareAccountLoginProviderRetirements( - current.id, - current.processGeneration, - ); - const begun = this.#store.beginAccountMutationEffect({ - attemptId, - profileId: current.id, - profileGeneration: targetGeneration, - evidence: { kind: "account.login", method: deviceCode ? "device_code" : "browser" }, - providerRetirements: retirements, - workStore: this.#work, - }); - this.#notifyAffectedWork(begun.affectedWorkIds); - this.#applyAccountLoginProviderRetirements( - retirements, - begun.retiredSessionIds, - ); - this.#rebindIsolatedProviderAuthorities( - current.id, - current.processGeneration, - targetGeneration, - ); - } catch (error: unknown) { - // Preparing the retirement drains bounded redactor custody. A - // failed atomic commit must stop this daemon so recovery exposes a - // provider gap instead of continuing from an incomplete stream. - this.#state = "closing"; - this.#interactionDeadlineAbort.abort( - new Error("Account login provider retirement did not commit exactly."), - ); - this.#interactionDeadlineWake?.(); - this.#interactionDeadlineWake = undefined; - this.#daemonAuthority.close(); - this.#scheduleStop(); - throw error; - } - }, - effect: async () => await this.#fencedEffect(async () => await this.#codex.login({ authority: authorityFor(this.#paths, authority), method: deviceCode ? "device_code" : "browser", signal })), - receipt: (value) => loginReceiptSchema.parse(value.status === "pending" - ? { status: "pending", loginId: value.loginId } - : { status: "signed_in", account: value.account }), - restore: restoreLoginReceipt, - commit: (attemptId, _value, receipt) => { - this.#store.completeAccountLoginMutation({ - attemptId, - profileId: current.id, - processGeneration: targetGeneration, - receipt: loginReceiptSchema.parse(receipt), - }); - }, - }); - const observed = this.#store.requireProfile(current.id); - const replayedPendingReceipt = prior?.state === "applied" && result.status === "pending"; - const login = replayedPendingReceipt && observed.state === "signed_in" - ? { - status: "signed_in" as const, - account: { - signedIn: true as const, - ...(observed.providerEmail === undefined ? {} : { email: observed.providerEmail }), - ...(observed.providerPlan === undefined ? {} : { plan: observed.providerPlan }), - }, - } - : replayedPendingReceipt && observed.state === "signed_out" - ? { status: "settled" as const, outcome: "signed_out" as const } - : result.status === "pending" - ? { - ...result, - next: `hra account login-cancel ${current.id}`, - } - : result; + const decisionNow = this.#now(); + const decision = automaticRateLimitResetDecision({ + providerPayload, + now: decisionNow, + }); + if (attempt === null && !decision.eligible) { return { - account: this.#publicProfile(observed), - login, - idempotencyKey: key, + authoritativeReread: false, + refresh: { state: "not_eligible", reason: decision.reason }, }; - } catch (error: unknown) { - if (error instanceof DaemonAuthoritySafetyError) throw error; - const observed = this.#store.requireProfile(current.id); - const attempt = this.#store.readMutation(key); - if (observed.processGeneration === targetGeneration) { - if (attempt?.state === "effect_started" || attempt?.state === "ambiguous") { - this.#quarantineProfile(observed); - } else if (observed.state === "login_pending") { - const stateChange = this.#store.setProfileStateWithWorkRetirement( - current.id, - targetGeneration, - "signed_out", - this.#work, + } + if (attempt !== null) { + // An ambiguous attempt represents an upstream effect that may already + // have succeeded. Reconcile only that durable idempotency key after the + // policy admits a fresh observation; current credits, usage, and window + // cannot prove whether the earlier dispatch committed. + if (attempt.state !== "ambiguous") { + if ( + decisionNow >= attempt.weeklyWindowResetsAt + || observation.weeklyWindowResetsAt !== attempt.weeklyWindowResetsAt + ) { + this.#store.closeAccountRateLimitReset( + attempt.idempotencyKey, + "weekly_window_changed", ); - this.#notifyAffectedWork(stateChange.affectedWorkIds); + return { + authoritativeReread: false, + refresh: { state: "window_changed" }, + }; + } + if ( + observation.creditsAvailable < 1 + || observation.usedPercent < AUTO_RATE_LIMIT_RESET_USED_PERCENT + ) { + return { + authoritativeReread: false, + refresh: { + state: "waiting", + reason: observation.creditsAvailable < 1 + ? "credits_unavailable" + : "below_threshold", + }, + }; } } - throw error; + } else { + if (!decision.eligible) { + return { + authoritativeReread: false, + refresh: { state: "not_eligible", reason: decision.reason }, + }; + } } - } - async #cancelLogin(selector: string, idempotencyKey: string | undefined, signal: AbortSignal): Promise { - const profile = this.#store.requireProfile(selector); - await this.#assertNoCompactProjectionRecoveryForProfile(profile.id); - const key = idempotencyKey ?? randomUUID(); - const prior = this.#store.readMutation(key); - if (prior !== null && (prior.kind !== "account.login-cancel" || prior.authorityId !== profile.id)) { - throw new CommandFailure("CONFLICT", "The idempotency key belongs to another mutation authority."); + await this.#daemonAuthority.assertCurrent(); + if (signal.aborted) throw signal.reason; + const confirmedFingerprint = await this.#proveUsageAccountIdentity({ + profile, + expectedFingerprint: accountFingerprint, + signal, + }); + if (confirmedFingerprint !== accountFingerprint) { + throw new Error("ACCOUNT_RATE_LIMIT_RESET_IDENTITY_PROOF_CHANGED_WITHOUT_CONFLICT"); } - if (prior?.state === "applied") { - // A replay returns the recorded settlement without another provider call. - const receipt = loginCancelReceiptSchema.parse(prior.result); + const dispatchProfile = this.#store.requireProfileById(profile.id); + if ( + dispatchProfile.processGeneration !== profile.processGeneration + || accountFingerprintForProfile(dispatchProfile) !== accountFingerprint + ) throw new Error("ACCOUNT_RATE_LIMIT_RESET_AUTHORITY_CHANGED"); + const dispatchPolicyDecision = this.#store.authorizeAccountRateLimitResetPolicy({ + profileId: dispatchProfile.id, + processGeneration: dispatchProfile.processGeneration, + accountFingerprint, + weeklyWindowDurationMinutes: CODEX_WEEKLY_RATE_LIMIT_WINDOW_MINUTES, + weeklyWindowResetsAt: observation.weeklyWindowResetsAt, + }); + if (dispatchPolicyDecision.decision !== "allow") { + const reason = dispatchPolicyDecision.reason === "weekly_window_unavailable" + && dispatchPolicyDecision.policy.state === "reconciliation_required" + ? "reconciliation_required" as const + : dispatchPolicyDecision.reason; return { - account: this.#publicProfile(profile), - loginId: receipt.loginId, - providerStatus: receipt.providerStatus, - status: receipt.provider.signedIn ? "signed_in" : "canceled", - idempotencyKey: key, + authoritativeReread: false, + refresh: { state: "suppressed", reason }, }; } - if (profile.state === "recovery_required") { - throw new CommandFailure( - "RECOVERY_REQUIRED", - "This login has no safely replayable cancellation authority. Inspect the account before changing provider state.", - ); + if ( + attempt !== null + && attempt.currentProcessGeneration !== dispatchProfile.processGeneration + ) { + attempt = this.#store.rebindAccountRateLimitReset({ + idempotencyKey: attempt.idempotencyKey, + expectedCurrentProcessGeneration: attempt.currentProcessGeneration, + nextProcessGeneration: dispatchProfile.processGeneration, + accountFingerprint, + }); } - if (profile.state === "signed_in") { - return { account: this.#publicProfile(profile), status: "signed_in" }; + if (attempt === null) { + if (!decision.eligible) { + throw new Error("ACCOUNT_RATE_LIMIT_RESET_DECISION_CHANGED_WITHOUT_ASYNC_GAP"); + } + attempt = this.#store.prepareAccountRateLimitReset({ + profileId: dispatchProfile.id, + processGeneration: dispatchProfile.processGeneration, + accountFingerprint, + weeklyWindowResetsAt: decision.weeklyWindowResetsAt, + observedUsedPercent: decision.usedPercent, + }); } - if (profile.state === "signed_out") { - return { account: this.#publicProfile(profile), status: "already_settled" }; + // prepareAccountRateLimitReset returns an existing terminal latch for the + // same account/window. Re-check here so a settled or locally closed + // logical redemption can never cross the provider mutation boundary. + if (attempt.state === "settled") { + if (attempt.outcome === null) { + throw new Error("ACCOUNT_RATE_LIMIT_RESET_SETTLED_OUTCOME_MISSING"); + } + return { + authoritativeReread: false, + refresh: { state: "latched", outcome: attempt.outcome }, + }; } - if (profile.state !== "login_pending") { - throw new CommandFailure("CONFLICT", "This account cannot cancel a login in its current state."); + if (attempt.state === "closed") { + if (attempt.localResolution === null) { + throw new Error("ACCOUNT_RATE_LIMIT_RESET_CLOSED_RESOLUTION_MISSING"); + } + return { + authoritativeReread: false, + refresh: { state: "latched", reason: attempt.localResolution }, + }; } - const login = this.#store.readPendingLoginAuthority(profile.id, profile.processGeneration); - if (login === null) { - throw new CommandFailure( - "RECOVERY_REQUIRED", - "The pending login has no exact durable provider login authority and cannot be canceled automatically.", - ); + if (attempt.state === "effect_started") { + return { + authoritativeReread: false, + refresh: { state: "recovery_pending" }, + }; } - const unsettledCancellations = this.#store.listUnsettledMutations({ authorityId: profile.id }) - .filter((attempt) => attempt.kind === "account.login-cancel" && attempt.authorityGeneration === profile.processGeneration); - if (unsettledCancellations.length > 0) { - throw new CommandFailure( - "RECOVERY_REQUIRED", - "An earlier cancellation of this login is indeterminate. Run `hra account show` to reconcile it before canceling again.", - { idempotencyKey: key }, + + signal.throwIfAborted(); + const begun = this.#store.beginAccountRateLimitReset(attempt.idempotencyKey); + if (begun.state !== "effect_started") { + throw new Error("ACCOUNT_RATE_LIMIT_RESET_BEGIN_STATE_INVALID"); + } + let outcome: Awaited>; + try { + outcome = await this.#codex.consumeRateLimitReset({ + authority: authorityFor(this.#paths, dispatchProfile), + idempotencyKey: attempt.idempotencyKey, + signal, + }); + } catch (providerError: unknown) { + const retryState = providerError instanceof IndeterminateCodexEffectError + ? "ambiguous" + : "retryable"; + try { + // Every failure retains the original key. An indeterminate effect can + // bypass ordinary eligibility only after durable policy authorization; + // determinate failures return through the ordinary window gates. + this.#store.deferAccountRateLimitReset(attempt.idempotencyKey, retryState); + } catch (journalError: unknown) { + this.#failStopAfterResetJournalFailure( + "Automatic reset recovery evidence could not be committed.", + ); + throw new AggregateError( + [providerError, journalError], + "An automatic reset may have reached Codex and its recovery state could not be committed.", + ); + } + // A successful usage read remains successful. A later refresh can retry + // only this exact durable upstream key after policy authorization. + return { + authoritativeReread: false, + refresh: { + state: retryState === "ambiguous" ? "recovery_pending" : "retry_pending", + }, + }; + } + try { + this.#store.settleAccountRateLimitReset(attempt.idempotencyKey, outcome); + } catch (journalError: unknown) { + this.#failStopAfterResetJournalFailure( + "An automatic reset outcome could not be committed.", + ); + throw new AggregateError( + [journalError], + `Codex returned the automatic reset outcome ${outcome}, but HRA could not commit it.`, ); } - const authority = authorityFor(this.#paths, profile); - // The attempt is recorded before the provider call, like every other Codex - // mutation, so a crash between dispatch and settlement is visible to - // restart recovery instead of leaving an unledgered cancellation. - const receipt = await this.#effect({ - kind: "account.login-cancel", - authorityId: profile.id, - authorityGeneration: profile.processGeneration, - request: { loginId: login.loginId }, - idempotencyKey: key, - beginEffect: (attemptId) => { - this.#store.beginLoginCancelMutationEffect({ - attemptId, - profileId: profile.id, - processGeneration: profile.processGeneration, - loginId: login.loginId, - }); - }, - effect: async () => { - const canceled = await this.#fencedEffect(async () => await this.#codex.cancelLogin({ - authority, - loginId: login.loginId, - signal, - })); - const provider = await this.#fencedEffect(async () => await this.#codex.readAccount({ - authority, - signal, - })); - return loginCancelReceiptSchema.parse({ - loginId: login.loginId, - providerStatus: canceled.status, - provider: { - signedIn: provider.signedIn, - ...(provider.email === undefined ? {} : { email: provider.email }), - ...(provider.plan === undefined ? {} : { plan: provider.plan }), - }, - }); - }, - receipt: (value) => loginCancelReceiptSchema.parse(value), - restore: (value) => loginCancelReceiptSchema.parse(value), - commit: (attemptId, value, recorded) => { - this.#store.settlePendingLogin({ - profileId: profile.id, - processGeneration: profile.processGeneration, - loginId: value.loginId, - providerStatus: value.providerStatus, - provider: { - signedIn: value.provider.signedIn, - ...(value.provider.email === undefined ? {} : { email: value.provider.email }), - ...(value.provider.plan === undefined ? {} : { plan: value.provider.plan }), - }, - }); - if (!this.#store.transitionMutation(attemptId, "effect_started", "applied", recorded)) { - throw new Error("LOGIN_CANCEL_MUTATION_CAS_CONFLICT"); - } - }, - }); return { - account: this.#publicProfile(this.#store.requireProfileById(profile.id)), - loginId: receipt.loginId, - providerStatus: receipt.providerStatus, - status: receipt.provider.signedIn ? "signed_in" : "canceled", - idempotencyKey: key, + authoritativeReread: true, + refresh: { state: "settled", outcome }, }; } - async #logout(selector: string, idempotencyKey: string | undefined, signal: AbortSignal): Promise { - const profile = this.#store.requireProfile(selector); - await this.#assertNoCompactProjectionRecoveryForProfile(profile.id); - this.#work.assertProfileCanChangeAuthority(profile.id, "codex"); - if (this.#store.hasUnsettledSessionMutationAuthority(profile.id, "codex")) { + async #proveUsageAccountIdentity(input: { + profile: ProfileRecord; + expectedFingerprint: string | null; + signal: AbortSignal; + }): Promise { + const account = await this.#fencedEffect(async () => + await this.#codex.readAccount({ + authority: authorityFor(this.#paths, input.profile), + signal: input.signal, + })); + const verifiedEmail = !account.signedIn || account.email === undefined + ? null + : account.email; + if (account.signedIn && verifiedEmail === null) { + this.#scheduleProfilePersonalAuthorityRevocation(input.profile); + throw new CommandFailure( + "RECOVERY_REQUIRED", + "Codex is signed in but did not expose a stable account identity. HRA is revoking the unprovable authority before any session or usage operation can continue.", + ); + } + const actualFingerprint = verifiedEmail === null + ? null + : digestText(verifiedEmail.trim().toLowerCase()); + const persistedFingerprint = accountFingerprintForProfile(input.profile); + const identityChanged = actualFingerprint === null + || (input.expectedFingerprint !== null + && actualFingerprint !== input.expectedFingerprint) + || (persistedFingerprint !== null + && actualFingerprint !== persistedFingerprint); + if (identityChanged) { + this.#scheduleProfilePersonalAuthorityRevocation(input.profile); throw new CommandFailure( "RECOVERY_REQUIRED", - "This account has an unsettled Codex session start or provider switch. Recover or abandon that session mutation before signing out.", - { provider: "codex", reason: "unsettled_session_mutation", retryable: true }, + "The provider account identity changed. HRA is releasing every controller and retiring the prior generation before accepting another identity.", ); } - const key = idempotencyKey ?? randomUUID(); - if (profile.state === "recovery_required") { - throw new CommandFailure("RECOVERY_REQUIRED", "This account has an indeterminate logout. Run `hra account show` to reconcile its exact provider state before another logout."); + if (verifiedEmail === null) { + throw new Error("ACCOUNT_USAGE_IDENTITY_PROOF_INVALID"); } - await this.#effect({ - kind: "account.logout", - authorityId: profile.id, - authorityGeneration: profile.processGeneration, - request: {}, - idempotencyKey: key, - beginEffect: (attemptId) => { - const begun = this.#store.beginAccountMutationEffect({ - attemptId, - profileId: profile.id, - profileGeneration: profile.processGeneration, - evidence: { kind: "account.logout", baselineSignedIn: profile.state !== "signed_out" }, - workStore: this.#work, - }); - this.#notifyAffectedWork(begun.affectedWorkIds); - }, - effect: async () => { - if (profile.state !== "signed_out") await this.#fencedEffect(async () => await this.#codex.logout({ authority: authorityFor(this.#paths, profile), signal })); - return { loggedOut: true as const }; - }, - receipt: (value) => logoutReceiptSchema.parse(value), - restore: (value) => logoutReceiptSchema.parse(value), - onAmbiguous: () => this.#quarantineProfile(profile), - }); - const current = this.#store.requireProfile(profile.id); - const stateChange = current.state === "signed_out" - ? null - : this.#store.setProfileStateWithWorkRetirement( - profile.id, - profile.processGeneration, - "signed_out", - this.#work, - ); - if (stateChange !== null) this.#notifyAffectedWork(stateChange.affectedWorkIds); - if (stateChange !== null && !stateChange.changed) { - this.#quarantineProfile(profile); - throw new CommandFailure("RECOVERY_REQUIRED", "Codex logged out, but its local account state could not be committed. Run `hra account show` to reconcile it."); + if (input.profile.providerEmail === undefined) { + this.#scheduleProfilePersonalAuthorityRevocation(input.profile); + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The local account had no prior stable identity. HRA fenced this observation; establish the identity through an explicit account login.", + ); } - return { account: this.#publicProfile(this.#store.requireProfile(profile.id)), idempotencyKey: key }; + return actualFingerprint; } - async #usage(selector: string | undefined, refresh: boolean, signal: AbortSignal): Promise { - if (selector === undefined && refresh) { - const usage: unknown[] = []; - for (const profile of this.#store.listProfiles()) { - const value = await this.#serialize(`account:${profile.id}`, async () => - this.#usage(profile.id, true, signal)) as { usage: unknown[] }; - usage.push(...value.usage); + #usageHistory( + command: Extract, + ): unknown { + const profile = this.#store.requireProfile(command.account); + const accountFingerprint = accountFingerprintForProfile(profile); + const now = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).parse(this.#now()); + let fromObservedAt: number; + let throughObservedAt: number; + let afterSourceRevision = 0; + let issuedAt = now; + if (command.cursor !== undefined) { + if (accountFingerprint === null) { + throw new UsageHistoryCursorError( + "Usage-history cursor belongs to an account identity that is no longer verified.", + "account_mismatch", + ); } - return { usage }; - } - const profiles = selector === undefined ? this.#store.listProfiles() : [this.#store.requireProfile(selector)]; - const usage = []; - for (const profile of profiles) { - let automaticResetRefresh: AutomaticRateLimitResetRefreshStatus | undefined; - if (refresh) { - this.#assertSignedIn(profile); - const observed = await this.#readAndRecordUsage(profile, signal); - const refreshedProfile = this.#store.requireProfileById(profile.id); - const reset = await this.#attemptAutomaticRateLimitReset( - refreshedProfile, - observed.accountFingerprint, - observed.snapshot.payload, - signal, + const decoded = this.#usageHistoryCursors.decode(command.cursor, { + accountId: profile.id, + accountFingerprint, + now, + ...(command.fromObservedAt === undefined + ? {} + : { fromObservedAt: command.fromObservedAt }), + ...(command.throughObservedAt === undefined + ? {} + : { throughObservedAt: command.throughObservedAt }), + }); + fromObservedAt = decoded.fromObservedAt; + throughObservedAt = decoded.throughObservedAt; + afterSourceRevision = decoded.afterSourceRevision; + issuedAt = decoded.issuedAt; + } else { + const retentionFloor = Math.max(0, now - USAGE_LOCAL_RETAIN_AGE_MS); + fromObservedAt = command.fromObservedAt ?? retentionFloor; + throughObservedAt = command.throughObservedAt ?? now; + if (fromObservedAt > throughObservedAt) { + throw new CommandFailure( + "INVALID_INPUT", + "Usage history --from must not be later than --through.", ); - automaticResetRefresh = reset.refresh; - if (reset.authoritativeReread) { - // Every closed reset outcome is followed by an authoritative read. - // The provider response itself never substitutes for updated limits. - await this.#readAndRecordUsage( - this.#store.requireProfileById(profile.id), - signal, - ); - } } - const now = this.#now(); - const currentProfile = this.#store.requireProfileById(profile.id); - const automaticResetPolicy = this.#store - .requireAccountRateLimitResetPolicy(profile.id); - const currentFingerprint = accountFingerprintForProfile(currentProfile); - const latestRecorded = currentFingerprint === null - ? null - : this.#store.latestUsageForAccount(profile.id, currentFingerprint); - const latestFailure = currentFingerprint === null - ? null - : this.#store.latestUsagePollFailure(profile.id, currentFingerprint); - const parsedLatest = latestRecorded === null - ? null - : storedAccountUsageSnapshotSchema.safeParse(latestRecorded.payload); - const latest = parsedLatest?.success === true - && currentFingerprint !== null - && parsedLatest.data.observation.accountFingerprint === currentFingerprint - ? latestRecorded + if (throughObservedAt > now) { + throw new CommandFailure( + "INVALID_INPUT", + "Usage history --through must not be in the future.", + ); + } + if (fromObservedAt < retentionFloor || throughObservedAt < retentionFloor) { + throw new CommandFailure( + "INVALID_INPUT", + "Usage history ranges must stay within the retained 24-hour window.", + { retentionFloorObservedAt: retentionFloor, throughObservedAt: now }, + ); + } + } + + if (accountFingerprint === null) { + return accountUsageHistoryPageSchema.parse({ + account: { id: profile.id, label: profile.label }, + range: { fromObservedAt, throughObservedAt }, + entries: [], + nextCursor: null, + }); + } + + const listed = this.#store.usageHistoryPage({ + profileId: profile.id, + accountFingerprint, + fromObservedAt, + throughObservedAt, + afterSourceRevision, + limit: command.limit, + }); + const entries = listed.entries.map((entry) => { + if (entry.state === "failed") { + return accountUsageHistoryEntrySchema.parse(entry); + } + const parsed = storedAccountUsageSnapshotSchema.safeParse(entry.payload); + const observation = parsed.success + && parsed.data.observation.sourceSequence === entry.sourceRevision + && parsed.data.observation.observedAt === entry.observedAt + ? parsed.data.observation : null; - const samples = accountUsageCounterSamples(this.#store.usageRange({ - profileId: profile.id, - fromObservedAt: Math.max(0, now - 30 * 60_000), - throughObservedAt: now, - limit: 2_000, - })).filter((sample) => sample.accountFingerprint === currentFingerprint); - const windows = ["1m", "5m", "15m"] satisfies readonly UsageVelocityWindow[]; - const velocity = Object.fromEntries(windows.map((window) => [ - window, - observedAccountTokenVelocity({ samples, window, now }), - ])); - const parsedStored = latest === null ? null : parsedLatest; - const resetObservation = latest === null - ? { available: false as const, reason: "weekly_window_unavailable" as const } - : automaticRateLimitResetObservation({ - providerPayload: providerUsagePayload(latest.payload), - now, - }); - const automaticResetLastAttempt = publicAutomaticRateLimitResetLastAttempt( - currentFingerprint === null - ? null - : this.#store.latestAccountRateLimitResetAttempt( - currentProfile.id, - currentFingerprint, - ), - ); - usage.push({ - account: this.#publicProfile(currentProfile), - automaticReset: automaticRateLimitResetStatusSchema.parse({ - policy: publicAutomaticRateLimitResetPolicy( - automaticResetPolicy, - currentFingerprint, - ), - threshold: { - remainingPercent: AUTO_RATE_LIMIT_RESET_REMAINING_PERCENT, - usedPercent: AUTO_RATE_LIMIT_RESET_USED_PERCENT, - }, - observation: resetObservation.available - ? { - state: "available", - creditsAvailable: resetObservation.creditsAvailable, - remainingPercent: Math.max(0, 100 - resetObservation.usedPercent), - usedPercent: resetObservation.usedPercent, - weeklyWindowResetsAt: resetObservation.weeklyWindowResetsAt, - } - : { state: "unavailable", reason: resetObservation.reason }, - lastAttempt: automaticResetLastAttempt, - ...(automaticResetRefresh === undefined - ? {} - : { refresh: automaticResetRefresh }), - }), - poll: latestFailure !== null - && (latest === null || latestFailure.sourceRevision > latest.sourceRevision) - ? { state: "failed", ...latestFailure } - : latest === null - ? { state: "never_observed" } - : { - observedAt: latest.observedAt, - sourceRevision: latest.sourceRevision, - state: "observed", - }, - snapshot: latest === null ? null : { - ...latest, - payload: providerUsagePayload(latest.payload), - ...(parsedStored?.success === true - ? { observation: parsedStored.data.observation } - : {}), - }, - velocity, + return accountUsageHistoryEntrySchema.parse({ + state: "observed", + sourceRevision: entry.sourceRevision, + observedAt: entry.observedAt, + receivedAt: observation?.receivedAt ?? null, + lifetimeTokens: observation?.lifetimeTokens ?? null, + gapBefore: observation?.gapBefore ?? null, }); + }); + const nextCursor = listed.nextSourceRevision === null + ? null + : this.#usageHistoryCursors.encode({ + version: 1, + type: "account_usage_history", + accountId: profile.id, + accountFingerprint, + fromObservedAt, + throughObservedAt, + afterSourceRevision: listed.nextSourceRevision, + issuedAt, + }); + return accountUsageHistoryPageSchema.parse({ + account: { id: profile.id, label: profile.label }, + range: { fromObservedAt, throughObservedAt }, + entries, + nextCursor, + }); + } + + async #switchAccount(selector: string, idempotencyKey: string, signal: AbortSignal): Promise { + if (this.#desktop === undefined) throw new CommandFailure("UNAVAILABLE", "Desktop account switching is available only on a supported macOS ChatGPT build."); + const desktop = this.#desktop; + const target = this.#store.requireProfile(selector); + if (target.state !== "signed_in") throw new CommandFailure("CONFLICT", "The target account is not signed in."); + const result = await this.#fencedEffect(async () => await desktop.switchAccount({ idempotencyKey, target: authorityFor(this.#paths, target), signal })); + if (result.status === "recovery_required") { + throw new CommandFailure( + "RECOVERY_REQUIRED", + result.diagnostic ?? "Desktop account switch requires recovery.", + { idempotencyKey: result.idempotencyKey, action: "hra account switch-recover" }, + ); } - return { usage }; + return result; } - async #readAndRecordUsage( - profile: ProfileRecord, + async #recoverDesktopSwitch(signal: AbortSignal): Promise { + if (this.#desktop === undefined) { + throw new CommandFailure("UNAVAILABLE", "Desktop account switching is available only on a supported macOS ChatGPT build."); + } + const desktop = this.#desktop; + return await this.#fencedEffect(async () => await desktop.recoverSwitch({ signal })); + } + + async #assertCompactProjectionRecoveryReady( + expected: Readonly<{ + acknowledgeGap: true; + idempotencyKey: string; + processGeneration: number; + profileId: ProfileRecord["id"]; + providerThreadId: string; + sessionId: SessionRecord["id"]; + }>, + ): Promise { + await this.#daemonAuthority.assertCurrent(); + const session = this.#requireBoundSession(expected.sessionId); + const profile = this.#store.requireProfileById(expected.profileId); + if ( + session.profileId !== expected.profileId + || session.providerThreadId !== expected.providerThreadId + || profile.processGeneration !== expected.processGeneration + ) { + throw new CommandFailure("CONFLICT", "The projection recovery authority changed before admission."); + } + this.#assertEstablishedSessionAccount(profile, session); + if (session.state !== "idle" || session.activeTurnId !== undefined) { + throw new CommandFailure("CONFLICT", "Projection recovery requires an idle session with no active turn."); + } + const unsettledMutations = this.#store.listUnsettledMutations({ sessionId: session.id }); + const unsettledQueueEffects = this.#store.listUnsettledQueueEffects(session.id); + const unsettledQueueEntries = this.#store.listQueue(session.id) + .filter((entry) => entry.state === "pending" || entry.state === "dispatching" || entry.state === "ambiguous"); + if (unsettledMutations.length > 0 || unsettledQueueEffects.length > 0 || unsettledQueueEntries.length > 0) { + throw new CommandFailure("RECOVERY_REQUIRED", "Projection recovery rejects a session with unsettled mutation or queue authority."); + } + } + + async #recoverCompactProjection( + expected: Readonly<{ + acknowledgeGap: true; + idempotencyKey: string; + processGeneration: number; + profileId: ProfileRecord["id"]; + providerThreadId: string; + sessionId: SessionRecord["id"]; + }>, signal: AbortSignal, - ): Promise>; - }>> { - let verifiedProfile = this.#store.requireProfileById(profile.id); - const expectedFingerprint = accountFingerprintForProfile(verifiedProfile); - const accountFingerprint = await this.#proveUsageAccountIdentity({ - profile: verifiedProfile, - expectedFingerprint, + ): Promise { + await this.#assertCompactProjectionRecoveryReady(expected); + return await this.#fencedEffect(async () => await this.#cloud.recoverCompactProjection({ + acknowledgeGap: expected.acknowledgeGap, + idempotencyKey: expected.idempotencyKey, + sessionPublicId: expected.sessionId, signal, + })); + } + + #encodeEventCursor(input: { + sessionId: SessionRecord["id"]; + streamEpoch: string; + sequence: number; + }): string { + return this.#eventCursors.encode({ + version: 1, + sessionId: input.sessionId, + streamEpoch: input.streamEpoch, + sequence: input.sequence, }); - verifiedProfile = this.#store.requireProfileById(profile.id); - const sourceSequence = this.#store.allocateNextUsageRevision(profile.id); - let snapshot: Awaited>; + } + + #factsMemoryExpiry(session: SessionRecord): number { + const admittedAt = Math.max(session.updatedAt, this.#now()); + return Math.min(Number.MAX_SAFE_INTEGER, admittedAt + FACTS_MEMORY_SESSION_TTL_MS); + } + + async #ensureFactsMemory(session: SessionRecord): Promise { + if (this.#factsMemory === undefined) return; try { - snapshot = await this.#fencedEffect(async () => - await this.#codex.readUsage({ - authority: authorityFor(this.#paths, verifiedProfile), - signal, - })); - } catch (error: unknown) { - if (!signal.aborted) { - this.#store.recordUsagePollFailure( - profile.id, - accountFingerprint, - sourceSequence, - this.#now(), - "account_usage_read_failed", - ); - } - throw error; + await this.#factsMemory.ensureSession({ + expiresAt: this.#factsMemoryExpiry(session), + ownerId: session.profileId, + sessionId: session.id, + }); + } catch (cause: unknown) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The session facts-memory authority could not be created or reconciled. The provider session remains under its existing HRA authority; retry this exact session operation after reconciling local memory custody.", + { cause: cause instanceof Error ? cause.name : "error", sessionId: session.id }, + ); } - const receivedAt = this.#now(); - const confirmedFingerprint = await this.#proveUsageAccountIdentity({ - profile: verifiedProfile, - expectedFingerprint: accountFingerprint, - signal, - }); - if (confirmedFingerprint !== accountFingerprint) { - throw new Error("ACCOUNT_USAGE_IDENTITY_PROOF_CHANGED_WITHOUT_CONFLICT"); + } + + async #cleanupFactsMemory( + session: SessionRecord, + reason: "abandon" | "archive" | "expired", + ): Promise { + if (this.#factsMemory === undefined) return; + try { + await this.#factsMemory.cleanupSession({ + ownerId: session.profileId, + reason, + sessionId: session.id, + }); + } catch (cause: unknown) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The session facts-memory directory could not be proven fully purged. HRA retained the cleanup authority for an exact retry.", + { cause: cause instanceof Error ? cause.name : "error", sessionId: session.id }, + ); } - verifiedProfile = this.#store.requireProfileById(profile.id); - const previous = this.#store.latestUsageForAccount( - profile.id, - accountFingerprint, - ); - const stored = createStoredAccountUsageSnapshot({ - providerPayload: snapshot.payload, - sourceSequence, - observedAt: snapshot.observedAt, - receivedAt, - accountFingerprint, - providerGeneration: verifiedProfile.processGeneration, - daemonGeneration: this.#daemonGeneration, - previousPayload: previous?.payload ?? null, - }); - this.#store.recordUsage(profile.id, sourceSequence, snapshot.observedAt, stored); - return { accountFingerprint, snapshot }; } - async #attemptAutomaticRateLimitReset( + async #cleanupTerminalFactsMemory( + session: SessionRecord, + reason: "abandon" | "archive" = "archive", + ): Promise { + // A terminal session dispatches nothing more, so its fact epoch is no + // longer consulted. Dropping it keeps the map bounded by live sessions. + this.#forgetSessionFactEpoch(session.id); + this.#terminalFactsMemoryRevision += 1; + await this.#cleanupFactsMemory(session, reason); + } + + async #reconcileCommittedSessionFactsMemory( + session: SessionRecord, + terminalReason: "abandon" | "archive" = "archive", + ): Promise { + if (session.state === "terminal") { + await this.#cleanupTerminalFactsMemory(session, terminalReason); + } else if (session.state !== "recovery_required") { + await this.#ensureFactsMemory(session); + } + } + + async #resumeClaudeSessionAfterExactProcessRelease( + session: SessionRecord, profile: ProfileRecord, - accountFingerprint: string, - providerPayload: unknown, + authority: ProfileAuthority, signal: AbortSignal, - ): Promise { - const now = this.#now(); - const observation = automaticRateLimitResetObservation({ - providerPayload, - now, - }); - const policyDecision = this.#store.authorizeAccountRateLimitResetPolicy({ - profileId: profile.id, - processGeneration: profile.processGeneration, - accountFingerprint, - weeklyWindowDurationMinutes: observation.available - ? CODEX_WEEKLY_RATE_LIMIT_WINDOW_MINUTES - : null, - weeklyWindowResetsAt: observation.available - ? observation.weeklyWindowResetsAt - : null, + ): Promise { + if ( + session.provider !== "claude" + || session.providerThreadId === undefined + ) { + throw new ClaudeSessionObservationError(); + } + const runtimeScope = this.#sessionHasActivePersonalBinding(session) ? "personal" : "managed"; + const priorProcess = this.#store.readClaudeProcessAuthority({ + providerThreadId: session.providerThreadId, + profileId: session.profileId, + runtimeScope, }); - if (policyDecision.decision !== "allow") { - const reason = policyDecision.reason === "weekly_window_unavailable" - && policyDecision.policy.state === "reconciliation_required" - ? "reconciliation_required" as const - : policyDecision.reason; - return { - authoritativeReread: false, - refresh: { state: "suppressed", reason }, - }; + if ( + priorProcess === null + || priorProcess.state !== "released" + || (priorProcess.sessionId !== null && priorProcess.sessionId !== session.id) + ) throw new ClaudeSessionObservationError(); + if (session.projectId === undefined) { + throw new ProviderRuntimeUnavailableError( + "A durable project is required to resume this Claude session.", + ); + } + const runtime = runtimeScope === "personal" + ? this.#personalClaude + : this.#claude; + if (runtime === undefined) { + throw new ProviderRuntimeUnavailableError( + "Claude session control cannot resume this fenced session.", + ); } - if (!observation.available) { - throw new Error("ACCOUNT_RATE_LIMIT_RESET_POLICY_OBSERVATION_MISMATCH"); + const project = this.#store.requireProject(session.projectId); + const projectRoot = await this.#requireUsableProjectRoot(project.rootPath); + const presetSelection = this.#store.requireSessionPresetRequirement(session.id); + if (presetSelection.preset !== session.preset) { + throw new ClaudeSessionObservationError(); } - - this.#store.recoverAccountRateLimitResetAttempts({ - profileId: profile.id, - processGeneration: profile.processGeneration, - accountFingerprint, - weeklyWindowResetsAt: observation.weeklyWindowResetsAt, - }); - let attempt = this.#store.readRecoverableAccountRateLimitReset( - profile.id, - accountFingerprint, + const providerThreadId = session.providerThreadId; + const providerAccountAuthority = this.#store.readSessionProviderAccountAuthority( + session.id, ); - if (attempt?.state === "effect_started") { - return { - authoritativeReread: false, - refresh: { state: "recovery_pending" }, - }; + if ( + providerAccountAuthority === null + || providerAccountAuthority.provider !== "claude" + || providerAccountAuthority.runtimeScope !== runtimeScope + ) throw new ClaudeSessionObservationError(); + await this.#assertPersonalSessionAccountAuthority(session, profile, signal, true); + const exactProviderAccountAuthority = this.#store.readSessionProviderAccountAuthority( + session.id, + ); + if ( + exactProviderAccountAuthority === null + || exactProviderAccountAuthority.provider !== providerAccountAuthority.provider + || exactProviderAccountAuthority.runtimeScope !== providerAccountAuthority.runtimeScope + || exactProviderAccountAuthority.accountKey !== providerAccountAuthority.accountKey + ) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The Claude session account authority changed before its exact-process resume.", + ); } - - const decisionNow = this.#now(); - const decision = automaticRateLimitResetDecision({ - providerPayload, - now: decisionNow, + const launchIntent = this.#store.stageClaudeProcessLaunchIntent({ + providerThreadId, + profileId: session.profileId, + profileGeneration: authority.generation, + runtimeScope, + providerAccountKey: providerAccountAuthority.accountKey, + sessionId: session.id, }); - if (attempt === null && !decision.eligible) { - return { - authoritativeReread: false, - refresh: { state: "not_eligible", reason: decision.reason }, - }; + let claimedIdentity: ClaudeProcessIdentity | undefined; + let projection: Awaited> | undefined; + let claimFailure: Readonly<{ error: unknown }> | undefined; + try { + projection = await this.#fencedEffect(async () => { + const value = await runtime.claimSession({ + authority, + admitProcessIdentity: async (identity) => { + claimedIdentity = await this.#recordClaimedClaudeProcess({ + authority, + providerThreadId, + runtimeScope, + sessionId: session.id, + launchIntent, + identity, + signal, + }); + }, + providerThreadId, + projectRoot, + title: session.title, + preset: session.preset, + requirement: presetSelection.requirement, + fast: session.fastEnabled, + sourceLiveness: "not_live", + signal, + }); + return value; + }); + } catch (error: unknown) { + if (error instanceof DaemonAuthoritySafetyError) throw error; + claimFailure = { error }; } - if (attempt !== null) { - // An ambiguous attempt represents an upstream effect that may already - // have succeeded. Reconcile only that durable idempotency key after the - // policy admits a fresh observation; current credits, usage, and window - // cannot prove whether the earlier dispatch committed. - if (attempt.state !== "ambiguous") { - if ( - decisionNow >= attempt.weeklyWindowResetsAt - || observation.weeklyWindowResetsAt !== attempt.weeklyWindowResetsAt - ) { - this.#store.closeAccountRateLimitReset( - attempt.idempotencyKey, - "weekly_window_changed", + let postClaimAccountFailure: Readonly<{ error: unknown }> | undefined; + try { + await this.#assertPersonalSessionAccountAuthority(session, profile, signal, true); + } catch (error: unknown) { + if (error instanceof DaemonAuthoritySafetyError) throw error; + postClaimAccountFailure = { error }; + } + if (postClaimAccountFailure !== undefined) { + if (claimedIdentity !== undefined) { + try { + await this.#releaseClaudeProcessAuthority( + { providerThreadId, profileId: session.profileId, runtimeScope }, + new AbortController().signal, + ); + } catch (releaseError: unknown) { + if (releaseError instanceof DaemonAuthoritySafetyError) throw releaseError; + throw new IndeterminateLocalCommitError( + "The Claude account changed during exact-process resume and its admitted process could not be released.", + new AggregateError([postClaimAccountFailure.error, releaseError]), ); - return { - authoritativeReread: false, - refresh: { state: "window_changed" }, - }; } - if ( - observation.creditsAvailable < 1 - || observation.usedPercent < AUTO_RATE_LIMIT_RESET_USED_PERCENT - ) { - return { - authoritativeReread: false, - refresh: { - state: "waiting", - reason: observation.creditsAvailable < 1 - ? "credits_unavailable" - : "below_threshold", - }, - }; + } + throw new IndeterminateLocalCommitError( + "The Claude account changed while its exact-process resume was in flight.", + claimFailure === undefined + ? postClaimAccountFailure.error + : new AggregateError([claimFailure.error, postClaimAccountFailure.error]), + ); + } + if (claimedIdentity === undefined) { + if (claimFailure?.error instanceof ClaudeProcessExitUnprovenError) { + throw claimFailure.error; + } + if (claimFailure !== undefined) { + try { + this.#cancelClaudeProcessLaunchIntent(launchIntent); + } catch (cancelError: unknown) { + if (cancelError instanceof DaemonAuthoritySafetyError) throw cancelError; + this.#quarantineSession(session.id); + throw new IndeterminateLocalCommitError( + "Claude rejected exact-process resume, but its launch intent could not be retired.", + new AggregateError([claimFailure.error, cancelError]), + ); } + throw claimFailure.error; } - } else { - if (!decision.eligible) { - return { - authoritativeReread: false, - refresh: { state: "not_eligible", reason: decision.reason }, - }; + throw new ClaudeProcessExitUnprovenError({ + cause: new Error("CLAUDE_PROCESS_IDENTITY_NOT_ADMITTED"), + }); + } + if (claimFailure !== undefined) { + try { + await this.#releaseClaudeProcessAuthority( + { providerThreadId, profileId: session.profileId, runtimeScope }, + new AbortController().signal, + ); + this.#cancelClaudeProcessLaunchIntent(launchIntent); + } catch (releaseError: unknown) { + if (releaseError instanceof DaemonAuthoritySafetyError) throw releaseError; + throw new ClaudeProcessExitUnprovenError({ + cause: new AggregateError([claimFailure.error, releaseError]), + }); + } + throw claimFailure.error; + } + if (projection === undefined) throw new Error("CLAUDE_RESUME_PROJECTION_MISSING"); + try { + const resumedRuntimeProfile = assertClaimedRuntimeProfile({ + authority, + fast: session.fastEnabled, + preset: session.preset, + provider: "claude", + requirement: presetSelection.requirement, + runtimeProfile: projection.effectiveRuntimeProfile, + }); + if ( + projection.providerThreadId !== session.providerThreadId + || projection.projectRoot !== projectRoot + ) { + throw new Error("CLAUDE_FENCED_RESUME_IDENTITY_MISMATCH"); + } + this.#store.bindClaimedClaudeProcessAuthority({ + providerThreadId, + profileId: session.profileId, + sessionId: session.id, + runtimeScope, + identity: claimedIdentity, + }); + this.#store.recordSessionRuntimeProfile({ + sessionId: session.id, + sourceKind: "session_start", + sourceId: `resume_${createHash("sha256") + .update( + `${session.id}\0${String(profile.processGeneration)}` + + `\0${String(projection.effectiveRuntimeProfile.observedAt)}`, + ) + .digest("hex")}`, + profile: resumedRuntimeProfile, + }); + const observation = await this.#fencedEffect(async () => await runtime.observeSession({ + authority, + providerThreadId, + signal, + })); + return observation; + } catch (error: unknown) { + if (error instanceof DaemonAuthoritySafetyError) throw error; + try { + await this.#releaseClaudeProcessAuthority( + { providerThreadId, profileId: session.profileId, runtimeScope }, + new AbortController().signal, + ); + this.#cancelClaudeProcessLaunchIntent(launchIntent); + } catch (releaseError: unknown) { + if (releaseError instanceof DaemonAuthoritySafetyError) throw releaseError; + throw new ClaudeProcessExitUnprovenError({ + cause: new AggregateError([error, releaseError]), + }); } + throw error; } + } + async #releaseAndResumeClaudeSessionAfterObservationFailure( + session: SessionRecord, + authority: ProfileAuthority, + signal: AbortSignal, + ): Promise { + if (session.provider !== "claude" || session.providerThreadId === undefined) { + throw new ClaudeSessionObservationError(); + } + const runtimeScope = this.#sessionHasActivePersonalBinding(session) + ? "personal" + : "managed"; + // Observation can fail after a newly started child was durably bound but + // before a connection id reached the in-memory routing map. Release by + // exact persisted PID/start authority, independent of caller cancellation, + // before attempting the ordinary exact-session resume path. + await this.#releaseClaudeProcessAuthority( + { + providerThreadId: session.providerThreadId, + profileId: session.profileId, + runtimeScope, + }, + new AbortController().signal, + ); + signal.throwIfAborted(); await this.#daemonAuthority.assertCurrent(); - if (signal.aborted) throw signal.reason; - const confirmedFingerprint = await this.#proveUsageAccountIdentity({ + const current = this.#currentObservationSession( + authority, + session.id, + session.providerThreadId, + ); + if (current === null || current.provider !== "claude") { + throw new ClaudeSessionObservationError(); + } + const profile = this.#store.requireProfileById(current.profileId); + return await this.#resumeClaudeSessionAfterExactProcessRelease( + current, profile, - expectedFingerprint: accountFingerprint, + authority, signal, - }); - if (confirmedFingerprint !== accountFingerprint) { - throw new Error("ACCOUNT_RATE_LIMIT_RESET_IDENTITY_PROOF_CHANGED_WITHOUT_CONFLICT"); - } - const dispatchProfile = this.#store.requireProfileById(profile.id); - if ( - dispatchProfile.processGeneration !== profile.processGeneration - || accountFingerprintForProfile(dispatchProfile) !== accountFingerprint - ) throw new Error("ACCOUNT_RATE_LIMIT_RESET_AUTHORITY_CHANGED"); - const dispatchPolicyDecision = this.#store.authorizeAccountRateLimitResetPolicy({ - profileId: dispatchProfile.id, - processGeneration: dispatchProfile.processGeneration, - accountFingerprint, - weeklyWindowDurationMinutes: CODEX_WEEKLY_RATE_LIMIT_WINDOW_MINUTES, - weeklyWindowResetsAt: observation.weeklyWindowResetsAt, - }); - if (dispatchPolicyDecision.decision !== "allow") { - const reason = dispatchPolicyDecision.reason === "weekly_window_unavailable" - && dispatchPolicyDecision.policy.state === "reconciliation_required" - ? "reconciliation_required" as const - : dispatchPolicyDecision.reason; + ); + } + + async #ensureSessionObservedLocked( + selector: string, + signal: AbortSignal, + ): Promise { + let session = this.#store.requireSession(selector); + const profile = this.#store.requireProfileById(session.profileId); + if (session.providerThreadId === undefined) { return { - authoritativeReread: false, - refresh: { state: "suppressed", reason }, + basis: "local_state", + coverage: "not_attempted", + freshness: "unknown", + observedAt: this.#now(), + profileGeneration: profile.processGeneration, + reason: "unbound", + source: "codex_app_server", + state: "not_applicable", }; } - if ( - attempt !== null - && attempt.currentProcessGeneration !== dispatchProfile.processGeneration - ) { - attempt = this.#store.rebindAccountRateLimitReset({ - idempotencyKey: attempt.idempotencyKey, - expectedCurrentProcessGeneration: attempt.currentProcessGeneration, - nextProcessGeneration: dispatchProfile.processGeneration, - accountFingerprint, - }); - } - if (attempt === null) { - if (!decision.eligible) { - throw new Error("ACCOUNT_RATE_LIMIT_RESET_DECISION_CHANGED_WITHOUT_ASYNC_GAP"); - } - attempt = this.#store.prepareAccountRateLimitReset({ - profileId: dispatchProfile.id, - processGeneration: dispatchProfile.processGeneration, - accountFingerprint, - weeklyWindowResetsAt: decision.weeklyWindowResetsAt, - observedUsedPercent: decision.usedPercent, - }); - } - // prepareAccountRateLimitReset returns an existing terminal latch for the - // same account/window. Re-check here so a settled or locally closed - // logical redemption can never cross the provider mutation boundary. - if (attempt.state === "settled") { - if (attempt.outcome === null) { - throw new Error("ACCOUNT_RATE_LIMIT_RESET_SETTLED_OUTCOME_MISSING"); - } + if (session.state === "terminal") { + await this.#cleanupTerminalFactsMemory(session); return { - authoritativeReread: false, - refresh: { state: "latched", outcome: attempt.outcome }, + basis: "local_state", + coverage: "not_attempted", + freshness: "unknown", + observedAt: this.#now(), + profileGeneration: profile.processGeneration, + reason: "terminal", + source: "codex_app_server", + state: "not_applicable", }; } - if (attempt.state === "closed") { - if (attempt.localResolution === null) { - throw new Error("ACCOUNT_RATE_LIMIT_RESET_CLOSED_RESOLUTION_MISSING"); - } + if (session.state === "recovery_required") { return { - authoritativeReread: false, - refresh: { state: "latched", reason: attempt.localResolution }, + basis: "local_state", + code: "session_quarantined", + coverage: "partial", + freshness: "fresh", + observedAt: this.#now(), + profileGeneration: profile.processGeneration, + source: "codex_app_server", + state: "recovery_required", }; } - if (attempt.state === "effect_started") { + await this.#ensureFactsMemory(session); + if ( + session.provider === "claude" + && this.#platform !== "linux" + && !this.#sessionHasMatchingActivePersonalBinding(session) + ) { + return this.#claudePlatformUnavailableObservation(profile); + } + if (!this.#profileAllowsEstablishedSession(profile, session)) { return { - authoritativeReread: false, - refresh: { state: "recovery_pending" }, + basis: "local_state", + code: "account_signed_out", + coverage: "unavailable", + freshness: "fresh", + observedAt: this.#now(), + profileGeneration: profile.processGeneration, + source: "codex_app_server", + state: "unavailable", }; } - - signal.throwIfAborted(); - const begun = this.#store.beginAccountRateLimitReset(attempt.idempotencyKey); - if (begun.state !== "effect_started") { - throw new Error("ACCOUNT_RATE_LIMIT_RESET_BEGIN_STATE_INVALID"); + await this.#assertPersonalSessionAccountAuthority(session, profile, signal, true); + if (this.#lastSessionEventIsProviderGap(session.id)) { + this.#sessionsAwaitingResubscription.add(session.id); } - let outcome: Awaited>; + const projectionRecoveryUnsettled = await this.#cloud + .isCompactProjectionRecoveryUnsettled(session.id); + await this.#daemonAuthority.assertCurrent(); + const observationFactEpoch = this.#snapshotSessionFactEpoch(session.id); + const providerThreadId = session.providerThreadId; + const authority = this.#authorityForSession(session, profile); + let observation: CodexSessionObservation; try { - outcome = await this.#codex.consumeRateLimitReset({ - authority: authorityFor(this.#paths, dispatchProfile), - idempotencyKey: attempt.idempotencyKey, + observation = await this.#fencedEffect(async () => await this.#runtimeForSession(session).observeSession({ + authority, + providerThreadId, signal, - }); - } catch (providerError: unknown) { - const retryState = providerError instanceof IndeterminateCodexEffectError - ? "ambiguous" - : "retryable"; - try { - // Every failure retains the original key. An indeterminate effect can - // bypass ordinary eligibility only after durable policy authorization; - // determinate failures return through the ordinary window gates. - this.#store.deferAccountRateLimitReset(attempt.idempotencyKey, retryState); - } catch (journalError: unknown) { - this.#failStopAfterResetJournalFailure( - "Automatic reset recovery evidence could not be committed.", + })); + } catch (error: unknown) { + if (error instanceof DaemonAuthoritySafetyError) throw error; + if (signal.aborted) throw signal.reason; + if (error instanceof ClaudeSessionObservationError) { + try { + observation = await this.#releaseAndResumeClaudeSessionAfterObservationFailure( + session, + authority, + signal, + ); + } catch (resumeError: unknown) { + if (resumeError instanceof DaemonAuthoritySafetyError) throw resumeError; + await this.#assertSessionAccountAuthorityAfterProviderEffect( + session, + profile, + signal, + ); + if (!(resumeError instanceof ClaudeProcessExitUnprovenError)) throw resumeError; + this.#quarantineSession(session.id); + throw new CommandFailure( + "RECOVERY_REQUIRED", + "Claude session recovery launched a controller whose exit could not be proved. HRA retained its exact launch authority and quarantined the session.", + { sessionId: session.id }, + ); + } + } else { + await this.#assertSessionAccountAuthorityAfterProviderEffect( + session, + profile, + signal, ); - throw new AggregateError( - [providerError, journalError], - "An automatic reset may have reached Codex and its recovery state could not be committed.", + const exact = this.#currentObservationSession( + authority, + session.id, + providerThreadId, ); + if (exact === null) { + return { + basis: "provider_read", + code: "resume_unavailable", + coverage: "unavailable", + freshness: "fresh", + observedAt: this.#now(), + profileGeneration: this.#currentProfileGeneration(authority), + source: "codex_app_server", + state: "unavailable", + }; + } + if ( + error instanceof CodexSessionObservationError + && error.reason === "thread_mismatch" + ) { + return this.#quarantineObservationMismatch(authority, exact); + } + if (!(error instanceof CodexSessionObservationError)) throw error; + this.#recordSessionObservationFailure(authority, exact, "resume_unavailable", false); + return { + basis: "provider_read", + code: "resume_unavailable", + coverage: "unavailable", + freshness: "fresh", + observedAt: this.#now(), + profileGeneration: profile.processGeneration, + source: "codex_app_server", + state: "unavailable", + }; } - // A successful usage read remains successful. A later refresh can retry - // only this exact durable upstream key after policy authorization. + } + await this.#daemonAuthority.assertCurrent(); + session = this.#store.requireSession(session.id); + const currentProfile = this.#store.requireProfileById(profile.id); + if ( + session.profileId !== profile.id + || session.providerThreadId === undefined + || session.providerThreadId !== observation.projection.providerThreadId + || currentProfile.processGeneration !== profile.processGeneration + || !this.#profileAllowsEstablishedSession(currentProfile, session) + ) { + if ( + this.#currentObservationSession(authority, session.id, providerThreadId) !== null + && observation.projection.providerThreadId !== providerThreadId + ) return this.#quarantineObservationMismatch(authority, session); return { - authoritativeReread: false, - refresh: { - state: retryState === "ambiguous" ? "recovery_pending" : "retry_pending", - }, + basis: "provider_read", + code: "resume_unavailable", + coverage: "unavailable", + freshness: "fresh", + observedAt: this.#now(), + profileGeneration: currentProfile.processGeneration, + source: "codex_app_server", + state: "unavailable", }; } - try { - this.#store.settleAccountRateLimitReset(attempt.idempotencyKey, outcome); - } catch (journalError: unknown) { - this.#failStopAfterResetJournalFailure( - "An automatic reset outcome could not be committed.", - ); - throw new AggregateError( - [journalError], - `Codex returned the automatic reset outcome ${outcome}, but HRA could not commit it.`, - ); + await this.#assertPersonalSessionAccountAuthority( + session, + currentProfile, + signal, + true, + ); + z.string().uuid().parse(observation.connectionId); + this.#sessionObservationFailures.delete(session.id); + this.#ensureSessionProviderConnection(authority, session, observation.connectionId); + const projection = observation.projection; + if ( + !projectionRecoveryUnsettled + && !this.#projectionRecoveriesInFlight.has(session.id) + && this.#currentSessionFactEpoch(session.id) === observationFactEpoch + ) { + const beforeState = session.state; + const beforeActiveTurnId = session.activeTurnId ?? null; + const reconciled = this.#store.reconcileSessionFromProvider({ + sessionId: session.id, + state: projection.status, + activeTurnId: projection.status === "active" + ? projection.activeTurnId ?? null + : null, + // Claude's runtime projection deliberately keeps only a compact + // display title. The durable HRA title may be longer, so observing a + // resumed Claude process must not truncate it. + ...(session.provider === "claude" ? {} : { title: projection.title }), + }); + if ( + reconciled.state !== beforeState + || (reconciled.activeTurnId ?? null) !== beforeActiveTurnId + ) { + this.#appendSessionEvent(authority, reconciled.id, observation.connectionId, { + type: "session_status", + status: projection.status, + activeTurnId: reconciled.activeTurnId ?? null, + }); + } + await this.#reconcileCommittedSessionFactsMemory(reconciled); } + const mode = this.#sessionResubscriptionConnections.get(session.id) === observation.connectionId + ? "resubscribed" + : "connected"; return { - authoritativeReread: true, - refresh: { state: "settled", outcome }, + basis: "provider_read", + connectionId: observation.connectionId, + coverage: "complete", + freshness: "fresh", + mode, + observedAt: this.#now(), + profileGeneration: profile.processGeneration, + source: "codex_app_server", + state: "live", }; } - async #proveUsageAccountIdentity(input: { - profile: ProfileRecord; - expectedFingerprint: string | null; - signal: AbortSignal; - }): Promise { - const account = await this.#fencedEffect(async () => - await this.#codex.readAccount({ - authority: authorityFor(this.#paths, input.profile), - signal: input.signal, - })); - const verifiedEmail = !account.signedIn || account.email === undefined - ? null - : account.email; - if (account.signedIn && verifiedEmail === null) { + #currentObservationSession( + authority: ProfileAuthority, + sessionId: SessionRecord["id"], + providerThreadId: string, + ): SessionRecord | null { + try { + const profile = this.#store.requireProfileById(authority.id); + const session = this.#store.requireSession(sessionId); + return profile.processGeneration === authority.generation + && this.#profileAllowsEstablishedSession(profile, session) + && session.profileId === authority.id + && session.providerThreadId === providerThreadId + && session.state !== "terminal" + ? session + : null; + } catch (error: unknown) { + if (error instanceof SelectionError && error.code === "NOT_FOUND") return null; + throw error; + } + } + + #currentProfileGeneration(authority: ProfileAuthority): number { + try { + return this.#store.requireProfileById(authority.id).processGeneration; + } catch (error: unknown) { + if (error instanceof SelectionError && error.code === "NOT_FOUND") { + return authority.generation; + } + throw error; + } + } + + #recordSessionObservationFailure( + authority: ProfileAuthority, + session: SessionRecord, + code: "resume_unavailable", + terminal: boolean, + ): void { + const marker = `${String(authority.generation)}:${code}`; + if (this.#sessionObservationFailures.get(session.id) === marker) return; + this.#sessionObservationFailures.set(session.id, marker); + this.#appendSessionEvent(authority, session.id, null, terminal + ? { + type: "error", + code: "provider_resume_unavailable", + message: "Provider observation is unavailable; HRA will not follow a stale event stream.", + terminal: true, + } + : { + type: "warning", + code: "provider_resume_unavailable", + message: "Provider observation is unavailable; HRA will not follow a stale event stream.", + }); + } + + #quarantineObservationMismatch( + authority: ProfileAuthority, + session: SessionRecord, + ): PublicProviderObservation { + this.#quarantineSession(session.id); + const marker = `${String(authority.generation)}:thread_mismatch`; + if (this.#sessionObservationFailures.get(session.id) !== marker) { + this.#sessionObservationFailures.set(session.id, marker); + this.#appendSessionEvent(authority, session.id, null, { + type: "error", + code: "provider_thread_mismatch", + message: "Provider observation returned a different thread; the session is quarantined.", + terminal: true, + }); + } + return { + basis: "provider_read", + code: "thread_mismatch", + coverage: "partial", + freshness: "fresh", + observedAt: this.#now(), + profileGeneration: authority.generation, + source: "codex_app_server", + state: "recovery_required", + }; + } + + #requireLiveProviderObservation(observation: PublicProviderObservation): void { + if (observation.state === "live") return; + if (observation.state === "recovery_required") { throw new CommandFailure( - "UNAVAILABLE", - "Codex is signed in but did not expose an account email, so HRA skipped the usage refresh and automatic reset.", + "RECOVERY_REQUIRED", + "The provider thread could not be observed under this session's exact authority; the session is quarantined.", + { providerObservation: observation }, ); } - const actualFingerprint = verifiedEmail === null - ? null - : digestText(verifiedEmail.trim().toLowerCase()); - const persistedFingerprint = accountFingerprintForProfile(input.profile); - const identityChanged = actualFingerprint === null - || (input.expectedFingerprint !== null - && actualFingerprint !== input.expectedFingerprint) - || (persistedFingerprint !== null - && actualFingerprint !== persistedFingerprint); - if (identityChanged) { - const stateChange = this.#store.setProfileStateWithWorkRetirement( - input.profile.id, - input.profile.processGeneration, - account.signedIn ? "signed_in" : "signed_out", - this.#work, - { - ...(verifiedEmail === null ? {} : { email: verifiedEmail }), - ...(account.plan === undefined ? {} : { plan: account.plan }), - }, - ); - this.#notifyAffectedWork(stateChange.affectedWorkIds); - if (!stateChange.changed) { - throw new CommandFailure( - "CONFLICT", - "Account generation changed while reconciling usage identity.", - ); - } + if (observation.state === "unavailable") { throw new CommandFailure( - "CONFLICT", - "The signed-in Codex account changed during usage refresh. HRA reconciled the account and discarded the unverified usage result; run the usage refresh again.", - ); - } - if (verifiedEmail === null) { - throw new Error("ACCOUNT_USAGE_IDENTITY_PROOF_INVALID"); - } - if (input.profile.providerEmail === undefined) { - const stateChange = this.#store.setProfileStateWithWorkRetirement( - input.profile.id, - input.profile.processGeneration, - "signed_in", - this.#work, - { - email: verifiedEmail, - ...(account.plan === undefined ? {} : { plan: account.plan }), - }, + "UNAVAILABLE", + observation.code === "provider_platform_unavailable" + ? `Claude session processes are acceptance-pending on ${this.#platform}. HRA retained the local session but will not contact Claude outside Linux.` + : "The provider thread is not currently observable; HRA will not use stale session state.", + { providerObservation: observation }, ); - this.#notifyAffectedWork(stateChange.affectedWorkIds); - if (!stateChange.changed) { - throw new Error("ACCOUNT_USAGE_IDENTITY_COMMIT_CONFLICT"); - } } - return actualFingerprint; + throw new CommandFailure( + observation.reason === "terminal" ? "CONFLICT" : "RECOVERY_REQUIRED", + observation.reason === "terminal" + ? "The session is terminal and has no live provider observation." + : "The session has no proven provider binding.", + { providerObservation: observation }, + ); } - #usageHistory( - command: Extract, - ): unknown { - const profile = this.#store.requireProfile(command.account); - const accountFingerprint = accountFingerprintForProfile(profile); - const now = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).parse(this.#now()); - let fromObservedAt: number; - let throughObservedAt: number; - let afterSourceRevision = 0; - let issuedAt = now; - if (command.cursor !== undefined) { - if (accountFingerprint === null) { - throw new UsageHistoryCursorError( - "Usage-history cursor belongs to an account identity that is no longer verified.", - "account_mismatch", - ); - } - const decoded = this.#usageHistoryCursors.decode(command.cursor, { - accountId: profile.id, - accountFingerprint, - now, - ...(command.fromObservedAt === undefined - ? {} - : { fromObservedAt: command.fromObservedAt }), - ...(command.throughObservedAt === undefined - ? {} - : { throughObservedAt: command.throughObservedAt }), - }); - fromObservedAt = decoded.fromObservedAt; - throughObservedAt = decoded.throughObservedAt; - afterSourceRevision = decoded.afterSourceRevision; - issuedAt = decoded.issuedAt; - } else { - const retentionFloor = Math.max(0, now - USAGE_LOCAL_RETAIN_AGE_MS); - fromObservedAt = command.fromObservedAt ?? retentionFloor; - throughObservedAt = command.throughObservedAt ?? now; - if (fromObservedAt > throughObservedAt) { - throw new CommandFailure( - "INVALID_INPUT", - "Usage history --from must not be later than --through.", - ); - } - if (throughObservedAt > now) { - throw new CommandFailure( - "INVALID_INPUT", - "Usage history --through must not be in the future.", - ); - } - if (fromObservedAt < retentionFloor || throughObservedAt < retentionFloor) { - throw new CommandFailure( - "INVALID_INPUT", - "Usage history ranges must stay within the retained 24-hour window.", - { retentionFloorObservedAt: retentionFloor, throughObservedAt: now }, - ); - } - } - - if (accountFingerprint === null) { - return accountUsageHistoryPageSchema.parse({ - account: { id: profile.id, label: profile.label }, - range: { fromObservedAt, throughObservedAt }, - entries: [], - nextCursor: null, - }); - } - - const listed = this.#store.usageHistoryPage({ - profileId: profile.id, - accountFingerprint, - fromObservedAt, - throughObservedAt, - afterSourceRevision, - limit: command.limit, + async #sessionStatus( + sessionId: SessionRecord["id"], + signal: AbortSignal, + ): Promise { + const providerObservation = await this.#ensureSessionObservedLocked(sessionId, signal); + const snapshot = this.#store.readSessionObservationSnapshot( + sessionId, + SESSION_STATUS_PENDING_SUMMARY_LIMIT, + ); + return sessionStatusSchema.parse({ + version: 2, + session: snapshot.session, + advisory: { + execution: snapshot.session.execution, + attention: deriveSessionAttention({ + execution: snapshot.session.execution, + localCoverage: "complete", + pendingInteractionCount: snapshot.interactions.pendingCount, + responseInFlightCount: snapshot.interactions.responseInFlightCount, + }), + queueDepth: snapshot.queue.depth, + }, + localObservation: { + source: "sqlite", + coverage: "complete", + freshness: "fresh", + observedAt: snapshot.observedAt, + }, + providerObservation, + eventStream: { + cursor: this.#encodeEventCursor({ + sessionId, + streamEpoch: snapshot.eventStream.streamEpoch, + sequence: snapshot.eventStream.observedThroughSequence, + }), + retentionFloorCursor: this.#encodeEventCursor({ + sessionId, + streamEpoch: snapshot.eventStream.streamEpoch, + sequence: Math.max(0, snapshot.eventStream.floorSequence - 1), + }), + streamEpoch: snapshot.eventStream.streamEpoch, + floorSequence: snapshot.eventStream.floorSequence, + observedThroughSequence: snapshot.eventStream.observedThroughSequence, + }, + interactions: snapshot.interactions, + queue: snapshot.queue, }); - const entries = listed.entries.map((entry) => { - if (entry.state === "failed") { - return accountUsageHistoryEntrySchema.parse(entry); + } + + #interactionPage(input: Readonly<{ + cursor?: string; + limit: number; + pending: boolean; + sessionId?: SessionRecord["id"]; + }>): Readonly<{ + interactions: readonly PublicInteraction[]; + nextCursor: string | null; + sessionId: SessionRecord["id"] | null; + }> { + const scope: InteractionCursorScope = input.sessionId === undefined + ? { type: "global" } + : { type: "session", sessionId: input.sessionId }; + let after: Readonly<{ publicId: string; requestedAt: number }> | undefined; + if (input.cursor !== undefined) { + try { + const decoded = this.#eventCursors.decodeInteraction(input.cursor, { + scope, + pending: input.pending, + }); + after = { requestedAt: decoded.requestedAt, publicId: decoded.publicId }; + } catch (error: unknown) { + if (error instanceof SessionEventCursorError) { + throw new CommandFailure( + "INVALID_INPUT", + "The interaction cursor is invalid for this exact interaction listing.", + ); + } + throw error; } - const parsed = storedAccountUsageSnapshotSchema.safeParse(entry.payload); - const observation = parsed.success - && parsed.data.observation.sourceSequence === entry.sourceRevision - && parsed.data.observation.observedAt === entry.observedAt - ? parsed.data.observation - : null; - return accountUsageHistoryEntrySchema.parse({ - state: "observed", - sourceRevision: entry.sourceRevision, - observedAt: entry.observedAt, - receivedAt: observation?.receivedAt ?? null, - lifetimeTokens: observation?.lifetimeTokens ?? null, - gapBefore: observation?.gapBefore ?? null, - }); + } + const page = this.#store.listInteractionPage({ + ...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }), + pendingOnly: input.pending, + limit: input.limit, + ...(after === undefined ? {} : { after }), }); - const nextCursor = listed.nextSourceRevision === null + const nextCursor = page.nextPosition === null ? null - : this.#usageHistoryCursors.encode({ + : this.#eventCursors.encodeInteraction({ version: 1, - type: "account_usage_history", - accountId: profile.id, - accountFingerprint, - fromObservedAt, - throughObservedAt, - afterSourceRevision: listed.nextSourceRevision, - issuedAt, + type: "interaction", + scope, + pending: input.pending, + requestedAt: page.nextPosition.requestedAt, + publicId: page.nextPosition.publicId, }); - return accountUsageHistoryPageSchema.parse({ - account: { id: profile.id, label: profile.label }, - range: { fromObservedAt, throughObservedAt }, - entries, + return { + sessionId: input.sessionId ?? null, + interactions: page.interactions.map((interaction) => this.#publicInteraction(interaction)), nextCursor, - }); + }; } - async #switchAccount(selector: string, idempotencyKey: string, signal: AbortSignal): Promise { - if (this.#desktop === undefined) throw new CommandFailure("UNAVAILABLE", "Desktop account switching is available only on a supported macOS ChatGPT build."); - const desktop = this.#desktop; - const target = this.#store.requireProfile(selector); - if (target.state !== "signed_in") throw new CommandFailure("CONFLICT", "The target account is not signed in."); - const result = await this.#fencedEffect(async () => await desktop.switchAccount({ idempotencyKey, target: authorityFor(this.#paths, target), signal })); - if (result.status === "recovery_required") { - throw new CommandFailure( - "RECOVERY_REQUIRED", - result.diagnostic ?? "Desktop account switch requires recovery.", - { idempotencyKey: result.idempotencyKey, action: "hra account switch-recover" }, - ); + async #sessionEvents( + command: Extract, + signal: AbortSignal, + ): Promise { + const selected = this.#store.requireSession(command.session); + const decodedCursor = command.cursor === undefined + ? undefined + : this.#eventCursors.decode(command.cursor); + if (decodedCursor !== undefined && decodedCursor.sessionId !== selected.id) { + throw new CommandFailure("INVALID_INPUT", "The session event cursor belongs to another session."); } - return result; - } - - async #recoverDesktopSwitch(signal: AbortSignal): Promise { - if (this.#desktop === undefined) { - throw new CommandFailure("UNAVAILABLE", "Desktop account switching is available only on a supported macOS ChatGPT build."); + const providerObservation = await this.#serializeSessionAuthority( + selected, + async () => await this.#ensureSessionObservedLocked(selected.id, signal), + { allowDuringProjectionRecovery: true }, + ); + const session = this.#store.requireSession(selected.id); + let requestedSequence: number | null = null; + let restoredRequestedSequence: number | null = null; + let streamRestored = false; + if (decodedCursor !== undefined) { + const current = this.#store.eventStreamPosition(session.id); + if (decodedCursor.streamEpoch !== current.streamEpoch) { + streamRestored = true; + restoredRequestedSequence = decodedCursor.sequence; + } else { + requestedSequence = decodedCursor.sequence; + } } - const desktop = this.#desktop; - return await this.#fencedEffect(async () => await desktop.recoverSwitch({ signal })); - } - async #recoverCompactProjection( - expected: Readonly<{ - acknowledgeGap: true; - idempotencyKey: string; - processGeneration: number; - profileId: ProfileRecord["id"]; - providerThreadId: string; - sessionId: SessionRecord["id"]; - }>, - signal: AbortSignal, - ): Promise { - await this.#daemonAuthority.assertCurrent(); - const session = this.#requireBoundSession(expected.sessionId); - const profile = this.#store.requireProfileById(expected.profileId); + let listed = this.#store.listSessionEvents({ + sessionId: session.id, + afterSequence: requestedSequence, + limit: command.limit, + }); if ( - session.profileId !== expected.profileId - || session.providerThreadId !== expected.providerThreadId - || profile.processGeneration !== expected.processGeneration + providerObservation.state === "live" + && !streamRestored + && listed.events.length === 0 + && command.waitMs > 0 ) { - throw new CommandFailure("CONFLICT", "The projection recovery authority changed before admission."); - } - this.#assertEstablishedSessionAccount(profile, session); - if (session.state !== "idle" || session.activeTurnId !== undefined) { - throw new CommandFailure("CONFLICT", "Projection recovery requires an idle session with no active turn."); + await this.#eventWaiters.wait({ + sessionId: session.id, + expectedObservedThrough: listed.observedThroughSequence, + waitMs: command.waitMs, + signal, + readObservedThrough: () => + this.#store.eventStreamPosition(session.id).observedThroughSequence, + }); + listed = this.#store.listSessionEvents({ + sessionId: session.id, + afterSequence: requestedSequence, + limit: command.limit, + }); } - const unsettledMutations = this.#store.listUnsettledMutations({ sessionId: session.id }); - const unsettledQueueEffects = this.#store.listUnsettledQueueEffects(session.id); - const unsettledQueueEntries = this.#store.listQueue(session.id) - .filter((entry) => entry.state === "pending" || entry.state === "dispatching" || entry.state === "ambiguous"); - if (unsettledMutations.length > 0 || unsettledQueueEffects.length > 0 || unsettledQueueEntries.length > 0) { - throw new CommandFailure("RECOVERY_REQUIRED", "Projection recovery rejects a session with unsettled mutation or queue authority."); + if ( + listed.events.length === 0 + && !streamRestored + && listed.gapReason === null + && providerObservation.state !== "live" + ) { + this.#requireLiveProviderObservation(providerObservation); } - return await this.#fencedEffect(async () => await this.#cloud.recoverCompactProjection({ - acknowledgeGap: expected.acknowledgeGap, - idempotencyKey: expected.idempotencyKey, - sessionPublicId: session.id, - signal, - })); + const gapCheckpointSequence = Math.max(0, listed.floorSequence - 1); + const nextSequence = listed.events.at(-1)?.sequence + ?? (streamRestored || listed.gapReason !== null + ? gapCheckpointSequence + : requestedSequence ?? gapCheckpointSequence); + const page = { + version: 1 as const, + sessionId: session.id, + requestedCursor: command.cursor ?? null, + retentionFloorCursor: this.#encodeEventCursor({ + sessionId: session.id, + streamEpoch: listed.streamEpoch, + sequence: Math.max(0, listed.floorSequence - 1), + }), + observedThroughCursor: this.#encodeEventCursor({ + sessionId: session.id, + streamEpoch: listed.streamEpoch, + sequence: listed.observedThroughSequence, + }), + nextCursor: this.#encodeEventCursor({ + sessionId: session.id, + streamEpoch: listed.streamEpoch, + sequence: nextSequence, + }), + gap: streamRestored + ? { + reason: "stream_restored" as const, + requestedSequence: restoredRequestedSequence, + retainedFromSequence: listed.floorSequence, + } + : listed.gapReason === null + ? null + : { + reason: listed.gapReason, + requestedSequence, + retainedFromSequence: listed.floorSequence, + }, + events: [...listed.events], + }; + return sessionEventPageSchema.parse(page); } - #encodeEventCursor(input: { - sessionId: SessionRecord["id"]; - streamEpoch: string; - sequence: number; - }): string { - return this.#eventCursors.encode({ - version: 1, - sessionId: input.sessionId, - streamEpoch: input.streamEpoch, - sequence: input.sequence, - }); + #workSequence(workId: WorkId): number { + const page = this.#work.events(workId, 0, 1); + return this.#eventCursors.decodeWorkEvent( + page.observedThroughCursor, + workId, + ).sequence; } - #factsMemoryExpiry(session: SessionRecord): number { - const admittedAt = Math.max(session.updatedAt, this.#now()); - return Math.min(Number.MAX_SAFE_INTEGER, admittedAt + FACTS_MEMORY_SESSION_TTL_MS); + #notifyWorkIfAdvanced(workId: WorkId, priorSequence: number): void { + if (this.#workSequence(workId) !== priorSequence) this.#workWaiters.notify(workId); } - async #ensureFactsMemory(session: SessionRecord): Promise { - if (this.#factsMemory === undefined) return; - try { - await this.#factsMemory.ensureSession({ - expiresAt: this.#factsMemoryExpiry(session), - ownerId: session.profileId, - sessionId: session.id, + #notifyAffectedWork(workIds: readonly string[]): void { + for (const workId of new Set(workIds)) this.#workWaiters.notify(workId); + } + + #normalizeWorkEventPage(input: Readonly<{ + workId: WorkId; + requestedCursor: string | undefined; + decodedCursor: ReturnType | undefined; + page: WorkEventPage; + readFromStart: () => WorkEventPage; + }>): WorkEventPage { + let page = input.page; + if ( + input.decodedCursor !== undefined + && input.decodedCursor.streamEpoch !== page.streamEpoch + ) { + page = input.readFromStart(); + return workEventPageSchema.parse({ + ...page, + requestedCursor: input.requestedCursor ?? null, + gap: { + reason: "stream_reset", + requestedSequence: input.decodedCursor.sequence, + retainedFromSequence: 1, + }, }); - } catch (cause: unknown) { + } + const observed = this.#eventCursors.decodeWorkEvent( + page.observedThroughCursor, + input.workId, + ); + if ( + input.decodedCursor !== undefined + && input.decodedCursor.sequence > observed.sequence + ) { throw new CommandFailure( - "RECOVERY_REQUIRED", - "The session facts-memory authority could not be created or reconciled. The provider session remains under its existing HRA authority; retry this exact session operation after reconciling local memory custody.", - { cause: cause instanceof Error ? cause.name : "error", sessionId: session.id }, + "CONFLICT", + "The work event cursor is ahead of the current durable stream.", + ); + } + return workEventPageSchema.parse({ + ...page, + requestedCursor: input.requestedCursor ?? null, + }); + } + + #readWorkSnapshot(workId: WorkId, actorSessionId?: string): unknown { + const priorSequence = this.#workSequence(workId); + const snapshot = this.#work.snapshot(workId, actorSessionId); + this.#notifyWorkIfAdvanced(workId, priorSequence); + return snapshot; + } + + #readWorkTask(command: Extract): unknown { + const historyMode = command.historyLimit !== undefined + || command.historyCursor !== undefined; + if (historyMode) { + const decoded = command.historyCursor === undefined + ? undefined + : this.#eventCursors.decodeWorkTaskHistory(command.historyCursor, command.task); + if (decoded !== undefined) { + // A continuation keeps its signed point-in-time projection while later + // work events append independently to the live stream. + return this.#work.taskHistory( + command.task, + command.historyLimit ?? WORK_TASK_HISTORY_DEFAULT_ITEM_LIMIT, + decoded, + ); + } + const prior = this.#work.taskPosition(command.task); + const page = this.#work.taskHistory( + command.task, + command.historyLimit ?? WORK_TASK_HISTORY_DEFAULT_ITEM_LIMIT, ); + const observed = this.#eventCursors.decodeWorkEvent( + page.observedThroughCursor, + page.workId, + ).sequence; + if (observed !== prior.sequence) this.#workWaiters.notify(page.workId); + return page; } + const prior = this.#work.taskPosition(command.task); + const detail = this.#work.task(command.task); + const current = this.#work.taskPosition(command.task); + if (current.sequence !== prior.sequence) this.#workWaiters.notify(detail.workId); + return detail; } - async #cleanupFactsMemory( - session: SessionRecord, - reason: "abandon" | "archive" | "expired", - ): Promise { - if (this.#factsMemory === undefined) return; - try { - await this.#factsMemory.cleanupSession({ - ownerId: session.profileId, - reason, - sessionId: session.id, + async #readWorkEvents( + command: Extract, + signal: AbortSignal, + ): Promise { + const decodedCursor = command.cursor === undefined + ? undefined + : this.#eventCursors.decodeWorkEvent(command.cursor, command.work); + const read = (): WorkEventPage => { + const priorSequence = this.#workSequence(command.work); + this.#work.snapshot(command.work); + this.#notifyWorkIfAdvanced(command.work, priorSequence); + return this.#normalizeWorkEventPage({ + workId: command.work, + requestedCursor: command.cursor, + decodedCursor, + page: this.#work.events( + command.work, + decodedCursor?.sequence ?? 0, + command.limit, + ), + readFromStart: () => this.#work.events(command.work, 0, command.limit), }); - } catch (cause: unknown) { - throw new CommandFailure( - "RECOVERY_REQUIRED", - "The session facts-memory directory could not be proven fully purged. HRA retained the cleanup authority for an exact retry.", - { cause: cause instanceof Error ? cause.name : "error", sessionId: session.id }, - ); - } - } - - async #cleanupTerminalFactsMemory( - session: SessionRecord, - reason: "abandon" | "archive" = "archive", - ): Promise { - // A terminal session dispatches nothing more, so its fact epoch is no - // longer consulted. Dropping it keeps the map bounded by live sessions. - this.#forgetSessionFactEpoch(session.id); - this.#terminalFactsMemoryRevision += 1; - await this.#cleanupFactsMemory(session, reason); - } - - async #reconcileCommittedSessionFactsMemory( - session: SessionRecord, - terminalReason: "abandon" | "archive" = "archive", - ): Promise { - if (session.state === "terminal") { - await this.#cleanupTerminalFactsMemory(session, terminalReason); - } else if (session.state !== "recovery_required") { - await this.#ensureFactsMemory(session); + }; + let page = read(); + if (page.events.length === 0 && page.gap === null && command.waitMs > 0) { + const expectedSequence = this.#eventCursors.decodeWorkEvent( + page.observedThroughCursor, + command.work, + ).sequence; + await this.#workWaiters.wait({ + workId: command.work, + expectedSequence, + waitMs: command.waitMs, + signal, + readSequence: () => this.#workSequence(command.work), + }); + page = read(); } + return page; } - async #ensureSessionObservedLocked( - selector: string, + async #pollWork( + command: Extract, signal: AbortSignal, - ): Promise { - let session = this.#store.requireSession(selector); - const profile = this.#store.requireProfileById(session.profileId); - if (session.providerThreadId === undefined) { - return { - basis: "local_state", - coverage: "not_attempted", - freshness: "unknown", - observedAt: this.#now(), - profileGeneration: profile.processGeneration, - reason: "unbound", - source: "codex_app_server", - state: "not_applicable", - }; - } - if (session.state === "terminal") { - await this.#cleanupTerminalFactsMemory(session); - return { - basis: "local_state", - coverage: "not_attempted", - freshness: "unknown", - observedAt: this.#now(), - profileGeneration: profile.processGeneration, - reason: "terminal", - source: "codex_app_server", - state: "not_applicable", - }; - } - if (session.state === "recovery_required") { - return { - basis: "local_state", - code: "session_quarantined", - coverage: "partial", - freshness: "fresh", - observedAt: this.#now(), - profileGeneration: profile.processGeneration, - source: "codex_app_server", - state: "recovery_required", - }; - } - await this.#ensureFactsMemory(session); - if (session.provider === "claude" && this.#platform !== "linux") { - return this.#claudePlatformUnavailableObservation(profile); - } - if (!this.#profileAllowsEstablishedSession(profile, session)) { - return { - basis: "local_state", - code: "account_signed_out", - coverage: "unavailable", - freshness: "fresh", - observedAt: this.#now(), - profileGeneration: profile.processGeneration, - source: "codex_app_server", - state: "unavailable", - }; - } - if (this.#lastSessionEventIsProviderGap(session.id)) { - this.#sessionsAwaitingResubscription.add(session.id); - } - const projectionRecoveryUnsettled = await this.#cloud - .isCompactProjectionRecoveryUnsettled(session.id); - await this.#daemonAuthority.assertCurrent(); - const observationFactEpoch = this.#snapshotSessionFactEpoch(session.id); - const providerThreadId = session.providerThreadId; - const authority = authorityFor(this.#paths, profile); - let observation: CodexSessionObservation; - try { - observation = await this.#fencedEffect(async () => await this.#runtimeForSession(session).observeSession({ - authority, - providerThreadId, - signal, - })); - } catch (error: unknown) { - if (signal.aborted) throw signal.reason; - const exact = this.#currentObservationSession( - authority, - session.id, - providerThreadId, + ): Promise { + const actionCursor = command.actionCursor; + if (actionCursor !== undefined && command.waitMs !== 0) { + throw new CommandFailure( + "INVALID_INPUT", + "A work action continuation is a fixed snapshot page and requires waitMs=0.", ); - if (exact === null) { - return { - basis: "provider_read", - code: "resume_unavailable", - coverage: "unavailable", - freshness: "fresh", - observedAt: this.#now(), - profileGeneration: this.#currentProfileGeneration(authority), - source: "codex_app_server", - state: "unavailable", - }; - } - if ( - error instanceof CodexSessionObservationError - && error.reason === "thread_mismatch" - ) { - return this.#quarantineObservationMismatch(authority, exact); - } - if (session.provider === "claude") { - this.#recordSessionObservationFailure( - authority, - exact, - "resume_unavailable", - false, - ); - return { - basis: "provider_read", - code: "resume_unavailable", - coverage: "unavailable", - freshness: "fresh", - observedAt: this.#now(), - profileGeneration: profile.processGeneration, - source: "codex_app_server", - state: "unavailable", - }; - } - if (!(error instanceof CodexSessionObservationError)) throw error; - this.#recordSessionObservationFailure(authority, exact, "resume_unavailable", false); - return { - basis: "provider_read", - code: "resume_unavailable", - coverage: "unavailable", - freshness: "fresh", - observedAt: this.#now(), - profileGeneration: profile.processGeneration, - source: "codex_app_server", - state: "unavailable", - }; - } - await this.#daemonAuthority.assertCurrent(); - session = this.#store.requireSession(session.id); - const currentProfile = this.#store.requireProfileById(profile.id); - if ( - session.profileId !== profile.id - || session.providerThreadId === undefined - || session.providerThreadId !== observation.projection.providerThreadId - || currentProfile.processGeneration !== profile.processGeneration - || !this.#profileAllowsEstablishedSession(currentProfile, session) - ) { - if ( - this.#currentObservationSession(authority, session.id, providerThreadId) !== null - && observation.projection.providerThreadId !== providerThreadId - ) return this.#quarantineObservationMismatch(authority, session); - return { - basis: "provider_read", - code: "resume_unavailable", - coverage: "unavailable", - freshness: "fresh", - observedAt: this.#now(), - profileGeneration: currentProfile.processGeneration, - source: "codex_app_server", - state: "unavailable", - }; } - z.string().uuid().parse(observation.connectionId); - this.#sessionObservationFailures.delete(session.id); - this.#ensureSessionProviderConnection(authority, session, observation.connectionId); - const projection = observation.projection; + const decodedCursor = command.cursor === undefined + ? undefined + : this.#eventCursors.decodeWorkEvent(command.cursor, command.work); + const decodedActionCursor = actionCursor === undefined + ? undefined + : this.#eventCursors.decodeWorkAction( + actionCursor, + command.work, + command.actor ?? null, + ); + const read = (): WorkPoll => { + const priorSequence = this.#workSequence(command.work); + const readPoll = (afterSequence: number): WorkPoll => this.#work.poll( + command.work, + command.actor, + afterSequence, + command.limit, + decodedActionCursor, + ); + let poll = readPoll(decodedCursor?.sequence ?? 0); + const eventPage = this.#normalizeWorkEventPage({ + workId: command.work, + requestedCursor: command.cursor, + decodedCursor, + page: poll.eventPage, + readFromStart: () => { + poll = readPoll(0); + return poll.eventPage; + }, + }); + this.#notifyWorkIfAdvanced(command.work, priorSequence); + return workPollSchema.parse({ ...poll, eventPage }); + }; + let poll = read(); if ( - !projectionRecoveryUnsettled - && !this.#projectionRecoveriesInFlight.has(session.id) - && this.#currentSessionFactEpoch(session.id) === observationFactEpoch + poll.eventPage.events.length === 0 + && poll.eventPage.gap === null + && command.waitMs > 0 + && poll.readyTasks.length === 0 + && poll.ownedAttempts.length === 0 + && poll.recoveryAttempts.length === 0 + && poll.reviewableSubmissions.length === 0 + && poll.signals.length === 0 + && poll.preparedEffects.length === 0 ) { - const beforeState = session.state; - const beforeActiveTurnId = session.activeTurnId ?? null; - const reconciled = this.#store.reconcileSessionFromProvider({ - sessionId: session.id, - state: projection.status, - activeTurnId: projection.status === "active" - ? projection.activeTurnId ?? null - : null, - title: projection.title, - }); - if ( - reconciled.state !== beforeState - || (reconciled.activeTurnId ?? null) !== beforeActiveTurnId - ) { - this.#appendSessionEvent(authority, reconciled.id, observation.connectionId, { - type: "session_status", - status: projection.status, - activeTurnId: reconciled.activeTurnId ?? null, + const expectedSequence = this.#eventCursors.decodeWorkEvent( + poll.eventPage.observedThroughCursor, + command.work, + ).sequence; + const waitMs = poll.nextWakeAt === null + ? command.waitMs + : Math.min(command.waitMs, Math.max(0, poll.nextWakeAt - this.#now())); + if (waitMs > 0) { + await this.#workWaiters.wait({ + workId: command.work, + expectedSequence, + waitMs, + signal, + readSequence: () => this.#workSequence(command.work), }); } - await this.#reconcileCommittedSessionFactsMemory(reconciled); + poll = read(); } - const mode = this.#sessionResubscriptionConnections.get(session.id) === observation.connectionId - ? "resubscribed" - : "connected"; - return { - basis: "provider_read", - connectionId: observation.connectionId, - coverage: "complete", - freshness: "fresh", - mode, - observedAt: this.#now(), - profileGeneration: profile.processGeneration, - source: "codex_app_server", - state: "live", - }; + return poll; } - #currentObservationSession( - authority: ProfileAuthority, - sessionId: SessionRecord["id"], - providerThreadId: string, - ): SessionRecord | null { - try { - const profile = this.#store.requireProfileById(authority.id); - const session = this.#store.requireSession(sessionId); - return profile.processGeneration === authority.generation - && this.#profileAllowsEstablishedSession(profile, session) - && session.profileId === authority.id - && session.providerThreadId === providerThreadId - && session.state !== "terminal" - ? session - : null; - } catch (error: unknown) { - if (error instanceof SelectionError && error.code === "NOT_FOUND") return null; - throw error; + #assertPreparedEffectBinding( + effect: WorkPreparedEffect, + status: NonNullable>, + ): void { + const subjectId = effect.kind === "dispatch" ? effect.attemptId : effect.signalId; + if ( + status.kind !== effect.kind + || status.subjectId !== subjectId + || status.targetSessionId !== effect.targetSessionId + || status.instructionDigest !== digestText(canonicalWorkJson(effect)) + ) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The prepared work effect no longer matches its durable authority binding.", + ); } } - #currentProfileGeneration(authority: ProfileAuthority): number { - try { - return this.#store.requireProfileById(authority.id).processGeneration; - } catch (error: unknown) { - if (error instanceof SelectionError && error.code === "NOT_FOUND") { - return authority.generation; - } - throw error; + #assertPreparedEffectStatusProjection( + projected: unknown, + status: NonNullable>, + ): void { + if ( + canonicalWorkJson(workPreparedEffectStatusSchema.parse(projected)) + !== canonicalWorkJson(status) + ) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The public work-effect receipt no longer matches its durable authority binding.", + ); } } - #recordSessionObservationFailure( - authority: ProfileAuthority, - session: SessionRecord, - code: "resume_unavailable", - terminal: boolean, + async #performPreparedWorkEffect( + effect: WorkPreparedEffect, + idempotencyKey: string, + signal: AbortSignal, + ): Promise { + const session = this.#store.requireSession(effect.targetSessionId); + const message = workPreparedEffectMessage(effect); + return await this.#serializeSessionAuthority(session, async () => { + const beforeEffect = (): void => { + const authorization = this.#work.authorizePreparedEffect(idempotencyKey); + this.#assertPreparedEffectBinding(effect, authorization.status); + if (!authorization.executable) throw new WorkEffectExecutionSuppressed(); + this.#assertAuthorizedWorkEffect(effect, authorization); + }; + if (effect.kind === "dispatch") { + await this.#send(session.id, message, effect.nestedMutationKey, signal, beforeEffect); + return; + } + if (effect.mode === "queue") { + await this.#queue(session.id, message, effect.nestedMutationKey, signal, beforeEffect); + return; + } + await this.#steer(session.id, message, effect.nestedMutationKey, signal, beforeEffect); + }); + } + + #assertAuthorizedWorkEffect( + expected: WorkPreparedEffect, + authorization: Extract, ): void { - const marker = `${String(authority.generation)}:${code}`; - if (this.#sessionObservationFailures.get(session.id) === marker) return; - this.#sessionObservationFailures.set(session.id, marker); - this.#appendSessionEvent(authority, session.id, null, terminal - ? { - type: "error", - code: "provider_resume_unavailable", - message: "Provider observation is unavailable; HRA will not follow a stale event stream.", - terminal: true, - } - : { - type: "warning", - code: "provider_resume_unavailable", - message: "Provider observation is unavailable; HRA will not follow a stale event stream.", - }); + this.#assertPreparedEffectBinding(authorization.effect, authorization.status); + if (canonicalWorkJson(authorization.effect) !== canonicalWorkJson(expected)) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The persisted work effect does not match the operation projection and was not executed.", + ); + } } - #quarantineObservationMismatch( - authority: ProfileAuthority, - session: SessionRecord, - ): PublicProviderObservation { - this.#quarantineSession(session.id); - const marker = `${String(authority.generation)}:thread_mismatch`; - if (this.#sessionObservationFailures.get(session.id) !== marker) { - this.#sessionObservationFailures.set(session.id, marker); - this.#appendSessionEvent(authority, session.id, null, { - type: "error", - code: "provider_thread_mismatch", - message: "Provider observation returned a different thread; the session is quarantined.", - terminal: true, - }); + #projectSettledWorkEffect( + operation: Extract, + effect: WorkPreparedEffect, + ): WorkOperationResult { + const status = this.#work.reprojectPreparedEffect(operation.idempotencyKey); + this.#assertPreparedEffectBinding(effect, status); + if (status.state === "accepted") { + const replay = workOperationResultSchema.parse( + this.#work.apply(operation, operation.idempotencyKey), + ); + if (replay.kind !== "attempt.dispatch" && replay.kind !== "signal.send") { + throw new CommandFailure("RECOVERY_REQUIRED", "The settled work effect replay changed operation kind."); + } + this.#assertPreparedEffectStatusProjection(replay.effect, status); + return replay; } - return { - basis: "provider_read", - code: "thread_mismatch", - coverage: "partial", - freshness: "fresh", - observedAt: this.#now(), - profileGeneration: authority.generation, - source: "codex_app_server", - state: "recovery_required", - }; + if (status.state === "failed") { + throw new CommandFailure( + "CONFLICT", + "The exact work effect was durably settled without an external effect.", + { idempotencyKey: operation.idempotencyKey, subjectId: status.subjectId }, + ); + } + throw new CommandFailure( + "RECOVERY_REQUIRED", + status.state === "unknown" + ? "The exact nested effect has an unknown outcome and will not be replayed." + : "The exact nested effect has unsettled durable authority and will not be replayed.", + { idempotencyKey: operation.idempotencyKey, subjectId: status.subjectId }, + ); } - #requireLiveProviderObservation(observation: PublicProviderObservation): void { - if (observation.state === "live") return; - if (observation.state === "recovery_required") { + async #applyWorkOperation( + operation: WorkOperation, + signal: AbortSignal, + ): Promise { + const result = workOperationResultSchema.parse( + this.#work.apply(operation, operation.idempotencyKey), + ); + const workId = result.workId; + this.#workWaiters.notify(workId); + if (result.kind !== "attempt.dispatch" && result.kind !== "signal.send") return result; + if ( + (operation.kind !== "attempt.dispatch" && operation.kind !== "signal.send") + || operation.kind !== result.kind + ) { + throw new CommandFailure("RECOVERY_REQUIRED", "The work effect result changed operation kind."); + } + + const prepared = this.#work.preparedEffect(operation.idempotencyKey); + if (prepared === null) { throw new CommandFailure( "RECOVERY_REQUIRED", - "The provider thread could not be observed under this session's exact authority; the session is quarantined.", - { providerObservation: observation }, + "The work effect result has no matching durable prepared-effect receipt.", ); } - if (observation.state === "unavailable") { + const { effect, status } = prepared; + this.#assertPreparedEffectStatusProjection(result.effect, status); + this.#assertPreparedEffectBinding(effect, status); + if (status.state !== "prepared") { + return this.#projectSettledWorkEffect(operation, effect); + } + + let executionError: unknown; + try { + await this.#performPreparedWorkEffect(effect, operation.idempotencyKey, signal); + } catch (error: unknown) { + executionError = error; + } + try { + let projected = this.#work.reprojectPreparedEffect(operation.idempotencyKey); + this.#assertPreparedEffectBinding(effect, projected); + if (projected.state === "prepared") { + projected = this.#work.settlePreparedEffectNoEffect( + operation.idempotencyKey, + "nested_preflight_no_effect", + ); + this.#assertPreparedEffectBinding(effect, projected); + } + this.#workWaiters.notify(workId); + } catch (settlementError: unknown) { + if (settlementError instanceof StateSecurityScrubRequiredError) throw settlementError; throw new CommandFailure( - "UNAVAILABLE", - observation.code === "provider_platform_unavailable" - ? `Claude session processes are acceptance-pending on ${this.#platform}. HRA retained the local session but will not contact Claude outside Linux.` - : "The provider thread is not currently observable; HRA will not use stale session state.", - { providerObservation: observation }, + "RECOVERY_REQUIRED", + "The nested work effect could not be projected into its durable work receipt; replay the exact operation document.", + { idempotencyKey: operation.idempotencyKey, subjectId: status.subjectId }, ); } - throw new CommandFailure( - observation.reason === "terminal" ? "CONFLICT" : "RECOVERY_REQUIRED", - observation.reason === "terminal" - ? "The session is terminal and has no live provider observation." - : "The session has no proven provider binding.", - { providerObservation: observation }, - ); + if (executionError instanceof StateSecurityScrubRequiredError) throw executionError; + return this.#projectSettledWorkEffect(operation, effect); } - async #sessionStatus( - sessionId: SessionRecord["id"], - signal: AbortSignal, - ): Promise { - const providerObservation = await this.#ensureSessionObservedLocked(sessionId, signal); - const snapshot = this.#store.readSessionObservationSnapshot( - sessionId, - SESSION_STATUS_PENDING_SUMMARY_LIMIT, - ); - return sessionStatusSchema.parse({ - version: 2, - session: snapshot.session, - advisory: { - execution: snapshot.session.execution, - attention: deriveSessionAttention({ - execution: snapshot.session.execution, - localCoverage: "complete", - pendingInteractionCount: snapshot.interactions.pendingCount, - responseInFlightCount: snapshot.interactions.responseInFlightCount, - }), - queueDepth: snapshot.queue.depth, - }, - localObservation: { - source: "sqlite", - coverage: "complete", - freshness: "fresh", - observedAt: snapshot.observedAt, - }, - providerObservation, - eventStream: { - cursor: this.#encodeEventCursor({ - sessionId, - streamEpoch: snapshot.eventStream.streamEpoch, - sequence: snapshot.eventStream.observedThroughSequence, - }), - retentionFloorCursor: this.#encodeEventCursor({ - sessionId, - streamEpoch: snapshot.eventStream.streamEpoch, - sequence: Math.max(0, snapshot.eventStream.floorSequence - 1), - }), - streamEpoch: snapshot.eventStream.streamEpoch, - floorSequence: snapshot.eventStream.floorSequence, - observedThroughSequence: snapshot.eventStream.observedThroughSequence, + #publicInteraction(interaction: InteractionRecord): PublicInteraction { + return publicInteractionSchema.parse({ + version: interaction.version, + id: interaction.publicId, + sessionId: interaction.sessionId, + kind: interaction.kind, + state: interaction.state, + revision: interaction.revision, + blocking: interaction.blocking, + display: interaction.display, + presentation: computeInteractionPresentation(interaction.display), + resolvedBy: interaction.resolvedBy ?? null, + responseRecorded: interaction.responseDigest !== null, + context: { + turnId: interaction.authority.turnId === null + ? null + : this.#eventCursors.projectPublicProviderIdentifier( + interaction.authority.turnId, + ), + itemId: interaction.authority.itemId === null + ? null + : this.#eventCursors.projectPublicProviderIdentifier( + interaction.authority.itemId, + ), }, - interactions: snapshot.interactions, - queue: snapshot.queue, + requestedAt: interaction.requestedAt, + deadlineAt: interaction.deadlineAt, + updatedAt: interaction.updatedAt, + terminalAt: interaction.terminalAt, }); } - #interactionPage(input: Readonly<{ - cursor?: string; - limit: number; - pending: boolean; - sessionId?: SessionRecord["id"]; - }>): Readonly<{ - interactions: readonly PublicInteraction[]; - nextCursor: string | null; - sessionId: SessionRecord["id"] | null; - }> { - const scope: InteractionCursorScope = input.sessionId === undefined - ? { type: "global" } - : { type: "session", sessionId: input.sessionId }; - let after: Readonly<{ publicId: string; requestedAt: number }> | undefined; - if (input.cursor !== undefined) { - try { - const decoded = this.#eventCursors.decodeInteraction(input.cursor, { - scope, - pending: input.pending, - }); - after = { requestedAt: decoded.requestedAt, publicId: decoded.publicId }; - } catch (error: unknown) { - if (error instanceof SessionEventCursorError) { - throw new CommandFailure( - "INVALID_INPUT", - "The interaction cursor is invalid for this exact interaction listing.", - ); - } - throw error; - } - } - const page = this.#store.listInteractionPage({ - ...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }), - pendingOnly: input.pending, - limit: input.limit, - ...(after === undefined ? {} : { after }), - }); - const nextCursor = page.nextPosition === null - ? null - : this.#eventCursors.encodeInteraction({ - version: 1, - type: "interaction", - scope, - pending: input.pending, - requestedAt: page.nextPosition.requestedAt, - publicId: page.nextPosition.publicId, - }); - return { - sessionId: input.sessionId ?? null, - interactions: page.interactions.map((interaction) => this.#publicInteraction(interaction)), - nextCursor, + #appendInteractionState(interaction: InteractionRecord): void { + if (interaction.sessionId === null) return; + // Route state transitions through the ordinary event pipeline so the + // session-state tracker re-reads the complete pending set. Direct store + // appends would leave an autorespond escalation stuck after the exact + // interaction resolved or expired. + const write: SessionEventWrite = { + sessionId: interaction.sessionId, + accountId: interaction.authority.profileId, + providerGeneration: interaction.authority.processGeneration, + providerConnectionId: interaction.authority.connectionId, + body: { + type: "interaction_state", + interactionId: interaction.publicId, + state: interaction.state, + revision: interaction.revision, + }, }; + this.#store.appendSessionEvent(write); + this.#eventWaiters.notify(interaction.sessionId); + this.#trackSessionState(write); + } + + #interactionPersistenceBoundaryError(input: Readonly<{ + cause: unknown; + effect: "known_unsent" | "possibly_sent"; + focalInteraction: InteractionRecord; + responseDigest?: string; + }>): InteractionPersistenceBoundaryError { + const failures: unknown[] = [input.cause]; + let focalInteraction = input.focalInteraction; + let quarantineFailed = false; + try { + const focalProvider = input.focalInteraction.sessionId === null + ? "codex" + : this.#store.requireSession(input.focalInteraction.sessionId).provider; + const blocked = this.#isolatedProviderAuthorityAdvanceBlocker( + input.focalInteraction.authority.profileId, + ); + if (focalProvider !== "codex" || blocked !== null) { + throw new Error( + focalProvider !== "codex" + ? `${focalProvider.toUpperCase()}_INTERACTION_QUARANTINE_REQUIRES_DAEMON_RETIREMENT` + : `CODEX_INTERACTION_QUARANTINE_BLOCKED_BY_${blocked?.provider.toUpperCase()}_${blocked?.blocker}`, + ); + } + const quarantined = this.#store.quarantineInteractionPersistenceBoundary({ + profileId: input.focalInteraction.authority.profileId, + processGeneration: input.focalInteraction.authority.processGeneration, + connectionId: input.focalInteraction.authority.connectionId, + focalInteractionId: input.focalInteraction.publicId, + effect: input.effect, + ...(input.responseDigest === undefined + ? {} + : { responseDigest: input.responseDigest }), + }); + focalInteraction = quarantined.focalInteraction; + for (const interaction of quarantined.terminalInteractions) { + if (interaction.sessionId !== null) this.#eventWaiters.notify(interaction.sessionId); + } + this.#rebindIsolatedProviderAuthorities( + quarantined.profile.id, + input.focalInteraction.authority.processGeneration, + quarantined.profile.processGeneration, + ); + } catch (error: unknown) { + quarantineFailed = true; + failures.push(error); + this.#state = "closing"; + this.#interactionDeadlineAbort.abort( + new Error("The interaction persistence quarantine failed."), + ); + this.#interactionDeadlineWake?.(); + this.#interactionDeadlineWake = undefined; + try { + focalInteraction = this.#store.requireInteraction( + input.focalInteraction.publicId, + ); + } catch (readError: unknown) { + failures.push(readError); + } + } + return new InteractionPersistenceBoundaryError( + focalInteraction, + quarantineFailed, + new AggregateError(failures, "Interaction persistence quarantine evidence."), + ); } - async #sessionEvents( - command: Extract, - signal: AbortSignal, - ): Promise { - const selected = this.#store.requireSession(command.session); - const decodedCursor = command.cursor === undefined - ? undefined - : this.#eventCursors.decode(command.cursor); - if (decodedCursor !== undefined && decodedCursor.sessionId !== selected.id) { - throw new CommandFailure("INVALID_INPUT", "The session event cursor belongs to another session."); + #assertResolutionMatches( + interaction: InteractionRecord, + resolution: InteractionResolution, + ): void { + if ( + interaction.kind === "file_change_approval" + && resolution.kind === "approval_decision" + && (resolution.decision === "once" || resolution.decision === "session") + ) { + throw new CommandFailure( + "INVALID_INPUT", + "File-change approval is disabled because the pinned provider callback does not expose exact affected paths or change detail.", + ); } - const providerObservation = await this.#serializeSessionAuthority( - selected, - async () => await this.#ensureSessionObservedLocked(selected.id, signal), - { allowDuringProjectionRecovery: true }, - ); - const session = this.#store.requireSession(selected.id); - let requestedSequence: number | null = null; - let restoredRequestedSequence: number | null = null; - let streamRestored = false; - if (decodedCursor !== undefined) { - const current = this.#store.eventStreamPosition(session.id); - if (decodedCursor.streamEpoch !== current.streamEpoch) { - streamRestored = true; - restoredRequestedSequence = decodedCursor.sequence; - } else { - requestedSequence = decodedCursor.sequence; - } + const expected = interaction.kind === "user_input" + ? "user_answers" + : interaction.kind === "mcp_elicitation" + ? "mcp_submission" + : "approval_decision"; + const permissionDecision = interaction.kind === "permission_approval" + && resolution.kind === "approval_decision" + && resolution.decision === "decline"; + const permissionGrant = interaction.kind === "permission_approval" + && resolution.kind === "permission_grant"; + if (!permissionDecision && !permissionGrant && resolution.kind !== expected) { + throw new CommandFailure( + "INVALID_INPUT", + interaction.kind === "permission_approval" + ? "A permission approval requires an exact permission grant or decline resolution." + : `A ${interaction.kind} interaction requires a ${expected} resolution.`, + ); } - - let listed = this.#store.listSessionEvents({ - sessionId: session.id, - afterSequence: requestedSequence, - limit: command.limit, - }); if ( - providerObservation.state === "live" - && !streamRestored - && listed.events.length === 0 - && command.waitMs > 0 + interaction.kind === "permission_approval" + && resolution.kind === "approval_decision" + && !permissionDecision ) { - await this.#eventWaiters.wait({ - sessionId: session.id, - expectedObservedThrough: listed.observedThroughSequence, - waitMs: command.waitMs, - signal, - readObservedThrough: () => - this.#store.eventStreamPosition(session.id).observedThroughSequence, - }); - listed = this.#store.listSessionEvents({ - sessionId: session.id, - afterSequence: requestedSequence, - limit: command.limit, - }); + throw new CommandFailure( + "INVALID_INPUT", + "Permission approvals can be declined, but cancel, once, and session decisions are not represented by this provider callback.", + ); } if ( - listed.events.length === 0 - && !streamRestored - && listed.gapReason === null - && providerObservation.state !== "live" + resolution.kind === "approval_decision" + && (interaction.display.kind === "command_approval" + || interaction.display.kind === "file_change_approval") + && !interaction.display.availableDecisions.includes(resolution.decision) ) { - this.#requireLiveProviderObservation(providerObservation); + throw new CommandFailure("INVALID_INPUT", "This provider request does not offer that decision."); + } + if ( + resolution.kind === "permission_grant" + && interaction.display.kind === "permission_approval" + ) { + const requested = new Set(interaction.display.requested.map((permission) => permission.name)); + if (resolution.permissions.some((name) => !requested.has(name))) { + throw new CommandFailure("INVALID_INPUT", "Granted permissions must be a subset of the request."); + } + if (resolution.scope === "session" && !interaction.display.allowsSessionScope) { + throw new CommandFailure("INVALID_INPUT", "This provider request does not allow session permission scope."); + } + } + if (resolution.kind === "user_answers" && interaction.display.kind === "user_input") { + const questions = new Set(interaction.display.questions.map((question) => question.id)); + const answers = Object.keys(resolution.answers); + if (answers.length !== questions.size || answers.some((id) => !questions.has(id))) { + throw new CommandFailure("INVALID_INPUT", "User answers must match the provider's exact question IDs."); + } + } + if (resolution.kind === "mcp_submission" && interaction.display.kind === "mcp_elicitation") { + if (interaction.display.mode !== "form" || interaction.display.fields === undefined) { + throw new CommandFailure("INVALID_INPUT", "This MCP form cannot be safely completed through HRA."); + } + if (resolution.action !== "accept") { + if (resolution.content !== undefined) { + throw new CommandFailure("INVALID_INPUT", "Declined or canceled MCP forms cannot include content."); + } + return; + } + try { + validateMcpFormSubmission(interaction.display.fields, resolution.content ?? {}); + } catch { + throw new CommandFailure( + "INVALID_INPUT", + "Protected MCP form content does not match the requested field contract.", + ); + } } - const gapCheckpointSequence = Math.max(0, listed.floorSequence - 1); - const nextSequence = listed.events.at(-1)?.sequence - ?? (streamRestored || listed.gapReason !== null - ? gapCheckpointSequence - : requestedSequence ?? gapCheckpointSequence); - const page = { - version: 1 as const, - sessionId: session.id, - requestedCursor: command.cursor ?? null, - retentionFloorCursor: this.#encodeEventCursor({ - sessionId: session.id, - streamEpoch: listed.streamEpoch, - sequence: Math.max(0, listed.floorSequence - 1), - }), - observedThroughCursor: this.#encodeEventCursor({ - sessionId: session.id, - streamEpoch: listed.streamEpoch, - sequence: listed.observedThroughSequence, - }), - nextCursor: this.#encodeEventCursor({ - sessionId: session.id, - streamEpoch: listed.streamEpoch, - sequence: nextSequence, - }), - gap: streamRestored - ? { - reason: "stream_restored" as const, - requestedSequence: restoredRequestedSequence, - retainedFromSequence: listed.floorSequence, - } - : listed.gapReason === null - ? null - : { - reason: listed.gapReason, - requestedSequence, - retainedFromSequence: listed.floorSequence, - }, - events: [...listed.events], - }; - return sessionEventPageSchema.parse(page); - } - - #workSequence(workId: WorkId): number { - const page = this.#work.events(workId, 0, 1); - return this.#eventCursors.decodeWorkEvent( - page.observedThroughCursor, - workId, - ).sequence; - } - - #notifyWorkIfAdvanced(workId: WorkId, priorSequence: number): void { - if (this.#workSequence(workId) !== priorSequence) this.#workWaiters.notify(workId); } - #notifyAffectedWork(workIds: readonly string[]): void { - for (const workId of new Set(workIds)) this.#workWaiters.notify(workId); + #intendedInteractionTerminalState( + resolution: InteractionResolution, + ): InteractionIntendedTerminalState { + if (resolution.kind === "approval_decision") { + if (resolution.decision === "decline") return "declined"; + if (resolution.decision === "cancel") return "canceled"; + return "resolved"; + } + if (resolution.kind === "mcp_submission") { + if (resolution.action === "decline") return "declined"; + if (resolution.action === "cancel") return "canceled"; + } + return "resolved"; } - #normalizeWorkEventPage(input: Readonly<{ - workId: WorkId; - requestedCursor: string | undefined; - decodedCursor: ReturnType | undefined; - page: WorkEventPage; - readFromStart: () => WorkEventPage; - }>): WorkEventPage { - let page = input.page; - if ( - input.decodedCursor !== undefined - && input.decodedCursor.streamEpoch !== page.streamEpoch - ) { - page = input.readFromStart(); - return workEventPageSchema.parse({ - ...page, - requestedCursor: input.requestedCursor ?? null, - gap: { - reason: "stream_reset", - requestedSequence: input.decodedCursor.sequence, - retainedFromSequence: 1, + async #inspectInteraction( + command: Extract, + signal: AbortSignal, + ): Promise { + return await this.#serialize(`interaction:${command.interaction}`, async () => { + const current = this.#store.requireInteraction(command.interaction); + if ( + current.revision !== command.expectedRevision + || current.state !== "pending" + || this.#now() >= current.deadlineAt + ) { + throw new CommandFailure( + "CONFLICT", + "The interaction revision, state, or deadline changed before protected inspection.", + ); + } + if (current.kind !== "command_approval" && current.kind !== "permission_approval") { + throw new CommandFailure( + "INVALID_INPUT", + "This interaction has no complete approval authority available for protected inspection.", + ); + } + const profile = this.#store.requireProfileById(current.authority.profileId); + const provider = this.#providerForInteraction(current); + this.#assertProviderProfileState(profile, provider); + let authority: Awaited>; + try { + await this.#daemonAuthority.assertCurrent(); + await this.#assertPersonalInteractionAccountAuthority(current, profile, signal); + authority = await this.#runtimeForInteraction(current).inspectInteractionAuthority({ + authority: this.#authorityForInteraction(current, profile), + provider: current.authority, + kind: current.kind, + signal, + }); + await this.#assertPersonalInteractionAccountAuthority(current, profile, signal); + await this.#daemonAuthority.assertCurrent(); + const exactProfile = this.#store.requireProfileById(profile.id); + this.#assertProviderProfileState(exactProfile, provider); + if (exactProfile.processGeneration !== current.authority.processGeneration) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The interaction belongs to a stale account authority.", + ); + } + } catch (error: unknown) { + if (error instanceof CommandFailure) throw error; + if (providerFailureCode(error) === "UNSUPPORTED_CAPABILITY") { + throw new CommandFailure("INVALID_INPUT", providerFailureMessage(error)); + } + throw new CommandFailure( + "CONFLICT", + "The interaction's exact live provider authority is no longer available.", + ); + } + const observed = this.#store.requireInteraction(current.publicId); + if ( + observed.revision !== current.revision + || observed.state !== "pending" + || observed.kind !== current.kind + || observed.sessionId !== current.sessionId + || observed.authority.profileId !== current.authority.profileId + || observed.authority.processGeneration !== current.authority.processGeneration + || observed.authority.connectionId !== current.authority.connectionId + || observed.authority.requestDigest !== current.authority.requestDigest + || observed.authority.requestId.type !== current.authority.requestId.type + || observed.authority.requestId.value !== current.authority.requestId.value + || this.#now() >= observed.deadlineAt + ) { + throw new CommandFailure( + "CONFLICT", + "The interaction authority changed during protected inspection.", + ); + } + const document = protectedInteractionDetailDocumentSchema.parse({ + type: "hra_protected_interaction_detail", + version: 1, + binding: { + interactionId: observed.publicId, + revision: observed.revision, + kind: observed.kind, + sessionId: observed.sessionId, + profileId: observed.authority.profileId, + processGeneration: observed.authority.processGeneration, + connectionId: observed.authority.connectionId, }, + authority, }); - } - const observed = this.#eventCursors.decodeWorkEvent( - page.observedThroughCursor, - input.workId, - ); - if ( - input.decodedCursor !== undefined - && input.decodedCursor.sequence > observed.sequence - ) { - throw new CommandFailure( - "CONFLICT", - "The work event cursor is ahead of the current durable stream.", - ); - } - return workEventPageSchema.parse({ - ...page, - requestedCursor: input.requestedCursor ?? null, + const encoded = encodeProtectedInteractionDetailDocument(document); + const fits = encoded.byteLength <= PROTECTED_INTERACTION_DETAIL_MAXIMUM_BYTES; + encoded.fill(0); + if (!fits) { + throw new CommandFailure( + "INVALID_INPUT", + "The complete approval authority exceeds HRA's protected-output limit.", + ); + } + return document; }); } - #readWorkSnapshot(workId: WorkId, actorSessionId?: string): unknown { - const priorSequence = this.#workSequence(workId); - const snapshot = this.#work.snapshot(workId, actorSessionId); - this.#notifyWorkIfAdvanced(workId, priorSequence); - return snapshot; + async #resolveInteraction( + command: Extract, + context: { signal: AbortSignal; afterResponse?: (callback: () => void) => void }, + ): Promise { + return await this.#serializeInteractionAuthority(command.interaction, async () => + await this.#resolveInteractionLocked(command, context)); } - #readWorkTask(command: Extract): unknown { - const historyMode = command.historyLimit !== undefined - || command.historyCursor !== undefined; - if (historyMode) { - const decoded = command.historyCursor === undefined - ? undefined - : this.#eventCursors.decodeWorkTaskHistory(command.historyCursor, command.task); - if (decoded !== undefined) { - // A continuation keeps its signed point-in-time projection while later - // work events append independently to the live stream. - return this.#work.taskHistory( - command.task, - command.historyLimit ?? WORK_TASK_HISTORY_DEFAULT_ITEM_LIMIT, - decoded, + async #resolveInteractionLocked( + command: Extract, + context: { signal: AbortSignal; afterResponse?: (callback: () => void) => void }, + ): Promise { + const signal = context.signal; + return await (async () => { + const current = this.#store.requireInteraction(command.interaction); + if (current.revision !== command.expectedRevision || current.state !== "pending") { + throw new CommandFailure( + "CONFLICT", + "The interaction revision or state changed before resolution.", + { interaction: this.#publicInteraction(current) }, ); } - const prior = this.#work.taskPosition(command.task); - const page = this.#work.taskHistory( - command.task, - command.historyLimit ?? WORK_TASK_HISTORY_DEFAULT_ITEM_LIMIT, - ); - const observed = this.#eventCursors.decodeWorkEvent( - page.observedThroughCursor, - page.workId, - ).sequence; - if (observed !== prior.sequence) this.#workWaiters.notify(page.workId); - return page; - } - const prior = this.#work.taskPosition(command.task); - const detail = this.#work.task(command.task); - const current = this.#work.taskPosition(command.task); - if (current.sequence !== prior.sequence) this.#workWaiters.notify(detail.workId); - return detail; + if (this.#now() >= current.deadlineAt) { + await this.#rejectManualResolutionAtDeadline(current); + } + this.#assertResolutionMatches(current, command.resolution); + const profile = this.#store.requireProfileById(current.authority.profileId); + const provider = this.#providerForInteraction(current); + this.#assertProviderProfileState(profile, provider); + if (profile.processGeneration !== current.authority.processGeneration) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The interaction belongs to a stale account authority.", + ); + } + const runtime = this.#runtimeForInteraction(current); + let responseDigest: string; + try { + await this.#daemonAuthority.assertCurrent(); + await this.#assertPersonalInteractionAccountAuthority(current, profile, signal); + const validated = await runtime.validateInteractionResolution({ + authority: this.#authorityForInteraction(current, profile), + provider: current.authority, + kind: current.kind, + resolution: command.resolution, + signal, + }); + await this.#assertPersonalInteractionAccountAuthority(current, profile, signal); + responseDigest = validated.responseDigest; + const exactProfile = this.#store.requireProfileById(profile.id); + this.#assertProviderProfileState(exactProfile, provider); + if (exactProfile.processGeneration !== current.authority.processGeneration) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The interaction belongs to a stale account authority.", + ); + } + } catch (error: unknown) { + if (error instanceof CommandFailure) throw error; + if (this.#now() >= current.deadlineAt) { + await this.#rejectManualResolutionAtDeadline(current); + } + if (providerFailureCode(error) === "INVALID_INPUT") { + throw new CommandFailure("INVALID_INPUT", providerFailureMessage(error)); + } + const terminal = providerFailureCode(error) === "INDETERMINATE_EFFECT" + ? this.#store.markInteractionResolutionUnknown({ + id: current.publicId, + expectedRevision: current.revision, + }) + : this.#store.expireInteraction({ + id: current.publicId, + expectedRevision: current.revision, + }); + this.#appendInteractionState(terminal); + if (providerFailureCode(error) === "INDETERMINATE_EFFECT") { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The interaction response may already have reached Codex; its resolution is unknown.", + { interaction: this.#publicInteraction(terminal) }, + ); + } + throw new CommandFailure( + "CONFLICT", + "The interaction's exact provider connection is no longer available.", + { interaction: this.#publicInteraction(terminal) }, + ); + } + if (this.#now() >= current.deadlineAt) { + await this.#rejectManualResolutionAtDeadline(current); + } + await this.#assertPersonalInteractionAccountAuthority(current, profile, signal); + let prepared: InteractionRecord; + try { + prepared = this.#store.prepareInteractionResponse({ + id: current.publicId, + expectedRevision: current.revision, + responseDigest, + intendedTerminalState: this.#intendedInteractionTerminalState(command.resolution), + }); + this.#appendInteractionState(prepared); + } catch (error: unknown) { + throw this.#interactionPersistenceBoundaryError({ + cause: error, + effect: "known_unsent", + focalInteraction: current, + }); + } + if (this.#now() >= prepared.deadlineAt) { + await this.#rejectPreparedManualResolutionAtDeadline(prepared); + } + try { + await this.#daemonAuthority.assertCurrent(); + const exactProfile = this.#store.requireProfileById(profile.id); + this.#assertProviderProfileState(exactProfile, provider); + if (exactProfile.processGeneration !== prepared.authority.processGeneration) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The interaction belongs to a stale account authority.", + ); + } + if (this.#now() >= prepared.deadlineAt) { + await this.#rejectPreparedManualResolutionAtDeadline(prepared); + } + await this.#assertPersonalInteractionAccountAuthority(prepared, exactProfile, signal); + await runtime.resolveInteraction({ + authority: this.#authorityForInteraction(prepared, profile), + provider: prepared.authority, + kind: prepared.kind, + resolution: command.resolution, + deadlineAt: prepared.deadlineAt, + signal, + }); + await this.#assertInteractionAccountAuthorityAfterProviderEffect( + prepared, + exactProfile, + signal, + ); + } catch (error: unknown) { + if (error instanceof CommandFailure) throw error; + if (providerFailureCode(error) === "DEADLINE_EXPIRED") { + await this.#rejectPreparedManualResolutionAtDeadline(prepared); + } + const indeterminate = error instanceof IndeterminateLocalCommitError + || providerFailureCode(error) === "INDETERMINATE_EFFECT"; + const latest = this.#store.requireInteraction(prepared.publicId); + const terminal = indeterminate + ? latest.state === "response_prepared" + && latest.revision === prepared.revision + && latest.responseDigest === responseDigest + ? this.#store.markInteractionResolutionUnknown({ + id: prepared.publicId, + expectedRevision: prepared.revision, + responseDigest, + }) + : latest + : this.#store.expireInteraction({ + id: prepared.publicId, + expectedRevision: prepared.revision, + }); + if (terminal !== latest) this.#appendInteractionState(terminal); + if (indeterminate) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The interaction response may have reached Codex; its resolution is unknown.", + { interaction: this.#publicInteraction(terminal) }, + ); + } + if (providerFailureCode(error) === "INVALID_INPUT") { + throw new CommandFailure("INVALID_INPUT", providerFailureMessage(error)); + } + throw new CommandFailure( + "CONFLICT", + "The interaction's exact provider connection is no longer available.", + { interaction: this.#publicInteraction(terminal) }, + ); + } + let written: InteractionRecord; + try { + written = this.#store.markInteractionResponseWritten({ + id: prepared.publicId, + expectedRevision: prepared.revision, + responseDigest, + }); + if (written.state === "response_written") this.#appendInteractionState(written); + } catch (error: unknown) { + throw this.#interactionPersistenceBoundaryError({ + cause: error, + effect: "possibly_sent", + focalInteraction: prepared, + responseDigest, + }); + } + return { interaction: this.#publicInteraction(written), responseWritten: true }; + })(); } - async #readWorkEvents( - command: Extract, - signal: AbortSignal, - ): Promise { - const decodedCursor = command.cursor === undefined - ? undefined - : this.#eventCursors.decodeWorkEvent(command.cursor, command.work); - const read = (): WorkEventPage => { - const priorSequence = this.#workSequence(command.work); - this.#work.snapshot(command.work); - this.#notifyWorkIfAdvanced(command.work, priorSequence); - return this.#normalizeWorkEventPage({ - workId: command.work, - requestedCursor: command.cursor, - decodedCursor, - page: this.#work.events( - command.work, - decodedCursor?.sequence ?? 0, - command.limit, - ), - readFromStart: () => this.#work.events(command.work, 0, command.limit), - }); - }; - let page = read(); - if (page.events.length === 0 && page.gap === null && command.waitMs > 0) { - const expectedSequence = this.#eventCursors.decodeWorkEvent( - page.observedThroughCursor, - command.work, - ).sequence; - await this.#workWaiters.wait({ - workId: command.work, - expectedSequence, - waitMs: command.waitMs, - signal, - readSequence: () => this.#workSequence(command.work), - }); - page = read(); - } - return page; + async #rejectManualResolutionAtDeadline(current: InteractionRecord): Promise { + await this.#expireInteractionAtDeadline( + current, + this.#interactionDeadlineAbort.signal, + ); + const terminal = this.#store.requireInteraction(current.publicId); + throw new CommandFailure( + "CONFLICT", + "The interaction deadline elapsed before the manual resolution could be dispatched.", + { interaction: this.#publicInteraction(terminal) }, + ); } - async #pollWork( - command: Extract, - signal: AbortSignal, - ): Promise { - const actionCursor = command.actionCursor; - if (actionCursor !== undefined && command.waitMs !== 0) { + async #rejectPreparedManualResolutionAtDeadline( + prepared: InteractionRecord, + ): Promise { + if ( + prepared.state !== "response_prepared" + || prepared.responseDigest === null + || prepared.intendedTerminalState === null + || prepared.intendedTerminalState === "expired" + ) throw new Error("INTERACTION_MANUAL_RESPONSE_NOT_PREPARED"); + if (!this.#interactionProfileAuthorityIsUsable(prepared)) { + const terminal = this.#store.expireInteraction({ + id: prepared.publicId, + expectedRevision: prepared.revision, + }); + this.#appendInteractionState(terminal); throw new CommandFailure( - "INVALID_INPUT", - "A work action continuation is a fixed snapshot page and requires waitMs=0.", + "RECOVERY_REQUIRED", + "The interaction authority was revoked before its timeout response could be dispatched.", + { interaction: this.#publicInteraction(terminal) }, ); } - const decodedCursor = command.cursor === undefined - ? undefined - : this.#eventCursors.decodeWorkEvent(command.cursor, command.work); - const decodedActionCursor = actionCursor === undefined - ? undefined - : this.#eventCursors.decodeWorkAction( - actionCursor, - command.work, - command.actor ?? null, - ); - const read = (): WorkPoll => { - const priorSequence = this.#workSequence(command.work); - const readPoll = (afterSequence: number): WorkPoll => this.#work.poll( - command.work, - command.actor, - afterSequence, - command.limit, - decodedActionCursor, - ); - let poll = readPoll(decodedCursor?.sequence ?? 0); - const eventPage = this.#normalizeWorkEventPage({ - workId: command.work, - requestedCursor: command.cursor, - decodedCursor, - page: poll.eventPage, - readFromStart: () => { - poll = readPoll(0); - return poll.eventPage; - }, + const profile = this.#store.requireProfileById(prepared.authority.profileId); + const runtime = this.#runtimeForInteraction(prepared); + const signal = this.#interactionDeadlineAbort.signal; + let timeoutResponseDigest: string; + try { + await this.#daemonAuthority.assertCurrent(); + await this.#assertPersonalInteractionAccountAuthority(prepared, profile, signal); + const validated = await runtime.validateInteractionTimeout({ + authority: this.#authorityForInteraction(prepared, profile), + provider: prepared.authority, + signal, }); - this.#notifyWorkIfAdvanced(command.work, priorSequence); - return workPollSchema.parse({ ...poll, eventPage }); - }; - let poll = read(); - if ( - poll.eventPage.events.length === 0 - && poll.eventPage.gap === null - && command.waitMs > 0 - && poll.readyTasks.length === 0 - && poll.ownedAttempts.length === 0 - && poll.recoveryAttempts.length === 0 - && poll.reviewableSubmissions.length === 0 - && poll.signals.length === 0 - && poll.preparedEffects.length === 0 - ) { - const expectedSequence = this.#eventCursors.decodeWorkEvent( - poll.eventPage.observedThroughCursor, - command.work, - ).sequence; - const waitMs = poll.nextWakeAt === null - ? command.waitMs - : Math.min(command.waitMs, Math.max(0, poll.nextWakeAt - this.#now())); - if (waitMs > 0) { - await this.#workWaiters.wait({ - workId: command.work, - expectedSequence, - waitMs, - signal, - readSequence: () => this.#workSequence(command.work), - }); - } - poll = read(); - } - return poll; - } - - #assertPreparedEffectBinding( - effect: WorkPreparedEffect, - status: NonNullable>, - ): void { - const subjectId = effect.kind === "dispatch" ? effect.attemptId : effect.signalId; - if ( - status.kind !== effect.kind - || status.subjectId !== subjectId - || status.targetSessionId !== effect.targetSessionId - || status.instructionDigest !== digestText(canonicalWorkJson(effect)) - ) { + await this.#assertPersonalInteractionAccountAuthority(prepared, profile, signal); + timeoutResponseDigest = validated.responseDigest; + } catch (error: unknown) { + const latest = this.#store.requireInteraction(prepared.publicId); + const terminal = latest.state === "response_prepared" + && latest.revision === prepared.revision + && latest.responseDigest === prepared.responseDigest + ? this.#store.markInteractionResolutionUnknown({ + id: latest.publicId, + expectedRevision: latest.revision, + responseDigest: prepared.responseDigest, + }) + : latest; + if (terminal !== latest) this.#appendInteractionState(terminal); throw new CommandFailure( "RECOVERY_REQUIRED", - "The prepared work effect no longer matches its durable authority binding.", + providerFailureCode(error) === "INDETERMINATE_EFFECT" + ? "The interaction response may already have reached Codex; its resolution is unknown." + : "The expired interaction could not be closed on its exact provider connection.", + { interaction: this.#publicInteraction(terminal) }, ); } - } - #assertPreparedEffectStatusProjection( - projected: unknown, - status: NonNullable>, - ): void { - if ( - canonicalWorkJson(workPreparedEffectStatusSchema.parse(projected)) - !== canonicalWorkJson(status) - ) { + let timeoutPrepared: InteractionRecord; + try { + timeoutPrepared = this.#store.supersedePreparedInteractionResponseWithTimeout({ + id: prepared.publicId, + expectedRevision: prepared.revision, + manualResponseDigest: prepared.responseDigest, + timeoutResponseDigest, + }); + this.#appendInteractionState(timeoutPrepared); + } catch (error: unknown) { + throw this.#interactionPersistenceBoundaryError({ + cause: error, + effect: "known_unsent", + focalInteraction: prepared, + }); + } + try { + await this.#daemonAuthority.assertCurrent(); + await this.#assertPersonalInteractionAccountAuthority( + timeoutPrepared, + profile, + signal, + ); + await runtime.timeoutInteraction({ + authority: this.#authorityForInteraction(timeoutPrepared, profile), + provider: timeoutPrepared.authority, + signal, + }); + await this.#assertInteractionAccountAuthorityAfterProviderEffect( + timeoutPrepared, + profile, + signal, + ); + } catch (error: unknown) { + const latest = this.#store.requireInteraction(timeoutPrepared.publicId); + const indeterminate = error instanceof IndeterminateLocalCommitError + || providerFailureCode(error) === "INDETERMINATE_EFFECT"; + const terminal = latest.state === "response_prepared" + && latest.revision === timeoutPrepared.revision + && latest.responseDigest === timeoutResponseDigest + ? indeterminate + ? this.#store.markInteractionResolutionUnknown({ + id: latest.publicId, + expectedRevision: latest.revision, + responseDigest: timeoutResponseDigest, + }) + : this.#store.expireInteraction({ + id: latest.publicId, + expectedRevision: latest.revision, + }) + : latest; + if (terminal !== latest) this.#appendInteractionState(terminal); throw new CommandFailure( - "RECOVERY_REQUIRED", - "The public work-effect receipt no longer matches its durable authority binding.", + indeterminate + ? "RECOVERY_REQUIRED" + : "CONFLICT", + indeterminate + ? "The provider timeout response may have reached Codex; its resolution is unknown." + : "The expired interaction could not be closed on its exact provider connection.", + { interaction: this.#publicInteraction(terminal) }, ); } + let terminal: InteractionRecord; + try { + const written = this.#store.markInteractionResponseWritten({ + id: timeoutPrepared.publicId, + expectedRevision: timeoutPrepared.revision, + responseDigest: timeoutResponseDigest, + }); + if (written.state === "response_written") this.#appendInteractionState(written); + terminal = written.state === "response_written" + ? this.#store.settleInteraction({ + id: written.publicId, + expectedRevision: written.revision, + state: "expired", + authority: written.authority, + responseDigest: timeoutResponseDigest, + }) + : written; + if (terminal !== written) this.#appendInteractionState(terminal); + } catch (error: unknown) { + throw this.#interactionPersistenceBoundaryError({ + cause: error, + effect: "possibly_sent", + focalInteraction: timeoutPrepared, + responseDigest: timeoutResponseDigest, + }); + } + throw new CommandFailure( + "CONFLICT", + "The interaction deadline elapsed before the manual resolution could be dispatched.", + { interaction: this.#publicInteraction(terminal) }, + ); } - async #performPreparedWorkEffect( - effect: WorkPreparedEffect, - idempotencyKey: string, - signal: AbortSignal, - ): Promise { - const session = this.#store.requireSession(effect.targetSessionId); - const message = workPreparedEffectMessage(effect); - return await this.#serializeSessionAuthority(session, async () => { - const beforeEffect = (): void => { - const authorization = this.#work.authorizePreparedEffect(idempotencyKey); - this.#assertPreparedEffectBinding(effect, authorization.status); - if (!authorization.executable) throw new WorkEffectExecutionSuppressed(); - this.#assertAuthorizedWorkEffect(effect, authorization); - }; - if (effect.kind === "dispatch") { - await this.#send(session.id, message, effect.nestedMutationKey, signal, beforeEffect); - return; - } - if (effect.mode === "queue") { - await this.#queue(session.id, message, effect.nestedMutationKey, beforeEffect); - return; - } - await this.#steer(session.id, message, effect.nestedMutationKey, signal, beforeEffect); + #personalCodexRestartRequired( + profile: Pick, + ): boolean { + const revocation = this.#store.readProviderRuntimeAccountRevocation({ + profileId: profile.id, + provider: "codex", + runtimeScope: "personal", }); + return revocation?.state === "completed" + && revocation.profileGeneration === profile.processGeneration; } - #assertAuthorizedWorkEffect( - expected: WorkPreparedEffect, - authorization: Extract, - ): void { - this.#assertPreparedEffectBinding(authorization.effect, authorization.status); - if (canonicalWorkJson(authorization.effect) !== canonicalWorkJson(expected)) { - throw new CommandFailure( - "RECOVERY_REQUIRED", - "The persisted work effect does not match the operation projection and was not executed.", - ); - } + #sessionAdoptionStatus(provider?: AdoptableProvider): unknown { + const providers: readonly AdoptableProvider[] = provider === undefined + ? ["codex", "claude"] + : [provider]; + return { + version: 1, + providers: providers.map((candidateProvider) => { + const policy = this.#store.readSessionAdoptionPolicy(candidateProvider); + const counts = this.#store.readSessionAdoptionCounts(candidateProvider); + return { + provider: candidateProvider, + enabled: policy?.enabled ?? false, + accountId: policy?.profileId ?? null, + ...(candidateProvider === "codex" + && this.#store.listProfiles().some((profile) => + this.#personalCodexRestartRequired(profile)) + ? { restartRequired: true } + : {}), + ...counts, + }; + }), + }; } - #projectSettledWorkEffect( - operation: Extract, - effect: WorkPreparedEffect, - ): WorkOperationResult { - const status = this.#work.reprojectPreparedEffect(operation.idempotencyKey); - this.#assertPreparedEffectBinding(effect, status); - if (status.state === "accepted") { - const replay = workOperationResultSchema.parse( - this.#work.apply(operation, operation.idempotencyKey), - ); - if (replay.kind !== "attempt.dispatch" && replay.kind !== "signal.send") { - throw new CommandFailure("RECOVERY_REQUIRED", "The settled work effect replay changed operation kind."); + async #setSessionAdoption( + command: Extract, + signal: AbortSignal, + ): Promise { + if (!command.enabled) { + if (command.provider === "codex") { + await this.#recoverInterruptedCodexAdoptionClaimsBeforePolicyChange(signal); } - this.#assertPreparedEffectStatusProjection(replay.effect, status); - return replay; + this.#store.setSessionAdoptionPolicy({ provider: command.provider, profileId: null }); + return this.#sessionAdoptionStatus(command.provider); } - if (status.state === "failed") { + if (command.account === undefined) { throw new CommandFailure( - "CONFLICT", - "The exact work effect was durably settled without an external effect.", - { idempotencyKey: operation.idempotencyKey, subjectId: status.subjectId }, + "INVALID_INPUT", + "Enabling personal-home session adoption requires an HRA account.", ); } - throw new CommandFailure( - "RECOVERY_REQUIRED", - status.state === "unknown" - ? "The exact nested effect has an unknown outcome and will not be replayed." - : "The exact nested effect has unsettled durable authority and will not be replayed.", - { idempotencyKey: operation.idempotencyKey, subjectId: status.subjectId }, - ); - } - - async #applyWorkOperation( - operation: WorkOperation, - signal: AbortSignal, - ): Promise { - const result = workOperationResultSchema.parse( - this.#work.apply(operation, operation.idempotencyKey), - ); - const workId = result.workId; - this.#workWaiters.notify(workId); - if (result.kind !== "attempt.dispatch" && result.kind !== "signal.send") return result; + const profile = this.#store.requireProfile(command.account); + const currentPolicy = this.#store.readSessionAdoptionPolicy(command.provider); if ( - (operation.kind !== "attempt.dispatch" && operation.kind !== "signal.send") - || operation.kind !== result.kind + command.provider === "codex" + && currentPolicy !== null + && currentPolicy.profileId !== null + && currentPolicy.profileId !== profile.id ) { - throw new CommandFailure("RECOVERY_REQUIRED", "The work effect result changed operation kind."); + await this.#recoverInterruptedCodexAdoptionClaimsBeforePolicyChange(signal); } - - const prepared = this.#work.preparedEffect(operation.idempotencyKey); - if (prepared === null) { + if (command.provider === "codex" && this.#personalCodexRestartRequired(profile)) { throw new CommandFailure( "RECOVERY_REQUIRED", - "The work effect result has no matching durable prepared-effect receipt.", + "The personal-home Codex controller was released after an account change. Restart the HRA daemon before enabling adoption again.", + { accountId: profile.id, provider: "codex", restartRequired: true }, ); } - const { effect, status } = prepared; - this.#assertPreparedEffectStatusProjection(result.effect, status); - this.#assertPreparedEffectBinding(effect, status); - if (status.state !== "prepared") { - return this.#projectSettledWorkEffect(operation, effect); + if (command.provider === "codex") { + this.#assertSignedIn(profile); + this.#assertIdentifiableAccountAuthority(profile); } - - let executionError: unknown; + const scopedRevocation = this.#store.readProviderRuntimeAccountRevocation({ + profileId: profile.id, + provider: command.provider, + runtimeScope: "personal", + }); + if (scopedRevocation?.profileGeneration === profile.processGeneration) { + // Enabling discovery is an authority-bearing admission. Reconcile a + // completed exact-key fence with a fresh provider read first; releasing, + // null-key, or changed-key scopes remain fail-closed. + await this.#assertPersonalProviderAccountAuthority( + profile, + command.provider, + signal, + true, + ); + } + this.#store.setSessionAdoptionPolicy({ + provider: command.provider, + profileId: profile.id, + }); try { - await this.#performPreparedWorkEffect(effect, operation.idempotencyKey, signal); + // The command dispatcher already owns this provider's adoption tail. + const discovery = await this.#discoverPersonalProviderWithAccountLock( + command.provider, + signal, + ); + return { ...this.#sessionAdoptionStatus(command.provider) as object, discovery }; } catch (error: unknown) { - executionError = error; + if (signal.aborted) throw signal.reason; + this.recordBackgroundDiagnostic("session_adoption_failed", error); + return { + ...this.#sessionAdoptionStatus(command.provider) as object, + discovery: { provider: command.provider, state: "unavailable" }, + }; } + } + + /** + * A policy mutation cannot discard an interrupted claim. After a real daemon + * restart, settle old Codex custody from the old policy's exact account lock + * before asking storage to disable or transfer the policy. + */ + async #recoverInterruptedCodexAdoptionClaimsBeforePolicyChange( + signal: AbortSignal, + ): Promise { + const policy = this.#store.readSessionAdoptionPolicy("codex"); + if ( + policy === null + || !policy.enabled + || policy.profileId === null + || this.#store.listSessionAdoptionCandidates({ + provider: "codex", + status: "claiming", + limit: 1, + }).length === 0 + ) return; + const policyProfileId = policy.profileId; try { - let projected = this.#work.reprojectPreparedEffect(operation.idempotencyKey); - this.#assertPreparedEffectBinding(effect, projected); - if (projected.state === "prepared") { - projected = this.#work.settlePreparedEffectNoEffect( - operation.idempotencyKey, - "nested_preflight_no_effect", + await this.#serialize(`account:${policyProfileId}`, async () => { + const exactPolicy = this.#store.readSessionAdoptionPolicy("codex"); + if ( + exactPolicy === null + || !exactPolicy.enabled + || exactPolicy.profileId !== policyProfileId + ) return; + const profile = this.#store.requireProfileById(policyProfileId); + this.#assertSignedIn(profile); + this.#assertIdentifiableAccountAuthority(profile); + const accountKey = await this.#assertPersonalProviderAccountAuthority( + profile, + "codex", + signal, + true, ); - this.#assertPreparedEffectBinding(effect, projected); - } - this.#workWaiters.notify(workId); - } catch (settlementError: unknown) { - if (settlementError instanceof StateSecurityScrubRequiredError) throw settlementError; - throw new CommandFailure( - "RECOVERY_REQUIRED", - "The nested work effect could not be projected into its durable work receipt; replay the exact operation document.", - { idempotencyKey: operation.idempotencyKey, subjectId: status.subjectId }, - ); + if (this.#personalCodexHome === undefined) { + throw new ProviderRuntimeUnavailableError( + "Personal-home Codex session control is unavailable on this daemon.", + ); + } + const authority = { + ...authorityFor(this.#paths, profile), + codexHome: this.#personalCodexHome, + }; + await this.#recoverInterruptedCodexAdoptionClaimsLocked( + profile, + authority, + accountKey, + signal, + ); + }); + } catch (error: unknown) { + if (signal.aborted) throw signal.reason; + if (error instanceof DaemonAuthoritySafetyError) throw error; + // The storage policy seam still refuses the mutation while any claim is + // unsettled; retain the most specific provider failure diagnostically. + this.recordBackgroundDiagnostic("session_adoption_failed", error); } - if (executionError instanceof StateSecurityScrubRequiredError) throw executionError; - return this.#projectSettledWorkEffect(operation, effect); } - #publicInteraction(interaction: InteractionRecord): PublicInteraction { - return publicInteractionSchema.parse({ - version: interaction.version, - id: interaction.publicId, - sessionId: interaction.sessionId, - kind: interaction.kind, - state: interaction.state, - revision: interaction.revision, - blocking: interaction.blocking, - display: interaction.display, - presentation: computeInteractionPresentation(interaction.display), - resolvedBy: interaction.resolvedBy ?? null, - responseRecorded: interaction.responseDigest !== null, - context: { - turnId: interaction.authority.turnId === null - ? null - : this.#eventCursors.projectPublicProviderIdentifier( - interaction.authority.turnId, - ), - itemId: interaction.authority.itemId === null - ? null - : this.#eventCursors.projectPublicProviderIdentifier( - interaction.authority.itemId, - ), - }, - requestedAt: interaction.requestedAt, - deadlineAt: interaction.deadlineAt, - updatedAt: interaction.updatedAt, - terminalAt: interaction.terminalAt, - }); + /** One bounded scan, also used by the daemon's single-owner poller. */ + async discoverPersonalSessions( + provider: AdoptableProvider | undefined, + signal: AbortSignal, + ): Promise { + const finish = this.#beginOperation(); + try { + await this.#daemonAuthority.assertCurrent(); + const selectedProvider = provider === undefined + ? undefined + : adoptableProviderSchema.parse(provider); + const providers: readonly AdoptableProvider[] = selectedProvider === undefined + ? ["codex", "claude"] + : [selectedProvider]; + const settled = await Promise.all(providers.map(async (candidateProvider) => + await this.#serialize( + `session-adoption:${candidateProvider}`, + async () => await this.#discoverPersonalProviderWithAccountLock( + candidateProvider, + signal, + ), + ))); + await this.#daemonAuthority.assertCurrent(); + return { version: 1, providers: settled }; + } finally { + finish(); + } } - #appendInteractionState(interaction: InteractionRecord): void { - if (interaction.sessionId === null) return; - // Route state transitions through the ordinary event pipeline so the - // session-state tracker re-reads the complete pending set. Direct store - // appends would leave an autorespond escalation stuck after the exact - // interaction resolved or expired. - const write: SessionEventWrite = { - sessionId: interaction.sessionId, - accountId: interaction.authority.profileId, - providerGeneration: interaction.authority.processGeneration, - providerConnectionId: interaction.authority.connectionId, - body: { - type: "interaction_state", - interactionId: interaction.publicId, - state: interaction.state, - revision: interaction.revision, - }, + async #projectForPersonalCandidate( + candidate: DiscoveredPersonalSession, + ): Promise { + if (candidate.projectRoot === undefined) return undefined; + let root: string | null; + try { + root = await resolveUsableCanonicalProjectDirectory(candidate.projectRoot); + } catch { + return undefined; + } + if (root === null) return undefined; + return this.#store.listProjects().find((project) => project.rootPath === root); + } + + #personalCandidateIsRecent(candidate: DiscoveredPersonalSession): boolean { + if (candidate.updatedAt === undefined) return candidate.liveness === "live"; + const now = this.#now(); + if (candidate.updatedAt > now + PERSONAL_SESSION_ADOPTION_CLOCK_SKEW_MS) return false; + if (now - candidate.updatedAt <= PERSONAL_SESSION_ADOPTION_RECENCY_MS) return true; + return candidate.provider === "codex" && candidate.scheduledTaskTarget === true; + } + + #personalCandidateNeedsScheduledAgeWaiver( + candidate: DiscoveredPersonalSession, + ): boolean { + if ( + candidate.provider !== "codex" + || candidate.scheduledTaskTarget !== true + || candidate.updatedAt === undefined + ) return false; + const now = this.#now(); + return candidate.updatedAt <= now + PERSONAL_SESSION_ADOPTION_CLOCK_SKEW_MS + && now - candidate.updatedAt > PERSONAL_SESSION_ADOPTION_RECENCY_MS; + } + + #personalCodexCandidateCompletesRecentLiveObservation( + candidate: DiscoveredPersonalSession, + ): boolean { + if ( + candidate.provider !== "codex" + || candidate.admissionEligible !== false + || candidate.liveness !== "not_live" + ) return false; + const durable = this.#store.readSessionAdoptionCandidate( + "codex", + candidate.providerThreadId, + ); + if ( + durable === null + || (durable.status !== "pending" && durable.status !== "claiming") + || durable.lastLiveObservedAt === null + ) return false; + const now = this.#now(); + return durable.lastLiveObservedAt <= now + PERSONAL_SESSION_ADOPTION_CLOCK_SKEW_MS + && now - durable.lastLiveObservedAt <= PERSONAL_SESSION_ADOPTION_RECENCY_MS; + } + + #personalCodexClaimClass( + candidate: DiscoveredPersonalSession, + ): PersonalCodexAdoptionClaimClass { + return this.#personalCandidateNeedsScheduledAgeWaiver(candidate) + ? "scheduled" + : "recent"; + } + + /** + * Interleave independently recent and schedule-waived candidates. Durable + * attempt order prevents a repeatedly failing id from owning its page, while + * the alternating first class removes a fixed bias when only one slot is + * usable on a particular poll. + */ + #orderPersonalCodexAdmissionCandidates( + candidates: readonly PreparedPersonalAdmissionCandidate[], + ): readonly PreparedPersonalAdmissionCandidate[] { + const compare = ( + left: PreparedPersonalAdmissionCandidate, + right: PreparedPersonalAdmissionCandidate, + ): number => { + const leftAttempt = left.durableCandidate.lastAttemptAt; + const rightAttempt = right.durableCandidate.lastAttemptAt; + if (leftAttempt === null && rightAttempt !== null) return -1; + if (leftAttempt !== null && rightAttempt === null) return 1; + if (leftAttempt !== null && rightAttempt !== null && leftAttempt !== rightAttempt) { + return leftAttempt - rightAttempt; + } + const leftUpdated = left.candidate.updatedAt ?? Number.MAX_SAFE_INTEGER; + const rightUpdated = right.candidate.updatedAt ?? Number.MAX_SAFE_INTEGER; + if (leftUpdated !== rightUpdated) return leftUpdated - rightUpdated; + return left.candidate.providerThreadId.localeCompare(right.candidate.providerThreadId); }; - this.#store.appendSessionEvent(write); - this.#eventWaiters.notify(interaction.sessionId); - this.#trackSessionState(write); + const byClass: Record< + PersonalCodexAdoptionClaimClass, + PreparedPersonalAdmissionCandidate[] + > = { recent: [], scheduled: [] }; + for (const candidate of candidates) { + byClass[this.#personalCodexClaimClass(candidate.candidate)].push(candidate); + } + byClass.recent.sort(compare); + byClass.scheduled.sort(compare); + const firstClass = this.#personalCodexNextClaimClass; + const secondClass: PersonalCodexAdoptionClaimClass = firstClass === "recent" + ? "scheduled" + : "recent"; + if (byClass.recent.length > 0 && byClass.scheduled.length > 0) { + this.#personalCodexNextClaimClass = secondClass; + } + const ordered: PreparedPersonalAdmissionCandidate[] = []; + for (let index = 0; index < Math.max( + byClass[firstClass].length, + byClass[secondClass].length, + ); index += 1) { + const first = byClass[firstClass][index]; + if (first !== undefined) ordered.push(first); + const second = byClass[secondClass][index]; + if (second !== undefined) ordered.push(second); + } + return ordered; } - #interactionPersistenceBoundaryError(input: Readonly<{ - cause: unknown; - effect: "known_unsent" | "possibly_sent"; - focalInteraction: InteractionRecord; - responseDigest?: string; - }>): InteractionPersistenceBoundaryError { - const failures: unknown[] = [input.cause]; - let focalInteraction = input.focalInteraction; - let quarantineFailed = false; + #orderPersonalAdmissionCandidates( + provider: AdoptableProvider, + candidates: readonly PreparedPersonalAdmissionCandidate[], + ): readonly PreparedPersonalAdmissionCandidate[] { + if (provider === "codex") { + return this.#orderPersonalCodexAdmissionCandidates(candidates); + } + return [...candidates].sort((left, right) => { + const leftAttempt = left.durableCandidate.lastAttemptAt; + const rightAttempt = right.durableCandidate.lastAttemptAt; + if (leftAttempt === null && rightAttempt !== null) return -1; + if (leftAttempt !== null && rightAttempt === null) return 1; + if (leftAttempt !== null && rightAttempt !== null && leftAttempt !== rightAttempt) { + return leftAttempt - rightAttempt; + } + const leftUpdated = left.candidate.updatedAt ?? Number.MAX_SAFE_INTEGER; + const rightUpdated = right.candidate.updatedAt ?? Number.MAX_SAFE_INTEGER; + if (leftUpdated !== rightUpdated) return leftUpdated - rightUpdated; + return left.candidate.providerThreadId.localeCompare( + right.candidate.providerThreadId, + ); + }); + } + + async #personalCodexScheduledAuthorityForDiscovery( + signal: AbortSignal, + ): Promise< + PersonalCodexScheduledAuthorityBatch + > { + const empty: PersonalCodexScheduledAuthorityBatch = { + providerThreadIds: [], + sourceDirectoryNamesByProviderThreadId: new Map(), + }; + const reader = this.#readPersonalCodexAutomations; + if (reader === undefined) return empty; try { - const focalProvider = input.focalInteraction.sessionId === null - ? "codex" - : this.#store.requireSession(input.focalInteraction.sessionId).provider; - const blocked = this.#isolatedProviderAuthorityAdvanceBlocker( - input.focalInteraction.authority.profileId, + const deadlineAt = this.#now() + PERSONAL_CODEX_AUTOMATION_AUTHORITY_DEADLINE_MS; + const readPage = async ( + after: string | null, + restartPage: number, + ): Promise => { + const scan = await this.#fencedEffect(async () => await reader({ + kind: "page", + after, + deadlineAt, + limit: PERSONAL_SESSION_ADOPTION_SCAN_LIMIT, + restartPage, + signal, + })); + signal.throwIfAborted(); + await this.#daemonAuthority.assertCurrent(); + if ( + (!scan.complete && scan.nextCursor === null) + || scan.entries.length > PERSONAL_SESSION_ADOPTION_SCAN_LIMIT + || scan.entries.length + scan.diagnostics.length + > PERSONAL_SESSION_ADOPTION_SCAN_LIMIT + ) { + throw new Error("Personal Codex automation authority page was unavailable."); + } + return scan; + }; + const requestedCursor = this.#personalCodexAutomationCursor; + const restartPage = this.#personalCodexAutomationRestartPending + ? this.#personalCodexAutomationRestartPage + : 0; + // A generation-derived seek is a best-effort fairness hint, not + // authority. Consume it before the I/O so a large or slow directory + // cannot make every later poll repeat the same deadline-bound seek and + // starve the ordinary page-zero cursor traversal forever. + this.#personalCodexAutomationRestartPending = false; + let scan = await readPage( + requestedCursor, + restartPage, ); - if (focalProvider !== "codex" || blocked !== null) { - throw new Error( - focalProvider !== "codex" - ? `${focalProvider.toUpperCase()}_INTERACTION_QUARANTINE_REQUIRES_DAEMON_RETIREMENT` - : `CODEX_INTERACTION_QUARANTINE_BLOCKED_BY_${blocked?.provider.toUpperCase()}_${blocked?.blocker}`, - ); + // An expired or cross-process cursor first rebuilds a live directory + // handle without returning authority. Consume that proof immediately so + // a polling interval longer than the cursor TTL cannot starve rotation. + if ( + requestedCursor !== null + && !scan.complete + && scan.entries.length === 0 + && scan.diagnostics.length === 0 + && scan.nextCursor !== null + ) { + const rebuiltCursor = scan.nextCursor; + // Retain the rebuilt live cursor even if its immediate proof read + // exhausts the shared deadline or fails transiently. A later poll must + // continue from the rebuilt handle rather than reconstructing the same + // expired cursor forever. + this.#personalCodexAutomationCursor = rebuiltCursor; + scan = await readPage(rebuiltCursor, 0); } - const quarantined = this.#store.quarantineInteractionPersistenceBoundary({ - profileId: input.focalInteraction.authority.profileId, - processGeneration: input.focalInteraction.authority.processGeneration, - connectionId: input.focalInteraction.authority.connectionId, - focalInteractionId: input.focalInteraction.publicId, - effect: input.effect, - ...(input.responseDigest === undefined - ? {} - : { responseDigest: input.responseDigest }), - }); - focalInteraction = quarantined.focalInteraction; - for (const interaction of quarantined.terminalInteractions) { - if (interaction.sessionId !== null) this.#eventWaiters.notify(interaction.sessionId); + const sourceNames = new Map(); + for (const entry of scan.entries) { + const automation = entry.automation; + if ( + automation.kind !== "heartbeat" + || automation.targetThreadId === null + || !isEligiblePersonalCodexAutomationStatus(automation.status) + ) continue; + const existing = sourceNames.get(automation.targetThreadId) ?? []; + if (!existing.includes(entry.sourceDirectoryName)) { + existing.push(entry.sourceDirectoryName); + } + sourceNames.set(automation.targetThreadId, existing); } - this.#rebindIsolatedProviderAuthorities( - quarantined.profile.id, - input.focalInteraction.authority.processGeneration, - quarantined.profile.processGeneration, - ); + this.#personalCodexAutomationCursor = scan.nextCursor; + return { + providerThreadIds: [...sourceNames.keys()].sort(), + sourceDirectoryNamesByProviderThreadId: new Map( + [...sourceNames.entries()].map(([providerThreadId, names]) => [ + providerThreadId, + [...names].sort(), + ]), + ), + }; } catch (error: unknown) { - quarantineFailed = true; - failures.push(error); - this.#state = "closing"; - this.#interactionDeadlineAbort.abort( - new Error("The interaction persistence quarantine failed."), - ); - this.#interactionDeadlineWake?.(); - this.#interactionDeadlineWake = undefined; - try { - focalInteraction = this.#store.requireInteraction( - input.focalInteraction.publicId, - ); - } catch (readError: unknown) { - failures.push(readError); - } + if (signal.aborted) throw signal.reason; + this.recordBackgroundDiagnostic("session_adoption_failed", error); + return empty; } - return new InteractionPersistenceBoundaryError( - focalInteraction, - quarantineFailed, - new AggregateError(failures, "Interaction persistence quarantine evidence."), - ); } - #assertResolutionMatches( - interaction: InteractionRecord, - resolution: InteractionResolution, - ): void { + async #assertPersonalCodexScheduledTargetStillPresent( + providerThreadId: string, + sourceDirectoryNames: readonly string[], + signal: AbortSignal, + ): Promise { + const reader = this.#readPersonalCodexAutomations; + if (reader === undefined || sourceDirectoryNames.length === 0) { + throw new Error("SESSION_ADOPTION_SCHEDULED_TARGET_CHANGED"); + } + const requestedSources = new Set(sourceDirectoryNames); + const scan = await this.#fencedEffect(async () => await reader({ + kind: "sources", + sourceDirectoryNames, + deadlineAt: this.#now() + PERSONAL_CODEX_AUTOMATION_AUTHORITY_DEADLINE_MS, + signal, + })); + signal.throwIfAborted(); + await this.#daemonAuthority.assertCurrent(); if ( - interaction.kind === "file_change_approval" - && resolution.kind === "approval_decision" - && (resolution.decision === "once" || resolution.decision === "session") + !scan.complete + || scan.nextCursor !== null + || scan.entries.length > requestedSources.size + || scan.entries.some((entry) => !requestedSources.has(entry.sourceDirectoryName)) + || !scan.entries.some((entry) => + entry.automation.kind === "heartbeat" + && entry.automation.targetThreadId === providerThreadId + && isEligiblePersonalCodexAutomationStatus(entry.automation.status)) ) { - throw new CommandFailure( - "INVALID_INPUT", - "File-change approval is disabled because the pinned provider callback does not expose exact affected paths or change detail.", + throw new Error("SESSION_ADOPTION_SCHEDULED_TARGET_CHANGED"); + } + } + + /** + * Reconcile claim custody left by an earlier daemon generation. A metadata + * read is deliberately used here: it proves current quiescence without + * resuming or subscribing to a thread whose prior release was unproven. + */ + async #recoverInterruptedCodexAdoptionClaimsLocked( + profile: ProfileRecord, + authority: ProfileAuthority, + expectedAccountKey: string, + signal: AbortSignal, + ): Promise { + const personalCodex = this.#personalCodex; + const readSessionMetadata = personalCodex?.readSessionMetadata?.bind(personalCodex); + if (readSessionMetadata === undefined) { + throw new ProviderRuntimeUnavailableError( + "Personal-home Codex control cannot read exact thread metadata for recovery.", ); } - const expected = interaction.kind === "user_input" - ? "user_answers" - : interaction.kind === "mcp_elicitation" - ? "mcp_submission" - : "approval_decision"; - const permissionDecision = interaction.kind === "permission_approval" - && resolution.kind === "approval_decision" - && resolution.decision === "decline"; - const permissionGrant = interaction.kind === "permission_approval" - && resolution.kind === "permission_grant"; - if (!permissionDecision && !permissionGrant && resolution.kind !== expected) { - throw new CommandFailure( - "INVALID_INPUT", - interaction.kind === "permission_approval" - ? "A permission approval requires an exact permission grant or decline resolution." - : `A ${interaction.kind} interaction requires a ${expected} resolution.`, + for (;;) { + const batch = this.#store.listSessionAdoptionCandidates({ + provider: "codex", + status: "claiming", + limit: PERSONAL_SESSION_ADOPTION_SCAN_LIMIT, + }); + if (batch.length === 0) return; + const candidates = batch.filter((candidate) => + !this.#unprovenCodexAdoptionClaims.has(candidate.providerThreadId)); + if (candidates.length === 0) return; + const observations = await Promise.all(candidates.map(async (candidate) => { + try { + const projection = await this.#fencedEffect(async () => + await readSessionMetadata( + authority, + candidate.providerThreadId, + signal, + )); + return { candidate, projection } as const; + } catch (error: unknown) { + return { candidate, error } as const; + } + })); + signal.throwIfAborted(); + await this.#daemonAuthority.assertCurrent(); + const exactProfile = this.#store.requireProfileById(profile.id); + this.#assertSignedIn(exactProfile); + this.#assertIdentifiableAccountAuthority(exactProfile); + if (exactProfile.processGeneration !== profile.processGeneration) { + throw new Error("SESSION_ADOPTION_PROFILE_AUTHORITY_CHANGED"); + } + const exactAccountKey = await this.#assertPersonalProviderAccountAuthority( + exactProfile, + "codex", + signal, + true, ); + if (exactAccountKey !== expectedAccountKey) { + throw new ProviderAccountAuthorityMismatchError( + "codex", + exactProfile, + ); + } + signal.throwIfAborted(); + await this.#daemonAuthority.assertCurrent(); + let recovered = 0; + for (const observation of observations) { + if ("error" in observation) { + this.recordBackgroundDiagnostic("session_adoption_failed", observation.error); + continue; + } + try { + if (observation.projection.providerThreadId !== observation.candidate.providerThreadId) { + throw new Error("SESSION_ADOPTION_RECOVERY_THREAD_MISMATCH"); + } + const liveness = inferCodexLiveness({ + status: observation.projection.status, + ...(observation.projection.activeTurnId === undefined + ? {} + : { activeTurnId: observation.projection.activeTurnId }), + ...(observation.projection.providerUpdatedAt === undefined + ? {} + : { updatedAt: observation.projection.providerUpdatedAt }), + now: this.#now(), + }); + const observed = this.#store + .updateCodexSessionAdoptionCandidateLivenessAfterExactRead({ + providerThreadId: observation.candidate.providerThreadId, + expectedRevision: observation.candidate.revision, + liveness, + ...(liveness === "live" && ( + observation.projection.status === "active" + || observation.projection.activeTurnId !== undefined + ) ? { trustedLiveObservation: true } : {}), + }); + if (liveness !== "not_live") continue; + this.#store.recoverSessionAdoptionClaimAfterObservation({ + provider: "codex", + providerThreadId: observed.providerThreadId, + profileId: exactProfile.id, + expectedRevision: observed.revision, + }); + recovered += 1; + } catch (error: unknown) { + this.recordBackgroundDiagnostic("session_adoption_failed", error); + } + } + // A live, unreadable, conflicted, or same-daemon claim still blocks the + // policy mutation. Stop here rather than repeatedly probing the same row. + if (recovered !== batch.length) return; } - if ( - interaction.kind === "permission_approval" - && resolution.kind === "approval_decision" - && !permissionDecision + } + + async #reprobeRetainedClaudeCandidates( + currentProviderThreadIds: ReadonlySet, + signal: AbortSignal, + ): Promise { + if (this.#claudeProcessLiveness === undefined) return []; + const retained = this.#store + .listRetainedClaudeSessionAdoptionCandidatesWithSourceIdentity({ + // Exclusion is applied by storage before ORDER BY/LIMIT. A full current + // registry snapshot therefore cannot starve recently HRA-observed + // live identities that have since disappeared from the registry. + excludeProviderThreadIds: [...currentProviderThreadIds], + liveObservedAfter: Math.max(0, this.#now() - PERSONAL_SESSION_ADOPTION_RECENCY_MS), + limit: PERSONAL_SESSION_ADOPTION_SCAN_LIMIT, + }); + if (retained.length === 0) return []; + + const projects = new Map( + this.#store.listProjects().map((project) => [project.id, project] as const), + ); + const deadlineAt = this.#now() + CLAUDE_PROCESS_LIVENESS_DEADLINE_MS; + const observed: RetainedClaudeCandidateObservation[] = []; + for ( + let offset = 0; + offset < retained.length; + offset += CLAUDE_RETAINED_CANDIDATE_PROBE_CONCURRENCY ) { - throw new CommandFailure( - "INVALID_INPUT", - "Permission approvals can be declined, but cancel, once, and session decisions are not represented by this provider callback.", + signal.throwIfAborted(); + const batch = retained.slice( + offset, + offset + CLAUDE_RETAINED_CANDIDATE_PROBE_CONCURRENCY, ); + const settled = await Promise.all(batch.map(async (candidate) => { + const sourceProcessIdentity = candidate.sourceProcessIdentity; + if (sourceProcessIdentity === null) return null; + try { + const liveness = await this.#fencedEffect(async () => + await this.#probeClaudeProcessIdentityLiveness( + sourceProcessIdentity, + signal, + deadlineAt, + )); + signal.throwIfAborted(); + await this.#daemonAuthority.assertCurrent(); + const durableCandidate = this.#store + .updateClaudeSessionAdoptionCandidateLivenessAfterExactProbe({ + providerThreadId: candidate.providerThreadId, + expectedRevision: candidate.revision, + expectedSourceProcessIdentity: sourceProcessIdentity, + liveness, + }); + const syntheticCandidate: DiscoveredPersonalSession = { + provider: "claude", + providerThreadId: durableCandidate.providerThreadId, + title: durableCandidate.title, + ...(durableCandidate.providerProjectRoot === null + ? {} + : { projectRoot: durableCandidate.providerProjectRoot }), + ...(durableCandidate.providerUpdatedAt === null + ? {} + : { updatedAt: durableCandidate.providerUpdatedAt }), + liveness: durableCandidate.liveness, + sourceProcessIdentity: durableCandidate.sourceProcessIdentity, + }; + return { + candidate: syntheticCandidate, + durableCandidate, + project: durableCandidate.projectId === null + ? undefined + : projects.get(durableCandidate.projectId), + } satisfies RetainedClaudeCandidateObservation; + } catch (error: unknown) { + if (signal.aborted) return { aborted: signal.reason as unknown } as const; + this.recordBackgroundDiagnostic("session_adoption_failed", error); + return null; + } + })); + for (const result of settled) { + if (result === null) continue; + if ("aborted" in result) throw result.aborted; + observed.push(result); + } } - if ( - resolution.kind === "approval_decision" - && (interaction.display.kind === "command_approval" - || interaction.display.kind === "file_change_approval") - && !interaction.display.availableDecisions.includes(resolution.decision) - ) { - throw new CommandFailure("INVALID_INPUT", "This provider request does not offer that decision."); + return observed; + } + + async #discoverPersonalProviderWithAccountLock( + provider: AdoptableProvider, + signal: AbortSignal, + ): Promise { + // Every caller owns session-adoption:. Preserve the global lock + // order used by logout/revocation, then hold the selected account tail for + // the entire provider claim and durable commit. A managed disconnect may + // advance the account generation before this lock or observe the committed + // personal binding afterward, but cannot invalidate authority mid-claim. + const policy = this.#store.readSessionAdoptionPolicy(provider); + if (policy === null || !policy.enabled || policy.profileId === null) { + return await this.#discoverPersonalProviderLocked(provider, signal); + } + return await this.#serialize( + `account:${policy.profileId}`, + async () => await this.#discoverPersonalProviderLocked(provider, signal), + ); + } + + async #discoverPersonalProviderLocked( + provider: AdoptableProvider, + signal: AbortSignal, + ): Promise { + const policy = this.#store.readSessionAdoptionPolicy(provider); + if (policy === null || !policy.enabled || policy.profileId === null) { + return { provider, state: "disabled", discovered: 0, adopted: 0, pending: 0 }; } - if ( - resolution.kind === "permission_grant" - && interaction.display.kind === "permission_approval" - ) { - const requested = new Set(interaction.display.requested.map((permission) => permission.name)); - if (resolution.permissions.some((name) => !requested.has(name))) { - throw new CommandFailure("INVALID_INPUT", "Granted permissions must be a subset of the request."); - } - if (resolution.scope === "session" && !interaction.display.allowsSessionScope) { - throw new CommandFailure("INVALID_INPUT", "This provider request does not allow session permission scope."); - } + if (this.#personalDiscovery === undefined || this.#personalCodexHome === undefined) { + throw new ProviderRuntimeUnavailableError( + `Personal-home ${provider} discovery is unavailable on this daemon.`, + ); } - if (resolution.kind === "user_answers" && interaction.display.kind === "user_input") { - const questions = new Set(interaction.display.questions.map((question) => question.id)); - const answers = Object.keys(resolution.answers); - if (answers.length !== questions.size || answers.some((id) => !questions.has(id))) { - throw new CommandFailure("INVALID_INPUT", "User answers must match the provider's exact question IDs."); + const profile = this.#store.requireProfileById(policy.profileId); + if (provider === "codex") { + this.#assertSignedIn(profile); + this.#assertIdentifiableAccountAuthority(profile); + } + const authority = { ...authorityFor(this.#paths, profile), codexHome: this.#personalCodexHome }; + const discoveryProviderAccountKey = await this.#assertPersonalProviderAccountAuthority( + profile, + provider, + signal, + true, + ); + if (provider === "codex") { + await this.#recoverInterruptedCodexAdoptionClaimsLocked( + profile, + authority, + discoveryProviderAccountKey, + signal, + ); + } + const codexScheduledAuthority = provider === "codex" + ? await this.#personalCodexScheduledAuthorityForDiscovery(signal) + : undefined; + const codexScheduledThreadIds = codexScheduledAuthority?.providerThreadIds; + const observed = await this.#fencedEffect(async () => { + return await this.#personalDiscovery?.discover({ + provider, + ...(codexScheduledThreadIds === undefined + ? {} + : { codexScheduledThreadIds }), + // Observation is independently bounded from provider claims. Reading + // all four Codex pages and the complete bounded Claude registry keeps + // live prefixes from hiding a later quiescent candidate. + limit: PERSONAL_SESSION_DISCOVERY_MAX_RESULTS, + deadlineMs: 5_000, + signal, + }) ?? []; + }); + + // Persist the complete bounded observation before any controller claim. + // A slow or rejected first claim therefore cannot prevent later live rows + // from becoming durable candidates for eventual quiescent takeover. + const registeredProjectsByRoot = new Map( + this.#store.listProjects().map((project) => [project.rootPath, project] as const), + ); + const preparedObserved: PreparedPersonalAdmissionCandidate[] = []; + let persisted = 0; + let retainedObservationPending = 0; + for (const candidate of observed) { + signal.throwIfAborted(); + if (candidate.provider !== provider) continue; + const admissionEligible = ( + candidate.admissionEligible !== false + && this.#personalCandidateIsRecent(candidate) + ) || this.#personalCodexCandidateCompletesRecentLiveObservation(candidate); + const retentionEligible = candidate.trustedLiveObservation === true + && candidate.liveness === "live" + && (provider === "codex" || candidate.sourceProcessIdentity != null); + if (!admissionEligible && !retentionEligible) continue; + // Registered project roots are canonical, so an exact provider string can + // be mapped without filesystem I/O. Noncanonical/symlink roots are not + // inferred for live-only retention here: only a later admission attempt, + // inside the two-candidate preflight budget, may canonicalize them. + const project = candidate.projectRoot === undefined + ? undefined + : registeredProjectsByRoot.get(candidate.projectRoot); + const durableCandidate = this.#store.upsertSessionAdoptionCandidate({ + provider, + providerThreadId: candidate.providerThreadId, + ...(project === undefined ? {} : { projectId: project.id }), + ...(candidate.projectRoot === undefined + ? {} + : { providerProjectRoot: candidate.projectRoot }), + title: candidate.title, + state: candidate.liveness === "live" ? "active" : "idle", + ...(candidate.updatedAt === undefined + ? {} + : { providerUpdatedAt: candidate.updatedAt }), + liveness: candidate.liveness, + ...(candidate.trustedLiveObservation === true + ? { trustedLiveObservation: true } + : {}), + ...(provider === "claude" + ? { sourceProcessIdentity: candidate.sourceProcessIdentity ?? null } + : {}), + }); + persisted += 1; + if (admissionEligible) { + preparedObserved.push({ + kind: "discovered", + candidate, + durableCandidate, + project, + }); + } else if ( + durableCandidate.status === "pending" + || durableCandidate.status === "claiming" + ) { + retainedObservationPending += 1; } } - if (resolution.kind === "mcp_submission" && interaction.display.kind === "mcp_elicitation") { - if (interaction.display.mode !== "form" || interaction.display.fields === undefined) { - throw new CommandFailure("INVALID_INPUT", "This MCP form cannot be safely completed through HRA."); + signal.throwIfAborted(); + await this.#daemonAuthority.assertCurrent(); + const retainedClaude = provider === "claude" + ? await this.#reprobeRetainedClaudeCandidates( + new Set(observed + .filter((candidate) => candidate.provider === "claude") + .map((candidate) => candidate.providerThreadId)), + signal, + ) + : []; + const admissionCandidates = this.#orderPersonalAdmissionCandidates(provider, [ + ...preparedObserved, + ...retainedClaude.map((candidate) => ({ kind: "retained" as const, ...candidate })), + ]); + let adopted = 0; + let pending = retainedObservationPending; + let failed = 0; + let preflightAttempts = 0; + for (const admissionCandidate of admissionCandidates) { + const candidate = admissionCandidate.candidate; + let project = admissionCandidate.project; + let durableCandidate = admissionCandidate.durableCandidate; + const codexClaimKey = provider === "codex" + ? candidate.providerThreadId + : null; + signal.throwIfAborted(); + if ( + codexClaimKey !== null + && this.#unprovenCodexAdoptionClaims.has(codexClaimKey) + ) { + if (durableCandidate.status === "pending" || durableCandidate.status === "claiming") { + pending += 1; + } + continue; } - if (resolution.action !== "accept") { - if (resolution.content !== undefined) { - throw new CommandFailure("INVALID_INPUT", "Declined or canceled MCP forms cannot include content."); + if (durableCandidate.status === "claiming" && candidate.liveness === "not_live") { + try { + durableCandidate = this.#store.recoverSessionAdoptionClaimAfterObservation({ + provider, + providerThreadId: candidate.providerThreadId, + profileId: profile.id, + expectedRevision: durableCandidate.revision, + }); + } catch (error: unknown) { + // A claiming row already contributes to the aggregate pending count. + // Keep it fenced until a later observation or exact Claude process + // release makes crash recovery provable. + this.recordBackgroundDiagnostic("session_adoption_failed", error); } - return; } - try { - validateMcpFormSubmission(interaction.display.fields, resolution.content ?? {}); - } catch { - throw new CommandFailure( - "INVALID_INPUT", - "Protected MCP form content does not match the requested field contract.", - ); + if (durableCandidate.status !== "pending") { + if (durableCandidate.status === "claiming") { + pending += 1; + } + continue; } - } - } - - #intendedInteractionTerminalState( - resolution: InteractionResolution, - ): InteractionIntendedTerminalState { - if (resolution.kind === "approval_decision") { - if (resolution.decision === "decline") return "declined"; - if (resolution.decision === "cancel") return "canceled"; - return "resolved"; - } - if (resolution.kind === "mcp_submission") { - if (resolution.action === "decline") return "declined"; - if (resolution.action === "cancel") return "canceled"; - } - return "resolved"; - } - - async #inspectInteraction( - command: Extract, - signal: AbortSignal, - ): Promise { - return await this.#serialize(`interaction:${command.interaction}`, async () => { - const current = this.#store.requireInteraction(command.interaction); if ( - current.revision !== command.expectedRevision - || current.state !== "pending" - || this.#now() >= current.deadlineAt + (provider === "claude" && candidate.liveness !== "not_live") + || (provider === "codex" && candidate.liveness !== "not_live") ) { - throw new CommandFailure( - "CONFLICT", - "The interaction revision, state, or deadline changed before protected inspection.", - ); + pending += 1; + continue; } - if (current.kind !== "command_approval" && current.kind !== "permission_approval") { - throw new CommandFailure( - "INVALID_INPUT", - "This interaction has no complete approval authority available for protected inspection.", - ); + if (this.#store.findSessionPersonalRuntimeBinding( + provider, + candidate.providerThreadId, + )?.state === "detaching") { + pending += 1; + continue; } - const profile = this.#store.requireProfileById(current.authority.profileId); - let authority: Awaited>; + if ( + preflightAttempts >= PERSONAL_SESSION_ADOPTION_CLAIM_ATTEMPT_LIMIT + ) { + pending += 1; + continue; + } + const claimState = { claimed: false, dispatched: false }; + let committedSession: SessionRecord | undefined; + let claudeProcessIdentity: ClaudeProcessIdentity | undefined; + let claudeLaunchIntent: ClaudeProcessLaunchIntentRecord | undefined; try { - await this.#daemonAuthority.assertCurrent(); - authority = await this.#runtimeForInteraction(current).inspectInteractionAuthority({ - authority: authorityFor(this.#paths, profile), - provider: current.authority, - kind: current.kind, - signal, + // Record and consume the bounded attempt before any candidate-specific + // async filesystem or schedule check. A rejected preflight has no + // provider effect, so the row stays pending while its durable attempt + // timestamp moves it behind untouched candidates on the next poll. + durableCandidate = this.#store.recordSessionAdoptionCandidatePreflightAttempt({ + provider, + providerThreadId: candidate.providerThreadId, + expectedRevision: durableCandidate.revision, }); - await this.#daemonAuthority.assertCurrent(); - } catch (error: unknown) { - if (providerFailureCode(error) === "UNSUPPORTED_CAPABILITY") { - throw new CommandFailure("INVALID_INPUT", providerFailureMessage(error)); + preflightAttempts += 1; + if (project === undefined) { + project = await this.#projectForPersonalCandidate(candidate); } - throw new CommandFailure( - "CONFLICT", - "The interaction's exact live provider authority is no longer available.", + if (project === undefined) { + throw new Error("SESSION_ADOPTION_PROJECT_UNAVAILABLE"); + } + const projectRoot = await this.#requireUsableProjectRoot(project.rootPath); + signal.throwIfAborted(); + await this.#daemonAuthority.assertCurrent(); + // Persist the bounded canonical result before any provider effect. It is + // then available to an exact-process Claude reprobe if the registry row + // disappears after this observation. + durableCandidate = this.#store.upsertSessionAdoptionCandidate({ + provider, + providerThreadId: candidate.providerThreadId, + projectId: project.id, + ...(candidate.projectRoot === undefined + ? {} + : { providerProjectRoot: candidate.projectRoot }), + title: candidate.title, + state: candidate.liveness === "live" ? "active" : "idle", + ...(candidate.updatedAt === undefined + ? {} + : { providerUpdatedAt: candidate.updatedAt }), + liveness: candidate.liveness, + ...(candidate.sourceProcessIdentity === undefined + ? {} + : { sourceProcessIdentity: candidate.sourceProcessIdentity }), + }); + const adoptionPreset = this.#store.readDefaultPreset(provider); + const adoptionFast = false; + const adoptionRequirement = presetRequirementForContract( + adoptionPreset, + currentPresetContract, ); - } - const observed = this.#store.requireInteraction(current.publicId); - if ( - observed.revision !== current.revision - || observed.state !== "pending" - || observed.kind !== current.kind - || observed.sessionId !== current.sessionId - || observed.authority.profileId !== current.authority.profileId - || observed.authority.processGeneration !== current.authority.processGeneration - || observed.authority.connectionId !== current.authority.connectionId - || observed.authority.requestDigest !== current.authority.requestDigest - || observed.authority.requestId.type !== current.authority.requestId.type - || observed.authority.requestId.value !== current.authority.requestId.value - || this.#now() >= observed.deadlineAt - ) { - throw new CommandFailure( - "CONFLICT", - "The interaction authority changed during protected inspection.", + const needsScheduledAgeWaiver = this.#personalCandidateNeedsScheduledAgeWaiver( + candidate, ); - } - const document = protectedInteractionDetailDocumentSchema.parse({ - type: "hra_protected_interaction_detail", - version: 1, - binding: { - interactionId: observed.publicId, - revision: observed.revision, - kind: observed.kind, - sessionId: observed.sessionId, - profileId: observed.authority.profileId, - processGeneration: observed.authority.processGeneration, - connectionId: observed.authority.connectionId, - }, - authority, - }); - const encoded = encodeProtectedInteractionDetailDocument(document); - const fits = encoded.byteLength <= PROTECTED_INTERACTION_DETAIL_MAXIMUM_BYTES; - encoded.fill(0); - if (!fits) { - throw new CommandFailure( - "INVALID_INPUT", - "The complete approval authority exceeds HRA's protected-output limit.", + const scheduledSourceDirectoryNames = needsScheduledAgeWaiver + ? codexScheduledAuthority?.sourceDirectoryNamesByProviderThreadId.get( + candidate.providerThreadId, + ) ?? [] + : []; + if (needsScheduledAgeWaiver) { + await this.#assertPersonalCodexScheduledTargetStillPresent( + candidate.providerThreadId, + scheduledSourceDirectoryNames, + signal, + ); + signal.throwIfAborted(); + await this.#daemonAuthority.assertCurrent(); + } + durableCandidate = this.#store.fenceSessionAdoptionCandidateForClaim({ + provider, + providerThreadId: candidate.providerThreadId, + expectedRevision: durableCandidate.revision, + }); + let projection: CodexSessionProjection; + let connectionId: string; + let effectiveRuntimeProfile: ReviewedRuntimeProfile; + if (provider === "codex") { + const personalCodex = this.#personalCodex; + if (personalCodex === undefined) { + throw new ProviderRuntimeUnavailableError( + "Personal-home Codex control is unavailable on this daemon.", + ); + } + if (personalCodex.claimSession === undefined) { + throw new ProviderRuntimeUnavailableError( + "Personal-home Codex control cannot claim an existing thread.", + ); + } + const claimSession = personalCodex.claimSession.bind(personalCodex); + const observation = await this.#fencedEffect(async () => { + claimState.dispatched = true; + const value = await claimSession({ + authority, + providerThreadId: candidate.providerThreadId, + projectRoot, + preset: adoptionPreset, + requirement: adoptionRequirement, + fast: adoptionFast, + signal, + }); + claimState.claimed = true; + return value; + }); + if (inferCodexLiveness({ + status: observation.projection.status, + ...(observation.projection.activeTurnId === undefined + ? {} + : { activeTurnId: observation.projection.activeTurnId }), + ...(observation.projection.providerUpdatedAt === undefined + ? {} + : { updatedAt: observation.projection.providerUpdatedAt }), + now: this.#now(), + }) !== "not_live") { + throw new Error("SESSION_ADOPTION_CLAIM_LIVENESS_CHANGED"); + } + projection = observation.projection; + connectionId = observation.connectionId; + effectiveRuntimeProfile = observation.effectiveRuntimeProfile; + } else { + const personalClaude = this.#personalClaude; + if (personalClaude === undefined) { + throw new ProviderRuntimeUnavailableError( + "Personal-home Claude control is unavailable on this daemon.", + ); + } + claudeLaunchIntent = this.#store.stageClaudeProcessLaunchIntent({ + providerThreadId: candidate.providerThreadId, + profileId: profile.id, + profileGeneration: authority.generation, + runtimeScope: "personal", + providerAccountKey: discoveryProviderAccountKey, + }); + const launchIntent = claudeLaunchIntent; + const resumed = await this.#fencedEffect(async () => { + const value = await personalClaude.claimSession({ + authority, + admitProcessIdentity: async (identity) => { + claudeProcessIdentity = await this.#recordClaimedClaudeProcess({ + authority, + providerThreadId: candidate.providerThreadId, + runtimeScope: "personal", + launchIntent, + identity, + signal, + }); + }, + providerThreadId: candidate.providerThreadId, + projectRoot, + title: candidate.title, + preset: adoptionPreset, + requirement: adoptionRequirement, + fast: adoptionFast, + sourceLiveness: "not_live", + signal, + }); + claimState.claimed = true; + return value; + }); + if (claudeProcessIdentity === undefined) { + throw new Error("CLAUDE_PROCESS_IDENTITY_NOT_ADMITTED"); + } + projection = resumed; + effectiveRuntimeProfile = resumed.effectiveRuntimeProfile; + const observation = await this.#fencedEffect(async () => + await personalClaude.observeSession({ + authority, + providerThreadId: candidate.providerThreadId, + signal, + })); + connectionId = observation.connectionId; + } + effectiveRuntimeProfile = assertClaimedRuntimeProfile({ + authority, + fast: adoptionFast, + preset: adoptionPreset, + provider, + requirement: adoptionRequirement, + runtimeProfile: effectiveRuntimeProfile, + }); + if ( + projection.providerThreadId !== candidate.providerThreadId + || projection.status !== "idle" + || projection.activeTurnId !== undefined + ) { + throw new Error("SESSION_ADOPTION_CLAIM_NOT_QUIESCENT"); + } + const projectionProject = projection.projectRoot === undefined + ? project + : await this.#projectForPersonalCandidate({ + ...candidate, + projectRoot: projection.projectRoot, + }); + if (projectionProject?.id !== project.id) { + throw new Error("SESSION_ADOPTION_PROJECT_CHANGED_DURING_CLAIM"); + } + signal.throwIfAborted(); + await this.#daemonAuthority.assertCurrent(); + const exactProfile = this.#store.requireProfileById(profile.id); + if (provider === "codex") { + this.#assertSignedIn(exactProfile); + this.#assertIdentifiableAccountAuthority(exactProfile); + } else if ( + exactProfile.state !== "signed_in" + && exactProfile.state !== "signed_out" + ) { + throw new Error("SESSION_ADOPTION_PROFILE_AUTHORITY_CHANGED"); + } + if (exactProfile.processGeneration !== authority.generation) { + throw new Error("SESSION_ADOPTION_PROFILE_AUTHORITY_CHANGED"); + } + // Identity is sandwiched around discovery and controller claim. The + // selected account, personal provider home, and durable session + // authority must still name one normalized provider identity at the + // exact claim-to-commit boundary. + const providerAccountKey = await this.#assertPersonalProviderAccountAuthority( + exactProfile, + provider, + signal, + true, ); + if (providerAccountKey !== discoveryProviderAccountKey) { + this.#scheduleProviderRuntimeAccountRevocation( + exactProfile, + provider, + "personal", + providerAccountKey, + ); + throw new ProviderAccountAuthorityMismatchError( + provider, + exactProfile, + ); + } + if (needsScheduledAgeWaiver) { + await this.#assertPersonalCodexScheduledTargetStillPresent( + candidate.providerThreadId, + scheduledSourceDirectoryNames, + signal, + ); + signal.throwIfAborted(); + await this.#daemonAuthority.assertCurrent(); + } + const claimedProviderProjectRoot = projection.projectRoot ?? candidate.projectRoot; + durableCandidate = this.#store.upsertSessionAdoptionCandidate({ + provider, + providerThreadId: projection.providerThreadId, + projectId: project.id, + ...(claimedProviderProjectRoot === undefined + ? {} + : { providerProjectRoot: claimedProviderProjectRoot }), + // The Claude adapter bounds its in-memory display title. Preserve + // the complete title discovered from the personal registry as the + // durable session title across claim and later resume. + title: provider === "claude" ? durableCandidate.title : projection.title, + state: projection.status, + ...(projection.providerUpdatedAt === undefined + ? {} + : { providerUpdatedAt: projection.providerUpdatedAt }), + liveness: provider === "claude" ? "not_live" : candidate.liveness, + }); + const result = this.#store.adoptSessionCandidate({ + provider, + providerThreadId: projection.providerThreadId, + expectedCandidateRevision: durableCandidate.revision, + profileId: profile.id, + profileGeneration: exactProfile.processGeneration, + projectId: project.id, + preset: adoptionPreset, + requirement: adoptionRequirement, + fastEnabled: adoptionFast, + runtimeProfile: effectiveRuntimeProfile, + providerAccountKey, + ...(claudeProcessIdentity === undefined ? {} : { claudeProcessIdentity }), + }); + committedSession = result.session; + if (codexClaimKey !== null) { + this.#unprovenCodexAdoptionClaims.delete(codexClaimKey); + } + adopted += 1; + this.#ensureSessionProviderConnection(authority, result.session, connectionId); + if (provider === "claude") { + const personalClaude = this.#personalClaude; + if (personalClaude === undefined) { + throw new ProviderRuntimeUnavailableError( + "Personal-home Claude control disappeared after adoption commit.", + ); + } + try { + // Re-prove the controller after the durable commit and provisional + // connection map are both visible. A child that disconnected in + // the claim-to-commit gap must not leave a bound dead-process row. + const confirmation = await this.#fencedEffect(async () => + await personalClaude.observeSession({ + authority, + providerThreadId: candidate.providerThreadId, + signal, + })); + if ( + confirmation.projection.providerThreadId !== candidate.providerThreadId + || confirmation.projection.status !== "idle" + || confirmation.projection.activeTurnId !== undefined + ) { + throw new ClaudeSessionObservationError(); + } + this.#ensureSessionProviderConnection( + authority, + result.session, + confirmation.connectionId, + ); + } catch (error: unknown) { + await this.#releaseClaudeProcessAuthority( + { + providerThreadId: candidate.providerThreadId, + profileId: profile.id, + runtimeScope: "personal", + }, + new AbortController().signal, + ); + this.recordBackgroundDiagnostic("recovery_observation_failed", error); + this.#scheduleRecoverySessionObservations([result.session]); + } + } + await this.#reconcileCommittedSessionFactsMemory(result.session); + } catch (error: unknown) { + if ( + codexClaimKey !== null + && error instanceof CodexClaimReleaseUnprovenError + ) { + this.#unprovenCodexAdoptionClaims.add(codexClaimKey); + } + // Codex's claim seam guarantees that every ordinary rejection has + // either acquired no subscription or synchronously released/retired + // the exact controller. Only the closed unproven-release error keeps + // durable `claiming` custody for restart recovery. Do not target-end a + // deterministic failed claim a second time: the provider identity may + // already have been retired along with its connection. + let releaseProven = provider === "codex" + && committedSession === undefined + && claimState.dispatched + && !claimState.claimed + && !(error instanceof CodexClaimReleaseUnprovenError); + if ( + committedSession === undefined + && !(error instanceof ClaudeProcessExitUnprovenError) + && (claimState.claimed || claudeLaunchIntent !== undefined) + ) { + const release = provider === "claude" + ? claudeProcessIdentity !== undefined + ? this.#releaseClaudeProcessAuthority( + { + providerThreadId: candidate.providerThreadId, + profileId: profile.id, + runtimeScope: "personal", + }, + new AbortController().signal, + ) + : claimState.claimed + ? this.#personalSessionRuntime(provider).endSession({ + authority, + providerThreadId: candidate.providerThreadId, + signal: new AbortController().signal, + }) + : Promise.resolve() + : this.#personalSessionRuntime(provider).endSession({ + authority, + providerThreadId: candidate.providerThreadId, + signal: new AbortController().signal, + }); + try { + await release; + if (claudeLaunchIntent !== undefined) { + this.#cancelClaudeProcessLaunchIntent(claudeLaunchIntent); + } + releaseProven = true; + } catch (releaseError: unknown) { + this.recordBackgroundDiagnostic("session_adoption_failed", releaseError); + } + } + if (releaseProven) { + if (codexClaimKey !== null) { + this.#unprovenCodexAdoptionClaims.delete(codexClaimKey); + } + try { + if (error instanceof ProviderAccountAuthorityMismatchError) { + this.#store.fenceSessionAdoptionCandidateAfterClaimRelease({ + provider, + providerThreadId: candidate.providerThreadId, + profileId: profile.id, + }); + } else { + this.#store.requeueSessionAdoptionCandidateAfterClaimRelease({ + provider, + providerThreadId: candidate.providerThreadId, + profileId: profile.id, + }); + } + } catch (requeueError: unknown) { + this.recordBackgroundDiagnostic("session_adoption_failed", requeueError); + } + } + if (signal.aborted) throw signal.reason; + if (committedSession === undefined) failed += 1; + if (committedSession !== undefined) { + this.#scheduleRecoverySessionObservations([committedSession]); + } + this.recordBackgroundDiagnostic("session_adoption_failed", error); } - return document; - }); + } + return { + provider, + state: "ready", + discovered: persisted, + adopted, + pending, + failed, + }; } - async #resolveInteraction( - command: Extract, - context: { signal: AbortSignal; afterResponse?: (callback: () => void) => void }, + async #detachPersonalSession( + sessionId: SessionRecord["id"], + signal: AbortSignal, ): Promise { - const signal = context.signal; - return await this.#serialize(`interaction:${command.interaction}`, async () => { - const current = this.#store.requireInteraction(command.interaction); - if (current.revision !== command.expectedRevision || current.state !== "pending") { - throw new CommandFailure( - "CONFLICT", - "The interaction revision or state changed before resolution.", - { interaction: this.#publicInteraction(current) }, + signal.throwIfAborted(); + const session = this.#store.requireSession(sessionId); + if (session.providerThreadId === undefined) { + throw new CommandFailure("RECOVERY_REQUIRED", "The session has no proven provider binding."); + } + const providerThreadId = session.providerThreadId; + const binding = this.#store.readSessionPersonalRuntimeBinding(session.id, true); + if (binding === null || binding.state === "detached") { + throw new CommandFailure("CONFLICT", "That session is not controlled from a personal provider home."); + } + if ( + binding.provider !== session.provider + || binding.providerThreadId !== providerThreadId + ) { + throw new CommandFailure( + "CONFLICT", + "The personal-home binding no longer matches this session's provider identity.", + ); + } + if ( + binding.state === "active" + && (session.state === "active" || session.activeTurnId !== undefined) + ) { + throw new CommandFailure( + "CONFLICT", + "Stop the active turn before detaching this session.", + ); + } + const profile = this.#store.requireProfileById(session.profileId); + const authority = this.#personalAuthorityForProfile(profile); + if (binding.state === "active") { + if (session.state !== "terminal" && session.state !== "recovery_required") { + const projection = await this.#readExactSessionProjection( + { ...session, providerThreadId }, + profile, + false, + signal, ); - } - if (this.#now() >= current.deadlineAt) { - await this.#rejectManualResolutionAtDeadline(current); - } - this.#assertResolutionMatches(current, command.resolution); - const profile = this.#store.requireProfileById(current.authority.profileId); - const runtime = this.#runtimeForInteraction(current); - let responseDigest: string; - try { + signal.throwIfAborted(); await this.#daemonAuthority.assertCurrent(); - const validated = await runtime.validateInteractionResolution({ - authority: authorityFor(this.#paths, profile), - provider: current.authority, - kind: current.kind, - resolution: command.resolution, - signal, - }); - responseDigest = validated.responseDigest; - } catch (error: unknown) { - if (this.#now() >= current.deadlineAt) { - await this.#rejectManualResolutionAtDeadline(current); - } - if (providerFailureCode(error) === "INVALID_INPUT") { - throw new CommandFailure("INVALID_INPUT", providerFailureMessage(error)); - } - const terminal = providerFailureCode(error) === "INDETERMINATE_EFFECT" - ? this.#store.markInteractionResolutionUnknown({ - id: current.publicId, - expectedRevision: current.revision, - }) - : this.#store.expireInteraction({ - id: current.publicId, - expectedRevision: current.revision, - }); - this.#appendInteractionState(terminal); - if (providerFailureCode(error) === "INDETERMINATE_EFFECT") { + if (projection.status !== "idle" || projection.activeTurnId !== undefined) { throw new CommandFailure( - "RECOVERY_REQUIRED", - "The interaction response may already have reached Codex; its resolution is unknown.", - { interaction: this.#publicInteraction(terminal) }, + "CONFLICT", + "The provider still reports an active turn. Stop it before detaching this session.", ); } - throw new CommandFailure( - "CONFLICT", - "The interaction's exact provider connection is no longer available.", - { interaction: this.#publicInteraction(terminal) }, - ); } - if (this.#now() >= current.deadlineAt) { - await this.#rejectManualResolutionAtDeadline(current); - } - let prepared: InteractionRecord; try { - prepared = this.#store.prepareInteractionResponse({ - id: current.publicId, - expectedRevision: current.revision, - responseDigest, - intendedTerminalState: this.#intendedInteractionTerminalState(command.resolution), - }); - this.#appendInteractionState(prepared); + this.#clearSessionFactAuthority(session.id); + this.#store.beginPersonalSessionDetach({ sessionId: session.id }); } catch (error: unknown) { - throw this.#interactionPersistenceBoundaryError({ - cause: error, - effect: "known_unsent", - focalInteraction: current, - }); - } - if (this.#now() >= prepared.deadlineAt) { - await this.#rejectPreparedManualResolutionAtDeadline(prepared); - } - try { - await this.#daemonAuthority.assertCurrent(); - if (this.#now() >= prepared.deadlineAt) { - await this.#rejectPreparedManualResolutionAtDeadline(prepared); + const code = error instanceof Error ? error.message : ""; + if (code.includes("SESSION_ADOPTION_DETACH_ACTIVE_TURN")) { + throw new CommandFailure( + "CONFLICT", + "Stop the active turn before detaching this session.", + ); } - await runtime.resolveInteraction({ - authority: authorityFor(this.#paths, profile), - provider: prepared.authority, - kind: prepared.kind, - resolution: command.resolution, - deadlineAt: prepared.deadlineAt, - signal, - }); - } catch (error: unknown) { - if (error instanceof CommandFailure) throw error; - if (providerFailureCode(error) === "DEADLINE_EXPIRED") { - await this.#rejectPreparedManualResolutionAtDeadline(prepared); + if (code.includes("SESSION_ADOPTION_DETACH_PENDING_INTERACTION")) { + throw new CommandFailure( + "CONFLICT", + "Resolve or wait for the pending provider interaction before detaching this session.", + ); } - const terminal = providerFailureCode(error) === "INDETERMINATE_EFFECT" - ? this.#store.markInteractionResolutionUnknown({ - id: prepared.publicId, - expectedRevision: prepared.revision, - responseDigest, - }) - : this.#store.expireInteraction({ - id: prepared.publicId, - expectedRevision: prepared.revision, - }); - this.#appendInteractionState(terminal); - if (providerFailureCode(error) === "INDETERMINATE_EFFECT") { + if (code.includes("SESSION_ADOPTION_DETACH_UNSETTLED_QUEUE")) { throw new CommandFailure( "RECOVERY_REQUIRED", - "The interaction response may have reached Codex; its resolution is unknown.", - { interaction: this.#publicInteraction(terminal) }, + "Wait for queued work to settle before detaching this session.", ); } - if (providerFailureCode(error) === "INVALID_INPUT") { - throw new CommandFailure("INVALID_INPUT", providerFailureMessage(error)); + if (code.includes("SESSION_ADOPTION_DETACH_UNSETTLED_MUTATION")) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "Resolve the session's unsettled provider mutation before detaching it.", + ); } - throw new CommandFailure( - "CONFLICT", - "The interaction's exact provider connection is no longer available.", - { interaction: this.#publicInteraction(terminal) }, - ); - } - let written: InteractionRecord; - try { - written = this.#store.markInteractionResponseWritten({ - id: prepared.publicId, - expectedRevision: prepared.revision, - responseDigest, - }); - if (written.state === "response_written") this.#appendInteractionState(written); - } catch (error: unknown) { - throw this.#interactionPersistenceBoundaryError({ - cause: error, - effect: "possibly_sent", - focalInteraction: prepared, - responseDigest, - }); + if (code.includes("SESSION_ADOPTION_DETACH_ACTIVE_TASK")) { + throw new CommandFailure( + "CONFLICT", + "Pause or delete active scheduled tasks before detaching this session.", + ); + } + throw error; } - return { interaction: this.#publicInteraction(written), responseWritten: true }; - }); - } - - async #rejectManualResolutionAtDeadline(current: InteractionRecord): Promise { - await this.#expireInteractionAtDeadline( - current, - this.#interactionDeadlineAbort.signal, - ); - const terminal = this.#store.requireInteraction(current.publicId); - throw new CommandFailure( - "CONFLICT", - "The interaction deadline elapsed before the manual resolution could be dispatched.", - { interaction: this.#publicInteraction(terminal) }, - ); - } - - async #rejectPreparedManualResolutionAtDeadline( - prepared: InteractionRecord, - ): Promise { - if ( - prepared.state !== "response_prepared" - || prepared.responseDigest === null - || prepared.intendedTerminalState === null - || prepared.intendedTerminalState === "expired" - ) throw new Error("INTERACTION_MANUAL_RESPONSE_NOT_PREPARED"); - const profile = this.#store.requireProfileById(prepared.authority.profileId); - const runtime = this.#runtimeForInteraction(prepared); - const signal = this.#interactionDeadlineAbort.signal; - let timeoutResponseDigest: string; + } try { - await this.#daemonAuthority.assertCurrent(); - const validated = await runtime.validateInteractionTimeout({ - authority: authorityFor(this.#paths, profile), - provider: prepared.authority, - signal, - }); - timeoutResponseDigest = validated.responseDigest; + if (binding.provider === "claude") { + await this.#releaseClaudeProcessAuthority( + { + providerThreadId, + profileId: session.profileId, + runtimeScope: "personal", + }, + new AbortController().signal, + ); + } else { + await this.#fencedEffect(async () => + await this.#personalSessionRuntime(binding.provider).endSession({ + authority, + providerThreadId, + signal: new AbortController().signal, + })); + } } catch (error: unknown) { - const latest = this.#store.requireInteraction(prepared.publicId); - const terminal = latest.state === "response_prepared" - && latest.revision === prepared.revision - && latest.responseDigest === prepared.responseDigest - ? this.#store.markInteractionResolutionUnknown({ - id: latest.publicId, - expectedRevision: latest.revision, - responseDigest: prepared.responseDigest, - }) - : latest; - if (terminal !== latest) this.#appendInteractionState(terminal); + if (error instanceof DaemonAuthoritySafetyError) throw error; + this.recordBackgroundDiagnostic("session_adoption_failed", error); throw new CommandFailure( "RECOVERY_REQUIRED", - providerFailureCode(error) === "INDETERMINATE_EFFECT" - ? "The interaction response may already have reached Codex; its resolution is unknown." - : "The expired interaction could not be closed on its exact provider connection.", - { interaction: this.#publicInteraction(terminal) }, + "The session is fenced from new work, but provider controller release did not finish. HRA will retry it during recovery.", ); } - - let timeoutPrepared: InteractionRecord; + await this.#daemonAuthority.assertCurrent(); + let detached: ReturnType; try { - timeoutPrepared = this.#store.supersedePreparedInteractionResponseWithTimeout({ - id: prepared.publicId, - expectedRevision: prepared.revision, - manualResponseDigest: prepared.responseDigest, - timeoutResponseDigest, - }); - this.#appendInteractionState(timeoutPrepared); + detached = this.#store.completePersonalSessionDetach({ sessionId: session.id }); } catch (error: unknown) { - throw this.#interactionPersistenceBoundaryError({ - cause: error, - effect: "known_unsent", - focalInteraction: prepared, - }); + this.recordBackgroundDiagnostic("session_adoption_failed", error); + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The provider controller was released, but durable session cleanup did not finish. HRA will retry it during recovery.", + ); } - try { - await this.#daemonAuthority.assertCurrent(); - await runtime.timeoutInteraction({ - authority: authorityFor(this.#paths, profile), - provider: timeoutPrepared.authority, - signal, - }); - } catch (error: unknown) { - const latest = this.#store.requireInteraction(timeoutPrepared.publicId); - const terminal = latest.state === "response_prepared" - && latest.revision === timeoutPrepared.revision - && latest.responseDigest === timeoutResponseDigest - ? providerFailureCode(error) === "INDETERMINATE_EFFECT" - ? this.#store.markInteractionResolutionUnknown({ - id: latest.publicId, - expectedRevision: latest.revision, - responseDigest: timeoutResponseDigest, - }) - : this.#store.expireInteraction({ - id: latest.publicId, - expectedRevision: latest.revision, - }) - : latest; - if (terminal !== latest) this.#appendInteractionState(terminal); + this.#sessionProviderConnections.delete(session.id); + this.#clearSessionFactAuthority(session.id); + this.#sessionObservationFailures.delete(session.id); + this.#sessionResubscriptionConnections.delete(session.id); + this.#sessionsAwaitingResubscription.delete(session.id); + return { + version: 1, + session: detached.session.id, + detached: true, + archived: detached.session.archivedAt !== undefined, + }; + } + + #beginSessionListTraversal(profile: ProfileRecord): Readonly<{ + id: string; + state: SessionListTraversalReplayState; + }> { + while (this.#sessionListTraversals.size >= SESSION_LIST_TRAVERSAL_LIMIT) { + const oldest = this.#sessionListTraversals.keys().next().value; + if (typeof oldest !== "string") break; + this.#sessionListTraversals.delete(oldest); + } + const id = randomUUID(); + const state: SessionListTraversalReplayState = { + accountId: profile.id, + providerGeneration: profile.processGeneration, + importedSessionIdsByProviderPage: new Map(), + emittedSessionIds: new Set(), + importReceiptCount: 0, + }; + this.#sessionListTraversals.set(id, state); + return { id, state }; + } + + #requireSessionListTraversal( + traversalId: string, + profile: ProfileRecord, + ): SessionListTraversalReplayState { + const state = this.#sessionListTraversals.get(traversalId); + if ( + state === undefined + || state.accountId !== profile.id + || state.providerGeneration !== profile.processGeneration + ) { + throw new CommandFailure( + "INVALID_INPUT", + "This session-list cursor's bounded replay state expired. Restart the account listing without a cursor.", + ); + } + // Map insertion order is the traversal LRU. A bounded eviction is explicit + // on reuse rather than silently changing which provider rows a cursor emits. + this.#sessionListTraversals.delete(traversalId); + this.#sessionListTraversals.set(traversalId, state); + return state; + } + + #rememberSessionListProviderImport( + traversalId: string, + state: SessionListTraversalReplayState, + providerPage: string, + sessionId: SessionRecord["id"], + ): void { + let receipt = state.importedSessionIdsByProviderPage.get(providerPage); + if (receipt?.has(sessionId) === true) return; + if (state.importReceiptCount >= SESSION_LIST_TRAVERSAL_IMPORT_RECEIPT_LIMIT) { + this.#sessionListTraversals.delete(traversalId); throw new CommandFailure( - providerFailureCode(error) === "INDETERMINATE_EFFECT" - ? "RECOVERY_REQUIRED" - : "CONFLICT", - providerFailureCode(error) === "INDETERMINATE_EFFECT" - ? "The provider timeout response may have reached Codex; its resolution is unknown." - : "The expired interaction could not be closed on its exact provider connection.", - { interaction: this.#publicInteraction(terminal) }, + "UNAVAILABLE", + "This session-list traversal exceeded its bounded replay evidence. Restart the account listing without a cursor; imported sessions remain safely local.", ); } - let terminal: InteractionRecord; - try { - const written = this.#store.markInteractionResponseWritten({ - id: timeoutPrepared.publicId, - expectedRevision: timeoutPrepared.revision, - responseDigest: timeoutResponseDigest, - }); - if (written.state === "response_written") this.#appendInteractionState(written); - terminal = written.state === "response_written" - ? this.#store.settleInteraction({ - id: written.publicId, - expectedRevision: written.revision, - state: "expired", - authority: written.authority, - responseDigest: timeoutResponseDigest, - }) - : written; - if (terminal !== written) this.#appendInteractionState(terminal); - } catch (error: unknown) { - throw this.#interactionPersistenceBoundaryError({ - cause: error, - effect: "possibly_sent", - focalInteraction: timeoutPrepared, - responseDigest: timeoutResponseDigest, - }); + if (receipt === undefined) { + receipt = new Set(); + state.importedSessionIdsByProviderPage.set(providerPage, receipt); } - throw new CommandFailure( - "CONFLICT", - "The interaction deadline elapsed before the manual resolution could be dispatched.", - { interaction: this.#publicInteraction(terminal) }, - ); + receipt.add(sessionId); + state.importReceiptCount += 1; } async #listSessions( @@ -7757,6 +12827,7 @@ export class HraService { accountId: profile.id, accountGeneration: profile.processGeneration, limit, + includeArchived, } as const; const decodedCursor = cursor === undefined ? undefined @@ -7786,6 +12857,7 @@ export class HraService { listing: signedOutSessionListMetadataSchema.parse({ accountSelector: profile.id, accountState: "signed_out", + provider: "codex", scope: "local_only", freshness: "stale", localCompleteness: nextCursor === null ? "complete" : "partial", @@ -7800,110 +12872,165 @@ export class HraService { accountId: profile.id, providerGeneration: profile.processGeneration, limit, + includeArchived, } as const; - let decodedComposite: ReturnType | undefined; - let decodedLegacy: ReturnType | undefined; + let decodedCursor: ReturnType | undefined; + let decodedLocalCursor: ReturnType | undefined; if (cursor !== undefined) { try { - decodedComposite = this.#eventCursors.decodeCompositeSessionList(cursor, cursorFilter); + decodedCursor = this.#eventCursors.decodeSessionList(cursor, cursorFilter); } catch (error: unknown) { - if (!(error instanceof SessionEventCursorError) || error.reason !== "type_mismatch") throw error; - decodedLegacy = this.#eventCursors.decodeSessionList(cursor, cursorFilter); + if (!(error instanceof SessionEventCursorError) || error.reason !== "type_mismatch") { + throw error; + } + decodedLocalCursor = this.#eventCursors.decodeAccountSessionLocal(cursor, cursorFilter); } } + const decodedTraversalId = decodedCursor?.traversalId ?? decodedLocalCursor?.traversalId; + if (cursor !== undefined && decodedTraversalId === undefined) { + throw new CommandFailure( + "INVALID_INPUT", + "This session-list cursor predates bounded replay authority. Restart the account listing without a cursor.", + ); + } + const traversal = cursor === undefined + ? this.#beginSessionListTraversal(profile) + : { + id: decodedTraversalId as string, + state: this.#requireSessionListTraversal(decodedTraversalId as string, profile), + }; if (await this.#cloud.isCompactProjectionRecoveryUnsettledForProfile(profile.id)) { await this.#daemonAuthority.assertCurrent(); - if (decodedComposite !== undefined || decodedLegacy !== undefined) { + if ( + decodedCursor !== undefined + || (decodedLocalCursor !== undefined && decodedLocalCursor.afterCreatedAt === null) + ) { throw new CommandFailure( "RECOVERY_REQUIRED", "Provider session-list continuation is paused while compact-projection recovery preserves exact local authority.", ); } + const localPage = this.#store.listLocalSessionPage({ + profileId: profile.id, + after: decodedLocalCursor === undefined + ? null + : { + createdAt: decodedLocalCursor.afterCreatedAt as number, + sessionId: decodedLocalCursor.afterSessionId as SessionRecord["id"], + }, + includeArchived, + limit, + requireCurrentAccountAuthority: true, + }); + for (const session of localPage.sessions) { + traversal.state.emittedSessionIds.add(session.id); + } + const nextCursor = localPage.nextPosition === null + ? null + : this.#eventCursors.encodeAccountSessionLocal({ + ...cursorFilter, + traversalId: traversal.id, + afterCreatedAt: localPage.nextPosition.createdAt, + afterSessionId: localPage.nextPosition.sessionId, + }); + if (nextCursor === null) this.#sessionListTraversals.delete(traversal.id); return { accountId: profile.id, - sessions: this.#store.listSessions(limit, profile.id, includeArchived), - nextCursor: null, + sessions: localPage.sessions, + nextCursor, recovery: { - diagnostic: "Provider reconciliation is paused while compact-projection recovery preserves exact local authority.", + diagnostic: nextCursor === null + ? "Every currently authorized local session was listed, but provider reconciliation remains paused while compact-projection recovery preserves exact local authority." + : "More currently authorized local sessions remain; provider reconciliation is paused while compact-projection recovery preserves exact local authority.", required: true, }, }; } - - const sessions: SessionRecord[] = []; - const shouldPageLocalSessions = decodedLegacy === undefined - && (decodedComposite === undefined || decodedComposite.continuation.phase === "local"); - if (shouldPageLocalSessions) { + const shouldReadLocalPage = decodedCursor === undefined + && ( + cursor === undefined + || ( + decodedLocalCursor !== undefined + && decodedLocalCursor.afterCreatedAt !== null + ) + ); + if (shouldReadLocalPage) { + const localAfter = decodedLocalCursor === undefined + || decodedLocalCursor.afterCreatedAt === null + || decodedLocalCursor.afterSessionId === null + ? null + : { + createdAt: decodedLocalCursor.afterCreatedAt, + sessionId: decodedLocalCursor.afterSessionId, + }; const localPage = this.#store.listLocalSessionPage({ profileId: profile.id, - after: decodedComposite?.continuation.phase === "local" - ? { - createdAt: decodedComposite.continuation.afterCreatedAt, - sessionId: decodedComposite.continuation.afterSessionId, - } - : null, - excludedProvider: this.#codex.provider, + after: localAfter, includeArchived, limit, + requireCurrentAccountAuthority: true, }); - sessions.push(...localPage.sessions); - if (localPage.nextPosition !== null) { - return { - accountId: profile.id, - sessions, - nextCursor: this.#eventCursors.encodeCompositeSessionList({ - ...cursorFilter, - continuation: { - phase: "local", - afterCreatedAt: localPage.nextPosition.createdAt, - afterSessionId: localPage.nextPosition.sessionId, - }, - }), - }; - } - if (sessions.length === limit) { + if (localPage.sessions.length > 0) { + for (const session of localPage.sessions) { + traversal.state.emittedSessionIds.add(session.id); + } return { accountId: profile.id, - sessions, - nextCursor: this.#eventCursors.encodeCompositeSessionList({ - ...cursorFilter, - continuation: { phase: "provider_start" }, - }), + sessions: localPage.sessions, + // null/null is a signed transition into provider discovery. Even an + // exact-boundary local page must not let the next request repeat the + // local phase or expose a source-specific ordering arm. + nextCursor: this.#eventCursors.encodeAccountSessionLocal({ + ...cursorFilter, + traversalId: traversal.id, + afterCreatedAt: localPage.nextPosition?.createdAt ?? null, + afterSessionId: localPage.nextPosition?.sessionId ?? null, + }), }; } } - - const providerCursor = decodedLegacy?.providerCursor - ?? (decodedComposite?.continuation.phase === "provider" - ? decodedComposite.continuation.state.providerCursor - : undefined); - const providerLimit = limit - sessions.length; - const remote = await this.#fencedEffect(async () => await this.#codex.listSessions({ - authority: authorityFor(this.#paths, profile), - limit: providerLimit, - ...(providerCursor === undefined ? {} : { cursor: providerCursor }), + this.#assertIdentifiableAccountAuthority(profile); + const expectedAccountFingerprint = accountFingerprintForProfile(profile); + await this.#proveUsageAccountIdentity({ + profile, + expectedFingerprint: expectedAccountFingerprint, signal, - })); - if (remote.sessions.length > providerLimit) { + }); + const providerAccountKey = profileCodexAccountAuthorityKey(profile); + if (providerAccountKey === null) { throw new CommandFailure( - "UNAVAILABLE", - "Codex returned more session-list rows than the requested page can safely retain.", + "RECOVERY_REQUIRED", + "The selected account has no stable Codex provider identity.", ); } + const remote = await this.#fencedEffect(async () => await this.#codex.listSessions({ + authority: authorityFor(this.#paths, profile), + limit, + ...(decodedCursor === undefined ? {} : { cursor: decodedCursor.providerCursor }), + signal, + })); + // The list response carries no account identity. Re-prove the same account + // after the read and before importing any row, so an account swap during + // the provider call cannot bind another identity's thread to this profile. + await this.#proveUsageAccountIdentity({ + profile, + expectedFingerprint: expectedAccountFingerprint, + signal, + }); + const providerPageReplayKey = decodedCursor === undefined + ? "provider:first" + : `provider:cursor:${decodedCursor.providerCursor}`; + let providerPageImportReceipt = traversal.state.importedSessionIdsByProviderPage + .get(providerPageReplayKey); let nextCursor: string | null = null; if (remote.nextCursor !== null) { try { - nextCursor = decodedLegacy === undefined - ? this.#eventCursors.advanceCompositeSessionList({ - ...cursorFilter, - providerCursor: remote.nextCursor, - ...(decodedComposite === undefined ? {} : { prior: decodedComposite }), - }) - : this.#eventCursors.advanceSessionList({ - ...cursorFilter, - providerCursor: remote.nextCursor, - prior: decodedLegacy, - }); + nextCursor = this.#eventCursors.advanceSessionList({ + ...cursorFilter, + traversalId: traversal.id, + providerCursor: remote.nextCursor, + ...(decodedCursor === undefined ? {} : { prior: decodedCursor }), + }); } catch (error: unknown) { if (error instanceof SessionEventCursorError) { throw new CommandFailure( @@ -7915,25 +13042,90 @@ export class HraService { } } const projects = this.#store.listProjects(); - for (const projection of remote.sessions) { + const sessions: SessionRecord[] = []; + const remoteSessionIds = new Set(); + for (const projection of remote.sessions.slice(0, limit)) { + const personalBinding = this.#store.findSessionPersonalRuntimeBinding( + "codex", + projection.providerThreadId, + ); + if (personalBinding !== null) { + const personalSession = this.#store.requireSession(personalBinding.sessionId); + if (personalBinding.state !== "detached") { + // A managed-home listing may expose a thread currently controlled + // through the personal home. Treat that as an identity collision, + // never as authority to mutate or emit the personally controlled row + // from the wrong provider source. Its ordinary row was already part + // of the source-neutral local phase. + continue; + } + // A detached row with the same account/thread identity remains an + // explicit personal-home detach fence. A managed-home import must not + // silently cross that authority boundary or confuse home ownership. + if (personalSession.profileId === profile.id) continue; + } + const localCollision = this.#store.findSessionByProviderThread( + profile.id, + projection.providerThreadId, + ); + if (localCollision !== null && localCollision.provider !== "codex") { + // Provider-thread ids are opaque within each provider home. A Codex + // projection must never mutate a local Claude row merely because the + // two providers selected the same string. + continue; + } + if ( + localCollision !== null + && !this.#store.sessionAccountAuthorityMatches(localCollision.id, profile.id) + ) { + // A provider identity replacement can reuse an opaque thread id. The + // old identity's row remains recoverable if that identity returns, but + // the replacement identity cannot mutate or inherit it. + continue; + } + const alreadyEmittedInTraversal = localCollision !== null + && traversal.state.emittedSessionIds.has(localCollision.id); + const emittedOnThisProviderPage = localCollision !== null + && providerPageImportReceipt?.has(localCollision.id) === true; const projectId = projection.projectRoot === undefined ? undefined : projects.find((project) => project.rootPath === projection.projectRoot)?.id; const session = this.#store.upsertProviderSession({ profileId: profile.id, + provider: "codex", providerThreadId: projection.providerThreadId, ...(projectId === undefined ? {} : { projectId }), title: projection.title, + preset: this.#store.readDefaultPreset("codex"), + fastEnabled: false, state: projection.status, ...(projection.activeTurnId === undefined ? {} : { activeTurnId: projection.activeTurnId }), ...(projection.providerUpdatedAt === undefined ? {} : { providerUpdatedAt: projection.providerUpdatedAt }), + providerAccountKey, + conversationAutomationEnabled: true, }); - if (includeArchived || session.archivedAt === undefined) sessions.push(session); + if (!alreadyEmittedInTraversal) { + this.#rememberSessionListProviderImport( + traversal.id, + traversal.state, + providerPageReplayKey, + session.id, + ); + providerPageImportReceipt = traversal.state.importedSessionIdsByProviderPage + .get(providerPageReplayKey); + traversal.state.emittedSessionIds.add(session.id); + } + if (remoteSessionIds.has(session.id)) continue; + remoteSessionIds.add(session.id); await this.#reconcileCommittedSessionFactsMemory(session); + if (!alreadyEmittedInTraversal || emittedOnThisProviderPage) sessions.push(session); } + const visibleRemoteSessions = includeArchived + ? sessions + : sessions.filter((session) => session.archivedAt === undefined); return { accountId: profile.id, // Archive is a listing filter over locally known sessions: the // provider has no archive concept, so its page is filtered here. - sessions, + sessions: visibleRemoteSessions, nextCursor, }; } @@ -7961,13 +13153,26 @@ export class HraService { async #showSession(selector: string, detail: boolean, signal: AbortSignal): Promise { const session = this.#store.requireSession(selector); - if (session.providerThreadId === undefined) return { session, effectiveRuntimeProfile: this.#store.latestSessionRuntimeProfile(session.id)?.profile ?? null }; + if (session.providerThreadId === undefined) { + return { + session, + effectiveRuntimeProfile: publicRuntimeProfile( + this.#store.latestSessionRuntimeProfile(session.id)?.profile, + ), + }; + } const providerThreadId = session.providerThreadId; const profile = this.#store.requireProfile(session.profileId); - if (session.provider === "claude" && this.#platform !== "linux") { + if ( + session.provider === "claude" + && this.#platform !== "linux" + && !this.#sessionHasMatchingActivePersonalBinding(session) + ) { return { session, - effectiveRuntimeProfile: this.#store.latestSessionRuntimeProfile(session.id)?.profile ?? null, + effectiveRuntimeProfile: publicRuntimeProfile( + this.#store.latestSessionRuntimeProfile(session.id)?.profile, + ), providerObservation: this.#claudePlatformUnavailableObservation(profile), }; } @@ -7980,7 +13185,12 @@ export class HraService { const projectionRecoveryUnsettled = await this.#cloud .isCompactProjectionRecoveryUnsettled(session.id); await this.#daemonAuthority.assertCurrent(); - const observed = await this.#fencedEffect(async () => await this.#runtimeForSession(session).readSession({ authority: authorityFor(this.#paths, profile), providerThreadId, detail, signal })); + const observed = await this.#readExactSessionProjection( + { ...session, providerThreadId }, + profile, + detail, + signal, + ); const projection = this.#withAttachmentManifests(session.id, observed); if (projectionRecoveryUnsettled || this.#projectionRecoveriesInFlight.has(session.id)) { const runtimeProfile = this.#store.latestSessionRuntimeProfile(session.id)?.profile ?? null; @@ -7988,7 +13198,7 @@ export class HraService { return { session: coherentSession, ...(projection.providerThreadId === providerThreadId ? { projection } : {}), - effectiveRuntimeProfile: runtimeProfile, + effectiveRuntimeProfile: publicRuntimeProfile(runtimeProfile), recovery: { cleared: false, diagnostic: projection.providerThreadId === providerThreadId @@ -8005,12 +13215,13 @@ export class HraService { const coherentSession = this.#store.requireSession(session.id); const runtimeProfile = this.#store.latestSessionRuntimeProfile(session.id)?.profile ?? null; return coherentSession.state === "recovery_required" - ? { session: coherentSession, projection, effectiveRuntimeProfile: runtimeProfile, recovery: { required: true, cleared: false } } - : { session: coherentSession, projection, effectiveRuntimeProfile: runtimeProfile }; + ? { session: coherentSession, projection, effectiveRuntimeProfile: publicRuntimeProfile(runtimeProfile), recovery: { required: true, cleared: false } } + : { session: coherentSession, projection, effectiveRuntimeProfile: publicRuntimeProfile(runtimeProfile) }; } async #startSession(command: Extract, signal: AbortSignal): Promise { const profile = this.#store.requireProfile(command.account); + await this.#assertNoCompactProjectionRecoveryForProfile(profile.id); const provider = command.provider ?? "codex"; const project = command.project === undefined ? this.#store.listProjects().find((candidate) => candidate.default) : this.#store.requireProject(command.project); if (project === undefined) throw new CommandFailure("INTERACTION_REQUIRED", "Add or select a project directory before starting a session."); @@ -8047,6 +13258,10 @@ export class HraService { let startedProjection: | (CodexSessionProjection & { effectiveRuntimeProfile: ReviewedRuntimeProfile }) | undefined; + let claudeProcessIdentity: ClaudeProcessIdentity | undefined; + let claudeLaunchIntent: ClaudeProcessLaunchIntentRecord | undefined; + let providerAccountKey: string | undefined; + const reservedClaudeProviderThreadId = provider === "claude" ? randomUUID() : undefined; let outcome: z.infer; try { outcome = await this.#effect>({ @@ -8062,6 +13277,12 @@ export class HraService { idempotencyKey: key, beginEffect: async (attemptId) => { clientMessageId = attemptId; + providerAccountKey = await this.#assertManagedProviderRuntimeAuthority( + profile, + provider, + signal, + true, + ); review = await this.#fencedRuntimeReview(runtime, async () => { const projectRoot = await this.#requireUsableProjectRoot(project.rootPath); return await runtime.reviewSessionStart({ @@ -8073,6 +13294,21 @@ export class HraService { signal, }); }); + const reviewedAccountKey = await this.#assertManagedProviderRuntimeAuthority( + this.#store.requireProfileById(profile.id), + provider, + signal, + true, + ); + if (reviewedAccountKey !== providerAccountKey) { + if (provider === "devin") { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The managed Devin authority changed during runtime review.", + ); + } + throw new ProviderAccountAuthorityMismatchError(provider, profile); + } const local = this.#store.beginSessionStartEffect({ attemptId, profileId: profile.id, @@ -8082,6 +13318,7 @@ export class HraService { providerAuthentication, preset: command.preset, fastEnabled: command.fast, + ...(providerAccountKey === undefined ? {} : { providerAccountKey }), evidence: { kind: "session.start", projectId: project.id, @@ -8092,27 +13329,152 @@ export class HraService { }, }); localSessionId = local.id; + if (provider === "claude") { + if (reservedClaudeProviderThreadId === undefined) { + throw new Error("CLAUDE_PROVIDER_THREAD_ID_NOT_RESERVED"); + } + if (providerAccountKey === undefined) { + throw new Error("CLAUDE_PROVIDER_ACCOUNT_AUTHORITY_MISSING"); + } + claudeLaunchIntent = this.#store.stageClaudeProcessLaunchIntent({ + providerThreadId: reservedClaudeProviderThreadId, + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "managed", + providerAccountKey, + sessionId: local.id, + }); + } }, effect: async () => { if (localSessionId === undefined || clientMessageId === undefined || review === undefined) throw new Error("Session start effect lost its durable placeholder or runtime-review binding."); const runtimeReview = review; const local = this.#store.requireSession(localSessionId); + const launchProviderThreadId = provider === "claude" + ? reservedClaudeProviderThreadId + : undefined; + if (provider === "claude" && launchProviderThreadId === undefined) { + throw new Error("CLAUDE_PROVIDER_THREAD_ID_NOT_RESERVED"); + } try { - startedProjection = await this.#fencedEffect(async () => { + await this.#fencedEffect(async () => { const projectRoot = await this.#requireUsableProjectRoot(project.rootPath); - return await runtime.startSession({ + const value = await runtime.startSession({ authority: authorityFor(this.#paths, profile), + ...(launchProviderThreadId === undefined + ? {} + : { + providerThreadId: launchProviderThreadId, + admitProcessIdentity: async (identity: ClaudeProcessIdentity) => { + if (claudeLaunchIntent === undefined) { + throw new Error("CLAUDE_PROCESS_LAUNCH_INTENT_MISSING"); + } + claudeProcessIdentity = await this.#recordClaimedClaudeProcess({ + authority: authorityFor(this.#paths, profile), + providerThreadId: launchProviderThreadId, + runtimeScope: "managed", + sessionId: local.id, + launchIntent: claudeLaunchIntent, + identity, + signal, + }); + }, + }), projectRoot, review: runtimeReview, signal, }); + startedProjection = value; + return value; }); + if (startedProjection === undefined) { + throw new Error("Session start returned no exact provider projection."); + } + if (provider === "claude") { + if ( + reservedClaudeProviderThreadId === undefined + || startedProjection.providerThreadId !== reservedClaudeProviderThreadId + || claudeProcessIdentity === undefined + ) throw new Error("CLAUDE_PROCESS_IDENTITY_NOT_ADMITTED"); + } + await this.#assertSessionAccountAuthorityAfterProviderEffect( + local, + this.#store.requireProfileById(profile.id), + signal, + ); } catch (error: unknown) { await this.#daemonAuthority.assertCurrent(); - if (error instanceof IndeterminateCodexEffectError) { + if ( + error instanceof IndeterminateCodexEffectError + || error instanceof IndeterminateLocalCommitError + ) { this.#quarantineSession(local.id); throw error; } + if (provider === "claude" && error instanceof ClaudeProcessExitUnprovenError) { + this.#quarantineSession(local.id); + throw new IndeterminateLocalCommitError( + "Claude session admission failed without proof that its controller exited.", + error, + ); + } + if (startedProjection !== undefined) { + try { + if (provider === "claude" && claudeProcessIdentity !== undefined) { + await this.#releaseClaudeProcessAuthority( + { + providerThreadId: startedProjection.providerThreadId, + profileId: profile.id, + runtimeScope: "managed", + }, + new AbortController().signal, + ); + } else { + await runtime.endSession({ + authority: authorityFor(this.#paths, profile), + providerThreadId: startedProjection.providerThreadId, + signal: new AbortController().signal, + }); + } + } catch (releaseError: unknown) { + this.#quarantineSession(local.id); + throw new IndeterminateLocalCommitError( + "The provider created a session, but HRA could not prove its controller was released after admission failed.", + releaseError, + ); + } + } else if (provider === "claude" && claudeProcessIdentity !== undefined) { + if (launchProviderThreadId === undefined) { + throw new Error("CLAUDE_PROVIDER_THREAD_ID_NOT_RESERVED"); + } + try { + await this.#releaseClaudeProcessAuthority( + { + providerThreadId: launchProviderThreadId, + profileId: profile.id, + runtimeScope: "managed", + }, + new AbortController().signal, + ); + } catch (releaseError: unknown) { + this.#quarantineSession(local.id); + throw new IndeterminateLocalCommitError( + "Claude admission failed after exact process custody, but controller release was not proven.", + releaseError, + ); + } + } + if (claudeLaunchIntent !== undefined) { + try { + this.#cancelClaudeProcessLaunchIntent(claudeLaunchIntent); + } catch (cancelError: unknown) { + this.#quarantineSession(local.id); + throw new IndeterminateLocalCommitError( + "Claude rejected session creation, but its launch intent could not be retired.", + cancelError, + ); + } + } if (!this.#store.deleteUnboundStartingSession(local.id, local.revision)) { this.#quarantineSession(local.id); throw new IndeterminateLocalCommitError("Codex rejected session creation, but its unused local placeholder could not be removed.", error); @@ -8123,9 +13485,14 @@ export class HraService { }, receipt: (value) => sessionStartReceiptSchema.parse(value), restore: (value) => sessionStartReceiptSchema.parse(value), - commit: (attemptId, _value, receipt) => { + commit: async (attemptId, _value, receipt) => { if (localSessionId === undefined || startedProjection === undefined) throw new Error("Session start commit lost its exact provider projection."); const local = this.#store.requireSession(localSessionId); + await this.#assertSessionAccountAuthorityAfterProviderEffect( + local, + this.#store.requireProfileById(profile.id), + signal, + ); this.#store.completeSessionStartEffect({ attemptId, sessionId: local.id, @@ -8135,6 +13502,7 @@ export class HraService { ...(startedProjection.activeTurnId === undefined ? {} : { activeTurnId: startedProjection.activeTurnId }), ...(startedProjection.providerUpdatedAt === undefined ? {} : { providerUpdatedAt: startedProjection.providerUpdatedAt }), runtimeProfile: startedProjection.effectiveRuntimeProfile, + ...(claudeProcessIdentity === undefined ? {} : { claudeProcessIdentity }), receipt, }); }, @@ -8183,9 +13551,10 @@ export class HraService { await this.#ensureSessionObservedLocked(outcome.sessionId, signal); return { session: this.#store.requireSession(outcome.sessionId), - effectiveRuntimeProfile: outcome.effectiveRuntimeProfile - ?? this.#store.latestSessionRuntimeProfile(outcome.sessionId)?.profile - ?? null, + effectiveRuntimeProfile: publicRuntimeProfile( + outcome.effectiveRuntimeProfile + ?? this.#store.latestSessionRuntimeProfile(outcome.sessionId)?.profile, + ), idempotencyKey: key, }; } @@ -8375,6 +13744,12 @@ export class HraService { } } const session = this.#requireBoundSession(knownSession.id); + if (this.#store.readClaudeProcessLaunchIntentForSession(session.id) !== null) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "This session has an unsettled Claude controller launch. HRA preserved the current provider binding and will not launch another target until restart recovery proves the prior child is gone.", + ); + } const requestedAccountId = command.account === undefined ? null : this.#store.requireProfile(command.account).id; @@ -8447,6 +13822,14 @@ export class HraService { let started: | (CodexSessionProjection & { effectiveRuntimeProfile: ReviewedRuntimeProfile }) | undefined; + let targetClaudeProcessIdentity: ClaudeProcessIdentity | undefined; + let targetClaudeLaunchIntent: ClaudeProcessLaunchIntentRecord | undefined; + let targetProviderAccountKey: string | undefined; + const reservedTargetClaudeProviderThreadId = command.provider === "claude" + ? randomUUID() + : undefined; + let targetProviderReleaseProven = false; + let targetReleaseRecorded = false; let seeded: | Readonly<{ turnId: string; @@ -8454,6 +13837,211 @@ export class HraService { effectiveRuntimeProfile: ReviewedRuntimeProfile; }> | undefined; + const requireTargetProviderAccountKey = (): string => { + if (command.provider === "devin") { + throw new Error("A Devin provider switch must not carry an account key."); + } + if (targetProviderAccountKey !== undefined) return targetProviderAccountKey; + throw new Error("Provider switch lost its exact target account authority."); + }; + const targetProviderAccountKeyInput = (): Readonly<{ + providerAccountKey?: string; + }> => command.provider === "devin" + ? {} + : { providerAccountKey: requireTargetProviderAccountKey() }; + const targetProviderAccountEvidence = (): Readonly<{ + targetProviderAccountKey?: string; + }> => command.provider === "devin" + ? {} + : { targetProviderAccountKey: requireTargetProviderAccountKey() }; + const assertTargetAccountStable = async ( + accountSignal: AbortSignal = signal, + ): Promise => { + const exactProfile = this.#store.requireProfileById(targetProfile.id); + const observedAccountKey = await this.#assertManagedProviderRuntimeAuthority( + exactProfile, + command.provider, + accountSignal, + true, + ); + if (command.provider === "devin") { + if (observedAccountKey !== undefined || targetProviderAccountKey !== undefined) { + throw new Error("A managed Devin authority unexpectedly produced an account key."); + } + return; + } + const expectedTargetProviderAccountKey = requireTargetProviderAccountKey(); + if (observedAccountKey === expectedTargetProviderAccountKey) return; + switch (command.provider) { + case "codex": + this.#scheduleProfilePersonalAuthorityRevocation(exactProfile); + break; + case "claude": + this.#scheduleProviderRuntimeAccountRevocation( + exactProfile, + "claude", + "managed", + observedAccountKey ?? null, + ); + break; + } + throw new ProviderAccountAuthorityMismatchError( + command.provider, + exactProfile, + ); + }; + const endStartedTargetExactly = async ( + providerThreadId: string, + cleanupSignal: AbortSignal, + ): Promise => { + await assertTargetAccountStable(cleanupSignal); + let effectFailure: Readonly<{ error: unknown }> | undefined; + try { + await this.#fencedEffect(async () => await runtime.endSession({ + authority: authorityFor(this.#paths, targetProfile), + providerThreadId, + signal: cleanupSignal, + })); + } catch (error: unknown) { + if (error instanceof DaemonAuthoritySafetyError) throw error; + effectFailure = { error }; + } + try { + await assertTargetAccountStable(cleanupSignal); + } catch (error: unknown) { + if (error instanceof DaemonAuthoritySafetyError) throw error; + throw new IndeterminateLocalCommitError( + "The target account changed while an exact provider-switch cleanup was in flight.", + error, + ); + } + if (effectFailure !== undefined) throw effectFailure.error; + }; + const releaseExactClaudeTarget = async ( + providerThreadId: string, + cleanupSignal: AbortSignal, + accountMismatch: boolean, + ): Promise => { + if (accountMismatch) { + await this.#releaseClaudeProcessAuthority({ + providerThreadId, + profileId: targetProfile.id, + runtimeScope: "managed", + }, cleanupSignal); + return; + } + await assertTargetAccountStable(cleanupSignal); + let effectFailure: Readonly<{ error: unknown }> | undefined; + try { + await this.#releaseClaudeProcessAuthority({ + providerThreadId, + profileId: targetProfile.id, + runtimeScope: "managed", + }, cleanupSignal); + } catch (error: unknown) { + if (error instanceof DaemonAuthoritySafetyError) throw error; + effectFailure = { error }; + } + try { + await assertTargetAccountStable(cleanupSignal); + } catch (error: unknown) { + if (error instanceof DaemonAuthoritySafetyError) throw error; + throw new IndeterminateLocalCommitError( + "The target account changed while its exact Claude process was being released.", + error, + ); + } + if (effectFailure !== undefined) throw effectFailure.error; + }; + const releaseStartedTarget = async ( + cleanupSignal: AbortSignal, + accountMismatch = false, + ): Promise => { + const target = started; + if (targetReleaseRecorded) return; + if (target === undefined && targetClaudeLaunchIntent === undefined) { + if (accountMismatch && command.provider === "codex") { + if (this.#codex.releaseOwnedAuthority === undefined) { + throw new ProviderRuntimeUnavailableError( + "The target Codex controller cannot release its exact account authority.", + ); + } + await this.#fencedEffect(async () => await this.#codex.releaseOwnedAuthority?.({ + authority: authorityFor(this.#paths, targetProfile), + signal: cleanupSignal, + })); + targetProviderReleaseProven = true; + } + return; + } + const providerThreadId = command.provider === "claude" + ? reservedTargetClaudeProviderThreadId + : target?.providerThreadId; + if (providerThreadId === undefined) return; + if (!targetProviderReleaseProven) { + switch (command.provider) { + case "claude": { + if (targetClaudeProcessIdentity !== undefined) { + await releaseExactClaudeTarget( + providerThreadId, + cleanupSignal, + accountMismatch, + ); + } else if (target !== undefined) { + throw new ProviderRuntimeUnavailableError( + "The Claude target started without exact process identity; HRA preserved its launch fence for restart recovery.", + ); + } else if (accountMismatch) { + throw new ProviderRuntimeUnavailableError( + "The Claude target account changed before exact process identity was admitted; HRA preserved its launch fence for restart recovery.", + ); + } + if (targetClaudeLaunchIntent !== undefined) { + this.#cancelClaudeProcessLaunchIntent(targetClaudeLaunchIntent); + } + break; + } + case "codex": { + if (target === undefined) return; + if (accountMismatch) { + if (this.#codex.releaseOwnedAuthority === undefined) { + throw new ProviderRuntimeUnavailableError( + "The target Codex controller cannot release its exact account authority.", + ); + } + await this.#fencedEffect(async () => await this.#codex.releaseOwnedAuthority?.({ + authority: authorityFor(this.#paths, targetProfile), + signal: cleanupSignal, + })); + } else { + await endStartedTargetExactly(providerThreadId, cleanupSignal); + } + break; + } + case "devin": { + if (target === undefined) return; + if (accountMismatch) { + await this.#fencedEffect(async () => await runtime.endSession({ + authority: authorityFor(this.#paths, targetProfile), + providerThreadId, + signal: cleanupSignal, + })); + } else { + await endStartedTargetExactly(providerThreadId, cleanupSignal); + } + break; + } + } + targetProviderReleaseProven = true; + } + this.#store.recordSessionProviderSwitchTargetReleased({ + attemptId: attemptIdSchema.parse(switchAttemptId), + sessionId: session.id, + providerThreadId, + ...targetProviderAccountKeyInput(), + }); + targetReleaseRecorded = true; + }; let outcome: z.infer; try { outcome = await this.#effect>({ @@ -8469,6 +14057,12 @@ export class HraService { idempotencyKey: key, beginEffect: async (attemptId) => { switchAttemptId = attemptId; + targetProviderAccountKey = await this.#assertManagedProviderRuntimeAuthority( + targetProfile, + command.provider, + signal, + true, + ); sessionReview = await this.#fencedRuntimeReview( runtime, async () => await runtime.reviewSessionStart({ @@ -8480,6 +14074,7 @@ export class HraService { signal, }), ); + await assertTargetAccountStable(); // Work claims do not share the session mutation tail. Recheck after // the asynchronous review; the reverse SQLite trigger makes this // transition atomic with a competing claim insert. @@ -8501,6 +14096,7 @@ export class HraService { targetProfileId: targetProfile.id, targetProcessGeneration: targetProfile.processGeneration, targetProvider: command.provider, + ...targetProviderAccountEvidence(), targetPreset: preset, transcriptDigest: transcript.digest, seedDigest: seed.digest, @@ -8511,29 +14107,141 @@ export class HraService { }); }, effect: async (attemptId) => { - if (sessionReview === undefined) { - throw new Error("Provider switch lost its reviewed target runtime."); + if ( + sessionReview === undefined + || (command.provider !== "devin" && targetProviderAccountKey === undefined) + ) { + throw new Error("Provider switch lost its reviewed target runtime or exact account authority."); } const targetSessionReview = sessionReview; - started = await this.#fencedEffect(async () => await runtime.startSession({ - authority: authorityFor(this.#paths, targetProfile), - ...(projectRoot === undefined ? {} : { projectRoot }), - review: targetSessionReview, - signal, - })); - const startedTarget = started; - const releaseTarget = async (): Promise => { - await this.#fencedEffect(async () => await runtime.endSession({ + const launchProviderThreadId = command.provider === "claude" + ? reservedTargetClaudeProviderThreadId + : undefined; + if (command.provider === "claude") { + if (launchProviderThreadId === undefined) { + throw new Error("CLAUDE_PROVIDER_THREAD_ID_NOT_RESERVED"); + } + targetClaudeLaunchIntent = this.#store.stageClaudeProcessLaunchIntent({ + providerThreadId: launchProviderThreadId, + profileId: targetProfile.id, + profileGeneration: targetProfile.processGeneration, + runtimeScope: "managed", + providerAccountKey: requireTargetProviderAccountKey(), + sessionId: session.id, + }); + } + await assertTargetAccountStable(); + let startFailure: Readonly<{ error: unknown }> | undefined; + try { + started = await this.#fencedEffect(async () => await runtime.startSession({ authority: authorityFor(this.#paths, targetProfile), - providerThreadId: startedTarget.providerThreadId, + ...(launchProviderThreadId === undefined + ? {} + : { + providerThreadId: launchProviderThreadId, + admitProcessIdentity: async (identity: ClaudeProcessIdentity) => { + if (targetClaudeLaunchIntent === undefined) { + throw new Error("CLAUDE_PROCESS_LAUNCH_INTENT_MISSING"); + } + targetClaudeProcessIdentity = await this.#recordClaimedClaudeProcess({ + authority: authorityFor(this.#paths, targetProfile), + providerThreadId: launchProviderThreadId, + runtimeScope: "managed", + sessionId: session.id, + launchIntent: targetClaudeLaunchIntent, + identity, + signal, + }); + }, + }), + ...(projectRoot === undefined ? {} : { projectRoot }), + review: targetSessionReview, signal, })); - this.#store.recordSessionProviderSwitchTargetReleased({ - attemptId, - sessionId: session.id, - providerThreadId: startedTarget.providerThreadId, - }); - }; + } catch (error: unknown) { + if (error instanceof DaemonAuthoritySafetyError) throw error; + startFailure = { error }; + } + let postStartAccountFailure: Readonly<{ error: unknown }> | undefined; + try { + await assertTargetAccountStable(); + } catch (error: unknown) { + if (error instanceof DaemonAuthoritySafetyError) throw error; + postStartAccountFailure = { error }; + } + if (postStartAccountFailure !== undefined) { + try { + await releaseStartedTarget(new AbortController().signal, true); + } catch (cleanupError: unknown) { + if (cleanupError instanceof DaemonAuthoritySafetyError) throw cleanupError; + this.#quarantineSession(session.id); + throw new IndeterminateLocalCommitError( + "The target account changed during provider start and its exact controller could not be safely released.", + new AggregateError([postStartAccountFailure.error, cleanupError]), + ); + } + throw new IndeterminateLocalCommitError( + "The provider-switch target start crossed an account-authority change.", + startFailure === undefined + ? postStartAccountFailure.error + : new AggregateError([ + startFailure.error, + postStartAccountFailure.error, + ]), + ); + } + if (startFailure !== undefined) { + if (startFailure.error instanceof ClaudeProcessExitUnprovenError) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "Claude target admission failed without proof that its controller exited. HRA preserved the source session and fenced this target launch until restart recovery proves the child is gone.", + ); + } + try { + await releaseStartedTarget(new AbortController().signal); + } catch (cleanupError: unknown) { + if (cleanupError instanceof DaemonAuthoritySafetyError) throw cleanupError; + this.#quarantineSession(session.id); + throw new IndeterminateLocalCommitError( + "The target provider start failed and its exact cleanup did not settle.", + new AggregateError([startFailure.error, cleanupError]), + ); + } + throw startFailure.error; + } + if (started === undefined) throw new Error("PROVIDER_SWITCH_TARGET_START_MISSING"); + if ( + command.provider === "claude" + && ( + started.providerThreadId !== reservedTargetClaudeProviderThreadId + || targetClaudeProcessIdentity === undefined + ) + ) { + const admissionError = new Error("CLAUDE_PROCESS_IDENTITY_NOT_ADMITTED"); + try { + await releaseStartedTarget(new AbortController().signal); + } catch (cleanupError: unknown) { + if (cleanupError instanceof DaemonAuthoritySafetyError) throw cleanupError; + this.#quarantineSession(session.id); + throw new IndeterminateLocalCommitError( + "The Claude target returned without matching its reserved exact-process custody, and safe cleanup did not settle.", + new AggregateError([admissionError, cleanupError]), + ); + } + throw admissionError; + } + const startedTarget = started; + if ( + targetProfile.id === currentProfile.id + && command.provider === session.provider + && startedTarget.providerThreadId === session.providerThreadId + ) { + this.#quarantineSession(session.id); + throw new IndeterminateLocalCommitError( + "The provider-switch target aliased the exact source thread. HRA left that thread untouched and quarantined the local session.", + new Error("PROVIDER_SWITCH_TARGET_ALIASED_SOURCE"), + ); + } try { this.#store.recordSessionProviderSwitchTarget({ attemptId, @@ -8542,8 +14250,9 @@ export class HraService { }); } catch (recordError: unknown) { try { - await releaseTarget(); + await releaseStartedTarget(new AbortController().signal); } catch (cleanupError: unknown) { + if (cleanupError instanceof DaemonAuthoritySafetyError) throw cleanupError; this.#quarantineSession(session.id); throw new IndeterminateLocalCommitError( "The target provider started, but neither its exact binding nor its cleanup could be durably proven.", @@ -8552,6 +14261,21 @@ export class HraService { } throw recordError; } + try { + await assertTargetAccountStable(); + } catch (accountError: unknown) { + if (accountError instanceof DaemonAuthoritySafetyError) throw accountError; + try { + await releaseStartedTarget(new AbortController().signal, true); + } catch (cleanupError: unknown) { + if (cleanupError instanceof DaemonAuthoritySafetyError) throw cleanupError; + throw new IndeterminateLocalCommitError( + "The target account changed and its exact controller could not be safely released.", + new AggregateError([accountError, cleanupError]), + ); + } + throw accountError; + } try { seedReview = await this.#fencedRuntimeReview( runtime, @@ -8565,6 +14289,7 @@ export class HraService { signal, }), ); + await assertTargetAccountStable(); const targetSeedReview = seedReview; this.#store.recordSessionProviderSwitchSeedIntent({ attemptId, @@ -8582,6 +14307,7 @@ export class HraService { clientMessageId: attemptId, signal, })); + await assertTargetAccountStable(); this.#store.recordSessionProviderSwitchSeedResult({ attemptId, sessionId: session.id, @@ -8591,9 +14317,14 @@ export class HraService { turnStatus: seeded.status, }); } catch (seedError: unknown) { + if (seedError instanceof DaemonAuthoritySafetyError) throw seedError; try { - await releaseTarget(); + await releaseStartedTarget( + new AbortController().signal, + seedError instanceof ProviderAccountAuthorityMismatchError, + ); } catch (cleanupError: unknown) { + if (cleanupError instanceof DaemonAuthoritySafetyError) throw cleanupError; this.#quarantineSession(session.id); throw new IndeterminateLocalCommitError( "The target seed did not settle and the target provider could not be safely cleaned up.", @@ -8630,6 +14361,7 @@ export class HraService { attemptId, sessionId: session.id, expectedSessionRevision: current.revision, + ...targetProviderAccountKeyInput(), title: startedTarget.title, ...(startedTarget.providerUpdatedAt === undefined ? {} @@ -8648,6 +14380,24 @@ export class HraService { recordError, ); } + try { + await assertTargetAccountStable(); + } catch (accountError: unknown) { + if (accountError instanceof DaemonAuthoritySafetyError) throw accountError; + try { + await releaseStartedTarget(new AbortController().signal, true); + } catch (cleanupError: unknown) { + if (cleanupError instanceof DaemonAuthoritySafetyError) throw cleanupError; + this.recordBackgroundDiagnostic( + "provider_switch_target_release_failed", + cleanupError, + ); + } + throw new IndeterminateLocalCommitError( + "The target account changed after the source controller was released.", + accountError, + ); + } return { from: { provider: fromProvider, preset: fromPreset, account: currentProfile.id }, providerThreadId: started.providerThreadId, @@ -8670,16 +14420,37 @@ export class HraService { }, receipt: (value) => sessionSwitchReceiptSchema.parse(value), restore: (value) => sessionSwitchReceiptSchema.parse(value), - commit: (attemptId, result, receipt) => { - if (started === undefined || seeded === undefined) { - throw new Error("Provider switch commit lost its exact provider projection or seed result."); + commit: async (attemptId, result, receipt) => { + if ( + started === undefined + || seeded === undefined + || (command.provider !== "devin" && targetProviderAccountKey === undefined) + ) { + throw new Error("Provider switch commit lost its exact provider projection, seed result, or account authority."); + } + try { + await assertTargetAccountStable(); + } catch (accountError: unknown) { + if (accountError instanceof DaemonAuthoritySafetyError) throw accountError; + try { + await releaseStartedTarget(new AbortController().signal, true); + } catch (cleanupError: unknown) { + if (cleanupError instanceof DaemonAuthoritySafetyError) throw cleanupError; + this.recordBackgroundDiagnostic( + "provider_switch_target_release_failed", + cleanupError, + ); + } + throw accountError; } + const committedTargetAccountKey = targetProviderAccountKey; const current = this.#store.requireSession(session.id); const state = seeded.status === "inProgress" ? "active" : "idle"; this.#store.completeSessionProviderSwitch({ attemptId, sessionId: current.id, expectedSessionRevision: current.revision, + expectedTargetProfileGeneration: targetProfile.processGeneration, provider: command.provider, profileId: targetProfile.id, preset, @@ -8690,6 +14461,12 @@ export class HraService { ? {} : { providerUpdatedAt: started.providerUpdatedAt }), runtimeProfile: started.effectiveRuntimeProfile, + ...(committedTargetAccountKey === undefined + ? {} + : { providerAccountKey: committedTargetAccountKey }), + ...(targetClaudeProcessIdentity === undefined + ? {} + : { claudeProcessIdentity: targetClaudeProcessIdentity }), seedTurnId: seeded.turnId, receipt: sessionSwitchReceiptSchema.parse(receipt ?? result), }); @@ -8717,6 +14494,7 @@ export class HraService { attemptId: switchAttemptId, sessionId: session.id, expectedSessionRevision: current.revision, + ...targetProviderAccountKeyInput(), title: started.title, ...(started.providerUpdatedAt === undefined ? {} @@ -8749,6 +14527,21 @@ export class HraService { reason = "provider switch", ): Promise { this.#assertEstablishedSessionAccount(profile, session); + await this.#assertPersonalSessionAccountAuthority(session, profile, signal, true); + if (session.provider === "claude") { + await this.#releaseClaudeProcessAuthority({ + providerThreadId: session.providerThreadId, + profileId: session.profileId, + runtimeScope: this.#sessionHasActivePersonalBinding(session) ? "personal" : "managed", + }, signal); + } else { + await this.#fencedEffect(async () => await this.#runtimeForSession(session).endSession({ + authority: this.#authorityForSession(session, profile), + providerThreadId: session.providerThreadId, + signal, + })); + } + await this.#assertSessionAccountAuthorityAfterProviderEffect(session, profile, signal); const connectionId = this.#sessionProviderConnections.get(session.id) ?? null; this.#persistSessionEventWrites(this.#eventRedactor.interruptSession({ accountId: profile.id, @@ -8762,15 +14555,11 @@ export class HraService { reason, }); this.#sessionProviderConnections.delete(session.id); + this.#clearSessionFactAuthority(session.id); this.#sessionObservationFailures.delete(session.id); this.#sessionResubscriptionConnections.delete(session.id); this.#sessionsAwaitingResubscription.delete(session.id); this.#forgetSessionFactEpoch(session.id); - await this.#fencedEffect(async () => await this.#runtimeForSession(session).endSession({ - authority: authorityFor(this.#paths, profile), - providerThreadId: session.providerThreadId, - signal, - })); } /* @@ -8860,12 +14649,13 @@ export class HraService { if (baseline === undefined || review === undefined) throw new Error("Session send lost its exact pre-effect provider baseline or runtime review."); const runtimeReview = review; if (baseline.status === "active" || baseline.activeTurnId !== undefined) throw new CommandFailure("CONFLICT", "The session already has an active turn. Use `session steer` or `session queue`."); + const projectRoot = project === undefined + ? undefined + : await this.#requireUsableProjectRoot(project.rootPath); + await this.#assertPersonalSessionAccountAuthority(session, profile, signal, true); startedResult = await this.#fencedEffect(async () => { - const projectRoot = project === undefined - ? undefined - : await this.#requireUsableProjectRoot(project.rootPath); return await this.#runtimeForSession(session).startTurn({ - authority: authorityFor(this.#paths, profile), + authority: this.#authorityForSession(session, profile), providerThreadId: session.providerThreadId, ...(projectRoot === undefined ? {} : { projectRoot }), review: runtimeReview, @@ -8875,16 +14665,22 @@ export class HraService { signal, }); }); + await this.#assertSessionAccountAuthorityAfterProviderEffect( + session, + profile, + signal, + ); return { ...startedResult, sourceId: attemptId }; }, beginEffect: async (attemptId) => { baseline = await this.#readExactSessionProjection(session, profile, false, signal); if (baseline.status === "active" || baseline.activeTurnId !== undefined) throw new CommandFailure("CONFLICT", "The session already has an active turn. Use `session steer` or `session queue`."); + const projectRoot = project === undefined + ? undefined + : await this.#requireUsableProjectRoot(project.rootPath); + await this.#assertPersonalSessionAccountAuthority(session, profile, signal, true); review = await this.#fencedEffect(async () => { - const projectRoot = project === undefined - ? undefined - : await this.#requireUsableProjectRoot(project.rootPath); return await this.#runtimeForSession(session).reviewTurnStart({ - authority: authorityFor(this.#paths, profile), + authority: this.#authorityForSession(session, profile), providerThreadId: session.providerThreadId, ...(projectRoot === undefined ? {} : { projectRoot }), preset: session.preset, @@ -8942,7 +14738,7 @@ export class HraService { return { session: reconciled, turnId: result.turnId, - effectiveRuntimeProfile: result.effectiveRuntimeProfile ?? null, + effectiveRuntimeProfile: publicRuntimeProfile(result.effectiveRuntimeProfile), ...(attachments.values.length === 0 ? {} : { attachments: attachments.values.map(attachmentReferenceOf) }), @@ -9004,7 +14800,9 @@ export class HraService { const result = await this.#effect({ kind: "session.steer", authorityId: session.id, authorityGeneration: profile.processGeneration, request: { message, ...(attachmentReferences.length === 0 ? {} : { attachments: attachmentReferences }) }, idempotencyKey: key, effect: async (attemptId) => { if (activeTurnId === undefined) throw new CommandFailure("CONFLICT", "The session has no active turn to steer."); const turnId = activeTurnId; - await this.#fencedEffect(async () => await this.#runtimeForSession(session).steer({ authority: authorityFor(this.#paths, profile), providerThreadId: session.providerThreadId, activeTurnId: turnId, message, ...(attachments.values.length === 0 ? {} : { attachments: attachments.values }), clientMessageId: attemptId, signal })); + await this.#assertPersonalSessionAccountAuthority(session, profile, signal, true); + await this.#fencedEffect(async () => await this.#runtimeForSession(session).steer({ authority: this.#authorityForSession(session, profile), providerThreadId: session.providerThreadId, activeTurnId: turnId, message, ...(attachments.values.length === 0 ? {} : { attachments: attachments.values }), clientMessageId: attemptId, signal })); + await this.#assertSessionAccountAuthorityAfterProviderEffect(session, profile, signal); return { steered: true as const, activeTurnId: turnId }; }, beginEffect: async (attemptId) => { baseline = await this.#readExactSessionProjection(session, profile, false, signal); @@ -9048,6 +14846,7 @@ export class HraService { selector: string, message: string, idempotencyKey: string | undefined, + signal: AbortSignal, beforeEffect?: () => void, attachmentReferences: readonly AttachmentReference[] = [], ): Promise { @@ -9058,6 +14857,7 @@ export class HraService { // outlives the attachments it references. const attachments = await this.#prepareAttachments(attachmentReferences); const key = idempotencyKey ?? randomUUID(); + await this.#assertPersonalSessionAccountAuthority(session, profile, signal, true); // Work authorization and durable enqueue are one synchronous fence boundary. beforeEffect?.(); const queued = this.#store.enqueueIdempotent({ sessionId: session.id, profileGeneration: profile.processGeneration, message, idempotencyKey: key }); @@ -9086,7 +14886,7 @@ export class HraService { if (this.#state !== "open") return; const profile = this.#store.requireProfile(session.profileId); if (!this.#profileAllowsEstablishedSession(profile, session)) return; - const task = this.#serializeSessionAuthority(session, async () => this.#dispatchNextQueue(session.id, authorityFor(this.#paths, profile))); + const task = this.#serializeSessionAuthority(session, async () => this.#dispatchNextQueue(session.id, this.#authorityForSession(session, profile))); const tracked = task.then( () => undefined, (error: unknown) => this.recordBackgroundDiagnostic("queue_dispatch_failed", error), @@ -9153,8 +14953,13 @@ export class HraService { return; } const profile = this.#store.requireProfile(current.profileId); - if (!this.#profileAllowsEstablishedSession(profile, current)) return; - await this.#serializeSessionAuthority(current, async () => this.#dispatchNextQueue(current.id, authorityFor(this.#paths, profile))); + if (!this.#profileAuthorityIsUsable( + profile.id, + profile.processGeneration, + current.provider, + current.id, + )) return; + await this.#serializeSessionAuthority(current, async () => this.#dispatchNextQueue(current.id, this.#authorityForSession(current, profile))); if (this.#store.requireQueue(queueId).state !== "pending") this.#queuePreEffectRetryCounts.delete(queueId); })(); const tracked = task.then( @@ -9185,7 +14990,9 @@ export class HraService { const result = await this.#effect({ kind: "session.stop", authorityId: session.id, authorityGeneration: profile.processGeneration, request: {}, idempotencyKey: key, effect: async () => { if (activeTurnId === null) return { stopped: false as const, activeTurnId: null }; const turnId = activeTurnId; - await this.#fencedEffect(async () => await this.#runtimeForSession(session).interrupt({ authority: authorityFor(this.#paths, profile), providerThreadId: session.providerThreadId, activeTurnId: turnId, signal })); + await this.#assertPersonalSessionAccountAuthority(session, profile, signal, true); + await this.#fencedEffect(async () => await this.#runtimeForSession(session).interrupt({ authority: this.#authorityForSession(session, profile), providerThreadId: session.providerThreadId, activeTurnId: turnId, signal })); + await this.#assertSessionAccountAuthorityAfterProviderEffect(session, profile, signal); return { stopped: true as const, activeTurnId: turnId }; }, beginEffect: async (attemptId) => { baseline = await this.#readExactSessionProjection(session, profile, false, signal); @@ -9220,7 +15027,8 @@ export class HraService { this.#assertSignedIn(profile); const key = idempotencyKey ?? randomUUID(); let baseline: CodexSessionProjection | undefined; - await this.#effect({ kind: "session.rename", authorityId: session.id, authorityGeneration: profile.processGeneration, request: { name }, idempotencyKey: key, effect: async () => { await this.#fencedEffect(async () => await this.#codex.rename({ authority: authorityFor(this.#paths, profile), providerThreadId: session.providerThreadId, name, signal })); return { renamed: true as const }; }, beginEffect: async (attemptId) => { + const codex = this.#runtimeForSession(session) as CodexRuntimePort; + await this.#effect({ kind: "session.rename", authorityId: session.id, authorityGeneration: profile.processGeneration, request: { name }, idempotencyKey: key, effect: async () => { await this.#assertPersonalSessionAccountAuthority(session, profile, signal, true); await this.#fencedEffect(async () => await codex.rename({ authority: this.#authorityForSession(session, profile), providerThreadId: session.providerThreadId, name, signal })); await this.#assertSessionAccountAuthorityAfterProviderEffect(session, profile, signal); return { renamed: true as const }; }, beginEffect: async (attemptId) => { baseline = await this.#readExactSessionProjection(session, profile, false, signal); this.#store.beginSessionMutationEffect({ attemptId, @@ -9282,7 +15090,7 @@ export class HraService { resolution: "provider_state_reconciled", provider: { providerThreadId: projection.providerThreadId, - title: projection.title, + title: session.provider === "claude" ? session.title : projection.title, status: projection.status, ...(projection.activeTurnId === undefined ? {} : { activeTurnId: projection.activeTurnId }), ...(projection.providerUpdatedAt === undefined ? {} : { providerUpdatedAt: projection.providerUpdatedAt }), @@ -9360,7 +15168,7 @@ export class HraService { const projection = await this.#readExactSessionProjection({ ...session, providerThreadId: session.providerThreadId }, profile, false, signal); const provider = { providerThreadId: projection.providerThreadId, - title: projection.title, + title: session.provider === "claude" ? session.title : projection.title, status: projection.status, ...(projection.activeTurnId === undefined ? {} : { activeTurnId: projection.activeTurnId }), ...(projection.providerUpdatedAt === undefined ? {} : { providerUpdatedAt: projection.providerUpdatedAt }), @@ -9433,22 +15241,357 @@ export class HraService { "A provider authority changed without an exact switch-recovery successor receipt.", ); } - } - const sourceProfile = this.#store.requireProfileById(evidence.sourceProfileId); - const targetProfile = this.#store.requireProfileById(evidence.targetProfileId); - const readDetached = async ( - profile: ProfileRecord, - provider: Provider, - providerThreadId: string, - detail: boolean, - ): Promise => { - const projection = await this.#fencedEffect(async () => - await this.#sessionRuntime(provider).readSession({ - authority: authorityFor(this.#paths, profile), - providerThreadId, - detail, - signal, - })); + } + const sourceProfile = this.#store.requireProfileById(evidence.sourceProfileId); + const targetProfile = this.#store.requireProfileById(evidence.targetProfileId); + let current = this.#store.requireSession(session.id); + let progress = this.#store.readSessionProviderSwitchProgress(attempt.id); + // Older unsettled receipts may lack this proof. They can only be settled + // locally; they must never authorize recovery or a target provider call. + const legacyTargetAccountAuthorityMissing = + evidence.targetProvider !== "devin" + && evidence.targetProviderAccountKey === undefined + && progress.targetProviderAccountKey === undefined; + const targetAccountAuthorityValid = + progress.targetProviderAccountKey === evidence.targetProviderAccountKey + && (evidence.targetProvider === "devin" + ? progress.targetProviderAccountKey === undefined + : progress.targetProviderAccountKey !== undefined); + if ( + !targetAccountAuthorityValid + && !(action === "abandon" && legacyTargetAccountAuthorityMissing) + ) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The provider-switch target account authority no longer matches its immutable evidence.", + ); + } + const sourceBound = (): boolean => + current.profileId === evidence.sourceProfileId + && current.provider === evidence.sourceProvider + && current.providerThreadId === evidence.sourceProviderThreadId; + const targetBound = (): boolean => + progress.targetProviderThreadId !== undefined + && current.profileId === evidence.targetProfileId + && current.provider === evidence.targetProvider + && current.providerThreadId === progress.targetProviderThreadId; + if (!sourceBound() && !targetBound()) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The session binding matches neither immutable side of the provider switch.", + ); + } + if (targetBound() && !progress.sourceReleased) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The target is locally bound without a durable source-release receipt.", + ); + } + const currentProviderAccountAuthority = + this.#store.readSessionProviderAccountAuthority(session.id); + const sourceBinding = this.#store.readSessionPersonalRuntimeBinding( + session.id, + true, + ); + let sourceRuntimeScope: RuntimeAccountScope | undefined; + let sourceExpectedAccountKey: string | undefined; + const targetExpectedAccountKey = evidence.targetProviderAccountKey; + if (sourceBound()) { + if (evidence.sourceProvider === "devin") { + if (currentProviderAccountAuthority !== null) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The keyless Devin source unexpectedly has provider-account authority.", + ); + } + sourceRuntimeScope = "managed"; + } else if ( + currentProviderAccountAuthority === null + || currentProviderAccountAuthority.provider !== evidence.sourceProvider + ) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The provider switch lost its immutable source account authority.", + ); + } else { + sourceRuntimeScope = currentProviderAccountAuthority.runtimeScope; + sourceExpectedAccountKey = currentProviderAccountAuthority.accountKey; + } + } else if (!(action === "abandon" && legacyTargetAccountAuthorityMissing)) { + if (evidence.targetProvider === "devin") { + if (targetExpectedAccountKey !== undefined || currentProviderAccountAuthority !== null) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The keyless Devin target unexpectedly has provider-account authority.", + ); + } + } else if ( + targetExpectedAccountKey === undefined + || ( + currentProviderAccountAuthority === null + || currentProviderAccountAuthority.provider !== evidence.targetProvider + || currentProviderAccountAuthority.runtimeScope !== "managed" + || currentProviderAccountAuthority.accountKey !== targetExpectedAccountKey + ) + ) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The provider switch lost its immutable target account authority.", + ); + } + } + if (sourceRuntimeScope === "personal") { + if ( + sourceBinding === null + || sourceBinding.state !== "active" + || sourceBinding.provider !== evidence.sourceProvider + || sourceBinding.providerThreadId !== evidence.sourceProviderThreadId + ) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The provider switch lost its exact personal-home source binding.", + ); + } + } else if (sourceBound() && ( + sourceBinding !== null + && sourceBinding.state !== "detached" + )) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The provider switch source runtime scope conflicts with its personal-home binding.", + ); + } else if ( + targetBound() + && sourceBinding !== null + && sourceBinding.state !== "detached" + ) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The provider switch target still has controlling personal-home authority.", + ); + } + let recoveredTargetProviderAccountKey: string | undefined; + let recoveredTargetAccountAuthorityProven = false; + const requireSourceRuntimeAccountAuthority = (): Readonly<{ + accountKey?: string; + runtimeScope: RuntimeAccountScope; + }> => { + if (sourceRuntimeScope !== undefined && evidence.sourceProvider === "devin") { + if (sourceRuntimeScope !== "managed" || sourceExpectedAccountKey !== undefined) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The keyless Devin source has invalid runtime-account authority.", + ); + } + return { runtimeScope: sourceRuntimeScope }; + } + if (sourceRuntimeScope !== undefined && sourceExpectedAccountKey !== undefined) { + return { + accountKey: sourceExpectedAccountKey, + runtimeScope: sourceRuntimeScope, + }; + } + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The provider switch no longer owns the exact source runtime authority.", + ); + }; + const assertRuntimeAccountStable = async ( + profile: ProfileRecord, + provider: Provider, + runtimeScope: RuntimeAccountScope, + expectedAccountKey: string | undefined, + missingDiagnostic: string, + ): Promise => { + const exactProfile = this.#store.requireProfileById(profile.id); + let observedAccountKey: string | undefined; + if (runtimeScope === "managed") { + observedAccountKey = await this.#assertManagedProviderRuntimeAuthority( + exactProfile, + provider, + signal, + true, + ); + } else { + if (provider === "devin") { + throw new CommandFailure("RECOVERY_REQUIRED", missingDiagnostic); + } + observedAccountKey = await this.#assertProviderRuntimeAccountAuthority( + exactProfile, + provider, + runtimeScope, + signal, + true, + ); + } + if (provider === "devin") { + if (expectedAccountKey !== undefined || observedAccountKey !== undefined) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The keyless Devin runtime unexpectedly produced account-key authority.", + ); + } + return undefined; + } + if (expectedAccountKey === undefined) { + throw new CommandFailure("RECOVERY_REQUIRED", missingDiagnostic); + } + if (observedAccountKey === expectedAccountKey) return observedAccountKey; + if (provider === "codex" && runtimeScope === "managed") { + this.#scheduleProfilePersonalAuthorityRevocation(exactProfile); + } else { + this.#scheduleProviderRuntimeAccountRevocation( + exactProfile, + provider, + runtimeScope, + observedAccountKey ?? null, + ); + } + throw new ProviderAccountAuthorityMismatchError( + provider, + exactProfile, + ); + }; + const readDetached = async ( + side: "source" | "target", + profile: ProfileRecord, + provider: Provider, + providerThreadId: string, + detail: boolean, + ): Promise => { + const isSource = side === "source"; + const sideMatches = isSource + ? profile.id === evidence.sourceProfileId + && provider === evidence.sourceProvider + && providerThreadId === evidence.sourceProviderThreadId + : profile.id === evidence.targetProfileId + && provider === evidence.targetProvider + && progress.targetProviderThreadId === providerThreadId; + if (!sideMatches) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The provider-switch detached-read side does not match its immutable evidence.", + ); + } + if ( + !isSource + && evidence.targetProfileId === evidence.sourceProfileId + && evidence.targetProvider === evidence.sourceProvider + && providerThreadId === evidence.sourceProviderThreadId + ) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The provider-switch target aliases its source thread and cannot be inspected.", + ); + } + const sourceAuthority = isSource + ? requireSourceRuntimeAccountAuthority() + : undefined; + const runtimeScope: RuntimeAccountScope = isSource + ? sourceAuthority?.runtimeScope ?? "managed" + : "managed"; + const expectedAccountKey = isSource + ? sourceAuthority?.accountKey + : targetExpectedAccountKey; + const assertAccountStable = async (): Promise => { + return await assertRuntimeAccountStable( + profile, + provider, + runtimeScope, + expectedAccountKey, + isSource + ? "The provider switch no longer owns the exact source account authority." + : "This provider-switch receipt predates durable target account authority and cannot inspect its target.", + ); + }; + const beforeAccountKey = await assertAccountStable(); + let runtime: SessionRuntimePort; + if (runtimeScope === "personal") { + if (provider === "devin") { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "A Devin provider-switch side cannot use personal-home runtime authority.", + ); + } + runtime = this.#personalSessionRuntime(provider); + } else { + runtime = this.#sessionRuntime(provider); + } + const authority = runtimeScope === "personal" + ? this.#personalAuthorityForProfile(profile) + : authorityFor(this.#paths, profile); + let projection: CodexSessionProjection | undefined; + let readFailure: Readonly<{ error: unknown }> | undefined; + try { + if (provider === "claude") { + const process = this.#store.readClaudeProcessAuthority({ + providerThreadId, + profileId: profile.id, + runtimeScope, + }); + if ( + process === null + || process.profileGeneration !== profile.processGeneration + || process.sessionId !== session.id + || (process.state !== "claimed" && process.state !== "bound") + ) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The provider switch no longer owns exact Claude process custody for this detached read.", + ); + } + const liveIdentity = await this.#fencedEffect(async () => + await (runtime as ClaudeRuntimePort).readSessionProcessIdentity({ + authority, + providerThreadId, + signal, + })); + if (!this.#sameClaudeProcessIdentity(liveIdentity, process.identity)) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The live Claude controller no longer matches the provider switch's durable process custody.", + ); + } + } + projection = await this.#fencedEffect(async () => + await runtime.readSession({ + authority, + providerThreadId, + detail, + signal, + })); + } catch (error: unknown) { + if (error instanceof DaemonAuthoritySafetyError) throw error; + readFailure = { error }; + } + let afterAccountKey: string | undefined; + try { + afterAccountKey = await assertAccountStable(); + } catch (error: unknown) { + if (error instanceof DaemonAuthoritySafetyError) throw error; + throw new IndeterminateLocalCommitError( + "A detached provider-switch read crossed an account-authority change.", + error, + ); + } + if (readFailure !== undefined) throw readFailure.error; + if (projection === undefined) throw new Error("PROVIDER_SWITCH_DETACHED_READ_MISSING"); + if (afterAccountKey !== beforeAccountKey) { + if (provider === "devin") { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The managed Devin authority changed during detached provider-switch inspection.", + ); + } + throw new ProviderAccountAuthorityMismatchError( + provider, + this.#store.requireProfileById(profile.id), + ); + } + if (!isSource) { + recoveredTargetProviderAccountKey = afterAccountKey; + recoveredTargetAccountAuthorityProven = true; + } if (projection.providerThreadId !== providerThreadId) { throw new CommandFailure( "RECOVERY_REQUIRED", @@ -9457,16 +15600,102 @@ export class HraService { } return projection; }; + const recoveredTargetProviderAccountKeyInput = (): Readonly<{ + providerAccountKey?: string; + }> => { + if (!recoveredTargetAccountAuthorityProven) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The provider switch has no fresh target account-authority proof.", + ); + } + if (evidence.targetProvider === "devin") { + if (recoveredTargetProviderAccountKey !== undefined) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The keyless Devin target unexpectedly has recovered account-key authority.", + ); + } + return {}; + } + if (recoveredTargetProviderAccountKey === undefined) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The provider switch has no fresh target account-authority proof.", + ); + } + return { providerAccountKey: recoveredTargetProviderAccountKey }; + }; const endDetachedTarget = async (providerThreadId: string): Promise => { - await this.#fencedEffect(async () => await this.#sessionRuntime(evidence.targetProvider).endSession({ - authority: authorityFor(this.#paths, targetProfile), - providerThreadId, - signal, - })); + if (providerThreadId !== progress.targetProviderThreadId) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The provider-switch cleanup target does not match its durable target receipt.", + ); + } + if ( + evidence.targetProfileId === evidence.sourceProfileId + && evidence.targetProvider === evidence.sourceProvider + && providerThreadId === evidence.sourceProviderThreadId + ) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The provider-switch target aliases its source thread and cannot be ended.", + ); + } + const providerAccountKey = await assertRuntimeAccountStable( + targetProfile, + evidence.targetProvider, + "managed", + targetExpectedAccountKey, + "This provider-switch receipt predates durable target account authority and cannot end its target.", + ); + let effectFailure: Readonly<{ error: unknown }> | undefined; + try { + switch (evidence.targetProvider) { + case "claude": + await this.#releaseClaudeProcessAuthority({ + providerThreadId, + profileId: targetProfile.id, + runtimeScope: "managed", + }, signal); + break; + case "codex": + case "devin": + await this.#fencedEffect(async () => await this.#sessionRuntime( + evidence.targetProvider, + ).endSession({ + authority: authorityFor(this.#paths, targetProfile), + providerThreadId, + signal, + })); + break; + } + } catch (error: unknown) { + if (error instanceof DaemonAuthoritySafetyError) throw error; + effectFailure = { error }; + } + try { + await assertRuntimeAccountStable( + targetProfile, + evidence.targetProvider, + "managed", + targetExpectedAccountKey, + "This provider-switch receipt predates durable target account authority and cannot end its target.", + ); + } catch (error: unknown) { + if (error instanceof DaemonAuthoritySafetyError) throw error; + throw new IndeterminateLocalCommitError( + "The provider-switch target account changed while its exact cleanup was in flight.", + error, + ); + } + if (effectFailure !== undefined) throw effectFailure.error; this.#store.recordSessionProviderSwitchTargetReleased({ attemptId: attempt.id, sessionId: session.id, providerThreadId, + ...(providerAccountKey === undefined ? {} : { providerAccountKey }), }); }; const providerState = (projection: CodexSessionProjection) => ({ @@ -9502,23 +15731,6 @@ export class HraService { }; }; - let current = this.#store.requireSession(session.id); - let progress = this.#store.readSessionProviderSwitchProgress(attempt.id); - const sourceBound = (): boolean => - current.profileId === evidence.sourceProfileId - && current.provider === evidence.sourceProvider - && current.providerThreadId === evidence.sourceProviderThreadId; - const targetBound = (): boolean => - progress.targetProviderThreadId !== undefined - && current.profileId === evidence.targetProfileId - && current.provider === evidence.targetProvider - && current.providerThreadId === progress.targetProviderThreadId; - if (!sourceBound() && !targetBound()) { - throw new CommandFailure( - "RECOVERY_REQUIRED", - "The session binding matches neither immutable side of the provider switch.", - ); - } const crossedDaemonRestart = evidence.daemonGeneration === undefined || evidence.daemonGeneration !== this.#daemonGeneration; const sourceClaudeStateUnavailable = crossedDaemonRestart @@ -9542,9 +15754,13 @@ export class HraService { let sourceStateUnknown = !sourceReleased; let sourceObserved = false; let observedSourceProviderUpdatedAt: number | null | undefined; - if (!sourceReleased && evidence.sourceProvider === "codex") { + if ( + !sourceReleased + && (evidence.sourceProvider === "codex" || evidence.sourceProvider === "devin") + ) { try { const sourceProjection = await readDetached( + "source", sourceProfile, evidence.sourceProvider, evidence.sourceProviderThreadId, @@ -9562,6 +15778,7 @@ export class HraService { progress.targetProviderThreadId !== undefined && !targetReleased && !targetClaudeStateUnavailable + && !legacyTargetAccountAuthorityMissing ) { try { await endDetachedTarget(progress.targetProviderThreadId); @@ -9631,7 +15848,11 @@ export class HraService { let targetReleased = progress.targetReleased; const sourceReleased = progress.sourceReleased; const targetAddressable = progress.targetProviderThreadId !== undefined; - if (progress.targetProviderThreadId !== undefined && !targetReleased) { + if ( + progress.targetProviderThreadId !== undefined + && !targetReleased + && !legacyTargetAccountAuthorityMissing + ) { try { await endDetachedTarget(progress.targetProviderThreadId); targetReleased = true; @@ -9643,6 +15864,7 @@ export class HraService { if (sourceBound() && !sourceReleased && targetReleased) { try { const sourceProjection = await readDetached( + "source", sourceProfile, evidence.sourceProvider, evidence.sourceProviderThreadId, @@ -9719,6 +15941,7 @@ export class HraService { throw new CommandFailure("RECOVERY_REQUIRED", "A released target no longer has the expected source binding."); } const sourceProjection = await readDetached( + "source", sourceProfile, evidence.sourceProvider, evidence.sourceProviderThreadId, @@ -9743,6 +15966,7 @@ export class HraService { if (progress.seed === undefined) { await endDetachedTarget(targetThreadId); const sourceProjection = await readDetached( + "source", sourceProfile, evidence.sourceProvider, evidence.sourceProviderThreadId, @@ -9766,6 +15990,7 @@ export class HraService { } if (progress.seedTurnId === undefined) { targetProjection = await readDetached( + "target", targetProfile, evidence.targetProvider, targetThreadId, @@ -9824,6 +16049,7 @@ export class HraService { } await endDetachedTarget(targetThreadId); const sourceProjection = await readDetached( + "source", sourceProfile, evidence.sourceProvider, evidence.sourceProviderThreadId, @@ -9848,6 +16074,15 @@ export class HraService { if (progress.seedTurnId === undefined || progress.seedTurnStatus === undefined) { throw new CommandFailure("RECOVERY_REQUIRED", "The target seed result is still not durably proven."); } + if (targetProjection === undefined) { + targetProjection = await readDetached( + "target", + targetProfile, + evidence.targetProvider, + targetThreadId, + false, + ); + } if (!progress.sourceReleased) { if (!sourceBound()) { throw new CommandFailure("RECOVERY_REQUIRED", "The source release is unproven and the source is no longer bound."); @@ -9871,12 +16106,20 @@ export class HraService { }); } catch { current = this.#store.requireSession(session.id); + targetProjection = await readDetached( + "target", + targetProfile, + evidence.targetProvider, + targetThreadId, + false, + ); this.#store.bindSessionProviderSwitchRecoveryTarget({ attemptId: attempt.id, sessionId: session.id, expectedSessionRevision: current.revision, - title: targetProjection?.title ?? current.title, - ...(targetProjection?.providerUpdatedAt === undefined + ...recoveredTargetProviderAccountKeyInput(), + title: targetProjection.title, + ...(targetProjection.providerUpdatedAt === undefined ? {} : { providerUpdatedAt: targetProjection.providerUpdatedAt }), recordSourceReleased: true, @@ -9889,12 +16132,20 @@ export class HraService { throw new CommandFailure("RECOVERY_REQUIRED", "The provider-switch release receipts changed before target adoption."); } if (sourceBound()) { + targetProjection = await readDetached( + "target", + targetProfile, + evidence.targetProvider, + targetThreadId, + false, + ); this.#store.bindSessionProviderSwitchRecoveryTarget({ attemptId: attempt.id, sessionId: session.id, expectedSessionRevision: current.revision, - title: targetProjection?.title ?? current.title, - ...(targetProjection?.providerUpdatedAt === undefined + ...recoveredTargetProviderAccountKeyInput(), + title: targetProjection.title, + ...(targetProjection.providerUpdatedAt === undefined ? {} : { providerUpdatedAt: targetProjection.providerUpdatedAt }), }); @@ -9904,6 +16155,7 @@ export class HraService { throw new CommandFailure("RECOVERY_REQUIRED", "The seeded target could not be bound to the recovering session."); } targetProjection = await readDetached( + "target", targetProfile, evidence.targetProvider, targetThreadId, @@ -10005,7 +16257,7 @@ export class HraService { const projection = await this.#readExactSessionProjection({ ...session, providerThreadId: session.providerThreadId }, profile, false, signal); const provider = { providerThreadId: projection.providerThreadId, - title: projection.title, + title: session.provider === "claude" ? session.title : projection.title, status: projection.status, ...(projection.activeTurnId === undefined ? {} : { activeTurnId: projection.activeTurnId }), ...(projection.providerUpdatedAt === undefined ? {} : { providerUpdatedAt: projection.providerUpdatedAt }), @@ -10044,8 +16296,18 @@ export class HraService { } async #readExactSessionProjection(session: BoundSessionRecord, profile: ProfileRecord, detail: boolean, signal: AbortSignal): Promise { - if (session.provider === "claude") this.#assertClaudeIsolationAccepted(); - const projection = await this.#fencedEffect(async () => await this.#runtimeForSession(session).readSession({ authority: authorityFor(this.#paths, profile), providerThreadId: session.providerThreadId, detail, signal })); + if ( + session.provider === "claude" + && !this.#sessionHasMatchingActivePersonalBinding(session) + ) this.#assertClaudeIsolationAccepted(); + await this.#assertPersonalSessionAccountAuthority(session, profile, signal, true); + const projection = await this.#fencedEffect(async () => await this.#runtimeForSession(session).readSession({ authority: this.#authorityForSession(session, profile), providerThreadId: session.providerThreadId, detail, signal })); + await this.#assertPersonalSessionAccountAuthority( + this.#store.requireSession(session.id), + this.#store.requireProfileById(profile.id), + signal, + true, + ); if (projection.providerThreadId !== session.providerThreadId) { throw new CommandFailure("RECOVERY_REQUIRED", "Codex returned a projection for a different provider thread."); } @@ -10068,7 +16330,16 @@ export class HraService { this.#requireLiveProviderObservation( await this.#ensureSessionObservedLocked(session.id, signal), ); - return await this.#fencedEffect(async () => await this.#codex.inspectTurn({ authority: authorityFor(this.#paths, profile), providerThreadId: session.providerThreadId, turnId, signal })); + const codex = this.#runtimeForSession(session) as CodexRuntimePort; + await this.#assertPersonalSessionAccountAuthority(session, profile, signal, true); + const inspected = await this.#fencedEffect(async () => await codex.inspectTurn({ authority: this.#authorityForSession(session, profile), providerThreadId: session.providerThreadId, turnId, signal })); + await this.#assertPersonalSessionAccountAuthority( + this.#store.requireSession(session.id), + this.#store.requireProfileById(profile.id), + signal, + true, + ); + return inspected; } #requireBoundSession(selector: string): BoundSessionRecord { @@ -10076,10 +16347,24 @@ export class HraService { if (session.providerThreadId === undefined) throw new CommandFailure("RECOVERY_REQUIRED", "The session has no proven provider binding."); if (session.state === "recovery_required") throw new CommandFailure("RECOVERY_REQUIRED", "The session requires recovery before another mutation."); if (session.state === "terminal") throw new CommandFailure("CONFLICT", "The session is terminal and cannot accept another mutation."); + // This is also the fail-closed admission gate for commands that only write + // local queue state before they need a provider runtime. + const profile = this.#store.requireProfileById(session.profileId); + this.#assertEstablishedSessionAccount(profile, session); + this.#sessionHasActivePersonalBinding(session); return { ...session, providerThreadId: session.providerThreadId }; } #assertSignedIn(profile: ProfileRecord): void { + if (this.#profileAuthorityRevocationIsPending( + profile.id, + profile.processGeneration, + )) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + `Account authority for ${profile.label} is being revoked; wait for controller release before another provider operation.`, + ); + } if (profile.state === "recovery_required") { throw new CommandFailure("RECOVERY_REQUIRED", `Run \`hra account show ${profile.id}\` to reconcile this account before another provider operation.`); } @@ -10099,6 +16384,36 @@ export class HraService { } } + #assertIdentifiableAccountAuthority( + profile: Pick, + ): void { + if (profile.providerEmail !== undefined) return; + throw new CommandFailure( + "UNAVAILABLE", + `The provider did not expose a stable account identity for ${profile.label}. HRA will not create or adopt sessions under an unprovable API-key or Bedrock credential.`, + { accountId: profile.id }, + ); + } + + #profileAuthorityIsUsable( + profileId: ProfileRecord["id"], + generation: number, + provider: Provider = "codex", + sessionId?: SessionRecord["id"], + ): boolean { + try { + const profile = this.#store.requireProfileById(profileId); + const session = sessionId === undefined + ? { provider } + : this.#store.requireSession(sessionId); + return profile.processGeneration === generation + && this.#profileAllowsEstablishedSession(profile, session) + && !this.#profileAuthorityRevocationIsPending(profileId, generation); + } catch { + return false; + } + } + /** * A profile's durable state is Codex account state. Claude authentication is * owned by Claude Code inside the same provider-neutral profile directory, @@ -10116,15 +16431,23 @@ export class HraService { signedIn: true; }>> { switch (provider) { - case "codex": + case "codex": { this.#assertSignedIn(profile); + this.#assertIdentifiableAccountAuthority(profile); return { profileId: profile.id, processGeneration: profile.processGeneration, provider, signedIn: true, }; + } case "claude": { + if (profile.state !== "signed_in" && profile.state !== "signed_out") { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "Resolve this profile's unsettled Codex account transition before starting a Claude provider effect.", + ); + } const unsettledLogin = this.#unsettledClaudeLogin(profile); if (unsettledLogin !== undefined) { throw new CommandFailure( @@ -10156,6 +16479,12 @@ export class HraService { ); } case "devin": { + if (profile.state !== "signed_in" && profile.state !== "signed_out") { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "Resolve this profile's unsettled Codex account transition before starting a Devin provider effect.", + ); + } const unsettledLogin = this.#unsettledDevinLogin(profile); if (unsettledLogin !== undefined) { throw new CommandFailure( @@ -10191,37 +16520,70 @@ export class HraService { /** Provider-touch admission for an established session. */ #profileAllowsEstablishedSession( profile: ProfileRecord, - session: Pick, + session: Pick & Partial< + Pick + >, ): boolean { switch (session.provider) { case "codex": return profile.state === "signed_in"; - case "claude": return this.#platform === "linux"; - case "devin": return true; + case "claude": { + if (profile.state !== "signed_in" && profile.state !== "signed_out") return false; + if (this.#platform === "linux") return true; + if (session.id === undefined || session.providerThreadId === undefined) return false; + return this.#sessionHasMatchingActivePersonalBinding({ + id: session.id, + provider: session.provider, + providerThreadId: session.providerThreadId, + }); + } + case "devin": return profile.state === "signed_in" || profile.state === "signed_out"; } } - /** Established Claude sessions ignore Codex auth state, but not platform custody. */ + /** Established sessions retain both their HRA profile and runtime-home authority. */ #assertEstablishedSessionAccount( profile: ProfileRecord, - session: Pick, + session: Pick, ): void { switch (session.provider) { - case "codex": - if (!this.#profileAllowsEstablishedSession(profile, session)) this.#assertSignedIn(profile); + case "codex": { + this.#assertSignedIn(profile); + this.#assertSessionAccountAuthority(session, profile); return; - case "claude": - this.#assertClaudeIsolationAccepted(); + } + case "claude": { + if (profile.state !== "signed_in" && profile.state !== "signed_out") { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The Claude session's profile or runtime-home authority is unsettled.", + ); + } + if (!this.#sessionHasMatchingActivePersonalBinding(session)) { + this.#assertClaudeIsolationAccepted(); + } return; - case "devin": + } + case "devin": { + if (!this.#profileAllowsEstablishedSession(profile, session)) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The Devin session's profile authority is unsettled.", + ); + } return; + } } } #quarantineProfile(profile: Pick): ProfileRecord { + this.#clearProfileFactAuthorities(profile.id); const current = this.#store.requireProfile(profile.id); if (current.processGeneration !== profile.processGeneration) { throw new Error("Account generation changed before recovery quarantine."); } + if (this.#profileHasControllingRuntimeAuthority(current)) { + this.#scheduleProfilePersonalAuthorityRevocation(current); + } const stateChange = current.state === "recovery_required" ? null : this.#store.setProfileStateWithWorkRetirement( @@ -10241,7 +16603,39 @@ export class HraService { return this.#store.requireProfile(profile.id); } + #quarantineCodexAccountMutation( + profile: Pick, + ): ProfileRecord { + // The exact Codex scopes were already fenced by the account mutation. + // Keep independent Claude controllers intact: recovery_required makes + // their effects temporarily unavailable without confusing a Codex + // credential outcome with permission to terminate Claude custody. + this.#clearProfileFactAuthorities(profile.id, "codex"); + const current = this.#store.requireProfile(profile.id); + if (current.processGeneration !== profile.processGeneration) { + throw new Error("Account generation changed before Codex recovery quarantine."); + } + if (current.state !== "recovery_required") { + const stateChange = this.#store.setProfileStateWithWorkRetirement( + profile.id, + profile.processGeneration, + "recovery_required", + this.#work, + { + ...(current.providerEmail === undefined ? {} : { email: current.providerEmail }), + ...(current.providerPlan === undefined ? {} : { plan: current.providerPlan }), + }, + ); + this.#notifyAffectedWork(stateChange.affectedWorkIds); + if (!stateChange.changed) { + throw new Error("Codex account could not be quarantined after an indeterminate mutation."); + } + } + return this.#store.requireProfile(profile.id); + } + #quarantineSession(sessionId: SessionRecord["id"]): SessionRecord { + this.#clearSessionFactAuthority(sessionId); const session = this.#store.quarantineSession(sessionId); if (session.state !== "recovery_required" && session.state !== "terminal") { throw new Error("Session quarantine did not reach a non-dispatchable state."); @@ -10355,18 +16749,25 @@ export class HraService { try { const signal = new AbortController().signal; const profile = this.#store.requireProfile(session.profileId); + if (!this.#profileAuthorityIsUsable( + profile.id, + authority.generation, + session.provider, + session.id, + )) return; await this.#requireUsableProjectRoot(project.rootPath); this.#requireLiveProviderObservation( await this.#ensureSessionObservedLocked(session.id, signal), ); const baseline = await this.#readExactSessionProjection(boundSession, profile, false, signal); if (baseline.status === "active" || baseline.activeTurnId !== undefined) return; + const reviewedProjectRoot = await this.#requireUsableProjectRoot(project.rootPath); + await this.#assertPersonalSessionAccountAuthority(session, profile, signal, true); const review = await this.#fencedEffect(async () => { - const projectRoot = await this.#requireUsableProjectRoot(project.rootPath); return await this.#runtimeForSession(session).reviewTurnStart({ authority, providerThreadId: boundSession.providerThreadId, - projectRoot, + projectRoot: reviewedProjectRoot, preset: session.preset, requirement: presetSelection.requirement, fast: session.fastEnabled, @@ -10397,12 +16798,13 @@ export class HraService { this.#queuePreEffectRetryCounts.delete(queued.id); const dispatchRevision = this.#store.requireSession(session.id).revision; const dispatchFactEpoch = this.#snapshotSessionFactEpoch(session.id); + const dispatchProjectRoot = await this.#requireUsableProjectRoot(project.rootPath); + await this.#assertPersonalSessionAccountAuthority(session, profile, signal, true); const result = await this.#fencedEffect(async () => { - const projectRoot = await this.#requireUsableProjectRoot(project.rootPath); return await this.#runtimeForSession(session).startTurn({ authority, providerThreadId: boundSession.providerThreadId, - projectRoot, + projectRoot: dispatchProjectRoot, review, message: queued.message, ...(queuedAttachments.values.length === 0 @@ -10413,6 +16815,11 @@ export class HraService { }); }); providerApplied = true; + await this.#assertSessionAccountAuthorityAfterProviderEffect( + boundSession, + profile, + signal, + ); this.#store.completeQueueEffect({ queueId: queued.id, expectedEvidenceDigest: evidence.digest, @@ -10474,12 +16881,14 @@ export class HraService { if ( profile.processGeneration !== authority.generation || profile.state !== "signed_in" + || this.#profileAuthorityRevocationIsPending(profile.id, authority.generation) ) return; await this.#serialize(`account:${profile.id}`, async () => { const current = this.#store.requireProfileById(profile.id); if ( current.processGeneration !== authority.generation || current.state !== "signed_in" + || this.#profileAuthorityRevocationIsPending(current.id, authority.generation) ) return; await this.#usage( current.id, @@ -10590,6 +16999,19 @@ export class HraService { } } + async #serializeKeys( + keys: readonly string[], + operation: () => Promise | T, + ): Promise { + const unique = [...new Set(keys)]; + const descend = async (index: number): Promise => { + const key = unique[index]; + if (key === undefined) return await operation(); + return await this.#serialize(key, async () => await descend(index + 1)); + }; + return await descend(0); + } + async #serializeProfileAuthorities( profileIds: readonly ProfileRecord["id"][], operation: () => Promise | T, @@ -10630,6 +17052,38 @@ export class HraService { void tracked.then(() => this.#background.delete(tracked)); } + async #applyOrderedSessionFact( + session: Pick, + operation: () => Promise | void, + ): Promise { + const accountKey = `account:${session.profileId}`; + const sessionKey = `session:${session.id}`; + const ordered = async (): Promise => { + await this.#serializeSessionAuthority( + session, + operation, + { allowDuringProjectionRecovery: true }, + ); + }; + if (!this.#mutationTails.has(accountKey) && !this.#mutationTails.has(sessionKey)) { + await ordered(); + return; + } + // Provider callbacks can be awaited from inside the provider effect that + // owns these tails. Queue the entire source revalidation and fact commit, + // then return the callback so the effect can release its authority. + const task = ordered(); + const tracked = task.then( + () => undefined, + (error: unknown) => { + if (error instanceof StateSecurityScrubRequiredError) this.#requestStop(); + else this.recordBackgroundDiagnostic("session_state_tracking_failed", error); + }, + ); + this.#background.add(tracked); + void tracked.then(() => this.#background.delete(tracked)); + } + #sessionRecoveryProfileIds(session: Pick): readonly ProfileRecord["id"][] { const ids = new Set([session.profileId]); for (const attempt of this.#store.listUnsettledMutations({ sessionId: session.id })) { @@ -10660,15 +17114,38 @@ export class HraService { operation: () => Promise | T, options: Readonly<{ allowDuringProjectionRecovery?: boolean }> = {}, ): Promise { - return await this.#serializeProfileAuthorities(profileIds, async () => + const authorityProfileIds = [...new Set(profileIds)]; + const profileRecoveryIsInFlight = (): boolean => + authorityProfileIds.some((profileId) => + this.#profileHasProjectionRecoveryInFlight(profileId)); + return await this.#serializeProfileAuthorities(authorityProfileIds, async () => this.#serialize(`session:${session.id}`, async () => { + this.#assertSessionAccountAuthorityIfSignedIn(this.#store.requireSession(session.id)); if (options.allowDuringProjectionRecovery !== true) { - const unsettled = await this.#cloud.isCompactProjectionRecoveryUnsettled(session.id); + if ( + this.#projectionRecoveriesInFlight.has(session.id) + || profileRecoveryIsInFlight() + ) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "This session or account has a compact-projection recovery in flight.", + ); + } + const [sessionRecoveryIsUnsettled, ...profileRecoveryStates] = await Promise.all([ + this.#cloud.isCompactProjectionRecoveryUnsettled(session.id), + ...authorityProfileIds.map(async (profileId) => + await this.#cloud.isCompactProjectionRecoveryUnsettledForProfile(profileId)), + ]); await this.#daemonAuthority.assertCurrent(); - if (unsettled) { + if ( + sessionRecoveryIsUnsettled + || profileRecoveryStates.some(Boolean) + || this.#projectionRecoveriesInFlight.has(session.id) + || profileRecoveryIsInFlight() + ) { throw new CommandFailure( "RECOVERY_REQUIRED", - "This session has an unsettled compact-projection recovery. Retry that exact recovery before changing local or provider state.", + "This session or account has an unsettled compact-projection recovery. Retry that exact recovery before changing local or provider state.", ); } } @@ -10676,10 +17153,41 @@ export class HraService { })); } + async #serializeInteractionAuthority( + interactionId: InteractionRecord["publicId"], + operation: () => Promise | T, + ): Promise { + const selected = this.#store.requireInteraction(interactionId); + if (selected.sessionId === null) { + return await this.#serialize(`account:${selected.authority.profileId}`, async () => + this.#serialize(`interaction:${selected.publicId}`, async () => { + await this.#assertNoCompactProjectionRecoveryForProfile( + selected.authority.profileId, + ); + return await operation(); + })); + } + const session = this.#store.requireSession(selected.sessionId); + if (session.profileId !== selected.authority.profileId) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "The interaction no longer belongs to its recorded account authority.", + ); + } + return await this.#serializeSessionAuthority(session, async () => + this.#serialize(`interaction:${selected.publicId}`, operation)); + } + async #assertNoCompactProjectionRecoveryForProfile(profileId: ProfileRecord["id"]): Promise { + if (this.#profileHasProjectionRecoveryInFlight(profileId)) { + throw new CommandFailure( + "RECOVERY_REQUIRED", + "This account has a compact-projection recovery in flight.", + ); + } const unsettled = await this.#cloud.isCompactProjectionRecoveryUnsettledForProfile(profileId); await this.#daemonAuthority.assertCurrent(); - if (unsettled) { + if (unsettled || this.#profileHasProjectionRecoveryInFlight(profileId)) { throw new CommandFailure( "RECOVERY_REQUIRED", "This account owns an unsettled compact-projection recovery. Retry that exact recovery before changing provider or account authority.", diff --git a/src/daemon/session-event-cursor.test.ts b/src/daemon/session-event-cursor.test.ts index 917dc1d..f66e51a 100644 --- a/src/daemon/session-event-cursor.test.ts +++ b/src/daemon/session-event-cursor.test.ts @@ -420,17 +420,24 @@ describe("SessionEventCursorCodec", () => { test("round trips bounded account-scoped session-list continuations", () => { const codec = new SessionEventCursorCodec(FIXED_KEY); + const traversalId = "00000000-0000-4000-8000-000000000041"; const filter = { accountId: "acct_00000000000000000000000000000000" as const, providerGeneration: 7, limit: 37, + includeArchived: false, }; - const first = codec.advanceSessionList({ ...filter, providerCursor: "provider-page-2" }); + const first = codec.advanceSessionList({ + ...filter, + traversalId, + providerCursor: "provider-page-2", + }); const decodedFirst = codec.decodeSessionList(first, filter); expect(decodedFirst).toMatchObject({ version: 1, type: "session_list", ...filter, + traversalId, providerCursor: "provider-page-2", power: 1, span: 0, @@ -445,12 +452,22 @@ describe("SessionEventCursorCodec", () => { }); const decodedSecond = codec.decodeSessionList(second, filter); expect(decodedSecond).toMatchObject({ + traversalId, providerCursor: "provider-page-3", power: 2, span: 0, pageCount: 2, }); expect(Buffer.byteLength(second, "utf8")).toBeLessThanOrEqual(HRA_CURSOR_MAX_BYTES); + expectCursorRejection( + () => codec.advanceSessionList({ + ...filter, + traversalId: "00000000-0000-4000-8000-000000000042", + providerCursor: "provider-page-4", + prior: decodedSecond, + }), + "filter_mismatch", + ); }); test("binds session-list cursors to the immutable account generation and exact limit", () => { @@ -459,12 +476,14 @@ describe("SessionEventCursorCodec", () => { accountId: "acct_00000000000000000000000000000000" as const, providerGeneration: 3, limit: 50, + includeArchived: false, }; const cursor = codec.advanceSessionList({ ...filter, providerCursor: "provider-next" }); for (const mismatch of [ { ...filter, accountId: "acct_11111111111111111111111111111111" as const }, { ...filter, providerGeneration: 4 }, { ...filter, limit: 49 }, + { ...filter, includeArchived: true }, ]) { expectCursorRejection(() => codec.decodeSessionList(cursor, mismatch), "filter_mismatch"); } @@ -476,6 +495,7 @@ describe("SessionEventCursorCodec", () => { accountId: "acct_00000000000000000000000000000000" as const, accountGeneration: 0, limit: 37, + includeArchived: false, }; const cursor = codec.encodeLocalSessionList({ ...filter, @@ -495,6 +515,7 @@ describe("SessionEventCursorCodec", () => { { ...filter, accountId: "acct_22222222222222222222222222222222" as const }, { ...filter, accountGeneration: 1 }, { ...filter, limit: 36 }, + { ...filter, includeArchived: true }, ]) { expectCursorRejection( () => codec.decodeLocalSessionList(cursor, mismatch), @@ -517,11 +538,118 @@ describe("SessionEventCursorCodec", () => { ); }); + test("round trips source-neutral account-local continuations disjoint from provider cursors", () => { + const codec = new SessionEventCursorCodec(FIXED_KEY); + const traversalId = "00000000-0000-4000-8000-000000000043"; + const filter = { + accountId: "acct_00000000000000000000000000000000" as const, + providerGeneration: 7, + limit: 2, + includeArchived: false, + }; + const initial = codec.encodeAccountSessionLocal({ + ...filter, + traversalId, + afterCreatedAt: null, + afterSessionId: null, + }); + expect(codec.decodeAccountSessionLocal(initial, filter)).toEqual({ + version: 1, + type: "session_list_account_local", + ...filter, + traversalId, + afterCreatedAt: null, + afterSessionId: null, + }); + const encodedPayload = initial.split(".")[1]; + if (encodedPayload === undefined) throw new Error("Expected an account-tail payload."); + expect(Buffer.from(encodedPayload, "base64url").toString("utf8")) + .not.toContain("adopted"); + const continuation = codec.encodeAccountSessionLocal({ + ...filter, + traversalId, + afterCreatedAt: 12_345, + afterSessionId: "sess_11111111111111111111111111111111", + }); + expect(codec.decodeAccountSessionLocal(continuation, filter)).toMatchObject({ + afterCreatedAt: 12_345, + afterSessionId: "sess_11111111111111111111111111111111", + }); + expectCursorRejection( + () => codec.decodeSessionList(initial, filter), + "type_mismatch", + ); + expectCursorRejection( + () => codec.decodeAccountSessionLocal(continuation, { ...filter, limit: 1 }), + "filter_mismatch", + ); + expectCursorRejection( + () => codec.decodeAccountSessionLocal(continuation, { + ...filter, + includeArchived: true, + }), + "filter_mismatch", + ); + const legacyAdopted = signedCursor(JSON.stringify({ + version: 1, + type: "session_list_adopted", + ...filter, + afterCreatedAt: null, + afterSessionId: null, + })); + expectCursorRejection( + () => codec.decodeAccountSessionLocal(legacyAdopted, filter), + "type_mismatch", + ); + }); + + test("binds composite session-list continuations to archive visibility", () => { + const codec = new SessionEventCursorCodec(FIXED_KEY); + const filter = { + accountId: "acct_00000000000000000000000000000000" as const, + providerGeneration: 7, + limit: 2, + includeArchived: false, + }; + const cursor = codec.encodeCompositeSessionList({ + ...filter, + continuation: { phase: "provider_start" }, + }); + expect(codec.decodeCompositeSessionList(cursor, filter)).toEqual({ + version: 1, + type: "session_list_composite", + ...filter, + continuation: { phase: "provider_start" }, + }); + expectCursorRejection( + () => codec.decodeCompositeSessionList(cursor, { + ...filter, + includeArchived: true, + }), + "filter_mismatch", + ); + + const advanced = codec.advanceCompositeSessionList({ + ...filter, + providerCursor: "provider-next", + }); + expect(codec.decodeCompositeSessionList(advanced, filter)).toMatchObject({ + ...filter, + continuation: { + phase: "provider", + state: { + ...filter, + providerCursor: "provider-next", + }, + }, + }); + }); + test("keeps provider and local session-list cursor authorities disjoint", () => { const codec = new SessionEventCursorCodec(FIXED_KEY); const accountId = "acct_00000000000000000000000000000000" as const; - const localFilter = { accountId, accountGeneration: 2, limit: 10 }; - const providerFilter = { accountId, providerGeneration: 2, limit: 10 }; + const localFilter = { accountId, accountGeneration: 2, limit: 10, includeArchived: false }; + const providerFilter = { accountId, providerGeneration: 2, limit: 10, includeArchived: false }; const local = codec.encodeLocalSessionList({ ...localFilter, afterCreatedAt: 1, @@ -547,6 +675,7 @@ describe("SessionEventCursorCodec", () => { accountId: "acct_00000000000000000000000000000000" as const, providerGeneration: 1, limit: 100, + includeArchived: false, }; const initialCursor = codec.advanceSessionList({ ...filter, @@ -581,6 +710,7 @@ describe("SessionEventCursorCodec", () => { accountId: "acct_00000000000000000000000000000000" as const, providerGeneration: 1, limit: 100, + includeArchived: false, }; const first = codec.decodeSessionList( codec.advanceSessionList({ ...filter, providerCursor: "a" }), @@ -625,6 +755,7 @@ describe("SessionEventCursorCodec", () => { accountId: "acct_00000000000000000000000000000000" as const, providerGeneration: 1, limit: 100, + includeArchived: false, }; const prefix = Array.from({ length: 73 }, (_, index) => `prefix-${String(index)}`); const cycle = Array.from({ length: 257 }, (_, index) => `cycle-${String(index)}`); @@ -659,6 +790,7 @@ describe("SessionEventCursorCodec", () => { accountId: "acct_00000000000000000000000000000000" as const, providerGeneration: 5, limit: 23, + includeArchived: false, }; fc.assert(fc.property( fc.uniqueArray(fc.integer(), { minLength: 33, maxLength: 256 }), @@ -707,6 +839,7 @@ describe("SessionEventCursorCodec", () => { accountId: "acct_00000000000000000000000000000000" as const, providerGeneration: 2, limit: 10, + includeArchived: false, }; const eventCursor = codec.encode({ version: 1, diff --git a/src/daemon/session-event-cursor.ts b/src/daemon/session-event-cursor.ts index 0cc380c..61fd384 100644 --- a/src/daemon/session-event-cursor.ts +++ b/src/daemon/session-event-cursor.ts @@ -105,6 +105,7 @@ export const sessionListCursorFilterSchema = z.object({ accountId: profileIdSchema, providerGeneration: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), limit: z.number().int().min(1).max(100), + includeArchived: z.boolean(), }).strict(); export type SessionListCursorFilter = z.infer; @@ -115,6 +116,8 @@ export const sessionListCursorPayloadSchema = z.object({ accountId: profileIdSchema, providerGeneration: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), limit: z.number().int().min(1).max(100), + includeArchived: z.boolean(), + traversalId: z.string().uuid().optional(), providerCursor: sessionListProviderCursorSchema, checkpointDigest: sessionListProviderCursorDigestSchema, power: safePositiveIntegerSchema, @@ -141,6 +144,30 @@ export const sessionListCursorPayloadSchema = z.object({ export type SessionListCursorPayload = z.infer; +/** Signed continuation for the source-neutral local phase of a signed-in account listing. */ +export const accountSessionLocalCursorPayloadSchema = z.object({ + version: z.literal(1), + type: z.literal("session_list_account_local"), + accountId: profileIdSchema, + providerGeneration: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + limit: z.number().int().min(1).max(100), + includeArchived: z.boolean(), + traversalId: z.string().uuid(), + afterCreatedAt: unixMillisecondsSchema.max(Number.MAX_SAFE_INTEGER).nullable(), + afterSessionId: sessionIdSchema.nullable(), +}).strict().superRefine((value, context) => { + if ((value.afterCreatedAt === null) !== (value.afterSessionId === null)) { + context.addIssue({ + code: "custom", + message: "Account local-session position must be wholly present or absent.", + }); + } +}); + +export type AccountSessionLocalCursorPayload = z.infer< + typeof accountSessionLocalCursorPayloadSchema +>; + export const compositeSessionListContinuationSchema = z.discriminatedUnion("phase", [ z.object({ phase: z.literal("local"), @@ -164,6 +191,7 @@ export const compositeSessionListCursorPayloadSchema = z.object({ accountId: profileIdSchema, providerGeneration: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), limit: z.number().int().min(1).max(100), + includeArchived: z.boolean(), continuation: compositeSessionListContinuationSchema, }).strict().superRefine((value, context) => { if ( @@ -184,6 +212,7 @@ export const localSessionListCursorFilterSchema = z.object({ accountId: profileIdSchema, accountGeneration: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), limit: z.number().int().min(1).max(100), + includeArchived: z.boolean(), }).strict(); export type LocalSessionListCursorFilter = z.infer; @@ -194,6 +223,7 @@ export const localSessionListCursorPayloadSchema = z.object({ accountId: profileIdSchema, accountGeneration: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), limit: z.number().int().min(1).max(100), + includeArchived: z.boolean(), afterCreatedAt: unixMillisecondsSchema.max(Number.MAX_SAFE_INTEGER), afterSessionId: sessionIdSchema, }).strict(); @@ -227,6 +257,8 @@ const canonicalSessionListPayload = (payload: SessionListCursorPayload): string accountId: payload.accountId, providerGeneration: payload.providerGeneration, limit: payload.limit, + includeArchived: payload.includeArchived, + ...(payload.traversalId === undefined ? {} : { traversalId: payload.traversalId }), providerCursor: payload.providerCursor, checkpointDigest: payload.checkpointDigest, power: payload.power, @@ -259,6 +291,7 @@ const canonicalCompositeSessionListPayload = ( accountId: payload.accountId, providerGeneration: payload.providerGeneration, limit: payload.limit, + includeArchived: payload.includeArchived, continuation: canonicalCompositeSessionListContinuation(payload.continuation), }); @@ -270,6 +303,21 @@ const canonicalLocalSessionListPayload = ( accountId: payload.accountId, accountGeneration: payload.accountGeneration, limit: payload.limit, + includeArchived: payload.includeArchived, + afterCreatedAt: payload.afterCreatedAt, + afterSessionId: payload.afterSessionId, +}); + +const canonicalAccountSessionLocalPayload = ( + payload: AccountSessionLocalCursorPayload, +): string => JSON.stringify({ + version: payload.version, + type: payload.type, + accountId: payload.accountId, + providerGeneration: payload.providerGeneration, + limit: payload.limit, + includeArchived: payload.includeArchived, + traversalId: payload.traversalId, afterCreatedAt: payload.afterCreatedAt, afterSessionId: payload.afterSessionId, }); @@ -328,14 +376,16 @@ const sameSessionListFilter = ( expected: SessionListCursorFilter, ): boolean => actual.accountId === expected.accountId && actual.providerGeneration === expected.providerGeneration - && actual.limit === expected.limit; + && actual.limit === expected.limit + && actual.includeArchived === expected.includeArchived; const sameLocalSessionListFilter = ( actual: LocalSessionListCursorFilter, expected: LocalSessionListCursorFilter, ): boolean => actual.accountId === expected.accountId && actual.accountGeneration === expected.accountGeneration - && actual.limit === expected.limit; + && actual.limit === expected.limit + && actual.includeArchived === expected.includeArchived; const sessionListProviderCursorDigest = (providerCursor: string): string => createHash("sha256") @@ -584,6 +634,7 @@ export class SessionEventCursorCodec { accountId: input.accountId, accountGeneration: input.accountGeneration, limit: input.limit, + includeArchived: input.includeArchived, afterCreatedAt: input.afterCreatedAt, afterSessionId: input.afterSessionId, }); @@ -640,6 +691,7 @@ export class SessionEventCursorCodec { accountId: input.accountId, providerGeneration: input.providerGeneration, limit: input.limit, + includeArchived: input.includeArchived, continuation: input.continuation, }); return this.#encodeCanonical( @@ -694,6 +746,7 @@ export class SessionEventCursorCodec { accountId: input.accountId, providerGeneration: input.providerGeneration, limit: input.limit, + includeArchived: input.includeArchived, }); const priorProviderState = input.prior?.continuation.phase === "provider" ? input.prior.continuation.state @@ -711,6 +764,7 @@ export class SessionEventCursorCodec { advanceSessionList( input: SessionListCursorFilter & Readonly<{ + traversalId?: string; providerCursor: string; prior?: SessionListCursorPayload; }>, @@ -719,6 +773,7 @@ export class SessionEventCursorCodec { accountId: input.accountId, providerGeneration: input.providerGeneration, limit: input.limit, + includeArchived: input.includeArchived, }); const providerCursor = sessionListProviderCursorSchema.safeParse(input.providerCursor); if (!providerCursor.success) { @@ -732,6 +787,9 @@ export class SessionEventCursorCodec { let power = 1; let span = 0; let pageCount = 1; + const traversalId = input.traversalId === undefined + ? input.prior?.traversalId + : z.string().uuid().parse(input.traversalId); if (input.prior !== undefined) { const prior = sessionListCursorPayloadSchema.safeParse(input.prior); if (!prior.success) { @@ -746,6 +804,12 @@ export class SessionEventCursorCodec { "filter_mismatch", ); } + if (prior.data.traversalId !== traversalId) { + throw new SessionEventCursorError( + "Session-list cursor traversal authority changed.", + "filter_mismatch", + ); + } if ( providerCursor.data === prior.data.providerCursor || digest === prior.data.checkpointDigest @@ -775,6 +839,7 @@ export class SessionEventCursorCodec { version: 1, type: "session_list", ...filter, + ...(traversalId === undefined ? {} : { traversalId }), providerCursor: providerCursor.data, checkpointDigest, power, @@ -820,6 +885,69 @@ export class SessionEventCursorCodec { return parsed.data; } + encodeAccountSessionLocal( + input: SessionListCursorFilter & Readonly<{ + traversalId: string; + afterCreatedAt: number | null; + afterSessionId: string | null; + }>, + ): string { + const filter = sessionListCursorFilterSchema.parse({ + accountId: input.accountId, + providerGeneration: input.providerGeneration, + limit: input.limit, + includeArchived: input.includeArchived, + }); + const payload = accountSessionLocalCursorPayloadSchema.parse({ + version: 1, + type: "session_list_account_local", + ...filter, + traversalId: input.traversalId, + afterCreatedAt: input.afterCreatedAt, + afterSessionId: input.afterSessionId, + }); + return this.#encodeCanonical( + canonicalAccountSessionLocalPayload(payload), + "Account local-session cursor", + ); + } + + decodeAccountSessionLocal( + cursor: string, + expectedFilter: SessionListCursorFilter, + ): AccountSessionLocalCursorPayload { + const expected = sessionListCursorFilterSchema.parse(expectedFilter); + const envelope = this.#decodeEnvelope(cursor, "Account local-session cursor"); + if ( + typeof envelope.value !== "object" + || envelope.value === null + || !("type" in envelope.value) + || envelope.value.type !== "session_list_account_local" + ) { + throw new SessionEventCursorError( + "Another HRA cursor type cannot be used as an account local-session cursor.", + "type_mismatch", + ); + } + const parsed = accountSessionLocalCursorPayloadSchema.safeParse(envelope.value); + if ( + !parsed.success + || canonicalAccountSessionLocalPayload(parsed.data) !== envelope.payloadJson + ) { + throw new SessionEventCursorError( + "Account local-session cursor payload is not canonical.", + "noncanonical", + ); + } + if (!sameSessionListFilter(parsed.data, expected)) { + throw new SessionEventCursorError( + "Account local-session cursor filters do not match the requested account listing.", + "filter_mismatch", + ); + } + return parsed.data; + } + #encodeCanonical(payloadJson: string, label: string): string { const encodedPayload = Buffer.from(payloadJson, "utf8").toString("base64url"); const signature = this.#signature(encodedPayload).toString("base64url"); diff --git a/src/daemon/streaming-redaction.test.ts b/src/daemon/streaming-redaction.test.ts index 389e213..c7d92ef 100644 --- a/src/daemon/streaming-redaction.test.ts +++ b/src/daemon/streaming-redaction.test.ts @@ -22,6 +22,7 @@ const createRedactor = ( options: Omit = {}, ): SessionEventStreamRedactor => new SessionEventStreamRedactor({ ...options, + isCodexSession: options.isCodexSession ?? (() => true), projectPublicProviderIdentifier: publicProviderId, }); @@ -204,6 +205,67 @@ describe("SessionEventStreamRedactor", () => { expect(texts(output, "authorization")).toContain("Safe suffix."); }); + test("protects a split exact heartbeat envelope before any live delta is released", () => { + const redactor = createRedactor(); + const output: SessionEventWrite[] = [...redactor.accept(start("heartbeat-echo"))]; + const heartbeat = [ + "", + " weekly-project-maintenance", + " 2030-01-02T03:04:05.678Z", + " ", + " Review the synthetic fixture project.", + " ", + "", + ].join("\n"); + const chunks = [ + heartbeat.slice(0, 7), + heartbeat.slice(7, 31), + heartbeat.slice(31, 79), + heartbeat.slice(79, 151), + heartbeat.slice(151), + ]; + for (const chunk of chunks) { + const released = redactor.accept(assistant("heartbeat-echo", chunk)); + expect(JSON.stringify(released)).not.toContain("weekly-project-maintenance"); + expect(JSON.stringify(released)).not.toContain("2030-01-02T03:04:05.678Z"); + expect(JSON.stringify(released)).not.toContain("synthetic fixture project"); + output.push(...released); + } + output.push(...redactor.accept(complete("heartbeat-echo"))); + + expect(texts(output, "heartbeat-echo")).toBe("[protected]"); + expect(JSON.stringify(output)).not.toContain("weekly-project-maintenance"); + expect(JSON.stringify(output)).not.toContain("2030-01-02T03:04:05.678Z"); + expect(JSON.stringify(output)).not.toContain("synthetic fixture project"); + + const nearMatch = heartbeat.replace( + "2030-01-02T03:04:05.678Z", + "2030-01-02T03:04:05Z", + ); + const nearOutput: SessionEventWrite[] = [ + ...redactor.accept(start("heartbeat-near")), + ...redactor.accept(assistant("heartbeat-near", nearMatch)), + ...redactor.accept(complete("heartbeat-near")), + ]; + const ordinaryNear = createRedactor({ isCodexSession: () => false }); + const ordinaryNearOutput: SessionEventWrite[] = [ + ...ordinaryNear.accept(start("heartbeat-near")), + ...ordinaryNear.accept(assistant("heartbeat-near", nearMatch)), + ...ordinaryNear.accept(complete("heartbeat-near")), + ]; + expect(texts(nearOutput, "heartbeat-near")) + .toBe(texts(ordinaryNearOutput, "heartbeat-near")); + + const nonCodex = createRedactor({ isCodexSession: () => false }); + const nonCodexOutput: SessionEventWrite[] = [ + ...nonCodex.accept(start("non-codex-envelope")), + ...nonCodex.accept(assistant("non-codex-envelope", heartbeat)), + ...nonCodex.accept(complete("non-codex-envelope")), + ]; + expect(texts(nonCodexOutput, "non-codex-envelope")).not.toBe("[protected]"); + expect(texts(nonCodexOutput, "non-codex-envelope")).toContain(""); + }); + test("keeps interleaved items and reasoning parts isolated and ordered", () => { const redactor = createRedactor(); const output: SessionEventWrite[] = []; diff --git a/src/daemon/streaming-redaction.ts b/src/daemon/streaming-redaction.ts index 2e31d1e..c8d601c 100644 --- a/src/daemon/streaming-redaction.ts +++ b/src/daemon/streaming-redaction.ts @@ -1,4 +1,9 @@ import { redactAbsolutePaths } from "../domain/text-safety"; +import { + CODEX_DESKTOP_HEARTBEAT_ENVELOPE_PREFIX, + isExactCodexDesktopHeartbeatEnvelope, + replaceCodexDesktopHeartbeatEnvelope, +} from "../domain/codex-heartbeat-envelope"; import type { InteractionDisplay } from "../domain/interactions"; import type { SessionEvent, SessionEventBody } from "../domain/session-events"; import { @@ -32,6 +37,7 @@ type StagedNode = { type ActiveStream = { context: Omit; + heartbeatEnvelopeState: "candidate" | "prefix" | "rejected"; pending: StagedNode[]; proven: string; redactor: StreamingSensitiveRedactor; @@ -194,9 +200,13 @@ export const sanitizeInteractionDisplay = ( const sanitizeCompleteBody = ( body: SessionEventBody, projectPublicProviderIdentifier: PublicProviderIdentifierProjector, + protectCodexDesktopHeartbeatEnvelope: boolean, ): SessionEventBody => { - const safe = (value: string): string => sanitizeProviderProse(value); - const safeInline = (value: string): string => sanitizeProviderProse(value, false); + const protect = (value: string): string => protectCodexDesktopHeartbeatEnvelope + ? replaceCodexDesktopHeartbeatEnvelope(value) + : value; + const safe = (value: string): string => sanitizeProviderProse(protect(value)); + const safeInline = (value: string): string => sanitizeProviderProse(protect(value), false); const publicId = (value: string): string => projectPublicProviderIdentifier(value); switch (body.type) { case "plan_updated": return { @@ -392,6 +402,7 @@ export class SessionEventStreamRedactor { readonly #maximumActiveStreamsPerSession: number; readonly #maximumStagedCodeUnits: number; readonly #maximumStagedNodes: number; + readonly #isCodexSession: (write: SessionEventWrite) => boolean; readonly #projectPublicProviderIdentifier: PublicProviderIdentifierProjector; #stagedCodeUnits = 0; #stagedNodes = 0; @@ -401,6 +412,7 @@ export class SessionEventStreamRedactor { maximumActiveStreamsPerSession?: number; maximumStagedCodeUnits?: number; maximumStagedNodes?: number; + isCodexSession?: (write: SessionEventWrite) => boolean; projectPublicProviderIdentifier?: PublicProviderIdentifierProjector; }> = {}) { const positive = (value: number | undefined, fallback: number): number => { @@ -420,6 +432,7 @@ export class SessionEventStreamRedactor { 2 * 1024 * 1024, ); this.#maximumStagedNodes = positive(input.maximumStagedNodes, 4_096); + this.#isCodexSession = input.isCodexSession ?? (() => false); this.#projectPublicProviderIdentifier = input.projectPublicProviderIdentifier ?? createEphemeralPublicProviderIdentifierProjector(); if (this.#maximumActiveStreamsPerSession > this.#maximumActiveStreams) { @@ -464,6 +477,7 @@ export class SessionEventStreamRedactor { } let stream = this.#streams.get(key); if (stream === undefined) { + const protectCodexDesktopHeartbeatEnvelope = this.#isCodexSession(write); stream = { context: { accountId: write.accountId, @@ -471,6 +485,9 @@ export class SessionEventStreamRedactor { providerGeneration: write.providerGeneration, sessionId: write.sessionId, }, + heartbeatEnvelopeState: protectCodexDesktopHeartbeatEnvelope + ? "prefix" + : "rejected", pending: [], proven: "", redactor: new StreamingSensitiveRedactor(), @@ -481,7 +498,7 @@ export class SessionEventStreamRedactor { const node: StagedNode = { rawText: body.text, readyBody: undefined, write }; stream.pending.push(node); this.#enqueue(node); - this.#applyProvenOutput(stream, stream.redactor.push(body.text)); + this.#applyStreamInput(stream, body.text); return [...released, ...this.#drainSession(write.sessionId)]; } @@ -492,6 +509,7 @@ export class SessionEventStreamRedactor { readyBody: sanitizeCompleteBody( body, this.#projectPublicProviderIdentifier, + this.#isCodexSession(write), ), write, }); @@ -657,7 +675,17 @@ export class SessionEventStreamRedactor { this.#protectPending(stream); continue; } - this.#applyProvenOutput(stream, stream.redactor.push("", true)); + if (stream.heartbeatEnvelopeState === "rejected") { + this.#applyProvenOutput(stream, stream.redactor.push("", true)); + } else { + const raw = stream.pending.map((node) => node.rawText).join(""); + if (isExactCodexDesktopHeartbeatEnvelope(raw)) { + this.#protectPending(stream); + continue; + } + stream.heartbeatEnvelopeState = "rejected"; + this.#applyProvenOutput(stream, stream.redactor.push(raw, true)); + } if (stream.pending.length === 0) continue; const raw = stream.pending.map((node) => node.rawText).join(""); if (stream.proven === raw) this.#releaseProvenNodes(stream); @@ -665,6 +693,26 @@ export class SessionEventStreamRedactor { } } + #applyStreamInput(stream: ActiveStream, value: string): void { + if (stream.heartbeatEnvelopeState === "rejected") { + this.#applyProvenOutput(stream, stream.redactor.push(value)); + return; + } + // Once the complete canonical prefix matches, only the item boundary can + // prove or reject the exact whole-envelope grammar. Keep later chunks in + // the existing bounded staging queue without repeatedly joining them. + if (stream.heartbeatEnvelopeState === "candidate") return; + const raw = stream.pending.map((node) => node.rawText).join(""); + if (raw.length < CODEX_DESKTOP_HEARTBEAT_ENVELOPE_PREFIX.length) { + if (CODEX_DESKTOP_HEARTBEAT_ENVELOPE_PREFIX.startsWith(raw)) return; + } else if (raw.startsWith(CODEX_DESKTOP_HEARTBEAT_ENVELOPE_PREFIX)) { + stream.heartbeatEnvelopeState = "candidate"; + return; + } + stream.heartbeatEnvelopeState = "rejected"; + this.#applyProvenOutput(stream, stream.redactor.push(raw)); + } + #applyProvenOutput(stream: ActiveStream, output: string): void { if (output.length === 0) return; const pendingRaw = stream.pending.map((node) => node.rawText).join(""); diff --git a/src/daemon/work-service.test.ts b/src/daemon/work-service.test.ts index 4fd7579..e72fdb8 100644 --- a/src/daemon/work-service.test.ts +++ b/src/daemon/work-service.test.ts @@ -100,6 +100,8 @@ class WorkRuntime implements CodexRuntimePort { return { signedIn: true, email: "work-agent@example.com", plan: "Plus" }; } + async releaseOwnedAuthority(): Promise {} + async reviewSessionStart( input: Parameters[0], ): Promise { diff --git a/src/domain/codex-heartbeat-envelope.ts b/src/domain/codex-heartbeat-envelope.ts new file mode 100644 index 0000000..b7074b3 --- /dev/null +++ b/src/domain/codex-heartbeat-envelope.ts @@ -0,0 +1,34 @@ +export const PROTECTED_PROVIDER_TEXT_MARKER = "[protected]"; + +export const CODEX_DESKTOP_HEARTBEAT_ENVELOPE_PREFIX = + "\n "; + +const CODEX_DESKTOP_HEARTBEAT_ROOT = ""; +const CODEX_DESKTOP_HEARTBEAT_TITLE_PREFIX = "\n \n {2}([^\r\n]+)<\/automation_id>\n {2}(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)<\/current_time_iso>\n {2}\n {2}[\s\S]*\n {2}<\/instructions>\n<\/heartbeat>$/u; + +export const isExactCodexDesktopHeartbeatEnvelope = (input: string): boolean => { + const match = codexDesktopHeartbeatEnvelope.exec(input); + if (match === null) return false; + const automationId = match[1]; + const currentTimeIso = match[2]; + if (automationId === undefined || automationId.trim() === "" || currentTimeIso === undefined) { + return false; + } + const timestamp = Date.parse(currentTimeIso); + return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === currentTimeIso; +}; + +export const replaceCodexDesktopHeartbeatEnvelope = (input: string): string => + isExactCodexDesktopHeartbeatEnvelope(input) + ? PROTECTED_PROVIDER_TEXT_MARKER + : input; + +export const replaceCodexDesktopHeartbeatTitle = (input: string): string => + input.length >= CODEX_DESKTOP_HEARTBEAT_ROOT.length + && ( + CODEX_DESKTOP_HEARTBEAT_TITLE_PREFIX.startsWith(input) + || input.startsWith(CODEX_DESKTOP_HEARTBEAT_TITLE_PREFIX) + ) + ? PROTECTED_PROVIDER_TEXT_MARKER + : input; diff --git a/src/domain/contracts.ts b/src/domain/contracts.ts index ce8823f..15fada1 100644 --- a/src/domain/contracts.ts +++ b/src/domain/contracts.ts @@ -3,7 +3,7 @@ import { isAbsolute, normalize } from "node:path"; import { z } from "zod"; import { attachmentReferenceListSchema } from "./attachment-schemas"; -import { presetSchema, providerSchema } from "./presets"; +import { adoptableProviderSchema, presetSchema, providerSchema } from "./presets"; import { interactionResolutionSchema } from "./interactions"; import { notificationEmailPolicySchema } from "./notification-email"; import { @@ -83,6 +83,7 @@ const daemonStopAuthoritySchema = z.object({ export const signedOutSessionListMetadataSchema = z.object({ accountSelector: profileIdSchema, accountState: z.literal("signed_out"), + provider: z.literal("codex"), scope: z.literal("local_only"), freshness: z.literal("stale"), localCompleteness: z.enum(["partial", "complete"]), @@ -432,6 +433,20 @@ export const localCommandSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("session.stop"), session: selectorSchema, idempotencyKey: idempotencyKeySchema }).strict(), z.object({ kind: z.literal("session.rename"), session: selectorSchema, name: titleSchema, idempotencyKey: idempotencyKeySchema }).strict(), z.object({ kind: z.literal("session.archive"), session: selectorSchema, archived: z.boolean() }).strict(), + z.object({ + kind: z.literal("session.adoption.status"), + provider: adoptableProviderSchema.optional(), + }).strict(), + z.object({ + kind: z.literal("session.adoption.set"), + provider: adoptableProviderSchema, + enabled: z.boolean(), + account: selectorSchema.optional(), + }).strict(), + z.object({ + kind: z.literal("session.adoption.discover"), + provider: adoptableProviderSchema.optional(), + }).strict(), z.object({ kind: z.literal("session.recover"), session: selectorSchema }).strict(), z.object({ kind: z.literal("session.abandon"), session: selectorSchema }).strict(), z.object({ kind: z.literal("session.note.get"), session: selectorSchema }).strict(), diff --git a/src/domain/presets.ts b/src/domain/presets.ts index c92a248..5d1931a 100644 --- a/src/domain/presets.ts +++ b/src/domain/presets.ts @@ -4,6 +4,10 @@ import { z } from "zod"; export const providerSchema = z.enum(["codex", "claude", "devin"]); export type Provider = z.infer; +/** Providers whose existing personal-home sessions HRA can adopt. */ +export const adoptableProviderSchema = z.enum(["codex", "claude"]); +export type AdoptableProvider = z.infer; + export const DEFAULT_PROVIDER = "codex" satisfies Provider; export const presetSchema = z.enum(["low", "high", "ultra", "fable-max", "astra"]); diff --git a/src/domain/runtime-profile.test.ts b/src/domain/runtime-profile.test.ts new file mode 100644 index 0000000..5999611 --- /dev/null +++ b/src/domain/runtime-profile.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "bun:test"; + +import { + effectiveClaudeRuntimeProfileSchema, + effectiveDevinRuntimeProfileSchema, + projectPublicReviewedRuntimeProfile, +} from "./runtime-profile"; + +const shared = { + claudeVersion: "2.1.260", + inputFormat: "stream-json" as const, + model: "claude-fable-5-1", + observedAt: 1_700_000_000_000, + outputFormat: "stream-json" as const, + permissionMode: "default" as const, + preset: "fable-max" as const, + processGeneration: 3, + profileId: "acct_00000000000000000000000000000000", + reasoningEffort: "max" as const, +}; + +describe("Claude runtime profile", () => { + test("records the reviewed config home without an adoption-specific tier", () => { + expect(effectiveClaudeRuntimeProfileSchema.parse({ + ...shared, + configHome: "isolated", + })).toEqual({ ...shared, configHome: "isolated" }); + expect(effectiveClaudeRuntimeProfileSchema.parse({ + ...shared, + configHome: "personal", + })).toEqual({ ...shared, configHome: "personal" }); + }); + + test("parses legacy isolated evidence byte-for-byte without rewriting it", () => { + const legacy = { + profileId: shared.profileId, + processGeneration: shared.processGeneration, + observedAt: shared.observedAt, + preset: shared.preset, + model: shared.model, + reasoningEffort: shared.reasoningEffort, + claudeVersion: shared.claudeVersion, + permissionMode: shared.permissionMode, + isolatedConfigDir: true as const, + outputFormat: shared.outputFormat, + inputFormat: shared.inputFormat, + }; + const parsed = effectiveClaudeRuntimeProfileSchema.parse(legacy); + expect(parsed).toEqual(legacy); + expect(JSON.stringify(parsed)).toBe(JSON.stringify(legacy)); + }); + + test("refuses an ambiguous or missing config-home attestation", () => { + expect(effectiveClaudeRuntimeProfileSchema.safeParse(shared).success).toBe(false); + expect(effectiveClaudeRuntimeProfileSchema.safeParse({ + ...shared, + configHome: "personal", + isolatedConfigDir: true, + }).success).toBe(false); + }); + + test("omits current and legacy config-home provenance only from public projections", () => { + const current = { ...shared, configHome: "personal" as const }; + const legacy = { ...shared, isolatedConfigDir: true as const }; + expect(projectPublicReviewedRuntimeProfile(current)).toEqual(shared); + expect(projectPublicReviewedRuntimeProfile(legacy)).toEqual(shared); + expect(current).toHaveProperty("configHome", "personal"); + expect(legacy).toHaveProperty("isolatedConfigDir", true); + }); +}); + +describe("Devin runtime profile", () => { + test("projects the complete provider-safe reviewed profile", () => { + const profile = effectiveDevinRuntimeProfileSchema.parse({ + devinVersion: "3000.6.14", + isolatedHome: true, + model: "gpt-6-astra", + observedAt: 1_700_000_000_000, + preset: "astra", + processGeneration: 3, + profileId: shared.profileId, + protocolVersion: 1, + reasoningEffort: "provider-default", + }); + + const projected = projectPublicReviewedRuntimeProfile(profile); + expect(projected).toEqual({ + devinVersion: profile.devinVersion, + model: profile.model, + observedAt: profile.observedAt, + preset: profile.preset, + processGeneration: profile.processGeneration, + profileId: profile.profileId, + protocolVersion: profile.protocolVersion, + reasoningEffort: profile.reasoningEffort, + }); + expect(projected).not.toHaveProperty("isolatedHome"); + expect(profile).toHaveProperty("isolatedHome", true); + }); +}); diff --git a/src/domain/runtime-profile.ts b/src/domain/runtime-profile.ts index b49309a..40b1263 100644 --- a/src/domain/runtime-profile.ts +++ b/src/domain/runtime-profile.ts @@ -74,10 +74,11 @@ export type EffectiveRuntimeProfile = z.infer; + +const currentEffectiveClaudeRuntimeProfileSchema = z.object({ + ...effectiveClaudeRuntimeProfileFields, + configHome: claudeConfigHomeSchema, + outputFormat: z.literal("stream-json"), + inputFormat: z.literal("stream-json"), +}).strict(); + +// Runtime-profile rows are immutable evidence. Keep accepting the exact +// legacy shape so its stored JSON and digest remain byte-stable; new reviews +// always write `configHome` instead. +const legacyEffectiveClaudeRuntimeProfileSchema = z.object({ + ...effectiveClaudeRuntimeProfileFields, isolatedConfigDir: z.literal(true), outputFormat: z.literal("stream-json"), inputFormat: z.literal("stream-json"), -}).strict().superRefine((value, context) => { +}).strict(); + +export const effectiveClaudeRuntimeProfileSchema = z.union([ + currentEffectiveClaudeRuntimeProfileSchema, + legacyEffectiveClaudeRuntimeProfileSchema, +]).superRefine((value, context) => { if (value.model !== presetRequirements[value.preset].model) { context.addIssue({ code: "custom", message: "The effective model must match the exact HRA preset." }); } @@ -102,7 +125,7 @@ export type EffectiveClaudeRuntimeProfile = z.infer { if (!isAdmittedPresetRequirement(value.preset, { @@ -151,6 +178,66 @@ export type ReviewedRuntimeProfile = | EffectiveClaudeRuntimeProfile | EffectiveDevinRuntimeProfile; +/** + * Public runtime evidence intentionally omits which Claude config home owns + * the process. That field is required private custody evidence, but exposing + * `personal` versus `isolated` would distinguish adopted sessions from native + * ones. The legacy isolation marker is provenance for the same reason. + */ +export const publicEffectiveClaudeRuntimeProfileSchema = z.object({ + ...effectiveClaudeRuntimeProfileFields, + outputFormat: z.literal("stream-json"), + inputFormat: z.literal("stream-json"), +}).strict().superRefine((value, context) => { + if (value.model !== presetRequirements[value.preset].model) { + context.addIssue({ code: "custom", message: "The effective model must match the exact HRA preset." }); + } +}); + +/** Public Devin evidence omits the private isolated-home custody marker. */ +export const publicEffectiveDevinRuntimeProfileSchema = z.object({ + ...effectiveDevinRuntimeProfileFields, +}).strict().superRefine((value, context) => { + if (!isAdmittedPresetRequirement(value.preset, { + effort: value.reasoningEffort, + model: value.model, + })) { + context.addIssue({ + code: "custom", + message: "The effective model and reasoning effort must match Devin's exact current HRA preset.", + }); + } +}); + +export const publicReviewedRuntimeProfileSchema = z.union([ + effectiveRuntimeProfileSchema, + publicEffectiveClaudeRuntimeProfileSchema, + publicEffectiveDevinRuntimeProfileSchema, +]); + +export type PublicReviewedRuntimeProfile = z.infer; + +export const projectPublicReviewedRuntimeProfile = ( + profile: ReviewedRuntimeProfile, +): PublicReviewedRuntimeProfile => { + const reviewed = reviewedRuntimeProfileSchema.parse(profile); + switch (reviewedRuntimeProfileProvider(reviewed)) { + case "codex": + return effectiveRuntimeProfileSchema.parse(reviewed); + case "claude": { + const publicProfile: Record = { ...reviewed }; + delete publicProfile.configHome; + delete publicProfile.isolatedConfigDir; + return publicEffectiveClaudeRuntimeProfileSchema.parse(publicProfile); + } + case "devin": { + const publicProfile: Record = { ...reviewed }; + delete publicProfile.isolatedHome; + return publicEffectiveDevinRuntimeProfileSchema.parse(publicProfile); + } + } +}; + /** The provider a reviewed profile belongs to, read from its exact preset. */ export const reviewedRuntimeProfileProvider = ( profile: ReviewedRuntimeProfile, diff --git a/src/install-normalizer.ts b/src/install-normalizer.ts index 9d2e208..22eb516 100644 --- a/src/install-normalizer.ts +++ b/src/install-normalizer.ts @@ -26,7 +26,7 @@ import { finished } from "node:stream/promises"; import { createGunzip } from "node:zlib"; export const HRA_INSTALL_BUN_VERSION = "1.3.14"; -export const HRA_INSTALL_CLI_SHA256 = "a0d3a473e5596267692d9338e340cda8002e25c1c8d36b3435da222dde3d9642"; +export const HRA_INSTALL_CLI_SHA256 = "8a01a01758ed2059540f70b4790aad4cf42219e86f447b8cc9bfdc4a6b1db3e8"; const expectedPackageName = "@hraness/hra"; const expectedPackageVersion = "0.6.0"; diff --git a/src/install-preflight-runtime.ts b/src/install-preflight-runtime.ts index 923007a..84ac080 100644 --- a/src/install-preflight-runtime.ts +++ b/src/install-preflight-runtime.ts @@ -29,8 +29,8 @@ export const HRA_INSTALL_BUN_VERSION = "1.3.14"; export const HRA_INSTALL_PACKAGE_NAME = "@hraness/hra"; const HRA_LEGACY_INSTALL_PACKAGE_NAME = "hra"; export const HRA_INSTALL_PACKAGE_VERSION = "0.6.0"; -export const HRA_INSTALL_CLI_SHA256 = "a0d3a473e5596267692d9338e340cda8002e25c1c8d36b3435da222dde3d9642"; -export const HRA_INSTALL_NORMALIZER_SHA256 = "2f1ff9527a565ead560cde6aceca05f44289414b8024c927203ffb994885e7d5"; +export const HRA_INSTALL_CLI_SHA256 = "8a01a01758ed2059540f70b4790aad4cf42219e86f447b8cc9bfdc4a6b1db3e8"; +export const HRA_INSTALL_NORMALIZER_SHA256 = "f7e02e2950f02090352d03d67c2240ecc06baed71ccb02765b819552dd37776b"; export const HRA_INSTALL_ARCHIVE_URL = "https://github.com/hraness/hra/releases/download/v0.6.0/hraness-hra-0.6.0.tgz"; export const HRA_INSTALL_ARCHIVE_NAME = "hraness-hra-0.6.0.tgz"; export const HRA_INSTALL_RELEASE_API_URL = "https://api.github.com/repos/hraness/hra/releases/tags/v0.6.0"; diff --git a/src/install-preflight.ts b/src/install-preflight.ts index 01bb204..ada672b 100644 --- a/src/install-preflight.ts +++ b/src/install-preflight.ts @@ -10,7 +10,7 @@ export { HRA_INSTALL_ARCHIVE_URL, HRA_INSTALL_BUN_VERSION }; export const HRA_INSTALL_PREFLIGHT_SOURCE_URL = "https://raw.githubusercontent.com/hraness/hra/v0.6.0/src/install-preflight-runtime.ts"; export const HRA_INSTALL_PREFLIGHT_SOURCE_SHA256 = - "61dcd716e2272c4c3d94d565ac1a9aab3dc99780813316a822f594938cf456e2"; + "1941cd63d43d06c29c0d1df8cff90c0a469cb14e3df524f5d537f6b49257eb05"; export const HRA_INSTALL_PREFLIGHT_SUCCESS = HRA_INSTALL_SUCCESS; export const HRA_INSTALL_PREFLIGHT_LOADER = [ "const[a,h]=process.argv.slice(1);", diff --git a/src/installation.test.ts b/src/installation.test.ts index 18b900c..992d66c 100644 --- a/src/installation.test.ts +++ b/src/installation.test.ts @@ -11,7 +11,7 @@ import { createAcceptanceInstallation, type AcceptanceInstallationDescriptor, } from "../scripts/live-acceptance-installation"; -import { resolveStatePaths } from "./storage/paths"; +import { personalProviderPaths, resolveStatePaths } from "./storage/paths"; const roots: string[] = []; const ACCEPTANCE_RUN_ID = "018f1f55-3f10-7c1a-8f7b-c6dc608bcd3b"; @@ -55,6 +55,7 @@ describe("HRA installation composition", () => { mcpOauth: "file", }); expect(installation.paths).toEqual(resolveStatePaths()); + expect(installation.personalProviderHomes).toEqual(personalProviderPaths()); }); test("uses only file-backed custody and an isolated process temp directory", async () => { @@ -96,6 +97,17 @@ describe("HRA installation composition", () => { cwd: descriptor.documentsDirectory, mcpOauth: "file", }); + expect(installation.personalProviderHomes).toEqual(personalProviderPaths( + join(installation.paths.root, "personal-home"), + )); + const personalEnvironment = await installation.codexEnvironment( + installation.personalProviderHomes.codexHome, + ); + expect(personalEnvironment?.TMPDIR).toBe(join( + installation.personalProviderHomes.codexHome, + "tmp", + )); + expect(personalEnvironment?.TMPDIR?.startsWith(`${installation.paths.root}/`)).toBe(true); }); test("refuses a changed Codex credential-store file on restart", async () => { diff --git a/src/installation.ts b/src/installation.ts index 53fd00f..ba2d449 100644 --- a/src/installation.ts +++ b/src/installation.ts @@ -1,7 +1,12 @@ import { homedir } from "node:os"; import { join, resolve } from "node:path"; -import { resolveStatePaths, type StatePaths } from "./storage/paths"; +import { + personalProviderPaths, + resolveStatePaths, + type PersonalProviderPaths, + type StatePaths, +} from "./storage/paths"; import { GenerationalSecretCustody } from "./storage/secret-custody"; type HraInstallationCommon = Readonly<{ @@ -14,6 +19,7 @@ type HraInstallationCommon = Readonly<{ }>; documentsDirectory: string; paths: StatePaths; + personalProviderHomes: PersonalProviderPaths; createSecretCustody(): GenerationalSecretCustody; prepareCodexHome(codexHome: string): Promise; }>; @@ -53,6 +59,7 @@ export function createProductionInstallation(): HraInstallation { expectedHomeDirectory: null, kind: "production", paths, + personalProviderHomes: personalProviderPaths(), prepareCodexHome: noOpPrepareCodexHome, }; } diff --git a/src/storage/paths.ts b/src/storage/paths.ts index d7c9a77..3a74205 100644 --- a/src/storage/paths.ts +++ b/src/storage/paths.ts @@ -17,6 +17,26 @@ export type StatePaths = { switchLock: string; }; +/** + * The provider homes owned by the current OS user rather than by an HRA + * profile. Merely resolving these paths grants no read or write authority; + * the session-adoption policy and the provider-specific custody check do that. + */ +export type PersonalProviderPaths = Readonly<{ + codexHome: string; + claudeConfigDir: string; +}>; + +export function personalProviderPaths( + homeDirectory: string = homedir(), +): PersonalProviderPaths { + const home = resolve(homeDirectory); + return { + claudeConfigDir: join(home, ".claude"), + codexHome: join(home, ".codex"), + }; +} + export function resolveStatePaths(input: { homeDirectory?: string; platform?: NodeJS.Platform; rootDirectory?: string } = {}): StatePaths { const home = resolve(input.homeDirectory ?? homedir()); const platform = input.platform ?? process.platform; diff --git a/src/storage/session-task-store.test.ts b/src/storage/session-task-store.test.ts index b50fac7..d7df547 100644 --- a/src/storage/session-task-store.test.ts +++ b/src/storage/session-task-store.test.ts @@ -5,6 +5,7 @@ import { createProfileId, createProjectId, createSessionId, + type ProfileId, type SessionId, } from "../domain/values"; import { @@ -20,6 +21,8 @@ import { } from "./session-task-store"; const databases: Database[] = []; +const codexAccountKey = `v1:codex:${"a".repeat(64)}`; +const claudeAccountKey = `v1:claude:${"b".repeat(64)}`; afterEach(() => { for (const database of databases.splice(0)) database.close(false); @@ -34,7 +37,9 @@ INSERT INTO daemon_state(singleton,generation) VALUES (1,7); CREATE TABLE profiles ( id TEXT PRIMARY KEY, state TEXT NOT NULL, - process_generation INTEGER NOT NULL + process_generation INTEGER NOT NULL, + provider_email TEXT, + codex_account_key TEXT DEFAULT '${codexAccountKey}' ) STRICT; CREATE TABLE projects ( id TEXT PRIMARY KEY, @@ -45,6 +50,56 @@ CREATE TABLE sessions ( profile_id TEXT NOT NULL REFERENCES profiles(id), project_id TEXT REFERENCES projects(id), provider_thread_id TEXT, + provider TEXT NOT NULL DEFAULT 'codex', + provider_v39 TEXT NOT NULL DEFAULT 'codex', + state TEXT NOT NULL +) STRICT; +CREATE TABLE session_account_authorities ( + session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, + profile_id TEXT NOT NULL REFERENCES profiles(id), + account_key TEXT, + recorded_at INTEGER NOT NULL +) STRICT; +CREATE TABLE provider_runtime_account_revocations ( + profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + profile_generation INTEGER NOT NULL, + provider TEXT NOT NULL, + runtime_scope TEXT NOT NULL, + current_account_key TEXT, + state TEXT NOT NULL, + PRIMARY KEY(profile_id,provider,runtime_scope) +) STRICT; +CREATE TABLE session_provider_account_authorities ( + session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + runtime_scope TEXT NOT NULL, + account_key TEXT NOT NULL, + recorded_at INTEGER NOT NULL +) STRICT; +CREATE TRIGGER session_account_authority_insert +AFTER INSERT ON sessions +BEGIN + INSERT INTO session_account_authorities(session_id,profile_id,account_key,recorded_at) + SELECT NEW.id,NEW.profile_id, + CASE WHEN p.provider_email IS NULL THEN NULL ELSE lower(trim(p.provider_email)) END, + 0 + FROM profiles p WHERE p.id=NEW.profile_id; +END; +CREATE TRIGGER session_provider_account_authority_insert +AFTER INSERT ON sessions +WHEN NEW.provider_v39='codex' +BEGIN + INSERT INTO session_provider_account_authorities( + session_id,provider,runtime_scope,account_key,recorded_at + ) + SELECT NEW.id,NEW.provider_v39,'managed',p.codex_account_key,0 + FROM profiles p + WHERE p.id=NEW.profile_id AND p.codex_account_key IS NOT NULL; +END; +CREATE TABLE session_personal_runtime_bindings ( + session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_thread_id TEXT NOT NULL, state TEXT NOT NULL ) STRICT; CREATE TABLE queue_sequence_authority ( @@ -68,6 +123,7 @@ const idempotencyKey = (): string => `123e4567-e89b-42d3-a456-${(++uuidSequence).toString(16).padStart(12, "0")}`; type Fixture = Readonly<{ + accountId: ProfileId; database: Database; now: { value: number }; otherSessionId: SessionId; @@ -89,7 +145,8 @@ function fixture(input: Readonly<{ const sessionId = createSessionId(); const otherSessionId = createSessionId(); database.query( - "INSERT INTO profiles(id,state,process_generation) VALUES (?,'signed_in',1)", + `INSERT INTO profiles(id,state,process_generation,provider_email) + VALUES (?,'signed_in',1,'scheduler@example.com')`, ).run(accountId); database.query("INSERT INTO projects(id,root_path) VALUES (?,?)").run(projectId, "/project"); for (const id of [sessionId, otherSessionId]) { @@ -103,7 +160,7 @@ function fixture(input: Readonly<{ now: () => now.value, resolveProjectDirectory: input.resolveProjectDirectory ?? (async (root) => root), }); - return { database, now, otherSessionId, sessionId, store }; + return { accountId, database, now, otherSessionId, sessionId, store }; } const createTask = ( @@ -123,6 +180,31 @@ const createTask = ( idempotencyKey: input.idempotencyKey ?? idempotencyKey(), }); +const adoptPersonalClaudeSession = ( + value: Fixture, + bindingState: "active" | "detaching" | "detached" = "active", +): void => { + value.database.query( + "UPDATE profiles SET state='signed_out',provider_email=NULL,codex_account_key=NULL WHERE id=?", + ).run(value.accountId); + value.database.query("UPDATE sessions SET provider='claude',provider_v39='claude' WHERE id=?") + .run(value.sessionId); + value.database.query( + "DELETE FROM session_provider_account_authorities WHERE session_id=?", + ).run(value.sessionId); + value.database.query( + `INSERT INTO session_provider_account_authorities( + session_id,provider,runtime_scope,account_key,recorded_at + ) VALUES (?,'claude','personal',?,0)`, + ).run(value.sessionId, claudeAccountKey); + value.database.query( + `INSERT INTO session_personal_runtime_bindings( + session_id,provider,provider_thread_id,state + ) SELECT id,provider,provider_thread_id,? + FROM sessions WHERE id=?`, + ).run(bindingState, value.sessionId); +}; + const expectStoreCode = ( callback: () => unknown, code: SessionTaskStoreErrorCode, @@ -607,6 +689,160 @@ describe("SessionTaskStore mutation authority", () => { }); describe("SessionTaskStore due materialization", () => { + test("materializes a managed Devin task once while its durable profile is signed out", async () => { + const value = fixture(); + value.database.query( + "UPDATE profiles SET state='signed_out',provider_email=NULL,codex_account_key=NULL WHERE id=?", + ).run(value.accountId); + value.database.query( + "UPDATE sessions SET provider='codex',provider_v39='devin' WHERE id=?", + ).run(value.sessionId); + value.database.query( + "DELETE FROM session_provider_account_authorities WHERE session_id=?", + ).run(value.sessionId); + const created = createTask(value, { + name: "Managed Devin follow-up", + prompt: "Continue the native Devin conversation.", + }); + const dueAt = created.nextDueAt ?? 0; + + expect(value.store.nextDueAt()).toBe(dueAt); + expect(await value.store.materializeDue({ now: dueAt })).toMatchObject([{ + task: { id: created.id, sessionId: value.sessionId }, + occurrence: { taskId: created.id, sessionId: value.sessionId }, + queue: { + message: "Continue the native Devin conversation.", + sessionId: value.sessionId, + state: "pending", + }, + }]); + expect(await value.store.materializeDue({ now: dueAt })).toEqual([]); + expect(value.store.listOccurrences(value.sessionId, created.id)).toHaveLength(1); + }); + + test("materializes an adopted personal Claude task while its profile is signed out", async () => { + const value = fixture(); + adoptPersonalClaudeSession(value); + const created = createTask(value, { + name: "Personal Claude follow-up", + prompt: "Continue the adopted Claude conversation.", + }); + const dueAt = created.nextDueAt ?? 0; + + expect(value.store.nextDueAt()).toBe(dueAt); + expect(await value.store.materializeDue({ now: dueAt })).toMatchObject([{ + task: { id: created.id, sessionId: value.sessionId }, + occurrence: { taskId: created.id, sessionId: value.sessionId }, + queue: { + message: "Continue the adopted Claude conversation.", + sessionId: value.sessionId, + state: "pending", + }, + }]); + expect(await value.store.materializeDue({ now: dueAt })).toEqual([]); + expect(value.database.query( + `SELECT q.state,s.provider,pa.runtime_scope,b.state AS binding_state + FROM queue_entries q + JOIN sessions s ON s.id=q.session_id + JOIN session_provider_account_authorities pa ON pa.session_id=s.id + JOIN session_personal_runtime_bindings b ON b.session_id=s.id + WHERE q.session_id=?`, + ).all(value.sessionId)).toEqual([{ + state: "pending", + provider: "claude", + runtime_scope: "personal", + binding_state: "active", + }]); + }); + + test("withholds detached, releasing, or mismatched personal Claude task authority", async () => { + for (const authorityLoss of ["detached", "releasing", "mismatched"] as const) { + const value = fixture(); + adoptPersonalClaudeSession(value); + const created = createTask(value, { + name: `Personal Claude ${authorityLoss}`, + }); + const dueAt = created.nextDueAt ?? 0; + if (authorityLoss === "detached") { + value.database.query( + "UPDATE session_personal_runtime_bindings SET state='detached' WHERE session_id=?", + ).run(value.sessionId); + } else { + value.database.query( + `INSERT INTO provider_runtime_account_revocations( + profile_id,profile_generation,provider,runtime_scope,current_account_key,state + ) VALUES (?,1,'claude','personal',?,?)`, + ).run( + value.accountId, + authorityLoss === "mismatched" + ? `v1:claude:${"c".repeat(64)}` + : claudeAccountKey, + authorityLoss === "mismatched" ? "completed" : "releasing", + ); + } + + expect(value.store.nextDueAt()).toBeNull(); + expect(await value.store.materializeDue({ now: dueAt })).toEqual([]); + expect(value.store.listOccurrences(value.sessionId, created.id)).toEqual([]); + expect(value.database.query( + "SELECT COUNT(*) AS count FROM queue_entries WHERE session_id=?", + ).get(value.sessionId)).toEqual({ count: 0 }); + } + }); + + test("rechecks personal Claude binding authority after project validation", async () => { + const fixtureReference: { value?: Fixture } = {}; + const current = fixture({ + resolveProjectDirectory: async (root) => { + const value = fixtureReference.value; + if (value === undefined) throw new Error("Missing task fixture."); + value.database.query( + "UPDATE session_personal_runtime_bindings SET state='detached' WHERE session_id=?", + ).run(value.sessionId); + return root; + }, + }); + fixtureReference.value = current; + adoptPersonalClaudeSession(current); + const created = createTask(current); + const dueAt = created.nextDueAt ?? 0; + expect(current.store.nextDueAt()).toBe(dueAt); + + expect(await current.store.materializeDue({ now: dueAt })).toEqual([]); + expect(current.store.listOccurrences(current.sessionId, created.id)).toEqual([]); + expect(current.database.query( + "SELECT COUNT(*) AS count FROM queue_entries WHERE session_id=?", + ).get(current.sessionId)).toEqual({ count: 0 }); + }); + + test("does not advertise or retry a native task under stale account identity", async () => { + const value = fixture(); + const created = createTask(value); + const dueAt = created.nextDueAt ?? 0; + expect(value.store.nextDueAt()).toBe(dueAt); + + value.database.query( + "UPDATE profiles SET provider_email='replacement@example.com' WHERE id=?", + ).run(value.accountId); + expect(value.store.nextDueAt()).toBeNull(); + expect(await value.store.materializeDue({ now: dueAt })).toEqual([]); + expect(await value.store.materializeDue({ now: dueAt })).toEqual([]); + expect(value.database.query( + "SELECT COUNT(*) AS count FROM session_task_occurrences", + ).get()).toEqual({ count: 0 }); + expect(value.database.query("SELECT COUNT(*) AS count FROM queue_entries").get()) + .toEqual({ count: 0 }); + + value.database.query( + "UPDATE profiles SET provider_email='scheduler@example.com' WHERE id=?", + ).run(value.accountId); + expect(await value.store.materializeDue({ now: dueAt })).toMatchObject([{ + task: { id: created.id, sessionId: value.sessionId }, + occurrence: { taskId: created.id, sessionId: value.sessionId }, + queue: { sessionId: value.sessionId }, + }]); + }); + test("returns after one atomic handoff before resolving the next candidate", async () => { let rejectSecond = true; const resolvedRoots: string[] = []; @@ -669,7 +905,8 @@ describe("SessionTaskStore due materialization", () => { const invalidProjectId = createProjectId(); const validProjectId = createProjectId(); value.database.query( - "INSERT INTO profiles(id,state,process_generation) VALUES (?,'signed_in',1)", + `INSERT INTO profiles(id,state,process_generation,provider_email) + VALUES (?,'signed_in',1,'other-scheduler@example.com')`, ).run(accountId); value.database.query("INSERT INTO projects(id,root_path) VALUES (?,?)") .run(invalidProjectId, "/invalid"); diff --git a/src/storage/session-task-store.ts b/src/storage/session-task-store.ts index 7b52be3..e7c92ff 100644 --- a/src/storage/session-task-store.ts +++ b/src/storage/session-task-store.ts @@ -415,12 +415,61 @@ const receiptRowSchema = z.object({ type ReceiptOperation = z.infer["operation"]; type ReceiptRow = z.infer; +// Keep the preliminary wake-up query, bounded candidate scan, and transactional +// recheck on one provider-scoped authority definition. Provider additions must +// extend the explicit final branch; an unknown provider never inherits Codex. +const currentSessionTaskAuthorityJoins = ` +JOIN profiles a ON a.id=s.profile_id +LEFT JOIN session_provider_account_authorities pa + ON pa.session_id=s.id AND pa.provider=s.provider_v39`; + +const currentSessionTaskAuthorityPredicate = ` +AND NOT EXISTS( + SELECT 1 FROM provider_runtime_account_revocations r + WHERE r.profile_id=s.profile_id + AND r.profile_generation=a.process_generation + AND r.provider=s.provider_v39 + AND r.runtime_scope=pa.runtime_scope + AND (r.state='releasing' OR r.current_account_key IS NULL + OR r.current_account_key!=pa.account_key) +) +AND ( + (pa.runtime_scope='personal' AND EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id + AND b.provider=s.provider_v39 + AND b.provider_thread_id=s.provider_thread_id + AND b.state='active' + )) + OR (pa.runtime_scope='managed' AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.state IN ('active','detaching') + )) + OR (s.provider_v39='devin' AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.state IN ('active','detaching') + )) +) +AND ( + (s.provider_v39='claude' AND a.state IN ('signed_in','signed_out')) + OR (s.provider_v39='codex' AND a.state='signed_in' + AND a.provider_email IS NOT NULL + AND a.codex_account_key=pa.account_key + AND EXISTS( + SELECT 1 FROM session_account_authorities legacy + WHERE legacy.session_id=s.id + AND legacy.profile_id=s.profile_id + AND legacy.account_key IS NOT NULL + AND legacy.account_key=lower(trim(a.provider_email)) + )) + OR (s.provider_v39='devin' AND a.state IN ('signed_in','signed_out')) +)`; + const dueCandidateRowSchema = taskRowSchema.extend({ project_root: z.string().min(1), }); const eligibleDueTaskRowSchema = dueCandidateRowSchema.extend({ - profile_state: z.literal("signed_in"), session_state: z.enum(["starting", "active", "idle"]), provider_thread_id: z.string().min(1), }); @@ -1145,13 +1194,13 @@ export class SessionTaskStore { `SELECT MIN(t.next_due_at) AS next_due_at FROM session_tasks t JOIN sessions s ON s.id=t.session_id - JOIN profiles a ON a.id=s.profile_id + ${currentSessionTaskAuthorityJoins} JOIN projects p ON p.id=s.project_id WHERE t.deleted_at IS NULL AND t.status='active' AND s.provider_thread_id IS NOT NULL AND s.state NOT IN ('terminal','recovery_required') - AND a.state='signed_in' + ${currentSessionTaskAuthorityPredicate} AND NOT EXISTS( SELECT 1 FROM session_task_occurrences o @@ -1199,14 +1248,14 @@ export class SessionTaskStore { t.revision,t.next_due_at,t.created_at,t.updated_at,t.deleted_at,p.root_path AS project_root FROM session_tasks t JOIN sessions s ON s.id=t.session_id - JOIN profiles a ON a.id=s.profile_id + ${currentSessionTaskAuthorityJoins} JOIN projects p ON p.id=s.project_id WHERE t.deleted_at IS NULL AND t.status='active' AND t.next_due_at<=? AND s.provider_thread_id IS NOT NULL AND s.state NOT IN ('terminal','recovery_required') - AND a.state='signed_in' + ${currentSessionTaskAuthorityPredicate} AND ( ? IS NULL OR t.next_due_at>? @@ -1266,11 +1315,11 @@ export class SessionTaskStore { `SELECT t.id,t.session_id,t.name,t.prompt,t.schedule_kind,t.interval_minutes,t.status, t.revision,t.next_due_at,t.created_at,t.updated_at,t.deleted_at, - p.root_path AS project_root,a.state AS profile_state,s.state AS session_state, + p.root_path AS project_root,s.state AS session_state, s.provider_thread_id FROM session_tasks t JOIN sessions s ON s.id=t.session_id - JOIN profiles a ON a.id=s.profile_id + ${currentSessionTaskAuthorityJoins} JOIN projects p ON p.id=s.project_id WHERE t.id=? AND t.session_id=? @@ -1279,7 +1328,7 @@ export class SessionTaskStore { AND t.next_due_at<=? AND s.provider_thread_id IS NOT NULL AND s.state NOT IN ('terminal','recovery_required') - AND a.state='signed_in' + ${currentSessionTaskAuthorityPredicate} AND NOT EXISTS( SELECT 1 FROM session_task_occurrences o diff --git a/src/storage/state-store.test.ts b/src/storage/state-store.test.ts index 8ff7917..7d6e751 100644 --- a/src/storage/state-store.test.ts +++ b/src/storage/state-store.test.ts @@ -1,9 +1,10 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { renameSync, symlinkSync } from "node:fs"; +import { existsSync, renameSync, symlinkSync } from "node:fs"; import { chmod, lstat, mkdtemp, mkdir, realpath, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { gunzipSync } from "node:zlib"; import { Database, constants as sqliteConstants } from "bun:sqlite"; import { z } from "zod"; @@ -32,9 +33,9 @@ import { import { utf8Bytes } from "../domain/values"; import { canTransitionQueue, queueStateSchema, type QueueState } from "../domain/transitions"; import { projectPublicProviderIdentifier } from "../public-provider-identifier"; +import { presetRequirements } from "../domain/presets"; import { effectiveClaudeRuntimeProfileSchema, - effectiveDevinRuntimeProfileSchema, effectiveRuntimeProfileSchema, } from "../domain/runtime-profile"; import { initializeProfilePaths, initializeStatePaths, resolveStatePaths } from "./paths"; @@ -58,6 +59,113 @@ const privateUserPathRoot = ["", "Users", "private"].join("/"); const publicProviderIdentifierKey = new Uint8Array(32).fill(19); const publicProviderIdentifier = (value: string) => projectPublicProviderIdentifier(value, publicProviderIdentifierKey); +const testProviderAccountKey = (provider: "codex" | "claude"): string => + `v1:${provider}:${createHash("sha256").update(`test-${provider}-account`).digest("hex")}`; +const namedProviderAccountKey = ( + provider: "codex" | "claude", + name: string, +): string => `v1:${provider}:${createHash("sha256").update(name).digest("hex")}`; +const providerAccountKeyForProfile = ( + store: StateStore, + profileId: string, + provider: "codex" | "claude", +): string => { + if (provider === "claude") return testProviderAccountKey("claude"); + const email = store.requireProfileById(profileId).providerEmail; + if (email === undefined) throw new Error("Expected identifiable Codex profile authority."); + return namedProviderAccountKey("codex", email.trim().toLowerCase()); +}; + +type ProviderSessionInput = Parameters[0]; +const upsertProvenTestSession = ( + store: StateStore, + input: Omit & + Partial>, +) => { + const provider = input.provider ?? "codex"; + return store.upsertProviderSession({ + ...input, + provider, + title: input.title ?? "Untitled session", + ...(provider === "devin" + ? (input.providerAccountKey === undefined + ? {} + : { providerAccountKey: input.providerAccountKey }) + : { + providerAccountKey: input.providerAccountKey + ?? providerAccountKeyForProfile(store, input.profileId, provider), + }), + }); +}; + +let provenTestSessionSequence = 0; +type CreateProvenTestSessionInput = + & Omit + & Partial>; +const createProvenTestSession = ( + store: StateStore, + input: CreateProvenTestSessionInput, +) => { + const { providerThreadId, state, ...rest } = input; + provenTestSessionSequence += 1; + return upsertProvenTestSession(store, { + ...rest, + providerThreadId: providerThreadId ?? `test-provider-thread-${provenTestSessionSequence}`, + state: state ?? "idle", + }); +}; + +const createRevocationWorkStore = (store: StateStore, generation = 1) => + store.createWorkStore( + generation, + () => "unused-revocation-cursor", + { + issue: () => `hrac1_${"A".repeat(43)}`, + verify: () => true, + }, + ); + +const completeCodexRuntimeAccountAuthorityRetirement = ( + store: StateStore, + profileId: string, + profileGeneration: number, + runtimeScope: "personal" | "managed", +): void => { + const workStore = createRevocationWorkStore(store); + const begun = store.beginProviderRuntimeAccountRevocation({ + profileId, + expectedGeneration: profileGeneration, + provider: "codex", + runtimeScope, + currentAccountKey: null, + workStore, + }); + store.completeProviderRuntimeAccountRevocation({ + profileId, + expectedGeneration: profileGeneration, + provider: "codex", + runtimeScope, + expectedRevision: begun.revocation.revision, + }); +}; + +const completeCodexAccountMutationAuthorityRetirement = ( + store: StateStore, + profileId: string, + profileGeneration: number, +): void => { + for (const runtimeScope of ["personal", "managed"] as const) { + completeCodexRuntimeAccountAuthorityRetirement( + store, + profileId, + profileGeneration, + runtimeScope, + ); + } +}; afterEach(() => { for (const store of stores.splice(0)) store.close(); @@ -65,6 +173,7 @@ afterEach(() => { async function fixture( options: Readonly<{ + now?: () => number; resolveMachineTimeZone?: MachineTimeZoneResolver; securityScrubCheckpoint?: SecurityScrubCheckpointPolicy; }> = {}, @@ -81,10 +190,427 @@ async function fixture( return { store, home }; } -const dropProviderAuthorityObjectsForLegacyFeatureFixture = (database: Database): void => { +const dropProviderSwitchProgressSchema = (database: Database): void => { + database.exec(` + DROP TRIGGER IF EXISTS session_mutation_authority_rebinds_v39_immutable_delete; + DROP TRIGGER IF EXISTS session_mutation_authority_rebinds_v39_immutable_update; + DROP TABLE IF EXISTS session_mutation_authority_rebinds_v39; + DROP TABLE IF EXISTS session_provider_switch_source_releases; + DROP TABLE IF EXISTS session_provider_switch_seed_results; + DROP TABLE IF EXISTS session_provider_switch_seed_intents; + DROP TABLE IF EXISTS session_provider_switch_target_releases; + DROP TABLE IF EXISTS session_provider_switch_targets; + DROP TABLE IF EXISTS session_mutation_authority_rebinds; + `); +}; + +const dropProviderV39SessionColumn = (database: Database): readonly Readonly<{ + name: string; + sql: string; +}>[] => { + if (database.query( + "SELECT 1 FROM pragma_table_info('sessions') WHERE name='provider_v39'", + ).get() === null) return []; + const triggers = z.object({ + name: z.string().regex(/^[a-z0-9_]+$/u), + sql: z.string().min(1), + }).strict().array().parse(database.query( + `SELECT name,sql FROM sqlite_master + WHERE type='trigger' AND sql LIKE '%provider_v39%' + ORDER BY name`, + ).all()); + for (const trigger of triggers) { + database.exec(`DROP TRIGGER "${trigger.name}"`); + } + database.exec("ALTER TABLE sessions DROP COLUMN provider_v39"); + return triggers; +}; + +const downgradeProviderV39SessionColumn = (database: Database): void => { + const triggers = dropProviderV39SessionColumn(database); + if (triggers.length === 0) return; + database.exec(` + DROP TRIGGER IF EXISTS session_mutation_authority_rebinds_v39_immutable_delete; + DROP TRIGGER IF EXISTS session_mutation_authority_rebinds_v39_immutable_update; + DROP TABLE IF EXISTS session_mutation_authority_rebinds_v39; + `); + for (const trigger of triggers) { + if (trigger.name.includes("devin")) continue; + database.exec(trigger.sql.replace(/\bprovider_v39\b/gu, "provider")); + } +}; + +const sessionAdoptionTableNames = [ + "session_claude_process_authorities", + "session_claude_process_launch_intents", + "session_personal_runtime_bindings", + "session_adoption_candidates", + "session_adoption_policies", + "session_adoption_profile_generation_permits", + "profile_personal_authority_revocations", + "provider_runtime_account_revocations", + "session_provider_account_authorities", + "session_account_authorities", +] as const; + +const dropSessionAdoptionSchema = (database: Database): void => { + const objects = z.object({ + name: z.string().regex(/^[a-z0-9_]+$/u), + type: z.enum(["index", "trigger"]), + }).strict().array().parse(database.query(` + SELECT name,type FROM sqlite_master + WHERE name NOT LIKE 'sqlite_%' AND ( + (type='index' AND ( + tbl_name IN (${sessionAdoptionTableNames.map(() => "?").join(",")}) + OR name='sessions_profile_created' + )) + OR (type='trigger' AND ( + sql LIKE '%session_account_authorit%' + OR sql LIKE '%provider_runtime_account_revocation%' + OR sql LIKE '%session_adoption_%' + OR sql LIKE '%session_personal_runtime_%' + OR sql LIKE '%session_claude_process_%' + OR sql LIKE '%profile_personal_authority_%' + OR sql LIKE '%codex_account_key%' + )) + ) + ORDER BY CASE type WHEN 'trigger' THEN 0 ELSE 1 END,name + `).all(...sessionAdoptionTableNames)); + for (const object of objects) { + database.exec(`DROP ${object.type.toUpperCase()} IF EXISTS "${object.name}"`); + } + for (const table of sessionAdoptionTableNames) { + database.exec(`DROP TABLE IF EXISTS "${table}"`); + } +}; + +const dropSessionAdoptionAndPresetContractSchema = (database: Database): void => { + dropSessionAdoptionSchema(database); + downgradeProviderV39SessionColumn(database); + const presetTriggers = z.object({ + name: z.string().regex(/^[a-z0-9_]+$/u), + }).strict().array().parse(database.query( + `SELECT name FROM sqlite_master + WHERE type='trigger' AND sql LIKE '%preset_contract%' + ORDER BY name`, + ).all()); + for (const trigger of presetTriggers) { + database.exec(`DROP TRIGGER IF EXISTS "${trigger.name}"`); + } + database.exec("ALTER TABLE profiles DROP COLUMN codex_account_key"); + database.exec("ALTER TABLE sessions DROP COLUMN preset_contract"); + database.exec("ALTER TABLE works DROP COLUMN preset_contract"); +}; + +const dropPostProviderSwitchSchema = (database: Database): void => { + dropSessionAdoptionAndPresetContractSchema(database); + database.exec(` + DROP TABLE IF EXISTS attention_email_policy; + DROP TABLE IF EXISTS notification_hours; + `); +}; + +const dropSchemaAfterVersion34 = (database: Database): void => { + dropPostProviderSwitchSchema(database); + dropProviderSwitchProgressSchema(database); +}; + +const providerVersion39WorkSignalMemberGuardSql = ` +CREATE TRIGGER work_signal_member_guard +BEFORE INSERT ON work_signals +WHEN NOT EXISTS (SELECT 1 FROM work_members WHERE work_id=NEW.work_id AND session_id=NEW.from_session_id) + OR NOT EXISTS (SELECT 1 FROM work_members WHERE work_id=NEW.work_id AND session_id=NEW.to_session_id) + OR NOT EXISTS ( + SELECT 1 FROM sessions AS s JOIN profiles AS p ON p.id=s.profile_id + WHERE s.id=NEW.to_session_id AND s.state IN ('active','idle') + AND p.state!='removed' AND (s.provider_v39 IN ('claude','devin') OR p.state='signed_in') + AND p.process_generation=NEW.target_account_generation + ) +BEGIN SELECT RAISE(ABORT,'WORK_SIGNAL_MEMBER_INVALID'); END; +`; + +const downgradeToExactProviderVersion39 = (database: Database): void => { + dropSessionAdoptionSchema(database); + database.exec(` + DROP TRIGGER IF EXISTS work_profile_attempt_identity_guard; + DROP TRIGGER IF EXISTS work_signal_member_guard; + ${providerVersion39WorkSignalMemberGuardSql} + ALTER TABLE profiles DROP COLUMN codex_account_key; + DELETE FROM migrations WHERE version>39; + PRAGMA user_version=39; + `); +}; + +// Exact sqlite_master SQL emitted by StateStore at 42c4235e35daed1dec7eca4ff985bd9e8606b78a. +// Keeping the generated predecessor surface compressed makes this bridge test +// independent of the current schema builders that it is intended to challenge. +const historicalAdoptionVersion35SchemaSha256 = + "b9ee8e59273df62581aad7ec065e518034bf3700f35e1cbea542bdf806209e0f"; +const historicalAdoptionVersion35SchemaGzipBase64 = + "H4sIAAAAAAAAE+0d23IaR/ZdX9F+QmywSkq8yToJrkLS2GYtQRbw2qnULjViWtKsYYbMDHL891t9v/f0DOAoNnmIEX07ffrcz+nmYhINZhGYDc6vIrAu8tt0CedrWJR5Fi/n8aa6z4u0+jQv4EO+iKs0z0pwfAR41zQBs+j9DPwyGV4PJr+CN9GvYBK9jCbR6CKasm7lcZp0wXgELqOraBaBi8H0YnAZ9aSJ7mAGC7wAGI5m0atoAkbjGRi9vboCF6+jizfHlp7n0exdFI3AKRiMLsHz09Mfzp4///bvz354dvr8+VkXzV9WcQUJjNp8pGU4AsedAi5hXKbZXafXWeSr9RJWMOl08QQFfEhLD1i8nQFz5gZmUcC4gsk8rlyzST1e9MEpHrVZJzWjpB4v+tIqZFG2IXkCuprSNCVTjifqEHNGMpggsC8jrG/OSAHtdo+6YDqbDC9mPx1daET3kCawmBebrEpXcB4vFvkmq2pJjuPgUdEb246V5HgjprpFnsA/EMUt400CGblRLJSLfG2nW7UHnmkVZ/EdTDq9DmNdOttiUxQwqzhOP8BPZE4y1REA9j6UFnD7eAI44H0KNN7/EmZ31f2xZXy3/8N3eCzAHcvNTVkVto69s97zbr/zcPYjnvfHTuCo0y7Gyqur8Tno/O23/54+fR4/vf3P3zpdG8gEv/UwPwuF+eyUAo1nDob6zAf1QVo9JmmFBko6jQuDNOkxwuopjOiUbyUskXrglMB0agqJWGPtNZqUdguQbKHyEY1xyAWLKECIpsxzMZjOlC6DKTi/Gp93u5oO/O5bosAKuMiLxKvB5C5Y8dWiM8nXSCbPF3GWpEj9cS2xlfzl6qi6L2CcmNgkM1FUmL11HHx7SnCwLvL/wUXF5kNfVWm1tIt4GdGkVx2KOSBue0frglEQL6r0AXZ6nTRZon8qWKxSoT5I87zaFII+GYUoLSaRqB1MrDz7hwq3ZMJMooEBs2wDibVszdxsWqYPMINlaccwa8RoQH91ep0sr+b04yb7kOUfM4qHMt8UCzhfo50qIkluEGDJX7+gxoL01c9903TQlpkn+SpOMxnjZqO0oqUR7SuJi49p1ul1lmm2+UPbTJEvECkUlW0V0ejgf7Oji0TPviVHvVjG6QoT38Z+JkoHDP8aZgm1ylEb+Yg5Hxs7tzBbcDudy4H5bZrdwWJdpFnl4y7rgG7/+2cYavt0RHX/JhQ3WpmAYS5LVrO0yqRCgTF7cUgsExhg7NxTuU2LsponabnIH2DhFd22roIJ47Ka5zclLB68kxj9XvRtMIg5F/dxdlc/pdSN7doGLzYNNRDEWnFVwdUa/auxv9Eq8YrW5NmPZmcQ48KiWYQxg+1NhV/6jBlcJCNbN9xGlmd4EjQFHW7AYhGEqtTThZbcaggbASG1rA1jXra41aXpJq0dZCDc/VRw5H5468GWyTpfpovUapfIRl4T00Qx8Dx2XVDUA2bxzRIL0iQtyccvJOShUCax+9lmMaQSJq18QcdwtNgGMU4IJwcjnoACbau02nFE7bbIVwHhDb2bN7ZBTqXKA+ZVOvW1Vb4hRGHpWUdLdWgmnIKZF5al4WXt0axv7HTtOwS12xCS7p/W+6TTiCyG9yqZzPrmJOvYZRbTKRRz2Jym3ubVjV2Pv9XAog2SsljHYsTe5JsM/StHm8nnvUjeNl43GUdAMs0dpUWYOvLXL/ryupb4i9hw35hPib5IiHzSp7jDro6gR3mMbkgpJN6zhG8k3m4oW5bxJlvcz9OsghkV3uSzKQXejob/ehupVMb7dvvffb/vmMNBODGpu0vWKiskKz2MJTq0Nmn4FHslbZ7qY5PepNjl3n1s8lFG5YIkOA+SJbCKF/dEcJPPexHcNMDhoRSpR2v6EnPg4Ww/ptRXWoTUl782pyOCr9adfTmeRMNXo1rXF1vmJsXZosCNXGiqlOgJ4yOxbNd0DDghNBzCfAl9hOSB1LMs29bnyCg8olymkavQ5iIn6stU1mYom2Umm2UkDfDMrGRtNrJpFrJh9nE3eZrh6DJ6H5TWn3NL+AggCgwZI4ULegFJOQDevY4mEVCsT8S4GrgegcLMvwKui/wGElB9AkgPJDoEGgHMHtmi8QZr7kMOSOFIdUjYnFBhTbgsLFhWGyprgFoKaT1O+fkageLLaHrhxbAaJmW44UBSE12F1R1NwAmieZrArEqrTyrg7lGSl9pbE3KliNNI9InkITnw6IGNdgmGSmiKHl5dAsbqZYVBpPpHLO7lBUodomXbtRBIj1vGtmNvdK4apCHo0yAVeGqJO6cBPidGiQqMs7dgECtEipljh4Qf1JxKWGVl5UykiC3mPhnts8nwFdIb3MLhIItCCJwZxjDN7zZxkRydR8gQBG9/uURTjF8SgHtaklmC5ujd62gEjkfRuxPNhBtPAPrWkp9mNhYWYNH74XQ2RTbDNLqKLmbgDLycjK8D/KIbjtWbE4HrPlo0TbiAvCFwPeGASS1c5KMx7A9Lu6Bqpaf4+qh7dB69Go7YHiaD4TQ6HpyPJ7Me4BYnYHthm0O5zSyvwA1c5CtIk/yd7k8gGl02OEfEAgUanGfqMQ5H02gyUxS61JeeHT46K6Ps4Hz+OR4KWgElppyTNOnLJ+Y+RqVPg+MsAw+z3P1RxosFXFfCYJfx3fxkf9/ADXSdKWmEWVWkkJ7l4bDaHBZGZAI+5sWH5kdUxeWHeZqVsKhcByV3lZmOGkKyz3s4wIYHWKUPKDxUom6b5XanSMx7nybcEAl2OM/HcZ7cTsqzqsiXSxSKkS5K4OqOT17LhupGnBVUORP7qHSGAv6+SQsWMhpfXXIlZHY5AhojuxSnJ08a42FEKcYnwt7ro6WpMiTLiFYp1Yt6sWnF18oow8XBrW9Hw/EIDKh7GQS2loJJJchTL+RpI8iRBY1GIdPEjdrAazOFBGThBbJohd7ixAhy4C14OENQMBCQgtWmRMYhzUCAG3ibF8hMxLEYwGivjju82JizeLidTUaBOBW8w+Z7gvHE/vrmjPkDIobyM+og/vShh8sLvriMJh7ST0uQZg/xMk08OKkLbIVgpDY4tm98MPOSgiDRRBusuCrjPz0OF1FlertYsoXgY+aAMAkP1vj8kAMii1XugMSmH6lJWeEBsnwuDhCyjZXpXQaTeZoJtyQ+0Wv4Ze9qLXQoXMXp0hrjU6boL/OPsDiuinR1rI9Gd7t8ZMO0LaMWwU1JDkuANPAqrhb3oLqHgOzlaZpJzgwNtjWnI2IgHw1ezqKJaRmXBOgjwJqGo9nYd7RyvEwKh8hRdrn4QVANOdSeeoYo4g5QuiVCdDCyHwo6sRlmavQpuppGwHsSCEFkYrSYCNSgXJukqxBN0vgzIksVsJ+OmmJ5j4GArbnQEgaQGY6Es2VO03h0O1bWFjqwbB0xNY09HOjj6xLpBUSRPirShTUgqUbDBlClG7GIJN5lSoBO5hEqWADNpLU0wUkEr4ziY4q9PUh5avlL/wULeJzsVdK9uINkBJqpDGqYNNUNu41THZj962L2fcXHDnT0ddGRQkJoX4Z37Zf6WBzqtoYzWFUCMoYLUSaBfVanKqHJbSP5BpTaruswEQUinq3h+qH+0tfygPGMLwL0fpabUu5ljD4GutzxPW9RGwlMyij12Nm8Lyl5Yf+S/7eiThTJWK02FboLBD6m1X2agTwT0Q96DrVkaZS18DKSOZ/fFHCecsae7Sqjv5AmiD/pMHEHlrOegosWG64Lcvkgr41toYIFEtrRi4RwjEv/UusuboqK3uK7lnhrEw0zsUarrTi1YOSx4IbTnnLjkHI84UrMPcbtTS791WbtFqe1l3GbkwbSuTCTixnMsjcLPLpGcsPk6+m61hmqtczTJTOrzLHjQyaayyMVBB6kj6zUzNh6baHdgUIeG4XgW8SfmqkKqUDLdtzsYnKjTRE4ttQEdDM8vdVMkHG49ZSpcrnXZ9wL71gU4Jr+sStUV28zd9uglCZxSxBnFL23KbY1FBu4kYlhxbNLlpBMijWCcsD9Nrin+TqR49sLvXtovf5hvcIT4ZaT1EfWzLWFbYqa8sbiRKnQ74tbHp4ktictvrYlxbvbZjFxCpy9DsYz4Ixv0xKwE9gZUXwG5jwQyl+HUBo7SiYJ7KUEwK3D2js6dMubrIRVtYQJKvZJV/68fyOGEEUABBe0BougwXwLRI2tyDVAksekvRCieBzmSyG8WZ3b6rP4IkyNSo2CJbunLsno11QUq6Pswt147seJ4VocB2HZhufW2rMxppvgui22ga6L6fxCF8u9XBrZ6Gg/PjnExriFoDUgWGp9yHGhEOXCvg1y+e1Evd7FL7zZCUser17B9FNDkzJEAXhah9luUyjtlN9+A/YoK4uxei01Ws1JUS4YdBd7st8E2fu+NHp31avKPeS8siWE70THTjbtvwQjCab6qzAygm6CxAa9EuN8IqGr9S+b4Mprl3FDgRd0P71HF5kwIRKLrIAVCgmit7W4pUIFeLhZYr7X5TBJTEFsL/A2+/FiBH08k6F/wYJuGuttDPlnrOnmjBiioOq5rAGPsYygsZ8GPCXucsu5O2EzcRM32Nhp8lYd+VcTGeRL5ykxKGg37VE4P6FpY9Xn5excZREnXoGCrnSLOyG8Jr5ksgTTtl4HLzErSRM5L9qFY3ee5Y5AwKjJGdU4tWiUDD7aLcofUhS3jOzyo5fzF+4YNQ2euuSkJcAq6yLaEi4l65g4mIW3Z+C28vEg2Wsl++65nKc72vI4zR7km4rFOahHa5Qw1t8ME3OZV8KkNmdFoxEdMesZ0bONuufdY8GdvggikQpCETPqXw/eSw9M4VJ38afy3IkcPGaVMGq0sqbI0IZihN+WN+4Is0r4Q68yrvIHFLQTSWQ9enQQPQejcr+iRxQgwSypFTwOZREeSXZrm4DKG8/eL/C8zEdSr9I1DRk7N8k6hCXYQrZKRRJ9dzSwaJRU/jmr/rjdXnrqX+qqAtXxW74R4jwcRqzUTAKrtMS1mTs6IWe2S1yw4mL7cGqf+9QUTzzEJ/F68g3ojwykr+k28kO8O+DWov8Cgn8XQS+d1Jjs9gAklqzyN1Lpdb0siBvIAl/gptv6nBZ5drtMF1WJi2Px7+6YtLntAYo6lq0OcPviII5EWwJG6asFJOru/9VVlzfKSNUWa9Sn8zxbUcZZf5jDnlqyU3p9erA9bfLaKvqDfGZNVTiJumIIkparIVBnFCL4LmMLC8NahrMzC4K/kcCKJJju4ZUCXDuFaiMnnkNqINUr2s7KefFc8VHQ+bTBQKtSSefmVYlIQkq1Lm8o4am+sP2da1YHYfGG274+4w35Szc88EIykbcXCOxxk0WcLeASJZGo22U8DkR23vq8Qv2ugKPZ7+MmOtm28Muct3fYEwy1gtFz/ccmG2tuXgXcu3LIRvfNpa3KQyzquGxY7yHfoeLQ2GwQ3z1gx6J6rtZXxNlGu4fUj+DbbiKhhWu2fI95azfQ7A96a50CHvU2RgQ+7G291OKH91kIvPWPfBtj/A99h1T38RKb2quaKCNIR20hJHxXfAOFxHa7aqChS1+oDcnQusyX/xc7zOcLduRzauqUvzEk6X3zB4oQxcjPs7XV6g6dXmdcYlfSGe1gqpz5sEyTs/Mmx1F7nB4N/mccZ+OXhxulH73oJiOA0zIQCK/Qo4PN8I1eVBQ/iGmIAIeBII8yLAEgY5G9cOS5yY1+0IuvaLwPNJhS64E/E8S6+p8L4r08zwYNpvLLQfYRlFEti2LNijCBfrzZ9PVs8+EhDAWKX+j89eduUJTCGfVgi1miHgJA39Xy8AcJbNO1f5ig8248eTMfzGbR9S+z+eDiYvx2NJsP3s5ejyfD2a/z6+H0ejC7eO0l7EWeF0maxVVeNCPu3RC1lZh38d6VSrhBT17JqLBGJrakwHhHVLRL6rkYjyeXw9FgNp40pZwVXN1YjSKfRCSDvjza+Vro5Tq6Po8akwryzOHHhqRCBu2RVKj2F4aOnWxo2x6Jh+zUriPZ8l8gLU2ifw+jd82pCW0P3SBqRE1k0JcneHBJ6hcjfWgk5Qs5myrfy8k4LdkqLu6g8FFsBu0jFAPT4avR4Kqt/cqFwYdWAgE9ew9T4aJF704+pFmCXl37oFfPWOlRFi6Y1vBHjQqd1IqgUciGDDco588iaQoOM3IIymRatoBLnap9WdFrG/UzOP7aDPAmhAn+D9/+9+D5kgAA"; + +const downgradeToHistoricalAdoptionVersion35 = (database: Database): void => { + dropSchemaAfterVersion34(database); + const schema = gunzipSync( + Buffer.from(historicalAdoptionVersion35SchemaGzipBase64, "base64"), + ).toString("utf8"); + if (createHash("sha256").update(schema).digest("hex") + !== historicalAdoptionVersion35SchemaSha256) { + throw new Error("Historical adoption-v35 fixture digest mismatch."); + } + database.exec(schema); + database.exec("DELETE FROM migrations WHERE version>35; PRAGMA user_version=35;"); +}; + +const dropPostLegacyAdoptionVersion36Schema = (database: Database): void => { + downgradeProviderV39SessionColumn(database); + const presetTriggers = z.object({ + name: z.string().regex(/^[a-z0-9_]+$/u), + }).strict().array().parse(database.query( + `SELECT name FROM sqlite_master + WHERE type='trigger' AND sql LIKE '%preset_contract%' + ORDER BY name`, + ).all()); + for (const trigger of presetTriggers) { + database.exec(`DROP TRIGGER IF EXISTS "${trigger.name}"`); + } + database.exec(` + ALTER TABLE sessions DROP COLUMN preset_contract; + ALTER TABLE works DROP COLUMN preset_contract; + ALTER TABLE session_adoption_candidates DROP COLUMN last_live_observed_at; + ALTER TABLE session_adoption_candidates DROP COLUMN provider_project_root; + DROP TABLE IF EXISTS attention_email_policy; + DROP TABLE IF EXISTS notification_hours; + `); +}; + +const legacyAdoptionWorkAuthorityGuards = ` +CREATE TRIGGER IF NOT EXISTS work_coordinator_account_authority_guard +BEFORE INSERT ON works +WHEN NOT EXISTS ( + SELECT 1 + FROM session_account_authorities AS a + JOIN sessions AS s ON s.id=a.session_id AND s.profile_id=a.profile_id + JOIN profiles AS p ON p.id=a.profile_id + WHERE a.session_id=NEW.coordinator_session_id + AND s.state IN ('active','idle') + AND p.state='signed_in' + AND a.account_key IS NOT NULL + AND p.provider_email IS NOT NULL + AND a.account_key=lower(trim(p.provider_email)) +) +BEGIN SELECT RAISE(ABORT,'WORK_COORDINATOR_AUTHORITY_MISMATCH'); END; +CREATE TRIGGER IF NOT EXISTS work_member_account_authority_guard +BEFORE INSERT ON work_members +WHEN NOT EXISTS ( + SELECT 1 + FROM session_account_authorities AS a + JOIN sessions AS s ON s.id=a.session_id AND s.profile_id=a.profile_id + JOIN profiles AS p ON p.id=a.profile_id + WHERE a.session_id=NEW.session_id + AND s.state IN ('active','idle') + AND p.state='signed_in' + AND a.account_key IS NOT NULL + AND p.provider_email IS NOT NULL + AND a.account_key=lower(trim(p.provider_email)) +) +BEGIN SELECT RAISE(ABORT,'WORK_MEMBER_AUTHORITY_MISMATCH'); END; +CREATE TRIGGER IF NOT EXISTS work_attempt_account_authority_guard +BEFORE INSERT ON work_attempts +WHEN NOT EXISTS ( + SELECT 1 + FROM session_account_authorities AS authority + JOIN sessions AS s + ON s.id=authority.session_id AND s.profile_id=authority.profile_id + JOIN profiles AS p ON p.id=authority.profile_id + WHERE authority.session_id=NEW.worker_session_id + AND authority.profile_id=NEW.account_id + AND s.state IN ('active','idle') + AND p.state='signed_in' + AND p.process_generation=NEW.account_generation + AND authority.account_key IS NOT NULL + AND p.provider_email IS NOT NULL + AND authority.account_key=lower(trim(p.provider_email)) +) +BEGIN SELECT RAISE(ABORT,'WORK_ATTEMPT_ACCOUNT_AUTHORITY_MISMATCH'); END; +CREATE TRIGGER IF NOT EXISTS work_review_account_authority_guard +BEFORE INSERT ON work_reviews +WHEN NOT EXISTS ( + SELECT 1 + FROM session_account_authorities AS a + JOIN sessions AS session ON session.id=a.session_id AND session.profile_id=a.profile_id + JOIN profiles AS p ON p.id=a.profile_id + WHERE a.session_id=NEW.reviewer_session_id + AND session.state IN ('active','idle') + AND p.state='signed_in' + AND a.account_key IS NOT NULL + AND p.provider_email IS NOT NULL + AND a.account_key=lower(trim(p.provider_email)) +) +BEGIN SELECT RAISE(ABORT,'WORK_REVIEWER_AUTHORITY_MISMATCH'); END; +CREATE TRIGGER IF NOT EXISTS work_signal_account_authority_guard +BEFORE INSERT ON work_signals +WHEN NOT EXISTS ( + SELECT 1 + FROM session_account_authorities AS a + JOIN sessions AS s ON s.id=a.session_id AND s.profile_id=a.profile_id + JOIN profiles AS p ON p.id=a.profile_id + WHERE a.session_id=NEW.from_session_id + AND s.state IN ('active','idle') + AND p.state='signed_in' + AND a.account_key IS NOT NULL + AND p.provider_email IS NOT NULL + AND a.account_key=lower(trim(p.provider_email)) +) +OR NOT EXISTS ( + SELECT 1 + FROM session_account_authorities AS a + JOIN sessions AS s ON s.id=a.session_id AND s.profile_id=a.profile_id + JOIN profiles AS p ON p.id=a.profile_id + WHERE a.session_id=NEW.to_session_id + AND s.state IN ('active','idle') + AND p.state='signed_in' + AND p.process_generation=NEW.target_account_generation + AND a.account_key IS NOT NULL + AND p.provider_email IS NOT NULL + AND a.account_key=lower(trim(p.provider_email)) +) +BEGIN SELECT RAISE(ABORT,'WORK_SIGNAL_ACCOUNT_AUTHORITY_MISMATCH'); END; +CREATE TRIGGER IF NOT EXISTS work_signal_ack_account_authority_guard +BEFORE INSERT ON work_signal_receipts +WHEN NEW.kind='ack' AND NOT EXISTS ( + SELECT 1 + FROM work_signals AS signal + JOIN session_account_authorities AS a ON a.session_id=signal.to_session_id + JOIN sessions AS s ON s.id=a.session_id AND s.profile_id=a.profile_id + JOIN profiles AS p ON p.id=a.profile_id + WHERE signal.id=NEW.signal_id + AND signal.to_session_id=NEW.actor_session_id + AND s.state IN ('active','idle') + AND p.state='signed_in' + AND p.process_generation=signal.target_account_generation + AND a.account_key IS NOT NULL + AND p.provider_email IS NOT NULL + AND a.account_key=lower(trim(p.provider_email)) +) +BEGIN SELECT RAISE(ABORT,'WORK_SIGNAL_ACK_ACCOUNT_AUTHORITY_MISMATCH'); END; +`; + +const downgradeToExactLegacyAdoptionVersion36 = (database: Database): void => { + dropPostLegacyAdoptionVersion36Schema(database); + database.exec(` + DROP TRIGGER IF EXISTS work_attempt_account_authority_guard; + DROP TRIGGER IF EXISTS work_coordinator_account_authority_guard; + DROP TRIGGER IF EXISTS work_member_account_authority_guard; + DROP TRIGGER IF EXISTS work_review_account_authority_guard; + DROP TRIGGER IF EXISTS work_signal_account_authority_guard; + DROP TRIGGER IF EXISTS work_signal_ack_account_authority_guard; + `); + database.exec(legacyAdoptionWorkAuthorityGuards); + database.exec(` + DELETE FROM migrations WHERE version > 36; + PRAGMA user_version=36; + `); +}; + +const codexAdoptionRuntimeProfile = ( + profile: Readonly<{ id: string; processGeneration: number }>, + preset: "low" | "high" | "ultra", + fast: boolean, +) => ({ + approvalPolicy: "on-request" as const, + computerUse: true as const, + enabledApps: [], + fast, + model: presetRequirements[preset].model, + observedAt: 2_000, + permissionProfile: ":workspace" as const, + pluginCapability: true as const, + preset, + processGeneration: profile.processGeneration, + profileId: profile.id, + reasoningEffort: presetRequirements[preset].effort, + reviewMode: "auto_review" as const, + serviceTier: fast ? "priority" as const : null, +}); + +const claudeAdoptionRuntimeProfile = ( + profile: Readonly<{ id: string; processGeneration: number }>, +) => ({ + claudeVersion: "2.1.260", + configHome: "personal" as const, + inputFormat: "stream-json" as const, + model: presetRequirements["fable-max"].model, + observedAt: 2_000, + outputFormat: "stream-json" as const, + permissionMode: "default" as const, + preset: "fable-max" as const, + processGeneration: profile.processGeneration, + profileId: profile.id, + reasoningEffort: "max" as const, +}); + +const managedClaudeRuntimeProfile = ( + profile: Readonly<{ id: string; processGeneration: number }>, +) => ({ + ...claudeAdoptionRuntimeProfile(profile), + configHome: "isolated" as const, +}); + +let personalClaudeSessionSequence = 0; +const adoptPersonalClaudeTestSession = ( + store: StateStore, + profile: Readonly<{ id: string; processGeneration: number }>, +) => { + personalClaudeSessionSequence += 1; + const providerThreadId = `personal-claude-thread-${personalClaudeSessionSequence}`; + const processIdentity = { + pid: 60_000 + personalClaudeSessionSequence, + pidDomain: "darwin" as const, + procStart: `personal-claude-process-${personalClaudeSessionSequence}`, + }; + const candidate = store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId, + title: `Personal Claude session ${personalClaudeSessionSequence}`, + state: "idle", + providerUpdatedAt: 10, + liveness: "not_live", + sourceProcessIdentity: processIdentity, + }); + store.fenceSessionAdoptionCandidateForClaim({ + provider: "claude", + providerThreadId, + expectedRevision: candidate.revision, + }); + store.recordClaimedClaudeProcessAuthority({ + providerThreadId, + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "personal", + identity: processIdentity, + }); + const claimed = store.listSessionAdoptionCandidates({ provider: "claude" }) + .find((current) => current.providerThreadId === providerThreadId); + if (claimed === undefined) throw new Error("Expected claimed personal Claude candidate."); + return store.adoptSessionCandidate({ + provider: "claude", + providerThreadId, + expectedCandidateRevision: claimed.revision, + profileId: profile.id, + profileGeneration: profile.processGeneration, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + fastEnabled: false, + runtimeProfile: claudeAdoptionRuntimeProfile(profile), + providerAccountKey: testProviderAccountKey("claude"), + claudeProcessIdentity: processIdentity, + }).session; +}; + +const dropProviderSwitchVersion35Objects = (database: Database): void => { database.exec(` PRAGMA foreign_keys=OFF; - DROP TABLE session_mutation_authority_rebinds_v39; DROP TABLE session_provider_switch_source_releases; DROP TABLE session_provider_switch_seed_results; DROP TABLE session_provider_switch_seed_intents; @@ -106,6 +632,7 @@ const replaceAutorespondEvidenceWithVersion30Fixture = ( database: Database, sessionId: string, ): void => { + dropSchemaAfterVersion34(database); database.query("DROP TABLE IF EXISTS autorespond_message_sources").run(); database.query("DROP TABLE IF EXISTS autorespond_evidence_next").run(); database.query("DROP TABLE autorespond_evidence").run(); @@ -382,6 +909,7 @@ function seedLegacyMcpUrlInteraction(input: Readonly<{ sessionId: string; }>): void { const legacy = new Database(input.paths.database, { create: false, strict: true }); + dropSchemaAfterVersion34(legacy); legacy.exec(` DROP TRIGGER IF EXISTS provider_interactions_mcp_url_guard_insert; DROP TRIGGER IF EXISTS provider_interactions_mcp_url_guard_update; @@ -448,11 +976,12 @@ function seedLegacyPermissionValueInteraction(input: Readonly<{ sessionId: string; }>): void { const legacy = new Database(input.paths.database, { create: false, strict: true }); + dropSchemaAfterVersion34(legacy); legacy.exec(` PRAGMA secure_delete=OFF; DROP TRIGGER IF EXISTS provider_interactions_permission_value_guard_insert; DROP TRIGGER IF EXISTS provider_interactions_permission_value_guard_update; - DELETE FROM migrations WHERE version=15; + DELETE FROM migrations WHERE version>14; PRAGMA user_version=14; `); const legacyPrivatePath = ["", "Users", "alice", "private"].join("/"); @@ -697,15 +1226,11 @@ describe("StateStore", () => { test("a new daemon boot fences every prior provider process and terminalizes callbacks", async () => { const { store } = await fixture(); const profile = signInProfile(store, "Daemon restart", "restart@example.com"); - const session = store.createSession({ + const session = upsertProvenTestSession(store, { profileId: profile.id, + providerThreadId: "thread-restart", preset: "high", fastEnabled: false, - }); - store.bindSession({ - sessionId: session.id, - expectedRevision: session.revision, - providerThreadId: "thread-restart", state: "idle", }); const admit = (publicId: string, requestId: string) => store.admitInteraction({ @@ -906,7 +1431,7 @@ describe("StateStore", () => { test("keeps imported sessions legacy until an explicit preset selection", async () => { const { store } = await fixture(); - const profile = store.createProfile("Preset contracts"); + const profile = signInProfile(store, "Preset contracts", "preset-contracts@example.com"); const created = store.createSession({ profileId: profile.id, preset: "high", @@ -919,9 +1444,13 @@ describe("StateStore", () => { const imported = store.upsertProviderSession({ profileId: profile.id, + provider: "codex", providerThreadId: "thread-imported-contract", title: "Imported", + preset: "high", + fastEnabled: false, state: "idle", + providerAccountKey: providerAccountKeyForProfile(store, profile.id, "codex"), }); expect(store.requireSessionPresetRequirement(imported.id)).toEqual({ preset: "high", @@ -948,10 +1477,14 @@ describe("StateStore", () => { const profile = signInProfile(store, "Legacy recovery preset", "legacy-recovery@example.com"); const session = store.upsertProviderSession({ profileId: profile.id, + provider: "codex", providerThreadId: "thread-legacy-recovery-preset", title: "Legacy recovery preset", + preset: "high", + fastEnabled: false, state: "idle", providerUpdatedAt: 10, + providerAccountKey: providerAccountKeyForProfile(store, profile.id, "codex"), }); const runtimeProfile = { approvalPolicy: "on-request" as const, @@ -1118,7 +1651,11 @@ describe("StateStore", () => { test("keeps show-thinking and the default preset as daemon settings with session overrides", async () => { const { store } = await fixture(); const profile = store.createProfile("Settings"); - const session = store.createSession({ profileId: profile.id, preset: "high", fastEnabled: false }); + const session = store.createSession({ + profileId: profile.id, + preset: "high", + fastEnabled: false, + }); expect(store.readDefaultShowThinking()).toBe(false); expect(store.readSessionShowThinking(session.id)).toEqual({ enabled: false, source: "default" }); @@ -1138,94 +1675,4461 @@ describe("StateStore", () => { expect(() => store.setDefaultPreset("max" as "low")).toThrow(); }); - test("keeps session recovery absorbing across passive and exact-state reconciliation", async () => { + test("adopts personal-home candidates atomically and keeps provenance private", async () => { const { store } = await fixture(); - const profile = signInProfile(store, "Recovery", "recovery@example.com"); - const local = store.createSession({ profileId: profile.id, preset: "high", fastEnabled: false }); - const bound = store.bindSession({ - sessionId: local.id, - expectedRevision: local.revision, - providerThreadId: "thread-recovery", + const profile = signInProfile(store, "Personal runtime", "personal-runtime@example.com"); + expect(store.readSessionAdoptionPolicy("codex")).toBeNull(); + expect(store.setSessionAdoptionPolicy({ + provider: "codex", + profileId: profile.id, + })).toMatchObject({ + enabled: true, + profileId: profile.id, + provider: "codex", + revision: 1, + }); + + const providerProjectRoot = "/private/provider-project/alias/.."; + const candidate = store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: "personal-thread", + providerProjectRoot, + title: "Personal terminal session", state: "idle", providerUpdatedAt: 10, + liveness: "not_live", }); - const quarantined = store.quarantineSession(bound.id); - expect(quarantined).toMatchObject({ state: "recovery_required", providerUpdatedAt: 10 }); - - const passive = store.upsertProviderSession({ + expect(candidate).toMatchObject({ + provider: "codex", + providerThreadId: "personal-thread", + providerProjectRoot, + status: "pending", + }); + const refreshedCandidate = store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: "personal-thread", + providerProjectRoot, + title: "Personal terminal session", + state: "idle", + providerUpdatedAt: 10, + liveness: "not_live", + }); + expect(() => store.adoptSessionCandidate({ + provider: "codex", + providerThreadId: candidate.providerThreadId, + expectedCandidateRevision: candidate.revision, profileId: profile.id, - providerThreadId: "thread-recovery", - title: "Passive projection", + profileGeneration: profile.processGeneration, + preset: "ultra", + requirement: presetRequirements.ultra, + fastEnabled: true, + runtimeProfile: codexAdoptionRuntimeProfile(profile, "ultra", true), + providerAccountKey: providerAccountKeyForProfile(store, profile.id, "codex"), + })).toThrow("SESSION_ADOPTION_CANDIDATE_STALE"); + expect(() => store.adoptSessionCandidate({ + provider: "codex", + providerThreadId: candidate.providerThreadId, + expectedCandidateRevision: refreshedCandidate.revision, + profileId: profile.id, + profileGeneration: profile.processGeneration, + preset: "ultra", + requirement: presetRequirements.ultra, + fastEnabled: true, + runtimeProfile: codexAdoptionRuntimeProfile(profile, "ultra", true), + providerAccountKey: providerAccountKeyForProfile(store, profile.id, "codex"), + })).toThrow("SESSION_ADOPTION_CANDIDATE_NOT_CLAIMED"); + const firstPreflight = store.recordSessionAdoptionCandidatePreflightAttempt({ + provider: "codex", + providerThreadId: candidate.providerThreadId, + expectedRevision: refreshedCandidate.revision, + }); + expect(firstPreflight).toMatchObject({ status: "pending" }); + expect(firstPreflight.lastAttemptAt).not.toBeNull(); + const secondPreflight = store.recordSessionAdoptionCandidatePreflightAttempt({ + provider: "codex", + providerThreadId: candidate.providerThreadId, + expectedRevision: firstPreflight.revision, + }); + expect(secondPreflight.lastAttemptAt).toBeGreaterThan(firstPreflight.lastAttemptAt ?? 0); + expect(() => store.recordSessionAdoptionCandidatePreflightAttempt({ + provider: "codex", + providerThreadId: candidate.providerThreadId, + expectedRevision: firstPreflight.revision, + })).toThrow("SESSION_ADOPTION_CANDIDATE_PREFLIGHT_CONFLICT"); + const claimedCandidate = store.fenceSessionAdoptionCandidateForClaim({ + provider: "codex", + providerThreadId: candidate.providerThreadId, + expectedRevision: secondPreflight.revision, + }); + const adopted = store.adoptSessionCandidate({ + provider: "codex", + providerThreadId: candidate.providerThreadId, + expectedCandidateRevision: claimedCandidate.revision, + profileId: profile.id, + profileGeneration: profile.processGeneration, + preset: "ultra", + requirement: presetRequirements.ultra, + fastEnabled: true, + runtimeProfile: codexAdoptionRuntimeProfile(profile, "ultra", true), + providerAccountKey: providerAccountKeyForProfile(store, profile.id, "codex"), + }); + expect(adopted.session).toMatchObject({ + profileId: profile.id, + provider: "codex", + providerThreadId: "personal-thread", + preset: "ultra", + fastEnabled: true, + state: "idle", + }); + expect(adopted.candidate.status).toBe("adopted"); + expect(adopted.binding).toMatchObject({ + sessionId: adopted.session.id, state: "active", - activeTurnId: "turn-passive", - providerUpdatedAt: 11, }); - expect(passive).toMatchObject({ state: "recovery_required", title: "Untitled session", revision: quarantined.revision }); + expect(store.latestSessionRuntimeProfile(adopted.session.id)).toMatchObject({ + sourceKind: "session_start", + profile: codexAdoptionRuntimeProfile(profile, "ultra", true), + }); + expect(Object.keys(adopted.session)).not.toContain("origin"); + expect(Object.keys(adopted.session)).not.toContain("adopted"); + expect(Object.keys(adopted.session)).not.toContain("providerProjectRoot"); + expect(store.isConversationAutomationEnabled( + adopted.session.id, + adopted.session.providerThreadId ?? "", + )).toBe(true); + expect(store.listLocalSessionPage({ + profileId: profile.id, + after: null, + limit: 1, + })).toEqual({ sessions: [adopted.session], nextPosition: null }); + store.setSessionArchived(adopted.session.id, true); + expect(store.listLocalSessionPage({ + profileId: profile.id, + after: null, + limit: 1, + }).sessions).toEqual([]); + expect(store.listLocalSessionPage({ + profileId: profile.id, + after: null, + includeArchived: true, + limit: 1, + }).sessions).toEqual([store.requireSession(adopted.session.id)]); + store.setSessionArchived(adopted.session.id, false); + + const detached = store.detachPersonalSession({ sessionId: adopted.session.id }); + expect(detached.binding.state).toBe("detached"); + expect(detached.candidate.status).toBe("fenced"); + expect(detached.session.archivedAt).toBeDefined(); + expect(store.readSessionPersonalRuntimeBinding(adopted.session.id)).toBeNull(); + expect(store.readSessionPersonalRuntimeBinding(adopted.session.id, true)?.state) + .toBe("detached"); + expect(store.listLocalSessionPage({ + profileId: profile.id, + after: null, + includeArchived: true, + limit: 1, + }).sessions).toEqual([store.requireSession(adopted.session.id)]); - expect(store.reconcileSessionFromProvider({ sessionId: quarantined.id, state: "active", activeTurnId: "turn-exact", title: "Exact projection" })).toEqual(quarantined); - expect(() => store.resolveSessionStatusRecovery({ - sessionId: quarantined.id, - expectedRevision: quarantined.revision, - resolution: "provider_state_reconciled", - provider: { - providerThreadId: "thread-recovery", - title: "Missing active turn", - status: "active", - providerUpdatedAt: 12, - }, - })).toThrow("SESSION_STATUS_RECOVERY_ACTIVE_TURN_MISSING"); - expect(store.requireSession(quarantined.id)).toEqual(quarantined); - }); + // A liveness-only observation does not invent conversation activity and + // therefore cannot defeat an explicit detach fence. + expect(store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: "personal-thread", + providerProjectRoot, + title: "Personal terminal session", + state: "idle", + providerUpdatedAt: 10, + liveness: "not_live", + }).status).toBe("fenced"); + const changedProviderProjectRoot = "/private/provider-project/changed/.."; + expect(store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: "personal-thread", + providerProjectRoot: changedProviderProjectRoot, + title: "Personal terminal session", + state: "idle", + providerUpdatedAt: 10, + liveness: "not_live", + })).toMatchObject({ + providerProjectRoot: changedProviderProjectRoot, + status: "pending", + }); - test("deletes only exact unbound and evidence-free starting sessions", async () => { - const { store } = await fixture(); - const profile = store.createProfile("Cleanup"); - const removable = store.createSession({ + store.setSessionAdoptionPolicy({ provider: "codex", profileId: null }); + store.setSessionAdoptionPolicy({ provider: "codex", profileId: profile.id }); + const pendingAgain = store.listSessionAdoptionCandidates({ provider: "codex" })[0]; + expect(pendingAgain?.status).toBe("pending"); + if (pendingAgain === undefined) throw new Error("Expected the candidate to be pending again."); + const claimedAgain = store.fenceSessionAdoptionCandidateForClaim({ + provider: "codex", + providerThreadId: pendingAgain.providerThreadId, + expectedRevision: pendingAgain.revision, + }); + const readopted = store.adoptSessionCandidate({ + provider: "codex", + providerThreadId: "personal-thread", + expectedCandidateRevision: claimedAgain.revision, profileId: profile.id, + profileGeneration: profile.processGeneration, preset: "high", + requirement: presetRequirements.high, fastEnabled: false, + runtimeProfile: codexAdoptionRuntimeProfile(profile, "high", false), + providerAccountKey: providerAccountKeyForProfile(store, profile.id, "codex"), }); - expect(store.deleteUnboundStartingSession(removable.id, removable.revision + 1)).toBe(false); - expect(store.deleteUnboundStartingSession(removable.id, removable.revision)).toBe(true); - expect(() => store.requireSession(removable.id)).toThrow(SelectionError); + expect(readopted.session).toMatchObject({ + id: adopted.session.id, + preset: "high", + fastEnabled: false, + }); + expect("archivedAt" in readopted.session).toBe(false); + expect(readopted.binding).toMatchObject({ state: "active", revision: 4 }); + expect(store.isConversationAutomationEnabled( + readopted.session.id, + readopted.session.providerThreadId ?? "", + )).toBe(true); - const bound = store.createSession({ profileId: profile.id, preset: "high", fastEnabled: false }); - store.bindSession({ - sessionId: bound.id, - expectedRevision: bound.revision, - providerThreadId: "thread-bound", + store.detachPersonalSession({ sessionId: adopted.session.id, archive: false }); + expect(store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: "personal-thread", + providerProjectRoot, + title: "Personal terminal session", state: "idle", - }); - expect(store.deleteUnboundStartingSession(bound.id, bound.revision)).toBe(false); + providerUpdatedAt: 11, + liveness: "not_live", + }).status).toBe("pending"); + }); - const queued = store.createSession({ profileId: profile.id, preset: "high", fastEnabled: false }); - store.enqueue(queued.id, "retained queue evidence"); - expect(store.deleteUnboundStartingSession(queued.id, queued.revision)).toBe(false); + test("records candidate preflight fairness across wall-clock rollback", async () => { + let now = 2_000; + const { store } = await fixture({ now: () => now }); + const candidate = store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: "rollback-preflight-candidate", + title: "Rollback preflight candidate", + state: "idle", + liveness: "not_live", + }); + now = 3_000; + const laterCandidate = store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: "later-rollback-preflight-candidate", + title: "Later rollback preflight candidate", + state: "idle", + liveness: "not_live", + }); - const summarized = store.createSession({ profileId: profile.id, preset: "high", fastEnabled: false }); - const database = new Database(store.paths.database, { create: false, strict: true }); - try { - database - .query("INSERT INTO turn_summaries(session_id,turn_id,sequence,summary_json,created_at) VALUES (?,?,?,?,?)") - .run(summarized.id, "turn-1", 0, "{}", 1_000); - } finally { - database.close(false); - } - expect(store.deleteUnboundStartingSession(summarized.id, summarized.revision)).toBe(false); - }); + now = 1_000; + const firstAttempt = store.recordSessionAdoptionCandidatePreflightAttempt({ + provider: candidate.provider, + providerThreadId: candidate.providerThreadId, + expectedRevision: candidate.revision, + }); + const laterAttempt = store.recordSessionAdoptionCandidatePreflightAttempt({ + provider: laterCandidate.provider, + providerThreadId: laterCandidate.providerThreadId, + expectedRevision: laterCandidate.revision, + }); + const retriedFirst = store.recordSessionAdoptionCandidatePreflightAttempt({ + provider: firstAttempt.provider, + providerThreadId: firstAttempt.providerThreadId, + expectedRevision: firstAttempt.revision, + }); - test("persists idempotent mutation receipts and rejects changed reuse", async () => { - const { store } = await fixture(); - const key = "b83efca6-d731-498e-ac2c-876555a4ae2d"; - const first = store.prepareMutation({ kind: "turn.start", authorityId: "session", authorityGeneration: 1, request: { message: "hello" }, idempotencyKey: key }); - expect(first.replay).toBe(false); - expect(store.transitionMutation(first.id, "prepared", "effect_started")).toBe(true); - expect(store.transitionMutation(first.id, "effect_started", "applied", { turnId: "turn-1" })).toBe(true); - expect(store.prepareMutation({ kind: "turn.start", authorityId: "session", authorityGeneration: 1, request: { message: "hello" }, idempotencyKey: key })).toMatchObject({ replay: true, state: "applied", result: { turnId: "turn-1" } }); - expect(() => store.prepareMutation({ kind: "turn.start", authorityId: "session", authorityGeneration: 1, request: { message: "changed" }, idempotencyKey: key })).toThrow("IDEMPOTENCY_CONFLICT"); + expect(firstAttempt.lastAttemptAt).toBe(candidate.lastObservedAt); + expect(laterAttempt.lastAttemptAt).toBe(laterCandidate.lastObservedAt); + expect(retriedFirst.lastAttemptAt).toBe((laterAttempt.lastAttemptAt ?? 0) + 1); + expect(retriedFirst.revision).toBe(candidate.revision + 2); + expect(retriedFirst.status).toBe("pending"); }); - test("leaves a crash before effect dispatch replayable without quarantining its authority", async () => { + test("stores truthful provider configuration and refuses adoption collisions", async () => { + const { store } = await fixture(); + const first = signInProfile(store, "First adopter", "first-adopter@example.com"); + const second = signInProfile(store, "Second adopter", "second-adopter@example.com"); + const claude = store.upsertProviderSession({ + profileId: first.id, + provider: "claude", + providerThreadId: "truthful-thread", + title: "Truthful provider", + preset: "fable-max", + fastEnabled: false, + state: "idle", + providerUpdatedAt: 1, + providerAccountKey: testProviderAccountKey("claude"), + }); + expect(claude).toMatchObject({ + provider: "claude", + preset: "fable-max", + fastEnabled: false, + }); + expect(() => store.upsertProviderSession({ + profileId: first.id, + provider: "codex", + providerThreadId: "truthful-thread", + title: "Conflicting provider", + preset: "high", + fastEnabled: false, + state: "idle", + providerUpdatedAt: 2, + providerAccountKey: providerAccountKeyForProfile(store, first.id, "codex"), + })).toThrow("SESSION_PROVIDER_THREAD_COLLISION"); + + store.setSessionAdoptionPolicy({ provider: "codex", profileId: first.id }); + const ownerCandidate = store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: "one-owner-thread", + title: "One owner", + state: "idle", + liveness: "not_live", + }); + const claimedOwner = store.fenceSessionAdoptionCandidateForClaim({ + provider: "codex", + providerThreadId: ownerCandidate.providerThreadId, + expectedRevision: ownerCandidate.revision, + }); + const adopted = store.adoptSessionCandidate({ + provider: "codex", + providerThreadId: "one-owner-thread", + expectedCandidateRevision: claimedOwner.revision, + profileId: first.id, + profileGeneration: first.processGeneration, + preset: "high", + requirement: presetRequirements.high, + fastEnabled: false, + runtimeProfile: codexAdoptionRuntimeProfile(first, "high", false), + providerAccountKey: providerAccountKeyForProfile(store, first.id, "codex"), + }); + expect(() => store.setSessionAdoptionPolicy({ + provider: "codex", + profileId: second.id, + })).toThrow("SESSION_ADOPTION_POLICY_ACTIVE_BINDINGS"); + expect(store.readSessionAdoptionPolicy("codex")?.profileId).toBe(first.id); + store.detachPersonalSession({ sessionId: adopted.session.id, archive: false }); + store.setSessionAdoptionPolicy({ provider: "codex", profileId: second.id }); + const reassignedCandidate = store.listSessionAdoptionCandidates({ + provider: "codex", + status: "pending", + })[0]; + if (reassignedCandidate === undefined) throw new Error("Expected a reassigned candidate."); + const claimedReassignment = store.fenceSessionAdoptionCandidateForClaim({ + provider: "codex", + providerThreadId: reassignedCandidate.providerThreadId, + expectedRevision: reassignedCandidate.revision, + }); + expect(() => store.adoptSessionCandidate({ + provider: "codex", + providerThreadId: "one-owner-thread", + expectedCandidateRevision: claimedReassignment.revision, + profileId: second.id, + profileGeneration: second.processGeneration, + preset: "high", + requirement: presetRequirements.high, + fastEnabled: false, + runtimeProfile: codexAdoptionRuntimeProfile(second, "high", false), + providerAccountKey: providerAccountKeyForProfile(store, second.id, "codex"), + })).toThrow("SESSION_ADOPTION_BINDING_COLLISION"); + expect(store.requireSession(adopted.session.id).profileId).toBe(first.id); + }); + + test("cannot adopt Codex after the selected profile loses identifiable authority", async () => { + const { store } = await fixture(); + const profile = signInProfile( + store, + "Codex identity loss", + "codex-identity-loss@example.com", + ); + store.setSessionAdoptionPolicy({ provider: "codex", profileId: profile.id }); + expect(store.setProfileState( + profile.id, + profile.processGeneration, + "signed_in", + { plan: "apiKey" }, + )).toBe(true); + expect(store.requireProfileById(profile.id).providerEmail).toBeUndefined(); + expect(store.readSessionAdoptionPolicy("codex")).toMatchObject({ + enabled: false, + profileId: null, + }); + + const candidate = store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: "codex-identity-loss-thread", + title: "Codex identity loss candidate", + state: "idle", + providerUpdatedAt: 10, + liveness: "not_live", + }); + const claiming = store.fenceSessionAdoptionCandidateForClaim({ + provider: "codex", + providerThreadId: candidate.providerThreadId, + expectedRevision: candidate.revision, + }); + + // Simulate a damaged pre-release database in which stale policy authority + // survived identity loss. The adoption transaction must still fail closed. + const raw = new Database(store.paths.database, { create: false, strict: true }); + try { + raw.exec("DROP TRIGGER session_adoption_policy_profile_guard_update"); + raw.exec("DROP TRIGGER session_adoption_policy_unsettled_claim_guard"); + raw.query( + `UPDATE session_adoption_policies + SET profile_id=?,state='enabled',revision=revision+1,updated_at=updated_at+1 + WHERE provider='codex'`, + ).run(profile.id); + } finally { + raw.close(false); + } + expect(() => store.adoptSessionCandidate({ + provider: "codex", + providerThreadId: claiming.providerThreadId, + expectedCandidateRevision: claiming.revision, + profileId: profile.id, + profileGeneration: profile.processGeneration, + preset: "high", + requirement: presetRequirements.high, + fastEnabled: false, + runtimeProfile: codexAdoptionRuntimeProfile(profile, "high", false), + providerAccountKey: testProviderAccountKey("codex"), + })).toThrow("SESSION_ADOPTION_PROFILE_NOT_SIGNED_IN"); + expect(store.findSessionPersonalRuntimeBinding( + "codex", + candidate.providerThreadId, + )).toBeNull(); + }); + + test("recovers an interrupted claim only after a later quiet observation", async () => { + const { store } = await fixture(); + const profile = signInProfile(store, "Interrupted adopter", "interrupted-adopter@example.com"); + const otherProfile = signInProfile( + store, + "Other interrupted adopter", + "other-interrupted-adopter@example.com", + ); + store.setSessionAdoptionPolicy({ provider: "codex", profileId: profile.id }); + const candidate = store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: "interrupted-claim-thread", + title: "Interrupted claim", + state: "idle", + providerUpdatedAt: 10, + liveness: "not_live", + }); + const claiming = store.fenceSessionAdoptionCandidateForClaim({ + provider: "codex", + providerThreadId: candidate.providerThreadId, + expectedRevision: candidate.revision, + }); + + expect(() => store.recoverSessionAdoptionClaimAfterObservation({ + provider: "codex", + providerThreadId: claiming.providerThreadId, + profileId: profile.id, + expectedRevision: claiming.revision, + })).toThrow("SESSION_ADOPTION_CLAIM_RECOVERY_NOT_PROVEN"); + expect(() => store.setSessionAdoptionPolicy({ + provider: "codex", + profileId: null, + })).toThrow("SESSION_ADOPTION_POLICY_UNSETTLED_CLAIM"); + expect(() => store.setSessionAdoptionPolicy({ + provider: "codex", + profileId: otherProfile.id, + })).toThrow("SESSION_ADOPTION_POLICY_UNSETTLED_CLAIM"); + expect(store.readSessionAdoptionPolicy("codex")?.profileId).toBe(profile.id); + expect(store.listSessionAdoptionCandidates({ provider: "codex" })[0]?.status) + .toBe("claiming"); + + const observedAgain = store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: claiming.providerThreadId, + title: "Interrupted claim", + state: "idle", + providerUpdatedAt: 10, + liveness: "not_live", + }); + expect(store.recoverSessionAdoptionClaimAfterObservation({ + provider: "codex", + providerThreadId: claiming.providerThreadId, + profileId: profile.id, + expectedRevision: observedAgain.revision, + }).status).toBe("pending"); + }); + + test("round-trips Claude source identity and applies exact-probe liveness with a strict CAS", async () => { + const home = await realpath(await mkdtemp(join(tmpdir(), "hra-store-claude-probe-"))); + const paths = resolveStatePaths({ homeDirectory: home, platform: "darwin" }); + await initializeStatePaths(paths); + const store = new StateStore(paths, { now: () => 1_000 }); + stores.push(store); + const profile = signInProfile(store, "Claude probe", "claude-probe@example.com"); + store.setSessionAdoptionPolicy({ provider: "claude", profileId: profile.id }); + const identity = { + pid: 41_001, + pidDomain: "darwin" as const, + procStart: "Fri Sep 4 11:00:00 2026", + }; + const replacementIdentity = { + pid: 41_002, + pidDomain: "darwin" as const, + procStart: "Fri Sep 4 11:01:00 2026", + }; + const observed = store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: "claude-retained-probe", + title: "Retained Claude candidate", + state: "terminal", + providerUpdatedAt: 10, + liveness: "live", + sourceProcessIdentity: identity, + trustedLiveObservation: true, + }); + expect(observed.sourceProcessIdentity).toEqual(identity); + const preserved = store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: observed.providerThreadId, + title: observed.title, + state: observed.providerState, + ...(observed.providerUpdatedAt === null + ? {} + : { providerUpdatedAt: observed.providerUpdatedAt }), + liveness: observed.liveness, + }); + expect(preserved.sourceProcessIdentity).toEqual(identity); + + const claiming = store.fenceSessionAdoptionCandidateForClaim({ + provider: "claude", + providerThreadId: preserved.providerThreadId, + expectedRevision: preserved.revision, + }); + const probed = store.updateClaudeSessionAdoptionCandidateLivenessAfterExactProbe({ + providerThreadId: claiming.providerThreadId, + expectedRevision: claiming.revision, + expectedSourceProcessIdentity: identity, + liveness: "not_live", + }); + expect(probed).toMatchObject({ + liveness: "not_live", + sourceProcessIdentity: identity, + status: "claiming", + }); + expect(probed.lastObservedAt).toBeGreaterThan(probed.lastAttemptAt ?? Number.MAX_VALUE); + expect(() => store.updateClaudeSessionAdoptionCandidateLivenessAfterExactProbe({ + providerThreadId: probed.providerThreadId, + expectedRevision: claiming.revision, + expectedSourceProcessIdentity: identity, + liveness: "live", + })).toThrow("SESSION_ADOPTION_CANDIDATE_PROBE_CONFLICT"); + expect(() => store.updateClaudeSessionAdoptionCandidateLivenessAfterExactProbe({ + providerThreadId: probed.providerThreadId, + expectedRevision: probed.revision, + expectedSourceProcessIdentity: replacementIdentity, + liveness: "live", + })).toThrow("SESSION_ADOPTION_CANDIDATE_PROBE_CONFLICT"); + + const recovered = store.recoverSessionAdoptionClaimAfterObservation({ + provider: "claude", + providerThreadId: probed.providerThreadId, + profileId: profile.id, + expectedRevision: probed.revision, + }); + const reclaimed = store.fenceSessionAdoptionCandidateForClaim({ + provider: "claude", + providerThreadId: recovered.providerThreadId, + expectedRevision: recovered.revision, + }); + store.recordClaimedClaudeProcessAuthority({ + providerThreadId: reclaimed.providerThreadId, + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "personal", + identity, + }); + const claimedWithAuthority = store.listSessionAdoptionCandidates({ + provider: "claude", + }).find((candidate) => candidate.providerThreadId === reclaimed.providerThreadId); + if (claimedWithAuthority === undefined) { + throw new Error("Expected the claimed candidate after process custody was recorded."); + } + const adopted = store.adoptSessionCandidate({ + provider: "claude", + providerThreadId: reclaimed.providerThreadId, + expectedCandidateRevision: claimedWithAuthority.revision, + profileId: profile.id, + profileGeneration: profile.processGeneration, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + fastEnabled: false, + runtimeProfile: claudeAdoptionRuntimeProfile(profile), + providerAccountKey: testProviderAccountKey("claude"), + claudeProcessIdentity: identity, + }); + expect(() => store.updateClaudeSessionAdoptionCandidateLivenessAfterExactProbe({ + providerThreadId: adopted.candidate.providerThreadId, + expectedRevision: adopted.candidate.revision, + expectedSourceProcessIdentity: identity, + liveness: "live", + })).toThrow("SESSION_ADOPTION_CANDIDATE_PROBE_CONFLICT"); + + const authority = store.readClaudeProcessAuthority({ + providerThreadId: adopted.candidate.providerThreadId, + profileId: profile.id, + runtimeScope: "personal", + }); + if (authority === null) throw new Error("Expected retained Claude process authority."); + const releasing = store.beginClaudeProcessAuthorityRelease({ + providerThreadId: authority.providerThreadId, + profileId: authority.profileId, + runtimeScope: authority.runtimeScope, + expectedRevision: authority.revision, + identity: authority.identity, + }); + store.completeClaudeProcessAuthorityRelease({ + providerThreadId: releasing.providerThreadId, + profileId: releasing.profileId, + runtimeScope: releasing.runtimeScope, + expectedRevision: releasing.revision, + identity: releasing.identity, + }); + const detached = store.detachPersonalSession({ + sessionId: adopted.session.id, + archive: false, + }); + expect(detached.candidate.status).toBe("fenced"); + expect(() => store.updateClaudeSessionAdoptionCandidateLivenessAfterExactProbe({ + providerThreadId: detached.candidate.providerThreadId, + expectedRevision: detached.candidate.revision, + expectedSourceProcessIdentity: identity, + liveness: "live", + })).toThrow("SESSION_ADOPTION_CANDIDATE_PROBE_CONFLICT"); + + const sameIdentity = store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: detached.candidate.providerThreadId, + title: detached.candidate.title, + state: detached.candidate.providerState, + ...(detached.candidate.providerUpdatedAt === null + ? {} + : { providerUpdatedAt: detached.candidate.providerUpdatedAt }), + liveness: detached.candidate.liveness, + }); + expect(sameIdentity).toMatchObject({ + sourceProcessIdentity: identity, + status: "fenced", + }); + const changedIdentity = store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: detached.candidate.providerThreadId, + title: detached.candidate.title, + state: detached.candidate.providerState, + ...(detached.candidate.providerUpdatedAt === null + ? {} + : { providerUpdatedAt: detached.candidate.providerUpdatedAt }), + liveness: detached.candidate.liveness, + sourceProcessIdentity: replacementIdentity, + }); + expect(changedIdentity).toMatchObject({ + sourceProcessIdentity: replacementIdentity, + status: "pending", + }); + expect(store.listRecentClaudeSessionAdoptionCandidatesWithSourceIdentity({ + providerUpdatedAfter: 10, + }).map((candidate) => candidate.providerThreadId)).toContain( + changedIdentity.providerThreadId, + ); + const detachedBindingProbed = store + .updateClaudeSessionAdoptionCandidateLivenessAfterExactProbe({ + providerThreadId: changedIdentity.providerThreadId, + expectedRevision: changedIdentity.revision, + expectedSourceProcessIdentity: replacementIdentity, + liveness: "not_live", + }); + expect(detachedBindingProbed.status).toBe("pending"); + expect(store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: detached.candidate.providerThreadId, + title: detached.candidate.title, + state: detached.candidate.providerState, + ...(detached.candidate.providerUpdatedAt === null + ? {} + : { providerUpdatedAt: detached.candidate.providerUpdatedAt }), + liveness: detached.candidate.liveness, + sourceProcessIdentity: null, + }).sourceProcessIdentity).toBeNull(); + expect(() => store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: "codex-cannot-own-source-pid", + title: "Invalid Codex source", + state: "idle", + providerUpdatedAt: 10, + liveness: "not_live", + sourceProcessIdentity: identity, + })).toThrow("SESSION_ADOPTION_SOURCE_PROCESS_PROVIDER_INVALID"); + }); + + test("rejects unsafe private provider project roots without disclosing them", async () => { + const { store } = await fixture(); + const invalidRoots = [ + "relative/provider-root", + `/private/${"x".repeat(8_192)}`, + "/private/unsafe\u0000provider-root", + ] as const; + for (const [index, providerProjectRoot] of invalidRoots.entries()) { + let failure: unknown; + try { + store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: `invalid-provider-root-${index}`, + providerProjectRoot, + title: "Invalid provider root", + state: "idle", + liveness: "not_live", + }); + } catch (error: unknown) { + failure = error; + } + expect(String(failure)).toContain("Provider project root is invalid."); + expect(String(failure)).not.toContain(providerProjectRoot); + } + }); + + test("fairly bounds recent retained Claude candidates eligible for exact reprobe", async () => { + const home = await realpath(await mkdtemp(join(tmpdir(), "hra-store-claude-fair-"))); + const paths = resolveStatePaths({ homeDirectory: home, platform: "darwin" }); + await initializeStatePaths(paths); + const store = new StateStore(paths, { now: () => 1_000 }); + stores.push(store); + const identity = (pid: number) => ({ + pid, + pidDomain: "darwin" as const, + procStart: `claude-process-${pid}`, + }); + const first = store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: "a-retained-claude", + title: "First retained Claude candidate", + state: "terminal", + providerUpdatedAt: 10, + liveness: "live", + sourceProcessIdentity: identity(43_001), + }); + const second = store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: "b-retained-claude", + title: "Second retained Claude candidate", + state: "terminal", + providerUpdatedAt: 10, + liveness: "live", + sourceProcessIdentity: identity(43_002), + }); + store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: "c-old-claude", + title: "Old Claude candidate", + state: "terminal", + providerUpdatedAt: 9, + liveness: "live", + sourceProcessIdentity: identity(43_003), + }); + store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: "d-identity-free-claude", + title: "Identity-free Claude candidate", + state: "terminal", + providerUpdatedAt: 10, + liveness: "unknown", + sourceProcessIdentity: null, + }); + expect(store.listRecentClaudeSessionAdoptionCandidatesWithSourceIdentity({ + providerUpdatedAfter: 10, + limit: 1, + }).map((candidate) => candidate.providerThreadId)).toEqual([first.providerThreadId]); + store.updateClaudeSessionAdoptionCandidateLivenessAfterExactProbe({ + providerThreadId: first.providerThreadId, + expectedRevision: first.revision, + expectedSourceProcessIdentity: identity(43_001), + liveness: "live", + }); + expect(store.listRecentClaudeSessionAdoptionCandidatesWithSourceIdentity({ + providerUpdatedAfter: 10, + limit: 2, + }).map((candidate) => candidate.providerThreadId)).toEqual([ + second.providerThreadId, + first.providerThreadId, + ]); + }); + + test("retains HRA live time only for an exact-pinned Claude process identity", async () => { + const home = await realpath(await mkdtemp(join(tmpdir(), "hra-store-claude-live-time-"))); + const paths = resolveStatePaths({ homeDirectory: home, platform: "darwin" }); + await initializeStatePaths(paths); + let now = 1_000; + const store = new StateStore(paths, { now: () => now }); + stores.push(store); + const identity = { + pid: 44_001, + pidDomain: "darwin" as const, + procStart: "claude-live-time-original", + }; + const first = store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: "claude-live-time", + title: "Claude live time", + state: "terminal", + providerUpdatedAt: 10, + liveness: "live", + sourceProcessIdentity: identity, + trustedLiveObservation: true, + }); + expect(first.lastLiveObservedAt).toBe(1_000); + + now = 1_100; + const nonLiveObservation = store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: first.providerThreadId, + title: first.title, + state: first.providerState, + providerUpdatedAt: 10, + liveness: "not_live", + }); + expect(nonLiveObservation.lastLiveObservedAt).toBe(1_000); + + now = 1_200; + const nonLiveProbe = store.updateClaudeSessionAdoptionCandidateLivenessAfterExactProbe({ + providerThreadId: first.providerThreadId, + expectedRevision: nonLiveObservation.revision, + expectedSourceProcessIdentity: identity, + liveness: "not_live", + }); + expect(nonLiveProbe.lastLiveObservedAt).toBe(1_000); + + now = 1_300; + const unpinnedLiveObservation = store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: first.providerThreadId, + title: first.title, + state: first.providerState, + providerUpdatedAt: 10, + liveness: "live", + }); + expect(unpinnedLiveObservation.lastLiveObservedAt).toBe(1_000); + + now = 1_400; + const liveProbe = store.updateClaudeSessionAdoptionCandidateLivenessAfterExactProbe({ + providerThreadId: first.providerThreadId, + expectedRevision: unpinnedLiveObservation.revision, + expectedSourceProcessIdentity: identity, + liveness: "live", + }); + expect(liveProbe.lastLiveObservedAt).toBe(1_400); + + now = 1_500; + const replacement = store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: first.providerThreadId, + title: first.title, + state: first.providerState, + providerUpdatedAt: 10, + liveness: "not_live", + sourceProcessIdentity: { + pid: 44_002, + pidDomain: "darwin", + procStart: "claude-live-time-replacement", + }, + }); + expect(replacement.lastLiveObservedAt).toBeNull(); + + expect(() => store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: "claude-unpinned-trusted-live", + title: "Claude unpinned trusted live", + state: "active", + liveness: "live", + trustedLiveObservation: true, + })).toThrow("SESSION_ADOPTION_TRUSTED_LIVE_OBSERVATION_INVALID"); + expect(() => store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: "codex-non-live-trusted-observation", + title: "Codex non-live trusted observation", + state: "idle", + liveness: "not_live", + trustedLiveObservation: true, + })).toThrow("SESSION_ADOPTION_TRUSTED_LIVE_OBSERVATION_INVALID"); + + now = 1_600; + const codexLive = store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: "codex-trusted-live-time", + title: "Codex trusted live time", + state: "active", + activeTurnId: "codex-active-turn", + liveness: "live", + trustedLiveObservation: true, + }); + expect(codexLive.lastLiveObservedAt).toBe(1_600); + now = 1_700; + const codexQuiet = store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: codexLive.providerThreadId, + title: codexLive.title, + state: "idle", + liveness: "not_live", + }); + expect(codexQuiet.lastLiveObservedAt).toBe(1_600); + const codexClaiming = store.fenceSessionAdoptionCandidateForClaim({ + provider: "codex", + providerThreadId: codexQuiet.providerThreadId, + expectedRevision: codexQuiet.revision, + }); + now = 1_800; + expect(store.updateCodexSessionAdoptionCandidateLivenessAfterExactRead({ + providerThreadId: codexClaiming.providerThreadId, + expectedRevision: codexClaiming.revision, + liveness: "live", + trustedLiveObservation: true, + }).lastLiveObservedAt).toBe(1_800); + }); + + test("excludes current Claude discoveries before bounding retained reprobes", async () => { + const home = await realpath(await mkdtemp(join(tmpdir(), "hra-store-claude-retained-"))); + const paths = resolveStatePaths({ homeDirectory: home, platform: "darwin" }); + await initializeStatePaths(paths); + const store = new StateStore(paths, { now: () => 2_000 }); + stores.push(store); + const identity = (pid: number) => ({ + pid, + pidDomain: "darwin" as const, + procStart: `claude-retained-${pid}`, + }); + const vanished = store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: "z-vanished-retained-claude", + title: "Vanished retained Claude", + state: "terminal", + providerUpdatedAt: 10, + liveness: "live", + sourceProcessIdentity: identity(45_000), + trustedLiveObservation: true, + }); + const vanishedNonLive = store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: vanished.providerThreadId, + title: vanished.title, + state: vanished.providerState, + providerUpdatedAt: 10, + liveness: "not_live", + }); + expect(vanishedNonLive.lastLiveObservedAt).toBe(2_000); + + const currentProviderThreadIds: string[] = []; + for (let index = 0; index < 125; index += 1) { + const providerThreadId = `a-current-claude-${String(index).padStart(3, "0")}`; + currentProviderThreadIds.push(providerThreadId); + store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId, + title: `Current Claude ${index}`, + state: "terminal", + providerUpdatedAt: 10, + liveness: "live", + sourceProcessIdentity: identity(45_001 + index), + trustedLiveObservation: true, + }); + } + store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: "a-no-exact-live-proof", + title: "No exact live proof", + state: "terminal", + providerUpdatedAt: 10, + liveness: "live", + }); + + expect(store.listRetainedClaudeSessionAdoptionCandidatesWithSourceIdentity({ + excludeProviderThreadIds: currentProviderThreadIds, + liveObservedAfter: 2_000, + limit: 1, + }).map((candidate) => candidate.providerThreadId)).toEqual([ + vanished.providerThreadId, + ]); + expect(store.listRetainedClaudeSessionAdoptionCandidatesWithSourceIdentity({ + excludeProviderThreadIds: currentProviderThreadIds, + liveObservedAfter: 2_001, + })).toEqual([]); + expect(() => store.listRetainedClaudeSessionAdoptionCandidatesWithSourceIdentity({ + excludeProviderThreadIds: Array.from({ length: 201 }, () => "duplicate-thread"), + liveObservedAfter: 0, + })).toThrow(); + expect(() => store.listRetainedClaudeSessionAdoptionCandidatesWithSourceIdentity({ + excludeProviderThreadIds: [], + liveObservedAfter: 0, + limit: 201, + })).toThrow(); + }); + + test("orders retained Claude reprobes by attempt age then HRA live age", async () => { + const home = await realpath(await mkdtemp(join(tmpdir(), "hra-store-claude-order-"))); + const paths = resolveStatePaths({ homeDirectory: home, platform: "darwin" }); + await initializeStatePaths(paths); + let now = 1_000; + const store = new StateStore(paths, { now: () => now }); + stores.push(store); + const observe = (providerThreadId: string, pid: number) => + store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId, + title: providerThreadId, + state: "terminal", + liveness: "live", + sourceProcessIdentity: { + pid, + pidDomain: "darwin", + procStart: `retained-order-${pid}`, + }, + trustedLiveObservation: true, + }); + const neverAttemptedOld = observe("z-never-attempted-old", 47_001); + now = 2_000; + const neverAttemptedNew = observe("a-never-attempted-new", 47_002); + now = 3_000; + const attemptedOld = observe("a-attempted-old", 47_003); + now = 4_000; + const attemptedNew = observe("z-attempted-new", 47_004); + now = 5_000; + store.fenceSessionAdoptionCandidateForClaim({ + provider: "claude", + providerThreadId: attemptedOld.providerThreadId, + expectedRevision: attemptedOld.revision, + }); + now = 6_000; + store.fenceSessionAdoptionCandidateForClaim({ + provider: "claude", + providerThreadId: attemptedNew.providerThreadId, + expectedRevision: attemptedNew.revision, + }); + + expect(store.listRetainedClaudeSessionAdoptionCandidatesWithSourceIdentity({ + excludeProviderThreadIds: [], + liveObservedAfter: 0, + limit: 4, + }).map((candidate) => candidate.providerThreadId)).toEqual([ + neverAttemptedOld.providerThreadId, + neverAttemptedNew.providerThreadId, + attemptedOld.providerThreadId, + attemptedNew.providerThreadId, + ]); + }); + + test("claims Claude only after liveness ends and fences profile removal", async () => { + const { store } = await fixture(); + const profile = signInProfile(store, "Claude adopter", "claude-adopter@example.com"); + store.setSessionAdoptionPolicy({ provider: "claude", profileId: profile.id }); + const live = store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: "claude-personal-thread", + title: "Claude terminal session", + state: "terminal", + providerUpdatedAt: 10, + liveness: "live", + }); + const processIdentity = { + pid: 42_001, + pidDomain: "darwin" as const, + procStart: "Fri Sep 4 12:00:00 2026", + }; + store.fenceSessionAdoptionCandidateForClaim({ + provider: "claude", + providerThreadId: live.providerThreadId, + expectedRevision: live.revision, + }); + store.recordClaimedClaudeProcessAuthority({ + providerThreadId: live.providerThreadId, + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "personal", + identity: processIdentity, + }); + const claimedLive = store.listSessionAdoptionCandidates({ provider: "claude" })[0]; + if (claimedLive === undefined) throw new Error("Expected the claimed Claude candidate."); + expect(() => store.adoptSessionCandidate({ + provider: "claude", + providerThreadId: live.providerThreadId, + expectedCandidateRevision: claimedLive.revision, + profileId: profile.id, + profileGeneration: profile.processGeneration, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + fastEnabled: false, + runtimeProfile: claudeAdoptionRuntimeProfile(profile), + providerAccountKey: testProviderAccountKey("claude"), + claudeProcessIdentity: processIdentity, + })).toThrow("SESSION_ADOPTION_SOURCE_STILL_LIVE"); + + const stopped = store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: "claude-personal-thread", + title: "Claude terminal session", + state: "terminal", + providerUpdatedAt: 10, + liveness: "not_live", + }); + const adopted = store.adoptSessionCandidate({ + provider: "claude", + providerThreadId: stopped.providerThreadId, + expectedCandidateRevision: stopped.revision, + profileId: profile.id, + profileGeneration: profile.processGeneration, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + fastEnabled: false, + runtimeProfile: claudeAdoptionRuntimeProfile(profile), + providerAccountKey: testProviderAccountKey("claude"), + claudeProcessIdentity: processIdentity, + }); + expect(adopted.session.state).toBe("terminal"); + expect(() => store.removeProfile(profile.id)) + .toThrow("SESSION_ADOPTION_PROFILE_ACTIVE_BINDINGS"); + + expect(store.setSessionAdoptionPolicy({ + provider: "claude", + profileId: null, + })).toMatchObject({ enabled: false, profileId: null }); + expect(store.readSessionPersonalRuntimeBinding(adopted.session.id)).toMatchObject({ + state: "active", + }); + expect(store.readClaudeProcessAuthority({ + providerThreadId: live.providerThreadId, + profileId: profile.id, + runtimeScope: "personal", + })).toMatchObject({ state: "bound", sessionId: adopted.session.id }); + + const claimedProcess = store.readClaudeProcessAuthority({ + providerThreadId: live.providerThreadId, + profileId: profile.id, + runtimeScope: "personal", + }); + if (claimedProcess === null) throw new Error("Expected Claude process custody."); + const releasingProcess = store.beginClaudeProcessAuthorityRelease({ + providerThreadId: claimedProcess.providerThreadId, + profileId: claimedProcess.profileId, + runtimeScope: claimedProcess.runtimeScope, + expectedRevision: claimedProcess.revision, + identity: claimedProcess.identity, + }); + store.completeClaudeProcessAuthorityRelease({ + providerThreadId: releasingProcess.providerThreadId, + profileId: releasingProcess.profileId, + runtimeScope: releasingProcess.runtimeScope, + expectedRevision: releasingProcess.revision, + identity: releasingProcess.identity, + }); + store.detachPersonalSession({ sessionId: adopted.session.id }); + store.removeProfile(profile.id); + expect(store.requireProfileById(profile.id, { includeRemoved: true }).state).toBe("removed"); + expect(store.readSessionAdoptionPolicy("claude")).toMatchObject({ + enabled: false, + profileId: null, + }); + }); + + test("adopts and queues personal Claude while the sibling Codex profile is signed out", async () => { + const { store } = await fixture(); + const profile = store.createProfile("Signed-out Codex Claude adopter"); + expect(profile).toMatchObject({ processGeneration: 0, state: "signed_out" }); + const unproven = store.createSession({ + profileId: profile.id, + provider: "claude", + preset: "fable-max", + fastEnabled: false, + }); + expect(() => store.bindSessionProviderAccountAuthority({ + sessionId: unproven.id, + provider: "claude", + runtimeScope: "managed", + accountKey: testProviderAccountKey("codex"), + })).toThrow("SESSION_PROVIDER_ACCOUNT_AUTHORITY_KEY_MISMATCH"); + expect(store.setSessionAdoptionPolicy({ + provider: "claude", + profileId: profile.id, + })).toMatchObject({ enabled: true, profileId: profile.id, provider: "claude" }); + const candidate = store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: "signed-out-codex-claude-personal-thread", + title: "Claude remains authoritative", + state: "idle", + providerUpdatedAt: 10, + liveness: "not_live", + }); + store.fenceSessionAdoptionCandidateForClaim({ + provider: "claude", + providerThreadId: "signed-out-codex-claude-personal-thread", + expectedRevision: candidate.revision, + }); + const processIdentity = { + pid: 42_099, + pidDomain: "darwin" as const, + procStart: "Fri Sep 4 12:09:00 2026", + }; + store.recordClaimedClaudeProcessAuthority({ + providerThreadId: candidate.providerThreadId, + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "personal", + identity: processIdentity, + }); + const claimed = store.listSessionAdoptionCandidates({ provider: "claude" }) + .find((entry) => entry.providerThreadId === candidate.providerThreadId); + if (claimed === undefined) throw new Error("Expected the claimed Claude candidate."); + const adopted = store.adoptSessionCandidate({ + provider: "claude", + providerThreadId: candidate.providerThreadId, + expectedCandidateRevision: claimed.revision, + profileId: profile.id, + profileGeneration: profile.processGeneration, + preset: "fable-max", + requirement: presetRequirements["fable-max"], + fastEnabled: false, + runtimeProfile: claudeAdoptionRuntimeProfile(profile), + providerAccountKey: testProviderAccountKey("claude"), + claudeProcessIdentity: processIdentity, + }); + expect(store.sessionAccountAuthorityMatches(adopted.session.id, profile.id)).toBe(true); + expect(store.listLocalSessionPage({ + profileId: profile.id, + after: null, + limit: 10, + requireCurrentAccountAuthority: true, + }).sessions.map((session) => session.id)).toEqual([adopted.session.id]); + expect(store.enqueue(adopted.session.id, "Continue from the schedule")).toMatchObject({ + sessionId: adopted.session.id, + state: "pending", + }); + }); + + test("restarts a generation-zero adopted Claude session after exact process release", async () => { + const { store } = await fixture(); + const profile = store.createProfile("Generation-zero Claude restart"); + store.setSessionAdoptionPolicy({ provider: "claude", profileId: profile.id }); + const session = adoptPersonalClaudeTestSession(store, profile); + const authority = store.readClaudeProcessAuthority({ + providerThreadId: session.providerThreadId as string, + profileId: profile.id, + runtimeScope: "personal", + }); + if (authority === null) throw new Error("Expected Claude process authority."); + const releasing = store.beginClaudeProcessAuthorityRelease({ + providerThreadId: authority.providerThreadId, + profileId: authority.profileId, + runtimeScope: authority.runtimeScope, + expectedRevision: authority.revision, + identity: authority.identity, + }); + store.completeClaudeProcessAuthorityRelease({ + providerThreadId: releasing.providerThreadId, + profileId: releasing.profileId, + runtimeScope: releasing.runtimeScope, + expectedRevision: releasing.revision, + identity: releasing.identity, + }); + + expect(store.nextDaemonGeneration(`boot_${"0".repeat(32)}`)).toBe(1); + expect(store.requireProfileById(profile.id)).toMatchObject({ + processGeneration: 1, + state: "signed_out", + }); + expect(store.requireSession(session.id)).toMatchObject({ state: "idle" }); + expect(store.readSessionPersonalRuntimeBinding(session.id)).toMatchObject({ + state: "active", + }); + }); + + test("carries generation-zero personal and scoped revocations through restart", async () => { + const { store } = await fixture(); + const globalProfile = store.createProfile("Generation-zero global revocation"); + store.setSessionAdoptionPolicy({ provider: "claude", profileId: globalProfile.id }); + const globalSession = adoptPersonalClaudeTestSession(store, globalProfile); + const global = store.beginProfilePersonalAuthorityRevocation({ + profileId: globalProfile.id, + expectedGeneration: globalProfile.processGeneration, + workStore: createRevocationWorkStore(store), + }); + const releasePersonalClaudeAuthority = ( + targetStore: StateStore, + profile: Readonly<{ id: string }>, + session: Readonly<{ providerThreadId?: string | null }>, + ): void => { + const authority = targetStore.readClaudeProcessAuthority({ + providerThreadId: session.providerThreadId as string, + profileId: profile.id, + runtimeScope: "personal", + }); + if (authority === null) throw new Error("Expected Claude process authority."); + const releasing = targetStore.beginClaudeProcessAuthorityRelease({ + providerThreadId: authority.providerThreadId, + profileId: authority.profileId, + runtimeScope: authority.runtimeScope, + expectedRevision: authority.revision, + identity: authority.identity, + }); + targetStore.completeClaudeProcessAuthorityRelease({ + providerThreadId: releasing.providerThreadId, + profileId: releasing.profileId, + runtimeScope: releasing.runtimeScope, + expectedRevision: releasing.revision, + identity: releasing.identity, + }); + }; + releasePersonalClaudeAuthority(store, globalProfile, globalSession); + + expect(store.nextDaemonGeneration(`boot_${"1".repeat(32)}`)).toBe(1); + const rolledGlobal = store.requireProfileById(globalProfile.id); + expect(store.readProfilePersonalAuthorityRevocation(globalProfile.id)).toMatchObject({ + profileGeneration: rolledGlobal.processGeneration, + revision: global.revocation.revision + 1, + state: "releasing", + }); + expect(rolledGlobal.processGeneration).toBe(1); + store.completePersonalSessionDetach({ sessionId: globalSession.id, archive: false }); + expect(store.completeProfilePersonalAuthorityRevocation({ + profileId: globalProfile.id, + expectedGeneration: rolledGlobal.processGeneration, + })).toMatchObject({ processGeneration: 2, state: "signed_out" }); + + const { store: scopedStore } = await fixture(); + const scopedProfile = scopedStore.createProfile("Generation-zero scoped revocation"); + scopedStore.setSessionAdoptionPolicy({ provider: "claude", profileId: scopedProfile.id }); + const scopedSession = adoptPersonalClaudeTestSession(scopedStore, scopedProfile); + const scoped = scopedStore.beginProviderRuntimeAccountRevocation({ + profileId: scopedProfile.id, + expectedGeneration: scopedProfile.processGeneration, + provider: "claude", + runtimeScope: "personal", + currentAccountKey: null, + workStore: createRevocationWorkStore(scopedStore), + }); + releasePersonalClaudeAuthority(scopedStore, scopedProfile, scopedSession); + + expect(scopedStore.nextDaemonGeneration(`boot_${"2".repeat(32)}`)).toBe(1); + const rolledScoped = scopedStore.requireProfileById(scopedProfile.id); + expect(scopedStore.readProviderRuntimeAccountRevocation({ + profileId: scopedProfile.id, + provider: "claude", + runtimeScope: "personal", + })).toMatchObject({ + profileGeneration: rolledScoped.processGeneration, + revision: scoped.revocation.revision + 1, + state: "releasing", + }); + expect(rolledScoped.processGeneration).toBe(1); + + scopedStore.completePersonalSessionDetach({ sessionId: scopedSession.id, archive: false }); + const rolledScopedRevocation = scopedStore.readProviderRuntimeAccountRevocation({ + profileId: scopedProfile.id, + provider: "claude", + runtimeScope: "personal", + }); + if (rolledScopedRevocation === null) throw new Error("Expected scoped revocation."); + expect(scopedStore.completeProviderRuntimeAccountRevocation({ + profileId: scopedProfile.id, + expectedGeneration: rolledScoped.processGeneration, + provider: "claude", + runtimeScope: "personal", + expectedRevision: rolledScopedRevocation.revision, + })).toMatchObject({ state: "completed" }); + }); + + test("advances generation-zero revocations even before a session exists", async () => { + const { store } = await fixture(); + const globalProfile = store.createProfile("Generation-zero empty global revocation"); + const scopedProfile = store.createProfile("Generation-zero empty scoped revocation"); + const global = store.beginProfilePersonalAuthorityRevocation({ + profileId: globalProfile.id, + expectedGeneration: globalProfile.processGeneration, + workStore: createRevocationWorkStore(store), + }); + const scoped = store.beginProviderRuntimeAccountRevocation({ + profileId: scopedProfile.id, + expectedGeneration: scopedProfile.processGeneration, + provider: "claude", + runtimeScope: "personal", + currentAccountKey: null, + workStore: createRevocationWorkStore(store), + }); + expect(global.sessionIds).toEqual([]); + expect(scoped.sessionIds).toEqual([]); + + expect(store.nextDaemonGeneration(`boot_${"3".repeat(32)}`)).toBe(1); + const rolledGlobal = store.requireProfileById(globalProfile.id); + const rolledScoped = store.requireProfileById(scopedProfile.id); + expect(rolledGlobal.processGeneration).toBe(1); + expect(rolledScoped.processGeneration).toBe(1); + expect(store.readProfilePersonalAuthorityRevocation(globalProfile.id)).toMatchObject({ + profileGeneration: 1, + revision: global.revocation.revision + 1, + state: "releasing", + }); + expect(store.readProviderRuntimeAccountRevocation({ + profileId: scopedProfile.id, + provider: "claude", + runtimeScope: "personal", + })).toMatchObject({ + profileGeneration: 1, + revision: scoped.revocation.revision + 1, + state: "releasing", + }); + + expect(store.completeProfilePersonalAuthorityRevocation({ + profileId: globalProfile.id, + expectedGeneration: rolledGlobal.processGeneration, + })).toMatchObject({ processGeneration: 2, state: "signed_out" }); + const rolledScopedRevocation = store.readProviderRuntimeAccountRevocation({ + profileId: scopedProfile.id, + provider: "claude", + runtimeScope: "personal", + }); + if (rolledScopedRevocation === null) throw new Error("Expected scoped revocation."); + expect(store.completeProviderRuntimeAccountRevocation({ + profileId: scopedProfile.id, + expectedGeneration: rolledScoped.processGeneration, + provider: "claude", + runtimeScope: "personal", + expectedRevision: rolledScopedRevocation.revision, + })).toMatchObject({ profileGeneration: 1, state: "completed" }); + }); + + test("scopes Claude process authority by provider home and releases only the exact row", async () => { + const { store } = await fixture(); + const first = signInProfile(store, "First Claude home", "first-claude-home@example.com"); + const second = signInProfile(store, "Second Claude home", "second-claude-home@example.com"); + const sharedThreadId = "same-opaque-claude-thread"; + const records = [ + store.recordClaimedClaudeProcessAuthority({ + providerThreadId: sharedThreadId, + profileId: first.id, + profileGeneration: first.processGeneration, + runtimeScope: "managed", + identity: { pid: 51_001, pidDomain: "darwin", procStart: "managed-first" }, + }), + store.recordClaimedClaudeProcessAuthority({ + providerThreadId: sharedThreadId, + profileId: first.id, + profileGeneration: first.processGeneration, + runtimeScope: "personal", + identity: { pid: 51_002, pidDomain: "darwin", procStart: "personal-first" }, + }), + store.recordClaimedClaudeProcessAuthority({ + providerThreadId: sharedThreadId, + profileId: second.id, + profileGeneration: second.processGeneration, + runtimeScope: "personal", + identity: { pid: 51_003, pidDomain: "darwin", procStart: "personal-second" }, + }), + ]; + expect(records.map((record) => [record.runtimeScope, record.profileId])).toEqual([ + ["managed", first.id], + ["personal", first.id], + ["personal", second.id], + ]); + + const selected = records[2]; + if (selected === undefined) throw new Error("Expected a selected authority row."); + const releasing = store.beginClaudeProcessAuthorityRelease({ + providerThreadId: selected.providerThreadId, + profileId: selected.profileId, + runtimeScope: selected.runtimeScope, + expectedRevision: selected.revision, + identity: selected.identity, + }); + store.completeClaudeProcessAuthorityRelease({ + providerThreadId: releasing.providerThreadId, + profileId: releasing.profileId, + runtimeScope: releasing.runtimeScope, + expectedRevision: releasing.revision, + identity: releasing.identity, + }); + expect(store.readClaudeProcessAuthority({ + providerThreadId: sharedThreadId, + profileId: second.id, + runtimeScope: "personal", + })?.state).toBe("released"); + expect(store.readClaudeProcessAuthority({ + providerThreadId: sharedThreadId, + profileId: first.id, + runtimeScope: "managed", + })?.state).toBe("claimed"); + expect(store.readClaudeProcessAuthority({ + providerThreadId: sharedThreadId, + profileId: first.id, + runtimeScope: "personal", + })?.state).toBe("claimed"); + }); + + test("stages one-shot Claude launch intent authority and fences restaging ABA", async () => { + const { store } = await fixture(); + const profile = signInProfile( + store, + "Claude launch intent", + "claude-launch-intent@example.com", + ); + const firstSession = upsertProvenTestSession(store, { + profileId: profile.id, + provider: "claude", + providerThreadId: "launch-intent-thread", + preset: "fable-max", + fastEnabled: false, + state: "idle", + }); + const secondSession = upsertProvenTestSession(store, { + profileId: profile.id, + provider: "claude", + providerThreadId: "second-launch-for-same-session", + preset: "fable-max", + fastEnabled: false, + state: "idle", + }); + const staged = store.stageClaudeProcessLaunchIntent({ + providerThreadId: "launch-intent-thread", + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "managed", + providerAccountKey: testProviderAccountKey("claude"), + sessionId: firstSession.id, + }); + expect(staged).toMatchObject({ + profileId: profile.id, + profileGeneration: profile.processGeneration, + providerThreadId: "launch-intent-thread", + revision: 1, + runtimeScope: "managed", + sessionId: firstSession.id, + }); + expect(staged.intentId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u, + ); + expect(() => store.stageClaudeProcessLaunchIntent({ + providerThreadId: staged.providerThreadId, + profileId: staged.profileId, + profileGeneration: staged.profileGeneration, + runtimeScope: staged.runtimeScope, + providerAccountKey: testProviderAccountKey("claude"), + sessionId: firstSession.id, + })).toThrow("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_CONFLICT"); + expect(store.readClaudeProcessLaunchIntent({ + providerThreadId: staged.providerThreadId, + profileId: staged.profileId, + runtimeScope: staged.runtimeScope, + })).toEqual(staged); + expect(store.listClaudeProcessLaunchIntents()).toEqual([staged]); + expect(store.profileHasClaudeProcessLaunchIntents( + profile.id, + profile.processGeneration, + )).toBe(true); + + expect(() => store.stageClaudeProcessLaunchIntent({ + providerThreadId: staged.providerThreadId, + profileId: staged.profileId, + profileGeneration: staged.profileGeneration, + runtimeScope: staged.runtimeScope, + providerAccountKey: testProviderAccountKey("claude"), + sessionId: secondSession.id, + })).toThrow("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_SESSION_AUTHORITY_MISMATCH"); + expect(() => store.stageClaudeProcessLaunchIntent({ + providerThreadId: "second-launch-for-same-session", + profileId: staged.profileId, + profileGeneration: staged.profileGeneration, + runtimeScope: staged.runtimeScope, + providerAccountKey: testProviderAccountKey("claude"), + sessionId: firstSession.id, + })).toThrow("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_SESSION_AUTHORITY_MISMATCH"); + expect(() => store.stageClaudeProcessLaunchIntent({ + providerThreadId: "stale-launch-intent", + profileId: profile.id, + profileGeneration: profile.processGeneration + 1, + runtimeScope: "managed", + providerAccountKey: testProviderAccountKey("claude"), + })).toThrow("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_PROFILE_STALE"); + const signedOut = store.createProfile("Signed-out Claude launch"); + expect(() => store.stageClaudeProcessLaunchIntent({ + providerThreadId: "signed-out-launch-intent", + profileId: signedOut.id, + profileGeneration: signedOut.processGeneration, + runtimeScope: "managed", + providerAccountKey: testProviderAccountKey("claude"), + })).toThrow("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_ADOPTION_AUTHORITY_MISMATCH"); + expect(() => store.stageClaudeProcessLaunchIntent({ + providerThreadId: "missing-personal-adoption-candidate", + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "personal", + providerAccountKey: testProviderAccountKey("claude"), + })).toThrow("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_ADOPTION_AUTHORITY_MISMATCH"); + store.setSessionAdoptionPolicy({ provider: "claude", profileId: profile.id }); + const adoptionCandidate = store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: "personal-adoption-launch-intent", + title: "Personal adoption launch intent", + state: "idle", + providerUpdatedAt: 10, + liveness: "not_live", + }); + store.fenceSessionAdoptionCandidateForClaim({ + provider: "claude", + providerThreadId: adoptionCandidate.providerThreadId, + expectedRevision: adoptionCandidate.revision, + }); + const adoptionIntent = store.stageClaudeProcessLaunchIntent({ + providerThreadId: adoptionCandidate.providerThreadId, + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "personal", + providerAccountKey: testProviderAccountKey("claude"), + }); + expect(adoptionIntent.sessionId).toBeNull(); + store.cancelClaudeProcessLaunchIntent({ + providerThreadId: adoptionIntent.providerThreadId, + profileId: adoptionIntent.profileId, + profileGeneration: adoptionIntent.profileGeneration, + runtimeScope: adoptionIntent.runtimeScope, + intentId: adoptionIntent.intentId, + expectedRevision: adoptionIntent.revision, + }); + + expect(() => store.cancelClaudeProcessLaunchIntent({ + providerThreadId: staged.providerThreadId, + profileId: staged.profileId, + profileGeneration: staged.profileGeneration, + runtimeScope: staged.runtimeScope, + intentId: "00000000-0000-4000-8000-000000000001", + expectedRevision: staged.revision, + })).toThrow("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_CONFLICT"); + expect(store.cancelClaudeProcessLaunchIntent({ + providerThreadId: staged.providerThreadId, + profileId: staged.profileId, + profileGeneration: staged.profileGeneration, + runtimeScope: staged.runtimeScope, + intentId: staged.intentId, + expectedRevision: staged.revision, + })).toEqual(staged); + const restaged = store.stageClaudeProcessLaunchIntent({ + providerThreadId: staged.providerThreadId, + profileId: staged.profileId, + profileGeneration: staged.profileGeneration, + runtimeScope: staged.runtimeScope, + providerAccountKey: testProviderAccountKey("claude"), + sessionId: firstSession.id, + }); + expect(restaged.intentId).not.toBe(staged.intentId); + expect(() => store.cancelClaudeProcessLaunchIntent({ + providerThreadId: staged.providerThreadId, + profileId: staged.profileId, + profileGeneration: staged.profileGeneration, + runtimeScope: staged.runtimeScope, + intentId: staged.intentId, + expectedRevision: staged.revision, + })).toThrow("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_CONFLICT"); + expect(store.readClaudeProcessLaunchIntent({ + providerThreadId: restaged.providerThreadId, + profileId: restaged.profileId, + runtimeScope: restaged.runtimeScope, + })).toEqual(restaged); + + const liveSession = upsertProvenTestSession(store, { + profileId: profile.id, + provider: "claude", + providerThreadId: "already-live-launch-intent", + preset: "fable-max", + fastEnabled: false, + state: "idle", + }); + const process = store.recordClaimedClaudeProcessAuthority({ + providerThreadId: "already-live-launch-intent", + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "managed", + sessionId: liveSession.id, + identity: { + pid: 51_050, + pidDomain: "darwin", + procStart: "already-live-before-launch-intent", + }, + }); + expect(() => store.stageClaudeProcessLaunchIntent({ + providerThreadId: process.providerThreadId, + profileId: process.profileId, + profileGeneration: process.profileGeneration, + runtimeScope: process.runtimeScope, + providerAccountKey: testProviderAccountKey("claude"), + sessionId: liveSession.id, + })).toThrow("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_PROCESS_LIVE"); + }); + + test("atomically hands exact Claude launch intent authority to exact process custody", async () => { + const { store } = await fixture(); + const profile = signInProfile( + store, + "Claude launch handoff", + "claude-launch-handoff@example.com", + ); + const session = upsertProvenTestSession(store, { + profileId: profile.id, + provider: "claude", + providerThreadId: "launch-handoff-thread", + preset: "fable-max", + fastEnabled: false, + state: "idle", + }); + const otherSession = store.createSession({ + profileId: profile.id, + provider: "claude", + preset: "fable-max", + fastEnabled: false, + }); + const intent = store.stageClaudeProcessLaunchIntent({ + providerThreadId: "launch-handoff-thread", + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "managed", + providerAccountKey: testProviderAccountKey("claude"), + sessionId: session.id, + }); + const collidingIdentity = { + pid: 51_051, + pidDomain: "darwin" as const, + procStart: "launch-handoff-collision", + }; + store.recordClaimedClaudeProcessAuthority({ + providerThreadId: "launch-handoff-existing-process", + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "managed", + identity: collidingIdentity, + }); + const claim = (overrides: Partial[0]> = {}) => store.recordClaimedClaudeProcessAuthority({ + providerThreadId: intent.providerThreadId, + profileId: intent.profileId, + profileGeneration: intent.profileGeneration, + runtimeScope: intent.runtimeScope, + ...(intent.sessionId === null ? {} : { sessionId: intent.sessionId }), + identity: { + pid: 51_052, + pidDomain: "darwin", + procStart: "launch-handoff-exact-process", + }, + expectedLaunchIntentId: intent.intentId, + expectedLaunchIntentRevision: intent.revision, + ...overrides, + }); + + expect(() => claim({ + expectedLaunchIntentId: "00000000-0000-4000-8000-000000000002", + })).toThrow("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_CONFLICT"); + expect(() => claim({ sessionId: otherSession.id })) + .toThrow("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_CONFLICT"); + expect(() => claim({ identity: collidingIdentity })).toThrow(); + expect(store.readClaudeProcessAuthority({ + providerThreadId: intent.providerThreadId, + profileId: intent.profileId, + runtimeScope: intent.runtimeScope, + })).toBeNull(); + expect(store.readClaudeProcessLaunchIntent({ + providerThreadId: intent.providerThreadId, + profileId: intent.profileId, + runtimeScope: intent.runtimeScope, + })).toEqual(intent); + + const claimed = claim(); + expect(claimed).toMatchObject({ + providerThreadId: intent.providerThreadId, + profileId: intent.profileId, + profileGeneration: intent.profileGeneration, + runtimeScope: intent.runtimeScope, + sessionId: session.id, + state: "claimed", + identity: { + pid: 51_052, + pidDomain: "darwin", + procStart: "launch-handoff-exact-process", + }, + }); + expect(store.readClaudeProcessLaunchIntent({ + providerThreadId: intent.providerThreadId, + profileId: intent.profileId, + runtimeScope: intent.runtimeScope, + })).toBeNull(); + expect(() => claim()).toThrow("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_CONFLICT"); + expect(store.readClaudeProcessAuthority({ + providerThreadId: intent.providerThreadId, + profileId: intent.profileId, + runtimeScope: intent.runtimeScope, + })).toEqual(claimed); + }); + + test("fences Claude launch intents across completed scoped account revocations", async () => { + const { store } = await fixture(); + const profile = signInProfile( + store, + "Claude launch account fence", + "claude-launch-account-fence@example.com", + ); + const accountKey = testProviderAccountKey("claude"); + const replacementKey = namedProviderAccountKey("claude", "replacement-launch-account"); + const selector = { + profileId: profile.id, + provider: "claude" as const, + runtimeScope: "managed" as const, + }; + + const unavailable = store.beginProviderRuntimeAccountRevocation({ + ...selector, + expectedGeneration: profile.processGeneration, + currentAccountKey: null, + workStore: createRevocationWorkStore(store), + }); + store.completeProviderRuntimeAccountRevocation({ + ...selector, + expectedGeneration: profile.processGeneration, + expectedRevision: unavailable.revocation.revision, + }); + expect(() => store.stageClaudeProcessLaunchIntent({ + providerThreadId: "completed-null-launch", + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "managed", + providerAccountKey: accountKey, + })).toThrow("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_ACCOUNT_STALE"); + expect(() => store.clearCompletedProviderRuntimeAccountRevocation({ + ...selector, + expectedGeneration: profile.processGeneration, + currentAccountKey: accountKey, + })).toThrow("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_NOT_RECONCILED"); + + const reconciled = store.beginProviderRuntimeAccountRevocation({ + ...selector, + expectedGeneration: profile.processGeneration, + currentAccountKey: accountKey, + workStore: createRevocationWorkStore(store), + }); + store.completeProviderRuntimeAccountRevocation({ + ...selector, + expectedGeneration: profile.processGeneration, + expectedRevision: reconciled.revocation.revision, + }); + store.clearCompletedProviderRuntimeAccountRevocation({ + ...selector, + expectedGeneration: profile.processGeneration, + currentAccountKey: accountKey, + }); + expect(store.readProviderRuntimeAccountRevocation(selector)).toBeNull(); + + const exactSession = upsertProvenTestSession(store, { + profileId: profile.id, + provider: "claude", + providerThreadId: "completed-mismatch-launch", + preset: "fable-max", + fastEnabled: false, + state: "idle", + providerAccountKey: accountKey, + }); + const intent = store.stageClaudeProcessLaunchIntent({ + providerThreadId: "completed-mismatch-launch", + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "managed", + providerAccountKey: accountKey, + sessionId: exactSession.id, + }); + const mismatched = store.beginProviderRuntimeAccountRevocation({ + ...selector, + expectedGeneration: profile.processGeneration, + currentAccountKey: replacementKey, + workStore: createRevocationWorkStore(store), + }); + // Simulate a pre-fix crash state that incorrectly marked the fence complete + // while the launch intent survived. Both stage and claim must still fail. + const raw = new Database(store.paths.database, { create: false, strict: true }); + try { + raw.query( + `UPDATE provider_runtime_account_revocations + SET state='completed',revision=revision+1, + updated_at=updated_at+1,completed_at=updated_at+1 + WHERE profile_id=? AND provider='claude' AND runtime_scope='managed' + AND revision=? AND state='releasing'`, + ).run(profile.id, mismatched.revocation.revision); + } finally { + raw.close(false); + } + expect(() => store.stageClaudeProcessLaunchIntent({ + providerThreadId: "second-completed-mismatch-launch", + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "managed", + providerAccountKey: accountKey, + })).toThrow("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_ACCOUNT_STALE"); + expect(() => store.recordClaimedClaudeProcessAuthority({ + providerThreadId: intent.providerThreadId, + profileId: intent.profileId, + profileGeneration: intent.profileGeneration, + runtimeScope: intent.runtimeScope, + sessionId: exactSession.id, + identity: { + pid: 51_053, + pidDomain: "darwin", + procStart: "completed-mismatch-launch-process", + }, + expectedLaunchIntentId: intent.intentId, + expectedLaunchIntentRevision: intent.revision, + })).toThrow("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_ACCOUNT_STALE"); + expect(store.readClaudeProcessAuthority({ + providerThreadId: intent.providerThreadId, + profileId: intent.profileId, + runtimeScope: intent.runtimeScope, + })).toBeNull(); + expect(store.readClaudeProcessLaunchIntent({ + providerThreadId: intent.providerThreadId, + profileId: intent.profileId, + runtimeScope: intent.runtimeScope, + })).toEqual(intent); + }); + + test("launch intents isolate Claude authority from Codex state and identity changes", async () => { + const { store } = await fixture(); + const profile = signInProfile( + store, + "Claude launch guards", + "claude-launch-guards@example.com", + ); + const otherProfile = signInProfile( + store, + "Other Claude launch guards", + "other-claude-launch-guards@example.com", + ); + store.setSessionAdoptionPolicy({ provider: "claude", profileId: profile.id }); + const candidate = store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: "launch-guard-thread", + title: "Claude launch guard candidate", + state: "idle", + providerUpdatedAt: 10, + liveness: "not_live", + }); + store.fenceSessionAdoptionCandidateForClaim({ + provider: "claude", + providerThreadId: candidate.providerThreadId, + expectedRevision: candidate.revision, + }); + const intent = store.stageClaudeProcessLaunchIntent({ + providerThreadId: "launch-guard-thread", + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "personal", + providerAccountKey: testProviderAccountKey("claude"), + }); + + expect(() => store.setSessionAdoptionPolicy({ + provider: "claude", + profileId: null, + })).toThrow("SESSION_ADOPTION_POLICY_UNSETTLED_CLAIM"); + expect(() => store.setSessionAdoptionPolicy({ + provider: "claude", + profileId: otherProfile.id, + })).toThrow("SESSION_ADOPTION_POLICY_UNSETTLED_CLAIM"); + expect(store.readSessionAdoptionPolicy("claude")?.profileId).toBe(profile.id); + + expect(store.setProfileState( + profile.id, + profile.processGeneration, + "signed_out", + )).toBe(true); + expect(() => store.removeProfile(profile.id)).toThrow(); + expect(() => store.advanceProfileGeneration( + profile.id, + profile.processGeneration, + )).toThrow("live session controllers must release before account generation changes"); + expect(store.setProfileState( + profile.id, + profile.processGeneration, + "signed_in", + )).toBe(true); + expect(store.setProfileState( + profile.id, + profile.processGeneration, + "signed_in", + { email: "replacement-launch-identity@example.com", plan: "Plus" }, + )).toBe(true); + expect(() => store.setProfileState( + profile.id, + profile.processGeneration, + "recovery_required", + { email: "claude-launch-guards@example.com", plan: "Plus" }, + )).toThrow("controller revocation must be staged before account recovery"); + + const revocation = store.stageProfilePersonalAuthorityRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + }); + expect(store.setProfileState( + profile.id, + profile.processGeneration, + "recovery_required", + { email: "claude-launch-guards@example.com", plan: "Plus" }, + )).toBe(true); + expect(() => store.completeProfilePersonalAuthorityRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + })).toThrow("PROFILE_PERSONAL_AUTHORITY_REVOCATION_CLAUDE_LAUNCH_INTENT_LIVE"); + expect(store.readProfilePersonalAuthorityRevocation(profile.id)).toEqual(revocation); + store.cancelClaudeProcessLaunchIntent({ + providerThreadId: intent.providerThreadId, + profileId: intent.profileId, + profileGeneration: intent.profileGeneration, + runtimeScope: intent.runtimeScope, + intentId: intent.intentId, + expectedRevision: intent.revision, + }); + expect(store.requireProfileById(profile.id)).toMatchObject({ + processGeneration: profile.processGeneration, + state: "recovery_required", + }); + expect(store.readSessionAdoptionPolicy("claude")).toMatchObject({ + enabled: true, + profileId: profile.id, + }); + expect(store.completeProfilePersonalAuthorityRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + }).state).toBe("signed_out"); + }); + + test("launch intents fence session rebind and personal detach", async () => { + const { store, home } = await fixture(); + const profile = signInProfile( + store, + "Claude launch session fences", + "claude-launch-session-fences@example.com", + ); + const projectRoot = join(home, "claude-launch-session-fences"); + await mkdir(projectRoot); + const project = await store.createProject( + "Claude launch session fences", + projectRoot, + true, + ); + const startAttempt = store.prepareMutation({ + kind: "session.start", + authorityId: profile.id, + authorityGeneration: profile.processGeneration, + request: { + projectId: project.id, + provider: "claude", + preset: "fable-max", + fast: false, + }, + idempotencyKey: "00000000-0000-4000-8000-000000000920", + }); + const starting = store.beginSessionStartEffect({ + attemptId: startAttempt.id, + profileId: profile.id, + profileGeneration: profile.processGeneration, + projectId: project.id, + provider: "claude", + preset: "fable-max", + fastEnabled: false, + providerAccountKey: testProviderAccountKey("claude"), + providerAuthentication: { + profileId: profile.id, + processGeneration: profile.processGeneration, + provider: "claude", + signedIn: true, + }, + evidence: { + kind: "session.start", + projectId: project.id, + clientMessageId: null, + messageDigest: null, + runtimeProfile: managedClaudeRuntimeProfile(profile), + }, + }); + const stageStartIntent = (overrides: Partial[0]> = {}) => store.stageClaudeProcessLaunchIntent({ + providerThreadId: "00000000-0000-4000-8000-000000000921", + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "managed", + providerAccountKey: testProviderAccountKey("claude"), + sessionId: starting.id, + ...overrides, + }); + expect(() => stageStartIntent({ + providerThreadId: "not-a-reserved-start-thread", + })).toThrow("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_SESSION_AUTHORITY_MISMATCH"); + expect(() => stageStartIntent({ + providerAccountKey: namedProviderAccountKey("claude", "wrong-start-account"), + })).toThrow("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_SESSION_AUTHORITY_MISMATCH"); + expect(() => stageStartIntent({ + runtimeScope: "personal", + })).toThrow("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_SESSION_AUTHORITY_MISMATCH"); + const startIntent = stageStartIntent(); + expect(() => store.bindSession({ + sessionId: starting.id, + expectedRevision: starting.revision, + providerThreadId: startIntent.providerThreadId, + state: "idle", + })).toThrow("live Claude process authority must be released before session rebind"); + store.cancelClaudeProcessLaunchIntent({ + providerThreadId: startIntent.providerThreadId, + profileId: startIntent.profileId, + profileGeneration: startIntent.profileGeneration, + runtimeScope: startIntent.runtimeScope, + intentId: startIntent.intentId, + expectedRevision: startIntent.revision, + }); + + store.setSessionAdoptionPolicy({ provider: "claude", profileId: profile.id }); + const adopted = adoptPersonalClaudeTestSession(store, profile); + if (adopted.providerThreadId === undefined) { + throw new Error("Expected an adopted Claude provider thread."); + } + const adoptedProcess = store.readClaudeProcessAuthority({ + providerThreadId: adopted.providerThreadId, + profileId: profile.id, + runtimeScope: "personal", + }); + if (adoptedProcess === null) throw new Error("Expected adopted Claude process custody."); + const releasing = store.beginClaudeProcessAuthorityRelease({ + providerThreadId: adoptedProcess.providerThreadId, + profileId: adoptedProcess.profileId, + runtimeScope: adoptedProcess.runtimeScope, + expectedRevision: adoptedProcess.revision, + identity: adoptedProcess.identity, + }); + store.completeClaudeProcessAuthorityRelease({ + providerThreadId: releasing.providerThreadId, + profileId: releasing.profileId, + runtimeScope: releasing.runtimeScope, + expectedRevision: releasing.revision, + identity: releasing.identity, + }); + const detachIntent = store.stageClaudeProcessLaunchIntent({ + providerThreadId: adopted.providerThreadId, + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "personal", + providerAccountKey: testProviderAccountKey("claude"), + sessionId: adopted.id, + }); + expect(() => store.beginPersonalSessionDetach({ + sessionId: adopted.id, + })).toThrow("Claude process launch intent must be cancelled before personal session detach"); + store.cancelClaudeProcessLaunchIntent({ + providerThreadId: detachIntent.providerThreadId, + profileId: detachIntent.profileId, + profileGeneration: detachIntent.profileGeneration, + runtimeScope: detachIntent.runtimeScope, + intentId: detachIntent.intentId, + expectedRevision: detachIntent.revision, + }); + expect(store.detachPersonalSession({ + sessionId: adopted.id, + archive: false, + }).binding.state).toBe("detached"); + }); + + test("reopens durable Claude launch intents without changing their authority", async () => { + const { store } = await fixture(); + const profile = signInProfile( + store, + "Claude launch reopen", + "claude-launch-reopen@example.com", + ); + store.setSessionAdoptionPolicy({ provider: "claude", profileId: profile.id }); + const candidate = store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: "launch-reopen-thread", + title: "Claude launch reopen candidate", + state: "idle", + providerUpdatedAt: 10, + liveness: "not_live", + }); + store.fenceSessionAdoptionCandidateForClaim({ + provider: "claude", + providerThreadId: candidate.providerThreadId, + expectedRevision: candidate.revision, + }); + const intent = store.stageClaudeProcessLaunchIntent({ + providerThreadId: "launch-reopen-thread", + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "personal", + providerAccountKey: testProviderAccountKey("claude"), + }); + const paths = store.paths; + store.close(); + stores.splice(stores.indexOf(store), 1); + + const reopened = new StateStore(paths); + stores.push(reopened); + expect(reopened.readClaudeProcessLaunchIntent({ + providerThreadId: intent.providerThreadId, + profileId: intent.profileId, + runtimeScope: intent.runtimeScope, + })).toEqual(intent); + expect(reopened.listClaudeProcessLaunchIntents()).toEqual([intent]); + expect(reopened.profileHasClaudeProcessLaunchIntents( + profile.id, + profile.processGeneration, + )).toBe(true); + expect(() => reopened.advanceProfileGeneration( + profile.id, + profile.processGeneration, + )).toThrow("live session controllers must release before account generation changes"); + expect(() => reopened.nextDaemonGeneration(`boot_${"l".repeat(32)}`)) + .toThrow("live session controllers must release before account generation changes"); + expect(reopened.requireProfileById(profile.id)).toMatchObject({ + processGeneration: profile.processGeneration, + state: "signed_in", + }); + expect(reopened.readClaudeProcessLaunchIntent({ + providerThreadId: intent.providerThreadId, + profileId: intent.profileId, + runtimeScope: intent.runtimeScope, + })).toEqual(intent); + }); + + test("preserves exact legacy adoption-v36 launch authority and rows", async () => { + const value = await fixture(); + let store = value.store; + const profile = signInProfile( + store, + "Legacy Claude launch proof", + "legacy-claude-launch-proof@example.com", + ); + const session = upsertProvenTestSession(store, { + profileId: profile.id, + provider: "claude", + providerThreadId: "legacy-v36-launch-proof", + preset: "fable-max", + fastEnabled: false, + state: "idle", + }); + const intent = store.stageClaudeProcessLaunchIntent({ + providerThreadId: "legacy-v36-launch-proof", + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "managed", + providerAccountKey: testProviderAccountKey("claude"), + sessionId: session.id, + }); + const candidate = store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: "legacy-v36-retained-candidate", + title: "Legacy v36 retained candidate", + state: "terminal", + providerUpdatedAt: 10, + liveness: "live", + sourceProcessIdentity: { + pid: 41_036, + pidDomain: "darwin", + procStart: "legacy-v36-retained-process", + }, + trustedLiveObservation: true, + }); + const paths = store.paths; + store.close(); + stores.splice(stores.indexOf(store), 1); + + const legacy = new Database(paths.database, { create: false, strict: true }); + try { + downgradeToExactLegacyAdoptionVersion36(legacy); + } finally { + legacy.close(false); + } + + store = new StateStore(paths); + stores.push(store); + const retained = store.readClaudeProcessLaunchIntent({ + providerThreadId: intent.providerThreadId, + profileId: intent.profileId, + runtimeScope: intent.runtimeScope, + }); + expect(retained).toEqual(intent); + expect(store.requireSession(session.id)).toMatchObject({ + id: session.id, + providerThreadId: session.providerThreadId, + }); + expect(store.readSessionAdoptionCandidate( + candidate.provider, + candidate.providerThreadId, + )).toMatchObject({ + lastLiveObservedAt: null, + providerProjectRoot: null, + providerThreadId: candidate.providerThreadId, + sourceProcessIdentity: candidate.sourceProcessIdentity, + status: candidate.status, + }); + const inspector = new Database(paths.database, { readonly: true, strict: true }); + try { + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); + expect(inspector.query( + "SELECT version FROM migrations WHERE version>=35 ORDER BY version", + ).all()).toEqual([ + { version: 35 }, + { version: 36 }, + { version: 37 }, + { version: 38 }, + { version: 39 }, + { version: 40 }, + ]); + expect(inspector.query( + "SELECT name FROM pragma_table_info('session_adoption_candidates') WHERE name='provider_project_root'", + ).get()).toEqual({ name: "provider_project_root" }); + } finally { + inspector.close(false); + } + }); + + test("rejects a weakened same-name Work guard in legacy adoption-v36 without writes", async () => { + const { store } = await fixture(); + const candidate = store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: "legacy-v36-weakened-work-guard", + title: "Legacy v36 weakened Work guard", + state: "idle", + providerUpdatedAt: 10, + liveness: "unknown", + }); + const paths = store.paths; + store.close(); + stores.splice(stores.indexOf(store), 1); + + const legacy = new Database(paths.database, { create: false, strict: true }); + try { + downgradeToExactLegacyAdoptionVersion36(legacy); + legacy.exec(` + DROP TRIGGER work_member_account_authority_guard; + CREATE TRIGGER work_member_account_authority_guard + BEFORE INSERT ON work_members BEGIN SELECT 1; END; + `); + } finally { + legacy.close(false); + } + + expect(() => new StateStore(paths)) + .toThrow("STATE_SCHEMA_V39_LEGACY_ADOPTION_WORK_INVALID"); + const inspector = new Database(paths.database, { readonly: true, strict: true }); + try { + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 36 }); + expect(inspector.query( + `SELECT revision,claim_status FROM session_adoption_candidates + WHERE provider=? AND provider_thread_id=?`, + ).get(candidate.provider, candidate.providerThreadId)).toEqual({ + claim_status: "pending", + revision: candidate.revision, + }); + expect(inspector.query( + "SELECT 1 FROM sqlite_master WHERE name='notification_hours'", + ).get()).toBeNull(); + } finally { + inspector.close(false); + } + }); + + test("rejects a weakened same-name Work authority guard in current adoption-v39", async () => { + const { store } = await fixture(); + const candidate = store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: "current-v39-weakened-work-guard", + title: "Current v39 weakened Work guard", + state: "idle", + providerUpdatedAt: 10, + liveness: "unknown", + }); + const paths = store.paths; + store.close(); + stores.splice(stores.indexOf(store), 1); + + const weakened = new Database(paths.database, { create: false, strict: true }); + try { + weakened.exec(` + DROP TRIGGER work_member_account_authority_guard; + CREATE TRIGGER work_member_account_authority_guard + BEFORE INSERT ON work_members BEGIN SELECT 1; END; + `); + } finally { + weakened.close(false); + } + + expect(() => new StateStore(paths)) + .toThrow("STATE_SCHEMA_V39_ADOPTION_WORK_INVALID"); + const inspector = new Database(paths.database, { readonly: true, strict: true }); + try { + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); + expect(inspector.query( + `SELECT revision,claim_status FROM session_adoption_candidates + WHERE provider=? AND provider_thread_id=?`, + ).get(candidate.provider, candidate.providerThreadId)).toEqual({ + claim_status: "pending", + revision: candidate.revision, + }); + } finally { + inspector.close(false); + } + }); + + test("carries an interrupted personal revocation across daemon generation rollover", async () => { + const { store } = await fixture(); + const profile = signInProfile( + store, + "Restarted personal revocation", + "restarted-personal-revocation@example.com", + ); + store.setSessionAdoptionPolicy({ provider: "codex", profileId: profile.id }); + const candidate = store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: "restarted-personal-revocation-thread", + title: "Restarted personal revocation", + state: "idle", + providerUpdatedAt: 10, + liveness: "not_live", + }); + const claiming = store.fenceSessionAdoptionCandidateForClaim({ + provider: "codex", + providerThreadId: candidate.providerThreadId, + expectedRevision: candidate.revision, + }); + const adopted = store.adoptSessionCandidate({ + provider: "codex", + providerThreadId: claiming.providerThreadId, + expectedCandidateRevision: claiming.revision, + profileId: profile.id, + profileGeneration: profile.processGeneration, + preset: "high", + requirement: presetRequirements.high, + fastEnabled: false, + runtimeProfile: codexAdoptionRuntimeProfile(profile, "high", false), + providerAccountKey: providerAccountKeyForProfile(store, profile.id, "codex"), + }); + const workStore = store.createWorkStore( + 11, + () => "unused-revocation-rollover-cursor", + { + issue: () => `hrac1_${"B".repeat(43)}`, + verify: () => true, + }, + ); + const begun = store.beginProfilePersonalAuthorityRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + workStore, + }); + expect(begun.revocation).toMatchObject({ + profileGeneration: profile.processGeneration, + state: "releasing", + }); + expect(begun.bindings).toEqual([ + expect.objectContaining({ sessionId: adopted.session.id, state: "detaching" }), + ]); + + const paths = store.paths; + store.close(); + stores.splice(stores.indexOf(store), 1); + const restarted = new StateStore(paths, { now: () => 2_000 }); + stores.push(restarted); + + expect(restarted.nextDaemonGeneration(`boot_${"v".repeat(32)}`)).toBe(1); + const rolledProfile = restarted.requireProfileById(profile.id); + const rolledRevocation = restarted.readProfilePersonalAuthorityRevocation(profile.id); + expect(rolledProfile).toMatchObject({ + processGeneration: profile.processGeneration + 1, + state: "recovery_required", + }); + expect(rolledRevocation).toMatchObject({ + profileGeneration: rolledProfile.processGeneration, + revision: begun.revocation.revision + 1, + state: "releasing", + }); + expect(rolledRevocation?.updatedAt).toBeGreaterThanOrEqual(begun.revocation.updatedAt); + + expect(restarted.completePersonalSessionDetach({ + sessionId: adopted.session.id, + archive: true, + }).binding.state).toBe("detached"); + expect(restarted.completeProfilePersonalAuthorityRevocation({ + profileId: profile.id, + expectedGeneration: rolledProfile.processGeneration, + })).toMatchObject({ + processGeneration: rolledProfile.processGeneration + 1, + state: "signed_out", + }); + }); + + test("allows stable Codex sign-out but blocks generation loss while managed Claude custody is live", async () => { + const { store } = await fixture(); + const profile = signInProfile( + store, + "Managed Claude controller", + "managed-claude-controller@example.com", + ); + const authority = store.recordClaimedClaudeProcessAuthority({ + providerThreadId: "managed-controller-thread", + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "managed", + identity: { + pid: 51_101, + pidDomain: "darwin", + procStart: "managed-controller-current-generation", + }, + }); + + expect(store.setProfileState( + profile.id, + profile.processGeneration, + "signed_out", + )).toBe(true); + expect(() => store.advanceProfileGeneration( + profile.id, + profile.processGeneration, + )).toThrow("live session controllers must release before account generation changes"); + + expect(store.requireProfileById(profile.id)).toMatchObject({ + processGeneration: profile.processGeneration, + state: "signed_out", + }); + expect(store.readClaudeProcessAuthority({ + providerThreadId: authority.providerThreadId, + profileId: authority.profileId, + runtimeScope: authority.runtimeScope, + })).toEqual(authority); + }); + + test("pages unbound Claude custody by exact profile generation and scope", async () => { + const { store } = await fixture(); + const profile = signInProfile( + store, + "Paged Claude custody", + "paged-claude-custody@example.com", + ); + for (const [index, providerThreadId] of ["custody-a", "custody-b"].entries()) { + store.recordClaimedClaudeProcessAuthority({ + providerThreadId, + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "managed", + identity: { + pid: 53_000 + index, + pidDomain: "darwin", + procStart: `paged-custody-${index}`, + }, + }); + } + const first = store.listUnreleasedClaudeProcessAuthorityPage({ + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "managed", + afterProviderThreadId: null, + limit: 1, + }); + expect(first.authorities).toEqual([ + expect.objectContaining({ providerThreadId: "custody-a", sessionId: null }), + ]); + expect(first.continueAfterProviderThreadId).toBe("custody-a"); + const second = store.listUnreleasedClaudeProcessAuthorityPage({ + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "managed", + afterProviderThreadId: first.continueAfterProviderThreadId, + limit: 1, + }); + expect(second.authorities).toEqual([ + expect.objectContaining({ providerThreadId: "custody-b", sessionId: null }), + ]); + }); + + test("binds native and adopted-neutral session authority to the original account identity", async () => { + const { store } = await fixture(); + const profile = signInProfile( + store, + "Session identity binding", + "original-session-owner@example.com", + ); + const originalAccountKey = providerAccountKeyForProfile(store, profile.id, "codex"); + const unproven = store.createSession({ + profileId: profile.id, + provider: "codex", + preset: "high", + fastEnabled: false, + }); + expect(store.sessionAccountAuthorityMatches(unproven.id, profile.id)).toBe(false); + expect(store.readSessionProviderAccountAuthority(unproven.id)).toBeNull(); + const merelyBound = store.bindSession({ + sessionId: unproven.id, + expectedRevision: unproven.revision, + providerThreadId: "account-unproven-native-thread", + state: "idle", + }); + expect(store.sessionAccountAuthorityMatches(merelyBound.id, profile.id)).toBe(false); + expect(() => store.enqueue(merelyBound.id, "must have provider identity proof")) + .toThrow("session provider account authority is not current"); + + const session = store.upsertProviderSession({ + profileId: profile.id, + provider: "codex", + providerThreadId: "account-bound-native-thread", + title: "Provider-proven session", + preset: "high", + fastEnabled: false, + state: "idle", + providerAccountKey: originalAccountKey, + }); + expect(store.sessionAccountAuthorityMatches(session.id, profile.id)).toBe(true); + expect(store.readSessionProviderAccountAuthority(session.id)).toMatchObject({ + accountKey: originalAccountKey, + provider: "codex", + runtimeScope: "managed", + }); + expect(store.listLocalSessionPage({ + profileId: profile.id, + after: null, + limit: 10, + requireCurrentAccountAuthority: true, + }).sessions).toEqual([session]); + + expect(store.setProfileState( + profile.id, + profile.processGeneration, + "signed_in", + { email: "replacement-session-owner@example.com", plan: "Plus" }, + )).toBe(true); + expect(store.sessionAccountAuthorityMatches(session.id, profile.id)).toBe(false); + expect(store.listLocalSessionPage({ + profileId: profile.id, + after: null, + limit: 10, + requireCurrentAccountAuthority: true, + }).sessions).toEqual([]); + expect(() => store.enqueue(session.id, "must not cross account identities")) + .toThrow("session provider account authority is not current"); + expect(() => store.setSessionTurnState({ + sessionId: session.id, + expectedRevision: session.revision, + state: "active", + activeTurnId: "replacement-account-turn", + })).toThrow("session provider account authority is not current"); + + expect(store.setProfileState( + profile.id, + profile.processGeneration, + "signed_in", + { email: "ORIGINAL-SESSION-OWNER@example.com", plan: "Plus" }, + )).toBe(true); + expect(store.sessionAccountAuthorityMatches(session.id, profile.id)).toBe(true); + expect(store.listLocalSessionPage({ + profileId: profile.id, + after: null, + limit: 10, + requireCurrentAccountAuthority: true, + }).sessions).toEqual([store.requireSession(session.id)]); + expect(store.enqueue(session.id, "same identity may resume")).toMatchObject({ + sessionId: session.id, + state: "pending", + }); + + expect(store.setProfileState( + profile.id, + profile.processGeneration, + "signed_in", + { plan: "apiKey" }, + )).toBe(true); + expect(store.sessionAccountAuthorityMatches(session.id, profile.id)).toBe(false); + expect(store.listLocalSessionPage({ + profileId: profile.id, + after: null, + limit: 10, + requireCurrentAccountAuthority: true, + }).sessions).toEqual([]); + expect(() => store.enqueue(session.id, "unprovable credentials stay fenced")) + .toThrow("session provider account authority is not current"); + expect(() => store.upsertProviderSession({ + profileId: profile.id, + provider: "codex", + providerThreadId: "unprovable-imported-thread", + title: "Unprovable imported thread", + preset: "high", + fastEnabled: false, + state: "idle", + providerAccountKey: originalAccountKey, + })).toThrow("SESSION_PROVIDER_ACCOUNT_AUTHORITY_ACCOUNT_MISMATCH"); + }); + + test("durably pages a scoped account fence and supersedes dirty observations", async () => { + const value = await fixture(); + let store = value.store; + const profile = signInProfile( + store, + "Paged scoped revocation", + "paged-scoped-revocation@example.com", + ); + const accountA = providerAccountKeyForProfile(store, profile.id, "codex"); + const replacementEmail = "paged-scoped-replacement@example.com"; + const accountB = namedProviderAccountKey("codex", replacementEmail); + const accountC = namedProviderAccountKey("codex", "scoped-account-c"); + const affected = Array.from({ length: 502 }, (_, index) => + store.upsertProviderSession({ + profileId: profile.id, + provider: "codex", + providerThreadId: `paged-scoped-${String(index).padStart(4, "0")}`, + title: `Paged scoped ${index}`, + preset: "high", + fastEnabled: false, + state: "idle", + providerAccountKey: accountA, + })); + expect(store.setProfileState( + profile.id, + profile.processGeneration, + "signed_in", + { email: replacementEmail, plan: "Plus" }, + )).toBe(true); + const safe = store.upsertProviderSession({ + profileId: profile.id, + provider: "codex", + providerThreadId: "paged-scoped-safe", + title: "Paged scoped safe", + preset: "high", + fastEnabled: false, + state: "idle", + providerAccountKey: accountB, + }); + + // Missing and falsely scoped provider rows must not disappear from the + // selection merely because the old implementation used an inner join. + const raw = new Database(store.paths.database, { create: false, strict: true }); + try { + const guard = z.object({ sql: z.string() }).strict().parse(raw.query( + `SELECT sql FROM sqlite_master + WHERE type='trigger' AND name='session_provider_account_authority_update_guard'`, + ).get()); + raw.exec("DROP TRIGGER session_provider_account_authority_update_guard"); + raw.query( + "DELETE FROM session_provider_account_authorities WHERE session_id=?", + ).run(affected[0]?.id ?? ""); + raw.query( + `UPDATE session_provider_account_authorities SET runtime_scope='personal' + WHERE session_id=?`, + ).run(affected[1]?.id ?? ""); + raw.exec(guard.sql); + } finally { + raw.close(false); + } + + const begun = store.beginProviderRuntimeAccountRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + provider: "codex", + runtimeScope: "managed", + currentAccountKey: accountB, + workStore: createRevocationWorkStore(store), + }); + expect(begun.revocation).toMatchObject({ + currentAccountKey: accountB, + revision: 1, + state: "releasing", + }); + expect(begun.sessionIds).toHaveLength(502); + expect(new Set(begun.sessionIds)).toEqual(new Set(affected.map((session) => session.id))); + expect(store.requireSession(safe.id).state).toBe("idle"); + const fencedRevision = store.requireSession(affected[0]?.id ?? "").revision; + for (const session of affected) { + expect(store.requireSession(session.id).state).toBe("recovery_required"); + } + + const paths = store.paths; + store.close(); + stores.splice(stores.indexOf(store), 1); + store = new StateStore(paths, { now: () => 5_000 }); + stores.push(store); + expect(store.listReleasingProviderRuntimeAccountRevocations()).toEqual([ + expect.objectContaining({ + profileId: profile.id, + currentAccountKey: accountB, + revision: begun.revocation.revision, + }), + ]); + + expect(store.nextDaemonGeneration(`boot_${"r".repeat(32)}`)).toBe(1); + const rolledProfile = store.requireProfileById(profile.id); + const rolled = store.readProviderRuntimeAccountRevocation({ + profileId: profile.id, + provider: "codex", + runtimeScope: "managed", + }); + expect(rolled).toMatchObject({ + profileGeneration: rolledProfile.processGeneration, + revision: begun.revocation.revision + 1, + state: "releasing", + }); + if (rolled === null) throw new Error("Expected the scoped revocation after restart."); + const repeated = store.beginProviderRuntimeAccountRevocation({ + profileId: profile.id, + expectedGeneration: rolledProfile.processGeneration, + provider: "codex", + runtimeScope: "managed", + currentAccountKey: accountB, + workStore: createRevocationWorkStore(store), + }); + expect(repeated.revocation.revision).toBe(rolled.revision); + expect(store.requireSession(affected[0]?.id ?? "").revision).toBe(fencedRevision); + + const superseding = store.beginProviderRuntimeAccountRevocation({ + profileId: profile.id, + expectedGeneration: rolledProfile.processGeneration, + provider: "codex", + runtimeScope: "managed", + currentAccountKey: accountC, + workStore: createRevocationWorkStore(store), + }); + expect(superseding.revocation).toMatchObject({ + currentAccountKey: accountC, + revision: rolled.revision + 1, + state: "releasing", + }); + expect(() => store.completeProviderRuntimeAccountRevocation({ + profileId: profile.id, + expectedGeneration: rolledProfile.processGeneration, + provider: "codex", + runtimeScope: "managed", + expectedRevision: rolled.revision, + })).toThrow("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_CONFLICT"); + const completed = store.completeProviderRuntimeAccountRevocation({ + profileId: profile.id, + expectedGeneration: rolledProfile.processGeneration, + provider: "codex", + runtimeScope: "managed", + expectedRevision: superseding.revocation.revision, + }); + expect(completed.state).toBe("completed"); + expect(store.completeProviderRuntimeAccountRevocation({ + profileId: profile.id, + expectedGeneration: rolledProfile.processGeneration, + provider: "codex", + runtimeScope: "managed", + expectedRevision: superseding.revocation.revision, + })).toEqual(completed); + }); + + test("keeps completed scoped revocations closed over every stale session authority guard", async () => { + const { store } = await fixture(); + const profile = signInProfile( + store, + "Completed scoped revocation", + "completed-scoped-revocation@example.com", + ); + const staleAccountKey = providerAccountKeyForProfile(store, profile.id, "codex"); + const currentEmail = "completed-current-account@example.com"; + const currentAccountKey = namedProviderAccountKey("codex", currentEmail); + const stale = store.upsertProviderSession({ + profileId: profile.id, + provider: "codex", + providerThreadId: "completed-scoped-stale", + title: "Completed scoped stale", + preset: "high", + fastEnabled: false, + state: "idle", + providerAccountKey: staleAccountKey, + }); + expect(store.setProfileState( + profile.id, + profile.processGeneration, + "signed_in", + { email: currentEmail, plan: "Plus" }, + )).toBe(true); + const current = store.upsertProviderSession({ + profileId: profile.id, + provider: "codex", + providerThreadId: "completed-scoped-current", + title: "Completed scoped current", + preset: "high", + fastEnabled: false, + state: "idle", + providerAccountKey: currentAccountKey, + }); + const tasks = store.createSessionTaskStore(); + const stalePausedTask = tasks.create({ + sessionId: stale.id, + name: "Stale paused task", + prompt: "Must stay paused", + minutes: 15, + status: "paused", + idempotencyKey: "00000000-0000-4000-8000-000000000871", + }); + const currentPausedTask = tasks.create({ + sessionId: current.id, + name: "Current paused task", + prompt: "May resume", + minutes: 15, + status: "paused", + idempotencyKey: "00000000-0000-4000-8000-000000000872", + }); + + const begun = store.beginProviderRuntimeAccountRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + provider: "codex", + runtimeScope: "managed", + currentAccountKey, + workStore: createRevocationWorkStore(store), + }); + expect(begun.sessionIds).toEqual([stale.id]); + const completed = store.completeProviderRuntimeAccountRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + provider: "codex", + runtimeScope: "managed", + expectedRevision: begun.revocation.revision, + }); + expect(completed).toMatchObject({ + currentAccountKey, + state: "completed", + }); + expect(() => store.upsertProviderSession({ + profileId: profile.id, + provider: "codex", + providerThreadId: "completed-scoped-new-stale", + title: "Completed scoped new stale", + preset: "high", + fastEnabled: false, + state: "idle", + providerAccountKey: staleAccountKey, + })).toThrow("SESSION_PROVIDER_ACCOUNT_AUTHORITY_ACCOUNT_MISMATCH"); + const postCompletionCurrent = store.upsertProviderSession({ + profileId: profile.id, + provider: "codex", + providerThreadId: "completed-scoped-new-current", + title: "Completed scoped new current", + preset: "high", + fastEnabled: false, + state: "idle", + providerAccountKey: currentAccountKey, + }); + + expect(store.nextDaemonGeneration(`boot_${"s".repeat(32)}`)).toBe(1); + const currentProfile = store.requireProfileById(profile.id); + + expect(store.sessionAccountAuthorityMatches(stale.id, profile.id)).toBe(false); + expect(store.sessionAccountAuthorityMatches(current.id, profile.id)).toBe(true); + expect(new Set(store.listLocalSessionPage({ + profileId: profile.id, + after: null, + limit: 10, + requireCurrentAccountAuthority: true, + }).sessions.map((session) => session.id))).toEqual(new Set([ + current.id, + postCompletionCurrent.id, + ])); + expect(() => store.enqueue(stale.id, "stale queue authority")) + .toThrow("session provider account authority is not current"); + expect(store.enqueue(current.id, "current queue authority")).toMatchObject({ + sessionId: current.id, + state: "pending", + }); + + const staleAfterFence = store.requireSession(stale.id); + expect(() => store.setSessionTurnState({ + sessionId: stale.id, + expectedRevision: staleAfterFence.revision, + state: "active", + activeTurnId: "stale-revocation-turn", + })).toThrow("session provider account authority is not current"); + expect(store.setSessionTurnState({ + sessionId: current.id, + expectedRevision: store.requireSession(current.id).revision, + state: "active", + activeTurnId: "current-revocation-turn", + })).toMatchObject({ state: "active", activeTurnId: "current-revocation-turn" }); + + const admit = (session: typeof stale, suffix: string) => store.admitInteraction({ + publicId: `00000000-0000-4000-8000-000000000${suffix}`, + sessionId: session.id, + authority: { + profileId: profile.id, + processGeneration: currentProfile.processGeneration, + connectionId: `00000000-0000-4000-8000-000000001${suffix}`, + requestId: { type: "string" as const, value: `completed-${suffix}` }, + method: "item/fileChange/requestApproval", + requestDigest: suffix.repeat(64).slice(0, 64), + threadId: session.providerThreadId ?? "", + turnId: `completed-turn-${suffix}`, + itemId: `completed-item-${suffix}`, + approvalId: null, + }, + kind: "file_change_approval" as const, + blocking: true, + display: { + kind: "file_change_approval" as const, + summary: "Completed revocation guard", + reason: null, + grantRoot: null, + availableDecisions: ["once", "decline", "cancel"] as const, + }, + }); + expect(() => admit(stale, "873")) + .toThrow("session provider account authority is not current"); + expect(admit(current, "874").record.sessionId).toBe(current.id); + + expect(() => tasks.create({ + sessionId: stale.id, + name: "Stale active task", + prompt: "Must not activate", + minutes: 15, + status: "active", + idempotencyKey: "00000000-0000-4000-8000-000000000875", + })).toThrow("session provider account authority is not current"); + expect(tasks.create({ + sessionId: current.id, + name: "Current active task", + prompt: "May activate", + minutes: 15, + status: "active", + idempotencyKey: "00000000-0000-4000-8000-000000000876", + }).status).toBe("active"); + expect(() => tasks.edit({ + sessionId: stale.id, + taskId: stalePausedTask.id, + expectedRevision: stalePausedTask.revision, + patch: { status: "active" }, + idempotencyKey: "00000000-0000-4000-8000-000000000877", + })).toThrow("session provider account authority is not current"); + expect(tasks.edit({ + sessionId: current.id, + taskId: currentPausedTask.id, + expectedRevision: currentPausedTask.revision, + patch: { status: "active" }, + idempotencyKey: "00000000-0000-4000-8000-000000000878", + }).status).toBe("active"); + }); + + test("a personal scoped revocation fences an overlapping claim before disabling adoption", async () => { + const { store } = await fixture(); + const profile = signInProfile( + store, + "Overlapping adoption claim", + "overlapping-adoption-claim@example.com", + ); + store.setSessionAdoptionPolicy({ provider: "codex", profileId: profile.id }); + const candidate = store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: "overlapping-adoption-claim", + title: "Overlapping adoption claim", + state: "idle", + liveness: "not_live", + }); + const claiming = store.fenceSessionAdoptionCandidateForClaim({ + provider: "codex", + providerThreadId: candidate.providerThreadId, + expectedRevision: candidate.revision, + }); + expect(claiming.status).toBe("claiming"); + + const begun = store.beginProviderRuntimeAccountRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + provider: "codex", + runtimeScope: "personal", + currentAccountKey: namedProviderAccountKey("codex", "replacement-personal-home"), + workStore: createRevocationWorkStore(store), + }); + expect(begun.sessionIds).toEqual([]); + expect(store.readSessionAdoptionPolicy("codex")).toMatchObject({ + enabled: false, + profileId: null, + }); + expect(store.listSessionAdoptionCandidates({ provider: "codex" })).toEqual([ + expect.objectContaining({ + providerThreadId: candidate.providerThreadId, + status: "fenced", + }), + ]); + expect(() => store.setSessionAdoptionPolicy({ + provider: "codex", + profileId: profile.id, + })).toThrow("provider runtime account revocation must complete before adoption is enabled"); + store.completeProviderRuntimeAccountRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + provider: "codex", + runtimeScope: "personal", + expectedRevision: begun.revocation.revision, + }); + expect(() => store.setSessionAdoptionPolicy({ + provider: "codex", + profileId: profile.id, + })).toThrow("provider runtime account revocation must complete before adoption is enabled"); + }); + + test("refuses a foreign WorkStore before staging a scoped fence", async () => { + const { store } = await fixture(); + const other = await fixture(); + const profile = signInProfile( + store, + "Scoped work transaction", + "scoped-work-transaction@example.com", + ); + const session = store.upsertProviderSession({ + profileId: profile.id, + provider: "codex", + providerThreadId: "scoped-work-transaction", + title: "Scoped work transaction", + preset: "high", + fastEnabled: false, + state: "idle", + providerAccountKey: providerAccountKeyForProfile(store, profile.id, "codex"), + }); + expect(() => store.beginProviderRuntimeAccountRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + provider: "codex", + runtimeScope: "managed", + currentAccountKey: namedProviderAccountKey("codex", "scoped-work-b"), + workStore: createRevocationWorkStore(other.store), + })).toThrow("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_WORK_STORE_MISMATCH"); + expect(store.readProviderRuntimeAccountRevocation({ + profileId: profile.id, + provider: "codex", + runtimeScope: "managed", + })).toBeNull(); + expect(store.requireSession(session.id).state).toBe("idle"); + }); + + test("rolls back work retirement with a failed scoped session fence", async () => { + const { store, home } = await fixture(); + const profile = signInProfile( + store, + "Atomic scoped work fence", + "atomic-scoped-work-fence@example.com", + ); + const projectRoot = join(home, "atomic-scoped-work-fence"); + await mkdir(projectRoot); + const project = await store.createProject( + "Atomic scoped work fence", + projectRoot, + true, + ); + const accountA = providerAccountKeyForProfile(store, profile.id, "codex"); + const importedSession = store.upsertProviderSession({ + profileId: profile.id, + provider: "codex", + providerThreadId: "atomic-scoped-work-fence", + projectId: project.id, + title: "Atomic scoped work fence", + preset: "high", + fastEnabled: false, + state: "idle", + providerAccountKey: accountA, + }); + const session = store.updateSessionMetadata({ + sessionId: importedSession.id, + expectedRevision: importedSession.revision, + preset: "high", + }); + const workStore = createRevocationWorkStore(store); + const created = workStore.apply({ + kind: "work.create", + idempotencyKey: "01890f31-a123-7000-8000-000000000901", + clientRef: "atomic-scoped-work-fence", + coordinatorSessionId: session.id, + objective: "Prove the shared authority transaction.", + routes: [{ + accountId: profile.id, + projectId: project.id, + preset: "high", + fast: false, + }], + tasks: [{ + clientRef: "atomic-scoped-work-task", + dependsOnRefs: [], + dependsOnTaskIds: [], + objective: "Hold one claimed task.", + instructions: "Remain claimed until authority is fenced.", + criteria: ["The authority fence retires the claim."], + route: { accountId: profile.id, projectId: project.id }, + preset: "high", + fast: false, + priority: 0, + maxAttempts: 3, + requiredReviews: 0, + resultKind: "text", + minEvidence: 0, + }], + }); + if (created.kind !== "work.create") throw new Error("Expected a created work item."); + const task = created.tasks[0]; + if (task === undefined) throw new Error("Expected one work task."); + const claimed = workStore.apply({ + kind: "task.claim", + idempotencyKey: "01890f31-a123-7000-8000-000000000902", + workId: created.work.id, + taskId: task.id, + expectedTaskRevision: task.revision, + actorSessionId: session.id, + actorCapability: `hrac1_${"A".repeat(43)}`, + leaseMs: 5_000, + }); + if (claimed.kind !== "task.claim") throw new Error("Expected a claimed work task."); + + const raw = new Database(store.paths.database, { create: false, strict: true }); + try { + raw.exec(` + CREATE TRIGGER injected_scoped_session_fence_failure + BEFORE UPDATE OF state ON sessions + WHEN OLD.id='${session.id}' AND NEW.state='recovery_required' + BEGIN SELECT RAISE(ABORT,'INJECTED_SCOPED_SESSION_FENCE_FAILURE'); END; + `); + expect(() => store.beginProviderRuntimeAccountRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + provider: "codex", + runtimeScope: "managed", + currentAccountKey: namedProviderAccountKey("codex", "atomic-scoped-work-b"), + workStore, + })).toThrow("INJECTED_SCOPED_SESSION_FENCE_FAILURE"); + expect(store.readProviderRuntimeAccountRevocation({ + profileId: profile.id, + provider: "codex", + runtimeScope: "managed", + })).toBeNull(); + expect(store.requireSession(session.id).state).toBe("idle"); + expect(workStore.task(task.id).activeAttempt).toMatchObject({ + id: claimed.attempt.id, + status: "claimed", + }); + raw.exec("DROP TRIGGER injected_scoped_session_fence_failure"); + } finally { + raw.close(false); + } + + const begun = store.beginProviderRuntimeAccountRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + provider: "codex", + runtimeScope: "managed", + currentAccountKey: namedProviderAccountKey("codex", "atomic-scoped-work-b"), + workStore, + }); + expect(begun.affectedWorkIds).toEqual([created.work.id]); + expect(store.requireSession(session.id).state).toBe("recovery_required"); + expect(workStore.task(task.id).activeAttempt).toBeNull(); + }); + + test("scoped revocation preserves an unresolved Claude launch and blocks restart", async () => { + const { store } = await fixture(); + const profile = signInProfile( + store, + "Revoked Claude launch", + "revoked-claude-launch@example.com", + ); + const session = upsertProvenTestSession(store, { + profileId: profile.id, + provider: "claude", + providerThreadId: "revoked-claude-launch", + preset: "fable-max", + fastEnabled: false, + state: "idle", + }); + const intent = store.stageClaudeProcessLaunchIntent({ + providerThreadId: "revoked-claude-launch", + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "managed", + providerAccountKey: testProviderAccountKey("claude"), + sessionId: session.id, + }); + const begun = store.beginProviderRuntimeAccountRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + provider: "claude", + runtimeScope: "managed", + currentAccountKey: null, + workStore: createRevocationWorkStore(store), + }); + expect(begun.sessionIds).toEqual([session.id]); + expect(store.requireSession(session.id).state).toBe("recovery_required"); + expect(store.readClaudeProcessLaunchIntent({ + providerThreadId: intent.providerThreadId, + profileId: intent.profileId, + runtimeScope: intent.runtimeScope, + })).toEqual(intent); + expect(() => store.completeProviderRuntimeAccountRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + provider: "claude", + runtimeScope: "managed", + expectedRevision: begun.revocation.revision, + })).toThrow("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_CLAUDE_LAUNCH_INTENT_LIVE"); + expect(() => store.nextDaemonGeneration(`boot_${"q".repeat(32)}`)) + .toThrow("live session controllers must release before account generation changes"); + expect(store.readClaudeProcessLaunchIntent({ + providerThreadId: intent.providerThreadId, + profileId: intent.profileId, + runtimeScope: intent.runtimeScope, + })).toEqual(intent); + expect(store.readProviderRuntimeAccountRevocation({ + profileId: profile.id, + provider: "claude", + runtimeScope: "managed", + })).toEqual(begun.revocation); + }); + + test("global account replacement retires native and adopted sessions identically", async () => { + const { store } = await fixture(); + const profile = signInProfile( + store, + "Native adopted parity", + "native-adopted-parity@example.com", + ); + const accountKey = providerAccountKeyForProfile(store, profile.id, "codex"); + const native = store.upsertProviderSession({ + profileId: profile.id, + provider: "codex", + providerThreadId: "native-parity-thread", + title: "Native parity", + preset: "high", + fastEnabled: false, + state: "idle", + providerAccountKey: accountKey, + }); + store.setSessionAdoptionPolicy({ provider: "codex", profileId: profile.id }); + const candidate = store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: "adopted-parity-thread", + title: "Adopted parity", + state: "idle", + liveness: "not_live", + }); + const claiming = store.fenceSessionAdoptionCandidateForClaim({ + provider: "codex", + providerThreadId: candidate.providerThreadId, + expectedRevision: candidate.revision, + }); + const adopted = store.adoptSessionCandidate({ + provider: "codex", + providerThreadId: candidate.providerThreadId, + expectedCandidateRevision: claiming.revision, + profileId: profile.id, + profileGeneration: profile.processGeneration, + preset: "high", + requirement: presetRequirements.high, + fastEnabled: false, + runtimeProfile: codexAdoptionRuntimeProfile(profile, "high", false), + providerAccountKey: accountKey, + }).session; + const queueIds = [native, adopted].map((session, index) => + store.enqueue(session.id, `parity queue ${index}`).id); + const mutationKeys = [native, adopted].map((session, index) => { + const idempotencyKey = `00000000-0000-4000-8000-${String(820 + index).padStart(12, "0")}`; + store.prepareMutation({ + kind: "session.rename", + authorityId: session.id, + authorityGeneration: profile.processGeneration, + request: { name: `parity ${index}` }, + idempotencyKey, + }); + return idempotencyKey; + }); + const taskStore = store.createSessionTaskStore(); + const taskIds = [native, adopted].map((session, index) => + taskStore.create({ + sessionId: session.id, + name: `Parity task ${index}`, + prompt: `Run parity task ${index}`, + minutes: 15, + status: "active", + idempotencyKey: `00000000-0000-4000-8000-${String(830 + index).padStart(12, "0")}`, + }).id); + const interactions = [native, adopted].map((session, index) => + store.admitInteraction({ + publicId: `00000000-0000-4000-8000-${String(840 + index).padStart(12, "0")}`, + sessionId: session.id, + authority: { + profileId: profile.id, + processGeneration: profile.processGeneration, + connectionId: `00000000-0000-4000-8000-${String(850 + index).padStart(12, "0")}`, + requestId: { type: "string", value: `parity-${index}` }, + method: "item/fileChange/requestApproval", + requestDigest: String(index + 1).repeat(64), + threadId: session.providerThreadId ?? "", + turnId: `parity-turn-${index}`, + itemId: `parity-item-${index}`, + approvalId: null, + }, + kind: "file_change_approval", + blocking: true, + display: { + kind: "file_change_approval", + summary: "Approve parity change", + reason: null, + grantRoot: null, + availableDecisions: ["once", "decline", "cancel"], + }, + }).record); + + const begun = store.beginProfilePersonalAuthorityRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + workStore: createRevocationWorkStore(store), + }); + expect(new Set(begun.sessionIds)).toEqual(new Set([native.id, adopted.id])); + for (const session of [native, adopted]) { + const fenced = store.requireSession(session.id); + expect(fenced.state).toBe("recovery_required"); + expect(fenced.activeTurnId).toBeUndefined(); + } + for (const queueId of queueIds) { + expect(store.requireQueue(queueId).state).toBe("cancelled"); + } + for (const mutationKey of mutationKeys) { + expect(store.readMutation(mutationKey)?.state).toBe("cancelled"); + } + for (const [index, taskId] of taskIds.entries()) { + const sessionId = [native, adopted][index]?.id ?? ""; + expect(taskStore.list(sessionId).find((task) => task.id === taskId)).toMatchObject({ + status: "paused", + nextDueAt: null, + }); + } + for (const interaction of interactions) { + expect(store.requireInteraction(interaction.publicId).state).toBe("expired"); + } + + store.completePersonalSessionDetach({ sessionId: adopted.id, archive: false }); + store.completeProfilePersonalAuthorityRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + }); + for (const session of [native, adopted]) { + const retired = store.requireSession(session.id); + expect(retired.state).toBe("recovery_required"); + expect(retired.archivedAt).toBeUndefined(); + } + }); + + test("preserves an ambiguous cross-profile switch across target account revocation and restart", async () => { + const { store } = await fixture(); + const sourceProfile = signInProfile( + store, + "Revoked switch source", + "revoked-switch-source@example.com", + ); + const targetProfile = signInProfile( + store, + "Revoked switch target", + "revoked-switch-target@example.com", + ); + const source = upsertProvenTestSession(store, { + profileId: sourceProfile.id, + provider: "codex", + providerThreadId: "revoked-switch-source-thread", + preset: "high", + fastEnabled: false, + state: "idle", + }); + const attempt = store.prepareMutation({ + authorityGeneration: targetProfile.processGeneration, + authorityId: source.id, + idempotencyKey: "00000000-0000-4000-8000-000000000879", + kind: "session.switch", + request: { preset: "fable-max", provider: "claude" }, + }); + const targetRuntimeProfile = claudeAdoptionRuntimeProfile(targetProfile); + store.beginSessionProviderSwitchEffect({ + attemptId: attempt.id, + sessionId: source.id, + providerAuthentication: { + profileId: targetProfile.id, + processGeneration: targetProfile.processGeneration, + provider: "claude", + signedIn: true, + }, + evidence: { + kind: "session.switch", + daemonGeneration: 0, + requestedAccountId: null, + requestedPreset: "fable-max", + runtimeProfile: targetRuntimeProfile, + seedDigest: "8".repeat(64), + seedIncludedRecords: 1, + seedOmittedRecords: 0, + sourcePreset: "high", + sourceProcessGeneration: sourceProfile.processGeneration, + sourceProfileId: sourceProfile.id, + sourceProvider: "codex", + sourceProviderThreadId: source.providerThreadId ?? "", + targetPreset: "fable-max", + targetProcessGeneration: targetProfile.processGeneration, + targetProfileId: targetProfile.id, + targetProvider: "claude", + targetProviderAccountKey: testProviderAccountKey("claude"), + transcriptDigest: "9".repeat(64), + }, + }); + + store.beginProfilePersonalAuthorityRevocation({ + profileId: targetProfile.id, + expectedGeneration: targetProfile.processGeneration, + workStore: createRevocationWorkStore(store), + }); + const released = store.completeProfilePersonalAuthorityRevocation({ + profileId: targetProfile.id, + expectedGeneration: targetProfile.processGeneration, + }); + expect(released).toMatchObject({ + processGeneration: targetProfile.processGeneration + 1, + state: "signed_out", + }); + expect(store.isSessionMutationProviderAuthorityCurrent({ + attemptId: attempt.id, + profileId: targetProfile.id, + provider: "claude", + originGeneration: targetProfile.processGeneration, + })).toBe(true); + + expect(store.nextDaemonGeneration(`boot_${"w".repeat(32)}`)).toBe(1); + expect(store.isSessionMutationProviderAuthorityCurrent({ + attemptId: attempt.id, + profileId: targetProfile.id, + provider: "claude", + originGeneration: targetProfile.processGeneration, + })).toBe(true); + }); + + test("stages recovery before releasing exact managed Claude custody", async () => { + const { store } = await fixture(); + const profile = signInProfile( + store, + "Managed Claude recovery", + "managed-claude-recovery@example.com", + ); + const authority = store.recordClaimedClaudeProcessAuthority({ + providerThreadId: "managed-recovery-thread", + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "managed", + identity: { + pid: 51_102, + pidDomain: "darwin", + procStart: "managed-controller-recovery", + }, + }); + + expect(() => store.setProfileState( + profile.id, + profile.processGeneration, + "recovery_required", + { email: "managed-claude-recovery@example.com", plan: "Plus" }, + )).toThrow("controller revocation must be staged before account recovery"); + expect(store.stageProfilePersonalAuthorityRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + })).toMatchObject({ + profileGeneration: profile.processGeneration, + state: "releasing", + }); + expect(store.setProfileState( + profile.id, + profile.processGeneration, + "recovery_required", + { email: "managed-claude-recovery@example.com", plan: "Plus" }, + )).toBe(true); + expect(() => store.completeProfilePersonalAuthorityRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + })).toThrow("PROFILE_PERSONAL_AUTHORITY_REVOCATION_CLAUDE_PROCESS_LIVE"); + + expect(() => store.beginClaudeProcessAuthorityRelease({ + providerThreadId: authority.providerThreadId, + profileId: authority.profileId, + runtimeScope: authority.runtimeScope, + expectedRevision: authority.revision, + identity: { ...authority.identity, procStart: "wrong-controller-identity" }, + })).toThrow("SESSION_CLAUDE_PROCESS_AUTHORITY_IDENTITY_MISMATCH"); + const releasing = store.beginClaudeProcessAuthorityRelease({ + providerThreadId: authority.providerThreadId, + profileId: authority.profileId, + runtimeScope: authority.runtimeScope, + expectedRevision: authority.revision, + identity: authority.identity, + }); + store.completeClaudeProcessAuthorityRelease({ + providerThreadId: releasing.providerThreadId, + profileId: releasing.profileId, + runtimeScope: releasing.runtimeScope, + expectedRevision: releasing.revision, + identity: releasing.identity, + }); + expect(store.completeProfilePersonalAuthorityRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + })).toMatchObject({ + processGeneration: profile.processGeneration + 1, + state: "signed_out", + }); + expect(store.readProfilePersonalAuthorityRevocation(profile.id)).toMatchObject({ + profileGeneration: profile.processGeneration, + state: "completed", + }); + }); + + test("requires exact managed Claude release before daemon generation rollover", async () => { + const { store } = await fixture(); + const profile = signInProfile( + store, + "Managed Claude restart", + "managed-claude-restart@example.com", + ); + const authority = store.recordClaimedClaudeProcessAuthority({ + providerThreadId: "managed-restart-thread", + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "managed", + identity: { + pid: 51_103, + pidDomain: "darwin", + procStart: "managed-controller-before-restart", + }, + }); + const workStore = store.createWorkStore( + 9, + () => "unused-test-cursor", + { + issue: () => `hrac1_${"A".repeat(43)}`, + verify: () => true, + }, + ); + + expect(() => store.advanceProfileGenerationForDaemonShutdown( + profile.id, + profile.processGeneration, + workStore, + )).toThrow("live session controllers must release before account generation changes"); + expect(store.requireProfileById(profile.id)).toMatchObject({ + processGeneration: profile.processGeneration, + state: "signed_in", + }); + expect(store.readClaudeProcessAuthority({ + providerThreadId: authority.providerThreadId, + profileId: authority.profileId, + runtimeScope: authority.runtimeScope, + })).toEqual(authority); + + const releasing = store.beginClaudeProcessAuthorityRelease({ + providerThreadId: authority.providerThreadId, + profileId: authority.profileId, + runtimeScope: authority.runtimeScope, + expectedRevision: authority.revision, + identity: authority.identity, + }); + const released = store.completeClaudeProcessAuthorityRelease({ + providerThreadId: releasing.providerThreadId, + profileId: releasing.profileId, + runtimeScope: releasing.runtimeScope, + expectedRevision: releasing.revision, + identity: releasing.identity, + }); + expect(store.advanceProfileGenerationForDaemonShutdown( + profile.id, + profile.processGeneration, + workStore, + )).toMatchObject({ + affectedWorkIds: [], + profile: { + processGeneration: profile.processGeneration + 1, + state: "signed_in", + }, + }); + expect(store.readClaudeProcessAuthority({ + providerThreadId: authority.providerThreadId, + profileId: authority.profileId, + runtimeScope: authority.runtimeScope, + })).toEqual(released); + expect(store.listUnreleasedClaudeProcessAuthorities()).not.toContainEqual(released); + }); + + test("carries releasing revocations through clean daemon retirement atomically", async () => { + const { store } = await fixture(); + const workStore = createRevocationWorkStore(store); + const globalProfile = signInProfile( + store, + "Clean global revocation rollover", + "clean-global-revocation@example.com", + ); + const scopedProfile = signInProfile( + store, + "Clean scoped revocation rollover", + "clean-scoped-revocation@example.com", + ); + const global = store.beginProfilePersonalAuthorityRevocation({ + profileId: globalProfile.id, + expectedGeneration: globalProfile.processGeneration, + workStore, + }); + const scoped = store.beginProviderRuntimeAccountRevocation({ + profileId: scopedProfile.id, + expectedGeneration: scopedProfile.processGeneration, + provider: "claude", + runtimeScope: "personal", + currentAccountKey: null, + workStore, + }); + + const rolledGlobal = store.advanceProfileGenerationForDaemonShutdown( + globalProfile.id, + globalProfile.processGeneration, + workStore, + ).profile; + const rolledScoped = store.advanceProfileGenerationForDaemonShutdown( + scopedProfile.id, + scopedProfile.processGeneration, + workStore, + ).profile; + expect(store.readProfilePersonalAuthorityRevocation(globalProfile.id)).toMatchObject({ + profileGeneration: rolledGlobal.processGeneration, + revision: global.revocation.revision + 1, + state: "releasing", + }); + expect(store.readProviderRuntimeAccountRevocation({ + profileId: scopedProfile.id, + provider: "claude", + runtimeScope: "personal", + })).toMatchObject({ + profileGeneration: rolledScoped.processGeneration, + revision: scoped.revocation.revision + 1, + state: "releasing", + }); + expect(store.completeProfilePersonalAuthorityRevocation({ + profileId: globalProfile.id, + expectedGeneration: rolledGlobal.processGeneration, + })).toMatchObject({ + processGeneration: rolledGlobal.processGeneration + 1, + state: "signed_out", + }); + const rolledScopedRevocation = store.readProviderRuntimeAccountRevocation({ + profileId: scopedProfile.id, + provider: "claude", + runtimeScope: "personal", + }); + if (rolledScopedRevocation === null) throw new Error("Expected scoped revocation."); + expect(store.completeProviderRuntimeAccountRevocation({ + profileId: scopedProfile.id, + expectedGeneration: rolledScoped.processGeneration, + provider: "claude", + runtimeScope: "personal", + expectedRevision: rolledScopedRevocation.revision, + })).toMatchObject({ + profileGeneration: rolledScoped.processGeneration, + state: "completed", + }); + + const blockedProfile = signInProfile( + store, + "Blocked clean revocation rollover", + "blocked-clean-revocation@example.com", + ); + store.recordClaimedClaudeProcessAuthority({ + providerThreadId: "blocked-clean-revocation-process", + profileId: blockedProfile.id, + profileGeneration: blockedProfile.processGeneration, + runtimeScope: "managed", + identity: { + pid: 51_104, + pidDomain: "darwin", + procStart: "blocked-clean-revocation-process", + }, + }); + const blocked = store.beginProviderRuntimeAccountRevocation({ + profileId: blockedProfile.id, + expectedGeneration: blockedProfile.processGeneration, + provider: "claude", + runtimeScope: "managed", + currentAccountKey: null, + workStore, + }); + expect(() => store.advanceProfileGenerationForDaemonShutdown( + blockedProfile.id, + blockedProfile.processGeneration, + workStore, + )).toThrow("live session controllers must release before account generation changes"); + expect(store.requireProfileById(blockedProfile.id).processGeneration) + .toBe(blockedProfile.processGeneration); + expect(store.readProviderRuntimeAccountRevocation({ + profileId: blockedProfile.id, + provider: "claude", + runtimeScope: "managed", + })).toEqual(blocked.revocation); + }); + + test("rejects a malformed current Claude authority table without rewriting custody", async () => { + const { store } = await fixture(); + const profile = signInProfile(store, "Legacy Claude authority", "legacy-claude@example.com"); + const identities = [ + { pid: 52_001, pidDomain: "darwin" as const, procStart: "legacy-claimed" }, + { pid: 52_002, pidDomain: "darwin" as const, procStart: "legacy-releasing" }, + { pid: 52_003, pidDomain: "darwin" as const, procStart: "legacy-released" }, + ]; + const claimed = identities.map((identity, index) => + store.recordClaimedClaudeProcessAuthority({ + providerThreadId: `legacy-thread-${index}`, + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "managed", + identity, + })); + const releasing = store.beginClaudeProcessAuthorityRelease({ + providerThreadId: claimed[1]?.providerThreadId ?? "", + profileId: profile.id, + runtimeScope: "managed", + expectedRevision: claimed[1]?.revision ?? 0, + identity: identities[1] ?? identities[0]!, + }); + const releasedBegin = store.beginClaudeProcessAuthorityRelease({ + providerThreadId: claimed[2]?.providerThreadId ?? "", + profileId: profile.id, + runtimeScope: "managed", + expectedRevision: claimed[2]?.revision ?? 0, + identity: identities[2] ?? identities[0]!, + }); + store.completeClaudeProcessAuthorityRelease({ + providerThreadId: releasedBegin.providerThreadId, + profileId: releasedBegin.profileId, + runtimeScope: releasedBegin.runtimeScope, + expectedRevision: releasedBegin.revision, + identity: releasedBegin.identity, + }); + expect(releasing.state).toBe("releasing"); + const databasePath = store.paths.database; + store.close(); + stores.splice(stores.indexOf(store), 1); + + const legacy = new Database(databasePath, { create: false, strict: true }); + try { + legacy.exec(` + DROP TRIGGER IF EXISTS session_claude_process_authority_session_guard_insert; + DROP TRIGGER IF EXISTS session_claude_process_authority_session_guard_update; + DROP TRIGGER IF EXISTS session_claude_process_authority_revision_guard; + DROP TRIGGER IF EXISTS sessions_claude_process_authority_rebind_guard; + DROP INDEX IF EXISTS session_claude_process_authorities_live_identity; + DROP INDEX IF EXISTS session_claude_process_authorities_session; + ALTER TABLE session_claude_process_authorities + RENAME TO session_claude_process_authorities_scoped_backup; + CREATE TABLE session_claude_process_authorities ( + provider_thread_id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL, + profile_generation INTEGER NOT NULL, + runtime_scope TEXT NOT NULL, + session_id TEXT, + pid INTEGER NOT NULL, + pid_domain TEXT NOT NULL, + proc_start TEXT NOT NULL, + state TEXT NOT NULL, + revision INTEGER NOT NULL, + recorded_at INTEGER NOT NULL, + released_at INTEGER + ) STRICT; + INSERT INTO session_claude_process_authorities + SELECT * FROM session_claude_process_authorities_scoped_backup; + DROP TABLE session_claude_process_authorities_scoped_backup; + `); + } finally { + legacy.close(false); + } + + expect(() => new StateStore(store.paths)) + .toThrow("STATE_SCHEMA_V39_OBJECT_MISSING:session_claude_process_authorities_live_identity"); + const primaryKey = new Database(databasePath, { readonly: true, strict: true }); + try { + expect((primaryKey.query( + "PRAGMA table_info(session_claude_process_authorities)", + ).all() as { name: string; pk: number }[]) + .filter((column) => column.pk > 0) + .sort((left, right) => left.pk - right.pk) + .map((column) => column.name)).toEqual(["provider_thread_id"]); + expect(primaryKey.query( + "SELECT state FROM session_claude_process_authorities ORDER BY provider_thread_id", + ).all()).toEqual([ + { state: "claimed" }, + { state: "releasing" }, + { state: "released" }, + ]); + } finally { + primaryKey.close(false); + } + }); + + test("quarantines mismatched and legacy session identity without inventing provenance", async () => { + const { store, home } = await fixture(); + const profile = signInProfile( + store, + "Legacy provider provenance", + "legacy-provider-provenance@example.com", + ); + const projectRoot = join(home, "legacy-provider-provenance"); + await mkdir(projectRoot); + const project = await store.createProject( + "Legacy provider provenance", + projectRoot, + true, + ); + const key = providerAccountKeyForProfile(store, profile.id, "codex"); + const proven = store.upsertProviderSession({ + profileId: profile.id, + provider: "codex", + providerThreadId: "proven-v35-session", + title: "Proven v35 session", + preset: "high", + fastEnabled: false, + state: "idle", + providerAccountKey: key, + }); + const importedPartial = store.upsertProviderSession({ + profileId: profile.id, + provider: "codex", + providerThreadId: "partial-v35-session", + projectId: project.id, + title: "Partial v35 session", + preset: "high", + fastEnabled: false, + state: "idle", + providerAccountKey: key, + }); + const partial = store.updateSessionMetadata({ + sessionId: importedPartial.id, + expectedRevision: importedPartial.revision, + preset: "high", + }); + const queue = store.enqueue(partial.id, "legacy queued effect"); + const mutationKey = "00000000-0000-4000-8000-000000000903"; + store.prepareMutation({ + kind: "session.rename", + authorityId: partial.id, + authorityGeneration: profile.processGeneration, + request: { name: "Legacy prepared rename" }, + idempotencyKey: mutationKey, + }); + const sessionTask = store.createSessionTaskStore().create({ + sessionId: partial.id, + name: "Legacy active task", + prompt: "Do not dispatch after migration.", + minutes: 15, + status: "active", + idempotencyKey: "00000000-0000-4000-8000-000000000904", + }); + const interaction = store.admitInteraction({ + publicId: "00000000-0000-4000-8000-000000000905", + sessionId: partial.id, + authority: { + profileId: profile.id, + processGeneration: profile.processGeneration, + connectionId: "00000000-0000-4000-8000-000000000906", + requestId: { type: "string", value: "legacy-migration" }, + method: "item/fileChange/requestApproval", + requestDigest: "9".repeat(64), + threadId: partial.providerThreadId ?? "", + turnId: "legacy-migration-turn", + itemId: "legacy-migration-item", + approvalId: null, + }, + kind: "file_change_approval", + blocking: true, + display: { + kind: "file_change_approval", + summary: "Legacy migration approval", + reason: null, + grantRoot: null, + availableDecisions: ["once", "decline", "cancel"], + }, + }).record; + const workStore = createRevocationWorkStore(store); + const createdWork = workStore.apply({ + kind: "work.create", + idempotencyKey: "01890f31-a123-7000-8000-000000000907", + clientRef: "legacy-migration-work", + coordinatorSessionId: partial.id, + objective: "Prove migration-time work retirement.", + routes: [{ + accountId: profile.id, + projectId: project.id, + preset: "high", + fast: false, + }], + tasks: [{ + clientRef: "legacy-migration-work-task", + dependsOnRefs: [], + dependsOnTaskIds: [], + objective: "Hold one legacy claim.", + instructions: "Retire this claim during v35 repair.", + criteria: ["The claim is no longer active."], + route: { accountId: profile.id, projectId: project.id }, + preset: "high", + fast: false, + priority: 0, + maxAttempts: 3, + requiredReviews: 0, + resultKind: "text", + minEvidence: 0, + }], + }); + if (createdWork.kind !== "work.create") throw new Error("Expected legacy work."); + const workTask = createdWork.tasks[0]; + if (workTask === undefined) throw new Error("Expected one legacy work task."); + const workClaim = workStore.apply({ + kind: "task.claim", + idempotencyKey: "01890f31-a123-7000-8000-000000000908", + workId: createdWork.work.id, + taskId: workTask.id, + expectedTaskRevision: workTask.revision, + actorSessionId: partial.id, + actorCapability: `hrac1_${"A".repeat(43)}`, + leaseMs: 5_000, + }); + if (workClaim.kind !== "task.claim") throw new Error("Expected legacy work claim."); + const paths = store.paths; + store.close(); + stores.splice(stores.indexOf(store), 1); + const partialV35 = new Database(paths.database, { create: false, strict: true }); + try { + const guard = z.object({ sql: z.string() }).strict().parse(partialV35.query( + `SELECT sql FROM sqlite_master + WHERE type='trigger' AND name='session_provider_account_authority_update_guard'`, + ).get()); + partialV35.exec("DROP TRIGGER session_provider_account_authority_update_guard"); + partialV35.query( + "UPDATE session_provider_account_authorities SET account_key=? WHERE session_id=?", + ).run(`v1:codex:${"f".repeat(64)}`, partial.id); + partialV35.exec(guard.sql); + downgradeToExactLegacyAdoptionVersion36(partialV35); + } finally { + partialV35.close(false); + } + const repaired = new StateStore(paths); + stores.push(repaired); + expect(repaired.sessionAccountAuthorityMatches(proven.id, profile.id)).toBe(true); + expect(repaired.sessionAccountAuthorityMatches(partial.id, profile.id)).toBe(false); + expect(repaired.readSessionProviderAccountAuthority(partial.id)).toBeNull(); + expect(repaired.requireSession(partial.id).state).toBe("recovery_required"); + expect(repaired.requireQueue(queue.id).state).toBe("cancelled"); + expect(repaired.readMutation(mutationKey)?.state).toBe("cancelled"); + expect(repaired.createSessionTaskStore().list(partial.id) + .find((task) => task.id === sessionTask.id)).toMatchObject({ + status: "paused", + nextDueAt: null, + }); + expect(repaired.requireInteraction(interaction.publicId).state).toBe("expired"); + expect(createRevocationWorkStore(repaired).task(workTask.id).activeAttempt).toBeNull(); + const inspector = new Database(paths.database, { create: false, strict: true }); + try { + expect(inspector.query( + "SELECT account_key FROM session_account_authorities WHERE session_id=?", + ).get(partial.id)).toEqual({ account_key: null }); + expect(() => inspector.query( + `INSERT INTO session_provider_account_authorities( + session_id,provider,runtime_scope,account_key,recorded_at + ) VALUES (?,'codex','managed',?,?)`, + ).run(partial.id, `v1:codex:${"f".repeat(64)}`, 1)) + .toThrow("session provider account authority does not match its session"); + } finally { + inspector.close(false); + } + + const legacyValue = await fixture(); + const legacyProfile = signInProfile( + legacyValue.store, + "Version 34 legacy session", + "version-34-legacy-session@example.com", + ); + const legacySession = legacyValue.store.createSession({ + profileId: legacyProfile.id, + provider: "codex", + preset: "high", + fastEnabled: false, + }); + legacyValue.store.close(); + stores.splice(stores.indexOf(legacyValue.store), 1); + const legacy = new Database(legacyValue.store.paths.database, { + create: false, + strict: true, + }); + try { + dropSchemaAfterVersion34(legacy); + legacy.exec("DELETE FROM migrations WHERE version>34; PRAGMA user_version=34;"); + } finally { + legacy.close(false); + } + const migrated = new StateStore(legacyValue.store.paths); + stores.push(migrated); + expect(migrated.sessionAccountAuthorityMatches( + legacySession.id, + legacyProfile.id, + )).toBe(false); + expect(migrated.requireSession(legacySession.id).state).toBe("recovery_required"); + expect(migrated.readSessionProviderAccountAuthority(legacySession.id)).toBeNull(); + }); + + test("quarantines malformed pre-release v36 Claude authority and every admitted effect", async () => { + const { store, home } = await fixture(); + const profile = signInProfile( + store, + "Legacy Claude provider provenance", + "legacy-claude-provider-provenance@example.com", + ); + const unaffectedProfile = store.createProfile("Unaffected signed-out Claude profile"); + const projectRoot = join(home, "legacy-claude-provider-provenance"); + await mkdir(projectRoot); + const project = await store.createProject( + "Legacy Claude provider provenance", + projectRoot, + true, + ); + const malformed = upsertProvenTestSession(store, { + profileId: profile.id, + provider: "claude", + providerThreadId: "pre-release-v36-malformed-claude", + projectId: project.id, + title: "Malformed pre-release v36 Claude", + preset: "fable-max", + fastEnabled: false, + state: "idle", + }); + const revoked = upsertProvenTestSession(store, { + profileId: profile.id, + provider: "claude", + providerThreadId: "pre-release-v36-revoked-claude", + title: "Revoked pre-release v36 Claude", + preset: "fable-max", + fastEnabled: false, + state: "idle", + }); + const unaffected = upsertProvenTestSession(store, { + profileId: unaffectedProfile.id, + provider: "claude", + providerThreadId: "valid-signed-out-claude", + title: "Valid signed-out Claude", + preset: "fable-max", + fastEnabled: false, + state: "idle", + }); + const queue = store.enqueue(malformed.id, "legacy Claude queued effect"); + const mutationKey = "00000000-0000-4000-8000-000000000909"; + store.prepareMutation({ + kind: "session.rename", + authorityId: malformed.id, + authorityGeneration: profile.processGeneration, + request: { name: "Legacy Claude prepared rename" }, + idempotencyKey: mutationKey, + }); + const sessionTask = store.createSessionTaskStore().create({ + sessionId: malformed.id, + name: "Legacy Claude active task", + prompt: "Do not dispatch after migration.", + minutes: 15, + status: "active", + idempotencyKey: "00000000-0000-4000-8000-000000000910", + }); + const interaction = store.admitInteraction({ + publicId: "00000000-0000-4000-8000-000000000911", + sessionId: malformed.id, + authority: { + profileId: profile.id, + processGeneration: profile.processGeneration, + connectionId: "00000000-0000-4000-8000-000000000912", + requestId: { type: "string", value: "legacy-claude-migration" }, + method: "item/fileChange/requestApproval", + requestDigest: "8".repeat(64), + threadId: malformed.providerThreadId ?? "", + turnId: "legacy-claude-migration-turn", + itemId: "legacy-claude-migration-item", + approvalId: null, + }, + kind: "file_change_approval", + blocking: true, + display: { + kind: "file_change_approval", + summary: "Legacy Claude migration approval", + reason: null, + grantRoot: null, + availableDecisions: ["once", "decline", "cancel"], + }, + }).record; + const paths = store.paths; + store.close(); + stores.splice(stores.indexOf(store), 1); + const preReleaseV36 = new Database(paths.database, { create: false, strict: true }); + try { + const guard = z.object({ sql: z.string() }).strict().parse(preReleaseV36.query( + `SELECT sql FROM sqlite_master + WHERE type='trigger' AND name='session_provider_account_authority_update_guard'`, + ).get()); + preReleaseV36.exec("DROP TRIGGER session_provider_account_authority_update_guard"); + preReleaseV36.query( + "UPDATE session_provider_account_authorities SET runtime_scope='personal' WHERE session_id=?", + ).run(malformed.id); + preReleaseV36.query( + `INSERT INTO provider_runtime_account_revocations( + profile_id,profile_generation,provider,runtime_scope,current_account_key, + state,revision,created_at,updated_at,completed_at + ) VALUES (?,?,'claude','managed',NULL,'completed',1,?,?,?)`, + ).run( + profile.id, + profile.processGeneration, + 2_000, + 2_000, + 2_000, + ); + preReleaseV36.exec(guard.sql); + downgradeToExactLegacyAdoptionVersion36(preReleaseV36); + } finally { + preReleaseV36.close(false); + } + + const repaired = new StateStore(paths); + stores.push(repaired); + expect(repaired.sessionAccountAuthorityMatches(unaffected.id, unaffectedProfile.id)).toBe(true); + expect(repaired.readSessionProviderAccountAuthority(unaffected.id)).not.toBeNull(); + for (const invalid of [malformed, revoked]) { + expect(repaired.sessionAccountAuthorityMatches(invalid.id, profile.id)).toBe(false); + expect(repaired.readSessionProviderAccountAuthority(invalid.id)).toBeNull(); + expect(repaired.requireSession(invalid.id).state).toBe("recovery_required"); + } + expect(repaired.requireQueue(queue.id).state).toBe("cancelled"); + expect(repaired.readMutation(mutationKey)?.state).toBe("cancelled"); + expect(repaired.createSessionTaskStore().list(malformed.id) + .find((task) => task.id === sessionTask.id)).toMatchObject({ + status: "paused", + nextDueAt: null, + }); + expect(repaired.requireInteraction(interaction.publicId).state).toBe("expired"); + }); + + test("rejects a malformed current adoption candidate table without releasing fences", async () => { + const { store } = await fixture(); + const candidate = store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: "legacy-v35-candidate", + title: "Legacy v35 candidate", + state: "terminal", + providerUpdatedAt: 10, + liveness: "not_live", + }); + const databasePath = store.paths.database; + store.close(); + stores.splice(stores.indexOf(store), 1); + + const legacy = new Database(databasePath, { create: false, strict: true }); + try { + legacy.query( + `UPDATE session_adoption_candidates + SET claim_status='fenced',fenced_fingerprint=candidate_fingerprint, + revision=revision+1 + WHERE provider=? AND provider_thread_id=?`, + ).run(candidate.provider, candidate.providerThreadId); + legacy.exec(` + PRAGMA foreign_keys=OFF; + PRAGMA legacy_alter_table=ON; + BEGIN IMMEDIATE; + DROP TRIGGER IF EXISTS session_adoption_candidate_identity_immutable; + DROP TRIGGER IF EXISTS session_adoption_candidate_revision_guard; + DROP TRIGGER IF EXISTS session_adoption_candidate_source_identity_guard_insert; + DROP TRIGGER IF EXISTS session_adoption_candidate_source_identity_guard_update; + DROP INDEX IF EXISTS session_adoption_candidates_pending; + DROP INDEX IF EXISTS session_adoption_candidates_claude_reprobe; + ALTER TABLE session_adoption_candidates + RENAME TO session_adoption_candidates_v35_early; + CREATE TABLE session_adoption_candidates ( + provider TEXT NOT NULL CHECK(provider IN ('codex','claude')), + provider_thread_id TEXT NOT NULL CHECK(length(provider_thread_id) BETWEEN 1 AND 200), + project_id TEXT, + title TEXT NOT NULL CHECK(length(CAST(title AS BLOB)) BETWEEN 1 AND 320), + provider_state TEXT NOT NULL CHECK(provider_state IN ('active','idle','terminal')), + active_turn_id TEXT CHECK(active_turn_id IS NULL OR length(active_turn_id) BETWEEN 1 AND 2048), + provider_updated_at REAL CHECK(provider_updated_at IS NULL OR provider_updated_at >= 0), + liveness TEXT NOT NULL CHECK(liveness IN ('live','not_live','unknown')), + claim_status TEXT NOT NULL CHECK(claim_status IN ('pending','claiming','adopted','fenced')), + candidate_fingerprint TEXT NOT NULL CHECK(length(candidate_fingerprint)=64 AND candidate_fingerprint GLOB '[0-9a-f]*'), + fenced_fingerprint TEXT CHECK(fenced_fingerprint IS NULL OR (length(fenced_fingerprint)=64 AND fenced_fingerprint GLOB '[0-9a-f]*')), + revision INTEGER NOT NULL CHECK(revision BETWEEN 1 AND 9007199254740991), + first_discovered_at INTEGER NOT NULL CHECK(first_discovered_at >= 0), + last_observed_at INTEGER NOT NULL CHECK(last_observed_at >= first_discovered_at), + last_changed_at INTEGER NOT NULL CHECK(last_changed_at BETWEEN first_discovered_at AND last_observed_at), + last_attempt_at INTEGER CHECK(last_attempt_at IS NULL OR last_attempt_at >= first_discovered_at), + PRIMARY KEY(provider,provider_thread_id), + CHECK( + (claim_status='fenced' AND fenced_fingerprint IS NOT NULL) + OR (claim_status!='fenced' AND fenced_fingerprint IS NULL) + ) + ) STRICT; + INSERT INTO session_adoption_candidates( + provider,provider_thread_id,project_id,title,provider_state, + active_turn_id,provider_updated_at,liveness,claim_status, + candidate_fingerprint,fenced_fingerprint,revision, + first_discovered_at,last_observed_at,last_changed_at,last_attempt_at + ) + SELECT provider,provider_thread_id,project_id,title,provider_state, + active_turn_id,provider_updated_at,liveness,claim_status, + candidate_fingerprint,fenced_fingerprint,revision, + first_discovered_at,last_observed_at,last_changed_at,last_attempt_at + FROM session_adoption_candidates_v35_early; + DROP TABLE session_adoption_candidates_v35_early; + COMMIT; + PRAGMA legacy_alter_table=OFF; + `); + } finally { + legacy.close(false); + } + + expect(() => new StateStore(store.paths)) + .toThrow("STATE_SCHEMA_V39_OBJECT_MISSING:session_adoption_candidates_claude_reprobe"); + const inspector = new Database(databasePath, { readonly: true, strict: true }); + try { + const candidateColumns = inspector.query( + "PRAGMA table_info(session_adoption_candidates)", + ).all() as Array<{ name: string }>; + const candidateColumnNames = candidateColumns.map((column) => column.name); + for (const name of [ + "source_pid", + "source_pid_domain", + "source_proc_start", + "last_live_observed_at", + "provider_project_root", + ]) { + expect(candidateColumnNames.includes(name)).toBe(false); + } + expect(inspector.query( + `SELECT claim_status,fenced_fingerprint FROM session_adoption_candidates + WHERE provider=? AND provider_thread_id=?`, + ).get(candidate.provider, candidate.providerThreadId)).toMatchObject({ + claim_status: "fenced", + }); + } finally { + inspector.close(false); + } + }); + + test("keeps session recovery absorbing across passive and exact-state reconciliation", async () => { + const { store } = await fixture(); + const profile = signInProfile(store, "Recovery", "recovery@example.com"); + const local = store.createSession({ profileId: profile.id, preset: "high", fastEnabled: false }); + const bound = store.bindSession({ + sessionId: local.id, + expectedRevision: local.revision, + providerThreadId: "thread-recovery", + state: "idle", + providerUpdatedAt: 10, + }); + const quarantined = store.quarantineSession(bound.id); + expect(quarantined).toMatchObject({ state: "recovery_required", providerUpdatedAt: 10 }); + + const passive = store.upsertProviderSession({ + profileId: profile.id, + provider: "codex", + providerThreadId: "thread-recovery", + preset: "high", + fastEnabled: false, + title: "Passive projection", + state: "active", + activeTurnId: "turn-passive", + providerUpdatedAt: 11, + providerAccountKey: providerAccountKeyForProfile(store, profile.id, "codex"), + }); + expect(passive).toMatchObject({ state: "recovery_required", title: "Untitled session", revision: quarantined.revision }); + + expect(store.reconcileSessionFromProvider({ sessionId: quarantined.id, state: "active", activeTurnId: "turn-exact", title: "Exact projection" })).toEqual(quarantined); + expect(() => store.resolveSessionStatusRecovery({ + sessionId: quarantined.id, + expectedRevision: quarantined.revision, + resolution: "provider_state_reconciled", + provider: { + providerThreadId: "thread-recovery", + title: "Missing active turn", + status: "active", + providerUpdatedAt: 12, + }, + })).toThrow("SESSION_STATUS_RECOVERY_ACTIVE_TURN_MISSING"); + expect(store.requireSession(quarantined.id)).toEqual(quarantined); + }); + + test("deletes only exact unbound and evidence-free starting sessions", async () => { + const { store, home } = await fixture(); + const profile = signInProfile(store, "Cleanup", "cleanup@example.com"); + const removable = store.createSession({ + profileId: profile.id, + preset: "high", + fastEnabled: false, + }); + expect(store.deleteUnboundStartingSession(removable.id, removable.revision + 1)).toBe(false); + expect(store.deleteUnboundStartingSession(removable.id, removable.revision)).toBe(true); + expect(() => store.requireSession(removable.id)).toThrow(SelectionError); + + const bound = store.createSession({ profileId: profile.id, preset: "high", fastEnabled: false }); + store.bindSession({ + sessionId: bound.id, + expectedRevision: bound.revision, + providerThreadId: "thread-bound", + state: "idle", + }); + expect(store.deleteUnboundStartingSession(bound.id, bound.revision)).toBe(false); + + const projectRoot = join(home, "starting-queue-evidence"); + await mkdir(projectRoot); + const project = await store.createProject("Starting queue evidence", projectRoot); + const startAttempt = store.prepareMutation({ + kind: "session.start", + authorityId: profile.id, + authorityGeneration: profile.processGeneration, + request: { projectId: project.id, preset: "high", fast: false }, + idempotencyKey: "00000000-0000-4000-8000-0000000006c0", + }); + const queued = store.beginSessionStartEffect({ + attemptId: startAttempt.id, + profileId: profile.id, + profileGeneration: profile.processGeneration, + projectId: project.id, + preset: "high", + fastEnabled: false, + providerAccountKey: providerAccountKeyForProfile(store, profile.id, "codex"), + evidence: { + kind: "session.start", + projectId: project.id, + clientMessageId: null, + messageDigest: null, + }, + }); + store.enqueue(queued.id, "retained queue evidence"); + expect(store.deleteUnboundStartingSession(queued.id, queued.revision)).toBe(false); + + const summarized = store.createSession({ profileId: profile.id, preset: "high", fastEnabled: false }); + const database = new Database(store.paths.database, { create: false, strict: true }); + try { + database + .query("INSERT INTO turn_summaries(session_id,turn_id,sequence,summary_json,created_at) VALUES (?,?,?,?,?)") + .run(summarized.id, "turn-1", 0, "{}", 1_000); + } finally { + database.close(false); + } + expect(store.deleteUnboundStartingSession(summarized.id, summarized.revision)).toBe(false); + }); + + test("persists idempotent mutation receipts and rejects changed reuse", async () => { + const { store } = await fixture(); + const key = "b83efca6-d731-498e-ac2c-876555a4ae2d"; + const first = store.prepareMutation({ kind: "turn.start", authorityId: "session", authorityGeneration: 1, request: { message: "hello" }, idempotencyKey: key }); + expect(first.replay).toBe(false); + expect(store.transitionMutation(first.id, "prepared", "effect_started")).toBe(true); + expect(store.transitionMutation(first.id, "effect_started", "applied", { turnId: "turn-1" })).toBe(true); + expect(store.prepareMutation({ kind: "turn.start", authorityId: "session", authorityGeneration: 1, request: { message: "hello" }, idempotencyKey: key })).toMatchObject({ replay: true, state: "applied", result: { turnId: "turn-1" } }); + expect(() => store.prepareMutation({ kind: "turn.start", authorityId: "session", authorityGeneration: 1, request: { message: "changed" }, idempotencyKey: key })).toThrow("IDEMPOTENCY_CONFLICT"); + }); + + test("leaves a crash before effect dispatch replayable without quarantining its authority", async () => { const { store } = await fixture(); const profile = signInProfile(store, "Prepared crash", "prepared@example.com"); const local = store.createSession({ profileId: profile.id, preset: "high", fastEnabled: false }); @@ -1481,106 +6385,6 @@ describe("StateStore", () => { })).toThrow("CLAUDE_LOGIN_NOT_UNSETTLED"); }); - test("persists and idempotently resolves Devin foreground-login authority across restart", async () => { - const { store, home } = await fixture(); - const profile = store.createProfile("Devin foreground auth"); - const key = "00000000-0000-4000-8000-000000000617"; - const attempt = store.prepareMutation({ - kind: "account.devin-login", - authorityId: profile.id, - authorityGeneration: profile.processGeneration, - request: { provider: "devin" }, - idempotencyKey: key, - }); - store.beginDevinLoginMutationEffect({ - attemptId: attempt.id, - profileId: profile.id, - profileGeneration: profile.processGeneration, - evidence: { kind: "account.devin-login", provider: "devin", baselineSignedIn: false }, - }); - expect(store.readMutation(key)).toMatchObject({ - state: "effect_started", - evidence: { evidence: { kind: "account.devin-login", provider: "devin" } }, - }); - expect(store.providerAuthorityAdvanceBlocker(profile.id, "devin")) - .toBe("unsettled_authority"); - expect(store.providerAuthorityAdvanceBlocker(profile.id, "codex")) - .toBe("unsettled_authority"); - expect(() => store.nextProfileGeneration(profile.id)) - .toThrow("DEVIN_LOGIN_AUTHORITY_UNSETTLED"); - store.close(); - - const restarted = new StateStore( - resolveStatePaths({ homeDirectory: home, platform: "darwin" }), - { now: (() => { let value = 2_000; return () => value++; })() }, - ); - stores.push(restarted); - expect(restarted.nextDaemonGeneration(`boot_${"d".repeat(32)}`)).toBe(1); - expect(restarted.requireProfile(profile.id).processGeneration).toBe(1); - expect(restarted.recoverEffectStartedMutations()).toEqual({ - recovered: [attempt.id], - unresolved: [], - }); - const completion = { - attemptId: attempt.id, - idempotencyKey: key, - profileId: profile.id, - profileGeneration: profile.processGeneration, - signedIn: true, - outcome: { state: "joined" as const, exitCode: 0, interruptedBy: null }, - }; - expect(restarted.settleDevinLoginMutation(completion)).toMatchObject({ - accountId: profile.id, - providerGeneration: profile.processGeneration, - signedIn: true, - }); - expect(restarted.settleDevinLoginMutation(completion)).toMatchObject({ signedIn: true }); - expect(restarted.readMutation(key)).toMatchObject({ - state: "reconciled", - originalState: "ambiguous", - resolution: { kind: "proven_applied" }, - }); - - const current = restarted.requireProfile(profile.id); - const abandonKey = "00000000-0000-4000-8000-000000000618"; - const abandonedAttempt = restarted.prepareMutation({ - kind: "account.devin-login", - authorityId: current.id, - authorityGeneration: current.processGeneration, - request: { provider: "devin" }, - idempotencyKey: abandonKey, - }); - restarted.beginDevinLoginMutationEffect({ - attemptId: abandonedAttempt.id, - profileId: current.id, - profileGeneration: current.processGeneration, - evidence: { kind: "account.devin-login", provider: "devin", baselineSignedIn: false }, - }); - const abandon = { - attemptId: abandonedAttempt.id, - idempotencyKey: abandonKey, - profileId: current.id, - profileGeneration: current.processGeneration, - acknowledgeChildExited: true as const, - }; - expect(restarted.abandonDevinLoginMutation(abandon)) - .toMatchObject({ acknowledgedChildExited: true }); - expect(restarted.abandonDevinLoginMutation(abandon)) - .toMatchObject({ acknowledgedChildExited: true }); - expect(restarted.readMutation(abandonKey)).toMatchObject({ - state: "reconciled", - resolution: { kind: "abandoned" }, - }); - expect(() => restarted.settleDevinLoginMutation({ - attemptId: abandonedAttempt.id, - idempotencyKey: abandonKey, - profileId: current.id, - profileGeneration: current.processGeneration, - signedIn: false, - outcome: { state: "joined", exitCode: 1, interruptedBy: null }, - })).toThrow("DEVIN_LOGIN_TERMINAL_OUTCOME_CONFLICT"); - }); - test("classifies effect-started authorities at restart and rejects new keys", async () => { const { store } = await fixture(); const profile = signInProfile(store, "Restart recovery", "restart@example.com"); @@ -1620,17 +6424,13 @@ describe("StateStore", () => { test("terminalizes only exact quiescent idle Claude authority for account login", async () => { const { store } = await fixture(); - const profile = store.createProfile("Claude relink"); - const created = store.createSession({ + const profile = signInProfile(store, "Claude relink", "claude-relink@example.com"); + let session = upsertProvenTestSession(store, { fastEnabled: false, preset: "fable-max", profileId: profile.id, provider: "claude", - }); - let session = store.bindSession({ - expectedRevision: created.revision, providerThreadId: "claude-thread-relink", - sessionId: created.id, state: "idle", }); const input = { @@ -1680,97 +6480,557 @@ describe("StateStore", () => { event: { body: { activeTurnId: null, status: "terminal", type: "session_status" }, }, - interactions: [], - session: { provider: "claude", state: "terminal" }, + interactions: [], + session: { provider: "claude", state: "terminal" }, + }); + }); + + test("scopes managed Claude login sessions and activity away from personal custody", async () => { + const { store } = await fixture(); + const profile = signInProfile( + store, + "Managed Claude login scope", + "managed-claude-login-scope@example.com", + ); + store.setSessionAdoptionPolicy({ provider: "claude", profileId: profile.id }); + + const personalIdle = adoptPersonalClaudeTestSession(store, profile); + const personalActive = adoptPersonalClaudeTestSession(store, profile); + const personalUnsettled = adoptPersonalClaudeTestSession(store, profile); + store.setSessionTurnState({ + activeTurnId: "personal-active-turn", + expectedRevision: personalActive.revision, + sessionId: personalActive.id, + state: "active", + }); + const personalMutation = store.prepareMutation({ + authorityGeneration: profile.processGeneration, + authorityId: personalUnsettled.id, + idempotencyKey: "00000000-0000-4000-8000-000000000617", + kind: "session.rename", + request: { name: "Personal unsettled rename" }, + }); + expect(store.transitionMutation( + personalMutation.id, + "prepared", + "effect_started", + )).toBe(true); + + expect(store.listNonterminalManagedClaudeSessions(profile.id)).toEqual([]); + expect(store.managedClaudeLoginAuthorityBlocker(profile.id)).toBeNull(); + expect(store.canReleaseIdleManagedClaudeSessionForAccountLogin({ + profileId: profile.id, + profileGeneration: profile.processGeneration, + sessionId: personalIdle.id, + })).toBe(false); + expect(store.canReleaseIdleClaudeSessionForAccountLogin({ + profileId: profile.id, + profileGeneration: profile.processGeneration, + sessionId: personalIdle.id, + })).toBe(false); + expect(() => store.terminalizeIdleClaudeSessionForAccountLogin({ + accountId: profile.id, + providerConnectionId: null, + providerGeneration: profile.processGeneration, + sessionId: personalIdle.id, + })).toThrow("CLAUDE_LOGIN_SESSION_NOT_QUIESCENT"); + expect(store.beginPersonalSessionDetach({ sessionId: personalIdle.id }).binding.state) + .toBe("detaching"); + expect(store.canReleaseIdleManagedClaudeSessionForAccountLogin({ + profileId: profile.id, + profileGeneration: profile.processGeneration, + sessionId: personalIdle.id, + })).toBe(false); + expect(store.listNonterminalManagedClaudeSessions(profile.id)).toEqual([]); + expect(store.managedClaudeLoginAuthorityBlocker(profile.id)).toBeNull(); + const personalIdleProcess = store.readSessionClaudeProcessAuthority(personalIdle.id); + if (personalIdleProcess === null) throw new Error("Expected detached personal Claude custody."); + const personalIdleReleasing = store.beginClaudeProcessAuthorityRelease({ + providerThreadId: personalIdleProcess.providerThreadId, + profileId: personalIdleProcess.profileId, + runtimeScope: personalIdleProcess.runtimeScope, + expectedRevision: personalIdleProcess.revision, + identity: personalIdleProcess.identity, + }); + store.completeClaudeProcessAuthorityRelease({ + providerThreadId: personalIdleReleasing.providerThreadId, + profileId: personalIdleReleasing.profileId, + runtimeScope: personalIdleReleasing.runtimeScope, + expectedRevision: personalIdleReleasing.revision, + identity: personalIdleReleasing.identity, + }); + expect(store.completePersonalSessionDetach({ + sessionId: personalIdle.id, + archive: false, + }).binding.state).toBe("detached"); + expect(store.requireSession(personalIdle.id).state).toBe("idle"); + expect(store.listNonterminalManagedClaudeSessions(profile.id)).toEqual([]); + expect(store.managedClaudeLoginAuthorityBlocker(profile.id)).toBeNull(); + expect(store.quarantineSession(personalIdle.id).state).toBe("recovery_required"); + expect(store.listNonterminalManagedClaudeSessions(profile.id)).toEqual([]); + expect(store.managedClaudeLoginAuthorityBlocker(profile.id)).toBeNull(); + + let managed = upsertProvenTestSession(store, { + fastEnabled: false, + preset: "fable-max", + profileId: profile.id, + provider: "claude", + providerThreadId: "managed-claude-login-thread", + state: "idle", + }); + expect(store.listNonterminalManagedClaudeSessions(profile.id).map((session) => session.id)) + .toEqual([managed.id]); + expect(store.canReleaseIdleManagedClaudeSessionForAccountLogin({ + profileId: profile.id, + profileGeneration: profile.processGeneration, + sessionId: managed.id, + })).toBe(true); + + managed = store.setSessionTurnState({ + activeTurnId: "managed-active-turn", + expectedRevision: managed.revision, + sessionId: managed.id, + state: "active", + }); + expect(store.managedClaudeLoginAuthorityBlocker(profile.id)).toBe("active_session"); + managed = store.setSessionTurnState({ + expectedRevision: managed.revision, + sessionId: managed.id, + state: "idle", + }); + const managedMutation = store.prepareMutation({ + authorityGeneration: profile.processGeneration, + authorityId: managed.id, + idempotencyKey: "00000000-0000-4000-8000-000000000618", + kind: "session.rename", + request: { name: "Managed unsettled rename" }, + }); + expect(store.transitionMutation( + managedMutation.id, + "prepared", + "effect_started", + )).toBe(true); + expect(store.managedClaudeLoginAuthorityBlocker(profile.id)) + .toBe("unsettled_authority"); + expect(store.transitionMutation( + managedMutation.id, + "effect_started", + "applied", + { renamed: true }, + )).toBe(true); + expect(store.managedClaudeLoginAuthorityBlocker(profile.id)).toBeNull(); + + const mismatchedCandidate = store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: "mismatched-personal-claude-thread", + title: "Mismatched personal Claude authority", + state: "idle", + providerUpdatedAt: 10, + liveness: "not_live", + }); + const direct = new Database(store.paths.database, { create: false, strict: true }); + try { + direct.exec("DROP TRIGGER session_personal_runtime_binding_identity_immutable"); + direct.query( + `UPDATE session_personal_runtime_bindings + SET provider_thread_id=?,revision=revision+1,updated_at=updated_at+1 + WHERE session_id=?`, + ).run(mismatchedCandidate.providerThreadId, personalActive.id); + } finally { + direct.close(false); + } + expect(store.listNonterminalManagedClaudeSessions(profile.id).map((session) => session.id)) + .toEqual([managed.id]); + expect(store.managedClaudeLoginAuthorityBlocker(profile.id)) + .toBe("unsettled_authority"); + }); + + test("scopes managed Claude login provider-switch authority by source and target", async () => { + const { store } = await fixture(); + const claudeProfile = signInProfile( + store, + "Claude switch login scope", + "claude-switch-login-scope@example.com", + ); + const codexProfile = signInProfile( + store, + "Codex switch login scope", + "codex-switch-login-scope@example.com", + ); + store.setSessionAdoptionPolicy({ provider: "claude", profileId: claudeProfile.id }); + const personalSource = adoptPersonalClaudeTestSession(store, claudeProfile); + if (personalSource.providerThreadId === undefined) { + throw new Error("Expected the personal Claude source provider thread."); + } + const sourceSwitch = store.prepareMutation({ + authorityGeneration: codexProfile.processGeneration, + authorityId: personalSource.id, + idempotencyKey: "00000000-0000-4000-8000-000000000619", + kind: "session.switch", + request: { preset: "high", provider: "codex" }, + }); + const personalSourceSeedText = "personal Claude source seed"; + const personalSourceSeed = createHash("sha256") + .update("hra:session-transcript-seed:v1\0", "utf8") + .update(personalSourceSeedText, "utf8") + .digest("hex"); + const personalSourceTranscript = createHash("sha256") + .update("personal Claude source transcript") + .digest("hex"); + const sourceSwitchEvidence = store.beginSessionProviderSwitchEffect({ + attemptId: sourceSwitch.id, + sessionId: personalSource.id, + evidence: { + kind: "session.switch", + daemonGeneration: 0, + requestedAccountId: null, + requestedPreset: "high", + runtimeProfile: codexAdoptionRuntimeProfile(codexProfile, "high", false), + seedDigest: personalSourceSeed, + seedIncludedRecords: 1, + seedOmittedRecords: 0, + sourcePreset: "fable-max", + sourceProcessGeneration: claudeProfile.processGeneration, + sourceProfileId: claudeProfile.id, + sourceProvider: "claude", + sourceProviderThreadId: personalSource.providerThreadId, + targetPreset: "high", + targetProcessGeneration: codexProfile.processGeneration, + targetProfileId: codexProfile.id, + targetProvider: "codex", + targetProviderAccountKey: providerAccountKeyForProfile(store, codexProfile.id, "codex"), + transcriptDigest: personalSourceTranscript, + }, }); - }); + expect(store.managedClaudeLoginAuthorityBlocker(claudeProfile.id)).toBeNull(); - test("terminalizes only exact quiescent idle Devin authority for account login", async () => { - const { store } = await fixture(); - const profile = store.createProfile("Devin relink"); - const created = store.createSession({ - fastEnabled: false, - preset: "astra", - profileId: profile.id, - provider: "devin", + const recoveredTargetThreadId = "recovered-codex-switch-target"; + store.recordSessionProviderSwitchTarget({ + attemptId: sourceSwitch.id, + sessionId: personalSource.id, + providerThreadId: recoveredTargetThreadId, }); - let session = store.bindSession({ - expectedRevision: created.revision, - providerThreadId: "devin-thread-relink", - sessionId: created.id, - state: "idle", + store.recordSessionProviderSwitchSeedIntent({ + attemptId: sourceSwitch.id, + sessionId: personalSource.id, + providerThreadId: recoveredTargetThreadId, + runtimeProfile: codexAdoptionRuntimeProfile(codexProfile, "high", false), + seedText: personalSourceSeedText, }); - const input = { - accountId: profile.id, - providerConnectionId: null, - providerGeneration: profile.processGeneration, - sessionId: session.id, - } as const; - - expect(store.canReleaseIdleDevinSessionForAccountLogin({ - profileId: profile.id, - profileGeneration: profile.processGeneration, - sessionId: session.id, - })).toBe(true); - expect(store.canReleaseIdleClaudeSessionForAccountLogin({ - profileId: profile.id, - profileGeneration: profile.processGeneration, - sessionId: session.id, - })).toBe(false); - session = store.setSessionTurnState({ - activeTurnId: "devin-turn-relink", - expectedRevision: session.revision, - sessionId: session.id, - state: "active", + store.recordSessionProviderSwitchSeedResult({ + attemptId: sourceSwitch.id, + sessionId: personalSource.id, + providerThreadId: recoveredTargetThreadId, + runtimeProfile: codexAdoptionRuntimeProfile(codexProfile, "high", false), + turnId: "recovered-codex-seed-turn", + turnStatus: "completed", }); - expect(store.canReleaseIdleDevinSessionForAccountLogin({ - profileId: profile.id, - profileGeneration: profile.processGeneration, - sessionId: session.id, - })).toBe(false); - expect(() => store.terminalizeIdleDevinSessionForAccountLogin(input)) - .toThrow("DEVIN_LOGIN_SESSION_NOT_QUIESCENT"); - session = store.setSessionTurnState({ - expectedRevision: session.revision, - sessionId: session.id, + const sourceProcess = store.readClaudeProcessAuthority({ + providerThreadId: personalSource.providerThreadId, + profileId: claudeProfile.id, + runtimeScope: "personal", + }); + if (sourceProcess === null) throw new Error("Expected personal Claude source process."); + const releasingSource = store.beginClaudeProcessAuthorityRelease({ + providerThreadId: sourceProcess.providerThreadId, + profileId: sourceProcess.profileId, + runtimeScope: sourceProcess.runtimeScope, + expectedRevision: sourceProcess.revision, + identity: sourceProcess.identity, + }); + store.completeClaudeProcessAuthorityRelease({ + providerThreadId: releasingSource.providerThreadId, + profileId: releasingSource.profileId, + runtimeScope: releasingSource.runtimeScope, + expectedRevision: releasingSource.revision, + identity: releasingSource.identity, + }); + store.recordSessionProviderSwitchSourceReleased({ + attemptId: sourceSwitch.id, + sessionId: personalSource.id, + }); + expect(store.readSessionProviderSwitchProgress(sourceSwitch.id)).toMatchObject({ + targetProviderAccountKey: providerAccountKeyForProfile(store, codexProfile.id, "codex"), + targetProviderThreadId: recoveredTargetThreadId, + }); + expect(() => store.bindSessionProviderSwitchRecoveryTarget({ + attemptId: sourceSwitch.id, + sessionId: personalSource.id, + expectedSessionRevision: personalSource.revision, + providerAccountKey: `v1:codex:${"f".repeat(64)}`, + title: "Wrong-account Codex target", + providerUpdatedAt: 20, + })).toThrow("SESSION_PROVIDER_SWITCH_RECOVERY_TARGET_MISMATCH"); + const recoveredTarget = store.bindSessionProviderSwitchRecoveryTarget({ + attemptId: sourceSwitch.id, + sessionId: personalSource.id, + expectedSessionRevision: personalSource.revision, + providerAccountKey: providerAccountKeyForProfile(store, codexProfile.id, "codex"), + title: "Recovered Codex target", + providerUpdatedAt: 20, + }); + expect(recoveredTarget).toMatchObject({ + profileId: codexProfile.id, + provider: "codex", + providerThreadId: recoveredTargetThreadId, + state: "recovery_required", + }); + expect(store.readSessionPersonalRuntimeBinding(personalSource.id, true)) + .toMatchObject({ state: "detached" }); + expect(store.readSessionProviderAccountAuthority(personalSource.id)).toEqual({ + sessionId: personalSource.id, + provider: "codex", + runtimeScope: "managed", + accountKey: providerAccountKeyForProfile(store, codexProfile.id, "codex"), + recordedAt: expect.any(Number), + }); + const inspector = new Database(store.paths.database, { create: false, strict: true }); + try { + inspector.query( + `INSERT INTO provider_runtime_account_revocations( + profile_id,profile_generation,provider,runtime_scope,current_account_key, + state,revision,created_at,updated_at,completed_at + ) VALUES (?,?,'codex','managed',NULL,'completed',1,?,?,?)`, + ).run( + codexProfile.id, + codexProfile.processGeneration, + 3_000, + 3_000, + 3_000, + ); + expect(() => store.bindSessionProviderSwitchRecoveryTarget({ + attemptId: sourceSwitch.id, + sessionId: personalSource.id, + expectedSessionRevision: recoveredTarget.revision, + providerAccountKey: providerAccountKeyForProfile(store, codexProfile.id, "codex"), + title: "Revoked Codex target", + providerUpdatedAt: 20, + })).toThrow("SESSION_PROVIDER_SWITCH_RECOVERY_TARGET_ACCOUNT_AUTHORITY_MISMATCH"); + expect(() => store.resolveSessionMutation({ + attemptId: sourceSwitch.id, + expectedOriginalState: "effect_started", + expectedEvidenceDigest: sourceSwitchEvidence.digest, + resolution: "proven_applied", + resolutionEvidence: { source: "revoked_target_read" }, + receipt: { revoked: true }, + provider: { + providerThreadId: recoveredTargetThreadId, + title: "Recovered Codex target", + status: "idle", + providerUpdatedAt: 20, + }, + })).toThrow("SESSION_PROVIDER_SWITCH_RECOVERY_BINDING_MISMATCH"); + inspector.query( + `DELETE FROM provider_runtime_account_revocations + WHERE profile_id=? AND provider='codex' AND runtime_scope='managed'`, + ).run(codexProfile.id); + inspector.exec("DROP TRIGGER mutation_effect_evidence_immutable_update"); + inspector.exec("DROP TRIGGER session_provider_account_authority_update_guard"); + const storedEvidence = inspector.query( + "SELECT evidence_json,evidence_digest FROM mutation_effect_evidence WHERE attempt_id=?", + ).get(sourceSwitch.id) as { evidence_json: string; evidence_digest: string }; + const legacyEvidence = JSON.parse(storedEvidence.evidence_json) as Record; + delete legacyEvidence.targetProviderAccountKey; + const legacyEvidenceJson = JSON.stringify(legacyEvidence); + const legacyEvidenceDigest = createHash("sha256").update(legacyEvidenceJson).digest("hex"); + inspector.query( + "UPDATE mutation_effect_evidence SET evidence_json=?,evidence_digest=? WHERE attempt_id=?", + ).run(legacyEvidenceJson, legacyEvidenceDigest, sourceSwitch.id); + expect(() => store.resolveSessionMutation({ + attemptId: sourceSwitch.id, + expectedOriginalState: "effect_started", + expectedEvidenceDigest: legacyEvidenceDigest, + resolution: "proven_applied", + resolutionEvidence: { source: "legacy_target_read" }, + receipt: { legacy: true }, + provider: { + providerThreadId: recoveredTargetThreadId, + title: "Recovered Codex target", + status: "idle", + providerUpdatedAt: 20, + }, + })).toThrow("SESSION_PROVIDER_SWITCH_RECOVERY_BINDING_MISMATCH"); + inspector.query( + "UPDATE mutation_effect_evidence SET evidence_json=?,evidence_digest=? WHERE attempt_id=?", + ).run(storedEvidence.evidence_json, storedEvidence.evidence_digest, sourceSwitch.id); + inspector.query( + "UPDATE session_provider_account_authorities SET account_key=? WHERE session_id=?", + ).run(`v1:codex:${"f".repeat(64)}`, personalSource.id); + expect(() => store.resolveSessionMutation({ + attemptId: sourceSwitch.id, + expectedOriginalState: "effect_started", + expectedEvidenceDigest: sourceSwitchEvidence.digest, + resolution: "proven_applied", + resolutionEvidence: { source: "mismatched_target_read" }, + receipt: { mismatch: true }, + provider: { + providerThreadId: recoveredTargetThreadId, + title: "Recovered Codex target", + status: "idle", + providerUpdatedAt: 20, + }, + })).toThrow("SESSION_PROVIDER_SWITCH_RECOVERY_BINDING_MISMATCH"); + inspector.query( + "UPDATE session_provider_account_authorities SET account_key=? WHERE session_id=?", + ).run(providerAccountKeyForProfile(store, codexProfile.id, "codex"), personalSource.id); + } finally { + inspector.close(false); + } + const sourceSwitchReceipt = { + from: { + account: claudeProfile.id, + preset: "fable-max" as const, + provider: "claude" as const, + }, + providerThreadId: recoveredTargetThreadId, + request: { accountId: null, preset: "high" as const, provider: "codex" as const }, + seed: { + digest: personalSourceSeed, + includedRecords: 1, + omittedRecords: 0, + status: "completed" as const, + }, + sessionId: personalSource.id, + to: { + account: codexProfile.id, + preset: "high" as const, + provider: "codex" as const, + }, + transcriptDigest: personalSourceTranscript, + turnId: "recovered-codex-seed-turn", + }; + expect(store.resolveSessionMutation({ + attemptId: sourceSwitch.id, + expectedOriginalState: "effect_started", + expectedEvidenceDigest: sourceSwitchEvidence.digest, + resolution: "proven_applied", + resolutionEvidence: { source: "target_read_after_source_release" }, + receipt: sourceSwitchReceipt, + provider: { + providerThreadId: recoveredTargetThreadId, + title: "Recovered Codex target", + status: "idle", + providerUpdatedAt: 20, + }, + })).toMatchObject({ + profileId: codexProfile.id, + provider: "codex", + providerThreadId: recoveredTargetThreadId, state: "idle", }); - const queued = store.enqueue(session.id, "preserve this Devin queued send"); - expect(store.canReleaseIdleDevinSessionForAccountLogin({ - profileId: profile.id, - profileGeneration: profile.processGeneration, - sessionId: session.id, - })).toBe(false); - expect(() => store.terminalizeIdleDevinSessionForAccountLogin(input)) - .toThrow("DEVIN_LOGIN_SESSION_NOT_QUIESCENT"); - expect(store.requireSession(session.id)).toMatchObject({ state: "idle" }); - expect(store.requireQueue(queued.id)).toMatchObject({ state: "pending" }); + expect(store.managedClaudeLoginAuthorityBlocker(claudeProfile.id)).toBeNull(); - expect(store.transitionQueue(queued.id, "pending", "cancelled")).toBe(true); - expect(store.terminalizeIdleDevinSessionForAccountLogin(input)).toMatchObject({ - changed: true, - event: { - body: { activeTurnId: null, status: "terminal", type: "session_status" }, + const managedCodexSource = upsertProvenTestSession(store, { + fastEnabled: false, + preset: "high", + profileId: codexProfile.id, + provider: "codex", + providerThreadId: "managed-codex-source-for-claude", + state: "idle", + }); + const targetSwitch = store.prepareMutation({ + authorityGeneration: claudeProfile.processGeneration, + authorityId: managedCodexSource.id, + idempotencyKey: "00000000-0000-4000-8000-000000000620", + kind: "session.switch", + request: { preset: "fable-max", provider: "claude" }, + }); + store.beginSessionProviderSwitchEffect({ + attemptId: targetSwitch.id, + sessionId: managedCodexSource.id, + providerAuthentication: { + profileId: claudeProfile.id, + processGeneration: claudeProfile.processGeneration, + provider: "claude", + signedIn: true, + }, + evidence: { + kind: "session.switch", + daemonGeneration: 0, + requestedAccountId: null, + requestedPreset: "fable-max", + runtimeProfile: { + ...claudeAdoptionRuntimeProfile(claudeProfile), + configHome: "isolated", + }, + seedDigest: createHash("sha256") + .update("managed Claude target seed") + .digest("hex"), + seedIncludedRecords: 1, + seedOmittedRecords: 0, + sourcePreset: "high", + sourceProcessGeneration: codexProfile.processGeneration, + sourceProfileId: codexProfile.id, + sourceProvider: "codex", + sourceProviderThreadId: managedCodexSource.providerThreadId ?? "", + targetPreset: "fable-max", + targetProcessGeneration: claudeProfile.processGeneration, + targetProfileId: claudeProfile.id, + targetProvider: "claude", + targetProviderAccountKey: testProviderAccountKey("claude"), + transcriptDigest: createHash("sha256") + .update("managed Claude target transcript") + .digest("hex"), }, - interactions: [], - session: { provider: "devin", state: "terminal" }, }); + const switchTargetLaunchInput = { + providerThreadId: "00000000-0000-4000-8000-000000000922", + profileId: claudeProfile.id, + profileGeneration: claudeProfile.processGeneration, + runtimeScope: "managed" as const, + providerAccountKey: testProviderAccountKey("claude"), + sessionId: managedCodexSource.id, + }; + expect(() => store.stageClaudeProcessLaunchIntent({ + ...switchTargetLaunchInput, + profileId: codexProfile.id, + profileGeneration: codexProfile.processGeneration, + })).toThrow("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_SESSION_AUTHORITY_MISMATCH"); + expect(() => store.stageClaudeProcessLaunchIntent({ + ...switchTargetLaunchInput, + providerAccountKey: namedProviderAccountKey("claude", "wrong-switch-target-account"), + })).toThrow("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_SESSION_AUTHORITY_MISMATCH"); + expect(() => store.stageClaudeProcessLaunchIntent({ + ...switchTargetLaunchInput, + runtimeScope: "personal", + })).toThrow("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_SESSION_AUTHORITY_MISMATCH"); + const switchTargetLaunch = store.stageClaudeProcessLaunchIntent( + switchTargetLaunchInput, + ); + expect(switchTargetLaunch).toMatchObject({ + providerThreadId: switchTargetLaunchInput.providerThreadId, + profileId: claudeProfile.id, + profileGeneration: claudeProfile.processGeneration, + runtimeScope: "managed", + sessionId: managedCodexSource.id, + }); + store.cancelClaudeProcessLaunchIntent({ + providerThreadId: switchTargetLaunch.providerThreadId, + profileId: switchTargetLaunch.profileId, + profileGeneration: switchTargetLaunch.profileGeneration, + runtimeScope: switchTargetLaunch.runtimeScope, + intentId: switchTargetLaunch.intentId, + expectedRevision: switchTargetLaunch.revision, + }); + expect(store.managedClaudeLoginAuthorityBlocker(claudeProfile.id)) + .toBe("unsettled_authority"); }); test("provider deletion atomically terminalizes pending and in-flight session authority", async () => { const { store } = await fixture(); const profile = signInProfile(store, "Provider deletion", "deleted@example.com"); - const created = store.createSession({ + const importedSession = upsertProvenTestSession(store, { fastEnabled: false, preset: "high", profileId: profile.id, - }); - const session = store.bindSession({ - expectedRevision: created.revision, providerThreadId: "thread-provider-deleted", - sessionId: created.id, state: "idle", }); + const session = store.updateSessionMetadata({ + sessionId: importedSession.id, + expectedRevision: importedSession.revision, + preset: "high", + }); store.prepareMutation({ authorityGeneration: profile.processGeneration, authorityId: session.id, @@ -1916,6 +7176,7 @@ describe("StateStore", () => { projectId: project.id, preset: "high", fastEnabled: false, + providerAccountKey: providerAccountKeyForProfile(store, profile.id, "codex"), evidence: { kind: "session.start", projectId: project.id, clientMessageId: null, messageDigest: null }, }); expect(store.readMutation("00000000-0000-4000-8000-000000000610")).toMatchObject({ @@ -1926,6 +7187,72 @@ describe("StateStore", () => { expect(store.recoverEffectStartedMutations()).toEqual({ recovered: [attempt.id], unresolved: [] }); expect(store.requireSession(session.id)).toMatchObject({ state: "recovery_required" }); expect(store.requireSession(session.id).providerThreadId).toBeUndefined(); + expect(store.sessionAccountAuthorityMatches(session.id, profile.id)).toBe(true); + expect(store.readSessionProviderAccountAuthority(session.id)).toMatchObject({ + accountKey: providerAccountKeyForProfile(store, profile.id, "codex"), + provider: "codex", + runtimeScope: "managed", + }); + }); + + test("rolls back idle Codex start completion after identifiable authority is lost", async () => { + const { store, home } = await fixture(); + const profile = signInProfile( + store, + "Start completion identity loss", + "start-completion-identity-loss@example.com", + ); + const projectRoot = join(home, "start-completion-identity-loss"); + await mkdir(projectRoot); + const project = await store.createProject( + "Start completion identity loss", + projectRoot, + true, + ); + const runtimeProfile = codexAdoptionRuntimeProfile(profile, "high", false); + const attempt = store.prepareMutation({ + authorityGeneration: profile.processGeneration, + authorityId: profile.id, + idempotencyKey: "00000000-0000-4000-8000-0000000006c9", + kind: "session.start", + request: { fast: false, preset: "high", projectId: project.id }, + }); + const starting = store.beginSessionStartEffect({ + attemptId: attempt.id, + evidence: { + clientMessageId: null, + kind: "session.start", + messageDigest: null, + projectId: project.id, + runtimeProfile, + }, + fastEnabled: false, + preset: "high", + profileGeneration: profile.processGeneration, + profileId: profile.id, + projectId: project.id, + provider: "codex", + providerAccountKey: providerAccountKeyForProfile(store, profile.id, "codex"), + }); + expect(store.setProfileState( + profile.id, + profile.processGeneration, + "signed_in", + { plan: "apiKey" }, + )).toBe(true); + expect(() => store.completeSessionStartEffect({ + attemptId: attempt.id, + sessionId: starting.id, + expectedSessionRevision: starting.revision, + providerThreadId: "identity-lost-completed-thread", + state: "idle", + runtimeProfile, + receipt: { sessionId: starting.id }, + })).toThrow("SESSION_START_ACCOUNT_AUTHORITY_MISMATCH"); + expect(store.requireSession(starting.id).state).toBe("starting"); + expect(store.requireSession(starting.id).providerThreadId).toBeUndefined(); + expect(store.readMutation("00000000-0000-4000-8000-0000000006c9")) + .toMatchObject({ state: "effect_started" }); }); test("keeps a bound unresolved session-start authority current across close and restart advances", async () => { @@ -1973,6 +7300,7 @@ describe("StateStore", () => { profileId: profile.id, projectId: project.id, provider: "codex", + providerAccountKey: providerAccountKeyForProfile(store, profile.id, "codex"), }); const bound = store.bindSessionStartRecoveryTarget({ attemptId: attempt.id, @@ -2020,19 +7348,20 @@ describe("StateStore", () => { }); }); - test("advances a terminal generation-zero unresolved session start on consecutive restarts", async () => { + test("advances a terminal unresolved Claude session start on consecutive restarts", async () => { const { store, home } = await fixture(); const key = "00000000-0000-4000-8000-0000000006c1"; - const { attempt, profile, project } = await prepareSignedOutSessionStart( - store, - home, - { - idempotencyKey: key, - label: "Terminal zero start", - preset: "fable-max", - provider: "claude", - }, - ); + const profile = signInProfile(store, "Terminal Claude start", "terminal-claude@example.com"); + const projectRoot = join(home, "terminal-claude-start-project"); + await mkdir(projectRoot); + const project = await store.createProject("Terminal Claude start project", projectRoot, true); + const attempt = store.prepareMutation({ + authorityGeneration: profile.processGeneration, + authorityId: profile.id, + idempotencyKey: key, + kind: "session.start", + request: { fast: false, preset: "fable-max", projectId: project.id, provider: "claude" }, + }); const starting = store.beginSessionStartEffect({ attemptId: attempt.id, evidence: { @@ -2047,6 +7376,7 @@ describe("StateStore", () => { profileId: profile.id, projectId: project.id, provider: "claude", + providerAccountKey: testProviderAccountKey("claude"), providerAuthentication: { profileId: profile.id, processGeneration: profile.processGeneration, @@ -2066,7 +7396,8 @@ describe("StateStore", () => { const firstRestart = new StateStore(paths, { now: () => 2_000 }); stores.push(firstRestart); expect(firstRestart.nextDaemonGeneration(`boot_${"d".repeat(32)}`)).toBe(1); - expect(firstRestart.requireProfileById(profile.id).processGeneration).toBe(1); + expect(firstRestart.requireProfileById(profile.id).processGeneration) + .toBe(profile.processGeneration + 1); expect(firstRestart.requireSession(starting.id).state).toBe("terminal"); expect(firstRestart.readMutation(key)).toMatchObject({ state: "effect_started" }); firstRestart.close(); @@ -2075,7 +7406,8 @@ describe("StateStore", () => { const secondRestart = new StateStore(paths, { now: () => 3_000 }); stores.push(secondRestart); expect(secondRestart.nextDaemonGeneration(`boot_${"e".repeat(32)}`)).toBe(2); - expect(secondRestart.requireProfileById(profile.id).processGeneration).toBe(2); + expect(secondRestart.requireProfileById(profile.id).processGeneration) + .toBe(profile.processGeneration + 2); expect(secondRestart.isSessionMutationProviderAuthorityCurrent({ attemptId: attempt.id, originGeneration: profile.processGeneration, @@ -2090,67 +7422,23 @@ describe("StateStore", () => { WHERE attempt_id=? AND profile_id=? ORDER BY from_generation`, ).all(attempt.id, profile.id)).toEqual([ - { provider: "claude", from_generation: 0, to_generation: 1 }, - { provider: "claude", from_generation: 1, to_generation: 2 }, + { + provider: "claude", + from_generation: profile.processGeneration, + to_generation: profile.processGeneration + 1, + }, + { + provider: "claude", + from_generation: profile.processGeneration + 1, + to_generation: profile.processGeneration + 2, + }, ]); } finally { inspector.close(false); } }); - test("direct account effect admission refuses relevant unsettled session authorities", async () => { - const loginFixture = await fixture(); - const loginStart = await prepareSignedOutSessionStart( - loginFixture.store, - loginFixture.home, - { - idempotencyKey: "00000000-0000-4000-8000-0000000006c2", - label: "Blocked login", - preset: "fable-max", - provider: "claude", - }, - ); - const loginKey = "00000000-0000-4000-8000-0000000006c3"; - const loginAttempt = loginFixture.store.prepareMutation({ - authorityGeneration: loginStart.profile.processGeneration + 1, - authorityId: loginStart.profile.id, - idempotencyKey: loginKey, - kind: "account.login", - request: { deviceCode: true }, - }); - loginFixture.store.beginSessionStartEffect({ - attemptId: loginStart.attempt.id, - evidence: { - clientMessageId: null, - kind: "session.start", - messageDigest: null, - projectId: loginStart.project.id, - }, - fastEnabled: false, - preset: "fable-max", - profileGeneration: loginStart.profile.processGeneration, - profileId: loginStart.profile.id, - projectId: loginStart.project.id, - provider: "claude", - providerAuthentication: { - profileId: loginStart.profile.id, - processGeneration: loginStart.profile.processGeneration, - provider: "claude", - signedIn: true, - }, - }); - expect(() => loginFixture.store.beginAccountMutationEffect({ - attemptId: loginAttempt.id, - evidence: { kind: "account.login", method: "device_code" }, - profileGeneration: loginStart.profile.processGeneration + 1, - profileId: loginStart.profile.id, - })).toThrow("SESSION_MUTATION_AUTHORITY_UNSETTLED"); - expect(loginFixture.store.readMutation(loginKey)).toMatchObject({ state: "prepared" }); - expect(loginFixture.store.requireProfileById(loginStart.profile.id)).toMatchObject({ - processGeneration: 0, - state: "signed_out", - }); - + test("direct account logout effect refuses relevant unsettled session authorities", async () => { const logoutFixture = await fixture(); const logoutProfile = signInProfile( logoutFixture.store, @@ -2193,6 +7481,11 @@ describe("StateStore", () => { profileId: logoutProfile.id, projectId: logoutProject.id, provider: "codex", + providerAccountKey: providerAccountKeyForProfile( + logoutFixture.store, + logoutProfile.id, + "codex", + ), }); expect(() => logoutFixture.store.beginAccountMutationEffect({ attemptId: logoutAttempt.id, @@ -2207,7 +7500,7 @@ describe("StateStore", () => { }); }); - test("admits a signed-out Claude session start with an exact provider authentication proof", async () => { + test("starts Claude under exact provider authority while Codex remains signed out", async () => { const { store, home } = await fixture(); const { attempt, profile, project } = await prepareSignedOutSessionStart( store, @@ -2234,6 +7527,7 @@ describe("StateStore", () => { profileId: profile.id, projectId: project.id, provider: "claude", + providerAccountKey: testProviderAccountKey("claude"), providerAuthentication: { profileId: profile.id, processGeneration: profile.processGeneration, @@ -2242,12 +7536,18 @@ describe("StateStore", () => { }, }); + expect(store.requireProfileById(profile.id)).toMatchObject({ + processGeneration: 0, + state: "signed_out", + }); expect(session).toMatchObject({ profileId: profile.id, provider: "claude", state: "starting", }); - expect(store.requireProfileById(profile.id).state).toBe("signed_out"); + expect(store.sessionAccountAuthorityMatches(session.id, profile.id)).toBe(true); + expect(store.readMutation("00000000-0000-4000-8000-0000000006a3")) + .toMatchObject({ state: "effect_started" }); }); test("refuses missing or mismatched Claude session-start authentication proof", async () => { @@ -2281,6 +7581,7 @@ describe("StateStore", () => { profileId: profile.id, projectId: project.id, provider: "claude", + providerAccountKey: testProviderAccountKey("claude"), ...(providerAuthentication === undefined ? {} : { providerAuthentication }), }); @@ -2329,6 +7630,7 @@ describe("StateStore", () => { profileId: profile.id, projectId: project.id, provider: "codex", + providerAccountKey: testProviderAccountKey("codex"), providerAuthentication: { profileId: profile.id, processGeneration: profile.processGeneration, @@ -2336,11 +7638,33 @@ describe("StateStore", () => { signedIn: true, }, })).toThrow("MUTATION_EFFECT_AUTHORITY_CHANGED"); + expect(store.setProfileState( + profile.id, + profile.processGeneration, + "signed_in", + { plan: "apiKey" }, + )).toBe(true); + expect(() => store.beginSessionStartEffect({ + attemptId: attempt.id, + evidence: { + clientMessageId: null, + kind: "session.start", + messageDigest: null, + projectId: project.id, + }, + fastEnabled: false, + preset: "high", + profileGeneration: profile.processGeneration, + profileId: profile.id, + projectId: project.id, + provider: "codex", + providerAccountKey: testProviderAccountKey("codex"), + })).toThrow("MUTATION_EFFECT_AUTHORITY_CHANGED"); expect(store.readMutation("00000000-0000-4000-8000-0000000006a5")) .toMatchObject({ state: "prepared" }); }); - test("carries every provider's reviewed runtime profile through one session-start evidence row", async () => { + test("carries either provider's reviewed runtime profile through one session-start evidence row", async () => { const { store, home } = await fixture(); const profile = signInProfile(store, "Both providers", "both-providers@example.com"); const projectRoot = join(home, "both-providers-project"); @@ -2377,24 +7701,21 @@ describe("StateStore", () => { profileId: profile.id, reasoningEffort: "max" as const, }; - const devinProfile = { - devinVersion: "3000.6.14" as const, - isolatedHome: true as const, - model: "gpt-6-astra" as const, - observedAt: 2_200, - preset: "astra" as const, - processGeneration: profile.processGeneration, - profileId: profile.id, - protocolVersion: 1 as const, - reasoningEffort: "provider-default" as const, - }; const start = ( idempotencyKey: string, - provider: "codex" | "claude" | "devin", - preset: "high" | "fable-max" | "astra", - runtimeProfile: typeof codexProfile | typeof claudeProfile | typeof devinProfile, + provider: "codex" | "claude", + preset: "high" | "fable-max", + runtimeProfile: typeof codexProfile | typeof claudeProfile, ) => { + const providerThreadId = `thread-${provider}`; + const claudeProcessIdentity = provider === "claude" + ? { + pid: 42_101, + pidDomain: "darwin" as const, + procStart: "Fri Sep 4 12:01:00 2026", + } + : undefined; const attempt = store.prepareMutation({ authorityGeneration: profile.processGeneration, authorityId: profile.id, @@ -2417,7 +7738,8 @@ describe("StateStore", () => { profileId: profile.id, projectId: project.id, provider, - ...(provider !== "codex" + providerAccountKey: providerAccountKeyForProfile(store, profile.id, provider), + ...(provider === "claude" ? { providerAuthentication: { profileId: profile.id, @@ -2428,12 +7750,23 @@ describe("StateStore", () => { } : {}), }); + if (claudeProcessIdentity !== undefined) { + store.recordClaimedClaudeProcessAuthority({ + providerThreadId, + profileId: profile.id, + profileGeneration: profile.processGeneration, + runtimeScope: "managed", + sessionId: session.id, + identity: claudeProcessIdentity, + }); + } store.completeSessionStartEffect({ attemptId: attempt.id, expectedSessionRevision: session.revision, - providerThreadId: `thread-${provider}`, + providerThreadId, receipt: { effectiveRuntimeProfile: runtimeProfile, sessionId: session.id }, runtimeProfile, + ...(claudeProcessIdentity === undefined ? {} : { claudeProcessIdentity }), sessionId: session.id, state: "idle", }); @@ -2442,19 +7775,14 @@ describe("StateStore", () => { const codex = start("00000000-0000-4000-8000-0000000006a0", "codex", "high", codexProfile); const claude = start("00000000-0000-4000-8000-0000000006a1", "claude", "fable-max", claudeProfile); - const devin = start("00000000-0000-4000-8000-0000000006a2", "devin", "astra", devinProfile); expect(store.requireSession(codex.session.id)).toMatchObject({ preset: "high", provider: "codex" }); expect(store.requireSession(claude.session.id)) .toMatchObject({ preset: "fable-max", provider: "claude" }); - expect(store.requireSession(devin.session.id)) - .toMatchObject({ preset: "astra", provider: "devin" }); expect(store.latestSessionRuntimeProfile(codex.session.id)) .toMatchObject({ profile: codexProfile, sourceKind: "session_start" }); expect(store.latestSessionRuntimeProfile(claude.session.id)) .toMatchObject({ profile: claudeProfile, sourceKind: "session_start" }); - expect(store.latestSessionRuntimeProfile(devin.session.id)) - .toMatchObject({ profile: devinProfile, sourceKind: "session_start" }); expect(store.readMutation("00000000-0000-4000-8000-0000000006a1")).toMatchObject({ evidence: { evidence: { kind: "session.start", runtimeProfile: claudeProfile } }, result: { effectiveRuntimeProfile: claudeProfile }, @@ -2475,8 +7803,6 @@ describe("StateStore", () => { .toBe(JSON.stringify(effectiveRuntimeProfileSchema.parse(codexProfile))); expect(stored.get(claude.session.id)) .toBe(JSON.stringify(effectiveClaudeRuntimeProfileSchema.parse(claudeProfile))); - expect(stored.get(devin.session.id)) - .toBe(JSON.stringify(effectiveDevinRuntimeProfileSchema.parse(devinProfile))); } finally { inspector.close(false); } @@ -2488,8 +7814,114 @@ describe("StateStore", () => { .toMatchObject({ profile: codexProfile }); expect(reopened.latestSessionRuntimeProfile(claude.session.id)) .toMatchObject({ profile: claudeProfile }); - expect(reopened.latestSessionRuntimeProfile(devin.session.id)) - .toMatchObject({ profile: devinProfile }); + }); + + test("rejects only exact same-home provider-switch target aliases", async () => { + const { store } = await fixture(); + const sourceProfile = signInProfile( + store, + "Switch alias source", + "switch-alias-source@example.com", + ); + const otherProfile = signInProfile( + store, + "Switch alias other home", + "switch-alias-other@example.com", + ); + const stageSwitch = ( + session: ReturnType, + targetProfile: typeof sourceProfile, + idempotencyKey: string, + seedName: string, + ) => { + if (session.providerThreadId === undefined) { + throw new Error("Expected a provider thread for switch alias testing."); + } + const mutation = store.prepareMutation({ + authorityGeneration: targetProfile.processGeneration, + authorityId: session.id, + idempotencyKey, + kind: "session.switch", + request: { preset: "high", provider: "codex" }, + }); + const seedDigest = createHash("sha256") + .update("hra:session-transcript-seed:v1\0", "utf8") + .update(seedName, "utf8") + .digest("hex"); + return store.beginSessionProviderSwitchEffect({ + attemptId: mutation.id, + sessionId: session.id, + evidence: { + kind: "session.switch", + daemonGeneration: 0, + requestedAccountId: targetProfile.id, + requestedPreset: "high", + runtimeProfile: codexAdoptionRuntimeProfile(targetProfile, "high", false), + seedDigest, + seedIncludedRecords: 1, + seedOmittedRecords: 0, + sourcePreset: "high", + sourceProcessGeneration: sourceProfile.processGeneration, + sourceProfileId: sourceProfile.id, + sourceProvider: "codex", + sourceProviderThreadId: session.providerThreadId, + targetPreset: "high", + targetProcessGeneration: targetProfile.processGeneration, + targetProfileId: targetProfile.id, + targetProvider: "codex", + targetProviderAccountKey: providerAccountKeyForProfile( + store, + targetProfile.id, + "codex", + ), + transcriptDigest: createHash("sha256").update(seedName).digest("hex"), + }, + }); + }; + + const sameHome = upsertProvenTestSession(store, { + profileId: sourceProfile.id, + provider: "codex", + providerThreadId: "same-home-switch-alias", + preset: "high", + fastEnabled: false, + state: "idle", + }); + const sameHomeEvidence = stageSwitch( + sameHome, + sourceProfile, + "00000000-0000-4000-8000-0000000006a8", + "same-home-switch-alias-seed", + ); + expect(() => store.recordSessionProviderSwitchTarget({ + attemptId: sameHomeEvidence.attemptId, + sessionId: sameHome.id, + providerThreadId: sameHome.providerThreadId ?? "", + })).toThrow("SESSION_PROVIDER_SWITCH_TARGET_AUTHORITY_MISMATCH"); + expect(store.readSessionProviderSwitchProgress(sameHomeEvidence.attemptId)) + .toMatchObject({ sourceReleased: false, targetReleased: false }); + + const crossHome = upsertProvenTestSession(store, { + profileId: sourceProfile.id, + provider: "codex", + providerThreadId: "cross-home-switch-alias", + preset: "high", + fastEnabled: false, + state: "idle", + }); + const crossHomeEvidence = stageSwitch( + crossHome, + otherProfile, + "00000000-0000-4000-8000-0000000006a9", + "cross-home-switch-alias-seed", + ); + store.recordSessionProviderSwitchTarget({ + attemptId: crossHomeEvidence.attemptId, + sessionId: crossHome.id, + providerThreadId: crossHome.providerThreadId ?? "", + }); + expect(store.readSessionProviderSwitchProgress(crossHomeEvidence.attemptId)) + .toMatchObject({ targetProviderThreadId: crossHome.providerThreadId }); }); test("rebinds a session to another provider and account in one transaction", async () => { @@ -2550,6 +7982,7 @@ describe("StateStore", () => { profileId: codexAccount.id, projectId: project.id, provider: "codex", + providerAccountKey: providerAccountKeyForProfile(store, codexAccount.id, "codex"), }); store.completeSessionStartEffect({ attemptId: startAttempt.id, @@ -2592,6 +8025,7 @@ describe("StateStore", () => { targetProcessGeneration: claudeAccount.processGeneration, targetProfileId: claudeAccount.id, targetProvider: "claude" as const, + targetProviderAccountKey: testProviderAccountKey("claude"), transcriptDigest, }; expect(() => store.beginSessionProviderSwitchEffect({ @@ -2640,6 +8074,12 @@ describe("StateStore", () => { sessionId: started.id, providerThreadId: "claude-thread", }); + expect(() => store.recordSessionProviderSwitchTargetReleased({ + attemptId: switchAttempt.id, + sessionId: started.id, + providerThreadId: "claude-thread", + providerAccountKey: `v1:claude:${"f".repeat(64)}`, + })).toThrow("SESSION_PROVIDER_SWITCH_TARGET_RELEASE_AUTHORITY_MISMATCH"); store.recordSessionProviderSwitchSeedIntent({ attemptId: switchAttempt.id, sessionId: started.id, @@ -2660,6 +8100,19 @@ describe("StateStore", () => { sessionId: started.id, }); const before = store.requireSession(started.id); + const claudeProcessIdentity = { + pid: 42_102, + pidDomain: "darwin" as const, + procStart: "Fri Sep 4 12:01:01 2026", + }; + store.recordClaimedClaudeProcessAuthority({ + providerThreadId: "claude-thread", + profileId: claudeAccount.id, + profileGeneration: claudeAccount.processGeneration, + runtimeScope: "managed", + sessionId: started.id, + identity: claudeProcessIdentity, + }); const switchReceipt = { from: { account: codexAccount.id, preset: "high" as const, provider: "codex" as const }, providerThreadId: "claude-thread", @@ -2678,10 +8131,13 @@ describe("StateStore", () => { expect(() => store.completeSessionProviderSwitch({ attemptId: switchAttempt.id, expectedSessionRevision: before.revision, + expectedTargetProfileGeneration: claudeAccount.processGeneration, preset: "fable-max", profileId: claudeAccount.id, provider: "claude", providerThreadId: "claude-thread", + providerAccountKey: testProviderAccountKey("claude"), + claudeProcessIdentity, receipt: { ...switchReceipt, turnId: "wrong-turn" }, runtimeProfile: claudeProfile, seedTurnId: "claude-turn", @@ -2692,10 +8148,13 @@ describe("StateStore", () => { expect(() => store.completeSessionProviderSwitch({ attemptId: switchAttempt.id, expectedSessionRevision: before.revision - 1, + expectedTargetProfileGeneration: claudeAccount.processGeneration, preset: "fable-max", profileId: claudeAccount.id, provider: "claude", providerThreadId: "claude-thread", + providerAccountKey: testProviderAccountKey("claude"), + claudeProcessIdentity, receipt: switchReceipt, runtimeProfile: claudeProfile, seedTurnId: "claude-turn", @@ -2705,56 +8164,516 @@ describe("StateStore", () => { const switched = store.completeSessionProviderSwitch({ attemptId: switchAttempt.id, expectedSessionRevision: before.revision, + expectedTargetProfileGeneration: claudeAccount.processGeneration, preset: "fable-max", profileId: claudeAccount.id, provider: "claude", providerThreadId: "claude-thread", receipt: switchReceipt, runtimeProfile: claudeProfile, - seedTurnId: "claude-turn", - sessionId: started.id, - state: "idle", + providerAccountKey: testProviderAccountKey("claude"), + claudeProcessIdentity, + seedTurnId: "claude-turn", + sessionId: started.id, + state: "idle", + }); + // The provider, the account, the preset, and the thread are one binding. + expect(switched).toMatchObject({ + preset: "fable-max", + profileId: claudeAccount.id, + provider: "claude", + providerThreadId: "claude-thread", + }); + // The runtime-profile authority guard requires the row's account to equal + // the session's, so the rebind must land before the profile is inserted. + expect(store.latestSessionRuntimeProfile(started.id)).toMatchObject({ + profile: { ...claudeProfile, profileId: claudeAccount.id }, + sourceKind: "turn_start", + }); + expect(store.readMutation("00000000-0000-4000-8000-0000000006b1")).toMatchObject({ + result: { + session: { + id: started.id, + profileId: claudeAccount.id, + provider: "claude", + providerThreadId: "claude-thread", + }, + }, + state: "applied", + }); + + // A stale revision never rebinds, and a preset the target cannot run is + // refused before anything is written. + const sourceProcess = store.readClaudeProcessAuthority({ + providerThreadId: "claude-thread", + profileId: claudeAccount.id, + runtimeScope: "managed", + }); + if (sourceProcess === null) throw new Error("Expected the bound Claude process authority."); + const releasingSource = store.beginClaudeProcessAuthorityRelease({ + providerThreadId: sourceProcess.providerThreadId, + profileId: sourceProcess.profileId, + runtimeScope: sourceProcess.runtimeScope, + expectedRevision: sourceProcess.revision, + identity: sourceProcess.identity, + }); + store.completeClaudeProcessAuthorityRelease({ + providerThreadId: releasingSource.providerThreadId, + profileId: releasingSource.profileId, + runtimeScope: releasingSource.runtimeScope, + expectedRevision: releasingSource.revision, + identity: releasingSource.identity, + }); + const replacementProcessIdentity = { + pid: 42_103, + pidDomain: "darwin" as const, + procStart: "Fri Sep 4 12:01:02 2026", + }; + store.recordClaimedClaudeProcessAuthority({ + providerThreadId: "claude-thread-2", + profileId: claudeAccount.id, + profileGeneration: claudeAccount.processGeneration, + runtimeScope: "managed", + sessionId: started.id, + identity: replacementProcessIdentity, + }); + expect(() => store.completeSessionProviderSwitch({ + attemptId: switchAttempt.id, + expectedSessionRevision: before.revision, + expectedTargetProfileGeneration: claudeAccount.processGeneration, + preset: "fable-max", + profileId: claudeAccount.id, + provider: "claude", + providerThreadId: "claude-thread-2", + receipt: {}, + runtimeProfile: claudeProfile, + providerAccountKey: testProviderAccountKey("claude"), + claudeProcessIdentity: replacementProcessIdentity, + seedTurnId: "claude-turn", + sessionId: started.id, + state: "idle", + })).toThrow("SESSION_PROVIDER_SWITCH_CAS_CONFLICT"); + expect(() => store.completeSessionProviderSwitch({ + attemptId: switchAttempt.id, + expectedSessionRevision: switched.revision, + expectedTargetProfileGeneration: claudeAccount.processGeneration, + preset: "high", + profileId: claudeAccount.id, + provider: "claude", + providerThreadId: "claude-thread-2", + receipt: {}, + runtimeProfile: claudeProfile, + providerAccountKey: testProviderAccountKey("claude"), + seedTurnId: "claude-turn", + sessionId: started.id, + state: "idle", + })).toThrow("does not support the `high` model preset"); + expect(store.requireSession(started.id).providerThreadId).toBe("claude-thread"); + }); + + test("rejects provider-switch completion after target generation changes without rebinding", async () => { + const scenarios: readonly ("signed_out" | "new_generation")[] = ["new_generation"]; + for (const scenario of scenarios) { + const { store } = await fixture(); + const sourceAccount = signInProfile( + store, + `Switch source ${scenario}`, + `source-${scenario}@example.com`, + ); + const targetAccount = signInProfile( + store, + `Switch target ${scenario}`, + `target-${scenario}@example.com`, + ); + const created = store.createSession({ + profileId: sourceAccount.id, + provider: "codex", + preset: "high", + fastEnabled: false, + }); + const session = store.bindSession({ + sessionId: created.id, + expectedRevision: created.revision, + providerThreadId: `source-thread-${scenario}`, + state: "idle", + }); + const idempotencyKey = scenario === "signed_out" + ? "00000000-0000-4000-8000-0000000006b3" + : "00000000-0000-4000-8000-0000000006b4"; + const attempt = store.prepareMutation({ + authorityGeneration: targetAccount.processGeneration, + authorityId: session.id, + idempotencyKey, + kind: "session.switch", + request: { preset: "fable-max", provider: "claude" }, + }); + expect(store.transitionMutation(attempt.id, "prepared", "effect_started")).toBe(true); + const processIdentity = { + pid: scenario === "signed_out" ? 42_104 : 42_105, + pidDomain: "darwin" as const, + procStart: scenario === "signed_out" + ? "Fri Sep 4 12:01:03 2026" + : "Fri Sep 4 12:01:04 2026", + }; + const providerThreadId = `target-thread-${scenario}`; + + if (scenario === "signed_out") { + expect(store.setProfileState( + targetAccount.id, + targetAccount.processGeneration, + "signed_out", + )).toBe(true); + expect(store.requireProfileById(targetAccount.id)).toMatchObject({ + state: "signed_out", + processGeneration: targetAccount.processGeneration, + }); + } else { + // Normal generation advancement now rejects unresolved switch authority + // unless the daemon records an explicit successor binding. Mutate the + // fixture directly to exercise completion's independent stale-target + // guard for a database that lacks that rebind evidence. + const direct = new Database(store.paths.database, { create: false, strict: true }); + try { + expect(direct.query( + `UPDATE profiles SET process_generation=process_generation+1 + WHERE id=? AND process_generation=?`, + ).run(targetAccount.id, targetAccount.processGeneration).changes).toBe(1); + } finally { + direct.close(false); + } + expect(store.requireProfileById(targetAccount.id)).toMatchObject({ + state: "signed_in", + processGeneration: targetAccount.processGeneration + 1, + }); + } + const beforeSession = store.requireSession(session.id); + const beforeMutation = store.readMutation(idempotencyKey); + expect(beforeMutation).toMatchObject({ + authorityGeneration: targetAccount.processGeneration, + authorityId: session.id, + state: "effect_started", + }); + + expect(() => store.completeSessionProviderSwitch({ + attemptId: attempt.id, + expectedSessionRevision: session.revision, + expectedTargetProfileGeneration: targetAccount.processGeneration, + preset: "fable-max", + profileId: targetAccount.id, + provider: "claude", + providerThreadId, + receipt: { providerThreadId, sessionId: session.id, toProvider: "claude" }, + providerAccountKey: testProviderAccountKey("claude"), + runtimeProfile: { + claudeVersion: "2.1.260", + inputFormat: "stream-json", + isolatedConfigDir: true, + model: "claude-fable-5-1", + observedAt: 2_100, + outputFormat: "stream-json", + permissionMode: "default", + preset: "fable-max", + processGeneration: targetAccount.processGeneration, + profileId: targetAccount.id, + reasoningEffort: "max", + }, + claudeProcessIdentity: processIdentity, + seedTurnId: "unused-target-drift-turn", + sessionId: session.id, + state: "idle", + })).toThrow("SESSION_PROVIDER_SWITCH_TARGET_AUTHORITY_CHANGED"); + + expect(store.requireSession(session.id)).toEqual(beforeSession); + expect(store.readMutation(idempotencyKey)).toEqual(beforeMutation); + expect(store.latestSessionRuntimeProfile(session.id)).toBeNull(); + } + }); + + test("retires personal runtime custody and readopts its old identity after a provider switch", async () => { + const { store } = await fixture(); + const codexAccount = signInProfile(store, "Adopted Codex", "adopted-codex@example.com"); + const claudeAccount = signInProfile(store, "Adopted Claude", "adopted-claude@example.com"); + store.setSessionAdoptionPolicy({ provider: "codex", profileId: codexAccount.id }); + const candidate = store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: "adopted-switch-thread", + title: "Adopted switch", + state: "idle", + providerUpdatedAt: 10, + liveness: "not_live", + }); + const claimed = store.fenceSessionAdoptionCandidateForClaim({ + provider: "codex", + providerThreadId: candidate.providerThreadId, + expectedRevision: candidate.revision, + }); + const adopted = store.adoptSessionCandidate({ + provider: "codex", + providerThreadId: candidate.providerThreadId, + expectedCandidateRevision: claimed.revision, + profileId: codexAccount.id, + profileGeneration: codexAccount.processGeneration, + preset: "high", + requirement: presetRequirements.high, + fastEnabled: false, + runtimeProfile: codexAdoptionRuntimeProfile(codexAccount, "high", false), + providerAccountKey: providerAccountKeyForProfile(store, codexAccount.id, "codex"), + }); + + const direct = new Database(store.paths.database, { create: false, strict: true }); + try { + expect(() => direct.query( + "UPDATE sessions SET provider_thread_id=? WHERE id=?", + ).run("unfenced-thread", adopted.session.id)) + .toThrow("active personal runtime binding must be retired before session rebind"); + } finally { + direct.close(false); + } + + const switchAttempt = store.prepareMutation({ + authorityGeneration: claudeAccount.processGeneration, + authorityId: adopted.session.id, + idempotencyKey: "00000000-0000-4000-8000-0000000006b2", + kind: "session.switch", + request: { preset: "fable-max", provider: "claude" }, + }); + const claudeProfile = { + claudeVersion: "2.1.260", + inputFormat: "stream-json" as const, + isolatedConfigDir: true as const, + model: "claude-fable-5-1", + observedAt: 2_100, + outputFormat: "stream-json" as const, + permissionMode: "default" as const, + preset: "fable-max" as const, + processGeneration: claudeAccount.processGeneration, + profileId: claudeAccount.id, + reasoningEffort: "max" as const, + }; + const seedText = "Continue the adopted session on Claude."; + const seedDigest = createHash("sha256") + .update("hra:session-transcript-seed:v1\0", "utf8") + .update(seedText, "utf8") + .digest("hex"); + const transcriptDigest = createHash("sha256") + .update("adopted provider-switch transcript") + .digest("hex"); + store.beginSessionProviderSwitchEffect({ + attemptId: switchAttempt.id, + sessionId: adopted.session.id, + providerAuthentication: { + profileId: claudeAccount.id, + processGeneration: claudeAccount.processGeneration, + provider: "claude", + signedIn: true, + }, + evidence: { + kind: "session.switch", + daemonGeneration: 0, + requestedAccountId: null, + requestedPreset: "fable-max", + runtimeProfile: claudeProfile, + seedDigest, + seedIncludedRecords: 1, + seedOmittedRecords: 0, + sourcePreset: "high", + sourceProcessGeneration: codexAccount.processGeneration, + sourceProfileId: codexAccount.id, + sourceProvider: "codex", + sourceProviderThreadId: candidate.providerThreadId, + targetPreset: "fable-max", + targetProcessGeneration: claudeAccount.processGeneration, + targetProfileId: claudeAccount.id, + targetProvider: "claude", + targetProviderAccountKey: testProviderAccountKey("claude"), + transcriptDigest, + }, + }); + store.recordSessionProviderSwitchTarget({ + attemptId: switchAttempt.id, + sessionId: adopted.session.id, + providerThreadId: "claimed-claude-thread", + }); + store.recordSessionProviderSwitchSeedIntent({ + attemptId: switchAttempt.id, + sessionId: adopted.session.id, + providerThreadId: "claimed-claude-thread", + runtimeProfile: claudeProfile, + seedText, + }); + store.recordSessionProviderSwitchSeedResult({ + attemptId: switchAttempt.id, + sessionId: adopted.session.id, + providerThreadId: "claimed-claude-thread", + runtimeProfile: claudeProfile, + turnId: "claimed-claude-turn", + turnStatus: "inProgress", + }); + store.recordSessionProviderSwitchSourceReleased({ + attemptId: switchAttempt.id, + sessionId: adopted.session.id, + }); + const switchReceipt = { + from: { account: codexAccount.id, preset: "high" as const, provider: "codex" as const }, + providerThreadId: "claimed-claude-thread", + request: { accountId: null, preset: "fable-max" as const, provider: "claude" as const }, + seed: { + digest: seedDigest, + includedRecords: 1, + omittedRecords: 0, + status: "inProgress" as const, + }, + sessionId: adopted.session.id, + to: { + account: claudeAccount.id, + preset: "fable-max" as const, + provider: "claude" as const, + }, + transcriptDigest, + turnId: "claimed-claude-turn", + }; + const targetProcessIdentity = { + pid: 42_002, + pidDomain: "darwin" as const, + procStart: "Fri Sep 4 12:00:01 2026", + }; + store.recordClaimedClaudeProcessAuthority({ + providerThreadId: "claimed-claude-thread", + profileId: claudeAccount.id, + profileGeneration: claudeAccount.processGeneration, + runtimeScope: "managed", + sessionId: adopted.session.id, + identity: targetProcessIdentity, + }); + let switched = store.completeSessionProviderSwitch({ + attemptId: switchAttempt.id, + expectedSessionRevision: adopted.session.revision, + expectedTargetProfileGeneration: claudeAccount.processGeneration, + preset: "fable-max", + profileId: claudeAccount.id, + provider: "claude", + providerThreadId: "claimed-claude-thread", + receipt: switchReceipt, + runtimeProfile: claudeProfile, + providerAccountKey: testProviderAccountKey("claude"), + claudeProcessIdentity: targetProcessIdentity, + seedTurnId: "claimed-claude-turn", + sessionId: adopted.session.id, + state: "active", + activeTurnId: "claimed-claude-turn", }); - // The provider, the account, the preset, and the thread are one binding. expect(switched).toMatchObject({ - preset: "fable-max", + activeTurnId: "claimed-claude-turn", profileId: claudeAccount.id, provider: "claude", - providerThreadId: "claude-thread", + providerThreadId: "claimed-claude-thread", + revision: adopted.session.revision + 2, + state: "active", }); - // The runtime-profile authority guard requires the row's account to equal - // the session's, so the rebind must land before the profile is inserted. - expect(store.latestSessionRuntimeProfile(started.id)).toMatchObject({ - profile: { ...claudeProfile, profileId: claudeAccount.id }, - sourceKind: "turn_start", + switched = store.reconcileSessionFromProvider({ + sessionId: switched.id, + state: "idle", + activeTurnId: null, }); - expect(store.readMutation("00000000-0000-4000-8000-0000000006b1")).toMatchObject({ - result: { - session: { - id: started.id, - profileId: claudeAccount.id, - provider: "claude", - providerThreadId: "claude-thread", - }, - }, - state: "applied", + expect(store.readSessionPersonalRuntimeBinding(switched.id)).toBeNull(); + expect(store.readSessionPersonalRuntimeBinding(switched.id, true)).toMatchObject({ + provider: "codex", + providerThreadId: "adopted-switch-thread", + state: "detached", }); + expect(store.canReleaseIdleManagedClaudeSessionForAccountLogin({ + profileId: claudeAccount.id, + profileGeneration: claudeAccount.processGeneration, + sessionId: switched.id, + })).toBe(true); + expect(store.listNonterminalManagedClaudeSessions(claudeAccount.id)) + .toEqual([switched]); + expect(store.listLocalSessionPage({ + profileId: claudeAccount.id, + after: null, + limit: 1, + })).toEqual({ sessions: [switched], nextPosition: null }); + expect(store.listSessionAdoptionCandidates({ provider: "codex" })[0]?.status) + .toBe("fenced"); - // A preset the target cannot run is refused before anything is written. - expect(() => store.completeSessionProviderSwitch({ - attemptId: switchAttempt.id, - expectedSessionRevision: switched.revision, - preset: "high", + const switchEvents = store.listSessionEvents({ + sessionId: switched.id, + afterSequence: 0, + }).events; + expect(switchEvents).toEqual([ + expect.objectContaining({ + body: expect.objectContaining({ + type: "provider_switched", + fromProvider: "codex", + toProvider: "claude", + transcriptDigest, + seedDigest, + }), + }), + expect.objectContaining({ + body: expect.objectContaining({ + type: "user_message", + actor: "provider_switch", + text: seedText, + }), + }), + ]); + + store.setSessionAdoptionPolicy({ provider: "codex", profileId: null }); + store.setSessionAdoptionPolicy({ provider: "codex", profileId: codexAccount.id }); + const pendingAgain = store.listSessionAdoptionCandidates({ + provider: "codex", + status: "pending", + })[0]; + if (pendingAgain === undefined) throw new Error("Expected the switched identity to be pending again."); + const claimedAgain = store.fenceSessionAdoptionCandidateForClaim({ + provider: "codex", + providerThreadId: pendingAgain.providerThreadId, + expectedRevision: pendingAgain.revision, + }); + const readopted = store.adoptSessionCandidate({ + provider: "codex", + providerThreadId: pendingAgain.providerThreadId, + expectedCandidateRevision: claimedAgain.revision, + profileId: codexAccount.id, + profileGeneration: codexAccount.processGeneration, + preset: "ultra", + requirement: presetRequirements.ultra, + fastEnabled: true, + runtimeProfile: codexAdoptionRuntimeProfile(codexAccount, "ultra", true), + providerAccountKey: providerAccountKeyForProfile(store, codexAccount.id, "codex"), + }); + + expect(readopted.session).toMatchObject({ + profileId: codexAccount.id, + provider: "codex", + providerThreadId: "adopted-switch-thread", + preset: "ultra", + fastEnabled: true, + }); + expect(readopted.session.id).not.toBe(switched.id); + expect(readopted.binding).toMatchObject({ + sessionId: readopted.session.id, + provider: "codex", + providerThreadId: "adopted-switch-thread", + state: "active", + }); + expect(store.requireSession(switched.id)).toMatchObject({ profileId: claudeAccount.id, provider: "claude", - providerThreadId: "claude-thread-2", - receipt: {}, - runtimeProfile: claudeProfile, - seedTurnId: "claude-turn", - sessionId: started.id, - state: "idle", - })).toThrow("does not support the `high` model preset"); - expect(store.requireSession(started.id).providerThreadId).toBe("claude-thread"); + providerThreadId: "claimed-claude-thread", + preset: "fable-max", + }); + expect(store.readSessionPersonalRuntimeBinding(switched.id, true)).toBeNull(); + expect(store.latestSessionRuntimeProfile(switched.id)).toMatchObject({ + sourceId: switchAttempt.id, + sourceKind: "turn_start", + profile: claudeProfile, + }); + expect(store.listSessionEvents({ + sessionId: switched.id, + afterSequence: 0, + }).events).toEqual(switchEvents); }); test("fences provider-switch seed reviews to the exact historical preset contract", async () => { @@ -2779,16 +8698,12 @@ describe("StateStore", () => { }; const solProfile = { ...astraProfile, model: "gpt-5.6-sol" }; const stageSwitch = (suffix: string, seedText: string) => { - const starting = store.createSession({ + const session = upsertProvenTestSession(store, { fastEnabled: false, preset: "fable-max", profileId: sourceProfile.id, provider: "claude", - }); - const session = store.bindSession({ - expectedRevision: starting.revision, providerThreadId: `source-${suffix}`, - sessionId: starting.id, state: "idle", }); const attempt = store.prepareMutation({ @@ -2817,6 +8732,11 @@ describe("StateStore", () => { sourceProvider: "claude" as const, sourceProviderThreadId: `source-${suffix}`, targetPreset: "high" as const, + targetProviderAccountKey: providerAccountKeyForProfile( + store, + targetProfile.id, + "codex", + ), targetProcessGeneration: targetProfile.processGeneration, targetProfileId: targetProfile.id, targetProvider: "codex" as const, @@ -2836,267 +8756,71 @@ describe("StateStore", () => { }; const current = stageSwitch("6b2", "Seed the current Astra target."); - expect(() => store.recordSessionProviderSwitchSeedIntent({ - attemptId: current.attempt.id, - providerThreadId: "target-6b2", - runtimeProfile: solProfile, - seedText: current.seedText, - sessionId: current.session.id, - })).toThrow("SESSION_PROVIDER_SWITCH_SEED_INTENT_AUTHORITY_MISMATCH"); - expect(store.readSessionProviderSwitchProgress(current.attempt.id).seed).toBeUndefined(); - store.recordSessionProviderSwitchSeedIntent({ - attemptId: current.attempt.id, - providerThreadId: "target-6b2", - runtimeProfile: astraProfile, - seedText: current.seedText, - sessionId: current.session.id, - }); - expect(store.readSessionProviderSwitchProgress(current.attempt.id).seed?.runtimeProfile.model) - .toBe("gpt-6-astra"); - - const legacy = stageSwitch("6b3", "Resume the historical Sol target."); - const inspector = new Database(store.paths.database, { create: false, strict: true }); - try { - // Simulate the immutable switch evidence a pre-v38 daemon could have - // left after the provider target was created but before its seed began. - const stored = inspector.query( - "SELECT evidence_json FROM mutation_effect_evidence WHERE attempt_id=?", - ).get(legacy.attempt.id) as { evidence_json: string }; - const legacyEvidence = JSON.parse(stored.evidence_json) as { - runtimeProfile: { model: string }; - }; - legacyEvidence.runtimeProfile.model = "gpt-5.6-sol"; - const legacyEvidenceJson = JSON.stringify(legacyEvidence); - inspector.exec("DROP TRIGGER mutation_effect_evidence_immutable_update"); - inspector.query( - `UPDATE mutation_effect_evidence SET evidence_json=?,evidence_digest=? - WHERE attempt_id=?`, - ).run( - legacyEvidenceJson, - createHash("sha256").update(legacyEvidenceJson).digest("hex"), - legacy.attempt.id, - ); - inspector.exec(` - CREATE TRIGGER mutation_effect_evidence_immutable_update - BEFORE UPDATE ON mutation_effect_evidence - BEGIN SELECT RAISE(ABORT, 'mutation effect evidence is immutable'); END; - `); - } finally { - inspector.close(false); - } - expect(store.readMutation("00000000-0000-4000-8000-0000000006b3")) - .toMatchObject({ evidence: { evidence: { runtimeProfile: { model: "gpt-5.6-sol" } } } }); - expect(store.isSessionMutationProviderAuthorityCurrent({ - attemptId: legacy.attempt.id, - originGeneration: targetProfile.processGeneration, - profileId: targetProfile.id, - provider: "codex", - })).toBe(true); - expect(() => store.recordSessionProviderSwitchSeedIntent({ - attemptId: legacy.attempt.id, - providerThreadId: "target-6b3", - runtimeProfile: solProfile, - seedText: legacy.seedText, - sessionId: legacy.session.id, - })).not.toThrow(); - expect(store.readSessionProviderSwitchProgress(legacy.attempt.id).seed?.runtimeProfile.model) - .toBe("gpt-5.6-sol"); - }); - - test("releases a Devin source binding before completing its provider switch", async () => { - const { store, home } = await fixture(); - const account = signInProfile(store, "Devin switch source", "devin-switch@example.com"); - const projectRoot = join(home, "devin-switch-project"); - await mkdir(projectRoot); - const project = await store.createProject("Devin switch project", projectRoot, true); - const devinProfile = { - devinVersion: "3000.6.14" as const, - isolatedHome: true as const, - model: "gpt-6-astra" as const, - observedAt: 2_000, - preset: "astra" as const, - processGeneration: account.processGeneration, - profileId: account.id, - protocolVersion: 1 as const, - reasoningEffort: "provider-default" as const, - }; - const codexProfile = { - approvalPolicy: "on-request" as const, - computerUse: true as const, - enabledApps: [], - fast: false, - model: "gpt-6-astra", - observedAt: 2_100, - permissionProfile: ":workspace" as const, - pluginCapability: true as const, - preset: "high" as const, - processGeneration: account.processGeneration, - profileId: account.id, - reasoningEffort: "max" as const, - reviewMode: "auto_review" as const, - serviceTier: null, - }; - const startAttempt = store.prepareMutation({ - authorityGeneration: account.processGeneration, - authorityId: account.id, - idempotencyKey: "00000000-0000-4000-8000-0000000006b2", - kind: "session.start", - request: { fast: false, preset: "astra", projectId: project.id }, - }); - const started = store.beginSessionStartEffect({ - attemptId: startAttempt.id, - evidence: { - clientMessageId: null, - kind: "session.start", - messageDigest: null, - projectId: project.id, - runtimeProfile: devinProfile, - }, - fastEnabled: false, - preset: "astra", - profileGeneration: account.processGeneration, - profileId: account.id, - projectId: project.id, - provider: "devin", - providerAuthentication: { - profileId: account.id, - processGeneration: account.processGeneration, - provider: "devin", - signedIn: true, - }, - }); - store.completeSessionStartEffect({ - attemptId: startAttempt.id, - expectedSessionRevision: started.revision, - providerThreadId: "devin-source-thread", - receipt: { effectiveRuntimeProfile: devinProfile, sessionId: started.id }, - runtimeProfile: devinProfile, - sessionId: started.id, - state: "idle", - }); - - expect(store.requireSession(started.id)).toMatchObject({ - preset: "astra", - provider: "devin", - providerThreadId: "devin-source-thread", - }); - const inspector = new Database(store.paths.database, { create: false, strict: true }); - try { - expect(inspector.query( - "SELECT provider,provider_v39 FROM sessions WHERE id=?", - ).get(started.id)).toEqual({ provider: "codex", provider_v39: "devin" }); - } finally { - inspector.close(false); - } - - const switchAttempt = store.prepareMutation({ - authorityGeneration: account.processGeneration, - authorityId: started.id, - idempotencyKey: "00000000-0000-4000-8000-0000000006b3", - kind: "session.switch", - request: { preset: "high", provider: "codex" }, - }); - const seedText = "Continue after switching away from Devin."; - const seedDigest = createHash("sha256") - .update("hra:session-transcript-seed:v1\0", "utf8") - .update(seedText, "utf8") - .digest("hex"); - const transcriptDigest = createHash("sha256") - .update("devin source switch transcript") - .digest("hex"); - const evidence = { - daemonGeneration: 0, - kind: "session.switch" as const, - requestedAccountId: null, - requestedPreset: "high" as const, - runtimeProfile: codexProfile, - seedDigest, - seedIncludedRecords: 1, - seedOmittedRecords: 0, - sourcePreset: "astra" as const, - sourceProcessGeneration: account.processGeneration, - sourceProfileId: account.id, - sourceProvider: "devin" as const, - sourceProviderThreadId: "devin-source-thread", - targetPreset: "high" as const, - targetProcessGeneration: account.processGeneration, - targetProfileId: account.id, - targetProvider: "codex" as const, - transcriptDigest, - }; - store.beginSessionProviderSwitchEffect({ - attemptId: switchAttempt.id, - evidence, - sessionId: started.id, - }); - store.recordSessionProviderSwitchTarget({ - attemptId: switchAttempt.id, - providerThreadId: "codex-target-thread", - sessionId: started.id, - }); - store.recordSessionProviderSwitchSeedIntent({ - attemptId: switchAttempt.id, - providerThreadId: "codex-target-thread", - runtimeProfile: codexProfile, - seedText, - sessionId: started.id, - }); - store.recordSessionProviderSwitchSeedResult({ - attemptId: switchAttempt.id, - providerThreadId: "codex-target-thread", - runtimeProfile: codexProfile, - sessionId: started.id, - turnId: "codex-seed-turn", - turnStatus: "completed", - }); - - expect(store.readSessionProviderSwitchProgress(switchAttempt.id).sourceReleased).toBe(false); - store.recordSessionProviderSwitchSourceReleased({ - attemptId: switchAttempt.id, - sessionId: started.id, - }); - expect(store.readSessionProviderSwitchProgress(switchAttempt.id).sourceReleased).toBe(true); - - const before = store.requireSession(started.id); - const switched = store.completeSessionProviderSwitch({ - attemptId: switchAttempt.id, - expectedSessionRevision: before.revision, - preset: "high", - profileId: account.id, - provider: "codex", - providerThreadId: "codex-target-thread", - receipt: { - from: { account: account.id, preset: "astra", provider: "devin" }, - providerThreadId: "codex-target-thread", - request: { accountId: null, preset: "high", provider: "codex" }, - seed: { - digest: seedDigest, - includedRecords: 1, - omittedRecords: 0, - status: "completed", - }, - sessionId: started.id, - to: { account: account.id, preset: "high", provider: "codex" }, - transcriptDigest, - turnId: "codex-seed-turn", - }, - runtimeProfile: codexProfile, - seedTurnId: "codex-seed-turn", - sessionId: started.id, - state: "idle", - }); - expect(switched).toMatchObject({ - preset: "high", - profileId: account.id, - provider: "codex", - providerThreadId: "codex-target-thread", + expect(() => store.recordSessionProviderSwitchSeedIntent({ + attemptId: current.attempt.id, + providerThreadId: "target-6b2", + runtimeProfile: solProfile, + seedText: current.seedText, + sessionId: current.session.id, + })).toThrow("SESSION_PROVIDER_SWITCH_SEED_INTENT_AUTHORITY_MISMATCH"); + expect(store.readSessionProviderSwitchProgress(current.attempt.id).seed).toBeUndefined(); + store.recordSessionProviderSwitchSeedIntent({ + attemptId: current.attempt.id, + providerThreadId: "target-6b2", + runtimeProfile: astraProfile, + seedText: current.seedText, + sessionId: current.session.id, }); + expect(store.readSessionProviderSwitchProgress(current.attempt.id).seed?.runtimeProfile.model) + .toBe("gpt-6-astra"); + + const legacy = stageSwitch("6b3", "Resume the historical Sol target."); + const inspector = new Database(store.paths.database, { create: false, strict: true }); + try { + // Simulate the immutable switch evidence a pre-v38 daemon could have + // left after the provider target was created but before its seed began. + const stored = inspector.query( + "SELECT evidence_json FROM mutation_effect_evidence WHERE attempt_id=?", + ).get(legacy.attempt.id) as { evidence_json: string }; + const legacyEvidence = JSON.parse(stored.evidence_json) as { + runtimeProfile: { model: string }; + }; + legacyEvidence.runtimeProfile.model = "gpt-5.6-sol"; + const legacyEvidenceJson = JSON.stringify(legacyEvidence); + inspector.exec("DROP TRIGGER mutation_effect_evidence_immutable_update"); + inspector.query( + `UPDATE mutation_effect_evidence SET evidence_json=?,evidence_digest=? + WHERE attempt_id=?`, + ).run( + legacyEvidenceJson, + createHash("sha256").update(legacyEvidenceJson).digest("hex"), + legacy.attempt.id, + ); + inspector.exec(` + CREATE TRIGGER mutation_effect_evidence_immutable_update + BEFORE UPDATE ON mutation_effect_evidence + BEGIN SELECT RAISE(ABORT, 'mutation effect evidence is immutable'); END; + `); + } finally { + inspector.close(false); + } expect(store.readMutation("00000000-0000-4000-8000-0000000006b3")) - .toMatchObject({ - evidence: { evidence: { sourceProvider: "devin", targetProvider: "codex" } }, - result: { from: { provider: "devin" }, session: { provider: "codex" } }, - state: "applied", - }); + .toMatchObject({ evidence: { evidence: { runtimeProfile: { model: "gpt-5.6-sol" } } } }); + expect(store.isSessionMutationProviderAuthorityCurrent({ + attemptId: legacy.attempt.id, + originGeneration: targetProfile.processGeneration, + profileId: targetProfile.id, + provider: "codex", + })).toBe(true); + expect(() => store.recordSessionProviderSwitchSeedIntent({ + attemptId: legacy.attempt.id, + providerThreadId: "target-6b3", + runtimeProfile: solProfile, + seedText: legacy.seedText, + sessionId: legacy.session.id, + })).not.toThrow(); + expect(store.readSessionProviderSwitchProgress(legacy.attempt.id).seed?.runtimeProfile.model) + .toBe("gpt-5.6-sol"); }); test("refuses a session-start evidence row whose profile names another provider", async () => { @@ -3139,6 +8863,7 @@ describe("StateStore", () => { profileId: profile.id, projectId: project.id, provider: "claude", + providerAccountKey: testProviderAccountKey("claude"), providerAuthentication: { profileId: profile.id, processGeneration: profile.processGeneration, @@ -3317,8 +9042,8 @@ describe("StateStore", () => { test("enforces every queue transition at both the store and SQLite boundaries", async () => { const { store } = await fixture(); - const profile = store.createProfile("Queue graph"); - const session = store.createSession({ + const profile = signInProfile(store, "Queue graph", "queue-graph@example.com"); + const session = createProvenTestSession(store, { profileId: profile.id, preset: "high", fastEnabled: false, @@ -3361,8 +9086,8 @@ describe("StateStore", () => { await initializeStatePaths(paths); const store = new StateStore(paths, { now: () => 1_000 }); stores.push(store); - const profile = store.createProfile("Queue FIFO"); - const session = store.createSession({ + const profile = signInProfile(store, "Queue FIFO", "queue-fifo@example.com"); + const session = createProvenTestSession(store, { profileId: profile.id, preset: "high", fastEnabled: false, @@ -3377,7 +9102,7 @@ describe("StateStore", () => { expect(store.transitionQueue(first.id, "dispatching", "failed")).toBe(true); expect(store.nextPendingQueue(session.id)?.id).toBe(second.id); expect(() => store.enqueue(`sess_${"f".repeat(32)}`, "must roll back")) - .toThrow("FOREIGN KEY constraint failed"); + .toThrow("session provider account authority is not current"); const third = store.enqueue(session.id, "third"); const inspector = new Database(paths.database, { create: false, strict: true }); @@ -3424,8 +9149,8 @@ describe("StateStore", () => { test("selects the oldest pending queue row without scanning terminal history", async () => { const { store } = await fixture(); - const profile = store.createProfile("Bounded queue lookup"); - const session = store.createSession({ + const profile = signInProfile(store, "Bounded queue lookup", "bounded-queue@example.com"); + const session = createProvenTestSession(store, { profileId: profile.id, preset: "high", fastEnabled: false, @@ -3472,18 +9197,19 @@ describe("StateStore", () => { test("removes settled queue bodies without losing replay or recovery authority", async () => { const { store } = await fixture(); const profile = signInProfile(store, "Queue body custody", "queue-body@example.com"); - const created = store.createSession({ + const importedSession = upsertProvenTestSession(store, { profileId: profile.id, preset: "high", fastEnabled: false, - }); - const session = store.bindSession({ - sessionId: created.id, - expectedRevision: created.revision, providerThreadId: "thread-queue-body-custody", state: "idle", providerUpdatedAt: 10, }); + const session = store.updateSessionMetadata({ + sessionId: importedSession.id, + expectedRevision: importedSession.revision, + preset: "high", + }); const runtime = { profileId: profile.id, processGeneration: profile.processGeneration, @@ -3754,18 +9480,19 @@ describe("StateStore", () => { test("migrates and physically scrubs v20 terminal and resolved-ambiguous queue bodies", async () => { const { store } = await fixture(); const profile = signInProfile(store, "Legacy queue bodies", "legacy-queue@example.com"); - const created = store.createSession({ + const importedSession = upsertProvenTestSession(store, { profileId: profile.id, preset: "high", fastEnabled: false, - }); - const session = store.bindSession({ - sessionId: created.id, - expectedRevision: created.revision, providerThreadId: "thread-legacy-queue-bodies", state: "idle", providerUpdatedAt: 10, }); + const session = store.updateSessionMetadata({ + sessionId: importedSession.id, + expectedRevision: importedSession.revision, + preset: "high", + }); const terminal = store.enqueue(session.id, "already terminal body"); expect(store.transitionQueue(terminal.id, "pending", "cancelled")).toBe(true); @@ -3809,6 +9536,7 @@ describe("StateStore", () => { store.close(); stores.splice(stores.indexOf(store), 1); const legacy = new Database(paths.database, { create: false, strict: true }); + dropSchemaAfterVersion34(legacy); legacy.exec(` PRAGMA secure_delete=OFF; DROP TRIGGER IF EXISTS queue_message_settlement_guard; @@ -3818,7 +9546,7 @@ describe("StateStore", () => { DROP TRIGGER IF EXISTS queue_message_scrub_authority_record; DROP TRIGGER IF EXISTS queue_effect_resolution_authority_guard; DROP TABLE IF EXISTS queue_message_scrub_authority; - DELETE FROM migrations WHERE version IN (21,22,23,24); + DELETE FROM migrations WHERE version>20; PRAGMA user_version=20; `); legacy.query( @@ -3868,7 +9596,7 @@ describe("StateStore", () => { const inspector = new Database(paths.database, { readonly: true, strict: true }); try { - expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 39 }); + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); expect(inspector.query( "SELECT applied_at FROM migrations WHERE version=23", ).get()).toEqual({ applied_at: 3_000 }); @@ -3889,7 +9617,7 @@ describe("StateStore", () => { test("keeps a pinned-reader queue scrub unavailable until restart can truncate its WAL", async () => { const { store } = await fixture({ securityScrubCheckpoint: shortScrubCheckpoint }); const profile = signInProfile(store, "Pinned queue scrub", "pinned-queue@example.com"); - const session = store.createSession({ + const session = createProvenTestSession(store, { profileId: profile.id, preset: "high", fastEnabled: false, @@ -3958,7 +9686,7 @@ describe("StateStore", () => { test("completes a queue scrub after a brief reader releases its WAL snapshot", async () => { const { store, home } = await fixture(); const profile = signInProfile(store, "Brief reader queue scrub", "brief-reader@example.com"); - const session = store.createSession({ + const session = createProvenTestSession(store, { profileId: profile.id, preset: "high", fastEnabled: false, @@ -4005,7 +9733,7 @@ describe("StateStore", () => { test("settles queue messages while a readonly status reader reopens the same state directory", async () => { const { store, home } = await fixture(); const profile = signInProfile(store, "Status reader settlement", "status-reader@example.com"); - const session = store.createSession({ + const session = createProvenTestSession(store, { profileId: profile.id, preset: "high", fastEnabled: false, @@ -4070,7 +9798,7 @@ describe("StateStore", () => { test("repairs stale current-version queue triggers before accepting more state", async () => { const { store } = await fixture(); const profile = signInProfile(store, "Stale queue trigger", "stale-trigger@example.com"); - const session = store.createSession({ + const session = createProvenTestSession(store, { profileId: profile.id, preset: "high", fastEnabled: false, @@ -4123,7 +9851,7 @@ describe("StateStore", () => { test("retains a newer scrub generation when a settlement follows a checkpoint snapshot", async () => { const { store } = await fixture(); const profile = signInProfile(store, "Scrub generation", "scrub-generation@example.com"); - const session = store.createSession({ + const session = createProvenTestSession(store, { profileId: profile.id, preset: "high", fastEnabled: false, @@ -4617,8 +10345,19 @@ describe("StateStore", () => { enabledApps: [], }; - const localSend = store.createSession({ profileId: profile.id, preset: "high", fastEnabled: false }); - const sendSession = store.bindSession({ sessionId: localSend.id, expectedRevision: localSend.revision, providerThreadId: "thread-send-cas", state: "idle", providerUpdatedAt: 10 }); + const importedSendSession = upsertProvenTestSession(store, { + profileId: profile.id, + preset: "high", + fastEnabled: false, + providerThreadId: "thread-send-cas", + state: "idle", + providerUpdatedAt: 10, + }); + const sendSession = store.updateSessionMetadata({ + sessionId: importedSendSession.id, + expectedRevision: importedSendSession.revision, + preset: "high", + }); const sendKey = "00000000-0000-4000-8000-000000000711"; const sendAttempt = store.prepareMutation({ kind: "session.send", authorityId: sendSession.id, authorityGeneration: profile.processGeneration, request: { message: "send" }, idempotencyKey: sendKey }); store.beginSessionMutationEffect({ @@ -4649,8 +10388,19 @@ describe("StateStore", () => { expect(store.latestSessionRuntimeProfile(sendSession.id)).toBeNull(); expect(store.requireSession(sendSession.id)).toMatchObject({ state: "idle", note: "concurrent" }); - const localQueue = store.createSession({ profileId: profile.id, preset: "high", fastEnabled: false }); - const queueSession = store.bindSession({ sessionId: localQueue.id, expectedRevision: localQueue.revision, providerThreadId: "thread-queue-cas", state: "idle", providerUpdatedAt: 10 }); + const importedQueueSession = upsertProvenTestSession(store, { + profileId: profile.id, + preset: "high", + fastEnabled: false, + providerThreadId: "thread-queue-cas", + state: "idle", + providerUpdatedAt: 10, + }); + const queueSession = store.updateSessionMetadata({ + sessionId: importedQueueSession.id, + expectedRevision: importedQueueSession.revision, + preset: "high", + }); const queue = store.enqueue(queueSession.id, "queued"); const queueEvidence = store.beginQueueEffect({ queueId: queue.id, @@ -4688,11 +10438,10 @@ describe("StateStore", () => { const { store } = await fixture(); const profile = signInProfile(store, "Cadence authority", "cadence@example.com"); expect(store.hasSessionWithActiveTurn()).toBe(false); - const created = store.createSession({ profileId: profile.id, preset: "high", fastEnabled: false }); - expect(store.hasSessionWithActiveTurn()).toBe(false); - const bound = store.bindSession({ - sessionId: created.id, - expectedRevision: created.revision, + const bound = upsertProvenTestSession(store, { + profileId: profile.id, + preset: "high", + fastEnabled: false, providerThreadId: "thread-cadence", state: "active", activeTurnId: "turn-cadence", @@ -4709,10 +10458,10 @@ describe("StateStore", () => { test("appends ordered bounded session events and reads an atomic snapshot cursor", async () => { const { store } = await fixture(); const profile = signInProfile(store, "Event authority", "events@example.com"); - const created = store.createSession({ profileId: profile.id, preset: "high", fastEnabled: false }); - const session = store.bindSession({ - sessionId: created.id, - expectedRevision: created.revision, + const session = upsertProvenTestSession(store, { + profileId: profile.id, + preset: "high", + fastEnabled: false, providerThreadId: "thread-events", state: "active", activeTurnId: "turn-events", @@ -4793,20 +10542,16 @@ describe("StateStore", () => { test("projects legacy private identifiers and MCP summaries on every public read", async () => { const { store } = await fixture(); const profile = signInProfile(store, "Legacy public projection", "legacy-projection@example.com"); - const created = store.createSession({ + const session = upsertProvenTestSession(store, { profileId: profile.id, title: "Legacy public projection", preset: "high", fastEnabled: false, - }); - const rawTurnId = `${privateUserPathRoot}/api_key=LEGACY-TURN-SECRET-1234`; - const session = store.bindSession({ - sessionId: created.id, - expectedRevision: created.revision, providerThreadId: "thread-legacy-public-projection", state: "active", - activeTurnId: rawTurnId, + activeTurnId: `${privateUserPathRoot}/api_key=LEGACY-TURN-SECRET-1234`, }); + const rawTurnId = `${privateUserPathRoot}/api_key=LEGACY-TURN-SECRET-1234`; const connectionId = "10000000-0000-4000-8000-000000000099"; const interaction = store.admitInteraction({ publicId: "10000000-0000-4000-8000-000000000098", @@ -5019,15 +10764,11 @@ describe("StateStore", () => { test("reads one bounded local session observation with exact interaction and queue semantics", async () => { const { store } = await fixture(); const profile = signInProfile(store, "Observation account", "observation@example.com"); - const created = store.createSession({ + const session = upsertProvenTestSession(store, { profileId: profile.id, title: `Observed api_key=TITLE-SECRET-1234 ${privateUserPathRoot}/work`, preset: "high", fastEnabled: false, - }); - const session = store.bindSession({ - sessionId: created.id, - expectedRevision: created.revision, providerThreadId: "thread-observation", state: "idle", }); @@ -5168,15 +10909,11 @@ describe("StateStore", () => { expect(expandedSnapshot.session.title).not.toContain("/a"); const privateTurnId = `${privateUserPathRoot}/api_key=TURN-SECRET-1234`; - const privateTurnCreated = store.createSession({ + const privateTurnSession = upsertProvenTestSession(store, { profileId: profile.id, title: "Private provider identifier", preset: "high", fastEnabled: false, - }); - const privateTurnSession = store.bindSession({ - sessionId: privateTurnCreated.id, - expectedRevision: privateTurnCreated.revision, providerThreadId: "thread-private-provider-id", state: "active", activeTurnId: privateTurnId, @@ -5190,15 +10927,11 @@ describe("StateStore", () => { expect(store.requireSession(privateTurnSession.id).activeTurnId).toBe(privateTurnId); const longTurnId = "l".repeat(201); - const longTurnCreated = store.createSession({ + const longTurnSession = upsertProvenTestSession(store, { profileId: profile.id, title: "Long provider identifier", preset: "high", fastEnabled: false, - }); - const longTurnSession = store.bindSession({ - sessionId: longTurnCreated.id, - expectedRevision: longTurnCreated.revision, providerThreadId: "thread-long-provider-id", state: "active", activeTurnId: longTurnId, @@ -5294,7 +11027,7 @@ describe("StateStore", () => { store.recordUsage(removed.id, 1, 100, { sentinel: "removed-account-payload" }); store.removeProfile(removed.id); - const session = store.createSession({ + const session = createProvenTestSession(store, { profileId: observed.id, title: "private session title", preset: "high", @@ -5336,7 +11069,7 @@ describe("StateStore", () => { const status = store.readRootStatusSnapshot(); expect(status.counts).toMatchObject({ accounts: { signedOut: 1, loginPending: 0, signedIn: 2, recoveryRequired: 0 }, - sessions: { starting: 1 }, + sessions: { idle: 1 }, interactions: { pending: 1 }, queue: { pending: 1 }, usage: { observed: 1, failed: 1, missing: 1 }, @@ -5387,7 +11120,7 @@ describe("StateStore", () => { test("emits executable root attention intents for every interaction kind and in-flight state", async () => { const { store } = await fixture(); const profile = signInProfile(store, "Actionable attention", "attention@example.com"); - const session = store.createSession({ + const session = createProvenTestSession(store, { profileId: profile.id, title: "Actionable attention", preset: "high", @@ -5557,14 +11290,10 @@ describe("StateStore", () => { test("atomically retires one provider generation before an account login advances it", async () => { const { store } = await fixture(); const profile = signInProfile(store, "Login retirement", "login-retirement@example.com"); - const created = store.createSession({ + const session = upsertProvenTestSession(store, { profileId: profile.id, preset: "high", fastEnabled: false, - }); - const session = store.bindSession({ - sessionId: created.id, - expectedRevision: created.revision, providerThreadId: "thread-login-retirement", state: "idle", }); @@ -5609,6 +11338,11 @@ describe("StateStore", () => { request: { deviceCode: false }, idempotencyKey: "00000000-0000-4000-8000-000000000811", }); + completeCodexAccountMutationAuthorityRetirement( + store, + profile.id, + profile.processGeneration, + ); const begun = store.beginAccountMutationEffect({ attemptId: attempt.id, @@ -5660,6 +11394,12 @@ describe("StateStore", () => { }).events; expect(events.map((event) => event.body)).toEqual([ { type: "connection", state: "connected" }, + { + type: "interaction_state", + interactionId: interaction.publicId, + state: "expired", + revision: interaction.revision + 1, + }, { type: "assistant_delta", turnId: publicProviderIdentifier("turn-login-retirement"), @@ -5671,12 +11411,6 @@ describe("StateStore", () => { code: "provider_resume_unavailable", message: "Provider observation is unavailable.", }, - { - type: "interaction_state", - interactionId: interaction.publicId, - state: "expired", - revision: interaction.revision + 1, - }, { type: "connection", state: "disconnected", reason: "closed" }, { type: "gap", @@ -5694,12 +11428,13 @@ describe("StateStore", () => { const { store } = await fixture(); const profile = signInProfile(store, "Rollback retirement", "rollback-retirement@example.com"); const other = signInProfile(store, "Other retirement", "other-retirement@example.com"); - const session = store.createSession({ + const session = createProvenTestSession(store, { profileId: profile.id, preset: "high", fastEnabled: false, + providerThreadId: "thread-rollback-retirement", }); - const otherSession = store.createSession({ + const otherSession = createProvenTestSession(store, { profileId: other.id, preset: "high", fastEnabled: false, @@ -5747,17 +11482,22 @@ describe("StateStore", () => { evidence: { kind: "account.login", method: "browser" }, ...(providerRetirements === undefined ? {} : { providerRetirements }), }); + completeCodexAccountMutationAuthorityRetirement( + store, + profile.id, + profile.processGeneration, + ); const assertUnchanged = (): void => { expect(store.requireProfileById(profile.id)).toMatchObject({ processGeneration: profile.processGeneration, state: "signed_in", }); expect(store.requireInteraction(interaction.publicId)).toMatchObject({ - revision: interaction.revision, - state: "pending", + revision: interaction.revision + 1, + state: "expired", }); expect(store.readMutation(key)).toMatchObject({ state: "prepared" }); - expect(store.eventStreamPosition(session.id).observedThroughSequence).toBe(0); + expect(store.eventStreamPosition(session.id).observedThroughSequence).toBe(1); }; expect(() => begin([{ @@ -5798,7 +11538,7 @@ describe("StateStore", () => { sessionId: otherSession.id, connectionId, releasedEvents: [], - }])).toThrow("ACCOUNT_LOGIN_RETIREMENT_SESSION_AUTHORITY_MISMATCH"); + }])).toThrow("ACCOUNT_MUTATION_RETIREMENT_SESSION_AUTHORITY_MISMATCH"); assertUnchanged(); expect(begin([{ @@ -5809,7 +11549,239 @@ describe("StateStore", () => { profile: { processGeneration: profile.processGeneration + 1 }, retiredSessionIds: [session.id], }); - expect(store.requireInteraction(interaction.publicId).state).toBe("expired"); + expect(store.requireInteraction(interaction.publicId).state).toBe("expired"); + }); + + test("requires completed Codex authority retirement before ordinary login and logout effects", async () => { + const { store } = await fixture(); + for (const [kind, idempotencyKey] of [ + ["account.login", "00000000-0000-4000-8000-000000000813"], + ["account.logout", "00000000-0000-4000-8000-000000000814"], + ] as const) { + const profile = signInProfile( + store, + `Retirement parity ${kind}`, + `retirement-parity-${kind.slice("account.".length)}@example.com`, + ); + const effectGeneration = kind === "account.login" + ? profile.processGeneration + 1 + : profile.processGeneration; + const attempt = store.prepareMutation({ + kind, + authorityId: profile.id, + authorityGeneration: effectGeneration, + request: kind === "account.login" ? { deviceCode: false } : {}, + idempotencyKey, + }); + const evidence = kind === "account.login" + ? { kind, method: "browser" as const } + : { kind, baselineSignedIn: true }; + + expect(() => store.beginAccountMutationEffect({ + attemptId: attempt.id, + profileId: profile.id, + profileGeneration: effectGeneration, + evidence, + })).toThrow("ACCOUNT_MUTATION_CODEX_AUTHORITY_NOT_RETIRED"); + expect(store.readMutation(idempotencyKey)).toMatchObject({ state: "prepared" }); + + completeCodexRuntimeAccountAuthorityRetirement( + store, + profile.id, + profile.processGeneration, + kind === "account.login" ? "personal" : "managed", + ); + expect(() => store.beginAccountMutationEffect({ + attemptId: attempt.id, + profileId: profile.id, + profileGeneration: effectGeneration, + evidence, + })).toThrow("ACCOUNT_MUTATION_CODEX_AUTHORITY_NOT_RETIRED"); + expect(store.readMutation(idempotencyKey)).toMatchObject({ state: "prepared" }); + + completeCodexAccountMutationAuthorityRetirement( + store, + profile.id, + profile.processGeneration, + ); + expect(store.beginAccountMutationEffect({ + attemptId: attempt.id, + profileId: profile.id, + profileGeneration: effectGeneration, + evidence, + })).toMatchObject({ + profile: { + processGeneration: effectGeneration, + state: kind === "account.login" ? "login_pending" : "signed_in", + }, + }); + expect(store.readMutation(idempotencyKey)).toMatchObject({ state: "effect_started" }); + for (const runtimeScope of ["personal", "managed"] as const) { + expect(store.readProviderRuntimeAccountRevocation({ + profileId: profile.id, + provider: "codex", + runtimeScope, + })).toMatchObject({ + currentAccountKey: null, + profileGeneration: profile.processGeneration, + state: "completed", + }); + } + } + }); + + test("starts logout after personal retirement while its exact managed null fence is still releasing", async () => { + const { store } = await fixture(); + const profile = signInProfile( + store, + "Ordered logout retirement", + "ordered-logout-retirement@example.com", + ); + const idempotencyKey = "00000000-0000-4000-8000-000000000815"; + const attempt = store.prepareMutation({ + kind: "account.logout", + authorityId: profile.id, + authorityGeneration: profile.processGeneration, + request: {}, + idempotencyKey, + }); + const workStore = createRevocationWorkStore(store); + const personal = store.beginProviderRuntimeAccountRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + provider: "codex", + runtimeScope: "personal", + currentAccountKey: null, + workStore, + }); + const managed = store.beginProviderRuntimeAccountRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + provider: "codex", + runtimeScope: "managed", + currentAccountKey: null, + workStore, + }); + + expect(() => store.beginAccountMutationEffect({ + attemptId: attempt.id, + profileId: profile.id, + profileGeneration: profile.processGeneration, + evidence: { kind: "account.logout", baselineSignedIn: true }, + })).toThrow("ACCOUNT_MUTATION_CODEX_AUTHORITY_NOT_RETIRED"); + expect(store.readMutation(idempotencyKey)).toMatchObject({ state: "prepared" }); + + store.completeProviderRuntimeAccountRevocation({ + profileId: profile.id, + expectedGeneration: profile.processGeneration, + provider: "codex", + runtimeScope: "personal", + expectedRevision: personal.revocation.revision, + }); + expect(store.beginAccountMutationEffect({ + attemptId: attempt.id, + profileId: profile.id, + profileGeneration: profile.processGeneration, + evidence: { kind: "account.logout", baselineSignedIn: true }, + })).toMatchObject({ + profile: { + processGeneration: profile.processGeneration, + state: "signed_in", + }, + }); + expect(store.readMutation(idempotencyKey)).toMatchObject({ state: "effect_started" }); + expect(store.readProviderRuntimeAccountRevocation({ + profileId: profile.id, + provider: "codex", + runtimeScope: "personal", + })).toMatchObject({ currentAccountKey: null, state: "completed" }); + expect(store.readProviderRuntimeAccountRevocation({ + profileId: profile.id, + provider: "codex", + runtimeScope: "managed", + })).toMatchObject({ + currentAccountKey: null, + profileGeneration: profile.processGeneration, + revision: managed.revocation.revision, + state: "releasing", + }); + }); + + test("does not extend logout's releasing exception to login or a non-null managed fence", async () => { + const { store } = await fixture(); + const loginProfile = signInProfile( + store, + "Still fenced login", + "still-fenced-login@example.com", + ); + const loginAttempt = store.prepareMutation({ + kind: "account.login", + authorityId: loginProfile.id, + authorityGeneration: loginProfile.processGeneration + 1, + request: { deviceCode: false }, + idempotencyKey: "00000000-0000-4000-8000-000000000816", + }); + completeCodexRuntimeAccountAuthorityRetirement( + store, + loginProfile.id, + loginProfile.processGeneration, + "personal", + ); + store.beginProviderRuntimeAccountRevocation({ + profileId: loginProfile.id, + expectedGeneration: loginProfile.processGeneration, + provider: "codex", + runtimeScope: "managed", + currentAccountKey: null, + workStore: createRevocationWorkStore(store), + }); + expect(() => store.beginAccountMutationEffect({ + attemptId: loginAttempt.id, + profileId: loginProfile.id, + profileGeneration: loginProfile.processGeneration + 1, + evidence: { kind: "account.login", method: "browser" }, + })).toThrow("ACCOUNT_MUTATION_CODEX_AUTHORITY_NOT_RETIRED"); + + const logoutProfile = signInProfile( + store, + "Non-null fenced logout", + "non-null-fenced-logout@example.com", + ); + const logoutAttempt = store.prepareMutation({ + kind: "account.logout", + authorityId: logoutProfile.id, + authorityGeneration: logoutProfile.processGeneration, + request: {}, + idempotencyKey: "00000000-0000-4000-8000-000000000817", + }); + completeCodexRuntimeAccountAuthorityRetirement( + store, + logoutProfile.id, + logoutProfile.processGeneration, + "personal", + ); + store.beginProviderRuntimeAccountRevocation({ + profileId: logoutProfile.id, + expectedGeneration: logoutProfile.processGeneration, + provider: "codex", + runtimeScope: "managed", + currentAccountKey: providerAccountKeyForProfile( + store, + logoutProfile.id, + "codex", + ), + workStore: createRevocationWorkStore(store), + }); + expect(() => store.beginAccountMutationEffect({ + attemptId: logoutAttempt.id, + profileId: logoutProfile.id, + profileGeneration: logoutProfile.processGeneration, + evidence: { kind: "account.logout", baselineSignedIn: true }, + })).toThrow("ACCOUNT_MUTATION_CODEX_AUTHORITY_NOT_RETIRED"); + expect(store.readMutation("00000000-0000-4000-8000-000000000816")) + .toMatchObject({ state: "prepared" }); + expect(store.readMutation("00000000-0000-4000-8000-000000000817")) + .toMatchObject({ state: "prepared" }); }); test("evicts a deterministic contiguous event prefix by age and reports the exact floor gap", async () => { @@ -5820,7 +11792,12 @@ describe("StateStore", () => { const store = new StateStore(paths, { now: () => currentTime }); stores.push(store); const profile = signInProfile(store, "Retention", "retention@example.com"); - const session = store.createSession({ profileId: profile.id, preset: "high", fastEnabled: false }); + const session = createProvenTestSession(store, { + profileId: profile.id, + preset: "high", + fastEnabled: false, + providerThreadId: "thread-interaction", + }); const append = (message: string) => store.appendSessionEvent({ sessionId: session.id, accountId: profile.id, @@ -5889,7 +11866,12 @@ describe("StateStore", () => { test("caps event pages by encoded bytes without splitting or reordering events", async () => { const { store } = await fixture(); const profile = signInProfile(store, "Page bytes", "page-bytes@example.com"); - const session = store.createSession({ profileId: profile.id, preset: "high", fastEnabled: false }); + const session = createProvenTestSession(store, { + profileId: profile.id, + preset: "high", + fastEnabled: false, + providerThreadId: "thread-interaction-page", + }); for (let index = 0; index < 18; index += 1) { store.appendSessionEvent({ sessionId: session.id, @@ -5915,7 +11897,12 @@ describe("StateStore", () => { test("brokers tagged provider requests with exact replay and write-ahead CAS states", async () => { const { store } = await fixture(); const profile = signInProfile(store, "Interactions", "interactions@example.com"); - const session = store.createSession({ profileId: profile.id, preset: "high", fastEnabled: false }); + const session = createProvenTestSession(store, { + profileId: profile.id, + preset: "high", + fastEnabled: false, + providerThreadId: "thread-deadline", + }); const connectionId = "20000000-0000-4000-8000-000000000001"; const authority = { profileId: profile.id, @@ -6058,7 +12045,12 @@ describe("StateStore", () => { const store = new StateStore(paths, { now: () => 20_000 }); stores.push(store); const profile = signInProfile(store, "Interaction pages", "interaction-pages@example.com"); - const session = store.createSession({ profileId: profile.id, preset: "high", fastEnabled: false }); + const session = createProvenTestSession(store, { + profileId: profile.id, + preset: "high", + fastEnabled: false, + providerThreadId: "thread-interaction-page", + }); const display = { kind: "command_approval" as const, summary: "Resolve the paged interaction", @@ -6125,7 +12117,13 @@ describe("StateStore", () => { const store = new StateStore(paths, { now: () => now }); stores.push(store); const profile = signInProfile(store, "Attention snapshot", "attention-snapshot@example.com"); - const session = store.createSession({ profileId: profile.id, preset: "high", fastEnabled: false }); + const session = upsertProvenTestSession(store, { + profileId: profile.id, + preset: "high", + fastEnabled: false, + providerThreadId: "thread-attention-snapshot", + state: "idle", + }); const connectionId = "24000000-0000-4000-8000-999999999999"; const display = { kind: "command_approval" as const, @@ -6225,7 +12223,13 @@ describe("StateStore", () => { const store = new StateStore(paths, { now: () => now }); stores.push(store); const profile = signInProfile(store, "Attention overflow", "attention-overflow@example.com"); - const session = store.createSession({ profileId: profile.id, preset: "high", fastEnabled: false }); + const session = upsertProvenTestSession(store, { + profileId: profile.id, + preset: "high", + fastEnabled: false, + providerThreadId: "thread-attention-overflow", + state: "idle", + }); const connectionId = "25000000-0000-4000-8000-999999999999"; const admit = (index: number, sessionId: string | null) => store.admitInteraction({ publicId: `25000000-0000-4000-8000-${String(index).padStart(12, "0")}`, @@ -6287,7 +12291,12 @@ describe("StateStore", () => { const store = new StateStore(paths, { now: () => now }); stores.push(store); const profile = signInProfile(store, "Deadline", "deadline@example.com"); - const session = store.createSession({ profileId: profile.id, preset: "high", fastEnabled: false }); + const session = createProvenTestSession(store, { + profileId: profile.id, + preset: "high", + fastEnabled: false, + providerThreadId: "thread-deadline", + }); const authority = { profileId: profile.id, processGeneration: profile.processGeneration, @@ -6380,7 +12389,12 @@ describe("StateStore", () => { const store = new StateStore(paths, { now: () => now }); stores.push(store); const profile = signInProfile(store, "Timeout CAS", "timeout-cas@example.com"); - const session = store.createSession({ profileId: profile.id, preset: "high", fastEnabled: false }); + const session = createProvenTestSession(store, { + profileId: profile.id, + preset: "high", + fastEnabled: false, + providerThreadId: "thread-timeout-cas", + }); const authority = { profileId: profile.id, processGeneration: profile.processGeneration, @@ -6889,10 +12903,11 @@ describe("StateStore", () => { `Persistence ${effect}`, `persistence-${effect}@example.com`, ); - const session = store.createSession({ + const session = createProvenTestSession(store, { profileId: profile.id, preset: "high", fastEnabled: false, + providerThreadId: "thread-persistence-quarantine", }); const connectionId = effect === "known_unsent" ? "30100000-0000-4000-8000-000000000001" @@ -7047,10 +13062,11 @@ describe("StateStore", () => { test("rolls back every quarantine transition when its generation fence cannot commit", async () => { const { store } = await fixture(); const profile = signInProfile(store, "Persistence rollback", "persistence-rollback@example.com"); - const session = store.createSession({ + const session = createProvenTestSession(store, { profileId: profile.id, preset: "high", fastEnabled: false, + providerThreadId: "thread-persistence-rollback", }); const connectionId = "30300000-0000-4000-8000-000000000001"; const admit = (requestId: number) => store.admitInteraction({ @@ -7254,6 +13270,7 @@ describe("StateStore", () => { const legacy = new Database(paths.database, { create: false, strict: true }); try { + dropSchemaAfterVersion34(legacy); legacy.exec(` DROP TRIGGER account_rate_limit_reset_attempt_policy_insert_guard; DROP TRIGGER account_rate_limit_reset_attempt_policy_begin_guard; @@ -7263,7 +13280,7 @@ describe("StateStore", () => { DROP TRIGGER account_rate_limit_reset_policy_transition_guard; DROP TRIGGER account_rate_limit_reset_policy_delete_guard; DROP TABLE account_rate_limit_reset_policies; - DELETE FROM migrations WHERE version=28; + DELETE FROM migrations WHERE version>27; PRAGMA user_version=27; `); } finally { @@ -7341,7 +13358,7 @@ describe("StateStore", () => { }); const inspector = new Database(paths.database, { readonly: true, strict: true }); try { - expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 39 }); + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); expect(inspector.query( "SELECT COUNT(*) AS count FROM account_rate_limit_reset_attempts", ).get()).toEqual({ count: 1 }); @@ -7370,7 +13387,8 @@ describe("StateStore", () => { const partial = new Database(paths.database, { create: false, strict: true }); try { - partial.exec("DELETE FROM migrations WHERE version=28; PRAGMA user_version=27"); + dropSchemaAfterVersion34(partial); + partial.exec("DELETE FROM migrations WHERE version>27; PRAGMA user_version=27"); } finally { partial.close(false); } @@ -8944,7 +14962,12 @@ describe("StateStore", () => { test("reopens v9 event and interaction state read-only without rotating authority", async () => { const { store } = await fixture(); const profile = signInProfile(store, "Readonly v9", "readonly-v9@example.com"); - const session = store.createSession({ profileId: profile.id, preset: "high", fastEnabled: false }); + const session = createProvenTestSession(store, { + profileId: profile.id, + preset: "high", + fastEnabled: false, + providerThreadId: "thread-readonly", + }); const event = store.appendSessionEvent({ sessionId: session.id, accountId: profile.id, @@ -8995,8 +15018,9 @@ describe("StateStore", () => { const { store } = await fixture(); const inspector = new Database(store.paths.database, { readonly: true, strict: true }); try { - expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 39 }); - expect(inspector.query("SELECT version FROM migrations ORDER BY version").all()).toEqual([{ version: 1 }, { version: 2 }, { version: 3 }, { version: 4 }, { version: 5 }, { version: 6 }, { version: 7 }, { version: 8 }, { version: 9 }, { version: 10 }, { version: 11 }, { version: 12 }, { version: 13 }, { version: 14 }, { version: 15 }, { version: 16 }, { version: 17 }, { version: 18 }, { version: 19 }, { version: 20 }, { version: 21 }, { version: 22 }, { version: 23 }, { version: 24 }, { version: 25 }, { version: 26 }, { version: 27 }, { version: 28 }, { version: 29 }, { version: 30 }, { version: 31 }, { version: 32 }, { version: 33 }, { version: 34 }, { version: 35 }, { version: 36 }, { version: 37 }, { version: 38 }, { version: 39 }]); + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); + expect(inspector.query("SELECT version FROM migrations ORDER BY version").all()) + .toEqual(Array.from({ length: 40 }, (_, index) => ({ version: index + 1 }))); expect(inspector.query("PRAGMA table_info(account_rate_limit_reset_attempts)").all()) .toContainEqual(expect.objectContaining({ name: "attempt_sequence", type: "INTEGER", pk: 1 })); expect(inspector.query("PRAGMA table_info(account_rate_limit_reset_attempts)").all()) @@ -9019,6 +15043,21 @@ describe("StateStore", () => { { name: "weekly_window_resets_at", type: "INTEGER", notnull: 0 }, { name: "revision", type: "INTEGER", notnull: 1 }, ]) expect(resetPolicyColumns).toContainEqual(expect.objectContaining(expected)); + expect(inspector.query("PRAGMA table_info(session_adoption_policies)").all()) + .toContainEqual(expect.objectContaining({ name: "provider", type: "TEXT", pk: 1 })); + const adoptionCandidateColumns = inspector + .query("PRAGMA table_info(session_adoption_candidates)").all(); + expect(adoptionCandidateColumns) + .toContainEqual(expect.objectContaining({ name: "liveness", type: "TEXT", notnull: 1 })); + for (const name of ["source_pid", "source_pid_domain", "source_proc_start"]) { + expect(adoptionCandidateColumns) + .toContainEqual(expect.objectContaining({ name, notnull: 0 })); + } + expect(inspector.query( + "SELECT name FROM sqlite_master WHERE type='index' AND name='session_adoption_candidates_claude_reprobe'", + ).get()).toEqual({ name: "session_adoption_candidates_claude_reprobe" }); + expect(inspector.query("PRAGMA table_info(session_personal_runtime_bindings)").all()) + .toContainEqual(expect.objectContaining({ name: "session_id", type: "TEXT", pk: 1 })); expect(inspector.query("PRAGMA table_info(profiles)").all()) .toContainEqual(expect.objectContaining({ name: "label_key", type: "TEXT" })); expect(inspector.query("PRAGMA table_info(projects)").all()) @@ -9066,236 +15105,736 @@ describe("StateStore", () => { { name: "provider_interactions_mcp_url_guard_update" }, ]); expect(inspector.query( - "SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'provider_interactions_listing_%' ORDER BY name", - ).all()).toEqual([ - { name: "provider_interactions_listing_global" }, - { name: "provider_interactions_listing_pending_global" }, - { name: "provider_interactions_listing_pending_session" }, - { name: "provider_interactions_listing_session" }, - ]); + "SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'provider_interactions_listing_%' ORDER BY name", + ).all()).toEqual([ + { name: "provider_interactions_listing_global" }, + { name: "provider_interactions_listing_pending_global" }, + { name: "provider_interactions_listing_pending_session" }, + { name: "provider_interactions_listing_session" }, + ]); + } finally { + inspector.close(false); + } + }); + + test("creates a fresh database before newer SQLite revalidates Work authority triggers", async () => { + const home = await realpath(await mkdtemp(join(tmpdir(), "hra-store-newer-sqlite-"))); + const repositoryRoot = join(import.meta.dir, "..", ".."); + const customSqliteCandidate = process.arch === "arm64" + ? "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib" + : "/usr/local/opt/sqlite/lib/libsqlite3.dylib"; + const customSqlite = process.platform === "darwin" && existsSync(customSqliteCandidate) + ? customSqliteCandidate + : ""; + const program = String.raw` + import { Database } from "bun:sqlite"; + import { join } from "node:path"; + import { pathToFileURL } from "node:url"; + + const [repositoryRoot, home, customSqlite] = Bun.argv.slice(1); + if (customSqlite.length > 0 && !Database.setCustomSQLite(customSqlite)) { + throw new Error("CUSTOM_SQLITE_CONFIGURATION_FAILED"); + } + const versionProbe = new Database(":memory:"); + const sqliteVersion = versionProbe.query("SELECT sqlite_version() AS version").get().version; + versionProbe.close(false); + + const pathsModule = await import(pathToFileURL( + join(repositoryRoot, "src", "storage", "paths.ts"), + ).href); + const storeModule = await import(pathToFileURL( + join(repositoryRoot, "src", "storage", "state-store.ts"), + ).href); + const paths = pathsModule.resolveStatePaths({ + homeDirectory: home, + platform: process.platform === "linux" ? "linux" : "darwin", + }); + await pathsModule.initializeStatePaths(paths); + const store = new storeModule.StateStore(paths, { + now: (() => { let value = 1_000; return () => value++; })(), + resolveMachineTimeZone: () => "UTC", + }); + store.close(); + + const inspector = new Database(paths.database, { readonly: true, strict: true }); + const userVersion = inspector.query("PRAGMA user_version").get().user_version; + const dependencyTables = inspector.query( + "SELECT name FROM sqlite_master WHERE type='table' AND name IN (" + + "'provider_runtime_account_revocations','session_account_authorities'," + + "'session_adoption_candidates','session_personal_runtime_bindings'," + + "'session_provider_account_authorities') ORDER BY name", + ).all().map((row) => row.name); + const profileColumns = inspector.query( + "SELECT name FROM pragma_table_info('profiles') WHERE name='codex_account_key'", + ).all().map((row) => row.name); + const foreignKeyViolations = inspector.query("PRAGMA foreign_key_check").all().length; + inspector.close(false); + console.log(JSON.stringify({ + dependencyTables, + foreignKeyViolations, + profileColumns, + sqliteVersion, + userVersion, + })); + `; + const child = Bun.spawn([ + process.execPath, + "--eval", + program, + repositoryRoot, + home, + customSqlite, + ], { + cwd: repositoryRoot, + env: {}, + stderr: "pipe", + stdin: "ignore", + stdout: "pipe", + }); + const [exitCode, stderr, stdout] = await Promise.all([ + child.exited, + new Response(child.stderr).text(), + new Response(child.stdout).text(), + ]); + + expect(exitCode, stderr).toBe(0); + expect(JSON.parse(stdout)).toEqual({ + dependencyTables: [ + "provider_runtime_account_revocations", + "session_account_authorities", + "session_adoption_candidates", + "session_personal_runtime_bindings", + "session_provider_account_authorities", + ], + foreignKeyViolations: 0, + profileColumns: ["codex_account_key"], + sqliteVersion: expect.stringMatching(/^3\./u), + userVersion: 40, + }); + }); + + test("migrates v34 through provider switch, notifications, contracts, and adoption v39", async () => { + const { store } = await fixture(); + const profile = signInProfile(store, "Historical Sol", "historical-sol@example.com"); + const session = store.upsertProviderSession({ + profileId: profile.id, + provider: "codex", + providerThreadId: "thread-historical-sol", + title: "Historical Sol", + preset: "high", + fastEnabled: false, + state: "idle", + providerAccountKey: providerAccountKeyForProfile(store, profile.id, "codex"), + }); + const runtimeProfile = effectiveRuntimeProfileSchema.parse({ + profileId: profile.id, + processGeneration: profile.processGeneration, + observedAt: 2_000, + preset: "high", + model: "gpt-5.6-sol", + reasoningEffort: "max", + serviceTier: null, + fast: false, + approvalPolicy: "on-request", + reviewMode: "auto_review", + permissionProfile: ":workspace", + computerUse: true, + pluginCapability: true, + enabledApps: [], + }); + store.recordSessionRuntimeProfile({ + sessionId: session.id, + sourceKind: "turn_start", + sourceId: "historical-sol-source", + profile: runtimeProfile, + }); + const paths = store.paths; + store.close(); + stores.splice(stores.indexOf(store), 1); + + const legacy = new Database(paths.database, { create: false, strict: true }); + try { + dropPostProviderSwitchSchema(legacy); + dropProviderSwitchProgressSchema(legacy); + legacy.exec("DELETE FROM migrations WHERE version>=35; PRAGMA user_version=34;"); + expect(legacy.query( + `SELECT COUNT(*) AS count FROM sqlite_master + WHERE name LIKE 'session_provider_switch_%' + OR name LIKE 'session_mutation_authority_rebinds%' + OR name='session_adoption_policies'`, + ).get()).toEqual({ count: 0 }); + } finally { + legacy.close(false); + } + + expect(() => new StateStore(paths, { readonly: true })) + .toThrow("STATE_SCHEMA_MIGRATION_REQUIRED:34:40"); + const migrated = new StateStore(paths, { now: () => 4_000 }); + stores.push(migrated); + expect(migrated.latestSessionRuntimeProfile(session.id)?.profile).toEqual(runtimeProfile); + expect(migrated.requireSessionPresetRequirement(session.id)).toEqual({ + preset: "high", + requirement: { model: "gpt-5.6-sol", effort: "max" }, + }); + const inspector = new Database(paths.database, { readonly: true, strict: true }); + try { + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); + expect(inspector.query("SELECT version FROM migrations WHERE version>=35 ORDER BY version").all()) + .toEqual([ + { version: 35 }, + { version: 36 }, + { version: 37 }, + { version: 38 }, + { version: 39 }, + { version: 40 }, + ]); + expect(inspector.query( + `SELECT type,COUNT(*) AS count FROM sqlite_master + WHERE name LIKE 'session_provider_switch_%' + OR name LIKE 'session_mutation_authority_rebinds%' + GROUP BY type ORDER BY type`, + ).all()).toEqual([ + { count: 7, type: "table" }, + { count: 14, type: "trigger" }, + ]); + expect(inspector.query( + "SELECT name FROM sqlite_master WHERE type='table' AND name='session_adoption_policies'", + ).get()).toEqual({ name: "session_adoption_policies" }); + } finally { + inspector.close(false); + } + }); + + test("migrates an upstream provider-switch v35 database to adoption v39", async () => { + const { store } = await fixture(); + const paths = store.paths; + store.close(); + stores.splice(stores.indexOf(store), 1); + + const upstreamV35 = new Database(paths.database, { create: false, strict: true }); + try { + dropPostProviderSwitchSchema(upstreamV35); + upstreamV35.exec("DELETE FROM migrations WHERE version>=36; PRAGMA user_version=35;"); + expect(upstreamV35.query( + "SELECT name FROM sqlite_master WHERE type='table' AND name='session_provider_switch_targets'", + ).get()).toEqual({ name: "session_provider_switch_targets" }); + expect(upstreamV35.query( + "SELECT name FROM sqlite_master WHERE type='table' AND name='session_adoption_policies'", + ).get()).toBeNull(); + } finally { + upstreamV35.close(false); + } + + const migrated = new StateStore(paths, { now: () => 2_000 }); + stores.push(migrated); + const inspector = new Database(paths.database, { readonly: true, strict: true }); + try { + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); + expect(inspector.query("SELECT version FROM migrations WHERE version>=35 ORDER BY version").all()) + .toEqual([ + { version: 35 }, + { version: 36 }, + { version: 37 }, + { version: 38 }, + { version: 39 }, + { version: 40 }, + ]); + expect(inspector.query( + `SELECT name FROM sqlite_master WHERE type='table' + AND name IN ('session_provider_switch_targets','session_adoption_policies') + ORDER BY name`, + ).all()).toEqual([ + { name: "session_adoption_policies" }, + { name: "session_provider_switch_targets" }, + ]); + } finally { + inspector.close(false); + } + }); + + test("migrates the exact protected-main provider-v39 predecessor to adoption v40", async () => { + const { store } = await fixture(); + const profile = store.createProfile("Provider v39 Devin"); + const session = store.createSession({ + profileId: profile.id, + provider: "devin", + preset: "astra", + fastEnabled: false, + }); + const paths = store.paths; + store.close(); + stores.splice(stores.indexOf(store), 1); + + const predecessor = new Database(paths.database, { create: false, strict: true }); + try { + downgradeToExactProviderVersion39(predecessor); + expect(predecessor.query("PRAGMA user_version").get()).toEqual({ user_version: 39 }); + expect(predecessor.query( + "SELECT name FROM pragma_table_info('profiles') WHERE name='codex_account_key'", + ).get()).toBeNull(); + expect(predecessor.query( + "SELECT name FROM sqlite_master WHERE type='table' AND name='session_adoption_policies'", + ).get()).toBeNull(); + expect(predecessor.query( + "SELECT provider_v39,preset_contract FROM sessions WHERE id=?", + ).get(session.id)).toEqual({ provider_v39: "devin", preset_contract: 2 }); + } finally { + predecessor.close(false); + } + + expect(() => new StateStore(paths, { readonly: true })) + .toThrow("STATE_SCHEMA_MIGRATION_REQUIRED:39:40"); + const migrated = new StateStore(paths, { now: () => 2_000 }); + stores.push(migrated); + expect(migrated.requireSession(session.id)).toMatchObject({ + provider: "devin", + preset: "astra", + }); + const inspector = new Database(paths.database, { readonly: true, strict: true }); + try { + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); + expect(inspector.query( + "SELECT version FROM migrations WHERE version>=39 ORDER BY version", + ).all()).toEqual([{ version: 39 }, { version: 40 }]); + expect(inspector.query( + "SELECT name FROM sqlite_master WHERE type='table' AND name='session_adoption_policies'", + ).get()).toEqual({ name: "session_adoption_policies" }); + } finally { + inspector.close(false); + } + }); + + test("rejects a drifted provider-v39 predecessor before adding adoption authority", async () => { + const { store } = await fixture(); + const paths = store.paths; + store.close(); + stores.splice(stores.indexOf(store), 1); + + const predecessor = new Database(paths.database, { create: false, strict: true }); + try { + downgradeToExactProviderVersion39(predecessor); + predecessor.exec(` + DROP TRIGGER session_mutation_authority_rebinds_v39_immutable_delete; + CREATE TRIGGER session_mutation_authority_rebinds_v39_immutable_delete + BEFORE DELETE ON session_mutation_authority_rebinds_v39 + WHEN OLD.recorded_at>=0 + BEGIN SELECT RAISE(ABORT, 'session mutation authority rebind v39 is immutable'); END; + `); + } finally { + predecessor.close(false); + } + + expect(() => new StateStore(paths, { now: () => 2_000 })) + .toThrow( + "STATE_SCHEMA_V39_OBJECT_INVALID:session_mutation_authority_rebinds_v39_immutable_delete", + ); + const inspector = new Database(paths.database, { readonly: true, strict: true }); + try { + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 39 }); + expect(inspector.query( + "SELECT name FROM sqlite_master WHERE type='table' AND name='session_adoption_policies'", + ).get()).toBeNull(); + expect(inspector.query( + `SELECT sql FROM sqlite_master + WHERE type='trigger' + AND name='session_mutation_authority_rebinds_v39_immutable_delete'`, + ).get()).toEqual(expect.objectContaining({ + sql: expect.stringContaining("WHEN OLD.recorded_at>=0"), + })); + } finally { + inspector.close(false); + } + }); + + test("migrates the exact 42c4235 adoption-v35 surface and preserves candidates", async () => { + const { store } = await fixture(); + const paths = store.paths; + store.close(); + stores.splice(stores.indexOf(store), 1); + + const historicalV35 = new Database(paths.database, { create: false, strict: true }); + try { + downgradeToHistoricalAdoptionVersion35(historicalV35); + historicalV35.query( + `INSERT INTO session_adoption_candidates( + provider,provider_thread_id,project_id,title,provider_state, + active_turn_id,provider_updated_at,liveness, + source_pid,source_pid_domain,source_proc_start,claim_status, + candidate_fingerprint,fenced_fingerprint,revision, + first_discovered_at,last_observed_at,last_changed_at,last_attempt_at + ) VALUES ('claude',?,NULL,?,'terminal',NULL,10,'not_live', + 46001,'darwin','historical-v35-process','pending',?,NULL,1,1000,1000,1000,NULL)`, + ).run( + "historical-v35-candidate", + "Historical v35 candidate", + "a".repeat(64), + ); + expect(historicalV35.query( + "SELECT sql FROM sqlite_master WHERE name='session_adoption_candidates'", + ).get()).not.toBeNull(); + expect(historicalV35.query( + "SELECT 1 FROM pragma_table_info('profiles') WHERE name='codex_account_key'", + ).get()).toBeNull(); + } finally { + historicalV35.close(false); + } + + const migrated = new StateStore(paths, { now: () => 2_000 }); + stores.push(migrated); + expect(migrated.readSessionAdoptionCandidate( + "claude", + "historical-v35-candidate", + )).toMatchObject({ + lastLiveObservedAt: null, + providerProjectRoot: null, + provider: "claude", + providerThreadId: "historical-v35-candidate", + sourceProcessIdentity: { + pid: 46_001, + pidDomain: "darwin", + procStart: "historical-v35-process", + }, + status: "pending", + }); + const inspector = new Database(paths.database, { readonly: true, strict: true }); + try { + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); + expect(inspector.query( + "SELECT 1 FROM pragma_table_info('session_adoption_candidates') WHERE name='last_live_observed_at'", + ).get()).toEqual({ 1: 1 }); + expect(inspector.query( + "SELECT 1 FROM pragma_table_info('session_adoption_candidates') WHERE name='provider_project_root'", + ).get()).toEqual({ 1: 1 }); + } finally { + inspector.close(false); + } + }); + + test("rejects an extra adoption object beside the exact 42c4235 v35 surface without writes", async () => { + const { store } = await fixture(); + const paths = store.paths; + store.close(); + stores.splice(stores.indexOf(store), 1); + + const historicalLookalike = new Database(paths.database, { create: false, strict: true }); + try { + downgradeToHistoricalAdoptionVersion35(historicalLookalike); + historicalLookalike.exec( + "CREATE TABLE session_adoption_unrecognized_authority (id TEXT PRIMARY KEY) STRICT", + ); + } finally { + historicalLookalike.close(false); + } + + expect(() => new StateStore(paths, { now: () => 2_000 })) + .toThrow("STATE_SCHEMA_V39_LEGACY_ADOPTION_OBJECT_INVALID"); + const inspector = new Database(paths.database, { readonly: true, strict: true }); + try { + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 35 }); + expect(inspector.query( + "SELECT name FROM sqlite_master WHERE name='session_adoption_unrecognized_authority'", + ).get()).toEqual({ name: "session_adoption_unrecognized_authority" }); + expect(inspector.query( + "SELECT 1 FROM sqlite_master WHERE name='notification_hours'", + ).get()).toBeNull(); } finally { inspector.close(false); } }); - test("migrates historical Sol runtime profiles without rewriting their durable JSON", async () => { + test("rejects a malformed adoption-v35 lookalike before mutating it", async () => { const { store } = await fixture(); - const profile = signInProfile(store, "Historical Sol", "historical-sol@example.com"); - const session = store.upsertProviderSession({ + const paths = store.paths; + store.close(); + stores.splice(stores.indexOf(store), 1); + + const featureV35 = new Database(paths.database, { create: false, strict: true }); + try { + dropPostLegacyAdoptionVersion36Schema(featureV35); + dropProviderSwitchProgressSchema(featureV35); + featureV35.exec("DELETE FROM migrations WHERE version>=36; PRAGMA user_version=35;"); + expect(featureV35.query( + "SELECT name FROM sqlite_master WHERE type='table' AND name='session_adoption_policies'", + ).get()).toEqual({ name: "session_adoption_policies" }); + expect(featureV35.query( + "SELECT name FROM sqlite_master WHERE type='table' AND name='session_provider_switch_targets'", + ).get()).toBeNull(); + } finally { + featureV35.close(false); + } + + expect(() => new StateStore(paths, { now: () => 2_000 })) + .toThrow(/STATE_SCHEMA_V39_LEGACY_ADOPTION_/u); + const inspector = new Database(paths.database, { readonly: true, strict: true }); + try { + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 35 }); + expect(inspector.query("SELECT version FROM migrations WHERE version>=35 ORDER BY version").all()) + .toEqual([{ version: 35 }]); + expect(inspector.query( + `SELECT name FROM sqlite_master WHERE type='table' + AND name IN ('session_provider_switch_targets','session_adoption_policies') + ORDER BY name`, + ).all()).toEqual([{ name: "session_adoption_policies" }]); + } finally { + inspector.close(false); + } + }); + + test("rejects stale same-name v39 provider-revocation policy guards", async () => { + const value = await fixture(); + const store = value.store; + const profile = signInProfile( + store, + "Stale v36 policy guards", + "stale-v36-policy-guards@example.com", + ); + const begun = store.beginProviderRuntimeAccountRevocation({ profileId: profile.id, - providerThreadId: "thread-historical-sol", - title: "Historical Sol", - state: "idle", + expectedGeneration: profile.processGeneration, + provider: "codex", + runtimeScope: "personal", + currentAccountKey: null, + workStore: createRevocationWorkStore(store), }); - const runtimeProfile = effectiveRuntimeProfileSchema.parse({ + store.completeProviderRuntimeAccountRevocation({ profileId: profile.id, - processGeneration: profile.processGeneration, - observedAt: 2_000, - preset: "high", - model: "gpt-5.6-sol", - reasoningEffort: "max", - serviceTier: null, - fast: false, - approvalPolicy: "on-request", - reviewMode: "auto_review", - permissionProfile: ":workspace", - computerUse: true, - pluginCapability: true, - enabledApps: [], - }); - store.recordSessionRuntimeProfile({ - sessionId: session.id, - sourceKind: "turn_start", - sourceId: "historical-sol-source", - profile: runtimeProfile, + expectedGeneration: profile.processGeneration, + provider: "codex", + runtimeScope: "personal", + expectedRevision: begun.revocation.revision, }); const paths = store.paths; store.close(); stores.splice(stores.indexOf(store), 1); - const legacy = new Database(paths.database, { create: false, strict: true }); - const before = z.object({ profile_json: z.string() }).strict().parse(legacy.query( - "SELECT profile_json FROM session_runtime_profiles WHERE source_id='historical-sol-source'", - ).get()).profile_json; - legacy.exec("DELETE FROM migrations WHERE version>=38; PRAGMA user_version=37;"); - legacy.close(false); + const stale = new Database(paths.database, { create: false, strict: true }); + try { + stale.exec(` + DROP TRIGGER session_adoption_policy_provider_revocation_guard_insert; + DROP TRIGGER session_adoption_policy_provider_revocation_guard_update; + CREATE TRIGGER session_adoption_policy_provider_revocation_guard_insert + BEFORE INSERT ON session_adoption_policies BEGIN SELECT 1; END; + CREATE TRIGGER session_adoption_policy_provider_revocation_guard_update + BEFORE UPDATE ON session_adoption_policies BEGIN SELECT 1; END; + `); + } finally { + stale.close(false); + } + + expect(() => new StateStore(paths)) + .toThrow("STATE_SCHEMA_V39_OBJECT_INVALID:session_adoption_policy_provider_revocation_guard_insert"); + }); + + test("readonly open rejects a malformed same-name v39 authority trigger", async () => { + const { store } = await fixture(); + const paths = store.paths; + store.close(); + stores.splice(stores.indexOf(store), 1); + const damaged = new Database(paths.database, { create: false, strict: true }); + try { + damaged.exec(` + DROP TRIGGER session_adoption_candidate_revision_guard; + CREATE TRIGGER session_adoption_candidate_revision_guard + BEFORE UPDATE ON session_adoption_candidates BEGIN SELECT 1; END; + `); + } finally { + damaged.close(false); + } expect(() => new StateStore(paths, { readonly: true })) - .toThrow("STATE_SCHEMA_MIGRATION_REQUIRED:37:39"); - const migrated = new StateStore(paths, { now: () => 4_000 }); - stores.push(migrated); - expect(migrated.latestSessionRuntimeProfile(session.id)?.profile).toEqual(runtimeProfile); - expect(migrated.requireSessionPresetRequirement(session.id)).toEqual({ - preset: "high", - requirement: { model: "gpt-5.6-sol", effort: "max" }, - }); + .toThrow("STATE_SCHEMA_V39_OBJECT_INVALID:session_adoption_candidate_revision_guard"); + }); + + test.each([ + "last_live_observed_at", + "provider_project_root", + ] as const)("current v39 opens never repair a missing candidate retention column: %s", async (column) => { + const { store } = await fixture(); + const paths = store.paths; + store.close(); + stores.splice(stores.indexOf(store), 1); + const malformed = new Database(paths.database, { create: false, strict: true }); + try { + malformed.exec(`ALTER TABLE session_adoption_candidates DROP COLUMN ${column}`); + } finally { + malformed.close(false); + } + + expect(() => new StateStore(paths, { readonly: true })) + .toThrow("STATE_SCHEMA_V39_OBJECT_INVALID:session_adoption_candidates"); + expect(() => new StateStore(paths)) + .toThrow("STATE_SCHEMA_V39_OBJECT_INVALID:session_adoption_candidates"); const inspector = new Database(paths.database, { readonly: true, strict: true }); try { + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); expect(inspector.query( - "SELECT profile_json FROM session_runtime_profiles WHERE source_id='historical-sol-source'", - ).get()).toEqual({ profile_json: before }); - expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 39 }); + "SELECT name FROM pragma_table_info('session_adoption_candidates') WHERE name=?", + ).get(column)).toBeNull(); } finally { inspector.close(false); } }); - test("widens v38 provider authority without replacing existing session or rebind rows", async () => { + test("current v40 writable open never recreates missing provider-v39 authority", async () => { const { store } = await fixture(); - const profile = signInProfile(store, "V38 provider authority", "v38-provider@example.com"); - const codex = store.createSession({ - profileId: profile.id, - preset: "high", - fastEnabled: false, - }); - const claude = store.createSession({ - profileId: profile.id, - provider: "claude", - preset: "fable-max", - fastEnabled: false, - }); - const queued = store.enqueue(codex.id, "retained across provider widening"); - const attempt = store.prepareMutation({ - authorityGeneration: profile.processGeneration, - authorityId: profile.id, - idempotencyKey: "00000000-0000-4000-8000-000000003901", - kind: "migration.provider-authority", - request: { provider: "claude" }, - }); const paths = store.paths; store.close(); stores.splice(stores.indexOf(store), 1); + const malformed = new Database(paths.database, { create: false, strict: true }); + try { + malformed.exec("DROP TABLE session_mutation_authority_rebinds_v39"); + } finally { + malformed.close(false); + } - const legacy = new Database(paths.database, { create: false, strict: true }); + expect(() => new StateStore(paths, { readonly: true })) + .toThrow("STATE_SCHEMA_V39_OBJECT_INVALID:session_mutation_authority_rebinds_v39"); + expect(() => new StateStore(paths)) + .toThrow("STATE_SCHEMA_V39_OBJECT_INVALID:session_mutation_authority_rebinds_v39"); + const inspector = new Database(paths.database, { readonly: true, strict: true }); try { - legacy.query( - `INSERT INTO session_mutation_authority_rebinds( - attempt_id,profile_id,provider,from_generation,to_generation,recorded_at - ) VALUES (?,?,?,?,?,?)`, - ).run( - attempt.id, - profile.id, - "claude", - profile.processGeneration, - profile.processGeneration + 1, - 1_500, - ); - legacy.exec(` - DROP TRIGGER work_devin_preset_contract_guard; - DROP TRIGGER work_session_devin_contract_guard; - DROP TRIGGER work_attempt_route_guard; - DROP TRIGGER work_session_attempt_authority_guard; - DROP TRIGGER work_profile_attempt_authority_guard; - DROP TRIGGER work_signal_member_guard; - DROP TABLE session_mutation_authority_rebinds_v39; - ALTER TABLE sessions DROP COLUMN provider_v39; - DELETE FROM migrations WHERE version=39; - PRAGMA user_version=38; + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); + expect(inspector.query( + "SELECT name FROM sqlite_master WHERE name='session_mutation_authority_rebinds_v39'", + ).get()).toBeNull(); + expect(inspector.query( + `SELECT COUNT(*) AS count FROM sqlite_master + WHERE name LIKE 'session_mutation_authority_rebinds_v39_%'`, + ).get()).toEqual({ count: 0 }); + } finally { + inspector.close(false); + } + }); + + test("current v40 rejects a weakened same-name provider-v39 authority trigger without repair", async () => { + const { store } = await fixture(); + const paths = store.paths; + store.close(); + stores.splice(stores.indexOf(store), 1); + const malformed = new Database(paths.database, { create: false, strict: true }); + try { + malformed.exec(` + DROP TRIGGER session_mutation_authority_rebinds_v39_immutable_update; + CREATE TRIGGER session_mutation_authority_rebinds_v39_immutable_update + BEFORE UPDATE ON session_mutation_authority_rebinds_v39 + WHEN NEW.recorded_at=OLD.recorded_at + BEGIN SELECT RAISE(ABORT, 'session mutation authority rebind v39 is immutable'); END; `); - expect(legacy.query("PRAGMA table_info(sessions)").all()) - .not.toContainEqual(expect.objectContaining({ name: "provider_v39" })); } finally { - legacy.close(false); + malformed.close(false); } - const migrated = new StateStore(paths, { now: () => 2_000 }); - stores.push(migrated); - expect(migrated.requireSession(codex.id)).toMatchObject({ provider: "codex", preset: "high" }); - expect(migrated.requireSession(claude.id)) - .toMatchObject({ provider: "claude", preset: "fable-max" }); - expect(migrated.requireQueue(queued.id).message).toBe("retained across provider widening"); - const devin = migrated.createSession({ - profileId: profile.id, - provider: "devin", - preset: "astra", - fastEnabled: false, - }); - expect(migrated.requireSession(devin.id)) - .toMatchObject({ provider: "devin", preset: "astra" }); + for (const readonly of [true, false]) { + expect(() => new StateStore(paths, readonly ? { readonly: true } : {})) + .toThrow( + "STATE_SCHEMA_V39_OBJECT_INVALID:session_mutation_authority_rebinds_v39_immutable_update", + ); + } + const inspector = new Database(paths.database, { readonly: true, strict: true }); + try { + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); + expect(inspector.query( + `SELECT sql FROM sqlite_master + WHERE type='trigger' + AND name='session_mutation_authority_rebinds_v39_immutable_update'`, + ).get()).toEqual(expect.objectContaining({ + sql: expect.stringContaining("WHEN NEW.recorded_at=OLD.recorded_at"), + })); + } finally { + inspector.close(false); + } + }); - const inspector = new Database(paths.database, { create: false, strict: true }); + test("current v40 writable open never backfills a missing provider-v39 column", async () => { + const { store } = await fixture(); + const paths = store.paths; + store.close(); + stores.splice(stores.indexOf(store), 1); + const malformed = new Database(paths.database, { create: false, strict: true }); try { - expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 39 }); + const removedTriggers = dropProviderV39SessionColumn(malformed); + expect(removedTriggers.length).toBeGreaterThan(0); + } finally { + malformed.close(false); + } + + expect(() => new StateStore(paths, { readonly: true })) + .toThrow("STATE_SCHEMA_V39_OBJECT_INVALID:sessions.provider_v39"); + expect(() => new StateStore(paths)) + .toThrow("STATE_SCHEMA_V39_OBJECT_INVALID:sessions.provider_v39"); + const inspector = new Database(paths.database, { readonly: true, strict: true }); + try { + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); expect(inspector.query( - "SELECT id,provider,provider_v39 FROM sessions ORDER BY id", - ).all()).toEqual([ - { id: claude.id, provider: "claude", provider_v39: "claude" }, - { id: codex.id, provider: "codex", provider_v39: "codex" }, - { id: devin.id, provider: "codex", provider_v39: "devin" }, - ].sort((left, right) => left.id.localeCompare(right.id))); + "SELECT name FROM pragma_table_info('sessions') WHERE name='provider_v39'", + ).get()).toBeNull(); expect(inspector.query( - `SELECT attempt_id,profile_id,provider,from_generation,to_generation,recorded_at - FROM session_mutation_authority_rebinds_v39 WHERE attempt_id=?`, - ).get(attempt.id)).toEqual({ - attempt_id: attempt.id, - profile_id: profile.id, - provider: "claude", - from_generation: profile.processGeneration, - to_generation: profile.processGeneration + 1, - recorded_at: 1_500, - }); - inspector.query( - `INSERT INTO session_mutation_authority_rebinds_v39( - attempt_id,profile_id,provider,from_generation,to_generation,recorded_at - ) VALUES (?,?,?,?,?,?)`, - ).run( - attempt.id, - profile.id, - "devin", - profile.processGeneration, - profile.processGeneration + 1, - 2_001, - ); - expect(() => inspector.query( - `UPDATE session_mutation_authority_rebinds_v39 - SET recorded_at=recorded_at+1 WHERE attempt_id=? AND provider='devin'`, - ).run(attempt.id)).toThrow("immutable"); + "SELECT name FROM sqlite_master WHERE type='trigger' AND name='work_attempt_route_guard'", + ).get()).toBeNull(); } finally { inspector.close(false); } }); - test("rejects a v39 Devin session persisted under the legacy preset contract", async () => { + test("writable open rejects malformed v39 identity uniqueness and revision guards", async () => { const { store } = await fixture(); - const profile = signInProfile(store, "Invalid Devin contract", "invalid-devin@example.com"); - const session = store.createSession({ - profileId: profile.id, - preset: "high", - fastEnabled: false, + const first = store.createProfile("First repaired Claude identity"); + const second = store.createProfile("Second repaired Claude identity"); + const identity = { + pid: 52_101, + pidDomain: "darwin" as const, + procStart: "repaired-shared-Claude-process", + }; + store.recordClaimedClaudeProcessAuthority({ + providerThreadId: "repaired-Claude-identity-first", + profileId: first.id, + profileGeneration: first.processGeneration, + runtimeScope: "personal", + identity, + }); + const candidate = store.upsertSessionAdoptionCandidate({ + provider: "claude", + providerThreadId: "repaired-Claude-revision-candidate", + title: "Repaired Claude revision candidate", + state: "idle", + providerUpdatedAt: 10, + liveness: "not_live", }); const paths = store.paths; store.close(); stores.splice(stores.indexOf(store), 1); - - const partial = new Database(paths.database, { create: false, strict: true }); + const damaged = new Database(paths.database, { create: false, strict: true }); try { - partial.exec("DROP TRIGGER work_session_devin_contract_guard"); - partial.exec("PRAGMA ignore_check_constraints=ON"); - partial.query( - "UPDATE sessions SET provider_v39='devin',preset_contract=? WHERE id=?", - ).run(legacyPresetContract, session.id); - partial.exec(` - PRAGMA ignore_check_constraints=OFF; - DELETE FROM migrations WHERE version=39; - PRAGMA user_version=38; + damaged.exec(` + DROP INDEX session_claude_process_authorities_live_identity; + CREATE INDEX session_claude_process_authorities_live_identity + ON session_claude_process_authorities(pid_domain,pid,proc_start) + WHERE state!='released'; + DROP TRIGGER session_adoption_candidate_revision_guard; + CREATE TRIGGER session_adoption_candidate_revision_guard + BEFORE UPDATE ON session_adoption_candidates BEGIN SELECT 1; END; `); } finally { - partial.close(false); + damaged.close(false); } expect(() => new StateStore(paths)) - .toThrow("STATE_SCHEMA_V39_DEVIN_PRESET_CONTRACT_INVALID:sessions"); - - const inspector = new Database(paths.database, { readonly: true, strict: true }); + .toThrow("STATE_SCHEMA_V39_OBJECT_INVALID:session_claude_process_authorities_live_identity"); + const inspector = new Database(paths.database, { create: false, strict: true }); try { - expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 38 }); + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); expect(inspector.query( - "SELECT provider_v39,preset_contract FROM sessions WHERE id=?", - ).get(session.id)).toEqual({ - provider_v39: "devin", - preset_contract: legacyPresetContract, + "SELECT sql FROM sqlite_master WHERE name='session_adoption_candidate_revision_guard'", + ).get()).toEqual(expect.objectContaining({ sql: expect.stringContaining("SELECT 1") })); + expect(inspector.query("SELECT id FROM profiles WHERE id=?").get(second.id)) + .toEqual({ id: second.id }); + expect(inspector.query( + `SELECT revision,claim_status FROM session_adoption_candidates + WHERE provider=? AND provider_thread_id=?`, + ).get(candidate.provider, candidate.providerThreadId)).toEqual({ + claim_status: "pending", + revision: candidate.revision, }); - expect(inspector.query("SELECT version FROM migrations WHERE version=39").get()).toBeNull(); } finally { inspector.close(false); } @@ -9314,25 +15853,12 @@ describe("StateStore", () => { stores.splice(stores.indexOf(store), 1); const legacy = new Database(paths.database, { create: false, strict: true }); - for (const trigger of [ - "work_attempt_route_guard", - "work_devin_preset_contract_guard", - "work_profile_attempt_authority_guard", - "work_session_attempt_authority_guard", - "work_session_devin_contract_guard", - "work_signal_member_guard", - "works_identity_immutable", - ] as const) legacy.exec(`DROP TRIGGER IF EXISTS ${trigger}`); - // Keep each ALTER separate: Bun's multi-statement exec can continue past - // an intermediate schema error, which would leave this fixture current. - legacy.exec("ALTER TABLE works DROP COLUMN preset_contract"); - legacy.exec("ALTER TABLE sessions DROP COLUMN provider_v39"); - legacy.exec("ALTER TABLE sessions DROP COLUMN preset_contract"); - expect(legacy.query("PRAGMA table_info(sessions)").all()) - .not.toContainEqual(expect.objectContaining({ name: "preset_contract" })); - expect(legacy.query("PRAGMA table_info(sessions)").all()) - .not.toContainEqual(expect.objectContaining({ name: "provider_v39" })); - legacy.exec("DELETE FROM migrations WHERE version > 25; PRAGMA user_version=25;"); + dropPostProviderSwitchSchema(legacy); + dropProviderSwitchProgressSchema(legacy); + legacy.exec(` + DELETE FROM migrations WHERE version > 25; + PRAGMA user_version=25; + `); legacy.close(false); const migrated = new StateStore(paths, { now: () => 5_000 }); @@ -9349,11 +15875,9 @@ describe("StateStore", () => { .toContainEqual(expect.objectContaining({ name: "preset_contract", notnull: 1 })); expect(inspector.query("SELECT preset_contract FROM sessions WHERE id=?").get(session.id)) .toEqual({ preset_contract: legacyPresetContract }); - expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 39 }); + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); expect(inspector.query("SELECT version FROM migrations WHERE version=38").get()) .toEqual({ version: 38 }); - expect(inspector.query("SELECT version FROM migrations WHERE version=39").get()) - .toEqual({ version: 39 }); } finally { inspector.close(false); } @@ -9441,16 +15965,15 @@ describe("StateStore", () => { `INSERT INTO session_provider_switch_targets(attempt_id,provider_thread_id,recorded_at) VALUES (?,?,?)`, ).run(attempt.id, "retained-provider-thread", 7_350); + dropPostProviderSwitchSchema(mainV35); mainV35.exec(` - DROP TABLE attention_email_policy; - DROP TABLE notification_hours; - DELETE FROM migrations WHERE version IN (36,37,38,39); + DELETE FROM migrations WHERE version>=36; PRAGMA user_version=35; `); mainV35.close(false); expect(() => new StateStore(paths, { readonly: true })) - .toThrow("STATE_SCHEMA_MIGRATION_REQUIRED:35:39"); + .toThrow("STATE_SCHEMA_MIGRATION_REQUIRED:35:40"); const migrated = new StateStore(paths, { now: () => 8_000, resolveMachineTimeZone: () => "UTC", @@ -9464,7 +15987,7 @@ describe("StateStore", () => { }); const inspector = new Database(paths.database, { readonly: true, strict: true }); try { - expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 39 }); + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); expect(inspector.query( "SELECT provider_thread_id,recorded_at FROM session_provider_switch_targets WHERE attempt_id=?", ).get(attempt.id)).toEqual({ @@ -9499,17 +16022,18 @@ describe("StateStore", () => { stores.splice(stores.indexOf(store), 1); const legacy = new Database(paths.database, { create: false, strict: true }); - dropProviderAuthorityObjectsForLegacyFeatureFixture(legacy); + dropSessionAdoptionAndPresetContractSchema(legacy); + dropProviderSwitchVersion35Objects(legacy); legacy.exec(` DROP TABLE attention_email_policy; - DELETE FROM migrations WHERE version IN (36,37,38,39); + DELETE FROM migrations WHERE version>=36; PRAGMA user_version=35; `); expect(providerSwitchSchemaObjectCount(legacy)).toBe(0); legacy.close(false); expect(() => new StateStore(paths, { readonly: true })) - .toThrow("STATE_SCHEMA_MIGRATION_REQUIRED:35:39"); + .toThrow("STATE_SCHEMA_MIGRATION_REQUIRED:35:40"); const migrated = new StateStore(paths, { now: () => 9_000, resolveMachineTimeZone: () => { @@ -9531,7 +16055,7 @@ describe("StateStore", () => { const inspector = new Database(paths.database, { readonly: true, strict: true }); try { expect(providerSwitchSchemaObjectCount(inspector)).toBe(21); - expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 39 }); + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); } finally { inspector.close(false); } @@ -9548,14 +16072,15 @@ describe("StateStore", () => { stores.splice(stores.indexOf(store), 1); const legacy = new Database(paths.database, { create: false, strict: true }); + dropSessionAdoptionAndPresetContractSchema(legacy); legacy.exec(` - DELETE FROM migrations WHERE version IN (37,38,39); + DELETE FROM migrations WHERE version>=37; PRAGMA user_version=36; `); legacy.close(false); expect(() => new StateStore(paths, { readonly: true })) - .toThrow("STATE_SCHEMA_MIGRATION_REQUIRED:36:39"); + .toThrow("STATE_SCHEMA_MIGRATION_REQUIRED:36:40"); expect(() => new StateStore(paths)) .toThrow("ATTENTION_EMAIL_POLICY_MIGRATION_OPT_IN_REFUSED"); @@ -9591,8 +16116,9 @@ describe("StateStore", () => { stores.splice(stores.indexOf(store), 1); const legacy = new Database(paths.database, { create: false, strict: true }); - dropProviderAuthorityObjectsForLegacyFeatureFixture(legacy); - legacy.exec("DELETE FROM migrations WHERE version IN (37,38,39); PRAGMA user_version=36;"); + dropSessionAdoptionAndPresetContractSchema(legacy); + dropProviderSwitchVersion35Objects(legacy); + legacy.exec("DELETE FROM migrations WHERE version>=37; PRAGMA user_version=36;"); const before = legacy.query( `SELECT h.start_minute,h.end_minute,h.time_zone,h.revision AS hours_revision, e.enabled,e.revision AS email_revision,e.created_at,e.updated_at @@ -9602,7 +16128,7 @@ describe("StateStore", () => { legacy.close(false); expect(() => new StateStore(paths, { readonly: true })) - .toThrow("STATE_SCHEMA_MIGRATION_REQUIRED:36:39"); + .toThrow("STATE_SCHEMA_MIGRATION_REQUIRED:36:40"); const migrated = new StateStore(paths, { now: () => 12_000, resolveMachineTimeZone: () => { @@ -9630,7 +16156,7 @@ describe("StateStore", () => { FROM notification_hours h JOIN attention_email_policy e ON h.singleton=e.singleton`, ).get()).toEqual(before); expect(providerSwitchSchemaObjectCount(inspector)).toBe(21); - expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 39 }); + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); } finally { inspector.close(false); } @@ -9647,10 +16173,11 @@ describe("StateStore", () => { stores.splice(stores.indexOf(store), 1); const lookalike = new Database(paths.database, { create: false, strict: true }); - dropProviderAuthorityObjectsForLegacyFeatureFixture(lookalike); + dropSessionAdoptionAndPresetContractSchema(lookalike); + dropProviderSwitchVersion35Objects(lookalike); lookalike.exec(` CREATE INDEX attention_email_policy_untrusted ON attention_email_policy(enabled); - DELETE FROM migrations WHERE version IN (37,38,39); + DELETE FROM migrations WHERE version>=37; PRAGMA user_version=36; `); lookalike.close(false); @@ -9759,17 +16286,16 @@ describe("StateStore", () => { stores.splice(stores.indexOf(store), 1); const legacy = new Database(paths.database, { create: false, strict: true }); - dropProviderAuthorityObjectsForLegacyFeatureFixture(legacy); + dropPostProviderSwitchSchema(legacy); + dropProviderSwitchVersion35Objects(legacy); legacy.exec(` - DROP TABLE attention_email_policy; - DROP TABLE notification_hours; - DELETE FROM migrations WHERE version BETWEEN 35 AND 39; + DELETE FROM migrations WHERE version>=35; PRAGMA user_version=34; `); legacy.close(false); expect(() => new StateStore(paths, { readonly: true })) - .toThrow("STATE_SCHEMA_MIGRATION_REQUIRED:34:39"); + .toThrow("STATE_SCHEMA_MIGRATION_REQUIRED:34:40"); const unchanged = new Database(paths.database, { readonly: true, strict: true }); try { expect(unchanged.query("PRAGMA user_version").get()).toEqual({ user_version: 34 }); @@ -9827,7 +16353,7 @@ describe("StateStore", () => { const schemaInspector = new Database(paths.database, { readonly: true, strict: true }); try { expect(providerSwitchSchemaObjectCount(schemaInspector)).toBe(21); - expect(schemaInspector.query("PRAGMA user_version").get()).toEqual({ user_version: 39 }); + expect(schemaInspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); } finally { schemaInspector.close(false); } @@ -9839,11 +16365,10 @@ describe("StateStore", () => { store.close(); stores.splice(stores.indexOf(store), 1); const legacy = new Database(paths.database, { create: false, strict: true }); - dropProviderAuthorityObjectsForLegacyFeatureFixture(legacy); + dropPostProviderSwitchSchema(legacy); + dropProviderSwitchVersion35Objects(legacy); legacy.exec(` - DROP TABLE attention_email_policy; - DROP TABLE notification_hours; - DELETE FROM migrations WHERE version BETWEEN 35 AND 39; + DELETE FROM migrations WHERE version>=35; PRAGMA user_version=34; `); legacy.close(false); @@ -10136,7 +16661,27 @@ describe("StateStore", () => { // SQLite accepts trigger definitions that reference a missing column. // The next schema rewrite must not be the first operation to discover it. legacy.exec(WORK_SCHEMA_SQL); + // Remove only the unshipped adoption surface. This upstream v30 probe + // intentionally pre-applied later additive provider/preset objects before + // lowering its version stamp, matching the provider-v39 predecessor. + dropSessionAdoptionSchema(legacy); + legacy.exec("ALTER TABLE profiles DROP COLUMN codex_account_key"); legacy.exec("DELETE FROM migrations WHERE version>30; PRAGMA user_version=30;"); + expect(legacy.query( + "SELECT name FROM pragma_table_info('profiles') WHERE name='codex_account_key'", + ).get()).toBeNull(); + expect(legacy.query( + `SELECT type,name,tbl_name FROM sqlite_master + WHERE type IN ('table','index','trigger') AND sql IS NOT NULL AND ( + name LIKE 'session_adoption_%' + OR name LIKE 'session_personal_runtime_%' + OR name LIKE 'session_claude_process_%' + OR name LIKE 'provider_runtime_account_%' + OR name LIKE 'profile_personal_authority_%' + OR lower(sql) LIKE '%codex_account_key%' + OR lower(sql) LIKE '%session_account_authorit%' + ) ORDER BY type,name`, + ).all()).toEqual([]); expect(legacy.query("PRAGMA user_version").get()).toEqual({ user_version: 30 }); expect(legacy.query("PRAGMA table_info(sessions)").all()) .not.toContainEqual(expect.objectContaining({ name: "provider" })); @@ -10150,13 +16695,13 @@ describe("StateStore", () => { stores.push(migrated); const inspector = new Database(paths.database, { readonly: true, strict: true }); try { - expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 39 }); + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); expect(inspector.query("PRAGMA table_info(sessions)").all()) .toContainEqual(expect.objectContaining({ name: "provider", dflt_value: "'codex'" })); expect(inspector.query("PRAGMA table_info(autorespond_evidence)").all()) .toContainEqual(expect.objectContaining({ name: "path" })); expect(inspector.query( - "SELECT version FROM migrations WHERE version BETWEEN 30 AND 39 ORDER BY version", + "SELECT version FROM migrations WHERE version BETWEEN 30 AND 40 ORDER BY version", ).all()).toEqual([ { version: 30 }, { version: 31 }, @@ -10168,6 +16713,7 @@ describe("StateStore", () => { { version: 37 }, { version: 38 }, { version: 39 }, + { version: 40 }, ]); } finally { inspector.close(false); @@ -10375,11 +16921,12 @@ describe("StateStore", () => { stores.splice(stores.indexOf(store), 1); const legacy = new Database(paths.database, { create: false, strict: true }); + dropSchemaAfterVersion34(legacy); legacy.exec(` DROP INDEX profiles_label_key_active; DROP TRIGGER profiles_label_key_insert_guard; DROP TRIGGER profiles_label_key_immutable; - DELETE FROM migrations WHERE version=24; + DELETE FROM migrations WHERE version>23; PRAGMA user_version=23; `); legacy.query( @@ -10415,11 +16962,12 @@ describe("StateStore", () => { stores.splice(stores.indexOf(store), 1); const legacy = new Database(paths.database, { create: false, strict: true }); + dropSchemaAfterVersion34(legacy); legacy.exec(` DROP INDEX projects_label_key_unique; DROP TRIGGER projects_label_key_insert_guard; DROP TRIGGER projects_label_key_immutable; - DELETE FROM migrations WHERE version=24; + DELETE FROM migrations WHERE version>23; PRAGMA user_version=23; `); legacy.query( @@ -10449,10 +16997,11 @@ describe("StateStore", () => { store.close(); stores.splice(stores.indexOf(store), 1); const legacy = new Database(paths.database, { create: false, strict: true }); + dropSchemaAfterVersion34(legacy); legacy.exec(` DROP TRIGGER IF EXISTS provider_interactions_response_fields_guard; DROP TRIGGER IF EXISTS provider_interactions_revision_guard; - DELETE FROM migrations WHERE version=19; + DELETE FROM migrations WHERE version>18; PRAGMA user_version=18; `); legacy.close(false); @@ -10461,7 +17010,7 @@ describe("StateStore", () => { stores.push(migrated); const inspector = new Database(paths.database, { readonly: true, strict: true }); try { - expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 39 }); + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); expect(inspector.query( `SELECT name FROM sqlite_master WHERE type='trigger' AND name IN ( @@ -10482,7 +17031,12 @@ describe("StateStore", () => { test("migrates legacy approval scope booleans into exact ordered decisions", async () => { const { store } = await fixture(); const profile = signInProfile(store, "Legacy approvals", "legacy-approvals@example.com"); - const session = store.createSession({ profileId: profile.id, preset: "high", fastEnabled: false }); + const session = createProvenTestSession(store, { + profileId: profile.id, + preset: "high", + fastEnabled: false, + providerThreadId: "thread-legacy-approvals", + }); const connectionId = "50000000-0000-4000-8000-000000000001"; const admit = (input: { publicId: string; @@ -10538,6 +17092,7 @@ describe("StateStore", () => { stores.splice(stores.indexOf(store), 1); const legacy = new Database(paths.database, { create: false, strict: true }); + dropSchemaAfterVersion34(legacy); legacy.exec("DROP TRIGGER IF EXISTS provider_interactions_authority_immutable"); legacy.query("UPDATE provider_interactions SET display_json=? WHERE public_id=?").run(JSON.stringify({ kind: "command_approval", @@ -10554,7 +17109,7 @@ describe("StateStore", () => { grantRoot: null, allowsSessionApproval: false, }), files.publicId); - legacy.exec("DELETE FROM migrations WHERE version=18; PRAGMA user_version=17"); + legacy.exec("DELETE FROM migrations WHERE version>17; PRAGMA user_version=17"); legacy.close(false); const migrated = new StateStore(paths, { now: () => 9_000 }); @@ -10569,7 +17124,7 @@ describe("StateStore", () => { }); const inspector = new Database(paths.database, { readonly: true, strict: true }); try { - expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 39 }); + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); expect(JSON.stringify(inspector.query( "SELECT display_json FROM provider_interactions ORDER BY public_id", ).all())).not.toContain("allowsSessionApproval"); @@ -10589,6 +17144,11 @@ describe("StateStore", () => { request: { deviceCode: false }, idempotencyKey, }); + completeCodexAccountMutationAuthorityRetirement( + store, + profile.id, + profile.processGeneration, + ); store.beginAccountMutationEffect({ attemptId: attempt.id, profileId: profile.id, @@ -10601,6 +17161,7 @@ describe("StateStore", () => { stores.splice(stores.indexOf(store), 1); const legacy = new Database(paths.database, { create: false, strict: true }); + dropSchemaAfterVersion34(legacy); legacy.exec(` DROP TRIGGER IF EXISTS provider_login_authority_identity_immutable; DROP TRIGGER IF EXISTS provider_login_authority_generation_guard; @@ -10639,11 +17200,12 @@ describe("StateStore", () => { test("redacts and terminalizes secret-bearing MCP URL interactions when upgrading v10", async () => { const { store } = await fixture(); const profile = signInProfile(store, "Legacy URL profile", "legacy-url@example.com"); - const session = store.createSession({ + const session = createProvenTestSession(store, { profileId: profile.id, title: "Legacy URL session", preset: "high", fastEnabled: false, + providerThreadId: "legacy-thread", }); const paths = store.paths; store.close(); @@ -10684,7 +17246,7 @@ describe("StateStore", () => { const inspector = new Database(paths.database, { readonly: true, strict: true }); try { - expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 39 }); + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); expect(inspector.query( "SELECT revision,state FROM provider_interaction_transitions WHERE public_id=? ORDER BY revision", ).all(interactionId)).toEqual([ @@ -10703,11 +17265,12 @@ describe("StateStore", () => { test("redacts and physically scrubs permission values when upgrading v14", async () => { const { store } = await fixture(); const profile = signInProfile(store, "Legacy permission profile", "legacy-permission@example.com"); - const session = store.createSession({ + const session = createProvenTestSession(store, { profileId: profile.id, title: "Legacy permission session", preset: "high", fastEnabled: false, + providerThreadId: "legacy-permission-thread", }); const paths = store.paths; store.close(); @@ -10728,8 +17291,8 @@ describe("StateStore", () => { const migrated = new StateStore(paths, { now: () => 9_000 }); stores.push(migrated); expect(migrated.requireInteraction(interactionId)).toMatchObject({ - state: "pending", - revision: 1, + state: "expired", + revision: 2, responseDigest: null, display: { kind: "permission_approval", @@ -10741,10 +17304,13 @@ describe("StateStore", () => { const inspector = new Database(paths.database, { readonly: true, strict: true }); try { - expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 39 }); + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); expect(inspector.query( "SELECT revision,state FROM provider_interaction_transitions WHERE public_id=? ORDER BY revision", - ).all(interactionId)).toEqual([{ revision: 1, state: "pending" }]); + ).all(interactionId)).toEqual([ + { revision: 1, state: "pending" }, + { revision: 2, state: "expired" }, + ]); expect(JSON.stringify(inspector.query( "SELECT display_json FROM provider_interactions WHERE public_id=?", ).get(interactionId))).not.toContain(sentinel); @@ -10761,11 +17327,12 @@ describe("StateStore", () => { await initializeStatePaths(paths); const store = new StateStore(paths, { now: () => 5_000 }); const profile = signInProfile(store, `Physical v${targetVersion}`, `physical-v${targetVersion}@example.com`); - const session = store.createSession({ + const session = createProvenTestSession(store, { profileId: profile.id, title: `Physical v${targetVersion} session`, preset: "high", fastEnabled: false, + providerThreadId: `legacy-physical-thread-${targetVersion}`, }); const queue = ["first", "deleted", "third", "fourth"].map((message) => store.enqueue(session.id, message)); store.close(); @@ -10820,16 +17387,14 @@ describe("StateStore", () => { queue[2]!.id, queue[3]!.id, ]); - expect(migrated.listRecoverableQueue().map((entry) => entry.id)).toEqual([ - queue[0]!.id, - queue[2]!.id, - queue[3]!.id, - ]); + expect(migrated.listQueue(session.id).map((entry) => entry.state)) + .toEqual(["cancelled", "cancelled", "cancelled"]); + expect(migrated.listRecoverableQueue()).toEqual([]); migrated.close(); const inspector = new Database(paths.database, { readonly: true, strict: true }); try { - expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 39 }); + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); expect(inspector.query( "SELECT enqueue_sequence FROM queue_entries ORDER BY enqueue_sequence", ).all()).toEqual([ @@ -10856,11 +17421,12 @@ describe("StateStore", () => { test("keeps a busy-reader MCP scrub unavailable until WAL truncation can finish", async () => { const { store } = await fixture(); const profile = signInProfile(store, "Pinned legacy URL", "pinned-legacy-url@example.com"); - const session = store.createSession({ + const session = createProvenTestSession(store, { profileId: profile.id, title: "Pinned legacy URL session", preset: "high", fastEnabled: false, + providerThreadId: "legacy-thread", }); const paths = store.paths; store.close(); @@ -10935,7 +17501,7 @@ describe("StateStore", () => { const inspector = new Database(paths.database, { readonly: true, strict: true }); try { - expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 39 }); + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); expect(inspector.query( "SELECT reason,required_at FROM security_scrub_authority WHERE singleton=1", ).get()).toEqual({ reason: "mcp_url_redaction", required_at: 9_000 }); @@ -11027,7 +17593,7 @@ describe("StateStore", () => { expect(reopened.listAutorespondEvidence({ sessionId: session.id })).toEqual([expectedEvidence]); const inspector = new Database(paths.database, { readonly: true, strict: true }); try { - expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 39 }); + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); expect(inspector.query("SELECT id,path,rule,model FROM autorespond_evidence").get()).toEqual({ id: 7, path: "protocol", @@ -11131,12 +17697,13 @@ describe("StateStore", () => { const preserved = store.requireSession("sess_00000000000000000000000000000000"); expect(preserved).toMatchObject({ title: "Preserved", - revision: 1, + revision: 2, + state: "recovery_required", }); expect("providerUpdatedAt" in preserved).toBe(false); const inspector = new Database(paths.database, { readonly: true, strict: true }); try { - expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 39 }); + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); expect(inspector.query("SELECT version, applied_at FROM migrations ORDER BY version").all()).toEqual([ { version: 1, applied_at: 1000 }, { version: 2, applied_at: 2000 }, @@ -11177,6 +17744,7 @@ describe("StateStore", () => { { version: 37, applied_at: 2000 }, { version: 38, applied_at: 2000 }, { version: 39, applied_at: 2000 }, + { version: 40, applied_at: 2000 }, ]); expect(inspector.query("PRAGMA table_info(sessions)").all()).toContainEqual(expect.objectContaining({ name: "provider_updated_at" })); expect(inspector.query("SELECT label,label_key FROM profiles").get()).toEqual({ @@ -11202,11 +17770,12 @@ describe("StateStore", () => { stores.splice(stores.indexOf(store), 1); const legacy = new Database(paths.database, { create: false, strict: true }); + dropSchemaAfterVersion34(legacy); legacy.exec(` DROP TRIGGER IF EXISTS queue_transition_guard; DROP TRIGGER IF EXISTS desktop_switch_transition_guard; DROP TABLE IF EXISTS desktop_switch_authority; - DELETE FROM migrations WHERE version=3; + DELETE FROM migrations WHERE version>2; PRAGMA user_version=2; `); legacy.close(false); @@ -11223,7 +17792,7 @@ describe("StateStore", () => { }); const inspector = new Database(paths.database, { readonly: true, strict: true }); try { - expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 39 }); + expect(inspector.query("PRAGMA user_version").get()).toEqual({ user_version: 40 }); expect(inspector.query("SELECT applied_at FROM migrations WHERE version=3").get()).toEqual({ applied_at: 9_000, }); @@ -11243,9 +17812,9 @@ describe("StateStore", () => { const paths = resolveStatePaths({ homeDirectory: home, platform: "darwin" }); await initializeStatePaths(paths); const newer = new Database(paths.database, { create: true, strict: true }); - newer.exec("PRAGMA user_version = 40"); + newer.exec("PRAGMA user_version = 41"); newer.close(false); await chmod(paths.database, 0o600); - expect(() => new StateStore(paths)).toThrow("STATE_SCHEMA_NEWER:40:39"); + expect(() => new StateStore(paths)).toThrow("STATE_SCHEMA_NEWER:41:40"); }); }); diff --git a/src/storage/state-store.ts b/src/storage/state-store.ts index 78e1727..52050b4 100644 --- a/src/storage/state-store.ts +++ b/src/storage/state-store.ts @@ -9,12 +9,15 @@ import { realpathSync, type Stats, } from "node:fs"; -import { resolve } from "node:path"; +import { isAbsolute, resolve } from "node:path"; import { Database, constants as sqliteConstants } from "bun:sqlite"; import { z } from "zod"; -import { redactAbsolutePaths } from "../domain/text-safety"; +import { + containsUnsafeTerminalScalar, + redactAbsolutePaths, +} from "../domain/text-safety"; import { attachmentDigestSchema, attachmentMediaTypeSchema, @@ -73,13 +76,16 @@ import { presetSchema, presetTiers, presetTierSchema, + adoptableProviderSchema, providerSchema, + type AdoptableProvider, type Preset, type PresetRequirement, type Provider, } from "../domain/presets"; import { isCodexRuntimeProfile, + isDevinRuntimeProfile, reviewedRuntimeProfileProvider, reviewedRuntimeProfileSchema, type ReviewedRuntimeProfile, @@ -155,6 +161,7 @@ import { resolveUsableCanonicalProjectDirectory } from "./project-directory"; import { WORK_SCHEMA_SQL, WorkStore, + assertProviderVersion39WorkSchema, assertReadonlyWorkSchema, assertWorkSchema, type WorkCapabilityIssuer, @@ -181,6 +188,45 @@ const processLocalPublicProviderIdentifierProjector = const profileStateSchema = z.enum(["signed_out", "login_pending", "signed_in", "recovery_required", "removed"]); const sessionStateSchema = z.enum(["starting", "active", "idle", "terminal", "recovery_required"]); const runtimeProfileSourceKindSchema = z.enum(["session_start", "turn_start", "queue_start"]); +const sessionAdoptionLivenessSchema = z.enum(["live", "not_live", "unknown"]); +const sessionAdoptionCandidateStatusSchema = z.enum(["pending", "claiming", "adopted", "fenced"]); +const sessionPersonalRuntimeBindingStateSchema = z.enum(["active", "detaching", "detached"]); +const claudeProcessAuthorityStateSchema = z.enum(["claimed", "bound", "releasing", "released"]); +const claudeProcessRuntimeScopeSchema = z.enum(["managed", "personal"]); +const providerAccountAuthorityKeySchema = z.string() + .regex(/^v1:(?:codex|claude):[a-f0-9]{64}$/u); +const providerAccountAuthorityKeyMatchesProvider = ( + provider: AdoptableProvider, + accountKey: string, +): boolean => accountKey.startsWith(`v1:${provider}:`); +const codexProviderAccountAuthorityKeyForEmail = (email: string): string => + `v1:codex:${createHash("sha256").update(email.trim().toLowerCase()).digest("hex")}`; +const profileStateAllowsProviderSessionAuthority = ( + provider: Provider, + state: ProfileRecord["state"], +): boolean => provider === "claude" || provider === "devin" + ? state === "signed_in" || state === "signed_out" + : state === "signed_in"; +const sessionAdoptionProviderThreadIdSchema = z.string().min(1).max(200); +const sessionAdoptionProviderProjectRootSchema = z.string().min(1).refine( + // Personal provider homes are supported on Darwin/Linux; keep the runtime + // boundary aligned with the SQLite leading-slash invariant. + (value) => value.startsWith("/") + && isAbsolute(value) + && utf8Bytes(value) <= 8 * 1_024 + && !containsUnsafeTerminalScalar(value), + "Provider project root is invalid.", +); +const claudeProcessIdentitySchema = z.object({ + pid: z.number().int().positive().safe(), + pidDomain: z.enum(["darwin", "linux"]), + procStart: z.string().min(1).max(128).regex(/^[\x20-\x7e]+$/u), +}).strict(); +const claudeProcessAuthorityKeySchema = z.object({ + providerThreadId: sessionAdoptionProviderThreadIdSchema, + profileId: profileIdSchema, + runtimeScope: claudeProcessRuntimeScopeSchema, +}).strict(); const providerAuthenticationSchema = z.object({ profileId: profileIdSchema, processGeneration: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), @@ -228,6 +274,7 @@ const profileRowSchema = z.object({ state: profileStateSchema, process_generation: z.number().int().nonnegative(), provider_email: z.string().nullable(), + codex_account_key: providerAccountAuthorityKeySchema.nullable(), provider_plan: z.string().nullable(), created_at: unixMillisecondsSchema, updated_at: unixMillisecondsSchema, @@ -268,6 +315,111 @@ const sessionRowSchema = z.object({ updated_at: unixMillisecondsSchema, }).strict(); +const sessionProviderAccountAuthorityRowSchema = z.object({ + session_id: sessionIdSchema, + provider: adoptableProviderSchema, + runtime_scope: claudeProcessRuntimeScopeSchema, + account_key: providerAccountAuthorityKeySchema, + recorded_at: unixMillisecondsSchema, +}).strict(); + +const sessionAdoptionPolicyRowSchema = z.object({ + provider: adoptableProviderSchema, + profile_id: profileIdSchema.nullable(), + state: z.enum(["enabled", "disabled"]), + revision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + created_at: unixMillisecondsSchema, + updated_at: unixMillisecondsSchema, +}).strict(); + +const sessionAdoptionCandidateRowSchema = z.object({ + provider: adoptableProviderSchema, + provider_thread_id: sessionAdoptionProviderThreadIdSchema, + project_id: projectIdSchema.nullable(), + provider_project_root: sessionAdoptionProviderProjectRootSchema.nullable(), + title: titleSchema, + provider_state: z.enum(["active", "idle", "terminal"]), + active_turn_id: z.string().min(1).max(2_048).nullable(), + provider_updated_at: z.number().finite().nonnegative().nullable(), + liveness: sessionAdoptionLivenessSchema, + source_pid: z.number().int().positive().safe().nullable(), + source_pid_domain: z.enum(["darwin", "linux"]).nullable(), + source_proc_start: z.string().min(1).max(128).regex(/^[\x20-\x7e]+$/u).nullable(), + claim_status: sessionAdoptionCandidateStatusSchema, + candidate_fingerprint: z.string().regex(/^[a-f0-9]{64}$/u), + fenced_fingerprint: z.string().regex(/^[a-f0-9]{64}$/u).nullable(), + revision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + first_discovered_at: unixMillisecondsSchema, + last_observed_at: unixMillisecondsSchema, + last_changed_at: unixMillisecondsSchema, + last_attempt_at: unixMillisecondsSchema.nullable(), + last_live_observed_at: unixMillisecondsSchema.nullable(), +}).strict(); + +const sessionPersonalRuntimeBindingRowSchema = z.object({ + session_id: sessionIdSchema, + provider: adoptableProviderSchema, + provider_thread_id: sessionAdoptionProviderThreadIdSchema, + state: sessionPersonalRuntimeBindingStateSchema, + revision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + adopted_at: unixMillisecondsSchema, + updated_at: unixMillisecondsSchema, + detached_at: unixMillisecondsSchema.nullable(), +}).strict(); + +const sessionClaudeProcessAuthorityRowSchema = z.object({ + provider_thread_id: sessionAdoptionProviderThreadIdSchema, + profile_id: profileIdSchema, + profile_generation: z.number().int().nonnegative().safe(), + runtime_scope: claudeProcessRuntimeScopeSchema, + session_id: sessionIdSchema.nullable(), + pid: z.number().int().positive().safe(), + pid_domain: z.enum(["darwin", "linux"]), + proc_start: z.string().min(1).max(128).regex(/^[\x20-\x7e]+$/u), + state: claudeProcessAuthorityStateSchema, + revision: z.number().int().positive().safe(), + recorded_at: unixMillisecondsSchema, + released_at: unixMillisecondsSchema.nullable(), +}).strict(); + +const sessionClaudeProcessLaunchIntentRowSchema = z.object({ + intent_id: z.string().uuid(), + provider_thread_id: sessionAdoptionProviderThreadIdSchema, + profile_id: profileIdSchema, + profile_generation: z.number().int().nonnegative().safe(), + runtime_scope: claudeProcessRuntimeScopeSchema, + provider_account_key: providerAccountAuthorityKeySchema.refine( + (value) => providerAccountAuthorityKeyMatchesProvider("claude", value), + ).nullable(), + session_id: sessionIdSchema.nullable(), + revision: z.number().int().positive().safe(), + staged_at: unixMillisecondsSchema, + updated_at: unixMillisecondsSchema, +}).strict(); + +const profilePersonalAuthorityRevocationRowSchema = z.object({ + profile_id: profileIdSchema, + profile_generation: z.number().int().nonnegative().safe(), + state: z.enum(["releasing", "completed"]), + revision: z.number().int().positive().safe(), + created_at: unixMillisecondsSchema, + updated_at: unixMillisecondsSchema, + completed_at: unixMillisecondsSchema.nullable(), +}).strict(); + +const providerRuntimeAccountRevocationRowSchema = z.object({ + profile_id: profileIdSchema, + profile_generation: z.number().int().nonnegative().safe(), + provider: adoptableProviderSchema, + runtime_scope: claudeProcessRuntimeScopeSchema, + current_account_key: providerAccountAuthorityKeySchema.nullable(), + state: z.enum(["releasing", "completed"]), + revision: z.number().int().positive().safe(), + created_at: unixMillisecondsSchema, + updated_at: unixMillisecondsSchema, + completed_at: unixMillisecondsSchema.nullable(), +}).strict(); + const sessionRuntimeProfileRowSchema = z.object({ session_id: sessionIdSchema, revision: z.number().int().positive(), @@ -446,6 +598,126 @@ export type SessionRecord = { updatedAt: number; }; +/** Private local policy assigning one personal provider home to one HRA account. */ +export type SessionAdoptionPolicyRecord = Readonly<{ + provider: AdoptableProvider; + enabled: boolean; + profileId: ProfileId | null; + revision: number; + createdAt: number; + updatedAt: number; +}>; + +/** Private immutable provider-account identity captured when a runtime gains custody. */ +export type SessionProviderAccountAuthorityRecord = Readonly<{ + sessionId: SessionId; + provider: AdoptableProvider; + runtimeScope: z.infer; + accountKey: string; + recordedAt: number; +}>; + +/** A bounded, provider-derived candidate. This is never part of SessionRecord. */ +export type SessionAdoptionCandidateRecord = Readonly<{ + provider: AdoptableProvider; + providerThreadId: string; + projectId: ProjectId | null; + /** Private local provider observation; never part of SessionRecord or sync. */ + providerProjectRoot: string | null; + title: string; + providerState: "active" | "idle" | "terminal"; + activeTurnId: string | null; + providerUpdatedAt: number | null; + liveness: z.infer; + sourceProcessIdentity: ClaudeProcessIdentity | null; + status: z.infer; + revision: number; + firstDiscoveredAt: number; + lastObservedAt: number; + lastChangedAt: number; + lastAttemptAt: number | null; + lastLiveObservedAt: number | null; +}>; + +/** Private runtime routing for a session adopted from the personal provider home. */ +export type SessionPersonalRuntimeBindingRecord = Readonly<{ + sessionId: SessionId; + provider: AdoptableProvider; + providerThreadId: string; + state: z.infer; + revision: number; + adoptedAt: number; + updatedAt: number; + detachedAt: number | null; +}>; + +export type SessionAdoptionResult = Readonly<{ + session: SessionRecord; + candidate: SessionAdoptionCandidateRecord; + binding: SessionPersonalRuntimeBindingRecord; +}>; + +export type SessionAdoptionCounts = Readonly<{ + pending: number; + adopted: number; + fenced: number; +}>; + +export type ClaudeProcessIdentity = z.infer; +export type ClaudeProcessAuthorityKey = z.infer; + +export type ClaudeProcessAuthorityRecord = Readonly<{ + providerThreadId: string; + profileId: ProfileId; + profileGeneration: number; + runtimeScope: z.infer; + sessionId: SessionId | null; + identity: ClaudeProcessIdentity; + state: z.infer; + revision: number; + recordedAt: number; + releasedAt: number | null; +}>; + +/** Durable authority proving one exact Claude process launch may begin. */ +export type ClaudeProcessLaunchIntentRecord = Readonly<{ + intentId: string; + providerThreadId: string; + profileId: ProfileId; + profileGeneration: number; + runtimeScope: z.infer; + /** Null only for a pre-release v36 launch fence recovered fail-closed. */ + providerAccountKey: string | null; + sessionId: SessionId | null; + revision: number; + stagedAt: number; + updatedAt: number; +}>; + +export type ProfilePersonalAuthorityRevocationRecord = Readonly<{ + profileId: ProfileId; + profileGeneration: number; + state: "releasing" | "completed"; + revision: number; + createdAt: number; + updatedAt: number; + completedAt: number | null; +}>; + +/** Durable release job for one concrete provider runtime/account scope. */ +export type ProviderRuntimeAccountRevocationRecord = Readonly<{ + profileId: ProfileId; + profileGeneration: number; + provider: AdoptableProvider; + runtimeScope: z.infer; + currentAccountKey: string | null; + state: "releasing" | "completed"; + revision: number; + createdAt: number; + updatedAt: number; + completedAt: number | null; +}>; + export type SessionRuntimeProfileRecord = { sessionId: SessionId; revision: number; @@ -717,7 +989,7 @@ export type MutationEffectEvidence = | { kind: "session.stop"; providerThreadId: string; baseline: SessionProviderBaseline; activeTurnId: string | null } | { kind: "session.rename"; providerThreadId: string; baseline: SessionProviderBaseline; requestedName: string } | { kind: "session.start"; projectId: ProjectId; clientMessageId: string | null; messageDigest: string | null; runtimeProfile?: ReviewedRuntimeProfile; conversationAutomationCapability?: typeof SESSION_CONVERSATION_AUTOMATION_CAPABILITY } - | { kind: "session.switch"; daemonGeneration?: number | undefined; requestedAccountId: ProfileId | null; requestedPreset: Preset | null; sourceProfileId: ProfileId; sourceProcessGeneration: number; sourceProvider: Provider; sourceProviderThreadId: string; sourcePreset: Preset; targetProfileId: ProfileId; targetProcessGeneration: number; targetProvider: Provider; targetPreset: Preset; transcriptDigest: string; seedDigest: string; seedIncludedRecords: number; seedOmittedRecords: number; runtimeProfile: ReviewedRuntimeProfile } + | { kind: "session.switch"; daemonGeneration?: number | undefined; requestedAccountId: ProfileId | null; requestedPreset: Preset | null; sourceProfileId: ProfileId; sourceProcessGeneration: number; sourceProvider: Provider; sourceProviderThreadId: string; sourcePreset: Preset; targetProfileId: ProfileId; targetProcessGeneration: number; targetProvider: Provider; targetProviderAccountKey?: string | undefined; targetPreset: Preset; transcriptDigest: string; seedDigest: string; seedIncludedRecords: number; seedOmittedRecords: number; runtimeProfile: ReviewedRuntimeProfile } | { kind: "account.login"; method: "browser" | "device_code" } | { kind: "account.claude-login"; provider: "claude"; baselineSignedIn: false } | { kind: "account.devin-login"; provider: "devin"; baselineSignedIn: false } @@ -827,6 +1099,9 @@ const mutationEffectEvidenceSchema = z.discriminatedUnion("kind", [ targetProfileId: profileIdSchema, targetProcessGeneration: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), targetProvider: providerSchema, + // Optional only for parsing an unsettled switch written before the + // provider-account proof became durable. Every new effect requires it. + targetProviderAccountKey: providerAccountAuthorityKeySchema.optional(), targetPreset: presetSchema, transcriptDigest: sha256Schema, seedDigest: sha256Schema, @@ -906,7 +1181,7 @@ type DesktopSwitchPlan = diagnostic: string; }; -const currentSchemaVersion = 39; +const currentSchemaVersion = 40; // A cloud device public id (`isOpaqueIdentifier` in src/cloud/contracts.ts). // The ledger keys on it, so the shape is pinned here rather than accepting an // arbitrary string from the cloud bridge. @@ -2859,6 +3134,134 @@ const applySchemaVersion34Attachments = (database: Database): void => { database.exec(schemaVersion34Attachments); }; +/** + * A session without immutable provider-account provenance cannot remain an + * apparently runnable legacy projection. Quarantine every such nonterminal + * row while the schema migration transaction is held, including all local and + * WorkStore effects that could otherwise dispatch after startup. + */ +const quarantineUnprovenProviderSessions = ( + database: Database, + quarantinedAt: number, +): void => { + const profiles = database.query( + `SELECT DISTINCT s.profile_id,p.process_generation + FROM sessions s + JOIN profiles p ON p.id=s.profile_id + LEFT JOIN session_provider_account_authorities a ON a.session_id=s.id + WHERE s.state!='terminal' AND s.provider_v39 IN ('codex','claude') + AND a.session_id IS NULL + ORDER BY s.profile_id`, + ).all().map((row) => z.object({ + profile_id: profileIdSchema, + process_generation: z.number().int().nonnegative().safe(), + }).strict().parse(row)); + if (profiles.length === 0) return; + + const daemon = z.object({ + generation: z.number().int().nonnegative().safe(), + }).strict().parse(database.query( + "SELECT generation FROM daemon_state WHERE singleton=1", + ).get()); + const workStore = new WorkStore(database, { + daemonGeneration: daemon.generation, + encodeCursor: () => "unused-migration-cursor", + issueCapability: () => `hrac1_${"A".repeat(43)}`, + verifyCapability: () => false, + projectProviderIdentifier: () => `opaque_v2_${"a".repeat(64)}`, + now: () => quarantinedAt, + }); + + for (const profile of profiles) { + let afterSessionId = ""; + for (;;) { + const page = database.query( + `SELECT s.id FROM sessions s + LEFT JOIN session_provider_account_authorities a ON a.session_id=s.id + WHERE s.profile_id=? AND s.state!='terminal' + AND s.provider_v39 IN ('codex','claude') AND a.session_id IS NULL + AND s.id>? + ORDER BY s.id LIMIT 500`, + ).all(profile.profile_id, afterSessionId).map((row) => + z.object({ id: sessionIdSchema }).strict().parse(row).id + ); + if (page.length === 0) break; + workStore.prepareSessionAuthorityChange(page, profile.process_generation); + for (const sessionId of page) { + database.query( + `UPDATE queue_entries + SET state=CASE state WHEN 'pending' THEN 'cancelled' ELSE 'ambiguous' END, + updated_at=MAX(updated_at,?) + WHERE session_id=? AND state IN ('pending','dispatching')`, + ).run(quarantinedAt, sessionId); + database.query( + `UPDATE mutation_attempts + SET state=CASE state WHEN 'prepared' THEN 'cancelled' ELSE 'ambiguous' END, + updated_at=MAX(updated_at,?) + WHERE state IN ('prepared','effect_started') AND ( + authority_id=? OR id IN ( + SELECT attempt_id FROM session_start_attempts WHERE session_id=? + ) + )`, + ).run(quarantinedAt, sessionId, sessionId); + database.query( + `UPDATE session_tasks + SET status='paused',revision=revision+1,next_due_at=NULL, + updated_at=MAX(updated_at+1,?) + WHERE session_id=? AND deleted_at IS NULL AND status='active'`, + ).run(quarantinedAt, sessionId); + const interactions = database.query( + `SELECT public_id,state,revision FROM provider_interactions + WHERE session_id=? + AND state IN ('pending','response_prepared','response_written') + ORDER BY requested_at,public_id`, + ).all(sessionId).map((row) => z.object({ + public_id: z.string().uuid(), + state: z.enum(["pending", "response_prepared", "response_written"]), + revision: z.number().int().positive().safe(), + }).strict().parse(row)); + for (const interaction of interactions) { + const terminalState = interaction.state === "pending" + ? "expired" + : "resolution_unknown"; + const changed = database.query( + `UPDATE provider_interactions + SET state=?,revision=revision+1,updated_at=MAX(updated_at,?), + terminal_at=MAX(requested_at,?) + WHERE public_id=? AND revision=? AND state=?`, + ).run( + terminalState, + quarantinedAt, + quarantinedAt, + interaction.public_id, + interaction.revision, + interaction.state, + ); + if (changed.changes !== 1) { + throw new Error("LEGACY_SESSION_INTERACTION_QUARANTINE_CONFLICT"); + } + database.query( + `INSERT INTO provider_interaction_transitions( + public_id,revision,state,response_digest,recorded_at + ) SELECT public_id,revision,state,response_digest,? + FROM provider_interactions WHERE public_id=?`, + ).run(quarantinedAt, interaction.public_id); + } + database.query( + `UPDATE sessions + SET state='recovery_required',active_turn_id=NULL, + revision=revision+1,updated_at=MAX(updated_at,?) + WHERE id=? AND state!='terminal' + AND (state!='recovery_required' OR active_turn_id IS NOT NULL)`, + ).run(quarantinedAt, sessionId); + } + const last = page.at(-1); + if (last === undefined) break; + afterSessionId = last; + } + } +}; + const applySchemaVersion35ProviderSwitchProgress = (database: Database): void => { database.exec(schemaVersion35ProviderSwitchProgress); }; @@ -2930,6 +3333,75 @@ BEFORE DELETE ON session_mutation_authority_rebinds_v39 BEGIN SELECT RAISE(ABORT, 'session mutation authority rebind v39 is immutable'); END; `; +const schemaVersion39ProviderAuthorityObjects = (() => { + const expected = new Database(":memory:"); + try { + expected.exec(schemaVersion39ProviderAuthority); + return expected.query( + `SELECT name,sql,tbl_name,type FROM sqlite_master + WHERE name IN ( + 'session_mutation_authority_rebinds_v39', + 'session_mutation_authority_rebinds_v39_immutable_update', + 'session_mutation_authority_rebinds_v39_immutable_delete' + ) + ORDER BY type,name`, + ).all().map((row) => z.object({ + name: z.string().min(1), + sql: z.string().min(1), + tbl_name: z.literal("session_mutation_authority_rebinds_v39"), + type: z.enum(["table", "trigger"]), + }).strict().parse(row)); + } finally { + expected.close(false); + } +})(); + +const splitSqlTableDefinitions = (sql: string): readonly string[] => { + const start = sql.indexOf("("); + if (start < 0) return []; + const definitions: string[] = []; + let definitionStart = start + 1; + let depth = 0; + let quote: "'" | '"' | "`" | "]" | null = null; + for (let index = start + 1; index < sql.length; index += 1) { + const character = sql[index]; + if (quote !== null) { + if (quote === "]") { + if (character === "]") quote = null; + } else if (character === quote) { + if (sql[index + 1] === quote) index += 1; + else quote = null; + } + continue; + } + if (character === "'" || character === '"' || character === "`") { + quote = character; + continue; + } + if (character === "[") { + quote = "]"; + continue; + } + if (character === "(") { + depth += 1; + continue; + } + if (character === ")") { + if (depth === 0) { + definitions.push(sql.slice(definitionStart, index)); + return definitions; + } + depth -= 1; + continue; + } + if (character === "," && depth === 0) { + definitions.push(sql.slice(definitionStart, index)); + definitionStart = index + 1; + } + } + return []; +}; + const schemaVersion39SessionProviderColumn = "ALTER TABLE sessions ADD COLUMN provider_v39 TEXT NOT NULL DEFAULT 'codex' " + "CHECK(provider_v39 IN ('codex','claude','devin') " @@ -2973,9 +3445,15 @@ const assertSchemaVersion39ProviderAuthority = (database: Database): void => { ); if ( !sessionsTable.success - || !sessionsTable.data.sql.includes( - "provider_v39 TEXT NOT NULL DEFAULT 'codex' CHECK(provider_v39 IN ('codex','claude','devin') AND (provider_v39!='devin' OR preset_contract=2))", - ) + || splitSqlTableDefinitions(sessionsTable.data.sql) + .filter((definition) => /^\s*provider_v39\b/u.test(definition)).length !== 1 + || normalizeSqlStructure(splitSqlTableDefinitions(sessionsTable.data.sql) + .find((definition) => /^\s*provider_v39\b/u.test(definition)) ?? "") + !== normalizeSqlStructure( + "provider_v39 TEXT NOT NULL DEFAULT 'codex' " + + "CHECK(provider_v39 IN ('codex','claude','devin') " + + `AND (provider_v39!='devin' OR preset_contract=${currentPresetContract}))`, + ) ) { throw new Error("STATE_SCHEMA_V39_OBJECT_INVALID:sessions.provider_v39"); } @@ -2986,3634 +3464,9558 @@ const assertSchemaVersion39ProviderAuthority = (database: Database): void => { ).get() !== null) { throw new Error("STATE_SCHEMA_V39_DEVIN_PRESET_CONTRACT_INVALID:sessions"); } - const table = z.object({ sql: z.string().min(1), tbl_name: z.literal("session_mutation_authority_rebinds_v39") }).strict().safeParse( - database.query( - "SELECT sql,tbl_name FROM sqlite_master WHERE type='table' AND name='session_mutation_authority_rebinds_v39'", - ).get(), - ); - if ( - !table.success - || !table.data.sql.includes("CHECK(provider IN ('codex','claude','devin'))") - ) { - throw new Error("STATE_SCHEMA_V39_OBJECT_INVALID:session_mutation_authority_rebinds_v39"); - } - for (const trigger of [ - "session_mutation_authority_rebinds_v39_immutable_delete", - "session_mutation_authority_rebinds_v39_immutable_update", - ] as const) { + for (const expected of schemaVersion39ProviderAuthorityObjects) { const row = z.object({ + name: z.string().min(1), sql: z.string().min(1), tbl_name: z.literal("session_mutation_authority_rebinds_v39"), + type: z.enum(["table", "trigger"]), }).strict().safeParse(database.query( - "SELECT sql,tbl_name FROM sqlite_master WHERE type='trigger' AND name=?", - ).get(trigger)); + "SELECT name,sql,tbl_name,type FROM sqlite_master WHERE type=? AND name=?", + ).get(expected.type, expected.name)); if (!row.success) { - throw new Error(`STATE_SCHEMA_V39_OBJECT_MISSING:${trigger}`); + if (expected.type === "trigger") { + throw new Error(`STATE_SCHEMA_V39_OBJECT_MISSING:${expected.name}`); + } + throw new Error(`STATE_SCHEMA_V39_OBJECT_INVALID:${expected.name}`); } - const action = trigger.endsWith("delete") ? "DELETE" : "UPDATE"; if ( - !row.data.sql.includes(`BEFORE ${action} ON session_mutation_authority_rebinds_v39`) - || !row.data.sql.includes("RAISE(ABORT, 'session mutation authority rebind v39 is immutable')") - ) throw new Error(`STATE_SCHEMA_V39_OBJECT_INVALID:${trigger}`); + row.data.name !== expected.name + || row.data.type !== expected.type + || normalizeSqlStructure(row.data.sql) !== normalizeSqlStructure(expected.sql) + ) throw new Error(`STATE_SCHEMA_V39_OBJECT_INVALID:${expected.name}`); } }; -const applySchemaVersion33DeviceCommands = (database: Database): void => { - if (!hasTableColumn(database, "daemon_state", "device_commands_allowed")) { - database.exec(schemaVersion33DeviceCommandsAllowedColumn); - } - if (!hasTableColumn(database, "daemon_state", "account_linking_allowed")) { - database.exec(schemaVersion33AccountLinkingAllowedColumn); - } - database.exec(schemaVersion33DeviceCommands); -}; +/* + * Personal-home session adoption. Provenance remains in these private routing + * tables instead of widening SessionRecord or any public projection. + * + * A provider policy is a single machine-local assignment: one personal home + * can feed at most one HRA account for that provider. Candidates are bounded + * observations, not execution authority. A binding is the sole-controller + * routing decision and its provider/thread identity never changes. + */ +const schemaVersion40CandidateLastLiveObservedAtColumn = ` last_live_observed_at INTEGER CHECK( + last_live_observed_at IS NULL OR ( + last_live_observed_at<=last_observed_at + AND ( + provider='codex' + OR ( + provider='claude' + AND source_pid IS NOT NULL + AND source_pid_domain IS NOT NULL + AND source_proc_start IS NOT NULL + ) + ) + ) + ), +`; -const schemaVersion28Objects = [ - { - name: "account_rate_limit_reset_policies", - table: "account_rate_limit_reset_policies", - type: "table", - }, - { - name: "account_rate_limit_reset_policy_insert_guard", - table: "account_rate_limit_reset_policies", - type: "trigger", - }, - { - name: "account_rate_limit_reset_policy_transition_guard", - table: "account_rate_limit_reset_policies", - type: "trigger", - }, - { - name: "account_rate_limit_reset_policy_delete_guard", - table: "account_rate_limit_reset_policies", - type: "trigger", - }, - { - name: "account_rate_limit_reset_attempt_transition_guard", - table: "account_rate_limit_reset_attempts", - type: "trigger", - }, - { - name: "account_rate_limit_reset_attempt_policy_insert_guard", - table: "account_rate_limit_reset_attempts", - type: "trigger", - }, - { - name: "account_rate_limit_reset_attempt_policy_begin_guard", - table: "account_rate_limit_reset_attempts", - type: "trigger", - }, - { - name: "account_rate_limit_reset_attempt_policy_close_guard", - table: "account_rate_limit_reset_attempts", - type: "trigger", - }, - { - name: "account_rate_limit_reset_rebind_policy_guard", - table: "account_rate_limit_reset_rebinds", - type: "trigger", - }, - { - name: "account_rate_limit_reset_rebind_insert_guard", - table: "account_rate_limit_reset_rebinds", - type: "trigger", - }, -] as const; +const schemaVersion40CandidateProviderProjectRootColumn = ` provider_project_root TEXT CHECK( + provider_project_root IS NULL OR ( + length(CAST(provider_project_root AS BLOB)) BETWEEN 1 AND 8192 + AND substr(provider_project_root,1,1)='/' + ) + ), +`; -const schemaVersion28ObjectSql = ( - object: (typeof schemaVersion28Objects)[number], -): string => { - const marker = object.type === "table" - ? `CREATE TABLE IF NOT EXISTS ${object.name}` - : `CREATE TRIGGER ${object.name}`; - const start = schemaVersion28.indexOf(marker); - const terminator = object.type === "table" ? ") STRICT;" : "END;"; - const end = schemaVersion28.indexOf(terminator, start); - if (start < 0 || end < 0) throw new Error("STATE_SCHEMA_V28_DEFINITION_INVALID"); - return schemaVersion28.slice(start, end + terminator.length); -}; +const schemaVersion40ProfileCodexAccountKeyColumn = `codex_account_key TEXT CHECK( + codex_account_key IS NULL OR ( + length(codex_account_key)=73 + AND substr(codex_account_key,1,9)='v1:codex:' + AND substr(codex_account_key,10) NOT GLOB '*[^0-9a-f]*' + ) +)`; -const schemaVersion24Objects = [ - { - name: "profiles_label_key_active", - table: "profiles", - type: "index", - sql: `CREATE UNIQUE INDEX profiles_label_key_active - ON profiles(label_key) WHERE state!='removed'`, - }, - { - name: "projects_label_key_unique", - table: "projects", - type: "index", - sql: `CREATE UNIQUE INDEX projects_label_key_unique - ON projects(label_key)`, - }, - { - name: "profiles_label_key_insert_guard", - table: "profiles", - type: "trigger", - sql: `CREATE TRIGGER profiles_label_key_insert_guard +const schemaVersion40SessionAdoption = ` +CREATE TRIGGER IF NOT EXISTS profile_codex_account_key_insert_guard BEFORE INSERT ON profiles -WHEN NEW.label_key IS NULL - OR length(CAST(NEW.label_key AS BLOB)) NOT BETWEEN 1 AND 4096 -BEGIN SELECT RAISE(ABORT, 'invalid profile label key'); END`, - }, - { - name: "profiles_label_key_immutable", - table: "profiles", - type: "trigger", - sql: `CREATE TRIGGER profiles_label_key_immutable -BEFORE UPDATE OF label,label_key ON profiles -WHEN NEW.label IS NOT OLD.label OR NEW.label_key IS NOT OLD.label_key -BEGIN SELECT RAISE(ABORT, 'profile label identity is immutable'); END`, - }, - { - name: "projects_label_key_insert_guard", - table: "projects", - type: "trigger", - sql: `CREATE TRIGGER projects_label_key_insert_guard -BEFORE INSERT ON projects -WHEN NEW.label_key IS NULL - OR length(CAST(NEW.label_key AS BLOB)) NOT BETWEEN 1 AND 4096 -BEGIN SELECT RAISE(ABORT, 'invalid project label key'); END`, - }, - { - name: "projects_label_key_immutable", - table: "projects", - type: "trigger", - sql: `CREATE TRIGGER projects_label_key_immutable -BEFORE UPDATE OF label,label_key ON projects -WHEN NEW.label IS NOT OLD.label OR NEW.label_key IS NOT OLD.label_key -BEGIN SELECT RAISE(ABORT, 'project label identity is immutable'); END`, - }, -] as const; +WHEN (NEW.provider_email IS NULL)!=(NEW.codex_account_key IS NULL) +BEGIN SELECT RAISE(ABORT, 'profile Codex account key must accompany its identity'); END; +CREATE TRIGGER IF NOT EXISTS profile_codex_account_key_update_guard +BEFORE UPDATE OF provider_email,codex_account_key ON profiles +WHEN (NEW.provider_email IS NULL)!=(NEW.codex_account_key IS NULL) + OR ( + OLD.provider_email IS NOT NULL AND NEW.provider_email IS NOT NULL + AND lower(trim(NEW.provider_email))!=lower(trim(OLD.provider_email)) + AND NEW.codex_account_key IS OLD.codex_account_key + ) + OR ( + OLD.provider_email IS NOT NULL AND NEW.provider_email IS NOT NULL + AND lower(trim(NEW.provider_email))=lower(trim(OLD.provider_email)) + AND NEW.codex_account_key IS NOT OLD.codex_account_key + ) +BEGIN SELECT RAISE(ABORT, 'profile Codex account key must change with its identity'); END; +CREATE TABLE IF NOT EXISTS session_account_authorities ( + session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, + profile_id TEXT NOT NULL REFERENCES profiles(id), + account_key TEXT CHECK(account_key IS NULL OR length(CAST(account_key AS BLOB)) BETWEEN 1 AND 320), + recorded_at INTEGER NOT NULL CHECK(recorded_at >= 0) +) STRICT; +CREATE TABLE IF NOT EXISTS provider_runtime_account_revocations ( + profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + profile_generation INTEGER NOT NULL CHECK(profile_generation BETWEEN 0 AND 9007199254740991), + provider TEXT NOT NULL CHECK(provider IN ('codex','claude')), + runtime_scope TEXT NOT NULL CHECK(runtime_scope IN ('managed','personal')), + current_account_key TEXT CHECK( + current_account_key IS NULL + OR (provider='codex' AND length(current_account_key)=73 + AND substr(current_account_key,1,9)='v1:codex:' + AND substr(current_account_key,10) NOT GLOB '*[^0-9a-f]*') + OR (provider='claude' AND length(current_account_key)=74 + AND substr(current_account_key,1,10)='v1:claude:' + AND substr(current_account_key,11) NOT GLOB '*[^0-9a-f]*') + ), + state TEXT NOT NULL CHECK(state IN ('releasing','completed')), + revision INTEGER NOT NULL CHECK(revision BETWEEN 1 AND 9007199254740991), + created_at INTEGER NOT NULL CHECK(created_at >= 0), + updated_at INTEGER NOT NULL CHECK(updated_at >= created_at), + completed_at INTEGER CHECK(completed_at IS NULL OR completed_at >= created_at), + CHECK((state='completed')=(completed_at IS NOT NULL)), + PRIMARY KEY(profile_id,provider,runtime_scope) +) STRICT; +CREATE TABLE IF NOT EXISTS session_provider_account_authorities ( + session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, + provider TEXT NOT NULL CHECK(provider IN ('codex','claude')), + runtime_scope TEXT NOT NULL CHECK(runtime_scope IN ('managed','personal')), + account_key TEXT NOT NULL CHECK( + (provider='codex' AND length(account_key)=73 + AND substr(account_key,1,9)='v1:codex:' + AND substr(account_key,10) NOT GLOB '*[^0-9a-f]*') + OR + (provider='claude' AND length(account_key)=74 + AND substr(account_key,1,10)='v1:claude:' + AND substr(account_key,11) NOT GLOB '*[^0-9a-f]*') + ), + recorded_at INTEGER NOT NULL CHECK(recorded_at >= 0) +) STRICT; +CREATE TRIGGER IF NOT EXISTS session_provider_account_authority_insert_guard +BEFORE INSERT ON session_provider_account_authorities +WHEN NOT EXISTS( + SELECT 1 FROM sessions s + WHERE s.id=NEW.session_id AND s.provider_v39=NEW.provider + AND ( + NEW.provider!='codex' OR EXISTS( + SELECT 1 FROM profiles p + WHERE p.id=s.profile_id AND p.state='signed_in' + AND p.provider_email IS NOT NULL + AND p.codex_account_key=NEW.account_key + ) + ) + AND NOT EXISTS( + SELECT 1 FROM provider_runtime_account_revocations r + WHERE r.profile_id=s.profile_id + AND r.profile_generation=( + SELECT p.process_generation FROM profiles p WHERE p.id=s.profile_id + ) + AND r.provider=NEW.provider + AND r.runtime_scope=NEW.runtime_scope + AND (r.state='releasing' OR r.current_account_key IS NULL + OR r.current_account_key!=NEW.account_key) + ) + ) OR NOT ( + (NEW.provider='codex' AND length(NEW.account_key)=73 + AND substr(NEW.account_key,1,9)='v1:codex:' + AND substr(NEW.account_key,10) NOT GLOB '*[^0-9a-f]*') + OR + (NEW.provider='claude' AND length(NEW.account_key)=74 + AND substr(NEW.account_key,1,10)='v1:claude:' + AND substr(NEW.account_key,11) NOT GLOB '*[^0-9a-f]*') + ) +BEGIN SELECT RAISE(ABORT, 'session provider account authority does not match its session'); END; +CREATE TRIGGER IF NOT EXISTS session_provider_account_authority_update_guard +BEFORE UPDATE ON session_provider_account_authorities +BEGIN SELECT RAISE(ABORT, 'session provider account authority is immutable'); END; +CREATE TRIGGER IF NOT EXISTS session_account_authority_insert +AFTER INSERT ON sessions +BEGIN + INSERT INTO session_account_authorities(session_id,profile_id,account_key,recorded_at) + SELECT NEW.id,NEW.profile_id, + CASE WHEN p.provider_email IS NULL THEN NULL ELSE lower(trim(p.provider_email)) END, + NEW.created_at + FROM profiles p WHERE p.id=NEW.profile_id; +END; +CREATE TRIGGER IF NOT EXISTS session_account_authority_rebind +AFTER UPDATE OF profile_id ON sessions +WHEN NEW.profile_id!=OLD.profile_id +BEGIN + UPDATE session_account_authorities + SET profile_id=NEW.profile_id, + account_key=(SELECT CASE WHEN p.provider_email IS NULL THEN NULL ELSE lower(trim(p.provider_email)) END + FROM profiles p WHERE p.id=NEW.profile_id), + recorded_at=NEW.updated_at + WHERE session_id=NEW.id; +END; +CREATE TRIGGER IF NOT EXISTS session_account_authority_update_guard +BEFORE UPDATE ON session_account_authorities +WHEN NOT EXISTS( + SELECT 1 FROM sessions s + WHERE s.id=NEW.session_id AND s.profile_id=NEW.profile_id +) OR ( + NEW.profile_id=OLD.profile_id + AND NEW.account_key IS NOT OLD.account_key + AND NOT ( + NOT EXISTS( + SELECT 1 FROM session_provider_account_authorities a + WHERE a.session_id=NEW.session_id + ) AND ( + (OLD.account_key IS NOT NULL AND NEW.account_key IS NULL) + OR ( + OLD.account_key IS NULL + AND NEW.account_key=( + SELECT lower(trim(p.provider_email)) FROM profiles p + WHERE p.id=NEW.profile_id AND p.provider_email IS NOT NULL + ) + ) + ) + ) +) +BEGIN SELECT RAISE(ABORT, 'session account authority is immutable within one account profile'); END; +CREATE TRIGGER IF NOT EXISTS session_account_authority_queue_guard +BEFORE INSERT ON queue_entries +WHEN NOT EXISTS( + SELECT 1 FROM sessions s + JOIN profiles p ON p.id=s.profile_id + LEFT JOIN session_provider_account_authorities pa + ON pa.session_id=s.id AND pa.provider=s.provider_v39 + WHERE s.id=NEW.session_id + AND NOT EXISTS( + SELECT 1 FROM provider_runtime_account_revocations r + WHERE r.profile_id=s.profile_id + AND r.profile_generation=p.process_generation + AND r.provider=s.provider_v39 AND r.runtime_scope=pa.runtime_scope + AND (r.state='releasing' OR r.current_account_key IS NULL + OR r.current_account_key!=pa.account_key) + ) + AND ( + (pa.runtime_scope='personal' AND EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.provider=s.provider_v39 + AND b.provider_thread_id=s.provider_thread_id AND b.state='active' + )) + OR (pa.runtime_scope='managed' AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.state IN ('active','detaching') + )) + OR (s.provider_v39='devin' AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.state IN ('active','detaching') + )) + ) + AND ( + (s.provider_v39='claude' AND p.state IN ('signed_in','signed_out')) + OR (s.provider_v39='codex' AND p.state='signed_in' + AND p.provider_email IS NOT NULL + AND p.codex_account_key=pa.account_key AND EXISTS( + SELECT 1 FROM session_account_authorities a + WHERE a.session_id=s.id AND a.profile_id=s.profile_id + AND a.account_key IS NOT NULL + AND a.account_key=lower(trim(p.provider_email)) + )) + OR (s.provider_v39='devin' AND p.state IN ('signed_in','signed_out')) + ) +) +BEGIN SELECT RAISE(ABORT, 'session provider account authority is not current'); END; +CREATE TRIGGER IF NOT EXISTS session_account_authority_interaction_guard +BEFORE INSERT ON provider_interactions +WHEN NEW.session_id IS NOT NULL AND NOT EXISTS( + SELECT 1 FROM sessions s + JOIN profiles p ON p.id=s.profile_id + LEFT JOIN session_provider_account_authorities pa + ON pa.session_id=s.id AND pa.provider=s.provider_v39 + WHERE s.id=NEW.session_id + AND NOT EXISTS( + SELECT 1 FROM provider_runtime_account_revocations r + WHERE r.profile_id=s.profile_id + AND r.profile_generation=p.process_generation + AND r.provider=s.provider_v39 AND r.runtime_scope=pa.runtime_scope + AND (r.state='releasing' OR r.current_account_key IS NULL + OR r.current_account_key!=pa.account_key) + ) + AND ( + (pa.runtime_scope='personal' AND EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.provider=s.provider_v39 + AND b.provider_thread_id=s.provider_thread_id AND b.state='active' + )) + OR (pa.runtime_scope='managed' AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.state IN ('active','detaching') + )) + OR (s.provider_v39='devin' AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.state IN ('active','detaching') + )) + ) + AND ( + (s.provider_v39='claude' AND p.state IN ('signed_in','signed_out')) + OR (s.provider_v39='codex' AND p.state='signed_in' + AND p.provider_email IS NOT NULL + AND p.codex_account_key=pa.account_key AND EXISTS( + SELECT 1 FROM session_account_authorities a + WHERE a.session_id=s.id AND a.profile_id=s.profile_id + AND a.account_key IS NOT NULL + AND a.account_key=lower(trim(p.provider_email)) + )) + OR (s.provider_v39='devin' AND p.state IN ('signed_in','signed_out')) + ) +) +BEGIN SELECT RAISE(ABORT, 'session provider account authority is not current'); END; +CREATE TRIGGER IF NOT EXISTS session_account_authority_active_state_guard +BEFORE UPDATE OF state,active_turn_id ON sessions +WHEN (NEW.state='active' OR NEW.active_turn_id IS NOT NULL) AND NOT EXISTS( + SELECT 1 FROM profiles p + LEFT JOIN session_provider_account_authorities pa + ON pa.session_id=NEW.id AND pa.provider=NEW.provider_v39 + WHERE p.id=NEW.profile_id + AND NOT EXISTS( + SELECT 1 FROM provider_runtime_account_revocations r + WHERE r.profile_id=NEW.profile_id + AND r.profile_generation=p.process_generation + AND r.provider=NEW.provider_v39 AND r.runtime_scope=pa.runtime_scope + AND (r.state='releasing' OR r.current_account_key IS NULL + OR r.current_account_key!=pa.account_key) + ) + AND ( + (pa.runtime_scope='personal' AND EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=NEW.id AND b.provider=NEW.provider_v39 + AND b.provider_thread_id=NEW.provider_thread_id AND b.state='active' + )) + OR (pa.runtime_scope='managed' AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=NEW.id AND b.state IN ('active','detaching') + )) + OR (NEW.provider_v39='devin' AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=NEW.id AND b.state IN ('active','detaching') + )) + ) + AND ( + (NEW.provider_v39='claude' AND p.state IN ('signed_in','signed_out')) + OR (NEW.provider_v39='codex' AND p.state='signed_in' + AND p.provider_email IS NOT NULL + AND p.codex_account_key=pa.account_key AND EXISTS( + SELECT 1 FROM session_account_authorities a + WHERE a.session_id=NEW.id AND a.profile_id=NEW.profile_id + AND a.account_key IS NOT NULL + AND a.account_key=lower(trim(p.provider_email)) + )) + OR (NEW.provider_v39='devin' AND p.state IN ('signed_in','signed_out')) + ) +) +BEGIN SELECT RAISE(ABORT, 'session provider account authority is not current'); END; +CREATE TRIGGER IF NOT EXISTS session_account_authority_task_insert_guard +BEFORE INSERT ON session_tasks +WHEN NEW.status='active' AND NOT EXISTS( + SELECT 1 FROM sessions s + JOIN profiles p ON p.id=s.profile_id + LEFT JOIN session_provider_account_authorities pa + ON pa.session_id=s.id AND pa.provider=s.provider_v39 + WHERE s.id=NEW.session_id + AND NOT EXISTS( + SELECT 1 FROM provider_runtime_account_revocations r + WHERE r.profile_id=s.profile_id + AND r.profile_generation=p.process_generation + AND r.provider=s.provider_v39 AND r.runtime_scope=pa.runtime_scope + AND (r.state='releasing' OR r.current_account_key IS NULL + OR r.current_account_key!=pa.account_key) + ) + AND ( + (pa.runtime_scope='personal' AND EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.provider=s.provider_v39 + AND b.provider_thread_id=s.provider_thread_id AND b.state='active' + )) + OR (pa.runtime_scope='managed' AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.state IN ('active','detaching') + )) + OR (s.provider_v39='devin' AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.state IN ('active','detaching') + )) + ) + AND ( + (s.provider_v39='claude' AND p.state IN ('signed_in','signed_out')) + OR (s.provider_v39='codex' AND p.state='signed_in' + AND p.provider_email IS NOT NULL + AND p.codex_account_key=pa.account_key AND EXISTS( + SELECT 1 FROM session_account_authorities a + WHERE a.session_id=s.id AND a.profile_id=s.profile_id + AND a.account_key IS NOT NULL + AND a.account_key=lower(trim(p.provider_email)) + )) + OR (s.provider_v39='devin' AND p.state IN ('signed_in','signed_out')) + ) +) +BEGIN SELECT RAISE(ABORT, 'session provider account authority is not current'); END; +CREATE TRIGGER IF NOT EXISTS session_account_authority_task_update_guard +BEFORE UPDATE OF status ON session_tasks +WHEN NEW.status='active' AND NOT EXISTS( + SELECT 1 FROM sessions s + JOIN profiles p ON p.id=s.profile_id + LEFT JOIN session_provider_account_authorities pa + ON pa.session_id=s.id AND pa.provider=s.provider_v39 + WHERE s.id=NEW.session_id + AND NOT EXISTS( + SELECT 1 FROM provider_runtime_account_revocations r + WHERE r.profile_id=s.profile_id + AND r.profile_generation=p.process_generation + AND r.provider=s.provider_v39 AND r.runtime_scope=pa.runtime_scope + AND (r.state='releasing' OR r.current_account_key IS NULL + OR r.current_account_key!=pa.account_key) + ) + AND ( + (pa.runtime_scope='personal' AND EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.provider=s.provider_v39 + AND b.provider_thread_id=s.provider_thread_id AND b.state='active' + )) + OR (pa.runtime_scope='managed' AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.state IN ('active','detaching') + )) + OR (s.provider_v39='devin' AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.state IN ('active','detaching') + )) + ) + AND ( + (s.provider_v39='claude' AND p.state IN ('signed_in','signed_out')) + OR (s.provider_v39='codex' AND p.state='signed_in' + AND p.provider_email IS NOT NULL + AND p.codex_account_key=pa.account_key AND EXISTS( + SELECT 1 FROM session_account_authorities a + WHERE a.session_id=s.id AND a.profile_id=s.profile_id + AND a.account_key IS NOT NULL + AND a.account_key=lower(trim(p.provider_email)) + )) + OR (s.provider_v39='devin' AND p.state IN ('signed_in','signed_out')) + ) +) +BEGIN SELECT RAISE(ABORT, 'session provider account authority is not current'); END; +CREATE TABLE IF NOT EXISTS session_adoption_policies ( + provider TEXT PRIMARY KEY CHECK(provider IN ('codex','claude')), + profile_id TEXT REFERENCES profiles(id), + state TEXT NOT NULL CHECK(state IN ('enabled','disabled')), + revision INTEGER NOT NULL CHECK(revision BETWEEN 1 AND 9007199254740991), + created_at INTEGER NOT NULL CHECK(created_at >= 0), + updated_at INTEGER NOT NULL CHECK(updated_at >= created_at), + CHECK( + (state='enabled' AND profile_id IS NOT NULL) + OR (state='disabled' AND profile_id IS NULL) + ) +) STRICT; +CREATE TRIGGER IF NOT EXISTS session_adoption_policy_profile_guard_insert +BEFORE INSERT ON session_adoption_policies +WHEN NEW.state='enabled' AND NOT EXISTS( + SELECT 1 FROM profiles p + WHERE p.id=NEW.profile_id + AND ((NEW.provider='claude' AND p.state IN ('signed_in','signed_out')) + OR (NEW.provider='codex' AND p.state='signed_in' + AND p.provider_email IS NOT NULL AND p.codex_account_key IS NOT NULL)) +) +BEGIN SELECT RAISE(ABORT, 'session adoption policy requires current provider authority'); END; +CREATE TRIGGER IF NOT EXISTS session_adoption_policy_profile_guard_update +BEFORE UPDATE OF state,profile_id ON session_adoption_policies +WHEN NEW.state='enabled' AND NOT EXISTS( + SELECT 1 FROM profiles p + WHERE p.id=NEW.profile_id + AND ((NEW.provider='claude' AND p.state IN ('signed_in','signed_out')) + OR (NEW.provider='codex' AND p.state='signed_in' + AND p.provider_email IS NOT NULL AND p.codex_account_key IS NOT NULL)) +) +BEGIN SELECT RAISE(ABORT, 'session adoption policy requires current provider authority'); END; +CREATE TRIGGER IF NOT EXISTS session_adoption_policy_identity_immutable +BEFORE UPDATE OF provider,created_at ON session_adoption_policies +BEGIN SELECT RAISE(ABORT, 'session adoption policy identity is immutable'); END; +CREATE TRIGGER IF NOT EXISTS session_adoption_policy_revision_guard +BEFORE UPDATE ON session_adoption_policies +WHEN NEW.revision!=OLD.revision+1 OR NEW.updated_at= 0), + liveness TEXT NOT NULL CHECK(liveness IN ('live','not_live','unknown')), + source_pid INTEGER CHECK(source_pid IS NULL OR (source_pid > 0 AND source_pid <= 9007199254740991)), + source_pid_domain TEXT CHECK(source_pid_domain IS NULL OR source_pid_domain IN ('darwin','linux')), + source_proc_start TEXT CHECK(source_proc_start IS NULL OR length(CAST(source_proc_start AS BLOB)) BETWEEN 1 AND 128), + claim_status TEXT NOT NULL CHECK(claim_status IN ('pending','claiming','adopted','fenced')), + candidate_fingerprint TEXT NOT NULL CHECK(length(candidate_fingerprint)=64 AND candidate_fingerprint GLOB '[0-9a-f]*'), + fenced_fingerprint TEXT CHECK(fenced_fingerprint IS NULL OR (length(fenced_fingerprint)=64 AND fenced_fingerprint GLOB '[0-9a-f]*')), + revision INTEGER NOT NULL CHECK(revision BETWEEN 1 AND 9007199254740991), + first_discovered_at INTEGER NOT NULL CHECK(first_discovered_at >= 0), + last_observed_at INTEGER NOT NULL CHECK(last_observed_at >= first_discovered_at), + last_changed_at INTEGER NOT NULL CHECK(last_changed_at BETWEEN first_discovered_at AND last_observed_at), + last_attempt_at INTEGER CHECK(last_attempt_at IS NULL OR last_attempt_at >= first_discovered_at), +${schemaVersion40CandidateLastLiveObservedAtColumn}${schemaVersion40CandidateProviderProjectRootColumn} PRIMARY KEY(provider,provider_thread_id), + CHECK( + (claim_status='fenced' AND fenced_fingerprint IS NOT NULL) + OR (claim_status!='fenced' AND fenced_fingerprint IS NULL) + ), + CHECK( + (source_pid IS NULL AND source_pid_domain IS NULL AND source_proc_start IS NULL) + OR ( + provider='claude' + AND source_pid IS NOT NULL + AND source_pid_domain IS NOT NULL + AND source_proc_start IS NOT NULL + ) + ) +) STRICT; +CREATE INDEX IF NOT EXISTS session_adoption_candidates_pending + ON session_adoption_candidates(provider,last_observed_at DESC,provider_thread_id) + WHERE claim_status='pending'; +CREATE TRIGGER IF NOT EXISTS session_adoption_candidate_identity_immutable +BEFORE UPDATE OF provider,provider_thread_id,first_discovered_at ON session_adoption_candidates +BEGIN SELECT RAISE(ABORT, 'session adoption candidate identity is immutable'); END; +CREATE TRIGGER IF NOT EXISTS session_adoption_candidate_revision_guard +BEFORE UPDATE ON session_adoption_candidates +WHEN NEW.revision!=OLD.revision+1 + OR NEW.last_observed_at= 0), + updated_at INTEGER NOT NULL CHECK(updated_at >= adopted_at), + detached_at INTEGER CHECK(detached_at IS NULL OR detached_at >= adopted_at), + UNIQUE(provider,provider_thread_id), + FOREIGN KEY(provider,provider_thread_id) + REFERENCES session_adoption_candidates(provider,provider_thread_id), + CHECK( + (state='active' AND detached_at IS NULL) + OR (state='detaching' AND detached_at IS NULL) + OR (state='detached' AND detached_at IS NOT NULL) + ) +) STRICT; +CREATE INDEX IF NOT EXISTS session_personal_runtime_bindings_active + ON session_personal_runtime_bindings(provider,session_id) + WHERE state='active'; +CREATE INDEX IF NOT EXISTS sessions_profile_created + ON sessions(profile_id,created_at DESC,id); +CREATE TRIGGER IF NOT EXISTS session_personal_runtime_binding_authority_guard +BEFORE INSERT ON session_personal_runtime_bindings +WHEN NOT EXISTS( + SELECT 1 FROM sessions s + WHERE s.id=NEW.session_id + AND s.provider_v39=NEW.provider + AND s.provider_thread_id=NEW.provider_thread_id +) +BEGIN SELECT RAISE(ABORT, 'personal runtime binding session authority mismatch'); END; +CREATE TRIGGER IF NOT EXISTS session_personal_runtime_binding_identity_immutable +BEFORE UPDATE OF session_id,provider,provider_thread_id,adopted_at +ON session_personal_runtime_bindings +BEGIN SELECT RAISE(ABORT, 'personal runtime binding identity is immutable'); END; +CREATE TRIGGER IF NOT EXISTS session_personal_runtime_binding_revision_guard +BEFORE UPDATE ON session_personal_runtime_bindings +WHEN NEW.revision!=OLD.revision+1 OR NEW.updated_at= 0), + updated_at INTEGER NOT NULL CHECK(updated_at >= staged_at), + PRIMARY KEY(runtime_scope,profile_id,provider_thread_id) +) STRICT; +CREATE INDEX IF NOT EXISTS session_claude_process_launch_intents_profile + ON session_claude_process_launch_intents(profile_id,profile_generation,staged_at,provider_thread_id); +CREATE UNIQUE INDEX IF NOT EXISTS session_claude_process_launch_intents_session + ON session_claude_process_launch_intents(session_id) + WHERE session_id IS NOT NULL; +CREATE TRIGGER IF NOT EXISTS session_claude_process_launch_intent_profile_guard +BEFORE INSERT ON session_claude_process_launch_intents +WHEN NEW.provider_account_key IS NULL + OR length(NEW.provider_account_key)!=74 + OR substr(NEW.provider_account_key,1,10)!='v1:claude:' + OR substr(NEW.provider_account_key,11) GLOB '*[^0-9a-f]*' + OR NOT EXISTS( + SELECT 1 FROM profiles p + WHERE p.id=NEW.profile_id + AND p.process_generation=NEW.profile_generation + AND p.state IN ('signed_in','signed_out') + AND NOT EXISTS( + SELECT 1 FROM provider_runtime_account_revocations r + WHERE r.profile_id=NEW.profile_id + AND r.profile_generation=NEW.profile_generation + AND r.provider='claude' + AND r.runtime_scope=NEW.runtime_scope + AND (r.state='releasing' OR r.current_account_key IS NULL + OR r.current_account_key!=NEW.provider_account_key) + ) +) +BEGIN SELECT RAISE(ABORT, 'Claude process launch intent requires current provider authority'); END; +CREATE TRIGGER IF NOT EXISTS session_claude_process_launch_intent_no_update +BEFORE UPDATE ON session_claude_process_launch_intents +BEGIN SELECT RAISE(ABORT, 'Claude process launch intent is immutable'); END; +CREATE TRIGGER IF NOT EXISTS session_claude_process_launch_intent_process_guard +BEFORE INSERT ON session_claude_process_launch_intents +WHEN EXISTS( + SELECT 1 FROM session_claude_process_authorities a + WHERE a.runtime_scope=NEW.runtime_scope + AND a.profile_id=NEW.profile_id + AND a.provider_thread_id=NEW.provider_thread_id + AND a.state!='released' +) +BEGIN SELECT RAISE(ABORT, 'Claude process launch intent conflicts with live process authority'); END; +CREATE TRIGGER IF NOT EXISTS session_personal_runtime_binding_launch_intent_detach_guard +BEFORE UPDATE OF state ON session_personal_runtime_bindings +WHEN NEW.state IN ('detaching','detached') + AND NEW.state!=OLD.state + AND EXISTS( + SELECT 1 FROM session_claude_process_launch_intents i + WHERE i.session_id=OLD.session_id + ) +BEGIN SELECT RAISE(ABORT, 'Claude process launch intent must be cancelled before personal session detach'); END; +CREATE TRIGGER IF NOT EXISTS session_adoption_profile_identity_guard +BEFORE UPDATE OF provider_email ON profiles +WHEN NEW.provider_email IS NOT OLD.provider_email AND EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + JOIN sessions s ON s.id=b.session_id + WHERE s.profile_id=OLD.id AND s.provider_v39='codex' + AND b.provider='codex' AND b.state IN ('active','detaching') +) +BEGIN SELECT RAISE(ABORT, 'live session controllers must release before account identity changes'); END; +CREATE TRIGGER IF NOT EXISTS sessions_personal_runtime_binding_rebind_guard +BEFORE UPDATE OF provider_v39,profile_id,provider_thread_id ON sessions +WHEN EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=OLD.id AND b.state IN ('active','detaching') +) +BEGIN SELECT RAISE(ABORT, 'active personal runtime binding must be retired before session rebind'); END; +CREATE TRIGGER IF NOT EXISTS session_adoption_profile_signout_guard +BEFORE UPDATE OF state ON profiles +WHEN NEW.state IN ('signed_out','removed') AND NEW.state!=OLD.state AND ( + EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + JOIN sessions s ON s.id=b.session_id + WHERE s.profile_id=OLD.id AND b.state IN ('active','detaching') + AND (NEW.state='removed' OR (s.provider_v39='codex' AND b.provider='codex')) + ) + OR (NEW.state='removed' AND EXISTS( + SELECT 1 FROM session_claude_process_authorities a + WHERE a.profile_id=OLD.id + AND a.profile_generation=OLD.process_generation + AND a.state!='released' + )) + OR (NEW.state='removed' AND EXISTS( + SELECT 1 FROM session_claude_process_launch_intents i + WHERE i.profile_id=OLD.id + AND i.profile_generation=OLD.process_generation + )) +) +BEGIN SELECT RAISE(ABORT, 'live session controllers must release before account authority ends'); END; +CREATE TRIGGER IF NOT EXISTS session_adoption_profile_generation_guard +BEFORE UPDATE OF process_generation ON profiles +WHEN NEW.process_generation!=OLD.process_generation AND ( + EXISTS( + SELECT 1 FROM session_claude_process_authorities a + WHERE a.profile_id=OLD.id + AND a.profile_generation=OLD.process_generation + AND a.state!='released' + ) + OR EXISTS( + SELECT 1 FROM session_claude_process_launch_intents i + WHERE i.profile_id=OLD.id + AND i.profile_generation=OLD.process_generation + ) + OR ( + EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + JOIN sessions s ON s.id=b.session_id + WHERE s.profile_id=OLD.id AND b.state IN ('active','detaching') + ) + AND NOT ( + EXISTS( + SELECT 1 FROM session_adoption_profile_generation_permits permit + WHERE permit.profile_id=OLD.id + AND permit.from_generation=OLD.process_generation + AND permit.to_generation=NEW.process_generation + ) + AND ( + NEW.state=OLD.state + OR ( + NEW.state='login_pending' + AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + JOIN sessions s ON s.id=b.session_id + WHERE s.profile_id=OLD.id AND s.provider_v39='codex' + AND b.provider='codex' AND b.state IN ('active','detaching') + ) + ) + ) + ) + ) +) +BEGIN SELECT RAISE(ABORT, 'live session controllers must release before account generation changes'); END; +CREATE TRIGGER IF NOT EXISTS session_adoption_profile_signed_out_policy_disable +AFTER UPDATE OF state ON profiles +WHEN NEW.state='signed_out' AND OLD.state!='signed_out' +BEGIN + UPDATE session_adoption_policies + SET profile_id=NULL,state='disabled',revision=revision+1, + updated_at=MAX(updated_at,NEW.updated_at) + WHERE profile_id=NEW.id AND provider='codex' AND state='enabled'; +END; +CREATE TRIGGER IF NOT EXISTS session_adoption_profile_unidentified_policy_disable +AFTER UPDATE OF provider_email ON profiles +WHEN NEW.provider_email IS NULL AND OLD.provider_email IS NOT NULL +BEGIN + UPDATE session_adoption_policies + SET profile_id=NULL,state='disabled',revision=revision+1, + updated_at=MAX(updated_at,NEW.updated_at) + WHERE profile_id=NEW.id AND provider='codex' AND state='enabled'; +END; +CREATE TABLE IF NOT EXISTS session_claude_process_authorities ( + provider_thread_id TEXT NOT NULL CHECK(length(provider_thread_id) BETWEEN 1 AND 200), + profile_id TEXT NOT NULL REFERENCES profiles(id), + profile_generation INTEGER NOT NULL CHECK(profile_generation BETWEEN 0 AND 9007199254740991), + runtime_scope TEXT NOT NULL CHECK(runtime_scope IN ('managed','personal')), + session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + pid INTEGER NOT NULL CHECK(pid > 0 AND pid <= 9007199254740991), + pid_domain TEXT NOT NULL CHECK(pid_domain IN ('darwin','linux')), + proc_start TEXT NOT NULL CHECK(length(CAST(proc_start AS BLOB)) BETWEEN 1 AND 128), + state TEXT NOT NULL CHECK(state IN ('claimed','bound','releasing','released')), + revision INTEGER NOT NULL CHECK(revision BETWEEN 1 AND 9007199254740991), + recorded_at INTEGER NOT NULL CHECK(recorded_at >= 0), + released_at INTEGER CHECK(released_at IS NULL OR released_at >= recorded_at), + CHECK((state='released')=(released_at IS NOT NULL)), + CHECK(state!='bound' OR session_id IS NOT NULL), + PRIMARY KEY(runtime_scope,profile_id,provider_thread_id) +) STRICT; +CREATE UNIQUE INDEX IF NOT EXISTS session_claude_process_authorities_live_identity + ON session_claude_process_authorities(pid_domain,pid,proc_start) + WHERE state!='released'; +CREATE INDEX IF NOT EXISTS session_claude_process_authorities_session + ON session_claude_process_authorities(session_id,state) + WHERE session_id IS NOT NULL; +CREATE TRIGGER IF NOT EXISTS session_claude_process_authority_session_guard_insert +BEFORE INSERT ON session_claude_process_authorities +WHEN NEW.state='bound' AND NOT EXISTS( + SELECT 1 FROM sessions s + WHERE s.id=NEW.session_id + AND s.provider_v39='claude' + AND s.profile_id=NEW.profile_id + AND s.provider_thread_id=NEW.provider_thread_id +) +BEGIN SELECT RAISE(ABORT, 'Claude process authority session binding mismatch'); END; +CREATE TRIGGER IF NOT EXISTS session_claude_process_authority_session_guard_update +BEFORE UPDATE OF session_id,state ON session_claude_process_authorities +WHEN NEW.state='bound' AND NOT EXISTS( + SELECT 1 FROM sessions s + WHERE s.id=NEW.session_id + AND s.provider_v39='claude' + AND s.profile_id=NEW.profile_id + AND s.provider_thread_id=NEW.provider_thread_id +) +BEGIN SELECT RAISE(ABORT, 'Claude process authority session binding mismatch'); END; +CREATE TRIGGER IF NOT EXISTS session_claude_process_authority_revision_guard +BEFORE UPDATE ON session_claude_process_authorities +WHEN NEW.revision!=OLD.revision+1 +BEGIN SELECT RAISE(ABORT, 'Claude process authority revision is invalid'); END; +CREATE TRIGGER IF NOT EXISTS sessions_claude_process_authority_rebind_guard +BEFORE UPDATE OF provider_v39,profile_id,provider_thread_id ON sessions +WHEN EXISTS( + SELECT 1 FROM session_claude_process_authorities a + WHERE a.session_id=OLD.id AND a.state IN ('bound','releasing') + UNION ALL + SELECT 1 FROM session_claude_process_launch_intents i + WHERE i.session_id=OLD.id +) +BEGIN SELECT RAISE(ABORT, 'live Claude process authority must be released before session rebind'); END; +CREATE TABLE IF NOT EXISTS profile_personal_authority_revocations ( + profile_id TEXT PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE, + profile_generation INTEGER NOT NULL CHECK(profile_generation BETWEEN 0 AND 9007199254740991), + state TEXT NOT NULL CHECK(state IN ('releasing','completed')), + revision INTEGER NOT NULL CHECK(revision BETWEEN 1 AND 9007199254740991), + created_at INTEGER NOT NULL CHECK(created_at >= 0), + updated_at INTEGER NOT NULL CHECK(updated_at >= created_at), + completed_at INTEGER CHECK(completed_at IS NULL OR completed_at >= created_at), + CHECK((state='completed')=(completed_at IS NOT NULL)) +) STRICT; +CREATE TRIGGER IF NOT EXISTS profile_personal_authority_revocation_revision_guard +BEFORE UPDATE ON profile_personal_authority_revocations +WHEN NEW.revision!=OLD.revision+1 OR NEW.updated_at { + const marker = `CREATE TABLE IF NOT EXISTS ${table}`; + const start = schemaVersion40SessionAdoption.indexOf(marker); + const terminator = ") STRICT;"; + const end = schemaVersion40SessionAdoption.indexOf(terminator, start); + if (start < 0 || end < 0) { + throw new Error(`STATE_SCHEMA_V40_WORK_DEPENDENCY_DEFINITION_INVALID:${table}`); + } + return schemaVersion40SessionAdoption.slice(start, end + terminator.length); +}; + +const schemaVersion40WorkAuthorityTables = schemaVersion40WorkAuthorityTableNames + .map(schemaVersion40TableSql) + .join("\n"); + +const ensureSchemaVersion40WorkAuthorityDependencies = (database: Database): void => { + // Create referenced tables before touching profiles: on newer SQLite, + // ALTER TABLE reparses every trigger and rejects a still-missing relation. + database.exec(schemaVersion40WorkAuthorityTables); + if (!hasTableColumn(database, "profiles", "codex_account_key")) { + database.exec( + `ALTER TABLE profiles ADD COLUMN ${schemaVersion40ProfileCodexAccountKeyColumn}`, + ); + } +}; + +const rebuildLegacyVersion35ClaudeProcessAuthorities = (database: Database): void => { + const table = database.query( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='session_claude_process_authorities'", + ).get(); + if (table === null) return; + const columnSchema = z.object({ + name: z.string(), + pk: z.number().int().nonnegative(), + }).passthrough(); + const primaryKey = database.query( + "PRAGMA table_info(session_claude_process_authorities)", + ).all().map((row) => columnSchema.parse(row)) + .filter((column) => column.pk > 0) + .sort((left, right) => left.pk - right.pk) + .map((column) => column.name); + const expectedPrimaryKey = ["runtime_scope", "profile_id", "provider_thread_id"]; + if (primaryKey.join("\u0000") === expectedPrimaryKey.join("\u0000")) return; + if (primaryKey.length !== 1 || primaryKey[0] !== "provider_thread_id") { + throw new Error("Unsupported Claude process-authority primary key."); + } + const rebuild = database.transaction(() => { + database.exec(` + DROP TRIGGER IF EXISTS session_claude_process_authority_session_guard_insert; + DROP TRIGGER IF EXISTS session_claude_process_authority_session_guard_update; + DROP TRIGGER IF EXISTS session_claude_process_authority_revision_guard; + DROP TRIGGER IF EXISTS sessions_claude_process_authority_rebind_guard; + DROP INDEX IF EXISTS session_claude_process_authorities_live_identity; + DROP INDEX IF EXISTS session_claude_process_authorities_session; + ALTER TABLE session_claude_process_authorities + RENAME TO session_claude_process_authorities_v35_legacy; + CREATE TABLE session_claude_process_authorities ( + provider_thread_id TEXT NOT NULL CHECK(length(provider_thread_id) BETWEEN 1 AND 200), + profile_id TEXT NOT NULL REFERENCES profiles(id), + profile_generation INTEGER NOT NULL CHECK(profile_generation BETWEEN 0 AND 9007199254740991), + runtime_scope TEXT NOT NULL CHECK(runtime_scope IN ('managed','personal')), + session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + pid INTEGER NOT NULL CHECK(pid > 0 AND pid <= 9007199254740991), + pid_domain TEXT NOT NULL CHECK(pid_domain IN ('darwin','linux')), + proc_start TEXT NOT NULL CHECK(length(CAST(proc_start AS BLOB)) BETWEEN 1 AND 128), + state TEXT NOT NULL CHECK(state IN ('claimed','bound','releasing','released')), + revision INTEGER NOT NULL CHECK(revision BETWEEN 1 AND 9007199254740991), + recorded_at INTEGER NOT NULL CHECK(recorded_at >= 0), + released_at INTEGER CHECK(released_at IS NULL OR released_at >= recorded_at), + CHECK((state='released')=(released_at IS NOT NULL)), + CHECK(state!='bound' OR session_id IS NOT NULL), + PRIMARY KEY(runtime_scope,profile_id,provider_thread_id) + ) STRICT; + INSERT INTO session_claude_process_authorities( + provider_thread_id,profile_id,profile_generation,runtime_scope, + session_id,pid,pid_domain,proc_start,state,revision,recorded_at,released_at + ) + SELECT provider_thread_id,profile_id,profile_generation,runtime_scope, + session_id,pid,pid_domain,proc_start,state,revision,recorded_at,released_at + FROM session_claude_process_authorities_v35_legacy; + DROP TABLE session_claude_process_authorities_v35_legacy; + `); + }); + rebuild.immediate(); +}; + +const schemaVersion40CandidateSourceProcessIdentity = ` +CREATE INDEX IF NOT EXISTS session_adoption_candidates_claude_reprobe + ON session_adoption_candidates(last_observed_at,provider_thread_id) + WHERE provider='claude' + AND provider_updated_at IS NOT NULL + AND claim_status IN ('pending','claiming') + AND source_pid IS NOT NULL + AND source_pid_domain IS NOT NULL + AND source_proc_start IS NOT NULL; +CREATE TRIGGER IF NOT EXISTS session_adoption_candidate_source_identity_guard_insert +BEFORE INSERT ON session_adoption_candidates +WHEN NOT ( + ( + NEW.source_pid IS NULL + AND NEW.source_pid_domain IS NULL + AND NEW.source_proc_start IS NULL + ) + OR ( + NEW.provider='claude' + AND NEW.source_pid IS NOT NULL + AND NEW.source_pid_domain IS NOT NULL + AND NEW.source_proc_start IS NOT NULL + ) +) +BEGIN SELECT RAISE(ABORT, 'session adoption candidate source identity is invalid'); END; +CREATE TRIGGER IF NOT EXISTS session_adoption_candidate_source_identity_guard_update +BEFORE UPDATE OF provider,source_pid,source_pid_domain,source_proc_start + ON session_adoption_candidates +WHEN NOT ( + ( + NEW.source_pid IS NULL + AND NEW.source_pid_domain IS NULL + AND NEW.source_proc_start IS NULL + ) + OR ( + NEW.provider='claude' + AND NEW.source_pid IS NOT NULL + AND NEW.source_pid_domain IS NOT NULL + AND NEW.source_proc_start IS NOT NULL + ) +) +BEGIN SELECT RAISE(ABORT, 'session adoption candidate source identity is invalid'); END; +`; + +const schemaVersion40SessionAccountAuthorityUpdateGuard = ` +CREATE TRIGGER session_account_authority_update_guard +BEFORE UPDATE ON session_account_authorities +WHEN NOT EXISTS( + SELECT 1 FROM sessions s + WHERE s.id=NEW.session_id AND s.profile_id=NEW.profile_id +) OR ( + NEW.profile_id=OLD.profile_id + AND NEW.account_key IS NOT OLD.account_key + AND NOT ( + NOT EXISTS( + SELECT 1 FROM session_provider_account_authorities a + WHERE a.session_id=NEW.session_id + ) AND ( + (OLD.account_key IS NOT NULL AND NEW.account_key IS NULL) + OR ( + OLD.account_key IS NULL + AND NEW.account_key=( + SELECT lower(trim(p.provider_email)) FROM profiles p + WHERE p.id=NEW.profile_id AND p.provider_email IS NOT NULL + ) + ) + ) + ) +) +BEGIN SELECT RAISE(ABORT, 'session account authority is immutable within one account profile'); END; +`; + +const ensureVersion40CandidateColumns = (database: Database): void => { + if (!hasTableColumn(database, "session_adoption_candidates", "source_pid")) { + database.exec( + "ALTER TABLE session_adoption_candidates ADD COLUMN source_pid INTEGER CHECK(source_pid IS NULL OR (source_pid > 0 AND source_pid <= 9007199254740991))", + ); + } + if (!hasTableColumn(database, "session_adoption_candidates", "source_pid_domain")) { + database.exec( + "ALTER TABLE session_adoption_candidates ADD COLUMN source_pid_domain TEXT CHECK(source_pid_domain IS NULL OR source_pid_domain IN ('darwin','linux'))", + ); + } + if (!hasTableColumn(database, "session_adoption_candidates", "source_proc_start")) { + database.exec( + "ALTER TABLE session_adoption_candidates ADD COLUMN source_proc_start TEXT CHECK(source_proc_start IS NULL OR length(CAST(source_proc_start AS BLOB)) BETWEEN 1 AND 128)", + ); + } + if (!hasTableColumn(database, "session_adoption_candidates", "last_live_observed_at")) { + database.exec( + `ALTER TABLE session_adoption_candidates ADD COLUMN last_live_observed_at INTEGER CHECK( + last_live_observed_at IS NULL OR ( + last_live_observed_at<=last_observed_at + AND ( + provider='codex' + OR ( + provider='claude' + AND source_pid IS NOT NULL + AND source_pid_domain IS NOT NULL + AND source_proc_start IS NOT NULL + ) + ) + ) + )`, + ); + } + if (!hasTableColumn(database, "session_adoption_candidates", "provider_project_root")) { + database.exec( + `ALTER TABLE session_adoption_candidates ADD COLUMN provider_project_root TEXT CHECK( + provider_project_root IS NULL OR ( + length(CAST(provider_project_root AS BLOB)) BETWEEN 1 AND 8192 + AND substr(provider_project_root,1,1)='/' + ) + )`, + ); + } + database.exec(schemaVersion40CandidateSourceProcessIdentity); +}; + +const applySchemaVersion40SessionAdoption = ( + database: Database, + migratedAt: number, +): void => { + // Adoption first existed on pre-release v35/v36 builds. The exact legacy + // recognizer runs before this bridge, so only admitted copies reach repair. + // thread id from one provider home can never alias authority in another. + rebuildSchemaVersion40RecreatableObjects(database); + // The first feature-v35 development backfill inferred identity from the profile's + // then-current email. A session without immutable provider evidence has no + // safe provenance, so both legacy v34 rows and partial v35 rows fail closed. + rebuildLegacyVersion35ClaudeProcessAuthorities(database); + if ( + database.query( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='session_claude_process_launch_intents'", + ).get() !== null + && !hasTableColumn( + database, + "session_claude_process_launch_intents", + "provider_account_key", + ) + ) { + // An interrupted pre-release v36 launch is retained, but its absent + // account proof remains NULL and can never be consumed to admit a child. + database.exec( + `ALTER TABLE session_claude_process_launch_intents + ADD COLUMN provider_account_key TEXT CHECK( + provider_account_key IS NULL OR ( + length(provider_account_key)=74 + AND substr(provider_account_key,1,10)='v1:claude:' + AND substr(provider_account_key,11) NOT GLOB '*[^0-9a-f]*' + ) + )`, + ); + } + ensureSchemaVersion40WorkAuthorityDependencies(database); + const profileIdentities = database.query( + "SELECT id,provider_email FROM profiles ORDER BY id", + ).all().map((row) => z.object({ + id: profileIdSchema, + provider_email: z.string().nullable(), + }).strict().parse(row)); + for (const profile of profileIdentities) { + database.query( + "UPDATE profiles SET codex_account_key=? WHERE id=?", + ).run( + profile.provider_email === null + ? null + : codexProviderAccountAuthorityKeyForEmail(profile.provider_email), + profile.id, + ); + } + database.exec(schemaVersion40SessionAdoption); + database.exec("DROP TRIGGER IF EXISTS session_account_authority_update_guard"); + database.exec(` + INSERT OR IGNORE INTO session_account_authorities( + session_id,profile_id,account_key,recorded_at + ) + SELECT s.id,s.profile_id,NULL,s.created_at + FROM sessions s + `); + // A pre-release copy may contain syntactically valid provider proof that no + // longer belongs to the current profile generation or runtime scope. Retire + // that proof rather than inferring provenance from mutable profile state. + // The existing quarantine pass then fences every nonterminal effect before + // startup can dispatch it. + database.exec(` + DELETE FROM session_provider_account_authorities + WHERE NOT EXISTS( + SELECT 1 FROM sessions s + JOIN profiles p ON p.id=s.profile_id + WHERE s.id=session_provider_account_authorities.session_id + AND s.provider_v39=session_provider_account_authorities.provider + AND NOT EXISTS( + SELECT 1 FROM provider_runtime_account_revocations r + WHERE r.profile_id=s.profile_id + AND r.profile_generation=p.process_generation + AND r.provider=s.provider_v39 + AND r.runtime_scope=session_provider_account_authorities.runtime_scope + AND (r.state='releasing' OR r.current_account_key IS NULL + OR r.current_account_key!=session_provider_account_authorities.account_key) + ) + AND ( + (session_provider_account_authorities.runtime_scope='personal' AND EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.provider=s.provider_v39 + AND b.provider_thread_id=s.provider_thread_id AND b.state='active' + )) + OR (session_provider_account_authorities.runtime_scope='managed' AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.state IN ('active','detaching') + )) + ) + AND ( + (s.provider_v39='claude' AND p.state IN ('signed_in','signed_out')) + OR (s.provider_v39='codex' AND p.state='signed_in' + AND p.provider_email IS NOT NULL + AND p.codex_account_key=session_provider_account_authorities.account_key + AND EXISTS( + SELECT 1 FROM session_account_authorities a + WHERE a.session_id=s.id AND a.profile_id=s.profile_id + AND a.account_key IS NOT NULL + AND a.account_key=lower(trim(p.provider_email)) + )) + ) + ) + `); + database.exec(` + UPDATE session_account_authorities + SET account_key=NULL + WHERE account_key IS NOT NULL AND NOT EXISTS( + SELECT 1 FROM session_provider_account_authorities a + WHERE a.session_id=session_account_authorities.session_id + ) + `); + database.exec(schemaVersion40SessionAccountAuthorityUpdateGuard); + // Work's account and route guards reference the adoption authority tables. + // Rebuild their merged v40 bodies before quarantine constructs WorkStore; + // otherwise a legacy same-name trigger can survive CREATE IF NOT EXISTS. + database.exec(WORK_SCHEMA_SQL); + assertWorkSchema(database); + quarantineUnprovenProviderSessions(database, migratedAt); + ensureVersion40CandidateColumns(database); + assertSchemaVersion40AdoptionObjects(database); +}; + +/** Maximum unclaimed candidates retained per provider. */ +export const SESSION_ADOPTION_PENDING_CANDIDATE_CAP = 2_000; +const applySchemaVersion33DeviceCommands = (database: Database): void => { + if (!hasTableColumn(database, "daemon_state", "device_commands_allowed")) { + database.exec(schemaVersion33DeviceCommandsAllowedColumn); + } + if (!hasTableColumn(database, "daemon_state", "account_linking_allowed")) { + database.exec(schemaVersion33AccountLinkingAllowedColumn); + } + database.exec(schemaVersion33DeviceCommands); +}; + +const schemaVersion28Objects = [ + { + name: "account_rate_limit_reset_policies", + table: "account_rate_limit_reset_policies", + type: "table", + }, + { + name: "account_rate_limit_reset_policy_insert_guard", + table: "account_rate_limit_reset_policies", + type: "trigger", + }, + { + name: "account_rate_limit_reset_policy_transition_guard", + table: "account_rate_limit_reset_policies", + type: "trigger", + }, + { + name: "account_rate_limit_reset_policy_delete_guard", + table: "account_rate_limit_reset_policies", + type: "trigger", + }, + { + name: "account_rate_limit_reset_attempt_transition_guard", + table: "account_rate_limit_reset_attempts", + type: "trigger", + }, + { + name: "account_rate_limit_reset_attempt_policy_insert_guard", + table: "account_rate_limit_reset_attempts", + type: "trigger", + }, + { + name: "account_rate_limit_reset_attempt_policy_begin_guard", + table: "account_rate_limit_reset_attempts", + type: "trigger", + }, + { + name: "account_rate_limit_reset_attempt_policy_close_guard", + table: "account_rate_limit_reset_attempts", + type: "trigger", + }, + { + name: "account_rate_limit_reset_rebind_policy_guard", + table: "account_rate_limit_reset_rebinds", + type: "trigger", + }, + { + name: "account_rate_limit_reset_rebind_insert_guard", + table: "account_rate_limit_reset_rebinds", + type: "trigger", + }, +] as const; + +const schemaVersion28ObjectSql = ( + object: (typeof schemaVersion28Objects)[number], +): string => { + const marker = object.type === "table" + ? `CREATE TABLE IF NOT EXISTS ${object.name}` + : `CREATE TRIGGER ${object.name}`; + const start = schemaVersion28.indexOf(marker); + const terminator = object.type === "table" ? ") STRICT;" : "END;"; + const end = schemaVersion28.indexOf(terminator, start); + if (start < 0 || end < 0) throw new Error("STATE_SCHEMA_V28_DEFINITION_INVALID"); + return schemaVersion28.slice(start, end + terminator.length); +}; + +const schemaVersion24Objects = [ + { + name: "profiles_label_key_active", + table: "profiles", + type: "index", + sql: `CREATE UNIQUE INDEX profiles_label_key_active + ON profiles(label_key) WHERE state!='removed'`, + }, + { + name: "projects_label_key_unique", + table: "projects", + type: "index", + sql: `CREATE UNIQUE INDEX projects_label_key_unique + ON projects(label_key)`, + }, + { + name: "profiles_label_key_insert_guard", + table: "profiles", + type: "trigger", + sql: `CREATE TRIGGER profiles_label_key_insert_guard +BEFORE INSERT ON profiles +WHEN NEW.label_key IS NULL + OR length(CAST(NEW.label_key AS BLOB)) NOT BETWEEN 1 AND 4096 +BEGIN SELECT RAISE(ABORT, 'invalid profile label key'); END`, + }, + { + name: "profiles_label_key_immutable", + table: "profiles", + type: "trigger", + sql: `CREATE TRIGGER profiles_label_key_immutable +BEFORE UPDATE OF label,label_key ON profiles +WHEN NEW.label IS NOT OLD.label OR NEW.label_key IS NOT OLD.label_key +BEGIN SELECT RAISE(ABORT, 'profile label identity is immutable'); END`, + }, + { + name: "projects_label_key_insert_guard", + table: "projects", + type: "trigger", + sql: `CREATE TRIGGER projects_label_key_insert_guard +BEFORE INSERT ON projects +WHEN NEW.label_key IS NULL + OR length(CAST(NEW.label_key AS BLOB)) NOT BETWEEN 1 AND 4096 +BEGIN SELECT RAISE(ABORT, 'invalid project label key'); END`, + }, + { + name: "projects_label_key_immutable", + table: "projects", + type: "trigger", + sql: `CREATE TRIGGER projects_label_key_immutable +BEFORE UPDATE OF label,label_key ON projects +WHEN NEW.label IS NOT OLD.label OR NEW.label_key IS NOT OLD.label_key +BEGIN SELECT RAISE(ABORT, 'project label identity is immutable'); END`, + }, +] as const; + +const schemaVersion24 = schemaVersion24Objects + .map((object) => `${object.sql};`) + .join("\n"); + +const dropSchemaVersion24 = [...schemaVersion24Objects] + .reverse() + .map((object) => `DROP ${object.type.toUpperCase()} IF EXISTS ${object.name};`) + .join("\n"); + +const rebuildSchemaVersion24 = (database: Database): void => { + database.exec(dropSchemaVersion24); + database.exec(schemaVersion24); +}; + +const sqliteSchemaObjectRowSchema = z.object({ + name: z.string(), + sql: z.string(), + tbl_name: z.string(), + type: z.enum(["index", "table", "trigger"]), +}).strict(); + +const normalizeSqlStructure = (sql: string): string => + sql.replace(/\s+/gu, " ").trim().replace(/;$/u, ""); + +const schemaVersion40AdoptionReferenceBase = ` +CREATE TABLE profiles ( + id TEXT PRIMARY KEY, + provider_email TEXT, + codex_account_key TEXT, + state TEXT, + process_generation INTEGER, + updated_at INTEGER +); +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + profile_id TEXT, + provider TEXT, + provider_v39 TEXT, + provider_thread_id TEXT, + state TEXT, + active_turn_id TEXT, + created_at INTEGER, + updated_at INTEGER +); +CREATE TABLE queue_entries (session_id TEXT); +CREATE TABLE provider_interactions ( + session_id TEXT, + profile_id TEXT, + process_generation INTEGER +); +CREATE TABLE session_tasks (session_id TEXT, status TEXT); +`; + +const schemaVersion40AdoptionObjects = (() => { + const expected = new Database(":memory:"); + try { + expected.exec(schemaVersion40AdoptionReferenceBase); + expected.exec(schemaVersion40SessionAdoption); + expected.exec("DROP TRIGGER session_account_authority_update_guard"); + expected.exec(schemaVersion40SessionAccountAuthorityUpdateGuard); + expected.exec(schemaVersion40CandidateSourceProcessIdentity); + return expected.query( + `SELECT type,name,tbl_name,sql FROM sqlite_master + WHERE name NOT LIKE 'sqlite_%' + AND name NOT IN ( + 'profiles','sessions','queue_entries','provider_interactions','session_tasks' + ) + ORDER BY type,name`, + ).all().map((row) => sqliteSchemaObjectRowSchema.parse(row)); + } finally { + expected.close(false); + } +})(); + +const legacyVersion36SessionAdoptionCandidatesSql = (() => { + const expected = schemaVersion40AdoptionObjects.find( + (object) => object.name === "session_adoption_candidates", + ); + if (expected === undefined) { + throw new Error("STATE_SCHEMA_V39_CANDIDATE_DEFINITION_MISSING"); + } + const withoutLastLive = expected.sql.replace( + schemaVersion40CandidateLastLiveObservedAtColumn, + "", + ); + if (withoutLastLive === expected.sql) { + throw new Error("STATE_SCHEMA_V39_CANDIDATE_DEFINITION_INVALID"); + } + const legacy = withoutLastLive.replace( + schemaVersion40CandidateProviderProjectRootColumn, + "", + ); + if (legacy === withoutLastLive) { + throw new Error("STATE_SCHEMA_V39_CANDIDATE_DEFINITION_INVALID"); + } + return legacy; +})(); + +// The first adoption build appended this proof column to an already-created +// launch-intent table. SQLite retains appended columns immediately before the +// table constraint in sqlite_master. Rows from that shape may contain NULL and +// are intentionally retained but remain unconsumable; no other table variant +// is admitted. +const legacyAlteredSessionClaudeProcessLaunchIntentsSql = ` +CREATE TABLE session_claude_process_launch_intents ( + intent_id TEXT NOT NULL UNIQUE CHECK(length(intent_id)=36), + provider_thread_id TEXT NOT NULL CHECK(length(provider_thread_id) BETWEEN 1 AND 200), + profile_id TEXT NOT NULL REFERENCES profiles(id), + profile_generation INTEGER NOT NULL CHECK(profile_generation BETWEEN 0 AND 9007199254740991), + runtime_scope TEXT NOT NULL CHECK(runtime_scope IN ('managed','personal')), + session_id TEXT REFERENCES sessions(id), + revision INTEGER NOT NULL CHECK(revision BETWEEN 1 AND 9007199254740991), + staged_at INTEGER NOT NULL CHECK(staged_at >= 0), + updated_at INTEGER NOT NULL CHECK(updated_at >= staged_at), + provider_account_key TEXT CHECK( + provider_account_key IS NULL OR ( + length(provider_account_key)=74 + AND substr(provider_account_key,1,10)='v1:claude:' + AND substr(provider_account_key,11) NOT GLOB '*[^0-9a-f]*' + ) + ), + PRIMARY KEY(runtime_scope,profile_id,provider_thread_id) +) STRICT`; + +const schemaVersion40AdoptionObjectSqlMatches = ( + observed: z.infer, + expected: z.infer, + options: Readonly<{ + allowLegacyCandidateWithoutRetentionColumns?: boolean; + useLegacySessionProviderColumn?: boolean; + }> = {}, +): boolean => { + const observedSql = normalizeSqlStructure( + observed.sql.replace(/\bIF NOT EXISTS\b/giu, ""), + ); + const expectedSql = normalizeSqlStructure( + expected.sql.replace(/\bIF NOT EXISTS\b/giu, ""), + ); + if (observedSql === expectedSql) return true; + const legacyProviderSql = expectedSql.replace(/\bprovider_v39\b/gu, "provider"); + if ( + options.useLegacySessionProviderColumn === true + && observedSql === legacyProviderSql + ) return true; + if ( + expected.name === "session_claude_process_launch_intents" + && observedSql === normalizeSqlStructure(legacyAlteredSessionClaudeProcessLaunchIntentsSql) + ) return true; + const legacyCandidateSql = normalizeSqlStructure( + legacyVersion36SessionAdoptionCandidatesSql.replace(/\bprovider_v39\b/gu, "provider"), + ); + return options.allowLegacyCandidateWithoutRetentionColumns === true + && expected.name === "session_adoption_candidates" + && observedSql === ( + options.useLegacySessionProviderColumn === true + ? legacyCandidateSql + : normalizeSqlStructure(legacyVersion36SessionAdoptionCandidatesSql) + ); +}; + +const rebuildSchemaVersion40RecreatableObjects = (database: Database): void => { + for (const expected of schemaVersion40AdoptionObjects) { + if (expected.type === "table") continue; + if (!/^[a-z0-9_]+$/u.test(expected.name)) { + throw new Error("STATE_SCHEMA_V39_OBJECT_NAME_INVALID"); + } + const observed = z.object({ type: z.string() }).strict().safeParse( + database.query("SELECT type FROM sqlite_master WHERE name=?").get(expected.name), + ); + if (!observed.success) continue; + if (observed.data.type !== "index" && observed.data.type !== "trigger") { + throw new Error(`STATE_SCHEMA_V39_OBJECT_INVALID:${expected.name}`); + } + database.exec(`DROP ${observed.data.type.toUpperCase()} "${expected.name}"`); + } +}; + +const assertSchemaVersion40AdoptionObjects = ( + database: Database, + options: Readonly<{ + allowLegacyCandidateWithoutRetentionColumns?: boolean; + useLegacySessionProviderColumn?: boolean; + }> = {}, +): void => { + for (const expected of schemaVersion40AdoptionObjects) { + const raw = database.query( + "SELECT type,name,tbl_name,sql FROM sqlite_master WHERE name=?", + ).get(expected.name); + if (raw === null) throw new Error(`STATE_SCHEMA_V39_OBJECT_MISSING:${expected.name}`); + const observed = sqliteSchemaObjectRowSchema.safeParse(raw); + if (!observed.success) { + throw new Error(`STATE_SCHEMA_V39_OBJECT_INVALID:${expected.name}`); + } + if ( + observed.data.type !== expected.type + || observed.data.tbl_name !== expected.tbl_name + || !schemaVersion40AdoptionObjectSqlMatches(observed.data, expected, options) + ) throw new Error(`STATE_SCHEMA_V39_OBJECT_INVALID:${expected.name}`); + } +}; + +type LegacySessionAdoptionSchema = "absent" | "version35" | "version36" | "version39"; + +const legacyVersion35AdoptionManifestDigest = + "21f79c06747e4c2fae52269a5d9abb8652e4c39382d741be57759a91f823acc6"; +const legacyVersion35OmittedObjectNames = new Set([ + "profile_codex_account_key_insert_guard", + "profile_codex_account_key_update_guard", + "session_adoption_profile_unidentified_policy_disable", +]); +const legacyVersion35AdoptionObjectNames = schemaVersion40AdoptionObjects + .map((object) => object.name) + .filter((name) => !legacyVersion35OmittedObjectNames.has(name)); +const schemaVersion40AdoptionObjectNames = schemaVersion40AdoptionObjects.map((object) => object.name); +const schemaVersion40AdoptionTableNames = schemaVersion40AdoptionObjects + .filter((object) => object.type === "table") + .map((object) => object.name); +const adoptionAwareWorkObjects = [ + { name: "work_attempt_account_authority_guard", table: "work_attempts" }, + { name: "work_coordinator_account_authority_guard", table: "works" }, + { name: "work_member_account_authority_guard", table: "work_members" }, + { name: "work_review_account_authority_guard", table: "work_reviews" }, + { name: "work_signal_account_authority_guard", table: "work_signals" }, + { name: "work_signal_ack_account_authority_guard", table: "work_signal_receipts" }, +] as const; +const adoptionAwareWorkObjectNames: ReadonlySet = new Set( + adoptionAwareWorkObjects.map((object) => object.name), +); +const legacyAdoptionWorkManifestDigest = + "881fb8d19adcd021220499be539264a6145fa3fa0ce6e80e089016dc77852541"; +const legacyVersion39AdoptionWorkManifestDigest = + "3735af40d7bf0ea29f2ec7600f417b18c2e3383db1df1529100ba37b7aeff1e6"; + +const adoptionManifestDigest = ( + rows: readonly z.infer[], +): string => createHash("sha256").update(rows.map((row) => [ + row.type, + row.name, + row.tbl_name, + normalizeSqlStructure(row.sql.replace(/\bIF NOT EXISTS\b/giu, "")), +].join("\u0000")).join("\n")).digest("hex"); + +// Keep the historical v35/v36 digest frozen above, but derive the current +// v39 authority surface from the Work schema that a migration will install. +// This makes the state-level adoption assertion reject a database whose six +// provider-authority triggers merely retain the expected names. +const generatedCurrentAdoptionWorkManifestDigest = (() => { + const rows = [...adoptionAwareWorkObjects] + .sort((left, right) => left.name.localeCompare(right.name)) + .map((object) => { + const markers = [ + `CREATE TRIGGER ${object.name}\n`, + `CREATE TRIGGER IF NOT EXISTS ${object.name}\n`, + ]; + const start = markers + .map((marker) => WORK_SCHEMA_SQL.indexOf(marker)) + .find((offset) => offset >= 0) ?? -1; + const end = WORK_SCHEMA_SQL.indexOf("END;", start); + if (start < 0 || end < 0) { + throw new Error(`STATE_SCHEMA_V39_WORK_DEFINITION_INVALID:${object.name}`); + } + return { + name: object.name, + sql: WORK_SCHEMA_SQL.slice(start, end + 4), + tbl_name: object.table, + type: "trigger" as const, + }; + }); + return adoptionManifestDigest(rows); +})(); +const currentAdoptionWorkManifestDigest = + "a09e1dfb270546540d5c8f7741d00b749c40faacce1ab5297c4979417b0db940"; +if (generatedCurrentAdoptionWorkManifestDigest !== currentAdoptionWorkManifestDigest) { + throw new Error( + `STATE_SCHEMA_V40_WORK_DEFINITION_DIGEST_INVALID:${generatedCurrentAdoptionWorkManifestDigest}`, + ); +} + +const adoptionFootprintObjects = ( + database: Database, +): readonly z.infer[] => { + const expectedNames = new Set(schemaVersion40AdoptionObjectNames); + const adoptionTables = new Set(schemaVersion40AdoptionTableNames); + return database.query( + `SELECT type,name,tbl_name,sql FROM sqlite_master + WHERE type IN ('table','index','trigger') + AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL + ORDER BY type,name`, + ).all().map((row) => sqliteSchemaObjectRowSchema.parse(row)).filter((row) => { + // The profiles table owns the adoption proof column, but is an older host + // table rather than an adoption object. Its exact column declaration and + // values are asserted separately by the legacy recognizer. + if (row.type === "table" && row.name === "profiles") return false; + if ( + expectedNames.has(row.name) + || adoptionAwareWorkObjectNames.has(row.name) + || adoptionTables.has(row.tbl_name) + ) return true; + if (row.name.startsWith("session_adoption_") + || row.name.startsWith("session_personal_runtime_") + || row.name.startsWith("session_claude_process_") + || row.name.startsWith("provider_runtime_account_") + || row.name.startsWith("profile_personal_authority_")) return true; + const normalized = normalizeSqlStructure(row.sql).toLowerCase(); + return normalized.includes("codex_account_key") + || schemaVersion40AdoptionTableNames.some((name) => normalized.includes(name)); + }); +}; + +const assertNoUnexpectedAdoptionFootprintObjects = ( + rows: readonly z.infer[], + expectedNames: ReadonlySet, +): void => { + const unexpected = rows.find((row) => + !expectedNames.has(row.name) && !adoptionAwareWorkObjectNames.has(row.name)); + if (unexpected !== undefined) { + throw new Error(`STATE_SCHEMA_V39_LEGACY_ADOPTION_OBJECT_INVALID:${unexpected.name}`); + } +}; + +const assertExactLegacyAdoptionWorkObjects = ( + rows: readonly z.infer[], +): void => { + const workRows = rows.filter((row) => adoptionAwareWorkObjectNames.has(row.name)); + if ( + workRows.length !== adoptionAwareWorkObjectNames.size + || adoptionManifestDigest(workRows) !== legacyAdoptionWorkManifestDigest + ) throw new Error("STATE_SCHEMA_V39_LEGACY_ADOPTION_WORK_INVALID"); +}; + +const assertExactCurrentAdoptionWorkObjects = ( + rows: readonly z.infer[], +): void => { + const workRows = rows.filter((row) => adoptionAwareWorkObjectNames.has(row.name)); + if ( + workRows.length !== adoptionAwareWorkObjectNames.size + || adoptionManifestDigest(workRows) !== currentAdoptionWorkManifestDigest + ) throw new Error("STATE_SCHEMA_V39_ADOPTION_WORK_INVALID"); +}; + +const schemaVersion40ProfileCodexAccountKeyColumnSql = normalizeSqlStructure( + schemaVersion40ProfileCodexAccountKeyColumn, +); + +const assertSchemaVersion40ProfileCodexAccountKeys = (database: Database): void => { + const profileSql = z.object({ sql: z.string() }).strict().safeParse(database.query( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='profiles'", + ).get()); + if ( + !profileSql.success + || !normalizeSqlStructure(profileSql.data.sql) + .includes(schemaVersion40ProfileCodexAccountKeyColumnSql) + ) throw new Error("STATE_SCHEMA_V39_PROFILE_CODEX_ACCOUNT_KEY_INVALID"); + const profiles = z.object({ + provider_email: z.string().nullable(), + codex_account_key: z.string().nullable(), + }).strict().array().parse(database.query( + "SELECT provider_email,codex_account_key FROM profiles ORDER BY id", + ).all()); + if (profiles.some((profile) => profile.codex_account_key !== ( + profile.provider_email === null + ? null + : codexProviderAccountAuthorityKeyForEmail(profile.provider_email) + ))) throw new Error("STATE_SCHEMA_V39_PROFILE_CODEX_ACCOUNT_KEY_INVALID"); +}; + +const assertExactSchemaVersion40AdoptionSurface = (database: Database): void => { + const rows = adoptionFootprintObjects(database); + const expectedNames = new Set([ + ...schemaVersion40AdoptionObjectNames, + ...adoptionAwareWorkObjectNames, + ]); + assertNoUnexpectedAdoptionFootprintObjects(rows, expectedNames); + if ( + rows.length !== expectedNames.size + || rows.some((row) => !expectedNames.has(row.name)) + ) throw new Error("STATE_SCHEMA_V39_ADOPTION_SURFACE_INVALID"); + assertExactCurrentAdoptionWorkObjects(rows); + assertSchemaVersion40ProfileCodexAccountKeys(database); +}; + +const assertLegacyAdoptionMigrationTail = ( + database: Database, + expectedVersion: 35 | 36 | 39, +): void => { + const rows = z.object({ version: z.number().int() }).strict().array().parse( + database.query( + "SELECT version FROM migrations WHERE version>=35 ORDER BY version", + ).all(), + ); + const expected = expectedVersion === 35 + ? [35] + : expectedVersion === 36 + ? [35, 36] + : [35, 36, 37, 38, 39]; + if ( + rows.length !== expected.length + || rows.some((row, index) => row.version !== expected[index]) + ) throw new Error("STATE_SCHEMA_V39_LEGACY_ADOPTION_LEDGER_INVALID"); +}; + +const assertNoPostAdoptionFeatureObjects = (database: Database): void => { + const disallowedNames = [ + ...schemaVersion36NotificationHoursObjects.map((object) => object.name), + ...schemaVersion37AttentionEmailPolicyObjects.map((object) => object.name), + ]; + const placeholders = disallowedNames.map(() => "?").join(","); + if (database.query( + `SELECT 1 FROM sqlite_master WHERE name IN (${placeholders}) LIMIT 1`, + ).get(...disallowedNames) !== null) { + throw new Error("STATE_SCHEMA_V39_LEGACY_ADOPTION_FEATURE_COLLISION"); + } + if ( + hasTableColumn(database, "sessions", "preset_contract") + || hasTableColumn(database, "works", "preset_contract") + ) throw new Error("STATE_SCHEMA_V39_LEGACY_ADOPTION_PRESET_COLLISION"); +}; + +const assertAdoptionRowRelationships = (database: Database): void => { + if (database.query("PRAGMA foreign_key_check").all().length !== 0) { + throw new Error("STATE_SCHEMA_V39_LEGACY_ADOPTION_FOREIGN_KEY_INVALID"); + } + const invalid = database.query(` + SELECT 1 FROM session_account_authorities a + LEFT JOIN sessions s ON s.id=a.session_id + WHERE s.id IS NULL OR s.profile_id!=a.profile_id + UNION ALL + SELECT 1 FROM session_provider_account_authorities a + LEFT JOIN sessions s ON s.id=a.session_id + WHERE s.id IS NULL OR s.provider!=a.provider + UNION ALL + SELECT 1 FROM session_personal_runtime_bindings b + LEFT JOIN sessions s ON s.id=b.session_id + WHERE s.id IS NULL OR s.provider!=b.provider + OR s.provider_thread_id IS NOT b.provider_thread_id + UNION ALL + SELECT 1 FROM session_personal_runtime_bindings b + LEFT JOIN session_adoption_candidates c + ON c.provider=b.provider AND c.provider_thread_id=b.provider_thread_id + WHERE c.provider IS NULL OR (b.state='active' AND c.claim_status!='adopted') + UNION ALL + SELECT 1 FROM session_claude_process_authorities a + JOIN sessions s ON s.id=a.session_id + WHERE a.state='bound' AND ( + s.provider!='claude' OR s.profile_id!=a.profile_id + OR s.provider_thread_id IS NOT a.provider_thread_id + ) + LIMIT 1 + `).get(); + if (invalid !== null) { + throw new Error("STATE_SCHEMA_V39_LEGACY_ADOPTION_RELATIONSHIP_INVALID"); + } +}; + +const assertExactLegacyVersion35Adoption = ( + database: Database, + footprint: readonly z.infer[], +): void => { + assertLegacyAdoptionMigrationTail(database, 35); + assertNoPostAdoptionFeatureObjects(database); + if (hasTableColumn(database, "profiles", "codex_account_key")) { + throw new Error("STATE_SCHEMA_V39_LEGACY_ADOPTION_PROFILE_INVALID"); + } + const expectedNames = new Set(legacyVersion35AdoptionObjectNames); + assertNoUnexpectedAdoptionFootprintObjects(footprint, expectedNames); + assertExactLegacyAdoptionWorkObjects(footprint); + const manifest = footprint.filter((row) => expectedNames.has(row.name)); + if ( + manifest.length !== legacyVersion35AdoptionObjectNames.length + || adoptionManifestDigest(manifest) !== legacyVersion35AdoptionManifestDigest + ) throw new Error("STATE_SCHEMA_V39_LEGACY_ADOPTION_V35_INVALID"); + const providerSwitchNames = schemaVersion35Objects.map((object) => object.name); + const providerSwitchPlaceholders = providerSwitchNames.map(() => "?").join(","); + if (database.query( + `SELECT 1 FROM sqlite_master + WHERE name IN (${providerSwitchPlaceholders}) LIMIT 1`, + ).get(...providerSwitchNames) !== null) { + throw new Error("STATE_SCHEMA_V39_LEGACY_ADOPTION_PROVIDER_SWITCH_COLLISION"); + } + assertAdoptionRowRelationships(database); +}; + +const assertExactLegacyVersion36Adoption = ( + database: Database, + footprint: readonly z.infer[], +): void => { + assertLegacyAdoptionMigrationTail(database, 36); + assertNoPostAdoptionFeatureObjects(database); + const expectedNames = new Set(schemaVersion40AdoptionObjectNames); + assertNoUnexpectedAdoptionFootprintObjects(footprint, expectedNames); + assertExactLegacyAdoptionWorkObjects(footprint); + if (footprint.filter((row) => expectedNames.has(row.name)).length !== expectedNames.size) { + throw new Error("STATE_SCHEMA_V39_LEGACY_ADOPTION_V36_INVALID"); + } + assertSchemaVersion35Objects(database); + assertSchemaVersion40AdoptionObjects(database, { + allowLegacyCandidateWithoutRetentionColumns: true, + useLegacySessionProviderColumn: true, + }); + try { + assertSchemaVersion40ProfileCodexAccountKeys(database); + } catch { + throw new Error("STATE_SCHEMA_V39_LEGACY_ADOPTION_PROFILE_INVALID"); + } + assertAdoptionRowRelationships(database); +}; + +const assertExactLegacyVersion39Adoption = ( + database: Database, + footprint: readonly z.infer[], +): void => { + assertLegacyAdoptionMigrationTail(database, 39); + if ( + hasTableColumn(database, "sessions", "provider_v39") + || database.query( + "SELECT 1 FROM sqlite_master WHERE name IN ('session_mutation_authority_rebinds_v39','session_mutation_authority_rebinds_v39_immutable_update','session_mutation_authority_rebinds_v39_immutable_delete') LIMIT 1", + ).get() !== null + ) throw new Error("STATE_SCHEMA_V39_LEGACY_ADOPTION_PROVIDER_COLLISION"); + const expectedNames = new Set(schemaVersion40AdoptionObjectNames); + assertNoUnexpectedAdoptionFootprintObjects(footprint, expectedNames); + const workRows = footprint.filter((row) => adoptionAwareWorkObjectNames.has(row.name)); + if ( + workRows.length !== adoptionAwareWorkObjectNames.size + || adoptionManifestDigest(workRows) !== legacyVersion39AdoptionWorkManifestDigest + ) throw new Error("STATE_SCHEMA_V39_LEGACY_ADOPTION_WORK_INVALID"); + if (footprint.filter((row) => expectedNames.has(row.name)).length !== expectedNames.size) { + throw new Error("STATE_SCHEMA_V39_LEGACY_ADOPTION_V39_INVALID"); + } + assertSchemaVersion35Objects(database); + assertSchemaVersion38PresetContracts(database); + assertCompositeNotificationPolicy(database); + assertSchemaVersion40AdoptionObjects(database, { + useLegacySessionProviderColumn: true, + }); + try { + assertSchemaVersion40ProfileCodexAccountKeys(database); + } catch { + throw new Error("STATE_SCHEMA_V39_LEGACY_ADOPTION_PROFILE_INVALID"); + } + assertAdoptionRowRelationships(database); +}; + +const classifyLegacySessionAdoptionSchema = ( + database: Database, + initialVersion: number, +): LegacySessionAdoptionSchema => { + const footprint = adoptionFootprintObjects(database); + const hasProfileColumn = hasTableColumn(database, "profiles", "codex_account_key"); + if (footprint.length === 0 && !hasProfileColumn) return "absent"; + if (initialVersion === 35) { + assertExactLegacyVersion35Adoption(database, footprint); + return "version35"; + } + if (initialVersion === 36) { + assertExactLegacyVersion36Adoption(database, footprint); + return "version36"; + } + if (initialVersion === 39) { + assertExactLegacyVersion39Adoption(database, footprint); + return "version39"; + } + throw new Error(`STATE_SCHEMA_V39_LEGACY_ADOPTION_UNRECOGNIZED:${initialVersion}`); +}; + +const schemaVersion36NotificationHoursObjectSql = ( + object: (typeof schemaVersion36NotificationHoursObjects)[number], +): string => { + const marker = object.type === "table" + ? `CREATE TABLE IF NOT EXISTS ${object.name}` + : `CREATE TRIGGER IF NOT EXISTS ${object.name}`; + const start = schemaVersion36NotificationHours.indexOf(marker); + const terminator = object.type === "table" ? ") STRICT;" : "END;"; + const end = schemaVersion36NotificationHours.indexOf(terminator, start); + if (start < 0 || end < 0) { + throw new Error("STATE_SCHEMA_V36_NOTIFICATION_HOURS_DEFINITION_INVALID"); + } + return schemaVersion36NotificationHours.slice(start, end + terminator.length); +}; + +const assertSchemaVersion36NotificationHoursObjects = ( + database: Database, +): void => { + const names = schemaVersion36NotificationHoursObjects + .map((object) => `'${object.name}'`).join(","); + const rows = database.query( + `SELECT type,name,tbl_name,sql FROM sqlite_master + WHERE name IN (${names}) ORDER BY name`, + ).all().map((row) => sqliteSchemaObjectRowSchema.parse(row)); + if (rows.length !== schemaVersion36NotificationHoursObjects.length) { + throw new Error("STATE_SCHEMA_V36_NOTIFICATION_HOURS_STRUCTURE_INVALID"); + } + for (const expected of schemaVersion36NotificationHoursObjects) { + const observed = rows.find((row) => row.name === expected.name); + const observedSql = observed?.sql.replace(/\bIF NOT EXISTS\b/giu, ""); + const expectedSql = schemaVersion36NotificationHoursObjectSql(expected) + .replace(/\bIF NOT EXISTS\b/giu, ""); + if ( + observed === undefined + || observed.type !== expected.type + || observed.tbl_name !== expected.table + || normalizeSqlStructure(observedSql ?? "") + !== normalizeSqlStructure(expectedSql) + ) throw new Error("STATE_SCHEMA_V36_NOTIFICATION_HOURS_STRUCTURE_INVALID"); + } +}; + +const schemaVersion37AttentionEmailPolicyObjectSql = ( + object: (typeof schemaVersion37AttentionEmailPolicyObjects)[number], +): string => { + const marker = object.type === "table" + ? `CREATE TABLE IF NOT EXISTS ${object.name}` + : `CREATE TRIGGER IF NOT EXISTS ${object.name}`; + const start = schemaVersion37AttentionEmailPolicy.indexOf(marker); + const terminator = object.type === "table" ? ") STRICT;" : "END;"; + const end = schemaVersion37AttentionEmailPolicy.indexOf(terminator, start); + if (start < 0 || end < 0) { + throw new Error("STATE_SCHEMA_V37_ATTENTION_EMAIL_DEFINITION_INVALID"); + } + return schemaVersion37AttentionEmailPolicy.slice( + start, + end + terminator.length, + ); +}; + +const assertSchemaVersion37AttentionEmailPolicyObjects = ( + database: Database, +): void => { + const names = schemaVersion37AttentionEmailPolicyObjects + .map((object) => `'${object.name}'`).join(","); + const rows = database.query( + `SELECT type,name,tbl_name,sql FROM sqlite_master + WHERE name IN (${names}) ORDER BY name`, + ).all().map((row) => sqliteSchemaObjectRowSchema.parse(row)); + if (rows.length !== schemaVersion37AttentionEmailPolicyObjects.length) { + throw new Error("STATE_SCHEMA_V37_ATTENTION_EMAIL_STRUCTURE_INVALID"); + } + for (const expected of schemaVersion37AttentionEmailPolicyObjects) { + const observed = rows.find((row) => row.name === expected.name); + const observedSql = observed?.sql.replace(/\bIF NOT EXISTS\b/giu, ""); + const expectedSql = schemaVersion37AttentionEmailPolicyObjectSql(expected) + .replace(/\bIF NOT EXISTS\b/giu, ""); + if ( + observed === undefined + || observed.type !== expected.type + || observed.tbl_name !== expected.table + || normalizeSqlStructure(observedSql ?? "") + !== normalizeSqlStructure(expectedSql) + ) throw new Error("STATE_SCHEMA_V37_ATTENTION_EMAIL_STRUCTURE_INVALID"); + } +}; + +const assertAttentionEmailPolicy = (database: Database): NotificationEmailPolicy => { + assertSchemaVersion37AttentionEmailPolicyObjects(database); + const row = database.query( + `SELECT version,enabled,revision,created_at,updated_at + FROM attention_email_policy WHERE singleton=1`, + ).get(); + if (row === null) throw new Error("ATTENTION_EMAIL_POLICY_MISSING"); + try { + return mapNotificationEmailPolicy(row); + } catch (error: unknown) { + throw new Error("ATTENTION_EMAIL_POLICY_INVALID", { cause: error }); + } +}; + +const assertCompositeNotificationPolicy = (database: Database): void => { + assertNotificationHoursPolicy(database); + const attentionEmail = assertAttentionEmailPolicy(database); + const hoursRow = database.query( + "SELECT revision FROM notification_hours WHERE singleton=1", + ).get(); + const hoursRevision = z.object({ + revision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + }).strict().parse(hoursRow).revision; + if (hoursRevision !== attentionEmail.revision) { + throw new Error("NOTIFICATION_POLICY_REVISION_DIVERGED"); + } +}; + +const hasExactAttachedSchemaObjects = ( + database: Database, + table: "attention_email_policy" | "notification_hours", + expectedNames: readonly string[], +): boolean => { + const rows = z.object({ name: z.string() }).strict().array().parse( + database.query( + `SELECT name FROM sqlite_master + WHERE name NOT LIKE 'sqlite_%' AND (name=? OR tbl_name=?) + ORDER BY name`, + ).all(table, table), + ); + return rows.length === expectedNames.length + && rows.every((row, index) => row.name === expectedNames[index]); +}; + +/** + * The notification feature branch briefly stamped hours as v35 and email as + * v36 before main assigned v35 to provider-switch custody. Only that exact, + * internally consistent schema may carry an already-enabled local opt-in + * across the renumbering. The hosted global gate remains a separate authority. + */ +const isExactLegacyFeatureVersion36 = ( + database: Database, + initialVersion: number, +): boolean => { + if (initialVersion !== 36) return false; + const migrationRows = z.object({ version: z.number().int() }).strict().array().parse( + database.query( + "SELECT version FROM migrations WHERE version BETWEEN 35 AND 37 ORDER BY version", + ).all(), + ); + if ( + migrationRows.length !== 2 + || migrationRows[0]?.version !== 35 + || migrationRows[1]?.version !== 36 + ) return false; + + const providerObjectNames = schemaVersion35Objects.map((object) => object.name); + const placeholders = providerObjectNames.map(() => "?").join(","); + const providerObjectCount = z.object({ count: z.number().int().nonnegative() }) + .strict().parse(database.query( + `SELECT COUNT(*) AS count FROM sqlite_master WHERE name IN (${placeholders})`, + ).get(...providerObjectNames)).count; + if (providerObjectCount !== 0) return false; + + const hoursObjectNames = schemaVersion36NotificationHoursObjects + .map((object) => object.name).sort(); + const emailObjectNames = schemaVersion37AttentionEmailPolicyObjects + .map((object) => object.name).sort(); + if ( + !hasExactAttachedSchemaObjects(database, "notification_hours", hoursObjectNames) + || !hasExactAttachedSchemaObjects(database, "attention_email_policy", emailObjectNames) + ) return false; + + assertCompositeNotificationPolicy(database); + return true; +}; + +const applySchemaVersion37AttentionEmailPolicy = ( + database: Database, + migratedAt: number, + allowExistingEnabledPolicy = false, +): void => { + database.exec(schemaVersion37AttentionEmailPolicy); + const existing = database.query( + "SELECT 1 FROM attention_email_policy WHERE singleton=1", + ).get(); + if (existing === null) { + const hoursRow = database.query( + "SELECT revision FROM notification_hours WHERE singleton=1", + ).get(); + const revision = z.object({ + revision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + }).strict().parse(hoursRow).revision; + database.query( + `INSERT INTO attention_email_policy( + singleton,version,enabled,revision,created_at,updated_at + ) VALUES (1,1,0,?,?,?)`, + ).run(revision, migratedAt, migratedAt); + } else if (assertAttentionEmailPolicy(database).enabled && !allowExistingEnabledPolicy) { + // Canonical schema v36 never shipped an opt-in. Only an exactly recognized + // feature-v36 database may retain the explicit local choice it already held. + throw new Error("ATTENTION_EMAIL_POLICY_MIGRATION_OPT_IN_REFUSED"); + } + assertCompositeNotificationPolicy(database); +}; + +// Preset aliases are durable user intent, but their exact model mapping has +// changed once. Existing and provider-imported rows retain the legacy mapping; +// HRA-created or explicitly reselected rows are stamped current at their write +// boundary. `works` is installed by WorkStore in the same database and carries +// the same contract so a claim cannot reinterpret its route mid-flight. +const schemaVersion38SessionPresetContractColumn = + `ALTER TABLE sessions ADD COLUMN preset_contract INTEGER NOT NULL DEFAULT ${legacyPresetContract} ` + + `CHECK(preset_contract IN (${legacyPresetContract},${currentPresetContract}))`; +const schemaVersion38WorkPresetContractColumn = + `ALTER TABLE works ADD COLUMN preset_contract INTEGER NOT NULL DEFAULT ${legacyPresetContract} ` + + `CHECK(preset_contract IN (${legacyPresetContract},${currentPresetContract}))`; + +const applySchemaVersion38PresetContracts = (database: Database): void => { + if (!hasTableColumn(database, "sessions", "preset_contract")) { + database.exec(schemaVersion38SessionPresetContractColumn); + } + const workTableExists = database.query( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='works'", + ).get() !== null; + if (workTableExists && !hasTableColumn(database, "works", "preset_contract")) { + database.exec(schemaVersion38WorkPresetContractColumn); + } +}; + +const assertSchemaVersion38PresetContracts = (database: Database): void => { + if (!hasTableColumn(database, "sessions", "preset_contract")) { + throw new Error("STATE_SCHEMA_V38_SESSION_PRESET_CONTRACT_MISSING"); + } + if (database.query( + `SELECT 1 FROM sessions + WHERE preset_contract IS NULL OR preset_contract NOT IN (${legacyPresetContract},${currentPresetContract}) + LIMIT 1`, + ).get() !== null) { + throw new Error("STATE_SCHEMA_V38_SESSION_PRESET_CONTRACT_INVALID"); + } + const workTableExists = database.query( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='works'", + ).get() !== null; + if (workTableExists && !hasTableColumn(database, "works", "preset_contract")) { + throw new Error("STATE_SCHEMA_V38_WORK_PRESET_CONTRACT_MISSING"); + } + if (workTableExists && database.query( + `SELECT 1 FROM works + WHERE preset_contract IS NULL OR preset_contract NOT IN (${legacyPresetContract},${currentPresetContract}) + LIMIT 1`, + ).get() !== null) { + throw new Error("STATE_SCHEMA_V38_WORK_PRESET_CONTRACT_INVALID"); + } +}; + +const assertSchemaVersion24Objects = (database: Database): void => { + const names = schemaVersion24Objects.map((object) => `'${object.name}'`).join(","); + const rows = database.query( + `SELECT type,name,tbl_name,sql FROM sqlite_master + WHERE name IN (${names}) ORDER BY name`, + ).all().map((row) => sqliteSchemaObjectRowSchema.parse(row)); + if (rows.length !== schemaVersion24Objects.length) { + throw new Error("STATE_SCHEMA_V24_STRUCTURE_INVALID"); + } + for (const expected of schemaVersion24Objects) { + const observed = rows.find((row) => row.name === expected.name); + if ( + observed === undefined + || observed.type !== expected.type + || observed.tbl_name !== expected.table + || normalizeSqlStructure(observed.sql) !== normalizeSqlStructure(expected.sql) + ) throw new Error("STATE_SCHEMA_V24_STRUCTURE_INVALID"); + } +}; + +const assertSchemaVersion28Objects = (database: Database): void => { + const names = schemaVersion28Objects.map((object) => `'${object.name}'`).join(","); + const rows = database.query( + `SELECT type,name,tbl_name,sql FROM sqlite_master + WHERE name IN (${names}) ORDER BY name`, + ).all().map((row) => sqliteSchemaObjectRowSchema.parse(row)); + if (rows.length !== schemaVersion28Objects.length) { + throw new Error("STATE_SCHEMA_V28_STRUCTURE_INVALID"); + } + for (const expected of schemaVersion28Objects) { + const observed = rows.find((row) => row.name === expected.name); + const observedSql = observed?.sql.replace(/\bIF NOT EXISTS\b/giu, ""); + const expectedSql = schemaVersion28ObjectSql(expected) + .replace(/\bIF NOT EXISTS\b/giu, ""); + if ( + observed === undefined + || observed.type !== expected.type + || observed.tbl_name !== expected.table + || normalizeSqlStructure(observedSql ?? "") + !== normalizeSqlStructure(expectedSql) + ) throw new Error("STATE_SCHEMA_V28_STRUCTURE_INVALID"); + } +}; + +const assertAccountRateLimitResetPolicies = (database: Database): void => { + assertSchemaVersion28Objects(database); + let policies: readonly AccountRateLimitResetPolicyRecord[]; + try { + policies = database.query( + "SELECT * FROM account_rate_limit_reset_policies ORDER BY profile_id", + ).all().map(mapAccountRateLimitResetPolicy); + } catch (error: unknown) { + throw new Error("STATE_ACCOUNT_RATE_LIMIT_RESET_POLICY_INVALID", { cause: error }); + } + const policyProfileIds = new Set(policies.map((policy) => policy.profileId)); + const activeProfileIds = database.query( + "SELECT id FROM profiles WHERE state!='removed' ORDER BY id", + ).all().map((row) => z.object({ id: profileIdSchema }).strict().parse(row).id); + if (activeProfileIds.some((profileId) => !policyProfileIds.has(profileId))) { + throw new Error("STATE_ACCOUNT_RATE_LIMIT_RESET_POLICY_MISSING"); + } + const activeProfileIdSet = new Set(activeProfileIds); + if (policies.some((policy) => !activeProfileIdSet.has(policy.profileId))) { + throw new Error("STATE_ACCOUNT_RATE_LIMIT_RESET_POLICY_ORPHANED"); + } +}; + +const ensureQueueMessageScrubGeneration = (database: Database): void => { + if (!hasTableColumn(database, "queue_message_scrub_authority", "generation")) { + database.exec( + "ALTER TABLE queue_message_scrub_authority ADD COLUMN generation INTEGER NOT NULL DEFAULT 1 CHECK(generation BETWEEN 1 AND 9007199254740991)", + ); + } +}; + +const hasSettledQueueMessagesToScrub = (database: Database): boolean => + database.query( + `SELECT 1 + FROM queue_entries + WHERE message!=? + AND ( + state IN ('applied','failed','cancelled') + OR EXISTS( + SELECT 1 FROM queue_effect_resolutions r + WHERE r.queue_id=queue_entries.id + ) + ) + LIMIT 1`, + ).get(settledQueueMessage) !== null; + +const scrubSettledQueueMessages = (database: Database): boolean => { + const terminal = database.query( + `UPDATE queue_entries + SET message=? + WHERE message!=? + AND state IN ('applied','failed','cancelled')`, + ).run(settledQueueMessage, settledQueueMessage); + const resolved = database.query( + `UPDATE queue_entries + SET message=? + WHERE message!=? + AND EXISTS( + SELECT 1 FROM queue_effect_resolutions r + WHERE r.queue_id=queue_entries.id + )`, + ).run(settledQueueMessage, settledQueueMessage); + return terminal.changes + resolved.changes > 0; +}; + +const userVersionSchema = z.object({ user_version: z.number().int().nonnegative() }).strict(); + +const securityScrubAuthorityRowSchema = z.object({ + reason: z.literal("mcp_url_redaction"), + required_at: unixMillisecondsSchema, +}).strict(); + +const queueMessageScrubAuthorityRowSchema = z.object({ + required_at: unixMillisecondsSchema, + requires_vacuum: z.union([z.literal(0), z.literal(1)]), + generation: z.number().int().positive().safe(), +}).strict(); + +const walCheckpointRowSchema = z.object({ + busy: z.number().int().min(0).max(1), + log: z.number().int().nonnegative(), + checkpointed: z.number().int().nonnegative(), +}).strict(); + +const journalModeRowSchema = z.object({ journal_mode: z.string() }).strict(); + +const requireWalMode = (database: Database, configure = false): void => { + const row = journalModeRowSchema.parse( + database.query(configure ? "PRAGMA journal_mode=WAL" : "PRAGMA journal_mode").get(), + ); + if (row.journal_mode.toLowerCase() !== "wal") throw new Error("STATE_WAL_REQUIRED"); +}; + +const readUserVersion = (database: Database): number => + userVersionSchema.parse(database.query("PRAGMA user_version").get()).user_version; + +const hasPendingSecurityScrub = (database: Database): boolean => { + const row = database.query( + "SELECT reason,required_at FROM security_scrub_authority WHERE singleton=1", + ).get(); + if (row !== null) { + securityScrubAuthorityRowSchema.parse(row); + return true; + } + const queueRow = database.query( + "SELECT required_at,requires_vacuum,generation FROM queue_message_scrub_authority WHERE singleton=1", + ).get(); + if (queueRow === null) return false; + queueMessageScrubAuthorityRowSchema.parse(queueRow); + return true; +}; + +const requireSecurityScrub = (database: Database, requiredAt: number): void => { + database.query( + `INSERT OR IGNORE INTO security_scrub_authority(singleton,reason,required_at) + VALUES (1,'mcp_url_redaction',?)`, + ).run(unixMillisecondsSchema.parse(requiredAt)); +}; + +const requireQueueMessageScrub = ( + database: Database, + requiredAt: number, + requiresVacuum: boolean, +): void => { + const recorded = database.query( + `INSERT INTO queue_message_scrub_authority(singleton,required_at,requires_vacuum,generation) + VALUES (1,?,?,1) + ON CONFLICT(singleton) DO UPDATE SET + required_at=MIN(required_at,excluded.required_at), + requires_vacuum=MAX(requires_vacuum,excluded.requires_vacuum), + generation=generation+1 + WHERE generation<9007199254740991`, + ).run( + unixMillisecondsSchema.parse(requiredAt), + requiresVacuum ? 1 : 0, + ); + if (recorded.changes !== 1) { + throw new Error("QUEUE_MESSAGE_SCRUB_GENERATION_EXHAUSTED"); + } +}; + +const completePendingSecurityScrub = ( + database: Database, + operationCommitted = false, + policy: SecurityScrubCheckpointPolicy = defaultSecurityScrubCheckpointPolicy, +): void => { + if (!hasPendingSecurityScrub(database)) return; + database.exec(`PRAGMA busy_timeout = ${policy.busyTimeoutMs}`); + try { + const truncateWal = (): void => { + // A readonly status read releases its snapshot well inside one attempt. + // A reader that outlives the whole bounded schedule still fails the + // scrub, so a settled body is never reported purged while frames remain. + for (let attempt = 1; ; attempt += 1) { + requireWalMode(database); + const checkpoint = walCheckpointRowSchema.parse( + database.query("PRAGMA wal_checkpoint(TRUNCATE)").get(), + ); + if (checkpoint.busy === 0 && checkpoint.log === 0 && checkpoint.checkpointed === 0) return; + if (attempt >= policy.attempts) { + throw new Error("SQLite could not truncate every WAL frame."); + } + Bun.sleepSync(policy.backoffMs * 2 ** (attempt - 1)); + } + }; + + for (;;) { + const snapshot = database.transaction(() => { + const legacyRow = database.query( + "SELECT reason,required_at FROM security_scrub_authority WHERE singleton=1", + ).get(); + const legacySecurityScrub = legacyRow === null + ? null + : securityScrubAuthorityRowSchema.parse(legacyRow); + const queueRow = database.query( + "SELECT required_at,requires_vacuum,generation FROM queue_message_scrub_authority WHERE singleton=1", + ).get(); + if (queueRow === null) { + return { legacySecurityScrub, queueAuthority: null }; + } + // The authority marker is committed before plaintext removal. This + // connection owns secure_delete=ON, so every body rewrite has known + // physical-deletion semantics even if the settling writer did not. + scrubSettledQueueMessages(database); + const queueAuthority = queueMessageScrubAuthorityRowSchema.parse(database.query( + "SELECT required_at,requires_vacuum,generation FROM queue_message_scrub_authority WHERE singleton=1", + ).get()); + return { legacySecurityScrub, queueAuthority }; + }).immediate(); + const { legacySecurityScrub, queueAuthority } = snapshot; + + if (legacySecurityScrub === null && queueAuthority === null) return; + + // The first truncation removes superseded runtime frames. A legacy + // security migration or an explicitly uncertain queue migration also + // rebuilds the main file so bytes left in free pages cannot survive. + truncateWal(); + if (legacySecurityScrub !== null || queueAuthority?.requires_vacuum === 1) { + database.exec("VACUUM"); + truncateWal(); + } + const cleared = database.transaction(() => { + if (legacySecurityScrub !== null) { + database.query("DELETE FROM security_scrub_authority WHERE singleton=1").run(); + } + if (queueAuthority === null) return true; + return database.query( + "DELETE FROM queue_message_scrub_authority WHERE singleton=1 AND generation=?", + ).run(queueAuthority.generation).changes === 1; + }).immediate(); + // A concurrent settlement advanced the generation after our checkpoint. + // Its authority remains durable and must be scrubbed in the next pass. + if (!cleared) continue; + if (!hasPendingSecurityScrub(database)) return; + } + } catch (cause) { + throw new StateSecurityScrubRequiredError(operationCommitted, cause); + } finally { + database.exec(`PRAGMA busy_timeout = ${stateBusyTimeoutMs}`); + } +}; + +const hasTableColumn = (database: Database, table: string, column: string): boolean => { + const columnSchema = z.object({ name: z.string() }).passthrough(); + if (!/^[a-z_]+$/u.test(table)) throw new Error("Unsafe SQLite table identifier."); + return database.query(`PRAGMA table_info(${table})`).all().some((row) => columnSchema.parse(row).name === column); +}; + +const applySchemaVersion31 = (database: Database): void => { + for (const statement of schemaVersion31Statements) database.query(statement).run(); +}; + +const ensureSessionEventProjectionVersion = (database: Database): void => { + if (!hasTableColumn(database, "session_events", "projection_version")) { + database.exec( + "ALTER TABLE session_events ADD COLUMN projection_version INTEGER NOT NULL DEFAULT 1 CHECK(projection_version IN (1,2))", + ); + } +}; + +const ensureUsagePollFailureAccountFingerprint = (database: Database): void => { + if (!hasTableColumn(database, "usage_poll_failures", "account_fingerprint")) { + database.exec( + `ALTER TABLE usage_poll_failures ADD COLUMN account_fingerprint TEXT + CHECK( + account_fingerprint IS NULL OR ( + length(account_fingerprint)=64 + AND account_fingerprint NOT GLOB '*[^a-f0-9]*' + ) + )`, + ); + } + database.exec( + `CREATE INDEX IF NOT EXISTS usage_poll_failures_identity_recent + ON usage_poll_failures( + profile_id,account_fingerprint,source_revision DESC + )`, + ); +}; + +type LabelIdentityKind = "ACCOUNT" | "PROJECT"; + +class StateLabelInvariantError extends Error { + constructor(kind: LabelIdentityKind, reason: "COLLISION" | "INVALID" | "KEY_INVALID") { + super(`STATE_${kind}_LABEL_${reason}`); + this.name = "StateLabelInvariantError"; + } +} + +const canonicalLabelIdentity = ( + value: unknown, + kind: LabelIdentityKind, +): Readonly<{ key: string; label: string }> => { + const parsed = labelSchema.safeParse(value); + if (!parsed.success || parsed.data !== value) { + throw new StateLabelInvariantError(kind, "INVALID"); + } + const key = canonicalLabelKey(parsed.data); + if (key.length === 0 || utf8Bytes(key) > 4_096) { + throw new StateLabelInvariantError(kind, "KEY_INVALID"); + } + return { key, label: parsed.data }; +}; + +const assertUniqueLabelKeys = ( + keys: readonly string[], + kind: LabelIdentityKind, +): void => { + const seen = new Set(); + for (const key of keys) { + if (seen.has(key)) throw new StateLabelInvariantError(kind, "COLLISION"); + seen.add(key); + } +}; + +const profileLabelMigrationRowSchema = z.object({ + id: profileIdSchema, + label: z.unknown(), + state: profileStateSchema, +}).strict(); +const projectLabelMigrationRowSchema = z.object({ + id: projectIdSchema, + label: z.unknown(), +}).strict(); +const profileLabelInvariantRowSchema = profileLabelMigrationRowSchema.extend({ + label_key: z.unknown(), +}).strict(); +const projectLabelInvariantRowSchema = projectLabelMigrationRowSchema.extend({ + label_key: z.unknown(), +}).strict(); + +const backfillCanonicalLabelKeys = (database: Database): void => { + if (!hasTableColumn(database, "profiles", "label_key")) { + database.exec("ALTER TABLE profiles ADD COLUMN label_key TEXT"); + } + if (!hasTableColumn(database, "projects", "label_key")) { + database.exec("ALTER TABLE projects ADD COLUMN label_key TEXT"); + } + + const profiles = database.query( + "SELECT id,label,state FROM profiles ORDER BY id", + ).all().map((row) => { + const parsed = profileLabelMigrationRowSchema.parse(row); + return { ...parsed, ...canonicalLabelIdentity(parsed.label, "ACCOUNT") }; + }); + assertUniqueLabelKeys( + profiles.filter((profile) => profile.state !== "removed").map((profile) => profile.key), + "ACCOUNT", + ); + + const projects = database.query( + "SELECT id,label FROM projects ORDER BY id", + ).all().map((row) => { + const parsed = projectLabelMigrationRowSchema.parse(row); + return { ...parsed, ...canonicalLabelIdentity(parsed.label, "PROJECT") }; + }); + assertUniqueLabelKeys(projects.map((project) => project.key), "PROJECT"); + + const updateProfile = database.query("UPDATE profiles SET label_key=? WHERE id=?"); + for (const profile of profiles) updateProfile.run(profile.key, profile.id); + const updateProject = database.query("UPDATE projects SET label_key=? WHERE id=?"); + for (const project of projects) updateProject.run(project.key, project.id); +}; + +const assertCanonicalLabelKeys = (database: Database): void => { + if (!hasTableColumn(database, "profiles", "label_key")) { + throw new StateLabelInvariantError("ACCOUNT", "KEY_INVALID"); + } + if (!hasTableColumn(database, "projects", "label_key")) { + throw new StateLabelInvariantError("PROJECT", "KEY_INVALID"); + } + + const profileKeys = database.query( + "SELECT label,label_key,state FROM profiles ORDER BY id", + ).all().map((row) => { + const parsed = profileLabelInvariantRowSchema.omit({ id: true }).parse(row); + const identity = canonicalLabelIdentity(parsed.label, "ACCOUNT"); + if (parsed.label_key !== identity.key) { + throw new StateLabelInvariantError("ACCOUNT", "KEY_INVALID"); + } + return { key: identity.key, state: parsed.state }; + }); + assertUniqueLabelKeys( + profileKeys.filter((profile) => profile.state !== "removed").map((profile) => profile.key), + "ACCOUNT", + ); + + const projectKeys = database.query( + "SELECT label,label_key FROM projects ORDER BY id", + ).all().map((row) => { + const parsed = projectLabelInvariantRowSchema.omit({ id: true }).parse(row); + const identity = canonicalLabelIdentity(parsed.label, "PROJECT"); + if (parsed.label_key !== identity.key) { + throw new StateLabelInvariantError("PROJECT", "KEY_INVALID"); + } + return identity.key; + }); + assertUniqueLabelKeys(projectKeys, "PROJECT"); +}; + +const ensureStableQueueSequence = (database: Database): void => { + if (!hasTableColumn(database, "queue_entries", "enqueue_sequence")) { + database.exec( + "ALTER TABLE queue_entries ADD COLUMN enqueue_sequence INTEGER CHECK(enqueue_sequence IS NULL OR enqueue_sequence BETWEEN 1 AND 9007199254740990)", + ); + } + database.exec(` + WITH ordered AS ( + SELECT rowid,ROW_NUMBER() OVER (ORDER BY rowid) AS enqueue_sequence + FROM queue_entries + ) + UPDATE queue_entries + SET enqueue_sequence=( + SELECT ordered.enqueue_sequence FROM ordered WHERE ordered.rowid=queue_entries.rowid + ) + WHERE enqueue_sequence IS NULL; + `); + database.exec(schemaVersion14); + const exhausted = database.query( + "SELECT 1 FROM queue_entries WHERE enqueue_sequence>=9007199254740991 LIMIT 1", + ).get(); + if (exhausted !== null) throw new Error("QUEUE_SEQUENCE_EXHAUSTED"); + database.query( + `UPDATE queue_sequence_authority + SET next_sequence=MAX( + next_sequence, + COALESCE((SELECT MAX(enqueue_sequence) FROM queue_entries),0)+1 + ) + WHERE singleton=1`, + ).run(); +}; + +const backfillExactTurnRuntimeProfiles = (database: Database, now: number): void => { + const rows = [ + ...database.query(`SELECT p.*,CASE + WHEN m.state='applied' THEN json_extract(m.result_json,'$.turnId') + WHEN r.resolution_kind='proven_applied' THEN json_extract(r.receipt_json,'$.turnId') + ELSE NULL END AS turn_id + FROM session_runtime_profiles p + JOIN mutation_attempts m ON m.id=p.source_id AND p.source_kind='turn_start' + LEFT JOIN mutation_resolutions r ON r.attempt_id=m.id`).all(), + ...database.query(`SELECT p.*,CASE + WHEN r.resolution_kind='proven_applied' THEN json_extract(r.receipt_json,'$.turnId') + ELSE NULL END AS turn_id + FROM session_runtime_profiles p + JOIN queue_entries q ON q.id=p.source_id AND p.source_kind='queue_start' + LEFT JOIN queue_effect_resolutions r ON r.queue_id=q.id`).all(), + ]; + for (const row of rows) { + const parsed = sessionRuntimeProfileRowSchema.extend({ + turn_id: z.string().min(1).max(200).nullable(), + }).parse(row); + if (parsed.turn_id === null || parsed.source_kind === "session_start") continue; + const profile = reviewedRuntimeProfileSchema.parse(JSON.parse(parsed.profile_json) as unknown); + if ( + profile.profileId !== parsed.profile_id + || profile.processGeneration !== parsed.process_generation + || profile.observedAt !== parsed.observed_at + ) throw new Error("Stored runtime profile authority is incoherent."); + database.query( + `INSERT INTO session_turn_runtime_profiles( + session_id,turn_id,source_kind,source_id,profile_id,process_generation, + observed_at,profile_json,profile_digest,recorded_at + ) VALUES (?,?,?,?,?,?,?,?,?,?)`, + ).run( + parsed.session_id, + parsed.turn_id, + parsed.source_kind, + parsed.source_id, + parsed.profile_id, + parsed.process_generation, + parsed.observed_at, + parsed.profile_json, + createHash("sha256").update(parsed.profile_json).digest("hex"), + now, + ); + } +}; -const schemaVersion24 = schemaVersion24Objects - .map((object) => `${object.sql};`) - .join("\n"); +const backfillSchemaVersion9 = (database: Database, now: number): void => { + const sessions = database.query( + "SELECT id FROM sessions WHERE NOT EXISTS(SELECT 1 FROM session_event_streams e WHERE e.session_id=sessions.id) ORDER BY id", + ).all(); + for (const row of sessions) { + const sessionId = z.object({ id: sessionIdSchema }).strict().parse(row).id; + database.query( + `INSERT OR IGNORE INTO session_event_streams( + session_id,stream_epoch,next_sequence,floor_sequence,observed_through_sequence, + retained_count,retained_bytes,retention_gap_reason,created_at,updated_at + ) VALUES (?,?,1,1,0,0,0,NULL,?,?)`, + ).run(sessionId, randomUUID(), now, now); + } + database.exec(`INSERT OR IGNORE INTO usage_revision_authority(profile_id,next_revision) + SELECT p.id,CASE + WHEN MAX(u.source_revision) IS NULL THEN 1 + WHEN MAX(u.source_revision)>=9007199254740991 THEN 9007199254740991 + ELSE MAX(u.source_revision)+1 + END + FROM profiles p LEFT JOIN usage_snapshots u ON u.profile_id=p.id + GROUP BY p.id`); +}; -const dropSchemaVersion24 = [...schemaVersion24Objects] - .reverse() - .map((object) => `DROP ${object.type.toUpperCase()} IF EXISTS ${object.name};`) - .join("\n"); +const backfillSchemaVersion10 = (database: Database): void => { + database.exec(`UPDATE usage_revision_authority + SET next_revision=MAX( + next_revision, + COALESCE(( + SELECT CASE + WHEN MAX(f.source_revision)>=9007199254740991 THEN 9007199254740991 + ELSE MAX(f.source_revision)+1 + END + FROM usage_poll_failures f + WHERE f.profile_id=usage_revision_authority.profile_id + ),1) + )`); +}; -const rebuildSchemaVersion24 = (database: Database): void => { - database.exec(dropSchemaVersion24); - database.exec(schemaVersion24); +const migratedMcpUrlDisplayJson = JSON.stringify(interactionDisplaySchema.parse({ + kind: "mcp_elicitation", + summary: "Unsupported MCP browser handoff canceled during security migration", + serverName: "redacted", + mode: "form", + url: null, + mayContainSecrets: true, +})); + +const legacyMcpUrlInteractionRowSchema = z.object({ + public_id: z.string().uuid(), + state: interactionStateSchema, + revision: z.number().int().positive(), + requested_at: unixMillisecondsSchema, + updated_at: unixMillisecondsSchema, +}).strict(); + +const redactLegacyMcpUrlInteractions = (database: Database, migratedAt: number): boolean => { + const rows = database.query( + `SELECT public_id,state,revision,requested_at,updated_at + FROM provider_interactions + WHERE kind='mcp_elicitation' AND ( + json_extract(display_json,'$.mode')='url' + OR COALESCE(json_type(display_json,'$.url'),'null')!='null' + ) + ORDER BY public_id`, + ).all().map((row) => legacyMcpUrlInteractionRowSchema.parse(row)); + if (rows.length === 0) return false; + + database.exec("DROP TRIGGER IF EXISTS provider_interactions_authority_immutable"); + for (const row of rows) { + const terminalAt = Math.max(migratedAt, row.requested_at, row.updated_at); + if (row.state === "pending" || row.state === "response_prepared" || row.state === "response_written") { + database.query( + `UPDATE provider_interactions + SET state='resolution_unknown',revision=revision+1,display_json=?,updated_at=?,terminal_at=? + WHERE public_id=? AND revision=?`, + ).run(migratedMcpUrlDisplayJson, terminalAt, terminalAt, row.public_id, row.revision); + const migrated = legacyMcpUrlInteractionRowSchema.parse(database.query( + `SELECT public_id,state,revision,requested_at,updated_at + FROM provider_interactions WHERE public_id=?`, + ).get(row.public_id)); + database.query( + `INSERT INTO provider_interaction_transitions( + public_id,revision,state,response_digest,recorded_at + ) SELECT public_id,revision,state,response_digest,? + FROM provider_interactions WHERE public_id=?`, + ).run(migrated.updated_at, migrated.public_id); + } else { + database.query( + "UPDATE provider_interactions SET display_json=? WHERE public_id=? AND revision=?", + ).run(migratedMcpUrlDisplayJson, row.public_id, row.revision); + } + } + database.exec(schemaVersion9); + return true; }; -const sqliteSchemaObjectRowSchema = z.object({ - name: z.string(), - sql: z.string(), - tbl_name: z.string(), - type: z.enum(["index", "table", "trigger"]), +const legacyPermissionDisplaySchema = z.object({ + kind: z.literal("permission_approval"), + summary: z.string().max(4_096), + reason: z.string().max(4_096).nullable(), + requested: z.array(z.object({ + name: z.string().min(1).max(256), + value: z.unknown(), + }).strict()).max(100), + allowsSessionScope: z.boolean(), }).strict(); -const normalizeSqlStructure = (sql: string): string => - sql.replace(/\s+/gu, " ").trim().replace(/;$/u, ""); +const legacyPermissionInteractionRowSchema = z.object({ + public_id: z.string().uuid(), + display_json: z.string(), +}).strict(); -const schemaVersion36NotificationHoursObjectSql = ( - object: (typeof schemaVersion36NotificationHoursObjects)[number], -): string => { - const marker = object.type === "table" - ? `CREATE TABLE IF NOT EXISTS ${object.name}` - : `CREATE TRIGGER IF NOT EXISTS ${object.name}`; - const start = schemaVersion36NotificationHours.indexOf(marker); - const terminator = object.type === "table" ? ") STRICT;" : "END;"; - const end = schemaVersion36NotificationHours.indexOf(terminator, start); - if (start < 0 || end < 0) { - throw new Error("STATE_SCHEMA_V36_NOTIFICATION_HOURS_DEFINITION_INVALID"); +const redactLegacyPermissionValues = (database: Database): boolean => { + const rows = database.query( + `SELECT public_id,display_json + FROM provider_interactions + WHERE kind='permission_approval' AND EXISTS( + SELECT 1 FROM json_each(json_extract(display_json,'$.requested')) + WHERE json_type(value,'$.value') IS NOT NULL + ) + ORDER BY public_id`, + ).all().map((row) => legacyPermissionInteractionRowSchema.parse(row)); + if (rows.length === 0) return false; + + database.exec("DROP TRIGGER IF EXISTS provider_interactions_authority_immutable"); + for (const row of rows) { + const legacy = legacyPermissionDisplaySchema.parse(JSON.parse(row.display_json)); + const display = interactionDisplaySchema.parse({ + ...legacy, + requested: legacy.requested.map(({ name }) => ({ name })), + }); + database.query( + "UPDATE provider_interactions SET display_json=? WHERE public_id=?", + ).run(JSON.stringify(display), row.public_id); + } + database.exec(schemaVersion9); + return true; +}; + +const legacyApprovalDisplaySchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("command_approval"), + summary: z.string().max(4_096), + reason: z.string().max(4_096).nullable(), + commandClass: z.string().min(1).max(256), + workingDirectory: z.string().max(1_024).nullable(), + allowsSessionApproval: z.boolean(), + }).strict(), + z.object({ + kind: z.literal("file_change_approval"), + summary: z.string().max(4_096), + reason: z.string().max(4_096).nullable(), + grantRoot: z.string().max(1_024).nullable(), + allowsSessionApproval: z.boolean(), + }).strict(), +]); + +const backfillExactApprovalDecisions = (database: Database): void => { + const rows = database.query( + `SELECT public_id,display_json FROM provider_interactions + WHERE kind IN ('command_approval','file_change_approval') + AND json_type(display_json,'$.availableDecisions') IS NULL + ORDER BY public_id`, + ).all().map((row) => z.object({ + public_id: z.string().uuid(), + display_json: z.string(), + }).strict().parse(row)); + if (rows.length === 0) return; + + database.exec("DROP TRIGGER IF EXISTS provider_interactions_authority_immutable"); + for (const row of rows) { + const legacy = legacyApprovalDisplaySchema.parse(JSON.parse(row.display_json) as unknown); + const { allowsSessionApproval, ...display } = legacy; + const availableDecisions = [ + "once" as const, + ...(allowsSessionApproval ? ["session" as const] : []), + "decline" as const, + "cancel" as const, + ]; + const migrated = interactionDisplaySchema.parse({ ...display, availableDecisions }); + database.query( + "UPDATE provider_interactions SET display_json=? WHERE public_id=?", + ).run(JSON.stringify(migrated), row.public_id); + } + database.exec(schemaVersion9); +}; + +const usageSnapshotReceivedAt = (snapshot: UsageSnapshotRecord): number => { + const parsed = storedAccountUsageSnapshotSchema.safeParse(snapshot.payload); + return parsed.success ? parsed.data.observation.receivedAt : snapshot.observedAt; +}; + +const recordUsageCloudUploadAnchor = ( + database: Database, + profileId: ProfileId, + sourceRevision: number, + receivedAt: number, +): void => { + const latest = database.query( + `SELECT source_revision,received_at FROM usage_cloud_upload_anchors + WHERE profile_id=? ORDER BY source_revision DESC LIMIT 1`, + ).get(profileId) as { source_revision: number; received_at: number } | null; + if ( + latest !== null + && ( + sourceRevision <= latest.source_revision + || receivedAt < latest.received_at + USAGE_CLOUD_UPLOAD_MIN_INTERVAL_MS + ) + ) return; + database.query( + `INSERT OR IGNORE INTO usage_cloud_upload_anchors( + profile_id,source_revision,received_at + ) VALUES (?,?,?)`, + ).run(profileId, sourceRevision, receivedAt); + database.query( + `DELETE FROM usage_cloud_upload_anchors WHERE rowid IN ( + SELECT rowid FROM usage_cloud_upload_anchors WHERE profile_id=? + ORDER BY source_revision DESC + LIMIT -1 OFFSET ${USAGE_CLOUD_UPLOAD_ANCHOR_COUNT} + )`, + ).run(profileId); +}; + +const backfillUsageCloudUploadAnchors = (database: Database): void => { + const profiles = database.query("SELECT id FROM profiles ORDER BY id").all(); + for (const row of profiles) { + const profileId = z.object({ id: profileIdSchema }).strict().parse(row).id; + const snapshots = database.query( + `SELECT source_revision,observed_at,payload_json FROM usage_snapshots + WHERE profile_id=? ORDER BY source_revision`, + ).all(profileId); + for (const snapshotRow of snapshots) { + const parsed = z.object({ + source_revision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + observed_at: unixMillisecondsSchema, + payload_json: z.string(), + }).strict().parse(snapshotRow); + recordUsageCloudUploadAnchor( + database, + profileId, + parsed.source_revision, + usageSnapshotReceivedAt({ + observedAt: parsed.observed_at, + payload: JSON.parse(parsed.payload_json) as unknown, + sourceRevision: parsed.source_revision, + }), + ); + } } - return schemaVersion36NotificationHours.slice(start, end + terminator.length); }; -const assertSchemaVersion36NotificationHoursObjects = ( +const pruneProfileUsageHistory = ( database: Database, + profileId: ProfileId, + now: number, ): void => { - const names = schemaVersion36NotificationHoursObjects - .map((object) => `'${object.name}'`).join(","); - const rows = database.query( - `SELECT type,name,tbl_name,sql FROM sqlite_master - WHERE name IN (${names}) ORDER BY name`, - ).all().map((row) => sqliteSchemaObjectRowSchema.parse(row)); - if (rows.length !== schemaVersion36NotificationHoursObjects.length) { - throw new Error("STATE_SCHEMA_V36_NOTIFICATION_HOURS_STRUCTURE_INVALID"); - } - for (const expected of schemaVersion36NotificationHoursObjects) { - const observed = rows.find((row) => row.name === expected.name); - const observedSql = observed?.sql.replace(/\bIF NOT EXISTS\b/giu, ""); - const expectedSql = schemaVersion36NotificationHoursObjectSql(expected) - .replace(/\bIF NOT EXISTS\b/giu, ""); - if ( - observed === undefined - || observed.type !== expected.type - || observed.tbl_name !== expected.table - || normalizeSqlStructure(observedSql ?? "") - !== normalizeSqlStructure(expectedSql) - ) throw new Error("STATE_SCHEMA_V36_NOTIFICATION_HOURS_STRUCTURE_INVALID"); + const cutoff = Math.max(0, now - USAGE_LOCAL_RETAIN_AGE_MS); + database.query( + `DELETE FROM usage_snapshots + WHERE profile_id=? + AND CASE + WHEN json_type(payload_json,'$.observation.receivedAt')='integer' + THEN json_extract(payload_json,'$.observation.receivedAt') + ELSE observed_at + END? + )`, + ).run(profileId, USAGE_LOCAL_RETAIN_BYTES); +}; + +const pruneAllUsageHistory = (database: Database, now: number): void => { + const rows = database.query("SELECT id FROM profiles ORDER BY id").all(); + for (const row of rows) { + const profileId = z.object({ id: profileIdSchema }).strict().parse(row).id; + pruneProfileUsageHistory(database, profileId, now); } }; -const schemaVersion37AttentionEmailPolicyObjectSql = ( - object: (typeof schemaVersion37AttentionEmailPolicyObjects)[number], -): string => { - const marker = object.type === "table" - ? `CREATE TABLE IF NOT EXISTS ${object.name}` - : `CREATE TRIGGER IF NOT EXISTS ${object.name}`; - const start = schemaVersion37AttentionEmailPolicy.indexOf(marker); - const terminator = object.type === "table" ? ") STRICT;" : "END;"; - const end = schemaVersion37AttentionEmailPolicy.indexOf(terminator, start); - if (start < 0 || end < 0) { - throw new Error("STATE_SCHEMA_V37_ATTENTION_EMAIL_DEFINITION_INVALID"); +const migrateWritableDatabase = ( + database: Database, + now: () => number, + securityScrubCheckpoint: SecurityScrubCheckpointPolicy = defaultSecurityScrubCheckpointPolicy, + resolveMachineTimeZone: MachineTimeZoneResolver = defaultMachineTimeZoneResolver, +): void => { + const initialVersion = readUserVersion(database); + if (initialVersion > currentSchemaVersion) { + throw new Error(`STATE_SCHEMA_NEWER:${initialVersion}:${currentSchemaVersion}`); } - return schemaVersion37AttentionEmailPolicy.slice( - start, - end + terminator.length, + if (initialVersion === currentSchemaVersion) { + // A current-version stamp is an assertion boundary, not permission to + // reconstruct authority. Prove every provider/adoption execution guard + // before the idempotent maintenance tail can touch any schema object. + assertCanonicalLabelKeys(database); + assertSchemaVersion39ProviderAuthority(database); + assertSchemaVersion40AdoptionObjects(database); + assertExactSchemaVersion40AdoptionSurface(database); + assertWorkSchema(database); + assertSessionTaskSchema(database); + } + // Both pre-release adoption and notification builds used version 36. Freeze + // their identity before any additive pre-application can blur the evidence. + const exactLegacyFeatureVersion36 = isExactLegacyFeatureVersion36( + database, + initialVersion, ); -}; + const legacySessionAdoption = initialVersion < currentSchemaVersion + ? classifyLegacySessionAdoptionSchema(database, initialVersion) + : "absent"; + if (initialVersion === 39 && legacySessionAdoption === "absent") { + // Provider v39 was the protected-main predecessor to adoption v40. Prove + // that complete released surface before any schema or row mutation; a + // same-version lookalike must not gain adoption authority through repair. + assertLegacyAdoptionMigrationTail(database, 39); + assertSchemaVersion35Objects(database); + assertCompositeNotificationPolicy(database); + assertSchemaVersion38PresetContracts(database); + assertSchemaVersion39ProviderAuthority(database); + assertProviderVersion39WorkSchema(database); + assertSessionTaskSchema(database); + } + + // Security migrations may replace secret-bearing legacy records. SQLite must + // overwrite superseded cell content instead of leaving it in free pages. + database.exec("PRAGMA secure_delete = ON"); + const securityScrubPending = database.transaction(() => { + let redacted = false; + let version = initialVersion; + // v9-v12 databases may have committed URL-bearing MCP records or their + // superseded bytes without retaining evidence that WAL truncation finished. + // Materializing the v13 authority inside this transaction makes the byte + // purge independently retryable from the interaction state transition. + // v14 also forces previously stamped v13 databases through this authority + // and installs stable queue ordering before the physical rebuild. + database.exec(schemaVersion13); + if (initialVersion >= 9 && initialVersion < 14) requireSecurityScrub(database, now()); + if (version < 1) { + database.exec(schemaVersion1); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(1, now()); + database.exec("PRAGMA user_version = 1"); + version = 1; + } + + // A few pre-release v1 fixtures contained only the then-reachable subset + // of the schema. Reapplying the canonical CREATE IF NOT EXISTS statements + // materializes the omitted tables without rewriting existing objects. + database.exec(schemaVersion1); + + // v26 installed the current work authority guards, which now inspect the + // provider bound to a session. Older SQLite releases admitted those trigger + // definitions before the additive v32 column existed, while newer releases + // reject an unrelated table rebuild as soon as they revalidate the broken + // trigger. Heal every pre-v32 database before any later migration can force + // that validation; v32 remains the migration that stamps the column. + if (version < 32) applySchemaVersion32(database); + + // Current WorkStore guards name both preset-contract columns. Install the + // additive v38 authority before any older migration replays the current + // work schema; the ordered v38 block below remains the ledger/version + // stamp. + applySchemaVersion38PresetContracts(database); + + // Work is installed at v26, while its current authority predicates also + // name the additive v40 account and personal-runtime relations. SQLite + // versions that eagerly revalidate triggers during a later table rewrite + // require those relations and the profile proof column to exist first. + // This pre-application creates no authority rows or guards; v40 remains + // responsible for backfill, quarantine, and the canonical trigger set. + if (version < 40) ensureSchemaVersion40WorkAuthorityDependencies(database); + + if (version < 2) { + // Early development builds accidentally stamped this column as schema v1. + // Accept those databases without weakening the canonical append-only v1→v2 path. + if (!hasTableColumn(database, "sessions", "provider_updated_at")) { + database.exec("ALTER TABLE sessions ADD COLUMN provider_updated_at REAL CHECK(provider_updated_at IS NULL OR provider_updated_at >= 0)"); + } + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(2, now()); + database.exec("PRAGMA user_version = 2"); + version = 2; + } + + if (version < 3) { + const desktopColumns = [ + ["switch_generation", "INTEGER CHECK(switch_generation IS NULL OR switch_generation > 0)"], + ["journal_prepared", "INTEGER NOT NULL DEFAULT 1 CHECK(journal_prepared IN (0,1))"], + ["journal_digest", "TEXT CHECK(journal_digest IS NULL OR length(journal_digest) = 64)"], + ["bundle_cd_hash", "TEXT"], + ["source_pid", "INTEGER CHECK(source_pid IS NULL OR source_pid > 0)"], + ["expected_account_key", "TEXT"], + ["launched_pid", "INTEGER CHECK(launched_pid IS NULL OR launched_pid > 0)"], + ] as const; + for (const [name, declaration] of desktopColumns) { + if (!hasTableColumn(database, "desktop_switches", name)) { + database.exec(`ALTER TABLE desktop_switches ADD COLUMN ${name} ${declaration}`); + } + } + database.exec(schemaVersion3); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(3, now()); + database.exec("PRAGMA user_version = 3"); + version = 3; + } + + if (version < 4) { + database.exec(schemaVersion4); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(4, now()); + database.exec("PRAGMA user_version = 4"); + version = 4; + } + + if (version < 5) { + const desktopRecoveryColumns = [ + ["ambiguous_from_phase", "TEXT CHECK(ambiguous_from_phase IS NULL OR ambiguous_from_phase IN ('prepared','quit_started','quit_confirmed','launch_started','verify_started'))"], + ["recovery_deadline_at", "INTEGER CHECK(recovery_deadline_at IS NULL OR recovery_deadline_at >= 0)"], + ] as const; + for (const [name, declaration] of desktopRecoveryColumns) { + if (!hasTableColumn(database, "desktop_switches", name)) { + database.exec(`ALTER TABLE desktop_switches ADD COLUMN ${name} ${declaration}`); + } + } + if (!hasTableColumn(database, "desktop_switch_authority", "released_generation")) { + database.exec("ALTER TABLE desktop_switch_authority ADD COLUMN released_generation INTEGER NOT NULL DEFAULT 0 CHECK(released_generation >= 0 AND released_generation <= current_generation)"); + } + database.exec(schemaVersion5); + database.exec("UPDATE desktop_switches SET recovery_deadline_at=updated_at+30000 WHERE journal_prepared=1 AND recovery_deadline_at IS NULL"); + database.exec(`UPDATE desktop_switch_authority + SET released_generation=current_generation + WHERE current_attempt_id IS NOT NULL + AND EXISTS( + SELECT 1 FROM mutation_attempts m + JOIN desktop_switches d ON d.attempt_id=m.id + WHERE m.id=desktop_switch_authority.current_attempt_id + AND ((m.state='applied' AND d.phase='applied') OR m.state='cancelled' OR (m.state='failed' AND d.phase='failed')) + )`); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(5, now()); + database.exec("PRAGMA user_version = 5"); + version = 5; + } + + if (version < 6) { + database.exec(schemaVersion6); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(6, now()); + database.exec("PRAGMA user_version = 6"); + version = 6; + } + + if (version < 7) { + database.exec(schemaVersion7); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(7, now()); + database.exec("PRAGMA user_version = 7"); + version = 7; + } -const assertSchemaVersion37AttentionEmailPolicyObjects = ( - database: Database, -): void => { - const names = schemaVersion37AttentionEmailPolicyObjects - .map((object) => `'${object.name}'`).join(","); - const rows = database.query( - `SELECT type,name,tbl_name,sql FROM sqlite_master - WHERE name IN (${names}) ORDER BY name`, - ).all().map((row) => sqliteSchemaObjectRowSchema.parse(row)); - if (rows.length !== schemaVersion37AttentionEmailPolicyObjects.length) { - throw new Error("STATE_SCHEMA_V37_ATTENTION_EMAIL_STRUCTURE_INVALID"); - } - for (const expected of schemaVersion37AttentionEmailPolicyObjects) { - const observed = rows.find((row) => row.name === expected.name); - const observedSql = observed?.sql.replace(/\bIF NOT EXISTS\b/giu, ""); - const expectedSql = schemaVersion37AttentionEmailPolicyObjectSql(expected) - .replace(/\bIF NOT EXISTS\b/giu, ""); - if ( - observed === undefined - || observed.type !== expected.type - || observed.tbl_name !== expected.table - || normalizeSqlStructure(observedSql ?? "") - !== normalizeSqlStructure(expectedSql) - ) throw new Error("STATE_SCHEMA_V37_ATTENTION_EMAIL_STRUCTURE_INVALID"); - } -}; + if (version < 8) { + database.exec(schemaVersion8); + const migratedAt = now(); + backfillExactTurnRuntimeProfiles(database, migratedAt); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(8, migratedAt); + database.exec("PRAGMA user_version = 8"); + version = 8; + } -const assertAttentionEmailPolicy = (database: Database): NotificationEmailPolicy => { - assertSchemaVersion37AttentionEmailPolicyObjects(database); - const row = database.query( - `SELECT version,enabled,revision,created_at,updated_at - FROM attention_email_policy WHERE singleton=1`, - ).get(); - if (row === null) throw new Error("ATTENTION_EMAIL_POLICY_MISSING"); - try { - return mapNotificationEmailPolicy(row); - } catch (error: unknown) { - throw new Error("ATTENTION_EMAIL_POLICY_INVALID", { cause: error }); - } -}; + if (version < 9) { + database.exec(schemaVersion9); + const migratedAt = now(); + backfillSchemaVersion9(database, migratedAt); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(9, migratedAt); + database.exec("PRAGMA user_version = 9"); + version = 9; + } -const assertCompositeNotificationPolicy = (database: Database): void => { - assertNotificationHoursPolicy(database); - const attentionEmail = assertAttentionEmailPolicy(database); - const hoursRow = database.query( - "SELECT revision FROM notification_hours WHERE singleton=1", - ).get(); - const hoursRevision = z.object({ - revision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), - }).strict().parse(hoursRow).revision; - if (hoursRevision !== attentionEmail.revision) { - throw new Error("NOTIFICATION_POLICY_REVISION_DIVERGED"); - } -}; + if (version < 10) { + database.exec(schemaVersion10); + backfillSchemaVersion10(database); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(10, now()); + database.exec("PRAGMA user_version = 10"); + version = 10; + } -const hasExactAttachedSchemaObjects = ( - database: Database, - table: "attention_email_policy" | "notification_hours", - expectedNames: readonly string[], -): boolean => { - const rows = z.object({ name: z.string() }).strict().array().parse( - database.query( - `SELECT name FROM sqlite_master - WHERE name NOT LIKE 'sqlite_%' AND (name=? OR tbl_name=?) - ORDER BY name`, - ).all(table, table), - ); - return rows.length === expectedNames.length - && rows.every((row, index) => row.name === expectedNames[index]); -}; + if (version < 11) { + const migratedAt = now(); + redacted = redactLegacyMcpUrlInteractions(database, migratedAt); + database.exec(schemaVersion11); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(11, migratedAt); + database.exec("PRAGMA user_version = 11"); + version = 11; + } -/** - * The notification feature branch briefly stamped hours as v35 and email as - * v36 before main assigned v35 to provider-switch custody. Only that exact, - * internally consistent schema may carry an already-enabled local opt-in - * across the renumbering. The hosted global gate remains a separate authority. - */ -const isExactLegacyFeatureVersion36 = ( - database: Database, - initialVersion: number, -): boolean => { - if (initialVersion !== 36) return false; - const migrationRows = z.object({ version: z.number().int() }).strict().array().parse( - database.query( - "SELECT version FROM migrations WHERE version BETWEEN 35 AND 37 ORDER BY version", - ).all(), - ); - if ( - migrationRows.length !== 2 - || migrationRows[0]?.version !== 35 - || migrationRows[1]?.version !== 36 - ) return false; + if (version < 12) { + database.exec(schemaVersion12); + backfillUsageCloudUploadAnchors(database); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(12, now()); + database.exec("PRAGMA user_version = 12"); + version = 12; + } - const providerObjectNames = schemaVersion35Objects.map((object) => object.name); - const placeholders = providerObjectNames.map(() => "?").join(","); - const providerObjectCount = z.object({ count: z.number().int().nonnegative() }) - .strict().parse(database.query( - `SELECT COUNT(*) AS count FROM sqlite_master WHERE name IN (${placeholders})`, - ).get(...providerObjectNames)).count; - if (providerObjectCount !== 0) return false; + if (version < 13) { + database.exec(schemaVersion13); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(13, now()); + database.exec("PRAGMA user_version = 13"); + version = 13; + } - const hoursObjectNames = schemaVersion36NotificationHoursObjects - .map((object) => object.name).sort(); - const emailObjectNames = schemaVersion37AttentionEmailPolicyObjects - .map((object) => object.name).sort(); - if ( - !hasExactAttachedSchemaObjects(database, "notification_hours", hoursObjectNames) - || !hasExactAttachedSchemaObjects(database, "attention_email_policy", emailObjectNames) - ) return false; + if (version < 14) { + ensureStableQueueSequence(database); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(14, now()); + database.exec("PRAGMA user_version = 14"); + version = 14; + } - assertCompositeNotificationPolicy(database); - return true; -}; + if (version < 15) { + redacted = redactLegacyPermissionValues(database) || redacted; + if (redacted) requireSecurityScrub(database, now()); + database.exec(schemaVersion15); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(15, now()); + database.exec("PRAGMA user_version = 15"); + version = 15; + } -const applySchemaVersion37AttentionEmailPolicy = ( - database: Database, - migratedAt: number, - allowExistingEnabledPolicy = false, -): void => { - database.exec(schemaVersion37AttentionEmailPolicy); - const existing = database.query( - "SELECT 1 FROM attention_email_policy WHERE singleton=1", - ).get(); - if (existing === null) { - const hoursRow = database.query( - "SELECT revision FROM notification_hours WHERE singleton=1", - ).get(); - const revision = z.object({ - revision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), - }).strict().parse(hoursRow).revision; - database.query( - `INSERT INTO attention_email_policy( - singleton,version,enabled,revision,created_at,updated_at - ) VALUES (1,1,0,?,?,?)`, - ).run(revision, migratedAt, migratedAt); - } else if (assertAttentionEmailPolicy(database).enabled && !allowExistingEnabledPolicy) { - // Canonical schema v36 never shipped an opt-in. Only an exactly recognized - // feature-v36 database may retain the explicit local choice it already held. - throw new Error("ATTENTION_EMAIL_POLICY_MIGRATION_OPT_IN_REFUSED"); - } - assertCompositeNotificationPolicy(database); -}; + if (version < 16) { + if (!hasTableColumn(database, "provider_interactions", "deadline_at")) { + database.exec("ALTER TABLE provider_interactions ADD COLUMN deadline_at INTEGER NOT NULL DEFAULT 9007199254740991 CHECK(deadline_at>=requested_at AND deadline_at<=9007199254740991)"); + } + if (!hasTableColumn(database, "provider_interactions", "intended_terminal_state")) { + database.exec("ALTER TABLE provider_interactions ADD COLUMN intended_terminal_state TEXT CHECK(intended_terminal_state IS NULL OR intended_terminal_state IN ('resolved','declined','canceled','expired'))"); + } + database.query( + "UPDATE provider_interactions SET deadline_at=MIN(requested_at+?,9007199254740991) WHERE deadline_at=9007199254740991", + ).run(INTERACTION_MAX_PENDING_MS); + database.exec( + "UPDATE provider_interactions SET intended_terminal_state=state WHERE intended_terminal_state IS NULL AND state IN ('resolved','declined','canceled','expired')", + ); + database.exec(schemaVersion16); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(16, now()); + database.exec("PRAGMA user_version = 16"); + version = 16; + } + + if (version < 17) { + database.exec(schemaVersion17); + const legacyLoginResolutionEvidence = JSON.stringify({ + source: "schema17", + reason: "missing_provider_login_id", + }); + database.query(`INSERT OR IGNORE INTO mutation_resolutions( + attempt_id,resolution_kind,evidence_json,receipt_json,created_at + ) + SELECT m.id,'abandoned',?,NULL,? + FROM mutation_attempts m + JOIN profiles p ON p.id=m.authority_id + LEFT JOIN mutation_resolutions r ON r.attempt_id=m.id + WHERE m.kind='account.login' + AND m.state='applied' + AND p.state='login_pending' + AND p.process_generation=m.authority_generation + AND r.attempt_id IS NULL + AND json_extract( + CASE WHEN json_valid(m.result_json) THEN m.result_json ELSE '{}' END, + '$.status' + )='pending' + AND json_extract( + CASE WHEN json_valid(m.result_json) THEN m.result_json ELSE '{}' END, + '$.loginId' + ) IS NULL`).run( + legacyLoginResolutionEvidence, + now(), + ); + database.query(`UPDATE profiles + SET state='signed_out',provider_email=NULL,provider_plan=NULL,updated_at=MAX(updated_at,?) + WHERE state='login_pending' + AND EXISTS( + SELECT 1 + FROM mutation_attempts m + JOIN mutation_resolutions r ON r.attempt_id=m.id + WHERE m.kind='account.login' + AND m.authority_id=profiles.id + AND m.authority_generation=profiles.process_generation + AND r.resolution_kind='abandoned' + AND r.evidence_json=? + )`).run(now(), legacyLoginResolutionEvidence); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(17, now()); + database.exec("PRAGMA user_version = 17"); + version = 17; + } -// Preset aliases are durable user intent, but their exact model mapping has -// changed once. Existing and provider-imported rows retain the legacy mapping; -// HRA-created or explicitly reselected rows are stamped current at their write -// boundary. `works` is installed by WorkStore in the same database and carries -// the same contract so a claim cannot reinterpret its route mid-flight. -const schemaVersion38SessionPresetContractColumn = - `ALTER TABLE sessions ADD COLUMN preset_contract INTEGER NOT NULL DEFAULT ${legacyPresetContract} ` - + `CHECK(preset_contract IN (${legacyPresetContract},${currentPresetContract}))`; -const schemaVersion38WorkPresetContractColumn = - `ALTER TABLE works ADD COLUMN preset_contract INTEGER NOT NULL DEFAULT ${legacyPresetContract} ` - + `CHECK(preset_contract IN (${legacyPresetContract},${currentPresetContract}))`; + if (version < 18) { + backfillExactApprovalDecisions(database); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(18, now()); + database.exec("PRAGMA user_version = 18"); + version = 18; + } -const applySchemaVersion38PresetContracts = (database: Database): void => { - if (!hasTableColumn(database, "sessions", "preset_contract")) { - database.exec(schemaVersion38SessionPresetContractColumn); - } - const workTableExists = database.query( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name='works'", - ).get() !== null; - if (workTableExists && !hasTableColumn(database, "works", "preset_contract")) { - database.exec(schemaVersion38WorkPresetContractColumn); - } -}; + if (version < 19) { + database.exec(schemaVersion19); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(19, now()); + database.exec("PRAGMA user_version = 19"); + version = 19; + } -const assertSchemaVersion38PresetContracts = (database: Database): void => { - if (!hasTableColumn(database, "sessions", "preset_contract")) { - throw new Error("STATE_SCHEMA_V38_SESSION_PRESET_CONTRACT_MISSING"); - } - if (database.query( - `SELECT 1 FROM sessions - WHERE preset_contract IS NULL OR preset_contract NOT IN (${legacyPresetContract},${currentPresetContract}) - LIMIT 1`, - ).get() !== null) { - throw new Error("STATE_SCHEMA_V38_SESSION_PRESET_CONTRACT_INVALID"); - } - const workTableExists = database.query( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name='works'", - ).get() !== null; - if (workTableExists && !hasTableColumn(database, "works", "preset_contract")) { - throw new Error("STATE_SCHEMA_V38_WORK_PRESET_CONTRACT_MISSING"); - } - if (workTableExists && database.query( - `SELECT 1 FROM works - WHERE preset_contract IS NULL OR preset_contract NOT IN (${legacyPresetContract},${currentPresetContract}) - LIMIT 1`, - ).get() !== null) { - throw new Error("STATE_SCHEMA_V38_WORK_PRESET_CONTRACT_INVALID"); - } -}; + if (version < 20) { + database.exec(schemaVersion20); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(20, now()); + database.exec("PRAGMA user_version = 20"); + version = 20; + } -const assertSchemaVersion24Objects = (database: Database): void => { - const names = schemaVersion24Objects.map((object) => `'${object.name}'`).join(","); - const rows = database.query( - `SELECT type,name,tbl_name,sql FROM sqlite_master - WHERE name IN (${names}) ORDER BY name`, - ).all().map((row) => sqliteSchemaObjectRowSchema.parse(row)); - if (rows.length !== schemaVersion24Objects.length) { - throw new Error("STATE_SCHEMA_V24_STRUCTURE_INVALID"); - } - for (const expected of schemaVersion24Objects) { - const observed = rows.find((row) => row.name === expected.name); - if ( - observed === undefined - || observed.type !== expected.type - || observed.tbl_name !== expected.table - || normalizeSqlStructure(observed.sql) !== normalizeSqlStructure(expected.sql) - ) throw new Error("STATE_SCHEMA_V24_STRUCTURE_INVALID"); - } -}; + if (version < 21) { + database.exec(schemaVersion21); + scrubSettledQueueMessages(database); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(21, now()); + database.exec("PRAGMA user_version = 21"); + version = 21; + } -const assertSchemaVersion28Objects = (database: Database): void => { - const names = schemaVersion28Objects.map((object) => `'${object.name}'`).join(","); - const rows = database.query( - `SELECT type,name,tbl_name,sql FROM sqlite_master - WHERE name IN (${names}) ORDER BY name`, - ).all().map((row) => sqliteSchemaObjectRowSchema.parse(row)); - if (rows.length !== schemaVersion28Objects.length) { - throw new Error("STATE_SCHEMA_V28_STRUCTURE_INVALID"); - } - for (const expected of schemaVersion28Objects) { - const observed = rows.find((row) => row.name === expected.name); - const observedSql = observed?.sql.replace(/\bIF NOT EXISTS\b/giu, ""); - const expectedSql = schemaVersion28ObjectSql(expected) - .replace(/\bIF NOT EXISTS\b/giu, ""); - if ( - observed === undefined - || observed.type !== expected.type - || observed.tbl_name !== expected.table - || normalizeSqlStructure(observedSql ?? "") - !== normalizeSqlStructure(expectedSql) - ) throw new Error("STATE_SCHEMA_V28_STRUCTURE_INVALID"); - } -}; + if (version < 22) { + database.exec(schemaVersion22); + // A pre-v22 database may already contain only tombstones while the + // superseded bodies remain in free pages or WAL. The durable marker makes + // the required rebuild independently retryable from the schema stamp. + if (initialVersion > 0) requireQueueMessageScrub(database, now(), true); + if (scrubSettledQueueMessages(database)) { + requireQueueMessageScrub(database, now(), true); + } + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(22, now()); + database.exec("PRAGMA user_version = 22"); + version = 22; + } -const assertAccountRateLimitResetPolicies = (database: Database): void => { - assertSchemaVersion28Objects(database); - let policies: readonly AccountRateLimitResetPolicyRecord[]; - try { - policies = database.query( - "SELECT * FROM account_rate_limit_reset_policies ORDER BY profile_id", - ).all().map(mapAccountRateLimitResetPolicy); - } catch (error: unknown) { - throw new Error("STATE_ACCOUNT_RATE_LIMIT_RESET_POLICY_INVALID", { cause: error }); - } - const policyProfileIds = new Set(policies.map((policy) => policy.profileId)); - const activeProfileIds = database.query( - "SELECT id FROM profiles WHERE state!='removed' ORDER BY id", - ).all().map((row) => z.object({ id: profileIdSchema }).strict().parse(row).id); - if (activeProfileIds.some((profileId) => !policyProfileIds.has(profileId))) { - throw new Error("STATE_ACCOUNT_RATE_LIMIT_RESET_POLICY_MISSING"); - } - const activeProfileIdSet = new Set(activeProfileIds); - if (policies.some((policy) => !activeProfileIdSet.has(policy.profileId))) { - throw new Error("STATE_ACCOUNT_RATE_LIMIT_RESET_POLICY_ORPHANED"); - } -}; + if (version < 23) { + ensureQueueMessageScrubGeneration(database); + database.exec(schemaVersion22); + database.exec(schemaVersion23); + // v22 shipped only in development, but its singleton authority had no + // generation fence and its trigger definitions could be stale. Rebuild + // every nonempty predecessor once, then let the generation-CAS loop own + // all future runtime purges. + if (initialVersion > 0) requireQueueMessageScrub(database, now(), true); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(23, now()); + database.exec("PRAGMA user_version = 23"); + version = 23; + } -const ensureQueueMessageScrubGeneration = (database: Database): void => { - if (!hasTableColumn(database, "queue_message_scrub_authority", "generation")) { - database.exec( - "ALTER TABLE queue_message_scrub_authority ADD COLUMN generation INTEGER NOT NULL DEFAULT 1 CHECK(generation BETWEEN 1 AND 9007199254740991)", - ); - } -}; + if (version < 24) { + database.exec(dropSchemaVersion24); + backfillCanonicalLabelKeys(database); + rebuildSchemaVersion24(database); + assertSchemaVersion24Objects(database); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(24, now()); + database.exec("PRAGMA user_version = 24"); + version = 24; + } -const hasSettledQueueMessagesToScrub = (database: Database): boolean => - database.query( - `SELECT 1 - FROM queue_entries - WHERE message!=? - AND ( - state IN ('applied','failed','cancelled') - OR EXISTS( - SELECT 1 FROM queue_effect_resolutions r - WHERE r.queue_id=queue_entries.id - ) - ) - LIMIT 1`, - ).get(settledQueueMessage) !== null; + if (version < 25) { + ensureSessionEventProjectionVersion(database); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(25, now()); + database.exec("PRAGMA user_version = 25"); + version = 25; + } -const scrubSettledQueueMessages = (database: Database): boolean => { - const terminal = database.query( - `UPDATE queue_entries - SET message=? - WHERE message!=? - AND state IN ('applied','failed','cancelled')`, - ).run(settledQueueMessage, settledQueueMessage); - const resolved = database.query( - `UPDATE queue_entries - SET message=? - WHERE message!=? - AND EXISTS( - SELECT 1 FROM queue_effect_resolutions r - WHERE r.queue_id=queue_entries.id - )`, - ).run(settledQueueMessage, settledQueueMessage); - return terminal.changes + resolved.changes > 0; -}; + if (version < 26) { + // Current Work guards read the additive authoritative provider column. + // The same transaction records the complete provider-v39 migration + // below after all earlier ordered migrations have been applied. + ensureSchemaVersion39SessionProviderColumn(database); + database.exec(WORK_SCHEMA_SQL); + // Current Work authority triggers depend on the provider-scoped tables + // and profile proof added by v39. The whole migration is one transaction, + // so validate the complete Work surface after those objects are present + // rather than rejecting this temporary v26 waypoint. + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(26, now()); + database.exec("PRAGMA user_version = 26"); + version = 26; + } + + if (version < 27) { + database.exec(schemaVersion27); + ensureUsagePollFailureAccountFingerprint(database); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(27, now()); + database.exec("PRAGMA user_version = 27"); + version = 27; + } + + if (version < 28) { + database.exec(schemaVersion28); + const migratedAt = unixMillisecondsSchema.parse(now()); + database.query( + `INSERT INTO account_rate_limit_reset_policies( + profile_id,state,account_fingerprint,weekly_window_resets_at, + revision,created_at,updated_at + ) + SELECT id,'reconciliation_required',NULL,NULL,1,?,? + FROM profiles WHERE state!='removed' ORDER BY id + ON CONFLICT(profile_id) DO UPDATE SET + state='reconciliation_required', + account_fingerprint=NULL, + weekly_window_resets_at=NULL, + revision=account_rate_limit_reset_policies.revision+1, + updated_at=MAX(account_rate_limit_reset_policies.updated_at,excluded.updated_at)`, + ).run(migratedAt, migratedAt); + database.query( + `DELETE FROM account_rate_limit_reset_policies + WHERE NOT EXISTS ( + SELECT 1 FROM profiles p + WHERE p.id=account_rate_limit_reset_policies.profile_id + AND p.state!='removed' + )`, + ).run(); + assertAccountRateLimitResetPolicies(database); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(28, migratedAt); + database.exec("PRAGMA user_version = 28"); + version = 28; + } -const userVersionSchema = z.object({ user_version: z.number().int().nonnegative() }).strict(); + if (version < 29) { + database.exec(SESSION_TASK_SCHEMA_SQL); + assertSessionTaskSchema(database); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(29, now()); + database.exec("PRAGMA user_version = 29"); + version = 29; + } -const securityScrubAuthorityRowSchema = z.object({ - reason: z.literal("mcp_url_redaction"), - required_at: unixMillisecondsSchema, -}).strict(); + if (version < 30) { + database.exec(schemaVersion30); + if (!hasTableColumn(database, "daemon_state", "default_approval_mode")) { + database.exec(schemaVersion30DefaultApprovalModeColumn); + } + if (!hasTableColumn(database, "provider_interactions", "resolved_by")) { + database.exec(schemaVersion30ResolvedByColumn); + } + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(30, now()); + database.exec("PRAGMA user_version = 30"); + version = 30; + } -const queueMessageScrubAuthorityRowSchema = z.object({ - required_at: unixMillisecondsSchema, - requires_vacuum: z.union([z.literal(0), z.literal(1)]), - generation: z.number().int().positive().safe(), -}).strict(); + if (version < 31) { + if (!hasTableColumn(database, "autorespond_evidence", "path")) { + applySchemaVersion31(database); + } + if (!hasTableColumn(database, "autorespond_evidence", "path")) { + throw new Error("STATE_SCHEMA_V31_AUTORESPOND_EVIDENCE_INVALID"); + } + database.exec(schemaVersion31Objects); + applySchemaVersion31Archive(database); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(31, now()); + database.exec("PRAGMA user_version = 31"); + version = 31; + } -const walCheckpointRowSchema = z.object({ - busy: z.number().int().min(0).max(1), - log: z.number().int().nonnegative(), - checkpointed: z.number().int().nonnegative(), -}).strict(); + if (version < 32) { + applySchemaVersion32(database); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(32, now()); + database.exec("PRAGMA user_version = 32"); + version = 32; + } -const journalModeRowSchema = z.object({ journal_mode: z.string() }).strict(); + if (version < 33) { + applySchemaVersion33DeviceCommands(database); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(33, now()); + database.exec("PRAGMA user_version = 33"); + version = 33; + } -const requireWalMode = (database: Database, configure = false): void => { - const row = journalModeRowSchema.parse( - database.query(configure ? "PRAGMA journal_mode=WAL" : "PRAGMA journal_mode").get(), - ); - if (row.journal_mode.toLowerCase() !== "wal") throw new Error("STATE_WAL_REQUIRED"); -}; + if (version < 34) { + applySchemaVersion34Attachments(database); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(34, now()); + database.exec("PRAGMA user_version = 34"); + version = 34; + } -const readUserVersion = (database: Database): number => - userVersionSchema.parse(database.query("PRAGMA user_version").get()).user_version; + if (version < 35) { + applySchemaVersion35ProviderSwitchProgress(database); + database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(35, now()); + database.exec("PRAGMA user_version = 35"); + version = 35; + } -const hasPendingSecurityScrub = (database: Database): boolean => { - const row = database.query( - "SELECT reason,required_at FROM security_scrub_authority WHERE singleton=1", - ).get(); - if (row !== null) { - securityScrubAuthorityRowSchema.parse(row); - return true; - } - const queueRow = database.query( - "SELECT required_at,requires_vacuum,generation FROM queue_message_scrub_authority WHERE singleton=1", - ).get(); - if (queueRow === null) return false; - queueMessageScrubAuthorityRowSchema.parse(queueRow); - return true; -}; + // Main shipped provider-switch progress as v35. Apply and assert it before + // notifications. An exact adoption-v35 build lacked these objects; its + // classifier has already admitted it for this bounded repair. + applySchemaVersion35ProviderSwitchProgress(database); + assertSchemaVersion35Objects(database); -const requireSecurityScrub = (database: Database, requiredAt: number): void => { - database.query( - `INSERT OR IGNORE INTO security_scrub_authority(singleton,reason,required_at) - VALUES (1,'mcp_url_redaction',?)`, - ).run(unixMillisecondsSchema.parse(requiredAt)); -}; + if (version < 36 || legacySessionAdoption === "version36") { + const migratedAt = unixMillisecondsSchema.parse(now()); + applySchemaVersion36NotificationHours( + database, + migratedAt, + resolveMachineTimeZone, + ); + database.query( + "INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)", + ).run(36, migratedAt); + database.exec("PRAGMA user_version = 36"); + version = 36; + } -const requireQueueMessageScrub = ( - database: Database, - requiredAt: number, - requiresVacuum: boolean, -): void => { - const recorded = database.query( - `INSERT INTO queue_message_scrub_authority(singleton,required_at,requires_vacuum,generation) - VALUES (1,?,?,1) - ON CONFLICT(singleton) DO UPDATE SET - required_at=MIN(required_at,excluded.required_at), - requires_vacuum=MAX(requires_vacuum,excluded.requires_vacuum), - generation=generation+1 - WHERE generation<9007199254740991`, - ).run( - unixMillisecondsSchema.parse(requiredAt), - requiresVacuum ? 1 : 0, - ); - if (recorded.changes !== 1) { - throw new Error("QUEUE_MESSAGE_SCRUB_GENERATION_EXHAUSTED"); - } -}; + if (version < 37) { + const migratedAt = unixMillisecondsSchema.parse(now()); + applySchemaVersion37AttentionEmailPolicy( + database, + migratedAt, + exactLegacyFeatureVersion36, + ); + database.query( + "INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)", + ).run(37, migratedAt); + database.exec("PRAGMA user_version = 37"); + version = 37; + } -const completePendingSecurityScrub = ( - database: Database, - operationCommitted = false, - policy: SecurityScrubCheckpointPolicy = defaultSecurityScrubCheckpointPolicy, -): void => { - if (!hasPendingSecurityScrub(database)) return; - database.exec(`PRAGMA busy_timeout = ${policy.busyTimeoutMs}`); - try { - const truncateWal = (): void => { - // A readonly status read releases its snapshot well inside one attempt. - // A reader that outlives the whole bounded schedule still fails the - // scrub, so a settled body is never reported purged while frames remain. - for (let attempt = 1; ; attempt += 1) { - requireWalMode(database); - const checkpoint = walCheckpointRowSchema.parse( - database.query("PRAGMA wal_checkpoint(TRUNCATE)").get(), - ); - if (checkpoint.busy === 0 && checkpoint.log === 0 && checkpoint.checkpointed === 0) return; - if (attempt >= policy.attempts) { - throw new Error("SQLite could not truncate every WAL frame."); - } - Bun.sleepSync(policy.backoffMs * 2 ** (attempt - 1)); - } - }; + if (version < 38) { + const migratedAt = unixMillisecondsSchema.parse(now()); + applySchemaVersion38PresetContracts(database); + database.query( + "INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)", + ).run(38, migratedAt); + database.exec("PRAGMA user_version = 38"); + version = 38; + } - for (;;) { - const snapshot = database.transaction(() => { - const legacyRow = database.query( - "SELECT reason,required_at FROM security_scrub_authority WHERE singleton=1", - ).get(); - const legacySecurityScrub = legacyRow === null - ? null - : securityScrubAuthorityRowSchema.parse(legacyRow); - const queueRow = database.query( - "SELECT required_at,requires_vacuum,generation FROM queue_message_scrub_authority WHERE singleton=1", - ).get(); - if (queueRow === null) { - return { legacySecurityScrub, queueAuthority: null }; - } - // The authority marker is committed before plaintext removal. This - // connection owns secure_delete=ON, so every body rewrite has known - // physical-deletion semantics even if the settling writer did not. - scrubSettledQueueMessages(database); - const queueAuthority = queueMessageScrubAuthorityRowSchema.parse(database.query( - "SELECT required_at,requires_vacuum,generation FROM queue_message_scrub_authority WHERE singleton=1", - ).get()); - return { legacySecurityScrub, queueAuthority }; - }).immediate(); - const { legacySecurityScrub, queueAuthority } = snapshot; + if (version < 39 || legacySessionAdoption === "version39") { + const migratedAt = unixMillisecondsSchema.parse(now()); + applySchemaVersion39ProviderAuthority(database); + if (version < 39) { + database.query( + "INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)", + ).run(39, migratedAt); + database.exec("PRAGMA user_version = 39"); + version = 39; + } + } - if (legacySecurityScrub === null && queueAuthority === null) return; + if (version < 40) { + const migratedAt = unixMillisecondsSchema.parse(now()); + applySchemaVersion40SessionAdoption(database, migratedAt); + database.query( + "INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)", + ).run(40, migratedAt); + database.exec("PRAGMA user_version = 40"); + version = 40; + } - // The first truncation removes superseded runtime frames. A legacy - // security migration or an explicitly uncertain queue migration also - // rebuilds the main file so bytes left in free pages cannot survive. - truncateWal(); - if (legacySecurityScrub !== null || queueAuthority?.requires_vacuum === 1) { - database.exec("VACUUM"); - truncateWal(); - } - const cleared = database.transaction(() => { - if (legacySecurityScrub !== null) { - database.query("DELETE FROM security_scrub_authority WHERE singleton=1").run(); - } - if (queueAuthority === null) return true; - return database.query( - "DELETE FROM queue_message_scrub_authority WHERE singleton=1 AND generation=?", - ).run(queueAuthority.generation).changes === 1; - }).immediate(); - // A concurrent settlement advanced the generation after our checkpoint. - // Its authority remains durable and must be scrubbed in the next pass. - if (!cleared) continue; - if (!hasPendingSecurityScrub(database)) return; + // Reapplying additive objects and idempotent authority backfills makes a + // restart after any pre-release partial fixture safe without changing rows. + applySchemaVersion32(database); + database.exec(schemaVersion9); + database.exec(schemaVersion10); + backfillSchemaVersion9(database, now()); + backfillSchemaVersion10(database); + redacted = redactLegacyMcpUrlInteractions(database, now()) || redacted; + redacted = redactLegacyPermissionValues(database) || redacted; + if (redacted) requireSecurityScrub(database, now()); + database.exec(schemaVersion11); + database.exec(schemaVersion12); + database.exec(schemaVersion13); + ensureStableQueueSequence(database); + database.exec(schemaVersion15); + database.exec(schemaVersion16); + database.exec(schemaVersion17); + backfillExactApprovalDecisions(database); + database.exec(schemaVersion19); + database.exec(schemaVersion20); + database.exec(schemaVersion21); + ensureQueueMessageScrubGeneration(database); + database.exec(schemaVersion22); + database.exec(schemaVersion23); + rebuildSchemaVersion24(database); + assertSchemaVersion24Objects(database); + ensureSessionEventProjectionVersion(database); + applySchemaVersion38PresetContracts(database); + if (initialVersion < currentSchemaVersion) applySchemaVersion39ProviderAuthority(database); + if (initialVersion < currentSchemaVersion) database.exec(WORK_SCHEMA_SQL); + assertSchemaVersion38PresetContracts(database); + assertSchemaVersion39ProviderAuthority(database); + database.exec(schemaVersion27); + ensureUsagePollFailureAccountFingerprint(database); + database.exec(schemaVersion28); + assertAccountRateLimitResetPolicies(database); + database.exec(SESSION_TASK_SCHEMA_SQL); + assertSessionTaskSchema(database); + database.exec(schemaVersion30); + database.exec(schemaVersion31Objects); + applySchemaVersion31Archive(database); + applySchemaVersion33DeviceCommands(database); + applySchemaVersion34Attachments(database); + applySchemaVersion35ProviderSwitchProgress(database); + assertSchemaVersion35Objects(database); + database.exec(schemaVersion36NotificationHours); + database.exec(schemaVersion37AttentionEmailPolicy); + assertCompositeNotificationPolicy(database); + assertSchemaVersion40AdoptionObjects(database); + assertExactSchemaVersion40AdoptionSurface(database); + assertWorkSchema(database); + if (hasSettledQueueMessagesToScrub(database)) { + requireQueueMessageScrub(database, now(), true); } - } catch (cause) { - throw new StateSecurityScrubRequiredError(operationCommitted, cause); - } finally { - database.exec(`PRAGMA busy_timeout = ${stateBusyTimeoutMs}`); - } + pruneAllUsageHistory(database, now()); + return hasPendingSecurityScrub(database); + })(); + if (securityScrubPending) completePendingSecurityScrub(database, false, securityScrubCheckpoint); }; -const hasTableColumn = (database: Database, table: string, column: string): boolean => { - const columnSchema = z.object({ name: z.string() }).passthrough(); - if (!/^[a-z_]+$/u.test(table)) throw new Error("Unsafe SQLite table identifier."); - return database.query(`PRAGMA table_info(${table})`).all().some((row) => columnSchema.parse(row).name === column); +const mapProfile = (row: unknown): ProfileRecord => { + const parsed = profileRowSchema.parse(row); + if (parsed.label_key !== canonicalLabelIdentity(parsed.label, "ACCOUNT").key) { + throw new StateLabelInvariantError("ACCOUNT", "KEY_INVALID"); + } + return { + id: parsed.id, + label: parsed.label, + state: parsed.state, + processGeneration: parsed.process_generation, + ...(parsed.provider_email === null ? {} : { providerEmail: parsed.provider_email }), + ...(parsed.provider_plan === null ? {} : { providerPlan: parsed.provider_plan }), + createdAt: parsed.created_at, + updatedAt: parsed.updated_at, + }; }; -const applySchemaVersion31 = (database: Database): void => { - for (const statement of schemaVersion31Statements) database.query(statement).run(); +const mapProject = (row: unknown): ProjectRecord => { + const parsed = projectRowSchema.parse(row); + if (parsed.label_key !== canonicalLabelIdentity(parsed.label, "PROJECT").key) { + throw new StateLabelInvariantError("PROJECT", "KEY_INVALID"); + } + return { id: parsed.id, label: parsed.label, rootPath: parsed.root_path, default: parsed.is_default === 1, createdAt: parsed.created_at, updatedAt: parsed.updated_at }; }; -const ensureSessionEventProjectionVersion = (database: Database): void => { - if (!hasTableColumn(database, "session_events", "projection_version")) { - database.exec( - "ALTER TABLE session_events ADD COLUMN projection_version INTEGER NOT NULL DEFAULT 1 CHECK(projection_version IN (1,2))", - ); - } +// The pre-v39 column remains only so old SQLite CHECK authority is preserved +// without a table rewrite. New providers use the Codex-compatible shadow; +// every semantic read and comparison uses `provider_v39`. +const legacySessionProviderShadow = (provider: Provider): "codex" | "claude" => + provider === "claude" ? "claude" : "codex"; + +const mapSession = (row: unknown): SessionRecord => { + const parsed = sessionRowSchema.parse(row); + return { + id: parsed.id, + profileId: parsed.profile_id, + ...(parsed.project_id === null ? {} : { projectId: parsed.project_id }), + ...(parsed.provider_thread_id === null ? {} : { providerThreadId: parsed.provider_thread_id }), + title: parsed.title, + note: parsed.note, + provider: parsed.provider_v39, + preset: presetForProviderTier(parsed.provider_v39, parsed.preset), + fastEnabled: parsed.fast_enabled === 1, + state: parsed.state, + ...(parsed.active_turn_id === null ? {} : { activeTurnId: parsed.active_turn_id }), + ...(parsed.provider_updated_at === null ? {} : { providerUpdatedAt: parsed.provider_updated_at }), + ...(parsed.archived_at === null ? {} : { archivedAt: parsed.archived_at }), + revision: parsed.revision, + createdAt: parsed.created_at, + updatedAt: parsed.updated_at, + }; }; -const ensureUsagePollFailureAccountFingerprint = (database: Database): void => { - if (!hasTableColumn(database, "usage_poll_failures", "account_fingerprint")) { - database.exec( - `ALTER TABLE usage_poll_failures ADD COLUMN account_fingerprint TEXT - CHECK( - account_fingerprint IS NULL OR ( - length(account_fingerprint)=64 - AND account_fingerprint NOT GLOB '*[^a-f0-9]*' - ) - )`, - ); - } - database.exec( - `CREATE INDEX IF NOT EXISTS usage_poll_failures_identity_recent - ON usage_poll_failures( - profile_id,account_fingerprint,source_revision DESC - )`, - ); +const mapSessionProviderAccountAuthority = ( + row: unknown, +): SessionProviderAccountAuthorityRecord => { + const parsed = sessionProviderAccountAuthorityRowSchema.parse(row); + return { + sessionId: parsed.session_id, + provider: parsed.provider, + runtimeScope: parsed.runtime_scope, + accountKey: parsed.account_key, + recordedAt: parsed.recorded_at, + }; }; -type LabelIdentityKind = "ACCOUNT" | "PROJECT"; +const mapSessionAdoptionPolicy = (row: unknown): SessionAdoptionPolicyRecord => { + const parsed = sessionAdoptionPolicyRowSchema.parse(row); + const enabled = parsed.state === "enabled"; + if ( + (enabled && parsed.profile_id === null) + || (!enabled && parsed.profile_id !== null) + || parsed.updated_at < parsed.created_at + ) throw new Error("SESSION_ADOPTION_POLICY_SHAPE_INVALID"); + return { + provider: parsed.provider, + enabled, + profileId: parsed.profile_id, + revision: parsed.revision, + createdAt: parsed.created_at, + updatedAt: parsed.updated_at, + }; +}; -class StateLabelInvariantError extends Error { - constructor(kind: LabelIdentityKind, reason: "COLLISION" | "INVALID" | "KEY_INVALID") { - super(`STATE_${kind}_LABEL_${reason}`); - this.name = "StateLabelInvariantError"; - } -} +const mapSessionAdoptionCandidate = (row: unknown): SessionAdoptionCandidateRecord => { + const parsed = sessionAdoptionCandidateRowSchema.parse(row); + const sourceIdentityAbsent = parsed.source_pid === null + && parsed.source_pid_domain === null + && parsed.source_proc_start === null; + const sourceProcessIdentity = sourceIdentityAbsent + ? null + : claudeProcessIdentitySchema.parse({ + pid: parsed.source_pid, + pidDomain: parsed.source_pid_domain, + procStart: parsed.source_proc_start, + }); + if ( + (parsed.claim_status === "fenced") !== (parsed.fenced_fingerprint !== null) + || (sourceProcessIdentity !== null && parsed.provider !== "claude") + || parsed.last_observed_at < parsed.first_discovered_at + || parsed.last_changed_at < parsed.first_discovered_at + || parsed.last_changed_at > parsed.last_observed_at + || (parsed.last_live_observed_at !== null && ( + (parsed.provider === "claude" && sourceProcessIdentity === null) + || parsed.last_live_observed_at > parsed.last_observed_at + )) + ) throw new Error("SESSION_ADOPTION_CANDIDATE_SHAPE_INVALID"); + return { + provider: parsed.provider, + providerThreadId: parsed.provider_thread_id, + projectId: parsed.project_id, + providerProjectRoot: parsed.provider_project_root, + title: parsed.title, + providerState: parsed.provider_state, + activeTurnId: parsed.active_turn_id, + providerUpdatedAt: parsed.provider_updated_at, + liveness: parsed.liveness, + sourceProcessIdentity, + status: parsed.claim_status, + revision: parsed.revision, + firstDiscoveredAt: parsed.first_discovered_at, + lastObservedAt: parsed.last_observed_at, + lastChangedAt: parsed.last_changed_at, + lastAttemptAt: parsed.last_attempt_at, + lastLiveObservedAt: parsed.last_live_observed_at, + }; +}; -const canonicalLabelIdentity = ( - value: unknown, - kind: LabelIdentityKind, -): Readonly<{ key: string; label: string }> => { - const parsed = labelSchema.safeParse(value); - if (!parsed.success || parsed.data !== value) { - throw new StateLabelInvariantError(kind, "INVALID"); - } - const key = canonicalLabelKey(parsed.data); - if (key.length === 0 || utf8Bytes(key) > 4_096) { - throw new StateLabelInvariantError(kind, "KEY_INVALID"); - } - return { key, label: parsed.data }; +const mapSessionPersonalRuntimeBinding = ( + row: unknown, +): SessionPersonalRuntimeBindingRecord => { + const parsed = sessionPersonalRuntimeBindingRowSchema.parse(row); + if ( + (parsed.state === "detached") !== (parsed.detached_at !== null) + || parsed.updated_at < parsed.adopted_at + ) throw new Error("SESSION_PERSONAL_RUNTIME_BINDING_SHAPE_INVALID"); + return { + sessionId: parsed.session_id, + provider: parsed.provider, + providerThreadId: parsed.provider_thread_id, + state: parsed.state, + revision: parsed.revision, + adoptedAt: parsed.adopted_at, + updatedAt: parsed.updated_at, + detachedAt: parsed.detached_at, + }; }; -const assertUniqueLabelKeys = ( - keys: readonly string[], - kind: LabelIdentityKind, -): void => { - const seen = new Set(); - for (const key of keys) { - if (seen.has(key)) throw new StateLabelInvariantError(kind, "COLLISION"); - seen.add(key); +const mapSessionClaudeProcessAuthority = ( + row: unknown, +): ClaudeProcessAuthorityRecord => { + const parsed = sessionClaudeProcessAuthorityRowSchema.parse(row); + if ( + (parsed.state === "released") !== (parsed.released_at !== null) + || (parsed.state === "bound" && parsed.session_id === null) + || (parsed.released_at !== null && parsed.released_at < parsed.recorded_at) + ) { + throw new Error("SESSION_CLAUDE_PROCESS_AUTHORITY_SHAPE_INVALID"); } + return { + providerThreadId: parsed.provider_thread_id, + profileId: parsed.profile_id, + profileGeneration: parsed.profile_generation, + runtimeScope: parsed.runtime_scope, + sessionId: parsed.session_id, + identity: { + pid: parsed.pid, + pidDomain: parsed.pid_domain, + procStart: parsed.proc_start, + }, + state: parsed.state, + revision: parsed.revision, + recordedAt: parsed.recorded_at, + releasedAt: parsed.released_at, + }; }; -const profileLabelMigrationRowSchema = z.object({ - id: profileIdSchema, - label: z.unknown(), - state: profileStateSchema, -}).strict(); -const projectLabelMigrationRowSchema = z.object({ - id: projectIdSchema, - label: z.unknown(), -}).strict(); -const profileLabelInvariantRowSchema = profileLabelMigrationRowSchema.extend({ - label_key: z.unknown(), -}).strict(); -const projectLabelInvariantRowSchema = projectLabelMigrationRowSchema.extend({ - label_key: z.unknown(), -}).strict(); - -const backfillCanonicalLabelKeys = (database: Database): void => { - if (!hasTableColumn(database, "profiles", "label_key")) { - database.exec("ALTER TABLE profiles ADD COLUMN label_key TEXT"); - } - if (!hasTableColumn(database, "projects", "label_key")) { - database.exec("ALTER TABLE projects ADD COLUMN label_key TEXT"); +const mapSessionClaudeProcessLaunchIntent = ( + row: unknown, +): ClaudeProcessLaunchIntentRecord => { + const parsed = sessionClaudeProcessLaunchIntentRowSchema.parse(row); + if (parsed.updated_at < parsed.staged_at) { + throw new Error("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_SHAPE_INVALID"); } + return { + intentId: parsed.intent_id, + providerThreadId: parsed.provider_thread_id, + profileId: parsed.profile_id, + profileGeneration: parsed.profile_generation, + runtimeScope: parsed.runtime_scope, + providerAccountKey: parsed.provider_account_key, + sessionId: parsed.session_id, + revision: parsed.revision, + stagedAt: parsed.staged_at, + updatedAt: parsed.updated_at, + }; +}; - const profiles = database.query( - "SELECT id,label,state FROM profiles ORDER BY id", - ).all().map((row) => { - const parsed = profileLabelMigrationRowSchema.parse(row); - return { ...parsed, ...canonicalLabelIdentity(parsed.label, "ACCOUNT") }; - }); - assertUniqueLabelKeys( - profiles.filter((profile) => profile.state !== "removed").map((profile) => profile.key), - "ACCOUNT", - ); - - const projects = database.query( - "SELECT id,label FROM projects ORDER BY id", - ).all().map((row) => { - const parsed = projectLabelMigrationRowSchema.parse(row); - return { ...parsed, ...canonicalLabelIdentity(parsed.label, "PROJECT") }; - }); - assertUniqueLabelKeys(projects.map((project) => project.key), "PROJECT"); +const mapProfilePersonalAuthorityRevocation = ( + row: unknown, +): ProfilePersonalAuthorityRevocationRecord => { + const parsed = profilePersonalAuthorityRevocationRowSchema.parse(row); + if ( + (parsed.state === "completed") !== (parsed.completed_at !== null) + || parsed.updated_at < parsed.created_at + ) throw new Error("PROFILE_PERSONAL_AUTHORITY_REVOCATION_SHAPE_INVALID"); + return { + profileId: parsed.profile_id, + profileGeneration: parsed.profile_generation, + state: parsed.state, + revision: parsed.revision, + createdAt: parsed.created_at, + updatedAt: parsed.updated_at, + completedAt: parsed.completed_at, + }; +}; - const updateProfile = database.query("UPDATE profiles SET label_key=? WHERE id=?"); - for (const profile of profiles) updateProfile.run(profile.key, profile.id); - const updateProject = database.query("UPDATE projects SET label_key=? WHERE id=?"); - for (const project of projects) updateProject.run(project.key, project.id); +const mapProviderRuntimeAccountRevocation = ( + row: unknown, +): ProviderRuntimeAccountRevocationRecord => { + const parsed = providerRuntimeAccountRevocationRowSchema.parse(row); + if ( + (parsed.state === "completed") !== (parsed.completed_at !== null) + || parsed.updated_at < parsed.created_at + || ( + parsed.current_account_key !== null + && !parsed.current_account_key.startsWith(`v1:${parsed.provider}:`) + ) + ) throw new Error("PROVIDER_RUNTIME_ACCOUNT_REVOCATION_SHAPE_INVALID"); + return { + profileId: parsed.profile_id, + profileGeneration: parsed.profile_generation, + provider: parsed.provider, + runtimeScope: parsed.runtime_scope, + currentAccountKey: parsed.current_account_key, + state: parsed.state, + revision: parsed.revision, + createdAt: parsed.created_at, + updatedAt: parsed.updated_at, + completedAt: parsed.completed_at, + }; }; -const assertCanonicalLabelKeys = (database: Database): void => { - if (!hasTableColumn(database, "profiles", "label_key")) { - throw new StateLabelInvariantError("ACCOUNT", "KEY_INVALID"); +const assertRuntimeProfileRequirement = ( + profile: ReviewedRuntimeProfile, + preset: Preset, + requirement: PresetRequirement, + code: string, +): void => { + if ( + profile.preset !== preset + || profile.model !== requirement.model + || profile.reasoningEffort !== requirement.effort + ) throw new Error(code); +}; + +const presetContractForRuntimeProfile = ( + profile: ReviewedRuntimeProfile, + preset: Preset, +): z.infer => { + const current = presetRequirementForContract(preset, currentPresetContract); + if (profile.model === current.model && profile.reasoningEffort === current.effort) { + return currentPresetContract; } - if (!hasTableColumn(database, "projects", "label_key")) { - throw new StateLabelInvariantError("PROJECT", "KEY_INVALID"); + const legacy = presetRequirementForContract(preset, legacyPresetContract); + if ( + legacy !== undefined + && profile.model === legacy.model + && profile.reasoningEffort === legacy.effort + ) { + return legacyPresetContract; } + throw new Error("SESSION_RUNTIME_PROFILE_PRESET_CONTRACT_UNADMITTED"); +}; - const profileKeys = database.query( - "SELECT label,label_key,state FROM profiles ORDER BY id", - ).all().map((row) => { - const parsed = profileLabelInvariantRowSchema.omit({ id: true }).parse(row); - const identity = canonicalLabelIdentity(parsed.label, "ACCOUNT"); - if (parsed.label_key !== identity.key) { - throw new StateLabelInvariantError("ACCOUNT", "KEY_INVALID"); - } - return { key: identity.key, state: parsed.state }; - }); - assertUniqueLabelKeys( - profileKeys.filter((profile) => profile.state !== "removed").map((profile) => profile.key), - "ACCOUNT", - ); +const sessionEventStreamRowSchema = z.object({ + stream_epoch: z.string().uuid(), + floor_sequence: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + observed_through_sequence: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + retained_count: z.number().int().nonnegative(), + retained_bytes: z.number().int().nonnegative(), + retention_gap_reason: sessionEventGapReasonSchema.nullable(), +}).strict(); - const projectKeys = database.query( - "SELECT label,label_key FROM projects ORDER BY id", - ).all().map((row) => { - const parsed = projectLabelInvariantRowSchema.omit({ id: true }).parse(row); - const identity = canonicalLabelIdentity(parsed.label, "PROJECT"); - if (parsed.label_key !== identity.key) { - throw new StateLabelInvariantError("PROJECT", "KEY_INVALID"); - } - return identity.key; - }); - assertUniqueLabelKeys(projectKeys, "PROJECT"); +const mapSessionEventStreamPosition = (row: unknown): SessionEventStreamPosition => { + const parsed = sessionEventStreamRowSchema.parse(row); + return { + streamEpoch: parsed.stream_epoch, + floorSequence: parsed.floor_sequence, + observedThroughSequence: parsed.observed_through_sequence, + }; }; -const ensureStableQueueSequence = (database: Database): void => { - if (!hasTableColumn(database, "queue_entries", "enqueue_sequence")) { - database.exec( - "ALTER TABLE queue_entries ADD COLUMN enqueue_sequence INTEGER CHECK(enqueue_sequence IS NULL OR enqueue_sequence BETWEEN 1 AND 9007199254740990)", - ); - } - database.exec(` - WITH ordered AS ( - SELECT rowid,ROW_NUMBER() OVER (ORDER BY rowid) AS enqueue_sequence - FROM queue_entries - ) - UPDATE queue_entries - SET enqueue_sequence=( - SELECT ordered.enqueue_sequence FROM ordered WHERE ordered.rowid=queue_entries.rowid - ) - WHERE enqueue_sequence IS NULL; - `); - database.exec(schemaVersion14); - const exhausted = database.query( - "SELECT 1 FROM queue_entries WHERE enqueue_sequence>=9007199254740991 LIMIT 1", - ).get(); - if (exhausted !== null) throw new Error("QUEUE_SEQUENCE_EXHAUSTED"); - database.query( - `UPDATE queue_sequence_authority - SET next_sequence=MAX( - next_sequence, - COALESCE((SELECT MAX(enqueue_sequence) FROM queue_entries),0)+1 - ) - WHERE singleton=1`, - ).run(); -}; +const interactionRowSchema = z.object({ + public_id: z.string().uuid(), + session_id: sessionIdSchema.nullable(), + profile_id: profileIdSchema, + process_generation: z.number().int().nonnegative(), + connection_id: z.string().uuid(), + request_id_type: z.enum(["number", "string"]), + request_id_number: z.number().int().safe().nullable(), + request_id_text: z.string().min(1).max(512).nullable(), + method: z.string().min(1).max(512), + request_digest: sha256Schema, + thread_id: z.string().min(1).max(512).nullable(), + turn_id: z.string().min(1).max(512).nullable(), + item_id: z.string().min(1).max(512).nullable(), + approval_id: z.string().min(1).max(512).nullable(), + kind: interactionKindSchema, + state: interactionStateSchema, + revision: z.number().int().positive(), + blocking: z.union([z.literal(0), z.literal(1)]), + display_json: z.string().min(2).max(65_536), + response_digest: sha256Schema.nullable(), + response_expected_revision: z.number().int().positive().nullable(), + intended_terminal_state: interactionIntendedTerminalStateSchema.nullable(), + resolved_by: z.enum(["autorespond"]).nullable(), + requested_at: unixMillisecondsSchema, + deadline_at: unixMillisecondsSchema, + updated_at: unixMillisecondsSchema, + terminal_at: unixMillisecondsSchema.nullable(), +}).strict(); -const backfillExactTurnRuntimeProfiles = (database: Database, now: number): void => { - const rows = [ - ...database.query(`SELECT p.*,CASE - WHEN m.state='applied' THEN json_extract(m.result_json,'$.turnId') - WHEN r.resolution_kind='proven_applied' THEN json_extract(r.receipt_json,'$.turnId') - ELSE NULL END AS turn_id - FROM session_runtime_profiles p - JOIN mutation_attempts m ON m.id=p.source_id AND p.source_kind='turn_start' - LEFT JOIN mutation_resolutions r ON r.attempt_id=m.id`).all(), - ...database.query(`SELECT p.*,CASE - WHEN r.resolution_kind='proven_applied' THEN json_extract(r.receipt_json,'$.turnId') - ELSE NULL END AS turn_id - FROM session_runtime_profiles p - JOIN queue_entries q ON q.id=p.source_id AND p.source_kind='queue_start' - LEFT JOIN queue_effect_resolutions r ON r.queue_id=q.id`).all(), - ]; - for (const row of rows) { - const parsed = sessionRuntimeProfileRowSchema.extend({ - turn_id: z.string().min(1).max(200).nullable(), - }).parse(row); - if (parsed.turn_id === null || parsed.source_kind === "session_start") continue; - const profile = reviewedRuntimeProfileSchema.parse(JSON.parse(parsed.profile_json) as unknown); - if ( - profile.profileId !== parsed.profile_id - || profile.processGeneration !== parsed.process_generation - || profile.observedAt !== parsed.observed_at - ) throw new Error("Stored runtime profile authority is incoherent."); - database.query( - `INSERT INTO session_turn_runtime_profiles( - session_id,turn_id,source_kind,source_id,profile_id,process_generation, - observed_at,profile_json,profile_digest,recorded_at - ) VALUES (?,?,?,?,?,?,?,?,?,?)`, - ).run( - parsed.session_id, - parsed.turn_id, - parsed.source_kind, - parsed.source_id, - parsed.profile_id, - parsed.process_generation, - parsed.observed_at, - parsed.profile_json, - createHash("sha256").update(parsed.profile_json).digest("hex"), - now, - ); - } +const mapInteraction = (row: unknown): InteractionRecord => { + const parsed = interactionRowSchema.parse(row); + const requestId = parsed.request_id_type === "number" + ? { type: "number" as const, value: z.number().int().safe().parse(parsed.request_id_number) } + : { type: "string" as const, value: z.string().min(1).max(512).parse(parsed.request_id_text) }; + const storedDisplay = interactionDisplaySchema.parse(JSON.parse(parsed.display_json) as unknown); + const display: InteractionDisplay = storedDisplay.kind === "mcp_elicitation" + ? { ...storedDisplay, summary: PUBLIC_MCP_FORM_SUMMARY } + : storedDisplay; + return interactionRecordSchema.parse({ + version: 1, + publicId: parsed.public_id, + sessionId: parsed.session_id, + authority: { + profileId: parsed.profile_id, + processGeneration: parsed.process_generation, + connectionId: parsed.connection_id, + requestId, + method: parsed.method, + requestDigest: parsed.request_digest, + threadId: parsed.thread_id, + turnId: parsed.turn_id, + itemId: parsed.item_id, + approvalId: parsed.approval_id, + }, + kind: parsed.kind, + state: parsed.state, + revision: parsed.revision, + blocking: parsed.blocking === 1, + display, + responseDigest: parsed.response_digest, + intendedTerminalState: parsed.intended_terminal_state, + resolvedBy: parsed.resolved_by, + requestedAt: parsed.requested_at, + deadlineAt: parsed.deadline_at, + updatedAt: parsed.updated_at, + terminalAt: parsed.terminal_at, + }); }; -const backfillSchemaVersion9 = (database: Database, now: number): void => { - const sessions = database.query( - "SELECT id FROM sessions WHERE NOT EXISTS(SELECT 1 FROM session_event_streams e WHERE e.session_id=sessions.id) ORDER BY id", - ).all(); - for (const row of sessions) { - const sessionId = z.object({ id: sessionIdSchema }).strict().parse(row).id; - database.query( - `INSERT OR IGNORE INTO session_event_streams( - session_id,stream_epoch,next_sequence,floor_sequence,observed_through_sequence, - retained_count,retained_bytes,retention_gap_reason,created_at,updated_at - ) VALUES (?,?,1,1,0,0,0,NULL,?,?)`, - ).run(sessionId, randomUUID(), now, now); - } - database.exec(`INSERT OR IGNORE INTO usage_revision_authority(profile_id,next_revision) - SELECT p.id,CASE - WHEN MAX(u.source_revision) IS NULL THEN 1 - WHEN MAX(u.source_revision)>=9007199254740991 THEN 9007199254740991 - ELSE MAX(u.source_revision)+1 - END - FROM profiles p LEFT JOIN usage_snapshots u ON u.profile_id=p.id - GROUP BY p.id`); +const storedSessionEventEnvelopeSchema = z.object({ + body: z.unknown(), +}).passthrough(); + +const parseStoredSessionEvent = ( + value: string, + projectionVersion: 1 | 2, + projector: PublicProviderIdentifierProjector, +): SessionEvent => { + const stored = storedSessionEventEnvelopeSchema.parse(JSON.parse(value) as unknown); + return sessionEventSchema.parse({ + ...stored, + body: projectionVersion === 2 + ? stored.body + : projectPublicSessionEventBody(stored.body, projector), + }); +}; + +const mapSessionRuntimeProfile = (row: unknown): SessionRuntimeProfileRecord => { + const parsed = sessionRuntimeProfileRowSchema.parse(row); + const profile = reviewedRuntimeProfileSchema.parse(JSON.parse(parsed.profile_json) as unknown); + if ( + profile.profileId !== parsed.profile_id + || profile.processGeneration !== parsed.process_generation + || profile.observedAt !== parsed.observed_at + ) throw new Error("Stored runtime profile authority is incoherent."); + return { + sessionId: parsed.session_id, + revision: parsed.revision, + sourceKind: parsed.source_kind, + sourceId: parsed.source_id, + profile, + recordedAt: parsed.recorded_at, + }; }; -const backfillSchemaVersion10 = (database: Database): void => { - database.exec(`UPDATE usage_revision_authority - SET next_revision=MAX( - next_revision, - COALESCE(( - SELECT CASE - WHEN MAX(f.source_revision)>=9007199254740991 THEN 9007199254740991 - ELSE MAX(f.source_revision)+1 - END - FROM usage_poll_failures f - WHERE f.profile_id=usage_revision_authority.profile_id - ),1) - )`); +const mapSessionTurnRuntimeProfile = (row: unknown): Readonly<{ + profile: ReviewedRuntimeProfile; + sessionId: SessionId; + sourceId: string; + sourceKind: "turn_start" | "queue_start"; + turnId: string; +}> => { + const parsed = sessionTurnRuntimeProfileRowSchema.parse(row); + const profile = reviewedRuntimeProfileSchema.parse(JSON.parse(parsed.profile_json) as unknown); + if ( + profile.profileId !== parsed.profile_id + || profile.processGeneration !== parsed.process_generation + || profile.observedAt !== parsed.observed_at + || digestJson(profile) !== parsed.profile_digest + ) throw new Error("Stored turn runtime profile authority is incoherent."); + return { + profile, + sessionId: parsed.session_id, + sourceId: parsed.source_id, + sourceKind: parsed.source_kind, + turnId: parsed.turn_id, + }; }; -const migratedMcpUrlDisplayJson = JSON.stringify(interactionDisplaySchema.parse({ - kind: "mcp_elicitation", - summary: "Unsupported MCP browser handoff canceled during security migration", - serverName: "redacted", - mode: "form", - url: null, - mayContainSecrets: true, -})); +const desktopSwitchRowSchema = z + .object({ + attempt_id: attemptIdSchema, + idempotency_key: z.string().uuid(), + mutation_state: mutationStateSchema, + result_json: z.string().nullable(), + source_profile_id: profileIdSchema.nullable(), + target_profile_id: profileIdSchema, + source_generation: positiveGenerationSchema.nullable(), + target_generation: positiveGenerationSchema, + phase: desktopSwitchPhaseSchema, + diagnostic_code: desktopDiagnosticSchema.nullable(), + switch_generation: positiveGenerationSchema, + journal_prepared: z.union([z.literal(0), z.literal(1)]), + journal_digest: z.string().regex(/^[a-f0-9]{64}$/u).nullable(), + bundle_cd_hash: z.string().regex(/^[a-f0-9]{40,128}$/u).nullable(), + source_pid: z.number().int().positive().nullable(), + expected_account_key: desktopAccountKeySchema, + launched_pid: z.number().int().positive().nullable(), + ambiguous_from_phase: z.enum(["prepared", "quit_started", "quit_confirmed", "launch_started", "verify_started"]).nullable(), + recovery_deadline_at: unixMillisecondsSchema.nullable(), + resolution_kind: desktopRecoveryResolutionSchema.nullable(), + resolution_diagnostic_code: desktopDiagnosticSchema.nullable(), + resolution_observation_digest: sha256Schema.nullable(), + resolution_receipt_json: z.string().nullable(), + resolution_resolved_at: unixMillisecondsSchema.nullable(), + }) + .strict(); -const legacyMcpUrlInteractionRowSchema = z.object({ - public_id: z.string().uuid(), - state: interactionStateSchema, - revision: z.number().int().positive(), - requested_at: unixMillisecondsSchema, - updated_at: unixMillisecondsSchema, -}).strict(); +type DesktopSwitchRow = z.infer; -const redactLegacyMcpUrlInteractions = (database: Database, migratedAt: number): boolean => { - const rows = database.query( - `SELECT public_id,state,revision,requested_at,updated_at - FROM provider_interactions - WHERE kind='mcp_elicitation' AND ( - json_extract(display_json,'$.mode')='url' - OR COALESCE(json_type(display_json,'$.url'),'null')!='null' - ) - ORDER BY public_id`, - ).all().map((row) => legacyMcpUrlInteractionRowSchema.parse(row)); - if (rows.length === 0) return false; +const desktopRecoveryReceiptSchema = z + .object({ + status: desktopRecoveryResolutionSchema, + attemptId: attemptIdSchema, + idempotencyKey: z.string().uuid(), + switchGeneration: positiveGenerationSchema, + sourceProfileId: profileIdSchema.nullable(), + sourceProcessGeneration: positiveGenerationSchema.nullable(), + targetProfileId: profileIdSchema, + targetProcessGeneration: positiveGenerationSchema, + diagnostic: desktopDiagnosticSchema, + observationDigest: sha256Schema, + resolvedAt: unixMillisecondsSchema, + activeAccount: z + .object({ + signedIn: z.boolean(), + email: z.string().trim().email().max(320).optional(), + plan: z.string().trim().min(1).max(160).optional(), + }) + .strict() + .optional(), + }) + .strict(); - database.exec("DROP TRIGGER IF EXISTS provider_interactions_authority_immutable"); - for (const row of rows) { - const terminalAt = Math.max(migratedAt, row.requested_at, row.updated_at); - if (row.state === "pending" || row.state === "response_prepared" || row.state === "response_written") { - database.query( - `UPDATE provider_interactions - SET state='resolution_unknown',revision=revision+1,display_json=?,updated_at=?,terminal_at=? - WHERE public_id=? AND revision=?`, - ).run(migratedMcpUrlDisplayJson, terminalAt, terminalAt, row.public_id, row.revision); - const migrated = legacyMcpUrlInteractionRowSchema.parse(database.query( - `SELECT public_id,state,revision,requested_at,updated_at - FROM provider_interactions WHERE public_id=?`, - ).get(row.public_id)); - database.query( - `INSERT INTO provider_interaction_transitions( - public_id,revision,state,response_digest,recorded_at - ) SELECT public_id,revision,state,response_digest,? - FROM provider_interactions WHERE public_id=?`, - ).run(migrated.updated_at, migrated.public_id); - } else { - database.query( - "UPDATE provider_interactions SET display_json=? WHERE public_id=? AND revision=?", - ).run(migratedMcpUrlDisplayJson, row.public_id, row.revision); - } - } - database.exec(schemaVersion9); - return true; +const desktopSwitchSelect = `SELECT d.attempt_id,m.idempotency_key,m.state AS mutation_state,m.result_json, + d.source_profile_id,d.target_profile_id,d.source_generation,d.target_generation,d.phase,d.diagnostic_code, + d.switch_generation,d.journal_prepared,d.journal_digest,d.bundle_cd_hash,d.source_pid,d.expected_account_key, + d.launched_pid,d.ambiguous_from_phase,d.recovery_deadline_at, + r.resolution_kind,r.diagnostic_code AS resolution_diagnostic_code, + r.observation_digest AS resolution_observation_digest,r.receipt_json AS resolution_receipt_json, + r.resolved_at AS resolution_resolved_at + FROM desktop_switches d + JOIN mutation_attempts m ON m.id=d.attempt_id + LEFT JOIN desktop_switch_resolutions r ON r.attempt_id=d.attempt_id`; + +const switchPhaseByStage: Readonly>> = { + prepared: "prepared", + "quit-requested": "quit_started", + "source-quiesced": "quit_confirmed", + "launch-requested": "launch_started", + "target-observed": "verify_started", + verified: "applied", + "recovery-required": "ambiguous", }; -const legacyPermissionDisplaySchema = z.object({ - kind: z.literal("permission_approval"), - summary: z.string().max(4_096), - reason: z.string().max(4_096).nullable(), - requested: z.array(z.object({ - name: z.string().min(1).max(256), - value: z.unknown(), - }).strict()).max(100), - allowsSessionScope: z.boolean(), -}).strict(); +const effectAdjacentPhases = new Set>([ + "quit_started", + "quit_confirmed", + "launch_started", + "verify_started", +]); +const desktopRecoverySettlementMs = 30_000; -const legacyPermissionInteractionRowSchema = z.object({ - public_id: z.string().uuid(), - display_json: z.string(), -}).strict(); +const digestJson = (value: unknown): string => + createHash("sha256").update(JSON.stringify(value)).digest("hex"); -const redactLegacyPermissionValues = (database: Database): boolean => { - const rows = database.query( - `SELECT public_id,display_json - FROM provider_interactions - WHERE kind='permission_approval' AND EXISTS( - SELECT 1 FROM json_each(json_extract(display_json,'$.requested')) - WHERE json_type(value,'$.value') IS NOT NULL - ) - ORDER BY public_id`, - ).all().map((row) => legacyPermissionInteractionRowSchema.parse(row)); - if (rows.length === 0) return false; +const sessionProviderSwitchTargetAliasesSource = ( + evidence: Extract, + targetProviderThreadId: string, +): boolean => evidence.sourceProfileId === evidence.targetProfileId + && evidence.sourceProvider === evidence.targetProvider + && evidence.sourceProviderThreadId === targetProviderThreadId; - database.exec("DROP TRIGGER IF EXISTS provider_interactions_authority_immutable"); - for (const row of rows) { - const legacy = legacyPermissionDisplaySchema.parse(JSON.parse(row.display_json)); - const display = interactionDisplaySchema.parse({ - ...legacy, - requested: legacy.requested.map(({ name }) => ({ name })), - }); - database.query( - "UPDATE provider_interactions SET display_json=? WHERE public_id=?", - ).run(JSON.stringify(display), row.public_id); - } - database.exec(schemaVersion9); - return true; +const sessionAdoptionCandidateFingerprint = (input: Readonly<{ + provider: AdoptableProvider; + providerThreadId: string; + projectId: ProjectId | null; + providerProjectRoot: string | null; + title: string; + providerState: "active" | "idle" | "terminal"; + activeTurnId: string | null; + providerUpdatedAt: number | null; + sourceProcessIdentity: ClaudeProcessIdentity | null; +}>): string => digestJson({ + provider: input.provider, + providerThreadId: input.providerThreadId, + projectId: input.projectId, + // Preserve the historical digest for migrated rows that predate retention + // of the private raw provider path. + ...(input.providerProjectRoot === null + ? {} + : { providerProjectRoot: input.providerProjectRoot }), + title: input.title, + providerState: input.providerState, + activeTurnId: input.activeTurnId, + providerUpdatedAt: input.providerUpdatedAt, + // Preserve the pre-source-identity digest for rows that have no exact + // Claude process evidence. Otherwise reopening an early v35 database would + // make every legacy fence look like a new provider observation. + ...(input.sourceProcessIdentity === null + ? {} + : { sourceProcessIdentity: input.sourceProcessIdentity }), +}); + +const parseSessionProviderSwitchReceipt = ( + value: unknown, + evidence: Extract, + sessionId: SessionId, + targetProviderThreadId: string, + seedTurnId: string, + seedTurnStatus: "completed" | "interrupted" | "failed" | "inProgress", +) => { + const receipt = sessionProviderSwitchReceiptSchema.parse(value); + if ( + receipt.sessionId !== sessionId + || receipt.providerThreadId !== targetProviderThreadId + || receipt.request.accountId !== evidence.requestedAccountId + || receipt.request.preset !== evidence.requestedPreset + || receipt.request.provider !== evidence.targetProvider + || receipt.from.account !== evidence.sourceProfileId + || receipt.from.preset !== evidence.sourcePreset + || receipt.from.provider !== evidence.sourceProvider + || receipt.to.account !== evidence.targetProfileId + || receipt.to.preset !== evidence.targetPreset + || receipt.to.provider !== evidence.targetProvider + || receipt.transcriptDigest !== evidence.transcriptDigest + || receipt.seed.digest !== evidence.seedDigest + || receipt.seed.includedRecords !== evidence.seedIncludedRecords + || receipt.seed.omittedRecords !== evidence.seedOmittedRecords + || receipt.seed.status !== seedTurnStatus + || receipt.turnId !== seedTurnId + ) throw new Error("SESSION_PROVIDER_SWITCH_RECEIPT_MISMATCH"); + return receipt; }; -const legacyApprovalDisplaySchema = z.discriminatedUnion("kind", [ - z.object({ - kind: z.literal("command_approval"), - summary: z.string().max(4_096), - reason: z.string().max(4_096).nullable(), - commandClass: z.string().min(1).max(256), - workingDirectory: z.string().max(1_024).nullable(), - allowsSessionApproval: z.boolean(), - }).strict(), - z.object({ - kind: z.literal("file_change_approval"), - summary: z.string().max(4_096), - reason: z.string().max(4_096).nullable(), - grantRoot: z.string().max(1_024).nullable(), - allowsSessionApproval: z.boolean(), - }).strict(), -]); +const parseOptionalPlan = (value: string | undefined): string | undefined => { + if (value === undefined) return undefined; + const parsed = z.string().trim().min(1).max(160).safeParse(value); + return parsed.success ? parsed.data : undefined; +}; -const backfillExactApprovalDecisions = (database: Database): void => { - const rows = database.query( - `SELECT public_id,display_json FROM provider_interactions - WHERE kind IN ('command_approval','file_change_approval') - AND json_type(display_json,'$.availableDecisions') IS NULL - ORDER BY public_id`, - ).all().map((row) => z.object({ - public_id: z.string().uuid(), - display_json: z.string(), - }).strict().parse(row)); - if (rows.length === 0) return; +export class SelectionError extends Error { + constructor(readonly code: "NOT_FOUND" | "AMBIGUOUS", readonly candidates: readonly { id: string; label: string }[] = []) { + super(code === "NOT_FOUND" ? "No matching object was found." : "The selector matches more than one object."); + this.name = "SelectionError"; + } +} - database.exec("DROP TRIGGER IF EXISTS provider_interactions_authority_immutable"); - for (const row of rows) { - const legacy = legacyApprovalDisplaySchema.parse(JSON.parse(row.display_json) as unknown); - const { allowsSessionApproval, ...display } = legacy; - const availableDecisions = [ - "once" as const, - ...(allowsSessionApproval ? ["session" as const] : []), - "decline" as const, - "cancel" as const, - ]; - const migrated = interactionDisplaySchema.parse({ ...display, availableDecisions }); - database.query( - "UPDATE provider_interactions SET display_json=? WHERE public_id=?", - ).run(JSON.stringify(migrated), row.public_id); +export class StateSecurityScrubRequiredError extends Error { + constructor( + readonly operationCommitted: boolean, + cause?: unknown, + ) { + super("STATE_SECURITY_SCRUB_REQUIRED", { cause }); + this.name = "StateSecurityScrubRequiredError"; } - database.exec(schemaVersion9); -}; +} -const usageSnapshotReceivedAt = (snapshot: UsageSnapshotRecord): number => { - const parsed = storedAccountUsageSnapshotSchema.safeParse(snapshot.payload); - return parsed.success ? parsed.data.observation.receivedAt : snapshot.observedAt; -}; +export class UnusableProjectRootError extends Error { + constructor() { + super("Project root must be an existing readable, writable, traversable canonical directory without symbolic links."); + this.name = "UnusableProjectRootError"; + } +} -const recordUsageCloudUploadAnchor = ( - database: Database, - profileId: ProfileId, - sourceRevision: number, - receivedAt: number, -): void => { - const latest = database.query( - `SELECT source_revision,received_at FROM usage_cloud_upload_anchors - WHERE profile_id=? ORDER BY source_revision DESC LIMIT 1`, - ).get(profileId) as { source_revision: number; received_at: number } | null; - if ( - latest !== null - && ( - sourceRevision <= latest.source_revision - || receivedAt < latest.received_at + USAGE_CLOUD_UPLOAD_MIN_INTERVAL_MS - ) - ) return; - database.query( - `INSERT OR IGNORE INTO usage_cloud_upload_anchors( - profile_id,source_revision,received_at - ) VALUES (?,?,?)`, - ).run(profileId, sourceRevision, receivedAt); - database.query( - `DELETE FROM usage_cloud_upload_anchors WHERE rowid IN ( - SELECT rowid FROM usage_cloud_upload_anchors WHERE profile_id=? - ORDER BY source_revision DESC - LIMIT -1 OFFSET ${USAGE_CLOUD_UPLOAD_ANCHOR_COUNT} - )`, - ).run(profileId); -}; +export class StateStore { + readonly #database: Database; + readonly #now: () => number; + readonly #readonly: boolean; + readonly #securityScrubCheckpoint: SecurityScrubCheckpointPolicy; + #publicProviderIdentifierProjector: PublicProviderIdentifierProjector; + readonly paths: StatePaths; -const backfillUsageCloudUploadAnchors = (database: Database): void => { - const profiles = database.query("SELECT id FROM profiles ORDER BY id").all(); - for (const row of profiles) { - const profileId = z.object({ id: profileIdSchema }).strict().parse(row).id; - const snapshots = database.query( - `SELECT source_revision,observed_at,payload_json FROM usage_snapshots - WHERE profile_id=? ORDER BY source_revision`, - ).all(profileId); - for (const snapshotRow of snapshots) { - const parsed = z.object({ - source_revision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), - observed_at: unixMillisecondsSchema, - payload_json: z.string(), - }).strict().parse(snapshotRow); - recordUsageCloudUploadAnchor( - database, - profileId, - parsed.source_revision, - usageSnapshotReceivedAt({ - observedAt: parsed.observed_at, - payload: JSON.parse(parsed.payload_json) as unknown, - sourceRevision: parsed.source_revision, - }), - ); + constructor(paths: StatePaths, options: { + readonly?: boolean; + now?: () => number; + beforeDatabaseOpen?: (input: Readonly<{ flags: number; path: string }>) => void; + publicProviderIdentifierProjector?: PublicProviderIdentifierProjector; + resolveMachineTimeZone?: MachineTimeZoneResolver; + // Test-only. Shortens the scrub checkpoint wait so a pinned-reader test + // does not spend the production 5 s budget. Never passed by the CLI or daemon. + securityScrubCheckpoint?: SecurityScrubCheckpointPolicy; + } = {}) { + this.paths = paths; + this.#now = options.now ?? Date.now; + this.#readonly = options.readonly === true; + this.#securityScrubCheckpoint = options.securityScrubCheckpoint === undefined + ? defaultSecurityScrubCheckpointPolicy + : securityScrubCheckpointPolicySchema.parse(options.securityScrubCheckpoint); + this.#publicProviderIdentifierProjector = options.publicProviderIdentifierProjector + ?? processLocalPublicProviderIdentifierProjector; + const databaseFile = prepareStateDatabaseFile(paths.database, this.#readonly); + const databaseOpenFlags = stateDatabaseOpenFlags(this.#readonly); + options.beforeDatabaseOpen?.({ flags: databaseOpenFlags, path: paths.database }); + // Bun's object options do not expose SQLITE_OPEN_NOFOLLOW. Numeric flags are + // therefore the actual SQLite open boundary. This store binds positionally, + // so dropping Bun's JavaScript-only `strict` binding option does not change + // its statement contract; SQLite STRICT tables remain schema-enforced. + this.#database = new Database(paths.database, databaseOpenFlags); + try { + assertStateDatabaseFile(paths.database, databaseFile); + this.#database.exec(`PRAGMA foreign_keys = ON; PRAGMA busy_timeout = ${stateBusyTimeoutMs};`); + if (!options.readonly) { + requireWalMode(this.#database, true); + this.#database.exec("PRAGMA synchronous = FULL;"); + migrateWritableDatabase( + this.#database, + this.#now, + this.#securityScrubCheckpoint, + options.resolveMachineTimeZone ?? defaultMachineTimeZoneResolver, + ); + } else { + const version = readUserVersion(this.#database); + if (version > currentSchemaVersion) throw new Error(`STATE_SCHEMA_NEWER:${version}:${currentSchemaVersion}`); + if (version < currentSchemaVersion) throw new Error(`STATE_SCHEMA_MIGRATION_REQUIRED:${version}:${currentSchemaVersion}`); + if (hasPendingSecurityScrub(this.#database)) throw new Error("STATE_SECURITY_SCRUB_REQUIRED"); + } + assertSchemaVersion24Objects(this.#database); + assertSchemaVersion35Objects(this.#database); + assertSchemaVersion38PresetContracts(this.#database); + assertSchemaVersion39ProviderAuthority(this.#database); + assertSchemaVersion40AdoptionObjects(this.#database); + assertExactSchemaVersion40AdoptionSurface(this.#database); + // A readonly open skips the O(rows) foreign_key_check so `hra status` + // never pins a WAL snapshot long enough to block the writer's scrub. + if (this.#readonly) assertReadonlyWorkSchema(this.#database); + else assertWorkSchema(this.#database); + assertCanonicalLabelKeys(this.#database); + assertAccountRateLimitResetPolicies(this.#database); + assertSessionTaskSchema(this.#database); + assertCompositeNotificationPolicy(this.#database); + assertStateDatabaseFile(paths.database, databaseFile); + } catch (error) { + this.#database.close(false); + throw error; } } -}; - -const pruneProfileUsageHistory = ( - database: Database, - profileId: ProfileId, - now: number, -): void => { - const cutoff = Math.max(0, now - USAGE_LOCAL_RETAIN_AGE_MS); - database.query( - `DELETE FROM usage_snapshots - WHERE profile_id=? - AND CASE - WHEN json_type(payload_json,'$.observation.receivedAt')='integer' - THEN json_extract(payload_json,'$.observation.receivedAt') - ELSE observed_at - END? - )`, - ).run(profileId, USAGE_LOCAL_RETAIN_BYTES); -}; -const pruneAllUsageHistory = (database: Database, now: number): void => { - const rows = database.query("SELECT id FROM profiles ORDER BY id").all(); - for (const row of rows) { - const profileId = z.object({ id: profileIdSchema }).strict().parse(row).id; - pruneProfileUsageHistory(database, profileId, now); + close(): void { + this.#database.close(false); } -}; -const migrateWritableDatabase = ( - database: Database, - now: () => number, - securityScrubCheckpoint: SecurityScrubCheckpointPolicy = defaultSecurityScrubCheckpointPolicy, - resolveMachineTimeZone: MachineTimeZoneResolver = defaultMachineTimeZoneResolver, -): void => { - const initialVersion = readUserVersion(database); - if (initialVersion > currentSchemaVersion) { - throw new Error(`STATE_SCHEMA_NEWER:${initialVersion}:${currentSchemaVersion}`); + createWorkStore( + daemonGeneration: number, + encodeCursor: WorkCursorEncoder, + capabilities: Readonly<{ + issue: WorkCapabilityIssuer; + verify: WorkCapabilityVerifier; + }>, + ): WorkStore { + return new WorkStore(this.#database, { + daemonGeneration, + encodeCursor, + issueCapability: capabilities.issue, + verifyCapability: capabilities.verify, + projectProviderIdentifier: (value) => this.#publicProviderIdentifierProjector(value), + now: this.#now, + }); } - if (initialVersion === currentSchemaVersion) assertCanonicalLabelKeys(database); - // Security migrations may replace secret-bearing legacy records. SQLite must - // overwrite superseded cell content instead of leaving it in free pages. - database.exec("PRAGMA secure_delete = ON"); - const securityScrubPending = database.transaction(() => { - let redacted = false; - let version = initialVersion; - const exactLegacyFeatureVersion36 = isExactLegacyFeatureVersion36( - database, - initialVersion, + createSessionTaskStore(): SessionTaskStore { + return new SessionTaskStore(this.#database, { now: this.#now }); + } + + isConversationAutomationEnabled( + sessionId: SessionId, + providerThreadId: string, + ): boolean { + const row = this.#database.query( + `SELECT 1 + FROM session_conversation_automation + WHERE session_id=? AND provider_thread_id=?`, + ).get( + sessionIdSchema.parse(sessionId), + providerThreadIdSchema.parse(providerThreadId), ); - // v9-v12 databases may have committed URL-bearing MCP records or their - // superseded bytes without retaining evidence that WAL truncation finished. - // Materializing the v13 authority inside this transaction makes the byte - // purge independently retryable from the interaction state transition. - // v14 also forces previously stamped v13 databases through this authority - // and installs stable queue ordering before the physical rebuild. - database.exec(schemaVersion13); - if (initialVersion >= 9 && initialVersion < 14) requireSecurityScrub(database, now()); - if (version < 1) { - database.exec(schemaVersion1); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(1, now()); - database.exec("PRAGMA user_version = 1"); - version = 1; - } + return row !== null; + } - // A few pre-release v1 fixtures contained only the then-reachable subset - // of the schema. Reapplying the canonical CREATE IF NOT EXISTS statements - // materializes the omitted tables without rewriting existing objects. - database.exec(schemaVersion1); + isSessionTaskQueueSource(sessionId: SessionId, queueId: QueueId): boolean { + const row = this.#database.query( + `SELECT 1 + FROM session_task_occurrences + WHERE session_id=? AND queue_id=?`, + ).get(sessionIdSchema.parse(sessionId), queueIdSchema.parse(queueId)); + return row !== null; + } - // v26 installed the current work authority guards, which now inspect the - // provider bound to a session. Older SQLite releases admitted those trigger - // definitions before the additive v32 column existed, while newer releases - // reject an unrelated table rebuild as soon as they revalidate the broken - // trigger. Heal every pre-v32 database before any later migration can force - // that validation; v32 remains the migration that stamps the column. - if (version < 32) applySchemaVersion32(database); + isSessionTaskTurnSource(sessionId: SessionId, turnId: string): boolean { + const row = this.#database.query( + `SELECT 1 + FROM session_task_occurrences o + JOIN session_turn_runtime_profiles p + ON p.session_id=o.session_id + AND p.source_kind='queue_start' + AND p.source_id=o.queue_id + WHERE o.session_id=? AND p.turn_id=?`, + ).get( + sessionIdSchema.parse(sessionId), + z.string().min(1).max(200).parse(turnId), + ); + return row !== null; + } - // Current WorkStore guards name both preset-contract columns. Install the - // additive v38 authority before any older migration replays the current - // work schema; the ordered v38 block below remains the ledger/version - // stamp. - applySchemaVersion38PresetContracts(database); + configurePublicProviderIdentifierProjector( + projector: PublicProviderIdentifierProjector, + ): void { + this.#publicProviderIdentifierProjector = projector; + } - if (version < 2) { - // Early development builds accidentally stamped this column as schema v1. - // Accept those databases without weakening the canonical append-only v1→v2 path. - if (!hasTableColumn(database, "sessions", "provider_updated_at")) { - database.exec("ALTER TABLE sessions ADD COLUMN provider_updated_at REAL CHECK(provider_updated_at IS NULL OR provider_updated_at >= 0)"); - } - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(2, now()); - database.exec("PRAGMA user_version = 2"); - version = 2; - } + projectPublicProviderIdentifier(value: string): PublicProviderIdentifier { + return this.#publicProviderIdentifierProjector(value); + } - if (version < 3) { - const desktopColumns = [ - ["switch_generation", "INTEGER CHECK(switch_generation IS NULL OR switch_generation > 0)"], - ["journal_prepared", "INTEGER NOT NULL DEFAULT 1 CHECK(journal_prepared IN (0,1))"], - ["journal_digest", "TEXT CHECK(journal_digest IS NULL OR length(journal_digest) = 64)"], - ["bundle_cd_hash", "TEXT"], - ["source_pid", "INTEGER CHECK(source_pid IS NULL OR source_pid > 0)"], - ["expected_account_key", "TEXT"], - ["launched_pid", "INTEGER CHECK(launched_pid IS NULL OR launched_pid > 0)"], - ] as const; - for (const [name, declaration] of desktopColumns) { - if (!hasTableColumn(database, "desktop_switches", name)) { - database.exec(`ALTER TABLE desktop_switches ADD COLUMN ${name} ${declaration}`); - } - } - database.exec(schemaVersion3); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(3, now()); - database.exec("PRAGMA user_version = 3"); - version = 3; - } + createProfile(label: string): ProfileRecord { + const id = createProfileId(); + const parsedLabel = labelSchema.parse(label); + const labelKey = canonicalLabelIdentity(parsedLabel, "ACCOUNT").key; + const now = unixMillisecondsSchema.parse(this.#now()); + const create = this.#database.transaction(() => { + this.#database.query("INSERT INTO profiles(id,label,label_key,state,process_generation,created_at,updated_at) VALUES (?,?,?,?,?,?,?)").run(id, parsedLabel, labelKey, "signed_out", 0, now, now); + this.#database.query( + `INSERT INTO account_rate_limit_reset_policies( + profile_id,state,account_fingerprint,weekly_window_resets_at, + revision,created_at,updated_at + ) VALUES (?,'active_unbound',NULL,NULL,1,?,?)`, + ).run(id, now, now); + return mapProfile(this.#database.query("SELECT * FROM profiles WHERE id=?").get(id)); + }); + return create.immediate(); + } - if (version < 4) { - database.exec(schemaVersion4); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(4, now()); - database.exec("PRAGMA user_version = 4"); - version = 4; - } + listProfiles(options: { includeRemoved?: boolean } = {}): readonly ProfileRecord[] { + const rows = options.includeRemoved + ? this.#database.query("SELECT * FROM profiles ORDER BY label_key, id").all() + : this.#database.query("SELECT * FROM profiles WHERE state != 'removed' ORDER BY label_key, id").all(); + return rows.map(mapProfile); + } - if (version < 5) { - const desktopRecoveryColumns = [ - ["ambiguous_from_phase", "TEXT CHECK(ambiguous_from_phase IS NULL OR ambiguous_from_phase IN ('prepared','quit_started','quit_confirmed','launch_started','verify_started'))"], - ["recovery_deadline_at", "INTEGER CHECK(recovery_deadline_at IS NULL OR recovery_deadline_at >= 0)"], - ] as const; - for (const [name, declaration] of desktopRecoveryColumns) { - if (!hasTableColumn(database, "desktop_switches", name)) { - database.exec(`ALTER TABLE desktop_switches ADD COLUMN ${name} ${declaration}`); - } - } - if (!hasTableColumn(database, "desktop_switch_authority", "released_generation")) { - database.exec("ALTER TABLE desktop_switch_authority ADD COLUMN released_generation INTEGER NOT NULL DEFAULT 0 CHECK(released_generation >= 0 AND released_generation <= current_generation)"); - } - database.exec(schemaVersion5); - database.exec("UPDATE desktop_switches SET recovery_deadline_at=updated_at+30000 WHERE journal_prepared=1 AND recovery_deadline_at IS NULL"); - database.exec(`UPDATE desktop_switch_authority - SET released_generation=current_generation - WHERE current_attempt_id IS NOT NULL - AND EXISTS( - SELECT 1 FROM mutation_attempts m - JOIN desktop_switches d ON d.attempt_id=m.id - WHERE m.id=desktop_switch_authority.current_attempt_id - AND ((m.state='applied' AND d.phase='applied') OR m.state='cancelled' OR (m.state='failed' AND d.phase='failed')) - )`); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(5, now()); - database.exec("PRAGMA user_version = 5"); - version = 5; + requireProfile(selector: string): ProfileRecord { + const selected = selectByIdOrLabel(this.listProfiles(), selector); + if (selected.kind === "found") return selected.value; + throw new SelectionError(selected.kind === "missing" ? "NOT_FOUND" : "AMBIGUOUS", selected.kind === "ambiguous" ? selected.values : []); + } + + requireProfileById(profileId: ProfileId, options: { includeRemoved?: boolean } = {}): ProfileRecord { + const parsedId = profileIdSchema.parse(profileId); + const row = options.includeRemoved + ? this.#database.query("SELECT * FROM profiles WHERE id=?").get(parsedId) + : this.#database.query("SELECT * FROM profiles WHERE id=? AND state!='removed'").get(parsedId); + if (row === null) throw new SelectionError("NOT_FOUND"); + return mapProfile(row); + } + + providerAuthorityAdvanceBlocker( + profileId: ProfileId, + provider: Provider, + ): "active_session" | "recovery_required" | "unsettled_authority" | null { + const parsedProfileId = profileIdSchema.parse(profileId); + const parsedProvider = providerSchema.parse(provider); + if ( + this.#hasUnsettledClaudeLoginAuthority(parsedProfileId) + || this.#hasUnsettledDevinLoginAuthority(parsedProfileId) + ) { + return "unsettled_authority"; } + const providerSwitch = this.#database.query( + `SELECT 1 AS present + FROM mutation_attempts m + JOIN mutation_effect_evidence e ON e.attempt_id=m.id + LEFT JOIN mutation_resolutions r ON r.attempt_id=m.id + WHERE e.kind='session.switch' + AND m.state IN ('effect_started','ambiguous') + AND r.attempt_id IS NULL + AND ( + (json_extract(e.evidence_json,'$.sourceProfileId')=? + AND json_extract(e.evidence_json,'$.sourceProvider')=?) + OR + (json_extract(e.evidence_json,'$.targetProfileId')=? + AND json_extract(e.evidence_json,'$.targetProvider')=?) + ) + LIMIT 1`, + ).get( + parsedProfileId, + parsedProvider, + parsedProfileId, + parsedProvider, + ); + if (providerSwitch !== null) return "unsettled_authority"; + const row = z.object({ + reason: z.enum([ + "active_session", + "recovery_required", + "unsettled_authority", + ]).nullable(), + }).strict().parse(this.#database.query( + `SELECT CASE + WHEN EXISTS( + SELECT 1 FROM sessions s + WHERE s.profile_id=? AND s.provider_v39=? + AND (s.state IN ('starting','active') OR s.active_turn_id IS NOT NULL) + ) THEN 'active_session' + WHEN EXISTS( + SELECT 1 FROM sessions s + WHERE s.profile_id=? AND s.provider_v39=? AND s.state='recovery_required' + ) THEN 'recovery_required' + WHEN EXISTS( + SELECT 1 + FROM sessions s + WHERE s.profile_id=? AND s.provider_v39=? + AND ( + EXISTS( + SELECT 1 FROM provider_interactions i + WHERE i.session_id=s.id + AND i.state IN ('pending','response_prepared','response_written') + ) + OR EXISTS( + SELECT 1 FROM mutation_attempts m + LEFT JOIN mutation_resolutions r ON r.attempt_id=m.id + WHERE m.authority_id=s.id + AND m.state IN ('effect_started','ambiguous') + AND r.attempt_id IS NULL + ) + OR EXISTS( + SELECT 1 FROM queue_entries q + LEFT JOIN queue_effect_resolutions r ON r.queue_id=q.id + WHERE q.session_id=s.id + AND q.state IN ('dispatching','ambiguous') + AND r.queue_id IS NULL + ) + OR EXISTS( + SELECT 1 FROM work_attempts w + WHERE w.worker_session_id=s.id + AND w.state IN ('claimed','dispatching','running','recovery_required') + ) + OR EXISTS( + SELECT 1 FROM work_signals w + WHERE w.to_session_id=s.id + AND NOT EXISTS( + SELECT 1 FROM work_signal_receipts r + WHERE r.signal_id=w.id AND r.kind='ack' + ) + ) + ) + ) THEN 'unsettled_authority' + ELSE NULL + END AS reason`, + ).get( + parsedProfileId, + parsedProvider, + parsedProfileId, + parsedProvider, + parsedProfileId, + parsedProvider, + )).reason; + return row; + } - if (version < 6) { - database.exec(schemaVersion6); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(6, now()); - database.exec("PRAGMA user_version = 6"); - version = 6; + /** + * Managed Claude login replaces only the isolated Claude home. An active or + * detaching personal binding owns a different provider home, so its session + * and source-side work cannot block that managed-only replacement. A stale + * binding/session identity mismatch fails closed, while a switch targeting + * Claude still reserves managed Claude authority. + */ + managedClaudeLoginAuthorityBlocker( + profileId: ProfileId, + ): "active_session" | "recovery_required" | "unsettled_authority" | null { + const parsedProfileId = profileIdSchema.parse(profileId); + if (this.#hasUnsettledClaudeLoginAuthority(parsedProfileId)) { + return "unsettled_authority"; } + const mismatchedPersonalBinding = this.#database.query( + `SELECT 1 AS present + FROM session_personal_runtime_bindings b + JOIN sessions s ON s.id=b.session_id + WHERE s.profile_id=? AND b.state IN ('active','detaching') + AND ( + b.provider!=s.provider_v39 + OR s.provider_thread_id IS NULL + OR b.provider_thread_id!=s.provider_thread_id + ) + LIMIT 1`, + ).get(parsedProfileId); + if (mismatchedPersonalBinding !== null) return "unsettled_authority"; + const providerSwitch = this.#database.query( + `SELECT 1 AS present + FROM mutation_attempts m + JOIN mutation_effect_evidence e ON e.attempt_id=m.id + LEFT JOIN mutation_resolutions r ON r.attempt_id=m.id + WHERE e.kind='session.switch' + AND m.state IN ('effect_started','ambiguous') + AND r.attempt_id IS NULL + AND ( + (json_extract(e.evidence_json,'$.targetProfileId')=? + AND json_extract(e.evidence_json,'$.targetProvider')='claude') + OR + (json_extract(e.evidence_json,'$.sourceProfileId')=? + AND json_extract(e.evidence_json,'$.sourceProvider')='claude' + AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=m.authority_id + AND b.provider='claude' AND b.state IN ('active','detaching') + ) + AND ( + EXISTS( + SELECT 1 FROM session_provider_account_authorities a + WHERE a.session_id=m.authority_id AND a.provider='claude' + AND a.runtime_scope='managed' + ) + OR NOT EXISTS( + SELECT 1 + FROM session_personal_runtime_bindings b + JOIN sessions s ON s.id=b.session_id + WHERE b.session_id=m.authority_id AND b.state='detached' + AND b.provider=s.provider_v39 + AND b.provider_thread_id=s.provider_thread_id + ) + )) + ) + LIMIT 1`, + ).get(parsedProfileId, parsedProfileId); + if (providerSwitch !== null) return "unsettled_authority"; + return z.object({ + reason: z.enum([ + "active_session", + "recovery_required", + "unsettled_authority", + ]).nullable(), + }).strict().parse(this.#database.query( + `SELECT CASE + WHEN EXISTS( + SELECT 1 FROM sessions s + WHERE s.profile_id=? AND s.provider_v39='claude' + AND (s.state IN ('starting','active') OR s.active_turn_id IS NOT NULL) + AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.state IN ('active','detaching') + ) + AND ( + EXISTS( + SELECT 1 FROM session_provider_account_authorities a + WHERE a.session_id=s.id AND a.provider='claude' + AND a.runtime_scope='managed' + ) + OR NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.state='detached' + AND b.provider=s.provider_v39 + AND b.provider_thread_id=s.provider_thread_id + ) + ) + ) THEN 'active_session' + WHEN EXISTS( + SELECT 1 FROM sessions s + WHERE s.profile_id=? AND s.provider_v39='claude' + AND s.state='recovery_required' + AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.state IN ('active','detaching') + ) + AND ( + EXISTS( + SELECT 1 FROM session_provider_account_authorities a + WHERE a.session_id=s.id AND a.provider='claude' + AND a.runtime_scope='managed' + ) + OR NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.state='detached' + AND b.provider=s.provider_v39 + AND b.provider_thread_id=s.provider_thread_id + ) + ) + ) THEN 'recovery_required' + WHEN EXISTS( + SELECT 1 + FROM sessions s + WHERE s.profile_id=? AND s.provider_v39='claude' + AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.state IN ('active','detaching') + ) + AND ( + EXISTS( + SELECT 1 FROM session_provider_account_authorities a + WHERE a.session_id=s.id AND a.provider='claude' + AND a.runtime_scope='managed' + ) + OR NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.state='detached' + AND b.provider=s.provider_v39 + AND b.provider_thread_id=s.provider_thread_id + ) + ) + AND ( + EXISTS( + SELECT 1 FROM provider_interactions i + WHERE i.session_id=s.id + AND i.state IN ('pending','response_prepared','response_written') + ) + OR EXISTS( + SELECT 1 FROM mutation_attempts m + LEFT JOIN mutation_resolutions r ON r.attempt_id=m.id + WHERE m.authority_id=s.id + AND m.state IN ('effect_started','ambiguous') + AND r.attempt_id IS NULL + ) + OR EXISTS( + SELECT 1 FROM queue_entries q + LEFT JOIN queue_effect_resolutions r ON r.queue_id=q.id + WHERE q.session_id=s.id + AND q.state IN ('dispatching','ambiguous') + AND r.queue_id IS NULL + ) + OR EXISTS( + SELECT 1 FROM work_attempts w + WHERE w.worker_session_id=s.id + AND w.state IN ('claimed','dispatching','running','recovery_required') + ) + OR EXISTS( + SELECT 1 FROM work_signals w + WHERE w.to_session_id=s.id + AND NOT EXISTS( + SELECT 1 FROM work_signal_receipts r + WHERE r.signal_id=w.id AND r.kind='ack' + ) + ) + ) + ) THEN 'unsettled_authority' + ELSE NULL + END AS reason`, + ).get( + parsedProfileId, + parsedProfileId, + parsedProfileId, + )).reason; + } - if (version < 7) { - database.exec(schemaVersion7); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(7, now()); - database.exec("PRAGMA user_version = 7"); - version = 7; - } + hasUnsettledSessionMutationAuthority( + profileId: ProfileId, + provider?: Provider, + ): boolean { + const tuples = this.#sessionMutationAuthorityTuplesForProfile( + profileIdSchema.parse(profileId), + ); + return provider === undefined + ? tuples.length > 0 + : tuples.some((tuple) => tuple.provider === providerSchema.parse(provider)); + } - if (version < 8) { - database.exec(schemaVersion8); - const migratedAt = now(); - backfillExactTurnRuntimeProfiles(database, migratedAt); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(8, migratedAt); - database.exec("PRAGMA user_version = 8"); - version = 8; - } + isSessionMutationProviderAuthorityCurrent(input: Readonly<{ + attemptId: AttemptId; + profileId: ProfileId; + provider: Provider; + originGeneration: number; + }>): boolean { + const attemptId = attemptIdSchema.parse(input.attemptId); + const profileId = profileIdSchema.parse(input.profileId); + const provider = providerSchema.parse(input.provider); + const originGeneration = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) + .parse(input.originGeneration); + const current = z.object({ + process_generation: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + }).strict().safeParse(this.#database.query( + "SELECT process_generation FROM profiles WHERE id=? AND state!='removed'", + ).get(profileId)); + if (!current.success) return false; + if (current.data.process_generation === originGeneration) return true; + return this.#database.query( + `WITH RECURSIVE authority_chain(generation) AS ( + VALUES (?) + UNION ALL + SELECT r.to_generation + FROM session_mutation_authority_rebinds_v39 r + JOIN authority_chain c ON r.from_generation=c.generation + WHERE r.attempt_id=? AND r.profile_id=? AND r.provider=? + ) + SELECT 1 AS current FROM authority_chain WHERE generation=? LIMIT 1`, + ).get( + originGeneration, + attemptId, + profileId, + provider, + current.data.process_generation, + ) !== null; + } - if (version < 9) { - database.exec(schemaVersion9); - const migratedAt = now(); - backfillSchemaVersion9(database, migratedAt); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(9, migratedAt); - database.exec("PRAGMA user_version = 9"); - version = 9; - } + hasNonterminalProviderSession(profileId: ProfileId, provider: Provider): boolean { + const parsedProfileId = profileIdSchema.parse(profileId); + const parsedProvider = providerSchema.parse(provider); + return this.#database.query( + `SELECT 1 AS present FROM sessions + WHERE profile_id=? AND provider_v39=? AND state!='terminal' + LIMIT 1`, + ).get(parsedProfileId, parsedProvider) !== null; + } - if (version < 10) { - database.exec(schemaVersion10); - backfillSchemaVersion10(database); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(10, now()); - database.exec("PRAGMA user_version = 10"); - version = 10; - } + listNonterminalProviderSessions( + profileId: ProfileId, + provider: Provider, + ): readonly SessionRecord[] { + const parsedProfileId = profileIdSchema.parse(profileId); + const parsedProvider = providerSchema.parse(provider); + return this.#database.query( + `SELECT * FROM sessions + WHERE profile_id=? AND provider_v39=? AND state!='terminal' + ORDER BY id`, + ).all(parsedProfileId, parsedProvider).map(mapSession); + } - if (version < 11) { - const migratedAt = now(); - redacted = redactLegacyMcpUrlInteractions(database, migratedAt); - database.exec(schemaVersion11); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(11, migratedAt); - database.exec("PRAGMA user_version = 11"); - version = 11; - } + listNonterminalManagedClaudeSessions( + profileId: ProfileId, + ): readonly SessionRecord[] { + const parsedProfileId = profileIdSchema.parse(profileId); + return this.#database.query( + `SELECT s.* FROM sessions s + WHERE s.profile_id=? AND s.provider_v39='claude' AND s.state!='terminal' + AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.state IN ('active','detaching') + ) + AND ( + EXISTS( + SELECT 1 FROM session_provider_account_authorities a + WHERE a.session_id=s.id AND a.provider='claude' + AND a.runtime_scope='managed' + ) + OR NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.state='detached' + AND b.provider=s.provider_v39 + AND b.provider_thread_id=s.provider_thread_id + ) + ) + ORDER BY s.id`, + ).all(parsedProfileId).map(mapSession); + } - if (version < 12) { - database.exec(schemaVersion12); - backfillUsageCloudUploadAnchors(database); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(12, now()); - database.exec("PRAGMA user_version = 12"); - version = 12; - } + canReleaseIdleClaudeSessionForAccountLogin(input: Readonly<{ + profileId: ProfileId; + profileGeneration: number; + sessionId: SessionId; + }>): boolean { + return this.canReleaseIdleManagedClaudeSessionForAccountLogin(input); + } - if (version < 13) { - database.exec(schemaVersion13); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(13, now()); - database.exec("PRAGMA user_version = 13"); - version = 13; - } + canReleaseIdleDevinSessionForAccountLogin(input: Readonly<{ + profileId: ProfileId; + profileGeneration: number; + sessionId: SessionId; + }>): boolean { + const profileId = profileIdSchema.parse(input.profileId); + const profileGeneration = z.number().int().nonnegative() + .max(Number.MAX_SAFE_INTEGER).parse(input.profileGeneration); + const sessionId = sessionIdSchema.parse(input.sessionId); + return this.#database.query( + `SELECT 1 AS releasable + FROM sessions s + JOIN profiles p ON p.id=s.profile_id + WHERE s.id=? AND s.profile_id=? AND p.process_generation=? + AND p.state!='removed' + AND s.provider_v39='devin' AND s.provider_thread_id IS NOT NULL + AND s.state='idle' AND s.active_turn_id IS NULL + AND NOT EXISTS( + SELECT 1 FROM mutation_attempts m + LEFT JOIN mutation_resolutions r ON r.attempt_id=m.id + LEFT JOIN session_start_attempts a ON a.attempt_id=m.id + WHERE (m.authority_id=s.id OR a.session_id=s.id) + AND m.state IN ('prepared','effect_started','ambiguous') + AND r.attempt_id IS NULL + ) + AND NOT EXISTS( + SELECT 1 FROM queue_entries q + LEFT JOIN queue_effect_resolutions r ON r.queue_id=q.id + WHERE q.session_id=s.id + AND q.state IN ('pending','dispatching','ambiguous') + AND r.queue_id IS NULL + ) + AND NOT EXISTS( + SELECT 1 FROM provider_interactions i + WHERE i.session_id=s.id + AND i.state IN ('pending','response_prepared','response_written') + ) + AND NOT EXISTS( + SELECT 1 FROM work_attempts w + WHERE w.worker_session_id=s.id + AND w.state IN ('claimed','dispatching','running','recovery_required') + ) + AND NOT EXISTS( + SELECT 1 FROM work_signals w + WHERE w.to_session_id=s.id + AND NOT EXISTS( + SELECT 1 FROM work_signal_receipts r + WHERE r.signal_id=w.id AND r.kind='ack' + ) + ) + LIMIT 1`, + ).get(sessionId, profileId, profileGeneration) !== null; + } - if (version < 14) { - ensureStableQueueSequence(database); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(14, now()); - database.exec("PRAGMA user_version = 14"); - version = 14; - } + canReleaseIdleManagedClaudeSessionForAccountLogin(input: Readonly<{ + profileId: ProfileId; + profileGeneration: number; + sessionId: SessionId; + }>): boolean { + const profileId = profileIdSchema.parse(input.profileId); + const profileGeneration = z.number().int().nonnegative() + .max(Number.MAX_SAFE_INTEGER).parse(input.profileGeneration); + const sessionId = sessionIdSchema.parse(input.sessionId); + return this.#database.query( + `SELECT 1 AS releasable + FROM sessions s + JOIN profiles p ON p.id=s.profile_id + JOIN session_provider_account_authorities pa + ON pa.session_id=s.id AND pa.provider='claude' AND pa.runtime_scope='managed' + WHERE s.id=? AND s.profile_id=? AND p.process_generation=? + AND p.state!='removed' + AND s.provider_v39='claude' AND s.provider_thread_id IS NOT NULL + AND s.state='idle' AND s.active_turn_id IS NULL + AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.state IN ('active','detaching') + ) + AND NOT EXISTS( + SELECT 1 FROM mutation_attempts m + LEFT JOIN mutation_resolutions r ON r.attempt_id=m.id + LEFT JOIN session_start_attempts a ON a.attempt_id=m.id + WHERE (m.authority_id=s.id OR a.session_id=s.id) + AND m.state IN ('prepared','effect_started','ambiguous') + AND r.attempt_id IS NULL + ) + AND NOT EXISTS( + SELECT 1 FROM queue_entries q + LEFT JOIN queue_effect_resolutions r ON r.queue_id=q.id + WHERE q.session_id=s.id + AND q.state IN ('pending','dispatching','ambiguous') + AND r.queue_id IS NULL + ) + AND NOT EXISTS( + SELECT 1 FROM provider_interactions i + WHERE i.session_id=s.id + AND i.state IN ('pending','response_prepared','response_written') + ) + AND NOT EXISTS( + SELECT 1 FROM work_attempts w + WHERE w.worker_session_id=s.id + AND w.state IN ('claimed','dispatching','running','recovery_required') + ) + AND NOT EXISTS( + SELECT 1 FROM work_signals w + WHERE w.to_session_id=s.id + AND NOT EXISTS( + SELECT 1 FROM work_signal_receipts r + WHERE r.signal_id=w.id AND r.kind='ack' + ) + ) + LIMIT 1`, + ).get(sessionId, profileId, profileGeneration) !== null; + } - if (version < 15) { - redacted = redactLegacyPermissionValues(database) || redacted; - if (redacted) requireSecurityScrub(database, now()); - database.exec(schemaVersion15); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(15, now()); - database.exec("PRAGMA user_version = 15"); - version = 15; - } + #hasUnsettledClaudeLoginAuthority(profileId: ProfileId): boolean { + return this.#database.query( + `SELECT 1 AS present FROM mutation_attempts m + LEFT JOIN mutation_resolutions r ON r.attempt_id=m.id + WHERE m.authority_id=? AND m.kind='account.claude-login' + AND m.state IN ('effect_started','ambiguous') + AND r.attempt_id IS NULL + LIMIT 1`, + ).get(profileId) !== null; + } - if (version < 16) { - if (!hasTableColumn(database, "provider_interactions", "deadline_at")) { - database.exec("ALTER TABLE provider_interactions ADD COLUMN deadline_at INTEGER NOT NULL DEFAULT 9007199254740991 CHECK(deadline_at>=requested_at AND deadline_at<=9007199254740991)"); + #hasUnsettledDevinLoginAuthority(profileId: ProfileId): boolean { + return this.#database.query( + `SELECT 1 AS present FROM mutation_attempts m + LEFT JOIN mutation_resolutions r ON r.attempt_id=m.id + WHERE m.authority_id=? AND m.kind='account.devin-login' + AND m.state IN ('effect_started','ambiguous') + AND r.attempt_id IS NULL + LIMIT 1`, + ).get(profileId) !== null; + } + + #sessionMutationAuthorityTuplesForProfile(profileId: ProfileId): readonly Readonly<{ + attemptId: AttemptId; + originGeneration: number; + provider: Provider; + }>[] { + const parsedProfileId = profileIdSchema.parse(profileId); + const rows = this.#database.query( + `SELECT m.id,m.kind AS mutation_kind,m.authority_id,m.authority_generation, + e.kind AS evidence_kind,e.evidence_json,e.evidence_digest, + a.session_id AS session_start_id + FROM mutation_attempts m + LEFT JOIN mutation_effect_evidence e ON e.attempt_id=m.id + LEFT JOIN session_start_attempts a ON a.attempt_id=m.id + LEFT JOIN mutation_resolutions r ON r.attempt_id=m.id + WHERE m.kind IN ('session.start','session.switch') + AND m.state IN ('effect_started','ambiguous') + AND r.attempt_id IS NULL + ORDER BY m.id`, + ).all() as readonly { + id: string; + mutation_kind: string; + authority_id: string; + authority_generation: number; + evidence_kind: string | null; + evidence_json: string | null; + evidence_digest: string | null; + session_start_id: string | null; + }[]; + const tuples = new Map(); + const add = (tuple: { + attemptId: AttemptId; + originGeneration: number; + provider: Provider; + }): void => { + const key = `${tuple.attemptId}\0${tuple.provider}`; + const existing = tuples.get(key); + if (existing !== undefined && existing.originGeneration !== tuple.originGeneration) { + throw new Error("SESSION_MUTATION_SUCCESSOR_AUTHORITY_AMBIGUOUS"); + } + tuples.set(key, tuple); + }; + for (const raw of rows) { + const attemptId = attemptIdSchema.parse(raw.id); + const mutationKind = z.enum(["session.start", "session.switch"]) + .parse(raw.mutation_kind); + if ( + raw.evidence_json === null + || raw.evidence_digest === null + || raw.evidence_kind !== mutationKind + ) throw new Error("SESSION_MUTATION_SUCCESSOR_EVIDENCE_MISSING"); + const evidence = mutationEffectEvidenceSchema.parse( + JSON.parse(raw.evidence_json) as unknown, + ); + if ( + evidence.kind !== mutationKind + || digestJson(evidence) !== sha256Schema.parse(raw.evidence_digest) + ) throw new Error("SESSION_MUTATION_SUCCESSOR_EVIDENCE_MISMATCH"); + const authorityGeneration = z.number().int().nonnegative() + .max(Number.MAX_SAFE_INTEGER).parse(raw.authority_generation); + if (mutationKind === "session.start" && evidence.kind === "session.start") { + const sessionId = sessionIdSchema.parse(raw.session_start_id); + const session = this.requireSession(sessionId); + if ( + profileIdSchema.parse(raw.authority_id) !== session.profileId + || (evidence.runtimeProfile !== undefined && ( + evidence.runtimeProfile.profileId !== session.profileId + || evidence.runtimeProfile.processGeneration !== authorityGeneration + || reviewedRuntimeProfileProvider(evidence.runtimeProfile) !== session.provider + )) + ) throw new Error("SESSION_MUTATION_SUCCESSOR_START_AUTHORITY_MISMATCH"); + if (session.profileId === parsedProfileId) { + add({ + attemptId, + originGeneration: authorityGeneration, + provider: session.provider, + }); + } + continue; } - if (!hasTableColumn(database, "provider_interactions", "intended_terminal_state")) { - database.exec("ALTER TABLE provider_interactions ADD COLUMN intended_terminal_state TEXT CHECK(intended_terminal_state IS NULL OR intended_terminal_state IN ('resolved','declined','canceled','expired'))"); + if (mutationKind !== "session.switch" || evidence.kind !== "session.switch") { + throw new Error("SESSION_MUTATION_SUCCESSOR_KIND_MISMATCH"); + } + const switchSessionId = sessionIdSchema.parse(raw.authority_id); + this.requireSession(switchSessionId); + if (authorityGeneration !== evidence.targetProcessGeneration) { + throw new Error("SESSION_MUTATION_SUCCESSOR_SWITCH_AUTHORITY_MISMATCH"); + } + if (evidence.sourceProfileId === parsedProfileId) { + add({ + attemptId, + originGeneration: evidence.sourceProcessGeneration, + provider: evidence.sourceProvider, + }); + } + if (evidence.targetProfileId === parsedProfileId) { + add({ + attemptId, + originGeneration: evidence.targetProcessGeneration, + provider: evidence.targetProvider, + }); } - database.query( - "UPDATE provider_interactions SET deadline_at=MIN(requested_at+?,9007199254740991) WHERE deadline_at=9007199254740991", - ).run(INTERACTION_MAX_PENDING_MS); - database.exec( - "UPDATE provider_interactions SET intended_terminal_state=state WHERE intended_terminal_state IS NULL AND state IN ('resolved','declined','canceled','expired')", - ); - database.exec(schemaVersion16); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(16, now()); - database.exec("PRAGMA user_version = 16"); - version = 16; } + return [...tuples.values()]; + } - if (version < 17) { - database.exec(schemaVersion17); - const legacyLoginResolutionEvidence = JSON.stringify({ - source: "schema17", - reason: "missing_provider_login_id", - }); - database.query(`INSERT OR IGNORE INTO mutation_resolutions( - attempt_id,resolution_kind,evidence_json,receipt_json,created_at - ) - SELECT m.id,'abandoned',?,NULL,? - FROM mutation_attempts m - JOIN profiles p ON p.id=m.authority_id - LEFT JOIN mutation_resolutions r ON r.attempt_id=m.id - WHERE m.kind='account.login' - AND m.state='applied' - AND p.state='login_pending' - AND p.process_generation=m.authority_generation - AND r.attempt_id IS NULL - AND json_extract( - CASE WHEN json_valid(m.result_json) THEN m.result_json ELSE '{}' END, - '$.status' - )='pending' - AND json_extract( - CASE WHEN json_valid(m.result_json) THEN m.result_json ELSE '{}' END, - '$.loginId' - ) IS NULL`).run( - legacyLoginResolutionEvidence, - now(), + #recordSessionMutationAuthoritySuccessors(input: Readonly<{ + fromGeneration: number; + now: number; + profileId: ProfileId; + }>): void { + const tuples = this.#sessionMutationAuthorityTuplesForProfile(input.profileId); + for (const tuple of tuples) { + if (!this.isSessionMutationProviderAuthorityCurrent({ + attemptId: tuple.attemptId, + profileId: input.profileId, + provider: tuple.provider, + originGeneration: tuple.originGeneration, + })) throw new Error("SESSION_MUTATION_SUCCESSOR_AUTHORITY_MISMATCH"); + const inserted = this.#database.query( + `INSERT INTO session_mutation_authority_rebinds_v39( + attempt_id,profile_id,provider,from_generation,to_generation,recorded_at + ) VALUES (?,?,?,?,?,?)`, + ).run( + tuple.attemptId, + input.profileId, + tuple.provider, + input.fromGeneration, + input.fromGeneration + 1, + input.now, ); - database.query(`UPDATE profiles - SET state='signed_out',provider_email=NULL,provider_plan=NULL,updated_at=MAX(updated_at,?) - WHERE state='login_pending' - AND EXISTS( - SELECT 1 - FROM mutation_attempts m - JOIN mutation_resolutions r ON r.attempt_id=m.id - WHERE m.kind='account.login' - AND m.authority_id=profiles.id - AND m.authority_generation=profiles.process_generation - AND r.resolution_kind='abandoned' - AND r.evidence_json=? - )`).run(now(), legacyLoginResolutionEvidence); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(17, now()); - database.exec("PRAGMA user_version = 17"); - version = 17; - } - - if (version < 18) { - backfillExactApprovalDecisions(database); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(18, now()); - database.exec("PRAGMA user_version = 18"); - version = 18; - } - - if (version < 19) { - database.exec(schemaVersion19); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(19, now()); - database.exec("PRAGMA user_version = 19"); - version = 19; - } - - if (version < 20) { - database.exec(schemaVersion20); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(20, now()); - database.exec("PRAGMA user_version = 20"); - version = 20; - } - - if (version < 21) { - database.exec(schemaVersion21); - scrubSettledQueueMessages(database); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(21, now()); - database.exec("PRAGMA user_version = 21"); - version = 21; + if (inserted.changes !== 1) throw new Error("SESSION_MUTATION_SUCCESSOR_CAS_CONFLICT"); + // Keep the v35 compatibility ledger current for its two representable + // providers. Devin authority exists only in the widened v39 ledger. + if (tuple.provider !== "devin") { + const compatibility = this.#database.query( + `INSERT INTO session_mutation_authority_rebinds( + attempt_id,profile_id,provider,from_generation,to_generation,recorded_at + ) VALUES (?,?,?,?,?,?)`, + ).run( + tuple.attemptId, + input.profileId, + tuple.provider, + input.fromGeneration, + input.fromGeneration + 1, + input.now, + ); + if (compatibility.changes !== 1) { + throw new Error("SESSION_MUTATION_SUCCESSOR_COMPATIBILITY_CAS_CONFLICT"); + } + } } + } - if (version < 22) { - database.exec(schemaVersion22); - // A pre-v22 database may already contain only tombstones while the - // superseded bodies remain in free pages or WAL. The durable marker makes - // the required rebuild independently retryable from the schema stamp. - if (initialVersion > 0) requireQueueMessageScrub(database, now(), true); - if (scrubSettledQueueMessages(database)) { - requireQueueMessageScrub(database, now(), true); + nextProfileGeneration(profileId: ProfileId): ProfileRecord { + const now = this.#now(); + const update = this.#database.transaction(() => { + const current = mapProfile(this.#database.query("SELECT * FROM profiles WHERE id = ? AND state != 'removed'").get(profileId)); + if (this.#hasUnsettledClaudeLoginAuthority(current.id)) { + throw new Error("CLAUDE_LOGIN_AUTHORITY_UNSETTLED"); } - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(22, now()); - database.exec("PRAGMA user_version = 22"); - version = 22; - } + if (this.#hasUnsettledDevinLoginAuthority(current.id)) { + throw new Error("DEVIN_LOGIN_AUTHORITY_UNSETTLED"); + } + if (this.#sessionMutationAuthorityTuplesForProfile(current.id).length > 0) { + throw new Error("SESSION_MUTATION_AUTHORITY_UNSETTLED"); + } + this.#database.query("UPDATE profiles SET process_generation = ?, updated_at = ? WHERE id = ? AND process_generation = ?").run(current.processGeneration + 1, now, profileId, current.processGeneration); + }); + update.immediate(); + return this.requireProfile(profileId); + } - if (version < 23) { - ensureQueueMessageScrubGeneration(database); - database.exec(schemaVersion22); - database.exec(schemaVersion23); - // v22 shipped only in development, but its singleton authority had no - // generation fence and its trigger definitions could be stale. Rebuild - // every nonempty predecessor once, then let the generation-CAS loop own - // all future runtime purges. - if (initialVersion > 0) requireQueueMessageScrub(database, now(), true); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(23, now()); - database.exec("PRAGMA user_version = 23"); - version = 23; - } + advanceProfileGeneration(profileId: ProfileId, expectedGeneration: number): ProfileRecord { + return this.#advanceProfileGeneration(profileId, expectedGeneration).profile; + } - if (version < 24) { - database.exec(dropSchemaVersion24); - backfillCanonicalLabelKeys(database); - rebuildSchemaVersion24(database); - assertSchemaVersion24Objects(database); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(24, now()); - database.exec("PRAGMA user_version = 24"); - version = 24; - } + advanceProfileGenerationWithWorkRetirement( + profileId: ProfileId, + expectedGeneration: number, + workStore: WorkStore, + options: Readonly<{ preserveSessionMutationAuthorities?: boolean }> = {}, + ): ProfileAuthorityChangeResult { + return this.#advanceProfileGeneration(profileId, expectedGeneration, workStore, options); + } - if (version < 25) { - ensureSessionEventProjectionVersion(database); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(25, now()); - database.exec("PRAGMA user_version = 25"); - version = 25; - } + /** + * A clean daemon shutdown closes every runtime before advancing its fencing + * generation. Personal bindings intentionally survive that restart, so this + * narrow path grants one transaction-local, exact +1 rollover without + * weakening login, logout, or provider-disconnect guards. + */ + advanceProfileGenerationForDaemonShutdown( + profileId: ProfileId, + expectedGeneration: number, + workStore: WorkStore, + options: Readonly<{ preserveSessionMutationAuthorities?: boolean }> = {}, + ): ProfileAuthorityChangeResult { + return this.#advanceProfileGeneration( + profileId, + expectedGeneration, + workStore, + { ...options, allowPersonalBindingRollover: true }, + ); + } - if (version < 26) { - // This binary's work-authority triggers name the additive v39 provider - // column. A fresh or pre-work database reaches v26 before the recorded - // v39 migration, so create only that column here; the same transaction - // still applies and records the complete v39 authority migration below. - ensureSchemaVersion39SessionProviderColumn(database); - database.exec(WORK_SCHEMA_SQL); - assertWorkSchema(database); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(26, now()); - database.exec("PRAGMA user_version = 26"); - version = 26; - } + #advanceProfileGeneration( + profileId: ProfileId, + expectedGeneration: number, + workStore?: WorkStore, + options: Readonly<{ + allowPersonalBindingRollover?: boolean; + preserveSessionMutationAuthorities?: boolean; + }> = {}, + ): ProfileAuthorityChangeResult { + const now = this.#now(); + const advance = this.#database.transaction(() => { + const current = mapProfile(this.#database.query("SELECT * FROM profiles WHERE id=? AND state!='removed'").get(profileId)); + if (current.processGeneration !== expectedGeneration) { + throw new Error("Profile generation authority changed."); + } + if (this.#hasUnsettledClaudeLoginAuthority(current.id)) { + throw new Error("CLAUDE_LOGIN_AUTHORITY_UNSETTLED"); + } + if (this.#hasUnsettledDevinLoginAuthority(current.id)) { + throw new Error("DEVIN_LOGIN_AUTHORITY_UNSETTLED"); + } + const sessionMutationAuthorities = this.#sessionMutationAuthorityTuplesForProfile(current.id); + if (sessionMutationAuthorities.length > 0) { + if (options.preserveSessionMutationAuthorities !== true) { + throw new Error("SESSION_MUTATION_AUTHORITY_UNSETTLED"); + } + this.#recordSessionMutationAuthoritySuccessors({ + fromGeneration: expectedGeneration, + now, + profileId: current.id, + }); + } + const activeLogin = this.#database.query(`SELECT attempt_id,process_generation + FROM provider_login_authorities + WHERE profile_id=? AND state='active'`).all(profileId) as { + attempt_id: string; + process_generation: number; + }[]; + if (activeLogin.length > 1) throw new Error("LOGIN_GENERATION_AUTHORITY_AMBIGUOUS"); + if (activeLogin.length === 1) { + if ( + current.state !== "login_pending" + || activeLogin[0]?.process_generation !== expectedGeneration + ) throw new Error("LOGIN_GENERATION_AUTHORITY_MISMATCH"); + const rebound = this.#database.query(`UPDATE provider_login_authorities + SET process_generation=?,updated_at=MAX(updated_at,?) + WHERE attempt_id=? AND process_generation=? AND state='active'`).run( + expectedGeneration + 1, + now, + activeLogin[0].attempt_id, + expectedGeneration, + ); + if (rebound.changes !== 1) throw new Error("LOGIN_GENERATION_AUTHORITY_CAS_CONFLICT"); + } + const state = current.state === "login_pending" && activeLogin.length === 0 + ? "recovery_required" + : current.state; + const affectedWorkIds = workStore?.prepareProfileAuthorityChange( + profileId, + expectedGeneration, + ) ?? []; + if (options.allowPersonalBindingRollover === true) { + this.#database.query( + `UPDATE profile_personal_authority_revocations + SET profile_generation=profile_generation+1,revision=revision+1, + updated_at=MAX(updated_at,?) + WHERE profile_id=? AND profile_generation=? AND state='releasing'`, + ).run(now, profileId, expectedGeneration); + this.#database.query( + `UPDATE provider_runtime_account_revocations + SET profile_generation=profile_generation+1,revision=revision+1, + updated_at=MAX(updated_at,?) + WHERE profile_id=? AND profile_generation=? AND state='releasing'`, + ).run(now, profileId, expectedGeneration); + const permit = this.#database.query( + `INSERT INTO session_adoption_profile_generation_permits( + profile_id,from_generation,to_generation + ) VALUES (?,?,?)`, + ).run(profileId, expectedGeneration, expectedGeneration + 1); + if (permit.changes !== 1) { + throw new Error("Profile generation rollover permit could not be acquired."); + } + } + const result = this.#database + .query("UPDATE profiles SET process_generation = ?, state=?, updated_at = ? WHERE id = ? AND process_generation = ? AND state != 'removed'") + .run(expectedGeneration + 1, state, now, profileId, expectedGeneration); + if (result.changes !== 1) throw new Error("Profile generation authority changed."); + if (options.allowPersonalBindingRollover === true) { + const released = this.#database.query( + `DELETE FROM session_adoption_profile_generation_permits + WHERE profile_id=? AND from_generation=? AND to_generation=?`, + ).run(profileId, expectedGeneration, expectedGeneration + 1); + if (released.changes !== 1) { + throw new Error("Profile generation rollover permit was not retired."); + } + } + return [...affectedWorkIds]; + }); + const affectedWorkIds = advance.immediate(); + return { profile: this.requireProfile(profileId), affectedWorkIds }; + } - if (version < 27) { - database.exec(schemaVersion27); - ensureUsagePollFailureAccountFingerprint(database); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(27, now()); - database.exec("PRAGMA user_version = 27"); - version = 27; - } + setProfileState(profileId: ProfileId, expectedGeneration: number, state: z.infer, identity?: { email?: string; plan?: string }): boolean { + return this.#setProfileState(profileId, expectedGeneration, state, identity).changed; + } - if (version < 28) { - database.exec(schemaVersion28); - const migratedAt = unixMillisecondsSchema.parse(now()); - database.query( - `INSERT INTO account_rate_limit_reset_policies( - profile_id,state,account_fingerprint,weekly_window_resets_at, - revision,created_at,updated_at - ) - SELECT id,'reconciliation_required',NULL,NULL,1,?,? - FROM profiles WHERE state!='removed' ORDER BY id - ON CONFLICT(profile_id) DO UPDATE SET - state='reconciliation_required', - account_fingerprint=NULL, - weekly_window_resets_at=NULL, - revision=account_rate_limit_reset_policies.revision+1, - updated_at=MAX(account_rate_limit_reset_policies.updated_at,excluded.updated_at)`, - ).run(migratedAt, migratedAt); - database.query( - `DELETE FROM account_rate_limit_reset_policies - WHERE NOT EXISTS ( - SELECT 1 FROM profiles p - WHERE p.id=account_rate_limit_reset_policies.profile_id - AND p.state!='removed' - )`, - ).run(); - assertAccountRateLimitResetPolicies(database); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(28, migratedAt); - database.exec("PRAGMA user_version = 28"); - version = 28; - } + setProfileStateWithWorkRetirement( + profileId: ProfileId, + expectedGeneration: number, + state: z.infer, + workStore: WorkStore, + identity?: { email?: string; plan?: string }, + ): ProfileStateChangeResult { + const result = this.#setProfileState( + profileId, + expectedGeneration, + state, + identity, + workStore, + ); + return { + ...result, + profile: this.requireProfileById(profileId, { includeRemoved: true }), + }; + } - if (version < 29) { - database.exec(SESSION_TASK_SCHEMA_SQL); - assertSessionTaskSchema(database); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(29, now()); - database.exec("PRAGMA user_version = 29"); - version = 29; - } + #closeRecoverableAccountRateLimitResetIdentityAttempts(input: { + profileId: ProfileId; + accountFingerprint: string; + selection: "matching" | "different"; + now: number; + }): void { + const fingerprintPredicate = input.selection === "matching" ? "=" : "!="; + this.#database.query( + `UPDATE account_rate_limit_reset_attempts + SET state='ambiguous',updated_at=MAX(updated_at,?) + WHERE profile_id=? AND account_fingerprint${fingerprintPredicate}? + AND state='effect_started'`, + ).run(input.now, input.profileId, input.accountFingerprint); + this.#database.query( + `UPDATE account_rate_limit_reset_attempts + SET state='closed',local_resolution='account_identity_changed', + updated_at=MAX(updated_at,?) + WHERE profile_id=? AND account_fingerprint${fingerprintPredicate}? + AND state IN ('prepared','ambiguous','retryable')`, + ).run(input.now, input.profileId, input.accountFingerprint); + } - if (version < 30) { - database.exec(schemaVersion30); - if (!hasTableColumn(database, "daemon_state", "default_approval_mode")) { - database.exec(schemaVersion30DefaultApprovalModeColumn); + #setProfileState( + profileId: ProfileId, + expectedGeneration: number, + state: z.infer, + identity?: { email?: string; plan?: string }, + workStore?: WorkStore, + ): Omit { + const now = this.#now(); + const update = this.#database.transaction(() => { + const current = this.#database.query( + "SELECT process_generation,state,provider_email FROM profiles WHERE id=? AND state!='removed'", + ).get(profileId) as { + process_generation: number; + provider_email: string | null; + state: z.infer; + } | null; + if ( + current === null + || current.process_generation !== expectedGeneration + || (current.state === "recovery_required" && state !== "recovery_required") + ) { + return { affectedWorkIds: [] as string[], changed: false }; } - if (!hasTableColumn(database, "provider_interactions", "resolved_by")) { - database.exec(schemaVersion30ResolvedByColumn); + const policy = this.requireAccountRateLimitResetPolicy(profileId); + const nextAccountFingerprint = state === "signed_in" && identity?.email !== undefined + ? canonicalAccountFingerprint(identity.email) + : null; + const nextProviderEmail = identity?.email ?? null; + const nextCodexAccountKey = nextProviderEmail === null + ? null + : codexProviderAccountAuthorityKeyForEmail(nextProviderEmail); + const previousProviderFingerprint = current.provider_email === null + ? null + : canonicalAccountFingerprint(current.provider_email); + const changedProviderIdentity = nextAccountFingerprint !== null + && previousProviderFingerprint !== null + && previousProviderFingerprint !== nextAccountFingerprint; + const changedPolicyIdentity = nextAccountFingerprint !== null + && policy.accountFingerprint !== null + && policy.accountFingerprint !== nextAccountFingerprint; + const affectedWorkIds = state === "signed_in" + ? [] + : [...(workStore?.prepareProfileAuthorityChange( + profileId, + expectedGeneration, + "codex", + ) ?? [])]; + const result = this.#database.query( + `UPDATE profiles + SET state=?,provider_email=?,codex_account_key=?,provider_plan=?,updated_at=? + WHERE id=? + AND process_generation=? + AND state!='removed' + AND (state!='recovery_required' OR ?='recovery_required')`, + ) + .run( + state, + nextProviderEmail, + nextCodexAccountKey, + identity?.plan ?? null, + now, + profileId, + expectedGeneration, + state, + ); + // Bun includes rows changed by AFTER triggers in this count. One exact + // profile row is the CAS authority; policy-disable trigger changes are + // expected auxiliary effects of that same successful update. + if (result.changes < 1) throw new Error("Profile state authority changed."); + if (changedProviderIdentity || changedPolicyIdentity) { + if (policy.accountFingerprint !== null) { + this.#closeRecoverableAccountRateLimitResetIdentityAttempts({ + profileId, + accountFingerprint: policy.accountFingerprint, + selection: "matching", + now, + }); + } + const policyChanged = this.#database.query( + `UPDATE account_rate_limit_reset_policies + SET state='reconciliation_required',account_fingerprint=NULL, + weekly_window_resets_at=NULL,revision=revision+1, + updated_at=MAX(updated_at,?) + WHERE profile_id=? AND revision=? + AND state=? + AND account_fingerprint IS ? + AND weekly_window_resets_at IS ?`, + ).run( + now, + profileId, + policy.revision, + policy.state, + policy.accountFingerprint, + policy.weeklyWindowResetsAt, + ); + if (policyChanged.changes !== 1) { + throw new Error("ACCOUNT_RATE_LIMIT_RESET_POLICY_CONFLICT"); + } } - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(30, now()); - database.exec("PRAGMA user_version = 30"); - version = 30; - } - - if (version < 31) { - if (!hasTableColumn(database, "autorespond_evidence", "path")) { - applySchemaVersion31(database); + if (state === "signed_in" || state === "signed_out") { + this.#database.query(`UPDATE provider_login_authorities + SET state='settled',settlement=?,updated_at=? + WHERE profile_id=? AND process_generation=? AND state='active'`).run( + state === "signed_in" ? "signed_in" : "provider_disconnected", + now, + profileId, + expectedGeneration, + ); } - if (!hasTableColumn(database, "autorespond_evidence", "path")) { - throw new Error("STATE_SCHEMA_V31_AUTORESPOND_EVIDENCE_INVALID"); + if (state === "signed_out") { + this.#database.query( + `UPDATE session_adoption_policies + SET profile_id=NULL,state='disabled',revision=revision+1, + updated_at=MAX(updated_at,?) + WHERE profile_id=? AND provider='codex' AND state='enabled'`, + ).run(now, profileId); } - database.exec(schemaVersion31Objects); - applySchemaVersion31Archive(database); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(31, now()); - database.exec("PRAGMA user_version = 31"); - version = 31; - } - - if (version < 32) { - applySchemaVersion32(database); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(32, now()); - database.exec("PRAGMA user_version = 32"); - version = 32; - } - - if (version < 33) { - applySchemaVersion33DeviceCommands(database); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(33, now()); - database.exec("PRAGMA user_version = 33"); - version = 33; - } - - if (version < 34) { - applySchemaVersion34Attachments(database); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(34, now()); - database.exec("PRAGMA user_version = 34"); - version = 34; - } - - if (version < 35) { - applySchemaVersion35ProviderSwitchProgress(database); - database.query("INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)").run(35, now()); - database.exec("PRAGMA user_version = 35"); - version = 35; - } - - // Main shipped provider-switch progress as v35. Apply and assert it before - // either notification migration so feature-v35/v36 databases converge to - // the same physical schema within this transaction. - applySchemaVersion35ProviderSwitchProgress(database); - assertSchemaVersion35Objects(database); + return { affectedWorkIds, changed: true }; + }); + return update.immediate(); + } - if (version < 36) { - const migratedAt = unixMillisecondsSchema.parse(now()); - applySchemaVersion36NotificationHours( - database, - migratedAt, - resolveMachineTimeZone, + reconcileProfileRecoveryFromAccountRead(input: { + profileId: ProfileId; + expectedGeneration: number; + provider: { signedIn: boolean; email?: string; plan?: string }; + }): ProfileRecord { + const profileId = profileIdSchema.parse(input.profileId); + const expectedGeneration = z.number().int().nonnegative() + .max(Number.MAX_SAFE_INTEGER).parse(input.expectedGeneration); + const provider = z.object({ + signedIn: z.boolean(), + email: z.string().email().optional(), + plan: z.string().max(128).optional(), + }).strict().parse(input.provider); + const reconcile = this.#database.transaction(() => { + const generic = z.object({ count: z.number().int().nonnegative() }).strict().parse( + this.#database.query( + `SELECT COUNT(*) AS count FROM mutation_attempts m + LEFT JOIN mutation_resolutions r ON r.attempt_id=m.id + WHERE m.authority_id=? AND m.authority_generation=? + AND m.state IN ('effect_started','ambiguous') + AND r.attempt_id IS NULL`, + ).get(profileId, expectedGeneration), ); - database.query( - "INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)", - ).run(36, migratedAt); - database.exec("PRAGMA user_version = 36"); - version = 36; - } - - if (version < 37) { - const migratedAt = unixMillisecondsSchema.parse(now()); - applySchemaVersion37AttentionEmailPolicy( - database, - migratedAt, - exactLegacyFeatureVersion36, + const resets = z.object({ count: z.number().int().nonnegative() }).strict().parse( + this.#database.query( + `SELECT COUNT(*) AS count FROM account_rate_limit_reset_attempts + WHERE profile_id=? AND current_process_generation=? + AND state IN ('prepared','effect_started','ambiguous','retryable')`, + ).get(profileId, expectedGeneration), ); - database.query( - "INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)", - ).run(37, migratedAt); - database.exec("PRAGMA user_version = 37"); - version = 37; - } - - if (version < 38) { - const migratedAt = unixMillisecondsSchema.parse(now()); - applySchemaVersion38PresetContracts(database); - database.query( - "INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)", - ).run(38, migratedAt); - database.exec("PRAGMA user_version = 38"); - version = 38; - } + if (generic.count !== 0 || resets.count !== 0) { + throw new Error("PROFILE_RECOVERY_AUTHORITY_UNSETTLED"); + } + const state = provider.signedIn ? "signed_in" : "signed_out"; + const now = unixMillisecondsSchema.parse(this.#now()); + const providerEmail = provider.signedIn ? provider.email ?? null : null; + const changed = this.#database.query( + `UPDATE profiles SET state=?,provider_email=?,codex_account_key=?,provider_plan=?,updated_at=? + WHERE id=? AND process_generation=? AND state='recovery_required'`, + ).run( + state, + providerEmail, + providerEmail === null ? null : codexProviderAccountAuthorityKeyForEmail(providerEmail), + provider.signedIn ? provider.plan ?? null : null, + now, + profileId, + expectedGeneration, + ); + if (changed.changes !== 1) { + throw new Error("PROFILE_RECOVERY_AUTHORITY_CHANGED"); + } + this.#database.query( + `UPDATE provider_login_authorities + SET state='settled',settlement=?,updated_at=? + WHERE profile_id=? AND process_generation=? AND state='active'`, + ).run( + state === "signed_in" ? "signed_in" : "provider_disconnected", + now, + profileId, + expectedGeneration, + ); + }); + reconcile.immediate(); + return this.requireProfileById(profileId); + } - if (version < 39) { - const migratedAt = unixMillisecondsSchema.parse(now()); - applySchemaVersion39ProviderAuthority(database); - database.query( - "INSERT OR IGNORE INTO migrations(version, applied_at) VALUES (?, ?)", - ).run(39, migratedAt); - database.exec("PRAGMA user_version = 39"); - version = 39; - } + removeProfile(profileId: ProfileId): void { + const id = profileIdSchema.parse(profileId); + const remove = this.#database.transaction(() => { + const activePersonalBinding = this.#database.query( + `SELECT 1 FROM session_personal_runtime_bindings b + JOIN sessions s ON s.id=b.session_id + WHERE s.profile_id=? AND b.state IN ('active','detaching') LIMIT 1`, + ).get(id); + if (activePersonalBinding !== null) { + throw new Error("SESSION_ADOPTION_PROFILE_ACTIVE_BINDINGS"); + } + const active = this.#database.query( + "SELECT COUNT(*) AS count FROM sessions WHERE profile_id=? AND state NOT IN ('terminal')", + ).get(id) as { count: number } | null; + if ((active?.count ?? 0) !== 0) { + throw new Error("Profile still owns active sessions."); + } + const now = unixMillisecondsSchema.parse(this.#now()); + this.#database.query( + `UPDATE session_adoption_policies + SET profile_id=NULL,state='disabled',revision=revision+1,updated_at=MAX(updated_at,?) + WHERE profile_id=? AND state='enabled'`, + ).run(now, id); + const result = this.#database.query( + `UPDATE profiles + SET state='removed',provider_email=NULL,codex_account_key=NULL, + provider_plan=NULL,updated_at=? + WHERE id=? AND state!='removed'`, + ).run(now, id); + if (result.changes !== 1) throw new SelectionError("NOT_FOUND"); + const policy = this.#database.query( + "DELETE FROM account_rate_limit_reset_policies WHERE profile_id=?", + ).run(id); + if (policy.changes !== 1) { + throw new Error("ACCOUNT_RATE_LIMIT_RESET_POLICY_MISSING"); + } + }); + remove.immediate(); + } - // Reapplying additive objects and idempotent authority backfills makes a - // restart after any pre-release partial fixture safe without changing rows. - applySchemaVersion32(database); - database.exec(schemaVersion9); - database.exec(schemaVersion10); - backfillSchemaVersion9(database, now()); - backfillSchemaVersion10(database); - redacted = redactLegacyMcpUrlInteractions(database, now()) || redacted; - redacted = redactLegacyPermissionValues(database) || redacted; - if (redacted) requireSecurityScrub(database, now()); - database.exec(schemaVersion11); - database.exec(schemaVersion12); - database.exec(schemaVersion13); - ensureStableQueueSequence(database); - database.exec(schemaVersion15); - database.exec(schemaVersion16); - database.exec(schemaVersion17); - backfillExactApprovalDecisions(database); - database.exec(schemaVersion19); - database.exec(schemaVersion20); - database.exec(schemaVersion21); - ensureQueueMessageScrubGeneration(database); - database.exec(schemaVersion22); - database.exec(schemaVersion23); - rebuildSchemaVersion24(database); - assertSchemaVersion24Objects(database); - ensureSessionEventProjectionVersion(database); - applySchemaVersion38PresetContracts(database); - applySchemaVersion39ProviderAuthority(database); - database.exec(WORK_SCHEMA_SQL); - assertWorkSchema(database); - assertSchemaVersion38PresetContracts(database); - assertSchemaVersion39ProviderAuthority(database); - database.exec(schemaVersion27); - ensureUsagePollFailureAccountFingerprint(database); - database.exec(schemaVersion28); - assertAccountRateLimitResetPolicies(database); - database.exec(SESSION_TASK_SCHEMA_SQL); - assertSessionTaskSchema(database); - database.exec(schemaVersion30); - database.exec(schemaVersion31Objects); - applySchemaVersion31Archive(database); - applySchemaVersion33DeviceCommands(database); - applySchemaVersion34Attachments(database); - applySchemaVersion35ProviderSwitchProgress(database); - assertSchemaVersion35Objects(database); - database.exec(schemaVersion36NotificationHours); - database.exec(schemaVersion37AttentionEmailPolicy); - assertCompositeNotificationPolicy(database); - if (hasSettledQueueMessagesToScrub(database)) { - requireQueueMessageScrub(database, now(), true); - } - pruneAllUsageHistory(database, now()); - return hasPendingSecurityScrub(database); - })(); - if (securityScrubPending) completePendingSecurityScrub(database, false, securityScrubCheckpoint); -}; + async createProject(label: string, requestedRoot: string, makeDefault = false): Promise { + const canonical = await resolveUsableCanonicalProjectDirectory(requestedRoot); + if (canonical === null) { + throw new UnusableProjectRootError(); + } + const id = createProjectId(); + const parsedLabel = labelSchema.parse(label); + const labelKey = canonicalLabelIdentity(parsedLabel, "PROJECT").key; + const now = this.#now(); + const insert = this.#database.transaction(() => { + if (makeDefault) this.#database.query("UPDATE projects SET is_default=0, updated_at=? WHERE is_default=1").run(now); + this.#database.query("INSERT INTO projects(id,label,label_key,root_path,is_default,created_at,updated_at) VALUES (?,?,?,?,?,?,?)").run(id, parsedLabel, labelKey, canonical, makeDefault ? 1 : 0, now, now); + }); + insert.immediate(); + return this.requireProject(id); + } -const mapProfile = (row: unknown): ProfileRecord => { - const parsed = profileRowSchema.parse(row); - if (parsed.label_key !== canonicalLabelIdentity(parsed.label, "ACCOUNT").key) { - throw new StateLabelInvariantError("ACCOUNT", "KEY_INVALID"); + listProjects(): readonly ProjectRecord[] { + return this.#database.query("SELECT * FROM projects ORDER BY is_default DESC, label_key, id").all().map(mapProject); } - return { - id: parsed.id, - label: parsed.label, - state: parsed.state, - processGeneration: parsed.process_generation, - ...(parsed.provider_email === null ? {} : { providerEmail: parsed.provider_email }), - ...(parsed.provider_plan === null ? {} : { providerPlan: parsed.provider_plan }), - createdAt: parsed.created_at, - updatedAt: parsed.updated_at, - }; -}; -const mapProject = (row: unknown): ProjectRecord => { - const parsed = projectRowSchema.parse(row); - if (parsed.label_key !== canonicalLabelIdentity(parsed.label, "PROJECT").key) { - throw new StateLabelInvariantError("PROJECT", "KEY_INVALID"); + requireProject(selector: string): ProjectRecord { + const selected = selectByIdOrLabel(this.listProjects(), selector); + if (selected.kind === "found") return selected.value; + throw new SelectionError(selected.kind === "missing" ? "NOT_FOUND" : "AMBIGUOUS", selected.kind === "ambiguous" ? selected.values : []); } - return { id: parsed.id, label: parsed.label, rootPath: parsed.root_path, default: parsed.is_default === 1, createdAt: parsed.created_at, updatedAt: parsed.updated_at }; -}; -// The pre-v39 column remains only so old SQLite CHECK authority is preserved -// without a table rewrite. New providers use the Codex-compatible shadow; -// every semantic read and comparison uses `provider_v39`. -const legacySessionProviderShadow = (provider: Provider): "codex" | "claude" => - provider === "claude" ? "claude" : "codex"; + setDefaultProject(projectId: ProjectId): ProjectRecord { + const now = this.#now(); + const transaction = this.#database.transaction(() => { + this.#database.query("UPDATE projects SET is_default=0,updated_at=? WHERE is_default=1").run(now); + const result = this.#database.query("UPDATE projects SET is_default=1,updated_at=? WHERE id=?").run(now, projectId); + if (result.changes !== 1) throw new SelectionError("NOT_FOUND"); + }); + transaction.immediate(); + return this.requireProject(projectId); + } -const mapSession = (row: unknown): SessionRecord => { - const parsed = sessionRowSchema.parse(row); - return { - id: parsed.id, - profileId: parsed.profile_id, - ...(parsed.project_id === null ? {} : { projectId: parsed.project_id }), - ...(parsed.provider_thread_id === null ? {} : { providerThreadId: parsed.provider_thread_id }), - title: parsed.title, - note: parsed.note, - provider: parsed.provider_v39, - preset: presetForProviderTier(parsed.provider_v39, parsed.preset), - fastEnabled: parsed.fast_enabled === 1, - state: parsed.state, - ...(parsed.active_turn_id === null ? {} : { activeTurnId: parsed.active_turn_id }), - ...(parsed.provider_updated_at === null ? {} : { providerUpdatedAt: parsed.provider_updated_at }), - ...(parsed.archived_at === null ? {} : { archivedAt: parsed.archived_at }), - revision: parsed.revision, - createdAt: parsed.created_at, - updatedAt: parsed.updated_at, - }; -}; + removeProject(projectId: ProjectId): void { + const result = this.#database.query("DELETE FROM projects WHERE id=? AND NOT EXISTS(SELECT 1 FROM sessions WHERE project_id=?)").run(projectId, projectId); + if (result.changes !== 1) throw new Error("Project is missing or still used by a session."); + } -const assertRuntimeProfileRequirement = ( - profile: ReviewedRuntimeProfile, - preset: Preset, - requirement: PresetRequirement, - code: string, -): void => { - if ( - profile.preset !== preset - || profile.model !== requirement.model - || profile.reasoningEffort !== requirement.effort - ) throw new Error(code); -}; + #insertSessionEventStream(sessionId: SessionId, now: number): void { + this.#database.query( + `INSERT OR IGNORE INTO session_event_streams( + session_id,stream_epoch,next_sequence,floor_sequence,observed_through_sequence, + retained_count,retained_bytes,retention_gap_reason,created_at,updated_at + ) VALUES (?,?,1,1,0,0,0,NULL,?,?)`, + ).run(sessionId, randomUUID(), now, now); + } -const presetContractForRuntimeProfile = ( - profile: ReviewedRuntimeProfile, - preset: Preset, -): z.infer => { - const current = presetRequirementForContract(preset, currentPresetContract); - if (profile.model === current.model && profile.reasoningEffort === current.effort) { - return currentPresetContract; + #ensureSessionEventStream(sessionId: SessionId): void { + if (this.#database.query("SELECT 1 FROM session_event_streams WHERE session_id=?").get(sessionId) !== null) return; + if (this.#readonly) throw new Error("SESSION_EVENT_STREAM_MISSING"); + const now = this.#now(); + this.#insertSessionEventStream(sessionId, now); } - const legacy = presetRequirementForContract(preset, legacyPresetContract); - if ( - legacy !== undefined - && profile.model === legacy.model - && profile.reasoningEffort === legacy.effort - ) { - return legacyPresetContract; + + createSession(input: { profileId: ProfileId; projectId?: ProjectId; title?: string; provider?: Provider; preset: Preset; fastEnabled: boolean }): SessionRecord { + const id = createSessionId(); + const now = this.#now(); + const title = input.title === undefined ? "Untitled session" : titleSchema.parse(input.title); + const provider = providerSchema.parse(input.provider ?? "codex"); + const preset = presetSchema.parse(input.preset); + assertPresetSupportedByProvider(provider, preset); + const create = this.#database.transaction(() => { + this.#database.query("INSERT INTO sessions(id,profile_id,project_id,title,provider,provider_v39,preset,preset_contract,fast_enabled,state,revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)").run(id, input.profileId, input.projectId ?? null, title, legacySessionProviderShadow(provider), provider, presetTiers[preset], currentPresetContract, input.fastEnabled ? 1 : 0, "starting", 1, now, now); + // This preparatory row has no provider-account observation yet. Clear the + // legacy profile-derived hint in the same transaction so a later plain + // bindSession cannot launder it into provider authority. Provider import + // and session-start paths add immutable provider proof before committing. + this.#database.query( + `UPDATE session_account_authorities + SET account_key=NULL + WHERE session_id=? AND account_key IS NOT NULL + AND NOT EXISTS( + SELECT 1 FROM session_provider_account_authorities a + WHERE a.session_id=session_account_authorities.session_id + )`, + ).run(id); + this.#insertSessionEventStream(id, now); + }); + create.immediate(); + return this.requireSession(id); } - throw new Error("SESSION_RUNTIME_PROFILE_PRESET_CONTRACT_UNADMITTED"); -}; -const sessionEventStreamRowSchema = z.object({ - stream_epoch: z.string().uuid(), - floor_sequence: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), - observed_through_sequence: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), - retained_count: z.number().int().nonnegative(), - retained_bytes: z.number().int().nonnegative(), - retention_gap_reason: sessionEventGapReasonSchema.nullable(), -}).strict(); + /** + * Archived sessions are excluded unless `includeArchived` is set: archive is + * a listing filter, never a deletion, so every other read path still sees + * the session and its history. + */ + listSessions(limit = 50, profileId?: ProfileId, includeArchived = false): readonly SessionRecord[] { + const bounded = Math.max(1, Math.min(100, Math.trunc(limit))); + const archiveClause = includeArchived ? "" : " AND archived_at IS NULL"; + const rows = profileId === undefined + ? this.#database.query( + `SELECT * FROM sessions WHERE 1=1${archiveClause} ORDER BY updated_at DESC,id LIMIT ?`, + ).all(bounded) + : this.#database.query( + `SELECT * FROM sessions WHERE profile_id=?${archiveClause} ORDER BY updated_at DESC,id LIMIT ?`, + ).all(profileId, bounded); + return rows.map(mapSession); + } -const mapSessionEventStreamPosition = (row: unknown): SessionEventStreamPosition => { - const parsed = sessionEventStreamRowSchema.parse(row); - return { - streamEpoch: parsed.stream_epoch, - floorSequence: parsed.floor_sequence, - observedThroughSequence: parsed.observed_through_sequence, - }; -}; + /** + * Archive or unarchive a session. The row's `revision` is left alone: this + * is local presentation state, not provider-observable session authority, + * so it must not invalidate an in-flight optimistic session update. + */ + setSessionArchived(sessionId: SessionId, archived: boolean): SessionRecord { + const parsedSessionId = sessionIdSchema.parse(sessionId); + const now = this.#now(); + const write = this.#database.transaction(() => { + const result = this.#database.query( + "UPDATE sessions SET archived_at=? WHERE id=?", + ).run(archived ? now : null, parsedSessionId); + if (result.changes !== 1) throw new SelectionError("NOT_FOUND"); + }); + write.immediate(); + return this.requireSession(parsedSessionId); + } -const interactionRowSchema = z.object({ - public_id: z.string().uuid(), - session_id: sessionIdSchema.nullable(), - profile_id: profileIdSchema, - process_generation: z.number().int().nonnegative(), - connection_id: z.string().uuid(), - request_id_type: z.enum(["number", "string"]), - request_id_number: z.number().int().safe().nullable(), - request_id_text: z.string().min(1).max(512).nullable(), - method: z.string().min(1).max(512), - request_digest: sha256Schema, - thread_id: z.string().min(1).max(512).nullable(), - turn_id: z.string().min(1).max(512).nullable(), - item_id: z.string().min(1).max(512).nullable(), - approval_id: z.string().min(1).max(512).nullable(), - kind: interactionKindSchema, - state: interactionStateSchema, - revision: z.number().int().positive(), - blocking: z.union([z.literal(0), z.literal(1)]), - display_json: z.string().min(2).max(65_536), - response_digest: sha256Schema.nullable(), - response_expected_revision: z.number().int().positive().nullable(), - intended_terminal_state: interactionIntendedTerminalStateSchema.nullable(), - resolved_by: z.enum(["autorespond"]).nullable(), - requested_at: unixMillisecondsSchema, - deadline_at: unixMillisecondsSchema, - updated_at: unixMillisecondsSchema, - terminal_at: unixMillisecondsSchema.nullable(), -}).strict(); + readNotificationHours(): NotificationHoursPolicy { + const row = this.#database.query( + `SELECT version,revision,start_minute,end_minute,time_zone,created_at,updated_at + FROM notification_hours WHERE singleton=1`, + ).get(); + if (row === null) throw new Error("NOTIFICATION_HOURS_POLICY_MISSING"); + try { + const policy = mapNotificationHoursPolicy(row); + const emailRow = this.#database.query( + `SELECT version,enabled,revision,created_at,updated_at + FROM attention_email_policy WHERE singleton=1`, + ).get(); + if (emailRow === null) throw new Error("ATTENTION_EMAIL_POLICY_MISSING"); + const emailPolicy = mapNotificationEmailPolicy(emailRow); + if (emailPolicy.revision !== policy.revision) { + throw new Error("NOTIFICATION_POLICY_REVISION_DIVERGED"); + } + return policy; + } catch (error: unknown) { + throw new Error("NOTIFICATION_HOURS_POLICY_INVALID", { cause: error }); + } + } + + readNotificationEmailPolicy(): NotificationEmailPolicy { + const row = this.#database.query( + `SELECT version,enabled,revision,created_at,updated_at + FROM attention_email_policy WHERE singleton=1`, + ).get(); + if (row === null) throw new Error("ATTENTION_EMAIL_POLICY_MISSING"); + try { + const policy = mapNotificationEmailPolicy(row); + const hoursRow = this.#database.query( + "SELECT revision FROM notification_hours WHERE singleton=1", + ).get(); + const hoursRevision = z.object({ + revision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + }).strict().parse(hoursRow).revision; + if (hoursRevision !== policy.revision) { + throw new Error("NOTIFICATION_POLICY_REVISION_DIVERGED"); + } + return policy; + } catch (error: unknown) { + throw new Error("ATTENTION_EMAIL_POLICY_INVALID", { cause: error }); + } + } -const mapInteraction = (row: unknown): InteractionRecord => { - const parsed = interactionRowSchema.parse(row); - const requestId = parsed.request_id_type === "number" - ? { type: "number" as const, value: z.number().int().safe().parse(parsed.request_id_number) } - : { type: "string" as const, value: z.string().min(1).max(512).parse(parsed.request_id_text) }; - const storedDisplay = interactionDisplaySchema.parse(JSON.parse(parsed.display_json) as unknown); - const display: InteractionDisplay = storedDisplay.kind === "mcp_elicitation" - ? { ...storedDisplay, summary: PUBLIC_MCP_FORM_SUMMARY } - : storedDisplay; - return interactionRecordSchema.parse({ - version: 1, - publicId: parsed.public_id, - sessionId: parsed.session_id, - authority: { - profileId: parsed.profile_id, - processGeneration: parsed.process_generation, - connectionId: parsed.connection_id, - requestId, - method: parsed.method, - requestDigest: parsed.request_digest, - threadId: parsed.thread_id, - turnId: parsed.turn_id, - itemId: parsed.item_id, - approvalId: parsed.approval_id, - }, - kind: parsed.kind, - state: parsed.state, - revision: parsed.revision, - blocking: parsed.blocking === 1, - display, - responseDigest: parsed.response_digest, - intendedTerminalState: parsed.intended_terminal_state, - resolvedBy: parsed.resolved_by, - requestedAt: parsed.requested_at, - deadlineAt: parsed.deadline_at, - updatedAt: parsed.updated_at, - terminalAt: parsed.terminal_at, - }); -}; + updateNotificationHours( + input: Readonly, + ): NotificationHoursPolicy { + const parsedInput = z.object({ + expectedRevision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + version: z.literal(1), + startMinute: z.number(), + endMinute: z.number(), + timeZone: z.string(), + }).strict().parse(input); + const desired = notificationHoursUpdateSchema.parse({ + version: parsedInput.version, + startMinute: parsedInput.startMinute, + endMinute: parsedInput.endMinute, + timeZone: parsedInput.timeZone, + }); + const updatedAt = unixMillisecondsSchema.parse(this.#now()); + const write = this.#database.transaction((): NotificationHoursPolicy => { + const current = this.readNotificationHours(); + if (current.revision !== parsedInput.expectedRevision) { + throw new Error("NOTIFICATION_HOURS_REVISION_CONFLICT"); + } + if (current.revision >= Number.MAX_SAFE_INTEGER) { + throw new Error("NOTIFICATION_HOURS_REVISION_EXHAUSTED"); + } + const result = this.#database.query( + `UPDATE notification_hours + SET version=?,revision=revision+1,start_minute=?,end_minute=?,time_zone=?, + updated_at=MAX(updated_at,?) + WHERE singleton=1 AND revision=?`, + ).run( + desired.version, + desired.startMinute, + desired.endMinute, + desired.timeZone, + updatedAt, + parsedInput.expectedRevision, + ); + if (result.changes !== 1) { + throw new Error("NOTIFICATION_HOURS_REVISION_CONFLICT"); + } + const emailResult = this.#database.query( + `UPDATE attention_email_policy + SET revision=revision+1,updated_at=MAX(updated_at,?) + WHERE singleton=1 AND revision=?`, + ).run(updatedAt, parsedInput.expectedRevision); + if (emailResult.changes !== 1) { + throw new Error("NOTIFICATION_HOURS_REVISION_CONFLICT"); + } + return this.readNotificationHours(); + }); + return write.immediate(); + } -const storedSessionEventEnvelopeSchema = z.object({ - body: z.unknown(), -}).passthrough(); + updateNotificationEmailPolicy( + input: Readonly<{ enabled: boolean; expectedRevision: number }>, + ): NotificationEmailPolicy { + const parsedInput = z.object({ + enabled: z.boolean(), + expectedRevision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + }).strict().parse(input); + const updatedAt = unixMillisecondsSchema.parse(this.#now()); + const write = this.#database.transaction((): NotificationEmailPolicy => { + const current = this.readNotificationEmailPolicy(); + if (current.revision !== parsedInput.expectedRevision) { + throw new Error("ATTENTION_EMAIL_POLICY_REVISION_CONFLICT"); + } + if (current.revision >= Number.MAX_SAFE_INTEGER) { + throw new Error("ATTENTION_EMAIL_POLICY_REVISION_EXHAUSTED"); + } + const emailResult = this.#database.query( + `UPDATE attention_email_policy + SET enabled=?,revision=revision+1,updated_at=MAX(updated_at,?) + WHERE singleton=1 AND revision=?`, + ).run( + parsedInput.enabled ? 1 : 0, + updatedAt, + parsedInput.expectedRevision, + ); + if (emailResult.changes !== 1) { + throw new Error("ATTENTION_EMAIL_POLICY_REVISION_CONFLICT"); + } + const hoursResult = this.#database.query( + `UPDATE notification_hours + SET revision=revision+1,updated_at=MAX(updated_at,?) + WHERE singleton=1 AND revision=?`, + ).run(updatedAt, parsedInput.expectedRevision); + if (hoursResult.changes !== 1) { + throw new Error("ATTENTION_EMAIL_POLICY_REVISION_CONFLICT"); + } + return this.readNotificationEmailPolicy(); + }); + return write.immediate(); + } -const parseStoredSessionEvent = ( - value: string, - projectionVersion: 1 | 2, - projector: PublicProviderIdentifierProjector, -): SessionEvent => { - const stored = storedSessionEventEnvelopeSchema.parse(JSON.parse(value) as unknown); - return sessionEventSchema.parse({ - ...stored, - body: projectionVersion === 2 - ? stored.body - : projectPublicSessionEventBody(stored.body, projector), - }); -}; + // --- Settings projection: show thinking and the daemon default preset ---- -const mapSessionRuntimeProfile = (row: unknown): SessionRuntimeProfileRecord => { - const parsed = sessionRuntimeProfileRowSchema.parse(row); - const profile = reviewedRuntimeProfileSchema.parse(JSON.parse(parsed.profile_json) as unknown); - if ( - profile.profileId !== parsed.profile_id - || profile.processGeneration !== parsed.process_generation - || profile.observedAt !== parsed.observed_at - ) throw new Error("Stored runtime profile authority is incoherent."); - return { - sessionId: parsed.session_id, - revision: parsed.revision, - sourceKind: parsed.source_kind, - sourceId: parsed.source_id, - profile, - recordedAt: parsed.recorded_at, - }; -}; + readDefaultShowThinking(): boolean { + const row = this.#database.query("SELECT default_show_thinking FROM daemon_state WHERE singleton=1").get(); + return z.object({ default_show_thinking: z.union([z.literal(0), z.literal(1)]) }) + .strict().parse(row).default_show_thinking === 1; + } -const mapSessionTurnRuntimeProfile = (row: unknown): Readonly<{ - profile: ReviewedRuntimeProfile; - sessionId: SessionId; - sourceId: string; - sourceKind: "turn_start" | "queue_start"; - turnId: string; -}> => { - const parsed = sessionTurnRuntimeProfileRowSchema.parse(row); - const profile = reviewedRuntimeProfileSchema.parse(JSON.parse(parsed.profile_json) as unknown); - if ( - profile.profileId !== parsed.profile_id - || profile.processGeneration !== parsed.process_generation - || profile.observedAt !== parsed.observed_at - || digestJson(profile) !== parsed.profile_digest - ) throw new Error("Stored turn runtime profile authority is incoherent."); - return { - profile, - sessionId: parsed.session_id, - sourceId: parsed.source_id, - sourceKind: parsed.source_kind, - turnId: parsed.turn_id, - }; -}; + setDefaultShowThinking(enabled: boolean): void { + const result = this.#database.query("UPDATE daemon_state SET default_show_thinking=? WHERE singleton=1") + .run(enabled ? 1 : 0); + if (result.changes !== 1) throw new Error("DAEMON_STATE_MISSING"); + } -const desktopSwitchRowSchema = z - .object({ - attempt_id: attemptIdSchema, - idempotency_key: z.string().uuid(), - mutation_state: mutationStateSchema, - result_json: z.string().nullable(), - source_profile_id: profileIdSchema.nullable(), - target_profile_id: profileIdSchema, - source_generation: positiveGenerationSchema.nullable(), - target_generation: positiveGenerationSchema, - phase: desktopSwitchPhaseSchema, - diagnostic_code: desktopDiagnosticSchema.nullable(), - switch_generation: positiveGenerationSchema, - journal_prepared: z.union([z.literal(0), z.literal(1)]), - journal_digest: z.string().regex(/^[a-f0-9]{64}$/u).nullable(), - bundle_cd_hash: z.string().regex(/^[a-f0-9]{40,128}$/u).nullable(), - source_pid: z.number().int().positive().nullable(), - expected_account_key: desktopAccountKeySchema, - launched_pid: z.number().int().positive().nullable(), - ambiguous_from_phase: z.enum(["prepared", "quit_started", "quit_confirmed", "launch_started", "verify_started"]).nullable(), - recovery_deadline_at: unixMillisecondsSchema.nullable(), - resolution_kind: desktopRecoveryResolutionSchema.nullable(), - resolution_diagnostic_code: desktopDiagnosticSchema.nullable(), - resolution_observation_digest: sha256Schema.nullable(), - resolution_receipt_json: z.string().nullable(), - resolution_resolved_at: unixMillisecondsSchema.nullable(), - }) - .strict(); + readSessionShowThinking(sessionId: SessionId): Readonly<{ enabled: boolean; source: "session" | "default" }> { + const parsedSessionId = sessionIdSchema.parse(sessionId); + const row = this.#database.query("SELECT enabled FROM session_show_thinking WHERE session_id=?").get(parsedSessionId); + if (row !== null) { + const parsed = z.object({ enabled: z.union([z.literal(0), z.literal(1)]) }).strict().parse(row); + return { enabled: parsed.enabled === 1, source: "session" }; + } + return { enabled: this.readDefaultShowThinking(), source: "default" }; + } -type DesktopSwitchRow = z.infer; + setSessionShowThinking(sessionId: SessionId, enabled: boolean | null): void { + const parsedSessionId = sessionIdSchema.parse(sessionId); + if (enabled === null) { + this.#database.query("DELETE FROM session_show_thinking WHERE session_id=?").run(parsedSessionId); + return; + } + const write = this.#database.transaction(() => { + if (this.#database.query("SELECT 1 FROM sessions WHERE id=?").get(parsedSessionId) === null) { + throw new SelectionError("NOT_FOUND"); + } + this.#database.query( + `INSERT INTO session_show_thinking(session_id,enabled,updated_at) VALUES (?,?,?) + ON CONFLICT(session_id) DO UPDATE SET enabled=excluded.enabled,updated_at=excluded.updated_at`, + ).run(parsedSessionId, enabled ? 1 : 0, this.#now()); + }); + write.immediate(); + } -const desktopRecoveryReceiptSchema = z - .object({ - status: desktopRecoveryResolutionSchema, - attemptId: attemptIdSchema, - idempotencyKey: z.string().uuid(), - switchGeneration: positiveGenerationSchema, - sourceProfileId: profileIdSchema.nullable(), - sourceProcessGeneration: positiveGenerationSchema.nullable(), - targetProfileId: profileIdSchema, - targetProcessGeneration: positiveGenerationSchema, - diagnostic: desktopDiagnosticSchema, - observationDigest: sha256Schema, - resolvedAt: unixMillisecondsSchema, - activeAccount: z - .object({ - signedIn: z.boolean(), - email: z.string().trim().email().max(320).optional(), - plan: z.string().trim().min(1).max(160).optional(), - }) - .strict() - .optional(), - }) - .strict(); + /** + * The daemon default is stored as a provider-neutral tier and read back + * against the daemon's default provider, so a Claude default and a Codex + * default share one column. + */ + readDefaultPreset(provider: Provider = "codex"): Preset { + const row = this.#database.query("SELECT default_preset FROM daemon_state WHERE singleton=1").get(); + const tier = z.object({ default_preset: presetTierSchema }).strict().parse(row).default_preset; + return presetForProviderTier(provider, tier); + } -const desktopSwitchSelect = `SELECT d.attempt_id,m.idempotency_key,m.state AS mutation_state,m.result_json, - d.source_profile_id,d.target_profile_id,d.source_generation,d.target_generation,d.phase,d.diagnostic_code, - d.switch_generation,d.journal_prepared,d.journal_digest,d.bundle_cd_hash,d.source_pid,d.expected_account_key, - d.launched_pid,d.ambiguous_from_phase,d.recovery_deadline_at, - r.resolution_kind,r.diagnostic_code AS resolution_diagnostic_code, - r.observation_digest AS resolution_observation_digest,r.receipt_json AS resolution_receipt_json, - r.resolved_at AS resolution_resolved_at - FROM desktop_switches d - JOIN mutation_attempts m ON m.id=d.attempt_id - LEFT JOIN desktop_switch_resolutions r ON r.attempt_id=d.attempt_id`; + setDefaultPreset(preset: Preset): void { + const parsed = presetSchema.parse(preset); + const result = this.#database.query("UPDATE daemon_state SET default_preset=? WHERE singleton=1").run(presetTiers[parsed]); + if (result.changes !== 1) throw new Error("DAEMON_STATE_MISSING"); + } -const switchPhaseByStage: Readonly>> = { - prepared: "prepared", - "quit-requested": "quit_started", - "source-quiesced": "quit_confirmed", - "launch-requested": "launch_started", - "target-observed": "verify_started", - verified: "applied", - "recovery-required": "ambiguous", -}; + listLocalSessionPage(input: Readonly<{ + profileId: ProfileId; + after: Readonly<{ createdAt: number; sessionId: SessionId }> | null; + excludedProvider?: Provider; + includeArchived?: boolean; + limit: number; + requireCurrentAccountAuthority?: boolean; + }>): Readonly<{ + sessions: readonly SessionRecord[]; + nextPosition: Readonly<{ createdAt: number; sessionId: SessionId }> | null; + }> { + const profileId = profileIdSchema.parse(input.profileId); + const limit = z.number().int().min(1).max(100).parse(input.limit); + const after = input.after === null + ? null + : { + createdAt: unixMillisecondsSchema.max(Number.MAX_SAFE_INTEGER).parse(input.after.createdAt), + sessionId: sessionIdSchema.parse(input.after.sessionId), + }; + const excludedProvider = input.excludedProvider === undefined + ? null + : providerSchema.parse(input.excludedProvider); + const archiveClause = input.includeArchived === true ? "" : " AND s.archived_at IS NULL"; + const accountAuthorityClause = input.requireCurrentAccountAuthority === true + ? ` AND EXISTS ( + SELECT 1 + FROM profiles p + LEFT JOIN session_provider_account_authorities pa + ON pa.session_id=s.id AND pa.provider=s.provider_v39 + WHERE p.id=s.profile_id + AND NOT EXISTS( + SELECT 1 FROM provider_runtime_account_revocations r + WHERE r.profile_id=s.profile_id + AND r.profile_generation=p.process_generation + AND r.provider=s.provider_v39 + AND r.runtime_scope=pa.runtime_scope + AND (r.state='releasing' OR r.current_account_key IS NULL + OR r.current_account_key!=pa.account_key) + ) + AND ( + (pa.runtime_scope='personal' AND EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.provider=s.provider_v39 + AND b.provider_thread_id=s.provider_thread_id + AND b.state='active' + )) + OR (pa.runtime_scope='managed' AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.state IN ('active','detaching') + )) + OR (s.provider_v39='devin' AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.state IN ('active','detaching') + )) + ) + AND ( + (s.provider_v39='claude' AND p.state IN ('signed_in','signed_out')) + OR (s.provider_v39='codex' AND p.state='signed_in' + AND p.provider_email IS NOT NULL + AND p.codex_account_key=pa.account_key AND EXISTS( + SELECT 1 FROM session_account_authorities a + WHERE a.session_id=s.id AND a.profile_id=s.profile_id + AND a.account_key IS NOT NULL + AND a.account_key=lower(trim(p.provider_email)) + )) + OR (s.provider_v39='devin' AND p.state IN ('signed_in','signed_out')) + ) + )` + : ""; + const rows = (after === null + ? this.#database.query( + `SELECT s.* FROM sessions s + WHERE s.profile_id=? AND (? IS NULL OR s.provider_v39!=?)${archiveClause}${accountAuthorityClause} + ORDER BY s.created_at DESC,s.id ASC + LIMIT ?`, + ).all(profileId, excludedProvider, excludedProvider, limit + 1) + : this.#database.query( + `SELECT s.* FROM sessions s + WHERE s.profile_id=? AND (? IS NULL OR s.provider_v39!=?)${archiveClause}${accountAuthorityClause} + AND (s.created_at < ? OR (s.created_at = ? AND s.id > ?)) + ORDER BY s.created_at DESC,s.id ASC + LIMIT ?`, + ).all( + profileId, + excludedProvider, + excludedProvider, + after.createdAt, + after.createdAt, + after.sessionId, + limit + 1, + )) + .map(mapSession); + const sessions = rows.slice(0, limit); + const last = sessions.at(-1); + return { + sessions, + nextPosition: rows.length > limit && last !== undefined + ? { createdAt: last.createdAt, sessionId: last.id } + : null, + }; + } -const effectAdjacentPhases = new Set>([ - "quit_started", - "quit_confirmed", - "launch_started", - "verify_started", -]); -const desktopRecoverySettlementMs = 30_000; + /* + * True while any non-terminal session is mid-turn. This is a cadence hint + * for the cloud sync loop, so it reads one indexless existence row and + * never projects session content. + */ + hasSessionWithActiveTurn(): boolean { + return this.#database.query( + "SELECT 1 AS present FROM sessions WHERE active_turn_id IS NOT NULL AND state='active' LIMIT 1", + ).get() !== null; + } -const digestJson = (value: unknown): string => - createHash("sha256").update(JSON.stringify(value)).digest("hex"); + listCloudSessionPage(input: Readonly<{ + afterId: string | null; + limit: number; + }>): Readonly<{ + continueAfterId: string | null; + isDone: boolean; + sessions: readonly SessionRecord[]; + }> { + const limit = z.number().int().min(1).max(100).parse(input.limit); + const afterId = input.afterId === null ? null : sessionIdSchema.parse(input.afterId); + const rows = (afterId === null + ? this.#database.query( + "SELECT * FROM sessions ORDER BY id ASC LIMIT ?", + ).all(limit + 1) + : this.#database.query( + "SELECT * FROM sessions WHERE id > ? ORDER BY id ASC LIMIT ?", + ).all(afterId, limit + 1)).map(mapSession); + const isDone = rows.length <= limit; + const sessions = rows.slice(0, limit); + const last = sessions.at(-1); + return { + continueAfterId: isDone ? null : last?.id ?? null, + isDone, + sessions, + }; + } -const parseSessionProviderSwitchReceipt = ( - value: unknown, - evidence: Extract, - sessionId: SessionId, - targetProviderThreadId: string, - seedTurnId: string, - seedTurnStatus: "completed" | "interrupted" | "failed" | "inProgress", -) => { - const receipt = sessionProviderSwitchReceiptSchema.parse(value); - if ( - receipt.sessionId !== sessionId - || receipt.providerThreadId !== targetProviderThreadId - || receipt.request.accountId !== evidence.requestedAccountId - || receipt.request.preset !== evidence.requestedPreset - || receipt.request.provider !== evidence.targetProvider - || receipt.from.account !== evidence.sourceProfileId - || receipt.from.preset !== evidence.sourcePreset - || receipt.from.provider !== evidence.sourceProvider - || receipt.to.account !== evidence.targetProfileId - || receipt.to.preset !== evidence.targetPreset - || receipt.to.provider !== evidence.targetProvider - || receipt.transcriptDigest !== evidence.transcriptDigest - || receipt.seed.digest !== evidence.seedDigest - || receipt.seed.includedRecords !== evidence.seedIncludedRecords - || receipt.seed.omittedRecords !== evidence.seedOmittedRecords - || receipt.seed.status !== seedTurnStatus - || receipt.turnId !== seedTurnId - ) throw new Error("SESSION_PROVIDER_SWITCH_RECEIPT_MISMATCH"); - return receipt; -}; + requireSession(selector: string): SessionRecord { + if (sessionIdSchema.safeParse(selector).success) { + const exact = this.#database.query("SELECT * FROM sessions WHERE id=?").get(selector); + if (exact !== null) return mapSession(exact); + } + const rows = this.#database.query("SELECT * FROM sessions WHERE title = ? COLLATE NOCASE ORDER BY updated_at DESC,id LIMIT 101").all(selector).map(mapSession); + if (rows.length === 1) { + const only = rows[0]; + if (only === undefined) throw new Error("Session selection cardinality changed unexpectedly."); + return only; + } + if (rows.length === 0) throw new SelectionError("NOT_FOUND"); + throw new SelectionError("AMBIGUOUS", rows.map((session) => ({ id: session.id, label: session.title }))); + } -const parseOptionalPlan = (value: string | undefined): string | undefined => { - if (value === undefined) return undefined; - const parsed = z.string().trim().min(1).max(160).safeParse(value); - return parsed.success ? parsed.data : undefined; -}; + #requireSessionPresetBinding(sessionId: SessionId): Readonly<{ + contract: z.infer; + preset: Preset; + requirement: PresetRequirement; + }> { + const parsed = sessionRowSchema.parse( + this.#database.query("SELECT * FROM sessions WHERE id=?").get(sessionIdSchema.parse(sessionId)), + ); + const preset = presetForProviderTier(parsed.provider_v39, parsed.preset); + const requirement = presetRequirementForContract(preset, parsed.preset_contract); + if (requirement === undefined) { + throw new Error("SESSION_PRESET_CONTRACT_UNADMITTED"); + } + return { + contract: parsed.preset_contract, + preset, + requirement, + }; + } -export class SelectionError extends Error { - constructor(readonly code: "NOT_FOUND" | "AMBIGUOUS", readonly candidates: readonly { id: string; label: string }[] = []) { - super(code === "NOT_FOUND" ? "No matching object was found." : "The selector matches more than one object."); - this.name = "SelectionError"; + /** Exact route for execution; the durable contract itself remains internal. */ + requireSessionPresetRequirement(sessionId: SessionId): Readonly<{ + preset: Preset; + requirement: PresetRequirement; + }> { + const binding = this.#requireSessionPresetBinding(sessionId); + return { preset: binding.preset, requirement: binding.requirement }; } -} -export class StateSecurityScrubRequiredError extends Error { - constructor( - readonly operationCommitted: boolean, - cause?: unknown, - ) { - super("STATE_SECURITY_SCRUB_REQUIRED", { cause }); - this.name = "StateSecurityScrubRequiredError"; + #assertSessionRuntimeProfileContract( + sessionId: SessionId, + profile: ReviewedRuntimeProfile, + ): void { + const binding = this.#requireSessionPresetBinding(sessionId); + assertRuntimeProfileRequirement( + profile, + binding.preset, + binding.requirement, + "SESSION_RUNTIME_PROFILE_PRESET_CONTRACT_MISMATCH", + ); } -} -export class UnusableProjectRootError extends Error { - constructor() { - super("Project root must be an existing readable, writable, traversable canonical directory without symbolic links."); - this.name = "UnusableProjectRootError"; + findSessionByProviderThread(profileId: ProfileId, providerThreadId: string): SessionRecord | null { + const row = this.#database.query("SELECT * FROM sessions WHERE profile_id=? AND provider_thread_id=?").get(profileId, providerThreadId); + return row === null ? null : mapSession(row); } -} -export class StateStore { - readonly #database: Database; - readonly #now: () => number; - readonly #readonly: boolean; - readonly #securityScrubCheckpoint: SecurityScrubCheckpointPolicy; - #publicProviderIdentifierProjector: PublicProviderIdentifierProjector; - readonly paths: StatePaths; + /** + * Resolve a provider-global thread association only when exactly one local + * session owns it. Provider thread ids are scoped by provider home, so two + * profiles may legitimately contain the same opaque id; callers without a + * profile/home authority must fail closed instead of choosing either row. + */ + findUniqueSessionByProviderThread( + provider: Provider, + providerThreadId: string, + ): SessionRecord | null { + const parsedProvider = providerSchema.parse(provider); + const parsedProviderThreadId = providerThreadIdSchema.parse(providerThreadId); + const rows = this.#database.query( + `SELECT * FROM sessions + WHERE provider_v39=? AND provider_thread_id=? + ORDER BY id LIMIT 2`, + ).all(parsedProvider, parsedProviderThreadId); + return rows.length === 1 ? mapSession(rows[0]) : null; + } - constructor(paths: StatePaths, options: { - readonly?: boolean; - now?: () => number; - beforeDatabaseOpen?: (input: Readonly<{ flags: number; path: string }>) => void; - publicProviderIdentifierProjector?: PublicProviderIdentifierProjector; - resolveMachineTimeZone?: MachineTimeZoneResolver; - // Test-only. Shortens the scrub checkpoint wait so a pinned-reader test - // does not spend the production 5 s budget. Never passed by the CLI or daemon. - securityScrubCheckpoint?: SecurityScrubCheckpointPolicy; - } = {}) { - this.paths = paths; - this.#now = options.now ?? Date.now; - this.#readonly = options.readonly === true; - this.#securityScrubCheckpoint = options.securityScrubCheckpoint === undefined - ? defaultSecurityScrubCheckpointPolicy - : securityScrubCheckpointPolicySchema.parse(options.securityScrubCheckpoint); - this.#publicProviderIdentifierProjector = options.publicProviderIdentifierProjector - ?? processLocalPublicProviderIdentifierProjector; - const databaseFile = prepareStateDatabaseFile(paths.database, this.#readonly); - const databaseOpenFlags = stateDatabaseOpenFlags(this.#readonly); - options.beforeDatabaseOpen?.({ flags: databaseOpenFlags, path: paths.database }); - // Bun's object options do not expose SQLITE_OPEN_NOFOLLOW. Numeric flags are - // therefore the actual SQLite open boundary. This store binds positionally, - // so dropping Bun's JavaScript-only `strict` binding option does not change - // its statement contract; SQLite STRICT tables remain schema-enforced. - this.#database = new Database(paths.database, databaseOpenFlags); - try { - assertStateDatabaseFile(paths.database, databaseFile); - this.#database.exec(`PRAGMA foreign_keys = ON; PRAGMA busy_timeout = ${stateBusyTimeoutMs};`); - if (!options.readonly) { - requireWalMode(this.#database, true); - this.#database.exec("PRAGMA synchronous = FULL;"); - migrateWritableDatabase( - this.#database, - this.#now, - this.#securityScrubCheckpoint, - options.resolveMachineTimeZone ?? defaultMachineTimeZoneResolver, - ); - } else { - const version = readUserVersion(this.#database); - if (version > currentSchemaVersion) throw new Error(`STATE_SCHEMA_NEWER:${version}:${currentSchemaVersion}`); - if (version < currentSchemaVersion) throw new Error(`STATE_SCHEMA_MIGRATION_REQUIRED:${version}:${currentSchemaVersion}`); - if (hasPendingSecurityScrub(this.#database)) throw new Error("STATE_SECURITY_SCRUB_REQUIRED"); - } - assertSchemaVersion24Objects(this.#database); - assertSchemaVersion35Objects(this.#database); - assertSchemaVersion38PresetContracts(this.#database); - assertSchemaVersion39ProviderAuthority(this.#database); - // A readonly open skips the O(rows) foreign_key_check so `hra status` - // never pins a WAL snapshot long enough to block the writer's scrub. - if (this.#readonly) assertReadonlyWorkSchema(this.#database); - else assertWorkSchema(this.#database); - assertCanonicalLabelKeys(this.#database); - assertAccountRateLimitResetPolicies(this.#database); - assertSessionTaskSchema(this.#database); - assertCompositeNotificationPolicy(this.#database); - assertStateDatabaseFile(paths.database, databaseFile); - } catch (error) { - this.#database.close(false); - throw error; - } + sessionAccountAuthorityMatches( + sessionId: SessionId, + profileId: ProfileId, + ): boolean { + const parsedSessionId = sessionIdSchema.parse(sessionId); + const parsedProfileId = profileIdSchema.parse(profileId); + return this.#database.query( + `SELECT 1 AS current + FROM sessions s + JOIN profiles p ON p.id=s.profile_id + LEFT JOIN session_provider_account_authorities pa + ON pa.session_id=s.id AND pa.provider=s.provider_v39 + WHERE s.id=? AND s.profile_id=? + AND NOT EXISTS( + SELECT 1 FROM provider_runtime_account_revocations r + WHERE r.profile_id=s.profile_id + AND r.profile_generation=p.process_generation + AND r.provider=s.provider_v39 + AND r.runtime_scope=pa.runtime_scope + AND (r.state='releasing' OR r.current_account_key IS NULL + OR r.current_account_key!=pa.account_key) + ) + AND ( + (pa.runtime_scope='personal' AND EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.provider=s.provider_v39 + AND b.provider_thread_id=s.provider_thread_id + AND b.state='active' + )) + OR (pa.runtime_scope='managed' AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.state IN ('active','detaching') + )) + OR (s.provider_v39='devin' AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.session_id=s.id AND b.state IN ('active','detaching') + )) + ) + AND ( + (s.provider_v39='claude' AND p.state IN ('signed_in','signed_out')) + OR (s.provider_v39='codex' AND p.state='signed_in' + AND p.provider_email IS NOT NULL + AND p.codex_account_key=pa.account_key AND EXISTS( + SELECT 1 FROM session_account_authorities a + WHERE a.session_id=s.id AND a.profile_id=s.profile_id + AND a.account_key IS NOT NULL + AND a.account_key=lower(trim(p.provider_email)) + )) + OR (s.provider_v39='devin' AND p.state IN ('signed_in','signed_out')) + ) + LIMIT 1`, + ).get(parsedSessionId, parsedProfileId) !== null; } - close(): void { - this.#database.close(false); + readSessionProviderAccountAuthority( + sessionId: SessionId, + ): SessionProviderAccountAuthorityRecord | null { + const parsedSessionId = sessionIdSchema.parse(sessionId); + const row = this.#database.query( + "SELECT * FROM session_provider_account_authorities WHERE session_id=?", + ).get(parsedSessionId); + return row === null ? null : mapSessionProviderAccountAuthority(row); } - createWorkStore( - daemonGeneration: number, - encodeCursor: WorkCursorEncoder, - capabilities: Readonly<{ - issue: WorkCapabilityIssuer; - verify: WorkCapabilityVerifier; - }>, - ): WorkStore { - return new WorkStore(this.#database, { - daemonGeneration, - encodeCursor, - issueCapability: capabilities.issue, - verifyCapability: capabilities.verify, - projectProviderIdentifier: (value) => this.#publicProviderIdentifierProjector(value), - now: this.#now, + bindSessionProviderAccountAuthority(input: { + sessionId: SessionId; + provider: AdoptableProvider; + runtimeScope: z.infer; + accountKey: string; + }): SessionProviderAccountAuthorityRecord { + const parsed = z.object({ + sessionId: sessionIdSchema, + provider: adoptableProviderSchema, + runtimeScope: claudeProcessRuntimeScopeSchema, + accountKey: providerAccountAuthorityKeySchema, + }).strict().parse(input); + const bind = this.#database.transaction(() => { + return this.#bindSessionProviderAccountAuthorityLocked( + parsed, + unixMillisecondsSchema.parse(this.#now()), + ); }); + return bind.immediate(); } - createSessionTaskStore(): SessionTaskStore { - return new SessionTaskStore(this.#database, { now: this.#now }); + #bindSessionProviderAccountAuthorityLocked( + input: Readonly<{ + sessionId: SessionId; + provider: AdoptableProvider; + runtimeScope: z.infer; + accountKey: string; + }>, + recordedAt: number, + ): SessionProviderAccountAuthorityRecord { + if (!providerAccountAuthorityKeyMatchesProvider( + input.provider, + input.accountKey, + )) throw new Error("SESSION_PROVIDER_ACCOUNT_AUTHORITY_KEY_MISMATCH"); + const session = this.requireSession(input.sessionId); + if (session.provider !== input.provider) { + throw new Error("SESSION_PROVIDER_ACCOUNT_AUTHORITY_PROVIDER_MISMATCH"); + } + if (input.provider === "codex") { + const profile = this.requireProfileById(session.profileId); + if ( + profile.providerEmail === undefined + || codexProviderAccountAuthorityKeyForEmail(profile.providerEmail) !== input.accountKey + ) throw new Error("SESSION_PROVIDER_ACCOUNT_AUTHORITY_ACCOUNT_MISMATCH"); + // createSession deliberately clears the legacy email authority until a + // provider observation proves the account. Restore that compatibility + // row only after the exact hashed Codex key has been verified and before + // the immutable provider proof is inserted; the surrounding transaction + // makes the two rows one authority boundary. + this.#database.query( + `UPDATE session_account_authorities + SET account_key=lower(trim(?)),recorded_at=? + WHERE session_id=? AND profile_id=? AND account_key IS NULL + AND NOT EXISTS( + SELECT 1 FROM session_provider_account_authorities a + WHERE a.session_id=session_account_authorities.session_id + )`, + ).run(profile.providerEmail, recordedAt, session.id, session.profileId); + } + const existing = this.readSessionProviderAccountAuthority(input.sessionId); + if (existing !== null) { + if ( + existing.provider !== input.provider + || existing.runtimeScope !== input.runtimeScope + || existing.accountKey !== input.accountKey + ) throw new Error("SESSION_PROVIDER_ACCOUNT_AUTHORITY_IMMUTABLE"); + return existing; + } + this.#database.query( + `INSERT INTO session_provider_account_authorities( + session_id,provider,runtime_scope,account_key,recorded_at + ) VALUES (?,?,?,?,?)`, + ).run( + input.sessionId, + input.provider, + input.runtimeScope, + providerAccountAuthorityKeySchema.parse(input.accountKey), + unixMillisecondsSchema.parse(recordedAt), + ); + const bound = this.readSessionProviderAccountAuthority(input.sessionId); + if (bound === null) throw new Error("SESSION_PROVIDER_ACCOUNT_AUTHORITY_MISSING"); + return bound; } - isConversationAutomationEnabled( - sessionId: SessionId, - providerThreadId: string, - ): boolean { + readSessionAdoptionPolicy(provider: AdoptableProvider): SessionAdoptionPolicyRecord | null { + const parsedProvider = adoptableProviderSchema.parse(provider); const row = this.#database.query( - `SELECT 1 - FROM session_conversation_automation - WHERE session_id=? AND provider_thread_id=?`, - ).get( - sessionIdSchema.parse(sessionId), - providerThreadIdSchema.parse(providerThreadId), - ); - return row !== null; + "SELECT * FROM session_adoption_policies WHERE provider=?", + ).get(parsedProvider); + return row === null ? null : mapSessionAdoptionPolicy(row); } - isSessionTaskQueueSource(sessionId: SessionId, queueId: QueueId): boolean { - const row = this.#database.query( - `SELECT 1 - FROM session_task_occurrences - WHERE session_id=? AND queue_id=?`, - ).get(sessionIdSchema.parse(sessionId), queueIdSchema.parse(queueId)); - return row !== null; + listSessionAdoptionPolicies(): readonly SessionAdoptionPolicyRecord[] { + return this.#database.query( + "SELECT * FROM session_adoption_policies ORDER BY provider", + ).all().map(mapSessionAdoptionPolicy); } - isSessionTaskTurnSource(sessionId: SessionId, turnId: string): boolean { - const row = this.#database.query( - `SELECT 1 - FROM session_task_occurrences o - JOIN session_turn_runtime_profiles p - ON p.session_id=o.session_id - AND p.source_kind='queue_start' - AND p.source_id=o.queue_id - WHERE o.session_id=? AND p.turn_id=?`, - ).get( - sessionIdSchema.parse(sessionId), - z.string().min(1).max(200).parse(turnId), - ); - return row !== null; + /** + * Enables or disables personal-home discovery for a provider. Every explicit + * enable is also a deliberate fence reset, so a previously detached + * candidate can be considered again without fabricating provider activity. + */ + setSessionAdoptionPolicy(input: { + provider: AdoptableProvider; + profileId: ProfileId | null; + }): SessionAdoptionPolicyRecord { + const parsed = z.object({ + provider: adoptableProviderSchema, + profileId: profileIdSchema.nullable(), + }).strict().parse(input); + const now = unixMillisecondsSchema.parse(this.#now()); + const write = this.#database.transaction(() => { + const current = this.#database.query( + "SELECT * FROM session_adoption_policies WHERE provider=?", + ).get(parsed.provider); + const currentPolicy = current === null ? null : mapSessionAdoptionPolicy(current); + const policyAuthorityChanges = currentPolicy !== null + && currentPolicy.profileId !== parsed.profileId; + if (policyAuthorityChanges) { + const claiming = this.#database.query( + `SELECT 1 FROM session_adoption_candidates + WHERE provider=? AND claim_status='claiming' LIMIT 1`, + ).get(parsed.provider); + const unresolvedClaudeClaim = parsed.provider === "claude" + ? this.#database.query( + `SELECT 1 FROM session_claude_process_launch_intents + WHERE runtime_scope='personal' LIMIT 1`, + ).get() !== null + || this.#database.query( + `SELECT 1 FROM session_claude_process_authorities + WHERE runtime_scope='personal' AND state='claimed' LIMIT 1`, + ).get() !== null + || (parsed.profileId !== null && this.#database.query( + `SELECT 1 FROM session_claude_process_authorities + WHERE runtime_scope='personal' AND state!='released' AND profile_id!=? + LIMIT 1`, + ).get(parsed.profileId) !== null) + : false; + if (claiming !== null || unresolvedClaudeClaim) { + throw new Error("SESSION_ADOPTION_POLICY_UNSETTLED_CLAIM"); + } + } + if (parsed.profileId !== null) { + const profile = this.#database.query( + "SELECT state FROM profiles WHERE id=?", + ).get(parsed.profileId); + if ( + profile === null + || !profileStateAllowsProviderSessionAuthority( + parsed.provider, + z.object({ state: profileStateSchema }).strict().parse(profile).state, + ) + ) { + throw new Error("SESSION_ADOPTION_POLICY_PROFILE_NOT_SIGNED_IN"); + } + const foreignActiveBinding = this.#database.query( + `SELECT 1 FROM session_personal_runtime_bindings b + JOIN sessions s ON s.id=b.session_id + WHERE b.provider=? AND b.state IN ('active','detaching') AND s.profile_id!=? + LIMIT 1`, + ).get(parsed.provider, parsed.profileId); + if (foreignActiveBinding !== null) { + throw new Error("SESSION_ADOPTION_POLICY_ACTIVE_BINDINGS"); + } + } + if (current === null) { + this.#database.query( + `INSERT INTO session_adoption_policies( + provider,profile_id,state,revision,created_at,updated_at + ) VALUES (?,?,?,1,?,?)`, + ).run( + parsed.provider, + parsed.profileId, + parsed.profileId === null ? "disabled" : "enabled", + now, + now, + ); + } else { + const policy = currentPolicy; + if (policy === null) throw new Error("SESSION_ADOPTION_POLICY_MISSING"); + const updated = this.#database.query( + `UPDATE session_adoption_policies + SET profile_id=?,state=?,revision=revision+1,updated_at=MAX(updated_at,?) + WHERE provider=? AND revision=?`, + ).run( + parsed.profileId, + parsed.profileId === null ? "disabled" : "enabled", + now, + parsed.provider, + policy.revision, + ); + if (updated.changes !== 1) throw new Error("SESSION_ADOPTION_POLICY_CONFLICT"); + } + if (parsed.profileId !== null) { + this.#database.query( + `UPDATE session_adoption_candidates + SET claim_status='pending',fenced_fingerprint=NULL,revision=revision+1 + WHERE provider=? AND claim_status='fenced' + AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.provider=session_adoption_candidates.provider + AND b.provider_thread_id=session_adoption_candidates.provider_thread_id + AND b.state='detaching' + )`, + ).run(parsed.provider); + this.#prunePendingSessionAdoptionCandidates(parsed.provider); + } + const row = this.#database.query( + "SELECT * FROM session_adoption_policies WHERE provider=?", + ).get(parsed.provider); + if (row === null) throw new Error("SESSION_ADOPTION_POLICY_MISSING"); + return mapSessionAdoptionPolicy(row); + }); + return write.immediate(); } - configurePublicProviderIdentifierProjector( - projector: PublicProviderIdentifierProjector, - ): void { - this.#publicProviderIdentifierProjector = projector; + upsertSessionAdoptionCandidate(input: { + provider: AdoptableProvider; + providerThreadId: string; + projectId?: ProjectId; + providerProjectRoot?: string; + title: string; + state: "active" | "idle" | "terminal"; + activeTurnId?: string; + providerUpdatedAt?: number; + liveness: z.infer; + sourceProcessIdentity?: ClaudeProcessIdentity | null; + trustedLiveObservation?: true; + }): SessionAdoptionCandidateRecord { + const parsed = z.object({ + provider: adoptableProviderSchema, + providerThreadId: providerThreadIdSchema, + projectId: projectIdSchema.optional(), + providerProjectRoot: sessionAdoptionProviderProjectRootSchema.optional(), + title: z.string(), + state: z.enum(["active", "idle", "terminal"]), + activeTurnId: z.string().min(1).max(2_048).optional(), + providerUpdatedAt: z.number().finite().nonnegative().optional(), + liveness: sessionAdoptionLivenessSchema, + sourceProcessIdentity: claudeProcessIdentitySchema.nullable().optional(), + trustedLiveObservation: z.literal(true).optional(), + }).strict().parse(input); + if (parsed.provider !== "claude" && parsed.sourceProcessIdentity != null) { + throw new Error("SESSION_ADOPTION_SOURCE_PROCESS_PROVIDER_INVALID"); + } + if ( + parsed.trustedLiveObservation === true + && ( + parsed.liveness !== "live" + || (parsed.provider === "claude" && parsed.sourceProcessIdentity == null) + || ( + parsed.provider === "codex" + && parsed.state !== "active" + && parsed.activeTurnId === undefined + ) + ) + ) throw new Error("SESSION_ADOPTION_TRUSTED_LIVE_OBSERVATION_INVALID"); + const title = safeObservationTitle(parsed.title); + const observation = { + provider: parsed.provider, + providerThreadId: parsed.providerThreadId, + projectId: parsed.projectId ?? null, + providerProjectRoot: parsed.providerProjectRoot ?? null, + title, + providerState: parsed.state, + activeTurnId: parsed.activeTurnId ?? null, + providerUpdatedAt: parsed.providerUpdatedAt ?? null, + } as const; + const now = unixMillisecondsSchema.parse(this.#now()); + const write = this.#database.transaction(() => { + const currentRow = this.#database.query( + `SELECT * FROM session_adoption_candidates + WHERE provider=? AND provider_thread_id=?`, + ).get(parsed.provider, parsed.providerThreadId); + if (currentRow === null) { + const normalized = { + ...observation, + sourceProcessIdentity: parsed.sourceProcessIdentity ?? null, + } as const; + const fingerprint = sessionAdoptionCandidateFingerprint(normalized); + this.#database.query( + `INSERT INTO session_adoption_candidates( + provider,provider_thread_id,project_id,provider_project_root,title,provider_state, + active_turn_id,provider_updated_at,liveness, + source_pid,source_pid_domain,source_proc_start,claim_status, + candidate_fingerprint,fenced_fingerprint,revision, + first_discovered_at,last_observed_at,last_changed_at,last_attempt_at, + last_live_observed_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?, 'pending',?,NULL,1,?,?,?,NULL,?)`, + ).run( + parsed.provider, + parsed.providerThreadId, + normalized.projectId, + normalized.providerProjectRoot, + normalized.title, + normalized.providerState, + normalized.activeTurnId, + normalized.providerUpdatedAt, + parsed.liveness, + normalized.sourceProcessIdentity?.pid ?? null, + normalized.sourceProcessIdentity?.pidDomain ?? null, + normalized.sourceProcessIdentity?.procStart ?? null, + fingerprint, + now, + now, + now, + parsed.trustedLiveObservation === true + ? now + : null, + ); + } else { + const current = sessionAdoptionCandidateRowSchema.parse(currentRow); + const currentRecord = mapSessionAdoptionCandidate(currentRow); + const normalized = { + ...observation, + sourceProcessIdentity: parsed.sourceProcessIdentity === undefined + ? currentRecord.sourceProcessIdentity + : parsed.sourceProcessIdentity, + } as const; + const fingerprint = sessionAdoptionCandidateFingerprint(normalized); + const observedAt = Math.max(now, current.last_observed_at); + const previousIdentity = currentRecord.sourceProcessIdentity; + const nextIdentity = normalized.sourceProcessIdentity; + const sourceIdentityChanged = previousIdentity?.pid !== nextIdentity?.pid + || previousIdentity?.pidDomain !== nextIdentity?.pidDomain + || previousIdentity?.procStart !== nextIdentity?.procStart; + const lastLiveObservedAt = parsed.trustedLiveObservation === true + ? sourceIdentityChanged + ? now + : Math.max(current.last_live_observed_at ?? 0, now) + : sourceIdentityChanged + ? null + : current.last_live_observed_at; + const changed = current.candidate_fingerprint !== fingerprint; + const releaseFence = current.claim_status === "fenced" && changed; + const updated = this.#database.query( + `UPDATE session_adoption_candidates + SET project_id=?,provider_project_root=?,title=?,provider_state=?,active_turn_id=?, + provider_updated_at=?,liveness=?,source_pid=?,source_pid_domain=?, + source_proc_start=?,claim_status=?, + candidate_fingerprint=?,fenced_fingerprint=?,revision=revision+1, + last_observed_at=?,last_changed_at=?,last_live_observed_at=? + WHERE provider=? AND provider_thread_id=? AND revision=?`, + ).run( + normalized.projectId, + normalized.providerProjectRoot, + normalized.title, + normalized.providerState, + normalized.activeTurnId, + normalized.providerUpdatedAt, + parsed.liveness, + normalized.sourceProcessIdentity?.pid ?? null, + normalized.sourceProcessIdentity?.pidDomain ?? null, + normalized.sourceProcessIdentity?.procStart ?? null, + releaseFence ? "pending" : current.claim_status, + fingerprint, + releaseFence ? null : current.fenced_fingerprint, + observedAt, + changed ? observedAt : current.last_changed_at, + lastLiveObservedAt, + parsed.provider, + parsed.providerThreadId, + current.revision, + ); + if (updated.changes !== 1) throw new Error("SESSION_ADOPTION_CANDIDATE_CONFLICT"); + } + this.#prunePendingSessionAdoptionCandidates( + parsed.provider, + parsed.providerThreadId, + ); + const row = this.#database.query( + `SELECT * FROM session_adoption_candidates + WHERE provider=? AND provider_thread_id=?`, + ).get(parsed.provider, parsed.providerThreadId); + if (row === null) throw new Error("SESSION_ADOPTION_CANDIDATE_MISSING"); + return mapSessionAdoptionCandidate(row); + }); + return write.immediate(); + } + + listSessionAdoptionCandidates(input: { + provider?: AdoptableProvider; + status?: z.infer; + limit?: number; + } = {}): readonly SessionAdoptionCandidateRecord[] { + const parsed = z.object({ + provider: adoptableProviderSchema.optional(), + status: sessionAdoptionCandidateStatusSchema.optional(), + limit: z.number().int().positive().max(500).optional(), + }).strict().parse(input); + const limit = parsed.limit ?? 100; + const clauses: string[] = []; + const values: (string | number)[] = []; + if (parsed.provider !== undefined) { + clauses.push("provider=?"); + values.push(parsed.provider); + } + if (parsed.status !== undefined) { + clauses.push("claim_status=?"); + values.push(parsed.status); + } + const where = clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`; + return this.#database.query( + `SELECT * FROM session_adoption_candidates ${where} + ORDER BY last_observed_at DESC,provider,provider_thread_id LIMIT ?`, + ).all(...values, limit).map(mapSessionAdoptionCandidate); } - projectPublicProviderIdentifier(value: string): PublicProviderIdentifier { - return this.#publicProviderIdentifierProjector(value); + readSessionAdoptionCandidate( + provider: AdoptableProvider, + providerThreadId: string, + ): SessionAdoptionCandidateRecord | null { + const parsedProvider = adoptableProviderSchema.parse(provider); + const parsedProviderThreadId = sessionAdoptionProviderThreadIdSchema.parse( + providerThreadId, + ); + const row = this.#database.query( + `SELECT * FROM session_adoption_candidates + WHERE provider=? AND provider_thread_id=?`, + ).get(parsedProvider, parsedProviderThreadId); + return row === null ? null : mapSessionAdoptionCandidate(row); } - createProfile(label: string): ProfileRecord { - const id = createProfileId(); - const parsedLabel = labelSchema.parse(label); - const labelKey = canonicalLabelIdentity(parsedLabel, "ACCOUNT").key; + /** + * Records a metadata-only Codex observation made after daemon restart. The + * logical timestamp is advanced past the interrupted attempt even when the + * wall clock has not ticked, matching the exact-process Claude recovery seam. + */ + updateCodexSessionAdoptionCandidateLivenessAfterExactRead(input: { + providerThreadId: string; + expectedRevision: number; + liveness: z.infer; + trustedLiveObservation?: true; + }): SessionAdoptionCandidateRecord { + const parsed = z.object({ + providerThreadId: sessionAdoptionProviderThreadIdSchema, + expectedRevision: z.number().int().positive().safe(), + liveness: sessionAdoptionLivenessSchema, + trustedLiveObservation: z.literal(true).optional(), + }).strict().parse(input); + if (parsed.trustedLiveObservation === true && parsed.liveness !== "live") { + throw new Error("SESSION_ADOPTION_TRUSTED_LIVE_OBSERVATION_INVALID"); + } const now = unixMillisecondsSchema.parse(this.#now()); - const create = this.#database.transaction(() => { - this.#database.query("INSERT INTO profiles(id,label,label_key,state,process_generation,created_at,updated_at) VALUES (?,?,?,?,?,?,?)").run(id, parsedLabel, labelKey, "signed_out", 0, now, now); - this.#database.query( - `INSERT INTO account_rate_limit_reset_policies( - profile_id,state,account_fingerprint,weekly_window_resets_at, - revision,created_at,updated_at - ) VALUES (?,'active_unbound',NULL,NULL,1,?,?)`, - ).run(id, now, now); - return mapProfile(this.#database.query("SELECT * FROM profiles WHERE id=?").get(id)); + const update = this.#database.transaction(() => { + const row = this.#database.query( + `SELECT * FROM session_adoption_candidates + WHERE provider='codex' AND provider_thread_id=?`, + ).get(parsed.providerThreadId); + if (row === null) throw new Error("SESSION_ADOPTION_CANDIDATE_RECOVERY_CONFLICT"); + const candidate = mapSessionAdoptionCandidate(row); + const hasControllingBinding = this.#database.query( + `SELECT 1 FROM session_personal_runtime_bindings + WHERE provider='codex' AND provider_thread_id=? + AND state IN ('active','detaching') LIMIT 1`, + ).get(parsed.providerThreadId) !== null; + if ( + candidate.revision !== parsed.expectedRevision + || candidate.status !== "claiming" + || hasControllingBinding + ) throw new Error("SESSION_ADOPTION_CANDIDATE_RECOVERY_CONFLICT"); + const timestampFloor = Math.max( + candidate.lastObservedAt, + candidate.lastAttemptAt ?? 0, + ); + if (timestampFloor >= Number.MAX_SAFE_INTEGER) { + throw new Error("SESSION_ADOPTION_CANDIDATE_TIMESTAMP_EXHAUSTED"); + } + const observedAt = unixMillisecondsSchema.parse(Math.max(now, timestampFloor + 1)); + const lastLiveObservedAt = parsed.trustedLiveObservation === true + ? Math.max(candidate.lastLiveObservedAt ?? 0, now) + : candidate.lastLiveObservedAt; + const changed = this.#database.query( + `UPDATE session_adoption_candidates + SET liveness=?,revision=revision+1,last_observed_at=?,last_live_observed_at=? + WHERE provider='codex' AND provider_thread_id=? AND revision=? + AND claim_status='claiming' + AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.provider=session_adoption_candidates.provider + AND b.provider_thread_id=session_adoption_candidates.provider_thread_id + AND b.state IN ('active','detaching') + )`, + ).run( + parsed.liveness, + observedAt, + lastLiveObservedAt, + parsed.providerThreadId, + parsed.expectedRevision, + ); + if (changed.changes !== 1) { + throw new Error("SESSION_ADOPTION_CANDIDATE_RECOVERY_CONFLICT"); + } + const updated = this.#database.query( + `SELECT * FROM session_adoption_candidates + WHERE provider='codex' AND provider_thread_id=?`, + ).get(parsed.providerThreadId); + if (updated === null) throw new Error("SESSION_ADOPTION_CANDIDATE_NOT_FOUND"); + return mapSessionAdoptionCandidate(updated); }); - return create.immediate(); + return update.immediate(); } - listProfiles(options: { includeRemoved?: boolean } = {}): readonly ProfileRecord[] { - const rows = options.includeRemoved - ? this.#database.query("SELECT * FROM profiles ORDER BY label_key, id").all() - : this.#database.query("SELECT * FROM profiles WHERE state != 'removed' ORDER BY label_key, id").all(); - return rows.map(mapProfile); + listRecentClaudeSessionAdoptionCandidatesWithSourceIdentity(input: { + providerUpdatedAfter: number; + limit?: number; + }): readonly SessionAdoptionCandidateRecord[] { + const parsed = z.object({ + providerUpdatedAfter: z.number().finite().nonnegative(), + limit: z.number().int().positive().max(100).optional(), + }).strict().parse(input); + return this.#database.query( + `SELECT c.* FROM session_adoption_candidates c + WHERE c.provider='claude' + AND c.claim_status IN ('pending','claiming') + AND c.provider_updated_at IS NOT NULL + AND c.provider_updated_at>=? + AND c.source_pid IS NOT NULL + AND c.source_pid_domain IS NOT NULL + AND c.source_proc_start IS NOT NULL + AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.provider=c.provider + AND b.provider_thread_id=c.provider_thread_id + AND b.state IN ('active','detaching') + ) + ORDER BY c.last_observed_at,c.provider_thread_id + LIMIT ?`, + ).all(parsed.providerUpdatedAfter, parsed.limit ?? 50) + .map(mapSessionAdoptionCandidate); } - requireProfile(selector: string): ProfileRecord { - const selected = selectByIdOrLabel(this.listProfiles(), selector); - if (selected.kind === "found") return selected.value; - throw new SelectionError(selected.kind === "missing" ? "NOT_FOUND" : "AMBIGUOUS", selected.kind === "ambiguous" ? selected.values : []); + listRetainedClaudeSessionAdoptionCandidatesWithSourceIdentity(input: { + excludeProviderThreadIds: readonly string[]; + liveObservedAfter: number; + limit?: number; + }): readonly SessionAdoptionCandidateRecord[] { + const parsed = z.object({ + excludeProviderThreadIds: z.array(sessionAdoptionProviderThreadIdSchema).max(200), + liveObservedAfter: unixMillisecondsSchema, + limit: z.number().int().positive().max(200).optional(), + }).strict().parse(input); + const excluded = [...new Set(parsed.excludeProviderThreadIds)]; + const exclusion = excluded.length === 0 + ? "" + : `AND c.provider_thread_id NOT IN (${excluded.map(() => "?").join(",")})`; + return this.#database.query( + `SELECT c.* FROM session_adoption_candidates c + WHERE c.provider='claude' + AND c.claim_status IN ('pending','claiming') + AND c.source_pid IS NOT NULL + AND c.source_pid_domain IS NOT NULL + AND c.source_proc_start IS NOT NULL + AND c.last_live_observed_at IS NOT NULL + AND c.last_live_observed_at>=? + ${exclusion} + AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.provider=c.provider + AND b.provider_thread_id=c.provider_thread_id + AND b.state IN ('active','detaching') + ) + ORDER BY c.last_attempt_at IS NOT NULL,c.last_attempt_at, + c.last_live_observed_at,c.provider_thread_id + LIMIT ?`, + ).all(parsed.liveObservedAfter, ...excluded, parsed.limit ?? 50) + .map(mapSessionAdoptionCandidate); } - requireProfileById(profileId: ProfileId, options: { includeRemoved?: boolean } = {}): ProfileRecord { - const parsedId = profileIdSchema.parse(profileId); - const row = options.includeRemoved - ? this.#database.query("SELECT * FROM profiles WHERE id=?").get(parsedId) - : this.#database.query("SELECT * FROM profiles WHERE id=? AND state!='removed'").get(parsedId); - if (row === null) throw new SelectionError("NOT_FOUND"); - return mapProfile(row); + updateClaudeSessionAdoptionCandidateLivenessAfterExactProbe(input: { + providerThreadId: string; + expectedRevision: number; + expectedSourceProcessIdentity: ClaudeProcessIdentity; + liveness: z.infer; + }): SessionAdoptionCandidateRecord { + const parsed = z.object({ + providerThreadId: sessionAdoptionProviderThreadIdSchema, + expectedRevision: z.number().int().positive().safe(), + expectedSourceProcessIdentity: claudeProcessIdentitySchema, + liveness: sessionAdoptionLivenessSchema, + }).strict().parse(input); + const now = unixMillisecondsSchema.parse(this.#now()); + const update = this.#database.transaction(() => { + const row = this.#database.query( + `SELECT * FROM session_adoption_candidates + WHERE provider='claude' AND provider_thread_id=?`, + ).get(parsed.providerThreadId); + if (row === null) throw new Error("SESSION_ADOPTION_CANDIDATE_PROBE_CONFLICT"); + const candidate = mapSessionAdoptionCandidate(row); + const identity = candidate.sourceProcessIdentity; + const hasControllingBinding = this.#database.query( + `SELECT 1 FROM session_personal_runtime_bindings + WHERE provider='claude' AND provider_thread_id=? + AND state IN ('active','detaching') LIMIT 1`, + ).get(parsed.providerThreadId) !== null; + if ( + candidate.revision !== parsed.expectedRevision + || (candidate.status !== "pending" && candidate.status !== "claiming") + || identity === null + || identity.pid !== parsed.expectedSourceProcessIdentity.pid + || identity.pidDomain !== parsed.expectedSourceProcessIdentity.pidDomain + || identity.procStart !== parsed.expectedSourceProcessIdentity.procStart + || hasControllingBinding + ) throw new Error("SESSION_ADOPTION_CANDIDATE_PROBE_CONFLICT"); + const timestampFloor = Math.max( + candidate.lastObservedAt, + candidate.lastAttemptAt ?? 0, + ); + if (timestampFloor >= Number.MAX_SAFE_INTEGER) { + throw new Error("SESSION_ADOPTION_CANDIDATE_TIMESTAMP_EXHAUSTED"); + } + const observedAt = unixMillisecondsSchema.parse(Math.max(now, timestampFloor + 1)); + const lastLiveObservedAt = parsed.liveness === "live" + ? Math.max(candidate.lastLiveObservedAt ?? 0, now) + : candidate.lastLiveObservedAt; + const changed = this.#database.query( + `UPDATE session_adoption_candidates + SET liveness=?,revision=revision+1,last_observed_at=?,last_live_observed_at=? + WHERE provider='claude' AND provider_thread_id=? AND revision=? + AND claim_status IN ('pending','claiming') + AND source_pid=? AND source_pid_domain=? AND source_proc_start=? + AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.provider=session_adoption_candidates.provider + AND b.provider_thread_id=session_adoption_candidates.provider_thread_id + AND b.state IN ('active','detaching') + )`, + ).run( + parsed.liveness, + observedAt, + lastLiveObservedAt, + parsed.providerThreadId, + parsed.expectedRevision, + parsed.expectedSourceProcessIdentity.pid, + parsed.expectedSourceProcessIdentity.pidDomain, + parsed.expectedSourceProcessIdentity.procStart, + ); + if (changed.changes !== 1) { + throw new Error("SESSION_ADOPTION_CANDIDATE_PROBE_CONFLICT"); + } + const updated = this.#database.query( + `SELECT * FROM session_adoption_candidates + WHERE provider='claude' AND provider_thread_id=?`, + ).get(parsed.providerThreadId); + if (updated === null) throw new Error("SESSION_ADOPTION_CANDIDATE_PROBE_CONFLICT"); + return mapSessionAdoptionCandidate(updated); + }); + return update.immediate(); } - providerAuthorityAdvanceBlocker( - profileId: ProfileId, - provider: Provider, - ): "active_session" | "recovery_required" | "unsettled_authority" | null { - const parsedProfileId = profileIdSchema.parse(profileId); - const parsedProvider = providerSchema.parse(provider); - if ( - this.#hasUnsettledClaudeLoginAuthority(parsedProfileId) - || this.#hasUnsettledDevinLoginAuthority(parsedProfileId) - ) { - return "unsettled_authority"; - } - const providerSwitch = this.#database.query( - `SELECT 1 AS present - FROM mutation_attempts m - JOIN mutation_effect_evidence e ON e.attempt_id=m.id - LEFT JOIN mutation_resolutions r ON r.attempt_id=m.id - WHERE e.kind='session.switch' - AND m.state IN ('effect_started','ambiguous') - AND r.attempt_id IS NULL - AND ( - (json_extract(e.evidence_json,'$.sourceProfileId')=? - AND json_extract(e.evidence_json,'$.sourceProvider')=?) - OR - (json_extract(e.evidence_json,'$.targetProfileId')=? - AND json_extract(e.evidence_json,'$.targetProvider')=?) - ) - LIMIT 1`, - ).get( - parsedProfileId, - parsedProvider, - parsedProfileId, - parsedProvider, - ); - if (providerSwitch !== null) return "unsettled_authority"; - const row = z.object({ - reason: z.enum([ - "active_session", - "recovery_required", - "unsettled_authority", - ]).nullable(), + readSessionAdoptionCounts(provider: AdoptableProvider): SessionAdoptionCounts { + const parsedProvider = adoptableProviderSchema.parse(provider); + return z.object({ + pending: z.number().int().nonnegative(), + adopted: z.number().int().nonnegative(), + fenced: z.number().int().nonnegative(), }).strict().parse(this.#database.query( - `SELECT CASE - WHEN EXISTS( - SELECT 1 FROM sessions s - WHERE s.profile_id=? AND s.provider_v39=? - AND (s.state IN ('starting','active') OR s.active_turn_id IS NOT NULL) - ) THEN 'active_session' - WHEN EXISTS( - SELECT 1 FROM sessions s - WHERE s.profile_id=? AND s.provider_v39=? AND s.state='recovery_required' - ) THEN 'recovery_required' - WHEN EXISTS( - SELECT 1 - FROM sessions s - WHERE s.profile_id=? AND s.provider_v39=? - AND ( - EXISTS( - SELECT 1 FROM provider_interactions i - WHERE i.session_id=s.id - AND i.state IN ('pending','response_prepared','response_written') - ) - OR EXISTS( - SELECT 1 FROM mutation_attempts m - LEFT JOIN mutation_resolutions r ON r.attempt_id=m.id - WHERE m.authority_id=s.id - AND m.state IN ('effect_started','ambiguous') - AND r.attempt_id IS NULL - ) - OR EXISTS( - SELECT 1 FROM queue_entries q - LEFT JOIN queue_effect_resolutions r ON r.queue_id=q.id - WHERE q.session_id=s.id - AND q.state IN ('dispatching','ambiguous') - AND r.queue_id IS NULL - ) - OR EXISTS( - SELECT 1 FROM work_attempts w - WHERE w.worker_session_id=s.id - AND w.state IN ('claimed','dispatching','running','recovery_required') - ) - OR EXISTS( - SELECT 1 FROM work_signals w - WHERE w.to_session_id=s.id - AND NOT EXISTS( - SELECT 1 FROM work_signal_receipts r - WHERE r.signal_id=w.id AND r.kind='ack' - ) - ) - ) - ) THEN 'unsettled_authority' - ELSE NULL - END AS reason`, - ).get( - parsedProfileId, - parsedProvider, - parsedProfileId, - parsedProvider, - parsedProfileId, - parsedProvider, - )).reason; - return row; - } - - hasUnsettledSessionMutationAuthority( - profileId: ProfileId, - provider?: Provider, - ): boolean { - const tuples = this.#sessionMutationAuthorityTuplesForProfile( - profileIdSchema.parse(profileId), - ); - return provider === undefined - ? tuples.length > 0 - : tuples.some((tuple) => tuple.provider === providerSchema.parse(provider)); + `SELECT + COUNT(*) FILTER (WHERE claim_status IN ('pending','claiming')) AS pending, + COUNT(*) FILTER (WHERE claim_status='adopted') AS adopted, + COUNT(*) FILTER (WHERE claim_status='fenced') AS fenced + FROM session_adoption_candidates + WHERE provider=?`, + ).get(parsedProvider)); } - isSessionMutationProviderAuthorityCurrent(input: Readonly<{ - attemptId: AttemptId; - profileId: ProfileId; - provider: Provider; - originGeneration: number; - }>): boolean { - const attemptId = attemptIdSchema.parse(input.attemptId); - const profileId = profileIdSchema.parse(input.profileId); - const provider = providerSchema.parse(input.provider); - const originGeneration = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) - .parse(input.originGeneration); - const current = z.object({ - process_generation: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), - }).strict().safeParse(this.#database.query( - "SELECT process_generation FROM profiles WHERE id=? AND state!='removed'", - ).get(profileId)); - if (!current.success) return false; - if (current.data.process_generation === originGeneration) return true; - return this.#database.query( - `WITH RECURSIVE authority_chain(generation) AS ( - VALUES (?) - UNION ALL - SELECT r.to_generation - FROM session_mutation_authority_rebinds_v39 r - JOIN authority_chain c ON r.from_generation=c.generation - WHERE r.attempt_id=? AND r.profile_id=? AND r.provider=? - ) - SELECT 1 AS current FROM authority_chain WHERE generation=? LIMIT 1`, - ).get( - originGeneration, - attemptId, - profileId, - provider, - current.data.process_generation, - ) !== null; + /** + * Durably rotates a pending candidate before any candidate-specific async + * preflight. This has no provider-side effect and deliberately leaves the + * row pending; its only authority is the revision-fenced attempt timestamp + * used by the next poll's fairness order. + */ + recordSessionAdoptionCandidatePreflightAttempt(input: { + provider: AdoptableProvider; + providerThreadId: string; + expectedRevision: number; + }): SessionAdoptionCandidateRecord { + const parsed = z.object({ + provider: adoptableProviderSchema, + providerThreadId: sessionAdoptionProviderThreadIdSchema, + expectedRevision: z.number().int().positive().safe(), + }).strict().parse(input); + const now = unixMillisecondsSchema.parse(this.#now()); + const record = this.#database.transaction(() => { + const currentRow = this.#database.query( + `SELECT first_discovered_at,last_observed_at,last_attempt_at + FROM session_adoption_candidates + WHERE provider=? AND provider_thread_id=? AND revision=? + AND claim_status='pending'`, + ).get(parsed.provider, parsed.providerThreadId, parsed.expectedRevision); + if (currentRow === null) { + throw new Error("SESSION_ADOPTION_CANDIDATE_PREFLIGHT_CONFLICT"); + } + const current = z.object({ + first_discovered_at: unixMillisecondsSchema, + last_observed_at: unixMillisecondsSchema, + last_attempt_at: unixMillisecondsSchema.nullable(), + }).strict().parse(currentRow); + const providerAttempt = z.object({ + max_attempt_at: unixMillisecondsSchema.nullable(), + }).strict().parse(this.#database.query( + `SELECT MAX(last_attempt_at) AS max_attempt_at + FROM session_adoption_candidates WHERE provider=?`, + ).get(parsed.provider)); + if (providerAttempt.max_attempt_at === Number.MAX_SAFE_INTEGER) { + throw new Error("SESSION_ADOPTION_CANDIDATE_TIMESTAMP_EXHAUSTED"); + } + const attemptedAt = unixMillisecondsSchema.parse(Math.max( + now, + current.first_discovered_at, + current.last_observed_at, + (providerAttempt.max_attempt_at ?? -1) + 1, + )); + const changed = this.#database.query( + `UPDATE session_adoption_candidates + SET revision=revision+1,last_attempt_at=? + WHERE provider=? AND provider_thread_id=? AND revision=? + AND claim_status='pending'`, + ).run( + attemptedAt, + parsed.provider, + parsed.providerThreadId, + parsed.expectedRevision, + ); + if (changed.changes !== 1) { + throw new Error("SESSION_ADOPTION_CANDIDATE_PREFLIGHT_CONFLICT"); + } + const row = this.#database.query( + `SELECT * FROM session_adoption_candidates + WHERE provider=? AND provider_thread_id=?`, + ).get(parsed.provider, parsed.providerThreadId); + if (row === null) throw new Error("SESSION_ADOPTION_CANDIDATE_NOT_FOUND"); + return mapSessionAdoptionCandidate(row); + }); + return record.immediate(); } - hasNonterminalProviderSession(profileId: ProfileId, provider: Provider): boolean { - const parsedProfileId = profileIdSchema.parse(profileId); - const parsedProvider = providerSchema.parse(provider); - return this.#database.query( - `SELECT 1 AS present FROM sessions - WHERE profile_id=? AND provider_v39=? AND state!='terminal' - LIMIT 1`, - ).get(parsedProfileId, parsedProvider) !== null; + fenceSessionAdoptionCandidateForClaim(input: { + provider: AdoptableProvider; + providerThreadId: string; + expectedRevision: number; + }): SessionAdoptionCandidateRecord { + const parsed = z.object({ + provider: adoptableProviderSchema, + providerThreadId: sessionAdoptionProviderThreadIdSchema, + expectedRevision: z.number().int().positive().safe(), + }).strict().parse(input); + const now = unixMillisecondsSchema.parse(this.#now()); + const fence = this.#database.transaction(() => { + const changed = this.#database.query( + `UPDATE session_adoption_candidates + SET claim_status='claiming',fenced_fingerprint=NULL, + revision=revision+1,last_attempt_at=MAX(COALESCE(last_attempt_at,0),?) + WHERE provider=? AND provider_thread_id=? AND revision=? + AND claim_status='pending'`, + ).run( + now, + parsed.provider, + parsed.providerThreadId, + parsed.expectedRevision, + ); + if (changed.changes !== 1) { + throw new Error("SESSION_ADOPTION_CANDIDATE_CLAIM_CONFLICT"); + } + const row = this.#database.query( + `SELECT * FROM session_adoption_candidates + WHERE provider=? AND provider_thread_id=?`, + ).get(parsed.provider, parsed.providerThreadId); + if (row === null) throw new Error("SESSION_ADOPTION_CANDIDATE_NOT_FOUND"); + return mapSessionAdoptionCandidate(row); + }); + return fence.immediate(); } - listNonterminalProviderSessions( - profileId: ProfileId, - provider: Provider, - ): readonly SessionRecord[] { - const parsedProfileId = profileIdSchema.parse(profileId); - const parsedProvider = providerSchema.parse(provider); - return this.#database.query( - `SELECT * FROM sessions - WHERE profile_id=? AND provider_v39=? AND state!='terminal' - ORDER BY id`, - ).all(parsedProfileId, parsedProvider).map(mapSession); + requeueSessionAdoptionCandidateAfterClaimRelease(input: { + provider: AdoptableProvider; + providerThreadId: string; + profileId: ProfileId; + }): SessionAdoptionCandidateRecord { + const parsed = z.object({ + provider: adoptableProviderSchema, + providerThreadId: sessionAdoptionProviderThreadIdSchema, + profileId: profileIdSchema, + }).strict().parse(input); + const requeue = this.#database.transaction(() => { + const binding = this.findSessionPersonalRuntimeBinding( + parsed.provider, + parsed.providerThreadId, + ); + if (binding !== null && binding.state !== "detached") { + throw new Error("SESSION_ADOPTION_CLAIM_RELEASE_BINDING_LIVE"); + } + if (parsed.provider === "claude") { + const process = this.readClaudeProcessAuthority({ + providerThreadId: parsed.providerThreadId, + profileId: parsed.profileId, + runtimeScope: "personal", + }); + if (process === null || process.state !== "released") { + throw new Error("SESSION_ADOPTION_CLAIM_RELEASE_PROCESS_LIVE"); + } + } + const changed = this.#database.query( + `UPDATE session_adoption_candidates + SET claim_status='pending',fenced_fingerprint=NULL,revision=revision+1 + WHERE provider=? AND provider_thread_id=? AND claim_status='claiming'`, + ).run(parsed.provider, parsed.providerThreadId); + if (changed.changes !== 1) { + throw new Error("SESSION_ADOPTION_CLAIM_RELEASE_CONFLICT"); + } + const row = this.#database.query( + `SELECT * FROM session_adoption_candidates + WHERE provider=? AND provider_thread_id=?`, + ).get(parsed.provider, parsed.providerThreadId); + if (row === null) throw new Error("SESSION_ADOPTION_CANDIDATE_NOT_FOUND"); + return mapSessionAdoptionCandidate(row); + }); + return requeue.immediate(); } - canReleaseIdleClaudeSessionForAccountLogin(input: Readonly<{ + fenceSessionAdoptionCandidateAfterClaimRelease(input: { + provider: AdoptableProvider; + providerThreadId: string; profileId: ProfileId; - profileGeneration: number; - sessionId: SessionId; - }>): boolean { - return this.#canReleaseIdleProviderSessionForAccountLogin({ - ...input, - provider: "claude", + }): SessionAdoptionCandidateRecord { + const parsed = z.object({ + provider: adoptableProviderSchema, + providerThreadId: sessionAdoptionProviderThreadIdSchema, + profileId: profileIdSchema, + }).strict().parse(input); + const fence = this.#database.transaction(() => { + const binding = this.findSessionPersonalRuntimeBinding( + parsed.provider, + parsed.providerThreadId, + ); + if (binding !== null && binding.state !== "detached") { + throw new Error("SESSION_ADOPTION_CLAIM_RELEASE_BINDING_LIVE"); + } + if (parsed.provider === "claude") { + const process = this.readClaudeProcessAuthority({ + providerThreadId: parsed.providerThreadId, + profileId: parsed.profileId, + runtimeScope: "personal", + }); + if (process === null || process.state !== "released") { + throw new Error("SESSION_ADOPTION_CLAIM_RELEASE_PROCESS_LIVE"); + } + } + const changed = this.#database.query( + `UPDATE session_adoption_candidates + SET claim_status='fenced',fenced_fingerprint=candidate_fingerprint, + revision=revision+1 + WHERE provider=? AND provider_thread_id=? AND claim_status='claiming'`, + ).run(parsed.provider, parsed.providerThreadId); + if (changed.changes !== 1) { + throw new Error("SESSION_ADOPTION_CLAIM_RELEASE_CONFLICT"); + } + const row = this.#database.query( + `SELECT * FROM session_adoption_candidates + WHERE provider=? AND provider_thread_id=?`, + ).get(parsed.provider, parsed.providerThreadId); + if (row === null) throw new Error("SESSION_ADOPTION_CANDIDATE_NOT_FOUND"); + return mapSessionAdoptionCandidate(row); }); + return fence.immediate(); } - canReleaseIdleDevinSessionForAccountLogin(input: Readonly<{ + /** + * Recover a claim interrupted by daemon loss only after a later discovery + * observation again proves the source quiet. Claude additionally requires + * any durable child-process custody to be released. This is deliberately + * separate from the controlled-failure release path above. + */ + recoverSessionAdoptionClaimAfterObservation(input: { + provider: AdoptableProvider; + providerThreadId: string; profileId: ProfileId; - profileGeneration: number; - sessionId: SessionId; - }>): boolean { - return this.#canReleaseIdleProviderSessionForAccountLogin({ - ...input, - provider: "devin", + expectedRevision: number; + }): SessionAdoptionCandidateRecord { + const parsed = z.object({ + provider: adoptableProviderSchema, + providerThreadId: sessionAdoptionProviderThreadIdSchema, + profileId: profileIdSchema, + expectedRevision: z.number().int().positive().safe(), + }).strict().parse(input); + const recover = this.#database.transaction(() => { + const row = this.#database.query( + `SELECT * FROM session_adoption_candidates + WHERE provider=? AND provider_thread_id=?`, + ).get(parsed.provider, parsed.providerThreadId); + if (row === null) throw new Error("SESSION_ADOPTION_CANDIDATE_NOT_FOUND"); + const candidate = mapSessionAdoptionCandidate(row); + if ( + candidate.revision !== parsed.expectedRevision + || candidate.status !== "claiming" + || candidate.liveness !== "not_live" + || candidate.lastAttemptAt === null + || candidate.lastObservedAt <= candidate.lastAttemptAt + ) throw new Error("SESSION_ADOPTION_CLAIM_RECOVERY_NOT_PROVEN"); + const binding = this.findSessionPersonalRuntimeBinding( + parsed.provider, + parsed.providerThreadId, + ); + if (binding !== null && binding.state !== "detached") { + throw new Error("SESSION_ADOPTION_CLAIM_RECOVERY_BINDING_LIVE"); + } + if (parsed.provider === "claude") { + const process = this.readClaudeProcessAuthority({ + providerThreadId: parsed.providerThreadId, + profileId: parsed.profileId, + runtimeScope: "personal", + }); + if (process !== null && process.state !== "released") { + throw new Error("SESSION_ADOPTION_CLAIM_RECOVERY_PROCESS_LIVE"); + } + } + const changed = this.#database.query( + `UPDATE session_adoption_candidates + SET claim_status='pending',fenced_fingerprint=NULL,revision=revision+1 + WHERE provider=? AND provider_thread_id=? AND revision=? + AND claim_status='claiming' AND liveness='not_live'`, + ).run( + parsed.provider, + parsed.providerThreadId, + parsed.expectedRevision, + ); + if (changed.changes !== 1) { + throw new Error("SESSION_ADOPTION_CLAIM_RECOVERY_CONFLICT"); + } + const recovered = this.#database.query( + `SELECT * FROM session_adoption_candidates + WHERE provider=? AND provider_thread_id=?`, + ).get(parsed.provider, parsed.providerThreadId); + if (recovered === null) throw new Error("SESSION_ADOPTION_CANDIDATE_NOT_FOUND"); + return mapSessionAdoptionCandidate(recovered); }); + return recover.immediate(); } - #canReleaseIdleProviderSessionForAccountLogin(input: Readonly<{ + adoptSessionCandidate(input: { + provider: AdoptableProvider; + providerThreadId: string; + expectedCandidateRevision: number; profileId: ProfileId; profileGeneration: number; - provider: "claude" | "devin"; - sessionId: SessionId; - }>): boolean { - const profileId = profileIdSchema.parse(input.profileId); - const profileGeneration = z.number().int().nonnegative() - .max(Number.MAX_SAFE_INTEGER).parse(input.profileGeneration); - const provider = z.enum(["claude", "devin"]).parse(input.provider); - const sessionId = sessionIdSchema.parse(input.sessionId); - return this.#database.query( - `SELECT 1 AS releasable - FROM sessions s - JOIN profiles p ON p.id=s.profile_id - WHERE s.id=? AND s.profile_id=? AND p.process_generation=? - AND p.state!='removed' - AND s.provider_v39=? AND s.provider_thread_id IS NOT NULL - AND s.state='idle' AND s.active_turn_id IS NULL - AND NOT EXISTS( - SELECT 1 FROM mutation_attempts m - LEFT JOIN mutation_resolutions r ON r.attempt_id=m.id - LEFT JOIN session_start_attempts a ON a.attempt_id=m.id - WHERE (m.authority_id=s.id OR a.session_id=s.id) - AND m.state IN ('prepared','effect_started','ambiguous') - AND r.attempt_id IS NULL - ) - AND NOT EXISTS( - SELECT 1 FROM queue_entries q - LEFT JOIN queue_effect_resolutions r ON r.queue_id=q.id - WHERE q.session_id=s.id - AND q.state IN ('pending','dispatching','ambiguous') - AND r.queue_id IS NULL - ) - AND NOT EXISTS( - SELECT 1 FROM provider_interactions i - WHERE i.session_id=s.id - AND i.state IN ('pending','response_prepared','response_written') - ) - AND NOT EXISTS( - SELECT 1 FROM work_attempts w - WHERE w.worker_session_id=s.id - AND w.state IN ('claimed','dispatching','running','recovery_required') - ) - AND NOT EXISTS( - SELECT 1 FROM work_signals w - WHERE w.to_session_id=s.id - AND NOT EXISTS( - SELECT 1 FROM work_signal_receipts r - WHERE r.signal_id=w.id AND r.kind='ack' - ) - ) - LIMIT 1`, - ).get(sessionId, profileId, profileGeneration, provider) !== null; - } - - #hasUnsettledClaudeLoginAuthority(profileId: ProfileId): boolean { - return this.#database.query( - `SELECT 1 AS present FROM mutation_attempts m - LEFT JOIN mutation_resolutions r ON r.attempt_id=m.id - WHERE m.authority_id=? AND m.kind='account.claude-login' - AND m.state IN ('effect_started','ambiguous') - AND r.attempt_id IS NULL - LIMIT 1`, - ).get(profileId) !== null; - } - - #hasUnsettledDevinLoginAuthority(profileId: ProfileId): boolean { - return this.#database.query( - `SELECT 1 AS present FROM mutation_attempts m - LEFT JOIN mutation_resolutions r ON r.attempt_id=m.id - WHERE m.authority_id=? AND m.kind='account.devin-login' - AND m.state IN ('effect_started','ambiguous') - AND r.attempt_id IS NULL - LIMIT 1`, - ).get(profileId) !== null; - } - - #sessionMutationAuthorityTuplesForProfile(profileId: ProfileId): readonly Readonly<{ - attemptId: AttemptId; - originGeneration: number; - provider: Provider; - }>[] { - const parsedProfileId = profileIdSchema.parse(profileId); - const rows = this.#database.query( - `SELECT m.id,m.kind AS mutation_kind,m.authority_id,m.authority_generation, - e.kind AS evidence_kind,e.evidence_json,e.evidence_digest, - a.session_id AS session_start_id - FROM mutation_attempts m - LEFT JOIN mutation_effect_evidence e ON e.attempt_id=m.id - LEFT JOIN session_start_attempts a ON a.attempt_id=m.id - LEFT JOIN mutation_resolutions r ON r.attempt_id=m.id - WHERE m.kind IN ('session.start','session.switch') - AND m.state IN ('effect_started','ambiguous') - AND r.attempt_id IS NULL - ORDER BY m.id`, - ).all() as readonly { - id: string; - mutation_kind: string; - authority_id: string; - authority_generation: number; - evidence_kind: string | null; - evidence_json: string | null; - evidence_digest: string | null; - session_start_id: string | null; - }[]; - const tuples = new Map(); - const add = (tuple: { - attemptId: AttemptId; - originGeneration: number; - provider: Provider; - }): void => { - const key = `${tuple.attemptId}\0${tuple.provider}`; - const existing = tuples.get(key); - if (existing !== undefined && existing.originGeneration !== tuple.originGeneration) { - throw new Error("SESSION_MUTATION_SUCCESSOR_AUTHORITY_AMBIGUOUS"); - } - tuples.set(key, tuple); - }; - for (const raw of rows) { - const attemptId = attemptIdSchema.parse(raw.id); - const mutationKind = z.enum(["session.start", "session.switch"]) - .parse(raw.mutation_kind); - if ( - raw.evidence_json === null - || raw.evidence_digest === null - || raw.evidence_kind !== mutationKind - ) throw new Error("SESSION_MUTATION_SUCCESSOR_EVIDENCE_MISSING"); - const evidence = mutationEffectEvidenceSchema.parse( - JSON.parse(raw.evidence_json) as unknown, - ); + projectId?: ProjectId; + preset: Preset; + requirement: PresetRequirement; + fastEnabled: boolean; + runtimeProfile: ReviewedRuntimeProfile; + providerAccountKey: string; + claudeProcessIdentity?: ClaudeProcessIdentity; + }): SessionAdoptionResult { + const parsed = z.object({ + provider: adoptableProviderSchema, + providerThreadId: providerThreadIdSchema, + expectedCandidateRevision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + profileId: profileIdSchema, + profileGeneration: z.number().int().nonnegative().safe(), + projectId: projectIdSchema.optional(), + preset: presetSchema, + requirement: z.object({ + model: z.string().min(1), + effort: z.enum(["max", "ultra"]), + }).strict(), + fastEnabled: z.boolean(), + runtimeProfile: reviewedRuntimeProfileSchema, + providerAccountKey: providerAccountAuthorityKeySchema, + claudeProcessIdentity: claudeProcessIdentitySchema.optional(), + }).strict().parse(input); + assertPresetSupportedByProvider(parsed.provider, parsed.preset); + const currentRequirement = presetRequirementForContract( + parsed.preset, + currentPresetContract, + ); + if ( + parsed.requirement.model !== currentRequirement.model + || parsed.requirement.effort !== currentRequirement.effort + ) throw new Error("SESSION_ADOPTION_PRESET_REQUIREMENT_MISMATCH"); + if ((parsed.provider === "claude") !== (parsed.claudeProcessIdentity !== undefined)) { + throw new Error("SESSION_ADOPTION_CLAUDE_PROCESS_AUTHORITY_REQUIRED"); + } + if ( + reviewedRuntimeProfileProvider(parsed.runtimeProfile) !== parsed.provider + || parsed.runtimeProfile.profileId !== parsed.profileId + || parsed.runtimeProfile.processGeneration !== parsed.profileGeneration + || parsed.runtimeProfile.preset !== parsed.preset + || (isCodexRuntimeProfile(parsed.runtimeProfile) + ? parsed.runtimeProfile.fast !== parsed.fastEnabled + : parsed.fastEnabled) + ) throw new Error("SESSION_ADOPTION_RUNTIME_PROFILE_MISMATCH"); + assertRuntimeProfileRequirement( + parsed.runtimeProfile, + parsed.preset, + parsed.requirement, + "SESSION_ADOPTION_RUNTIME_PROFILE_MISMATCH", + ); + const now = unixMillisecondsSchema.parse(this.#now()); + const adopt = this.#database.transaction(() => { + const policy = this.readSessionAdoptionPolicy(parsed.provider); if ( - evidence.kind !== mutationKind - || digestJson(evidence) !== sha256Schema.parse(raw.evidence_digest) - ) throw new Error("SESSION_MUTATION_SUCCESSOR_EVIDENCE_MISMATCH"); - const authorityGeneration = z.number().int().nonnegative() - .max(Number.MAX_SAFE_INTEGER).parse(raw.authority_generation); - if (mutationKind === "session.start" && evidence.kind === "session.start") { - const sessionId = sessionIdSchema.parse(raw.session_start_id); - const session = this.requireSession(sessionId); + policy === null + || !policy.enabled + || policy.profileId !== parsed.profileId + ) throw new Error("SESSION_ADOPTION_POLICY_MISMATCH"); + const profileState = this.#database.query( + "SELECT state,process_generation,provider_email FROM profiles WHERE id=?", + ).get(parsed.profileId); + if (profileState === null) throw new Error("SESSION_ADOPTION_PROFILE_NOT_SIGNED_IN"); + const profileAuthority = z.object({ + state: profileStateSchema, + process_generation: z.number().int().nonnegative().safe(), + provider_email: z.string().nullable(), + }).strict().parse(profileState); + if (!profileStateAllowsProviderSessionAuthority( + parsed.provider, + profileAuthority.state, + ) || (parsed.provider === "codex" && profileAuthority.provider_email === null)) { + throw new Error("SESSION_ADOPTION_PROFILE_NOT_SIGNED_IN"); + } + if (profileAuthority.process_generation !== parsed.profileGeneration) { + throw new Error("SESSION_ADOPTION_PROFILE_AUTHORITY_CHANGED"); + } + const candidateRow = this.#database.query( + `SELECT * FROM session_adoption_candidates + WHERE provider=? AND provider_thread_id=?`, + ).get(parsed.provider, parsed.providerThreadId); + if (candidateRow === null) throw new Error("SESSION_ADOPTION_CANDIDATE_NOT_FOUND"); + const candidate = mapSessionAdoptionCandidate(candidateRow); + if (candidate.revision !== parsed.expectedCandidateRevision) { + throw new Error("SESSION_ADOPTION_CANDIDATE_STALE"); + } + const claimedClaudeProcess = parsed.provider === "claude" + ? this.readClaudeProcessAuthority({ + providerThreadId: parsed.providerThreadId, + profileId: parsed.profileId, + runtimeScope: "personal", + }) + : null; + const candidateHasExactClaim = claimedClaudeProcess !== null + && claimedClaudeProcess.state === "claimed" + && claimedClaudeProcess.profileId === parsed.profileId + && parsed.claudeProcessIdentity !== undefined + && claimedClaudeProcess.identity.pid === parsed.claudeProcessIdentity.pid + && claimedClaudeProcess.identity.pidDomain === parsed.claudeProcessIdentity.pidDomain + && claimedClaudeProcess.identity.procStart === parsed.claudeProcessIdentity.procStart; + if (candidate.status === "fenced") throw new Error("SESSION_ADOPTION_CANDIDATE_FENCED"); + if (candidate.status === "adopted") { + throw new Error("SESSION_ADOPTION_CANDIDATE_ALREADY_ADOPTED"); + } + if (candidate.status !== "claiming") { + throw new Error("SESSION_ADOPTION_CANDIDATE_NOT_CLAIMED"); + } + if (parsed.provider === "claude" && !candidateHasExactClaim) { + throw new Error("SESSION_ADOPTION_CLAUDE_PROCESS_AUTHORITY_MISMATCH"); + } + if (candidate.liveness !== "not_live") { + throw new Error("SESSION_ADOPTION_SOURCE_STILL_LIVE"); + } + + const priorBindingRow = this.#database.query( + `SELECT * FROM session_personal_runtime_bindings + WHERE provider=? AND provider_thread_id=?`, + ).get(parsed.provider, parsed.providerThreadId); + let priorBinding = priorBindingRow === null + ? null + : mapSessionPersonalRuntimeBinding(priorBindingRow); + if (priorBinding !== null) { + const priorSession = this.requireSession(priorBinding.sessionId); if ( - profileIdSchema.parse(raw.authority_id) !== session.profileId - || (evidence.runtimeProfile !== undefined && ( - evidence.runtimeProfile.profileId !== session.profileId - || evidence.runtimeProfile.processGeneration !== authorityGeneration - || reviewedRuntimeProfileProvider(evidence.runtimeProfile) !== session.provider - )) - ) throw new Error("SESSION_MUTATION_SUCCESSOR_START_AUTHORITY_MISMATCH"); - if (session.profileId === parsedProfileId) { - add({ - attemptId, - originGeneration: authorityGeneration, - provider: session.provider, - }); + priorBinding.state === "detached" + && ( + priorSession.provider !== parsed.provider + || priorSession.providerThreadId !== parsed.providerThreadId + ) + ) { + // A completed provider switch leaves its runtime-profile and event + // history on the old session. Its detached binding is no longer + // current authority and must not reserve the abandoned identity. + const deleted = this.#database.query( + `DELETE FROM session_personal_runtime_bindings + WHERE session_id=? AND provider=? AND provider_thread_id=? + AND state='detached' AND revision=?`, + ).run( + priorBinding.sessionId, + priorBinding.provider, + priorBinding.providerThreadId, + priorBinding.revision, + ); + if (deleted.changes !== 1) { + throw new Error("SESSION_ADOPTION_BINDING_CONFLICT"); + } + priorBinding = null; + } else { + if (priorSession.profileId !== parsed.profileId) { + throw new Error("SESSION_ADOPTION_BINDING_COLLISION"); + } + if (priorBinding.state === "detaching") { + throw new Error("SESSION_ADOPTION_BINDING_DETACHING"); + } } - continue; } - if (mutationKind !== "session.switch" || evidence.kind !== "session.switch") { - throw new Error("SESSION_MUTATION_SUCCESSOR_KIND_MISMATCH"); + if (priorBinding === null) { + const collisions = this.#database.query( + `SELECT id,profile_id FROM sessions + WHERE provider=? AND provider_thread_id=? ORDER BY id LIMIT 2`, + ).all(parsed.provider, parsed.providerThreadId).map((row) => z.object({ + id: sessionIdSchema, + profile_id: profileIdSchema, + }).strict().parse(row)); + // Provider thread ids are scoped by provider home. A same-profile row + // from the isolated managed home is still a different authority and + // must never be rebound merely because its opaque id happens to match. + if (collisions.length > 0) throw new Error("SESSION_ADOPTION_SESSION_COLLISION"); } - const switchSessionId = sessionIdSchema.parse(raw.authority_id); - this.requireSession(switchSessionId); - if (authorityGeneration !== evidence.targetProcessGeneration) { - throw new Error("SESSION_MUTATION_SUCCESSOR_SWITCH_AUTHORITY_MISMATCH"); + + const requestedProjectId = parsed.projectId ?? candidate.projectId ?? undefined; + const projectId = requestedProjectId !== undefined + && this.#database.query("SELECT 1 FROM projects WHERE id=?").get(requestedProjectId) !== null + ? requestedProjectId + : undefined; + if (parsed.projectId !== undefined && projectId === undefined) { + throw new Error("SESSION_ADOPTION_PROJECT_NOT_FOUND"); } - if (evidence.sourceProfileId === parsedProfileId) { - add({ - attemptId, - originGeneration: evidence.sourceProcessGeneration, - provider: evidence.sourceProvider, - }); + let session = this.#upsertProviderSessionLocked({ + profileId: parsed.profileId, + provider: parsed.provider, + providerThreadId: parsed.providerThreadId, + ...(projectId === undefined ? {} : { projectId }), + title: candidate.title, + preset: parsed.preset, + presetContract: currentPresetContract, + fastEnabled: parsed.fastEnabled, + state: candidate.providerState, + ...(candidate.activeTurnId === null ? {} : { activeTurnId: candidate.activeTurnId }), + ...(candidate.providerUpdatedAt === null + ? {} + : { providerUpdatedAt: candidate.providerUpdatedAt }), + runtimeScope: "personal", + providerAccountKey: parsed.providerAccountKey, + }); + if (session.state === "recovery_required") { + throw new Error("SESSION_ADOPTION_SESSION_RECOVERY_REQUIRED"); } - if (evidence.targetProfileId === parsedProfileId) { - add({ - attemptId, - originGeneration: evidence.targetProcessGeneration, - provider: evidence.targetProvider, - }); + if ( + priorBinding?.state !== "active" + && ( + session.preset !== parsed.preset + || this.#requireSessionPresetBinding(session.id).contract !== currentPresetContract + || session.fastEnabled !== parsed.fastEnabled + ) + ) { + const updated = this.#database.query( + `UPDATE sessions + SET preset=?,preset_contract=?,fast_enabled=?,revision=revision+1, + updated_at=MAX(updated_at,?) + WHERE id=? AND revision=?`, + ).run( + presetTiers[parsed.preset], + currentPresetContract, + parsed.fastEnabled ? 1 : 0, + now, + session.id, + session.revision, + ); + if (updated.changes !== 1) throw new Error("SESSION_ADOPTION_SESSION_CONFLICT"); + session = this.requireSession(session.id); } - } - return [...tuples.values()]; - } + this.#assertSessionRuntimeProfileContract(session.id, parsed.runtimeProfile); - #recordSessionMutationAuthoritySuccessors(input: Readonly<{ - fromGeneration: number; - now: number; - profileId: ProfileId; - }>): void { - const tuples = this.#sessionMutationAuthorityTuplesForProfile(input.profileId); - for (const tuple of tuples) { - if (!this.isSessionMutationProviderAuthorityCurrent({ - attemptId: tuple.attemptId, - profileId: input.profileId, - provider: tuple.provider, - originGeneration: tuple.originGeneration, - })) throw new Error("SESSION_MUTATION_SUCCESSOR_AUTHORITY_MISMATCH"); - const inserted = this.#database.query( - `INSERT INTO session_mutation_authority_rebinds_v39( - attempt_id,profile_id,provider,from_generation,to_generation,recorded_at - ) VALUES (?,?,?,?,?,?)`, - ).run( - tuple.attemptId, - input.profileId, - tuple.provider, - input.fromGeneration, - input.fromGeneration + 1, - input.now, - ); - if (inserted.changes !== 1) throw new Error("SESSION_MUTATION_SUCCESSOR_CAS_CONFLICT"); - // Keep the v35 compatibility ledger current for its two representable - // providers. Devin authority exists only in the widened v39 ledger. - if (tuple.provider !== "devin") { - const compatibility = this.#database.query( - `INSERT INTO session_mutation_authority_rebinds( - attempt_id,profile_id,provider,from_generation,to_generation,recorded_at - ) VALUES (?,?,?,?,?,?)`, + if (priorBinding === null) { + this.#database.query( + `INSERT INTO session_personal_runtime_bindings( + session_id,provider,provider_thread_id,state,revision, + adopted_at,updated_at,detached_at + ) VALUES (?,?,?,'active',1,?,?,NULL)`, ).run( - tuple.attemptId, - input.profileId, - tuple.provider, - input.fromGeneration, - input.fromGeneration + 1, - input.now, + session.id, + parsed.provider, + parsed.providerThreadId, + now, + now, ); - if (compatibility.changes !== 1) { - throw new Error("SESSION_MUTATION_SUCCESSOR_COMPATIBILITY_CAS_CONFLICT"); - } + } else if (priorBinding.state === "detached") { + const rebound = this.#database.query( + `UPDATE session_personal_runtime_bindings + SET state='active',revision=revision+1,updated_at=MAX(updated_at,?),detached_at=NULL + WHERE session_id=? AND revision=? AND state='detached'`, + ).run(now, priorBinding.sessionId, priorBinding.revision); + if (rebound.changes !== 1) throw new Error("SESSION_ADOPTION_BINDING_CONFLICT"); } - } - } - - nextProfileGeneration(profileId: ProfileId): ProfileRecord { - const now = this.#now(); - const update = this.#database.transaction(() => { - const current = mapProfile(this.#database.query("SELECT * FROM profiles WHERE id = ? AND state != 'removed'").get(profileId)); - if (this.#hasUnsettledClaudeLoginAuthority(current.id)) { - throw new Error("CLAUDE_LOGIN_AUTHORITY_UNSETTLED"); + this.#database.query( + `UPDATE session_adoption_candidates + SET claim_status='adopted',fenced_fingerprint=NULL,revision=revision+1, + last_attempt_at=MAX(COALESCE(last_attempt_at,0),?) + WHERE provider=? AND provider_thread_id=?`, + ).run(now, parsed.provider, parsed.providerThreadId); + this.#database.query("UPDATE sessions SET archived_at=NULL WHERE id=?").run(session.id); + this.#database.query( + `INSERT INTO session_conversation_automation( + session_id,provider_thread_id,enabled_at + ) VALUES (?,?,?) + ON CONFLICT(session_id) DO NOTHING`, + ).run(session.id, parsed.providerThreadId, now); + const automation = this.#database.query( + `SELECT 1 FROM session_conversation_automation + WHERE session_id=? AND provider_thread_id=?`, + ).get(session.id, parsed.providerThreadId); + if (automation === null) { + throw new Error("SESSION_ADOPTION_CONVERSATION_AUTOMATION_BINDING_CONFLICT"); } - if (this.#hasUnsettledDevinLoginAuthority(current.id)) { - throw new Error("DEVIN_LOGIN_AUTHORITY_UNSETTLED"); + if (parsed.claudeProcessIdentity !== undefined) { + this.#bindClaudeProcessAuthorityLocked({ + sessionId: session.id, + providerThreadId: parsed.providerThreadId, + profileId: parsed.profileId, + runtimeScope: "personal", + identity: parsed.claudeProcessIdentity, + }); } - if (this.#sessionMutationAuthorityTuplesForProfile(current.id).length > 0) { - throw new Error("SESSION_MUTATION_AUTHORITY_UNSETTLED"); + if (!this.sessionAccountAuthorityMatches(session.id, parsed.profileId)) { + throw new Error("SESSION_ADOPTION_ACCOUNT_AUTHORITY_MISMATCH"); } - this.#database.query("UPDATE profiles SET process_generation = ?, updated_at = ? WHERE id = ? AND process_generation = ?").run(current.processGeneration + 1, now, profileId, current.processGeneration); + this.#insertSessionRuntimeProfile({ + sessionId: session.id, + sourceKind: "session_start", + sourceId: `adopt_${parsed.expectedCandidateRevision}_${createHash("sha256") + .update(`${parsed.provider}:${parsed.providerThreadId}`) + .digest("hex")}`, + profile: parsed.runtimeProfile, + }, now); + + const binding = this.readSessionPersonalRuntimeBinding(session.id); + const adoptedCandidateRow = this.#database.query( + `SELECT * FROM session_adoption_candidates + WHERE provider=? AND provider_thread_id=?`, + ).get(parsed.provider, parsed.providerThreadId); + if (binding === null || adoptedCandidateRow === null) { + throw new Error("SESSION_ADOPTION_COMMIT_INCOMPLETE"); + } + return { + session: this.requireSession(session.id), + candidate: mapSessionAdoptionCandidate(adoptedCandidateRow), + binding, + }; }); - update.immediate(); - return this.requireProfile(profileId); + return adopt.immediate(); } - advanceProfileGeneration(profileId: ProfileId, expectedGeneration: number): ProfileRecord { - return this.#advanceProfileGeneration(profileId, expectedGeneration).profile; + readSessionPersonalRuntimeBinding( + sessionId: SessionId, + includeDetached = false, + ): SessionPersonalRuntimeBindingRecord | null { + const parsedSessionId = sessionIdSchema.parse(sessionId); + const row = this.#database.query( + `SELECT * FROM session_personal_runtime_bindings + WHERE session_id=?${includeDetached ? "" : " AND state='active'"}`, + ).get(parsedSessionId); + return row === null ? null : mapSessionPersonalRuntimeBinding(row); } - advanceProfileGenerationWithWorkRetirement( - profileId: ProfileId, - expectedGeneration: number, - workStore: WorkStore, - options: Readonly<{ preserveSessionMutationAuthorities?: boolean }> = {}, - ): ProfileAuthorityChangeResult { - return this.#advanceProfileGeneration(profileId, expectedGeneration, workStore, options); + findSessionPersonalRuntimeBinding( + provider: Provider, + providerThreadId: string, + ): SessionPersonalRuntimeBindingRecord | null { + const parsedProvider = providerSchema.parse(provider); + const parsedProviderThreadId = providerThreadIdSchema.parse(providerThreadId); + const row = this.#database.query( + `SELECT * FROM session_personal_runtime_bindings + WHERE provider=? AND provider_thread_id=?`, + ).get(parsedProvider, parsedProviderThreadId); + return row === null ? null : mapSessionPersonalRuntimeBinding(row); } - #advanceProfileGeneration( - profileId: ProfileId, - expectedGeneration: number, - workStore?: WorkStore, - options: Readonly<{ preserveSessionMutationAuthorities?: boolean }> = {}, - ): ProfileAuthorityChangeResult { - const now = this.#now(); - const advance = this.#database.transaction(() => { - const current = mapProfile(this.#database.query("SELECT * FROM profiles WHERE id=? AND state!='removed'").get(profileId)); - if (current.processGeneration !== expectedGeneration) { - throw new Error("Profile generation authority changed."); - } - if (this.#hasUnsettledClaudeLoginAuthority(current.id)) { - throw new Error("CLAUDE_LOGIN_AUTHORITY_UNSETTLED"); + stageClaudeProcessLaunchIntent(input: { + providerThreadId: string; + profileId: ProfileId; + profileGeneration: number; + runtimeScope: z.infer; + providerAccountKey: string; + sessionId?: SessionId; + }): ClaudeProcessLaunchIntentRecord { + const parsed = z.object({ + providerThreadId: sessionAdoptionProviderThreadIdSchema, + profileId: profileIdSchema, + profileGeneration: z.number().int().nonnegative().safe(), + runtimeScope: claudeProcessRuntimeScopeSchema, + providerAccountKey: providerAccountAuthorityKeySchema.refine( + (value) => providerAccountAuthorityKeyMatchesProvider("claude", value), + ), + sessionId: sessionIdSchema.optional(), + }).strict().parse(input); + const stage = this.#database.transaction(() => { + const profile = this.requireProfileById(parsed.profileId); + if ( + profile.processGeneration !== parsed.profileGeneration + || !profileStateAllowsProviderSessionAuthority("claude", profile.state) + ) throw new Error("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_PROFILE_STALE"); + const revocation = this.readProviderRuntimeAccountRevocation({ + profileId: parsed.profileId, + provider: "claude", + runtimeScope: parsed.runtimeScope, + }); + if ( + revocation?.profileGeneration === parsed.profileGeneration + && ( + revocation.state === "releasing" + || revocation.currentAccountKey === null + || revocation.currentAccountKey !== parsed.providerAccountKey + ) + ) throw new Error("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_ACCOUNT_STALE"); + if (parsed.sessionId === undefined) { + const policy = this.readSessionAdoptionPolicy("claude"); + const candidateRow = this.#database.query( + `SELECT * FROM session_adoption_candidates + WHERE provider='claude' AND provider_thread_id=?`, + ).get(parsed.providerThreadId); + const candidate = candidateRow === null + ? null + : mapSessionAdoptionCandidate(candidateRow); + const priorBinding = this.findSessionPersonalRuntimeBinding( + "claude", + parsed.providerThreadId, + ); + if ( + parsed.runtimeScope !== "personal" + || policy === null + || !policy.enabled + || policy.profileId !== parsed.profileId + || candidate === null + || candidate.status !== "claiming" + || candidate.liveness !== "not_live" + || (priorBinding !== null && priorBinding.state !== "detached") + ) { + throw new Error( + "SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_ADOPTION_AUTHORITY_MISMATCH", + ); + } + } else { + const sessionRow = this.#database.query( + "SELECT * FROM sessions WHERE id=?", + ).get(parsed.sessionId); + if (sessionRow === null) { + throw new Error("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_SESSION_MISSING"); + } + const session = mapSession(sessionRow); + const personalBinding = this.readSessionPersonalRuntimeBinding( + parsed.sessionId, + true, + ); + if (personalBinding?.state === "detaching") { + throw new Error("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_SESSION_DETACHING"); + } + const providerAuthority = this.readSessionProviderAccountAuthority( + parsed.sessionId, + ); + const exactClaudeSessionAuthority = providerAuthority !== null + && providerAuthority.provider === "claude" + && providerAuthority.runtimeScope === parsed.runtimeScope + && providerAuthority.accountKey === parsed.providerAccountKey + && this.sessionAccountAuthorityMatches(session.id, parsed.profileId); + const existingSessionResume = session.profileId === parsed.profileId + && session.provider === "claude" + && session.providerThreadId === parsed.providerThreadId + && session.state !== "starting" + && session.state !== "terminal" + && exactClaudeSessionAuthority; + + const startRows = this.#database.query( + `SELECT m.authority_id,m.authority_generation, + e.evidence_json,e.evidence_digest + FROM session_start_attempts sa + JOIN mutation_attempts m ON m.id=sa.attempt_id + JOIN mutation_effect_evidence e ON e.attempt_id=m.id + LEFT JOIN mutation_resolutions r ON r.attempt_id=m.id + WHERE sa.session_id=? AND m.kind='session.start' + AND m.state='effect_started' AND r.attempt_id IS NULL + ORDER BY m.created_at,m.id LIMIT 2`, + ).all(parsed.sessionId); + const startAuthority = startRows.length === 1 + ? z.object({ + authority_id: profileIdSchema, + authority_generation: z.number().int().nonnegative().safe(), + evidence_json: z.string(), + evidence_digest: sha256Schema, + }).strict().parse(startRows[0]) + : null; + const startEvidence = (() => { + if (startAuthority === null) return null; + try { + const evidence = mutationEffectEvidenceSchema.parse( + JSON.parse(startAuthority.evidence_json) as unknown, + ); + return evidence.kind === "session.start" + && digestJson(evidence) === startAuthority.evidence_digest + ? evidence + : null; + } catch { + return null; + } + })(); + const startRuntimeProfile = startEvidence?.kind === "session.start" + ? startEvidence.runtimeProfile + : undefined; + const startRuntimeIsManagedClaude = startRuntimeProfile !== undefined + && !isCodexRuntimeProfile(startRuntimeProfile) + && !isDevinRuntimeProfile(startRuntimeProfile) + && ("configHome" in startRuntimeProfile + ? startRuntimeProfile.configHome === "isolated" + : startRuntimeProfile.isolatedConfigDir); + const sessionStartPlaceholder = parsed.runtimeScope === "managed" + && z.string().uuid().safeParse(parsed.providerThreadId).success + && session.profileId === parsed.profileId + && session.provider === "claude" + && session.providerThreadId === undefined + && session.state === "starting" + && exactClaudeSessionAuthority + && startAuthority !== null + && startAuthority.authority_id === parsed.profileId + && startAuthority.authority_generation === parsed.profileGeneration + && startEvidence?.kind === "session.start" + && startEvidence.projectId === session.projectId + && startRuntimeProfile !== undefined + && reviewedRuntimeProfileProvider(startRuntimeProfile) === "claude" + && startRuntimeProfile.profileId === parsed.profileId + && startRuntimeProfile.processGeneration === parsed.profileGeneration + && startRuntimeProfile.preset === session.preset + && startRuntimeIsManagedClaude; + + const switchRows = this.#database.query( + `SELECT m.id AS attempt_id,m.authority_generation, + e.evidence_json,e.evidence_digest, + t.provider_thread_id AS target_provider_thread_id, + EXISTS( + SELECT 1 FROM session_provider_switch_target_releases tr + WHERE tr.attempt_id=m.id + ) AS target_released + FROM mutation_attempts m + JOIN mutation_effect_evidence e ON e.attempt_id=m.id + LEFT JOIN session_provider_switch_targets t ON t.attempt_id=m.id + LEFT JOIN mutation_resolutions r ON r.attempt_id=m.id + WHERE m.authority_id=? AND m.kind='session.switch' + AND m.state='effect_started' AND r.attempt_id IS NULL + ORDER BY m.created_at,m.id LIMIT 2`, + ).all(parsed.sessionId); + const switchAuthority = switchRows.length === 1 + ? z.object({ + attempt_id: attemptIdSchema, + authority_generation: z.number().int().nonnegative().safe(), + evidence_json: z.string(), + evidence_digest: sha256Schema, + target_provider_thread_id: providerThreadIdSchema.nullable(), + target_released: z.number().int().min(0).max(1), + }).strict().parse(switchRows[0]) + : null; + const switchEvidence = (() => { + if (switchAuthority === null) return null; + try { + const evidence = mutationEffectEvidenceSchema.parse( + JSON.parse(switchAuthority.evidence_json) as unknown, + ); + return evidence.kind === "session.switch" + && digestJson(evidence) === switchAuthority.evidence_digest + ? evidence + : null; + } catch { + return null; + } + })(); + const switchRuntimeProfile = switchEvidence?.kind === "session.switch" + ? switchEvidence.runtimeProfile + : undefined; + const switchRuntimeIsManagedClaude = switchRuntimeProfile !== undefined + && !isCodexRuntimeProfile(switchRuntimeProfile) + && !isDevinRuntimeProfile(switchRuntimeProfile) + && ("configHome" in switchRuntimeProfile + ? switchRuntimeProfile.configHome === "isolated" + : switchRuntimeProfile.isolatedConfigDir); + const sessionSwitchTarget = parsed.runtimeScope === "managed" + && z.string().uuid().safeParse(parsed.providerThreadId).success + && switchAuthority !== null + && switchAuthority.target_provider_thread_id === null + && switchAuthority.target_released === 0 + && switchAuthority.authority_generation === parsed.profileGeneration + && switchEvidence?.kind === "session.switch" + && switchEvidence.sourceProfileId === session.profileId + && switchEvidence.sourceProvider === session.provider + && switchEvidence.sourceProviderThreadId === session.providerThreadId + && switchEvidence.sourcePreset === session.preset + && switchEvidence.targetProfileId === parsed.profileId + && switchEvidence.targetProcessGeneration === parsed.profileGeneration + && switchEvidence.targetProvider === "claude" + && switchEvidence.targetProviderAccountKey === parsed.providerAccountKey + && switchEvidence.targetPreset === "fable-max" + && !sessionProviderSwitchTargetAliasesSource( + switchEvidence, + parsed.providerThreadId, + ) + && switchRuntimeProfile !== undefined + && reviewedRuntimeProfileProvider(switchRuntimeProfile) === "claude" + && switchRuntimeProfile.profileId === parsed.profileId + && switchRuntimeProfile.processGeneration === parsed.profileGeneration + && switchRuntimeProfile.preset === switchEvidence.targetPreset + && switchRuntimeIsManagedClaude + && this.sessionAccountAuthorityMatches( + session.id, + switchEvidence.sourceProfileId, + ) + && this.isSessionMutationProviderAuthorityCurrent({ + attemptId: switchAuthority.attempt_id, + profileId: switchEvidence.sourceProfileId, + provider: switchEvidence.sourceProvider, + originGeneration: switchEvidence.sourceProcessGeneration, + }) + && this.isSessionMutationProviderAuthorityCurrent({ + attemptId: switchAuthority.attempt_id, + profileId: switchEvidence.targetProfileId, + provider: switchEvidence.targetProvider, + originGeneration: switchEvidence.targetProcessGeneration, + }); + if (!existingSessionResume && !sessionStartPlaceholder && !sessionSwitchTarget) { + throw new Error( + "SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_SESSION_AUTHORITY_MISMATCH", + ); + } } - if (this.#hasUnsettledDevinLoginAuthority(current.id)) { - throw new Error("DEVIN_LOGIN_AUTHORITY_UNSETTLED"); + const key = { + providerThreadId: parsed.providerThreadId, + profileId: parsed.profileId, + runtimeScope: parsed.runtimeScope, + } as const; + const processAuthority = this.readClaudeProcessAuthority(key); + if (processAuthority !== null && processAuthority.state !== "released") { + throw new Error("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_PROCESS_LIVE"); } - const sessionMutationAuthorities = this.#sessionMutationAuthorityTuplesForProfile(current.id); - if (sessionMutationAuthorities.length > 0) { - if (options.preserveSessionMutationAuthorities !== true) { - throw new Error("SESSION_MUTATION_AUTHORITY_UNSETTLED"); - } - this.#recordSessionMutationAuthoritySuccessors({ - fromGeneration: expectedGeneration, - now, - profileId: current.id, - }); + const current = this.readClaudeProcessLaunchIntent(key); + if (current !== null) { + throw new Error("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_CONFLICT"); } - const activeLogin = this.#database.query(`SELECT attempt_id,process_generation - FROM provider_login_authorities - WHERE profile_id=? AND state='active'`).all(profileId) as { - attempt_id: string; - process_generation: number; - }[]; - if (activeLogin.length > 1) throw new Error("LOGIN_GENERATION_AUTHORITY_AMBIGUOUS"); - if (activeLogin.length === 1) { - if ( - current.state !== "login_pending" - || activeLogin[0]?.process_generation !== expectedGeneration - ) throw new Error("LOGIN_GENERATION_AUTHORITY_MISMATCH"); - const rebound = this.#database.query(`UPDATE provider_login_authorities - SET process_generation=?,updated_at=MAX(updated_at,?) - WHERE attempt_id=? AND process_generation=? AND state='active'`).run( - expectedGeneration + 1, - now, - activeLogin[0].attempt_id, - expectedGeneration, - ); - if (rebound.changes !== 1) throw new Error("LOGIN_GENERATION_AUTHORITY_CAS_CONFLICT"); + if ( + parsed.sessionId !== undefined + && this.#database.query( + "SELECT 1 FROM session_claude_process_launch_intents WHERE session_id=? LIMIT 1", + ).get(parsed.sessionId) !== null + ) throw new Error("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_SESSION_CONFLICT"); + const now = unixMillisecondsSchema.parse(this.#now()); + const intentId = randomUUID(); + const inserted = this.#database.query( + `INSERT INTO session_claude_process_launch_intents( + intent_id,provider_thread_id,profile_id,profile_generation, + runtime_scope,provider_account_key,session_id,revision,staged_at,updated_at + ) VALUES (?,?,?,?,?,?,?,1,?,?)`, + ).run( + intentId, + parsed.providerThreadId, + parsed.profileId, + parsed.profileGeneration, + parsed.runtimeScope, + parsed.providerAccountKey, + parsed.sessionId ?? null, + now, + now, + ); + if (inserted.changes !== 1) { + throw new Error("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_CONFLICT"); } - const state = current.state === "login_pending" && activeLogin.length === 0 - ? "recovery_required" - : current.state; - const affectedWorkIds = workStore?.prepareProfileAuthorityChange( - profileId, - expectedGeneration, - ) ?? []; - const result = this.#database - .query("UPDATE profiles SET process_generation = ?, state=?, updated_at = ? WHERE id = ? AND process_generation = ? AND state != 'removed'") - .run(expectedGeneration + 1, state, now, profileId, expectedGeneration); - if (result.changes !== 1) throw new Error("Profile generation authority changed."); - return [...affectedWorkIds]; + const staged = this.readClaudeProcessLaunchIntent(key); + if ( + staged?.intentId !== intentId + || staged.providerAccountKey !== parsed.providerAccountKey + ) { + throw new Error("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_STAGE_INCOMPLETE"); + } + return staged; }); - const affectedWorkIds = advance.immediate(); - return { profile: this.requireProfile(profileId), affectedWorkIds }; + return stage.immediate(); } - setProfileState(profileId: ProfileId, expectedGeneration: number, state: z.infer, identity?: { email?: string; plan?: string }): boolean { - return this.#setProfileState(profileId, expectedGeneration, state, identity).changed; + readClaudeProcessLaunchIntent( + input: ClaudeProcessAuthorityKey, + ): ClaudeProcessLaunchIntentRecord | null { + const parsed = claudeProcessAuthorityKeySchema.parse(input); + const row = this.#database.query( + `SELECT * FROM session_claude_process_launch_intents + WHERE runtime_scope=? AND profile_id=? AND provider_thread_id=?`, + ).get(parsed.runtimeScope, parsed.profileId, parsed.providerThreadId); + return row === null ? null : mapSessionClaudeProcessLaunchIntent(row); } - setProfileStateWithWorkRetirement( - profileId: ProfileId, - expectedGeneration: number, - state: z.infer, - workStore: WorkStore, - identity?: { email?: string; plan?: string }, - ): ProfileStateChangeResult { - const result = this.#setProfileState( - profileId, - expectedGeneration, - state, - identity, - workStore, - ); - return { - ...result, - profile: this.requireProfileById(profileId, { includeRemoved: true }), - }; + readClaudeProcessLaunchIntentForSession( + sessionId: SessionId, + ): ClaudeProcessLaunchIntentRecord | null { + const parsedSessionId = sessionIdSchema.parse(sessionId); + const row = this.#database.query( + `SELECT * FROM session_claude_process_launch_intents + WHERE session_id=?`, + ).get(parsedSessionId); + return row === null ? null : mapSessionClaudeProcessLaunchIntent(row); } - #closeRecoverableAccountRateLimitResetIdentityAttempts(input: { - profileId: ProfileId; - accountFingerprint: string; - selection: "matching" | "different"; - now: number; - }): void { - const fingerprintPredicate = input.selection === "matching" ? "=" : "!="; - this.#database.query( - `UPDATE account_rate_limit_reset_attempts - SET state='ambiguous',updated_at=MAX(updated_at,?) - WHERE profile_id=? AND account_fingerprint${fingerprintPredicate}? - AND state='effect_started'`, - ).run(input.now, input.profileId, input.accountFingerprint); - this.#database.query( - `UPDATE account_rate_limit_reset_attempts - SET state='closed',local_resolution='account_identity_changed', - updated_at=MAX(updated_at,?) - WHERE profile_id=? AND account_fingerprint${fingerprintPredicate}? - AND state IN ('prepared','ambiguous','retryable')`, - ).run(input.now, input.profileId, input.accountFingerprint); + listClaudeProcessLaunchIntents( + limit = 500, + ): readonly ClaudeProcessLaunchIntentRecord[] { + const parsedLimit = z.number().int().positive().max(500).parse(limit); + return this.#database.query( + `SELECT * FROM session_claude_process_launch_intents + ORDER BY staged_at,intent_id LIMIT ?`, + ).all(parsedLimit).map(mapSessionClaudeProcessLaunchIntent); } - #setProfileState( + profileHasClaudeProcessLaunchIntents( profileId: ProfileId, - expectedGeneration: number, - state: z.infer, - identity?: { email?: string; plan?: string }, - workStore?: WorkStore, - ): Omit { - const now = this.#now(); - const update = this.#database.transaction(() => { - const current = this.#database.query( - "SELECT process_generation,state,provider_email FROM profiles WHERE id=? AND state!='removed'", - ).get(profileId) as { - process_generation: number; - provider_email: string | null; - state: z.infer; - } | null; + profileGeneration?: number, + ): boolean { + const parsedProfileId = profileIdSchema.parse(profileId); + if (profileGeneration === undefined) { + return this.#database.query( + `SELECT 1 FROM session_claude_process_launch_intents + WHERE profile_id=? LIMIT 1`, + ).get(parsedProfileId) !== null; + } + const parsedGeneration = z.number().int().nonnegative().safe().parse(profileGeneration); + return this.#database.query( + `SELECT 1 FROM session_claude_process_launch_intents + WHERE profile_id=? AND profile_generation=? LIMIT 1`, + ).get(parsedProfileId, parsedGeneration) !== null; + } + + cancelClaudeProcessLaunchIntent(input: { + providerThreadId: string; + profileId: ProfileId; + profileGeneration: number; + runtimeScope: z.infer; + intentId: string; + expectedRevision: number; + }): ClaudeProcessLaunchIntentRecord { + const parsed = z.object({ + providerThreadId: sessionAdoptionProviderThreadIdSchema, + profileId: profileIdSchema, + profileGeneration: z.number().int().nonnegative().safe(), + runtimeScope: claudeProcessRuntimeScopeSchema, + intentId: z.string().uuid(), + expectedRevision: z.number().int().positive().safe(), + }).strict().parse(input); + const cancel = this.#database.transaction(() => { + const current = this.readClaudeProcessLaunchIntent({ + providerThreadId: parsed.providerThreadId, + profileId: parsed.profileId, + runtimeScope: parsed.runtimeScope, + }); if ( current === null - || current.process_generation !== expectedGeneration - || (current.state === "recovery_required" && state !== "recovery_required") - ) { - return { affectedWorkIds: [] as string[], changed: false }; + || current.intentId !== parsed.intentId + || current.profileGeneration !== parsed.profileGeneration + || current.revision !== parsed.expectedRevision + ) throw new Error("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_CONFLICT"); + const deleted = this.#database.query( + `DELETE FROM session_claude_process_launch_intents + WHERE runtime_scope=? AND profile_id=? AND provider_thread_id=? + AND profile_generation=? AND intent_id=? AND revision=?`, + ).run( + parsed.runtimeScope, + parsed.profileId, + parsed.providerThreadId, + parsed.profileGeneration, + parsed.intentId, + parsed.expectedRevision, + ); + if (deleted.changes !== 1) { + throw new Error("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_CONFLICT"); } - const policy = this.requireAccountRateLimitResetPolicy(profileId); - const nextAccountFingerprint = state === "signed_in" && identity?.email !== undefined - ? canonicalAccountFingerprint(identity.email) - : null; - const previousProviderFingerprint = current.provider_email === null - ? null - : canonicalAccountFingerprint(current.provider_email); - const changedProviderIdentity = nextAccountFingerprint !== null - && previousProviderFingerprint !== null - && previousProviderFingerprint !== nextAccountFingerprint; - const changedPolicyIdentity = nextAccountFingerprint !== null - && policy.accountFingerprint !== null - && policy.accountFingerprint !== nextAccountFingerprint; - const affectedWorkIds = state === "signed_in" - ? [] - : [...(workStore?.prepareProfileAuthorityChange( - profileId, - expectedGeneration, - "codex", - ) ?? [])]; - const result = this.#database.query( - `UPDATE profiles - SET state=?,provider_email=?,provider_plan=?,updated_at=? - WHERE id=? - AND process_generation=? - AND state!='removed' - AND (state!='recovery_required' OR ?='recovery_required')`, - ) - .run(state, identity?.email ?? null, identity?.plan ?? null, now, profileId, expectedGeneration, state); - if (result.changes !== 1) throw new Error("Profile state authority changed."); - if (changedProviderIdentity || changedPolicyIdentity) { - if (policy.accountFingerprint !== null) { - this.#closeRecoverableAccountRateLimitResetIdentityAttempts({ - profileId, - accountFingerprint: policy.accountFingerprint, - selection: "matching", - now, - }); + return current; + }); + return cancel.immediate(); + } + + readClaudeProcessAuthority( + input: ClaudeProcessAuthorityKey, + ): ClaudeProcessAuthorityRecord | null { + const parsed = claudeProcessAuthorityKeySchema.parse(input); + const row = this.#database.query( + `SELECT * FROM session_claude_process_authorities + WHERE runtime_scope=? AND profile_id=? AND provider_thread_id=?`, + ).get(parsed.runtimeScope, parsed.profileId, parsed.providerThreadId); + return row === null ? null : mapSessionClaudeProcessAuthority(row); + } + + readSessionClaudeProcessAuthority( + sessionId: SessionId, + includeReleased = false, + ): ClaudeProcessAuthorityRecord | null { + const parsedSessionId = sessionIdSchema.parse(sessionId); + const row = this.#database.query( + `SELECT * FROM session_claude_process_authorities + WHERE session_id=?${includeReleased ? "" : " AND state!='released'"} + ORDER BY recorded_at DESC,provider_thread_id LIMIT 1`, + ).get(parsedSessionId); + return row === null ? null : mapSessionClaudeProcessAuthority(row); + } + + listUnreleasedClaudeProcessAuthorities( + limit = 500, + ): readonly ClaudeProcessAuthorityRecord[] { + const parsedLimit = z.number().int().positive().max(500).parse(limit); + return this.#database.query( + `SELECT * FROM session_claude_process_authorities + WHERE state!='released' + ORDER BY recorded_at,provider_thread_id LIMIT ?`, + ).all(parsedLimit).map(mapSessionClaudeProcessAuthority); + } + + recordClaimedClaudeProcessAuthority(input: { + providerThreadId: string; + profileId: ProfileId; + profileGeneration: number; + runtimeScope: z.infer; + sessionId?: SessionId; + identity: ClaudeProcessIdentity; + expectedLaunchIntentId?: string; + expectedLaunchIntentRevision?: number; + }): ClaudeProcessAuthorityRecord { + const parsed = z.object({ + providerThreadId: sessionAdoptionProviderThreadIdSchema, + profileId: profileIdSchema, + profileGeneration: z.number().int().nonnegative().safe(), + runtimeScope: claudeProcessRuntimeScopeSchema, + sessionId: sessionIdSchema.optional(), + identity: claudeProcessIdentitySchema, + expectedLaunchIntentId: z.string().uuid().optional(), + expectedLaunchIntentRevision: z.number().int().positive().safe().optional(), + }).strict().parse(input); + if ( + (parsed.expectedLaunchIntentId === undefined) + !== (parsed.expectedLaunchIntentRevision === undefined) + ) throw new Error("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_EXPECTATION_INCOMPLETE"); + const launchIntentExpectation = parsed.expectedLaunchIntentId !== undefined + && parsed.expectedLaunchIntentRevision !== undefined + ? { + intentId: parsed.expectedLaunchIntentId, + revision: parsed.expectedLaunchIntentRevision, } - const policyChanged = this.#database.query( - `UPDATE account_rate_limit_reset_policies - SET state='reconciliation_required',account_fingerprint=NULL, - weekly_window_resets_at=NULL,revision=revision+1, - updated_at=MAX(updated_at,?) - WHERE profile_id=? AND revision=? - AND state=? - AND account_fingerprint IS ? - AND weekly_window_resets_at IS ?`, + : null; + const record = this.#database.transaction(() => { + const profile = this.requireProfileById(parsed.profileId); + if ( + profile.processGeneration !== parsed.profileGeneration + || ( + launchIntentExpectation !== null + && !profileStateAllowsProviderSessionAuthority("claude", profile.state) + ) + ) { + throw new Error("SESSION_CLAUDE_PROCESS_AUTHORITY_PROFILE_STALE"); + } + const key = { + providerThreadId: parsed.providerThreadId, + profileId: parsed.profileId, + runtimeScope: parsed.runtimeScope, + } as const; + if (launchIntentExpectation !== null) { + const intent = this.readClaudeProcessLaunchIntent(key); + if ( + intent === null + || intent.intentId !== launchIntentExpectation.intentId + || intent.revision !== launchIntentExpectation.revision + || intent.profileGeneration !== parsed.profileGeneration + || intent.providerAccountKey === null + || intent.sessionId !== (parsed.sessionId ?? null) + ) throw new Error("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_CONFLICT"); + const revocation = this.readProviderRuntimeAccountRevocation({ + profileId: parsed.profileId, + provider: "claude", + runtimeScope: parsed.runtimeScope, + }); + if ( + revocation?.profileGeneration === parsed.profileGeneration + && ( + revocation.state === "releasing" + || revocation.currentAccountKey === null + || revocation.currentAccountKey !== intent.providerAccountKey + ) + ) throw new Error("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_ACCOUNT_STALE"); + } + const current = this.readClaudeProcessAuthority(key); + if (current === null) { + const now = unixMillisecondsSchema.parse(this.#now()); + this.#database.query( + `INSERT INTO session_claude_process_authorities( + provider_thread_id,profile_id,profile_generation,runtime_scope, + session_id,pid,pid_domain,proc_start,state,revision,recorded_at,released_at + ) VALUES (?,?,?,?,?,?,?,?,'claimed',1,?,NULL)`, ).run( + parsed.providerThreadId, + parsed.profileId, + parsed.profileGeneration, + parsed.runtimeScope, + parsed.sessionId ?? null, + parsed.identity.pid, + parsed.identity.pidDomain, + parsed.identity.procStart, now, - profileId, - policy.revision, - policy.state, - policy.accountFingerprint, - policy.weeklyWindowResetsAt, ); - if (policyChanged.changes !== 1) { - throw new Error("ACCOUNT_RATE_LIMIT_RESET_POLICY_CONFLICT"); + } else if (current.state !== "released") { + if ( + current.state !== "claimed" + || current.profileId !== parsed.profileId + || current.profileGeneration !== parsed.profileGeneration + || current.runtimeScope !== parsed.runtimeScope + || current.sessionId !== (parsed.sessionId ?? null) + || current.identity.pid !== parsed.identity.pid + || current.identity.pidDomain !== parsed.identity.pidDomain + || current.identity.procStart !== parsed.identity.procStart + ) throw new Error("SESSION_CLAUDE_PROCESS_AUTHORITY_ALREADY_LIVE"); + } else { + const now = unixMillisecondsSchema.parse(this.#now()); + const changed = this.#database.query( + `UPDATE session_claude_process_authorities + SET profile_generation=?,session_id=?, + pid=?,pid_domain=?,proc_start=?,state='claimed',revision=revision+1, + recorded_at=?,released_at=NULL + WHERE runtime_scope=? AND profile_id=? AND provider_thread_id=? + AND revision=? AND state='released'`, + ).run( + parsed.profileGeneration, + parsed.sessionId ?? null, + parsed.identity.pid, + parsed.identity.pidDomain, + parsed.identity.procStart, + now, + parsed.runtimeScope, + parsed.profileId, + parsed.providerThreadId, + current.revision, + ); + if (changed.changes !== 1) { + throw new Error("SESSION_CLAUDE_PROCESS_AUTHORITY_CONFLICT"); } } - if (state === "signed_in" || state === "signed_out") { - this.#database.query(`UPDATE provider_login_authorities - SET state='settled',settlement=?,updated_at=? - WHERE profile_id=? AND process_generation=? AND state='active'`).run( - state === "signed_in" ? "signed_in" : "provider_disconnected", - now, - profileId, - expectedGeneration, + if (parsed.runtimeScope === "personal") { + const claiming = this.#database.query( + `UPDATE session_adoption_candidates + SET claim_status='claiming',fenced_fingerprint=NULL, + revision=revision+1 + WHERE provider='claude' AND provider_thread_id=? + AND claim_status='claiming'`, + ).run(parsed.providerThreadId); + // Custody evidence must survive even if a concurrent defensive prune + // already removed the candidate. The caller will release the claimed + // process and discovery can reconstruct a later candidate. + void claiming; + } + const claimed = this.readClaudeProcessAuthority(key); + if ( + claimed?.state !== "claimed" + || claimed.profileGeneration !== parsed.profileGeneration + || claimed.sessionId !== (parsed.sessionId ?? null) + || claimed.identity.pid !== parsed.identity.pid + || claimed.identity.pidDomain !== parsed.identity.pidDomain + || claimed.identity.procStart !== parsed.identity.procStart + ) { + throw new Error("SESSION_CLAUDE_PROCESS_AUTHORITY_CLAIM_INCOMPLETE"); + } + if (launchIntentExpectation !== null) { + const consumed = this.#database.query( + `DELETE FROM session_claude_process_launch_intents + WHERE runtime_scope=? AND profile_id=? AND provider_thread_id=? + AND profile_generation=? AND session_id IS ? + AND intent_id=? AND revision=?`, + ).run( + parsed.runtimeScope, + parsed.profileId, + parsed.providerThreadId, + parsed.profileGeneration, + parsed.sessionId ?? null, + launchIntentExpectation.intentId, + launchIntentExpectation.revision, ); + if (consumed.changes !== 1) { + throw new Error("SESSION_CLAUDE_PROCESS_LAUNCH_INTENT_CONFLICT"); + } } - return { affectedWorkIds, changed: true }; + return claimed; }); - return update.immediate(); + return record.immediate(); } - reconcileProfileRecoveryFromAccountRead(input: { + #bindClaudeProcessAuthorityLocked(input: { + providerThreadId: string; profileId: ProfileId; - expectedGeneration: number; - provider: { signedIn: boolean; email?: string; plan?: string }; - }): ProfileRecord { - const profileId = profileIdSchema.parse(input.profileId); - const expectedGeneration = z.number().int().nonnegative() - .max(Number.MAX_SAFE_INTEGER).parse(input.expectedGeneration); - const provider = z.object({ - signedIn: z.boolean(), - email: z.string().email().optional(), - plan: z.string().max(128).optional(), - }).strict().parse(input.provider); - const reconcile = this.#database.transaction(() => { - const generic = z.object({ count: z.number().int().nonnegative() }).strict().parse( - this.#database.query( - `SELECT COUNT(*) AS count FROM mutation_attempts m - LEFT JOIN mutation_resolutions r ON r.attempt_id=m.id - WHERE m.authority_id=? AND m.authority_generation=? - AND m.state IN ('effect_started','ambiguous') - AND r.attempt_id IS NULL`, - ).get(profileId, expectedGeneration), - ); - const resets = z.object({ count: z.number().int().nonnegative() }).strict().parse( - this.#database.query( - `SELECT COUNT(*) AS count FROM account_rate_limit_reset_attempts - WHERE profile_id=? AND current_process_generation=? - AND state IN ('prepared','effect_started','ambiguous','retryable')`, - ).get(profileId, expectedGeneration), - ); - if (generic.count !== 0 || resets.count !== 0) { - throw new Error("PROFILE_RECOVERY_AUTHORITY_UNSETTLED"); + sessionId: SessionId; + runtimeScope: z.infer; + identity: ClaudeProcessIdentity; + }): ClaudeProcessAuthorityRecord { + const current = this.readClaudeProcessAuthority({ + providerThreadId: input.providerThreadId, + profileId: input.profileId, + runtimeScope: input.runtimeScope, + }); + if (current === null) throw new Error("SESSION_CLAUDE_PROCESS_AUTHORITY_MISSING"); + if ( + current.identity.pid !== input.identity.pid + || current.identity.pidDomain !== input.identity.pidDomain + || current.identity.procStart !== input.identity.procStart + ) throw new Error("SESSION_CLAUDE_PROCESS_AUTHORITY_IDENTITY_MISMATCH"); + const session = this.requireSession(input.sessionId); + const profile = this.requireProfileById(session.profileId, { includeRemoved: true }); + if ( + current.profileId !== session.profileId + || current.profileId !== input.profileId + || current.profileGeneration !== profile.processGeneration + || current.runtimeScope !== input.runtimeScope + ) throw new Error("SESSION_CLAUDE_PROCESS_AUTHORITY_BINDING_MISMATCH"); + if (current.state === "bound" && current.sessionId === input.sessionId) return current; + if (current.state !== "claimed") { + throw new Error("SESSION_CLAUDE_PROCESS_AUTHORITY_NOT_CLAIMED"); + } + const changed = this.#database.query( + `UPDATE session_claude_process_authorities + SET session_id=?,state='bound',revision=revision+1 + WHERE runtime_scope=? AND profile_id=? AND provider_thread_id=? + AND revision=? AND state='claimed'`, + ).run( + input.sessionId, + input.runtimeScope, + input.profileId, + input.providerThreadId, + current.revision, + ); + if (changed.changes !== 1) { + throw new Error("SESSION_CLAUDE_PROCESS_AUTHORITY_CONFLICT"); + } + const bound = this.readClaudeProcessAuthority({ + providerThreadId: input.providerThreadId, + profileId: input.profileId, + runtimeScope: input.runtimeScope, + }); + if (bound?.state !== "bound" || bound.sessionId !== input.sessionId) { + throw new Error("SESSION_CLAUDE_PROCESS_AUTHORITY_BIND_INCOMPLETE"); + } + return bound; + } + + bindClaimedClaudeProcessAuthority(input: { + providerThreadId: string; + profileId: ProfileId; + sessionId: SessionId; + runtimeScope: z.infer; + identity: ClaudeProcessIdentity; + }): ClaudeProcessAuthorityRecord { + const parsed = z.object({ + providerThreadId: sessionAdoptionProviderThreadIdSchema, + profileId: profileIdSchema, + sessionId: sessionIdSchema, + runtimeScope: claudeProcessRuntimeScopeSchema, + identity: claudeProcessIdentitySchema, + }).strict().parse(input); + const bind = this.#database.transaction(() => + this.#bindClaudeProcessAuthorityLocked(parsed)); + return bind.immediate(); + } + + beginClaudeProcessAuthorityRelease(input: { + providerThreadId: string; + profileId: ProfileId; + runtimeScope: z.infer; + expectedRevision: number; + identity: ClaudeProcessIdentity; + }): ClaudeProcessAuthorityRecord { + const parsed = z.object({ + providerThreadId: sessionAdoptionProviderThreadIdSchema, + profileId: profileIdSchema, + runtimeScope: claudeProcessRuntimeScopeSchema, + expectedRevision: z.number().int().positive().safe(), + identity: claudeProcessIdentitySchema, + }).strict().parse(input); + const begin = this.#database.transaction(() => { + const current = this.readClaudeProcessAuthority({ + providerThreadId: parsed.providerThreadId, + profileId: parsed.profileId, + runtimeScope: parsed.runtimeScope, + }); + if (current === null) throw new Error("SESSION_CLAUDE_PROCESS_AUTHORITY_MISSING"); + if ( + current.identity.pid !== parsed.identity.pid + || current.identity.pidDomain !== parsed.identity.pidDomain + || current.identity.procStart !== parsed.identity.procStart + ) throw new Error("SESSION_CLAUDE_PROCESS_AUTHORITY_IDENTITY_MISMATCH"); + if (current.state === "released" || current.state === "releasing") return current; + if (current.revision !== parsed.expectedRevision) { + throw new Error("SESSION_CLAUDE_PROCESS_AUTHORITY_CONFLICT"); } - const state = provider.signedIn ? "signed_in" : "signed_out"; - const now = unixMillisecondsSchema.parse(this.#now()); const changed = this.#database.query( - `UPDATE profiles SET state=?,provider_email=?,provider_plan=?,updated_at=? - WHERE id=? AND process_generation=? AND state='recovery_required'`, + `UPDATE session_claude_process_authorities + SET state='releasing',revision=revision+1 + WHERE runtime_scope=? AND profile_id=? AND provider_thread_id=? + AND revision=? AND state IN ('claimed','bound')`, ).run( - state, - provider.signedIn ? provider.email ?? null : null, - provider.signedIn ? provider.plan ?? null : null, - now, - profileId, - expectedGeneration, + parsed.runtimeScope, + parsed.profileId, + parsed.providerThreadId, + parsed.expectedRevision, ); if (changed.changes !== 1) { - throw new Error("PROFILE_RECOVERY_AUTHORITY_CHANGED"); + throw new Error("SESSION_CLAUDE_PROCESS_AUTHORITY_CONFLICT"); } - this.#database.query( - `UPDATE provider_login_authorities - SET state='settled',settlement=?,updated_at=? - WHERE profile_id=? AND process_generation=? AND state='active'`, - ).run( - state === "signed_in" ? "signed_in" : "provider_disconnected", - now, - profileId, - expectedGeneration, - ); + const releasing = this.readClaudeProcessAuthority({ + providerThreadId: parsed.providerThreadId, + profileId: parsed.profileId, + runtimeScope: parsed.runtimeScope, + }); + if (releasing?.state !== "releasing") { + throw new Error("SESSION_CLAUDE_PROCESS_AUTHORITY_RELEASE_BEGIN_INCOMPLETE"); + } + return releasing; }); - reconcile.immediate(); - return this.requireProfileById(profileId); + return begin.immediate(); } - removeProfile(profileId: ProfileId): void { - const id = profileIdSchema.parse(profileId); - const remove = this.#database.transaction(() => { - const active = this.#database.query( - "SELECT COUNT(*) AS count FROM sessions WHERE profile_id=? AND state NOT IN ('terminal')", - ).get(id) as { count: number } | null; - if ((active?.count ?? 0) !== 0) { - throw new Error("Profile still owns active sessions."); + completeClaudeProcessAuthorityRelease(input: { + providerThreadId: string; + profileId: ProfileId; + runtimeScope: z.infer; + expectedRevision: number; + identity: ClaudeProcessIdentity; + }): ClaudeProcessAuthorityRecord { + const parsed = z.object({ + providerThreadId: sessionAdoptionProviderThreadIdSchema, + profileId: profileIdSchema, + runtimeScope: claudeProcessRuntimeScopeSchema, + expectedRevision: z.number().int().positive().safe(), + identity: claudeProcessIdentitySchema, + }).strict().parse(input); + const complete = this.#database.transaction(() => { + const current = this.readClaudeProcessAuthority({ + providerThreadId: parsed.providerThreadId, + profileId: parsed.profileId, + runtimeScope: parsed.runtimeScope, + }); + if (current === null) throw new Error("SESSION_CLAUDE_PROCESS_AUTHORITY_MISSING"); + if ( + current.identity.pid !== parsed.identity.pid + || current.identity.pidDomain !== parsed.identity.pidDomain + || current.identity.procStart !== parsed.identity.procStart + ) throw new Error("SESSION_CLAUDE_PROCESS_AUTHORITY_IDENTITY_MISMATCH"); + if (current.state === "released") return current; + if (current.state !== "releasing" || current.revision !== parsed.expectedRevision) { + throw new Error("SESSION_CLAUDE_PROCESS_AUTHORITY_CONFLICT"); } const now = unixMillisecondsSchema.parse(this.#now()); - const result = this.#database.query( - `UPDATE profiles - SET state='removed',provider_email=NULL,provider_plan=NULL,updated_at=? - WHERE id=? AND state!='removed'`, - ).run(now, id); - if (result.changes !== 1) throw new SelectionError("NOT_FOUND"); - const policy = this.#database.query( - "DELETE FROM account_rate_limit_reset_policies WHERE profile_id=?", - ).run(id); - if (policy.changes !== 1) { - throw new Error("ACCOUNT_RATE_LIMIT_RESET_POLICY_MISSING"); + const changed = this.#database.query( + `UPDATE session_claude_process_authorities + SET state='released',revision=revision+1,released_at=? + WHERE runtime_scope=? AND profile_id=? AND provider_thread_id=? + AND revision=? AND state='releasing'`, + ).run( + now, + parsed.runtimeScope, + parsed.profileId, + parsed.providerThreadId, + parsed.expectedRevision, + ); + if (changed.changes !== 1) { + throw new Error("SESSION_CLAUDE_PROCESS_AUTHORITY_CONFLICT"); + } + const released = this.readClaudeProcessAuthority({ + providerThreadId: parsed.providerThreadId, + profileId: parsed.profileId, + runtimeScope: parsed.runtimeScope, + }); + if (released?.state !== "released" || released.releasedAt === null) { + throw new Error("SESSION_CLAUDE_PROCESS_AUTHORITY_RELEASE_INCOMPLETE"); } + return released; }); - remove.immediate(); + return complete.immediate(); } - async createProject(label: string, requestedRoot: string, makeDefault = false): Promise { - const canonical = await resolveUsableCanonicalProjectDirectory(requestedRoot); - if (canonical === null) { - throw new UnusableProjectRootError(); + listSessionPersonalRuntimeBindings(input: { + provider?: AdoptableProvider; + state?: z.infer; + includeDetached?: boolean; + limit?: number; + } = {}): readonly SessionPersonalRuntimeBindingRecord[] { + const parsed = z.object({ + provider: adoptableProviderSchema.optional(), + state: sessionPersonalRuntimeBindingStateSchema.optional(), + includeDetached: z.boolean().optional(), + limit: z.number().int().positive().max(500).optional(), + }).strict().parse(input); + const clauses = parsed.state !== undefined + ? ["state=?"] + : parsed.includeDetached === true ? [] : ["state='active'"]; + const values: (string | number)[] = []; + if (parsed.state !== undefined) values.push(parsed.state); + if (parsed.provider !== undefined) { + clauses.push("provider=?"); + values.push(parsed.provider); } - const id = createProjectId(); - const parsedLabel = labelSchema.parse(label); - const labelKey = canonicalLabelIdentity(parsedLabel, "PROJECT").key; - const now = this.#now(); - const insert = this.#database.transaction(() => { - if (makeDefault) this.#database.query("UPDATE projects SET is_default=0, updated_at=? WHERE is_default=1").run(now); - this.#database.query("INSERT INTO projects(id,label,label_key,root_path,is_default,created_at,updated_at) VALUES (?,?,?,?,?,?,?)").run(id, parsedLabel, labelKey, canonical, makeDefault ? 1 : 0, now, now); - }); - insert.immediate(); - return this.requireProject(id); + const where = clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`; + return this.#database.query( + `SELECT * FROM session_personal_runtime_bindings ${where} + ORDER BY updated_at DESC,session_id LIMIT ?`, + ).all(...values, parsed.limit ?? 100).map(mapSessionPersonalRuntimeBinding); } - listProjects(): readonly ProjectRecord[] { - return this.#database.query("SELECT * FROM projects ORDER BY is_default DESC, label_key, id").all().map(mapProject); + listProfileDetachingPersonalRuntimeBindingPage(input: { + profileId: ProfileId; + provider: AdoptableProvider; + afterSessionId: SessionId | null; + limit?: number; + }): Readonly<{ + bindings: readonly SessionPersonalRuntimeBindingRecord[]; + continueAfterSessionId: SessionId | null; + }> { + const parsed = z.object({ + profileId: profileIdSchema, + provider: adoptableProviderSchema, + afterSessionId: sessionIdSchema.nullable(), + limit: z.number().int().min(1).max(500).default(500), + }).strict().parse(input); + const bindings = this.#database.query( + `SELECT b.* FROM session_personal_runtime_bindings b + JOIN sessions s ON s.id=b.session_id + WHERE s.profile_id=? AND b.provider=? AND b.state='detaching' + AND b.session_id>? + ORDER BY b.session_id LIMIT ?`, + ).all( + parsed.profileId, + parsed.provider, + parsed.afterSessionId ?? "", + parsed.limit, + ).map(mapSessionPersonalRuntimeBinding); + const last = bindings.at(-1); + return { + bindings, + continueAfterSessionId: + bindings.length === parsed.limit && last !== undefined + ? last.sessionId + : null, + }; } - requireProject(selector: string): ProjectRecord { - const selected = selectByIdOrLabel(this.listProjects(), selector); - if (selected.kind === "found") return selected.value; - throw new SelectionError(selected.kind === "missing" ? "NOT_FOUND" : "AMBIGUOUS", selected.kind === "ambiguous" ? selected.values : []); + profileHasControllingPersonalSessions(profileId: ProfileId): boolean { + const parsedProfileId = profileIdSchema.parse(profileId); + return this.#database.query( + `SELECT 1 FROM session_personal_runtime_bindings b + JOIN sessions s ON s.id=b.session_id + WHERE s.profile_id=? AND b.state IN ('active','detaching') + LIMIT 1`, + ).get(parsedProfileId) !== null; } - setDefaultProject(projectId: ProjectId): ProjectRecord { - const now = this.#now(); - const transaction = this.#database.transaction(() => { - this.#database.query("UPDATE projects SET is_default=0,updated_at=? WHERE is_default=1").run(now); - const result = this.#database.query("UPDATE projects SET is_default=1,updated_at=? WHERE id=?").run(now, projectId); - if (result.changes !== 1) throw new SelectionError("NOT_FOUND"); - }); - transaction.immediate(); - return this.requireProject(projectId); + listProfileControllingPersonalRuntimeBindings( + profileId: ProfileId, + ): readonly SessionPersonalRuntimeBindingRecord[] { + const parsedProfileId = profileIdSchema.parse(profileId); + return this.#database.query( + `SELECT b.* FROM session_personal_runtime_bindings b + JOIN sessions s ON s.id=b.session_id + WHERE s.profile_id=? AND b.state IN ('active','detaching') + ORDER BY b.session_id`, + ).all(parsedProfileId).map(mapSessionPersonalRuntimeBinding); } - removeProject(projectId: ProjectId): void { - const result = this.#database.query("DELETE FROM projects WHERE id=? AND NOT EXISTS(SELECT 1 FROM sessions WHERE project_id=?)").run(projectId, projectId); - if (result.changes !== 1) throw new Error("Project is missing or still used by a session."); + listOpenInteractionsForProfile( + profileId: ProfileId, + processGeneration: number, + ): readonly InteractionRecord[] { + const parsedProfileId = profileIdSchema.parse(profileId); + const parsedGeneration = z.number().int().nonnegative().safe().parse(processGeneration); + return this.#database.query( + `SELECT * FROM provider_interactions + WHERE profile_id=? AND process_generation=? + AND state IN ('pending','response_prepared','response_written') + ORDER BY requested_at,public_id`, + ).all(parsedProfileId, parsedGeneration).map(mapInteraction); } - #insertSessionEventStream(sessionId: SessionId, now: number): void { - this.#database.query( - `INSERT OR IGNORE INTO session_event_streams( - session_id,stream_epoch,next_sequence,floor_sequence,observed_through_sequence, - retained_count,retained_bytes,retention_gap_reason,created_at,updated_at - ) VALUES (?,?,1,1,0,0,0,NULL,?,?)`, - ).run(sessionId, randomUUID(), now, now); + listUnreleasedClaudeProcessAuthoritiesForProfile( + profileId: ProfileId, + profileGeneration?: number, + ): readonly ClaudeProcessAuthorityRecord[] { + const parsedProfileId = profileIdSchema.parse(profileId); + if (profileGeneration === undefined) { + return this.#database.query( + `SELECT * FROM session_claude_process_authorities + WHERE profile_id=? AND state!='released' + ORDER BY recorded_at,provider_thread_id`, + ).all(parsedProfileId).map(mapSessionClaudeProcessAuthority); + } + const parsedGeneration = z.number().int().nonnegative().safe().parse(profileGeneration); + return this.#database.query( + `SELECT * FROM session_claude_process_authorities + WHERE profile_id=? AND profile_generation=? AND state!='released' + ORDER BY recorded_at,provider_thread_id`, + ).all(parsedProfileId, parsedGeneration).map(mapSessionClaudeProcessAuthority); } - #ensureSessionEventStream(sessionId: SessionId): void { - if (this.#database.query("SELECT 1 FROM session_event_streams WHERE session_id=?").get(sessionId) !== null) return; - if (this.#readonly) throw new Error("SESSION_EVENT_STREAM_MISSING"); - const now = this.#now(); - this.#insertSessionEventStream(sessionId, now); + listUnreleasedClaudeProcessAuthorityPage(input: { + profileId: ProfileId; + profileGeneration: number; + runtimeScope: z.infer; + afterProviderThreadId: string | null; + limit?: number; + }): Readonly<{ + authorities: readonly ClaudeProcessAuthorityRecord[]; + continueAfterProviderThreadId: string | null; + }> { + const parsed = z.object({ + profileId: profileIdSchema, + profileGeneration: z.number().int().nonnegative().safe(), + runtimeScope: claudeProcessRuntimeScopeSchema, + afterProviderThreadId: sessionAdoptionProviderThreadIdSchema.nullable(), + limit: z.number().int().min(1).max(500).default(500), + }).strict().parse(input); + const authorities = this.#database.query( + `SELECT * FROM session_claude_process_authorities + WHERE profile_id=? AND profile_generation=? AND runtime_scope=? + AND state!='released' AND provider_thread_id>? + ORDER BY provider_thread_id LIMIT ?`, + ).all( + parsed.profileId, + parsed.profileGeneration, + parsed.runtimeScope, + parsed.afterProviderThreadId ?? "", + parsed.limit, + ).map(mapSessionClaudeProcessAuthority); + const last = authorities.at(-1); + return { + authorities, + continueAfterProviderThreadId: + authorities.length === parsed.limit && last !== undefined + ? last.providerThreadId + : null, + }; } - createSession(input: { profileId: ProfileId; projectId?: ProjectId; title?: string; provider?: Provider; preset: Preset; fastEnabled: boolean }): SessionRecord { - const id = createSessionId(); - const now = this.#now(); - const title = input.title === undefined ? "Untitled session" : titleSchema.parse(input.title); - const provider = providerSchema.parse(input.provider ?? "codex"); - const preset = presetSchema.parse(input.preset); - assertPresetSupportedByProvider(provider, preset); - const create = this.#database.transaction(() => { - this.#database.query("INSERT INTO sessions(id,profile_id,project_id,title,provider,provider_v39,preset,preset_contract,fast_enabled,state,revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)").run(id, input.profileId, input.projectId ?? null, title, legacySessionProviderShadow(provider), provider, presetTiers[preset], currentPresetContract, input.fastEnabled ? 1 : 0, "starting", 1, now, now); - this.#insertSessionEventStream(id, now); - }); - create.immediate(); - return this.requireSession(id); + profileHasUnreleasedClaudeProcessAuthorities( + profileId: ProfileId, + profileGeneration?: number, + ): boolean { + const parsedProfileId = profileIdSchema.parse(profileId); + if (profileGeneration === undefined) { + return this.#database.query( + `SELECT 1 FROM session_claude_process_authorities + WHERE profile_id=? AND state!='released' LIMIT 1`, + ).get(parsedProfileId) !== null; + } + const parsedGeneration = z.number().int().nonnegative().safe().parse(profileGeneration); + return this.#database.query( + `SELECT 1 FROM session_claude_process_authorities + WHERE profile_id=? AND profile_generation=? AND state!='released' + LIMIT 1`, + ).get(parsedProfileId, parsedGeneration) !== null; } - /** - * Archived sessions are excluded unless `includeArchived` is set: archive is - * a listing filter, never a deletion, so every other read path still sees - * the session and its history. - */ - listSessions(limit = 50, profileId?: ProfileId, includeArchived = false): readonly SessionRecord[] { - const bounded = Math.max(1, Math.min(100, Math.trunc(limit))); - const archiveClause = includeArchived ? "" : " AND archived_at IS NULL"; - const rows = profileId === undefined - ? this.#database.query( - `SELECT * FROM sessions WHERE 1=1${archiveClause} ORDER BY updated_at DESC,id LIMIT ?`, - ).all(bounded) - : this.#database.query( - `SELECT * FROM sessions WHERE profile_id=?${archiveClause} ORDER BY updated_at DESC,id LIMIT ?`, - ).all(profileId, bounded); - return rows.map(mapSession); + readProfilePersonalAuthorityRevocation( + profileId: ProfileId, + ): ProfilePersonalAuthorityRevocationRecord | null { + const parsedProfileId = profileIdSchema.parse(profileId); + const row = this.#database.query( + "SELECT * FROM profile_personal_authority_revocations WHERE profile_id=?", + ).get(parsedProfileId); + return row === null ? null : mapProfilePersonalAuthorityRevocation(row); } - /** - * Archive or unarchive a session. The row's `revision` is left alone: this - * is local presentation state, not provider-observable session authority, - * so it must not invalidate an in-flight optimistic session update. - */ - setSessionArchived(sessionId: SessionId, archived: boolean): SessionRecord { - const parsedSessionId = sessionIdSchema.parse(sessionId); - const now = this.#now(); - const write = this.#database.transaction(() => { - const result = this.#database.query( - "UPDATE sessions SET archived_at=? WHERE id=?", - ).run(archived ? now : null, parsedSessionId); - if (result.changes !== 1) throw new SelectionError("NOT_FOUND"); + /** Persist the observed loss of account authority before asynchronous release starts. */ + stageProfilePersonalAuthorityRevocation(input: { + profileId: ProfileId; + expectedGeneration: number; + }): ProfilePersonalAuthorityRevocationRecord { + const parsed = z.object({ + profileId: profileIdSchema, + expectedGeneration: z.number().int().nonnegative().safe(), + }).strict().parse(input); + const stage = this.#database.transaction(() => { + const profile = this.requireProfileById(parsed.profileId); + if ( + profile.processGeneration !== parsed.expectedGeneration + || profile.state === "removed" + ) throw new Error("PROFILE_PERSONAL_AUTHORITY_REVOCATION_STALE"); + const now = unixMillisecondsSchema.parse(this.#now()); + const current = this.readProfilePersonalAuthorityRevocation(parsed.profileId); + if (current === null) { + this.#database.query( + `INSERT INTO profile_personal_authority_revocations( + profile_id,profile_generation,state,revision,created_at,updated_at,completed_at + ) VALUES (?,?,'releasing',1,?,?,NULL)`, + ).run(parsed.profileId, parsed.expectedGeneration, now, now); + } else if ( + current.state === "completed" + && current.profileGeneration !== parsed.expectedGeneration + ) { + const changed = this.#database.query( + `UPDATE profile_personal_authority_revocations + SET profile_generation=?,state='releasing',revision=revision+1, + created_at=?,updated_at=?,completed_at=NULL + WHERE profile_id=? AND revision=? AND state='completed'`, + ).run( + parsed.expectedGeneration, + now, + now, + parsed.profileId, + current.revision, + ); + if (changed.changes !== 1) { + throw new Error("PROFILE_PERSONAL_AUTHORITY_REVOCATION_CONFLICT"); + } + } else if ( + current.state !== "releasing" + || current.profileGeneration !== parsed.expectedGeneration + ) { + throw new Error("PROFILE_PERSONAL_AUTHORITY_REVOCATION_CONFLICT"); + } + const staged = this.readProfilePersonalAuthorityRevocation(parsed.profileId); + if ( + staged === null + || staged.state !== "releasing" + || staged.profileGeneration !== parsed.expectedGeneration + ) throw new Error("PROFILE_PERSONAL_AUTHORITY_REVOCATION_STAGE_INCOMPLETE"); + return staged; }); - write.immediate(); - return this.requireSession(parsedSessionId); + return stage.immediate(); } - readNotificationHours(): NotificationHoursPolicy { + listReleasingProfilePersonalAuthorityRevocations(): readonly ProfilePersonalAuthorityRevocationRecord[] { + return this.#database.query( + `SELECT * FROM profile_personal_authority_revocations + WHERE state='releasing' ORDER BY created_at,profile_id`, + ).all().map(mapProfilePersonalAuthorityRevocation); + } + + readProviderRuntimeAccountRevocation(input: { + profileId: ProfileId; + provider: AdoptableProvider; + runtimeScope: z.infer; + }): ProviderRuntimeAccountRevocationRecord | null { + const parsed = z.object({ + profileId: profileIdSchema, + provider: adoptableProviderSchema, + runtimeScope: claudeProcessRuntimeScopeSchema, + }).strict().parse(input); const row = this.#database.query( - `SELECT version,revision,start_minute,end_minute,time_zone,created_at,updated_at - FROM notification_hours WHERE singleton=1`, - ).get(); - if (row === null) throw new Error("NOTIFICATION_HOURS_POLICY_MISSING"); - try { - const policy = mapNotificationHoursPolicy(row); - const emailRow = this.#database.query( - `SELECT version,enabled,revision,created_at,updated_at - FROM attention_email_policy WHERE singleton=1`, - ).get(); - if (emailRow === null) throw new Error("ATTENTION_EMAIL_POLICY_MISSING"); - const emailPolicy = mapNotificationEmailPolicy(emailRow); - if (emailPolicy.revision !== policy.revision) { - throw new Error("NOTIFICATION_POLICY_REVISION_DIVERGED"); + `SELECT * FROM provider_runtime_account_revocations + WHERE profile_id=? AND provider=? AND runtime_scope=?`, + ).get(parsed.profileId, parsed.provider, parsed.runtimeScope); + return row === null ? null : mapProviderRuntimeAccountRevocation(row); + } + + /** + * Clears a completed scoped fence only after the caller freshly attested the + * exact replacement identity. The revocation transaction already removed + * every mismatched session proof; asserting that invariant here prevents a + * successful reread from resurrecting an older account's sessions. + */ + clearCompletedProviderRuntimeAccountRevocation(input: { + profileId: ProfileId; + expectedGeneration: number; + provider: AdoptableProvider; + runtimeScope: z.infer; + currentAccountKey: string; + }): void { + const parsed = z.object({ + profileId: profileIdSchema, + expectedGeneration: z.number().int().nonnegative().safe(), + provider: adoptableProviderSchema, + runtimeScope: claudeProcessRuntimeScopeSchema, + currentAccountKey: providerAccountAuthorityKeySchema, + }).strict().parse(input); + if (!providerAccountAuthorityKeyMatchesProvider( + parsed.provider, + parsed.currentAccountKey, + )) throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_KEY_MISMATCH"); + const clear = this.#database.transaction(() => { + const profile = this.requireProfileById(parsed.profileId); + if (profile.processGeneration !== parsed.expectedGeneration) { + throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_STALE"); + } + const current = this.readProviderRuntimeAccountRevocation({ + profileId: parsed.profileId, + provider: parsed.provider, + runtimeScope: parsed.runtimeScope, + }); + if (current === null) return; + if ( + current.profileGeneration !== parsed.expectedGeneration + || current.state !== "completed" + || current.currentAccountKey !== parsed.currentAccountKey + ) throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_NOT_RECONCILED"); + const mismatchedProof = this.#database.query( + `SELECT 1 FROM sessions s + JOIN session_provider_account_authorities a ON a.session_id=s.id + WHERE s.profile_id=? AND s.provider_v39=? AND a.provider=s.provider_v39 + AND a.runtime_scope=? AND a.account_key!=? LIMIT 1`, + ).get( + parsed.profileId, + parsed.provider, + parsed.runtimeScope, + parsed.currentAccountKey, + ); + if (mismatchedProof !== null) { + throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_STALE_PROOF"); } - return policy; - } catch (error: unknown) { - throw new Error("NOTIFICATION_HOURS_POLICY_INVALID", { cause: error }); - } + const deleted = this.#database.query( + `DELETE FROM provider_runtime_account_revocations + WHERE profile_id=? AND provider=? AND runtime_scope=? + AND profile_generation=? AND state='completed' AND revision=? + AND current_account_key=?`, + ).run( + parsed.profileId, + parsed.provider, + parsed.runtimeScope, + parsed.expectedGeneration, + current.revision, + parsed.currentAccountKey, + ); + if (deleted.changes !== 1) { + throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_CONFLICT"); + } + }); + clear.immediate(); } - readNotificationEmailPolicy(): NotificationEmailPolicy { - const row = this.#database.query( - `SELECT version,enabled,revision,created_at,updated_at - FROM attention_email_policy WHERE singleton=1`, - ).get(); - if (row === null) throw new Error("ATTENTION_EMAIL_POLICY_MISSING"); - try { - const policy = mapNotificationEmailPolicy(row); - const hoursRow = this.#database.query( - "SELECT revision FROM notification_hours WHERE singleton=1", - ).get(); - const hoursRevision = z.object({ - revision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), - }).strict().parse(hoursRow).revision; - if (hoursRevision !== policy.revision) { - throw new Error("NOTIFICATION_POLICY_REVISION_DIVERGED"); - } - return policy; - } catch (error: unknown) { - throw new Error("ATTENTION_EMAIL_POLICY_INVALID", { cause: error }); - } + listReleasingProviderRuntimeAccountRevocations(): readonly ProviderRuntimeAccountRevocationRecord[] { + return this.#database.query( + `SELECT * FROM provider_runtime_account_revocations + WHERE state='releasing' + ORDER BY created_at,profile_id,provider,runtime_scope`, + ).all().map(mapProviderRuntimeAccountRevocation); } - updateNotificationHours( - input: Readonly, - ): NotificationHoursPolicy { - const parsedInput = z.object({ - expectedRevision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), - version: z.literal(1), - startMinute: z.number(), - endMinute: z.number(), - timeZone: z.string(), - }).strict().parse(input); - const desired = notificationHoursUpdateSchema.parse({ - version: parsedInput.version, - startMinute: parsedInput.startMinute, - endMinute: parsedInput.endMinute, - timeZone: parsedInput.timeZone, + /** + * Fences only the sessions whose exact provider home no longer names their + * immutable account key. The selected HRA profile remains signed in: a + * personal Codex home or either Claude home is separate authority from the + * managed Codex account that owns the profile. + */ + beginProviderRuntimeAccountRevocation(input: { + profileId: ProfileId; + expectedGeneration: number; + provider: AdoptableProvider; + runtimeScope: z.infer; + currentAccountKey: string | null; + workStore: WorkStore; + }): Readonly<{ + revocation: ProviderRuntimeAccountRevocationRecord; + bindings: readonly SessionPersonalRuntimeBindingRecord[]; + interactions: readonly InteractionRecord[]; + sessionIds: readonly SessionId[]; + affectedWorkIds: readonly string[]; + }> { + const parsed = z.object({ + profileId: profileIdSchema, + expectedGeneration: z.number().int().nonnegative().safe(), + provider: adoptableProviderSchema, + runtimeScope: claudeProcessRuntimeScopeSchema, + currentAccountKey: providerAccountAuthorityKeySchema.nullable(), + }).strict().parse({ + profileId: input.profileId, + expectedGeneration: input.expectedGeneration, + provider: input.provider, + runtimeScope: input.runtimeScope, + currentAccountKey: input.currentAccountKey, }); - const updatedAt = unixMillisecondsSchema.parse(this.#now()); - const write = this.#database.transaction((): NotificationHoursPolicy => { - const current = this.readNotificationHours(); - if (current.revision !== parsedInput.expectedRevision) { - throw new Error("NOTIFICATION_HOURS_REVISION_CONFLICT"); + if (!input.workStore.isBackedByDatabase(this.#database)) { + throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_WORK_STORE_MISMATCH"); + } + const begin = this.#database.transaction(() => { + const profile = this.requireProfileById(parsed.profileId); + if ( + profile.processGeneration !== parsed.expectedGeneration + || profile.state === "removed" + ) throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_STALE"); + const globalRevocation = this.readProfilePersonalAuthorityRevocation(parsed.profileId); + if ( + globalRevocation?.state === "releasing" + && globalRevocation.profileGeneration === parsed.expectedGeneration + ) throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_SUPERSEDED"); + const now = unixMillisecondsSchema.parse(this.#now()); + const revocationSelector = { + profileId: parsed.profileId, + provider: parsed.provider, + runtimeScope: parsed.runtimeScope, + } as const; + const currentRevocation = this.readProviderRuntimeAccountRevocation( + revocationSelector, + ); + if (currentRevocation === null) { + this.#database.query( + `INSERT INTO provider_runtime_account_revocations( + profile_id,profile_generation,provider,runtime_scope,current_account_key, + state,revision,created_at,updated_at,completed_at + ) VALUES (?,?,?,?,?,'releasing',1,?,?,NULL)`, + ).run( + parsed.profileId, + parsed.expectedGeneration, + parsed.provider, + parsed.runtimeScope, + parsed.currentAccountKey, + now, + now, + ); + } else if ( + currentRevocation.state !== "releasing" + || currentRevocation.profileGeneration !== parsed.expectedGeneration + || currentRevocation.currentAccountKey !== parsed.currentAccountKey + ) { + const changed = this.#database.query( + `UPDATE provider_runtime_account_revocations + SET profile_generation=?,current_account_key=?,state='releasing', + revision=revision+1,created_at=?,updated_at=MAX(updated_at,?),completed_at=NULL + WHERE profile_id=? AND provider=? AND runtime_scope=? AND revision=?`, + ).run( + parsed.expectedGeneration, + parsed.currentAccountKey, + now, + now, + parsed.profileId, + parsed.provider, + parsed.runtimeScope, + currentRevocation.revision, + ); + if (changed.changes !== 1) { + throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_CONFLICT"); + } } - if (current.revision >= Number.MAX_SAFE_INTEGER) { - throw new Error("NOTIFICATION_HOURS_REVISION_EXHAUSTED"); + + if (parsed.runtimeScope === "personal") { + this.#database.query( + `UPDATE session_adoption_candidates + SET claim_status='fenced',fenced_fingerprint=candidate_fingerprint, + revision=revision+1 + WHERE provider=? AND claim_status='claiming' AND EXISTS( + SELECT 1 FROM session_adoption_policies p + WHERE p.provider=session_adoption_candidates.provider + AND p.profile_id=? AND p.state='enabled' + )`, + ).run(parsed.provider, parsed.profileId); + this.#database.query( + `UPDATE session_adoption_policies + SET profile_id=NULL,state='disabled',revision=revision+1, + updated_at=MAX(updated_at,?) + WHERE provider=? AND profile_id=? AND state='enabled'`, + ).run(now, parsed.provider, parsed.profileId); } - const result = this.#database.query( - `UPDATE notification_hours - SET version=?,revision=revision+1,start_minute=?,end_minute=?,time_zone=?, - updated_at=MAX(updated_at,?) - WHERE singleton=1 AND revision=?`, - ).run( - desired.version, - desired.startMinute, - desired.endMinute, - desired.timeZone, - updatedAt, - parsedInput.expectedRevision, - ); - if (result.changes !== 1) { - throw new Error("NOTIFICATION_HOURS_REVISION_CONFLICT"); + + const sessionIds: SessionId[] = []; + const affectedWorkIds = new Set(); + const interactions: InteractionRecord[] = []; + let afterSessionId = ""; + for (;;) { + // Runtime scope comes from the exact active/detaching binding. The + // immutable authority row is deliberately only a LEFT JOIN: absent, + // wrong-provider and wrong-scope rows are precisely what must be fenced. + const page = this.#database.query( + `SELECT s.id FROM sessions s + LEFT JOIN session_personal_runtime_bindings b + ON b.session_id=s.id AND b.provider=s.provider_v39 + AND b.provider_thread_id=s.provider_thread_id + AND b.state IN ('active','detaching') + LEFT JOIN session_provider_account_authorities a ON a.session_id=s.id + WHERE s.profile_id=? AND s.provider_v39=? AND s.id>? + AND ((?='personal' AND b.session_id IS NOT NULL) + OR (?='managed' AND b.session_id IS NULL)) + AND (a.session_id IS NULL OR a.provider!=s.provider_v39 + OR a.runtime_scope!=? OR ? IS NULL OR a.account_key!=?) + ORDER BY s.id LIMIT 500`, + ).all( + parsed.profileId, + parsed.provider, + afterSessionId, + parsed.runtimeScope, + parsed.runtimeScope, + parsed.runtimeScope, + parsed.currentAccountKey, + parsed.currentAccountKey, + ).map((row) => z.object({ id: sessionIdSchema }).strict().parse(row).id); + if (page.length === 0) break; + for (const workId of input.workStore.prepareSessionAuthorityChange( + page, + parsed.expectedGeneration, + )) affectedWorkIds.add(workId); + for (const sessionId of page) { + sessionIds.push(sessionId); + this.#database.query( + `UPDATE sessions + SET state='recovery_required',active_turn_id=NULL, + revision=revision+1,updated_at=MAX(updated_at,?) + WHERE id=? AND state!='terminal' + AND (state!='recovery_required' OR active_turn_id IS NOT NULL)`, + ).run(now, sessionId); + this.#database.query( + `UPDATE queue_entries + SET state=CASE state WHEN 'pending' THEN 'cancelled' ELSE 'ambiguous' END, + updated_at=MAX(updated_at,?) + WHERE session_id=? AND state IN ('pending','dispatching')`, + ).run(now, sessionId); + this.#database.query( + `UPDATE mutation_attempts + SET state=CASE state WHEN 'prepared' THEN 'cancelled' ELSE 'ambiguous' END, + updated_at=MAX(updated_at,?) + WHERE state IN ('prepared','effect_started') AND ( + authority_id=? OR id IN ( + SELECT attempt_id FROM session_start_attempts WHERE session_id=? + ) + )`, + ).run(now, sessionId, sessionId); + this.#database.query( + `UPDATE session_tasks + SET status='paused',revision=revision+1,next_due_at=NULL, + updated_at=MAX(updated_at+1,?) + WHERE session_id=? AND deleted_at IS NULL AND status='active'`, + ).run(now, sessionId); + if (parsed.runtimeScope === "personal") { + const binding = this.readSessionPersonalRuntimeBinding(sessionId, true); + if ( + binding === null + || binding.provider !== parsed.provider + || binding.state === "detached" + ) throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_BINDING_MISSING"); + if (binding.state === "active") { + const launchInFlight = this.#database.query( + `SELECT 1 FROM session_claude_process_launch_intents + WHERE session_id=? AND profile_id=? AND profile_generation=? + AND runtime_scope='personal' LIMIT 1`, + ).get(sessionId, parsed.profileId, parsed.expectedGeneration); + if (launchInFlight === null) { + const changed = this.#database.query( + `UPDATE session_personal_runtime_bindings + SET state='detaching',revision=revision+1, + updated_at=MAX(updated_at,?) + WHERE session_id=? AND revision=? AND state='active'`, + ).run(now, sessionId, binding.revision); + if (changed.changes !== 1) { + throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_BINDING_CONFLICT"); + } + } + } + this.#database.query( + `UPDATE session_adoption_candidates + SET claim_status='fenced',fenced_fingerprint=candidate_fingerprint, + revision=revision+1 + WHERE provider=? AND provider_thread_id=? + AND (claim_status!='fenced' + OR fenced_fingerprint IS NOT candidate_fingerprint)`, + ).run(binding.provider, binding.providerThreadId); + } + const interactionRows = this.#database.query( + `SELECT * FROM provider_interactions + WHERE session_id=? + AND state IN ('pending','response_prepared','response_written') + ORDER BY requested_at,public_id`, + ).all(sessionId); + for (const value of interactionRows) { + const current = interactionRowSchema.parse(value); + const terminalState = current.state === "pending" + ? "expired" + : "resolution_unknown"; + const changed = this.#database.query( + `UPDATE provider_interactions + SET state=?,revision=revision+1,updated_at=MAX(updated_at,?), + terminal_at=MAX(requested_at,?) + WHERE public_id=? AND revision=? AND state=?`, + ).run( + terminalState, + now, + now, + current.public_id, + current.revision, + current.state, + ); + if (changed.changes !== 1) { + throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_INTERACTION_CONFLICT"); + } + const terminal = this.#requireInteractionRow(current.public_id); + this.#recordInteractionTransition(terminal, now); + const interaction = mapInteraction(terminal); + this.#ensureInteractionStateEventInTransaction(interaction, now); + interactions.push(interaction); + } + // Revocation is a permanent loss of this exact session authority, + // not merely a same-generation pause. Removing the mismatched proof + // here prevents a daemon-generation advance or a later same-scope + // revocation from making an old account key authoritative again. + this.#database.query( + "DELETE FROM session_provider_account_authorities WHERE session_id=?", + ).run(sessionId); + } + const last = page.at(-1); + if (last === undefined) break; + afterSessionId = last; } - const emailResult = this.#database.query( - `UPDATE attention_email_policy - SET revision=revision+1,updated_at=MAX(updated_at,?) - WHERE singleton=1 AND revision=?`, - ).run(updatedAt, parsedInput.expectedRevision); - if (emailResult.changes !== 1) { - throw new Error("NOTIFICATION_HOURS_REVISION_CONFLICT"); + const bindings = parsed.runtimeScope === "personal" + ? this.#database.query( + `SELECT b.* FROM session_personal_runtime_bindings b + JOIN sessions s ON s.id=b.session_id + WHERE s.profile_id=? AND b.provider=? AND b.state='detaching' + ORDER BY b.session_id`, + ).all(parsed.profileId, parsed.provider).map(mapSessionPersonalRuntimeBinding) + : []; + const revocation = this.readProviderRuntimeAccountRevocation(revocationSelector); + if (revocation?.state !== "releasing") { + throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_BEGIN_INCOMPLETE"); } - return this.readNotificationHours(); + return { + revocation, + bindings, + interactions, + sessionIds, + affectedWorkIds: [...affectedWorkIds].sort(), + }; }); - return write.immediate(); + return begin.immediate(); } - updateNotificationEmailPolicy( - input: Readonly<{ enabled: boolean; expectedRevision: number }>, - ): NotificationEmailPolicy { - const parsedInput = z.object({ - enabled: z.boolean(), - expectedRevision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + completeProviderRuntimeAccountRevocation(input: { + profileId: ProfileId; + expectedGeneration: number; + provider: AdoptableProvider; + runtimeScope: z.infer; + expectedRevision: number; + }): ProviderRuntimeAccountRevocationRecord { + const parsed = z.object({ + profileId: profileIdSchema, + expectedGeneration: z.number().int().nonnegative().safe(), + provider: adoptableProviderSchema, + runtimeScope: claudeProcessRuntimeScopeSchema, + expectedRevision: z.number().int().positive().safe(), }).strict().parse(input); - const updatedAt = unixMillisecondsSchema.parse(this.#now()); - const write = this.#database.transaction((): NotificationEmailPolicy => { - const current = this.readNotificationEmailPolicy(); - if (current.revision !== parsedInput.expectedRevision) { - throw new Error("ATTENTION_EMAIL_POLICY_REVISION_CONFLICT"); + const complete = this.#database.transaction(() => { + const revocationSelector = { + profileId: parsed.profileId, + provider: parsed.provider, + runtimeScope: parsed.runtimeScope, + } as const; + const current = this.readProviderRuntimeAccountRevocation(revocationSelector); + if (current === null) { + throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_MISSING"); } - if (current.revision >= Number.MAX_SAFE_INTEGER) { - throw new Error("ATTENTION_EMAIL_POLICY_REVISION_EXHAUSTED"); + if (current.state === "completed") { + if ( + current.profileGeneration === parsed.expectedGeneration + && current.revision === parsed.expectedRevision + 1 + ) return current; + throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_CONFLICT"); } - const emailResult = this.#database.query( - `UPDATE attention_email_policy - SET enabled=?,revision=revision+1,updated_at=MAX(updated_at,?) - WHERE singleton=1 AND revision=?`, + if ( + current.profileGeneration !== parsed.expectedGeneration + || current.revision !== parsed.expectedRevision + ) throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_CONFLICT"); + const profile = this.requireProfileById(parsed.profileId, { includeRemoved: true }); + if ( + profile.processGeneration !== parsed.expectedGeneration + || profile.state === "removed" + ) throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_STALE"); + if (parsed.provider === "claude") { + const liveProcess = this.#database.query( + `SELECT 1 FROM session_claude_process_authorities + WHERE profile_id=? AND runtime_scope=? AND state!='released' LIMIT 1`, + ).get(parsed.profileId, parsed.runtimeScope); + if (liveProcess !== null) { + throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_CLAUDE_PROCESS_LIVE"); + } + const launchIntent = this.#database.query( + `SELECT 1 FROM session_claude_process_launch_intents + WHERE profile_id=? AND runtime_scope=? LIMIT 1`, + ).get(parsed.profileId, parsed.runtimeScope); + if (launchIntent !== null) { + throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_CLAUDE_LAUNCH_INTENT_LIVE"); + } + } + if (parsed.runtimeScope === "personal") { + const detaching = this.#database.query( + `SELECT 1 FROM session_personal_runtime_bindings b + JOIN sessions s ON s.id=b.session_id + WHERE s.profile_id=? AND b.provider=? AND b.state='detaching' LIMIT 1`, + ).get(parsed.profileId, parsed.provider); + if (detaching !== null) { + throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_BINDINGS_LIVE"); + } + } + const now = unixMillisecondsSchema.parse(this.#now()); + const changed = this.#database.query( + `UPDATE provider_runtime_account_revocations + SET state='completed',revision=revision+1, + updated_at=MAX(updated_at,?),completed_at=? + WHERE profile_id=? AND provider=? AND runtime_scope=? + AND profile_generation=? AND revision=? AND state='releasing'`, ).run( - parsedInput.enabled ? 1 : 0, - updatedAt, - parsedInput.expectedRevision, + now, + now, + parsed.profileId, + parsed.provider, + parsed.runtimeScope, + parsed.expectedGeneration, + parsed.expectedRevision, ); - if (emailResult.changes !== 1) { - throw new Error("ATTENTION_EMAIL_POLICY_REVISION_CONFLICT"); + if (changed.changes !== 1) { + throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_CONFLICT"); } - const hoursResult = this.#database.query( - `UPDATE notification_hours - SET revision=revision+1,updated_at=MAX(updated_at,?) - WHERE singleton=1 AND revision=?`, - ).run(updatedAt, parsedInput.expectedRevision); - if (hoursResult.changes !== 1) { - throw new Error("ATTENTION_EMAIL_POLICY_REVISION_CONFLICT"); + const completed = this.readProviderRuntimeAccountRevocation(revocationSelector); + if (completed?.state !== "completed") { + throw new Error("PROVIDER_ACCOUNT_AUTHORITY_REVOCATION_COMPLETE_INCOMPLETE"); } - return this.readNotificationEmailPolicy(); + return completed; }); - return write.immediate(); - } - - // --- Settings projection: show thinking and the daemon default preset ---- - - readDefaultShowThinking(): boolean { - const row = this.#database.query("SELECT default_show_thinking FROM daemon_state WHERE singleton=1").get(); - return z.object({ default_show_thinking: z.union([z.literal(0), z.literal(1)]) }) - .strict().parse(row).default_show_thinking === 1; - } - - setDefaultShowThinking(enabled: boolean): void { - const result = this.#database.query("UPDATE daemon_state SET default_show_thinking=? WHERE singleton=1") - .run(enabled ? 1 : 0); - if (result.changes !== 1) throw new Error("DAEMON_STATE_MISSING"); - } - - readSessionShowThinking(sessionId: SessionId): Readonly<{ enabled: boolean; source: "session" | "default" }> { - const parsedSessionId = sessionIdSchema.parse(sessionId); - const row = this.#database.query("SELECT enabled FROM session_show_thinking WHERE session_id=?").get(parsedSessionId); - if (row !== null) { - const parsed = z.object({ enabled: z.union([z.literal(0), z.literal(1)]) }).strict().parse(row); - return { enabled: parsed.enabled === 1, source: "session" }; - } - return { enabled: this.readDefaultShowThinking(), source: "default" }; + return complete.immediate(); } - setSessionShowThinking(sessionId: SessionId, enabled: boolean | null): void { - const parsedSessionId = sessionIdSchema.parse(sessionId); - if (enabled === null) { - this.#database.query("DELETE FROM session_show_thinking WHERE session_id=?").run(parsedSessionId); - return; + beginProfilePersonalAuthorityRevocation(input: { + profileId: ProfileId; + expectedGeneration: number; + workStore: WorkStore; + }): Readonly<{ + revocation: ProfilePersonalAuthorityRevocationRecord; + bindings: readonly SessionPersonalRuntimeBindingRecord[]; + interactions: readonly InteractionRecord[]; + sessionIds: readonly SessionId[]; + affectedWorkIds: readonly string[]; + }> { + const parsed = z.object({ + profileId: profileIdSchema, + expectedGeneration: z.number().int().nonnegative().safe(), + }).strict().parse({ + profileId: input.profileId, + expectedGeneration: input.expectedGeneration, + }); + if (!input.workStore.isBackedByDatabase(this.#database)) { + throw new Error("PROFILE_PERSONAL_AUTHORITY_REVOCATION_WORK_STORE_MISMATCH"); } - const write = this.#database.transaction(() => { - if (this.#database.query("SELECT 1 FROM sessions WHERE id=?").get(parsedSessionId) === null) { - throw new SelectionError("NOT_FOUND"); + const begin = this.#database.transaction(() => { + const profile = this.requireProfileById(parsed.profileId); + if ( + profile.processGeneration !== parsed.expectedGeneration + || profile.state === "removed" + ) { + throw new Error("PROFILE_PERSONAL_AUTHORITY_REVOCATION_STALE"); } + const now = unixMillisecondsSchema.parse(this.#now()); + const resetPolicy = this.requireAccountRateLimitResetPolicy(parsed.profileId); + const accountFingerprints = new Set(); + if (resetPolicy.accountFingerprint !== null) { + accountFingerprints.add(resetPolicy.accountFingerprint); + } + if (profile.providerEmail !== undefined) { + accountFingerprints.add(canonicalAccountFingerprint(profile.providerEmail)); + } + for (const accountFingerprint of accountFingerprints) { + this.#closeRecoverableAccountRateLimitResetIdentityAttempts({ + profileId: parsed.profileId, + accountFingerprint, + selection: "matching", + now, + }); + } + if ( + resetPolicy.state !== "reconciliation_required" + || resetPolicy.accountFingerprint !== null + || resetPolicy.weeklyWindowResetsAt !== null + ) { + const resetPolicyChanged = this.#database.query( + `UPDATE account_rate_limit_reset_policies + SET state='reconciliation_required',account_fingerprint=NULL, + weekly_window_resets_at=NULL,revision=revision+1, + updated_at=MAX(updated_at,?) + WHERE profile_id=? AND revision=?`, + ).run(now, parsed.profileId, resetPolicy.revision); + if (resetPolicyChanged.changes !== 1) { + throw new Error("ACCOUNT_RATE_LIMIT_RESET_POLICY_CONFLICT"); + } + } + const currentRevocation = this.readProfilePersonalAuthorityRevocation(parsed.profileId); + if (currentRevocation === null) { + this.#database.query( + `INSERT INTO profile_personal_authority_revocations( + profile_id,profile_generation,state,revision,created_at,updated_at,completed_at + ) VALUES (?,?,'releasing',1,?,?,NULL)`, + ).run(parsed.profileId, parsed.expectedGeneration, now, now); + } else if ( + currentRevocation.state === "completed" + && currentRevocation.profileGeneration !== parsed.expectedGeneration + ) { + this.#database.query( + `UPDATE profile_personal_authority_revocations + SET profile_generation=?,state='releasing',revision=revision+1, + created_at=?,updated_at=?,completed_at=NULL + WHERE profile_id=? AND revision=? AND state='completed'`, + ).run( + parsed.expectedGeneration, + now, + now, + parsed.profileId, + currentRevocation.revision, + ); + } else if ( + currentRevocation.state !== "releasing" + || currentRevocation.profileGeneration !== parsed.expectedGeneration + ) { + throw new Error("PROFILE_PERSONAL_AUTHORITY_REVOCATION_CONFLICT"); + } + + const affectedWorkIds = input.workStore.prepareProfileAuthorityChange( + parsed.profileId, + parsed.expectedGeneration, + ); + this.#database.query( + `UPDATE profiles SET state='recovery_required',updated_at=MAX(updated_at,?) + WHERE id=? AND process_generation=? AND state!='removed'`, + ).run(now, parsed.profileId, parsed.expectedGeneration); + this.#database.query( + `UPDATE session_adoption_candidates + SET claim_status='fenced',fenced_fingerprint=candidate_fingerprint, + revision=revision+1 + WHERE claim_status='claiming' AND provider IN ( + SELECT provider FROM session_adoption_policies + WHERE profile_id=? AND state='enabled' + )`, + ).run(parsed.profileId); this.#database.query( - `INSERT INTO session_show_thinking(session_id,enabled,updated_at) VALUES (?,?,?) - ON CONFLICT(session_id) DO UPDATE SET enabled=excluded.enabled,updated_at=excluded.updated_at`, - ).run(parsedSessionId, enabled ? 1 : 0, this.#now()); - }); - write.immediate(); - } + `UPDATE session_adoption_policies + SET profile_id=NULL,state='disabled',revision=revision+1,updated_at=MAX(updated_at,?) + WHERE profile_id=? AND state='enabled'`, + ).run(now, parsed.profileId); + + // Native and adopted sessions have the same account authority. Process + // every nonterminal row, in bounded pages, without imposing a profile- + // wide cap that could leave the tail operational. + const sessionIds: SessionId[] = []; + let afterSessionId = ""; + for (;;) { + const page = this.#database.query( + `SELECT id FROM sessions + WHERE profile_id=? AND state!='terminal' AND id>? + ORDER BY id LIMIT 500`, + ).all(parsed.profileId, afterSessionId) + .map((row) => z.object({ id: sessionIdSchema }).strict().parse(row).id); + if (page.length === 0) break; + for (const sessionId of page) { + sessionIds.push(sessionId); + this.#database.query( + `UPDATE sessions + SET state='recovery_required',active_turn_id=NULL, + revision=revision+1,updated_at=MAX(updated_at,?) + WHERE id=? AND state!='terminal' + AND (state!='recovery_required' OR active_turn_id IS NOT NULL)`, + ).run(now, sessionId); + this.#database.query( + `UPDATE queue_entries + SET state=CASE state WHEN 'pending' THEN 'cancelled' ELSE 'ambiguous' END, + updated_at=MAX(updated_at,?) + WHERE session_id=? AND state IN ('pending','dispatching')`, + ).run(now, sessionId); + this.#database.query( + `UPDATE mutation_attempts + SET state=CASE state WHEN 'prepared' THEN 'cancelled' ELSE 'ambiguous' END, + updated_at=MAX(updated_at,?) + WHERE state IN ('prepared','effect_started') AND ( + authority_id=? OR id IN ( + SELECT attempt_id FROM session_start_attempts WHERE session_id=? + ) + )`, + ).run(now, sessionId, sessionId); + this.#database.query( + `UPDATE session_tasks + SET status='paused',revision=revision+1,next_due_at=NULL, + updated_at=MAX(updated_at+1,?) + WHERE session_id=? AND deleted_at IS NULL AND status='active'`, + ).run(now, sessionId); + } + const last = page.at(-1); + if (last === undefined) break; + afterSessionId = last; + } - /** - * The daemon default is stored as a provider-neutral tier and read back - * against the daemon's default provider, so a Claude default and a Codex - * default share one column. - */ - readDefaultPreset(provider: Provider = "codex"): Preset { - const row = this.#database.query("SELECT default_preset FROM daemon_state WHERE singleton=1").get(); - const tier = z.object({ default_preset: presetTierSchema }).strict().parse(row).default_preset; - return presetForProviderTier(provider, tier); - } + const bindingRows = this.#database.query( + `SELECT b.* FROM session_personal_runtime_bindings b + JOIN sessions s ON s.id=b.session_id + WHERE s.profile_id=? AND b.state IN ('active','detaching') + ORDER BY b.session_id`, + ).all(parsed.profileId); + for (const value of bindingRows) { + const binding = mapSessionPersonalRuntimeBinding(value); + const session = this.requireSession(binding.sessionId); + if ( + session.provider !== binding.provider + || session.providerThreadId !== binding.providerThreadId + ) throw new Error("PROFILE_PERSONAL_AUTHORITY_REVOCATION_BINDING_MISMATCH"); + if (binding.state === "active") { + const launchInFlight = this.#database.query( + `SELECT 1 FROM session_claude_process_launch_intents + WHERE session_id=? AND profile_id=? AND profile_generation=? + LIMIT 1`, + ).get(binding.sessionId, parsed.profileId, parsed.expectedGeneration); + if (launchInFlight === null) { + this.#database.query( + `UPDATE session_personal_runtime_bindings + SET state='detaching',revision=revision+1,updated_at=MAX(updated_at,?) + WHERE session_id=? AND revision=? AND state='active'`, + ).run(now, binding.sessionId, binding.revision); + } + } + this.#database.query( + `UPDATE session_adoption_candidates + SET claim_status='fenced',fenced_fingerprint=candidate_fingerprint, + revision=revision+1 + WHERE provider=? AND provider_thread_id=? + AND (claim_status!='fenced' + OR fenced_fingerprint IS NOT candidate_fingerprint)`, + ).run(binding.provider, binding.providerThreadId); + } - setDefaultPreset(preset: Preset): void { - const parsed = presetSchema.parse(preset); - const result = this.#database.query("UPDATE daemon_state SET default_preset=? WHERE singleton=1").run(presetTiers[parsed]); - if (result.changes !== 1) throw new Error("DAEMON_STATE_MISSING"); + const interactionRows = this.#database.query( + `SELECT * FROM provider_interactions + WHERE profile_id=? AND process_generation=? + AND state IN ('pending','response_prepared','response_written') + ORDER BY requested_at,public_id`, + ).all(parsed.profileId, parsed.expectedGeneration); + const interactions: InteractionRecord[] = []; + for (const value of interactionRows) { + const current = interactionRowSchema.parse(value); + const terminalState = current.state === "pending" ? "expired" : "resolution_unknown"; + this.#database.query( + `UPDATE provider_interactions + SET state=?,revision=revision+1,updated_at=MAX(updated_at,?), + terminal_at=MAX(requested_at,?) + WHERE public_id=? AND revision=? AND state=?`, + ).run( + terminalState, + now, + now, + current.public_id, + current.revision, + current.state, + ); + const terminal = this.#requireInteractionRow(current.public_id); + this.#recordInteractionTransition(terminal, now); + const interaction = mapInteraction(terminal); + this.#ensureInteractionStateEventInTransaction(interaction, now); + interactions.push(interaction); + } + const revocation = this.readProfilePersonalAuthorityRevocation(parsed.profileId); + if (revocation?.state !== "releasing") { + throw new Error("PROFILE_PERSONAL_AUTHORITY_REVOCATION_BEGIN_INCOMPLETE"); + } + return { + revocation, + bindings: this.#database.query( + `SELECT b.* FROM session_personal_runtime_bindings b + JOIN sessions s ON s.id=b.session_id + WHERE s.profile_id=? AND b.state='detaching' + ORDER BY b.session_id`, + ).all(parsed.profileId).map(mapSessionPersonalRuntimeBinding), + interactions, + sessionIds, + affectedWorkIds, + }; + }); + return begin.immediate(); } - listLocalSessionPage(input: Readonly<{ + completeProfilePersonalAuthorityRevocation(input: { profileId: ProfileId; - after: Readonly<{ createdAt: number; sessionId: SessionId }> | null; - excludedProvider?: Provider; - includeArchived?: boolean; - limit: number; - }>): Readonly<{ - sessions: readonly SessionRecord[]; - nextPosition: Readonly<{ createdAt: number; sessionId: SessionId }> | null; - }> { - const profileId = profileIdSchema.parse(input.profileId); - const limit = z.number().int().min(1).max(100).parse(input.limit); - const after = input.after === null - ? null - : { - createdAt: unixMillisecondsSchema.max(Number.MAX_SAFE_INTEGER).parse(input.after.createdAt), - sessionId: sessionIdSchema.parse(input.after.sessionId), - }; - const excludedProvider = input.excludedProvider === undefined - ? null - : providerSchema.parse(input.excludedProvider); - const archiveClause = input.includeArchived === true ? "" : " AND archived_at IS NULL"; - const rows = (after === null - ? this.#database.query( - `SELECT * FROM sessions - WHERE profile_id=? AND (? IS NULL OR provider_v39!=?)${archiveClause} - ORDER BY created_at DESC,id ASC - LIMIT ?`, - ).all(profileId, excludedProvider, excludedProvider, limit + 1) - : this.#database.query( - `SELECT * FROM sessions - WHERE profile_id=? AND (? IS NULL OR provider_v39!=?)${archiveClause} - AND (created_at < ? OR (created_at = ? AND id > ?)) - ORDER BY created_at DESC,id ASC - LIMIT ?`, - ).all( - profileId, - excludedProvider, - excludedProvider, - after.createdAt, - after.createdAt, - after.sessionId, - limit + 1, - )) - .map(mapSession); - const sessions = rows.slice(0, limit); - const last = sessions.at(-1); - return { - sessions, - nextPosition: rows.length > limit && last !== undefined - ? { createdAt: last.createdAt, sessionId: last.id } - : null, - }; - } - - /* - * True while any non-terminal session is mid-turn. This is a cadence hint - * for the cloud sync loop, so it reads one indexless existence row and - * never projects session content. - */ - hasSessionWithActiveTurn(): boolean { - return this.#database.query( - "SELECT 1 AS present FROM sessions WHERE active_turn_id IS NOT NULL AND state='active' LIMIT 1", - ).get() !== null; - } - - listCloudSessionPage(input: Readonly<{ - afterId: string | null; - limit: number; - }>): Readonly<{ - continueAfterId: string | null; - isDone: boolean; - sessions: readonly SessionRecord[]; - }> { - const limit = z.number().int().min(1).max(100).parse(input.limit); - const afterId = input.afterId === null ? null : sessionIdSchema.parse(input.afterId); - const rows = (afterId === null - ? this.#database.query( - "SELECT * FROM sessions ORDER BY id ASC LIMIT ?", - ).all(limit + 1) - : this.#database.query( - "SELECT * FROM sessions WHERE id > ? ORDER BY id ASC LIMIT ?", - ).all(afterId, limit + 1)).map(mapSession); - const isDone = rows.length <= limit; - const sessions = rows.slice(0, limit); - const last = sessions.at(-1); - return { - continueAfterId: isDone ? null : last?.id ?? null, - isDone, - sessions, - }; - } - - requireSession(selector: string): SessionRecord { - if (sessionIdSchema.safeParse(selector).success) { - const exact = this.#database.query("SELECT * FROM sessions WHERE id=?").get(selector); - if (exact !== null) return mapSession(exact); - } - const rows = this.#database.query("SELECT * FROM sessions WHERE title = ? COLLATE NOCASE ORDER BY updated_at DESC,id LIMIT 101").all(selector).map(mapSession); - if (rows.length === 1) { - const only = rows[0]; - if (only === undefined) throw new Error("Session selection cardinality changed unexpectedly."); - return only; - } - if (rows.length === 0) throw new SelectionError("NOT_FOUND"); - throw new SelectionError("AMBIGUOUS", rows.map((session) => ({ id: session.id, label: session.title }))); + expectedGeneration: number; + }): ProfileRecord { + const parsed = z.object({ + profileId: profileIdSchema, + expectedGeneration: z.number().int().nonnegative().safe(), + }).strict().parse(input); + const complete = this.#database.transaction(() => { + const revocation = this.readProfilePersonalAuthorityRevocation(parsed.profileId); + if ( + revocation === null + || revocation.state !== "releasing" + || revocation.profileGeneration !== parsed.expectedGeneration + ) throw new Error("PROFILE_PERSONAL_AUTHORITY_REVOCATION_MISSING"); + if (this.profileHasControllingPersonalSessions(parsed.profileId)) { + throw new Error("PROFILE_PERSONAL_AUTHORITY_REVOCATION_BINDINGS_LIVE"); + } + const liveClaude = this.#database.query( + `SELECT 1 FROM session_claude_process_authorities + WHERE profile_id=? AND state!='released' LIMIT 1`, + ).get(parsed.profileId); + if (liveClaude !== null) { + throw new Error("PROFILE_PERSONAL_AUTHORITY_REVOCATION_CLAUDE_PROCESS_LIVE"); + } + if (this.profileHasClaudeProcessLaunchIntents( + parsed.profileId, + )) { + throw new Error("PROFILE_PERSONAL_AUTHORITY_REVOCATION_CLAUDE_LAUNCH_INTENT_LIVE"); + } + const now = unixMillisecondsSchema.parse(this.#now()); + // An account-wide release can race an ambiguous cross-profile start or + // switch. Preserve that exact mutation's provider authority across the + // generation advance so restart/recovery can still settle it; otherwise + // the next daemon admission sees an unbridgeable origin generation. + this.#recordSessionMutationAuthoritySuccessors({ + fromGeneration: parsed.expectedGeneration, + now, + profileId: parsed.profileId, + }); + const profile = this.#database.query( + `UPDATE profiles + SET process_generation=process_generation+1, + state='signed_out',provider_email=NULL,codex_account_key=NULL, + provider_plan=NULL, + updated_at=MAX(updated_at,?) + WHERE id=? AND process_generation=? AND state='recovery_required' + RETURNING id`, + ).get(now, parsed.profileId, parsed.expectedGeneration); + if (profile === null) { + throw new Error("PROFILE_PERSONAL_AUTHORITY_REVOCATION_PROFILE_CONFLICT"); + } + // A global account release subsumes every in-flight scoped release. The + // scoped worker's old revision can no longer complete over this result. + this.#database.query( + `UPDATE provider_runtime_account_revocations + SET state='completed',revision=revision+1, + updated_at=MAX(updated_at,?),completed_at=? + WHERE profile_id=? AND state='releasing'`, + ).run(now, now, parsed.profileId); + const changed = this.#database.query( + `UPDATE profile_personal_authority_revocations + SET state='completed',revision=revision+1,updated_at=MAX(updated_at,?),completed_at=? + WHERE profile_id=? AND revision=? AND state='releasing'`, + ).run(now, now, parsed.profileId, revocation.revision); + if (changed.changes !== 1) { + throw new Error("PROFILE_PERSONAL_AUTHORITY_REVOCATION_CONFLICT"); + } + return this.requireProfileById(parsed.profileId); + }); + return complete.immediate(); } - #requireSessionPresetBinding(sessionId: SessionId): Readonly<{ - contract: z.infer; - preset: Preset; - requirement: PresetRequirement; - }> { - const parsed = sessionRowSchema.parse( - this.#database.query("SELECT * FROM sessions WHERE id=?").get(sessionIdSchema.parse(sessionId)), - ); - const preset = presetForProviderTier(parsed.provider_v39, parsed.preset); - const requirement = presetRequirementForContract(preset, parsed.preset_contract); - if (requirement === undefined) { - throw new Error("SESSION_PRESET_CONTRACT_UNADMITTED"); - } - return { - contract: parsed.preset_contract, - preset, - requirement, - }; + beginPersonalSessionDetach(input: { sessionId: SessionId }): SessionAdoptionResult { + const parsed = z.object({ sessionId: sessionIdSchema }).strict().parse(input); + const now = unixMillisecondsSchema.parse(this.#now()); + const begin = this.#database.transaction(() => { + const binding = this.readSessionPersonalRuntimeBinding(parsed.sessionId, true); + if (binding === null) throw new Error("SESSION_PERSONAL_RUNTIME_BINDING_NOT_FOUND"); + if (binding.state === "detached") { + throw new Error("SESSION_ADOPTION_BINDING_ALREADY_DETACHED"); + } + if (binding.state === "active") { + const sessionAuthority = z.object({ + state: sessionStateSchema, + active_turn_id: z.string().nullable(), + }).strict().parse(this.#database.query( + "SELECT state,active_turn_id FROM sessions WHERE id=?", + ).get(parsed.sessionId)); + if (sessionAuthority.state === "active" || sessionAuthority.active_turn_id !== null) { + throw new Error("SESSION_ADOPTION_DETACH_ACTIVE_TURN"); + } + if (this.listInteractions({ + sessionId: parsed.sessionId, + pendingOnly: true, + limit: 1, + }).length > 0) { + throw new Error("SESSION_ADOPTION_DETACH_PENDING_INTERACTION"); + } + const unsettledQueue = this.listQueue(parsed.sessionId).some((entry) => + entry.state === "pending" + || entry.state === "dispatching" + || entry.state === "ambiguous" + ); + if ( + unsettledQueue + || this.listUnsettledQueueEffects(parsed.sessionId).length > 0 + ) { + throw new Error("SESSION_ADOPTION_DETACH_UNSETTLED_QUEUE"); + } + if (this.listUnsettledMutations({ sessionId: parsed.sessionId }).length > 0) { + throw new Error("SESSION_ADOPTION_DETACH_UNSETTLED_MUTATION"); + } + const activeTask = this.#database.query( + `SELECT 1 FROM session_tasks + WHERE session_id=? AND deleted_at IS NULL AND status='active' + LIMIT 1`, + ).get(parsed.sessionId); + if (activeTask !== null) { + throw new Error("SESSION_ADOPTION_DETACH_ACTIVE_TASK"); + } + // Prepared attempts have no provider effect. Retire them while the + // authority fence is held so an old idempotency key cannot dispatch + // stale work after a later explicit readoption. + this.#database.query( + `UPDATE mutation_attempts + SET state='cancelled',updated_at=? + WHERE state='prepared' + AND ( + authority_id=? OR id IN ( + SELECT attempt_id FROM session_start_attempts WHERE session_id=? + ) + )`, + ).run(now, parsed.sessionId, parsed.sessionId); + const changed = this.#database.query( + `UPDATE session_personal_runtime_bindings + SET state='detaching',revision=revision+1,updated_at=MAX(updated_at,?) + WHERE session_id=? AND revision=? AND state='active'`, + ).run(now, parsed.sessionId, binding.revision); + if (changed.changes !== 1) throw new Error("SESSION_ADOPTION_BINDING_CONFLICT"); + } + const fenced = this.#database.query( + `UPDATE session_adoption_candidates + SET claim_status='fenced',fenced_fingerprint=candidate_fingerprint, + revision=revision+1 + WHERE provider=? AND provider_thread_id=?`, + ).run(binding.provider, binding.providerThreadId); + if (fenced.changes !== 1) throw new Error("SESSION_ADOPTION_CANDIDATE_NOT_FOUND"); + const detachingBinding = this.readSessionPersonalRuntimeBinding(parsed.sessionId, true); + const candidateRow = this.#database.query( + `SELECT * FROM session_adoption_candidates + WHERE provider=? AND provider_thread_id=?`, + ).get(binding.provider, binding.providerThreadId); + if (detachingBinding?.state !== "detaching" || candidateRow === null) { + throw new Error("SESSION_ADOPTION_DETACH_BEGIN_INCOMPLETE"); + } + return { + session: this.requireSession(parsed.sessionId), + candidate: mapSessionAdoptionCandidate(candidateRow), + binding: detachingBinding, + }; + }); + return begin.immediate(); } - /** Exact route for execution; the durable contract itself remains internal. */ - requireSessionPresetRequirement(sessionId: SessionId): Readonly<{ - preset: Preset; - requirement: PresetRequirement; - }> { - const binding = this.#requireSessionPresetBinding(sessionId); - return { preset: binding.preset, requirement: binding.requirement }; + completePersonalSessionDetach(input: { + sessionId: SessionId; + archive?: boolean; + }): SessionAdoptionResult { + const parsed = z.object({ + sessionId: sessionIdSchema, + archive: z.boolean().optional(), + }).strict().parse(input); + const now = unixMillisecondsSchema.parse(this.#now()); + const complete = this.#database.transaction(() => { + const binding = this.readSessionPersonalRuntimeBinding(parsed.sessionId, true); + if (binding === null) throw new Error("SESSION_PERSONAL_RUNTIME_BINDING_NOT_FOUND"); + if (binding.state === "active") { + throw new Error("SESSION_ADOPTION_DETACH_NOT_BEGUN"); + } + if (binding.state === "detaching") { + const session = this.requireSession(parsed.sessionId); + if (session.provider === "claude") { + const providerThreadId = session.providerThreadId; + if (providerThreadId === undefined) { + throw new Error("SESSION_ADOPTION_DETACH_PROVIDER_BINDING_MISSING"); + } + const processAuthority = this.readClaudeProcessAuthority({ + providerThreadId, + profileId: session.profileId, + runtimeScope: "personal", + }); + if (processAuthority === null || processAuthority.state !== "released") { + throw new Error("SESSION_ADOPTION_DETACH_CLAUDE_PROCESS_AUTHORITY_LIVE"); + } + } + const changed = this.#database.query( + `UPDATE session_personal_runtime_bindings + SET state='detached',revision=revision+1,updated_at=MAX(updated_at,?),detached_at=? + WHERE session_id=? AND revision=? AND state='detaching'`, + ).run(now, now, parsed.sessionId, binding.revision); + if (changed.changes !== 1) throw new Error("SESSION_ADOPTION_BINDING_CONFLICT"); + } + const fenced = this.#database.query( + `UPDATE session_adoption_candidates + SET claim_status='fenced',fenced_fingerprint=candidate_fingerprint, + revision=revision+1 + WHERE provider=? AND provider_thread_id=?`, + ).run(binding.provider, binding.providerThreadId); + if (fenced.changes !== 1) throw new Error("SESSION_ADOPTION_CANDIDATE_NOT_FOUND"); + if (parsed.archive !== false) { + this.#database.query("UPDATE sessions SET archived_at=? WHERE id=?").run(now, parsed.sessionId); + } + const detachedBinding = this.readSessionPersonalRuntimeBinding(parsed.sessionId, true); + const candidateRow = this.#database.query( + `SELECT * FROM session_adoption_candidates + WHERE provider=? AND provider_thread_id=?`, + ).get(binding.provider, binding.providerThreadId); + if (detachedBinding?.state !== "detached" || candidateRow === null) { + throw new Error("SESSION_ADOPTION_DETACH_COMPLETE_INCOMPLETE"); + } + return { + session: this.requireSession(parsed.sessionId), + candidate: mapSessionAdoptionCandidate(candidateRow), + binding: detachedBinding, + }; + }); + return complete.immediate(); } - #assertSessionRuntimeProfileContract( - sessionId: SessionId, - profile: ReviewedRuntimeProfile, - ): void { - const binding = this.#requireSessionPresetBinding(sessionId); - assertRuntimeProfileRequirement( - profile, - binding.preset, - binding.requirement, - "SESSION_RUNTIME_PROFILE_PRESET_CONTRACT_MISMATCH", - ); + /** Storage-only convenience for callers that have no external controller to release. */ + detachPersonalSession(input: { + sessionId: SessionId; + archive?: boolean; + }): SessionAdoptionResult { + this.beginPersonalSessionDetach({ sessionId: input.sessionId }); + return this.completePersonalSessionDetach(input); } - findSessionByProviderThread(profileId: ProfileId, providerThreadId: string): SessionRecord | null { - const row = this.#database.query("SELECT * FROM sessions WHERE profile_id=? AND provider_thread_id=?").get(profileId, providerThreadId); - return row === null ? null : mapSession(row); + #prunePendingSessionAdoptionCandidates( + provider: Provider, + preserveProviderThreadId?: string, + ): void { + const offset = preserveProviderThreadId === undefined + ? SESSION_ADOPTION_PENDING_CANDIDATE_CAP + : SESSION_ADOPTION_PENDING_CANDIDATE_CAP - 1; + const exclusion = preserveProviderThreadId === undefined + ? "" + : " AND provider_thread_id!=?"; + const values = preserveProviderThreadId === undefined + ? [provider, offset] + : [provider, preserveProviderThreadId, offset]; + this.#database.query( + `DELETE FROM session_adoption_candidates WHERE rowid IN ( + SELECT c.rowid FROM session_adoption_candidates c + WHERE c.provider=? AND c.claim_status='pending' + ${exclusion} + AND NOT EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + WHERE b.provider=c.provider + AND b.provider_thread_id=c.provider_thread_id + ) + ORDER BY c.last_observed_at DESC,c.provider_thread_id + LIMIT -1 OFFSET ? + )`, + ).run(...values); } recordSessionRuntimeProfile(input: { @@ -6766,11 +13168,15 @@ export class StateStore { activeTurnId?: string; providerUpdatedAt?: number; runtimeProfile: ReviewedRuntimeProfile; + claudeProcessIdentity?: ClaudeProcessIdentity; receipt: unknown; }): void { const attemptId = attemptIdSchema.parse(input.attemptId); const sessionId = sessionIdSchema.parse(input.sessionId); const profile = reviewedRuntimeProfileSchema.parse(input.runtimeProfile); + const claudeProcessIdentity = input.claudeProcessIdentity === undefined + ? undefined + : claudeProcessIdentitySchema.parse(input.claudeProcessIdentity); const receiptJson = JSON.stringify(input.receipt); const now = this.#now(); const transaction = this.#database.transaction(() => { @@ -6801,6 +13207,30 @@ export class StateStore { z.number().int().positive().parse(input.expectedSessionRevision), ); if (bound.changes !== 1) throw new Error("SESSION_START_BINDING_CAS_CONFLICT"); + const boundSession = this.requireSession(sessionId); + const currentProfile = this.requireProfileById(boundSession.profileId); + if ( + boundSession.profileId !== profile.profileId + || boundSession.provider !== reviewedRuntimeProfileProvider(profile) + || currentProfile.processGeneration !== profile.processGeneration + || !this.sessionAccountAuthorityMatches(sessionId, boundSession.profileId) + ) throw new Error("SESSION_START_ACCOUNT_AUTHORITY_MISMATCH"); + if ((boundSession.provider === "claude") !== (claudeProcessIdentity !== undefined)) { + throw new Error("SESSION_START_CLAUDE_PROCESS_AUTHORITY_REQUIRED"); + } + if (claudeProcessIdentity !== undefined) { + const boundProviderThreadId = boundSession.providerThreadId; + if (boundProviderThreadId === undefined) { + throw new Error("SESSION_START_PROVIDER_BINDING_MISSING"); + } + this.#bindClaudeProcessAuthorityLocked({ + sessionId, + providerThreadId: boundProviderThreadId, + profileId: boundSession.profileId, + runtimeScope: "managed", + identity: claudeProcessIdentity, + }); + } if ( evidence.conversationAutomationCapability === SESSION_CONVERSATION_AUTOMATION_CAPABILITY @@ -6877,21 +13307,162 @@ export class StateStore { return row === null ? null : mapSessionRuntimeProfile(row); } - upsertProviderSession(input: { profileId: ProfileId; providerThreadId: string; projectId?: ProjectId; title: string; state: "active" | "idle" | "terminal"; activeTurnId?: string; providerUpdatedAt?: number }): SessionRecord { + upsertProviderSession(input: { + profileId: ProfileId; + provider: Provider; + providerThreadId: string; + projectId?: ProjectId; + title: string; + preset: Preset; + fastEnabled: boolean; + state: "active" | "idle" | "terminal"; + activeTurnId?: string; + providerUpdatedAt?: number; + providerAccountKey?: string; + conversationAutomationEnabled?: boolean; + }): SessionRecord { + const parsed = z.object({ + profileId: profileIdSchema, + provider: providerSchema, + providerThreadId: providerThreadIdSchema, + projectId: projectIdSchema.optional(), + title: titleSchema, + preset: presetSchema, + fastEnabled: z.boolean(), + state: z.enum(["active", "idle", "terminal"]), + activeTurnId: z.string().min(1).max(2_048).optional(), + providerUpdatedAt: z.number().finite().nonnegative().optional(), + providerAccountKey: providerAccountAuthorityKeySchema.optional(), + conversationAutomationEnabled: z.boolean().optional(), + }).strict().parse(input); + if (parsed.provider === "devin") { + if (parsed.providerAccountKey !== undefined) { + throw new Error("SESSION_IMPORT_DEVIN_ACCOUNT_KEY_FORBIDDEN"); + } + } else if ( + parsed.providerAccountKey === undefined + || !providerAccountAuthorityKeyMatchesProvider( + parsed.provider, + parsed.providerAccountKey, + ) + ) { + throw new Error("SESSION_IMPORT_PROVIDER_ACCOUNT_AUTHORITY_REQUIRED"); + } + assertPresetSupportedByProvider(parsed.provider, parsed.preset); + const upsert = this.#database.transaction(() => { + const session = this.#upsertProviderSessionLocked({ + profileId: parsed.profileId, + provider: parsed.provider, + providerThreadId: parsed.providerThreadId, + ...(parsed.projectId === undefined ? {} : { projectId: parsed.projectId }), + title: parsed.title, + preset: parsed.preset, + presetContract: parsed.provider === "devin" + ? currentPresetContract + : legacyPresetContract, + fastEnabled: parsed.fastEnabled, + state: parsed.state, + ...(parsed.activeTurnId === undefined ? {} : { activeTurnId: parsed.activeTurnId }), + ...(parsed.providerUpdatedAt === undefined + ? {} + : { providerUpdatedAt: parsed.providerUpdatedAt }), + runtimeScope: "managed", + ...(parsed.providerAccountKey === undefined + ? {} + : { providerAccountKey: parsed.providerAccountKey }), + }); + if (parsed.conversationAutomationEnabled === true) { + this.#database.query( + `INSERT INTO session_conversation_automation( + session_id,provider_thread_id,enabled_at + ) VALUES (?,?,?) + ON CONFLICT(session_id) DO NOTHING`, + ).run(session.id, parsed.providerThreadId, this.#now()); + const automation = this.#database.query( + `SELECT 1 FROM session_conversation_automation + WHERE session_id=? AND provider_thread_id=?`, + ).get(session.id, parsed.providerThreadId); + if (automation === null) { + throw new Error("SESSION_IMPORT_CONVERSATION_AUTOMATION_BINDING_CONFLICT"); + } + } + if (!this.sessionAccountAuthorityMatches(session.id, parsed.profileId)) { + throw new Error("SESSION_IMPORT_ACCOUNT_AUTHORITY_MISMATCH"); + } + return session; + }); + return upsert.immediate(); + } + + #upsertProviderSessionLocked(input: { + profileId: ProfileId; + provider: Provider; + providerThreadId: string; + projectId?: ProjectId; + title: string; + preset: Preset; + presetContract: z.infer; + fastEnabled: boolean; + state: "active" | "idle" | "terminal"; + activeTurnId?: string; + providerUpdatedAt?: number; + runtimeScope: z.infer; + providerAccountKey?: string; + }): SessionRecord { const current = this.findSessionByProviderThread(input.profileId, input.providerThreadId); const now = this.#now(); if (current === null) { const id = createSessionId(); - const create = this.#database.transaction(() => { - this.#database.query("INSERT INTO sessions(id,profile_id,project_id,provider_thread_id,title,preset,preset_contract,fast_enabled,state,active_turn_id,provider_updated_at,revision,created_at,updated_at) VALUES (?,?,?,?,?,'high',?,0,?,?,?,1,?,?)").run(id, input.profileId, input.projectId ?? null, input.providerThreadId, titleSchema.parse(input.title), legacyPresetContract, input.state, input.activeTurnId ?? null, input.providerUpdatedAt ?? null, now, now); - this.#insertSessionEventStream(id, now); - }); - create.immediate(); + this.#database.query( + `INSERT INTO sessions( + id,profile_id,project_id,provider_thread_id,title,provider,provider_v39,preset, + preset_contract,fast_enabled,state,active_turn_id,provider_updated_at, + revision,created_at,updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`, + ).run( + id, + input.profileId, + input.projectId ?? null, + input.providerThreadId, + input.title, + legacySessionProviderShadow(input.provider), + input.provider, + presetTiers[input.preset], + input.presetContract, + input.fastEnabled ? 1 : 0, + input.state, + input.activeTurnId ?? null, + input.providerUpdatedAt ?? null, + now, + now, + ); + this.#insertSessionEventStream(id, now); + if (input.provider !== "devin" && input.providerAccountKey !== undefined) { + this.#bindSessionProviderAccountAuthorityLocked({ + sessionId: id, + provider: input.provider, + runtimeScope: input.runtimeScope, + accountKey: input.providerAccountKey, + }, now); + } return this.requireSession(id); } + if (current.provider !== input.provider) { + throw new Error("SESSION_PROVIDER_THREAD_COLLISION"); + } + if (input.provider !== "devin" && input.providerAccountKey !== undefined) { + this.#bindSessionProviderAccountAuthorityLocked({ + sessionId: current.id, + provider: input.provider, + runtimeScope: input.runtimeScope, + accountKey: input.providerAccountKey, + }, now); + } else if (this.readSessionProviderAccountAuthority(current.id) !== null) { + throw new Error("SESSION_IMPORT_DEVIN_ACCOUNT_AUTHORITY_PRESENT"); + } if (current.state === "recovery_required") return current; if (input.providerUpdatedAt === undefined || input.providerUpdatedAt <= (current.providerUpdatedAt ?? -1)) return current; - const result = this.#database.query("UPDATE sessions SET project_id=COALESCE(project_id,?),title=?,state=?,active_turn_id=?,provider_updated_at=?,revision=revision+1,updated_at=? WHERE id=? AND revision=? AND (provider_updated_at IS NULL OR provider_updated_at < ?)").run(input.projectId ?? null, titleSchema.parse(input.title), input.state, input.activeTurnId ?? null, input.providerUpdatedAt, now, current.id, current.revision, input.providerUpdatedAt); + const result = this.#database.query("UPDATE sessions SET project_id=COALESCE(project_id,?),title=?,state=?,active_turn_id=?,provider_updated_at=?,revision=revision+1,updated_at=? WHERE id=? AND revision=? AND (provider_updated_at IS NULL OR provider_updated_at < ?)").run(input.projectId ?? null, input.title, input.state, input.activeTurnId ?? null, input.providerUpdatedAt, now, current.id, current.revision, input.providerUpdatedAt); if (result.changes !== 1) throw new Error("Session changed while importing the provider projection."); return this.requireSession(current.id); } @@ -7601,10 +14172,10 @@ export class StateStore { * * A session's provider, account, preset, and provider thread all move * together: they are one binding, and half of it is never durable on its - * own. The order inside the transaction matters. `sessions` is updated - * first so that `session_runtime_profile_authority_guard` — which requires - * a runtime profile's account to equal the session's account — sees the new - * binding when the target provider's reviewed profile is inserted. The + * own. The order inside the transaction matters. An active personal-home + * binding is retired and its candidate fenced before `sessions` changes. + * The session changes before the runtime profile is inserted, so its + * authority guard sees the new account. The * conversation-automation row follows the new provider thread, otherwise a * scheduled session task would keep addressing the abandoned one. * @@ -7616,6 +14187,7 @@ export class StateStore { attemptId: AttemptId; sessionId: SessionId; expectedSessionRevision: number; + expectedTargetProfileGeneration: number; provider: Provider; profileId: ProfileId; preset: Preset; @@ -7624,6 +14196,8 @@ export class StateStore { activeTurnId?: string; providerUpdatedAt?: number; runtimeProfile: ReviewedRuntimeProfile; + providerAccountKey?: string; + claudeProcessIdentity?: ClaudeProcessIdentity; seedTurnId: string; receipt: unknown; }): SessionRecord { @@ -7631,20 +14205,111 @@ export class StateStore { const sessionId = sessionIdSchema.parse(input.sessionId); const provider = providerSchema.parse(input.provider); const profileId = profileIdSchema.parse(input.profileId); + const expectedTargetProfileGeneration = z.number().int().nonnegative() + .parse(input.expectedTargetProfileGeneration); const preset = presetSchema.parse(input.preset); assertPresetSupportedByProvider(provider, preset); const runtimeProfile = reviewedRuntimeProfileSchema.parse(input.runtimeProfile); + const providerAccountKey = input.providerAccountKey === undefined + ? undefined + : providerAccountAuthorityKeySchema.parse(input.providerAccountKey); + if (provider === "devin") { + if (providerAccountKey !== undefined) { + throw new Error("SESSION_PROVIDER_SWITCH_DEVIN_ACCOUNT_KEY_FORBIDDEN"); + } + } else if ( + providerAccountKey === undefined + || !providerAccountAuthorityKeyMatchesProvider(provider, providerAccountKey) + ) { + throw new Error("SESSION_PROVIDER_SWITCH_TARGET_ACCOUNT_AUTHORITY_REQUIRED"); + } + const claudeProcessIdentity = input.claudeProcessIdentity === undefined + ? undefined + : claudeProcessIdentitySchema.parse(input.claudeProcessIdentity); if (reviewedRuntimeProfileProvider(runtimeProfile) !== provider) { throw new Error("SESSION_PROVIDER_SWITCH_RUNTIME_PROFILE_PROVIDER_MISMATCH"); } if (runtimeProfile.profileId !== profileId) { throw new Error("SESSION_PROVIDER_SWITCH_RUNTIME_PROFILE_ACCOUNT_MISMATCH"); } + if ((provider === "claude") !== (claudeProcessIdentity !== undefined)) { + throw new Error("SESSION_PROVIDER_SWITCH_CLAUDE_PROCESS_AUTHORITY_REQUIRED"); + } const targetPresetContract = presetContractForRuntimeProfile(runtimeProfile, preset); const providerThreadId = providerThreadIdSchema.parse(input.providerThreadId); const seedTurnId = providerThreadIdSchema.parse(input.seedTurnId); const now = this.#now(); const transaction = this.#database.transaction(() => { + const targetProfileAuthority = mapProfile( + this.#database.query("SELECT * FROM profiles WHERE id=?").get(profileId), + ); + if ( + !profileStateAllowsProviderSessionAuthority(provider, targetProfileAuthority.state) + || targetProfileAuthority.processGeneration !== expectedTargetProfileGeneration + || (provider === "codex" && targetProfileAuthority.providerEmail === undefined) + ) { + throw new Error("SESSION_PROVIDER_SWITCH_TARGET_AUTHORITY_CHANGED"); + } + const sourceSession = this.requireSession(sessionId); + const sourceProviderAuthority = this.readSessionProviderAccountAuthority(sessionId); + const expectedSessionRevision = z.number().int().positive().parse( + input.expectedSessionRevision, + ); + if ( + sourceSession.revision !== expectedSessionRevision + || sourceSession.state === "recovery_required" + || sourceSession.state === "terminal" + ) throw new Error("SESSION_PROVIDER_SWITCH_CAS_CONFLICT"); + if ( + sourceSession.provider === "devin" + ? sourceProviderAuthority !== null + : sourceProviderAuthority === null + || sourceProviderAuthority.provider !== sourceSession.provider + ) throw new Error("SESSION_PROVIDER_SWITCH_SOURCE_ACCOUNT_AUTHORITY_MISSING"); + const personalBindingRow = this.#database.query( + `SELECT * FROM session_personal_runtime_bindings + WHERE session_id=? AND state='active'`, + ).get(sessionId); + const personalBinding = personalBindingRow === null + ? null + : mapSessionPersonalRuntimeBinding(personalBindingRow); + if (sourceSession.provider === "claude" && sourceSession.providerThreadId !== undefined) { + const sourceProcess = this.readClaudeProcessAuthority({ + providerThreadId: sourceSession.providerThreadId, + profileId: sourceSession.profileId, + runtimeScope: personalBinding === null ? "managed" : "personal", + }); + if (sourceProcess === null || sourceProcess.state !== "released") { + throw new Error("SESSION_PROVIDER_SWITCH_CLAUDE_PROCESS_AUTHORITY_LIVE"); + } + } + if (personalBinding !== null) { + const retired = this.#database.query( + `UPDATE session_personal_runtime_bindings + SET state='detached',revision=revision+1,updated_at=MAX(updated_at,?),detached_at=? + WHERE session_id=? AND revision=? AND state='active'`, + ).run(now, now, sessionId, personalBinding.revision); + if (retired.changes !== 1) { + throw new Error("SESSION_PROVIDER_SWITCH_PERSONAL_BINDING_CONFLICT"); + } + const fenced = this.#database.query( + `UPDATE session_adoption_candidates + SET claim_status='fenced',fenced_fingerprint=candidate_fingerprint, + revision=revision+1 + WHERE provider=? AND provider_thread_id=?`, + ).run(personalBinding.provider, personalBinding.providerThreadId); + if (fenced.changes !== 1) { + throw new Error("SESSION_PROVIDER_SWITCH_PERSONAL_CANDIDATE_MISSING"); + } + } + if (sourceSession.provider !== "devin") { + const retiredProviderAuthority = this.#database.query( + "DELETE FROM session_provider_account_authorities WHERE session_id=?", + ).run(sessionId); + if (retiredProviderAuthority.changes !== 1) { + throw new Error("SESSION_PROVIDER_SWITCH_SOURCE_ACCOUNT_AUTHORITY_CONFLICT"); + } + } const authority = z.object({ authority_id: sessionIdSchema, authority_generation: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), @@ -7690,8 +14355,10 @@ export class StateStore { || authority.target_released !== 0 || evidence.targetProfileId !== profileId || evidence.targetProvider !== provider + || evidence.targetProviderAccountKey !== providerAccountKey || evidence.targetPreset !== preset || authority.target_provider_thread_id !== providerThreadId + || sessionProviderSwitchTargetAliasesSource(evidence, providerThreadId) || authority.seed_client_message_id !== attemptId || authority.seed_turn_id !== seedTurnId || digestTranscriptSeed(authority.seed_text) !== evidence.seedDigest @@ -7727,6 +14394,10 @@ export class StateStore { || (expectedSeedState === "active" && input.activeTurnId !== seedTurnId) || (expectedSeedState !== "active" && input.activeTurnId !== undefined) ) throw new Error("SESSION_PROVIDER_SWITCH_SEED_STATE_MISMATCH"); + // Rebind through an inert state. The active-state guard must never see + // a target session before its exact provider-account proof exists. Both + // writes and the proof insertion are held by this transaction, so no + // caller can observe the intermediate idle row. const bound = this.#database.query( `UPDATE sessions SET provider=?,provider_v39=?,profile_id=?,preset=?,preset_contract=?,provider_thread_id=?,state=?,active_turn_id=?, @@ -7740,17 +14411,69 @@ export class StateStore { presetTiers[preset], targetPresetContract, providerThreadId, - input.state, - input.activeTurnId ?? null, + "idle", + null, input.providerUpdatedAt ?? null, now, sessionId, - z.number().int().positive().parse(input.expectedSessionRevision), + expectedSessionRevision, evidence.sourceProfileId, evidence.sourceProvider, evidence.sourceProviderThreadId, ); - if (bound.changes !== 1) throw new Error("SESSION_PROVIDER_SWITCH_CAS_CONFLICT"); + // SQLite includes the account-authority rebind trigger's UPDATE in this + // count. The session predicate still names one exact id and revision, so + // any positive count proves the CAS landed while zero proves it did not. + if (bound.changes < 1) throw new Error("SESSION_PROVIDER_SWITCH_CAS_CONFLICT"); + if (provider !== "devin" && providerAccountKey !== undefined) { + this.#bindSessionProviderAccountAuthorityLocked({ + sessionId, + provider, + runtimeScope: "managed", + accountKey: providerAccountKey, + }, now); + } + let expectedReboundRevision = expectedSessionRevision + 1; + if (input.state === "active") { + const activated = this.#database.query( + `UPDATE sessions + SET state='active',active_turn_id=?,revision=revision+1,updated_at=? + WHERE id=? AND revision=? AND profile_id=? AND provider_v39=? + AND provider_thread_id=? AND state='idle' AND active_turn_id IS NULL`, + ).run( + input.activeTurnId ?? null, + now, + sessionId, + expectedReboundRevision, + profileId, + provider, + providerThreadId, + ); + if (activated.changes !== 1) { + throw new Error("SESSION_PROVIDER_SWITCH_CAS_CONFLICT"); + } + expectedReboundRevision += 1; + } + const rebound = this.requireSession(sessionId); + if ( + rebound.revision !== expectedReboundRevision + || rebound.profileId !== profileId + || rebound.provider !== provider + || rebound.providerThreadId !== providerThreadId + || rebound.preset !== preset + || rebound.state !== input.state + || rebound.activeTurnId !== input.activeTurnId + || !this.sessionAccountAuthorityMatches(rebound.id, profileId) + ) throw new Error("SESSION_PROVIDER_SWITCH_CAS_CONFLICT"); + if (claudeProcessIdentity !== undefined) { + this.#bindClaudeProcessAuthorityLocked({ + providerThreadId, + profileId, + sessionId, + runtimeScope: "managed", + identity: claudeProcessIdentity, + }); + } this.#database.query( `UPDATE session_conversation_automation SET provider_thread_id=? WHERE session_id=?`, @@ -7908,21 +14631,14 @@ export class StateStore { const now = unixMillisecondsSchema.parse(this.#now()); const terminalize = this.#database.transaction(() => { const current = this.requireSession(parsedSessionId); - if (input.source === "claude_account_login" || input.source === "devin_account_login") { - const provider = input.source === "claude_account_login" ? "claude" : "devin"; - if (!this.#canReleaseIdleProviderSessionForAccountLogin({ + if ( + input.source === "claude_account_login" + && !this.canReleaseIdleManagedClaudeSessionForAccountLogin({ profileId: accountId, profileGeneration: providerGeneration, - provider, sessionId: current.id, - })) { - throw new Error( - provider === "claude" - ? "CLAUDE_LOGIN_SESSION_NOT_QUIESCENT" - : "DEVIN_LOGIN_SESSION_NOT_QUIESCENT", - ); - } - } + }) + ) throw new Error("CLAUDE_LOGIN_SESSION_NOT_QUIESCENT"); let sessionChanged = false; let event: SessionEvent | undefined; if (current.state !== "terminal") { @@ -8672,10 +15388,12 @@ export class StateStore { now, ); } else { + const providerEmail = receipt.account.email ?? null; const profileChanged = this.#database.query(`UPDATE profiles - SET state='signed_in',provider_email=?,provider_plan=?,updated_at=? + SET state='signed_in',provider_email=?,codex_account_key=?,provider_plan=?,updated_at=? WHERE id=? AND process_generation=? AND state='login_pending'`).run( - receipt.account.email ?? null, + providerEmail, + providerEmail === null ? null : codexProviderAccountAuthorityKeyForEmail(providerEmail), receipt.account.plan ?? null, now, profileId, @@ -8722,11 +15440,13 @@ export class StateStore { throw new Error("LOGIN_CANCEL_AUTHORITY_MISMATCH"); } const now = this.#now(); + const providerEmail = provider.signedIn ? provider.email ?? null : null; const changed = this.#database.query(`UPDATE profiles - SET state=?,provider_email=?,provider_plan=?,updated_at=? + SET state=?,provider_email=?,codex_account_key=?,provider_plan=?,updated_at=? WHERE id=? AND process_generation=? AND state='login_pending'`).run( provider.signedIn ? "signed_in" : "signed_out", - provider.signedIn ? provider.email ?? null : null, + providerEmail, + providerEmail === null ? null : codexProviderAccountAuthorityKeyForEmail(providerEmail), provider.signedIn ? provider.plan ?? null : null, now, profileId, @@ -8942,6 +15662,7 @@ export class StateStore { }>; preset: Preset; fastEnabled: boolean; + providerAccountKey?: string; evidence: Extract; }): SessionRecord { const parsedAttemptId = attemptIdSchema.parse(input.attemptId); @@ -8950,6 +15671,19 @@ export class StateStore { const parsedProjectId = projectIdSchema.parse(input.projectId); const parsedPreset = presetSchema.parse(input.preset); const parsedProvider = providerSchema.parse(input.provider ?? "codex"); + const providerAccountKey = input.providerAccountKey === undefined + ? undefined + : providerAccountAuthorityKeySchema.parse(input.providerAccountKey); + if (parsedProvider === "devin") { + if (providerAccountKey !== undefined) { + throw new Error("SESSION_START_DEVIN_ACCOUNT_KEY_FORBIDDEN"); + } + } else if ( + providerAccountKey === undefined + || !providerAccountAuthorityKeyMatchesProvider(parsedProvider, providerAccountKey) + ) { + throw new Error("SESSION_START_PROVIDER_ACCOUNT_AUTHORITY_REQUIRED"); + } const providerAuthentication = input.providerAuthentication === undefined ? undefined : providerAuthenticationSchema.parse(input.providerAuthentication); @@ -8991,19 +15725,33 @@ export class StateStore { const sessionId = createSessionId(); const now = this.#now(); const begin = this.#database.transaction(() => { - const authority = z.object({ kind: z.literal("session.start"), authority_id: profileIdSchema, authority_generation: z.number().int().nonnegative(), state: z.literal("prepared"), process_generation: z.number().int().nonnegative(), profile_state: profileStateSchema }).strict().parse( - this.#database.query(`SELECT m.kind,m.authority_id,m.authority_generation,m.state,p.process_generation,p.state AS profile_state + const authority = z.object({ kind: z.literal("session.start"), authority_id: profileIdSchema, authority_generation: z.number().int().nonnegative(), state: z.literal("prepared"), process_generation: z.number().int().nonnegative(), profile_state: profileStateSchema, provider_email: z.string().nullable() }).strict().parse( + this.#database.query(`SELECT m.kind,m.authority_id,m.authority_generation,m.state,p.process_generation,p.state AS profile_state,p.provider_email FROM mutation_attempts m JOIN profiles p ON p.id=m.authority_id WHERE m.id=?`).get(parsedAttemptId), ); if ( authority.authority_id !== parsedProfileId || authority.authority_generation !== parsedGeneration || authority.process_generation !== parsedGeneration - || authority.profile_state === "removed" - || (parsedProvider === "codex" && authority.profile_state !== "signed_in") + || !profileStateAllowsProviderSessionAuthority( + parsedProvider, + authority.profile_state, + ) + || (parsedProvider === "codex" && authority.provider_email === null) ) throw new Error("MUTATION_EFFECT_AUTHORITY_CHANGED"); this.#database.query("INSERT INTO sessions(id,profile_id,project_id,title,provider,provider_v39,preset,preset_contract,fast_enabled,state,revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)").run(sessionId, parsedProfileId, parsedProjectId, "Untitled session", legacySessionProviderShadow(parsedProvider), parsedProvider, presetTiers[parsedPreset], currentPresetContract, input.fastEnabled ? 1 : 0, "starting", 1, now, now); this.#insertSessionEventStream(sessionId, now); + if (parsedProvider !== "devin") { + this.#bindSessionProviderAccountAuthorityLocked({ + sessionId, + provider: parsedProvider, + runtimeScope: "managed", + accountKey: providerAccountKey as string, + }, now); + } + if (!this.sessionAccountAuthorityMatches(sessionId, parsedProfileId)) { + throw new Error("SESSION_START_ACCOUNT_AUTHORITY_MISMATCH"); + } this.#database.query("INSERT INTO session_start_attempts(attempt_id,session_id,created_at) VALUES (?,?,?)").run(parsedAttemptId, sessionId, now); this.#database.query("INSERT INTO mutation_effect_evidence(attempt_id,kind,evidence_json,evidence_digest,recorded_at) VALUES (?,?,?,?,?)").run(parsedAttemptId, evidence.kind, canonical, digest, now); const changed = this.#database.query("UPDATE mutation_attempts SET state='effect_started',updated_at=? WHERE id=? AND state='prepared'").run(now, parsedAttemptId); @@ -9027,6 +15775,19 @@ export class StateStore { const attemptId = attemptIdSchema.parse(input.attemptId); const sessionId = sessionIdSchema.parse(input.sessionId); const evidence = mutationEffectEvidenceSchema.parse(input.evidence) as typeof input.evidence; + if (evidence.targetProvider === "devin") { + if (evidence.targetProviderAccountKey !== undefined) { + throw new Error("SESSION_PROVIDER_SWITCH_DEVIN_ACCOUNT_KEY_FORBIDDEN"); + } + } else if ( + evidence.targetProviderAccountKey === undefined + || !providerAccountAuthorityKeyMatchesProvider( + evidence.targetProvider, + evidence.targetProviderAccountKey, + ) + ) { + throw new Error("SESSION_PROVIDER_SWITCH_TARGET_ACCOUNT_AUTHORITY_REQUIRED"); + } const providerAuthentication = input.providerAuthentication === undefined ? undefined : providerAuthenticationSchema.parse(input.providerAuthentication); @@ -9071,6 +15832,7 @@ export class StateStore { session_state: sessionStateSchema, target_process_generation: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), target_profile_state: profileStateSchema, + target_provider_email: z.string().nullable(), }).strict().parse(this.#database.query( `SELECT (SELECT generation FROM daemon_state WHERE singleton=1) AS daemon_generation, m.kind,m.authority_id,m.authority_generation,m.state AS mutation_state, @@ -9078,7 +15840,7 @@ export class StateStore { s.provider_v39 AS source_provider,s.provider_thread_id AS source_provider_thread_id, s.preset AS source_preset,s.state AS session_state, tp.process_generation AS target_process_generation, - tp.state AS target_profile_state + tp.state AS target_profile_state,tp.provider_email AS target_provider_email FROM mutation_attempts m JOIN sessions s ON s.id=m.authority_id JOIN profiles sp ON sp.id=s.profile_id @@ -9098,9 +15860,36 @@ export class StateStore { !== evidence.sourcePreset || authority.session_state === "terminal" || authority.session_state === "recovery_required" + || !this.sessionAccountAuthorityMatches(sessionId, evidence.sourceProfileId) || authority.target_process_generation !== evidence.targetProcessGeneration - || authority.target_profile_state === "removed" - || (evidence.targetProvider === "codex" && authority.target_profile_state !== "signed_in") + || !profileStateAllowsProviderSessionAuthority( + evidence.targetProvider, + authority.target_profile_state, + ) + || ( + evidence.targetProvider === "codex" + && ( + authority.target_provider_email === null + || codexProviderAccountAuthorityKeyForEmail( + authority.target_provider_email, + ) !== evidence.targetProviderAccountKey + ) + ) + ) throw new Error("SESSION_PROVIDER_SWITCH_AUTHORITY_CHANGED"); + const targetRevocation = evidence.targetProvider === "devin" + ? null + : this.readProviderRuntimeAccountRevocation({ + profileId: evidence.targetProfileId, + provider: evidence.targetProvider, + runtimeScope: "managed", + }); + if ( + targetRevocation?.profileGeneration === evidence.targetProcessGeneration + && ( + targetRevocation.state === "releasing" + || targetRevocation.currentAccountKey === null + || targetRevocation.currentAccountKey !== evidence.targetProviderAccountKey + ) ) throw new Error("SESSION_PROVIDER_SWITCH_AUTHORITY_CHANGED"); this.#database.query( "INSERT INTO mutation_effect_evidence(attempt_id,kind,evidence_json,evidence_digest,recorded_at) VALUES (?,?,?,?,?)", @@ -9142,6 +15931,7 @@ export class StateStore { row.authority_id !== sessionId || evidence.kind !== "session.switch" || digestJson(evidence) !== row.evidence_digest + || sessionProviderSwitchTargetAliasesSource(evidence, providerThreadId) || !this.isSessionMutationProviderAuthorityCurrent({ attemptId, profileId: evidence.targetProfileId, @@ -9199,6 +15989,7 @@ export class StateStore { || row.target_released !== 0 || evidence.kind !== "session.switch" || digestJson(evidence) !== row.evidence_digest + || sessionProviderSwitchTargetAliasesSource(evidence, providerThreadId) || evidence.seedDigest !== digestTranscriptSeed(seedText) || reviewedRuntimeProfileProvider(runtimeProfile) !== evidence.targetProvider || runtimeProfile.profileId !== evidence.targetProfileId @@ -9300,10 +16091,12 @@ export class StateStore { profile_id: profileIdSchema, provider: providerSchema, provider_thread_id: providerThreadIdSchema, + target_provider_thread_id: providerThreadIdSchema, target_released: z.number().int().min(0).max(1), }).strict().parse(this.#database.query( `SELECT m.authority_id,m.kind,m.state,e.evidence_digest,e.evidence_json, session.profile_id,session.provider_v39 AS provider,session.provider_thread_id, + t.provider_thread_id AS target_provider_thread_id, EXISTS(SELECT 1 FROM session_provider_switch_target_releases tr WHERE tr.attempt_id=m.id) AS target_released FROM mutation_attempts m JOIN mutation_effect_evidence e ON e.attempt_id=m.id @@ -9323,6 +16116,10 @@ export class StateStore { || row.profile_id !== evidence.sourceProfileId || row.provider !== evidence.sourceProvider || row.provider_thread_id !== evidence.sourceProviderThreadId + || sessionProviderSwitchTargetAliasesSource( + evidence, + row.target_provider_thread_id, + ) || row.target_released !== 0 || !this.isSessionMutationProviderAuthorityCurrent({ attemptId, @@ -9351,10 +16148,14 @@ export class StateStore { attemptId: AttemptId; sessionId: SessionId; providerThreadId: string; + providerAccountKey?: string; }): void { const attemptId = attemptIdSchema.parse(input.attemptId); const sessionId = sessionIdSchema.parse(input.sessionId); const providerThreadId = providerThreadIdSchema.parse(input.providerThreadId); + const providerAccountKey = input.providerAccountKey === undefined + ? undefined + : providerAccountAuthorityKeySchema.parse(input.providerAccountKey); const now = this.#now(); const record = this.#database.transaction(() => { const row = z.object({ @@ -9376,10 +16177,22 @@ export class StateStore { const evidence = mutationEffectEvidenceSchema.parse( JSON.parse(row.evidence_json) as unknown, ); + const targetAccountAuthorityMatches = evidence.kind === "session.switch" + && (evidence.targetProvider === "devin" + ? providerAccountKey === undefined + && evidence.targetProviderAccountKey === undefined + : providerAccountKey !== undefined + && evidence.targetProviderAccountKey === providerAccountKey + && providerAccountAuthorityKeyMatchesProvider( + evidence.targetProvider, + providerAccountKey, + )); if ( row.authority_id !== sessionId || evidence.kind !== "session.switch" || digestJson(evidence) !== row.evidence_digest + || !targetAccountAuthorityMatches + || sessionProviderSwitchTargetAliasesSource(evidence, providerThreadId) || (row.target_provider_thread_id !== null && row.target_provider_thread_id !== providerThreadId) || !this.isSessionMutationProviderAuthorityCurrent({ @@ -9424,9 +16237,11 @@ export class StateStore { seedTurnStatus?: "completed" | "interrupted" | "failed" | "inProgress"; sourceReleased: boolean; targetReleased: boolean; + targetProviderAccountKey?: string; targetProviderThreadId?: string; }> { const row = z.object({ + evidence_json: z.string(), provider_thread_id: providerThreadIdSchema.nullable(), client_message_id: attemptIdSchema.nullable(), seed_text: z.string().nullable(), @@ -9436,11 +16251,12 @@ export class StateStore { source_released: z.number().int().min(0).max(1), target_released: z.number().int().min(0).max(1), }).strict().parse(this.#database.query( - `SELECT t.provider_thread_id,i.client_message_id,i.seed_text,i.runtime_profile_json, + `SELECT e.evidence_json,t.provider_thread_id,i.client_message_id,i.seed_text,i.runtime_profile_json, sr.turn_id AS seed_turn_id,sr.turn_status AS seed_turn_status, EXISTS(SELECT 1 FROM session_provider_switch_source_releases r WHERE r.attempt_id=m.id) AS source_released, EXISTS(SELECT 1 FROM session_provider_switch_target_releases r WHERE r.attempt_id=m.id) AS target_released FROM mutation_attempts m + JOIN mutation_effect_evidence e ON e.attempt_id=m.id LEFT JOIN session_provider_switch_targets t ON t.attempt_id=m.id LEFT JOIN session_provider_switch_seed_intents i ON i.attempt_id=m.id LEFT JOIN session_provider_switch_seed_results sr ON sr.attempt_id=m.id @@ -9452,6 +16268,16 @@ export class StateStore { || (row.seed_turn_id !== null && row.client_message_id === null) || (row.seed_turn_id === null) !== (row.seed_turn_status === null) ) throw new Error("SESSION_PROVIDER_SWITCH_SEED_PROGRESS_CORRUPT"); + const evidence = mutationEffectEvidenceSchema.parse( + JSON.parse(row.evidence_json) as unknown, + ); + if (evidence.kind !== "session.switch") { + throw new Error("SESSION_PROVIDER_SWITCH_SEED_PROGRESS_CORRUPT"); + } + if ( + row.provider_thread_id !== null + && sessionProviderSwitchTargetAliasesSource(evidence, row.provider_thread_id) + ) throw new Error("SESSION_PROVIDER_SWITCH_TARGET_ALIASES_SOURCE"); const seed = row.client_message_id === null ? undefined : { @@ -9464,6 +16290,9 @@ export class StateStore { return { sourceReleased: row.source_released === 1, targetReleased: row.target_released === 1, + ...(evidence.targetProviderAccountKey === undefined + ? {} + : { targetProviderAccountKey: evidence.targetProviderAccountKey }), ...(seed === undefined ? {} : { seed }), ...(row.seed_turn_id === null ? {} : { seedTurnId: row.seed_turn_id }), ...(row.seed_turn_status === null ? {} : { seedTurnStatus: row.seed_turn_status }), @@ -9548,12 +16377,16 @@ export class StateStore { attemptId: AttemptId; sessionId: SessionId; expectedSessionRevision: number; + providerAccountKey?: string; title: string; providerUpdatedAt?: number; recordSourceReleased?: boolean; }): SessionRecord { const attemptId = attemptIdSchema.parse(input.attemptId); const sessionId = sessionIdSchema.parse(input.sessionId); + const providerAccountKey = input.providerAccountKey === undefined + ? undefined + : providerAccountAuthorityKeySchema.parse(input.providerAccountKey); const now = this.#now(); const bind = this.#database.transaction(() => { const authority = z.object({ @@ -9582,9 +16415,24 @@ export class StateStore { const evidence = mutationEffectEvidenceSchema.parse( JSON.parse(authority.evidence_json) as unknown, ); + const targetAccountAuthorityMatches = evidence.kind === "session.switch" + && (evidence.targetProvider === "devin" + ? providerAccountKey === undefined + && evidence.targetProviderAccountKey === undefined + : providerAccountKey !== undefined + && evidence.targetProviderAccountKey === providerAccountKey + && providerAccountAuthorityKeyMatchesProvider( + evidence.targetProvider, + providerAccountKey, + )); if ( authority.authority_id !== sessionId || evidence.kind !== "session.switch" + || !targetAccountAuthorityMatches + || sessionProviderSwitchTargetAliasesSource( + evidence, + authority.target_provider_thread_id, + ) || digestJson(evidence) !== authority.evidence_digest || !this.isSessionMutationProviderAuthorityCurrent({ attemptId, @@ -9633,6 +16481,133 @@ export class StateStore { if (sourceRelease === null) { throw new Error("SESSION_PROVIDER_SWITCH_SOURCE_RELEASE_UNPROVEN"); } + const targetProfile = this.requireProfileById(evidence.targetProfileId); + if ( + !profileStateAllowsProviderSessionAuthority( + evidence.targetProvider, + targetProfile.state, + ) + || (evidence.targetProvider === "codex" + && targetProfile.providerEmail === undefined) + || !this.isSessionMutationProviderAuthorityCurrent({ + attemptId, + profileId: evidence.targetProfileId, + provider: evidence.targetProvider, + originGeneration: evidence.targetProcessGeneration, + }) + ) throw new Error("SESSION_PROVIDER_SWITCH_RECOVERY_TARGET_AUTHORITY_CHANGED"); + if (targetBinding) { + const targetProviderAuthority = this.readSessionProviderAccountAuthority(sessionId); + const personalBinding = this.readSessionPersonalRuntimeBinding(sessionId, true); + const targetProviderAuthorityMatches = evidence.targetProvider === "devin" + ? targetProviderAuthority === null + : targetProviderAuthority !== null + && targetProviderAuthority.provider === evidence.targetProvider + && targetProviderAuthority.runtimeScope === "managed" + && providerAccountKey !== undefined + && targetProviderAuthority.accountKey === providerAccountKey; + if ( + !targetProviderAuthorityMatches + || (personalBinding !== null && personalBinding.state !== "detached") + || !this.sessionAccountAuthorityMatches( + current.id, + evidence.targetProfileId, + ) + ) throw new Error("SESSION_PROVIDER_SWITCH_RECOVERY_TARGET_ACCOUNT_AUTHORITY_MISMATCH"); + if (evidence.targetProvider === "claude") { + const targetProcess = this.readClaudeProcessAuthority({ + providerThreadId: authority.target_provider_thread_id, + profileId: evidence.targetProfileId, + runtimeScope: "managed", + }); + if ( + targetProcess === null + || targetProcess.state !== "bound" + || targetProcess.sessionId !== sessionId + || targetProcess.profileGeneration !== targetProfile.processGeneration + ) throw new Error("SESSION_PROVIDER_SWITCH_RECOVERY_TARGET_PROCESS_AUTHORITY_MISMATCH"); + } + return current; + } + const sourceProviderAuthority = this.readSessionProviderAccountAuthority(sessionId); + if ( + evidence.sourceProvider === "devin" + ? sourceProviderAuthority !== null + : sourceProviderAuthority === null + || sourceProviderAuthority.provider !== evidence.sourceProvider + ) throw new Error("SESSION_PROVIDER_SWITCH_RECOVERY_SOURCE_ACCOUNT_AUTHORITY_MISSING"); + const personalBinding = this.readSessionPersonalRuntimeBinding(sessionId, true); + if (sourceProviderAuthority?.runtimeScope === "personal") { + if ( + personalBinding === null + || personalBinding.state === "detached" + || personalBinding.provider !== evidence.sourceProvider + || personalBinding.providerThreadId !== evidence.sourceProviderThreadId + ) throw new Error("SESSION_PROVIDER_SWITCH_RECOVERY_SOURCE_PERSONAL_BINDING_MISMATCH"); + } else if (personalBinding !== null && personalBinding.state !== "detached") { + throw new Error("SESSION_PROVIDER_SWITCH_RECOVERY_SOURCE_RUNTIME_SCOPE_MISMATCH"); + } + if (evidence.sourceProvider === "claude") { + if (sourceProviderAuthority === null) { + throw new Error("SESSION_PROVIDER_SWITCH_RECOVERY_SOURCE_ACCOUNT_AUTHORITY_MISSING"); + } + const sourceProcess = this.readClaudeProcessAuthority({ + providerThreadId: evidence.sourceProviderThreadId, + profileId: evidence.sourceProfileId, + runtimeScope: sourceProviderAuthority.runtimeScope, + }); + if (sourceProcess === null || sourceProcess.state !== "released") { + throw new Error("SESSION_PROVIDER_SWITCH_RECOVERY_SOURCE_PROCESS_AUTHORITY_LIVE"); + } + } + let targetClaudeProcess: ClaudeProcessAuthorityRecord | null = null; + if (evidence.targetProvider === "claude") { + targetClaudeProcess = this.readClaudeProcessAuthority({ + providerThreadId: authority.target_provider_thread_id, + profileId: evidence.targetProfileId, + runtimeScope: "managed", + }); + if ( + targetClaudeProcess === null + || targetClaudeProcess.state !== "claimed" + || targetClaudeProcess.sessionId !== sessionId + || targetClaudeProcess.profileGeneration !== targetProfile.processGeneration + ) throw new Error("SESSION_PROVIDER_SWITCH_RECOVERY_TARGET_PROCESS_AUTHORITY_MISMATCH"); + } + if (personalBinding !== null && personalBinding.state !== "detached") { + const detached = this.#database.query( + `UPDATE session_personal_runtime_bindings + SET state='detached',revision=revision+1, + updated_at=MAX(updated_at,?),detached_at=? + WHERE session_id=? AND revision=? AND state IN ('active','detaching')`, + ).run(now, now, sessionId, personalBinding.revision); + if (detached.changes !== 1) { + throw new Error("SESSION_PROVIDER_SWITCH_RECOVERY_SOURCE_PERSONAL_BINDING_CONFLICT"); + } + const fenced = this.#database.query( + `UPDATE session_adoption_candidates + SET claim_status='fenced',fenced_fingerprint=candidate_fingerprint, + revision=revision+1 + WHERE provider=? AND provider_thread_id=?`, + ).run(personalBinding.provider, personalBinding.providerThreadId); + if (fenced.changes !== 1) { + throw new Error("SESSION_PROVIDER_SWITCH_RECOVERY_SOURCE_PERSONAL_CANDIDATE_MISSING"); + } + } + if (sourceProviderAuthority !== null) { + const retiredProviderAuthority = this.#database.query( + `DELETE FROM session_provider_account_authorities + WHERE session_id=? AND provider=? AND runtime_scope=? AND account_key=?`, + ).run( + sessionId, + sourceProviderAuthority.provider, + sourceProviderAuthority.runtimeScope, + sourceProviderAuthority.accountKey, + ); + if (retiredProviderAuthority.changes !== 1) { + throw new Error("SESSION_PROVIDER_SWITCH_RECOVERY_SOURCE_ACCOUNT_AUTHORITY_CONFLICT"); + } + } const changed = this.#database.query( `UPDATE sessions SET provider=?,provider_v39=?,profile_id=?,preset=?,preset_contract=?,provider_thread_id=?,title=?, @@ -9652,7 +16627,33 @@ export class StateStore { sessionId, current.revision, ); - if (changed.changes !== 1) throw new Error("SESSION_PROVIDER_SWITCH_RECOVERY_TARGET_CAS_CONFLICT"); + // SQLite includes the account-authority rebind trigger's session write in + // this count, so successful cross-account recovery can report >1 change. + if (changed.changes < 1) throw new Error("SESSION_PROVIDER_SWITCH_RECOVERY_TARGET_CAS_CONFLICT"); + if (evidence.targetProvider !== "devin" && providerAccountKey !== undefined) { + this.#bindSessionProviderAccountAuthorityLocked({ + sessionId, + provider: evidence.targetProvider, + runtimeScope: "managed", + accountKey: providerAccountKey, + }, now); + } + const rebound = this.requireSession(sessionId); + if ( + rebound.profileId !== evidence.targetProfileId + || rebound.provider !== evidence.targetProvider + || rebound.providerThreadId !== authority.target_provider_thread_id + || !this.sessionAccountAuthorityMatches(rebound.id, evidence.targetProfileId) + ) throw new Error("SESSION_PROVIDER_SWITCH_RECOVERY_TARGET_CAS_CONFLICT"); + if (targetClaudeProcess !== null) { + this.#bindClaudeProcessAuthorityLocked({ + providerThreadId: targetClaudeProcess.providerThreadId, + profileId: targetClaudeProcess.profileId, + sessionId, + runtimeScope: "managed", + identity: targetClaudeProcess.identity, + }); + } this.#database.query( "UPDATE session_conversation_automation SET provider_thread_id=? WHERE session_id=?", ).run(authority.target_provider_thread_id, sessionId); @@ -9732,9 +16733,6 @@ export class StateStore { }); return { connectionId, releasedEvents, sessionId }; }); - if (evidence.kind !== "account.login" && providerRetirements.length > 0) { - throw new Error("ACCOUNT_LOGOUT_CANNOT_RETIRE_PROVIDER_GENERATION"); - } const canonical = JSON.stringify(evidence); const digest = createHash("sha256").update(canonical).digest("hex"); const now = this.#now(); @@ -9770,23 +16768,65 @@ export class StateStore { && this.#sessionMutationAuthorityTuplesForProfile(parsedProfileId) .some((tuple) => tuple.provider === "codex") ) throw new Error("SESSION_MUTATION_AUTHORITY_UNSETTLED"); + for (const runtimeScope of ["personal", "managed"] as const) { + const revocation = this.readProviderRuntimeAccountRevocation({ + profileId: parsedProfileId, + provider: "codex", + runtimeScope, + }); + const retirementStateIsSufficient = revocation?.state === "completed" + || ( + evidence.kind === "account.logout" + && runtimeScope === "managed" + && revocation?.state === "releasing" + ); + if ( + revocation?.profileGeneration !== expectedCurrentGeneration + || !retirementStateIsSufficient + || revocation.currentAccountKey !== null + ) throw new Error("ACCOUNT_MUTATION_CODEX_AUTHORITY_NOT_RETIRED"); + } const retiredSessionIds = new Set(); let affectedWorkIds: readonly string[] = []; - if (evidence.kind === "account.login") { - for (const retirement of providerRetirements) { - const session = z.object({ - profile_id: profileIdSchema, - provider: providerSchema, - }).strict().parse(this.#database.query( - "SELECT profile_id,provider_v39 AS provider FROM sessions WHERE id=?", - ).get(retirement.sessionId)); - if (session.profile_id !== parsedProfileId || session.provider !== "codex") { - throw new Error("ACCOUNT_LOGIN_RETIREMENT_SESSION_AUTHORITY_MISMATCH"); - } - for (const event of retirement.releasedEvents) { - this.#appendSessionEventInTransaction({ ...event, recordedAt: now }); - } + for (const retirement of providerRetirements) { + const session = z.object({ + profile_id: profileIdSchema, + provider: providerSchema, + }).strict().parse(this.#database.query( + "SELECT profile_id,provider_v39 AS provider FROM sessions WHERE id=?", + ).get(retirement.sessionId)); + if (session.profile_id !== parsedProfileId || session.provider !== "codex") { + throw new Error("ACCOUNT_MUTATION_RETIREMENT_SESSION_AUTHORITY_MISMATCH"); + } + for (const event of retirement.releasedEvents) { + this.#appendSessionEventInTransaction({ ...event, recordedAt: now }); } + this.#appendSessionEventInTransaction({ + accountId: parsedProfileId, + body: { type: "connection", state: "disconnected", reason: "closed" }, + providerConnectionId: retirement.connectionId, + providerGeneration: expectedCurrentGeneration, + recordedAt: now, + sessionId: retirement.sessionId, + }); + const position = this.#readSessionEventStream(retirement.sessionId); + const missingSequence = position.observed_through_sequence + 1; + this.#appendSessionEventInTransaction({ + accountId: parsedProfileId, + body: { + type: "gap", + reason: "provider_disconnect", + fromSequence: missingSequence, + throughSequence: missingSequence, + }, + providerConnectionId: retirement.connectionId, + providerGeneration: expectedCurrentGeneration, + recordedAt: now, + sessionId: retirement.sessionId, + }); + retiredSessionIds.add(retirement.sessionId); + } + if (evidence.kind === "account.login") { const openInteractions = this.#database.query( `SELECT i.* FROM provider_interactions i WHERE i.profile_id=? AND i.process_generation=? @@ -9817,38 +16857,34 @@ export class StateStore { this.#ensureInteractionStateEventInTransaction(interaction, now); if (interaction.sessionId !== null) retiredSessionIds.add(interaction.sessionId); } - for (const retirement of providerRetirements) { - this.#appendSessionEventInTransaction({ - accountId: parsedProfileId, - body: { type: "connection", state: "disconnected", reason: "closed" }, - providerConnectionId: retirement.connectionId, - providerGeneration: expectedCurrentGeneration, - recordedAt: now, - sessionId: retirement.sessionId, - }); - const position = this.#readSessionEventStream(retirement.sessionId); - const missingSequence = position.observed_through_sequence + 1; - this.#appendSessionEventInTransaction({ - accountId: parsedProfileId, - body: { - type: "gap", - reason: "provider_disconnect", - fromSequence: missingSequence, - throughSequence: missingSequence, - }, - providerConnectionId: retirement.connectionId, - providerGeneration: expectedCurrentGeneration, - recordedAt: now, - sessionId: retirement.sessionId, - }); - retiredSessionIds.add(retirement.sessionId); - } affectedWorkIds = input.workStore?.prepareProfileAuthorityChange( parsedProfileId, expectedCurrentGeneration, ) ?? []; - const advanced = this.#database.query("UPDATE profiles SET process_generation=?,state='login_pending',provider_email=NULL,provider_plan=NULL,updated_at=? WHERE id=? AND process_generation=?").run(parsedGeneration, now, parsedProfileId, expectedCurrentGeneration); + const personalClaudeBinding = this.#database.query( + `SELECT 1 FROM session_personal_runtime_bindings b + JOIN sessions s ON s.id=b.session_id + WHERE s.profile_id=? AND s.provider_v39='claude' AND b.provider='claude' + AND b.state IN ('active','detaching') LIMIT 1`, + ).get(parsedProfileId) !== null; + if (personalClaudeBinding) { + this.#database.query( + `INSERT INTO session_adoption_profile_generation_permits( + profile_id,from_generation,to_generation + ) VALUES (?,?,?)`, + ).run(parsedProfileId, expectedCurrentGeneration, parsedGeneration); + } + const advanced = this.#database.query("UPDATE profiles SET process_generation=?,state='login_pending',provider_email=NULL,codex_account_key=NULL,provider_plan=NULL,updated_at=? WHERE id=? AND process_generation=?").run(parsedGeneration, now, parsedProfileId, expectedCurrentGeneration); if (advanced.changes !== 1) throw new Error("MUTATION_EFFECT_AUTHORITY_CHANGED"); + if (personalClaudeBinding) { + const released = this.#database.query( + `DELETE FROM session_adoption_profile_generation_permits + WHERE profile_id=? AND from_generation=? AND to_generation=?`, + ).run(parsedProfileId, expectedCurrentGeneration, parsedGeneration); + if (released.changes !== 1) { + throw new Error("ACCOUNT_LOGIN_PERSONAL_CLAUDE_ROLLOVER_CONFLICT"); + } + } } this.#database.query("INSERT INTO mutation_effect_evidence(attempt_id,kind,evidence_json,evidence_digest,recorded_at) VALUES (?,?,?,?,?)").run(parsedAttemptId, evidence.kind, canonical, digest, now); const changed = this.#database.query("UPDATE mutation_attempts SET state='effect_started',updated_at=? WHERE id=? AND state='prepared'").run(now, parsedAttemptId); @@ -10519,11 +17555,37 @@ export class StateStore { && progress.source_released === 1 && progress.target_released === 0 && progress.provider_thread_id === input.provider.providerThreadId + && !sessionProviderSwitchTargetAliasesSource( + effectEvidence, + progress.provider_thread_id, + ) && session.profileId === effectEvidence.targetProfileId - && session.provider === effectEvidence.targetProvider; + && session.provider === effectEvidence.targetProvider + && (() => { + const providerAuthority = this.readSessionProviderAccountAuthority(session.id); + const personalBinding = this.readSessionPersonalRuntimeBinding(session.id, true); + const providerAuthorityMatches = effectEvidence.targetProvider === "devin" + ? providerAuthority === null + && effectEvidence.targetProviderAccountKey === undefined + : providerAuthority !== null + && providerAuthority.provider === effectEvidence.targetProvider + && providerAuthority.runtimeScope === "managed" + && effectEvidence.targetProviderAccountKey !== undefined + && providerAuthority.accountKey === effectEvidence.targetProviderAccountKey; + return providerAuthorityMatches + && (personalBinding === null || personalBinding.state === "detached") + && this.sessionAccountAuthorityMatches( + session.id, + effectEvidence.targetProfileId, + ); + })(); const sourceRetention = resolution === "abandoned" && progress.source_released === 0 && progress.target_released === 1 + && !sessionProviderSwitchTargetAliasesSource( + effectEvidence, + progress.provider_thread_id, + ) && effectEvidence.sourceProviderThreadId === input.provider.providerThreadId && session.profileId === effectEvidence.sourceProfileId && session.provider === effectEvidence.sourceProvider; @@ -10570,7 +17632,12 @@ export class StateStore { && session.provider === effectEvidence.targetProvider && session.providerThreadId === progress.provider_thread_id; if ( - (!sourceBinding && !targetBinding) + (progress.provider_thread_id !== null + && sessionProviderSwitchTargetAliasesSource( + effectEvidence, + progress.provider_thread_id, + )) + || (!sourceBinding && !targetBinding) || ( (progress.source_released !== 1 || progress.target_released !== 1) && input.acknowledgeProviderStateUnknown !== true @@ -10745,10 +17812,12 @@ export class StateStore { ); if (row.state !== input.expectedOriginalState || row.evidence_digest !== expectedDigest) throw new Error("MUTATION_RECOVERY_CAS_CONFLICT"); profileId = row.authority_id; - const changed = this.#database.query(`UPDATE profiles SET state=?,provider_email=?,provider_plan=?,updated_at=? + const providerEmail = input.provider.signedIn ? input.provider.email ?? null : null; + const changed = this.#database.query(`UPDATE profiles SET state=?,provider_email=?,codex_account_key=?,provider_plan=?,updated_at=? WHERE id=? AND process_generation=? AND state='recovery_required'`).run( input.provider.signedIn ? "signed_in" : "signed_out", - input.provider.email ?? null, + providerEmail, + providerEmail === null ? null : codexProviderAccountAuthorityKeyForEmail(providerEmail), input.provider.plan ?? null, now, row.authority_id, @@ -10894,7 +17963,11 @@ export class StateStore { && session.provider === effectEvidence.targetProvider && session.providerThreadId === target.provider_thread_id; if ( - this.isSessionMutationProviderAuthorityCurrent({ + !(target !== null && sessionProviderSwitchTargetAliasesSource( + effectEvidence, + target.provider_thread_id, + )) + && this.isSessionMutationProviderAuthorityCurrent({ attemptId: id, profileId: effectEvidence.sourceProfileId, provider: effectEvidence.sourceProvider, @@ -11489,8 +18562,15 @@ export class StateStore { const resolvedAt = this.#now(); if (activeAccount !== undefined) { const reconciledProfile = this.#database - .query("UPDATE profiles SET state='signed_in',provider_email=?,provider_plan=?,updated_at=? WHERE id=? AND process_generation=? AND state!='removed'") - .run(activeAccount.email, activeAccount.plan ?? null, resolvedAt, row.target_profile_id, row.target_generation); + .query("UPDATE profiles SET state='signed_in',provider_email=?,codex_account_key=?,provider_plan=?,updated_at=? WHERE id=? AND process_generation=? AND state!='removed'") + .run( + activeAccount.email, + codexProviderAccountAuthorityKeyForEmail(activeAccount.email), + activeAccount.plan ?? null, + resolvedAt, + row.target_profile_id, + row.target_generation, + ); if (reconciledProfile.changes !== 1) throw new Error("DESKTOP_RECOVERY_PROFILE_CAS_CONFLICT"); } const receipt = desktopRecoveryReceiptSchema.parse({ @@ -14437,6 +21517,61 @@ export class StateStore { this.#database.query(`UPDATE provider_login_authorities SET process_generation=process_generation+1,updated_at=MAX(updated_at,?) WHERE state='active'`).run(now); + this.#database.query( + `INSERT INTO session_adoption_profile_generation_permits( + profile_id,from_generation,to_generation + ) + SELECT p.id,p.process_generation,p.process_generation+1 + FROM profiles p + WHERE p.state!='removed' + AND ( + EXISTS( + SELECT 1 FROM session_personal_runtime_bindings b + JOIN sessions s ON s.id=b.session_id + WHERE s.profile_id=p.id AND b.state IN ('active','detaching') + ) + OR EXISTS( + SELECT 1 FROM profile_personal_authority_revocations r + WHERE r.profile_id=p.id + AND r.profile_generation=p.process_generation + AND r.state='releasing' + ) + OR EXISTS( + SELECT 1 FROM provider_runtime_account_revocations r + WHERE r.profile_id=p.id + AND r.profile_generation=p.process_generation + AND r.state='releasing' + ) + )`, + ).run(); + // Any unreleased Claude process remains bound to the generation that + // launched it. Startup must prove that exact PID/start identity dead + // and complete its release before this transaction can advance the + // profile; the generation guard below otherwise aborts atomically. + this.#database.query( + `UPDATE profile_personal_authority_revocations + SET profile_generation=profile_generation+1,revision=revision+1, + updated_at=MAX(updated_at,?) + WHERE state='releasing' AND EXISTS( + SELECT 1 FROM profiles p + WHERE p.id=profile_personal_authority_revocations.profile_id + AND p.state!='removed' + AND p.process_generation= + profile_personal_authority_revocations.profile_generation + )`, + ).run(now); + this.#database.query( + `UPDATE provider_runtime_account_revocations + SET profile_generation=profile_generation+1,revision=revision+1, + updated_at=MAX(updated_at,?) + WHERE state='releasing' AND EXISTS( + SELECT 1 FROM profiles p + WHERE p.id=provider_runtime_account_revocations.profile_id + AND p.state!='removed' + AND p.process_generation= + provider_runtime_account_revocations.profile_generation + )`, + ).run(now); this.#database.query( `UPDATE profiles SET process_generation=process_generation+1,updated_at=MAX(updated_at,?) @@ -14480,8 +21615,15 @@ export class StateStore { WHERE r.attempt_id=m.id ) ) + OR EXISTS ( + SELECT 1 FROM session_adoption_profile_generation_permits permit + WHERE permit.profile_id=profiles.id + AND permit.from_generation=profiles.process_generation + AND permit.to_generation=profiles.process_generation+1 + ) )`, ).run(now); + this.#database.query("DELETE FROM session_adoption_profile_generation_permits").run(); this.#database.query("UPDATE daemon_state SET generation=?,boot_id=?,started_at=?,stopped_at=NULL WHERE singleton=1 AND generation=?").run(current.generation + 1, bootId, now, current.generation); return current.generation + 1; }); diff --git a/src/storage/work-store.test.ts b/src/storage/work-store.test.ts index 6403619..7ec5d4e 100644 --- a/src/storage/work-store.test.ts +++ b/src/storage/work-store.test.ts @@ -42,6 +42,10 @@ import { const databases: Database[] = []; const capability = `hrac1_${"A".repeat(43)}`; +const codexAccountKey = (email: string): string => + `v1:codex:${createHash("sha256").update(email.trim().toLowerCase()).digest("hex")}`; +const claudeAccountKey = (identity: string): string => + `v1:claude:${createHash("sha256").update(identity).digest("hex")}`; let uuidV7Sequence = 0; @@ -88,7 +92,9 @@ const parentSchema = ` CREATE TABLE profiles( id TEXT PRIMARY KEY, state TEXT NOT NULL, - process_generation INTEGER NOT NULL + process_generation INTEGER NOT NULL, + provider_email TEXT, + codex_account_key TEXT ) STRICT; CREATE TABLE projects( id TEXT PRIMARY KEY @@ -99,11 +105,59 @@ CREATE TABLE sessions( project_id TEXT NOT NULL REFERENCES projects(id), provider TEXT NOT NULL DEFAULT 'codex', provider_v39 TEXT NOT NULL DEFAULT 'codex', + provider_thread_id TEXT, preset TEXT NOT NULL, preset_contract INTEGER NOT NULL DEFAULT 2 CHECK(preset_contract IN (1,2)), fast_enabled INTEGER NOT NULL, state TEXT NOT NULL ) STRICT; +CREATE TABLE session_account_authorities( + session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, + profile_id TEXT NOT NULL REFERENCES profiles(id), + account_key TEXT, + recorded_at INTEGER NOT NULL +) STRICT; +CREATE TABLE provider_runtime_account_revocations( + profile_id TEXT NOT NULL REFERENCES profiles(id), + profile_generation INTEGER NOT NULL, + provider TEXT NOT NULL, + runtime_scope TEXT NOT NULL, + current_account_key TEXT, + state TEXT NOT NULL, + PRIMARY KEY(profile_id,provider,runtime_scope) +) STRICT; +CREATE TABLE session_provider_account_authorities( + session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + runtime_scope TEXT NOT NULL, + account_key TEXT NOT NULL, + recorded_at INTEGER NOT NULL +) STRICT; +CREATE TABLE session_personal_runtime_bindings( + session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_thread_id TEXT NOT NULL, + state TEXT NOT NULL +) STRICT; +CREATE TRIGGER session_account_authority_insert +AFTER INSERT ON sessions +BEGIN + INSERT INTO session_account_authorities(session_id,profile_id,account_key,recorded_at) + SELECT NEW.id,NEW.profile_id, + CASE WHEN p.provider_email IS NULL THEN NULL ELSE lower(trim(p.provider_email)) END, + 0 + FROM profiles p WHERE p.id=NEW.profile_id; +END; +CREATE TRIGGER session_provider_account_authority_insert +AFTER INSERT ON sessions +WHEN NEW.provider_v39='codex' +BEGIN + INSERT INTO session_provider_account_authorities( + session_id,provider,runtime_scope,account_key,recorded_at + ) + SELECT NEW.id,'codex','managed',p.codex_account_key,0 + FROM profiles p WHERE p.id=NEW.profile_id AND p.codex_account_key IS NOT NULL; +END; CREATE TABLE session_events( session_id TEXT NOT NULL, sequence INTEGER NOT NULL, @@ -163,8 +217,9 @@ function fixture(): Fixture { const actorSessionId = createSessionId(); const reviewerSessionId = createSessionId(); database.query( - "INSERT INTO profiles(id,state,process_generation) VALUES (?,'signed_in',1)", - ).run(accountId); + `INSERT INTO profiles(id,state,process_generation,provider_email,codex_account_key) + VALUES (?,'signed_in',1,'worker@example.com',?)`, + ).run(accountId, codexAccountKey("worker@example.com")); database.query("INSERT INTO projects(id) VALUES (?)").run(projectId); for (const sessionId of [actorSessionId, reviewerSessionId]) { database.query( @@ -192,6 +247,90 @@ function fixture(): Fixture { }; } +function bindPersonalProviderAuthority( + value: Fixture, + sessionId: SessionId, + provider: "claude" | "codex", + providerThreadId: string, + accountKey: string, +): void { + value.database.query( + "DELETE FROM session_personal_runtime_bindings WHERE session_id=?", + ).run(sessionId); + value.database.query( + "DELETE FROM session_provider_account_authorities WHERE session_id=?", + ).run(sessionId); + value.database.query( + "UPDATE sessions SET provider=?,provider_v39=?,provider_thread_id=? WHERE id=?", + ).run(provider, provider, providerThreadId, sessionId); + value.database.query( + `INSERT INTO session_provider_account_authorities( + session_id,provider,runtime_scope,account_key,recorded_at + ) VALUES (?,?, 'personal',?,0)`, + ).run(sessionId, provider, accountKey); + value.database.query( + `INSERT INTO session_personal_runtime_bindings( + session_id,provider,provider_thread_id,state + ) VALUES (?,?,?,'active')`, + ).run(sessionId, provider, providerThreadId); +} + +function addSignedOutPersonalClaudeSession( + value: Fixture, + label: string, +): Readonly<{ + accountKey: string; + profileId: ProfileId; + sessionId: SessionId; +}> { + const profileId = createProfileId(); + const sessionId = createSessionId(); + const providerThreadId = `personal-claude-${label}`; + const accountKey = claudeAccountKey(label); + value.database.query( + `INSERT INTO profiles(id,state,process_generation,provider_email,codex_account_key) + VALUES (?,'signed_out',1,NULL,NULL)`, + ).run(profileId); + value.database.query( + `INSERT INTO sessions( + id,profile_id,project_id,provider,provider_v39,provider_thread_id,preset,fast_enabled,state + ) VALUES (?,?,?,'claude','claude',?,'high',0,'active')`, + ).run(sessionId, profileId, value.projectId, providerThreadId); + bindPersonalProviderAuthority( + value, + sessionId, + "claude", + providerThreadId, + accountKey, + ); + return { accountKey, profileId, sessionId }; +} + +function addSignedOutManagedDevinSession( + value: Fixture, + label: string, +): Readonly<{ + profileId: ProfileId; + sessionId: SessionId; +}> { + const profileId = createProfileId(); + const sessionId = createSessionId(); + value.database.query( + `INSERT INTO profiles(id,state,process_generation,provider_email,codex_account_key) + VALUES (?,'signed_out',1,NULL,NULL)`, + ).run(profileId); + value.database.query( + `INSERT INTO sessions( + id,profile_id,project_id,provider,provider_v39,provider_thread_id,preset, + preset_contract,fast_enabled,state + ) VALUES (?,?,?,'codex','devin',?,'ultra',2,0,'active')`, + ).run(sessionId, profileId, value.projectId, `devin-${label}`); + value.database.query( + "DELETE FROM session_account_authorities WHERE session_id=?", + ).run(sessionId); + return { profileId, sessionId }; +} + function fillWorkEventCapacity( value: Fixture, workId: string, @@ -513,6 +652,84 @@ describe("WorkStore schema and atomic plans", () => { ); }); + test("rejects stale or unidentified session account authority for create, join, and claim", () => { + const value = fixture(); + const original = createWork(value); + value.database.query( + `UPDATE profiles + SET provider_email='replacement@example.com',codex_account_key=? + WHERE id=?`, + ).run(codexAccountKey("replacement@example.com"), value.accountId); + + expect(() => createWork(value)).toThrow(new WorkStoreError("MEMBER_NOT_FOUND")); + expect(value.database.query("SELECT COUNT(*) AS count FROM works").get()).toEqual({ count: 1 }); + + const freshCoordinatorSessionId = createSessionId(); + value.database.query( + `INSERT INTO sessions(id,profile_id,project_id,preset,fast_enabled,state) + VALUES (?,?,?,'high',0,'active')`, + ).run(freshCoordinatorSessionId, value.accountId, value.projectId); + const freshKey = randomUUID(); + const fresh = value.store.apply({ + kind: "work.create", + idempotencyKey: freshKey, + clientRef: `plan-${freshKey}`, + coordinatorSessionId: freshCoordinatorSessionId, + objective: "Use only the replacement account authority.", + routes: [{ + accountId: value.accountId, + projectId: value.projectId, + preset: "high", + fast: false, + }], + tasks: [taskSpec(value, "replacement")], + }); + if (fresh.kind !== "work.create") throw new Error("unexpected result"); + expect(() => value.store.apply({ + kind: "work.join", + idempotencyKey: randomUUID(), + workId: fresh.work.id, + coordinatorSessionId: freshCoordinatorSessionId, + coordinatorCapability: capability, + actorSessionId: value.reviewerSessionId, + })).toThrow(new WorkStoreError("MEMBER_NOT_FOUND")); + expect(value.database.query( + "SELECT COUNT(*) AS count FROM work_members WHERE work_id=?", + ).get(fresh.work.id)).toEqual({ count: 1 }); + value.database.query( + "UPDATE sessions SET state='recovery_required' WHERE id=?", + ).run(freshCoordinatorSessionId); + expect(() => value.store.apply({ + kind: "task.addBatch", + idempotencyKey: randomUUID(), + workId: fresh.work.id, + expectedWorkRevision: fresh.work.revision, + coordinatorSessionId: freshCoordinatorSessionId, + coordinatorCapability: capability, + tasks: [taskSpec(value, "recovery-fenced")], + })).toThrow(new WorkStoreError("ATTEMPT_NOT_OWNER")); + expect(value.database.query( + "SELECT COUNT(*) AS count FROM work_tasks WHERE work_id=?", + ).get(fresh.work.id)).toEqual({ count: 1 }); + + expect(() => claim(value, { + workId: original.work.id, + taskId: original.tasks[0]!.id, + revision: original.tasks[0]!.revision, + })).toThrow(new WorkStoreError("ROUTE_MISMATCH")); + expect(value.database.query( + "SELECT COUNT(*) AS count FROM work_attempts WHERE work_id=?", + ).get(original.work.id)).toEqual({ count: 0 }); + + const unidentified = fixture(); + unidentified.database.query( + "UPDATE profiles SET provider_email=NULL WHERE id=?", + ).run(unidentified.accountId); + expect(() => createWork(unidentified)).toThrow(new WorkStoreError("MEMBER_NOT_FOUND")); + expect(unidentified.database.query("SELECT COUNT(*) AS count FROM works").get()) + .toEqual({ count: 0 }); + }); + test("rejects current work guards against a pre-provider session schema", () => { const database = new Database(":memory:", { strict: true }); databases.push(database); @@ -645,6 +862,17 @@ describe("WorkStore schema and atomic plans", () => { }); describe("WorkStore claims, fences, and prepared effects", () => { + test("requires signed-in HRA account authority for established Claude work", () => { + const value = fixture(); + value.database.query("UPDATE sessions SET provider='claude' WHERE profile_id=?") + .run(value.accountId); + value.database.query("UPDATE profiles SET state='signed_out',process_generation=0 WHERE id=?") + .run(value.accountId); + + expect(() => createWork(value)).toThrow(new WorkStoreError("MEMBER_NOT_FOUND")); + expect(value.database.query("SELECT COUNT(*) AS count FROM works").get()).toEqual({ count: 0 }); + }); + test("refuses a claim when the session and immutable work preset contracts differ", () => { const value = fixture(); const created = createWork(value); @@ -715,11 +943,29 @@ describe("WorkStore claims, fences, and prepared effects", () => { }); }); - test("refuses Claude task execution while keeping session-owned signals provider-neutral", () => { + test("gives signed-out adopted Claude coordinator, member, signal, and ack authority", () => { const value = fixture(); - value.database.query("UPDATE profiles SET state='signed_out',process_generation=0 WHERE id=?") - .run(value.accountId); - value.database.query("UPDATE sessions SET provider_v39='claude',preset='ultra' WHERE profile_id=?") + const accountKey = claudeAccountKey("personal-work-signals"); + bindPersonalProviderAuthority( + value, + value.actorSessionId, + "claude", + "personal-claude-work-actor", + accountKey, + ); + bindPersonalProviderAuthority( + value, + value.reviewerSessionId, + "claude", + "personal-claude-work-reviewer", + accountKey, + ); + value.database.query( + `UPDATE profiles + SET state='signed_out',provider_email=NULL,codex_account_key=NULL + WHERE id=?`, + ).run(value.accountId); + value.database.query("UPDATE sessions SET preset='ultra' WHERE profile_id=?") .run(value.accountId); const created = createWork(value, [taskSpec(value, "claude-tier-collision", { @@ -742,11 +988,275 @@ describe("WorkStore claims, fences, and prepared effects", () => { senderCapability: capability, targetSessionId: value.reviewerSessionId, mode: "queue", - body: "Claude remains reachable independently of Codex authentication.", + body: "Claude remains reachable through the provider-neutral signal lane.", + }); + if (signal.kind !== "signal.send") throw new Error("unexpected result"); + expect(signal.signal.accountGeneration).toBe(1); + expect(value.store.authorizePreparedEffect(signalKey)).toMatchObject({ executable: true }); + const delivered = value.store.finalizeSignal(signalKey, { + kind: "failed", + code: "queue_closed", + }); + const acknowledged = value.store.apply({ + kind: "signal.ack", + idempotencyKey: randomUUID(), + workId: created.work.id, + signalId: signal.signal.id, + expectedSignalRevision: delivered.revision, + actorSessionId: value.reviewerSessionId, + actorCapability: capability, + }); + if (acknowledged.kind !== "signal.ack") throw new Error("unexpected result"); + expect(acknowledged.signal.acknowledgedAt).not.toBeNull(); + + const detachedKey = randomUUID(); + const beforeDetach = value.store.apply({ + kind: "signal.send", + idempotencyKey: detachedKey, + workId: created.work.id, + senderSessionId: value.actorSessionId, + senderCapability: capability, + targetSessionId: value.reviewerSessionId, + mode: "queue", + body: "This delivery is fenced if the adopted target detaches.", + }); + if (beforeDetach.kind !== "signal.send") throw new Error("unexpected result"); + value.database.query( + "UPDATE session_personal_runtime_bindings SET state='detaching' WHERE session_id=?", + ).run(value.reviewerSessionId); + expect(value.store.authorizePreparedEffect(detachedKey)).toMatchObject({ + disposition: "settled", + executable: false, + status: { state: "failed" }, + }); + expect(() => value.store.apply({ + kind: "signal.send", + idempotencyKey: randomUUID(), + workId: created.work.id, + senderSessionId: value.actorSessionId, + senderCapability: capability, + targetSessionId: value.reviewerSessionId, + mode: "queue", + body: "A detached target is no longer a valid delivery authority.", + })).toThrow(new WorkStoreError("ROUTE_MISMATCH")); + value.database.query( + "UPDATE session_personal_runtime_bindings SET state='active' WHERE session_id=?", + ).run(value.reviewerSessionId); + + value.database.query( + `INSERT INTO provider_runtime_account_revocations( + profile_id,profile_generation,provider,runtime_scope,current_account_key,state + ) VALUES (?,1,'claude','personal',?,'releasing')`, + ).run(value.accountId, accountKey); + expect(() => createWork(value)).toThrow(new WorkStoreError("MEMBER_NOT_FOUND")); + value.database.query( + `UPDATE provider_runtime_account_revocations + SET state='completed',current_account_key=? + WHERE profile_id=? AND provider='claude' AND runtime_scope='personal'`, + ).run(claudeAccountKey("replacement-work-signals"), value.accountId); + expect(() => join( + value, + created.work.id, + created.work.revision, + value.reviewerSessionId, + )).toThrow(new WorkStoreError("ATTEMPT_NOT_OWNER")); + value.database.query( + `UPDATE provider_runtime_account_revocations + SET current_account_key=? + WHERE profile_id=? AND provider='claude' AND runtime_scope='personal'`, + ).run(accountKey, value.accountId); + expect(() => createWork(value)).not.toThrow(); + }); + + test("gives keyless signed-out managed Devin control-plane authority but no Work execution", () => { + const value = fixture(); + const coordinator = addSignedOutManagedDevinSession(value, "coordinator"); + const member = addSignedOutManagedDevinSession(value, "member"); + expect(value.database.query( + `SELECT COUNT(*) AS count FROM session_account_authorities + WHERE session_id IN (?,?)`, + ).get(coordinator.sessionId, member.sessionId)).toEqual({ count: 0 }); + expect(value.database.query( + `SELECT COUNT(*) AS count FROM session_provider_account_authorities + WHERE session_id IN (?,?)`, + ).get(coordinator.sessionId, member.sessionId)).toEqual({ count: 0 }); + + const createKey = randomUUID(); + const created = value.store.apply({ + kind: "work.create", + idempotencyKey: createKey, + clientRef: `devin-control-${createKey}`, + coordinatorSessionId: coordinator.sessionId, + objective: "Coordinate without granting the Devin session execution authority.", + routes: [{ + accountId: coordinator.profileId, + projectId: value.projectId, + preset: "ultra", + fast: false, + }], + tasks: [taskSpec({ + accountId: coordinator.profileId, + projectId: value.projectId, + }, "devin-control", { preset: "ultra" })], + }); + if (created.kind !== "work.create") throw new Error("unexpected result"); + expect(value.database.query( + "SELECT preset_contract FROM works WHERE id=?", + ).get(created.work.id)).toEqual({ preset_contract: 2 }); + const joined = value.store.apply({ + kind: "work.join", + idempotencyKey: randomUUID(), + workId: created.work.id, + coordinatorSessionId: coordinator.sessionId, + coordinatorCapability: capability, + actorSessionId: member.sessionId, + }); + if (joined.kind !== "work.join") throw new Error("unexpected result"); + expect(() => claim(value, { + workId: created.work.id, + taskId: created.tasks[0]!.id, + revision: created.tasks[0]!.revision, + actorSessionId: coordinator.sessionId, + })).toThrow(new WorkStoreError("ROUTE_MISMATCH")); + expect(value.store.apply({ + kind: "task.claimNext", + idempotencyKey: randomUUID(), + workId: created.work.id, + actorSessionId: member.sessionId, + actorCapability: capability, + route: { accountId: coordinator.profileId, projectId: value.projectId }, + leaseMs: 5_000, + })).toMatchObject({ kind: "task.claimNext", task: null, attempt: null }); + expect(value.database.query( + "SELECT COUNT(*) AS count FROM work_attempts WHERE work_id=?", + ).get(created.work.id)).toEqual({ count: 0 }); + + const signalKey = randomUUID(); + const signal = value.store.apply({ + kind: "signal.send", + idempotencyKey: signalKey, + workId: created.work.id, + senderSessionId: coordinator.sessionId, + senderCapability: capability, + targetSessionId: member.sessionId, + mode: "queue", + body: "Review the plan without claiming an execution attempt.", }); if (signal.kind !== "signal.send") throw new Error("unexpected result"); - expect(signal.signal.accountGeneration).toBe(0); expect(value.store.authorizePreparedEffect(signalKey)).toMatchObject({ executable: true }); + const delivered = value.store.finalizeSignal(signalKey, { + kind: "failed", + code: "queue_closed", + }); + const acknowledged = value.store.apply({ + kind: "signal.ack", + idempotencyKey: randomUUID(), + workId: created.work.id, + signalId: signal.signal.id, + expectedSignalRevision: delivered.revision, + actorSessionId: member.sessionId, + actorCapability: capability, + }); + if (acknowledged.kind !== "signal.ack") throw new Error("unexpected result"); + expect(acknowledged.signal.acknowledgedAt).not.toBeNull(); + }); + + test("refuses dispatch after a claimed Codex worker is observed as Devin", () => { + const value = fixture(); + const created = createWork(value); + const claimed = claim(value, { + workId: created.work.id, + taskId: created.tasks[0]!.id, + revision: created.tasks[0]!.revision, + }); + value.database.exec("DROP TRIGGER work_session_attempt_authority_guard"); + value.database.query( + "UPDATE sessions SET provider_v39='devin',preset_contract=2 WHERE id=?", + ).run(value.actorSessionId); + value.database.exec(WORK_SCHEMA_SQL); + + expect(() => value.store.apply({ + kind: "attempt.dispatch", + idempotencyKey: randomUUID(), + workId: created.work.id, + attemptId: claimed.attempt.id, + expectedAttemptRevision: claimed.attempt.revision, + fence: claimed.attempt.fence, + actorSessionId: value.actorSessionId, + attemptCapability: capability, + targetSessionId: value.actorSessionId, + mode: "send", + })).toThrow(new WorkStoreError("REVISION_CONFLICT")); + expect(value.database.query( + "SELECT state,target_session_id FROM work_attempts WHERE id=?", + ).get(claimed.attempt.id)).toEqual({ state: "released", target_session_id: null }); + }); + + test("allows adopted Codex claims and rejects detached or revoked personal authority", () => { + const valid = fixture(); + const validAccountKey = codexAccountKey("worker@example.com"); + bindPersonalProviderAuthority( + valid, + valid.actorSessionId, + "codex", + "personal-codex-valid-worker", + validAccountKey, + ); + const validWork = createWork(valid); + expect(claim(valid, { + workId: validWork.work.id, + taskId: validWork.tasks[0]!.id, + revision: validWork.tasks[0]!.revision, + }).attempt.fence).toBe(1); + + const detached = fixture(); + bindPersonalProviderAuthority( + detached, + detached.actorSessionId, + "codex", + "personal-codex-detached-worker", + validAccountKey, + ); + const detachedWork = createWork(detached); + detached.database.query( + "UPDATE session_personal_runtime_bindings SET state='detached' WHERE session_id=?", + ).run(detached.actorSessionId); + expect(() => claim(detached, { + workId: detachedWork.work.id, + taskId: detachedWork.tasks[0]!.id, + revision: detachedWork.tasks[0]!.revision, + })).toThrow(new WorkStoreError("ROUTE_MISMATCH")); + + const revoked = fixture(); + bindPersonalProviderAuthority( + revoked, + revoked.actorSessionId, + "codex", + "personal-codex-revoked-worker", + validAccountKey, + ); + const revokedWork = createWork(revoked); + revoked.database.query( + `INSERT INTO provider_runtime_account_revocations( + profile_id,profile_generation,provider,runtime_scope,current_account_key,state + ) VALUES (?,1,'codex','personal',?,'releasing')`, + ).run(revoked.accountId, validAccountKey); + expect(() => claim(revoked, { + workId: revokedWork.work.id, + taskId: revokedWork.tasks[0]!.id, + revision: revokedWork.tasks[0]!.revision, + })).toThrow(new WorkStoreError("ROUTE_MISMATCH")); + + revoked.database.query( + `UPDATE provider_runtime_account_revocations + SET state='completed',current_account_key=? + WHERE profile_id=? AND provider='codex' AND runtime_scope='personal'`, + ).run(codexAccountKey("replacement@example.com"), revoked.accountId); + expect(() => claim(revoked, { + workId: revokedWork.work.id, + taskId: revokedWork.tasks[0]!.id, + revision: revokedWork.tasks[0]!.revision, + })).toThrow(new WorkStoreError("ROUTE_MISMATCH")); }); test("fences provider identity and rechecks it before Codex task dispatch", () => { @@ -833,12 +1343,12 @@ describe("WorkStore claims, fences, and prepared effects", () => { 1, "b".repeat(64), ); + const claimTask = (): unknown => claim(value, { workId: created.work.id, taskId: created.tasks[0]!.id, revision: created.tasks[0]!.revision, }); - const next = value.store.apply({ kind: "task.claimNext", idempotencyKey: randomUUID(), @@ -865,16 +1375,11 @@ describe("WorkStore claims, fences, and prepared effects", () => { expect(claimTask()).toMatchObject({ kind: "task.claim" }); }); - test("still refuses Codex work authority while the Codex profile is signed out", () => { + test("refuses Codex work admission while the HRA profile is signed out", () => { const value = fixture(); value.database.query("UPDATE profiles SET state='signed_out',process_generation=0 WHERE id=?") .run(value.accountId); - const created = createWork(value); - expect(() => claim(value, { - workId: created.work.id, - taskId: created.tasks[0]!.id, - revision: created.tasks[0]!.revision, - })).toThrow(new WorkStoreError("ROUTE_MISMATCH")); + expect(() => createWork(value)).toThrow(new WorkStoreError("MEMBER_NOT_FOUND")); }); test("CAS claims a ready task once and increments its committed fence", () => { @@ -1500,6 +2005,94 @@ describe("WorkStore claims, fences, and prepared effects", () => { }); describe("WorkStore submissions, reviews, and signals", () => { + test("allows a signed-out adopted Claude member to review a Codex submission", () => { + const value = fixture(); + const reviewer = addSignedOutPersonalClaudeSession(value, "reviewer"); + const created = createWork(value, [taskSpec(value, "adopted-claude-review", { + requiredReviews: 1, + })]); + join( + value, + created.work.id, + created.work.revision, + reviewer.sessionId, + ); + const claimed = claim(value, { + workId: created.work.id, + taskId: created.tasks[0]!.id, + revision: created.tasks[0]!.revision, + }); + const dispatchKey = randomUUID(); + value.store.apply({ + kind: "attempt.dispatch", + idempotencyKey: dispatchKey, + workId: created.work.id, + attemptId: claimed.attempt.id, + expectedAttemptRevision: claimed.attempt.revision, + fence: claimed.attempt.fence, + actorSessionId: value.actorSessionId, + attemptCapability: capability, + targetSessionId: value.actorSessionId, + mode: "send", + }); + expect(value.store.authorizePreparedEffect(dispatchKey).executable).toBe(true); + const running = value.store.finalizeDispatch(dispatchKey, { + kind: "accepted", + receipt: turnStartedReceipt(), + }); + const reported = value.store.apply({ + kind: "attempt.report", + idempotencyKey: randomUUID(), + workId: created.work.id, + attemptId: running.id, + expectedAttemptRevision: running.revision, + fence: running.fence, + actorSessionId: value.actorSessionId, + attemptCapability: capability, + report: { + kind: "submit", + summary: "Ready for the adopted Claude reviewer.", + result: { kind: "text", text: "complete" }, + evidence: [], + }, + }); + if (reported.kind !== "attempt.report" || reported.submission === null) { + throw new Error("submission missing"); + } + const submission = reported.submission; + + value.database.query( + "UPDATE session_personal_runtime_bindings SET state='detaching' WHERE session_id=?", + ).run(reviewer.sessionId); + expect(() => value.store.apply({ + kind: "submission.review", + idempotencyKey: randomUUID(), + workId: created.work.id, + submissionId: submission.id, + expectedSubmissionRevision: submission.revision, + expectedContentDigest: submission.contentDigest, + reviewerSessionId: reviewer.sessionId, + reviewerCapability: capability, + review: { decision: "accept", summary: "Authority must be current.", evidence: [] }, + })).toThrow(new WorkStoreError("ROUTE_MISMATCH")); + value.database.query( + "UPDATE session_personal_runtime_bindings SET state='active' WHERE session_id=?", + ).run(reviewer.sessionId); + const reviewed = value.store.apply({ + kind: "submission.review", + idempotencyKey: randomUUID(), + workId: created.work.id, + submissionId: submission.id, + expectedSubmissionRevision: submission.revision, + expectedContentDigest: submission.contentDigest, + reviewerSessionId: reviewer.sessionId, + reviewerCapability: capability, + review: { decision: "accept", summary: "Independent review passed.", evidence: [] }, + }); + if (reviewed.kind !== "submission.review") throw new Error("unexpected result"); + expect(reviewed.submission.status).toBe("accepted"); + }); + test("preserves revisions and requires an independent reviewer before completion", () => { const value = fixture(); const created = createWork(value, [taskSpec(value, "reviewed", { requiredReviews: 1 })]); @@ -3143,6 +3736,55 @@ describe("WorkStore submissions, reviews, and signals", () => { }); describe("WorkStore cancellation and recovery", () => { + test("retires only attempts owned by the exact bounded session set", () => { + const value = fixture(); + const created = createWork(value, [ + taskSpec(value, "actor-task"), + taskSpec(value, "reviewer-task"), + ]); + join(value, created.work.id, created.work.revision, value.reviewerSessionId); + const actorTask = created.tasks.find((task) => task.clientRef === "actor-task")!; + const reviewerTask = created.tasks.find((task) => task.clientRef === "reviewer-task")!; + const actorClaim = claim(value, { + workId: created.work.id, + taskId: actorTask.id, + revision: actorTask.revision, + }); + const reviewerClaim = claim(value, { + workId: created.work.id, + taskId: reviewerTask.id, + revision: reviewerTask.revision, + actorSessionId: value.reviewerSessionId, + }); + + expect(() => value.store.prepareSessionAuthorityChange( + [value.reviewerSessionId], + 2, + )).toThrow(new WorkStoreError("REVISION_CONFLICT")); + expect(value.store.task(actorTask.id).activeAttempt?.status).toBe("claimed"); + expect(value.store.task(reviewerTask.id).activeAttempt?.status).toBe("claimed"); + expect(() => value.store.prepareSessionAuthorityChange( + Array.from({ length: 501 }, () => value.reviewerSessionId), + 1, + )).toThrow(new WorkStoreError("WORK_CAPACITY_EXCEEDED")); + expect(value.store.task(reviewerTask.id).activeAttempt?.status).toBe("claimed"); + + expect(value.store.prepareSessionAuthorityChange( + [value.reviewerSessionId, value.reviewerSessionId], + 1, + )).toEqual([created.work.id]); + expect(value.store.task(actorTask.id).activeAttempt).toMatchObject({ + id: actorClaim.attempt.id, + status: "claimed", + }); + expect(value.store.task(reviewerTask.id).activeAttempt).toBeNull(); + expect(value.store.task(reviewerTask.id).task.status).toBe("ready"); + expect(value.database.query( + "SELECT state FROM work_attempts WHERE id=?", + ).get(reviewerClaim.attempt.id)).toEqual({ state: "released" }); + expect(value.store.prepareSessionAuthorityChange([], 1)).toEqual([]); + }); + test("reconciles no-effect only from a determinate failed nested mutation", () => { const prepareRecovery = (nestedState: "ambiguous" | "failed") => { const value = fixture(); diff --git a/src/storage/work-store.ts b/src/storage/work-store.ts index cb33dcc..4c1936e 100644 --- a/src/storage/work-store.ts +++ b/src/storage/work-store.ts @@ -2,12 +2,11 @@ import { createHash, randomUUID } from "node:crypto"; import type { Database } from "bun:sqlite"; -import { currentPresetContract } from "../domain/presets"; +import { currentPresetContract, type Provider } from "../domain/presets"; import { isUuidV7 } from "../domain/uuid-v7"; -import type { Provider } from "../domain/presets"; import { workReadSuccessWireBytes } from "../domain/terminal-json"; import { workPreparedEffectMessage } from "../domain/work-message"; -import { MESSAGE_MAX_BYTES } from "../domain/values"; +import { MESSAGE_MAX_BYTES, sessionIdSchema } from "../domain/values"; import { verifyWorkEvidence, WorkEvidenceVerificationError, @@ -102,6 +101,76 @@ const WORK_TASK_DEPTH_LIMIT = DOMAIN_WORK_TASK_DEPTH_LIMIT; const WORK_PAGE_LIMIT = WORK_EVENT_PAGE_LIMIT; const WORK_POLL_DEFAULT_LIMIT = 50; const WORK_POLL_LIMIT = WORK_POLL_ITEM_LIMIT; +const WORK_SESSION_AUTHORITY_CHANGE_LIMIT = 500; + +/** + * One closed provider-authority predicate for every Work admission and + * recheck. The optional expression is a SQL boolean such as an exact captured + * profile-generation match. Provider additions must extend the final branch; + * an unknown provider never inherits Codex or Claude authority. + */ +const currentWorkSessionAuthorityExistsSql = ( + sessionIdExpression: string, + generationPredicate = "", +): string => `EXISTS ( + SELECT 1 + FROM sessions AS authority_session + JOIN profiles AS authority_profile ON authority_profile.id=authority_session.profile_id + LEFT JOIN session_provider_account_authorities AS provider_authority + ON provider_authority.session_id=authority_session.id + AND provider_authority.provider=authority_session.provider_v39 + WHERE authority_session.id=${sessionIdExpression} + AND authority_session.state IN ('active','idle') + ${generationPredicate.length === 0 ? "" : `AND (${generationPredicate})`} + AND NOT EXISTS ( + SELECT 1 FROM provider_runtime_account_revocations AS authority_revocation + WHERE authority_revocation.profile_id=authority_session.profile_id + AND authority_revocation.profile_generation=authority_profile.process_generation + AND authority_revocation.provider=authority_session.provider_v39 + AND authority_revocation.runtime_scope=provider_authority.runtime_scope + AND ( + authority_revocation.state='releasing' + OR authority_revocation.current_account_key IS NULL + OR authority_revocation.current_account_key!=provider_authority.account_key + ) + ) + AND ( + (provider_authority.runtime_scope='personal' AND EXISTS ( + SELECT 1 FROM session_personal_runtime_bindings AS authority_binding + WHERE authority_binding.session_id=authority_session.id + AND authority_binding.provider=authority_session.provider_v39 + AND authority_binding.provider_thread_id=authority_session.provider_thread_id + AND authority_binding.state='active' + )) + OR (provider_authority.runtime_scope='managed' AND NOT EXISTS ( + SELECT 1 FROM session_personal_runtime_bindings AS authority_binding + WHERE authority_binding.session_id=authority_session.id + AND authority_binding.state IN ('active','detaching') + )) + OR (authority_session.provider_v39='devin' AND NOT EXISTS ( + SELECT 1 FROM session_personal_runtime_bindings AS authority_binding + WHERE authority_binding.session_id=authority_session.id + AND authority_binding.state IN ('active','detaching') + )) + ) + AND ( + (authority_session.provider_v39='claude' + AND authority_profile.state IN ('signed_in','signed_out')) + OR (authority_session.provider_v39='codex' + AND authority_profile.state='signed_in' + AND authority_profile.provider_email IS NOT NULL + AND authority_profile.codex_account_key=provider_authority.account_key + AND EXISTS ( + SELECT 1 FROM session_account_authorities AS legacy_authority + WHERE legacy_authority.session_id=authority_session.id + AND legacy_authority.profile_id=authority_session.profile_id + AND legacy_authority.account_key IS NOT NULL + AND legacy_authority.account_key=lower(trim(authority_profile.provider_email)) + )) + OR (authority_session.provider_v39='devin' + AND authority_profile.state IN ('signed_in','signed_out')) + ) +)`; export const WORK_SCHEMA_SQL = ` CREATE TABLE IF NOT EXISTS work_clock ( @@ -184,6 +253,11 @@ WHEN NEW.provider_v39='devin' AND ( ) ) BEGIN SELECT RAISE(ABORT,'WORK_DEVIN_PRESET_CONTRACT_MISMATCH'); END; +DROP TRIGGER IF EXISTS work_coordinator_account_authority_guard; +CREATE TRIGGER work_coordinator_account_authority_guard +BEFORE INSERT ON works +WHEN NOT ${currentWorkSessionAuthorityExistsSql("NEW.coordinator_session_id")} +BEGIN SELECT RAISE(ABORT,'WORK_COORDINATOR_AUTHORITY_MISMATCH'); END; CREATE TABLE IF NOT EXISTS work_routes ( work_id TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE, @@ -609,6 +683,11 @@ CREATE TRIGGER IF NOT EXISTS work_member_limit_guard BEFORE INSERT ON work_members WHEN (SELECT COUNT(*) FROM work_members WHERE work_id=NEW.work_id) >= ${WORK_MEMBER_LIMIT} BEGIN SELECT RAISE(ABORT,'WORK_MEMBER_LIMIT'); END; +DROP TRIGGER IF EXISTS work_member_account_authority_guard; +CREATE TRIGGER work_member_account_authority_guard +BEFORE INSERT ON work_members +WHEN NOT ${currentWorkSessionAuthorityExistsSql("NEW.session_id")} +BEGIN SELECT RAISE(ABORT,'WORK_MEMBER_AUTHORITY_MISMATCH'); END; CREATE TRIGGER IF NOT EXISTS work_tasks_no_update BEFORE UPDATE ON work_tasks BEGIN SELECT RAISE(ABORT,'WORK_TASK_IMMUTABLE'); END; @@ -698,6 +777,14 @@ WHEN NOT EXISTS ( ) ) BEGIN SELECT RAISE(ABORT,'WORK_ATTEMPT_ROUTE_MISMATCH'); END; +DROP TRIGGER IF EXISTS work_attempt_account_authority_guard; +CREATE TRIGGER work_attempt_account_authority_guard +BEFORE INSERT ON work_attempts +WHEN NOT ${currentWorkSessionAuthorityExistsSql( + "NEW.worker_session_id", + "authority_session.provider_v39='codex' AND authority_session.profile_id=NEW.account_id AND authority_profile.process_generation=NEW.account_generation", + )} +BEGIN SELECT RAISE(ABORT,'WORK_ATTEMPT_ACCOUNT_AUTHORITY_MISMATCH'); END; DROP TRIGGER IF EXISTS work_session_switch_attempt_authority_guard; CREATE TRIGGER work_session_switch_attempt_authority_guard BEFORE UPDATE OF state ON mutation_attempts @@ -742,6 +829,14 @@ WHEN EXISTS ( ) ) BEGIN SELECT RAISE(ABORT,'WORK_PROFILE_ATTEMPT_AUTHORITY'); END; +CREATE TRIGGER IF NOT EXISTS work_profile_attempt_identity_guard +BEFORE UPDATE OF provider_email ON profiles +WHEN lower(trim(NEW.provider_email)) IS NOT lower(trim(OLD.provider_email)) AND EXISTS ( + SELECT 1 FROM work_attempts AS a + WHERE a.account_id=OLD.id + AND a.state IN ('claimed','dispatching','running') +) +BEGIN SELECT RAISE(ABORT,'WORK_PROFILE_ATTEMPT_AUTHORITY'); END; CREATE TRIGGER IF NOT EXISTS work_attempt_reports_no_update BEFORE UPDATE ON work_attempt_reports BEGIN SELECT RAISE(ABORT,'WORK_REPORT_IMMUTABLE'); END; @@ -769,6 +864,11 @@ WHEN NOT EXISTS (SELECT 1 FROM work_members WHERE work_id=NEW.work_id AND sessio WHERE s.id=NEW.submission_id AND s.worker_session_id=NEW.reviewer_session_id ) BEGIN SELECT RAISE(ABORT,'WORK_REVIEWER_INVALID'); END; +DROP TRIGGER IF EXISTS work_review_account_authority_guard; +CREATE TRIGGER work_review_account_authority_guard +BEFORE INSERT ON work_reviews +WHEN NOT ${currentWorkSessionAuthorityExistsSql("NEW.reviewer_session_id")} +BEGIN SELECT RAISE(ABORT,'WORK_REVIEWER_AUTHORITY_MISMATCH'); END; CREATE TRIGGER IF NOT EXISTS work_signals_no_update BEFORE UPDATE ON work_signals BEGIN SELECT RAISE(ABORT,'WORK_SIGNAL_IMMUTABLE'); END; @@ -783,11 +883,19 @@ WHEN NOT EXISTS (SELECT 1 FROM work_members WHERE work_id=NEW.work_id AND sessio OR NOT EXISTS (SELECT 1 FROM work_members WHERE work_id=NEW.work_id AND session_id=NEW.to_session_id) OR NOT EXISTS ( SELECT 1 FROM sessions AS s JOIN profiles AS p ON p.id=s.profile_id - WHERE s.id=NEW.to_session_id AND s.state IN ('active','idle') - AND p.state!='removed' AND (s.provider_v39 IN ('claude','devin') OR p.state='signed_in') + WHERE s.id=NEW.to_session_id AND p.process_generation=NEW.target_account_generation ) BEGIN SELECT RAISE(ABORT,'WORK_SIGNAL_MEMBER_INVALID'); END; +DROP TRIGGER IF EXISTS work_signal_account_authority_guard; +CREATE TRIGGER work_signal_account_authority_guard +BEFORE INSERT ON work_signals +WHEN NOT ${currentWorkSessionAuthorityExistsSql("NEW.from_session_id")} +OR NOT ${currentWorkSessionAuthorityExistsSql( + "NEW.to_session_id", + "authority_profile.process_generation=NEW.target_account_generation", + )} +BEGIN SELECT RAISE(ABORT,'WORK_SIGNAL_ACCOUNT_AUTHORITY_MISMATCH'); END; CREATE TRIGGER IF NOT EXISTS work_receipts_no_update BEFORE UPDATE ON work_signal_receipts BEGIN SELECT RAISE(ABORT,'WORK_SIGNAL_RECEIPT_IMMUTABLE'); END; CREATE TRIGGER IF NOT EXISTS work_receipts_no_delete @@ -805,6 +913,19 @@ WHEN NEW.kind='ack' AND NOT EXISTS ( WHERE s.id=NEW.signal_id AND s.to_session_id=NEW.actor_session_id ) BEGIN SELECT RAISE(ABORT,'WORK_SIGNAL_ACK_ACTOR_INVALID'); END; +DROP TRIGGER IF EXISTS work_signal_ack_account_authority_guard; +CREATE TRIGGER work_signal_ack_account_authority_guard +BEFORE INSERT ON work_signal_receipts +WHEN NEW.kind='ack' AND NOT EXISTS ( + SELECT 1 FROM work_signals AS signal + WHERE signal.id=NEW.signal_id + AND signal.to_session_id=NEW.actor_session_id + AND ${currentWorkSessionAuthorityExistsSql( + "signal.to_session_id", + "authority_profile.process_generation=signal.target_account_generation", + )} +) +BEGIN SELECT RAISE(ABORT,'WORK_SIGNAL_ACK_ACCOUNT_AUTHORITY_MISMATCH'); END; CREATE TRIGGER IF NOT EXISTS work_receipt_chain_guard BEFORE INSERT ON work_signal_receipts WHEN NEW.sequence != COALESCE(( @@ -946,6 +1067,65 @@ WHEN NOT EXISTS ( BEGIN SELECT RAISE(ABORT,'WORK_NESTED_EFFECT_SETTLEMENT_IMMUTABLE'); END; `; +// These guards jointly bind Work to the session's provider, preset contract, +// and immutable account authority. They have changed across schema versions, +// so presence alone is not enough: a same-name legacy body would silently +// weaken the current route fence. +const exactWorkAuthorityTriggerNames = [ + "works_identity_immutable", + "work_devin_preset_contract_guard", + "work_session_devin_contract_guard", + "work_coordinator_account_authority_guard", + "work_member_account_authority_guard", + "work_attempt_route_guard", + "work_attempt_account_authority_guard", + "work_session_switch_attempt_authority_guard", + "work_session_attempt_authority_guard", + "work_review_account_authority_guard", + "work_signal_member_guard", + "work_signal_account_authority_guard", + "work_signal_ack_account_authority_guard", +] as const; + +const normalizeWorkSchemaSql = (sql: string): string => + sql.replace(/\bIF NOT EXISTS\b/giu, "").replace(/\s+/gu, " ").trim().replace(/;$/u, ""); + +const exactWorkAuthorityTriggerSql = new Map( + exactWorkAuthorityTriggerNames.map((name) => { + const markers = [`CREATE TRIGGER ${name}\n`, `CREATE TRIGGER IF NOT EXISTS ${name}\n`]; + const start = markers + .map((marker) => WORK_SCHEMA_SQL.indexOf(marker)) + .find((offset) => offset >= 0) ?? -1; + const end = WORK_SCHEMA_SQL.indexOf("END;", start); + if (start < 0 || end < 0) throw new Error(`WORK_SCHEMA_DEFINITION_INVALID:${name}`); + return [name, normalizeWorkSchemaSql(WORK_SCHEMA_SQL.slice(start, end + 4))] as const; + }), +); + +const providerVersion39AddedAuthorityTriggerNames = new Set([ + "work_coordinator_account_authority_guard", + "work_member_account_authority_guard", + "work_attempt_account_authority_guard", + "work_profile_attempt_identity_guard", + "work_review_account_authority_guard", + "work_signal_account_authority_guard", + "work_signal_ack_account_authority_guard", +]); + +const providerVersion39SignalMemberGuardSql = normalizeWorkSchemaSql(` +CREATE TRIGGER work_signal_member_guard +BEFORE INSERT ON work_signals +WHEN NOT EXISTS (SELECT 1 FROM work_members WHERE work_id=NEW.work_id AND session_id=NEW.from_session_id) + OR NOT EXISTS (SELECT 1 FROM work_members WHERE work_id=NEW.work_id AND session_id=NEW.to_session_id) + OR NOT EXISTS ( + SELECT 1 FROM sessions AS s JOIN profiles AS p ON p.id=s.profile_id + WHERE s.id=NEW.to_session_id AND s.state IN ('active','idle') + AND p.state!='removed' AND (s.provider_v39 IN ('claude','devin') OR p.state='signed_in') + AND p.process_generation=NEW.target_account_generation + ) +BEGIN SELECT RAISE(ABORT,'WORK_SIGNAL_MEMBER_INVALID'); END +`); + const requiredWorkTables = [ "work_clock", "work_purge_authority", @@ -979,6 +1159,7 @@ const requiredWorkTriggers = [ "work_retained_limit_guard", "work_devin_preset_contract_guard", "work_session_devin_contract_guard", + "work_coordinator_account_authority_guard", "works_no_delete", "works_state_guard", "works_stream_advance_guard", @@ -989,6 +1170,7 @@ const requiredWorkTriggers = [ "work_members_no_update", "work_members_no_delete", "work_member_limit_guard", + "work_member_account_authority_guard", "work_tasks_no_update", "work_tasks_no_delete", "work_dependencies_no_update", @@ -1003,9 +1185,11 @@ const requiredWorkTriggers = [ "work_attempt_no_delete", "work_attempt_state_guard", "work_attempt_route_guard", + "work_attempt_account_authority_guard", "work_session_switch_attempt_authority_guard", "work_session_attempt_authority_guard", "work_profile_attempt_authority_guard", + "work_profile_attempt_identity_guard", "work_attempt_reports_no_update", "work_attempt_reports_no_delete", "work_submissions_no_update", @@ -1013,9 +1197,11 @@ const requiredWorkTriggers = [ "work_reviews_no_update", "work_reviews_no_delete", "work_review_member_guard", + "work_review_account_authority_guard", "work_signals_no_update", "work_signals_no_delete", "work_signal_member_guard", + "work_signal_account_authority_guard", "work_task_history_index_attempt", "work_task_history_index_attempt_report", "work_task_history_index_submission", @@ -1029,6 +1215,7 @@ const requiredWorkTriggers = [ "work_receipts_no_update", "work_receipts_no_delete", "work_signal_ack_guard", + "work_signal_ack_account_authority_guard", "work_receipt_chain_guard", "work_events_no_update", "work_events_no_delete", @@ -1077,10 +1264,33 @@ const assertWorkSchemaShape = (database: Database): void => { for (const name of requiredWorkTriggers) { if (!triggers.has(name)) throw new Error(`WORK_SCHEMA_MISSING_TRIGGER:${name}`); } + for (const name of exactWorkAuthorityTriggerNames) { + const row = database.query( + "SELECT type,tbl_name,sql FROM sqlite_master WHERE name=?", + ).get(name) as { type?: unknown; tbl_name?: unknown; sql?: unknown } | null; + if ( + row?.type !== "trigger" + || typeof row.tbl_name !== "string" + || typeof row.sql !== "string" + || normalizeWorkSchemaSql(row.sql) !== exactWorkAuthorityTriggerSql.get(name) + ) throw new Error(`WORK_SCHEMA_STALE_TRIGGER:${name}`); + } const requiredColumns: Readonly> = { // Current work authority guards inspect the provider on their parent // session, so a merely present trigger is not a usable work schema. - sessions: ["provider_v39", "preset_contract"], + profiles: ["state", "process_generation", "provider_email", "codex_account_key"], + sessions: ["provider_v39", "provider_thread_id", "preset_contract"], + session_account_authorities: ["session_id", "profile_id", "account_key"], + session_provider_account_authorities: [ + "session_id", "provider", "runtime_scope", "account_key", + ], + session_personal_runtime_bindings: [ + "session_id", "provider", "provider_thread_id", "state", + ], + provider_runtime_account_revocations: [ + "profile_id", "profile_generation", "provider", "runtime_scope", + "current_account_key", "state", + ], work_release_tombstones: [ "work_id", "release_idempotency_key", "release_request_digest", "client_ref_digest", "terminal_kind", "final_revision", "final_head_hash", "discarded_counts_json", @@ -1144,6 +1354,78 @@ export function assertWorkSchema(database: Database): void { if (integrity.length !== 0) throw new Error("WORK_SCHEMA_FOREIGN_KEY_VIOLATION"); } +/** + * Exact authority surface shipped by provider schema v39 immediately before + * adoption v40. This is deliberately narrower than a repair: it proves the + * only predecessor whose Work guards may be replaced during the migration. + */ +export function assertProviderVersion39WorkSchema(database: Database): void { + const tableRows = database.query("PRAGMA table_list").all() as Array<{ + name?: unknown; + strict?: unknown; + type?: unknown; + }>; + const tables = new Map( + tableRows + .filter((row) => row.type === "table" && typeof row.name === "string") + .map((row) => [row.name as string, row.strict]), + ); + for (const name of requiredWorkTables) { + if (!tables.has(name)) throw new Error(`WORK_SCHEMA_V39_MISSING:${name}`); + if (tables.get(name) !== 1) throw new Error(`WORK_SCHEMA_V39_NOT_STRICT:${name}`); + } + + const triggerRows = database.query( + "SELECT name,type,tbl_name,sql FROM sqlite_master WHERE type='trigger'", + ).all() as Array<{ + name?: unknown; + type?: unknown; + tbl_name?: unknown; + sql?: unknown; + }>; + const triggers = new Map(triggerRows.flatMap((row) => + typeof row.name === "string" ? [[row.name, row] as const] : [])); + for (const name of requiredWorkTriggers) { + if (providerVersion39AddedAuthorityTriggerNames.has(name)) { + if (triggers.has(name)) throw new Error(`WORK_SCHEMA_V39_UNEXPECTED_TRIGGER:${name}`); + } else if (!triggers.has(name)) { + throw new Error(`WORK_SCHEMA_V39_MISSING_TRIGGER:${name}`); + } + } + for (const name of exactWorkAuthorityTriggerNames) { + if (providerVersion39AddedAuthorityTriggerNames.has(name)) continue; + const row = triggers.get(name); + const expected = name === "work_signal_member_guard" + ? providerVersion39SignalMemberGuardSql + : exactWorkAuthorityTriggerSql.get(name); + if ( + row?.type !== "trigger" + || typeof row.tbl_name !== "string" + || typeof row.sql !== "string" + || normalizeWorkSchemaSql(row.sql) !== expected + ) throw new Error(`WORK_SCHEMA_V39_STALE_TRIGGER:${name}`); + } + const requiredColumns: Readonly> = { + profiles: ["state", "process_generation", "provider_email"], + sessions: ["provider_v39", "provider_thread_id", "preset_contract"], + works: ["preset_contract"], + }; + for (const [table, columns] of Object.entries(requiredColumns)) { + const present = new Set((database.query(`PRAGMA table_info(${table})`).all() as Array<{ + name?: unknown; + }>).flatMap((row) => typeof row.name === "string" ? [row.name] : [])); + for (const name of columns) { + if (!present.has(name)) throw new Error(`WORK_SCHEMA_V39_MISSING_COLUMN:${table}.${name}`); + } + } + const clock = database.query( + "SELECT logical_time FROM work_clock WHERE singleton=1", + ).get() as { logical_time?: unknown } | null; + if (!Number.isSafeInteger(clock?.logical_time) || (clock?.logical_time as number) < 0) { + throw new Error("WORK_SCHEMA_V39_CLOCK_MISSING"); + } +} + // Readonly opens (`hra status`, `hra doctor --offline`) verify the same table, // trigger, column, and clock identity but skip the O(rows) foreign_key_check. // A long readonly scan pins a WAL snapshot, and the writer's queue scrub @@ -1694,6 +1976,15 @@ export class WorkStore { return structuredClone(WORK_PROTOCOL_DESCRIPTION); } + /** + * StateStore authority changes call into WorkStore from an enclosing SQLite + * transaction. Identity, rather than a matching pathname, proves those + * writes participate in that same transaction. + */ + isBackedByDatabase(database: Database): boolean { + return this.#database === database; + } + #capability(authority: WorkCapabilityAuthority): string { return workCapabilitySchema.parse(this.#issueCapability(authority)); } @@ -2037,14 +2328,15 @@ export class WorkStore { JOIN work_members AS m ON m.work_id=t.work_id AND m.session_id=? JOIN works AS w ON w.id=t.work_id JOIN sessions AS s ON s.id=m.session_id - JOIN profiles AS p ON p.id=s.profile_id WHERE t.id=? AND t.work_id=? AND t.account_id=s.profile_id AND t.project_id=s.project_id AND t.preset=s.preset AND t.fast=s.fast_enabled AND s.provider_v39='codex' AND (s.preset_contract=w.preset_contract OR s.preset='low') - AND s.state IN ('active','idle') AND p.state='signed_in' - AND p.process_generation=?`, + AND ${currentWorkSessionAuthorityExistsSql( + "s.id", + "authority_profile.process_generation=?", + )}`, ).get( effect.targetSessionId, effect.taskId, @@ -2061,12 +2353,12 @@ export class WorkStore { FROM work_signals AS w JOIN work_members AS m ON m.work_id=w.work_id AND m.session_id=w.to_session_id JOIN sessions AS s ON s.id=m.session_id - JOIN profiles AS p ON p.id=s.profile_id WHERE w.id=? AND w.work_id=? AND w.to_session_id=? AND w.mode=? AND w.target_account_generation=? - AND p.process_generation=w.target_account_generation - AND s.state IN ('active','idle') AND p.state!='removed' - AND (s.provider_v39 IN ('claude','devin') OR p.state='signed_in')`, + AND ${currentWorkSessionAuthorityExistsSql( + "s.id", + "authority_profile.process_generation=w.target_account_generation", + )}`, ).get( effect.signalId, effect.workId, @@ -2618,9 +2910,40 @@ export class WorkStore { if (row === null) throw new WorkStoreError("MEMBER_NOT_FOUND"); } + #sessionAccountAuthorityCurrent( + sessionId: string, + expectedGeneration?: number, + ): boolean { + const authority = this.#database.query( + `SELECT 1 AS present + WHERE ${currentWorkSessionAuthorityExistsSql( + "?", + "? IS NULL OR authority_profile.process_generation=?", + )}`, + ).get( + sessionId, + expectedGeneration ?? null, + expectedGeneration ?? null, + ) as { present: number } | null; + return authority !== null; + } + + #requireSessionAccountAuthority( + sessionId: string, + code: WorkStoreErrorCode, + expectedGeneration?: number, + ): void { + if (!this.#sessionAccountAuthorityCurrent(sessionId, expectedGeneration)) { + throw new WorkStoreError(code); + } + } + #assertCoordinator(workId: string, coordinatorSessionId: string): WorkRow { const work = this.#requireWork(workId); - if (work.coordinator_session_id !== coordinatorSessionId) { + if ( + work.coordinator_session_id !== coordinatorSessionId + || !this.#sessionAccountAuthorityCurrent(coordinatorSessionId) + ) { throw new WorkStoreError("ATTEMPT_NOT_OWNER"); } return work; @@ -3614,7 +3937,7 @@ export class WorkStore { AND sm.state IN ('effect_started','ambiguous') AND sr.attempt_id IS NULL ) - AND s.state IN ('active','idle') AND p.state='signed_in'`, + AND ${currentWorkSessionAuthorityExistsSql("s.id")}`, ).get( task.work_id, actorSessionId, @@ -3632,9 +3955,8 @@ export class WorkStore { #attemptAuthorityCurrent(attempt: AttemptRow): boolean { const authority = this.#database.query( - `SELECT 1 AS present + `SELECT 1 AS present FROM sessions AS s - JOIN profiles AS p ON p.id=s.profile_id JOIN work_members AS m ON m.work_id=? AND m.session_id=s.id JOIN works AS w ON w.id=m.work_id WHERE s.id=? AND s.profile_id=? AND s.project_id=? AND s.preset=? AND s.fast_enabled=? @@ -3643,8 +3965,10 @@ export class WorkStore { s.preset_contract=w.preset_contract OR s.preset='low' ) - AND s.state IN ('active','idle') AND p.state='signed_in' - AND p.process_generation=?`, + AND ${currentWorkSessionAuthorityExistsSql( + "s.id", + "authority_profile.process_generation=?", + )}`, ).get( attempt.work_id, attempt.worker_session_id, @@ -3657,6 +3981,74 @@ export class WorkStore { return authority !== null; } + #retireAttemptAuthority( + attempts: readonly AttemptRow[], + expectedGeneration: number, + claimedSummary: string, + ): readonly string[] { + const workIds = [...new Set(attempts.map((attempt) => attempt.work_id))].sort(); + const now = this.#tick(); + for (const attempt of attempts) { + if (attempt.state === "claimed") { + const task = this.#requireTask(attempt.task_id, attempt.work_id); + const failureReason = this.#retryFailureReason(task.task, task.state, now); + this.#database.query( + `UPDATE work_attempts + SET state='released',revision=revision+1,updated_at=?,terminal_at=? + WHERE id=? AND state='claimed' AND account_generation=?`, + ).run(now, now, attempt.id, expectedGeneration); + this.#database.query( + `UPDATE work_task_states + SET state=?,revision=revision+1,retry_not_before=NULL,updated_at=? + WHERE task_id=? AND state='claimed'`, + ).run(failureReason === null ? "pending" : "failed", now, attempt.task_id); + const released = { + type: "attempt.released" as const, + attemptId: attempt.id, + summaryDigest: digestJson(claimedSummary), + }; + this.#appendEvent( + attempt.work_id, + released.type, + attempt.worker_session_id, + released, + ); + if (failureReason !== null) { + this.#appendTaskFailure( + attempt.work_id, + attempt.task_id, + failureReason, + attempt.worker_session_id, + ); + } + continue; + } + this.#database.query( + `UPDATE work_attempts + SET state='recovery_required',revision=revision+1,updated_at=? + WHERE id=? AND state=? AND account_generation=?`, + ).run(now, attempt.id, attempt.state, expectedGeneration); + this.#database.query( + `UPDATE work_task_states + SET state='recovery_required',revision=revision+1,updated_at=? + WHERE task_id=?`, + ).run(now, attempt.task_id); + const recovery = { + type: "attempt.recovery_required" as const, + attemptId: attempt.id, + fence: attempt.fence, + reason: "custodian_restart" as const, + }; + this.#appendEvent( + attempt.work_id, + recovery.type, + attempt.worker_session_id, + recovery, + ); + } + return workIds; + } + prepareProfileAuthorityChange( profileId: string, expectedGeneration: number, @@ -3677,67 +4069,70 @@ export class WorkStore { AND (? IS NULL OR s.provider_v39=?) ORDER BY a.work_id,a.created_at,a.id`, ).all(profileId, expectedGeneration, provider ?? null, provider ?? null) as AttemptRow[]; - const workIds = [...new Set(attempts.map((attempt) => attempt.work_id))].sort(); - const now = this.#tick(); - for (const attempt of attempts) { - if (attempt.state === "claimed") { - const task = this.#requireTask(attempt.task_id, attempt.work_id); - const failureReason = this.#retryFailureReason(task.task, task.state, now); - this.#database.query( - `UPDATE work_attempts - SET state='released',revision=revision+1,updated_at=?,terminal_at=? - WHERE id=? AND state='claimed' AND account_generation=?`, - ).run(now, now, attempt.id, expectedGeneration); - this.#database.query( - `UPDATE work_task_states - SET state=?,revision=revision+1,retry_not_before=NULL,updated_at=? - WHERE task_id=? AND state='claimed'`, - ).run(failureReason === null ? "pending" : "failed", now, attempt.task_id); - const released = { - type: "attempt.released" as const, - attemptId: attempt.id, - summaryDigest: digestJson("Profile authority retired before dispatch."), - }; - this.#appendEvent( - attempt.work_id, - released.type, - attempt.worker_session_id, - released, - ); - if (failureReason !== null) { - this.#appendTaskFailure( - attempt.work_id, - attempt.task_id, - failureReason, - attempt.worker_session_id, - ); - } - continue; - } - this.#database.query( - `UPDATE work_attempts - SET state='recovery_required',revision=revision+1,updated_at=? - WHERE id=? AND state=? AND account_generation=?`, - ).run(now, attempt.id, attempt.state, expectedGeneration); - this.#database.query( - `UPDATE work_task_states - SET state='recovery_required',revision=revision+1,updated_at=? - WHERE task_id=?`, - ).run(now, attempt.task_id); - const recovery = { - type: "attempt.recovery_required" as const, - attemptId: attempt.id, - fence: attempt.fence, - reason: "custodian_restart" as const, - }; - this.#appendEvent( - attempt.work_id, - recovery.type, - attempt.worker_session_id, - recovery, - ); + return this.#retireAttemptAuthority( + attempts, + expectedGeneration, + "Profile authority retired before dispatch.", + ); + }); + return prepare.immediate(); + } + + prepareSessionAuthorityChange( + sessionIds: readonly string[], + expectedProfileGeneration: number, + ): readonly string[] { + if (sessionIds.length > WORK_SESSION_AUTHORITY_CHANGE_LIMIT) { + throw new WorkStoreError("WORK_CAPACITY_EXCEEDED"); + } + if ( + !Number.isSafeInteger(expectedProfileGeneration) + || expectedProfileGeneration < 0 + ) throw new WorkStoreError("REVISION_CONFLICT"); + const uniqueSessionIds = [...new Set(sessionIds)]; + if (uniqueSessionIds.some((sessionId) => !sessionIdSchema.safeParse(sessionId).success)) { + throw new WorkStoreError("REVISION_CONFLICT"); + } + if (uniqueSessionIds.length === 0) return []; + uniqueSessionIds.sort(); + const prepare = this.#database.transaction((): readonly string[] => { + let profileId: string | null = null; + for (const sessionId of uniqueSessionIds) { + const session = this.#database.query( + `SELECT s.profile_id,p.process_generation + FROM sessions AS s + JOIN profiles AS p ON p.id=s.profile_id + WHERE s.id=?`, + ).get(sessionId) as { + process_generation: number; + profile_id: string; + } | null; + if ( + session === null + || session.process_generation !== expectedProfileGeneration + || (profileId !== null && session.profile_id !== profileId) + ) throw new WorkStoreError("REVISION_CONFLICT"); + profileId = session.profile_id; + } + const attemptsById = new Map(); + for (const sessionId of uniqueSessionIds) { + const attempts = this.#database.query( + `SELECT * FROM work_attempts + WHERE worker_session_id=? AND account_generation=? + AND state IN ('claimed','dispatching','running') + ORDER BY work_id,created_at,id`, + ).all(sessionId, expectedProfileGeneration) as AttemptRow[]; + for (const attempt of attempts) attemptsById.set(attempt.id, attempt); } - return workIds; + const attempts = [...attemptsById.values()].sort((left, right) => + left.work_id.localeCompare(right.work_id) + || left.created_at - right.created_at + || left.id.localeCompare(right.id)); + return this.#retireAttemptAuthority( + attempts, + expectedProfileGeneration, + "Session controller authority retired before dispatch.", + ); }); return prepare.immediate(); } @@ -4360,10 +4755,10 @@ export class WorkStore { if (retainedCount >= WORK_RETAINED_LIMIT) { throw new WorkStoreError("WORK_CAPACITY_EXCEEDED"); } - const coordinator = this.#database.query( - "SELECT 1 AS present FROM sessions WHERE id=?", - ).get(operation.coordinatorSessionId) as { present: number } | null; - if (coordinator === null) throw new WorkStoreError("MEMBER_NOT_FOUND"); + this.#requireSessionAccountAuthority( + operation.coordinatorSessionId, + "MEMBER_NOT_FOUND", + ); const workId = createWorkId(); const now = this.#tick(); this.#database.query( @@ -4451,10 +4846,7 @@ export class WorkStore { case "work.join": { this.#requireActiveWork(operation.workId); this.#assertCoordinator(operation.workId, operation.coordinatorSessionId); - const session = this.#database.query("SELECT 1 AS present FROM sessions WHERE id=?").get( - operation.actorSessionId, - ) as { present: number } | null; - if (session === null) throw new WorkStoreError("MEMBER_NOT_FOUND"); + this.#requireSessionAccountAuthority(operation.actorSessionId, "MEMBER_NOT_FOUND"); const joined = this.#database.query( "SELECT 1 AS present FROM work_members WHERE work_id=? AND session_id=?", ).get(operation.workId, operation.actorSessionId) as { present: number } | null; @@ -5083,6 +5475,7 @@ export class WorkStore { #review(operation: Extract): WorkApplyResult { this.#requireActiveWork(operation.workId); this.#requireMember(operation.workId, operation.reviewerSessionId); + this.#requireSessionAccountAuthority(operation.reviewerSessionId, "ROUTE_MISMATCH"); const submission = this.#database.query( "SELECT * FROM work_submissions WHERE id=? AND work_id=?", ).get(operation.submissionId, operation.workId) as SubmissionRow | null; @@ -5283,6 +5676,7 @@ export class WorkStore { this.#requireActiveWork(operation.workId); this.#requireMember(operation.workId, operation.senderSessionId); this.#requireMember(operation.workId, operation.targetSessionId); + this.#requireSessionAccountAuthority(operation.senderSessionId, "ROUTE_MISMATCH"); this.#assertSignalGovernance( operation.workId, operation.senderSessionId, @@ -5292,9 +5686,9 @@ export class WorkStore { ); const targetAuthority = this.#database.query( `SELECT p.process_generation AS account_generation - FROM sessions AS s JOIN profiles AS p ON p.id=s.profile_id - WHERE s.id=? AND s.state IN ('active','idle') AND p.state!='removed' - AND (s.provider_v39 IN ('claude','devin') OR p.state='signed_in')`, + FROM sessions AS s + JOIN profiles AS p ON p.id=s.profile_id + WHERE s.id=? AND ${currentWorkSessionAuthorityExistsSql("s.id")}`, ).get(operation.targetSessionId) as { account_generation: number } | null; if ( targetAuthority === null @@ -5379,15 +5773,22 @@ export class WorkStore { this.#requireWork(operation.workId); this.#requireMember(operation.workId, operation.actorSessionId); const signal = this.#database.query( - "SELECT id,to_session_id FROM work_signals WHERE id=? AND work_id=?", + `SELECT id,to_session_id,target_account_generation + FROM work_signals WHERE id=? AND work_id=?`, ).get(operation.signalId, operation.workId) as { id: string; + target_account_generation: number; to_session_id: string; } | null; if (signal === null) throw new WorkStoreError("SIGNAL_NOT_FOUND"); if (signal.to_session_id !== operation.actorSessionId) { throw new WorkStoreError("MEMBER_NOT_FOUND"); } + this.#requireSessionAccountAuthority( + operation.actorSessionId, + "ROUTE_MISMATCH", + signal.target_account_generation, + ); const signalBefore = this.#signalRecord(signal.id); if (signalBefore.revision !== operation.expectedSignalRevision) { throw new WorkStoreError("REVISION_CONFLICT"); From 1c5f3cc46f978d2e4a41c1e16f3187da1ca1c98f Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sun, 6 Sep 2026 04:16:54 -0400 Subject: [PATCH 2/5] fix: refresh adoption package inventory --- scripts/package-policy.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/package-policy.ts b/scripts/package-policy.ts index 20a78b5..3798196 100644 --- a/scripts/package-policy.ts +++ b/scripts/package-policy.ts @@ -127,9 +127,9 @@ export async function assertProductionPackageOnly( export async function assertReviewedReleaseInventory(packageRoot: string): Promise { const expected = Object.freeze({ - count: 164, - jsonBytes: 7_520, - sha256: "49b3ad3a66d907ac5d30ba9372ff92eb1a977229b651e5838ddc1fa009a9d7c6", + count: 167, + jsonBytes: 7_687, + sha256: "f50986d7886de711102821bedbfa59a3e641c0b3a653d18ae0907f2e38235a61", }); const inventory: Array = []; const visit = async (path: string): Promise => { From a676353955f27230b02cb5c5fe473862fee05d2f Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sun, 6 Sep 2026 05:15:16 -0400 Subject: [PATCH 3/5] fix: harden adopted session authority cleanup --- scripts/package-policy.ts | 2 +- src/daemon/service.test.ts | 601 +++++++++++++++++++++++++++++++- src/daemon/service.ts | 13 +- src/storage/state-store.test.ts | 359 ++++++++++++++++++- src/storage/state-store.ts | 6 + src/storage/work-store.test.ts | 83 +++++ src/storage/work-store.ts | 56 +-- 7 files changed, 1055 insertions(+), 65 deletions(-) diff --git a/scripts/package-policy.ts b/scripts/package-policy.ts index 3798196..2741cdb 100644 --- a/scripts/package-policy.ts +++ b/scripts/package-policy.ts @@ -129,7 +129,7 @@ export async function assertReviewedReleaseInventory(packageRoot: string): Promi const expected = Object.freeze({ count: 167, jsonBytes: 7_687, - sha256: "f50986d7886de711102821bedbfa59a3e641c0b3a653d18ae0907f2e38235a61", + sha256: "751769c9fc5b8332ae65b50068533bd4f3c739ca5b50e77ad1c3cfe01bd824d1", }); const inventory: Array = []; const visit = async (path: string): Promise => { diff --git a/src/daemon/service.test.ts b/src/daemon/service.test.ts index 5f0f86b..ab0a08f 100644 --- a/src/daemon/service.test.ts +++ b/src/daemon/service.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { mkdtemp, mkdir, realpath, rename, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, realpath, rename, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -31,6 +31,7 @@ import { import { renderSuccess } from "../cli/render"; import { localCommandSchema, + publicSessionListPageSchema, type LocalCommand, type NotificationEmailHostedAuthority, } from "../domain/contracts"; @@ -1838,7 +1839,7 @@ function seedUnsettledInteractionStates( } async function seedResolvableInteraction( - value: Awaited>, + value: Awaited> & Readonly<{ personalCodex?: FakeCodex }>, sessionId: SessionRecord["id"], requestId: string, timing?: Readonly<{ requestedAt: number; deadlineAt: number }>, @@ -1850,8 +1851,15 @@ async function seedResolvableInteraction( const session = value.store.requireSession(sessionId); const profile = value.store.requireProfileById(session.profileId); if (session.providerThreadId === undefined) throw new Error("Expected a bound session."); - const connectionId = value.codex.observationConnectionId; - const authority: ProfileAuthority = { + const personalCodex = source === "personal" ? value.personalCodex : undefined; + const personalClaim = personalCodex?.claimRequests.findLast((request) => + request.providerThreadId === session.providerThreadId); + if (source === "personal" && personalClaim === undefined) { + throw new Error("Expected exact personal Codex claim authority."); + } + const connectionId = personalCodex?.observationConnectionId + ?? value.codex.observationConnectionId; + const authority: ProfileAuthority = personalClaim?.authority ?? { id: profile.id, generation: profile.processGeneration, codexHome: "unused", @@ -3244,6 +3252,211 @@ describe("HraService personal-session adoption", () => { }); }); + test("adopts an old exact target from a real paused Desktop heartbeat without projecting task metadata", async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), "hra-scheduled-adoption-chain-"))); + serviceRoots.push(root); + const codexHome = join(root, "codex-home"); + const automationsDirectory = join(codexHome, "automations"); + const sourceDirectoryName = "desktop-paused-heartbeat-source"; + const privateAutomationId = "desktop-private-task-identifier"; + const privateName = "Desktop private task name"; + const privatePrompt = "Desktop private task prompt"; + const privateCwd = join(root, "desktop-private-task-cwd"); + const privateRrule = "FREQ=DAILY;INTERVAL=17;BYHOUR=3"; + const providerThreadId = "scheduled-real-paused-heartbeat-thread"; + const automationPath = join(automationsDirectory, sourceDirectoryName, "automation.toml"); + const automationDocument = [ + `id = "${privateAutomationId}"`, + 'kind = "heartbeat"', + `name = "${privateName}"`, + `prompt = "${privatePrompt}"`, + `cwds = ["${privateCwd}"]`, + `rrule = "${privateRrule}"`, + 'status = "PAUSED"', + `target_thread_id = "${providerThreadId}"`, + "", + ].join("\n"); + await mkdir(join(automationsDirectory, sourceDirectoryName), { recursive: true }); + await writeFile(automationPath, automationDocument); + + const personalCodex = new FakeCodex(); + const context: { personalAuthority?: ProfileAuthority } = {}; + const requirePersonalAuthority = (): ProfileAuthority => { + if (context.personalAuthority === undefined) { + throw new Error("Expected personal Codex discovery authority."); + } + return context.personalAuthority; + }; + const discovery = new BoundedPersonalSessionDiscovery({ + now: () => personalAdoptionNow, + codexListPage: ({ cursor, limit, signal: discoverySignal }) => + personalCodex.listSessions({ + authority: requirePersonalAuthority(), + ...(cursor === undefined ? {} : { cursor }), + limit, + signal: discoverySignal, + }), + codexReadSession: ({ providerThreadId: exactThreadId, signal: discoverySignal }) => + personalCodex.readSessionMetadata( + requirePersonalAuthority(), + exactThreadId, + discoverySignal, + ), + }); + const authorityRequests: CodexAutomationAuthorityRequest[] = []; + const authorityScans: CodexAutomationAuthorityScan[] = []; + const readPersonalCodexAutomations = async ( + request: CodexAutomationAuthorityRequest, + ): Promise => { + authorityRequests.push(request); + const scan = await readCodexAutomationAuthority({ + ...request, + automationsDirectory, + }); + authorityScans.push(scan); + return scan; + }; + const value = await fixture( + undefined, + new FakeCloud(), + () => undefined, + () => personalAdoptionNow, + undefined, + {}, + { + personalCodex, + personalCodexHome: codexHome, + personalDiscovery: discovery, + readPersonalCodexAutomations, + }, + ); + const projectRoot = value.documents; + const added = await value.service.execute({ + kind: "account.add", + label: "Real paused Desktop heartbeat", + }, { signal }) as { account: { id: `acct_${string}` } }; + await value.service.execute({ + kind: "account.login", + account: added.account.id, + deviceCode: false, + }, { signal }); + await value.service.execute({ + kind: "project.add", + label: "Real paused Desktop heartbeat project", + path: projectRoot, + }, { signal }); + const profile = value.store.requireProfileById(added.account.id); + context.personalAuthority = { + id: profile.id, + generation: profile.processGeneration, + codexHome, + desktopUserData: profilePaths(value.paths, profile.id).desktopUserData, + }; + personalCodex.readProjection = { + providerThreadId, + title: "Old exact Desktop heartbeat target", + projectRoot, + status: "idle", + providerUpdatedAt: personalAdoptionNow - 24 * 60 * 60_000, + }; + + await expect(value.service.execute({ + kind: "session.adoption.set", + provider: "codex", + enabled: true, + account: added.account.id, + }, { signal })).resolves.toMatchObject({ + discovery: { provider: "codex", discovered: 1, adopted: 1, failed: 0 }, + }); + + expect(personalCodex.sessionListRequests).toHaveLength(1); + expect(personalCodex.listedProjections).toEqual([]); + expect(personalCodex.metadataReadRequests).toEqual([{ + authority: context.personalAuthority, + providerThreadId, + }]); + expect(authorityRequests.map((request) => request.kind)).toEqual([ + "page", + "sources", + "sources", + ]); + expect(authorityRequests.slice(1).map((request) => + request.kind === "sources" ? request.sourceDirectoryNames : [])).toEqual([ + [sourceDirectoryName], + [sourceDirectoryName], + ]); + expect(authorityScans).toHaveLength(3); + for (const scan of authorityScans) { + expect(scan).toMatchObject({ complete: true, diagnostics: [], nextCursor: null }); + expect(scan.entries).toEqual([{ + automation: { + kind: "heartbeat", + status: "paused", + targetThreadId: providerThreadId, + }, + sourceDirectoryName, + }]); + expect(Object.keys(scan.entries[0]?.automation ?? {})).toEqual([ + "kind", + "status", + "targetThreadId", + ]); + } + expect(personalCodex.claimRequests).toHaveLength(1); + expect(personalCodex.claimRequests[0]).toMatchObject({ + authority: { codexHome }, + providerThreadId, + projectRoot, + }); + const session = value.store.findSessionByProviderThread( + added.account.id, + providerThreadId, + ); + if (session === null || session.projectId === undefined) { + throw new Error("Expected the real scheduled target to become a project-bound session."); + } + expect(value.store.isConversationAutomationEnabled(session.id, providerThreadId)).toBe(true); + expect(value.store.createSessionTaskStore().list(session.id)).toEqual([]); + expect(await readFile(automationPath, "utf8")).toBe(automationDocument); + + const listCommand = localCommandSchema.parse({ + kind: "session.list", + archived: false, + limit: 10, + }); + const listed = await value.service.execute(listCommand, { signal }); + const rendered = JSON.parse(renderJson(listCommand, listed)) as { data: unknown }; + const publicPage = publicSessionListPageSchema.parse(rendered.data); + expect(publicPage.sessions).toEqual([{ + id: session.id, + profileId: session.profileId, + projectId: session.projectId, + title: session.title, + state: session.state, + provider: session.provider, + preset: session.preset, + fastEnabled: session.fastEnabled, + revision: session.revision, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + }]); + const publicJson = JSON.stringify(publicPage); + for (const privateValue of [ + sourceDirectoryName, + privateAutomationId, + privateName, + privatePrompt, + privateCwd, + privateRrule, + providerThreadId, + ]) { + expect(publicJson).not.toContain(privateValue); + } + expect(publicJson).not.toContain("scheduledTaskTarget"); + expect(publicJson).not.toContain("sourceDirectoryName"); + expect(publicJson).not.toContain("automation"); + }); + test.each(["active", "paused"] as const)( "adopts a stale Codex thread targeted by a present %s heartbeat without exposing its source", async (status) => { @@ -6909,6 +7122,61 @@ describe("HraService personal-session adoption", () => { }); }); + test("terminalizes an adopted Codex session task and releases personal custody on provider deletion", async () => { + const providerThreadId = "personal-thread-deleted-with-task"; + const value = await adoptedCodexFixture( + "Adopted deletion with task", + providerThreadId, + ); + const authority = value.personalCodex.claimRequests[0]?.authority; + if (authority === undefined) throw new Error("Expected personal claim authority."); + const taskStore = value.store.createSessionTaskStore(); + const activeTask = taskStore.create({ + sessionId: value.session.id, + name: "Adopted session task", + prompt: "Must not run after the personal provider deletes this session.", + minutes: 15, + status: "active", + idempotencyKey: "00000000-0000-4000-8000-00000000d001", + }); + const personalEndsBefore = value.personalCodex.calls.filter((call) => call === "end").length; + const managedEndsBefore = value.codex.calls.filter((call) => call === "end").length; + const deletedFact = { + ...parseFact("thread/deleted", { threadId: providerThreadId }), + connectionId: value.personalCodex.observationConnectionId, + }; + + await value.service.observePersonalCodexFact(authority, deletedFact); + await value.service.settled(); + + const terminalSession = value.store.requireSession(value.session.id); + expect(terminalSession).toMatchObject({ state: "terminal" }); + expect(terminalSession.activeTurnId).toBeUndefined(); + const pausedTask = taskStore.list(value.session.id).find((task) => + task.id === activeTask.id); + expect(pausedTask).toMatchObject({ + status: "paused", + revision: activeTask.revision + 1, + nextDueAt: null, + }); + expect(value.personalCodex.calls.filter((call) => call === "end")) + .toHaveLength(personalEndsBefore + 1); + expect(value.codex.calls.filter((call) => call === "end")) + .toHaveLength(managedEndsBefore); + expect(value.service.backgroundDiagnostics()).toEqual({ last: null, byCode: [] }); + expect(value.store.readSessionPersonalRuntimeBinding(value.session.id)).toBeNull(); + expect(value.store.readSessionPersonalRuntimeBinding(value.session.id, true)) + .toMatchObject({ state: "detached" }); + await value.service.observePersonalCodexFact(authority, deletedFact); + await value.service.settled(); + expect(taskStore.list(value.session.id).find((task) => task.id === activeTask.id)) + .toEqual(pausedTask); + expect(value.personalCodex.calls.filter((call) => call === "end")) + .toHaveLength(personalEndsBefore + 1); + expect(value.codex.calls.filter((call) => call === "end")) + .toHaveLength(managedEndsBefore); + }); + test("keeps committed adoption authority and retries failed memory initialization", async () => { const factsMemory = new FakeFactsMemoryLifecycle(); factsMemory.ensureErrorOnce = new Error("lost adoption memory receipt"); @@ -7003,6 +7271,327 @@ describe("HraService personal-session adoption", () => { }); }); + test("keeps adopted Codex protected approvals pending through personal custody", async () => { + const providerThreadId = "personal-thread-protected-approvals"; + const value = await adoptedCodexFixture( + "Adopted protected approvals", + providerThreadId, + ); + const authority = value.personalCodex.claimRequests[0]?.authority; + if (authority === undefined) throw new Error("Expected personal claim authority."); + const connectionId = value.personalCodex.observationConnectionId; + const providerAuthority = ( + requestId: string, + method: string, + ): ProviderInteractionAuthority => ({ + profileId: value.session.profileId, + processGeneration: authority.generation, + connectionId, + requestId: { type: "string", value: requestId }, + method, + requestDigest: createHash("sha256").update(requestId).digest("hex"), + threadId: providerThreadId, + turnId: `turn-${requestId}`, + itemId: `item-${requestId}`, + approvalId: null, + }); + const findInteraction = (requestId: string): InteractionRecord => { + const interaction = value.store.listInteractions({ + sessionId: value.session.id, + limit: 10, + }).find((candidate) => candidate.authority.requestId.value === requestId); + if (interaction === undefined) throw new Error(`Expected interaction ${requestId}.`); + return interaction; + }; + + value.store.setSessionApprovalMode(value.session.id, "auto:workspace"); + const commandRequestId = "adopted-workspace-command"; + await value.service.observePersonalCodexFact(authority, { + type: "interactionRequested", + connectionId, + provider: providerAuthority( + commandRequestId, + "item/commandExecution/requestApproval", + ), + kind: "command_approval", + blocking: true, + display: { + kind: "command_approval", + summary: "Run the adopted workspace command", + reason: null, + commandClass: "bun test", + workingDirectory: null, + availableDecisions: ["once", "session", "decline", "cancel"], + }, + }); + const command = findInteraction(commandRequestId); + await waitFor(() => value.store.listAutorespondEvidence({ + sessionId: value.session.id, + }).length === 1); + + const permissionRequestId = "adopted-workspace-permission"; + await value.service.observePersonalCodexFact(authority, { + type: "interactionRequested", + connectionId, + provider: providerAuthority( + permissionRequestId, + "item/permissions/requestApproval", + ), + kind: "permission_approval", + blocking: true, + display: { + kind: "permission_approval", + summary: "Allow the adopted workspace permission", + reason: null, + requested: [{ name: "workspace_write" }], + allowsSessionScope: true, + }, + }); + const permission = findInteraction(permissionRequestId); + await waitFor(() => value.store.listAutorespondEvidence({ + sessionId: value.session.id, + }).length === 2); + + value.store.setSessionApprovalMode(value.session.id, "auto:all"); + const fileChangeRequestId = "adopted-all-file-change"; + await value.service.observePersonalCodexFact(authority, { + type: "interactionRequested", + connectionId, + provider: providerAuthority( + fileChangeRequestId, + "item/fileChange/requestApproval", + ), + kind: "file_change_approval", + blocking: true, + display: { + kind: "file_change_approval", + summary: "Allow the adopted file changes", + reason: null, + grantRoot: null, + availableDecisions: ["once", "decline", "cancel"], + }, + }); + const fileChange = findInteraction(fileChangeRequestId); + await waitFor(() => value.store.listAutorespondEvidence({ + sessionId: value.session.id, + }).length === 3); + + for (const interaction of [command, permission, fileChange]) { + expect(value.store.requireInteraction(interaction.publicId)).toMatchObject({ + resolvedBy: null, + state: "pending", + }); + } + const evidence = value.store.listAutorespondEvidence({ + sessionId: value.session.id, + }); + for (const [interaction, mode] of [ + [command, "auto:workspace"], + [permission, "auto:workspace"], + [fileChange, "auto:all"], + ] as const) { + expect(evidence.find((row) => row.interactionId === interaction.publicId)) + .toMatchObject({ + decision: "protected_authority_required", + interactionId: interaction.publicId, + mode, + outcome: "refused", + }); + } + expect(value.store.readSessionState(value.session.id)).toMatchObject({ + attention: true, + reason: "autorespond_protected_authority_required", + state: "needs_approval", + }); + expect(value.personalCodex.validatedInteractions).toHaveLength(0); + expect(value.personalCodex.resolvedInteractions).toHaveLength(0); + expect(value.codex.validatedInteractions).toHaveLength(0); + expect(value.codex.resolvedInteractions).toHaveLength(0); + }); + + test("inspects and manually resolves an adopted Codex approval only through personal custody", async () => { + const value = await adoptedCodexFixture( + "Adopted manual approval", + "personal-thread-manual-approval", + ); + value.store.setSessionApprovalMode(value.session.id, "manual"); + const seeded = await seedResolvableInteraction( + value, + value.session.id, + "adopted-manual-approval", + undefined, + "personal", + ); + await waitFor(() => value.store.listAutorespondEvidence({ + sessionId: value.session.id, + }).length === 1); + + await expect(value.service.execute({ + kind: "interaction.inspect", + interaction: seeded.interaction.publicId, + expectedRevision: seeded.interaction.revision, + }, { signal })).resolves.toMatchObject({ + binding: { interactionId: seeded.interaction.publicId }, + authority: { kind: "command_approval", command: "git status --short" }, + }); + await expect(value.service.execute({ + kind: "interaction.resolve", + interaction: seeded.interaction.publicId, + expectedRevision: seeded.interaction.revision, + resolution: { kind: "approval_decision", decision: "once" }, + }, { signal })).resolves.toMatchObject({ + responseWritten: true, + interaction: { state: "response_written" }, + }); + + expect(value.personalCodex.inspectedInteractions).toHaveLength(1); + expect(value.personalCodex.inspectedInteractions[0]).toMatchObject({ + authority: seeded.authority, + provider: seeded.interaction.authority, + }); + expect(value.personalCodex.validatedInteractions).toHaveLength(1); + expect(value.personalCodex.resolvedInteractions).toHaveLength(1); + expect(value.personalCodex.resolvedInteractions[0]).toMatchObject({ + authority: seeded.authority, + provider: seeded.interaction.authority, + resolution: { kind: "approval_decision", decision: "once" }, + }); + expect(value.codex.inspectedInteractions).toHaveLength(0); + expect(value.codex.validatedInteractions).toHaveLength(0); + expect(value.codex.resolvedInteractions).toHaveLength(0); + expect(value.store.requireInteraction(seeded.interaction.publicId)).toMatchObject({ + resolvedBy: null, + state: "response_written", + }); + expect(value.store.readAutorespondBudgets(value.session.id).consecutive).toBe(0); + }); + + test("does not credit autorespond when an adopted Codex account drifts after provider write", async () => { + const value = await adoptedCodexFixture( + "Adopted post-write account drift", + "personal-thread-post-write-account-drift", + ); + const authority = value.personalCodex.claimRequests[0]?.authority; + if (authority === undefined) throw new Error("Expected personal claim authority."); + value.store.setSessionApprovalMode(value.session.id, "auto:all"); + const managedRevocationBefore = value.store.readProviderRuntimeAccountRevocation({ + profileId: value.accountId, + provider: "codex", + runtimeScope: "managed", + }); + const managedValidatedBefore = value.codex.validatedInteractions.length; + const managedResolvedBefore = value.codex.resolvedInteractions.length; + const managedReleasesBefore = value.codex.releasedAuthorities.length; + + let validationStarted!: () => void; + const validationAdmission = new Promise((resolve) => { + validationStarted = resolve; + }); + let finishValidation!: () => void; + const validationGate = new Promise((resolve) => { + finishValidation = resolve; + }); + value.personalCodex.beforeValidateInteractionResolutionReturn = async () => { + validationStarted(); + await validationGate; + }; + let releaseStarted!: () => void; + const releaseAdmission = new Promise((resolve) => { + releaseStarted = resolve; + }); + let finishRelease!: () => void; + const releaseGate = new Promise((resolve) => { + finishRelease = resolve; + }); + value.personalCodex.beforeReleaseOwnedAuthorityReturn = async () => { + releaseStarted(); + await releaseGate; + }; + const replacementAccount: CodexAccountProjection = { + signedIn: true, + email: "replacement-after-adopted-response@example.com", + plan: "Plus", + }; + value.personalCodex.beforeResolveInteractionReturn = async () => { + value.personalCodex.accountProjection = replacementAccount; + await expect(value.service.observePersonalCodexAccount( + authority, + replacementAccount, + )).rejects.toMatchObject({ code: "RECOVERY_REQUIRED" }); + }; + + const seeded = await seedResolvableInteraction( + value, + value.session.id, + "adopted-post-write-account-drift", + undefined, + "personal", + ); + await validationAdmission; + finishValidation(); + await waitFor(() => value.personalCodex.resolvedInteractions.length === 1); + await releaseAdmission; + await waitFor(() => value.store.listAutorespondEvidence({ + sessionId: value.session.id, + }).length === 1); + + expect(value.store.requireInteraction(seeded.interaction.publicId)).toMatchObject({ + intendedTerminalState: "resolved", + resolvedBy: null, + state: "resolution_unknown", + }); + expect(value.store.readAutorespondBudgets(value.session.id)).toMatchObject({ + consecutive: 0, + lastDay: 0, + lastHour: 0, + }); + expect(value.store.listAutorespondEvidence({ sessionId: value.session.id })[0]) + .toMatchObject({ + decision: "once", + interactionId: seeded.interaction.publicId, + mode: "auto:all", + outcome: "refused", + }); + expect(value.store.readProviderRuntimeAccountRevocation({ + profileId: value.accountId, + provider: "codex", + runtimeScope: "personal", + })).toMatchObject({ + currentAccountKey: codexProviderAccountKey( + "replacement-after-adopted-response@example.com", + ), + profileGeneration: authority.generation, + state: "releasing", + }); + expect(value.store.readProviderRuntimeAccountRevocation({ + profileId: value.accountId, + provider: "codex", + runtimeScope: "managed", + })).toEqual(managedRevocationBefore); + expect(value.personalCodex.validatedInteractions).toHaveLength(1); + expect(value.personalCodex.resolvedInteractions).toHaveLength(1); + expect(value.codex.validatedInteractions).toHaveLength(managedValidatedBefore); + expect(value.codex.resolvedInteractions).toHaveLength(managedResolvedBefore); + expect(value.codex.releasedAuthorities).toHaveLength(managedReleasesBefore); + + finishRelease(); + await value.service.settled(); + expect(value.personalCodex.releasedAuthorities).toEqual([authority]); + expect(value.store.readProviderRuntimeAccountRevocation({ + profileId: value.accountId, + provider: "codex", + runtimeScope: "personal", + })).toMatchObject({ state: "completed" }); + expect(value.store.readSessionPersonalRuntimeBinding(value.session.id, true)) + .toMatchObject({ state: "detached" }); + expect(value.store.readProviderRuntimeAccountRevocation({ + profileId: value.accountId, + provider: "codex", + runtimeScope: "managed", + })).toEqual(managedRevocationBefore); + expect(value.codex.releasedAuthorities).toHaveLength(managedReleasesBefore); + }); + test("refuses protected approval inspection when the adopted Codex account changes in flight", async () => { const value = await adoptedCodexFixture( "Adopted inspection account race", @@ -20084,7 +20673,7 @@ describe("HraService autorespond", () => { const session = value.store.requireSession(sessionId); const profile = value.store.requireProfileById(session.profileId); if (session.providerThreadId === undefined) throw new Error("Expected a bound session."); - const connectionId = "46000000-0000-4000-8000-000000000002"; + const connectionId = value.codex.observationConnectionId; await value.service.observeCodexFact({ id: profile.id, generation: profile.processGeneration, @@ -20129,7 +20718,7 @@ describe("HraService autorespond", () => { const session = value.store.requireSession(sessionId); const profile = value.store.requireProfileById(session.profileId); if (session.providerThreadId === undefined) throw new Error("Expected a bound session."); - const connectionId = "46000000-0000-4000-8000-000000000003"; + const connectionId = value.codex.observationConnectionId; await value.service.observeCodexFact({ id: profile.id, generation: profile.processGeneration, diff --git a/src/daemon/service.ts b/src/daemon/service.ts index 6539dc2..b3ba615 100644 --- a/src/daemon/service.ts +++ b/src/daemon/service.ts @@ -2659,12 +2659,15 @@ export class HraService { } #scheduleTerminalPersonalDetach(session: SessionRecord): void { + // The durable fence is already `detaching`, so it intentionally no longer + // satisfies the live-session account-authority predicate. Preserve the + // normal adoption/account/session lock order while releasing that fenced + // controller without trying to re-admit it as operational authority. const task = this.#serialize(`session-adoption:${session.provider}`, async () => - await this.#serializeSessionAuthority( - session, - async () => { await this.#detachPersonalSession(session.id, this.#backgroundAbort.signal); }, - { allowDuringProjectionRecovery: true }, - )); + await this.#serializeProfileAuthorities([session.profileId], async () => + await this.#serialize(`session:${session.id}`, async () => { + await this.#detachPersonalSession(session.id, this.#backgroundAbort.signal); + }))); const tracked = task.catch((error: unknown) => { if (this.#backgroundAbort.signal.aborted) return; this.recordBackgroundDiagnostic("session_adoption_failed", error); diff --git a/src/storage/state-store.test.ts b/src/storage/state-store.test.ts index 7d6e751..2614c76 100644 --- a/src/storage/state-store.test.ts +++ b/src/storage/state-store.test.ts @@ -30,7 +30,7 @@ import { observedAccountTokenVelocity, type StoredAccountUsageSnapshot, } from "../domain/usage-metrics"; -import { utf8Bytes } from "../domain/values"; +import { createAttemptId, utf8Bytes } from "../domain/values"; import { canTransitionQueue, queueStateSchema, type QueueState } from "../domain/transitions"; import { projectPublicProviderIdentifier } from "../public-provider-identifier"; import { presetRequirements } from "../domain/presets"; @@ -7091,6 +7091,17 @@ describe("StateStore", () => { sessionId: session.id, }, }); + const taskStore = store.createSessionTaskStore(); + const activeTask = taskStore.create({ + sessionId: session.id, + name: "Provider-deleted task", + prompt: "Must not run after the provider deletes this session.", + minutes: 15, + status: "active", + idempotencyKey: "00000000-0000-4000-8000-000000000605", + }); + const readTask = () => taskStore.list(session.id).find((task) => + task.id === activeTask.id); expect(() => store.terminalizeSessionFromProviderDeletion({ accountId: profile.id, @@ -7104,6 +7115,12 @@ describe("StateStore", () => { expect(store.readMutation("00000000-0000-4000-8000-000000000604")) .toMatchObject({ state: "effect_started" }); expect(store.requireQueue(queued.id)).toMatchObject({ state: "dispatching" }); + expect(readTask()).toMatchObject({ + status: "active", + revision: activeTask.revision, + nextDueAt: activeTask.nextDueAt, + updatedAt: activeTask.updatedAt, + }); const terminal = store.terminalizeSessionFromProviderDeletion({ accountId: profile.id, @@ -7138,6 +7155,12 @@ describe("StateStore", () => { }); expect(store.listUnsettledMutations({ sessionId: session.id })).toEqual([]); expect(store.listUnsettledQueueEffects(session.id)).toEqual([]); + const pausedTask = readTask(); + expect(pausedTask).toMatchObject({ + status: "paused", + revision: activeTask.revision + 1, + nextDueAt: null, + }); expect(store.terminalizeSessionFromProviderDeletion({ accountId: profile.id, providerConnectionId: null, @@ -7148,6 +7171,7 @@ describe("StateStore", () => { interactions: [], session: { state: "terminal" }, }); + expect(readTask()).toEqual(pausedTask); expect(store.listSessionEvents({ afterSequence: 0, sessionId: session.id, @@ -7156,6 +7180,225 @@ describe("StateStore", () => { .toHaveLength(1); }); + test("late accepted Work dispatch stays in recovery after provider deletion and restart", async () => { + const { store, home } = await fixture(); + const profile = signInProfile( + store, + "Provider-deleted Work authority", + "provider-deleted-work@example.com", + ); + const projectRoot = join(home, "provider-deleted-work"); + await mkdir(projectRoot); + const project = await store.createProject( + "Provider-deleted Work", + projectRoot, + true, + ); + store.setSessionAdoptionPolicy({ provider: "codex", profileId: profile.id }); + const candidate = store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: "provider-deleted-work-thread", + title: "Provider-deleted Work", + state: "idle", + providerUpdatedAt: 10, + liveness: "not_live", + }); + const claiming = store.fenceSessionAdoptionCandidateForClaim({ + provider: "codex", + providerThreadId: candidate.providerThreadId, + expectedRevision: candidate.revision, + }); + const adopted = store.adoptSessionCandidate({ + provider: "codex", + providerThreadId: claiming.providerThreadId, + expectedCandidateRevision: claiming.revision, + profileId: profile.id, + profileGeneration: profile.processGeneration, + preset: "high", + requirement: presetRequirements.high, + fastEnabled: false, + runtimeProfile: codexAdoptionRuntimeProfile(profile, "high", false), + providerAccountKey: providerAccountKeyForProfile(store, profile.id, "codex"), + }); + const session = store.updateSessionMetadata({ + sessionId: adopted.session.id, + expectedRevision: adopted.session.revision, + projectId: project.id, + }); + const workCapability = `hrac1_${"A".repeat(43)}`; + const encodeWorkCursor = (payload: unknown) => + `hra1.${Buffer.from(JSON.stringify(payload), "utf8").toString("base64url")}.${"A".repeat(43)}`; + const createWorkStore = (generation: number) => store.createWorkStore( + generation, + encodeWorkCursor, + { + issue: () => workCapability, + verify: (candidateCapability) => candidateCapability === workCapability, + }, + ); + const workStore = createWorkStore(17); + const created = workStore.apply({ + kind: "work.create", + idempotencyKey: "01890f31-a123-7000-8000-000000000951", + clientRef: "provider-deleted-work", + coordinatorSessionId: session.id, + objective: "Keep late provider receipts from resurrecting deleted session authority.", + routes: [{ + accountId: profile.id, + projectId: project.id, + preset: "high", + fast: false, + }], + tasks: [{ + clientRef: "provider-deleted-work-task", + dependsOnRefs: [], + dependsOnTaskIds: [], + objective: "Hold one dispatch across provider deletion.", + instructions: "Remain fail-closed when the accepted receipt arrives late.", + criteria: ["The attempt never returns to running."], + route: { accountId: profile.id, projectId: project.id }, + preset: "high", + fast: false, + priority: 0, + maxAttempts: 3, + requiredReviews: 0, + resultKind: "text", + minEvidence: 0, + }], + }); + if (created.kind !== "work.create") throw new Error("Expected a created work item."); + const task = created.tasks[0]; + if (task === undefined) throw new Error("Expected one work task."); + const claimed = workStore.apply({ + kind: "task.claim", + idempotencyKey: "01890f31-a123-7000-8000-000000000952", + workId: created.work.id, + taskId: task.id, + expectedTaskRevision: task.revision, + actorSessionId: session.id, + actorCapability: workCapability, + leaseMs: 50_000, + }); + if (claimed.kind !== "task.claim") throw new Error("Expected a claimed work task."); + const dispatchKey = "01890f31-a123-7000-8000-000000000953"; + const prepared = workStore.apply({ + kind: "attempt.dispatch", + idempotencyKey: dispatchKey, + workId: created.work.id, + attemptId: claimed.attempt.id, + expectedAttemptRevision: claimed.attempt.revision, + fence: claimed.attempt.fence, + actorSessionId: session.id, + attemptCapability: workCapability, + targetSessionId: session.id, + mode: "send", + }); + if (prepared.kind !== "attempt.dispatch") throw new Error("Expected a dispatch effect."); + expect(workStore.authorizePreparedEffect(dispatchKey)).toMatchObject({ + executable: true, + status: { state: "effect_started" }, + }); + + expect(store.terminalizeSessionFromProviderDeletion({ + accountId: profile.id, + providerConnectionId: null, + providerGeneration: profile.processGeneration, + sessionId: session.id, + })).toMatchObject({ + changed: true, + session: { state: "terminal" }, + }); + const acceptedReceipt = { + kind: "turn_started" as const, + turnId: `opaque_v2_${"d".repeat(64)}`, + runtimeProfileDigest: "e".repeat(64), + mutationAttemptId: createAttemptId(), + accountGeneration: profile.processGeneration, + }; + const recovered = workStore.finalizeDispatch(dispatchKey, { + kind: "accepted", + receipt: acceptedReceipt, + }); + expect(recovered).toMatchObject({ + id: claimed.attempt.id, + status: "unknown", + dispatchReceipt: acceptedReceipt, + }); + const rawAttempt = () => { + const inspector = new Database(store.paths.database, { readonly: true, strict: true }); + try { + return inspector.query( + "SELECT state,revision FROM work_attempts WHERE id=?", + ).get(claimed.attempt.id); + } finally { + inspector.close(false); + } + }; + expect(rawAttempt()).toEqual({ + state: "recovery_required", + revision: recovered.revision, + }); + expect(workStore.task(task.id)).toMatchObject({ + task: { status: "blocked" }, + activeAttempt: { id: claimed.attempt.id, status: "unknown" }, + }); + const afterFinalization = workStore.events(created.work.id, 0, 50); + expect(afterFinalization.events.at(-1)?.body).toEqual({ + type: "attempt.dispatch_finalized", + attemptId: claimed.attempt.id, + outcome: "accepted", + }); + + expect(workStore.finalizeDispatch(dispatchKey, { + kind: "accepted", + receipt: acceptedReceipt, + })).toEqual(recovered); + expect(workStore.snapshot(created.work.id).tasks[0]).toMatchObject({ + id: task.id, + status: "blocked", + }); + expect(workStore.events(created.work.id, 0, 50)).toEqual(afterFinalization); + + const paths = store.paths; + store.close(); + stores.splice(stores.indexOf(store), 1); + const restarted = new StateStore(paths); + stores.push(restarted); + const restartedWork = restarted.createWorkStore( + 18, + encodeWorkCursor, + { + issue: () => workCapability, + verify: (candidateCapability) => candidateCapability === workCapability, + }, + ); + expect(restartedWork.authorizePreparedEffect(dispatchKey)).toMatchObject({ + executable: false, + disposition: "settled", + status: { state: "accepted" }, + }); + expect(restartedWork.finalizeDispatch(dispatchKey, { + kind: "accepted", + receipt: acceptedReceipt, + })).toEqual(recovered); + expect(restartedWork.snapshot(created.work.id).tasks[0]).toMatchObject({ + id: task.id, + status: "blocked", + }); + expect(restartedWork.events(created.work.id, 0, 50)).toEqual(afterFinalization); + const restartedInspector = new Database(paths.database, { readonly: true, strict: true }); + try { + expect(restartedInspector.query( + "SELECT state,revision FROM work_attempts WHERE id=?", + ).get(claimed.attempt.id)).toEqual({ + state: "recovery_required", + revision: recovered.revision, + }); + } finally { + restartedInspector.close(false); + } + }); + test("atomically binds a session-start placeholder before its provider effect is admitted", async () => { const { store, home } = await fixture(); const profile = signInProfile(store, "Bound start", "bound-start@example.com"); @@ -12573,10 +12816,12 @@ describe("StateStore", () => { const profile = signInProfile(store, "Active restart attention", "active-restart-attention@example.com"); const threadId = "thread-active-restart-attention"; const turnId = "turn-active-restart-attention"; - const session = store.upsertProviderSession({ + const session = createProvenTestSession(store, { profileId: profile.id, providerThreadId: threadId, title: "Active restart attention", + preset: "high", + fastEnabled: false, state: "active", activeTurnId: turnId, }); @@ -12649,10 +12894,12 @@ describe("StateStore", () => { const { store } = await fixture(); const profile = signInProfile(store, "Idle restart attention", "idle-restart-attention@example.com"); const threadId = "thread-idle-restart-attention"; - const session = store.upsertProviderSession({ + const session = createProvenTestSession(store, { profileId: profile.id, providerThreadId: threadId, title: "Idle restart attention", + preset: "high", + fastEnabled: false, state: "idle", }); const interaction = admitRestartCommandInteraction(store, { @@ -12733,10 +12980,12 @@ describe("StateStore", () => { for (const input of cases) { const threadId = `thread-non-interaction-attention-${String(input.index)}`; const turnId = `turn-non-interaction-attention-${String(input.index)}`; - const session = store.upsertProviderSession({ + const session = createProvenTestSession(store, { profileId: profile.id, providerThreadId: threadId, title: `Non-interaction attention ${String(input.index)}`, + preset: "high", + fastEnabled: false, state: "active", activeTurnId: turnId, }); @@ -12780,10 +13029,12 @@ describe("StateStore", () => { const profile = signInProfile(store, "Multi restart attention", "multi-restart-attention@example.com"); const threadId = "thread-multi-restart-attention"; const turnId = "turn-multi-restart-attention"; - const session = store.upsertProviderSession({ + const session = createProvenTestSession(store, { profileId: profile.id, providerThreadId: threadId, title: "Multi restart attention", + preset: "high", + fastEnabled: false, state: "active", activeTurnId: turnId, }); @@ -12837,10 +13088,12 @@ describe("StateStore", () => { const profile = signInProfile(store, "Restart repair rollback", "restart-repair-rollback@example.com"); const threadId = "thread-restart-repair-rollback"; const turnId = "turn-restart-repair-rollback"; - const session = store.upsertProviderSession({ + const session = createProvenTestSession(store, { profileId: profile.id, providerThreadId: threadId, title: "Restart repair rollback", + preset: "high", + fastEnabled: false, state: "active", activeTurnId: turnId, }); @@ -12895,6 +13148,100 @@ describe("StateStore", () => { } }); + test("repairs adopted Codex attention before advancing personal authority", async () => { + const { store } = await fixture(); + const profile = signInProfile( + store, + "Adopted restart attention", + "adopted-restart-attention@example.com", + ); + const threadId = "thread-adopted-restart-attention"; + store.setSessionAdoptionPolicy({ provider: "codex", profileId: profile.id }); + const candidate = store.upsertSessionAdoptionCandidate({ + provider: "codex", + providerThreadId: threadId, + title: "Adopted restart attention", + state: "idle", + providerUpdatedAt: 10, + liveness: "not_live", + }); + const claiming = store.fenceSessionAdoptionCandidateForClaim({ + provider: "codex", + providerThreadId: threadId, + expectedRevision: candidate.revision, + }); + const adopted = store.adoptSessionCandidate({ + provider: "codex", + providerThreadId: threadId, + expectedCandidateRevision: claiming.revision, + profileId: profile.id, + profileGeneration: profile.processGeneration, + preset: "high", + requirement: presetRequirements.high, + fastEnabled: false, + runtimeProfile: codexAdoptionRuntimeProfile(profile, "high", false), + providerAccountKey: providerAccountKeyForProfile(store, profile.id, "codex"), + }); + const interaction = admitRestartCommandInteraction(store, { + index: 41, + processGeneration: profile.processGeneration, + profileId: profile.id, + sessionId: adopted.session.id, + threadId, + turnId: null, + }); + const beforeState = store.upsertSessionState({ + sessionId: adopted.session.id, + state: "needs_approval", + attention: true, + reason: "autorespond_protected_authority_required", + verbatimRequired: false, + verbatimLiteral: undefined, + lastActivityAt: 941, + revision: 41, + }); + + expect(store.nextDaemonGeneration(`boot_${"6".repeat(32)}`)).toBe(1); + + expect(store.requireInteraction(interaction.publicId)).toMatchObject({ + state: "expired", + revision: interaction.revision + 1, + }); + expect(store.readSessionState(adopted.session.id)).toMatchObject({ + state: "aborted", + attention: false, + reason: "provider interaction ended during daemon restart", + revision: beforeState.revision + 1, + }); + expect(store.requireProfileById(profile.id).processGeneration) + .toBe(profile.processGeneration + 1); + expect(store.readSessionPersonalRuntimeBinding(adopted.session.id)).toMatchObject({ + provider: "codex", + providerThreadId: threadId, + state: "active", + }); + expect(store.sessionAccountAuthorityMatches(adopted.session.id, profile.id)).toBe(true); + expect(store.listSessionEvents({ + sessionId: adopted.session.id, + afterSequence: 0, + }).events.map((event) => ({ + type: event.body.type, + providerGeneration: event.providerGeneration, + }))).toEqual([ + { type: "interaction_state", providerGeneration: profile.processGeneration }, + { type: "session_state", providerGeneration: profile.processGeneration }, + { type: "gap", providerGeneration: profile.processGeneration + 1 }, + ]); + const inspector = new Database(store.paths.database, { readonly: true, strict: true }); + try { + expect(inspector.query( + "SELECT COUNT(*) AS count FROM session_adoption_profile_generation_permits", + ).get()).toEqual({ count: 0 }); + } finally { + inspector.close(false); + } + }); + for (const effect of ["known_unsent", "possibly_sent"] as const) { test(`atomically quarantines an interaction persistence boundary that is ${effect}`, async () => { const { store } = await fixture(); diff --git a/src/storage/state-store.ts b/src/storage/state-store.ts index 52050b4..922f87f 100644 --- a/src/storage/state-store.ts +++ b/src/storage/state-store.ts @@ -14700,6 +14700,12 @@ export class StateStore { AND m.state IN ('effect_started','ambiguous') AND r.attempt_id IS NULL`, ).run(providerDeletionEvidence, now, current.id, current.id); + this.#database.query( + `UPDATE session_tasks + SET status='paused',revision=revision+1,next_due_at=NULL, + updated_at=MAX(updated_at+1,?) + WHERE session_id=? AND deleted_at IS NULL AND status='active'`, + ).run(now, current.id); const rows = this.#database.query( `SELECT * FROM provider_interactions WHERE session_id=? AND state IN ('pending','response_prepared','response_written') diff --git a/src/storage/work-store.test.ts b/src/storage/work-store.test.ts index 7ec5d4e..6bfbf89 100644 --- a/src/storage/work-store.test.ts +++ b/src/storage/work-store.test.ts @@ -1295,6 +1295,89 @@ describe("WorkStore claims, fences, and prepared effects", () => { }); }); + test("retains a late accepted receipt without reviving swept provider authority", () => { + const value = fixture(); + const created = createWork(value); + const claimed = claim(value, { + workId: created.work.id, + taskId: created.tasks[0]!.id, + revision: created.tasks[0]!.revision, + }); + const dispatchKey = randomUUID(); + value.store.apply({ + kind: "attempt.dispatch", + idempotencyKey: dispatchKey, + workId: created.work.id, + attemptId: claimed.attempt.id, + expectedAttemptRevision: claimed.attempt.revision, + fence: claimed.attempt.fence, + actorSessionId: value.actorSessionId, + attemptCapability: capability, + targetSessionId: value.actorSessionId, + mode: "send", + }); + expect(value.store.authorizePreparedEffect(dispatchKey).executable).toBe(true); + + value.database.query("UPDATE sessions SET state='terminal' WHERE id=?") + .run(value.actorSessionId); + expect(value.store.snapshot(created.work.id).tasks[0]).toMatchObject({ + status: "blocked", + }); + expect(value.database.query( + "SELECT state FROM work_attempts WHERE id=?", + ).get(claimed.attempt.id)).toEqual({ state: "recovery_required" }); + expect(value.store.events(created.work.id, 0, 50).events.at(-1)?.body).toEqual({ + type: "attempt.recovery_required", + attemptId: claimed.attempt.id, + fence: claimed.attempt.fence, + reason: "custodian_restart", + }); + + const receipt = turnStartedReceipt(); + const recovered = value.store.finalizeDispatch(dispatchKey, { + kind: "accepted", + receipt, + }); + expect(recovered).toMatchObject({ + status: "unknown", + dispatchReceipt: receipt, + }); + const afterFinalization = value.store.events(created.work.id, 0, 50); + expect(afterFinalization.events.at(-1)?.body).toEqual({ + type: "attempt.dispatch_finalized", + attemptId: claimed.attempt.id, + outcome: "accepted", + }); + + const restarted = new WorkStore(value.database, { + daemonGeneration: 8, + now: () => value.now.value, + encodeCursor, + issueCapability, + verifyCapability, + projectProviderIdentifier, + }); + expect(restarted.authorizePreparedEffect(dispatchKey)).toMatchObject({ + executable: false, + disposition: "settled", + status: { state: "accepted" }, + }); + expect(restarted.finalizeDispatch(dispatchKey, { + kind: "accepted", + receipt, + })).toEqual(recovered); + expect(restarted.snapshot(created.work.id).tasks[0]).toMatchObject({ + status: "blocked", + }); + expect(restarted.events(created.work.id, 0, 50)).toEqual(afterFinalization); + expect(value.database.query( + "SELECT state,revision FROM work_attempts WHERE id=?", + ).get(claimed.attempt.id)).toEqual({ + state: "recovery_required", + revision: recovered.revision, + }); + }); + test("lets a Work claim win before a provider-switch effect starts", () => { const value = fixture(); const created = createWork(value); diff --git a/src/storage/work-store.ts b/src/storage/work-store.ts index 4c1936e..b64a1df 100644 --- a/src/storage/work-store.ts +++ b/src/storage/work-store.ts @@ -6684,53 +6684,15 @@ export class WorkStore { this.#tryFinalizePendingWork(effect.work_id); return this.#attemptRecord(this.#requireAttempt(attempt.id, effect.work_id)); } - if (attempt.state !== "dispatching") throw new WorkStoreError("ATTEMPT_RECOVERY_REQUIRED"); - const now = this.#tick(); - const nextState = parsedOutcome.kind === "accepted" - ? "running" - : parsedOutcome.kind === "unknown" - ? "recovery_required" - : "failed"; - this.#database.query( - `UPDATE work_attempts - SET state=?,revision=revision+1,updated_at=?,terminal_at=? - WHERE id=? AND state='dispatching'`, - ).run(nextState, now, nextState === "failed" ? now : null, attempt.id); - if (nextState === "failed") { - const task = this.#requireTask(attempt.task_id, effect.work_id); - this.#database.query( - `UPDATE work_task_states - SET state=?,revision=revision+1,updated_at=? WHERE task_id=?`, - ).run( - task.state.attempt_count < task.task.max_attempts ? "pending" : "failed", - now, - attempt.task_id, - ); - } else { - this.#database.query( - `UPDATE work_task_states - SET state=?,revision=revision+1,updated_at=? WHERE task_id=?`, - ).run(nextState, now, attempt.task_id); - } - const settled = this.#database.query( - `UPDATE work_prepared_effects - SET state=?,outcome_digest=?,outcome_json=?,finalized_at=? - WHERE idempotency_key=? AND state='effect_started'`, - ).run( - parsedOutcome.kind, - outcomeDigest, - outcomeJson, - now, - idempotencyKey, - ); - if (settled.changes !== 1) throw new WorkStoreError("IDEMPOTENCY_CONFLICT"); - const body = { - type: "attempt.dispatch_finalized" as const, - attemptId: attempt.id, - outcome: parsedOutcome.kind, - }; - this.#appendEvent(effect.work_id, body.type, attempt.worker_session_id, body); - this.#tryFinalizePendingWork(effect.work_id); + if ( + attempt.state !== "dispatching" + && !(attempt.state === "recovery_required" && parsedOutcome.kind === "accepted") + ) throw new WorkStoreError("ATTEMPT_RECOVERY_REQUIRED"); + // Use the same full resume predicate as restart recovery: active Work, + // live lease, open task deadline, and exact current provider authority. + // A late accepted receipt is still retained after an intervening stale- + // authority sweep, but that receipt cannot reactivate a fenced attempt. + this.#settleAuthorizedDispatch(effect, parsedOutcome); return this.#attemptRecord(this.#requireAttempt(attempt.id, effect.work_id)); }); return finalize.immediate(); From b28e2407bb124c3d3370ea2b89dc779395345c16 Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sun, 6 Sep 2026 05:22:36 -0400 Subject: [PATCH 4/5] test: await exact projection refusal evidence --- src/daemon/service.test.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/daemon/service.test.ts b/src/daemon/service.test.ts index ab0a08f..a446496 100644 --- a/src/daemon/service.test.ts +++ b/src/daemon/service.test.ts @@ -13751,7 +13751,8 @@ describe("HraService", () => { { requestedAt: now, deadlineAt: now + 10_000 }, ); const evidenceDeadline = Date.now() + 2_000; - while (value.store.listAutorespondEvidence({ sessionId }).length === 0) { + while (!value.store.listAutorespondEvidence({ sessionId }).some((row) => + row.interactionId === automatic.interaction.publicId)) { if (Date.now() >= evidenceDeadline) { throw new Error("Timed out waiting for fenced autorespond evidence."); } @@ -13765,10 +13766,23 @@ describe("HraService", () => { expect(value.store.requireInteraction(manual.interaction.publicId).state).toBe("pending"); expect(value.store.requireInteraction(deadline.interaction.publicId).state).toBe("pending"); expect(value.store.requireInteraction(automatic.interaction.publicId).state).toBe("pending"); - expect(value.store.listAutorespondEvidence({ sessionId })).toMatchObject([{ - interactionId: automatic.interaction.publicId, + const evidence = value.store.listAutorespondEvidence({ sessionId }); + expect(evidence).toHaveLength(3); + expect(evidence.find((row) => row.interactionId === manual.interaction.publicId)).toMatchObject({ + decision: "manual_mode", + mode: "manual", outcome: "refused", - }]); + }); + expect(evidence.find((row) => row.interactionId === deadline.interaction.publicId)).toMatchObject({ + decision: "manual_mode", + mode: "manual", + outcome: "refused", + }); + expect(evidence.find((row) => row.interactionId === automatic.interaction.publicId)).toMatchObject({ + decision: "once", + mode: "auto:all", + outcome: "refused", + }); expect(value.store.requireProfileById(profile.id).state).toBe("signed_in"); release(); From 066296513422f6c6bbecd0eff208283e051e04cb Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sun, 6 Sep 2026 05:36:26 -0400 Subject: [PATCH 5/5] ci: provision typed lint heap --- .github/workflows/ci.yml | 4 ++++ scripts/release-workflow.test.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c72a50..2c56e12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,10 @@ jobs: /usr/bin/unshare --user --map-root-user --fork /usr/bin/true - name: Run the repository gate + env: + # Typed ESLint loads the complete repository project graph and can + # exceed V8's default heap on the 7 GiB macOS runner. + NODE_OPTIONS: "--max-old-space-size=4096" run: bun run check - name: Restore Ubuntu user-namespace restriction diff --git a/scripts/release-workflow.test.ts b/scripts/release-workflow.test.ts index 8a766f8..f150ba8 100644 --- a/scripts/release-workflow.test.ts +++ b/scripts/release-workflow.test.ts @@ -699,6 +699,9 @@ describe("release workflow", () => { expect(governedHistory).not.toContain("pull_request.head.sha"); expect(asRecord(install, "CI install step").run).toBe("bun install --frozen-lockfile --ignore-scripts"); expect(asRecord(gate, "CI gate step").run).toBe("bun run check"); + expect(asRecord(asRecord(gate, "CI gate step").env, "CI gate environment")).toEqual({ + NODE_OPTIONS: "--max-old-space-size=4096", + }); // The gate already verifies generated public documents and runs the // Linux custody test through `bun test ./scripts`; CI does not repeat them. const packageScripts = asRecord(asRecord(