From 81c8a4d91b8033df7cc9d1540bea9250c59155d6 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 03:38:44 -0400 Subject: [PATCH 01/22] =?UTF-8?q?=F0=9F=8F=9B=EF=B8=8F=20Scope=20native=20?= =?UTF-8?q?capability=20advertisement=20to=20proved=20builds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 3 +- specs/decisions.md | 82 ++++++++++ specs/native-agent-session-launch-spec.md | 185 +++++++++++++++++----- 3 files changed, 231 insertions(+), 39 deletions(-) diff --git a/architecture.md b/architecture.md index 90e980cac..ee8c8b26c 100644 --- a/architecture.md +++ b/architecture.md @@ -118,6 +118,7 @@ Existing documents and code get aligned to this section retroactively. | session coordinator | the host-built capability that answers who owns one logical agent session right now, across processes. It is passed directly into the provider that needs it and is deliberately not contextual: a decision document middleware could replace is not one. Acquisition never waits | | construction route | how one logical agent session was first constructed — through ACP, or under an identity XMD chose — as strict create-once durable state beside the coordinator's own records. It answers a different question from ownership and grants no right to act: the coordinator remains the single live authority. A route never converts, and a session ACP already established is recorded as ACP-first rather than reclassified. Two schemas are readable: the released `session-route.v1`, and `session-route.v2`, which exists only for `client-native` and adds a required executable build binding. New client-native construction publishes V2; a V1 client-native route stays legacy-unbound, is never upgraded in place, and authorizes native resume only | | executable build binding | which build of a provider executable accepted a client-allocated identity, as a schema, an adapter-canonical version and the lowercase SHA-256 of the canonical executable target. Equality is exact over all three, so a matching build reached at another path is the same build and a changed build at the same path is not. It contains no path: a path says where a build was, which stops being true, and names host layout besides | +| native capability compatibility point | the smallest installed-provider environment for which one native capability's real-CLI proof passed: adapter, capability, adapter-canonical reported version, host operating system and host architecture. A proof authorizes only that point, never every semver-shaped build or every platform. The executable digest remains the per-session continuity binding rather than a global allow-list: the compatibility proof describes a released provider build across installations, while the route must still recognize the exact executable that accepted its identity | | executable observer | the host-built capability that resolves a launcher command through the host's real execution environment, canonicalizes the target, requires an executable regular file, hashes that target and asks that same file its version. Passed directly into the provider like the coordinator beside it, and deliberately not contextual: executable validation decides which retained history may be accepted, and a resolver document middleware could replace could point the observation at one binary while the run spawns another | | client-native attachment | joining a conversation a native process constructed, through ACP, under the identity the route already names. It is attachment, never conversion: the route stays client-native, `resumeSessionId` receives that exact identity, and the provider's canonical assertion must equal it before the first turn. Advertised separately from native launch, because handing a session to a native UI and later joining it prove different things | | identity provenance | who chose a provider-native session identity: the provider returned it, or the client allocated it before the provider existed. Retained rather than inferred, because after the fact both are just a string in the record. A released record written before the distinction existed reads as provider-returned, which is the only compatibility inference | @@ -4646,7 +4647,7 @@ Status is measured against main. | nested run-profile Agent and elicitation declarations | lets one `` declare one child-scoped `` scenario set and one non-delegating `` matcher set; only frozen test data crosses the harness request, the trusted host constructs both providers inside the isolated child, siblings share no session or provider state, ordinary component shadowing remains in force, and the child journal retains only the selected Prompt and Elicit components' ordinary results. A controlled `` may author an exact scenario label that this host alone maps to Plan's derived conversation identity; declaration selection uses the label while runtime state stays keyed by the opaque identity and child, with no matcher or fallback added to ordinary TestAgent sessions | built on the #641 stack; controlled Plan routing added on the #728 stack | | `Config` run deadline / exec default / Fetch default / verbosity | three independently owned contextual timeouts, absent unless configured, each read by exactly one consumer, and contextual verbosity — a boolean that is false unless configured, seeded by the command line and overridable for a lexical subtree, bounding nothing and owning no authority | built on this stack | | terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified and implemented in #732, which is what gives a pane's `` that pane's terminal rather than the root's; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts — one invocation-private server per grid, authenticated persistent pane workers carrying exact argv, cwd and environment outside tmux parsing, explicit row-major layout imposed by pane swaps, a required composite `launch()` that gives a pane's `` its own terminal rather than the root's, and one ordered teardown that proves worker quiescence, channel closure and server disappearance before the document continues; its evidence uses a fake tmux with real workers and real sockets, and real tmux behaviour on macOS remains #726's; Node and Bun catalog and validate the same grids and install neither the provider nor the process observer, refusing before pane start; DEC-016 specifies the final behavior-preserving extraction into `@executablemd/terminal` and `@executablemd/terminal-tmux`, with every repository import moved to the canonical packages and the unshipped old terminal paths deleted | -| native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store and executable observer — and Node and Bun keep the same advertised names while assembling none of it, so every advertised operation refuses before provider work; `claude` is advertised for native launch after passing the client-allocated gate at Claude Code 2.1.241 on macOS arm64 (#520) and separately for client-native attachment after passing the native-to-ACP marker gate (#561), and Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | +| native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. The trusted host also states its operating system and architecture and admits each native capability only at a compatibility point whose applicable real-CLI proof passed; a canonical-looking version is only an observation, not proof. That compatibility gate follows observation and precedes identity allocation, route publication, private-file creation, native process start, attachment ensure and incomplete replay. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires the live capability point and retained binding both to match, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. A route whose exact provider identity is absent remains authoritative: the provider's explicit refusal is an accepted zero-turn outcome and XMD neither allocates, creates, converts, deletes, rewrites nor substitutes a conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store, executable observer and live platform facts — and Node and Bun assemble none of that authority, so every advertised operation refuses before provider work; `claude` is admitted for native launch only at the proved Claude Code 2.1.241/macOS/arm64 compatibility point (#520) and separately for client-native attachment at the point proven by the native-to-ACP marker gate (#561); another Claude version or platform is unadvertised until its own applicable real-CLI proofs pass, while an exact missing-session refusal remains an accepted client-allocated outcome; Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | | `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack | | `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 | | `` | removes one file the document names, inside the contextual working directory. An ordinary overridable core default with a closed schema of one required non-empty `path`, **self-closing only** — a paired spelling never enters its body, because the component declares its one form and canonical invocation-form dispatch enters that body only for the form the scan recorded, before `Env.cwd` is read and before the provider is reached. Neither the composable `Component.hasContent()` chain nor a method on whatever object a caller handed over takes part. It renders the empty string, declares no `returns` and hands back no receipt, so an ordinary `as` captures that empty string; absence is the same success, so deleting a path twice succeeds twice. One regular file or one final symbolic link goes — the link rather than its target, inside or outside — and every directory is refused, an empty one included. Empty, absolute, lexically escaping and parent-link-escaping paths are refused before any removal, and a printed error names only the path the document wrote. One semantic `API.Files.deleteFile` call and no filesystem access of its own; under a workflow run it is one `workspace_file` effect retaining `{ kind: "deleted" }`. The standard Deno workflow profile admits it to generated XMD as the exact self-closing identity `@executablemd/core#File.Delete`, third in the write table, where it performs that same ordinary effect and contributes no evaluator result | built on the #567 stack | diff --git a/specs/decisions.md b/specs/decisions.md index 4a4a64e2d..3a0d53b87 100644 --- a/specs/decisions.md +++ b/specs/decisions.md @@ -806,3 +806,85 @@ publication, generated dependency ordering, package discovery, runtime test discovery, and release verification. Moving tests changes the measured corpus, so its weights are remeasured by the repository workflow rather than edited by hand. + +## DEC-017: Native capability advertisement is proof-point scoped + +**Status:** Decided + +**Date:** 2026-09-06 + +### Context + +Claude's client-allocated launch proof ran against Claude Code 2.1.241 on +macOS arm64, but the built-in advertisement named only `claude` and the version +parser accepted every canonical semver-shaped Claude Code line. That turns one +installed-CLI observation into a claim about builds and platforms the proof did +not exercise. + +Executable build binding answers a different question. Its canonical version +and executable digest let a later operation recognize the exact executable that +accepted one retained identity. They do not prove that native launch or +client-native ACP attachment works on that executable in the first place. + +The client-allocated contract deliberately accepts two zero-turn outcomes: the +same identity resumes, or the provider refuses that exact absent identity and +XMD fails closed. A provider which persists no conversation until its first user +turn therefore does not, by that fact alone, violate the contract. Adding a +hidden materialization turn would instead violate the accepted rule that native +launch performs no model turn. + +### Decision + +One real-CLI proof admits one native capability compatibility point: + +```text +adapter + capability + canonical reported version + host OS + host architecture +``` + +Native launch and client-native attachment retain separate admission because +their proofs establish different behavior. The trusted host supplies OS and +architecture directly beside the coordinator, route store and executable +observer; shared provider code does not detect a runtime and document code +cannot replace these facts. The executable digest remains the route's exact +per-session continuity binding rather than a global allow-list for one +operator's installation. + +For a new client-allocated session the provider observes the executable and +checks the applicable point before identity allocation, route publication, +private-file creation or process start. A bound route is checked again before +native resume, attachment ensure and incomplete replay. A point the host has not +admitted refuses with `unsupported-capability`; a live build which differs from +the route still refuses with `executable-binding-refused`. Completed replay and +legacy V1 native-only resume keep their existing behavior. + +Claude is admitted for native launch and, independently, attachment only at the +applicable Claude Code 2.1.241/macOS/arm64 points already proved. Another +version or platform remains unsupported until the full applicable real-CLI +proof passes and its exact point is added. A semver-shaped version line alone +never widens admission. + +An already-published route whose exact provider identity is absent remains the +authoritative account. Native resume or ACP attachment fails closed without +allocating, creating, converting, deleting, rewriting or substituting a +conversation. An exact-resume refusal is an ordinary child outcome: settled +child teardown, cleanup and handle release acknowledge quiescence even when the +child exited unsuccessfully; any unproved teardown keeps the recovery tombstone +active. + +No XMD-owned materialization turn is added. A provider-native creation protocol +which retains the exact identity without a turn may replace the current command +only after its own real-CLI proof. Allowing a fixed bootstrap/model turn is a +separate product decision and specification change. + +### Consequences + +The provider's static adapter-name sets are only a coarse selection and cannot +authorize client-allocated work by themselves. The host/provider assembly gains +live platform facts and a controlled compatibility-admission seam, so tests can +state exact points without reading the active runtime. Route, journal, request, +provider identity, authored syntax and build-binding schemas do not change. + +This is a repair to the unmerged terminal/native-session stack and lands +directly atop its delivery head. It is not a separate Story: the terminal grid +made the native-launch path observable but did not create this capability +boundary. diff --git a/specs/native-agent-session-launch-spec.md b/specs/native-agent-session-launch-spec.md index 46c04eb97..ebb193e4b 100644 --- a/specs/native-agent-session-launch-spec.md +++ b/specs/native-agent-session-launch-spec.md @@ -399,6 +399,71 @@ observed, and no binding is invented for it. It never authorizes ACP attachment and is never upgraded in place, because a build observed today says which build is installed now, not which one established the conversation. +### Compatibility admission + +An installed-CLI proof admits one **compatibility point**, not an adapter name +for every build which happens to parse. The point consists of the adapter, the +capability proved, the adapter-canonical reported version, the host operating +system and the host architecture. Native launch and client-native attachment +have independent points because their proofs ask different questions. The +executable digest remains the exact continuity binding for one retained +session; it is not a global allow-list for one operator's installation. + +```ts +interface NativeCapabilityCompatibilityPoint { + readonly agent: string; + readonly capability: "native-launch" | "client-native-attachment"; + readonly reportedVersion: string; + readonly platform: string; + readonly architecture: string; +} + +interface NativeCapabilityCompatibility { + readonly host: { readonly platform: string; readonly architecture: string }; + readonly points: readonly NativeCapabilityCompatibilityPoint[]; +} +``` + +The ACP provider receives `NativeCapabilityCompatibility` through its trusted +construction dependencies. The built-in point list is adapter-owned evidence; +the Deno and compiled entrypoints supply their live host pair, and controlled +tests replace the whole value. The existing advertised-name sets remain the +coarse host selection needed by provider-returned adapters and by hosts which +must explain why their authority is incomplete. For a client-allocated adapter, +a selected name without a matching point authorizes nothing. + +The trusted host supplies its operating system and architecture directly with +the coordinator, route store and executable observer. They are not document +values, Context, Agent input or middleware. After observing a client-allocated +adapter's build, the provider requires a matching compatibility point before it +allocates an identity, publishes a route, writes the instruction file or starts +a process. Existing bound routes are checked again before native resume, +client-native attachment or incomplete replay can contact the provider. A +canonical semver-shaped line establishes only that the build can be named; it +does not advertise a capability. + +A point mismatch is `unsupported-capability`. The diagnostic may identify the +agent and capability, and carries no raw version output, executable path, +digest, environment or host message. The route and journal schemas do not +change: platform facts are live admission inputs, while the route's build +binding continues to answer whether this is the exact executable that accepted +the retained identity. + +Claude's two admitted points are scoped to `2.1.241 (Claude Code)` on +`darwin`/`arm64`, where the applicable launch and attachment proofs ran. A +different Claude version, operating system or architecture is unsupported until +the applicable real-CLI proof passes there and that exact point is added. The +observed zero-turn behavior of a newer Claude that retains no conversation is +not by itself a regression: claim 5 explicitly permits the provider to refuse +that exact identity, provided XMD fails closed without substitution. + +Compatibility admission adds no materialization turn. Launch still performs no +model turn, and bootstrap turns remain outside this contract. Enabling a build +which cannot satisfy the accepted protocol requires either a different +provider-native creation mechanism proven to retain the same identity without a +turn, or an explicit product decision that changes this contract; parsing its +version is never enough. + ### Attachment capability Native launch and client-native ACP attachment are separate trusted-host @@ -406,7 +471,8 @@ choices, and neither is inferred from the other or from an adapter's shape. An adapter may be proven to hand a session to a native UI without being proven to join that conversation afterwards. -`claude` is advertised for both. Its attachment claim was proven by +`claude` is advertised for both only when the live compatibility point matches. +Its attachment claim was proven by `packages/acp/src/ClaudeNativeToAcp.test.md`: one native turn planted a random marker, a checked-in marker-free ACP `` recovered it under the same identity and the same observed build, and an independent route naming an absent @@ -424,10 +490,10 @@ before a provider-returned adapter's ACP session is released, and before a client-allocated adapter allocates an identity or writes a private file. That is the failure this contract asks for rather than a hopeful spawn. -`claude` is advertised. Its client-allocated claims were proven through the -production CLI against **Claude Code 2.1.241 on macOS arm64**, which is the -compatibility point the advertisement stands on. `codex` is unadvertised: its -command shape and adapter contract tests exist, and nothing has run its +`claude` is advertised only at the applicable compatibility points. Its +client-allocated claims were proven through the production CLI against +**Claude Code 2.1.241 on macOS arm64**. `codex` is unadvertised: its command +shape and adapter contract tests exist, and nothing has run its provider-returned claims against an installed Codex. ## Runtime sequence @@ -469,16 +535,22 @@ owner to release — what has to be settled first is which conversation this is: than converting: a session ACP already established, or a route that disagrees about the instruction layer or the launcher, ends the launch here. 10. The build is observed before an identity is made, so a build this run - cannot name ends the launch before anything durable is written, and a route - that already names a different build ends it with + cannot name ends the launch before anything durable is written. The + adapter, capability, canonical reported version and the trusted host's + operating system and architecture must then match a proved compatibility + point; a mismatch ends with `unsupported-capability`, still before an + identity or session-state mutation. The launch retains that refusal at + `prepared` without an identity, as it does every other preparation refusal. + A route that already names a different exact build ends with `executable-binding-refused`. A legacy unbound route is the exception: it observes nothing, resumes under the launcher name, and gains no binding. - Whether an identity is needed at all is decided next. An existing compatible - `client-native` route already names this conversation, so its retained - identity is adopted and **nothing is allocated** — a second candidate for a - conversation that already exists is a value with nowhere to go. Only where - no route names it yet does the adapter allocate one, inside ownership and - before any process exists; nothing else supplies or replaces it. + Whether an identity is needed at all is decided next. An existing + compatible `client-native` route already names this conversation, so its + retained identity is adopted and **nothing is allocated** — a second + candidate for a conversation that already exists is a value with nowhere + to go. Only where no route names it yet does the adapter allocate one, + inside ownership and before any process exists; nothing else supplies or + replaces it. 11. A launch that allocated publishes the bound V2 `client-native` route create-once, and what it publishes against is authoritative — whoever published first @@ -987,13 +1059,14 @@ identity invents none. Every incomplete replay requires exact agreement between its journal and its route on identity, provenance, instruction digest, launcher and build binding -before its first live effect, and then requires the live build to equal that -binding. Neither account repairs or republishes the other: a replay that -found a disagreement has discovered that the session it was going to continue is -not the session it prepared, and retains `identity-unavailable` without starting -a child. Equal instructions may resume the retained identity; different -instructions retain `instructions-refused` and replace neither the layer, the -route, the identity, nor any provider state. +before its first live effect, and then requires the live capability point to be +admitted and the live build to equal that binding. Neither account repairs or +republishes the other: a replay that found a disagreement has discovered that +the session it was going to continue is not the session it prepared, and +retains `identity-unavailable` without starting a child. Equal instructions may +resume the retained identity; different instructions retain +`instructions-refused` and replace neither the layer, the route, the identity, +nor any provider state. An incomplete replay of a legacy unbound client-allocated launch retains `executable-binding-refused` before any live work: nothing available to it can @@ -1104,12 +1177,30 @@ names the stable class, the launcher, and the two canonical versions being compared; it carries no executable path, raw version output, host error, argv, environment, credential, instruction text or provider payload. +A build the provider can name but whose adapter, capability, canonical version, +operating system or architecture has no admitted compatibility point fails with +`unsupported-capability`. On initial construction this is before identity +allocation and every provider or session-state mutation; the launch's retained +refusal is the only durable outcome. On a bound route it is before native +resume, attachment ensure or incomplete replay performs live work. The route +remains unchanged. + An attachment that reaches the provider and cannot open the conversation the route names fails with `identity-unavailable`: missing provider history, an adapter that cannot resume by name, a retained provider arrangement asserting another conversation or none, and a returned identity that differs from the route's are one answer, and none of them creates a substitute conversation. +A native resume which the provider refuses because the exact retained identity +has no conversation is the other permitted zero-turn answer. Its child outcome +is retained normally and the authoritative route stays in place; a later +attempt does not retry creation, allocate a second identity, convert or rewrite +the route, or delete provider state. A nonzero child outcome is not itself +unproven teardown. Once child settlement, private-file cleanup and handle +release prove that nothing can still act on the session, ownership acknowledges +quiescence and becomes idle. If any of those facts is unproved, the existing +recovery-tombstone rule applies instead. + A launch that cannot take ownership retains `session-busy` or `session-recovery-required` as its preparation and stops there. Both are retained rather than raised bare, so a replay resumes from the phase that @@ -1175,10 +1266,11 @@ means a launch under `` never reaches the host's launcher. Only the Deno and compiled hosts assemble machine-wide agent sessions: a session coordinator, a construction-route store and an executable observer, all rooted -together, plus the two advertised capability sets this host has proven. Node and -Bun keep the same advertised names and assemble none of the answers, so every -advertised operation refuses before provider work rather than acting while a -native UI may be in the conversation — as *Ownership and concurrency* describes. +together, plus live host platform facts and the compatibility points the +applicable proofs admitted. Node and Bun keep the same coarse advertised names +and assemble none of those answers, so every advertised operation refuses +before provider work rather than acting while a native UI may be in the +conversation — as *Ownership and concurrency* describes. Only ordinary `xmd run` receives that assembly. Every other command receives none, and a host profile whose session authority differs from ordinary `xmd run` @@ -1302,7 +1394,14 @@ Focused tests prove: pane remain exclusive, the same logical Agent session still contends across panes, pane readiness occurs only after successful native-child start, grid close awaits launch cancellation and session quiescence, and completed and - partial grid replay preserve the launch's existing identity rules. + partial grid replay preserve the launch's existing identity rules; and +25. compatibility admission accepts only the separately proved capability, + canonical version, operating system and architecture; an unproved point is + refused before identity allocation, route publication, private-file + creation, child start, attachment ensure or incomplete replay, while an + already-published absent identity is retained and never substituted. A + settled exact-resume refusal acknowledges quiescence after cleanup, while a + planted unproved teardown leaves the recovery tombstone active. The authored half of this is one executable Markdown document, `packages/test-agent/src/NativeSessionLaunch.test.md`, run whole. It authors the @@ -1378,9 +1477,10 @@ provider-native identity that is either asserted by the provider or allocated by the adapter before the provider exists, retained explicitly and never inferred; a strict create-once construction route beside the coordinator's own records, in a released unbound form and a bound one; the host-owned executable observer -and the build binding it produces; ACP attachment to a bound client-native -session under its exact retained identity, through runtime partitions keyed by -agent command and build; +and the build binding it produces; proof-scoped compatibility admission over +the adapter, capability, canonical version and live host platform; ACP +attachment to a bound client-native session under its exact retained identity, +through runtime partitions keyed by agent command and build; an inherited root- or pane-terminal interactive child with cancellation and bounded reaping; composition with the terminal grid's independent pane leases without changing session ownership or durable launch identity; @@ -1390,14 +1490,15 @@ model. The following capabilities remain outside V1 and fail closed rather than degrading: -- **Only `claude` is advertised**, and separately for each capability. It is - client-allocated, and its proofs ran the applicable claims under - *Provider-native identity* against Claude Code 2.1.241 on macOS arm64. `codex` - has a command shape and contract tests and is not launch-capable, because - nothing has proven its provider-returned claims against an installed Codex. A - launch naming an unadvertised agent is refused with `unsupported-capability` - before anything of the session moves, and so is an attachment naming an agent - advertised only for native launch. +- **Only `claude` has advertised compatibility points**, and separately for + each capability. It is client-allocated, and its applicable points are Claude + Code 2.1.241 on macOS arm64, where the claims under *Provider-native identity* + passed. A different build or platform is unadvertised until its applicable + real-CLI proof passes. `codex` has a command shape and contract tests and is + not launch-capable, because nothing has proven its provider-returned claims + against an installed Codex. A launch naming an unadvertised agent or point is + refused with `unsupported-capability` before anything of the session moves, + and so is an attachment whose own point was not proved. - **`Agent.AddDir` is unbuilt**, so a launch declares no additional roots. The retained request says so explicitly — an empty ordered list — rather than omitting the fact, and no adapter maps a root it was never given. The ACP @@ -1507,8 +1608,16 @@ Implementation review checks these frozen invariants: core and CLI terminal paths are absent and every repository import is canonical. The terminal package imports no Agent, core, runtime, CLI or tmux module, the tmux package imports only the neutral terminal domain, and the - complete launch evidence above passes without changing any request, route, - record, provider advertisement or diagnostic. + package extraction itself changes no request, route, record, provider + advertisement or diagnostic. +31. A real-CLI proof advertises only its adapter, capability, canonical version, + operating system and architecture. A client-allocated point mismatch is + refused after observation but before allocation or any provider or + session-state mutation; its identity-free launch refusal is retained. The + point is checked again before bound resume, attachment and incomplete + replay. An exact absent identity remains authoritative and unavailable; + neither that refusal nor an unproved compatibility point creates a + replacement conversation or a materialization turn. Item 12 is the 2026-08-20 architecture amendment. ACPX fixes `systemPrompt` at session creation, while native turns are not authoritative in its cached From 267293535e24d0309ae2f89bfeafca097840aaab Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 04:28:07 -0400 Subject: [PATCH 02/22] =?UTF-8?q?=F0=9F=94=92=20Admit=20a=20native=20capab?= =?UTF-8?q?ility=20per=20proved=20build,=20not=20per=20adapter=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An advertised adapter name selected a command shape and, by omission, authorized acting on a session a native UI may be in. DEC-017 makes the authority a point — adapter, capability, exact reported version, host OS and architecture — so one real-CLI proof admits exactly what it proved. The host states the machine as a value beside the coordinator, route store and observer, so shared provider and session assembly never detects the runtime it happens to be on. --- packages/acp/mod.ts | 18 + packages/acp/src/native-capability.ts | 91 +++ packages/acp/src/native-launch.ts | 87 ++- packages/acp/src/provider.ts | 75 ++ packages/acp/tests/native-launch.test.ts | 680 ++++++++++++++++++ packages/cli/src/agent-stack.ts | 8 +- packages/cli/src/compiled.ts | 5 +- packages/cli/src/deno.ts | 5 +- packages/cli/src/session-coordinator.ts | 39 +- .../tests/agent-session-coordinator.test.ts | 83 ++- packages/test-agent/src/provider.ts | 32 +- 11 files changed, 1084 insertions(+), 39 deletions(-) create mode 100644 packages/acp/src/native-capability.ts diff --git a/packages/acp/mod.ts b/packages/acp/mod.ts index c39c348b8..ede4d89d4 100644 --- a/packages/acp/mod.ts +++ b/packages/acp/mod.ts @@ -42,6 +42,7 @@ export { ADVERTISED_NATIVE_LAUNCH, knownNativeAdapters, nativeAdapterFor, + nativeCapabilityCompatibility, } from "./src/native-launch.ts"; export { allocatesIdentity } from "./src/native-launch.ts"; export type { @@ -51,6 +52,23 @@ export type { ProviderReturnedAdapter, } from "./src/native-launch.ts"; +/** + * Which exact builds and machines a native capability is admitted on. + * + * Public because the host is what states them: it supplies its own OS and + * architecture beside the coordinator and the observer, and shared provider + * code must not detect either for itself. + */ +export { admitsNativeCapability } from "./src/native-capability.ts"; +export type { + NativeCapability, + NativeCapabilityCompatibility, + NativeCapabilityCompatibilityPoint, + NativeCapabilityHost, + ObservedNativeCapability, + ProvedNativeCapability, +} from "./src/native-capability.ts"; + /** * ACPX's own runtime types. * diff --git a/packages/acp/src/native-capability.ts b/packages/acp/src/native-capability.ts new file mode 100644 index 000000000..b1b9212bc --- /dev/null +++ b/packages/acp/src/native-capability.ts @@ -0,0 +1,91 @@ +/** + * What one real-CLI proof admits, and nothing wider (specs/decisions.md + * DEC-017). + * + * An adapter name says which command shape to consider. It does not say that + * the thing found under that name works: a proof ran against one build of one + * CLI, on one operating system, on one architecture, and every other build and + * machine is a claim nobody made. So admission is stated as points, and a point + * is the whole tuple — + * + * adapter + capability + canonical reported version + host OS + host + * architecture + * + * — matched exactly. Nothing here parses, orders or ranges a version. A + * semver-shaped line is a value one adapter recognized, not evidence about the + * build behind it, and comparing two of them would turn one installed CLI into + * a statement about releases that have never run. + * + * The host pair is supplied rather than read. Which machine this is is a fact + * the trusted host has and shared provider code must not go looking for: a + * provider that detected its own runtime would answer the compatibility + * question with the thing being asked about. + */ + +/** + * Which behavior a proof established. + * + * Two, not one, because they are proved separately: handing a session to a + * native UI and later joining that same conversation through ACP are different + * things that can be true independently. + */ +export type NativeCapability = "native-launch" | "client-native-attachment"; + +/** The machine a host is actually running on, as that host states it. */ +export interface NativeCapabilityHost { + readonly platform: string; + readonly architecture: string; +} + +/** One capability, proved for one exact build on one exact machine. */ +export interface NativeCapabilityCompatibilityPoint { + readonly agent: string; + readonly capability: NativeCapability; + /** The canonical line the adapter recognized, whole. Never a number alone. */ + readonly reportedVersion: string; + readonly platform: string; + readonly architecture: string; +} + +/** Everything a host admits, beside the machine it admits it on. */ +export interface NativeCapabilityCompatibility { + readonly host: NativeCapabilityHost; + readonly points: readonly NativeCapabilityCompatibilityPoint[]; +} + +/** What an adapter carries about its own proofs, before a host names a machine. */ +export type ProvedNativeCapability = Omit; + +/** The live capability an observation offers for admission. */ +export interface ObservedNativeCapability { + readonly agent: string; + readonly capability: NativeCapability; + readonly reportedVersion: string; +} + +/** + * Whether this host admits what was actually observed. + * + * The host's own OS and architecture are what an admitted point is compared + * against, so a point proved elsewhere cannot admit anything here. Absent + * compatibility admits nothing: a host that states no proof has none, and + * treating silence as permission is the failure this whole tuple exists to + * prevent. + */ +export function admitsNativeCapability( + compatibility: NativeCapabilityCompatibility | undefined, + observed: ObservedNativeCapability, +): boolean { + if (compatibility === undefined) { + return false; + } + const { platform, architecture } = compatibility.host; + return compatibility.points.some( + (point) => + point.agent === observed.agent && + point.capability === observed.capability && + point.reportedVersion === observed.reportedVersion && + point.platform === platform && + point.architecture === architecture, + ); +} diff --git a/packages/acp/src/native-launch.ts b/packages/acp/src/native-launch.ts index d77b7a58a..c25ce16a1 100644 --- a/packages/acp/src/native-launch.ts +++ b/packages/acp/src/native-launch.ts @@ -32,6 +32,11 @@ import { randomUUID } from "node:crypto"; import type { IdentityProvenance } from "@executablemd/core"; +import type { + NativeCapabilityCompatibility, + NativeCapabilityHost, + ProvedNativeCapability, +} from "./native-capability.ts"; /** * What an adapter knows about the build behind its executable. @@ -111,6 +116,15 @@ interface AdapterCommands { identity: IdentityProvenance; /** The argv that resumes this exact provider-native session. */ resume(nativeSessionId: string): string[]; + /** + * The exact builds and machines a real-CLI proof of this adapter ran on. + * + * The adapter's, because the proof is about this adapter's own contract + * against its own installed CLI. Absent is the honest default: knowing a + * command shape establishes nothing, and an adapter that has proved nothing + * contributes no point for a host to admit. + */ + proved?: readonly ProvedNativeCapability[]; } /** @@ -153,6 +167,21 @@ export function allocatesIdentity(adapter: NativeAdapter): adapter is ClientAllo return adapter.identity === "client-allocated"; } +/** + * The one build and machine Claude's proofs ran on. + * + * Written once and shared by both points below so they cannot drift apart into + * two claims about two builds. Raising either is a new proof rather than an + * edit here: what makes this admissible is that a real CLI was driven through + * the whole applicable contract on exactly this, and nothing about that + * generalizes to the next release or the next machine. + */ +const CLAUDE_PROVED_BUILD = { + reportedVersion: "2.1.241 (Claude Code)", + platform: "darwin", + architecture: "arm64", +} as const; + const ADAPTERS: Readonly> = { claude: { launcher: "claude", @@ -161,6 +190,14 @@ const ADAPTERS: Readonly> = { identity: "client-allocated", // Claude takes a UUID it has never seen and makes it the session's name. allocate: () => randomUUID(), + // Two points rather than one: `ClaudeNativeLaunch.test.md` and + // `ClaudeZeroTurnExit.test.md` showed the launch contract, and + // `ClaudeNativeToAcp.test.md` showed attachment. Either could have failed + // while the other held, so neither is written down as the other's evidence. + proved: [ + { capability: "native-launch", ...CLAUDE_PROVED_BUILD }, + { capability: "client-native-attachment", ...CLAUDE_PROVED_BUILD }, + ], binding: { command: "claude", version: claudeVersion, @@ -189,42 +226,50 @@ const ADAPTERS: Readonly> = { }; /** - * The adapters whose native creation, instruction and resume contracts have - * been proven against the installed CLI. + * The adapters this host will consider for native launch at all. * - * `claude` is here because `packages/acp/src/ClaudeNativeLaunch.test.md` and - * `packages/acp/src/ClaudeZeroTurnExit.test.md` ran the production command - * through the built binary against Claude Code 2.1.241 on macOS arm64 and - * showed the whole applicable contract: the adapter allocated the identity, the - * native process created that exact conversation from a private mode-0600 file, - * the layer governed the first user turn with no bootstrap, and a second - * independent invocation resumed the same identity — including a session left - * without a word said in it. + * A coarse selection and nothing more. For an adapter that names its own + * sessions the name authorizes no work by itself: what admits one is the + * compatibility point below, matched against the build actually found and the + * machine actually running. A name reaches the question; it does not answer it. * * `codex` is absent. Its command shape is known and its adapter contract tests * pass, and neither is the proof: nothing has run it against an installed - * Codex. A host may still advertise an adapter itself by passing its name - * through `AcpxProviderDependencies.advertiseNativeLaunch`. + * Codex. A host may still name an adapter itself by passing it through + * `AcpxProviderDependencies.advertiseNativeLaunch`. */ export const ADVERTISED_NATIVE_LAUNCH: readonly string[] = ["claude"]; /** - * The adapters whose client-native ACP attachment has been proven against the - * installed CLI. + * The adapters this host will consider for client-native ACP attachment. * * A separate list from the one above, because they are separate capabilities: * handing a session to a native UI and later joining that same conversation * through ACP prove different things. An adapter may have the first without the - * second. - * - * `claude` is here because `packages/acp/src/ClaudeNativeToAcp.test.md` ran the - * production command through the built binary: a native turn carrying a random - * marker, then a marker-free ACP Prompt that recovered it under the same - * provider-native identity and the same observed build, and an independent - * absent identity that refused without taking a turn. + * second. Like that list, this one selects rather than authorizes. */ export const ADVERTISED_CLIENT_NATIVE_ATTACHMENT: readonly string[] = ["claude"]; +/** + * What this build's adapters have proved, on the machine a host says it is. + * + * The two halves come from where each is known. Which builds were driven + * through a real CLI is the adapters' own evidence and is compiled in beside + * them; which OS and architecture are underneath right now is the host's, and + * arrives here rather than being detected. Neither half admits anything alone — + * a point is only admitted where a proof and the machine it ran on meet. + */ +export function nativeCapabilityCompatibility( + host: NativeCapabilityHost, +): NativeCapabilityCompatibility { + return { + host, + points: Object.entries(ADAPTERS).flatMap(([agent, adapter]) => + (adapter.proved ?? []).map((proved) => ({ agent, ...proved })), + ), + }; +} + export function nativeAdapterFor(agentName: string): NativeAdapter | undefined { return Object.hasOwn(ADAPTERS, agentName) ? ADAPTERS[agentName] : undefined; } diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index 6eb0597a5..7b91b51b6 100644 --- a/packages/acp/src/provider.ts +++ b/packages/acp/src/provider.ts @@ -104,6 +104,8 @@ import { nativeAdapterFor, } from "./native-launch.ts"; import type { NativeAdapter } from "./native-launch.ts"; +import { admitsNativeCapability } from "./native-capability.ts"; +import type { NativeCapability, NativeCapabilityCompatibility } from "./native-capability.ts"; /** * One MCP server as ACPX configures them. @@ -241,6 +243,20 @@ export interface AcpxProviderDependencies { * whose sessions XMD names refuses before any provider effect. */ executableObserver?: ExecutableObserver; + /** + * Which exact builds this host has proved each native capability on, and the + * machine it proved them for. + * + * Supplied by the trusted host beside the coordinator and the observer, and + * for the same reason: it carries this machine's OS and architecture, and + * shared provider code that went and read those would be answering the + * compatibility question with the thing being asked about. + * + * The advertised sets above choose which adapter to consider. This is what + * says the build actually found under it may be acted on. Absent admits + * nothing — a host that states no proof has none. + */ + compatibility?: NativeCapabilityCompatibility; /** * Extra native adapters, by agent name. A harness driving an agent this * package has never heard of supplies its own resume command shape here @@ -817,6 +833,7 @@ function* useAcpxProviderState( const coordinator = dependencies?.coordinator; const routeStore = dependencies?.routeStore; const executableObserver = dependencies?.executableObserver; + const compatibility = dependencies?.compatibility; const agentCwd = dependencies?.agentCwd ?? cwd; const prepareAgent = dependencies?.prepareAgent; const mcpServers = dependencies?.mcpServers; @@ -1720,6 +1737,40 @@ function* useAcpxProviderState( }; } + /** + * Whether this host has proved this capability on the build it just observed. + * + * One function for all four paths that reach a client-allocated adapter, so + * "proved" means the same tuple everywhere rather than four readings of it. + * It runs after the observation, because the build is what is being admitted, + * and before every effect on the other side — an identity, a published route, + * a private file, a child, an ACP ensure — because a capability nobody proved + * is not a thing to discover halfway through. + * + * Distinct from build drift beside it, which is a different question. Drift + * asks whether this is still the build that accepted one retained identity; + * this asks whether the build works at all. A session may fail either while + * passing the other. + */ + function admitCapability( + agentName: string, + capability: NativeCapability, + build: BoundBuild, + ): LaunchFailure | undefined { + const { reportedVersion } = build.binding; + if (admitsNativeCapability(compatibility, { agent: agentName, capability, reportedVersion })) { + return undefined; + } + return { + class: "unsupported-capability", + message: + `this host has proved no ${capability} capability for "${agentName}" at ` + + `${reportedVersion} on the machine it is running on, so it will not act on a session ` + + `with it. An advertised adapter name selects a command shape; only a proof against ` + + `that exact installed build admits one.`, + }; + } + /** The stable comparison two builds of one session fail. */ function buildDrift( sessionKey: string, @@ -1817,6 +1868,14 @@ function* useAcpxProviderState( } const binding = (adapterFor(agentName) as ClientAllocatedAdapter).binding; const build = yield* observeBuild(agentName, agentCommand, binding); + // Before the comparison and long before the ensure. A build this host has + // not proved attachment on is refused whether or not it happens to be the + // build that created the session — being the right one is not evidence that + // joining the conversation through ACP works on it. + const unproved = admitCapability(agentName, "client-native-attachment", build); + if (unproved) { + throw new AttachmentRefused(unproved); + } if (!sameExecutableBuild(build.binding, route.executableBinding)) { throw new AttachmentRefused( buildDrift(prepared.sessionKey, route.executableBinding, build.binding), @@ -2335,6 +2394,14 @@ function* useAcpxProviderState( } throw error; } + // Admitted here, which is before both of the things that follow: allocating + // an identity for a session that has none, and resuming one that has. The + // route is only read on this side of it, never written — a session already + // published stays exactly as its first publication left it. + const unproved = admitCapability(agentName, "native-launch", build); + if (unproved) { + return refusal(unproved.class, unproved.message, known); + } if ( route?.route === "client-native" && !sameExecutableBuild(build.binding, route.executableBinding) @@ -2968,6 +3035,14 @@ function* useAcpxProviderState( } throw error; } + // The replay's own first live phase, and this is still ahead of it. A + // predecessor that was admitted proves nothing about this run: the build + // under the same command may have been replaced since, and a capability is + // a claim about the build rather than about the session. + const unproved = admitCapability(prepared.agent, "native-launch", build); + if (unproved) { + return unproved; + } if (!sameExecutableBuild(build.binding, route.executableBinding)) { return buildDrift(prepared.sessionKey, route.executableBinding, build.binding); } diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index 6cdf80860..4d1de0c2c 100644 --- a/packages/acp/tests/native-launch.test.ts +++ b/packages/acp/tests/native-launch.test.ts @@ -22,6 +22,7 @@ import { Agent } from "@executablemd/core"; import type { AgentLaunchRequest, AgentProviderAuthority, + DetachedLaunchRecord, ExitedLaunchRecord, LaunchRecord, PreparedLaunchRecord, @@ -38,7 +39,13 @@ import { allocatesIdentity, knownNativeAdapters, nativeAdapterFor, + nativeCapabilityCompatibility, } from "../src/native-launch.ts"; +import type { + NativeCapability, + NativeCapabilityCompatibility, + NativeCapabilityHost, +} from "../src/native-capability.ts"; import { createHash, randomUUID } from "node:crypto"; import { readFile, rm, stat, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; @@ -165,6 +172,39 @@ const OBSERVED_BUILD: ExecutableBuildBindingV1 = { /** The canonical path that same observation reports. */ const OBSERVED_PATH = "/opt/builds/claude"; +/** + * The machine these cases describe. + * + * Stated, never read from the runner. A capability point names an exact OS and + * architecture, so a suite that asked the machine underneath it what it was + * would be admitting whatever it happened to run on — and the shipped Claude + * evidence, which is a Mac with Apple silicon, would be exercised on one CI + * shard and skipped everywhere else. + */ +const PROVED_HOST: NativeCapabilityHost = { platform: "darwin", architecture: "arm64" }; + +/** + * A host that has proved the named builds of `claude`, both capabilities each. + * + * For the cases that need two admitted builds at once. Everything else takes + * the package's own evidence, so what most of this file runs against is the + * shipped table rather than a convenient stand-in. + */ +function admitting(...reportedVersions: readonly string[]): NativeCapabilityCompatibility { + const capabilities: readonly NativeCapability[] = ["native-launch", "client-native-attachment"]; + return { + host: PROVED_HOST, + points: reportedVersions.flatMap((reportedVersion) => + capabilities.map((capability) => ({ + agent: "claude", + capability, + reportedVersion, + ...PROVED_HOST, + })), + ), + }; +} + /** Everything the launch touched, in the order it touched it. */ interface Trace { records: LaunchRecord[]; @@ -181,6 +221,19 @@ interface Trace { * which is the only way to show that neither answers the other. */ replay?: Replay; + /** + * A journal with every phase already in it. + * + * Separate from `replay` because there is no phase left to invoke: the + * authority hands all three records back and the provider is never entered. + * That is the whole claim a case makes with one, so a suffix that happened to + * call nothing would not be the same thing. + */ + completed?: { + prepared: PreparedLaunchRecord; + detached: DetachedLaunchRecord; + exited: ExitedLaunchRecord; + }; } interface ProviderOptions { @@ -189,6 +242,14 @@ interface ProviderOptions { attach?: readonly string[]; /** `false` gives this host no way to observe a build at all. */ observer?: ExecutableObserver | false; + /** + * Which exact points this host has proved. + * + * Defaults to the package's own evidence for the machine above, so a case + * says nothing unless it is describing a host that proved something else. + * `false` is a host that has proved nothing and admits nothing. + */ + compatibility?: NativeCapabilityCompatibility | false; store?: AcpSessionStore; adapters?: Record; /** @@ -274,6 +335,16 @@ function traceAuthority(trace: Trace): AgentProviderAuthority { throw new Error("this stub authority names no provider turn"); }, *perform(_request, phases) { + // A journal that already reached `exited` is replayed whole: no phase is + // invoked, so the provider is not entered at all. + const completed = trace.completed; + if (completed) { + for (const record of [completed.prepared, completed.detached, completed.exited]) { + trace.records.push(record); + trace.order.push(record.phase); + } + return; + } // A replay hands back what the journal retained rather than calling the // provider's live preparation, exactly as the real authority does when // the phase is already recorded. @@ -361,6 +432,9 @@ function* installLaunchStack( ...(options.observer === false ? {} : { executableObserver: options.observer ?? createFakeObserver().observer }), + ...(options.compatibility === false + ? {} + : { compatibility: options.compatibility ?? nativeCapabilityCompatibility(PROVED_HOST) }), coordinator: options.coordinator ?? trace.ownership.coordinator, ...(options.routeStore ? { routeStore: options.routeStore } : {}), ...(options.withSessionRoute ? { withSessionRoute: options.withSessionRoute } : {}), @@ -3364,6 +3438,8 @@ describe("Tier RT — bound runtime partitions", () => { adapters: { claude: adapter() }, routeStore: routes, observer: observer.observer, + // Two builds is the whole case, so this host has proved both of them. + compatibility: admitting("2.1.241 (Claude Code)", "2.1.242 (Claude Code)"), }); yield* Agent.operations.session(); @@ -3932,3 +4008,607 @@ describe("Tier CV — canonical Claude version", () => { expect(parse("2.1.241 (Claude Code)\n2.1.241 (Claude Code)\n")).toBe(undefined); }); }); + +/** + * Tier NP — the proved capability points a host admits + * (specs/decisions.md §DEC-017). + * + * An advertised adapter name selects a command shape. What admits work on a + * session is a point: one agent, one capability, one canonical reported + * version, one OS and one architecture, matched whole. These cases run the four + * paths that check one — fresh client-native construction, bound native resume, + * bound ACP attachment and incomplete replay — and each refusal is read at the + * boundary it is supposed to stop in front of, rather than by its message + * alone: nothing may be allocated, published, written, spawned, ensured or + * retained behind it. + */ +describe("Tier NP — proved native capability points", () => { + const ALLOCATED = "cafe0000-1111-2222-3333-444444444444"; + + /** The build the shipped Claude evidence names, and the one that follows it. */ + const PROVED_VERSION = "2.1.241 (Claude Code)"; + const LATER_VERSION = "2.1.263 (Claude Code)"; + + /** Every boundary a refused point must stop in front of. */ + interface Boundaries { + allocations: number; + creates: number; + resumes: number; + published: AgentSessionRoute[]; + } + + function boundaries(): Boundaries { + return { allocations: 0, creates: 0, resumes: 0, published: [] }; + } + + /** + * The client-allocated adapter, with each thing it can do counted. + * + * Counting the adapter rather than watching for a side effect is what makes + * "nothing was allocated" a reading instead of an inference: allocation is + * the adapter's own act, and a call to it is visible whether or not anything + * downstream kept the answer. + */ + function countedAdapter(seen: Boundaries, allocate: () => string = () => ALLOCATED) { + return { + launcher: "claude", + identity: "client-allocated", + binding: TEST_BINDING, + allocate: () => { + seen.allocations += 1; + return allocate(); + }, + create: (nativeSessionId: string, instructionFile: string) => { + seen.creates += 1; + return ["claude", "--session-id", nativeSessionId, "--system-prompt-file", instructionFile]; + }, + resume: (nativeSessionId: string) => { + seen.resumes += 1; + return ["claude", "--resume", nativeSessionId]; + }, + } satisfies NativeAdapter; + } + + /** The same store, with every publication that reached it retained. */ + function countedRoutes(seen: Boundaries): AgentSessionRouteStore { + const inner = createMemorySessionRouteStore(); + return { + read: (key) => inner.read(key), + *publish(candidate) { + seen.published.push(candidate); + return yield* inner.publish(candidate); + }, + }; + } + + const KEY = { provider: "acpx", agent: AGENT_COMMAND, sessionKey: SESSION_KEY }; + + /** The bound route a completed client-native launch leaves behind. */ + function bound(binding: ExecutableBuildBindingV1 = OBSERVED_BUILD): AgentSessionRoute { + return { + schema: "session-route.v2", + route: "client-native", + provider: "acpx", + agent: AGENT_COMMAND, + sessionKey: SESSION_KEY, + nativeSessionId: ALLOCATED, + identityProvenance: "client-allocated", + instructionsDigest: createHash("sha256").update(INSTRUCTIONS).digest("hex"), + launcher: "claude", + executableBinding: binding, + }; + } + + /** The same session as the released unbound contract published it. */ + function legacyRoute(): AgentSessionRoute { + return { + schema: "session-route.v1", + route: "client-native", + provider: "acpx", + agent: AGENT_COMMAND, + sessionKey: SESSION_KEY, + nativeSessionId: ALLOCATED, + identityProvenance: "client-allocated", + instructionsDigest: createHash("sha256").update(INSTRUCTIONS).digest("hex"), + launcher: "claude", + }; + } + + /** A retained preparation, as an interrupted launch would have left one. */ + function preparedRecord(): PreparedLaunchRecord { + return { + phase: "prepared", + agent: "claude", + sessionKey: SESSION_KEY, + provider: "acpx", + nativeSessionId: ALLOCATED, + sessionState: "created", + instructionChannel: "claude.systemPromptFile", + instructionReconciliation: "installed", + identityProvenance: "client-allocated", + executableBinding: OBSERVED_BUILD, + instructionsDigest: createHash("sha256").update(INSTRUCTIONS).digest("hex"), + instructions: INSTRUCTIONS, + cwd: CWD, + additionalDirectories: [], + permissionMode: "approve-reads", + launcher: "claude", + }; + } + + /** + * Run `body` with the private root pointed at somewhere unusable. + * + * Reading an empty directory afterwards would prove nothing: a launch that + * did write a private file removes it, so success and refusal leave the same + * empty directory behind. A root that cannot hold one at all is a reading + * instead — creating the private directory under it fails in the host's own + * words, and that surfaces as `process-creation-failed`. A run that refuses + * for a capability under this fault has provably not reached the write, and + * the row below takes the same planted fault to the failure it does cause. + */ + function* withUnusablePrivateRoot(body: () => Operation): Operation { + const occupied = join(tmpdir(), `xmd-np-${randomUUID()}`); + yield* until(writeFile(occupied, "not a directory")); + yield* ensure(function* () { + yield* until(rm(occupied, { force: true }).catch(() => undefined)); + }); + const previous = process.env.TMPDIR; + process.env.TMPDIR = occupied; + try { + yield* body(); + } finally { + if (previous === undefined) { + delete process.env.TMPDIR; + } else { + process.env.TMPDIR = previous; + } + } + } + + it("NP1: the exact proved launch point constructs, on the machine it names", function* () { + // The package's own evidence, unmodified, against the build it was proved + // against. Nothing here supplies a point: what admits this launch is the + // shipped Claude adapter's, read for darwin/arm64. + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const routes = countedRoutes(seen); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: routes, + }); + + yield* launch(INSTRUCTIONS); + + const prepared = trace.records[0] as PreparedLaunchRecord; + expect(prepared.failure).toBe(undefined); + expect(prepared.nativeSessionId).toBe(ALLOCATED); + expect(prepared.executableBinding?.reportedVersion).toBe(PROVED_VERSION); + expect(seen.allocations).toBe(1); + expect(seen.published).toHaveLength(1); + expect(trace.launches).toHaveLength(1); + // And the point that admitted it is the shipped one, for both capabilities. + expect(nativeCapabilityCompatibility(PROVED_HOST).points).toEqual([ + { + agent: "claude", + capability: "native-launch", + reportedVersion: PROVED_VERSION, + ...PROVED_HOST, + }, + { + agent: "claude", + capability: "client-native-attachment", + reportedVersion: PROVED_VERSION, + ...PROVED_HOST, + }, + ]); + }); + + it("NP2: an unproved point refuses a construction before it does anything", function* () { + // Four ways to be outside the proof, one refusal each. A later build, the + // same build on another OS, the same build on another architecture, and a + // host that has proved nothing at all: none of them is a near miss that + // some other reading could let through. + for (const [name, options] of [ + ["a later build of the proved agent", { versionOutput: `${LATER_VERSION}\n` }], + ["another operating system", { host: { platform: "linux", architecture: "arm64" } }], + ["another architecture", { host: { platform: "darwin", architecture: "x64" } }], + ["a host that has proved nothing", { none: true }], + ] as const) { + yield* scoped(function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const store = makeStore(); + const observer = createFakeObserver( + "versionOutput" in options ? { versionOutput: options.versionOutput } : {}, + ); + + yield* withUnusablePrivateRoot(function* () { + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: countedRoutes(seen), + store, + observer: observer.observer, + compatibility: + "none" in options + ? false + : "host" in options + ? nativeCapabilityCompatibility(options.host) + : undefined, + }); + const failure = yield* attempt(trace, INSTRUCTIONS); + // Not `process-creation-failed`: the private root is unusable for the + // whole of this launch, so a run that reached the instruction write + // would have failed there instead. + expect([name, failure?.class]).toEqual([name, "unsupported-capability"]); + }); + + // The build was observed — that is how the point became a question at + // all — and then everything the answer gates stopped. Each of these is + // something NP1 sees happen on the admitted path. + expect([name, observer.observed]).toEqual([name, ["claude"]]); + expect([name, seen.allocations]).toEqual([name, 0]); + expect([name, seen.creates + seen.resumes]).toEqual([name, 0]); + expect([name, seen.published]).toEqual([name, []]); + expect([name, trace.launches]).toEqual([name, []]); + // Nothing durable, and no provider conversation, under any of them. + expect([name, [...store.records.keys()]]).toEqual([name, []]); + expect([name, harness.ensureCalls]).toEqual([name, []]); + }); + } + + // The same planted fault, on the point this host has proved: the launch + // gets past admission and fails where the private file is written. Without + // this the row above would be reading a fault that never fires. + yield* scoped(function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + + yield* withUnusablePrivateRoot(function* () { + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: countedRoutes(seen), + }); + yield* Agent.operations.launch(launchRequest(INSTRUCTIONS)); + }); + + const exited = trace.records.findLast( + (record) => record.phase === "exited", + ) as ExitedLaunchRecord; + expect(exited.failure?.class).toBe("process-creation-failed"); + expect(seen.allocations).toBe(1); + expect(trace.launches).toEqual([]); + }); + }); + + it("NP3: handing a session over and joining it later are admitted separately", function* () { + // Two capabilities, two proofs. A host that has proved one of them does + // that one thing and refuses the other, in both directions — so neither is + // ever inferred from the other having been proved. + const launchOnly: NativeCapabilityCompatibility = { + host: PROVED_HOST, + points: [ + { + agent: "claude", + capability: "native-launch", + reportedVersion: PROVED_VERSION, + ...PROVED_HOST, + }, + ], + }; + const attachOnly: NativeCapabilityCompatibility = { + host: PROVED_HOST, + points: [ + { + agent: "claude", + capability: "client-native-attachment", + reportedVersion: PROVED_VERSION, + ...PROVED_HOST, + }, + ], + }; + + // Proved for launch only: the launch runs, and attaching to what it left + // behind refuses. + yield* scoped(function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: countedRoutes(seen), + compatibility: launchOnly, + }); + + yield* launch(INSTRUCTIONS); + expect((trace.records[0] as PreparedLaunchRecord).failure).toBe(undefined); + + let raised: Error | undefined; + try { + yield* Agent.operations.session(); + } catch (error) { + raised = error as Error; + } + expect(raised?.message).toContain("client-native-attachment"); + expect(harness.ensureCalls).toEqual([]); + }); + + // Proved for attachment only: the same session attaches, and launching + // refuses. + yield* scoped(function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const routes = countedRoutes(seen); + yield* routes.publish(bound()); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: routes, + compatibility: attachOnly, + }); + + const session = yield* Agent.operations.session(); + expect(session.agentSessionId).toBe(ALLOCATED); + + const failure = yield* attempt(trace, INSTRUCTIONS); + expect(failure?.class).toBe("unsupported-capability"); + expect(failure?.message).toContain("native-launch"); + expect(trace.launches).toEqual([]); + }); + }); + + it("NP4: a bound route is refused before resume and before ensure, and stays as it was", function* () { + // The route was published by a run this host did admit. The build under the + // command has not changed; what changed is that this host no longer proves + // it. Both paths refuse, and neither rewrites, republishes or removes the + // account of a session a native UI may still be holding. + for (const [name, act] of [ + ["a native resume", (trace: Trace) => attempt(trace, INSTRUCTIONS)], + [ + "an ACP attachment", + function* (): Operation { + try { + yield* Agent.operations.session(); + } catch { + return undefined; + } + throw new Error("the attachment was not refused"); + }, + ], + ] as const) { + yield* scoped(function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const routes = countedRoutes(seen); + yield* routes.publish(bound()); + const before = JSON.stringify(yield* routes.read(KEY)); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: routes, + compatibility: false, + }); + + yield* act(trace); + + expect([name, seen.resumes]).toEqual([name, 0]); + expect([name, seen.creates]).toEqual([name, 0]); + expect([name, seen.allocations]).toEqual([name, 0]); + expect([name, harness.ensureCalls]).toEqual([name, []]); + expect([name, trace.launches]).toEqual([name, []]); + // The only publication that reached the store is the one this case made + // before the provider existed, and what it reads back is byte-identical. + expect([name, seen.published.length]).toEqual([name, 1]); + expect([name, JSON.stringify(yield* routes.read(KEY))]).toEqual([name, before]); + }); + } + }); + + it("NP5: an admitted point still refuses a build the route does not name", function* () { + // Admission and continuity are different questions, and passing the first + // is not an answer to the second. The live build is exactly the proved one; + // the route names an earlier one, so this is the drift refusal, not the + // capability one. + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const routes = countedRoutes(seen); + yield* routes.publish( + bound({ + schema: "executable-build.v1", + reportedVersion: PROVED_VERSION, + executableDigest: { algorithm: "sha256", value: "e".repeat(64) }, + }), + ); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: routes, + }); + + const failure = yield* attempt(trace, INSTRUCTIONS); + + expect(failure?.class).toBe("executable-binding-refused"); + expect(seen.resumes).toBe(0); + expect(trace.launches).toEqual([]); + }); + + it("NP6: an incomplete replay is gated, and legacy history is unchanged", function* () { + // An incomplete replay: the point is checked before the live phase it is + // standing in front of, whichever suffix the journal retained. + for (const suffix of ["prepared", "prepared+detached"] as const) { + yield* scoped(function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const routes = countedRoutes(seen); + yield* routes.publish(bound()); + const before = JSON.stringify(yield* routes.read(KEY)); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: routes, + compatibility: false, + }); + trace.replay = { prepared: preparedRecord(), suffix }; + + yield* Agent.operations.launch(launchRequest(INSTRUCTIONS)); + + const failure = trace.records.findLast((record) => record.failure)?.failure; + expect([suffix, failure?.class]).toEqual([suffix, "unsupported-capability"]); + expect([suffix, seen.resumes + seen.creates]).toEqual([suffix, 0]); + expect([suffix, trace.launches]).toEqual([suffix, []]); + expect([suffix, JSON.stringify(yield* routes.read(KEY))]).toEqual([suffix, before]); + }); + } + + // A legacy session, on a host that proves nothing: the released unbound + // behavior, unchanged. It refuses for the reason it always did — the run + // cannot say which build has this history — and never for a capability. + yield* scoped(function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const routes = countedRoutes(seen); + yield* routes.publish(legacyRoute()); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: routes, + compatibility: false, + }); + const { executableBinding: _unbound, ...legacyPrepared } = preparedRecord(); + trace.replay = { prepared: legacyPrepared, suffix: "prepared+detached" }; + + yield* Agent.operations.launch(launchRequest(INSTRUCTIONS)); + + const failure = trace.records.findLast((record) => record.failure)?.failure; + expect(failure?.class).toBe("executable-binding-refused"); + }); + }); + + it("NP7: a completed replay neither observes a build nor asks about a point", function* () { + // Every phase is already retained, so the authority replays the journal + // whole and calls no live phase. A host that has proved nothing changes + // nothing about that: a launch that already happened is not work this run + // is being admitted to do, and the provider is never reached to say so. + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const observer = createFakeObserver(); + const routes = countedRoutes(seen); + yield* routes.publish(bound()); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: routes, + observer: observer.observer, + compatibility: false, + }); + trace.completed = { + prepared: preparedRecord(), + detached: { phase: "detached" }, + exited: { phase: "exited", exitCode: 0 }, + }; + + yield* Agent.operations.launch(launchRequest(INSTRUCTIONS)); + + expect(trace.records.map((record) => record.phase)).toEqual(["prepared", "detached", "exited"]); + expect(trace.records.some((record) => record.failure)).toBe(false); + expect(observer.observed).toEqual([]); + expect(seen.allocations + seen.creates + seen.resumes).toBe(0); + expect(trace.launches).toEqual([]); + }); + + it("NP8: a published route whose exact conversation is absent refuses, and is kept", function* () { + // The identity in the route is the only one this session has. A backend + // that does not have it is not an invitation to allocate another and + // publish over the account of a conversation a native UI may still hold — + // it is the end of this attempt. + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const routes = countedRoutes(seen); + yield* routes.publish(bound()); + const before = JSON.stringify(yield* routes.read(KEY)); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: routes, + }); + // Refused through the gate rather than `ensureFailure`, because the gate + // runs after the attempt is recorded: what this case needs to read is the + // identity that was asked for, not merely that asking failed. + harness.ensureGate = () => + (function* (): Operation { + throw new Error("No conversation found with that session ID"); + })(); + + let raised: Error | undefined; + try { + yield* Agent.operations.session(); + } catch (error) { + raised = error as Error; + } + + // Asked for exactly once, under exactly the published identity. + expect(raised).toBeDefined(); + expect(harness.ensureCalls.map((call) => call.resumeSessionId)).toEqual([ALLOCATED]); + expect(harness.turns).toEqual([]); + // No second identity was reached for, and the route still says what it said. + expect(seen.allocations).toBe(0); + expect(seen.published.length).toBe(1); + expect(JSON.stringify(yield* routes.read(KEY))).toBe(before); + }); + + it("NP9: a nonzero exact resume that provably settled leaves the session free", function* () { + // The native child's status is the launch's outcome, not evidence about + // whether it is gone. What decides ownership is whether teardown settled: + // one that did releases the session for the next acquisition, and one that + // could not be proven leaves the record standing to be recovered. + const shared = makeCoordinator(); + const routes = createMemorySessionRouteStore(); + const store = makeStore(); + yield* routes.publish(bound()); + + const first = createFakeRuntime(); + const firstTrace = newTrace(); + firstTrace.ownership = shared; + yield* scoped(function* () { + const seen = boundaries(); + yield* installLaunchStack(first, firstTrace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: routes, + store, + coordinator: shared.coordinator, + exitCode: 9, + }); + + yield* launch(INSTRUCTIONS); + + // The exact published identity was resumed, never recreated. + expect(seen.resumes).toBe(1); + expect(seen.creates).toBe(0); + expect(firstTrace.launches[0]!.command).toEqual([OBSERVED_PATH, "--resume", ALLOCATED]); + expect(firstTrace.records.at(-1)).toMatchObject({ phase: "exited", exitCode: 9 }); + }); + + // Everything the launch registered has unwound, and it settled, so the + // session was given back rather than left standing. + expect(shared.events.at(-1)).toBe("released-idle"); + + const second = createFakeRuntime(); + const secondTrace = newTrace(); + secondTrace.ownership = shared; + yield* scoped(function* () { + yield* installLaunchStack(second, secondTrace, { + adapters: { claude: countedAdapter(boundaries()) }, + routeStore: routes, + store, + coordinator: shared.coordinator, + }); + yield* launch(INSTRUCTIONS); + }); + + expect(shared.acquisitions.map((entry) => entry.outcome)).toEqual(["granted", "granted"]); + expect(secondTrace.launches.length).toBe(1); + }); +}); diff --git a/packages/cli/src/agent-stack.ts b/packages/cli/src/agent-stack.ts index d754acf5b..e7b42d744 100644 --- a/packages/cli/src/agent-stack.ts +++ b/packages/cli/src/agent-stack.ts @@ -146,9 +146,10 @@ export function* resolveAgentStack( * carrying an agent release this machine does not have (#672). * * Each of the rest reaches the provider directly rather than through a context: - * who owns a session and which build it belongs to are security decisions, and - * ones a document could replace are not ones. The two advertised sets are stated - * by the host, not inherited. + * who owns a session, which build it belongs to and which builds this machine + * has proved are security decisions, and ones a document could replace are not + * ones. The two advertised sets are stated by the host, not inherited, and they + * select rather than authorize: the compatibility beside them is what admits. */ export function hostAcpDependencies(stack: AuthorshipStack): AcpxProviderDependencies { const { sessions } = stack; @@ -161,6 +162,7 @@ export function hostAcpDependencies(stack: AuthorshipStack): AcpxProviderDepende ...(sessions.coordinator ? { coordinator: sessions.coordinator } : {}), ...(sessions.routeStore ? { routeStore: sessions.routeStore } : {}), ...(sessions.executableObserver ? { executableObserver: sessions.executableObserver } : {}), + ...(sessions.compatibility ? { compatibility: sessions.compatibility } : {}), advertiseNativeLaunch: sessions.advertiseNativeLaunch, advertiseClientNativeAttachment: sessions.advertiseClientNativeAttachment, }; diff --git a/packages/cli/src/compiled.ts b/packages/cli/src/compiled.ts index c7d70d281..db26ba4b7 100644 --- a/packages/cli/src/compiled.ts +++ b/packages/cli/src/compiled.ts @@ -100,7 +100,10 @@ if (paneWorker !== undefined) { denoRunRepositories(HELPER), () => readInputStream(process.stdin), () => useDenoWorkflowHost(HELPER), - useMachineSessions(), + // The machine, read at the one place that is actually running on it. A + // native capability is admitted for an exact OS and architecture, so + // those cross from here as values rather than being detected below. + useMachineSessions({ platform: process.platform, architecture: process.arch }), // This host presents grids: it has a terminal to divide, and it can // re-invoke itself for one pane. foregroundTerminalGrid(), diff --git a/packages/cli/src/deno.ts b/packages/cli/src/deno.ts index a1e7c9b5e..e657843c1 100644 --- a/packages/cli/src/deno.ts +++ b/packages/cli/src/deno.ts @@ -119,7 +119,10 @@ if (paneWorker !== undefined) { denoRunRepositories(HELPER), () => readInputStream(process.stdin), () => useDenoWorkflowHost(HELPER), - useMachineSessions(), + // The machine, read at the one place that is actually running on it. A + // native capability is admitted for an exact OS and architecture, so + // those cross from here as values rather than being detected below. + useMachineSessions({ platform: process.platform, architecture: process.arch }), // This host presents grids: it has a terminal to divide, and it can // re-invoke itself for one pane. foregroundTerminalGrid(), diff --git a/packages/cli/src/session-coordinator.ts b/packages/cli/src/session-coordinator.ts index 970f5d637..806dd6f4a 100644 --- a/packages/cli/src/session-coordinator.ts +++ b/packages/cli/src/session-coordinator.ts @@ -23,8 +23,13 @@ import { ADVERTISED_CLIENT_NATIVE_ATTACHMENT, ADVERTISED_NATIVE_LAUNCH, createDenoSessionRouteStore, + nativeCapabilityCompatibility, +} from "@executablemd/acp"; +import type { + AgentSessionRouteStore, + NativeCapabilityCompatibility, + NativeCapabilityHost, } from "@executablemd/acp"; -import type { AgentSessionRouteStore } from "@executablemd/acp"; export function sessionCoordinatorRoot(): string { return join(homedir(), ".acpx", "xmd-native-sessions", "v1"); @@ -46,6 +51,15 @@ export interface MachineSessionAssembly { executableObserver?: ExecutableObserver; advertiseNativeLaunch: readonly string[]; advertiseClientNativeAttachment: readonly string[]; + /** + * Which exact builds this host admits each capability on, and the machine it + * admits them for. + * + * Beside the observer rather than derived from the names above, because the + * names are a coarse selection: an adapter reaches the question through them + * and is answered here. Absent admits nothing. + */ + compatibility?: NativeCapabilityCompatibility; } /** This host's session coordinator, or nothing when it cannot provide one. */ @@ -74,10 +88,16 @@ export function useExecutableObserver(): ExecutableObserver | undefined { } /** - * The ordinary `xmd run` profile: this machine's sessions, and the adapters - * proven against the installed CLI. + * The ordinary `xmd run` profile: this machine's sessions, and what its + * adapters have been proved to do on it. + * + * `host` is passed in rather than read here, and read at the entrypoint rather + * than anywhere below it. Which OS and architecture are underneath is exactly + * the fact a capability point is matched against, so a module that went and + * found it for itself would be supplying the answer as well as the question — + * and a case stating an exact point could never contradict it. */ -export function useMachineSessions(): MachineSessionAssembly { +export function useMachineSessions(host: NativeCapabilityHost): MachineSessionAssembly { return { ...(useSessionCoordinator() === undefined ? {} : { coordinator: useSessionCoordinator() }), ...(useSessionRouteStore() === undefined ? {} : { routeStore: useSessionRouteStore() }), @@ -86,17 +106,18 @@ export function useMachineSessions(): MachineSessionAssembly { : { executableObserver: useExecutableObserver() }), advertiseNativeLaunch: ADVERTISED_NATIVE_LAUNCH, advertiseClientNativeAttachment: ADVERTISED_CLIENT_NATIVE_ATTACHMENT, + compatibility: nativeCapabilityCompatibility(host), }; } /** * The same advertised names on a host that assembles none of the answers. * - * Node and Bun run the same commands and offer the same agents, and neither can - * take a kernel-released advisory lock, keep durable routes, or observe a - * build. Keeping the names is what makes the refusal say so: every advertised - * operation stops before provider work rather than acting while a native UI may - * be in the conversation. + * Node and Bun run the same commands and offer the same agents, and none of + * them can take a kernel-released advisory lock, keep durable routes, observe a + * build, or say which builds this machine has proved. Keeping the names is what + * makes the refusal say so: every advertised operation stops before provider + * work rather than acting while a native UI may be in the conversation. */ export function unassembledMachineSessions(): MachineSessionAssembly { return { diff --git a/packages/cli/tests/agent-session-coordinator.test.ts b/packages/cli/tests/agent-session-coordinator.test.ts index 7837bef20..19f1b8ca2 100644 --- a/packages/cli/tests/agent-session-coordinator.test.ts +++ b/packages/cli/tests/agent-session-coordinator.test.ts @@ -34,23 +34,35 @@ import type { AgentSessionCoordinator } from "@executablemd/runtime"; import { ADVERTISED_CLIENT_NATIVE_ATTACHMENT, ADVERTISED_NATIVE_LAUNCH, + admitsNativeCapability, createAcpxProvider, createDenoSessionRouteStore, createMemorySessionRouteStore, } from "@executablemd/acp"; -import type { AgentSessionRouteStore, NativeAdapter, NativeBinding } from "@executablemd/acp"; +import type { + AgentSessionRouteStore, + NativeAdapter, + NativeBinding, + NativeCapabilityHost, +} from "@executablemd/acp"; import type { ExecutableObserver } from "@executablemd/runtime"; import type { NativeLaunchRequest } from "@executablemd/terminal"; import { createFakeObserver } from "../../acp/tests/helpers.ts"; import { sessionCoordinatorRoot, useExecutableObserver, + unassembledMachineSessions, useMachineSessions, useSessionCoordinator, } from "../src/session-coordinator.ts"; const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "src"); +/** The machine the shipped Claude points were proved on. */ +const PROVED_HOST: NativeCapabilityHost = { platform: "darwin", architecture: "arm64" }; + +const CLAUDE_PROVED_VERSION = "2.1.241 (Claude Code)"; + /** Whether this is the runtime that can take a kernel-released advisory lock. */ function onDeno(): boolean { const found: unknown = Reflect.get(globalThis, "Deno"); @@ -300,6 +312,12 @@ function* launchUnder( ...(coordinator ? { coordinator } : {}), ...(options.routeStore ? { routeStore: options.routeStore } : {}), ...(options.observer ? { executableObserver: options.observer } : {}), + // The points this host states, for the machine they were proved on rather + // than the one running the suite: the subject is what a host assembles, + // and stating the pair as a value is how a host does it. + ...(useMachineSessions(PROVED_HOST).compatibility === undefined + ? {} + : { compatibility: useMachineSessions(PROVED_HOST).compatibility }), }); yield* factory( { defaultAgent: agent, permissionMode: "deny-all" }, @@ -513,7 +531,14 @@ describe("Tier HC — host session ownership", () => { it("HC5: the Deno and compiled entrypoints assemble machine sessions; Node and Bun do not", function* () { for (const name of ["deno.ts", "compiled.ts"]) { - expect((yield* entrypoint(name)).includes("useMachineSessions()")).toBe(true); + // The machine crosses from the entrypoint as values. Shared assembly is + // handed the pair rather than reading it, so the one place that is + // actually running on this host is the one place that says so. + expect( + (yield* entrypoint(name)).includes( + "useMachineSessions({ platform: process.platform, architecture: process.arch })", + ), + ).toBe(true); } for (const name of ["node.ts", "bun.ts"]) { const source = yield* entrypoint(name); @@ -554,7 +579,7 @@ describe("Tier HC — host session ownership", () => { // says which adapters it has proven for each rather than inheriting either. expect([...ADVERTISED_NATIVE_LAUNCH]).toEqual(["claude"]); expect([...ADVERTISED_CLIENT_NATIVE_ATTACHMENT]).toEqual(["claude"]); - const assembly = useMachineSessions(); + const assembly = useMachineSessions(PROVED_HOST); expect([...assembly.advertiseNativeLaunch]).toEqual(["claude"]); expect([...assembly.advertiseClientNativeAttachment]).toEqual(["claude"]); // Built from the same trusted root as the coordinator beside it. @@ -563,4 +588,56 @@ describe("Tier HC — host session ownership", () => { expect(assembly.executableObserver === undefined).toBe(!onDeno()); expect(useExecutableObserver() === undefined).toBe(!onDeno()); }); + + it("HC13: the machine an assembly admits for is the one it was handed", function* () { + // A name is a selection; a point is the proof. The assembly states the + // proved points beside the observer, and the machine they are proved for + // arrives as a value, so the same shared code admits nothing on a host it + // was never proved against. + const proved = useMachineSessions(PROVED_HOST).compatibility; + expect(proved?.host).toEqual(PROVED_HOST); + expect(proved?.points).toEqual([ + { + agent: "claude", + capability: "native-launch", + reportedVersion: CLAUDE_PROVED_VERSION, + ...PROVED_HOST, + }, + { + agent: "claude", + capability: "client-native-attachment", + reportedVersion: CLAUDE_PROVED_VERSION, + ...PROVED_HOST, + }, + ]); + const claudeLaunch = { + agent: "claude", + capability: "native-launch", + reportedVersion: CLAUDE_PROVED_VERSION, + } as const; + expect(admitsNativeCapability(proved, claudeLaunch)).toBe(true); + for (const elsewhere of [ + { platform: "linux", architecture: "arm64" }, + { platform: "darwin", architecture: "x64" }, + ]) { + expect( + admitsNativeCapability(useMachineSessions(elsewhere).compatibility, claudeLaunch), + ).toBe(false); + } + // Node and Bun keep the coarse names and assemble no authority, so the + // advertised name reaches a question this profile answers with a refusal. + const unassembled = unassembledMachineSessions(); + expect([...unassembled.advertiseNativeLaunch]).toEqual(["claude"]); + expect(unassembled.compatibility).toBe(undefined); + expect(admitsNativeCapability(unassembled.compatibility, claudeLaunch)).toBe(false); + // Shared assembly is handed the machine rather than reading it: only the + // entrypoints checked in HC5 name the running runtime. + expect((yield* entrypoint("session-coordinator.ts")).includes("process.platform")).toBe(false); + // The workflow profile advertises neither capability and states no points, + // so a workflow Claude prompt never reaches this question at all. + const workflow = yield* entrypoint("workflow-agent.ts"); + expect(workflow.includes("advertiseNativeLaunch: [],")).toBe(true); + expect(workflow.includes("advertiseClientNativeAttachment: [],")).toBe(true); + expect(workflow.includes("compatibility:")).toBe(false); + }); }); diff --git a/packages/test-agent/src/provider.ts b/packages/test-agent/src/provider.ts index ae5cb1eb8..5500c6ed1 100644 --- a/packages/test-agent/src/provider.ts +++ b/packages/test-agent/src/provider.ts @@ -10,6 +10,7 @@ */ import { randomUUID } from "node:crypto"; +import process from "node:process"; import type { Operation } from "effection"; import { useAcpxProvider } from "@executablemd/acp"; import type { @@ -17,7 +18,11 @@ import type { AcpxProviderDependencies, SessionRouteContext, } from "@executablemd/acp"; -import type { AgentSessionRouteStore, NativeAdapter } from "@executablemd/acp"; +import type { + AgentSessionRouteStore, + NativeAdapter, + NativeCapabilityCompatibility, +} from "@executablemd/acp"; import { useRouteSlot } from "./route-slot.ts"; import type { AgentSessionCoordinator, ExecutableObserver } from "@executablemd/runtime"; import type { AcpAgentRegistry, AcpSessionRecord, AcpSessionStore } from "acpx/runtime"; @@ -142,6 +147,30 @@ export const TEST_AGENT_CLIENT_NATIVE_ADAPTER: NativeAdapter = { resume: (nativeSessionId) => ["xmd-test-agent-ui", "--resume", nativeSessionId], }; +/** + * The points this partition admits, for the machine it is running on. + * + * This partition is its own trusted host — it supplies its own coordinator, + * route store and observer — so it also says which builds it has proved. What + * a real host proves by driving an installed CLI, this one proves by being the + * build: the controlled UI reports exactly `TEST_AGENT_BUILD_VERSION` and does + * the same thing everywhere, so the live pair is what the point is stated for. + * Reading the machine here rather than in `@executablemd/acp` is the whole + * point of the seam — a scenario stating an exact foreign point still refuses. + */ +function testAgentCompatibility(): NativeCapabilityCompatibility { + const host = { platform: process.platform, architecture: process.arch }; + return { + host, + points: (["native-launch", "client-native-attachment"] as const).map((capability) => ({ + agent: TEST_AGENT_CLIENT_NATIVE, + capability, + reportedVersion: TEST_AGENT_BUILD_VERSION, + ...host, + })), + }; +} + export function* useTestAgentProvider( options: TestAgentProviderOptions, ): Operation { @@ -203,6 +232,7 @@ export function* useTestAgentProvider( ...(options.coordinator ? { coordinator: options.coordinator } : {}), ...(options.routeStore ? { routeStore: options.routeStore } : {}), ...(options.executableObserver ? { executableObserver: options.executableObserver } : {}), + compatibility: testAgentCompatibility(), ...(options.dependencies?.createRuntime ? { createRuntime: options.dependencies.createRuntime } : {}), From 0e171c5e62cd2b9e5ce5bccb4cc3cb299d1e9531 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 04:53:08 -0400 Subject: [PATCH 03/22] =?UTF-8?q?=F0=9F=94=92=20Prove=20a=20launch's=20own?= =?UTF-8?q?=20teardown=20before=20releasing=20its=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A native launch acknowledged ownership quiescence whenever `stop()` on its scope succeeded. A launch that completed naturally tore itself down first, so a finalizer that failed on the way reached the owner as a crash while `stop()` afterwards succeeded against an already-settled scope — proof that the scope is settled now, never that the cleanup which failed had succeeded. The session was released idle with an unproved child behind it. The invocation now suspends once the native child settles, so the one teardown there is happens under the one call that reports it. Quiescence is still withheld unless the child settled, every finalizer of that invocation completed, and the provider holds no handle; otherwise the exclusion is released with the record left active, and the next acquisition is refused as session-recovery-required before any spawn, ensure, allocation or route work. The original cleanup failure is what the launch caller observes. --- packages/acp/src/provider.ts | 39 ++++++---- packages/acp/tests/native-launch.test.ts | 94 ++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 13 deletions(-) diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index 7b91b51b6..257f2a535 100644 --- a/packages/acp/src/provider.ts +++ b/packages/acp/src/provider.ts @@ -2832,12 +2832,14 @@ function* useAcpxProviderState( // reached; written as this scope's cleanup, it is reached on every // path there is. const [running, stop] = createScope(yield* useScope()); + const performed = withResolvers(); let stopped = false; yield* ensure(function* () { // Registered after the scope exists, so it runs before the scope - // is destroyed on its own: the launch comes down here, and - // `destroy()` carries the outcome of its teardown. A child that + // would be destroyed alongside its parent: the launch comes down + // here, and `destroy()` carries the outcome of its teardown. A + // child that // could not be proven stopped, or a cleanup that failed, throws // out of it — and is not quiescence, and is still a failure. try { @@ -2845,21 +2847,28 @@ function* useAcpxProviderState( stopped = true; } finally { // Everything this owner started has to be finished with the - // session, and that is two facts rather than one: the native - // child and its cleanup settled, and this provider holds no - // handle for the session — a detach that failed, or a session - // prepared and never handed over, leaves one. Either one - // missing leaves the session owned rather than looking - // finished, which is what the next owner is told to recover - // deliberately. + // session, and that is three facts rather than one: the native + // child settled, every finalizer belonging to that invocation + // completed, and this provider holds no handle for the session + // — a detach that failed, or a session prepared and never + // handed over, leaves one. Any of them missing leaves the + // session owned rather than looking finished, which is what the + // next owner is told to recover deliberately. if (stopped && !holding(placement.sessionKey)) { ownership.quiesced(); } } }); - yield* running.run(() => - authority.perform(request, { + // Held open after the native child settles so that the invocation + // is never torn down except by the `stop()` above. A launch left to + // unwind on its own runs its finalizers where nothing can be told + // how that went — the failure reaches this owner as a crash, after + // the scope has already settled, and stopping a settled scope + // proves only that it is settled now. Suspending puts the one + // teardown there is under the one call that reports it. + running.run(function* (): Operation { + yield* authority.perform(request, { prepare: () => withSessionRoute(context, () => prepareLaunch( @@ -2873,8 +2882,12 @@ function* useAcpxProviderState( detach: (prepared) => detachSession(invocation, prepared, agentCommandOf(placement)), exit: (prepared) => runNativeUi(invocation, prepared, agentCommandOf(placement)), - }), - ); + }); + performed.resolve(); + yield* suspend(); + }); + + yield* performed.operation; }, ); } catch (error) { diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index 4d1de0c2c..bb957f87b 100644 --- a/packages/acp/tests/native-launch.test.ts +++ b/packages/acp/tests/native-launch.test.ts @@ -4611,4 +4611,98 @@ describe("Tier NP — proved native capability points", () => { expect(shared.acquisitions.map((entry) => entry.outcome)).toEqual(["granted", "granted"]); expect(secondTrace.launches.length).toBe(1); }); + + it("NP10: a launch whose own cleanup failed as it completed stays owned", function* () { + // The other half of CX2. Cancellation is not the only way teardown fails: + // a launch that ran to completion unwinds its own finalizers on the way + // out, and a failure there leaves exactly the same fact unproved. Bringing + // that scope down afterwards says it is settled now — never that the + // cleanup which failed on the way had succeeded. + const CLEANUP_FAILED = "the native child could not be proven stopped"; + const shared = makeCoordinator(); + const seen = boundaries(); + const routes = countedRoutes(seen); + const store = makeStore(); + yield* routes.publish(bound()); + const before = JSON.stringify(yield* routes.read(KEY)); + + const first = createFakeRuntime(); + const firstTrace = newTrace(); + firstTrace.ownership = shared; + const started = withResolvers(); + const release = withResolvers(); + let raised = ""; + + yield* scoped(function* () { + yield* installLaunchStack(first, firstTrace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: routes, + store, + coordinator: shared.coordinator, + cleanupFails: CLEANUP_FAILED, + hold: (function* () { + started.resolve(); + yield* release.operation; + })(), + }); + + // Signalled rather than timed: the child is let go only once it has + // provably started, and the launch is then awaited to its own + // settlement — which is where its finalizers run. + yield* spawn(function* () { + yield* started.operation; + release.resolve(); + }); + try { + yield* launch(INSTRUCTIONS); + } catch (error) { + raised = error instanceof Error ? error.message : String(error); + } + }); + + // The child did start, under the identity the route names, and completed + // on its own — this is not a cancellation. + expect(firstTrace.launches.map((request) => request.command)).toEqual([ + [OBSERVED_PATH, "--resume", ALLOCATED], + ]); + // What the caller was handed is the original cleanup failure itself. + expect(raised).toContain(CLEANUP_FAILED); + // And nothing was acknowledged: the live exclusion came back, the record + // stayed standing. + expect(shared.events).toEqual(["owned", "released-active"]); + + const publishedBefore = seen.published.length; + const second = createFakeRuntime(); + const secondTrace = newTrace(); + secondTrace.ownership = shared; + const later = boundaries(); + let failure: PreparedLaunchRecord["failure"]; + // Under a private root that could not hold an instruction file, so a run + // that had reached the write would fail in the host's own words instead of + // refusing for the session. + yield* withUnusablePrivateRoot(function* () { + yield* scoped(function* () { + yield* installLaunchStack(second, secondTrace, { + adapters: { claude: countedAdapter(later) }, + routeStore: routes, + store, + coordinator: shared.coordinator, + }); + failure = yield* attempt(secondTrace, INSTRUCTIONS); + }); + }); + + expect(failure?.class).toBe("session-recovery-required"); + expect(shared.acquisitions.map((entry) => entry.outcome)).toEqual([ + "granted", + "recovery-required", + ]); + // Refused in front of every boundary: no child, no ACP session, no second + // identity, no publication over the account of the one the route names. + expect(secondTrace.launches).toEqual([]); + expect(second.ensureCalls).toEqual([]); + expect(later.allocations + later.creates + later.resumes).toBe(0); + expect(seen.published.length).toBe(publishedBefore); + expect(JSON.stringify(yield* routes.read(KEY))).toBe(before); + }); }); From 0876896b183adc6b4b9d0d4011aad8df685fdc23 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 05:12:20 -0400 Subject: [PATCH 04/22] =?UTF-8?q?=F0=9F=8F=9B=EF=B8=8F=20Admit=20native=20?= =?UTF-8?q?capabilities=20by=20observed=20protocol=20shape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 8 +- specs/decisions.md | 106 +++++--- specs/native-agent-session-launch-spec.md | 311 +++++++++++++--------- 3 files changed, 261 insertions(+), 164 deletions(-) diff --git a/architecture.md b/architecture.md index ee8c8b26c..ba63cbf11 100644 --- a/architecture.md +++ b/architecture.md @@ -117,9 +117,9 @@ Existing documents and code get aligned to this section retroactively. | provider authority | what core delivers to the provider factory it installs, as an argument that factory closes over. It validates the routed request, runs each absent phase once, cross-checks and retains what comes back, and derives the result. There is no reader for one, no context holding one, and no request member carrying one | | session coordinator | the host-built capability that answers who owns one logical agent session right now, across processes. It is passed directly into the provider that needs it and is deliberately not contextual: a decision document middleware could replace is not one. Acquisition never waits | | construction route | how one logical agent session was first constructed — through ACP, or under an identity XMD chose — as strict create-once durable state beside the coordinator's own records. It answers a different question from ownership and grants no right to act: the coordinator remains the single live authority. A route never converts, and a session ACP already established is recorded as ACP-first rather than reclassified. Two schemas are readable: the released `session-route.v1`, and `session-route.v2`, which exists only for `client-native` and adds a required executable build binding. New client-native construction publishes V2; a V1 client-native route stays legacy-unbound, is never upgraded in place, and authorizes native resume only | -| executable build binding | which build of a provider executable accepted a client-allocated identity, as a schema, an adapter-canonical version and the lowercase SHA-256 of the canonical executable target. Equality is exact over all three, so a matching build reached at another path is the same build and a changed build at the same path is not. It contains no path: a path says where a build was, which stops being true, and names host layout besides | -| native capability compatibility point | the smallest installed-provider environment for which one native capability's real-CLI proof passed: adapter, capability, adapter-canonical reported version, host operating system and host architecture. A proof authorizes only that point, never every semver-shaped build or every platform. The executable digest remains the per-session continuity binding rather than a global allow-list: the compatibility proof describes a released provider build across installations, while the route must still recognize the exact executable that accepted its identity | -| executable observer | the host-built capability that resolves a launcher command through the host's real execution environment, canonicalizes the target, requires an executable regular file, hashes that target and asks that same file its version. Passed directly into the provider like the coordinator beside it, and deliberately not contextual: executable validation decides which retained history may be accepted, and a resolver document middleware could replace could point the observation at one binary while the run spawns another | +| executable build binding | which build of a provider executable accepted a client-allocated identity, as a schema, the lowercase SHA-256 of the canonical executable target and an optional adapter-canonical reported version. Digest equality is mandatory, and a retained version must be reproduced when one exists; a build which reports none binds by digest alone. It contains no path: a path says where a build was, which stops being true, and names host layout besides | +| native capability admission profile | the adapter protocol and observable command shape for which one native capability's real-CLI proof passed, inside its proved host operating system and architecture. The Agent name and reported version authorize nothing by themselves: the exact observed executable must advertise the profile's required shape through a read-only metadata query. The executable digest remains the per-session continuity binding, while an optional canonical version is retained only as supplemental evidence and a diagnostic | +| executable observer | the host-built capability that resolves a launcher command through the host's real execution environment, canonicalizes the target, requires an executable regular file, hashes that target and runs the adapter's declared read-only metadata queries against that same path. Passed directly into the provider like the coordinator beside it, and deliberately not contextual: executable validation decides which retained history may be accepted, and a resolver document middleware could replace could point the observation at one binary while the run spawns another | | client-native attachment | joining a conversation a native process constructed, through ACP, under the identity the route already names. It is attachment, never conversion: the route stays client-native, `resumeSessionId` receives that exact identity, and the provider's canonical assertion must equal it before the first turn. Advertised separately from native launch, because handing a session to a native UI and later joining it prove different things | | identity provenance | who chose a provider-native session identity: the provider returned it, or the client allocated it before the provider existed. Retained rather than inferred, because after the fact both are just a string in the record. A released record written before the distinction existed reads as provider-returned, which is the only compatibility inference | | quiescence | the one-use acknowledgement an ownership body gives. It does not mean "I finished" — it means nothing this owner started can still act on the session, which is why a handoff that could not release its handle never gives it | @@ -4647,7 +4647,7 @@ Status is measured against main. | nested run-profile Agent and elicitation declarations | lets one `` declare one child-scoped `` scenario set and one non-delegating `` matcher set; only frozen test data crosses the harness request, the trusted host constructs both providers inside the isolated child, siblings share no session or provider state, ordinary component shadowing remains in force, and the child journal retains only the selected Prompt and Elicit components' ordinary results. A controlled `` may author an exact scenario label that this host alone maps to Plan's derived conversation identity; declaration selection uses the label while runtime state stays keyed by the opaque identity and child, with no matcher or fallback added to ordinary TestAgent sessions | built on the #641 stack; controlled Plan routing added on the #728 stack | | `Config` run deadline / exec default / Fetch default / verbosity | three independently owned contextual timeouts, absent unless configured, each read by exactly one consumer, and contextual verbosity — a boolean that is false unless configured, seeded by the command line and overridable for a lexical subtree, bounding nothing and owning no authority | built on this stack | | terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified and implemented in #732, which is what gives a pane's `` that pane's terminal rather than the root's; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts — one invocation-private server per grid, authenticated persistent pane workers carrying exact argv, cwd and environment outside tmux parsing, explicit row-major layout imposed by pane swaps, a required composite `launch()` that gives a pane's `` its own terminal rather than the root's, and one ordered teardown that proves worker quiescence, channel closure and server disappearance before the document continues; its evidence uses a fake tmux with real workers and real sockets, and real tmux behaviour on macOS remains #726's; Node and Bun catalog and validate the same grids and install neither the provider nor the process observer, refusing before pane start; DEC-016 specifies the final behavior-preserving extraction into `@executablemd/terminal` and `@executablemd/terminal-tmux`, with every repository import moved to the canonical packages and the unshipped old terminal paths deleted | -| native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. The trusted host also states its operating system and architecture and admits each native capability only at a compatibility point whose applicable real-CLI proof passed; a canonical-looking version is only an observation, not proof. That compatibility gate follows observation and precedes identity allocation, route publication, private-file creation, native process start, attachment ensure and incomplete replay. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires the live capability point and retained binding both to match, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. A route whose exact provider identity is absent remains authoritative: the provider's explicit refusal is an accepted zero-turn outcome and XMD neither allocates, creates, converts, deletes, rewrites nor substitutes a conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store, executable observer and live platform facts — and Node and Bun assemble none of that authority, so every advertised operation refuses before provider work; `claude` is admitted for native launch only at the proved Claude Code 2.1.241/macOS/arm64 compatibility point (#520) and separately for client-native attachment at the point proven by the native-to-ACP marker gate (#561); another Claude version or platform is unadvertised until its own applicable real-CLI proofs pass, while an exact missing-session refusal remains an accepted client-allocated outcome; Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | +| native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. The trusted host also states its operating system and architecture and admits each native capability only when the resolved adapter protocol, the independently requested capability, a read-only observation of the required CLI shape, and the proved host envelope all agree; neither the Agent name nor reported version authorizes it. That gate follows executable observation and precedes identity allocation, route publication, private-file creation, native process start, attachment ensure and incomplete replay. Reported version is optional diagnostic and per-session continuity evidence; the executable digest remains mandatory for a retained identity, so a compatible upgraded CLI can create new sessions while an existing session never crosses to a changed build implicitly. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires the live capability profile and retained binding both to match, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. A route whose exact provider identity is absent remains authoritative: the provider's explicit refusal is an accepted zero-turn outcome and XMD neither allocates, creates, converts, deletes, rewrites nor substitutes a conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store, executable observer, capability-profile admission and live platform facts — and Node and Bun assemble none of that authority, so every advertised operation refuses before provider work; the Claude client-native protocol is admitted independently for launch and attachment when its required help shape is observed on the proved macOS/arm64 host envelope, including compatible versions after 2.1.241 and builds which omit version output; an absent or changed required shape or an unproved platform refuses, while an exact missing-session refusal remains an accepted client-allocated outcome; Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | | `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack | | `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 | | `` | removes one file the document names, inside the contextual working directory. An ordinary overridable core default with a closed schema of one required non-empty `path`, **self-closing only** — a paired spelling never enters its body, because the component declares its one form and canonical invocation-form dispatch enters that body only for the form the scan recorded, before `Env.cwd` is read and before the provider is reached. Neither the composable `Component.hasContent()` chain nor a method on whatever object a caller handed over takes part. It renders the empty string, declares no `returns` and hands back no receipt, so an ordinary `as` captures that empty string; absence is the same success, so deleting a path twice succeeds twice. One regular file or one final symbolic link goes — the link rather than its target, inside or outside — and every directory is refused, an empty one included. Empty, absolute, lexically escaping and parent-link-escaping paths are refused before any removal, and a printed error names only the path the document wrote. One semantic `API.Files.deleteFile` call and no filesystem access of its own; under a workflow run it is one `workspace_file` effect retaining `{ kind: "deleted" }`. The standard Deno workflow profile admits it to generated XMD as the exact self-closing identity `@executablemd/core#File.Delete`, third in the write table, where it performs that same ordinary effect and contributes no evaluator result | built on the #567 stack | diff --git a/specs/decisions.md b/specs/decisions.md index 3a0d53b87..b0b759d80 100644 --- a/specs/decisions.md +++ b/specs/decisions.md @@ -807,7 +807,7 @@ discovery, and release verification. Moving tests changes the measured corpus, so its weights are remeasured by the repository workflow rather than edited by hand. -## DEC-017: Native capability advertisement is proof-point scoped +## DEC-017: Native capability admission follows a proved protocol shape **Status:** Decided @@ -816,15 +816,16 @@ hand. ### Context Claude's client-allocated launch proof ran against Claude Code 2.1.241 on -macOS arm64, but the built-in advertisement named only `claude` and the version -parser accepted every canonical semver-shaped Claude Code line. That turns one -installed-CLI observation into a claim about builds and platforms the proof did -not exercise. +macOS arm64. The first repair admitted only that exact reported version. That +fails closed, but it makes a routine Claude Code upgrade disable every new +native session even when the executable still advertises the protocol surface +the adapter uses. -Executable build binding answers a different question. Its canonical version -and executable digest let a later operation recognize the exact executable that -accepted one retained identity. They do not prove that native launch or -client-native ACP attachment works on that executable in the first place. +Executable build binding answers a different question. Its executable digest +and optional canonical reported version let a later operation recognize the +exact executable that accepted one retained identity. They do not globally +authorize native launch or client-native ACP attachment, and a version string +does not describe a capability. The client-allocated contract deliberately accepts two zero-turn outcomes: the same identity resumes, or the provider refuses that exact absent identity and @@ -835,33 +836,54 @@ launch performs no model turn. ### Decision -One real-CLI proof admits one native capability compatibility point: +One real-CLI proof establishes an adapter protocol profile. A live executable +is admitted only when all of these facts agree: ```text -adapter + capability + canonical reported version + host OS + host architecture +adapter protocol + independently requested capability + observed protocol shape ++ host OS + host architecture ``` -Native launch and client-native attachment retain separate admission because -their proofs establish different behavior. The trusted host supplies OS and -architecture directly beside the coordinator, route store and executable -observer; shared provider code does not detect a runtime and document code -cannot replace these facts. The executable digest remains the route's exact -per-session continuity binding rather than a global allow-list for one -operator's installation. - -For a new client-allocated session the provider observes the executable and -checks the applicable point before identity allocation, route publication, -private-file creation or process start. A bound route is checked again before -native resume, attachment ensure and incomplete replay. A point the host has not -admitted refuses with `unsupported-capability`; a live build which differs from -the route still refuses with `executable-binding-refused`. Completed replay and -legacy V1 native-only resume keep their existing behavior. - -Claude is admitted for native launch and, independently, attachment only at the -applicable Claude Code 2.1.241/macOS/arm64 points already proved. Another -version or platform remains unsupported until the full applicable real-CLI -proof passes and its exact point is added. A semver-shaped version line alone -never widens admission. +The adapter protocol is a stable implementation identifier, not the Agent name +or executable command. Its adapter-owned probe asks the exact observed +executable for side-effect-free help metadata, with no terminal, session +identity, instruction text, credentials or provider state. The Claude profile +recognizes the product and the required option shapes without matching the +whole help text: native launch requires client allocation, private-file +instructions and exact resume; client-native attachment independently requires +exact resume plus the pinned ACP bridge's `resumeSessionId` contract. Additive +options, line wrapping and an unrelated version change do not invalidate that +shape. A missing, ambiguous or changed required shape admits nothing. + +Native launch and client-native attachment remain separate admission decisions +because their proofs establish different behavior. The trusted host supplies +OS and architecture directly beside the coordinator, route store and +executable observer; shared provider code does not detect a runtime and +document code cannot replace these facts. The currently proved host envelope +remains macOS arm64. A different operating system or architecture requires its +own applicable real-CLI evidence even when the help shape is identical. + +For a new client-allocated session the provider resolves and hashes the +executable, runs the adapter's read-only metadata query against that exact path, +and checks the requested capability before identity allocation, route +publication, private-file creation or native child start. A bound route is +checked again before native resume, attachment ensure and incomplete replay. A +profile or shape the host has not admitted refuses with +`unsupported-capability`; a live build which differs from the route still +refuses with `executable-binding-refused`. Completed replay and legacy V1 +native-only resume keep their existing behavior. + +Claude Code's version query is optional metadata. One canonical version line is +retained when available; changed wording, noncanonical output, a failed version +query or no version output does not deny an otherwise recognized capability +shape. New sessions created by such a build retain its exact digest and omit +the version. A retained binding still protects one published identity: digest +equality is mandatory, two present versions must agree, and a retained version +which the same observation can no longer reproduce refuses conservatively. An +upgrade may therefore create new named sessions immediately when its shape and +host envelope are admitted, while a session bound to the previous executable +does not cross the changed digest. Crossing builds for one existing identity +requires a separate migration contract. An already-published route whose exact provider identity is absent remains the authoritative account. Native resume or ACP attachment fails closed without @@ -879,10 +901,22 @@ separate product decision and specification change. ### Consequences The provider's static adapter-name sets are only a coarse selection and cannot -authorize client-allocated work by themselves. The host/provider assembly gains -live platform facts and a controlled compatibility-admission seam, so tests can -state exact points without reading the active runtime. Route, journal, request, -provider identity, authored syntax and build-binding schemas do not change. +authorize client-allocated work by themselves. Admission requires the resolved +adapter's stable protocol identifier, its observed capability shape and the +host envelope. The host/provider assembly keeps live platform facts and a +controlled admission seam, so tests can state profiles and observations without +reading the active runtime. The unmerged executable binding makes +`reportedVersion` optional; route and journal identity, request, provider +identity and authored syntax otherwise do not change. + +The metadata probe is deliberately less than a semantic trial. No +side-effect-free query can prove that an implementation has no hidden +regression; proving that by creating a disposable conversation would violate +the product contract. XMD relies on the CLI's advertised protocol inside the +real-proof envelope, then keeps every existing exact-identity, no-substitution, +settlement and recovery check at the live boundary. A provider which needs +stronger attestation must expose it without a model turn before its profile can +use it. This is a repair to the unmerged terminal/native-session stack and lands directly atop its delivery head. It is not a separate Story: the terminal grid diff --git a/specs/native-agent-session-launch-spec.md b/specs/native-agent-session-launch-spec.md index ebb193e4b..1c3c1d893 100644 --- a/specs/native-agent-session-launch-spec.md +++ b/specs/native-agent-session-launch-spec.md @@ -353,37 +353,46 @@ retains which build accepted it: ```ts interface ExecutableBuildBindingV1 { readonly schema: "executable-build.v1"; - readonly reportedVersion: string; + readonly reportedVersion?: string; readonly executableDigest: { readonly algorithm: "sha256"; readonly value: string }; } ``` -Every member is exact, and equality requires all of them to agree. The digest is -the lowercase SHA-256 of the canonical executable target; `reportedVersion` is -the adapter's canonical parse of what that exact target reports. That parse -accepts exactly one canonical line: output naming no build is unrecognized, and -output naming several is a list of builds rather than an answer — taking the -first would be choosing one, which is the question a binding exists to settle. -Neither is repeated in a diagnostic. A matching build -reached at another path is the same build; a changed build at the same path is -not. A path is never a member: it says where a build was, which stops being -true, and it names host layout besides. +The digest is always present and exact: it is the lowercase SHA-256 of the +canonical executable target. A matching digest reached at another path is the +same build; a changed build at the same path is not. `reportedVersion` is the +adapter's optional canonical parse of what that exact target reports. One +canonical line is retained as supplemental continuity evidence and may appear +in a diagnostic; raw output is never retained or repeated. A failed version +query, no output, changed wording or several candidate lines leaves the member +absent and does not make an otherwise observable executable or capability +unsupported. + +Continuity always requires digest equality. When the retained binding carries a +version, a later observation must reproduce that same canonical value; a +different or absent value refuses conservatively. A binding created without a +version compares by digest alone, and a later version observation does not +rewrite it. A path is never a member: it says where a build was, which stops +being true, and it names host layout besides. The host supplies an executable observer directly to the provider, alongside the coordinator and the route store. It resolves the launcher command through the host's real execution environment, canonicalizes the target, requires an -executable regular file, hashes that target, and asks that same file its -version. It is deliberately not a Context, contextual Api, Agent operation, -component or middleware value: executable validation decides which retained -history may be accepted, and a resolver document middleware could replace could -point the observation at one binary while the run spawns another. A controlled -test substitutes the whole observer through the same constructor seam. +executable regular file, hashes that target, and runs only the adapter's declared +read-only metadata queries against that same path. It is deliberately not a +Context, contextual Api, Agent operation, component or middleware value: +executable validation decides which retained history may be accepted, and a +resolver document middleware could replace could point the observation at one +binary while the run spawns another. A controlled test substitutes the whole +observer through the same constructor seam. The shared observer executes argv +and returns settled status and captured channel data; it does not know Claude, +parse a provider's output or detect the active runtime. One observation yields two kinds of value: ```text -durable: the executable build binding -live: the canonical executable path +durable: the executable build binding, with a version only when reported canonically +live: the canonical executable path and provider-private metadata observations ``` The live path exists only in the operation that observed it. Native creation and @@ -401,77 +410,108 @@ is installed now, not which one established the conversation. ### Compatibility admission -An installed-CLI proof admits one **compatibility point**, not an adapter name -for every build which happens to parse. The point consists of the adapter, the -capability proved, the adapter-canonical reported version, the host operating -system and the host architecture. Native launch and client-native attachment -have independent points because their proofs ask different questions. The -executable digest remains the exact continuity binding for one retained -session; it is not a global allow-list for one operator's installation. +An installed-CLI proof establishes a **capability admission profile**, not an +adapter name or version allow-list. A live admission consists of a stable +adapter protocol, one independently requested capability, an observed CLI shape +that satisfies that protocol, and the host operating system and architecture on +which the applicable real-CLI proof passed. Native launch and client-native +attachment have independent admissions because their proofs ask different +questions. The executable digest remains the exact continuity binding for one +retained session; it is not a global allow-list for one operator's installation. ```ts -interface NativeCapabilityCompatibilityPoint { - readonly agent: string; +interface NativeCapabilityAdmission { + readonly adapterProtocol: string; readonly capability: "native-launch" | "client-native-attachment"; - readonly reportedVersion: string; + readonly probeProfile: string; readonly platform: string; readonly architecture: string; } -interface NativeCapabilityCompatibility { +interface NativeCapabilityPolicy { readonly host: { readonly platform: string; readonly architecture: string }; - readonly points: readonly NativeCapabilityCompatibilityPoint[]; + readonly admissions: readonly NativeCapabilityAdmission[]; } ``` -The ACP provider receives `NativeCapabilityCompatibility` through its trusted -construction dependencies. The built-in point list is adapter-owned evidence; -the Deno and compiled entrypoints supply their live host pair, and controlled -tests replace the whole value. The existing advertised-name sets remain the -coarse host selection needed by provider-returned adapters and by hosts which -must explain why their authority is incomplete. For a client-allocated adapter, -a selected name without a matching point authorizes nothing. +The ACP provider receives `NativeCapabilityPolicy` through its trusted +construction dependencies. Each `NativeAdapter` carries a stable protocol +identifier and an adapter-owned probe profile. These are implementation +identities, not the Agent registry key, launcher command or document input. The +Deno and compiled entrypoints supply their live host pair, and controlled tests +replace the whole policy and observation. The existing advertised-name sets +remain the coarse host selection needed by provider-returned adapters and by +hosts which must explain why their authority is incomplete. For a +client-allocated adapter, a selected name without a matching protocol, +observation and host admission authorizes nothing. + +The built-in Claude probe invokes the exact resolved executable with `--help`, +an empty environment, no TTY or stdin, and stdout and stderr captured. It +supplies no session identity, instruction text, provider state or credential and +never invokes the native UI or ACP adapter. Its parser recognizes a Claude Code +help surface and only the option shapes the adapter consumes. For native launch +that means a caller-supplied UUID session identity, an instruction layer supplied by +private file, and exact resume by identity. For client-native attachment it +means exact resume by identity together with the pinned ACP bridge's proven +`resumeSessionId` contract. The two capability answers are computed separately. +An exact option declaration and Claude's documented `--system-prompt[-file]` +family spelling both establish the private-file member. Added options, changed +line wrapping and unrelated prose do not change the answer; a missing, +ambiguous or renamed required member does. The trusted host supplies its operating system and architecture directly with the coordinator, route store and executable observer. They are not document -values, Context, Agent input or middleware. After observing a client-allocated -adapter's build, the provider requires a matching compatibility point before it -allocates an identity, publishes a route, writes the instruction file or starts -a process. Existing bound routes are checked again before native resume, -client-native attachment or incomplete replay can contact the provider. A -canonical semver-shaped line establishes only that the build can be named; it -does not advertise a capability. - -A point mismatch is `unsupported-capability`. The diagnostic may identify the -agent and capability, and carries no raw version output, executable path, -digest, environment or host message. The route and journal schemas do not -change: platform facts are live admission inputs, while the route's build -binding continues to answer whether this is the exact executable that accepted -the retained identity. - -Claude's two admitted points are scoped to `2.1.241 (Claude Code)` on -`darwin`/`arm64`, where the applicable launch and attachment proofs ran. A -different Claude version, operating system or architecture is unsupported until -the applicable real-CLI proof passes there and that exact point is added. The -observed zero-turn behavior of a newer Claude that retains no conversation is -not by itself a regression: claim 5 explicitly permits the provider to refuse -that exact identity, provided XMD fails closed without substitution. +values, Context, Agent input or middleware. The initial real-CLI evidence proves +the Claude profile on macOS arm64, so that remains the admitted host envelope; +the same help shape on another operating system or architecture is insufficient +until the applicable proof passes there. Shared provider code detects no +runtime. + +After resolving and hashing a client-allocated adapter's executable, the +provider runs the read-only probe against that exact path and requires the +requested capability in its result. This precedes identity allocation, route +publication, private-file creation and native child start. Existing bound +routes are checked again before native resume, client-native attachment or +incomplete replay can contact the provider. A canonical version line is +evidence about the observed build, not capability admission. + +A protocol, shape, capability or host mismatch is `unsupported-capability`. +The diagnostic may identify the agent, capability, adapter protocol and +canonical reported version when one exists; it carries no raw help or version +output, executable path, digest, environment or host message. Platform and +probe facts remain live admission inputs. The route's build binding continues +to answer whether this is the exact executable that accepted the retained +identity, with optional `reportedVersion` as specified above. + +The version query is optional and independent of the help probe. A future +Claude build with the same admitted protocol shape on macOS arm64 remains +usable when its version changes, its version wording changes, or it omits +version output. A build that omits a canonical version creates a binding with +only its digest. An upgraded executable may establish new sessions, while a +route bound to an earlier digest remains bound to that build and refuses rather +than migrating its identity implicitly. The observed zero-turn behavior of a +newer Claude that retains no conversation is not by itself a regression: claim +5 explicitly permits the provider to refuse that exact identity, provided XMD +fails closed without substitution. Compatibility admission adds no materialization turn. Launch still performs no -model turn, and bootstrap turns remain outside this contract. Enabling a build -which cannot satisfy the accepted protocol requires either a different -provider-native creation mechanism proven to retain the same identity without a -turn, or an explicit product decision that changes this contract; parsing its -version is never enough. +model turn, and bootstrap turns remain outside this contract. The metadata +query is not a disposable session and proves no hidden implementation behavior; +it is the CLI's side-effect-free declaration that the previously proved +protocol remains present. A provider that cannot make that declaration, or +needs a model turn to establish it, remains unsupported. Enabling a different +protocol requires its own real-CLI proof and admission profile; a version string +or adapter name is never enough. ### Attachment capability Native launch and client-native ACP attachment are separate trusted-host -choices, and neither is inferred from the other or from an adapter's shape. An -adapter may be proven to hand a session to a native UI without being proven to -join that conversation afterwards. +choices. Both may inspect one adapter protocol shape, but neither capability is +inferred from the other or from the Agent name. An adapter may be proven to hand +a session to a native UI without being proven to join that conversation +afterwards. -`claude` is advertised for both only when the live compatibility point matches. +`claude` is advertised for both only when each capability's live profile matches. Its attachment claim was proven by `packages/acp/src/ClaudeNativeToAcp.test.md`: one native turn planted a random marker, a checked-in marker-free ACP `` recovered it under the same @@ -490,10 +530,11 @@ before a provider-returned adapter's ACP session is released, and before a client-allocated adapter allocates an identity or writes a private file. That is the failure this contract asks for rather than a hopeful spawn. -`claude` is advertised only at the applicable compatibility points. Its -client-allocated claims were proven through the production CLI against -**Claude Code 2.1.241 on macOS arm64**. `codex` is unadvertised: its command -shape and adapter contract tests exist, and nothing has run its +`claude` is advertised only when the applicable profile is observed inside the +proved **macOS arm64** host envelope. Its client-allocated protocol claims were +established through the production CLI at Claude Code 2.1.241; that version is +evidence for the profile, not its upper or lower bound. `codex` is unadvertised: +its command shape and adapter contract tests exist, and nothing has run its provider-returned claims against an installed Codex. ## Runtime sequence @@ -534,16 +575,19 @@ owner to release — what has to be settled first is which conversation this is: 9. The provider reads both durable accounts under ownership and refuses rather than converting: a session ACP already established, or a route that disagrees about the instruction layer or the launcher, ends the launch here. -10. The build is observed before an identity is made, so a build this run - cannot name ends the launch before anything durable is written. The - adapter, capability, canonical reported version and the trusted host's - operating system and architecture must then match a proved compatibility - point; a mismatch ends with `unsupported-capability`, still before an - identity or session-state mutation. The launch retains that refusal at - `prepared` without an identity, as it does every other preparation refusal. - A route that already names a different exact build ends with - `executable-binding-refused`. A legacy unbound route is the exception: it - observes nothing, resumes under the launcher name, and gains no binding. +10. The build is resolved and hashed before an identity is made, so an + executable this run cannot observe ends the launch before anything durable + is written. The exact path is asked for its read-only metadata next. The + resolved adapter protocol, requested capability, observed protocol shape + and trusted host operating system and architecture must match one admitted + profile; a mismatch ends with `unsupported-capability`, still before an + identity or session-state mutation. Reported version does not participate + in that decision. The launch retains the refusal at `prepared` without an + identity, as it does every other preparation refusal. A route that already + names a different exact digest, or cannot reproduce a version its binding + retained, ends with `executable-binding-refused`. A legacy unbound route is + the exception: it observes nothing, resumes under the launcher name, and + gains no binding. Whether an identity is needed at all is decided next. An existing compatible `client-native` route already names this conversation, so its retained identity is adopted and **nothing is allocated** — a second @@ -1171,19 +1215,21 @@ the session it named rather than meeting one. A build this run cannot show is the build behind the session fails with `executable-binding-refused`. Resolution, canonicalization, executable-file -validation, version parsing, digesting, schema recognition, equality, and a -session established before any build was recorded all end there. The diagnostic -names the stable class, the launcher, and the two canonical versions being -compared; it carries no executable path, raw version output, host error, argv, -environment, credential, instruction text or provider payload. - -A build the provider can name but whose adapter, capability, canonical version, -operating system or architecture has no admitted compatibility point fails with +validation, digesting, schema recognition, digest equality, failure to reproduce +a retained canonical version, and a session established before any build was +recorded all end there. The diagnostic names the stable class and launcher and +may name canonical versions which were actually observed; it carries no +executable path, raw metadata output, host error, argv, environment, credential, +instruction text or provider payload. + +An observed executable whose adapter protocol, requested capability, required +CLI shape, operating system or architecture has no admitted profile fails with `unsupported-capability`. On initial construction this is before identity allocation and every provider or session-state mutation; the launch's retained refusal is the only durable outcome. On a bound route it is before native resume, attachment ensure or incomplete replay performs live work. The route -remains unchanged. +remains unchanged. A changed or absent reported version alone never produces +this refusal. An attachment that reaches the provider and cannot open the conversation the route names fails with `identity-unavailable`: missing provider history, an @@ -1266,7 +1312,7 @@ means a launch under `` never reaches the host's launcher. Only the Deno and compiled hosts assemble machine-wide agent sessions: a session coordinator, a construction-route store and an executable observer, all rooted -together, plus live host platform facts and the compatibility points the +together, plus live host platform facts and the capability profiles the applicable proofs admitted. Node and Bun keep the same coarse advertised names and assemble none of those answers, so every advertised operation refuses before provider work rather than acting while a native UI may be in the @@ -1367,9 +1413,10 @@ Focused tests prove: natural key, contention refuses instead of queueing, a crashed owner leaves a recovery tombstone, and a host with no coordinator refuses before contacting an agent; -18. a build binding is read and compared exactly — a moved matching build is - accepted, a changed build is not, and an inexact record refuses rather than - being read past; +18. a build binding is read and compared conservatively — a moved matching + digest is accepted, a changed digest is not, a retained version must be + reproduced, a binding created without one compares by digest, and an + inexact record refuses rather than being read past; 19. new client-native construction observes the build before it allocates, publishes a bound V2 route, and retains a preparation that agrees with it, while a legacy V1 route resumes natively under the launcher name and gains @@ -1388,20 +1435,32 @@ Focused tests prove: created it, a cancellation waits for an ensure already in flight and closes what it answers with before acknowledging quiescence, and a close that failed releases nothing and withholds quiescence; and -23. a canonical version parse accepts exactly one matching line, and refuses - zero or several without repeating the output; and +23. a canonical version parse retains exactly one matching line as optional + evidence, while zero, several or a changed version format leaves it absent + without repeating the output or denying a valid capability shape; and 24. launches on distinct pane terminals run concurrently while launches in one pane remain exclusive, the same logical Agent session still contends across panes, pane readiness occurs only after successful native-child start, grid close awaits launch cancellation and session quiescence, and completed and partial grid replay preserve the launch's existing identity rules; and -25. compatibility admission accepts only the separately proved capability, - canonical version, operating system and architecture; an unproved point is - refused before identity allocation, route publication, private-file - creation, child start, attachment ensure or incomplete replay, while an - already-published absent identity is retained and never substituted. A - settled exact-resume refusal acknowledges quiescence after cleanup, while a - planted unproved teardown leaves the recovery tombstone active. +25. capability admission requires the stable adapter protocol, separately + requested capability, required read-only CLI shape, operating system and + architecture; neither an Agent name nor version string admits it. A newer + canonical version, omitted version and additive unrelated help are accepted + when that shape and host envelope match, while a missing or ambiguous + required member, another adapter protocol, or an unproved host is refused; +26. the metadata probe runs against the exact resolved executable and carries + no terminal, stdin, session identity, instructions, credential or provider + state. It completes before identity allocation, route publication, + private-file creation, native child start, attachment ensure and incomplete + replay; its raw output reaches no route, journal, result or diagnostic, and + completed replay runs no probe; +27. native launch and client-native attachment are admitted independently. A + launch-only observation cannot attach, an attachment-only observation + cannot launch, and a bound route whose exact identity is absent remains + authoritative and is never substituted. A settled exact-resume refusal + acknowledges quiescence after cleanup, while a planted unproved teardown + leaves the recovery tombstone active. The authored half of this is one executable Markdown document, `packages/test-agent/src/NativeSessionLaunch.test.md`, run whole. It authors the @@ -1477,8 +1536,9 @@ provider-native identity that is either asserted by the provider or allocated by the adapter before the provider exists, retained explicitly and never inferred; a strict create-once construction route beside the coordinator's own records, in a released unbound form and a bound one; the host-owned executable observer -and the build binding it produces; proof-scoped compatibility admission over -the adapter, capability, canonical version and live host platform; ACP +and the build binding it produces; proof-scoped capability admission over the +adapter protocol, independently requested capability, observed CLI shape and +live host platform; ACP attachment to a bound client-native session under its exact retained identity, through runtime partitions keyed by agent command and build; an inherited root- or pane-terminal interactive child with cancellation and @@ -1490,15 +1550,16 @@ model. The following capabilities remain outside V1 and fail closed rather than degrading: -- **Only `claude` has advertised compatibility points**, and separately for - each capability. It is client-allocated, and its applicable points are Claude - Code 2.1.241 on macOS arm64, where the claims under *Provider-native identity* - passed. A different build or platform is unadvertised until its applicable - real-CLI proof passes. `codex` has a command shape and contract tests and is - not launch-capable, because nothing has proven its provider-returned claims - against an installed Codex. A launch naming an unadvertised agent or point is - refused with `unsupported-capability` before anything of the session moves, - and so is an attachment whose own point was not proved. +- **Only the Claude client-native protocol has admitted profiles**, and + separately for each capability. Its applicable profiles recognize the + required CLI help shape on macOS arm64, where the claims under + *Provider-native identity* passed at Claude Code 2.1.241. A compatible newer + version remains admitted; a missing required shape or different platform does + not. `codex` has a command shape and contract tests and is not launch-capable, + because nothing has proven its provider-returned claims against an installed + Codex. A launch naming an unadvertised protocol or profile is refused with + `unsupported-capability` before anything of the session moves, and so is an + attachment whose own capability was not proved. - **`Agent.AddDir` is unbuilt**, so a launch declares no additional roots. The retained request says so explicitly — an empty ordered list — rather than omitting the fact, and no adapter maps a root it was never given. The ACP @@ -1610,14 +1671,16 @@ Implementation review checks these frozen invariants: module, the tmux package imports only the neutral terminal domain, and the package extraction itself changes no request, route, record, provider advertisement or diagnostic. -31. A real-CLI proof advertises only its adapter, capability, canonical version, - operating system and architecture. A client-allocated point mismatch is - refused after observation but before allocation or any provider or - session-state mutation; its identity-free launch refusal is retained. The - point is checked again before bound resume, attachment and incomplete - replay. An exact absent identity remains authoritative and unavailable; - neither that refusal nor an unproved compatibility point creates a - replacement conversation or a materialization turn. +31. A real-CLI proof admits only its adapter protocol, independently requested + capability, required observable CLI shape, operating system and + architecture. Version output is optional evidence, not an allow-list key. A + client-allocated profile mismatch is refused after read-only observation but + before allocation or any provider or session-state mutation; its + identity-free launch refusal is retained. The profile is checked again + before bound resume, attachment and incomplete replay. An exact absent + identity remains authoritative and unavailable; neither that refusal nor an + unproved profile creates a replacement conversation or a materialization + turn. Item 12 is the 2026-08-20 architecture amendment. ACPX fixes `systemPrompt` at session creation, while native turns are not authoritative in its cached From cef72fd852bf387da7f81a23513d55b1a91c98ea Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 08:57:10 -0400 Subject: [PATCH 05/22] =?UTF-8?q?=F0=9F=8F=9B=EF=B8=8F=20Admit=20a=20nativ?= =?UTF-8?q?e=20capability=20by=20protocol=20and=20profile,=20not=20by=20re?= =?UTF-8?q?lease?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An exact-version compatibility point admitted a capability from the release string an executable happened to print. That is the wrong evidence twice: a release name says nothing about which operations a build declares, and pinning one refuses every later build that declares the same shape. Admission is now four exact facts — the adapter's own versioned protocol, the capability being asked for, the profile its observer returned, and the host's platform and architecture. An Agent registry name or a launcher command admits nothing. The two Claude capabilities are read independently, so neither is ever inferred from the other having been proved. The host-owned observer is generalized: it resolves and canonicalizes the launcher, requires an executable regular file, hashes it once, and asks that same exact path whatever read-only queries the adapter declared, returning settled status and captured output. It knows no provider and parses nothing. Reading Claude's `--help` structurally — caller-supplied identity, exact resume, private-file instructions — stays in the adapter, so additive options, prose and wrapping do not change the answer. Version becomes optional evidence beside the digest rather than a gate. A build that will not name its release, names it in words this adapter does not recognize, or names several is bound by its bytes alone. Build equality is asymmetric: the digest decides, a retained release is a claim the live build must still make, and a record that named none is not rewritten when one appears. --- packages/acp/mod.ts | 10 +- packages/acp/src/native-capability.ts | 81 ++- packages/acp/src/native-launch.ts | 289 +++++++- packages/acp/src/provider.ts | 138 ++-- packages/acp/src/session-route.ts | 46 +- .../fixtures/claude-native-launch-proof.ts | 42 +- .../fixtures/claude-native-to-acp-proof.ts | 62 +- packages/acp/tests/helpers.ts | 60 +- packages/acp/tests/native-launch.test.ts | 672 ++++++++++++++---- packages/acp/tests/session-route.test.ts | 16 + packages/cli/src/agent-stack.ts | 4 +- packages/cli/src/session-coordinator.ts | 18 +- .../tests/agent-session-coordinator.test.ts | 79 +- packages/core/src/agent/launch-journal.ts | 44 +- packages/core/src/agent/launch.ts | 36 +- .../core/tests/agent-session-launch.test.ts | 43 +- packages/runtime/deno-executable-observer.ts | 78 +- packages/runtime/executable-observer.ts | 80 ++- packages/runtime/mod.ts | 3 + .../runtime/tests/executable-observer.test.ts | 145 +++- .../test-agent/src/executable-observer.ts | 39 +- packages/test-agent/src/provider.ts | 108 ++- 22 files changed, 1633 insertions(+), 460 deletions(-) diff --git a/packages/acp/mod.ts b/packages/acp/mod.ts index ede4d89d4..24dd7aea8 100644 --- a/packages/acp/mod.ts +++ b/packages/acp/mod.ts @@ -42,18 +42,20 @@ export { ADVERTISED_NATIVE_LAUNCH, knownNativeAdapters, nativeAdapterFor, - nativeCapabilityCompatibility, + nativeCapabilityPolicy, } from "./src/native-launch.ts"; export { allocatesIdentity } from "./src/native-launch.ts"; export type { ClientAllocatedAdapter, NativeAdapter, NativeBinding, + NativeCapabilityProbe, + ProbedNativeCapabilities, ProviderReturnedAdapter, } from "./src/native-launch.ts"; /** - * Which exact builds and machines a native capability is admitted on. + * Which protocol shapes and machines a native capability is admitted on. * * Public because the host is what states them: it supplies its own OS and * architecture beside the coordinator and the observer, and shared provider @@ -62,9 +64,9 @@ export type { export { admitsNativeCapability } from "./src/native-capability.ts"; export type { NativeCapability, - NativeCapabilityCompatibility, - NativeCapabilityCompatibilityPoint, + NativeCapabilityAdmission, NativeCapabilityHost, + NativeCapabilityPolicy, ObservedNativeCapability, ProvedNativeCapability, } from "./src/native-capability.ts"; diff --git a/packages/acp/src/native-capability.ts b/packages/acp/src/native-capability.ts index b1b9212bc..b2c7332ef 100644 --- a/packages/acp/src/native-capability.ts +++ b/packages/acp/src/native-capability.ts @@ -2,24 +2,25 @@ * What one real-CLI proof admits, and nothing wider (specs/decisions.md * DEC-017). * - * An adapter name says which command shape to consider. It does not say that - * the thing found under that name works: a proof ran against one build of one - * CLI, on one operating system, on one architecture, and every other build and - * machine is a claim nobody made. So admission is stated as points, and a point - * is the whole tuple — + * An Agent name says which command shape to consider. It does not say that the + * thing found under that name works: a proof ran against one implementation's + * protocol, on one operating system, on one architecture, and every other + * protocol and machine is a claim nobody made. So admission is stated as + * profiles, and a profile is the whole tuple — * - * adapter + capability + canonical reported version + host OS + host + * adapter protocol + capability + observed probe profile + host OS + host * architecture * - * — matched exactly. Nothing here parses, orders or ranges a version. A - * semver-shaped line is a value one adapter recognized, not evidence about the - * build behind it, and comparing two of them would turn one installed CLI into - * a statement about releases that have never run. + * — matched exactly. Nothing here is a version. A version says which release + * was installed, not what it can do, and admitting one would make a routine + * upgrade disable every new session while telling nobody why. What is matched + * instead is the adapter's stable protocol identifier and what its own + * side-effect-free probe recognized in the exact executable that was hashed. * * The host pair is supplied rather than read. Which machine this is is a fact * the trusted host has and shared provider code must not go looking for: a - * provider that detected its own runtime would answer the compatibility - * question with the thing being asked about. + * provider that detected its own runtime would answer the admission question + * with the thing being asked about. */ /** @@ -37,55 +38,61 @@ export interface NativeCapabilityHost { readonly architecture: string; } -/** One capability, proved for one exact build on one exact machine. */ -export interface NativeCapabilityCompatibilityPoint { - readonly agent: string; +/** One capability, proved for one protocol shape on one exact machine. */ +export interface NativeCapabilityAdmission { + /** + * The adapter implementation whose protocol was proved. + * + * Deliberately not the Agent registry name and not the launcher command: + * either can be pointed at something else, and neither says which protocol + * the thing behind it speaks. + */ + readonly adapterProtocol: string; readonly capability: NativeCapability; - /** The canonical line the adapter recognized, whole. Never a number alone. */ - readonly reportedVersion: string; + /** The probe whose recognized shape this admission was proved against. */ + readonly probeProfile: string; readonly platform: string; readonly architecture: string; } /** Everything a host admits, beside the machine it admits it on. */ -export interface NativeCapabilityCompatibility { +export interface NativeCapabilityPolicy { readonly host: NativeCapabilityHost; - readonly points: readonly NativeCapabilityCompatibilityPoint[]; + readonly admissions: readonly NativeCapabilityAdmission[]; } /** What an adapter carries about its own proofs, before a host names a machine. */ -export type ProvedNativeCapability = Omit; +export type ProvedNativeCapability = Omit; /** The live capability an observation offers for admission. */ export interface ObservedNativeCapability { - readonly agent: string; + readonly adapterProtocol: string; readonly capability: NativeCapability; - readonly reportedVersion: string; + readonly probeProfile: string; } /** * Whether this host admits what was actually observed. * - * The host's own OS and architecture are what an admitted point is compared - * against, so a point proved elsewhere cannot admit anything here. Absent - * compatibility admits nothing: a host that states no proof has none, and - * treating silence as permission is the failure this whole tuple exists to - * prevent. + * The host's own OS and architecture are what an admitted profile is compared + * against, so a profile proved elsewhere cannot admit anything here. An absent + * policy admits nothing: a host that states no proof has none, and treating + * silence as permission is the failure this whole tuple exists to prevent. */ export function admitsNativeCapability( - compatibility: NativeCapabilityCompatibility | undefined, + policy: NativeCapabilityPolicy | undefined, observed: ObservedNativeCapability, ): boolean { - if (compatibility === undefined) { + if (policy === undefined) { return false; } - const { platform, architecture } = compatibility.host; - return compatibility.points.some( - (point) => - point.agent === observed.agent && - point.capability === observed.capability && - point.reportedVersion === observed.reportedVersion && - point.platform === platform && - point.architecture === architecture, + const { platform, architecture } = policy.host; + return policy.admissions.some( + (admission) => + admission.adapterProtocol === observed.adapterProtocol && + admission.capability === observed.capability && + admission.probeProfile === observed.probeProfile && + admission.platform === platform && + admission.architecture === architecture, ); } diff --git a/packages/acp/src/native-launch.ts b/packages/acp/src/native-launch.ts index c25ce16a1..ca4f54b12 100644 --- a/packages/acp/src/native-launch.ts +++ b/packages/acp/src/native-launch.ts @@ -32,34 +32,70 @@ import { randomUUID } from "node:crypto"; import type { IdentityProvenance } from "@executablemd/core"; +import type { ExecutableMetadata, ExecutableMetadataQuery } from "@executablemd/runtime"; import type { - NativeCapabilityCompatibility, + NativeCapability, NativeCapabilityHost, + NativeCapabilityPolicy, ProvedNativeCapability, } from "./native-capability.ts"; +/** + * What one adapter's probe recognized in the executable that was just hashed. + * + * The profile travels with the answer rather than being assumed by the caller, + * because it is what an admission is matched against: a probe that recognized a + * different shape than the one a proof ran on must not be read as the proved + * one. An empty capability list is the ordinary answer for an executable this + * probe does not recognize — the shape was looked for and was not there. + */ +export interface ProbedNativeCapabilities { + readonly probeProfile: string; + readonly capabilities: readonly NativeCapability[]; +} + +/** + * An adapter's own reading of its executable's read-only declarations. + * + * Adapter-owned because only the adapter knows which declarations it consumes. + * Structural rather than a snapshot: it inspects the shapes this adapter's argv + * depends on, so added options, unrelated prose and rewrapped lines mean + * nothing and a missing or renamed one means everything. + */ +export type NativeCapabilityProbe = (metadata: ExecutableMetadata) => ProbedNativeCapabilities; + /** * What an adapter knows about the build behind its executable. * * A session whose identity XMD chose only means something while the build that * accepted it can be recognized later: two builds of one provider accept the * same identity and disagree silently about what it names. Everything here is - * that adapter's private dialect — which command to observe, what its version - * output looks like, and what the ACP adapter child needs in order to run the - * same build. None of it reaches a document. + * that adapter's private dialect — which command to observe, which read-only + * questions to ask it, how to read the answers, and what the ACP adapter child + * needs in order to run the same build. None of it reaches a document. */ export interface NativeBinding { /** The command whose build is observed, bound and retained. */ command: string; - /** The arguments that ask that exact file its version. */ - versionArgs?: readonly string[]; /** - * The canonical version from that output, or `undefined` when the output is - * not something this adapter recognizes. An unrecognized version is a - * refusal, not a value to retain — a build XMD cannot name is one it cannot - * later confirm. + * The read-only questions one observation asks that exact file. + * + * Declared here so the host's observer runs argv it was handed rather than + * argv it invented. A question that did anything but report would be a side + * effect on a session nobody has decided to act on yet. + */ + metadata: readonly ExecutableMetadataQuery[]; + /** What those answers say this build can do. */ + probe: NativeCapabilityProbe; + /** + * The canonical version those answers report, or `undefined` when they report + * none this adapter recognizes. + * + * Optional evidence, deliberately: a version says which release is installed, + * not what it can do. A build that will not say, says something unexpected, or + * says several things is bound by its digest alone rather than refused. */ - version(output: string): string | undefined; + reportedVersion(metadata: ExecutableMetadata): string | undefined; /** * The exact ACP adapter command this binding was proven against, when the * proof is tied to one. @@ -87,21 +123,171 @@ export interface NativeBinding { environment(livePath: string): Record; } +/** + * The output one query produced, or nothing when it did not answer. + * + * A query that failed to start, failed to settle, or settled nonzero reported + * nothing about the shape it was asked about. Reading its output anyway would + * let a crashing executable look like one missing an option. + */ +function answered(metadata: ExecutableMetadata, name: string): string | undefined { + const observation = metadata[name]; + if (observation === undefined || !observation.settled || observation.code !== 0) { + return undefined; + } + return observation.stdout; +} + +/** + * The option declarations in a help surface, one entry per option, rewrapped. + * + * Claude's help is Commander's: an option entry begins at exactly two spaces + * and a dash, and its description wraps onto more deeply indented lines. Those + * continuations are rejoined so a declaration that happened to wrap reads the + * same as one that did not — which is the difference between a structural + * reading and a snapshot of one terminal width. + * + * Everything else is dropped, and that is the point: `Arguments:`, `Commands:` + * and free prose are not declarations. An option named inside another option's + * description is a mention, not a thing this executable accepts. + */ +function optionEntries(help: string): string[] { + const entries: string[] = []; + let open = false; + for (const line of help.split("\n")) { + if (/^ {2}-/.test(line)) { + entries.push(line.trim()); + open = true; + continue; + } + if (open && /^ {3,}\S/.test(line)) { + entries[entries.length - 1] += ` ${line.trim()}`; + continue; + } + open = false; + } + return entries; +} + +/** What one option entry declares: its spellings, and whether it takes a value. */ +function declaredFlags(entry: string): { flags: string[]; takesValue: boolean } { + const flags: string[] = []; + for (const raw of entry.split(" ")) { + const token = raw.endsWith(",") ? raw.slice(0, -1) : raw; + if (/^-{1,2}[A-Za-z0-9][\w-]*$/.test(token)) { + flags.push(token); + continue; + } + // The head of an entry is its spellings and at most one value placeholder. + // Anything else has begun the description, and a description is prose. + return { flags, takesValue: token.startsWith("<") || token.startsWith("[") }; + } + return { flags, takesValue: false }; +} + +/** + * How this executable declares one option, if it declares it at all. + * + * `ambiguous` is separate from `absent` on purpose. Two entries declaring one + * spelling is output this adapter cannot read as a single answer, and choosing + * either would be guessing which one a launch would reach. + */ +function declaresOption( + entries: string[], + flag: string, +): "absent" | "ambiguous" | "flag" | "valued" { + const matched = entries + .map(declaredFlags) + .filter((declaration) => declaration.flags.includes(flag)); + if (matched.length === 0) { + return "absent"; + } + if (matched.length > 1) { + return "ambiguous"; + } + return matched[0].takesValue ? "valued" : "flag"; +} + +/** Whitespace-insensitive text, for reading prose rather than layout. */ +function normalized(text: string): string { + return text.replace(/\s+/g, " "); +} + +/** + * The ACP adapter Claude's attachment proof ran against. + * + * Named once and used twice — pinned for the child that actually runs, and an + * input to the probe that decides whether attachment was observed — so the + * capability cannot be answered for a bridge nobody proved. + */ +const CLAUDE_ACP_BRIDGE = "npx -y @agentclientprotocol/claude-agent-acp@0.70.0"; + +/** The probe whose recognized shape Claude's admissions were proved against. */ +const CLAUDE_PROBE_PROFILE = "claude-help-native-session.v1"; + +/** + * What Claude's own help declares about the operations a launch needs. + * + * Structural rather than a version comparison: what matters is whether this + * build accepts a caller-chosen session identity, resumes one exactly, and + * takes its private instruction layer as a file. A release that adds options, + * rewords prose or rewraps lines still declares those, and a release that + * stopped declaring one cannot launch whatever it calls itself. + * + * The private-file member has two accepted spellings because Claude declares + * the family rather than the member: `--system-prompt-file` appears as the + * documented `--system-prompt[-file]` spelling in builds that do not give it + * its own entry. Both are the same declaration, and neither is inferred from + * the other's absence. + * + * The two capabilities are read independently from what is present, never one + * from the other: attachment additionally needs the bridge this adapter pins, + * which is knowledge about the ACP child rather than about the CLI. + */ +function claudeNativeProbe(pinnedBridge: string | undefined): NativeCapabilityProbe { + return (metadata) => { + const capabilities: NativeCapability[] = []; + const help = answered(metadata, "help"); + if (help === undefined) { + return { probeProfile: CLAUDE_PROBE_PROFILE, capabilities }; + } + const text = normalized(help); + const entries = optionEntries(help); + const product = text.includes("Claude Code") && /(^| )Usage: claude( |$)/.test(text); + const identity = declaresOption(entries, "--session-id") === "valued"; + const resume = declaresOption(entries, "--resume") === "valued"; + const privateFile = + declaresOption(entries, "--system-prompt-file") === "valued" || + text.includes("--system-prompt[-file]"); + + if (product && identity && resume && privateFile) { + capabilities.push("native-launch"); + } + if (product && resume && pinnedBridge === CLAUDE_ACP_BRIDGE) { + capabilities.push("client-native-attachment"); + } + return { probeProfile: CLAUDE_PROBE_PROFILE, capabilities }; + }; +} + /** * Claude reports `2.1.241 (Claude Code)`. * * The whole line is retained rather than the number alone, because the number * alone is not a build: the same version string from a different product would - * compare equal. Anything that does not look like this is unrecognized, and an - * unrecognized build is refused rather than retained under a guess. + * compare equal. * - * Exactly one line may match. Zero is an output this adapter does not - * recognize; two or more is output it cannot read as one answer, and taking - * the first would be picking a build out of a list of them. Neither is - * repeated anywhere — the caller refuses with a stable class, and the output - * itself is provider-private. + * Exactly one line may match. Zero is output this adapter does not recognize; + * two or more is output it cannot read as one answer, and taking the first + * would be picking a build out of a list of them. Both mean no version was + * reported, which is an ordinary answer rather than a refusal — the digest is + * what binds the build. */ -function claudeVersion(output: string): string | undefined { +function claudeReportedVersion(metadata: ExecutableMetadata): string | undefined { + const output = answered(metadata, "version"); + if (output === undefined) { + return undefined; + } const canonical = output .split("\n") .map((line) => line.trim()) @@ -112,6 +298,15 @@ function claudeVersion(output: string): string | undefined { interface AdapterCommands { /** Stable adapter identity — `claude`, `codex`. Never an executable path. */ launcher: string; + /** + * The protocol this adapter speaks, as an admission names it. + * + * Distinct from both the Agent registry name and the launcher command, and + * versioned, because either of those can be pointed at something else while + * neither says what the thing behind it speaks. Changing what this adapter + * does to a session is a new protocol identifier, not an edit to this one. + */ + protocol: string; /** Who chooses this adapter's native session identity. */ identity: IdentityProvenance; /** The argv that resumes this exact provider-native session. */ @@ -168,16 +363,20 @@ export function allocatesIdentity(adapter: NativeAdapter): adapter is ClientAllo } /** - * The one build and machine Claude's proofs ran on. + * The one shape and machine Claude's proofs ran on. * * Written once and shared by both points below so they cannot drift apart into - * two claims about two builds. Raising either is a new proof rather than an + * two claims about two things. Raising either is a new proof rather than an * edit here: what makes this admissible is that a real CLI was driven through * the whole applicable contract on exactly this, and nothing about that - * generalizes to the next release or the next machine. + * generalizes to another machine or another way of recognizing the shape. + * + * No version appears. A version says which release was installed, not what it + * can do, and admitting one would disable every new session on a routine + * upgrade while telling nobody why. */ const CLAUDE_PROVED_BUILD = { - reportedVersion: "2.1.241 (Claude Code)", + probeProfile: CLAUDE_PROBE_PROFILE, platform: "darwin", architecture: "arm64", } as const; @@ -185,6 +384,7 @@ const CLAUDE_PROVED_BUILD = { const ADAPTERS: Readonly> = { claude: { launcher: "claude", + protocol: "claude-client-native.v1", // XMD names the session before Claude exists, so the native process is // told which conversation to make rather than reporting one afterwards. identity: "client-allocated", @@ -200,10 +400,17 @@ const ADAPTERS: Readonly> = { ], binding: { command: "claude", - version: claudeVersion, - // The version #561's gates are proven against. Raising it is a new proof, - // not a version bump. - adapterCommand: "npx -y @agentclientprotocol/claude-agent-acp@0.70.0", + // Read-only by construction: both report and exit, and neither carries a + // session, an instruction, or anything else a launch would act on. + metadata: [ + { name: "help", args: ["--help"] }, + { name: "version", args: ["--version"] }, + ], + probe: claudeNativeProbe(CLAUDE_ACP_BRIDGE), + reportedVersion: claudeReportedVersion, + // The bridge #561's attachment gate is proven against. Raising it is a + // new proof, not a version bump. + adapterCommand: CLAUDE_ACP_BRIDGE, // The first thing the Claude ACP adapter consults when deciding which // Claude to run. Without it the adapter resolves the build shipped with // the Agent SDK it pins, which is not the build that created the session. @@ -220,6 +427,7 @@ const ADAPTERS: Readonly> = { }, codex: { launcher: "codex", + protocol: "codex-provider-returned.v1", identity: "provider-returned", resume: (nativeSessionId) => ["codex", "resume", nativeSessionId], }, @@ -230,8 +438,9 @@ const ADAPTERS: Readonly> = { * * A coarse selection and nothing more. For an adapter that names its own * sessions the name authorizes no work by itself: what admits one is the - * compatibility point below, matched against the build actually found and the - * machine actually running. A name reaches the question; it does not answer it. + * admission below, matched against the protocol resolved, the shape its own + * probe recognized in the executable actually found, and the machine actually + * running. A name reaches the question; it does not answer it. * * `codex` is absent. Its command shape is known and its adapter contract tests * pass, and neither is the proof: nothing has run it against an installed @@ -253,19 +462,21 @@ export const ADVERTISED_CLIENT_NATIVE_ATTACHMENT: readonly string[] = ["claude"] /** * What this build's adapters have proved, on the machine a host says it is. * - * The two halves come from where each is known. Which builds were driven - * through a real CLI is the adapters' own evidence and is compiled in beside - * them; which OS and architecture are underneath right now is the host's, and - * arrives here rather than being detected. Neither half admits anything alone — - * a point is only admitted where a proof and the machine it ran on meet. + * The two halves come from where each is known. Which protocols and shapes were + * driven through a real CLI is the adapters' own evidence and is compiled in + * beside them; which OS and architecture are underneath right now is the host's, + * and arrives here rather than being detected. Neither half admits anything + * alone — an admission stands only where a proof and the machine it ran on meet. + * + * Keyed by each adapter's protocol rather than by the Agent name it happens to + * be registered under, because the Agent name is what a document can point + * somewhere else. */ -export function nativeCapabilityCompatibility( - host: NativeCapabilityHost, -): NativeCapabilityCompatibility { +export function nativeCapabilityPolicy(host: NativeCapabilityHost): NativeCapabilityPolicy { return { host, - points: Object.entries(ADAPTERS).flatMap(([agent, adapter]) => - (adapter.proved ?? []).map((proved) => ({ agent, ...proved })), + admissions: Object.values(ADAPTERS).flatMap((adapter) => + (adapter.proved ?? []).map((proved) => ({ adapterProtocol: adapter.protocol, ...proved })), ), }; } diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index 257f2a535..717f96ad6 100644 --- a/packages/acp/src/provider.ts +++ b/packages/acp/src/provider.ts @@ -105,7 +105,7 @@ import { } from "./native-launch.ts"; import type { NativeAdapter } from "./native-launch.ts"; import { admitsNativeCapability } from "./native-capability.ts"; -import type { NativeCapability, NativeCapabilityCompatibility } from "./native-capability.ts"; +import type { NativeCapability, NativeCapabilityPolicy } from "./native-capability.ts"; /** * One MCP server as ACPX configures them. @@ -244,19 +244,19 @@ export interface AcpxProviderDependencies { */ executableObserver?: ExecutableObserver; /** - * Which exact builds this host has proved each native capability on, and the - * machine it proved them for. + * Which protocol shapes this host has proved each native capability on, and + * the machine it proved them for. * * Supplied by the trusted host beside the coordinator and the observer, and * for the same reason: it carries this machine's OS and architecture, and * shared provider code that went and read those would be answering the - * compatibility question with the thing being asked about. + * admission question with the thing being asked about. * * The advertised sets above choose which adapter to consider. This is what - * says the build actually found under it may be acted on. Absent admits + * says the executable actually found under it may be acted on. Absent admits * nothing — a host that states no proof has none. */ - compatibility?: NativeCapabilityCompatibility; + nativeCapabilityPolicy?: NativeCapabilityPolicy; /** * Extra native adapters, by agent name. A harness driving an agent this * package has never heard of supplies its own resume command shape here @@ -347,6 +347,17 @@ interface BoundBuild { environment: Record; /** The exact ACP adapter command this binding was proven against, if pinned. */ adapterCommand: string | undefined; + /** The protocol the adapter this build was reached through speaks. */ + adapterProtocol: string; + /** + * What this executable's own probe recognized, and which probe recognized it. + * + * Carried rather than recomputed, so admission is matched against the answer + * an observation actually produced instead of asking the question a second + * time and hoping for the same one. + */ + probeProfile: string; + capabilities: readonly NativeCapability[]; } /** @@ -833,7 +844,7 @@ function* useAcpxProviderState( const coordinator = dependencies?.coordinator; const routeStore = dependencies?.routeStore; const executableObserver = dependencies?.executableObserver; - const compatibility = dependencies?.compatibility; + const nativeCapabilityPolicy = dependencies?.nativeCapabilityPolicy; const agentCwd = dependencies?.agentCwd ?? cwd; const prepareAgent = dependencies?.prepareAgent; const mcpServers = dependencies?.mcpServers; @@ -899,11 +910,17 @@ function* useAcpxProviderState( return options; } - /** The `(agent command, build)` partition a bound runtime is kept under. */ + /** + * The `(agent command, build)` partition a bound runtime is kept under. + * + * The digest is what separates two builds, so a build that reported no + * version still shares no partition with a different one — the version is + * kept beside it only so a partition names what a record names. + */ function partitionOf(build: BoundBuild): string { return [ build.agentCommand, - build.binding.reportedVersion, + build.binding.reportedVersion ?? "", build.binding.executableDigest.algorithm, build.binding.executableDigest.value, ].join("\u0000"); @@ -1677,16 +1694,24 @@ function* useAcpxProviderState( * would give a conversation that already exists a second identity. */ /** - * The exact live path of the build behind an adapter's command. + * The exact live path of the build behind an adapter's command, and what its + * own probe recognized in it. + * + * One observation: the launcher is resolved and canonicalized, required to be + * an executable regular file, hashed once, and asked the adapter's read-only + * questions at that same exact path — so the shape recognized and the bytes + * bound are the same file rather than two resolutions of one name. * * Every way this can fail — no observer, resolution, canonicalization, an - * unreadable or non-executable file, a version this adapter does not - * recognize — ends in one stable class, because they are all the same - * question: is this the build that established the session. + * unreadable or non-executable file — ends in one stable class, because they + * are all the same question: is this the build that established the session. + * A version this adapter does not recognize is not among them; that is + * optional evidence, and its absence leaves a build bound by its digest. */ function* observeBuild( agentName: string, agentCommand: string, + adapterProtocol: string, binding: NativeBinding, ): Operation { if (!executableObserver) { @@ -1700,7 +1725,7 @@ function* useAcpxProviderState( let observed; try { observed = yield* executableObserver.observe(binding.command, { - ...(binding.versionArgs === undefined ? {} : { versionArgs: binding.versionArgs }), + metadata: binding.metadata, }); } catch (error) { // Only the observer's own stable reason crosses. Its message names a @@ -1712,28 +1737,24 @@ function* useAcpxProviderState( }`, }); } - const version = binding.version(observed.versionOutput); - if (version === undefined) { - // Deliberately without the raw output: it is provider-private, and this - // message is retained in a diagnostic. - throw new AttachmentRefused({ - class: "executable-binding-refused", - message: - `"${agentName}" reported a version this adapter does not recognize, so the build ` + - `behind it cannot be named`, - }); - } + const probed = binding.probe(observed.metadata); + const version = binding.reportedVersion(observed.metadata); return { agentName, agentCommand, livePath: observed.path, binding: { schema: "executable-build.v1", - reportedVersion: version, + // Present only when this build said something the adapter recognized. + // A quiet build is bound by its bytes, which is what binds either way. + ...(version === undefined ? {} : { reportedVersion: version }), executableDigest: observed.digest, }, environment: binding.environment(observed.path), adapterCommand: binding.adapterCommand, + adapterProtocol, + probeProfile: probed.probeProfile, + capabilities: probed.capabilities, }; } @@ -1757,31 +1778,51 @@ function* useAcpxProviderState( capability: NativeCapability, build: BoundBuild, ): LaunchFailure | undefined { - const { reportedVersion } = build.binding; - if (admitsNativeCapability(compatibility, { agent: agentName, capability, reportedVersion })) { + // Two questions, and both must answer. The executable has to declare the + // shape this capability needs, and this host has to have proved that + // protocol, that recognized shape, and this machine. Either alone would + // admit something nobody ran: a declaration is not a proof, and a proof + // elsewhere is not this executable. + const observed = + build.capabilities.includes(capability) && + admitsNativeCapability(nativeCapabilityPolicy, { + adapterProtocol: build.adapterProtocol, + capability, + probeProfile: build.probeProfile, + }); + if (observed) { return undefined; } return { class: "unsupported-capability", message: - `this host has proved no ${capability} capability for "${agentName}" at ` + - `${reportedVersion} on the machine it is running on, so it will not act on a session ` + - `with it. An advertised adapter name selects a command shape; only a proof against ` + - `that exact installed build admits one.`, + `this host has proved no ${capability} capability for the ${build.adapterProtocol} ` + + `protocol behind "${agentName}" in the shape observed on the machine it is running ` + + `on, so it will not act on a session with it. An advertised adapter name selects a ` + + `command shape; only a proof against that exact installed executable admits one.`, }; } - /** The stable comparison two builds of one session fail. */ + /** + * The stable comparison two builds of one session fail. + * + * Canonical versions appear when they exist because they are the readable + * half of the answer, and a build that reported none says so rather than + * substituting its digest — a digest is host-observable evidence and belongs + * in no message. + */ function buildDrift( sessionKey: string, retained: ExecutableBuildBindingV1, live: ExecutableBuildBindingV1, ): LaunchFailure { + const named = (binding: ExecutableBuildBindingV1) => + binding.reportedVersion ?? "a build reporting no version this adapter recognizes"; return { class: "executable-binding-refused", message: - `session "${sessionKey}" was created by ${retained.reportedVersion} and this run ` + - `would use ${live.reportedVersion}, so the conversation it names cannot be confirmed`, + `session "${sessionKey}" was created by ${named(retained)} and this run ` + + `would use ${named(live)}, so the conversation it names cannot be confirmed`, }; } @@ -1866,8 +1907,13 @@ function* useAcpxProviderState( `.`, }); } - const binding = (adapterFor(agentName) as ClientAllocatedAdapter).binding; - const build = yield* observeBuild(agentName, agentCommand, binding); + const attaching = adapterFor(agentName) as ClientAllocatedAdapter; + const build = yield* observeBuild( + agentName, + agentCommand, + attaching.protocol, + attaching.binding, + ); // Before the comparison and long before the ensure. A build this host has // not proved attachment on is refused whether or not it happens to be the // build that created the session — being the right one is not evidence that @@ -1876,7 +1922,7 @@ function* useAcpxProviderState( if (unproved) { throw new AttachmentRefused(unproved); } - if (!sameExecutableBuild(build.binding, route.executableBinding)) { + if (!sameExecutableBuild(route.executableBinding, build.binding)) { throw new AttachmentRefused( buildDrift(prepared.sessionKey, route.executableBinding, build.binding), ); @@ -2387,7 +2433,7 @@ function* useAcpxProviderState( // the launch before anything durable is written. let build: BoundBuild; try { - build = yield* observeBuild(agentName, agentCommand, adapter.binding); + build = yield* observeBuild(agentName, agentCommand, adapter.protocol, adapter.binding); } catch (error) { if (error instanceof AttachmentRefused) { return refusal(error.failure.class, error.failure.message, known); @@ -2404,7 +2450,7 @@ function* useAcpxProviderState( } if ( route?.route === "client-native" && - !sameExecutableBuild(build.binding, route.executableBinding) + !sameExecutableBuild(route.executableBinding, build.binding) ) { const drift = buildDrift(sessionKey, route.executableBinding, build.binding); return refusal(drift.class, drift.message, known); @@ -3023,7 +3069,13 @@ function* useAcpxProviderState( `accepted its identity, so this run cannot confirm the conversation it names`, }; } - if (!sameExecutableBuild(route.executableBinding, prepared.executableBinding)) { + // Both directions, because neither of these is the live build: they are two + // durable accounts of one observation, so one saying less than the other is + // already a disagreement rather than a build that has gone quiet. + if ( + !sameExecutableBuild(route.executableBinding, prepared.executableBinding) || + !sameExecutableBuild(prepared.executableBinding, route.executableBinding) + ) { return stop( `session "${prepared.sessionKey}" is described differently by its journal and its ` + `construction route, and neither account repairs the other`, @@ -3041,7 +3093,7 @@ function* useAcpxProviderState( } let build: BoundBuild; try { - build = yield* observeBuild(prepared.agent, agentCommand, adapter.binding); + build = yield* observeBuild(prepared.agent, agentCommand, adapter.protocol, adapter.binding); } catch (error) { if (error instanceof AttachmentRefused) { return error.failure; @@ -3056,7 +3108,7 @@ function* useAcpxProviderState( if (unproved) { return unproved; } - if (!sameExecutableBuild(build.binding, route.executableBinding)) { + if (!sameExecutableBuild(route.executableBinding, build.binding)) { return buildDrift(prepared.sessionKey, route.executableBinding, build.binding); } invocation.bound.set(prepared.sessionKey, { build, adapter }); diff --git a/packages/acp/src/session-route.ts b/packages/acp/src/session-route.ts index 128d0b27a..d20cfa06e 100644 --- a/packages/acp/src/session-route.ts +++ b/packages/acp/src/session-route.ts @@ -105,7 +105,17 @@ const CLIENT_NATIVE_MEMBERS = [ "launcher", ]; const BOUND_CLIENT_NATIVE_MEMBERS = [...CLIENT_NATIVE_MEMBERS, "executableBinding"]; -const BINDING_MEMBERS = ["schema", "reportedVersion", "executableDigest"]; +const BINDING_MEMBERS = ["schema", "executableDigest"]; +/** + * The one member a binding may omit. + * + * Separate from the required set rather than merged into it, because a binding + * is compared for equality: an unexpected member is still a fact the writer + * thought was part of the build's identity, and reading past it would call two + * different builds the same one. Omitting this one is not an unexpected member + * — it is the record saying the build reported no version it recognized. + */ +const BINDING_OPTIONAL_MEMBERS = ["reportedVersion"]; const DIGEST_MEMBERS = ["algorithm", "value"]; function isRecord(value: unknown): value is Record { @@ -117,6 +127,18 @@ function exactly(value: Record, members: readonly string[]): bo return keys.length === members.length && keys.every((key) => members.includes(key)); } +function declared( + value: Record, + required: readonly string[], + optional: readonly string[], +): boolean { + const keys = Object.keys(value); + return ( + required.every((member) => keys.includes(member)) && + keys.every((key) => required.includes(key) || optional.includes(key)) + ); +} + /** * Read a route strictly. * @@ -129,21 +151,23 @@ function exactly(value: Record, members: readonly string[]): bo /** * Read a retained build binding strictly. * - * The member set is exact rather than minimal, because a binding is compared - * for equality: a member this build ignores is a fact the writer thought was - * part of the build's identity, and comparing without it would call two - * different builds the same one. + * The digest is required and exact — it is what binds a session to a build. + * A present `reportedVersion` must still be a real one: a member written as an + * empty string is a claim about a release nobody can reproduce, which is not + * the same as having made no claim. */ function parseExecutableBinding(value: unknown): ExecutableBuildBindingV1 | undefined { - if (!isRecord(value) || !exactly(value, BINDING_MEMBERS)) { + if (!isRecord(value) || !declared(value, BINDING_MEMBERS, BINDING_OPTIONAL_MEMBERS)) { return undefined; } const { schema, reportedVersion, executableDigest } = value; if (schema !== "executable-build.v1") { return undefined; } - if (typeof reportedVersion !== "string" || reportedVersion.length === 0) { - return undefined; + if (reportedVersion !== undefined) { + if (typeof reportedVersion !== "string" || reportedVersion.length === 0) { + return undefined; + } } if (!isRecord(executableDigest) || !exactly(executableDigest, DIGEST_MEMBERS)) { return undefined; @@ -154,7 +178,7 @@ function parseExecutableBinding(value: unknown): ExecutableBuildBindingV1 | unde } return { schema: "executable-build.v1", - reportedVersion, + ...(reportedVersion === undefined ? {} : { reportedVersion }), executableDigest: { algorithm: "sha256", value: digest }, }; } @@ -250,7 +274,9 @@ export function serializeAgentSessionRoute(route: AgentSessionRoute): string { if (route.schema === "session-route.v2") { payload.executableBinding = { schema: route.executableBinding.schema, - reportedVersion: route.executableBinding.reportedVersion, + ...(route.executableBinding.reportedVersion === undefined + ? {} + : { reportedVersion: route.executableBinding.reportedVersion }), executableDigest: { algorithm: route.executableBinding.executableDigest.algorithm, value: route.executableBinding.executableDigest.value, diff --git a/packages/acp/tests/fixtures/claude-native-launch-proof.ts b/packages/acp/tests/fixtures/claude-native-launch-proof.ts index 717991d94..071e03e78 100644 --- a/packages/acp/tests/fixtures/claude-native-launch-proof.ts +++ b/packages/acp/tests/fixtures/claude-native-launch-proof.ts @@ -46,8 +46,12 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import process from "node:process"; import { createAgentRegistry } from "../../src/acpx-runtime.ts"; -import { agentSessionKeyDigest } from "@executablemd/runtime"; -import { ADVERTISED_NATIVE_LAUNCH } from "../../src/native-launch.ts"; +import { agentSessionKeyDigest, createDenoExecutableObserver } from "@executablemd/runtime"; +import { + ADVERTISED_NATIVE_LAUNCH, + allocatesIdentity, + nativeAdapterFor, +} from "../../src/native-launch.ts"; /** Opting in at all. Absent, every mode refuses before a provider child. */ const PROOF_ENV = "XMD_CLAUDE_NATIVE_PROOF"; @@ -58,8 +62,26 @@ const AUTHORIZED_TURNS = "2"; /** The production target, exactly as an operator would type it. */ const TARGET = "AGENTS.md#Implementor"; -/** The compatibility point these journeys are only meaningful against. */ -const REQUIRED_CLAUDE_VERSION = "2.1.241 (Claude Code)"; +/** + * What the installed build has to declare for these journeys to mean anything. + * + * The shape, not a release. What is proved here is that a build declaring + * caller-supplied identity, exact resume and private-file instructions can be + * handed a session — so the journeys run against any build that declares it, + * and refuse to draw conclusions from one that does not. + */ +const REQUIRED_CAPABILITY = "native-launch"; + +/** Whether the installed build declares the shape, read by the shipped adapter. */ +function* declaresRequiredShape(): Operation { + const observer = createDenoExecutableObserver(); + const adapter = nativeAdapterFor("claude"); + if (!observer || !adapter || !allocatesIdentity(adapter)) { + return false; + } + const found = yield* observer.observe("claude", { metadata: adapter.binding.metadata }); + return adapter.binding.probe(found.metadata).capabilities.includes(REQUIRED_CAPABILITY); +} /** * The role contract's opening sentence. @@ -1502,9 +1524,9 @@ function* ready(journey: Journey, verdict: JourneyVerdict): Operation { return false; } verdict.claudeVersion = yield* claudeVersion(journey); - if (verdict.claudeVersion !== REQUIRED_CLAUDE_VERSION) { + if (!(yield* declaresRequiredShape())) { verdict.verdict = "ENVIRONMENT_BLOCKED"; - verdict.detail = `this journey is only meaningful against ${REQUIRED_CLAUDE_VERSION}`; + verdict.detail = `the installed build does not declare ${REQUIRED_CAPABILITY}`; return false; } if (!verdict.projectCopyVerified) { @@ -1830,8 +1852,10 @@ function* runPreflight(): Operation { verdict.ptyUsable = pty.stdout.includes("PTY"); verdict.binaryBuilt = yield* exists(XMD_BINARY); + // No release is named here. The three flag readings below are the shape this + // preflight exists to establish, and a build that declares them is one these + // journeys can be run against whatever release it reports. const established = - verdict.claudeVersion === REQUIRED_CLAUDE_VERSION && verdict.platform === "darwin" && verdict.architecture === "arm64" && !verdict.claudeConfigDirSet && @@ -1846,8 +1870,8 @@ function* runPreflight(): Operation { verdict.advertised[0] === "claude"; verdict.verdict = established ? "PASS" : "ENVIRONMENT_BLOCKED"; verdict.detail = established - ? "the frozen compatibility point is established, and no model turn was spent" - : "the frozen compatibility point is not established"; + ? "the required capability shape and host are established, and no model turn was spent" + : "the required capability shape and host are not established"; return verdict; } diff --git a/packages/acp/tests/fixtures/claude-native-to-acp-proof.ts b/packages/acp/tests/fixtures/claude-native-to-acp-proof.ts index b218ecc1a..0a868cc02 100644 --- a/packages/acp/tests/fixtures/claude-native-to-acp-proof.ts +++ b/packages/acp/tests/fixtures/claude-native-to-acp-proof.ts @@ -86,8 +86,15 @@ const RECOVER_PREFIX = "RECOVERED-"; /** The production target, exactly as an operator would type it. */ const TARGET = "AGENTS.md#Implementor"; -/** The compatibility point these journeys are only meaningful against. */ -const REQUIRED_CLAUDE_VERSION = "2.1.241 (Claude Code)"; +/** + * What the installed build has to declare for these journeys to mean anything. + * + * The shape, not a release. A build that declares caller-supplied identity, + * exact resume and private-file instructions is one these journeys can be run + * against whatever it calls itself, and a build that does not is one where a + * failure would say nothing about the product. + */ +const REQUIRED_CAPABILITIES = ["native-launch", "client-native-attachment"] as const; /** * The role contract's opening sentence. @@ -1616,9 +1623,11 @@ function* ready(journey: Journey, verdict: JourneyVerdict): Operation { return false; } verdict.claudeVersion = yield* claudeVersion(journey); - if (verdict.claudeVersion !== REQUIRED_CLAUDE_VERSION) { + const declared = (yield* observeBuild(journey)).capabilities; + const missing = REQUIRED_CAPABILITIES.filter((capability) => !declared.includes(capability)); + if (missing.length > 0) { verdict.verdict = "ENVIRONMENT_BLOCKED"; - verdict.detail = `this journey is only meaningful against ${REQUIRED_CLAUDE_VERSION}`; + verdict.detail = `the installed build does not declare ${missing.join(" or ")}`; return false; } if (!verdict.projectCopyVerified) { @@ -1712,20 +1721,29 @@ function boundAdapterCommand(): string { return adapter.binding.adapterCommand ?? ""; } -/** What this run's own observer says the installed Claude build is. */ -function* observeBuild(journey: Journey): Operation<{ version: string; digest: string }> { +/** + * What this run's own observer says the installed Claude build is. + * + * The declared read-only queries are asked through the shipped adapter's own + * binding, so what this reads is the same evidence the provider will read. + * `version` is optional evidence: a build that will not name its release is + * bound by its digest, and this reports that as the empty string. + */ +function* observeBuild( + journey: Journey, +): Operation<{ version: string; digest: string; capabilities: readonly string[] }> { const observer = createDenoExecutableObserver(); - if (!observer) { - return { version: "", digest: "" }; - } - const found = yield* observer.observe("claude"); const adapter = nativeAdapterFor("claude"); - const version = - adapter && allocatesIdentity(adapter) - ? adapter.binding.version(found.versionOutput) - : undefined; + if (!observer || !adapter || !allocatesIdentity(adapter)) { + return { version: "", digest: "", capabilities: [] }; + } + const found = yield* observer.observe("claude", { metadata: adapter.binding.metadata }); void journey; - return { version: version ?? "", digest: found.digest.value }; + return { + version: adapter.binding.reportedVersion(found.metadata) ?? "", + digest: found.digest.value, + capabilities: adapter.binding.probe(found.metadata).capabilities, + }; } /** @@ -1746,7 +1764,7 @@ function* runAbsentIdentity(journey: Journey, verdict: JourneyVerdict): Operatio journey.absentKey = sessionKey; const store = createDenoSessionRouteStore(SESSION_COORDINATOR_ROOT); const observed = yield* observeBuild(journey); - if (!store || observed.version.length === 0 || observed.digest.length !== 64) { + if (!store || observed.digest.length !== 64) { verdict.verdict = "HARNESS_FAILED"; verdict.detail = "the absent-identity case could not name a build to bind to"; return; @@ -1763,7 +1781,9 @@ function* runAbsentIdentity(journey: Journey, verdict: JourneyVerdict): Operatio launcher: "claude", executableBinding: { schema: "executable-build.v1", - reportedVersion: observed.version, + // Only when this build named a release. A binding that invented one would + // refuse for drift, and this case is about an absent conversation. + ...(observed.version.length > 0 ? { reportedVersion: observed.version } : {}), executableDigest: { algorithm: "sha256", value: observed.digest }, }, }); @@ -1844,8 +1864,14 @@ function decideAttachment(verdict: JourneyVerdict): void { verdict.detail = "the route, the journal and the ACP arrangement do not name one conversation"; return; } + // The digest is what binds, and the route and the journal both carry one or + // this run never got a build observation at all. A retained release is a + // further claim the live build has to still make — checked when the route + // named one, and nothing to reproduce when it did not. verdict.observed.matchesRoute = - verdict.observed.version.length > 0 && verdict.observed.version === verdict.route.buildVersion; + verdict.route.buildVersion.length === 0 + ? verdict.route.buildDigestPresent && verdict.observed.digestPresent + : verdict.observed.version === verdict.route.buildVersion; if ( !verdict.observed.matchesRoute || verdict.journal.buildVersion !== verdict.route.buildVersion diff --git a/packages/acp/tests/helpers.ts b/packages/acp/tests/helpers.ts index 3a55c2340..509d4ce8c 100644 --- a/packages/acp/tests/helpers.ts +++ b/packages/acp/tests/helpers.ts @@ -24,6 +24,7 @@ import type { AgentSessionOwner, AgentSessionOwnerKind, AgentSessionOwnership, + ExecutableMetadataObservation, ExecutableObserver, ExecutableRefusal, } from "@executablemd/runtime"; @@ -190,17 +191,57 @@ export interface FakeRuntimeHarness { script(turn: ScriptedTurn): void; } +/** One query's answer, in the shape a real observation produces. */ +export function answered(stdout: string): ExecutableMetadataObservation { + return { settled: true, code: 0, stdout, stderr: "" }; +} + +/** The three declarations native launch is observed from, as Claude spells them. */ +export const CLAUDE_HELP_DECLARATIONS = { + sessionId: " --session-id Use a specific session ID for the conversation", + resume: " -r, --resume [sessionId] Resume a conversation", + privateFile: " --system-prompt-file Load the system prompt from a file", +} as const; + +/** + * A Commander-shaped Claude help surface. + * + * Written out rather than captured, because what the probe reads is the shape + * of the declarations rather than any wording: a scenario varies exactly one of + * them, and everything a real build also prints is noise the answer must + * survive. + */ +export function claudeHelp(options?: { + product?: string; + usage?: string; + declarations?: readonly string[]; + extra?: readonly string[]; +}): string { + return [ + options?.product ?? "Claude Code - starts an interactive session by default", + "", + options?.usage ?? "Usage: claude [options] [command] [prompt]", + "", + "Options:", + ...(options?.declarations ?? Object.values(CLAUDE_HELP_DECLARATIONS)), + ...(options?.extra ?? []), + "", + ].join("\n"); +} + /** One controlled build, as an observer would report it. */ export interface FakeObservation { path: string; digest: string; - versionOutput: string; + metadata: Record; } export interface FakeObserverHarness { observer: ExecutableObserver; /** Every command this observer was asked about, in order. */ observed: string[]; + /** Every metadata query it was asked to run, as `name argv…`, in order. */ + queried: string[]; /** What the next observation answers, or the failure it raises. */ observation: FakeObservation; /** Answers taken in order before `observation`, so a build can change. */ @@ -214,22 +255,31 @@ export interface FakeObserverHarness { * The whole seam is replaced, exactly as a trusted host supplies the whole * thing: nothing inside the observer is made replaceable to be testable, so * drift is expressed by answering differently rather than by a control the - * production path also has. + * production path also has. The answers are what a build declared, so the + * adapter's own probe reads them — a scenario removes a declaration rather + * than removing the capability it implies. */ export function createFakeObserver(observation?: Partial): FakeObserverHarness { const harness: FakeObserverHarness = { observed: [], + queried: [], queued: [], observation: { path: "/opt/builds/claude", digest: "a".repeat(64), - versionOutput: "2.1.241 (Claude Code)\n", + metadata: { + help: answered(claudeHelp()), + version: answered("2.1.241 (Claude Code)\n"), + }, ...observation, }, observer: { // deno-lint-ignore require-yield - *observe(command) { + *observe(command, options) { harness.observed.push(command); + for (const query of options?.metadata ?? []) { + harness.queried.push([query.name, ...query.args].join(" ")); + } if (harness.failure) { throw new ExecutableObservationError(`${command} could not be observed`, { refusal: harness.failure, @@ -239,7 +289,7 @@ export function createFakeObserver(observation?: Partial): Fake return { path: answer.path, digest: { algorithm: "sha256", value: answer.digest }, - versionOutput: answer.versionOutput, + metadata: answer.metadata, }; }, }, diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index bb957f87b..b61e61a02 100644 --- a/packages/acp/tests/native-launch.test.ts +++ b/packages/acp/tests/native-launch.test.ts @@ -39,23 +39,26 @@ import { allocatesIdentity, knownNativeAdapters, nativeAdapterFor, - nativeCapabilityCompatibility, + nativeCapabilityPolicy, } from "../src/native-launch.ts"; import type { NativeCapability, - NativeCapabilityCompatibility, NativeCapabilityHost, + NativeCapabilityPolicy, } from "../src/native-capability.ts"; import { createHash, randomUUID } from "node:crypto"; import { readFile, rm, stat, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import process from "node:process"; -import type { NativeAdapter, NativeBinding } from "../src/native-launch.ts"; +import type { ClientAllocatedAdapter, NativeAdapter, NativeBinding } from "../src/native-launch.ts"; import { AgentSessionRouteError, createMemorySessionRouteStore } from "../src/session-route.ts"; import type { AgentSessionRoute, AgentSessionRouteStore } from "../src/session-route.ts"; import { deriveSessionKey } from "../src/session-key.ts"; import { + answered, + claudeHelp, + CLAUDE_HELP_DECLARATIONS, createFakeObserver, createFakeRuntime, makeCoordinator, @@ -64,7 +67,12 @@ import { makeStore, useFlatWorld, } from "./helpers.ts"; -import type { CoordinatorHarness, FakeObserverHarness, FakeRuntimeHarness } from "./helpers.ts"; +import type { + CoordinatorHarness, + FakeObservation, + FakeObserverHarness, + FakeRuntimeHarness, +} from "./helpers.ts"; import type { ExecutableBuildBindingV1 } from "@executablemd/core"; import type { AcpxSessionPolicy } from "../src/provider.ts"; import type { ExecutableObserver } from "@executablemd/runtime"; @@ -143,25 +151,47 @@ function createCwdBarrier(dir: string): CwdBarrier { }; } +/** + * The shipped Claude adapter, so the controlled adapters here carry the real + * one's evidence rather than a convenient stand-in. + * + * What a build declares is adapter-owned knowledge, and a test binding that + * read help its own way would prove a parser nothing ships. Taking the shipped + * one means removing a declaration from an observation exercises production + * admission, which is the point of injecting the whole observation. + */ +function shippedClaude(): ClientAllocatedAdapter { + const adapter = nativeAdapterFor("claude"); + if (adapter === undefined || !allocatesIdentity(adapter)) { + throw new Error("the shipped claude adapter names its own sessions"); + } + return adapter; +} +const CLAUDE_ADAPTER = shippedClaude(); +const CLAUDE_PROTOCOL = CLAUDE_ADAPTER.protocol; +const CLAUDE_PROBE_PROFILE = CLAUDE_ADAPTER.binding.probe({}).probeProfile; + /** * The Claude-shaped build contract every controlled client-allocated adapter - * here carries: which command to observe, what its version output means, and - * what the ACP child needs in order to run that same build. + * here carries: which command to observe, which read-only questions to ask it, + * what the answers mean, and what the ACP child needs to run that same build. */ const TEST_BINDING: NativeBinding = { command: "claude", - // The same contract the shipped Claude adapter carries: exactly one canonical - // line is an answer, and zero or several are not. - version: (output) => { - const canonical = output - .split("\n") - .map((line) => line.trim()) - .filter((line) => /^\d+\.\d+\.\d+ \(Claude Code\)$/.test(line)); - return canonical.length === 1 ? canonical[0] : undefined; - }, + metadata: CLAUDE_ADAPTER.binding.metadata, + probe: CLAUDE_ADAPTER.binding.probe, + reportedVersion: CLAUDE_ADAPTER.binding.reportedVersion, environment: (livePath) => ({ CLAUDE_CODE_EXECUTABLE: livePath }), }; +/** + * The ACP bridge the shipped adapter pins. + * + * Attachment is observed from exact resume plus this pin, so a controlled + * adapter that dropped it would be describing a different contract. + */ +const TEST_BRIDGE = CLAUDE_ADAPTER.binding.adapterCommand; + /** What `createFakeObserver()`'s default observation binds to. */ const OBSERVED_BUILD: ExecutableBuildBindingV1 = { schema: "executable-build.v1", @@ -169,13 +199,46 @@ const OBSERVED_BUILD: ExecutableBuildBindingV1 = { executableDigest: { algorithm: "sha256", value: "a".repeat(64) }, }; +/** The same build, bound by a build that reported no version it recognized. */ +const DIGEST_ONLY_BUILD: ExecutableBuildBindingV1 = { + schema: "executable-build.v1", + executableDigest: { algorithm: "sha256", value: "a".repeat(64) }, +}; + /** The canonical path that same observation reports. */ const OBSERVED_PATH = "/opt/builds/claude"; +/** + * One controlled observation, varying only what a case is about. + * + * The default is a build that declares the whole native-launch shape and + * reports the proved version, so a case that changes nothing is describing an + * ordinary installation. + */ +function observation(overrides?: { + path?: string; + digest?: string; + /** What `--help` answered. `false` is a build that would not answer it. */ + help?: string | false; + /** What `--version` answered. `false` is a query that did not settle. */ + version?: string | false; +}): FakeObservation { + return { + path: overrides?.path ?? OBSERVED_PATH, + digest: overrides?.digest ?? "a".repeat(64), + metadata: { + ...(overrides?.help === false ? {} : { help: answered(overrides?.help ?? claudeHelp()) }), + ...(overrides?.version === false + ? {} + : { version: answered(overrides?.version ?? "2.1.241 (Claude Code)\n") }), + }, + }; +} + /** * The machine these cases describe. * - * Stated, never read from the runner. A capability point names an exact OS and + * Stated, never read from the runner. An admission names an exact OS and * architecture, so a suite that asked the machine underneath it what it was * would be admitting whatever it happened to run on — and the shipped Claude * evidence, which is a Mac with Apple silicon, would be exercised on one CI @@ -184,24 +247,25 @@ const OBSERVED_PATH = "/opt/builds/claude"; const PROVED_HOST: NativeCapabilityHost = { platform: "darwin", architecture: "arm64" }; /** - * A host that has proved the named builds of `claude`, both capabilities each. + * A host that has proved the named protocol on this machine, both capabilities. * - * For the cases that need two admitted builds at once. Everything else takes - * the package's own evidence, so what most of this file runs against is the - * shipped table rather than a convenient stand-in. + * For the cases that describe a host which proved something other than what + * ships. Everything else takes the package's own evidence, so what most of this + * file runs against is the shipped policy. */ -function admitting(...reportedVersions: readonly string[]): NativeCapabilityCompatibility { +function admitting( + adapterProtocol: string = CLAUDE_PROTOCOL, + probeProfile: string = CLAUDE_PROBE_PROFILE, +): NativeCapabilityPolicy { const capabilities: readonly NativeCapability[] = ["native-launch", "client-native-attachment"]; return { host: PROVED_HOST, - points: reportedVersions.flatMap((reportedVersion) => - capabilities.map((capability) => ({ - agent: "claude", - capability, - reportedVersion, - ...PROVED_HOST, - })), - ), + admissions: capabilities.map((capability) => ({ + adapterProtocol, + capability, + probeProfile, + ...PROVED_HOST, + })), }; } @@ -243,13 +307,13 @@ interface ProviderOptions { /** `false` gives this host no way to observe a build at all. */ observer?: ExecutableObserver | false; /** - * Which exact points this host has proved. + * Which exact admissions this host has proved. * * Defaults to the package's own evidence for the machine above, so a case * says nothing unless it is describing a host that proved something else. * `false` is a host that has proved nothing and admits nothing. */ - compatibility?: NativeCapabilityCompatibility | false; + nativeCapabilityPolicy?: NativeCapabilityPolicy | false; store?: AcpSessionStore; adapters?: Record; /** @@ -432,9 +496,12 @@ function* installLaunchStack( ...(options.observer === false ? {} : { executableObserver: options.observer ?? createFakeObserver().observer }), - ...(options.compatibility === false + ...(options.nativeCapabilityPolicy === false ? {} - : { compatibility: options.compatibility ?? nativeCapabilityCompatibility(PROVED_HOST) }), + : { + nativeCapabilityPolicy: + options.nativeCapabilityPolicy ?? nativeCapabilityPolicy(PROVED_HOST), + }), coordinator: options.coordinator ?? trace.ownership.coordinator, ...(options.routeStore ? { routeStore: options.routeStore } : {}), ...(options.withSessionRoute ? { withSessionRoute: options.withSessionRoute } : {}), @@ -463,6 +530,7 @@ function newTrace(): Trace { */ const PROVIDER_RETURNED_CLAUDE: NativeAdapter = { launcher: "claude", + protocol: "claude-provider-returned.v1", identity: "provider-returned", resume: (nativeSessionId) => ["claude", "--resume", nativeSessionId], }; @@ -1513,6 +1581,7 @@ describe("Tier CN — client-allocated construction", () => { function clientNative(allocate: () => string = () => ALLOCATED): NativeAdapter { return { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate, @@ -1905,6 +1974,7 @@ describe("Tier CR — client-allocated incomplete replay", () => { const ADAPTER: NativeAdapter = { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate: () => ALLOCATED, @@ -2181,6 +2251,7 @@ describe("Tier PF — normalized private failures", () => { adapters: { claude: { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate: () => "66666666-7777-8888-9999-000000000000", @@ -2216,6 +2287,7 @@ describe("Tier PF — normalized private failures", () => { adapters: { claude: { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate: () => "77777777-8888-9999-0000-111111111111", @@ -2265,6 +2337,7 @@ describe("Tier PF — normalized private failures", () => { adapters: { claude: { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate: () => "88888888-9999-0000-1111-222222222222", @@ -2313,6 +2386,7 @@ describe("Tier PF — normalized private failures", () => { adapters: { claude: { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate: () => "33333333-4444-5555-6666-777777777777", @@ -2352,6 +2426,7 @@ describe("Tier RR — racing construction routes", () => { const ADAPTER: NativeAdapter = { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate: () => ALLOCATED, @@ -2573,6 +2648,7 @@ describe("Tier CX — cancellation before ownership ends", () => { adapters: { claude: { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate: () => "55555555-6666-7777-8888-999999999999", @@ -2683,6 +2759,7 @@ describe("Tier CA — client-native attachment", () => { function adapter(): NativeAdapter { return { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate: () => ALLOCATED, @@ -2846,13 +2923,13 @@ describe("Tier CA — client-native attachment", () => { [ "another version of the same bytes", (observer: FakeObserverHarness) => { - observer.observation.versionOutput = "2.1.242 (Claude Code)\n"; + observer.observation.metadata.version = answered("2.1.242 (Claude Code)\n"); }, ], [ - "output this adapter does not recognize", + "a build that no longer reproduces the version this route retained", (observer: FakeObserverHarness) => { - observer.observation.versionOutput = "claude version 2.1.241\n"; + observer.observation.metadata.version = answered("claude version 2.1.241\n"); }, ], [ @@ -2955,14 +3032,7 @@ describe("Tier CA — client-native attachment", () => { // somewhere else is the same partition key and a different file to run — // and handing the next attachment the old path is how it would run one. const observer = createFakeObserver(); - observer.queued = [ - { path: OBSERVED_PATH, digest: "a".repeat(64), versionOutput: "2.1.241 (Claude Code)\n" }, - { - path: "/moved/bin/claude", - digest: "a".repeat(64), - versionOutput: "2.1.241 (Claude Code)\n", - }, - ]; + observer.queued = [observation(), observation({ path: "/moved/bin/claude" })]; const space = yield* installAttachment(bound(), { observer: observer.observer }); // A second session bound to the same build, so what the next attachment // reaches for is the same partition. It is a different session because the @@ -3123,14 +3193,7 @@ describe("Tier CA — client-native attachment", () => { it("CA16: a halted ensure that then fails gives its claim back", function* () { const observer = createFakeObserver(); - observer.queued = [ - { path: OBSERVED_PATH, digest: "a".repeat(64), versionOutput: "2.1.241 (Claude Code)\n" }, - { - path: "/moved/bin/claude", - digest: "a".repeat(64), - versionOutput: "2.1.241 (Claude Code)\n", - }, - ]; + observer.queued = [observation(), observation({ path: "/moved/bin/claude" })]; const space = yield* installAttachment(bound(), { observer: observer.observer }); const second = deriveSessionKey(AGENT_COMMAND, CWD, "second"); yield* space.routes.publish(bound({ sessionKey: second, nativeSessionId: SECOND_ALLOCATED })); @@ -3167,14 +3230,7 @@ describe("Tier CA — client-native attachment", () => { // that goes with it is a fresh one: a value that still named a runtime // would answer from the path that runtime was built with. const observer = createFakeObserver(); - observer.queued = [ - { path: OBSERVED_PATH, digest: "a".repeat(64), versionOutput: "2.1.241 (Claude Code)\n" }, - { - path: "/moved/bin/claude", - digest: "a".repeat(64), - versionOutput: "2.1.241 (Claude Code)\n", - }, - ]; + observer.queued = [observation(), observation({ path: "/moved/bin/claude" })]; const space = yield* installAttachment(bound(), { observer: observer.observer }); const session = yield* Agent.operations.session(); @@ -3248,17 +3304,22 @@ describe("Tier CA — client-native attachment", () => { expect(space.harness.createdOptions).toHaveLength(2); }); - it("CA14: output naming several builds refuses before a child, an ensure or a turn", function* () { + it("CA14: a route's retained version that this build will not reproduce refuses before a child, an ensure or a turn", function* () { // One canonical line is an answer. Several is a list of builds, and taking - // the first would be choosing one — which is the question this refuses. + // the first would be choosing one — so this build reports no version. That + // is not a refusal in itself; what refuses is that the route retained one, + // and a claim the live build no longer makes cannot be confirmed. const observer = createFakeObserver({ - versionOutput: "2.1.241 (Claude Code)\n2.1.242 (Claude Code)\n", + metadata: { + help: answered(claudeHelp()), + version: answered("2.1.241 (Claude Code)\n2.1.242 (Claude Code)\n"), + }, }); const space = yield* installAttachment(bound(), { observer: observer.observer }); const raised = yield* attach(); - expect(raised?.message).toContain("does not recognize"); + expect(raised?.message).toContain("cannot be confirmed"); // Nothing was repeated back: the output is the provider's, not the reader's. expect(raised?.message).not.toContain("2.1.242"); expect(space.harness.createdOptions).toEqual([]); @@ -3326,6 +3387,7 @@ describe("Tier RT — bound runtime partitions", () => { function adapter(): NativeAdapter { return { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate: () => FIRST, @@ -3428,8 +3490,8 @@ describe("Tier RT — bound runtime partitions", () => { const routes = createMemorySessionRouteStore(); const observer = createFakeObserver(); observer.queued = [ - { path: OBSERVED_PATH, digest: "a".repeat(64), versionOutput: "2.1.241 (Claude Code)\n" }, - { path: OTHER_PATH, digest: "d".repeat(64), versionOutput: "2.1.242 (Claude Code)\n" }, + observation(), + observation({ path: OTHER_PATH, digest: "d".repeat(64), version: "2.1.242 (Claude Code)\n" }), ]; const second = deriveSessionKey(AGENT_COMMAND, CWD, "second"); yield* routes.publish(route(SESSION_KEY, FIRST)); @@ -3438,8 +3500,6 @@ describe("Tier RT — bound runtime partitions", () => { adapters: { claude: adapter() }, routeStore: routes, observer: observer.observer, - // Two builds is the whole case, so this host has proved both of them. - compatibility: admitting("2.1.241 (Claude Code)", "2.1.242 (Claude Code)"), }); yield* Agent.operations.session(); @@ -3784,6 +3844,7 @@ describe("Tier LU — legacy unbound client-native", () => { const ADAPTER: NativeAdapter = { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate: () => ALLOCATED, @@ -3959,21 +4020,162 @@ describe("Tier AO — explicit ACP-only capability", () => { }); }); +/** + * Tier CP — what the shipped Claude adapter reads out of a build's help + * (specs/native-agent-session-launch-spec.md §Executable binding). + * + * The fixtures above carry this same probe, but they are fixtures. This is the + * parser production runs, and what it decides is which builds may be handed a + * session at all. + * + * It is structural rather than a snapshot: a release that adds options, rewords + * prose or rewraps a line still declares the same operations, and one that + * stopped declaring one cannot do the work whatever it calls itself. + */ +describe("Tier CP — the Claude capability probe", () => { + function probe(help: string | undefined): readonly NativeCapability[] { + return CLAUDE_ADAPTER.binding.probe(help === undefined ? {} : { help: answered(help) }) + .capabilities; + } + + it("CP1: a build declaring all three operations can be handed a session", function* () { + expect(probe(claudeHelp())).toEqual(["native-launch", "client-native-attachment"]); + // And the profile it answers under is the one an admission names. + expect(CLAUDE_ADAPTER.binding.probe({}).probeProfile).toBe("claude-help-native-session.v1"); + }); + + it("CP2: options, prose and wrapping this adapter did not ask about change nothing", function* () { + const noisy = claudeHelp({ + product: + "Claude Code - starts an interactive session by default\n\n" + + "Learn more at https://docs.claude.com/en/docs/claude-code", + extra: [ + " --brand-new-option An option no proof has ever seen", + " -c, --continue Continue the most recent conversation", + " --bare Print only the response. Use --system-prompt[-file]", + " to steer it.", + ], + }); + expect(probe(noisy)).toEqual(["native-launch", "client-native-attachment"]); + + // The same declarations, wrapped onto continuation lines the way Commander + // wraps a long description. What is read is the flag and whether it takes a + // value, so where the description broke is not part of the answer. + const wrapped = claudeHelp({ + declarations: [ + " --session-id Use a specific session ID for the", + " conversation (must be a valid UUID)", + " -r, --resume [sessionId] Resume a conversation — provide a session", + " ID or interactively select one", + " --system-prompt-file Load the system prompt from a file, for", + " this invocation only", + ], + }); + expect(probe(wrapped)).toEqual(["native-launch", "client-native-attachment"]); + }); + + it("CP3: the private-file declaration is read in either spelling Claude uses", function* () { + // Builds that give it no entry of its own document the family in prose + // instead. Both are the same declaration, and neither is inferred from the + // other's absence. + const family = claudeHelp({ + declarations: [CLAUDE_HELP_DECLARATIONS.sessionId, CLAUDE_HELP_DECLARATIONS.resume], + extra: [ + " --system-prompt Override the system prompt", + " --bare Print only the response. Combine with", + " --system-prompt[-file] to steer it.", + ], + }); + expect(family).toContain("--system-prompt[-file]"); + expect(probe(family)).toEqual(["native-launch", "client-native-attachment"]); + }); + + it("CP4: a missing or unreadable declaration is not a capability", function* () { + // Each row withdraws exactly one thing and states what is left. A build + // missing something only launch needs still attaches, because the two are + // read independently — what must never survive is the capability whose own + // evidence went away. + const entries = CLAUDE_HELP_DECLARATIONS; + const ATTACH_ONLY: readonly NativeCapability[] = ["client-native-attachment"]; + for (const [name, help, remaining] of [ + // Nothing is recognized without the product, so both go. + ["no help at all", undefined, []], + ["help that names another product", claudeHelp({ product: "gemini-cli" }), []], + [ + "help whose usage line is another command", + claudeHelp({ usage: "Usage: gemini [options]" }), + [], + ], + // Exact resume is the one declaration both capabilities need. + [ + "no exact resume", + claudeHelp({ declarations: [entries.sessionId, entries.privateFile] }), + [], + ], + // Launch-only evidence: attachment is untouched by its absence. + [ + "no caller-supplied identity", + claudeHelp({ declarations: [entries.resume, entries.privateFile] }), + ATTACH_ONLY, + ], + [ + "an identity option that takes no value", + claudeHelp({ + declarations: [ + " --session-id Start a new session", + entries.resume, + entries.privateFile, + ], + }), + ATTACH_ONLY, + ], + [ + "two entries declaring the same identity option", + claudeHelp({ + declarations: [ + entries.sessionId, + " --session-id Deprecated spelling", + entries.resume, + entries.privateFile, + ], + }), + ATTACH_ONLY, + ], + [ + "no private instruction file", + claudeHelp({ declarations: [entries.sessionId, entries.resume] }), + ATTACH_ONLY, + ], + ] as const) { + expect([name, probe(help)]).toEqual([name, remaining]); + } + }); + + it("CP5: neither capability is inferred from the other", function* () { + // Exact resume is what attachment needs, and it is one of the three things + // launch needs. A build declaring resume and nothing else attaches and + // cannot launch — the shared declaration authorizes only its own capability. + const resumeOnly = claudeHelp({ declarations: [CLAUDE_HELP_DECLARATIONS.resume] }); + expect(probe(resumeOnly)).toEqual(["client-native-attachment"]); + + // And launch does not carry attachment, because attachment also needs the + // ACP bridge this adapter pins — knowledge about the child process rather + // than about the CLI, which no help surface can supply. + expect(TEST_BRIDGE).toBe("npx -y @agentclientprotocol/claude-agent-acp@0.70.0"); + }); +}); + /** * Tier CV — the shipped Claude adapter's canonical version * (specs/native-agent-session-launch-spec.md §Executable binding). * - * The fixtures above carry the same contract, but they are fixtures. This is - * the parser production runs, and what it decides is which builds a session may - * be bound to at all. + * Optional evidence, deliberately: a version says which release is installed, + * not what it can do. Nothing here decides a capability, and every unreadable + * answer is an ordinary absence rather than a refusal. */ describe("Tier CV — canonical Claude version", () => { function parse(output: string): string | undefined { - const adapter = nativeAdapterFor("claude"); - if (!adapter || !allocatesIdentity(adapter)) { - throw new Error("the shipped claude adapter names its own sessions"); - } - return adapter.binding.version(output); + return CLAUDE_ADAPTER.binding.reportedVersion({ version: answered(output) }); } it("CV1: one canonical line is the answer, whole", function* () { @@ -4007,6 +4209,21 @@ describe("Tier CV — canonical Claude version", () => { // this adapter cannot read as one answer. expect(parse("2.1.241 (Claude Code)\n2.1.241 (Claude Code)\n")).toBe(undefined); }); + + it("CV4: a query that failed, never settled, or was never asked reports nothing", function* () { + const version = CLAUDE_ADAPTER.binding.reportedVersion; + // Never asked. + expect(version({})).toBe(undefined); + // Asked, and the child never started. + expect(version({ version: { settled: false, stdout: "", stderr: "" } })).toBe(undefined); + // Asked, answered, and exited nonzero — output beside a failure is not a + // report, whatever it happens to say. + expect( + version({ + version: { settled: true, code: 1, stdout: "2.1.241 (Claude Code)\n", stderr: "" }, + }), + ).toBe(undefined); + }); }); /** @@ -4014,21 +4231,44 @@ describe("Tier CV — canonical Claude version", () => { * (specs/decisions.md §DEC-017). * * An advertised adapter name selects a command shape. What admits work on a - * session is a point: one agent, one capability, one canonical reported - * version, one OS and one architecture, matched whole. These cases run the four - * paths that check one — fresh client-native construction, bound native resume, - * bound ACP attachment and incomplete replay — and each refusal is read at the - * boundary it is supposed to stop in front of, rather than by its message - * alone: nothing may be allocated, published, written, spawned, ensured or - * retained behind it. + * session is an admission: one adapter protocol, one capability, one probe + * profile, one OS and one architecture, matched whole — and it is only reached + * when the executable itself declared that shape. Both gates are needed: + * declaring the shape is not proof this host ran it, and proving a machine is + * not proof the build installed on it can do the work. + * + * No Claude Code version appears in any of it. Which release is installed is a + * fact about a route, retained when the build will say and absent when it will + * not; what admits is what the build declares it can do. + * + * These cases run the four paths that check one — fresh client-native + * construction, bound native resume, bound ACP attachment and incomplete replay + * — and each refusal is read at the boundary it is supposed to stop in front + * of, rather than by its message alone: nothing may be allocated, published, + * written, spawned, ensured or retained behind it. */ -describe("Tier NP — proved native capability points", () => { +describe("Tier NP — proved native capability admissions", () => { const ALLOCATED = "cafe0000-1111-2222-3333-444444444444"; - /** The build the shipped Claude evidence names, and the one that follows it. */ + /** The build the shipped Claude proof ran against, and one that follows it. */ const PROVED_VERSION = "2.1.241 (Claude Code)"; const LATER_VERSION = "2.1.263 (Claude Code)"; + /** + * The same help surface with exactly one required declaration withdrawn. + * + * A build is refused for what it stopped declaring, not for what a test + * turned off: the observation is complete and the adapter's own probe reads + * it, so each row here is a release that could ship. + */ + function without(member: keyof typeof CLAUDE_HELP_DECLARATIONS): string { + return claudeHelp({ + declarations: Object.entries(CLAUDE_HELP_DECLARATIONS) + .filter(([name]) => name !== member) + .map(([, entry]) => entry), + }); + } + /** Every boundary a refused point must stop in front of. */ interface Boundaries { allocations: number; @@ -4052,6 +4292,7 @@ describe("Tier NP — proved native capability points", () => { function countedAdapter(seen: Boundaries, allocate: () => string = () => ALLOCATED) { return { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate: () => { @@ -4166,54 +4407,82 @@ describe("Tier NP — proved native capability points", () => { } } - it("NP1: the exact proved launch point constructs, on the machine it names", function* () { - // The package's own evidence, unmodified, against the build it was proved - // against. Nothing here supplies a point: what admits this launch is the - // shipped Claude adapter's, read for darwin/arm64. - const harness = createFakeRuntime(); - const trace = newTrace(); - const seen = boundaries(); - const routes = countedRoutes(seen); - yield* installLaunchStack(harness, trace, { - adapters: { claude: countedAdapter(seen) }, - routeStore: routes, - }); + it("NP1: a build that declares the proved shape constructs, whatever release it is", function* () { + // The package's own evidence, unmodified. Nothing here supplies a policy: + // what admits these launches is the shipped Claude adapter's, read for + // darwin/arm64 — and the release the build reports is not part of it, so + // the version the proof ran against and a later one are the same admission. + for (const [name, version] of [ + ["the release the proof ran against", PROVED_VERSION], + ["a later release declaring the same shape", LATER_VERSION], + ] as const) { + yield* scoped(function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const observer = createFakeObserver(observation({ version: `${version}\n` })); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: countedRoutes(seen), + observer: observer.observer, + }); - yield* launch(INSTRUCTIONS); + yield* launch(INSTRUCTIONS); - const prepared = trace.records[0] as PreparedLaunchRecord; - expect(prepared.failure).toBe(undefined); - expect(prepared.nativeSessionId).toBe(ALLOCATED); - expect(prepared.executableBinding?.reportedVersion).toBe(PROVED_VERSION); - expect(seen.allocations).toBe(1); - expect(seen.published).toHaveLength(1); - expect(trace.launches).toHaveLength(1); - // And the point that admitted it is the shipped one, for both capabilities. - expect(nativeCapabilityCompatibility(PROVED_HOST).points).toEqual([ + const prepared = trace.records[0] as PreparedLaunchRecord; + expect([name, prepared.failure]).toEqual([name, undefined]); + expect([name, prepared.nativeSessionId]).toEqual([name, ALLOCATED]); + // Retained, because this build would say — but it is the digest that + // binds, and the two runs are admitted alike. + expect([name, prepared.executableBinding?.reportedVersion]).toEqual([name, version]); + expect([name, seen.allocations]).toEqual([name, 1]); + expect([name, seen.published]).toHaveLength(2); + expect([name, trace.launches]).toHaveLength(2); + // And the only questions the build was asked are the read-only ones the + // adapter declared, at the exact path that was observed. + expect([name, observer.queried]).toEqual([name, ["help --help", "version --version"]]); + }); + } + + // The admissions that let both through are the shipped ones, for both + // capabilities, and they name no release at all. + expect(nativeCapabilityPolicy(PROVED_HOST).admissions).toEqual([ { - agent: "claude", + adapterProtocol: CLAUDE_PROTOCOL, capability: "native-launch", - reportedVersion: PROVED_VERSION, + probeProfile: CLAUDE_PROBE_PROFILE, ...PROVED_HOST, }, { - agent: "claude", + adapterProtocol: CLAUDE_PROTOCOL, capability: "client-native-attachment", - reportedVersion: PROVED_VERSION, + probeProfile: CLAUDE_PROBE_PROFILE, ...PROVED_HOST, }, ]); + expect(JSON.stringify(nativeCapabilityPolicy(PROVED_HOST))).not.toContain("Claude Code"); }); - it("NP2: an unproved point refuses a construction before it does anything", function* () { - // Four ways to be outside the proof, one refusal each. A later build, the - // same build on another OS, the same build on another architecture, and a - // host that has proved nothing at all: none of them is a near miss that - // some other reading could let through. + it("NP2: an unadmitted construction refuses before it does anything", function* () { + // Two ways to fail, several of each. A build that stopped declaring one of + // the operations native launch is made of, or would not answer at all; and + // a host whose proof does not cover this protocol, this profile, this OS or + // this architecture — or that has proved nothing. None is a near miss that + // some other reading could let through, and neither gate stands in for the + // other. for (const [name, options] of [ - ["a later build of the proved agent", { versionOutput: `${LATER_VERSION}\n` }], + ["a build declaring no caller-supplied identity", { help: without("sessionId") }], + ["a build declaring no exact resume", { help: without("resume") }], + ["a build declaring no private instruction file", { help: without("privateFile") }], + ["a build that is not the Claude product", { help: claudeHelp({ product: "gemini-cli" }) }], + ["a build that would not answer the required query", { help: false }], ["another operating system", { host: { platform: "linux", architecture: "arm64" } }], ["another architecture", { host: { platform: "darwin", architecture: "x64" } }], + ["a host that proved another adapter protocol", { policy: admitting("claude-fork.v1") }], + [ + "a host that proved another probe profile", + { policy: admitting(CLAUDE_PROTOCOL, "claude-help-native.v2") }, + ], ["a host that has proved nothing", { none: true }], ] as const) { yield* scoped(function* () { @@ -4222,7 +4491,7 @@ describe("Tier NP — proved native capability points", () => { const seen = boundaries(); const store = makeStore(); const observer = createFakeObserver( - "versionOutput" in options ? { versionOutput: options.versionOutput } : {}, + "help" in options ? observation({ help: options.help }) : {}, ); yield* withUnusablePrivateRoot(function* () { @@ -4231,12 +4500,14 @@ describe("Tier NP — proved native capability points", () => { routeStore: countedRoutes(seen), store, observer: observer.observer, - compatibility: + nativeCapabilityPolicy: "none" in options ? false : "host" in options - ? nativeCapabilityCompatibility(options.host) - : undefined, + ? nativeCapabilityPolicy(options.host) + : "policy" in options + ? options.policy + : undefined, }); const failure = yield* attempt(trace, INSTRUCTIONS); // Not `process-creation-failed`: the private root is unusable for the @@ -4245,7 +4516,7 @@ describe("Tier NP — proved native capability points", () => { expect([name, failure?.class]).toEqual([name, "unsupported-capability"]); }); - // The build was observed — that is how the point became a question at + // The build was observed — that is how admission became a question at // all — and then everything the answer gates stopped. Each of these is // something NP1 sees happen on the admitted path. expect([name, observer.observed]).toEqual([name, ["claude"]]); @@ -4288,24 +4559,24 @@ describe("Tier NP — proved native capability points", () => { // Two capabilities, two proofs. A host that has proved one of them does // that one thing and refuses the other, in both directions — so neither is // ever inferred from the other having been proved. - const launchOnly: NativeCapabilityCompatibility = { + const launchOnly: NativeCapabilityPolicy = { host: PROVED_HOST, - points: [ + admissions: [ { - agent: "claude", + adapterProtocol: CLAUDE_PROTOCOL, capability: "native-launch", - reportedVersion: PROVED_VERSION, + probeProfile: CLAUDE_PROBE_PROFILE, ...PROVED_HOST, }, ], }; - const attachOnly: NativeCapabilityCompatibility = { + const attachOnly: NativeCapabilityPolicy = { host: PROVED_HOST, - points: [ + admissions: [ { - agent: "claude", + adapterProtocol: CLAUDE_PROTOCOL, capability: "client-native-attachment", - reportedVersion: PROVED_VERSION, + probeProfile: CLAUDE_PROBE_PROFILE, ...PROVED_HOST, }, ], @@ -4320,7 +4591,7 @@ describe("Tier NP — proved native capability points", () => { yield* installLaunchStack(harness, trace, { adapters: { claude: countedAdapter(seen) }, routeStore: countedRoutes(seen), - compatibility: launchOnly, + nativeCapabilityPolicy: launchOnly, }); yield* launch(INSTRUCTIONS); @@ -4347,7 +4618,7 @@ describe("Tier NP — proved native capability points", () => { yield* installLaunchStack(harness, trace, { adapters: { claude: countedAdapter(seen) }, routeStore: routes, - compatibility: attachOnly, + nativeCapabilityPolicy: attachOnly, }); const session = yield* Agent.operations.session(); @@ -4389,7 +4660,7 @@ describe("Tier NP — proved native capability points", () => { yield* installLaunchStack(harness, trace, { adapters: { claude: countedAdapter(seen) }, routeStore: routes, - compatibility: false, + nativeCapabilityPolicy: false, }); yield* act(trace); @@ -4435,6 +4706,133 @@ describe("Tier NP — proved native capability points", () => { expect(trace.launches).toEqual([]); }); + it("NP5b: a build whose release cannot be read is still admitted, and binds by digest", function* () { + // The version query is a courtesy, not a gate. Four ways for it to tell + // this run nothing — it answered nothing, it answered in words this adapter + // does not recognize, it answered with several things that could each be a + // version, and it did not settle at all. Under every one of them the shape + // was observed, so the launch runs; and the route it publishes says only + // what the run can stand behind, which is the digest. + for (const [name, version] of [ + ["no output at all", ""], + ["output in words this adapter does not recognize", "claude, the coding agent\n"], + [ + "several lines that could each be the release", + "2.1.241 (Claude Code)\n9.9.9 (Claude Code)\n", + ], + ["a query that never settled", false], + ] as const) { + yield* scoped(function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const observer = createFakeObserver(observation({ version })); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: countedRoutes(seen), + observer: observer.observer, + }); + + yield* launch(INSTRUCTIONS); + + const prepared = trace.records[0] as PreparedLaunchRecord; + expect([name, prepared.failure]).toEqual([name, undefined]); + expect([name, prepared.executableBinding]).toEqual([name, DIGEST_ONLY_BUILD]); + expect([name, seen.allocations]).toEqual([name, 1]); + expect([name, trace.launches]).toHaveLength(2); + // And what the build did say is nowhere the route could be read back. + const published = JSON.stringify(seen.published); + expect([name, published.includes("9.9.9")]).toEqual([name, false]); + expect([name, published.includes("the coding agent")]).toEqual([name, false]); + }); + } + }); + + it("NP5c: a digest-only route continues on its digest, and is never rewritten", function* () { + // What the earlier run could not read, this one can. That is a fact about + // the query, not about the executable: the digest is the same, so this is + // the same build and the session continues. The route keeps saying what the + // run that published it could stand behind — a version observed later is + // not a claim that route ever made. + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const routes = countedRoutes(seen); + yield* routes.publish(bound(DIGEST_ONLY_BUILD)); + const before = JSON.stringify(yield* routes.read(KEY)); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: routes, + observer: createFakeObserver(observation({ version: `${LATER_VERSION}\n` })).observer, + }); + + // Both continuations: the native resume and the ACP attachment. + yield* launch(INSTRUCTIONS); + const session = yield* Agent.operations.session(); + + expect(trace.records.some((record) => record.failure)).toBe(false); + expect(seen.resumes).toBe(1); + expect(seen.creates).toBe(0); + expect(session.agentSessionId).toBe(ALLOCATED); + expect(harness.ensureCalls.map((call) => call.resumeSessionId)).toEqual([ALLOCATED]); + // Nothing republished the route, and it still names no release. + expect(seen.published.length).toBe(1); + expect(JSON.stringify(yield* routes.read(KEY))).toBe(before); + expect(before).not.toContain(LATER_VERSION); + }); + + it("NP5d: a retained release must still be reproduced, and a digest decides regardless", function* () { + // The two halves of the asymmetry. A route that named a release is a claim + // the live build has to still make: one that reports another, or none this + // adapter can read, is not the build that history belongs to. And the + // digest is the one that always decides — a build whose shape is admitted + // and whose release matches is still refused when it is a different build. + for (const [name, retained, live] of [ + [ + "a live build reporting another release", + OBSERVED_BUILD, + observation({ version: `${LATER_VERSION}\n` }), + ], + ["a live build whose release cannot be read", OBSERVED_BUILD, observation({ version: "" })], + [ + "a different build, admitted and reporting the retained release", + OBSERVED_BUILD, + observation({ digest: "b".repeat(64) }), + ], + [ + "a different build, admitted, where the route named no release", + DIGEST_ONLY_BUILD, + observation({ digest: "b".repeat(64) }), + ], + ] as const) { + yield* scoped(function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const routes = countedRoutes(seen); + yield* routes.publish(bound(retained)); + const before = JSON.stringify(yield* routes.read(KEY)); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: routes, + observer: createFakeObserver(live).observer, + }); + + const failure = yield* attempt(trace, INSTRUCTIONS); + + expect([name, failure?.class]).toEqual([name, "executable-binding-refused"]); + expect([name, seen.resumes + seen.creates]).toEqual([name, 0]); + expect([name, harness.ensureCalls]).toEqual([name, []]); + expect([name, trace.launches]).toEqual([name, []]); + expect([name, seen.published.length]).toEqual([name, 1]); + expect([name, JSON.stringify(yield* routes.read(KEY))]).toEqual([name, before]); + // The refusal says which releases are involved and never the digest. + expect([name, failure?.message.includes("a".repeat(64))]).toEqual([name, false]); + expect([name, failure?.message.includes("b".repeat(64))]).toEqual([name, false]); + }); + } + }); + it("NP6: an incomplete replay is gated, and legacy history is unchanged", function* () { // An incomplete replay: the point is checked before the live phase it is // standing in front of, whichever suffix the journal retained. @@ -4449,7 +4847,7 @@ describe("Tier NP — proved native capability points", () => { yield* installLaunchStack(harness, trace, { adapters: { claude: countedAdapter(seen) }, routeStore: routes, - compatibility: false, + nativeCapabilityPolicy: false, }); trace.replay = { prepared: preparedRecord(), suffix }; @@ -4475,7 +4873,7 @@ describe("Tier NP — proved native capability points", () => { yield* installLaunchStack(harness, trace, { adapters: { claude: countedAdapter(seen) }, routeStore: routes, - compatibility: false, + nativeCapabilityPolicy: false, }); const { executableBinding: _unbound, ...legacyPrepared } = preparedRecord(); trace.replay = { prepared: legacyPrepared, suffix: "prepared+detached" }; @@ -4502,7 +4900,7 @@ describe("Tier NP — proved native capability points", () => { adapters: { claude: countedAdapter(seen) }, routeStore: routes, observer: observer.observer, - compatibility: false, + nativeCapabilityPolicy: false, }); trace.completed = { prepared: preparedRecord(), @@ -4515,6 +4913,8 @@ describe("Tier NP — proved native capability points", () => { expect(trace.records.map((record) => record.phase)).toEqual(["prepared", "detached", "exited"]); expect(trace.records.some((record) => record.failure)).toBe(false); expect(observer.observed).toEqual([]); + // Not merely no build observation — no read-only query against one either. + expect(observer.queried).toEqual([]); expect(seen.allocations + seen.creates + seen.resumes).toBe(0); expect(trace.launches).toEqual([]); }); diff --git a/packages/acp/tests/session-route.test.ts b/packages/acp/tests/session-route.test.ts index 5b3a15c5d..beeb658ef 100644 --- a/packages/acp/tests/session-route.test.ts +++ b/packages/acp/tests/session-route.test.ts @@ -484,6 +484,22 @@ describe("Tier SV — the bound construction route", () => { } }); + it("SV3b: a digest-only binding round-trips, and stays digest-only", function* () { + // A build that reported no version it recognized was still observed + // exactly. The record says so by omitting the member, and reading it back + // must not invent one — a version written in later would be a claim about + // a session nobody made. + const digestOnly = { + schema: "executable-build.v1", + executableDigest: { algorithm: "sha256", value: "c".repeat(64) }, + } as const; + const route = boundClientNative({ executableBinding: digestOnly }); + const text = serializeAgentSessionRoute(route); + expect(text).not.toContain("reportedVersion"); + const round = parseAgentSessionRoute(JSON.parse(text)); + expect(round).toEqual(route); + }); + it("SV4: serialization preserves the schema it was given", function* () { // Nothing here upgrades a route. A V1 record read and written again is // still V1, which is what keeps a build observed today from being written diff --git a/packages/cli/src/agent-stack.ts b/packages/cli/src/agent-stack.ts index e7b42d744..6d7fbb5e9 100644 --- a/packages/cli/src/agent-stack.ts +++ b/packages/cli/src/agent-stack.ts @@ -162,7 +162,9 @@ export function hostAcpDependencies(stack: AuthorshipStack): AcpxProviderDepende ...(sessions.coordinator ? { coordinator: sessions.coordinator } : {}), ...(sessions.routeStore ? { routeStore: sessions.routeStore } : {}), ...(sessions.executableObserver ? { executableObserver: sessions.executableObserver } : {}), - ...(sessions.compatibility ? { compatibility: sessions.compatibility } : {}), + ...(sessions.nativeCapabilityPolicy + ? { nativeCapabilityPolicy: sessions.nativeCapabilityPolicy } + : {}), advertiseNativeLaunch: sessions.advertiseNativeLaunch, advertiseClientNativeAttachment: sessions.advertiseClientNativeAttachment, }; diff --git a/packages/cli/src/session-coordinator.ts b/packages/cli/src/session-coordinator.ts index 806dd6f4a..634bfdd11 100644 --- a/packages/cli/src/session-coordinator.ts +++ b/packages/cli/src/session-coordinator.ts @@ -23,11 +23,11 @@ import { ADVERTISED_CLIENT_NATIVE_ATTACHMENT, ADVERTISED_NATIVE_LAUNCH, createDenoSessionRouteStore, - nativeCapabilityCompatibility, + nativeCapabilityPolicy, } from "@executablemd/acp"; import type { AgentSessionRouteStore, - NativeCapabilityCompatibility, + NativeCapabilityPolicy, NativeCapabilityHost, } from "@executablemd/acp"; @@ -52,14 +52,14 @@ export interface MachineSessionAssembly { advertiseNativeLaunch: readonly string[]; advertiseClientNativeAttachment: readonly string[]; /** - * Which exact builds this host admits each capability on, and the machine it - * admits them for. + * Which protocol shapes this host admits each capability on, and the machine + * it admits them for. * * Beside the observer rather than derived from the names above, because the * names are a coarse selection: an adapter reaches the question through them * and is answered here. Absent admits nothing. */ - compatibility?: NativeCapabilityCompatibility; + nativeCapabilityPolicy?: NativeCapabilityPolicy; } /** This host's session coordinator, or nothing when it cannot provide one. */ @@ -93,9 +93,9 @@ export function useExecutableObserver(): ExecutableObserver | undefined { * * `host` is passed in rather than read here, and read at the entrypoint rather * than anywhere below it. Which OS and architecture are underneath is exactly - * the fact a capability point is matched against, so a module that went and - * found it for itself would be supplying the answer as well as the question — - * and a case stating an exact point could never contradict it. + * the fact an admission is matched against, so a module that went and found it + * for itself would be supplying the answer as well as the question — and a case + * stating an exact machine could never contradict it. */ export function useMachineSessions(host: NativeCapabilityHost): MachineSessionAssembly { return { @@ -106,7 +106,7 @@ export function useMachineSessions(host: NativeCapabilityHost): MachineSessionAs : { executableObserver: useExecutableObserver() }), advertiseNativeLaunch: ADVERTISED_NATIVE_LAUNCH, advertiseClientNativeAttachment: ADVERTISED_CLIENT_NATIVE_ATTACHMENT, - compatibility: nativeCapabilityCompatibility(host), + nativeCapabilityPolicy: nativeCapabilityPolicy(host), }; } diff --git a/packages/cli/tests/agent-session-coordinator.test.ts b/packages/cli/tests/agent-session-coordinator.test.ts index 19f1b8ca2..88bbcdd79 100644 --- a/packages/cli/tests/agent-session-coordinator.test.ts +++ b/packages/cli/tests/agent-session-coordinator.test.ts @@ -58,10 +58,12 @@ import { const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "src"); -/** The machine the shipped Claude points were proved on. */ +/** The machine the shipped Claude admissions were proved on. */ const PROVED_HOST: NativeCapabilityHost = { platform: "darwin", architecture: "arm64" }; -const CLAUDE_PROVED_VERSION = "2.1.241 (Claude Code)"; +/** What the built-in Claude adapter is, and what its observer answers as. */ +const CLAUDE_PROTOCOL = "claude-client-native.v1"; +const CLAUDE_PROBE_PROFILE = "claude-help-native-session.v1"; /** Whether this is the runtime that can take a kernel-released advisory lock. */ function onDeno(): boolean { @@ -203,6 +205,7 @@ function launchRequest(agent = "claude"): AgentLaunchRequest { */ const PROVIDER_RETURNED: NativeAdapter = { launcher: "claude", + protocol: "claude-provider-returned.v1", identity: "provider-returned", resume: (nativeSessionId) => ["claude", "--resume", nativeSessionId], }; @@ -210,8 +213,18 @@ const PROVIDER_RETURNED: NativeAdapter = { /** The Claude-shaped build contract a client-allocated adapter carries. */ const BINDING: NativeBinding = { command: "claude", - version: (output) => { - const line = output.trim(); + metadata: [ + { name: "help", args: ["--help"] }, + { name: "version", args: ["--version"] }, + ], + // Whatever the fake observer answered declares the shape this host proved, + // because the subject here is which host assembles a policy at all. + probe: () => ({ + probeProfile: CLAUDE_PROBE_PROFILE, + capabilities: ["native-launch", "client-native-attachment"], + }), + reportedVersion: (metadata) => { + const line = (metadata.version?.stdout ?? "").trim(); return /^\d+\.\d+\.\d+ \(Claude Code\)$/.test(line) ? line : undefined; }, environment: (livePath) => ({ CLAUDE_CODE_EXECUTABLE: livePath }), @@ -220,6 +233,7 @@ const BINDING: NativeBinding = { /** A Claude-shaped adapter that names its own sessions. */ const CLIENT_ALLOCATED: NativeAdapter = { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: BINDING, allocate: () => randomUUID(), @@ -312,12 +326,12 @@ function* launchUnder( ...(coordinator ? { coordinator } : {}), ...(options.routeStore ? { routeStore: options.routeStore } : {}), ...(options.observer ? { executableObserver: options.observer } : {}), - // The points this host states, for the machine they were proved on rather - // than the one running the suite: the subject is what a host assembles, - // and stating the pair as a value is how a host does it. - ...(useMachineSessions(PROVED_HOST).compatibility === undefined + // The admissions this host states, for the machine they were proved on + // rather than the one running the suite: the subject is what a host + // assembles, and stating the policy as a value is how a host does it. + ...(useMachineSessions(PROVED_HOST).nativeCapabilityPolicy === undefined ? {} - : { compatibility: useMachineSessions(PROVED_HOST).compatibility }), + : { nativeCapabilityPolicy: useMachineSessions(PROVED_HOST).nativeCapabilityPolicy }), }); yield* factory( { defaultAgent: agent, permissionMode: "deny-all" }, @@ -590,54 +604,67 @@ describe("Tier HC — host session ownership", () => { }); it("HC13: the machine an assembly admits for is the one it was handed", function* () { - // A name is a selection; a point is the proof. The assembly states the - // proved points beside the observer, and the machine they are proved for - // arrives as a value, so the same shared code admits nothing on a host it - // was never proved against. - const proved = useMachineSessions(PROVED_HOST).compatibility; + // A name is a selection; an admission is the proof. The assembly states the + // proved admissions beside the observer, and the machine they are proved + // for arrives as a value, so the same shared code admits nothing on a host + // it was never proved against. + const proved = useMachineSessions(PROVED_HOST).nativeCapabilityPolicy; expect(proved?.host).toEqual(PROVED_HOST); - expect(proved?.points).toEqual([ + expect(proved?.admissions).toEqual([ { - agent: "claude", + adapterProtocol: CLAUDE_PROTOCOL, capability: "native-launch", - reportedVersion: CLAUDE_PROVED_VERSION, + probeProfile: CLAUDE_PROBE_PROFILE, ...PROVED_HOST, }, { - agent: "claude", + adapterProtocol: CLAUDE_PROTOCOL, capability: "client-native-attachment", - reportedVersion: CLAUDE_PROVED_VERSION, + probeProfile: CLAUDE_PROBE_PROFILE, ...PROVED_HOST, }, ]); + // No Claude Code version appears anywhere in what this host proved: which + // build ran is a fact about a route, not a term of admission. + expect(JSON.stringify(proved)).not.toContain("Claude Code"); const claudeLaunch = { - agent: "claude", + adapterProtocol: CLAUDE_PROTOCOL, capability: "native-launch", - reportedVersion: CLAUDE_PROVED_VERSION, + probeProfile: CLAUDE_PROBE_PROFILE, } as const; expect(admitsNativeCapability(proved, claudeLaunch)).toBe(true); + // The Agent name is not what is matched. A different adapter answering for + // the same registry name is a different protocol, and nothing proved it. + expect( + admitsNativeCapability(proved, { ...claudeLaunch, adapterProtocol: "claude-fork.v1" }), + ).toBe(false); + // Nor is the declaration: a build that answered a profile this host never + // proved is a shape nobody has evidence for. + expect( + admitsNativeCapability(proved, { ...claudeLaunch, probeProfile: "claude-help-native.v2" }), + ).toBe(false); for (const elsewhere of [ { platform: "linux", architecture: "arm64" }, { platform: "darwin", architecture: "x64" }, ]) { expect( - admitsNativeCapability(useMachineSessions(elsewhere).compatibility, claudeLaunch), + admitsNativeCapability(useMachineSessions(elsewhere).nativeCapabilityPolicy, claudeLaunch), ).toBe(false); } // Node and Bun keep the coarse names and assemble no authority, so the // advertised name reaches a question this profile answers with a refusal. const unassembled = unassembledMachineSessions(); expect([...unassembled.advertiseNativeLaunch]).toEqual(["claude"]); - expect(unassembled.compatibility).toBe(undefined); - expect(admitsNativeCapability(unassembled.compatibility, claudeLaunch)).toBe(false); + expect(unassembled.nativeCapabilityPolicy).toBe(undefined); + expect(admitsNativeCapability(unassembled.nativeCapabilityPolicy, claudeLaunch)).toBe(false); // Shared assembly is handed the machine rather than reading it: only the // entrypoints checked in HC5 name the running runtime. expect((yield* entrypoint("session-coordinator.ts")).includes("process.platform")).toBe(false); - // The workflow profile advertises neither capability and states no points, + // The workflow profile advertises neither capability and states no policy, // so a workflow Claude prompt never reaches this question at all. const workflow = yield* entrypoint("workflow-agent.ts"); expect(workflow.includes("advertiseNativeLaunch: [],")).toBe(true); expect(workflow.includes("advertiseClientNativeAttachment: [],")).toBe(true); - expect(workflow.includes("compatibility:")).toBe(false); + expect(workflow.includes("nativeCapabilityPolicy")).toBe(false); }); }); diff --git a/packages/core/src/agent/launch-journal.ts b/packages/core/src/agent/launch-journal.ts index d268b2b6e..6a38ee181 100644 --- a/packages/core/src/agent/launch-journal.ts +++ b/packages/core/src/agent/launch-journal.ts @@ -132,7 +132,17 @@ function provenance(value: unknown): IdentityProvenance | undefined { return value === "provider-returned" || value === "client-allocated" ? value : undefined; } -const BINDING_MEMBERS = ["schema", "reportedVersion", "executableDigest"]; +const BINDING_MEMBERS = ["schema", "executableDigest"]; +/** + * The one member a binding may omit. + * + * Separate from the required set rather than merged into it, because a binding + * is compared for equality: an unexpected member is still a fact the writer + * thought was part of the build's identity, and reading past it would call two + * different builds the same one. Omitting this one is not an unexpected member + * — it is the record saying the build reported no version it recognized. + */ +const BINDING_OPTIONAL_MEMBERS = ["reportedVersion"]; const DIGEST_MEMBERS = ["algorithm", "value"]; const LOWERCASE_SHA256 = /^[0-9a-f]{64}$/; @@ -141,24 +151,38 @@ function exactMembers(value: Record, members: readonly string[] return keys.length === members.length && members.every((member) => keys.includes(member)); } +function declaredMembers( + value: Record, + required: readonly string[], + optional: readonly string[], +): boolean { + const keys = Object.keys(value); + return ( + required.every((member) => keys.includes(member)) && + keys.every((key) => required.includes(key) || optional.includes(key)) + ); +} + /** * Read a retained build binding strictly. * - * The member set is exact rather than minimal, because a binding is compared - * for equality: a member this build ignores is a fact the writer thought was - * part of the build's identity, and comparing without it would call two - * different builds the same one. + * The digest is required and exact — it is what binds a session to a build. + * A present `reportedVersion` must still be a real one: a member written as an + * empty string is a claim about a release nobody can reproduce, which is not + * the same as having made no claim. */ function executableBinding(value: unknown): ExecutableBuildBindingV1 | undefined { - if (!isRecord(value) || !exactMembers(value, BINDING_MEMBERS)) { + if (!isRecord(value) || !declaredMembers(value, BINDING_MEMBERS, BINDING_OPTIONAL_MEMBERS)) { return undefined; } const { schema, reportedVersion, executableDigest } = value; if (schema !== "executable-build.v1") { return undefined; } - if (typeof reportedVersion !== "string" || reportedVersion.length === 0) { - return undefined; + if (reportedVersion !== undefined) { + if (typeof reportedVersion !== "string" || reportedVersion.length === 0) { + return undefined; + } } if (!isRecord(executableDigest) || !exactMembers(executableDigest, DIGEST_MEMBERS)) { return undefined; @@ -172,7 +196,7 @@ function executableBinding(value: unknown): ExecutableBuildBindingV1 | undefined } return { schema: "executable-build.v1", - reportedVersion, + ...(reportedVersion === undefined ? {} : { reportedVersion }), executableDigest: { algorithm: "sha256", value: digestValue }, }; } @@ -180,7 +204,7 @@ function executableBinding(value: unknown): ExecutableBuildBindingV1 | undefined function serializeBinding(binding: ExecutableBuildBindingV1): Json { return { schema: binding.schema, - reportedVersion: binding.reportedVersion, + ...(binding.reportedVersion === undefined ? {} : { reportedVersion: binding.reportedVersion }), executableDigest: { algorithm: binding.executableDigest.algorithm, value: binding.executableDigest.value, diff --git a/packages/core/src/agent/launch.ts b/packages/core/src/agent/launch.ts index 0ae7d79e3..d8a3b57ec 100644 --- a/packages/core/src/agent/launch.ts +++ b/packages/core/src/agent/launch.ts @@ -107,12 +107,17 @@ export type IdentityProvenance = "provider-returned" | "client-allocated"; * cannot be reproduced is refused rather than resumed. * * What is retained is deliberately not a path: a path says where a build was, - * which stops being true, while a version and a digest say which build it was, - * which does not. That also keeps the record free of host layout. + * which stops being true, while a digest says which build it was, which does + * not. That also keeps the record free of host layout. + * + * The digest is what binds. `reportedVersion` is optional evidence beside it: + * an executable that will not say which release it is, says something this + * provider does not recognize, or says several things is bound by its bytes + * alone rather than refused for being quiet. */ export interface ExecutableBuildBindingV1 { readonly schema: "executable-build.v1"; - readonly reportedVersion: string; + readonly reportedVersion?: string; readonly executableDigest: { readonly algorithm: "sha256"; readonly value: string; @@ -120,20 +125,27 @@ export interface ExecutableBuildBindingV1 { } /** - * Whether two bindings name the same build. + * Whether `live` is the build `retained` names. + * + * Asymmetric, because the two arguments are not the same kind of claim. The + * digest decides: identical bytes are the same build reached through whatever + * path, and different bytes are a different build however it describes itself. * - * Equality is over what was retained, so the same build reached through a - * different path is compatible and a different build at the same path is not. + * A retained version is then a claim the live build must still make. A record + * that named a release and now meets one calling itself something else — or + * nothing at all — has lost the evidence it was written with. A record that + * named none never had that evidence, so a version appearing later adds + * nothing to reproduce and does not rewrite what was retained. */ export function sameExecutableBuild( - left: ExecutableBuildBindingV1, - right: ExecutableBuildBindingV1, + retained: ExecutableBuildBindingV1, + live: ExecutableBuildBindingV1, ): boolean { return ( - left.schema === right.schema && - left.reportedVersion === right.reportedVersion && - left.executableDigest.algorithm === right.executableDigest.algorithm && - left.executableDigest.value === right.executableDigest.value + retained.schema === live.schema && + retained.executableDigest.algorithm === live.executableDigest.algorithm && + retained.executableDigest.value === live.executableDigest.value && + (retained.reportedVersion === undefined || retained.reportedVersion === live.reportedVersion) ); } diff --git a/packages/core/tests/agent-session-launch.test.ts b/packages/core/tests/agent-session-launch.test.ts index 8ce019cd5..3407cbb2b 100644 --- a/packages/core/tests/agent-session-launch.test.ts +++ b/packages/core/tests/agent-session-launch.test.ts @@ -1433,6 +1433,19 @@ describe("Tier EB — executable build binding", () => { expect(parsed?.executableBinding).toBe(undefined); }); + it("EB2b: a binding that reported no version is read as the digest-only one it is", function* () { + // What binds a session to a build is the bytes. An executable that would + // not say which release it is was still observed exactly, and a record + // that omitted the version made no claim rather than a bad one. + const digestOnly = { + schema: "executable-build.v1", + executableDigest: { algorithm: "sha256", value: "d".repeat(64) }, + }; + const parsed = parsePrepared(bound({ executableBinding: digestOnly })); + expect(parsed?.executableBinding).toEqual(digestOnly); + expect(parsed?.executableBinding?.reportedVersion).toBe(undefined); + }); + it("EB3: an inexact binding refuses rather than being read past", function* () { const cases: [string, Json][] = [ ["unknown schema", { ...BOUND, schema: "executable-build.v2" }], @@ -1469,7 +1482,7 @@ describe("Tier EB — executable build binding", () => { } }); - it("EB4: equality is over the retained build, and a path is not part of it", function* () { + it("EB4: the digest decides, and a path is not part of it", function* () { // There is no path to ignore, which is the point: the same build reached // through a different path compares equal because nothing about where it // was ever entered the record. @@ -1490,6 +1503,34 @@ describe("Tier EB — executable build binding", () => { ).toBe(false); }); + it("EB4b: a retained version is a claim the live build must still make", function* () { + // Asymmetric, because the two sides are not the same kind of claim. A + // record written with a version has evidence a quiet build cannot + // reproduce; a record written without one never had that evidence, so a + // version appearing later adds nothing to reproduce. + const digest = { algorithm: "sha256", value: "d".repeat(64) } as const; + const versioned: ExecutableBuildBindingV1 = { + schema: "executable-build.v1", + reportedVersion: "2.1.241 (Claude Code)", + executableDigest: digest, + }; + const quiet: ExecutableBuildBindingV1 = { + schema: "executable-build.v1", + executableDigest: digest, + }; + + expect(sameExecutableBuild(versioned, quiet)).toBe(false); + expect(sameExecutableBuild(quiet, versioned)).toBe(true); + expect(sameExecutableBuild(quiet, quiet)).toBe(true); + // And a changed digest refuses either way, whatever either says it is. + const moved: ExecutableBuildBindingV1 = { + ...quiet, + executableDigest: { algorithm: "sha256", value: "e".repeat(64) }, + }; + expect(sameExecutableBuild(quiet, moved)).toBe(false); + expect(sameExecutableBuild(moved, quiet)).toBe(false); + }); + it("EB5: completed replay stays provider-cold for a bound preparation", function* () { yield* replayIsCold({ identityProvenance: "client-allocated", executableBinding: BINDING }); }); diff --git a/packages/runtime/deno-executable-observer.ts b/packages/runtime/deno-executable-observer.ts index 37b6c55bc..fe2962950 100644 --- a/packages/runtime/deno-executable-observer.ts +++ b/packages/runtime/deno-executable-observer.ts @@ -19,7 +19,11 @@ import { createHash } from "node:crypto"; import { readFile, realpath, stat } from "node:fs/promises"; import { delimiter, isAbsolute, join, resolve } from "node:path"; import { ExecutableObservationError } from "./executable-observer.ts"; -import type { ExecutableObserver, ObservedExecutable } from "./executable-observer.ts"; +import type { + ExecutableMetadataObservation, + ExecutableObserver, + ObservedExecutable, +} from "./executable-observer.ts"; type HostCall = (...args: unknown[]) => unknown; @@ -73,17 +77,25 @@ export function hasDenoExecutableObserver(): boolean { return observerHost() !== undefined; } -/** What the version invocation produced, decoded. */ -function decode(value: unknown): { code: number; text: string } { +/** What one metadata query produced, decoded. */ +function decode(value: unknown): ExecutableMetadataObservation { + const decoder = new TextDecoder(); + const text = (channel: unknown) => (channel instanceof Uint8Array ? decoder.decode(channel) : ""); if (typeof value !== "object" || value === null) { - return { code: -1, text: "" }; + return { settled: false, stdout: "", stderr: "" }; } const code = Reflect.get(value, "code"); - const stdout = Reflect.get(value, "stdout"); - const decoder = new TextDecoder(); + if (typeof code !== "number") { + // A child that produced no status did not answer, whatever it wrote on the + // way. Reporting output beside an unknown status would invite reading it as + // an answer. + return { settled: false, stdout: "", stderr: "" }; + } return { - code: typeof code === "number" ? code : -1, - text: stdout instanceof Uint8Array ? decoder.decode(stdout) : "", + settled: true, + code, + stdout: text(Reflect.get(value, "stdout")), + stderr: text(Reflect.get(value, "stderr")), }; } @@ -174,29 +186,43 @@ export function createDenoExecutableObserver(overrides?: { }), ); - const versionArgs = options?.versionArgs ?? ["--version"]; - const produced = yield* until( - host - .command(path, { args: [...versionArgs], stdout: "piped", stderr: "null" }) - .output() - .catch((cause: unknown) => { - throw new ExecutableObservationError(`${command} could not be asked its version`, { - refusal: "version-unavailable", - cause, - }); - }), - ); - const version = decode(produced); - if (version.code !== 0) { - throw new ExecutableObservationError(`${command} refused to report a version`, { - refusal: "version-unavailable", - }); + // Asked with nothing: no inherited environment, no stdin, and both + // channels captured rather than attached. A metadata query is meant to + // report and exit, so it is given nothing to read, nothing to inherit and + // no terminal to draw on — and what it writes is returned to the caller + // rather than appearing on the reader's. + const metadata: Record = {}; + for (const query of options?.metadata ?? []) { + // `output()` raises rather than rejecting when the child cannot be + // spawned at all, so a failure to start is caught here as well as + // there. Either way it is an observation that did not answer, not a + // failed observation: the file was found, and asking it a question is + // not what decides whether it is the build. + let answer: unknown; + try { + answer = yield* until( + host + .command(path, { + args: [...query.args], + clearEnv: true, + env: {}, + stdin: "null", + stdout: "piped", + stderr: "piped", + }) + .output() + .catch(() => undefined), + ); + } catch { + answer = undefined; + } + metadata[query.name] = decode(answer); } return { path, digest: { algorithm: "sha256", value: createHash("sha256").update(bytes).digest("hex") }, - versionOutput: version.text, + metadata, }; }, }; diff --git a/packages/runtime/executable-observer.ts b/packages/runtime/executable-observer.ts index a7cd9d454..7adbda3a3 100644 --- a/packages/runtime/executable-observer.ts +++ b/packages/runtime/executable-observer.ts @@ -1,6 +1,7 @@ /** - * Observing which executable build a command actually runs - * (specs/native-agent-session-launch-spec.md §Executable binding). + * Observing which executable build a command actually runs, and what that exact + * file declares about itself (specs/native-agent-session-launch-spec.md + * §Executable binding). * * A provider session whose identity XMD chose itself only means something * while the build that established it can be reproduced. Two builds of one @@ -11,9 +12,10 @@ * recognize it later. * * What is observed is a canonical path, the SHA-256 of that file's bytes, and - * whatever the file says when asked its version. Only the last two ever become - * durable: a path stops being true when a build moves, and names host layout - * besides. + * the answers that same file gives to the read-only questions its adapter + * declared. Only the digest is durable by itself: a path stops being true when + * a build moves and names host layout besides, and the answers are the + * adapter's to read. * * This is a plain capability the trusted host builds and hands directly to the * provider that needs it. It is deliberately not a contextual Api. Executable @@ -21,20 +23,16 @@ * document middleware could replace is not one — a replaceable resolver could * point the observation at a different binary than the one that runs. * - * Provider-specific meaning is not this module's business: which command to - * run, what a version string looks like, and what to do about a mismatch - * belong to the adapter that knows the provider. + * Provider-specific meaning is not this module's business. Which questions to + * ask is the adapter's, what the answers mean is the adapter's, and what to do + * about a mismatch is the caller's. Here a question is argv and an answer is a + * settled status with the bytes the child wrote. */ import type { Operation } from "effection"; /** Why an executable could not be observed, in terms a caller can act on. */ -export type ExecutableRefusal = - | "not-found" - | "not-a-file" - | "not-executable" - | "unreadable" - | "version-unavailable"; +export type ExecutableRefusal = "not-found" | "not-a-file" | "not-executable" | "unreadable"; /** * An observation failure that names its reason. @@ -53,31 +51,67 @@ export class ExecutableObservationError extends Error { } } +/** + * One read-only question to ask the file that was just hashed. + * + * `name` is the caller's own label, and is what the answer comes back under, so + * an adapter reads its questions by meaning rather than by position. `args` is + * argv after the executable itself and is the adapter's whole dialect: this + * module never adds to it, and a query that did anything but report is a query + * this contract has no way to take back. + */ +export interface ExecutableMetadataQuery { + readonly name: string; + readonly args: readonly string[]; +} + +/** + * How one query settled. + * + * `settled` is false when the child never ran to completion, which is a + * different fact from running and failing: an adapter may accept a missing + * answer while refusing a wrong one. `code` exists only alongside a settled + * child, and both channels are captured rather than inherited so nothing the + * queried file writes reaches the caller's terminal. + */ +export interface ExecutableMetadataObservation { + readonly settled: boolean; + readonly code?: number; + readonly stdout: string; + readonly stderr: string; +} + +/** Every declared question's answer, under the name it was asked by. */ +export type ExecutableMetadata = Readonly>; + /** * One executable, as it exists during this invocation. * - * `path` is canonical and live: it is what a caller spawns and what it asked - * for a version, and it is absent from everything durable. `versionOutput` is - * raw — the adapter that knows the provider parses it, and neither this string - * nor the path may reach a record, a diagnostic, or the environment of - * anything but the matching child. + * `path` is canonical and live: it is what a caller spawns and what every query + * was asked, and it is absent from everything durable. `metadata` is raw — the + * adapter that knows the provider reads it, and neither those bytes nor the + * path may reach a record, a diagnostic, or the environment of anything but the + * matching child. */ export interface ObservedExecutable { path: string; digest: { algorithm: "sha256"; value: string }; - versionOutput: string; + metadata: ExecutableMetadata; } export interface ExecutableObserver { /** * Resolve `command`, canonicalize it, require an executable regular file, - * hash its bytes, and ask that exact path for its version. + * hash its bytes once, and ask that exact path each declared query. * - * Asking the same path that was hashed is the point: a version read from a + * Asking the same path that was hashed is the point: an answer read from a * differently-resolved file describes a build this observation did not make. + * A query that cannot start or cannot settle is reported as such rather than + * failing the observation — whether a missing answer is fatal is a question + * about the provider, and this module knows none. */ observe( command: string, - options?: { versionArgs?: readonly string[] }, + options?: { metadata?: readonly ExecutableMetadataQuery[] }, ): Operation; } diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index fe4e38f68..cc3322c95 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -150,6 +150,9 @@ export { } from "./deno-agent-session-coordinator.ts"; export { ExecutableObservationError } from "./executable-observer.ts"; export type { + ExecutableMetadata, + ExecutableMetadataObservation, + ExecutableMetadataQuery, ExecutableObserver, ExecutableRefusal, ObservedExecutable, diff --git a/packages/runtime/tests/executable-observer.test.ts b/packages/runtime/tests/executable-observer.test.ts index b48d85354..031c432e9 100644 --- a/packages/runtime/tests/executable-observer.test.ts +++ b/packages/runtime/tests/executable-observer.test.ts @@ -4,12 +4,15 @@ * * Real files and a real child, because every question here is about the host: * which file a name resolves to, whether a symlink and its target are one - * build, what the bytes hash to, and what that exact path says when asked. + * build, what the bytes hash to, and what that exact path answers when asked + * the read-only questions a caller declared. * - * The redaction cases matter as much as the observation ones. The canonical - * path is live capability — it is spawned, and it reaches the matching ACP - * child — and it must not be recoverable from anything this module hands to a - * caller that will retain it. + * What this module must not do matters as much as what it does. It knows no + * provider: it runs the argv it was handed and reports the exit status and the + * captured channels, and every reading of those belongs to whoever asked. The + * canonical path is live capability — it is spawned, and it reaches the + * matching ACP child — and it must not be recoverable from anything this + * module hands to a caller that will retain it. */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; @@ -20,6 +23,7 @@ import { createHash, randomUUID } from "node:crypto"; import { chmod, realpath, symlink } from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import process from "node:process"; import { until } from "effection"; import { createDenoExecutableObserver, @@ -56,6 +60,16 @@ function* script(root: string, name: string, version: string): Operation return file; } +/** A real executable that answers different argv differently. */ +function* answering(root: string, name: string, body: string): Operation { + const file = path.join(root, name); + yield* writeTextFile(file, `#!/bin/sh\n${body}\n`); + yield* until(chmod(file, 0o755)); + return file; +} + +const VERSION_QUERY = [{ name: "version", args: ["--version"] }]; + function* refusalOf(op: () => Operation): Operation { try { yield* op(); @@ -72,19 +86,24 @@ describe("Tier EO — executable observation", () => { expect(createDenoExecutableObserver() === undefined).toBe(!onDeno); }); - it("EO2: the digest is over the file's bytes, and the version comes from that same file", function* () { + it("EO2: the digest is over the file's bytes, and a query answers from that same file", function* () { const root = yield* workspace(); const file = yield* script(root, "claude", "2.1.235 (Claude Code)"); const observer = createDenoExecutableObserver()!; - const observed = yield* observer.observe(file); + const observed = yield* observer.observe(file, { metadata: VERSION_QUERY }); const bytes = `#!/bin/sh\necho "2.1.235 (Claude Code)"\n`; expect(observed.digest).toEqual({ algorithm: "sha256", value: createHash("sha256").update(bytes).digest("hex"), }); - expect(observed.versionOutput.trim()).toBe("2.1.235 (Claude Code)"); + expect(observed.metadata.version).toEqual({ + settled: true, + code: 0, + stdout: "2.1.235 (Claude Code)\n", + stderr: "", + }); expect(observed.path).toBe(yield* canonical(file)); }); @@ -111,14 +130,14 @@ describe("Tier EO — executable observation", () => { const root = yield* workspace(); const file = yield* script(root, "claude", "2.1.235"); const observer = createDenoExecutableObserver()!; - const before = yield* observer.observe(file); + const before = yield* observer.observe(file, { metadata: VERSION_QUERY }); yield* script(root, "claude", "2.2.0"); - const after = yield* observer.observe(file); + const after = yield* observer.observe(file, { metadata: VERSION_QUERY }); expect(after.path).toBe(before.path); expect(after.digest.value).not.toBe(before.digest.value); - expect(after.versionOutput.trim()).toBe("2.2.0"); + expect(after.metadata.version.stdout.trim()).toBe("2.2.0"); }); it("EO5: PATH search finds a bare name, from the observer's own environment", function* () { @@ -143,22 +162,102 @@ describe("Tier EO — executable observation", () => { yield* ensureDir(directory); const unreadable = path.join(root, "plain"); yield* writeTextFile(unreadable, "not a program\n"); - const broken = path.join(root, "broken"); - yield* writeTextFile(broken, "#!/bin/sh\nexit 3\n"); - yield* until(chmod(broken, 0o755)); expect(yield* refusalOf(() => observer.observe(""))).toBe("not-found"); expect(yield* refusalOf(() => observer.observe("absent-command"))).toBe("not-found"); expect(yield* refusalOf(() => observer.observe(directory))).toBe("not-a-file"); expect(yield* refusalOf(() => observer.observe(unreadable))).toBe("not-executable"); - // Present, executable, and unwilling to say what it is: a build that - // cannot be identified is not one a session may be bound to. - expect(yield* refusalOf(() => observer.observe(broken))).toBe("version-unavailable"); }); - it("EO7: a refusal carries no path, and an observation retains none", function* () { + it("EO7: what a query answered is reported, never judged", function* () { + // A build that will not say what it is has still been observed. Whether + // that is fatal is the caller's question about its own capability, and a + // module that refused here would be answering it for every caller. + const root = yield* workspace(); + const observer = createDenoExecutableObserver({ path: root })!; + const quiet = yield* answering(root, "quiet", `echo "trouble" >&2\nexit 3`); + + const observed = yield* observer.observe(quiet, { metadata: VERSION_QUERY }); + + expect(observed.metadata.version).toEqual({ + settled: true, + code: 3, + stdout: "", + stderr: "trouble\n", + }); + expect(observed.digest.algorithm).toBe("sha256"); + }); + + it("EO8: a child that never started answered nothing at all", function* () { + // An executable regular file whose interpreter does not exist. Reporting + // empty output beside an unknown status would let a build that could not + // run look like one that answered nothing. + const root = yield* workspace(); + const observer = createDenoExecutableObserver({ path: root })!; + const broken = yield* answering(root, "broken", ""); + yield* writeTextFile(broken, `#!/xmd-no-such-interpreter\n`); + yield* until(chmod(broken, 0o755)); + + const observed = yield* observer.observe(broken, { metadata: VERSION_QUERY }); + + expect(observed.metadata.version).toEqual({ settled: false, stdout: "", stderr: "" }); + }); + + it("EO9: each declared query runs at the observed path, and only those", function* () { + const root = yield* workspace(); + const observer = createDenoExecutableObserver({ path: root })!; + const file = yield* answering(root, "claude", `echo "asked: $*"`); + + const observed = yield* observer.observe(file, { + metadata: [ + { name: "help", args: ["--help"] }, + { name: "version", args: ["--version"] }, + ], + }); + + expect(observed.metadata.help.stdout.trim()).toBe("asked: --help"); + expect(observed.metadata.version.stdout.trim()).toBe("asked: --version"); + expect(Object.keys(observed.metadata).sort()).toEqual(["help", "version"]); + // A caller that declared none asked none. + const silent = yield* observer.observe(file); + expect(silent.metadata).toEqual({}); + }); + + it("EO10: a query is asked with nothing — no environment, no stdin, no terminal", function* () { + // A read-only question given a working environment could read a credential + // out of it, and one attached to a terminal could draw on the reader's. + const root = yield* workspace(); + const observer = createDenoExecutableObserver({ path: root })!; + // `PATH` and `HOME` are set in every process that runs this suite, so an + // inherited environment would be visible rather than merely possible. The + // shell sets a few of its own on the way in, which is the shell's doing and + // not this observer's, so what is asserted is that nothing crossed. + const file = yield* answering( + root, + "claude", + `echo "path: [$PATH]"\n` + + `echo "home: [$HOME]"\n` + + `echo "stdin: [$(cat)]"\n` + + `if [ -t 1 ]; then echo "tty: yes"; else echo "tty: no"; fi`, + ); + + const observed = yield* observer.observe(file, { metadata: VERSION_QUERY }); + + const answered = observed.metadata.version.stdout.split("\n"); + expect(answered).toContain("home: []"); + expect(answered).toContain("stdin: []"); + expect(answered).toContain("tty: no"); + // `PATH` is the one `/bin/sh` supplies a default for when it inherits + // none, so its emptiness would prove nothing. What proves the environment + // was cleared is that the value this process actually has did not cross. + const inherited = process.env.PATH ?? ""; + expect(inherited.length).toBeGreaterThan(0); + expect(answered).not.toContain(`path: [${inherited}]`); + }); + + it("EO11: a refusal carries no path, and an observation retains none", function* () { // The canonical path is live capability. What a caller keeps is the digest - // and the version; the path is spawned and forgotten. + // and whatever it made of the answers; the path is spawned and forgotten. const root = yield* workspace(); const secret = path.join(root, "secret-layout"); yield* ensureDir(secret); @@ -174,15 +273,15 @@ describe("Tier EO — executable observation", () => { expect(message).not.toContain(secret); expect(message).not.toContain(root); - // And the durable half of an observation is exactly two facts. + // And an observation is exactly three facts. const file = yield* script(secret, "claude", "2.1.235"); - const observed = yield* observer.observe(file); + const observed = yield* observer.observe(file, { metadata: VERSION_QUERY }); const retained = { schema: "executable-build.v1", - reportedVersion: observed.versionOutput.trim(), + reportedVersion: observed.metadata.version.stdout.trim(), executableDigest: observed.digest, }; expect(JSON.stringify(retained)).not.toContain(root); - expect(Object.keys(observed).sort()).toEqual(["digest", "path", "versionOutput"]); + expect(Object.keys(observed).sort()).toEqual(["digest", "metadata", "path"]); }); }); diff --git a/packages/test-agent/src/executable-observer.ts b/packages/test-agent/src/executable-observer.ts index 47e7df0e9..c38ee54ee 100644 --- a/packages/test-agent/src/executable-observer.ts +++ b/packages/test-agent/src/executable-observer.ts @@ -5,25 +5,35 @@ * A session whose identity XMD chose is only meaningful while the build that * accepted it can be recognized later, and the provider will not act on one * without an observer to ask. There is no real executable here, so this answers - * the same question deterministically: one stable digest and one canonical - * version for the life of the partition. + * the same question deterministically: one stable digest, one declared help + * surface and one canonical version for the life of the partition. + * + * The whole observation is injected rather than any one answer being patched, + * because that is what keeps the production path unweakened: a scenario that + * wants a capability to go missing removes a declaration from what this + * answers, and the same probe reads it. * * Its lifetime is that partition, which is what makes two sibling `` - * elements two worlds rather than two views of one. A harness that wants to - * watch a build drift replaces what this whole seam answers, rather than - * reaching for a control the production path also has. + * elements two worlds rather than two views of one. */ import { createHash } from "node:crypto"; -import { TEST_AGENT_BUILD_VERSION } from "./provider.ts"; -import type { ExecutableObserver } from "@executablemd/runtime"; +import { TEST_AGENT_BUILD_VERSION, TEST_AGENT_HELP } from "./provider.ts"; +import type { ExecutableMetadata, ExecutableObserver } from "@executablemd/runtime"; + +/** One query's answer, in the shape a real observation produces. */ +function reported(stdout: string): ExecutableMetadata[string] { + return { settled: true, code: 0, stdout, stderr: "" }; +} export interface ControlledExecutableObserver { observer: ExecutableObserver; /** What the next observation answers. Change it to drift the build. */ - observed: { path: string; digest: string; versionOutput: string }; + observed: { path: string; digest: string; metadata: Record }; /** Every command this partition was asked about, in order. */ asked: string[]; + /** Every metadata query it was asked to run, as `name argv…`, in order. */ + queried: string[]; } export function createControlledExecutableObserver( @@ -31,21 +41,28 @@ export function createControlledExecutableObserver( ): ControlledExecutableObserver { const controlled: ControlledExecutableObserver = { asked: [], + queried: [], observed: { // Never a real path: nothing is spawned, and the value exists only so // that a test can prove it does not reach a record. path: `/xmd-test-agent/${seed}/ui`, digest: createHash("sha256").update(seed).digest("hex"), - versionOutput: `${TEST_AGENT_BUILD_VERSION}\n`, + metadata: { + help: reported(TEST_AGENT_HELP), + version: reported(`${TEST_AGENT_BUILD_VERSION}\n`), + }, }, observer: { // deno-lint-ignore require-yield - *observe(command) { + *observe(command, options) { controlled.asked.push(command); + for (const query of options?.metadata ?? []) { + controlled.queried.push([query.name, ...query.args].join(" ")); + } return { path: controlled.observed.path, digest: { algorithm: "sha256", value: controlled.observed.digest }, - versionOutput: controlled.observed.versionOutput, + metadata: controlled.observed.metadata, }; }, }, diff --git a/packages/test-agent/src/provider.ts b/packages/test-agent/src/provider.ts index 5500c6ed1..cd13e1395 100644 --- a/packages/test-agent/src/provider.ts +++ b/packages/test-agent/src/provider.ts @@ -21,10 +21,16 @@ import type { import type { AgentSessionRouteStore, NativeAdapter, - NativeCapabilityCompatibility, + NativeCapability, + NativeCapabilityPolicy, + ProbedNativeCapabilities, } from "@executablemd/acp"; import { useRouteSlot } from "./route-slot.ts"; -import type { AgentSessionCoordinator, ExecutableObserver } from "@executablemd/runtime"; +import type { + AgentSessionCoordinator, + ExecutableMetadata, + ExecutableObserver, +} from "@executablemd/runtime"; import type { AcpAgentRegistry, AcpSessionRecord, AcpSessionStore } from "acpx/runtime"; /** @@ -101,6 +107,7 @@ export const TEST_AGENT_LAUNCHER = "test-agent"; export const TEST_AGENT_NATIVE_ADAPTER: NativeAdapter = { launcher: TEST_AGENT_LAUNCHER, + protocol: "test-agent-provider-returned.v1", // The worker asserts its own session identity, which is the provider-returned // contract. It stays that, so #518's path keeps being exercised. identity: "provider-returned", @@ -121,17 +128,84 @@ export const TEST_AGENT_CLIENT_NATIVE = "test-agent-client-native"; /** The version this partition's controlled build reports. Shaped like a real one. */ export const TEST_AGENT_BUILD_VERSION = "9.9.9 (Test Agent)"; +/** + * This partition's own protocol and probe. + * + * Its own rather than Claude's, so nothing a `` scenario admits can + * reach a real adapter, and nothing a real adapter proved can admit this one. + */ +export const TEST_AGENT_PROTOCOL = "test-agent-client-native.v1"; +export const TEST_AGENT_PROBE_PROFILE = "test-agent-native-session.v1"; + +/** + * The help surface this partition's controlled build declares. + * + * Written out rather than assumed, because the probe below reads it the way a + * real probe reads a real CLI: a scenario that wants to watch a capability go + * missing removes a declaration from what the observer answers with. + */ +export const TEST_AGENT_HELP = [ + "Usage: xmd-test-agent-ui [options]", + "", + "Options:", + " --session-id create the session under this identity", + " --resume resume exactly this session", + " --system-prompt-file read the private instruction layer from a file", + "", +].join("\n"); + +/** The output one query produced, or nothing when it did not answer. */ +function answered(metadata: ExecutableMetadata, name: string): string | undefined { + const observation = metadata[name]; + if (observation === undefined || !observation.settled || observation.code !== 0) { + return undefined; + } + return observation.stdout; +} + +/** + * What this partition's controlled build declares about a native launch. + * + * Structural for the same reason a real probe is: what is read is whether the + * three operations a launch needs are declared, not which release declared + * them. Both capabilities come from the same declarations here because this + * build has no separate ACP bridge to prove. + */ +function testAgentProbe(metadata: ExecutableMetadata): ProbedNativeCapabilities { + const capabilities: NativeCapability[] = []; + const help = answered(metadata, "help"); + const declares = (flag: string) => new RegExp(`^ {2}${flag} <`, "m").test(help ?? ""); + if ( + help !== undefined && + help.includes("Usage: xmd-test-agent-ui") && + declares("--session-id") && + declares("--resume") && + declares("--system-prompt-file") + ) { + capabilities.push("native-launch", "client-native-attachment"); + } + return { probeProfile: TEST_AGENT_PROBE_PROFILE, capabilities }; +} + /** A controlled adapter that names its own sessions, in the test agent's dialect. */ export const TEST_AGENT_CLIENT_NATIVE_ADAPTER: NativeAdapter = { launcher: TEST_AGENT_LAUNCHER, + protocol: TEST_AGENT_PROTOCOL, identity: "client-allocated", binding: { command: "xmd-test-agent-ui", - version: (output) => - output + metadata: [ + { name: "help", args: ["--help"] }, + { name: "version", args: ["--version"] }, + ], + probe: testAgentProbe, + reportedVersion: (metadata) => { + const canonical = (answered(metadata, "version") ?? "") .split("\n") .map((line) => line.trim()) - .find((line) => /^\d+\.\d+\.\d+ \(Test Agent\)$/.test(line)), + .filter((line) => /^\d+\.\d+\.\d+ \(Test Agent\)$/.test(line)); + return canonical.length === 1 ? canonical[0] : undefined; + }, // No pinned adapter command: this partition's registry answers with the // scenario route, and pinning one would name a process that does not exist. environment: (livePath) => ({ XMD_TEST_AGENT_EXECUTABLE: livePath }), @@ -148,24 +222,24 @@ export const TEST_AGENT_CLIENT_NATIVE_ADAPTER: NativeAdapter = { }; /** - * The points this partition admits, for the machine it is running on. + * What this partition admits, for the machine it is running on. * * This partition is its own trusted host — it supplies its own coordinator, - * route store and observer — so it also says which builds it has proved. What - * a real host proves by driving an installed CLI, this one proves by being the - * build: the controlled UI reports exactly `TEST_AGENT_BUILD_VERSION` and does - * the same thing everywhere, so the live pair is what the point is stated for. - * Reading the machine here rather than in `@executablemd/acp` is the whole - * point of the seam — a scenario stating an exact foreign point still refuses. + * route store and observer — so it also says what it has proved. What a real + * host proves by driving an installed CLI, this one proves by being the build: + * the controlled UI declares exactly `TEST_AGENT_HELP` and does the same thing + * everywhere, so its own protocol and probe are what is admitted. Reading the + * machine here rather than in `@executablemd/acp` is the whole point of the + * seam — a scenario stating an exact foreign machine still refuses. */ -function testAgentCompatibility(): NativeCapabilityCompatibility { +function testAgentPolicy(): NativeCapabilityPolicy { const host = { platform: process.platform, architecture: process.arch }; return { host, - points: (["native-launch", "client-native-attachment"] as const).map((capability) => ({ - agent: TEST_AGENT_CLIENT_NATIVE, + admissions: (["native-launch", "client-native-attachment"] as const).map((capability) => ({ + adapterProtocol: TEST_AGENT_PROTOCOL, capability, - reportedVersion: TEST_AGENT_BUILD_VERSION, + probeProfile: TEST_AGENT_PROBE_PROFILE, ...host, })), }; @@ -232,7 +306,7 @@ export function* useTestAgentProvider( ...(options.coordinator ? { coordinator: options.coordinator } : {}), ...(options.routeStore ? { routeStore: options.routeStore } : {}), ...(options.executableObserver ? { executableObserver: options.executableObserver } : {}), - compatibility: testAgentCompatibility(), + nativeCapabilityPolicy: testAgentPolicy(), ...(options.dependencies?.createRuntime ? { createRuntime: options.dependencies.createRuntime } : {}), From bb275f81dd4d4eeb97ccebaacc0d9b2058f5683d Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 09:20:56 -0400 Subject: [PATCH 06/22] =?UTF-8?q?=F0=9F=94=92=20Admit=20a=20Claude=20opera?= =?UTF-8?q?tion=20by=20the=20value=20its=20option=20takes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The help probe read flag spellings. `--session-id `, `--resume ` and `--system-prompt-file ` all satisfied it, so a build that accepts the spelling and means something else by it was admitted for native launch — and the launch would then hand a UUID to an option that takes a name. Each declaration is now parsed into its spellings, its value placeholder, whether that value is required, and its own description. Chosen identity needs the sole `--session-id` to require a UUID. Exact resume needs the sole `--resume` to name session identity, either in its placeholder or, for the uncommitted `[value]` spelling Claude ships today, in that entry's own words. Private instructions need a file-valued `--system-prompt-file`, or the `--system-prompt[-file]` family spelling inside a parsed entry. Product is read from dedicated unindented lines, so a compatibility claim or a quoted usage example no longer says what this executable is. Two entries declaring one spelling is unreadable, not a choice between them. --- packages/acp/src/native-launch.ts | 206 ++++++++++++++++++----- packages/acp/tests/native-launch.test.ts | 202 ++++++++++++++++++++++ 2 files changed, 367 insertions(+), 41 deletions(-) diff --git a/packages/acp/src/native-launch.ts b/packages/acp/src/native-launch.ts index ca4f54b12..f51cae896 100644 --- a/packages/acp/src/native-launch.ts +++ b/packages/acp/src/native-launch.ts @@ -169,48 +169,174 @@ function optionEntries(help: string): string[] { return entries; } -/** What one option entry declares: its spellings, and whether it takes a value. */ -function declaredFlags(entry: string): { flags: string[]; takesValue: boolean } { +/** + * One option declaration, kept in parts rather than collapsed to a verdict. + * + * The parts are what make this a value contract instead of a spelling check. + * "Takes a value" cannot tell `--session-id ` from `--session-id `, + * and a launch that supplied a UUID to the second would be naming a session by + * something the build does not accept as one. So the placeholder is retained + * whole, and so is the description an entry uses to say what its value is. + */ +interface OptionDeclaration { + /** Every spelling this entry declares — `-r` and `--resume` alike. */ + readonly flags: readonly string[]; + /** The declared value with its brackets stripped, absent for a bare flag. */ + readonly placeholder: string | undefined; + /** Whether the value is required (``) rather than optional (`[value]`). */ + readonly required: boolean; + /** Everything after the head, whitespace-normalized and lowercased. */ + readonly description: string; + /** The whole entry, for a spelling this adapter reads inside a description. */ + readonly entry: string; +} + +/** Whitespace-insensitive text, for reading prose rather than layout. */ +function normalized(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +/** + * One entry, split into the spellings it declares and the value it takes. + * + * An entry's head is its spellings and at most one placeholder; the first token + * that is neither has begun the description. The placeholder is matched against + * the rejoined remainder rather than a single token so a value written with + * spaces inside its brackets is still one value. + */ +function declaredOption(entry: string): OptionDeclaration { + const tokens = entry.split(" ").filter((token) => token.length > 0); const flags: string[] = []; - for (const raw of entry.split(" ")) { + let index = 0; + for (; index < tokens.length; index += 1) { + const raw = tokens[index] ?? ""; const token = raw.endsWith(",") ? raw.slice(0, -1) : raw; - if (/^-{1,2}[A-Za-z0-9][\w-]*$/.test(token)) { - flags.push(token); - continue; + if (!/^-{1,2}[A-Za-z0-9][\w-]*$/.test(token)) { + break; } - // The head of an entry is its spellings and at most one value placeholder. - // Anything else has begun the description, and a description is prose. - return { flags, takesValue: token.startsWith("<") || token.startsWith("[") }; + flags.push(token); } - return { flags, takesValue: false }; + const rest = tokens.slice(index).join(" "); + const value = /^<([^<>]*)>|^\[([^[\]]*)\]/.exec(rest); + return { + flags, + placeholder: value === null ? undefined : (value[1] ?? value[2]), + required: value !== null && rest.startsWith("<"), + description: normalized(value === null ? rest : rest.slice(value[0].length)).toLowerCase(), + entry, + }; +} + +/** A placeholder compared by its letters, so `` and `[sessionId]` agree. */ +function placeholderName(placeholder: string): string { + return placeholder.replace(/[^a-z0-9]/gi, "").toLowerCase(); } /** - * How this executable declares one option, if it declares it at all. + * The one entry declaring `flag`, or nothing when it is absent or ambiguous. * - * `ambiguous` is separate from `absent` on purpose. Two entries declaring one - * spelling is output this adapter cannot read as a single answer, and choosing - * either would be guessing which one a launch would reach. + * Two entries declaring one spelling is output this adapter cannot read as a + * single answer, and choosing either would be guessing which one a launch + * would reach. Both are the same absence of evidence. */ -function declaresOption( - entries: string[], +function soleDeclaration( + options: readonly OptionDeclaration[], flag: string, -): "absent" | "ambiguous" | "flag" | "valued" { - const matched = entries - .map(declaredFlags) - .filter((declaration) => declaration.flags.includes(flag)); - if (matched.length === 0) { - return "absent"; +): OptionDeclaration | undefined { + const matched = options.filter((option) => option.flags.includes(flag)); + return matched.length === 1 ? matched[0] : undefined; +} + +/** + * Whether this help surface is Claude Code's own. + * + * Read from dedicated, unindented lines. Every option entry and every wrapped + * continuation of one is indented, so a product named inside a description is a + * mention — `compatible with Claude Code`, or a usage example quoted in prose, + * says what some other tool interoperates with rather than what this build is. + */ +function declaresClaudeProduct(help: string): boolean { + let named = false; + let usage = false; + for (const line of help.split("\n")) { + named ||= /^Claude Code\b/.test(line); + usage ||= /^Usage: claude(\s|$)/.test(line); + } + return named && usage; +} + +/** + * Whether this build accepts a session identity the caller chose. + * + * The value has to be required and has to be a UUID: that is the whole contract + * a client-allocated identity stands on. An option that will take any name is + * not one this adapter can hand a UUID to and expect the same session back. + */ +function declaresChosenIdentity(options: readonly OptionDeclaration[]): boolean { + const declaration = soleDeclaration(options, "--session-id"); + return ( + declaration?.placeholder !== undefined && + declaration.required && + placeholderName(declaration.placeholder) === "uuid" + ); +} + +/** Placeholders that name the identity itself. */ +const IDENTITY_VALUE = /^(session|conversation)id$/; + +/** Placeholders that commit to nothing, so the entry has to say what it takes. */ +const UNCOMMITTED_VALUE = /^(value|arg|argument|id)$/; + +/** + * Whether this build resumes the exact conversation an identity names. + * + * A placeholder that names the identity answers by itself. A placeholder that + * names something else — a URL, a path, a title — is a positive statement that + * the argument is not an identity, and no description overrides it. Only a + * placeholder committing to nothing is settled by the entry's own words, which + * is how the shipped `[value]` spelling is read without pinning its prose. + */ +function declaresIdentityResume(options: readonly OptionDeclaration[]): boolean { + const declaration = soleDeclaration(options, "--resume"); + if (declaration?.placeholder === undefined) { + return false; } - if (matched.length > 1) { - return "ambiguous"; + const value = placeholderName(declaration.placeholder); + if (IDENTITY_VALUE.test(value)) { + return true; } - return matched[0].takesValue ? "valued" : "flag"; + if (!UNCOMMITTED_VALUE.test(value)) { + return false; + } + return ( + declaration.description.includes("session id") && + declaration.description.includes("conversation") + ); } -/** Whitespace-insensitive text, for reading prose rather than layout. */ -function normalized(text: string): string { - return text.replace(/\s+/g, " "); +/** A placeholder or description saying the value is a file on disk. */ +const FILE_VALUE = /\b(file|filename|filepath|path)\b/; + +/** + * Whether the instruction layer can be handed over as a private file. + * + * Two accepted spellings, because Claude documents the family rather than the + * member: builds that give `--system-prompt-file` no entry of its own name it + * as `--system-prompt[-file]` inside another option's description. That is a + * declaration this executable makes about itself, so it is read from a parsed + * entry — never from a header, a command list, a footer, or free prose, where + * the same characters say only that someone wrote them. + */ +function declaresPrivateInstructionFile(options: readonly OptionDeclaration[]): boolean { + const declaration = soleDeclaration(options, "--system-prompt-file"); + if ( + declaration?.placeholder !== undefined && + (FILE_VALUE.test(placeholderName(declaration.placeholder)) || + FILE_VALUE.test(declaration.description)) + ) { + return true; + } + return options.some((option) => option.entry.includes("--system-prompt[-file]")); } /** @@ -234,11 +360,12 @@ const CLAUDE_PROBE_PROFILE = "claude-help-native-session.v1"; * rewords prose or rewraps lines still declares those, and a release that * stopped declaring one cannot launch whatever it calls itself. * - * The private-file member has two accepted spellings because Claude declares - * the family rather than the member: `--system-prompt-file` appears as the - * documented `--system-prompt[-file]` spelling in builds that do not give it - * its own entry. Both are the same declaration, and neither is inferred from - * the other's absence. + * Each member is a value contract, not a flag spelling. A launch supplies a + * UUID it chose, names that exact conversation again later, and hands over + * instructions as a private file — so what is read is the value each option + * says it takes. A build offering `--session-id ` or `--resume ` + * accepts the spelling and means something else by it, and admitting it would + * be reading agreement out of a coincidence of names. * * The two capabilities are read independently from what is present, never one * from the other: attachment additionally needs the bridge this adapter pins, @@ -251,14 +378,11 @@ function claudeNativeProbe(pinnedBridge: string | undefined): NativeCapabilityPr if (help === undefined) { return { probeProfile: CLAUDE_PROBE_PROFILE, capabilities }; } - const text = normalized(help); - const entries = optionEntries(help); - const product = text.includes("Claude Code") && /(^| )Usage: claude( |$)/.test(text); - const identity = declaresOption(entries, "--session-id") === "valued"; - const resume = declaresOption(entries, "--resume") === "valued"; - const privateFile = - declaresOption(entries, "--system-prompt-file") === "valued" || - text.includes("--system-prompt[-file]"); + const options = optionEntries(help).map(declaredOption); + const product = declaresClaudeProduct(help); + const identity = declaresChosenIdentity(options); + const resume = declaresIdentityResume(options); + const privateFile = declaresPrivateInstructionFile(options); if (product && identity && resume && privateFile) { capabilities.push("native-launch"); diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index b61e61a02..8f15c7939 100644 --- a/packages/acp/tests/native-launch.test.ts +++ b/packages/acp/tests/native-launch.test.ts @@ -4151,6 +4151,208 @@ describe("Tier CP — the Claude capability probe", () => { } }); + /** + * Two real releases, transcribed to the declarations this adapter reads. + * + * Compact rather than whole: the surface is hundreds of lines and pinning it + * would be a snapshot, which is the thing this probe exists not to be. What + * is kept is every part an answer is read from, in the layout the release + * actually printed — including 2.1.263 putting the usage line first, giving + * `--resume` a generic placeholder settled by its own words, and naming the + * private-file spelling only inside `--bare`. + */ + const RELEASE_2_1_241 = [ + "Claude Code - starts an interactive session by default", + "", + "Usage: claude [options] [command] [prompt]", + "", + "Options:", + " --session-id Use a specific session ID for the conversation", + " -r, --resume [sessionId] Resume a conversation", + " --system-prompt-file Load the system prompt from a file", + "", + ].join("\n"); + + const RELEASE_2_1_263 = [ + "Usage: claude [options] [command] [prompt]", + "", + "Claude Code - starts an interactive session by default, use -p/--print for", + "non-interactive output", + "", + "Options:", + " --bare Minimal mode: skip hooks, LSP, plugin", + " sync. Explicitly provide context", + " via: --system-prompt[-file],", + " --append-system-prompt[-file], --add-dir", + " -r, --resume [value] Resume a conversation by session ID, or", + " open interactive picker with optional", + " search term", + " --session-id Use a specific session ID for the", + " conversation (must be a valid UUID)", + " --system-prompt System prompt to use for the session", + " --system-prompt-snapshot Record the system prompt once per session", + "", + ].join("\n"); + + it("CP6: both real releases declare the shape, in the layouts they printed it", function* () { + // Neither is read as a version. 2.1.241 gives the private file its own + // entry and names the resume argument in its placeholder; 2.1.263 leads + // with usage, settles a generic `[value]` in the entry's own words, and + // documents the private-file family inside another option. Same answer. + expect(probe(RELEASE_2_1_241)).toEqual(["native-launch", "client-native-attachment"]); + expect(probe(RELEASE_2_1_263)).toEqual(["native-launch", "client-native-attachment"]); + }); + + it("CP7: an option accepting the spelling but not the value is not the operation", function* () { + // Each row takes a release that does declare the shape and changes exactly + // what the argument is. The flag survives; the contract does not — and a + // launch that read the flag alone would hand a UUID to an option that + // takes a name, or ask for a conversation from one that takes a URL. + const ATTACH_ONLY: readonly NativeCapability[] = ["client-native-attachment"]; + for (const [name, help, remaining] of [ + // Identity. Launch needs it; attachment never did, so it stays. + [ + "an identity that takes any name", + RELEASE_2_1_263.replace("--session-id ", "--session-id "), + ATTACH_ONLY, + ], + [ + "an identity whose value is optional and generic", + RELEASE_2_1_263.replace("--session-id ", "--session-id [value]"), + ATTACH_ONLY, + ], + [ + "two identity entries disagreeing about the value", + RELEASE_2_1_241.replace( + " --session-id Use a specific session ID for the conversation", + " --session-id Use a specific session ID for the conversation\n" + + " --session-id Deprecated: name the session", + ), + ATTACH_ONLY, + ], + // Resume. Both capabilities stand on it, so both go. + [ + "a resume that takes a URL", + RELEASE_2_1_263.replace("--resume [value]", "--resume "), + [], + ], + [ + "a resume that takes a path", + RELEASE_2_1_241.replace("--resume [sessionId]", "--resume "), + [], + ], + [ + "a generic resume whose entry never says which conversation", + RELEASE_2_1_263.replace( + "Resume a conversation by session ID, or", + "Reopen a workspace, or", + ), + [], + ], + // Private instructions. Launch only. + [ + "a private-instruction option that takes text rather than a file", + RELEASE_2_1_241.replace( + " --system-prompt-file Load the system prompt from a file", + " --system-prompt-file Use this as the system prompt", + ), + ATTACH_ONLY, + ], + [ + "the family spelling written in free prose instead of an entry", + `${RELEASE_2_1_241.replace( + " --system-prompt-file Load the system prompt from a file\n", + "", + )}\nSee the docs for --system-prompt[-file] and friends.\n`, + ATTACH_ONLY, + ], + ] as const) { + expect([name, probe(help)]).toEqual([name, remaining]); + } + }); + + it("CP8: a product named in passing is not this executable saying what it is", function* () { + // Both readings come from dedicated, unindented lines. Every option entry + // and every wrapped continuation is indented, so a product or a usage + // example quoted inside a description says what someone wrote about this + // build rather than what the build is — and nothing is recognized without + // the product, so both capabilities go. + for (const [name, help] of [ + [ + "a product line that only claims compatibility", + RELEASE_2_1_241.replace( + "Claude Code - starts", + "A wrapper compatible with Claude Code - starts", + ), + ], + [ + "the product named inside an option's description", + RELEASE_2_1_241.replace(/^Claude Code.*$/m, "Session tooling").replace( + " --session-id Use a specific session ID for the conversation", + " --session-id Use a specific session ID, as Claude Code does", + ), + ], + [ + "the usage line quoted in prose", + RELEASE_2_1_241.replace( + "Usage: claude [options] [command] [prompt]", + "Run it as Usage: claude", + ), + ], + [ + "the usage line quoted inside an option's description", + RELEASE_2_1_241.replace( + "Usage: claude [options] [command] [prompt]", + "Usage: wrapper [options]", + ).replace( + " -r, --resume [sessionId] Resume a conversation", + " -r, --resume [sessionId] Resume a conversation, like Usage: claude --resume", + ), + ], + ] as const) { + expect([name, probe(help)]).toEqual([name, []]); + } + }); + + it("CP9: a surface that only talks about the shape declares none of it", function* () { + // Product and usage are the real ones, so the only thing missing is the + // declarations themselves. Every spelling this adapter looks for is here — + // in a header, in a section body, in a command's description, and in + // footer prose — and not one of them is an option this build accepts. + const talksAboutIt = [ + "Claude Code - session tooling", + "", + "Usage: claude [options] [command] [prompt]", + "", + "Session options are forwarded: --session-id , --resume [sessionId],", + "and --system-prompt-file .", + "", + "Commands:", + " resume Resume a conversation by session ID", + "", + "Options:", + " -h, --help Show this message", + "", + "Instructions can be supplied with --system-prompt[-file].", + "", + ].join("\n"); + expect(probe(talksAboutIt)).toEqual([]); + + // The one variable. The same surface, with those three spellings declared + // as options it accepts, is admitted — so what the row above reads is the + // absence of declarations, not the presence of anything else. + const declaresIt = talksAboutIt.replace( + " -h, --help Show this message", + [ + " -h, --help Show this message", + " --session-id Use a specific session ID for the conversation", + " -r, --resume [sessionId] Resume a conversation", + " --system-prompt-file Load the system prompt from a file", + ].join("\n"), + ); + expect(probe(declaresIt)).toEqual(["native-launch", "client-native-attachment"]); + }); + it("CP5: neither capability is inferred from the other", function* () { // Exact resume is what attachment needs, and it is one of the three things // launch needs. A build declaring resume and nothing else attaches and From 3f614b4fcc1fa49b21ed8167375e7fcc89251071 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 09:36:47 -0400 Subject: [PATCH 07/22] =?UTF-8?q?=F0=9F=94=92=20Recognize=20what=20a=20Cla?= =?UTF-8?q?ude=20help=20entry=20states,=20not=20what=20it=20mentions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three surfaces that say the opposite of the contract were admitted by it. `Resume a conversation by URL; session ID is not supported` contained both words the read looked for. `Inline text; file paths are not supported` was accepted from a description while its argument was ``. `Claude Code compatibility wrapper` began a line with the product name. Prose is now read one clause at a time, and a clause that withdraws what it states admits nothing: a sentence states one thing, and its neighbours are not it. A generic `[value]` resume needs the phrase saying the argument is what the conversation is named by, not two words scattered through it. The private instruction file is decided by the argument alone, since a description explains a value rather than changing it. A product line names its subject and then describes it, so words running on into a longer name have named something else. The 2.1.241 and 2.1.263 surfaces, additive options, wrapping and unrelated prose are unchanged, and no version is consulted. --- packages/acp/src/native-launch.ts | 63 +++++++++++++++++++----- packages/acp/tests/native-launch.test.ts | 45 ++++++++++++++++- 2 files changed, 95 insertions(+), 13 deletions(-) diff --git a/packages/acp/src/native-launch.ts b/packages/acp/src/native-launch.ts index f51cae896..6bd737f30 100644 --- a/packages/acp/src/native-launch.ts +++ b/packages/acp/src/native-launch.ts @@ -247,6 +247,19 @@ function soleDeclaration( return matched.length === 1 ? matched[0] : undefined; } +/** + * A line whose whole subject is Claude Code, rather than one beginning with it. + * + * A program description line is a name and then a summary of it, so the name + * ends where the separator starts. `Claude Code compatibility wrapper` does not + * name Claude Code and describe it — it continues the words into the name of + * something else, which is exactly what a wrapper is. + */ +const CLAUDE_PRODUCT_LINE = /^Claude Code\s*(?:[-–—:]|$)/; + +/** The invocation this build documents for itself, not one it mentions. */ +const CLAUDE_USAGE_LINE = /^Usage: claude(?:\s|$)/; + /** * Whether this help surface is Claude Code's own. * @@ -259,12 +272,32 @@ function declaresClaudeProduct(help: string): boolean { let named = false; let usage = false; for (const line of help.split("\n")) { - named ||= /^Claude Code\b/.test(line); - usage ||= /^Usage: claude(\s|$)/.test(line); + named ||= CLAUDE_PRODUCT_LINE.test(line); + usage ||= CLAUDE_USAGE_LINE.test(line); } return named && usage; } +/** + * The separately-stated parts of a description. + * + * Prose is read one clause at a time because a sentence states one thing and + * its neighbours are not it. `Resume a conversation by URL; session ID is not + * supported` contains every word a naive read wants, distributed across two + * clauses that each deny what the read would conclude. + */ +function clauses(description: string): readonly string[] { + return description.split(/[;.]/).map((clause) => clause.trim()); +} + +/** Wording that takes back the clause it appears in. */ +const DENIED = /\b(?:not|never|no|cannot|unsupported|instead of|rather than)\b/; + +/** A clause that states `stated` and does not then withdraw it. */ +function states(description: string, stated: RegExp): boolean { + return clauses(description).some((clause) => stated.test(clause) && !DENIED.test(clause)); +} + /** * Whether this build accepts a session identity the caller chose. * @@ -287,6 +320,14 @@ const IDENTITY_VALUE = /^(session|conversation)id$/; /** Placeholders that commit to nothing, so the entry has to say what it takes. */ const UNCOMMITTED_VALUE = /^(value|arg|argument|id)$/; +/** + * The one thing an uncommitted resume value may be: what the conversation is + * named by. Read as a phrase rather than as words that happen to co-occur — + * `by session ID` says the argument is the identity, where `conversation` and + * `session ID` scattered through a sentence say only that both were mentioned. + */ +const RESUMED_BY_IDENTITY = /\bby (?:its |the |a )?(?:session|conversation) id\b/; + /** * Whether this build resumes the exact conversation an identity names. * @@ -308,18 +349,19 @@ function declaresIdentityResume(options: readonly OptionDeclaration[]): boolean if (!UNCOMMITTED_VALUE.test(value)) { return false; } - return ( - declaration.description.includes("session id") && - declaration.description.includes("conversation") - ); + return states(declaration.description, RESUMED_BY_IDENTITY); } -/** A placeholder or description saying the value is a file on disk. */ -const FILE_VALUE = /\b(file|filename|filepath|path)\b/; +/** A placeholder naming a file on disk, compared by its letters. */ +const FILE_VALUE = /^(?:file|filename|filepath|path)$/; /** * Whether the instruction layer can be handed over as a private file. * + * The argument itself has to be the file. A description is where a build + * explains its value, not where it changes it: `--system-prompt-file ` + * takes the prompt inline whatever its prose goes on to mention about paths. + * * Two accepted spellings, because Claude documents the family rather than the * member: builds that give `--system-prompt-file` no entry of its own name it * as `--system-prompt[-file]` inside another option's description. That is a @@ -331,12 +373,11 @@ function declaresPrivateInstructionFile(options: readonly OptionDeclaration[]): const declaration = soleDeclaration(options, "--system-prompt-file"); if ( declaration?.placeholder !== undefined && - (FILE_VALUE.test(placeholderName(declaration.placeholder)) || - FILE_VALUE.test(declaration.description)) + FILE_VALUE.test(placeholderName(declaration.placeholder)) ) { return true; } - return options.some((option) => option.entry.includes("--system-prompt[-file]")); + return options.some((option) => states(option.entry.toLowerCase(), /--system-prompt\[-file]/)); } /** diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index 8f15c7939..70dac58fe 100644 --- a/packages/acp/tests/native-launch.test.ts +++ b/packages/acp/tests/native-launch.test.ts @@ -4207,7 +4207,10 @@ describe("Tier CP — the Claude capability probe", () => { // Each row takes a release that does declare the shape and changes exactly // what the argument is. The flag survives; the contract does not — and a // launch that read the flag alone would hand a UUID to an option that - // takes a name, or ask for a conversation from one that takes a URL. + // takes a name, or ask for a conversation from one that takes a URL. The + // later rows say the same thing in prose that names the contract only to + // withdraw it, which is a build declining the operation in the clearest + // words it has rather than a build offering it. const ATTACH_ONLY: readonly NativeCapability[] = ["client-native-attachment"]; for (const [name, help, remaining] of [ // Identity. Launch needs it; attachment never did, so it stays. @@ -4249,6 +4252,15 @@ describe("Tier CP — the Claude capability probe", () => { ), [], ], + [ + "a generic resume naming a URL, whose prose mentions session ID to deny it", + RELEASE_2_1_241.replace( + " -r, --resume [sessionId] Resume a conversation", + " -r, --resume [value] Resume a conversation by URL;" + + " session ID is not supported", + ), + [], + ], // Private instructions. Launch only. [ "a private-instruction option that takes text rather than a file", @@ -4258,6 +4270,29 @@ describe("Tier CP — the Claude capability probe", () => { ), ATTACH_ONLY, ], + [ + "a private-instruction option taking text, whose prose mentions paths to deny them", + RELEASE_2_1_241.replace( + " --system-prompt-file Load the system prompt from a file", + " --system-prompt-file Inline text; file paths are not supported", + ), + ATTACH_ONLY, + ], + [ + "the family spelling named by the entry that says it is unavailable", + RELEASE_2_1_263.replace( + [ + " sync. Explicitly provide context", + " via: --system-prompt[-file],", + " --append-system-prompt[-file], --add-dir", + ].join("\n"), + [ + " sync. Context is inline only;", + " --system-prompt[-file] is not supported", + ].join("\n"), + ), + ATTACH_ONLY, + ], [ "the family spelling written in free prose instead of an entry", `${RELEASE_2_1_241.replace( @@ -4276,7 +4311,9 @@ describe("Tier CP — the Claude capability probe", () => { // and every wrapped continuation is indented, so a product or a usage // example quoted inside a description says what someone wrote about this // build rather than what the build is — and nothing is recognized without - // the product, so both capabilities go. + // the product, so both capabilities go. A dedicated line is not enough on + // its own either: a description line names its subject and then describes + // it, so words that run on into a longer name have named something else. for (const [name, help] of [ [ "a product line that only claims compatibility", @@ -4285,6 +4322,10 @@ describe("Tier CP — the Claude capability probe", () => { "A wrapper compatible with Claude Code - starts", ), ], + [ + "a product line that begins with the name but is the name of something else", + RELEASE_2_1_241.replace(/^Claude Code.*$/m, "Claude Code compatibility wrapper"), + ], [ "the product named inside an option's description", RELEASE_2_1_241.replace(/^Claude Code.*$/m, "Session tooling").replace( From 2e27febcfd4677a237459891debe8d02c463a53c Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 09:53:24 -0400 Subject: [PATCH 08/22] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20Continue=20client?= =?UTF-8?q?-native=20sessions=20across=20compatible=20releases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 6 +- specs/acp-client-spec.md | 24 +- specs/decisions.md | 74 ++++-- specs/native-agent-session-launch-spec.md | 268 +++++++++++++--------- specs/test-agent-spec.md | 10 +- 5 files changed, 235 insertions(+), 147 deletions(-) diff --git a/architecture.md b/architecture.md index ba63cbf11..939c4f7ac 100644 --- a/architecture.md +++ b/architecture.md @@ -117,8 +117,8 @@ Existing documents and code get aligned to this section retroactively. | provider authority | what core delivers to the provider factory it installs, as an argument that factory closes over. It validates the routed request, runs each absent phase once, cross-checks and retains what comes back, and derives the result. There is no reader for one, no context holding one, and no request member carrying one | | session coordinator | the host-built capability that answers who owns one logical agent session right now, across processes. It is passed directly into the provider that needs it and is deliberately not contextual: a decision document middleware could replace is not one. Acquisition never waits | | construction route | how one logical agent session was first constructed — through ACP, or under an identity XMD chose — as strict create-once durable state beside the coordinator's own records. It answers a different question from ownership and grants no right to act: the coordinator remains the single live authority. A route never converts, and a session ACP already established is recorded as ACP-first rather than reclassified. Two schemas are readable: the released `session-route.v1`, and `session-route.v2`, which exists only for `client-native` and adds a required executable build binding. New client-native construction publishes V2; a V1 client-native route stays legacy-unbound, is never upgraded in place, and authorizes native resume only | -| executable build binding | which build of a provider executable accepted a client-allocated identity, as a schema, the lowercase SHA-256 of the canonical executable target and an optional adapter-canonical reported version. Digest equality is mandatory, and a retained version must be reproduced when one exists; a build which reports none binds by digest alone. It contains no path: a path says where a build was, which stops being true, and names host layout besides | -| native capability admission profile | the adapter protocol and observable command shape for which one native capability's real-CLI proof passed, inside its proved host operating system and architecture. The Agent name and reported version authorize nothing by themselves: the exact observed executable must advertise the profile's required shape through a read-only metadata query. The executable digest remains the per-session continuity binding, while an optional canonical version is retained only as supplemental evidence and a diagnostic | +| executable build binding | immutable audit evidence about the provider executable observed when a client-allocated identity was first published: a schema, the lowercase SHA-256 of the canonical executable target and an optional adapter-canonical reported version. The V2 route and its prepared journal record retain the same value exactly and never rewrite it, but neither digest nor version authorizes or denies a later continuation. It contains no path: a path says where a build was, which stops being true, and names host layout besides | +| native capability admission profile | the adapter protocol and observable command shape for which one native capability's real-CLI proof passed, inside its proved host operating system and architecture. The exact executable used by each live continuation must independently advertise the requested profile through a read-only metadata query; the Agent name, reported version, executable digest and a predecessor's admission authorize nothing by themselves | | executable observer | the host-built capability that resolves a launcher command through the host's real execution environment, canonicalizes the target, requires an executable regular file, hashes that target and runs the adapter's declared read-only metadata queries against that same path. Passed directly into the provider like the coordinator beside it, and deliberately not contextual: executable validation decides which retained history may be accepted, and a resolver document middleware could replace could point the observation at one binary while the run spawns another | | client-native attachment | joining a conversation a native process constructed, through ACP, under the identity the route already names. It is attachment, never conversion: the route stays client-native, `resumeSessionId` receives that exact identity, and the provider's canonical assertion must equal it before the first turn. Advertised separately from native launch, because handing a session to a native UI and later joining it prove different things | | identity provenance | who chose a provider-native session identity: the provider returned it, or the client allocated it before the provider existed. Retained rather than inferred, because after the fact both are just a string in the record. A released record written before the distinction existed reads as provider-returned, which is the only compatibility inference | @@ -4647,7 +4647,7 @@ Status is measured against main. | nested run-profile Agent and elicitation declarations | lets one `` declare one child-scoped `` scenario set and one non-delegating `` matcher set; only frozen test data crosses the harness request, the trusted host constructs both providers inside the isolated child, siblings share no session or provider state, ordinary component shadowing remains in force, and the child journal retains only the selected Prompt and Elicit components' ordinary results. A controlled `` may author an exact scenario label that this host alone maps to Plan's derived conversation identity; declaration selection uses the label while runtime state stays keyed by the opaque identity and child, with no matcher or fallback added to ordinary TestAgent sessions | built on the #641 stack; controlled Plan routing added on the #728 stack | | `Config` run deadline / exec default / Fetch default / verbosity | three independently owned contextual timeouts, absent unless configured, each read by exactly one consumer, and contextual verbosity — a boolean that is false unless configured, seeded by the command line and overridable for a lexical subtree, bounding nothing and owning no authority | built on this stack | | terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified and implemented in #732, which is what gives a pane's `` that pane's terminal rather than the root's; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts — one invocation-private server per grid, authenticated persistent pane workers carrying exact argv, cwd and environment outside tmux parsing, explicit row-major layout imposed by pane swaps, a required composite `launch()` that gives a pane's `` its own terminal rather than the root's, and one ordered teardown that proves worker quiescence, channel closure and server disappearance before the document continues; its evidence uses a fake tmux with real workers and real sockets, and real tmux behaviour on macOS remains #726's; Node and Bun catalog and validate the same grids and install neither the provider nor the process observer, refusing before pane start; DEC-016 specifies the final behavior-preserving extraction into `@executablemd/terminal` and `@executablemd/terminal-tmux`, with every repository import moved to the canonical packages and the unshipped old terminal paths deleted | -| native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. The trusted host also states its operating system and architecture and admits each native capability only when the resolved adapter protocol, the independently requested capability, a read-only observation of the required CLI shape, and the proved host envelope all agree; neither the Agent name nor reported version authorizes it. That gate follows executable observation and precedes identity allocation, route publication, private-file creation, native process start, attachment ensure and incomplete replay. Reported version is optional diagnostic and per-session continuity evidence; the executable digest remains mandatory for a retained identity, so a compatible upgraded CLI can create new sessions while an existing session never crosses to a changed build implicitly. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires the live capability profile and retained binding both to match, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. A route whose exact provider identity is absent remains authoritative: the provider's explicit refusal is an accepted zero-turn outcome and XMD neither allocates, creates, converts, deletes, rewrites nor substitutes a conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store, executable observer, capability-profile admission and live platform facts — and Node and Bun assemble none of that authority, so every advertised operation refuses before provider work; the Claude client-native protocol is admitted independently for launch and attachment when its required help shape is observed on the proved macOS/arm64 host envelope, including compatible versions after 2.1.241 and builds which omit version output; an absent or changed required shape or an unproved platform refuses, while an exact missing-session refusal remains an accepted client-allocated outcome; Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | +| native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session retains immutable audit evidence for the executable observed before its identity was allocated: that binding is published with the V2 route and copied into each agreeing prepared record, while every live create, resume, attachment and incomplete replay observes the executable it will actually use and independently admits the requested capability before a process, an ensure or a turn. The trusted host admits only when the route's stable adapter protocol, the independently requested capability, the live executable's positive CLI shape, and the proved host operating system and architecture agree; neither the Agent name, reported version, executable digest nor a predecessor's admission authorizes or denies the continuation. A compatible Claude upgrade therefore resumes the exact retained native identity without allocating, converting, republishing or rewriting the route or journal. A route/journal disagreement still refuses because the two durable accounts no longer describe one preparation, not because the live build changed. The live canonical path replaces the launcher name only for that invocation. A `` or `` meeting a bound client-native route similarly admits attachment against the current executable, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on a protocol, profile, capability or host mismatch, missing history or a differing assertion without creating a substitute conversation. A route whose exact provider identity is absent remains authoritative: the provider's explicit refusal is an accepted zero-turn outcome and XMD neither allocates, creates, converts, deletes, rewrites nor substitutes a conversation. ACP runtimes are partitioned by the resolved agent command and live executable observation serving them, never by the route's historical binding; each handle is closed by the partition that created it, a live partition is never migrated after an upgrade, and a bound partition is torn down when its last handle and claimed work settle. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store, executable observer, capability-profile admission and live platform facts — and Node and Bun assemble none of that authority, so every advertised operation refuses before provider work; the Claude client-native protocol is admitted independently for launch and attachment when its required help shape is observed on the proved macOS/arm64 host envelope, including compatible versions after 2.1.241 and builds which omit version output; an existing V2 session continues across compatible Claude releases; an absent or changed required shape, a different protocol or an unproved platform refuses, while an exact missing-session refusal remains an accepted client-allocated outcome; Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | | `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack | | `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 | | `` | removes one file the document names, inside the contextual working directory. An ordinary overridable core default with a closed schema of one required non-empty `path`, **self-closing only** — a paired spelling never enters its body, because the component declares its one form and canonical invocation-form dispatch enters that body only for the form the scan recorded, before `Env.cwd` is read and before the provider is reached. Neither the composable `Component.hasContent()` chain nor a method on whatever object a caller handed over takes part. It renders the empty string, declares no `returns` and hands back no receipt, so an ordinary `as` captures that empty string; absence is the same success, so deleting a path twice succeeds twice. One regular file or one final symbolic link goes — the link rather than its target, inside or outside — and every directory is refused, an empty one included. Empty, absolute, lexically escaping and parent-link-escaping paths are refused before any removal, and a printed error names only the path the document wrote. One semantic `API.Files.deleteFile` call and no filesystem access of its own; under a workflow run it is one `workspace_file` effect retaining `{ kind: "deleted" }`. The standard Deno workflow profile admits it to generated XMD as the exact self-closing identity `@executablemd/core#File.Delete`, third in the write table, where it performs that same ordinary effect and contributes no evaluator result | built on the #567 stack | diff --git a/specs/acp-client-spec.md b/specs/acp-client-spec.md index f87857ba4..9714575d1 100644 --- a/specs/acp-client-spec.md +++ b/specs/acp-client-spec.md @@ -100,16 +100,20 @@ routes an opaque request and cannot manufacture that result. Publication is create-once, so the loser of either order adopts the winner, and no route converts. - A `session()` or subscribed `prompt()` meeting a **bound** `client-native` - route **attaches** to it. Under the same ownership it reobserves the build and - compares the binding exactly, requires any retained ACP arrangement to assert - that route's identity, selects the runtime for `(resolved agent command, - binding)`, calls `ensureSession()` with `resumeSessionId` equal to that - identity, and requires the returned canonical assertion to equal it before a - turn. The observed executable path reaches only that runtime's transient child - environment. A legacy unbound `client-native` route, an agent this host has - not advertised for attachment, build drift, a disagreeing arrangement and a - differing returned identity each refuse before a turn and create no substitute - conversation. + route **attaches** to it. Under the same ownership it reobserves the executable + and independently admits the route's stable adapter protocol, attachment + capability, positive CLI shape and proved host envelope. Its digest or version + may differ from the route's immutable audit evidence. The provider requires + any retained ACP arrangement to assert that route's identity, selects the + runtime for `(resolved agent command, live executable observation)`, calls + `ensureSession()` with `resumeSessionId` equal to that identity, and requires + the returned canonical assertion to equal it before a turn. The observed + executable path reaches only that runtime's transient child environment. A + legacy unbound `client-native` route, an agent this host has not advertised for + attachment, a protocol/profile/capability/host mismatch, a disagreeing + arrangement and a differing returned identity each refuse before a turn and + create no substitute conversation. A compatible release change rewrites + neither route nor provider history. - `withSessionRoute` remains routing only: it selects which partition serves a call and carries no authority to construct, own or answer. - **Base behavior:** with no provider installed, `agent()`, `session()`, diff --git a/specs/decisions.md b/specs/decisions.md index b0b759d80..c490aff52 100644 --- a/specs/decisions.md +++ b/specs/decisions.md @@ -813,6 +813,8 @@ hand. **Date:** 2026-09-06 +**Amended:** 2026-09-06 — compatible cross-release continuation + ### Context Claude's client-allocated launch proof ran against Claude Code 2.1.241 on @@ -821,11 +823,17 @@ fails closed, but it makes a routine Claude Code upgrade disable every new native session even when the executable still advertises the protocol surface the adapter uses. -Executable build binding answers a different question. Its executable digest -and optional canonical reported version let a later operation recognize the -exact executable that accepted one retained identity. They do not globally -authorize native launch or client-native ACP attachment, and a version string -does not describe a capability. +The first shape-based repair still used the executable digest and optional +canonical reported version as a per-session continuity gate. In practice that +let Claude Code 2.1.261 establish a session and made 2.1.263 refuse it before +resume, even though both releases independently advertised the same admitted +protocol. That preserves bytes rather than the provider-native identity the +route exists to preserve. + +The retained build binding remains useful as immutable audit evidence and as a +cross-check between the route and the prepared journal. It does not establish +whether another release implements the same operation. That question belongs +to the live capability profile. The client-allocated contract deliberately accepts two zero-turn outcomes: the same identity resumes, or the provider refuses that exact absent identity and @@ -867,23 +875,38 @@ For a new client-allocated session the provider resolves and hashes the executable, runs the adapter's read-only metadata query against that exact path, and checks the requested capability before identity allocation, route publication, private-file creation or native child start. A bound route is -checked again before native resume, attachment ensure and incomplete replay. A -profile or shape the host has not admitted refuses with -`unsupported-capability`; a live build which differs from the route still -refuses with `executable-binding-refused`. Completed replay and legacy V1 -native-only resume keep their existing behavior. +checked again before native resume, attachment ensure and incomplete replay. +The live executable may differ in digest or reported version from the build the +route records; it acts only when its stable adapter protocol, independently +requested capability, positive observed CLI shape and proved host envelope all +match. A mismatch refuses with `unsupported-capability`. Completed replay and +legacy V1 native-only resume keep their existing behavior. Claude Code's version query is optional metadata. One canonical version line is retained when available; changed wording, noncanonical output, a failed version query or no version output does not deny an otherwise recognized capability shape. New sessions created by such a build retain its exact digest and omit -the version. A retained binding still protects one published identity: digest -equality is mandatory, two present versions must agree, and a retained version -which the same observation can no longer reproduce refuses conservatively. An -upgrade may therefore create new named sessions immediately when its shape and -host envelope are admitted, while a session bound to the previous executable -does not cross the changed digest. Crossing builds for one existing identity -requires a separate migration contract. +the version. The V2 route and each agreeing prepared record keep the binding +they were first written with and never rewrite it after an upgrade. Their exact +agreement proves that the two durable accounts describe one preparation; it +does not require the live executable to reproduce either value. A compatible +upgrade may therefore create a new session or continue an existing one under +the route's exact retained native identity. + +`session-route.v2` fixes the client-native adapter protocol through its exact +provider, agent and launcher contract. The only admitted Claude interpretation +is `claude-client-native.v1`; changing that protocol requires a route contract +which names the new protocol rather than silently reinterpreting V2 history. +This lets an existing V2 route cross executable releases without letting it +cross protocols. + +Incomplete replay keeps the durable boundary. The route and journal must still +agree exactly on provider, native identity, provenance, instruction digest, +launcher and their original build binding. A `prepared`-only replay may create +under that same identity and a `detached` replay may only resume it, but each +uses the newly observed executable only after that executable independently +passes native-launch admission. Neither replay allocates, republishes or +rewrites. Completed replay observes nothing and performs no live work. An already-published route whose exact provider identity is absent remains the authoritative account. Native resume or ACP attachment fails closed without @@ -903,11 +926,18 @@ separate product decision and specification change. The provider's static adapter-name sets are only a coarse selection and cannot authorize client-allocated work by themselves. Admission requires the resolved adapter's stable protocol identifier, its observed capability shape and the -host envelope. The host/provider assembly keeps live platform facts and a -controlled admission seam, so tests can state profiles and observations without -reading the active runtime. The unmerged executable binding makes -`reportedVersion` optional; route and journal identity, request, provider -identity and authored syntax otherwise do not change. +host envelope on every live continuation. The host/provider assembly keeps live +platform facts and a controlled admission seam, so tests can state profiles and +observations without reading the active runtime. Route and journal schemas, +identity, request, provider identity and authored syntax do not change. + +ACP runtime partitions follow the resolved agent command and the live build +that actually serves them, not the historical binding in a route. A compatible +upgrade creates a new live partition after any old partition becomes idle; an +existing partition is never rekeyed or migrated, and every handle still closes +through its creator. Sessions with different historical bindings may share one +current partition only when they are being served by the same live build and +protocol. Different live builds never share a child. The metadata probe is deliberately less than a semantic trial. No side-effect-free query can prove that an implementation has no hidden diff --git a/specs/native-agent-session-launch-spec.md b/specs/native-agent-session-launch-spec.md index 1c3c1d893..65397c9ff 100644 --- a/specs/native-agent-session-launch-spec.md +++ b/specs/native-agent-session-launch-spec.md @@ -345,10 +345,9 @@ codex resume ### Executable build binding -A client-allocated identity means one thing only while the build that accepted -it can be recognized later. Two builds of one provider accept the same identity -and disagree silently about what it names, so a new client-allocated session -retains which build accepted it: +A new client-allocated session records the executable observed before its +identity was published. This is immutable audit evidence and a durable +cross-check, not a release lock: ```ts interface ExecutableBuildBindingV1 { @@ -359,21 +358,28 @@ interface ExecutableBuildBindingV1 { ``` The digest is always present and exact: it is the lowercase SHA-256 of the -canonical executable target. A matching digest reached at another path is the -same build; a changed build at the same path is not. `reportedVersion` is the -adapter's optional canonical parse of what that exact target reports. One -canonical line is retained as supplemental continuity evidence and may appear -in a diagnostic; raw output is never retained or repeated. A failed version -query, no output, changed wording or several candidate lines leaves the member -absent and does not make an otherwise observable executable or capability -unsupported. - -Continuity always requires digest equality. When the retained binding carries a -version, a later observation must reproduce that same canonical value; a -different or absent value refuses conservatively. A binding created without a -version compares by digest alone, and a later version observation does not -rewrite it. A path is never a member: it says where a build was, which stops -being true, and it names host layout besides. +canonical executable target. `reportedVersion` is the adapter's optional +canonical parse of what that exact target reports. One canonical line is +retained when available; raw output is never retained or repeated. A failed +version query, no output, changed wording or several candidate lines leaves the +member absent and does not make an otherwise observable executable or +capability unsupported. A path is never a member: it says where a build was, +which stops being true, and it names host layout besides. + +The V2 construction route and every prepared journal record derived from it +carry the same original binding exactly. They never replace it with a later +observation. Exact agreement between those durable accounts detects a partial, +foreign or inconsistent preparation; digest or version equality with the live +executable is not required. A compatible later release continues the retained +native identity after it independently passes the same live capability gate. +The retained digest and version may be inspected as audit evidence, but neither +by itself authorizes or denies continuation. + +For the current V2 route, the exact provider, agent and launcher contract pins +the stable `claude-client-native.v1` protocol under which its identity was +published. A future incompatible protocol cannot reinterpret that route; it +requires a route contract which names the new protocol. Executable releases may +therefore change without allowing the session to cross protocol boundaries. The host supplies an executable observer directly to the provider, alongside the coordinator and the route store. It resolves the launcher command through the @@ -391,7 +397,7 @@ parse a provider's output or detect the active runtime. One observation yields two kinds of value: ```text -durable: the executable build binding, with a version only when reported canonically +durable on first publication: the executable build binding, with a version only when reported canonically live: the canonical executable path and provider-private metadata observations ``` @@ -411,13 +417,14 @@ is installed now, not which one established the conversation. ### Compatibility admission An installed-CLI proof establishes a **capability admission profile**, not an -adapter name or version allow-list. A live admission consists of a stable -adapter protocol, one independently requested capability, an observed CLI shape -that satisfies that protocol, and the host operating system and architecture on -which the applicable real-CLI proof passed. Native launch and client-native -attachment have independent admissions because their proofs ask different -questions. The executable digest remains the exact continuity binding for one -retained session; it is not a global allow-list for one operator's installation. +adapter name, version allow-list or executable-digest allow-list. Every live +admission consists of the route's stable adapter protocol, one independently +requested capability, an observed positive CLI shape that satisfies that +protocol, and the host operating system and architecture on which the +applicable real-CLI proof passed. Native launch and client-native attachment +have independent admissions because their proofs ask different questions. A +predecessor's admission and retained build evidence authorize nothing in the +current run. ```ts interface NativeCapabilityAdmission { @@ -472,27 +479,26 @@ provider runs the read-only probe against that exact path and requires the requested capability in its result. This precedes identity allocation, route publication, private-file creation and native child start. Existing bound routes are checked again before native resume, client-native attachment or -incomplete replay can contact the provider. A canonical version line is -evidence about the observed build, not capability admission. +incomplete replay can contact the provider. A canonical version line and digest +are evidence about the observed build, not capability admission or a +cross-release refusal. A protocol, shape, capability or host mismatch is `unsupported-capability`. The diagnostic may identify the agent, capability, adapter protocol and canonical reported version when one exists; it carries no raw help or version output, executable path, digest, environment or host message. Platform and -probe facts remain live admission inputs. The route's build binding continues -to answer whether this is the exact executable that accepted the retained -identity, with optional `reportedVersion` as specified above. +probe facts remain live admission inputs. A live digest or version different +from the route's retained audit evidence is not a mismatch. The version query is optional and independent of the help probe. A future Claude build with the same admitted protocol shape on macOS arm64 remains usable when its version changes, its version wording changes, or it omits -version output. A build that omits a canonical version creates a binding with -only its digest. An upgraded executable may establish new sessions, while a -route bound to an earlier digest remains bound to that build and refuses rather -than migrating its identity implicitly. The observed zero-turn behavior of a -newer Claude that retains no conversation is not by itself a regression: claim -5 explicitly permits the provider to refuse that exact identity, provided XMD -fails closed without substitution. +version output. It may establish a new session or continue an existing V2 +session. Continuation adopts only the route's exact native identity and leaves +the route, journal and provider history unchanged. The observed zero-turn +behavior of a newer Claude that retains no conversation is not by itself a +regression: claim 5 explicitly permits the provider to refuse that exact +identity, provided XMD fails closed without substitution. Compatibility admission adds no materialization turn. Launch still performs no model turn, and bootstrap turns remain outside this contract. The metadata @@ -515,8 +521,12 @@ afterwards. Its attachment claim was proven by `packages/acp/src/ClaudeNativeToAcp.test.md`: one native turn planted a random marker, a checked-in marker-free ACP `` recovered it under the same -identity and the same observed build, and an independent route naming an absent -identity refused before a turn without creating history in its place. +identity, and an independent route naming an absent identity refused before a +turn without creating history in its place. That real proof used one observed +build. The cross-release contract relies on each later executable independently +passing the same proved profile and keeps the proof's exact-identity and +no-substitution checks at the live boundary; the controlled evidence below +changes only the executable release between construction and continuation. Those command shapes are adapter implementation details, not authored document values. A custom ACP agent without a declared native launcher fails with an @@ -581,13 +591,13 @@ owner to release — what has to be settled first is which conversation this is: resolved adapter protocol, requested capability, observed protocol shape and trusted host operating system and architecture must match one admitted profile; a mismatch ends with `unsupported-capability`, still before an - identity or session-state mutation. Reported version does not participate - in that decision. The launch retains the refusal at `prepared` without an - identity, as it does every other preparation refusal. A route that already - names a different exact digest, or cannot reproduce a version its binding - retained, ends with `executable-binding-refused`. A legacy unbound route is - the exception: it observes nothing, resumes under the launcher name, and - gains no binding. + identity or session-state mutation. Reported version and digest do not + participate in that decision. The launch retains the refusal at `prepared` + without an identity, as it does every other preparation refusal. An existing + V2 route may carry different build evidence: after live admission the launch + adopts its exact identity and leaves that evidence unchanged. A legacy + unbound route is the exception: it observes nothing, resumes under the + launcher name, and gains no binding. Whether an identity is needed at all is decided next. An existing compatible `client-native` route already names this conversation, so its retained identity is adopted and **nothing is allocated** — a second @@ -608,9 +618,12 @@ owner to release — what has to be settled first is which conversation this is: launcher won — neither account repairs the other, so the launch refuses the same way. A `created` or `resumed` record is built from the compatible winning route - rather than from this launch's candidate, so the two accounts agree by - construction rather than by comparison. A refusal is not: it prepared no - identity, so it retains the failure the authoritative winner produced + rather than from this launch's candidate, including that route's original + build evidence, so the two durable accounts agree by construction. A + concurrently adopted route may have been published after a different + compatible executable observation; its digest and version do not defeat the + winner after this launch's own live capability admission. A refusal prepared + no identity, so it retains the failure the authoritative winner produced without mirroring that route's identity or provenance — no session id, and the weaker provenance claim, because nobody chose one. It is retained at `prepared` and reaches no private file, no detach and no spawn. @@ -664,22 +677,30 @@ this sequence, and every step happens while the coordinator holds the session: 4. A legacy unbound `client-native` route refuses with `executable-binding-refused`, and an agent this host has not advertised for attachment refuses with `unsupported-capability`. -5. Reobserve the executable and compare the binding exactly. +5. Reobserve the executable and independently admit + `client-native-attachment` for the route's stable protocol, the live positive + CLI shape and the proved host envelope. A changed digest or version is audit + evidence, not a refusal. 6. Inspect any retained ACP arrangement without creating one: absence may enter exact resume, because exact resume is the operation being attempted; a record must assert this route's identity and nothing else. -7. Select the live runtime for `(resolved agent command, binding)` and give the - observed path only to that runtime's child environment. +7. Select the live runtime for `(resolved agent command, live executable + observation)` and give the observed path only to that runtime's child + environment. 8. Ensure with `resumeSessionId` equal to the route's identity. 9. Require the provider's canonical assertion to equal it. Absence or disagreement closes the handle and refuses before a turn. 10. Only then return a `Session`, or start the subscribed turn. -Runtime partitions are scope-owned. Different bindings never share an ACP child, -a managed handle remembers the partition that created it, and every turn, close, -detach, cancellation and stale-handle release goes through that same partition. -When a bound partition's last handle closes it is removed and torn down; a later -attachment reobserves and builds another. +Runtime partitions are scope-owned and follow the executable serving live work, +not the historical binding in a route. Different live builds never share an ACP +child. Sessions carrying different historical bindings may share when the same +resolved agent command, current executable and protocol serve them. A managed +handle remembers the partition that created it, and every turn, close, detach, +cancellation and stale-handle release goes through that same partition. An +upgrade never rekeys or migrates a live partition. When its last handle closes +and its claimed work settles it is removed and torn down; a later attachment +reobserves and may build a partition for the upgraded executable. A partition is kept exactly as long as something is standing on it, and two different things can be: a handle nobody has closed, and work that has claimed @@ -704,11 +725,12 @@ are met rather than a further invariant beside them: - **Claimed work that produced no handle releases its claim.** The runtime is built before the ensure that would use it, so a rejection would otherwise - strand a partition holding a live path for work that never happened — and a - binding compares a version and a digest, so the same build found somewhere - else is the same partition key and a different file to run. Success transfers - the claim into ownership of the handle instead, in one step: a moment where - neither count is held is a moment another operation could evict. + strand a partition holding a live path for work that never happened. The live + observation, not the route's historical evidence, supplies the partition key; + the same current build found somewhere else is the same partition and a + different live path to run. Success transfers the claim into ownership of the + handle instead, in one step: a moment where neither count is held is a moment + another operation could evict. - **Cancellation observes the ensure it started, and settles it before quiescence.** Starting an ensure is not the same as owning it: the call runs whether or not anybody is still waiting, so a cancellation is not the end of @@ -1102,16 +1124,25 @@ provider-returned preparation carries none, and a refusal that prepared no identity invents none. Every incomplete replay requires exact agreement between its journal and its -route on identity, provenance, instruction digest, launcher and build binding -before its first live effect, and then requires the live capability point to be -admitted and the live build to equal that binding. Neither account repairs or -republishes the other: a replay that found a disagreement has discovered that -the session it was going to continue is not the session it prepared, and -retains `identity-unavailable` without starting a child. Equal instructions may -resume the retained identity; different instructions retain +route on identity, provenance, instruction digest, launcher and the original +build binding before its first live effect. It then observes the executable it +will use and independently requires the native-launch capability for the same +stable adapter protocol, positive CLI shape and proved host envelope. The live +digest and version need not equal the retained evidence. Neither durable account +repairs, republishes or rewrites the other: a replay that found a disagreement +has discovered that the session it was going to continue is not the session it +prepared, and retains `identity-unavailable` without starting a child. Equal +instructions may continue the retained identity; different instructions retain `instructions-refused` and replace neither the layer, the route, the identity, nor any provider state. +A `prepared`-only replay still creates under the exact retained identity because +its handoff never began. A `detached` replay still resumes that identity and +never falls back to creation because a predecessor may have started. Both use +the current admitted executable path, allocate nothing and leave the historical +binding unchanged. A completed replay still observes no executable and performs +no live work. + An incomplete replay of a legacy unbound client-allocated launch retains `executable-binding-refused` before any live work: nothing available to it can show which build has that session's history, and resuming anyway would answer @@ -1213,14 +1244,15 @@ different layer, exactly as a session a native UI has been in is. A `` that only placed one has established nothing, so a launch inside it constructs the session it named rather than meeting one. -A build this run cannot show is the build behind the session fails with -`executable-binding-refused`. Resolution, canonicalization, executable-file -validation, digesting, schema recognition, digest equality, failure to reproduce -a retained canonical version, and a session established before any build was -recorded all end there. The diagnostic names the stable class and launcher and -may name canonical versions which were actually observed; it carries no -executable path, raw metadata output, host error, argv, environment, credential, -instruction text or provider payload. +An executable this run cannot resolve, canonicalize, validate, hash or observe +fails with `executable-binding-refused`, as does a session established before +any build was recorded on the paths which require a bound route. A different +live digest or canonical version does not. The diagnostic names the stable class +and launcher and may name canonical versions which were actually observed; it +carries no executable path, raw metadata output, host error, argv, environment, +credential, instruction text or provider payload. A malformed build binding or +a route and journal carrying different bindings is a durable-account mismatch +and refuses before live work; it is never repaired from the current executable. An observed executable whose adapter protocol, requested capability, required CLI shape, operating system or architecture has no admitted profile fails with @@ -1413,22 +1445,26 @@ Focused tests prove: natural key, contention refuses instead of queueing, a crashed owner leaves a recovery tombstone, and a host with no coordinator refuses before contacting an agent; -18. a build binding is read and compared conservatively — a moved matching - digest is accepted, a changed digest is not, a retained version must be - reproduced, a binding created without one compares by digest, and an - inexact record refuses rather than being read past; +18. a build binding remains immutable audit evidence shared exactly by the V2 + route and its prepared journal record; a changed live digest, changed or + absent version and moved executable are accepted after fresh capability + admission, while malformed evidence or disagreement between the two durable + accounts refuses rather than being read past or repaired; 19. new client-native construction observes the build before it allocates, publishes a bound V2 route, and retains a preparation that agrees with it, while a legacy V1 route resumes natively under the launcher name and gains nothing; 20. a `` or `` on a bound route supplies the route identity as - the exact resume identity, delivers the observed path only to the matching - child's transient environment, and refuses before ensure on a missing - attachment gate, a missing observer, build drift, a disagreeing retained - arrangement or a returned identity that is not the route's; and -21. ACP runtimes are partitioned by resolved agent command and binding, a handle - is closed by the partition that created it, the last close evicts a bound - partition, and provider teardown settles what remains; + the exact resume identity, delivers the current admitted executable path + only to the matching child's transient environment, continues across a + compatible release, and refuses before a turn on a missing attachment gate, + a missing observer, a disagreeing retained arrangement or a returned + identity that is not the route's; and +21. ACP runtimes are partitioned by resolved agent command and live executable + observation rather than historical route evidence, a handle is closed by + the partition that created it, an upgrade never migrates a live partition, + the last close and claim release evict it, and provider teardown settles + what remains; 22. claimed runtime work that produced no handle releases its claim, a partition is evicted only with no handles and no work in flight, a handle that came back survives every later refusal bound to its creator whichever path @@ -1447,7 +1483,8 @@ Focused tests prove: requested capability, required read-only CLI shape, operating system and architecture; neither an Agent name nor version string admits it. A newer canonical version, omitted version and additive unrelated help are accepted - when that shape and host envelope match, while a missing or ambiguous + when that shape and host envelope match, including for an existing V2 route + carrying a different digest or version, while a missing or ambiguous required member, another adapter protocol, or an unproved host is refused; 26. the metadata probe runs against the exact resolved executable and carries no terminal, stdin, session identity, instructions, credential or provider @@ -1460,7 +1497,17 @@ Focused tests prove: cannot launch, and a bound route whose exact identity is absent remains authoritative and is never substituted. A settled exact-resume refusal acknowledges quiescence after cleanup, while a planted unproved teardown - leaves the recovery tombstone active. + leaves the recovery tombstone active; and +28. a V2 route and journal created with Claude Code 2.1.261 continue through an + independently admitted 2.1.263 executable for both native resume and ACP + attachment: no identity is allocated, the exact retained native identity is + passed and asserted, the historical binding is not rewritten, and the live + partition uses 2.1.263. The same fixtures refuse before live provider work + when protocol, requested capability, positive shape or host differs, and + close without a turn or substitution when the provider reports an absent or + different identity. Prepared-only and detached incomplete replays prove + create-versus-resume remains phase-driven across the same upgrade, while + completed replay probes nothing. The authored half of this is one executable Markdown document, `packages/test-agent/src/NativeSessionLaunch.test.md`, run whole. It authors the @@ -1536,11 +1583,12 @@ provider-native identity that is either asserted by the provider or allocated by the adapter before the provider exists, retained explicitly and never inferred; a strict create-once construction route beside the coordinator's own records, in a released unbound form and a bound one; the host-owned executable observer -and the build binding it produces; proof-scoped capability admission over the -adapter protocol, independently requested capability, observed CLI shape and -live host platform; ACP -attachment to a bound client-native session under its exact retained identity, -through runtime partitions keyed by agent command and build; +and the immutable audit binding it produces on first publication; proof-scoped +capability admission over the stable adapter protocol, independently requested +capability, live observed CLI shape and live host platform on every +continuation; compatible cross-release native resume and ACP attachment to a +bound client-native session under its exact retained identity, through runtime +partitions keyed by agent command and the live executable observation; an inherited root- or pane-terminal interactive child with cancellation and bounded reaping; composition with the terminal grid's independent pane leases without changing session ownership or durable launch identity; @@ -1569,10 +1617,11 @@ degrading: and `Session.Launch` expose no model prop or launch option. A provider may report the current model as observational evidence, but native launch neither selects nor changes it. -- **Executable upgrade migration is unbuilt.** A V2 route freezes one build for - that logical session, and a later build refuses with - `executable-binding-refused` rather than modifying the route or the provider's - history. Rebinding old provider history to a new build is a separate design. +- **Protocol migration is unbuilt.** A V2 route fixes the stable + `claude-client-native.v1` protocol through its provider, agent and launcher + contract. A compatible executable release may continue it, but another + protocol cannot reinterpret, rewrite or adopt it. Supporting that transition + requires a route contract which names the new protocol. - **A legacy unbound client-native session never attaches.** It was constructed before XMD recorded which build accepted its identity, so nothing available now can show this run is talking to that build. It keeps native resume and @@ -1624,9 +1673,11 @@ Implementation review checks these frozen invariants: reaches neither argv nor environment. 17. Private setup and child-creation failures are normalized before they cross a public or durable boundary. -18. A new client-native session is bound to one observed executable build, and - every later create, resume, attachment and incomplete replay reobserves and - compares before a process, an ensure or a turn. +18. A new client-native session retains immutable evidence for the executable + observed before its identity is published. The V2 route and prepared journal + agree on that evidence and never rewrite it, while every later create, + resume, attachment and incomplete replay independently admits the live + executable without requiring its digest or version to equal the evidence. 19. The canonical executable path is live only: it enters no route, journal, retained provider state, public result, diagnostic or global environment, and no partition that outlives its last handle. @@ -1677,10 +1728,11 @@ Implementation review checks these frozen invariants: client-allocated profile mismatch is refused after read-only observation but before allocation or any provider or session-state mutation; its identity-free launch refusal is retained. The profile is checked again - before bound resume, attachment and incomplete replay. An exact absent - identity remains authoritative and unavailable; neither that refusal nor an - unproved profile creates a replacement conversation or a materialization - turn. + before bound resume, attachment and incomplete replay, and a compatible live + release continues the exact retained identity without allocating, + republishing or rewriting durable evidence. An exact absent identity remains + authoritative and unavailable; neither that refusal nor an unproved profile + creates a replacement conversation or a materialization turn. Item 12 is the 2026-08-20 architecture amendment. ACPX fixes `systemPrompt` at session creation, while native turns are not authoritative in its cached diff --git a/specs/test-agent-spec.md b/specs/test-agent-spec.md index 4b92f7fc8..91c73b249 100644 --- a/specs/test-agent-spec.md +++ b/specs/test-agent-spec.md @@ -268,10 +268,12 @@ owned by a coordinator of its own. The observer is the whole seam, replaced whole. It answers one stable canonical version and one stable digest for the life of a partition, and a harness that -wants to watch a build drift changes what it answers rather than reaching for a -control the production path also has. There is no author-facing binding or path -prop: an authored document sees attachment and refusal, and nothing about which -file was observed. +wants to exercise a compatible release change supplies a later independently +admitted observation rather than reaching for a control the production path +also has. The original route and journal evidence stays unchanged while the live +runtime partition follows that later observation. There is no author-facing +binding or path prop: an authored document sees attachment or refusal, and +nothing about which file was observed. `` also installs a controlled native launcher for its body. The test agent's native UI is fictional in the way its agent is — the worker asserts a From d74e090e8bce85fe94d88fb601ed654642cdcfd7 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 10:17:23 -0400 Subject: [PATCH 09/22] =?UTF-8?q?=F0=9F=94=93=20Continue=20a=20client-nati?= =?UTF-8?q?ve=20session=20across=20compatible=20releases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A route's `ExecutableBuildBindingV1` was a release lock: every continuation held the live observation against the retained one, so upgrading Claude stranded every session the previous build had opened. It is now immutable audit evidence — written once at first publication, never rewritten, and never compared with a live build. What authorizes acting on an existing session is what the installed executable independently proves: the route's stable adapter protocol, the capability the work needs, the shape its own probe recognizes, and this host. Which build accepted the identity first is history, and history is not a capability. Removes the four live-vs-retained comparisons (attachment, native resume, concurrent winner, replay) and the `buildDrift` helper they shared. The one remaining comparison stays: a route and its prepared journal are two durable accounts of a single observation, so a difference between them is still a replay that cannot say which session it is resuming. Tier XR takes a 2.1.261 session to a 2.1.263 build across native resume, ACP attachment, prepared-only replay, detached replay, a concurrently published winner, and a build reporting no release — each asserting the exact retained identity, unchanged audit evidence, no allocation or republication, and the live executable's own path. XR7 and XR8 hold the fail-closed boundary that a release change is not a protocol change. --- packages/acp/src/provider.ts | 97 ++-- packages/acp/tests/native-launch.test.ts | 643 ++++++++++++++++++++--- packages/core/src/agent/launch.ts | 27 +- 3 files changed, 607 insertions(+), 160 deletions(-) diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index 717f96ad6..feab8df16 100644 --- a/packages/acp/src/provider.ts +++ b/packages/acp/src/provider.ts @@ -370,7 +370,7 @@ interface BoundBuild { */ interface RuntimeEntry { runtime: ProbeCapableRuntime; - /** The `(agent command, binding)` partition, or nothing for the unbound one. */ + /** The live-executable partition, or nothing for the unbound one. */ partition: string | undefined; /** Handles created through this runtime that have not been closed. */ handles: number; @@ -911,11 +911,16 @@ function* useAcpxProviderState( } /** - * The `(agent command, build)` partition a bound runtime is kept under. + * The `(agent command, live executable)` partition a bound runtime is kept + * under. * - * The digest is what separates two builds, so a build that reported no - * version still shares no partition with a different one — the version is - * kept beside it only so a partition names what a record names. + * Keyed by the observation serving this work, never by what a route retained: + * a partition holds a live path and a running child, so what may share one is + * decided by which executable is being run now. Two sessions whose routes + * record different builds share this child when the same release serves them + * both, and two live releases never do. The digest is what separates them, so + * a build that reported no version still shares no partition with a different + * one. */ function partitionOf(build: BoundBuild): string { return [ @@ -1704,9 +1709,9 @@ function* useAcpxProviderState( * * Every way this can fail — no observer, resolution, canonicalization, an * unreadable or non-executable file — ends in one stable class, because they - * are all the same question: is this the build that established the session. - * A version this adapter does not recognize is not among them; that is - * optional evidence, and its absence leaves a build bound by its digest. + * are all the same question: can this run see the executable it would use at + * all. A version this adapter does not recognize is not among them; that is + * optional evidence, and its absence leaves a build described by its digest. */ function* observeBuild( agentName: string, @@ -1768,10 +1773,13 @@ function* useAcpxProviderState( * a private file, a child, an ACP ensure — because a capability nobody proved * is not a thing to discover halfway through. * - * Distinct from build drift beside it, which is a different question. Drift - * asks whether this is still the build that accepted one retained identity; - * this asks whether the build works at all. A session may fail either while - * passing the other. + * It is the whole authorization for acting on an existing session, too. What + * a retained identity needs is an executable that implements the operation, + * and this asks exactly that; which executable happened to accept the identity + * first is history, and history is not a capability. So a release that changed + * under the same command continues the conversation once it passes here on its + * own, and one that no longer implements the operation is refused even if it + * is byte-for-byte the build that opened it. */ function admitCapability( agentName: string, @@ -1803,29 +1811,6 @@ function* useAcpxProviderState( }; } - /** - * The stable comparison two builds of one session fail. - * - * Canonical versions appear when they exist because they are the readable - * half of the answer, and a build that reported none says so rather than - * substituting its digest — a digest is host-observable evidence and belongs - * in no message. - */ - function buildDrift( - sessionKey: string, - retained: ExecutableBuildBindingV1, - live: ExecutableBuildBindingV1, - ): LaunchFailure { - const named = (binding: ExecutableBuildBindingV1) => - binding.reportedVersion ?? "a build reporting no version this adapter recognizes"; - return { - class: "executable-binding-refused", - message: - `session "${sessionKey}" was created by ${named(retained)} and this run ` + - `would use ${named(live)}, so the conversation it names cannot be confirmed`, - }; - } - /** * What a client-native route's provider arrangement already asserts. * @@ -1861,8 +1846,8 @@ function* useAcpxProviderState( * `` and `` are eager, so a session nobody constructed is * constructed here as `acp-first` before ensure. A session a native process * constructed is attached to — never converted, never republished — and only - * when this host has proven that capability for this adapter and can still - * show it is talking to the build that created it. + * when this host has proven that capability for the executable it is about to + * run, whichever release that has become. */ function* constructRoute( agentName: string, @@ -1914,19 +1899,15 @@ function* useAcpxProviderState( attaching.protocol, attaching.binding, ); - // Before the comparison and long before the ensure. A build this host has - // not proved attachment on is refused whether or not it happens to be the - // build that created the session — being the right one is not evidence that - // joining the conversation through ACP works on it. + // Long before the ensure, and the whole authorization. Being the executable + // that created the session was never evidence that joining the conversation + // through ACP works on it, and the route's retained binding is an account of + // a past observation rather than a claim about the one running now. What has + // to hold is that this executable independently implements the operation. const unproved = admitCapability(agentName, "client-native-attachment", build); if (unproved) { throw new AttachmentRefused(unproved); } - if (!sameExecutableBuild(route.executableBinding, build.binding)) { - throw new AttachmentRefused( - buildDrift(prepared.sessionKey, route.executableBinding, build.binding), - ); - } yield* retainedAssertion(prepared.sessionKey, route.nativeSessionId); return { build, resumeSessionId: route.nativeSessionId }; } @@ -2448,13 +2429,6 @@ function* useAcpxProviderState( if (unproved) { return refusal(unproved.class, unproved.message, known); } - if ( - route?.route === "client-native" && - !sameExecutableBuild(route.executableBinding, build.binding) - ) { - const drift = buildDrift(sessionKey, route.executableBinding, build.binding); - return refusal(drift.class, drift.message, known); - } // A session that already has an identity is resumed under it, and nothing // is allocated at all: a second candidate for a conversation that already @@ -2513,14 +2487,14 @@ function* useAcpxProviderState( invocation.fresh.set(sessionKey, false); return retained(agentName, adapter, winner, instructions, sessionCwd, "resumed"); } - if (!sameExecutableBuild(winner.executableBinding, build.binding)) { - const drift = buildDrift(sessionKey, winner.executableBinding, build.binding); - return refusal(drift.class, drift.message, known); - } - // The record is built from the winner rather than from the candidate, so - // the two accounts agree by construction rather than by comparison. Losing - // the race means this session already exists and is resumed. + // The record is built from the winner rather than from the candidate — its + // identity and its binding alike — so the two durable accounts agree by + // construction rather than by comparison. A winner published from a + // different compatible observation keeps the evidence it was written with: + // this launch already admitted the executable it will actually run, and the + // winner's account of an earlier one is not a second thing to satisfy. + // Losing the race means this session already exists and is resumed. const fresh = winner.nativeSessionId === candidate; invocation.fresh.set(sessionKey, fresh); invocation.bound.set(sessionKey, { build, adapter }); @@ -3108,9 +3082,6 @@ function* useAcpxProviderState( if (unproved) { return unproved; } - if (!sameExecutableBuild(route.executableBinding, build.binding)) { - return buildDrift(prepared.sessionKey, route.executableBinding, build.binding); - } invocation.bound.set(prepared.sessionKey, { build, adapter }); return undefined; } diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index 70dac58fe..5fc997bc0 100644 --- a/packages/acp/tests/native-launch.test.ts +++ b/packages/acp/tests/native-launch.test.ts @@ -208,6 +208,20 @@ const DIGEST_ONLY_BUILD: ExecutableBuildBindingV1 = { /** The canonical path that same observation reports. */ const OBSERVED_PATH = "/opt/builds/claude"; +/** + * Two real Claude releases, and where a build of the later one is installed. + * + * A session opened under the first and continued under the second is the case + * the cross-release contract exists for, so it is named once here rather than + * assembled per tier: the releases differ, the bytes differ, and the path + * differs, which is every way a durable account can stop describing what is + * installed. + */ +const RETAINED_RELEASE = "2.1.261 (Claude Code)"; +const LATER_RELEASE = "2.1.263 (Claude Code)"; +const LATER_DIGEST = "b".repeat(64); +const LATER_PATH = "/opt/builds/claude-2.1.263"; + /** * One controlled observation, varying only what a case is about. * @@ -2912,48 +2926,69 @@ describe("Tier CA — client-native attachment", () => { expect(space.harness.createdOptions).toEqual([]); }); - it("CA5: a build this run cannot name, or cannot reach, refuses before ensure", function* () { - for (const [name, mutate] of [ - [ - "another build at the same command", - (observer: FakeObserverHarness) => { - observer.observation.digest = "b".repeat(64); - }, - ], - [ - "another version of the same bytes", - (observer: FakeObserverHarness) => { - observer.observation.metadata.version = answered("2.1.242 (Claude Code)\n"); - }, - ], - [ - "a build that no longer reproduces the version this route retained", - (observer: FakeObserverHarness) => { - observer.observation.metadata.version = answered("claude version 2.1.241\n"); - }, - ], - [ - "an executable that could not be observed at all", - (observer: FakeObserverHarness) => { - observer.failure = "not-executable"; - }, - ], + it("CA5: an executable this run cannot reach refuses before ensure", function* () { + // What stops an attachment is not knowing which release is installed — it + // is not being able to see the executable at all. Every way of failing to + // reach one ends in the same class, because none of them produced an + // account of a build this run could then admit. + for (const [name, failure] of [ + ["an executable that is not there", "not-found"], + ["a path that is not an executable file", "not-executable"], ] as const) { yield* scoped(function* () { const observer = createFakeObserver(); - mutate(observer); + observer.failure = failure; const space = yield* installAttachment(bound(), { observer: observer.observer }); const raised = yield* attach(); expect([name, raised === undefined]).toEqual([name, false]); expect([name, space.harness.ensureCalls]).toEqual([name, []]); - // A moved build is still that build, so nothing here may name a path. + expect([name, space.harness.createdOptions]).toEqual([name, []]); expect([name, raised?.message.includes(OBSERVED_PATH)]).toEqual([name, false]); }); } }); + it("CA5b: a release this route never met attaches on its own admission", function* () { + // The counterpart, and the whole of the new rule at this seam. A route's + // binding is an account of the build that opened the conversation; joining + // it is a question about the build running now. Each of these is a live + // executable the retained account does not describe — other bytes, another + // release, a release this adapter cannot read — and each independently + // declares the attachment shape on the proved host, so each joins. + for (const [name, live] of [ + ["another build at the same command", observation({ digest: "b".repeat(64) })], + ["another release of the same bytes", observation({ version: `${LATER_RELEASE}\n` })], + [ + "a release this adapter cannot read", + observation({ version: "claude, the coding agent\n" }), + ], + ] as const) { + yield* scoped(function* () { + const route = bound(); + const observer = createFakeObserver(live); + const space = yield* installAttachment(route, { observer: observer.observer }); + + const session = yield* Agent.operations.session(); + + // The route's own identity crossed, unchanged and unreplaced. + expect([name, session.agentSessionId]).toEqual([name, ALLOCATED]); + expect([name, space.harness.ensureCalls.map((call) => call.resumeSessionId)]).toEqual([ + name, + [ALLOCATED], + ]); + // Served by the executable observed now, not by the one recorded then. + expect([name, space.harness.createdOptions.at(-1)?.agentProcessEnv]).toEqual([ + name, + { CLAUDE_CODE_EXECUTABLE: live.path }, + ]); + // And the account of how it started is exactly as it was written. + expect([name, yield* space.routes.read(KEY)]).toEqual([name, route]); + }); + } + }); + it("CA6: a moved build is the same build", function* () { // Equality is over what was retained — a version and a digest — so the same // bytes reached at another path attach exactly as they would have before. @@ -3304,11 +3339,13 @@ describe("Tier CA — client-native attachment", () => { expect(space.harness.createdOptions).toHaveLength(2); }); - it("CA14: a route's retained version that this build will not reproduce refuses before a child, an ensure or a turn", function* () { + it("CA14: a build that names no release attaches on its shape, and repeats nothing", function* () { // One canonical line is an answer. Several is a list of builds, and taking - // the first would be choosing one — so this build reports no version. That - // is not a refusal in itself; what refuses is that the route retained one, - // and a claim the live build no longer makes cannot be confirmed. + // the first would be choosing one — so this build reports no version at + // all. That settles nothing about whether it can join a conversation, which + // is a question about the shape it declares; the shape is there, so it + // does. The route retained a release, and this build makes no claim about + // it either way. const observer = createFakeObserver({ metadata: { help: answered(claudeHelp()), @@ -3317,15 +3354,14 @@ describe("Tier CA — client-native attachment", () => { }); const space = yield* installAttachment(bound(), { observer: observer.observer }); - const raised = yield* attach(); + const session = yield* Agent.operations.session(); - expect(raised?.message).toContain("cannot be confirmed"); - // Nothing was repeated back: the output is the provider's, not the reader's. - expect(raised?.message).not.toContain("2.1.242"); - expect(space.harness.createdOptions).toEqual([]); - expect(space.harness.ensureCalls).toEqual([]); - expect(space.harness.turns).toEqual([]); - expect(space.trace.launches).toEqual([]); + expect(session.agentSessionId).toBe(ALLOCATED); + expect(space.harness.ensureCalls.map((call) => call.resumeSessionId)).toEqual([ALLOCATED]); + // Nothing the build said was repeated back through the session it opened. + expect(JSON.stringify(space.harness.createdOptions)).not.toContain("2.1.242"); + // The route still says what the run that published it could stand behind. + expect(yield* space.routes.read(KEY)).toEqual(bound()); }); it("CA9: a legacy unbound route refuses rather than attaching", function* () { @@ -3459,19 +3495,70 @@ describe("Tier RT — bound runtime partitions", () => { }); yield* Agent.operations.session(); - // The same command, a different build behind it, and a route that names - // the old one: the attachment refuses rather than reusing the child. + // The same command and the same route, with a different build behind it + // now. The session continues — that is the cross-release contract — but it + // continues through the executable that is actually installed, so the + // second attachment is served by a child of its own. observer.observation.digest = "c".repeat(64); observer.observation.path = "/opt/builds/other-claude"; - let raised: Error | undefined; - try { - yield* Agent.operations.session(); - } catch (error) { - raised = error as Error; - } + yield* Agent.operations.session(); - expect(raised?.message).toContain("cannot be confirmed"); - // One runtime, for the one build that was ever accepted. + // Two builds, two children, and each one given the path it was built for. + expect(environments(harness)).toEqual([OBSERVED_PATH, "/opt/builds/other-claude"]); + // Nothing was rekeyed: each handle went back through its own runtime. + expect(harness.closeCalls).toHaveLength(2); + expect(harness.closeRuntimes).toEqual([OBSERVED_PATH, "/opt/builds/other-claude"]); + }); + + it("RT2b: one live build serves routes that record different builds", function* () { + // The other direction, and the one the old rule could not express. These + // two sessions were opened by different releases and their routes still + // say so. What decides whether they may share a child is which executable + // is running now, and it is the same one — so they do. + const harness = createFakeRuntime(); + const trace = newTrace(); + const routes = createMemorySessionRouteStore(); + const observer = createFakeObserver(); + const other: ExecutableBuildBindingV1 = { + schema: "executable-build.v1", + reportedVersion: RETAINED_RELEASE, + executableDigest: { algorithm: "sha256", value: "e".repeat(64) }, + }; + const held = deriveSessionKey(AGENT_COMMAND, CWD, "held"); + const beside = deriveSessionKey(AGENT_COMMAND, CWD, "beside"); + yield* routes.publish(route(held, FIRST)); + yield* routes.publish(route(beside, SECOND, other)); + yield* installLaunchStack(harness, trace, { + adapters: { claude: adapter() }, + routeStore: routes, + observer: observer.observer, + }); + + // A partition is only shared while something is holding it, so the first + // session is kept in flight rather than allowed to return — otherwise the + // second would find an evicted partition and build its own either way. + const gate = withResolvers(); + const arrived = withResolvers(); + harness.ensureGate = (input) => { + if (input.sessionKey === held) { + arrived.resolve(); + return gate.operation; + } + return undefined; + }; + + const first = yield* spawn(() => Agent.operations.session("held")); + yield* arrived.operation; + const later = yield* Agent.operations.session("beside"); + const built = harness.createdOptions.length; + gate.resolve(); + const settled = yield* first; + + expect([settled.agentSessionId, later.agentSessionId]).toEqual([FIRST, SECOND]); + // Both were observed — neither was taken on the strength of its route — + // and one child answered for both. + expect(observer.observed).toEqual(["claude", "claude"]); + expect(built).toBe(1); expect(environments(harness)).toEqual([OBSERVED_PATH]); }); @@ -4495,7 +4582,7 @@ describe("Tier NP — proved native capability admissions", () => { /** The build the shipped Claude proof ran against, and one that follows it. */ const PROVED_VERSION = "2.1.241 (Claude Code)"; - const LATER_VERSION = "2.1.263 (Claude Code)"; + const LATER_VERSION = LATER_RELEASE; /** * The same help surface with exactly one required declaration withdrawn. @@ -4921,32 +5008,37 @@ describe("Tier NP — proved native capability admissions", () => { } }); - it("NP5: an admitted point still refuses a build the route does not name", function* () { - // Admission and continuity are different questions, and passing the first - // is not an answer to the second. The live build is exactly the proved one; - // the route names an earlier one, so this is the drift refusal, not the - // capability one. + it("NP5: an admitted point continues a session the route's build did not open", function* () { + // Admission is the whole authorization, and it is a question about the + // executable this run would use. The route names bytes that are not + // installed any more; this build declares the shape on the proved host, so + // it continues the conversation rather than being asked to account for its + // predecessor. const harness = createFakeRuntime(); const trace = newTrace(); const seen = boundaries(); const routes = countedRoutes(seen); - yield* routes.publish( - bound({ - schema: "executable-build.v1", - reportedVersion: PROVED_VERSION, - executableDigest: { algorithm: "sha256", value: "e".repeat(64) }, - }), - ); + const published = bound({ + schema: "executable-build.v1", + reportedVersion: PROVED_VERSION, + executableDigest: { algorithm: "sha256", value: "e".repeat(64) }, + }); + yield* routes.publish(published); yield* installLaunchStack(harness, trace, { adapters: { claude: countedAdapter(seen) }, routeStore: routes, }); - const failure = yield* attempt(trace, INSTRUCTIONS); + yield* launch(INSTRUCTIONS); - expect(failure?.class).toBe("executable-binding-refused"); - expect(seen.resumes).toBe(0); - expect(trace.launches).toEqual([]); + expect(trace.records.some((record) => record.failure)).toBe(false); + // The exact retained identity, resumed rather than recreated, and no + // second identity reached for. + expect([seen.resumes, seen.creates, seen.allocations]).toEqual([1, 0, 0]); + expect(trace.launches[0]!.command).toEqual([OBSERVED_PATH, "--resume", ALLOCATED]); + // The audit evidence is what it was, and nothing published over it. + expect(seen.published.length).toBe(1); + expect(yield* routes.read(KEY)).toEqual(published); }); it("NP5b: a build whose release cannot be read is still admitted, and binds by digest", function* () { @@ -5024,28 +5116,28 @@ describe("Tier NP — proved native capability admissions", () => { expect(before).not.toContain(LATER_VERSION); }); - it("NP5d: a retained release must still be reproduced, and a digest decides regardless", function* () { - // The two halves of the asymmetry. A route that named a release is a claim - // the live build has to still make: one that reports another, or none this - // adapter can read, is not the build that history belongs to. And the - // digest is the one that always decides — a build whose shape is admitted - // and whose release matches is still refused when it is a different build. + it("NP5d: no way a live build can differ from the retained one stops the resume", function* () { + // Every shape the difference can take, because the rule is that the + // comparison is not made at all — not that some differences are tolerated. + // A later release, a release this adapter cannot read, other bytes under + // the same release, other bytes where the route named no release: each is + // admitted on its own and each resumes the one identity there is. for (const [name, retained, live] of [ [ - "a live build reporting another release", + "a live build reporting a later release", OBSERVED_BUILD, - observation({ version: `${LATER_VERSION}\n` }), + observation({ version: `${LATER_RELEASE}\n` }), ], ["a live build whose release cannot be read", OBSERVED_BUILD, observation({ version: "" })], [ - "a different build, admitted and reporting the retained release", + "a different build reporting the release the route retained", OBSERVED_BUILD, - observation({ digest: "b".repeat(64) }), + observation({ digest: LATER_DIGEST }), ], [ - "a different build, admitted, where the route named no release", + "a different build where the route named no release", DIGEST_ONLY_BUILD, - observation({ digest: "b".repeat(64) }), + observation({ digest: LATER_DIGEST }), ], ] as const) { yield* scoped(function* () { @@ -5061,17 +5153,18 @@ describe("Tier NP — proved native capability admissions", () => { observer: createFakeObserver(live).observer, }); - const failure = yield* attempt(trace, INSTRUCTIONS); + yield* launch(INSTRUCTIONS); - expect([name, failure?.class]).toEqual([name, "executable-binding-refused"]); - expect([name, seen.resumes + seen.creates]).toEqual([name, 0]); - expect([name, harness.ensureCalls]).toEqual([name, []]); - expect([name, trace.launches]).toEqual([name, []]); + expect([name, trace.records.some((record) => record.failure)]).toEqual([name, false]); + // Resumed under the retained identity; nothing created, nothing allocated. + expect([name, [seen.resumes, seen.creates, seen.allocations]]).toEqual([name, [1, 0, 0]]); + expect([name, trace.launches[0]?.command]).toEqual([ + name, + [live.path, "--resume", ALLOCATED], + ]); + // The durable account is untouched: one publication, byte-identical. expect([name, seen.published.length]).toEqual([name, 1]); expect([name, JSON.stringify(yield* routes.read(KEY))]).toEqual([name, before]); - // The refusal says which releases are involved and never the digest. - expect([name, failure?.message.includes("a".repeat(64))]).toEqual([name, false]); - expect([name, failure?.message.includes("b".repeat(64))]).toEqual([name, false]); }); } }); @@ -5349,3 +5442,379 @@ describe("Tier NP — proved native capability admissions", () => { expect(JSON.stringify(yield* routes.read(KEY))).toBe(before); }); }); + +/** + * Tier XR — one session across two releases + * (specs/native-agent-session-launch-spec.md §Cross-release continuation). + * + * A session opened by 2.1.261 and continued by 2.1.263. The retained binding is + * an account of the observation that opened it, written once and never again; + * what authorizes acting on that session now is what the executable running now + * independently proves — the route's stable protocol, the capability the work + * needs, the shape it declares, and this host. The two are never held to each + * other, which is the whole of what these cases discriminate: every one of them + * fails under a rule that compares them, and none of them is a fixture change. + * + * What that must not cost is the fail-closed boundary. A release changing is not + * a protocol changing, so the last cases here take the same 2.1.263 build to a + * host that proved something else, and to a journal that disagrees with its + * route about the build history it retained. + */ +describe("Tier XR — one session across two releases", () => { + const ALLOCATED = "5eed0000-1111-2222-3333-444444444444"; + /** What a candidate would allocate if this run ever reached for an identity. */ + const CANDIDATE = "beef0000-1111-2222-3333-444444444444"; + + /** The audit evidence 2.1.261 wrote when it opened the session. */ + const RETAINED_BUILD: ExecutableBuildBindingV1 = { + schema: "executable-build.v1", + reportedVersion: RETAINED_RELEASE, + executableDigest: { algorithm: "sha256", value: "a".repeat(64) }, + }; + + /** The 2.1.263 build installed now: other bytes, other release, other path. */ + function live(overrides: { version?: string | false } = {}): FakeObservation { + return observation({ + path: LATER_PATH, + digest: LATER_DIGEST, + version: overrides.version === undefined ? `${LATER_RELEASE}\n` : overrides.version, + }); + } + + const KEY = { provider: "acpx", agent: AGENT_COMMAND, sessionKey: SESSION_KEY }; + + interface Seen { + allocations: number; + creates: number; + resumes: number; + published: AgentSessionRoute[]; + } + + function seen(): Seen { + return { allocations: 0, creates: 0, resumes: 0, published: [] }; + } + + /** The adapter, with every identity-bearing act it can perform counted. */ + function countedAdapter(counts: Seen): NativeAdapter { + return { + launcher: "claude", + protocol: CLAUDE_PROTOCOL, + identity: "client-allocated", + binding: TEST_BINDING, + allocate: () => { + counts.allocations += 1; + return CANDIDATE; + }, + create: (nativeSessionId: string, instructionFile: string) => { + counts.creates += 1; + return ["claude", "--session-id", nativeSessionId, "--system-prompt-file", instructionFile]; + }, + resume: (nativeSessionId: string) => { + counts.resumes += 1; + return ["claude", "--resume", nativeSessionId]; + }, + }; + } + + function countedRoutes(counts: Seen, inner = createMemorySessionRouteStore()) { + return { + read: (key: Parameters[0]) => inner.read(key), + *publish(candidate: AgentSessionRoute) { + counts.published.push(candidate); + return yield* inner.publish(candidate); + }, + } satisfies AgentSessionRouteStore; + } + + /** The route 2.1.261 published, which no run after it may rewrite. */ + function route(binding: ExecutableBuildBindingV1 = RETAINED_BUILD): AgentSessionRoute { + return { + schema: "session-route.v2", + route: "client-native", + provider: "acpx", + agent: AGENT_COMMAND, + sessionKey: SESSION_KEY, + nativeSessionId: ALLOCATED, + identityProvenance: "client-allocated", + instructionsDigest: createHash("sha256").update(INSTRUCTIONS).digest("hex"), + launcher: "claude", + executableBinding: binding, + }; + } + + /** The journal 2.1.261 left, agreeing with that route exactly. */ + function prepared(overrides: Partial = {}): PreparedLaunchRecord { + return { + phase: "prepared", + agent: "claude", + sessionKey: SESSION_KEY, + provider: "acpx", + nativeSessionId: ALLOCATED, + sessionState: "created", + instructionChannel: "claude.systemPromptFile", + instructionReconciliation: "installed", + identityProvenance: "client-allocated", + executableBinding: RETAINED_BUILD, + instructionsDigest: createHash("sha256").update(INSTRUCTIONS).digest("hex"), + instructions: INSTRUCTIONS, + cwd: CWD, + additionalDirectories: [], + permissionMode: "approve-reads", + launcher: "claude", + ...overrides, + }; + } + + interface Continued { + harness: FakeRuntimeHarness; + trace: Trace; + routes: AgentSessionRouteStore; + observer: FakeObserverHarness; + counts: Seen; + } + + /** + * The published 2.1.261 session, with 2.1.263 the executable under the command. + * + * Everything else is the ordinary installation, so what each case varies is + * the one thing it is about. + */ + function* continuing( + options: { + published?: AgentSessionRoute; + observation?: FakeObservation; + routes?: AgentSessionRouteStore; + nativeCapabilityPolicy?: NativeCapabilityPolicy | false; + } = {}, + ): Operation { + const counts = seen(); + const harness = createFakeRuntime(); + const trace = newTrace(); + const routes = options.routes ?? countedRoutes(counts); + const observer = createFakeObserver(options.observation ?? live()); + yield* routes.publish(options.published ?? route()); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(counts) }, + routeStore: routes, + observer: observer.observer, + ...(options.nativeCapabilityPolicy === undefined + ? {} + : { nativeCapabilityPolicy: options.nativeCapabilityPolicy }), + }); + return { harness, trace, routes, observer, counts }; + } + + /** Everything a continuation must leave exactly as 2.1.261 wrote it. */ + function* unchanged(space: Continued, published: AgentSessionRoute = route()): Operation { + // The account of how this session started, read back whole: the identity + // it names, and the build that opened it, neither converted nor supplemented + // by the release that is continuing it. + expect(yield* space.routes.read(KEY)).toEqual(published); + // And read as the one publication this case made before the provider + // existed, so "unchanged" is not a rewrite that happened to agree. + expect(space.counts.published).toEqual([published]); + expect(space.counts.allocations).toBe(0); + } + + it("XR1: a native resume runs the installed release under the retained identity", function* () { + const space = yield* continuing(); + + yield* launch(INSTRUCTIONS); + + expect(space.trace.records.some((record) => record.failure)).toBe(false); + // The one identity this session has, resumed by the executable installed + // now — not the path the route's build was found at. + expect(space.trace.launches.map((request) => request.command)).toEqual([ + [LATER_PATH, "--resume", ALLOCATED], + ]); + expect([space.counts.resumes, space.counts.creates]).toEqual([1, 0]); + yield* unchanged(space); + }); + + it("XR2: an ACP attachment joins through the installed release", function* () { + const space = yield* continuing(); + + const session = yield* Agent.operations.session(); + + // Attachment is admitted on its own capability, and the identity that + // crosses is the route's — required back from the provider before a turn. + expect(session.agentSessionId).toBe(ALLOCATED); + expect(space.harness.ensureCalls.map((call) => call.resumeSessionId)).toEqual([ALLOCATED]); + expect(space.harness.createdOptions.map((options) => options.agentProcessEnv)).toEqual([ + { CLAUDE_CODE_EXECUTABLE: LATER_PATH }, + ]); + yield* unchanged(space); + }); + + it("XR3: a prepared-only replay creates under the retained identity", function* () { + // The predecessor never detached, so creation may still be owed. It is + // owed under the identity 2.1.261 allocated, performed by 2.1.263, with an + // instruction file this run wrote. + const space = yield* continuing(); + space.trace.replay = { prepared: prepared(), suffix: "prepared" }; + + yield* Agent.operations.launch(launchRequest(INSTRUCTIONS)); + + expect(space.trace.records.some((record) => record.failure)).toBe(false); + const command = space.trace.launches[0]!.command; + expect(command[0]).toBe(LATER_PATH); + expect(command).toContain("--session-id"); + expect(command).toContain(ALLOCATED); + expect(command).not.toContain("--resume"); + expect([space.counts.creates, space.counts.resumes]).toEqual([1, 0]); + yield* unchanged(space); + }); + + it("XR4: a detached replay resumes, and never falls back to creating", function* () { + // The journal proves the session was already handed to a native process, + // so there is a conversation under this identity. A creation here would + // open a second one, and no change of release makes that the fallback. + const space = yield* continuing(); + space.trace.replay = { prepared: prepared(), suffix: "prepared+detached" }; + + yield* Agent.operations.launch(launchRequest(INSTRUCTIONS)); + + expect(space.trace.records.some((record) => record.failure)).toBe(false); + expect(space.trace.launches.map((request) => request.command)).toEqual([ + [LATER_PATH, "--resume", ALLOCATED], + ]); + expect([space.counts.resumes, space.counts.creates]).toEqual([1, 0]); + expect(space.harness.ensureCalls).toEqual([]); + yield* unchanged(space); + }); + + it("XR5: a live build that reports no release continues just the same", function* () { + // The version query is evidence beside the digest, not a gate, and a build + // that will not answer it says nothing about the session either way. Both + // continuations, so neither seam is quietly reading the release. + const space = yield* continuing({ observation: live({ version: false }) }); + + yield* launch(INSTRUCTIONS); + const session = yield* Agent.operations.session(); + + expect(space.trace.records.some((record) => record.failure)).toBe(false); + expect(space.trace.launches.map((request) => request.command)).toEqual([ + [LATER_PATH, "--resume", ALLOCATED], + ]); + expect(session.agentSessionId).toBe(ALLOCATED); + yield* unchanged(space); + }); + + it("XR6: a compatible winner is adopted with the evidence it was written with", function* () { + // What losing a publication race looks like from inside the loser: it read + // no route, so it prepared one of its own, and the store handed back the + // account that got there first. That winner records a build this run never + // observed. It is adopted whole — its identity and its audit evidence — + // because the winner is the session, and this run has already admitted the + // executable it is about to run. + const inner = createMemorySessionRouteStore(); + const routes: AgentSessionRouteStore = { + // The read this invocation made happened before the winner existed, and + // a compare-and-set publication is where it finds out otherwise. + // deno-lint-ignore require-yield + *read() { + return undefined; + }, + publish: (candidate) => inner.publish(candidate), + }; + const space = yield* continuing({ routes, published: route() }); + + yield* launch(INSTRUCTIONS); + + expect(space.trace.records.some((record) => record.failure)).toBe(false); + // The winner's identity, resumed — never the one this run allocated. + expect(space.trace.launches.map((request) => request.command)).toEqual([ + [LATER_PATH, "--resume", ALLOCATED], + ]); + expect(JSON.stringify(space.trace.launches)).not.toContain(CANDIDATE); + expect([space.counts.resumes, space.counts.creates]).toEqual([1, 0]); + // The winner's own binding survived the adoption, unrewritten. + const adopted = space.trace.records[0] as PreparedLaunchRecord; + expect(adopted.nativeSessionId).toBe(ALLOCATED); + expect(adopted.executableBinding).toEqual(RETAINED_BUILD); + expect(yield* inner.read(KEY)).toEqual(route()); + }); + + it("XR7: a release changing is not a protocol changing", function* () { + // The fail-closed half. Continuation asks the installed executable to prove + // the route's protocol, the capability, the shape and this host — so a + // 2.1.263 build that is admitted for something else, or on some other + // machine, continues nothing. Both seams, because they are admitted apart. + for (const [name, policy] of [ + ["another adapter protocol", admitting("some-other-native.v1")], + ["another probe profile", admitting(CLAUDE_PROTOCOL, "some-other-help.v1")], + ["another platform", { ...admitting(), host: { platform: "linux", architecture: "arm64" } }], + [ + "another architecture", + { ...admitting(), host: { platform: "darwin", architecture: "x64" } }, + ], + ["a host that proved nothing", false], + ] as const) { + yield* scoped(function* () { + const space = yield* continuing({ nativeCapabilityPolicy: policy }); + + const failure = yield* attempt(space.trace, INSTRUCTIONS); + let raised: Error | undefined; + try { + yield* Agent.operations.session(); + } catch (error) { + raised = error as Error; + } + + expect([name, failure?.class]).toEqual([name, "unsupported-capability"]); + expect([name, raised === undefined]).toEqual([name, false]); + // In front of every live boundary, on both paths. + expect([name, space.trace.launches]).toEqual([name, []]); + expect([name, space.harness.ensureCalls]).toEqual([name, []]); + expect([name, space.harness.createdOptions]).toEqual([name, []]); + expect([name, [space.counts.resumes, space.counts.creates]]).toEqual([name, [0, 0]]); + yield* unchanged(space); + }); + } + }); + + it("XR8: a journal and route that disagree about the build refuse before the live phase", function* () { + // The one place two build accounts are still held to each other, and the + // reason it survives: these are two durable records of a single + // observation, so a difference between them is a replay that cannot say + // which session it is resuming. Asked in both directions, because a record + // saying less than its route has lost evidence just as surely as one saying + // something else. + const quiet: ExecutableBuildBindingV1 = { + schema: "executable-build.v1", + executableDigest: RETAINED_BUILD.executableDigest, + }; + const elsewhere: ExecutableBuildBindingV1 = { + schema: "executable-build.v1", + reportedVersion: RETAINED_RELEASE, + executableDigest: { algorithm: "sha256", value: "f".repeat(64) }, + }; + const disagreements: [string, ExecutableBuildBindingV1, ExecutableBuildBindingV1][] = [ + ["a journal naming other bytes", RETAINED_BUILD, elsewhere], + ["a journal that named no release", RETAINED_BUILD, quiet], + ["a route that named no release", quiet, RETAINED_BUILD], + ]; + + for (const suffix of ["prepared", "prepared+detached"] as const) { + for (const [name, retained, journalled] of disagreements) { + const label = `${suffix}/${name}`; + yield* scoped(function* () { + const published = route(retained); + const space = yield* continuing({ published }); + space.trace.replay = { + prepared: prepared({ executableBinding: journalled }), + suffix, + }; + + yield* Agent.operations.launch(launchRequest(INSTRUCTIONS)); + + const failed = space.trace.records.findLast((record) => record.failure); + expect([label, failed?.failure?.class]).toEqual([label, "identity-unavailable"]); + expect([label, space.trace.launches]).toEqual([label, []]); + expect([label, [space.counts.resumes, space.counts.creates]]).toEqual([label, [0, 0]]); + yield* unchanged(space, published); + }); + } + } + }); +}); diff --git a/packages/core/src/agent/launch.ts b/packages/core/src/agent/launch.ts index d8a3b57ec..f29f48b77 100644 --- a/packages/core/src/agent/launch.ts +++ b/packages/core/src/agent/launch.ts @@ -125,27 +125,34 @@ export interface ExecutableBuildBindingV1 { } /** - * Whether `live` is the build `retained` names. + * Whether `observed` is the build `retained` names. * * Asymmetric, because the two arguments are not the same kind of claim. The * digest decides: identical bytes are the same build reached through whatever * path, and different bytes are a different build however it describes itself. * - * A retained version is then a claim the live build must still make. A record + * A retained version is then a claim the other account must still make. One * that named a release and now meets one calling itself something else — or - * nothing at all — has lost the evidence it was written with. A record that - * named none never had that evidence, so a version appearing later adds - * nothing to reproduce and does not rewrite what was retained. + * nothing at all — has lost the evidence it was written with. One that named + * none never had that evidence, so a version appearing later adds nothing to + * reproduce and does not rewrite what was retained. + * + * This says which build an account describes, and nothing about what an + * installed executable can do. Continuation is not decided by asking it about a + * live observation — a session crosses releases on a fresh capability admission + * instead. What it is for is holding two durable accounts of one preparation to + * each other, which is exact when asked in both directions. */ export function sameExecutableBuild( retained: ExecutableBuildBindingV1, - live: ExecutableBuildBindingV1, + observed: ExecutableBuildBindingV1, ): boolean { return ( - retained.schema === live.schema && - retained.executableDigest.algorithm === live.executableDigest.algorithm && - retained.executableDigest.value === live.executableDigest.value && - (retained.reportedVersion === undefined || retained.reportedVersion === live.reportedVersion) + retained.schema === observed.schema && + retained.executableDigest.algorithm === observed.executableDigest.algorithm && + retained.executableDigest.value === observed.executableDigest.value && + (retained.reportedVersion === undefined || + retained.reportedVersion === observed.reportedVersion) ); } From d1be015ac884daf4fa9c404cab44c1e8e0332869 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 14:58:47 -0400 Subject: [PATCH 10/22] =?UTF-8?q?=F0=9F=94=92=20Pin=20a=20V2=20route's=20n?= =?UTF-8?q?ative=20protocol=20to=20its=20route=20contract=20(#717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A V2 construction route fixes the stable protocol its identity was published under through its provider, agent and launcher contract. Continuation read that protocol off whatever adapter happened to be registered under the launcher instead, so an adapter declaring another protocol — beside a host policy that had proved exactly that protocol, for that capability, in that shape, on this machine — could adopt a session it never constructed. ACP attachment also never held the route's retained launcher to the live adapter at all. The pin is compiled in and reachable through no host-supplied dependency, and it is asked before the executable is observed: which conversation this is was never a question about a build. Native resume, ACP attachment, concurrent-winner adoption and both incomplete replay phases now require the live adapter's launcher and protocol to be the ones the retained contract names, and the independent admission of the current executable's capability, shape and host follows it. A refusal observes nothing, allocates nothing, launches, ensures, prompts and constructs nothing, and leaves the route and the journal exactly as their first publication left them. Executable bindings stay what they became: immutable audit evidence, and a cross-check between two durable accounts of one observation — never a live same-build gate. The source contracts that still described them as one are reconciled. Evidence: XR9 takes an injected adapter speaking another protocol, with the host policy admitting that protocol, through native continuation, ACP attachment, both replay phases and concurrent-winner adoption, and asserts the executable is never even observed. XR10 refuses an attachment whose retained launcher is not the live adapter's. Planting each seam's absence back one at a time fails only that seam's case, and reading the pin off the adapter fails both cases and nothing else. --- packages/acp/src/native-launch.ts | 51 ++++++-- packages/acp/src/provider.ts | 94 +++++++++++++- packages/acp/src/session-route.ts | 17 ++- packages/acp/tests/native-launch.test.ts | 150 +++++++++++++++++++++-- packages/core/src/agent/launch.ts | 10 +- 5 files changed, 294 insertions(+), 28 deletions(-) diff --git a/packages/acp/src/native-launch.ts b/packages/acp/src/native-launch.ts index 6bd737f30..df8725c7b 100644 --- a/packages/acp/src/native-launch.ts +++ b/packages/acp/src/native-launch.ts @@ -67,10 +67,12 @@ export type NativeCapabilityProbe = (metadata: ExecutableMetadata) => ProbedNati /** * What an adapter knows about the build behind its executable. * - * A session whose identity XMD chose only means something while the build that - * accepted it can be recognized later: two builds of one provider accept the - * same identity and disagree silently about what it names. Everything here is - * that adapter's private dialect — which command to observe, which read-only + * One observation answers two separate questions: what this run may do with the + * executable it is about to spawn, and what to write down once about the build + * that first accepted an identity. The first is the whole authorization — a + * capability is a claim about a build, asked fresh every time — and the second + * is audit evidence that is never asked again. Everything here is that + * adapter's private dialect — which command to observe, which read-only * questions to ask it, how to read the answers, and what the ACP adapter child * needs in order to run the same build. None of it reaches a document. */ @@ -506,12 +508,13 @@ export interface ClientAllocatedAdapter extends AdapterCommands { /** The argv that creates a session under `id` with that instruction layer. */ create(nativeSessionId: string, instructionFile: string): string[]; /** - * Which build of this adapter's executable a session is bound to. + * How this adapter's executable is observed, admitted and recorded. * - * Required, because an identity XMD chose is only meaningful beside the build - * that accepted it. The argv `create` and `resume` return still begins with - * the stable launcher name, which is what durable records carry; a run - * replaces that first member with the exact path it observed. + * Required, because an adapter that names its own sessions may not act on one + * until the build it would run has been admitted for what it is about to do. + * The argv `create` and `resume` return still begins with the stable launcher + * name, which is what durable records carry; a run replaces that first member + * with the exact path it observed. */ binding: NativeBinding; } @@ -598,6 +601,36 @@ const ADAPTERS: Readonly> = { }, }; +/** + * The stable protocol each published client-native route contract fixes. + * + * Written here, compiled in, and reachable through no dependency a host can + * supply — because it is the one thing about a route that a later installation + * may not answer. Everything else on the live side is discovered: which adapter + * is registered, which executable is found, what it declares. If the protocol + * were discovered too, then registering an adapter under the same launcher name + * would be enough to adopt a conversation constructed by something else, and a + * host policy that admitted the newcomer's own protocol would call that proved. + * + * Keyed by the launcher because that is the durable member of the contract that + * names an implementation. A route's provider says which provider published it + * and its agent is the command it is filed under; those are matched by the + * caller that read the record, and this answers the remaining question of what + * the thing behind that launcher was speaking at the time. + * + * An absent entry is a refusal, not a default. There is no protocol migration: + * a different protocol needs a route contract that names it, so a launcher this + * build has fixed no protocol for is one whose sessions it cannot continue. + */ +const ROUTE_PROTOCOLS: Readonly> = { + claude: "claude-client-native.v1", +}; + +/** The protocol a client-native route naming `launcher` was published under. */ +export function pinnedRouteProtocol(launcher: string): string | undefined { + return Object.hasOwn(ROUTE_PROTOCOLS, launcher) ? ROUTE_PROTOCOLS[launcher] : undefined; +} + /** * The adapters this host will consider for native launch at all. * diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index feab8df16..784ff67f5 100644 --- a/packages/acp/src/provider.ts +++ b/packages/acp/src/provider.ts @@ -60,7 +60,11 @@ import type { import { allocatesIdentity } from "./native-launch.ts"; import type { ClientAllocatedAdapter, NativeBinding } from "./native-launch.ts"; import { AgentSessionRouteError } from "./session-route.ts"; -import type { AgentSessionRoute, AgentSessionRouteStore } from "./session-route.ts"; +import type { + AgentSessionRoute, + AgentSessionRouteStore, + AgentSessionRouteV2, +} from "./session-route.ts"; import { createAcpRuntime, createAgentRegistry, createRuntimeStore } from "./acpx-runtime.ts"; import type { AcpAgentRegistry, @@ -102,6 +106,7 @@ import { ADVERTISED_NATIVE_LAUNCH, knownNativeAdapters, nativeAdapterFor, + pinnedRouteProtocol, } from "./native-launch.ts"; import type { NativeAdapter } from "./native-launch.ts"; import { admitsNativeCapability } from "./native-capability.ts"; @@ -1811,6 +1816,54 @@ function* useAcpxProviderState( }; } + /** + * Whether the adapter this run would use is the one a published V2 route + * names. + * + * Asked before the executable is observed at all, because it is not a question + * about a build. A route fixes the protocol its identity was published under + * through its provider, agent and launcher contract, and that fixing is not + * open to reinterpretation: what is installed under the launcher tomorrow does + * not get to say what yesterday's conversation was. So the retained launcher + * has to be this adapter's, and the protocol this adapter speaks has to be the + * one that contract fixes. + * + * Deliberately not read off the adapter. An adapter is registered, and a + * registration is a live fact — accepting whatever protocol it declares would + * let anything filed under this launcher adopt the session, and a host policy + * that had proved that newcomer's own protocol would then call it admitted. + * The pin is compiled in, so the admission below answers "can this build do + * the thing" for the protocol the session already has rather than for whatever + * protocol happens to be asking. + * + * A refusal here reads nothing further and writes nothing at all: the route + * and the journal keep the identity and the build evidence they were published + * with, and no identity, private file, child, ensure or turn follows. + */ + function admitRouteContract( + sessionKey: string, + route: AgentSessionRouteV2, + adapter: NativeAdapter, + ): LaunchFailure | undefined { + const pinned = + route.provider === ACPX_PROVIDER ? pinnedRouteProtocol(route.launcher) : undefined; + if ( + pinned !== undefined && + adapter.launcher === route.launcher && + adapter.protocol === pinned + ) { + return undefined; + } + return { + class: "unsupported-capability", + message: + `session "${sessionKey}" was constructed under a native protocol this run's adapter ` + + `does not speak, and a construction route is never reinterpreted by whatever is ` + + `installed under its launcher later. Continue it with the adapter it was constructed ` + + `under, or name a different .`, + }; + } + /** * What a client-native route's provider arrangement already asserts. * @@ -1893,6 +1946,15 @@ function* useAcpxProviderState( }); } const attaching = adapterFor(agentName) as ClientAllocatedAdapter; + // Which conversation this is, before anything about which build is + // installed. Joining through ACP acts on the session a native process was + // handed, so the adapter doing the joining has to be the one that contract + // names — a different launcher, or a different protocol behind the same + // launcher, is a different conversation whatever this host has proved. + const foreign = admitRouteContract(prepared.sessionKey, route, attaching); + if (foreign) { + throw new AttachmentRefused(foreign); + } const build = yield* observeBuild( agentName, agentCommand, @@ -2410,6 +2472,17 @@ function* useAcpxProviderState( return retained(agentName, adapter, route, instructions, sessionCwd, "resumed"); } + // A published route settles which protocol this session is, and it settles + // it before any question about builds is asked. Refused here, nothing has + // been observed, allocated or written, and the route still says exactly what + // its first publication said. + if (route?.route === "client-native" && route.schema === "session-route.v2") { + const foreign = admitRouteContract(sessionKey, route, adapter); + if (foreign) { + return refusal(foreign.class, foreign.message, known); + } + } + // Observed before an identity exists, so a build this run cannot name stops // the launch before anything durable is written. let build: BoundBuild; @@ -2488,6 +2561,16 @@ function* useAcpxProviderState( return retained(agentName, adapter, winner, instructions, sessionCwd, "resumed"); } + // The winner is the account that governs, so it is the account whose + // protocol this adapter has to speak. A concurrent publication that reached + // the namespace first describes a session this launch did not construct, and + // adopting it is acting on it: the same contract check the read above makes, + // asked again of the record that actually won. + const foreign = admitRouteContract(sessionKey, winner, adapter); + if (foreign) { + return refusal(foreign.class, foreign.message, known); + } + // The record is built from the winner rather than from the candidate — its // identity and its binding alike — so the two durable accounts agree by // construction rather than by comparison. A winner published from a @@ -3065,6 +3148,15 @@ function* useAcpxProviderState( `so the conversation it prepared cannot be confirmed`, }; } + // Still ahead of the observation, and ahead of every phase a replay could + // act with. A replay resumes a conversation someone else's process may + // already be in, so what it must establish first is that this adapter is + // what that conversation was constructed under — not that some adapter under + // this launcher is admitted for something. + const foreign = admitRouteContract(prepared.sessionKey, route, adapter); + if (foreign) { + return foreign; + } let build: BoundBuild; try { build = yield* observeBuild(prepared.agent, agentCommand, adapter.protocol, adapter.binding); diff --git a/packages/acp/src/session-route.ts b/packages/acp/src/session-route.ts index d20cfa06e..eed913f8a 100644 --- a/packages/acp/src/session-route.ts +++ b/packages/acp/src/session-route.ts @@ -64,9 +64,20 @@ export type AgentSessionRouteV1 = * The exact V2 record, which exists only for `client-native`. * * V2 adds the one fact V1 never had: which build of the provider executable - * accepted the identity XMD chose. That fact is what lets a later ACP - * attachment know it is talking to the same build the native UI is in, so it - * is required rather than optional here. + * accepted the identity XMD chose. Required rather than optional, because it is + * what makes this record a complete account of that publication — audit + * evidence, and the thing a prepared journal derived from it must still agree + * with exactly. It is never compared with a build observed later: a release + * that changed under the same launcher continues this session once it is + * admitted on its own, and one that no longer implements the operation is + * refused even if it is byte-for-byte the build that opened it. + * + * What this record does fix is the protocol. The exact provider, agent and + * launcher contract it carries names the stable native protocol its identity + * was published under, and no member here can be reinterpreted by whatever is + * installed under that launcher afterwards. A different protocol needs a route + * contract that names it; there is no migration, and this record is never + * rewritten into one. * * There is no V2 `acp-first`. ACP-first construction gained no fact, so a * second schema for it would be a version number with nothing behind it. diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index 5fc997bc0..a92d4d82e 100644 --- a/packages/acp/tests/native-launch.test.ts +++ b/packages/acp/tests/native-launch.test.ts @@ -3409,10 +3409,12 @@ describe("Tier CA — client-native attachment", () => { * Tier RT — one runtime per agent command and build * (specs/acp-client-spec.md §ACPX provider). * - * A child running the wrong build accepts the session identity and disagrees - * silently about what it names, so sessions bound to different builds never - * share one. A partition holds a live executable path for the work it owns, and - * when the last handle it made closes it is gone rather than kept for nobody. + * A partition holds a live executable path and the child running it, so what may + * share one is decided by the observation serving the work now — never by what a + * route retained. Sessions whose routes record different builds share a child + * when one installed release serves them both, and two live releases never do. + * When the last handle a partition made closes it is gone rather than kept for + * nobody. */ describe("Tier RT — bound runtime partitions", () => { const FIRST = "aaaaaaaa-1111-2222-3333-444444444444"; @@ -5457,8 +5459,11 @@ describe("Tier NP — proved native capability admissions", () => { * * What that must not cost is the fail-closed boundary. A release changing is not * a protocol changing, so the last cases here take the same 2.1.263 build to a - * host that proved something else, and to a journal that disagrees with its - * route about the build history it retained. + * host that proved something else, to a journal that disagrees with its route + * about the build history it retained, and to an adapter registered under this + * launcher speaking a protocol the route never named — that last one with the + * host's policy admitting exactly what the newcomer declares, so the only thing + * left refusing is the contract the route itself fixes. */ describe("Tier XR — one session across two releases", () => { const ALLOCATED = "5eed0000-1111-2222-3333-444444444444"; @@ -5494,11 +5499,27 @@ describe("Tier XR — one session across two releases", () => { return { allocations: 0, creates: 0, resumes: 0, published: [] }; } - /** The adapter, with every identity-bearing act it can perform counted. */ - function countedAdapter(counts: Seen): NativeAdapter { + /** + * A protocol no route contract in this build fixes, and a launcher no adapter + * here shares with the route below. + */ + const FOREIGN_PROTOCOL = "claude-fork-native.v1"; + const FOREIGN_LAUNCHER = "codex"; + + /** + * The adapter, with every identity-bearing act it can perform counted. + * + * `contract` is what a host registered under this agent name declares about + * itself. Varying it is how a case asks whether a live declaration is allowed + * to answer what a published route already settled. + */ + function countedAdapter( + counts: Seen, + contract: { launcher?: string; protocol?: string } = {}, + ): NativeAdapter { return { - launcher: "claude", - protocol: CLAUDE_PROTOCOL, + launcher: contract.launcher ?? "claude", + protocol: contract.protocol ?? CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate: () => { @@ -5585,6 +5606,7 @@ describe("Tier XR — one session across two releases", () => { observation?: FakeObservation; routes?: AgentSessionRouteStore; nativeCapabilityPolicy?: NativeCapabilityPolicy | false; + adapter?: { launcher?: string; protocol?: string }; } = {}, ): Operation { const counts = seen(); @@ -5594,7 +5616,7 @@ describe("Tier XR — one session across two releases", () => { const observer = createFakeObserver(options.observation ?? live()); yield* routes.publish(options.published ?? route()); yield* installLaunchStack(harness, trace, { - adapters: { claude: countedAdapter(counts) }, + adapters: { claude: countedAdapter(counts, options.adapter) }, routeStore: routes, observer: observer.observer, ...(options.nativeCapabilityPolicy === undefined @@ -5817,4 +5839,110 @@ describe("Tier XR — one session across two releases", () => { } } }); + + it("XR9: an adapter speaking another protocol continues nothing, however well proved", function* () { + // The route fixes the protocol its identity was published under, and no + // amount of live evidence reopens that. Here the host has proved this + // adapter's own protocol, for both capabilities, in the shape the installed + // executable declares, on the machine actually running — everything an + // admission asks for, all of it about the wrong protocol. So the policy is + // saying yes and the refusal is the pin, which is what makes this case + // discriminate the production contract rather than a mismatched host. + const foreign = { + adapter: { protocol: FOREIGN_PROTOCOL }, + nativeCapabilityPolicy: admitting(FOREIGN_PROTOCOL), + } as const; + + yield* scoped(function* () { + const space = yield* continuing(foreign); + + const failure = yield* attempt(space.trace, INSTRUCTIONS); + + expect(failure?.class).toBe("unsupported-capability"); + expect(space.trace.launches).toEqual([]); + expect([space.counts.resumes, space.counts.creates]).toEqual([0, 0]); + // Ahead of the observation, not after it. Which conversation this is was + // never a question about the build, so the build is never asked. + expect(space.observer.observed).toEqual([]); + yield* unchanged(space); + }); + + yield* scoped(function* () { + const space = yield* continuing(foreign); + + let raised: Error | undefined; + try { + yield* Agent.operations.session(); + } catch (error) { + raised = error as Error; + } + + expect(raised === undefined).toBe(false); + expect(space.harness.ensureCalls).toEqual([]); + expect(space.harness.createdOptions).toEqual([]); + expect(space.observer.observed).toEqual([]); + yield* unchanged(space); + }); + + for (const suffix of ["prepared", "prepared+detached"] as const) { + yield* scoped(function* () { + const space = yield* continuing(foreign); + space.trace.replay = { prepared: prepared(), suffix }; + + yield* Agent.operations.launch(launchRequest(INSTRUCTIONS)); + + const failed = space.trace.records.findLast((record) => record.failure); + expect([suffix, failed?.failure?.class]).toEqual([suffix, "unsupported-capability"]); + expect([suffix, space.trace.launches]).toEqual([suffix, []]); + expect([suffix, [space.counts.resumes, space.counts.creates]]).toEqual([suffix, [0, 0]]); + expect([suffix, space.observer.observed]).toEqual([suffix, []]); + yield* unchanged(space); + }); + } + + yield* scoped(function* () { + // Adoption, where the route arrives after the read that found none. The + // winner is the account that governs, so it is the account this adapter + // has to have been constructed under — and it was not. + const inner = createMemorySessionRouteStore(); + const routes: AgentSessionRouteStore = { + // deno-lint-ignore require-yield + *read() { + return undefined; + }, + publish: (candidate) => inner.publish(candidate), + }; + const space = yield* continuing({ ...foreign, routes, published: route() }); + + const failure = yield* attempt(space.trace, INSTRUCTIONS); + + expect(failure?.class).toBe("unsupported-capability"); + expect(space.trace.launches).toEqual([]); + expect([space.counts.resumes, space.counts.creates]).toEqual([0, 0]); + expect(JSON.stringify(space.trace.records)).not.toContain(CANDIDATE); + // The winner kept its identity and the evidence it was written with. + expect(yield* inner.read(KEY)).toEqual(route()); + }); + }); + + it("XR10: an attachment refuses a route another launcher constructed", function* () { + // Attachment acts on the conversation a native process was handed, so the + // route's launcher is as much a part of what it may join as its identity is. + // The protocol this adapter declares is the pinned one, so nothing but the + // launcher is refusing. + const space = yield* continuing({ adapter: { launcher: FOREIGN_LAUNCHER } }); + + let raised: Error | undefined; + try { + yield* Agent.operations.session(); + } catch (error) { + raised = error as Error; + } + + expect(raised === undefined).toBe(false); + expect(space.harness.ensureCalls).toEqual([]); + expect(space.harness.createdOptions).toEqual([]); + expect(space.observer.observed).toEqual([]); + yield* unchanged(space); + }); }); diff --git a/packages/core/src/agent/launch.ts b/packages/core/src/agent/launch.ts index f29f48b77..e94316cd8 100644 --- a/packages/core/src/agent/launch.ts +++ b/packages/core/src/agent/launch.ts @@ -101,10 +101,12 @@ export type IdentityProvenance = "provider-returned" | "client-allocated"; /** * Which build of a provider executable a session was established against. * - * A client-allocated session is only meaningful while the build that created - * it can be reproduced. Two builds of the same provider accept the same - * identity and disagree silently about what it names, so a session whose build - * cannot be reproduced is refused rather than resumed. + * Written once, when the identity is first published, and never rewritten. It + * is audit evidence about a past observation and a cross-check between the two + * durable accounts of that one observation — never a gate on the build + * installed now. Whether a session may be continued is decided by admitting the + * live executable on its own terms; which build accepted the identity first is + * history, and history is not a capability. * * What is retained is deliberately not a path: a path says where a build was, * which stops being true, while a digest says which build it was, which does From 8a736ab22b7a430591fd57221a8be8826e1d9e27 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 15:21:58 -0400 Subject: [PATCH 11/22] =?UTF-8?q?=F0=9F=94=92=20Settle=20a=20client-native?= =?UTF-8?q?=20route's=20protocol=20before=20the=20race=20(#717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty route read settles nothing: a concurrent publication is only revealed by publishing. Until now a run whose read found no route fell straight through to observing the executable, allocating an identity and writing a candidate, and asked whether it could account for the protocol only after meeting the winner — having already acted on behalf of a session it was never entitled to construct. The pin is now asked twice from one reading. Prospectively, before any observation or allocation, against the live adapter's launcher; and again of the record that actually won, whose route may differ from the candidate's. The legacy V1 path is untouched. Reconcile the remaining same-build continuation wording across the touched source set. A retained binding is immutable audit evidence and is only ever held to the prepared journal derived from the same observation, never to a build installed later. --- packages/acp/src/native-launch.ts | 4 +- packages/acp/src/provider.ts | 127 +++++++++++++++++----- packages/acp/src/session-route.ts | 11 +- packages/acp/tests/native-launch.test.ts | 94 +++++++++++++++- packages/core/src/agent/launch-journal.ts | 10 +- packages/core/src/agent/launch.ts | 36 ++++-- 6 files changed, 226 insertions(+), 56 deletions(-) diff --git a/packages/acp/src/native-launch.ts b/packages/acp/src/native-launch.ts index df8725c7b..3aa30bc04 100644 --- a/packages/acp/src/native-launch.ts +++ b/packages/acp/src/native-launch.ts @@ -77,7 +77,7 @@ export type NativeCapabilityProbe = (metadata: ExecutableMetadata) => ProbedNati * needs in order to run the same build. None of it reaches a document. */ export interface NativeBinding { - /** The command whose build is observed, bound and retained. */ + /** The command whose build is observed, admitted and recorded. */ command: string; /** * The read-only questions one observation asks that exact file. @@ -448,7 +448,7 @@ function claudeNativeProbe(pinnedBridge: string | undefined): NativeCapabilityPr * two or more is output it cannot read as one answer, and taking the first * would be picking a build out of a list of them. Both mean no version was * reported, which is an ordinary answer rather than a refusal — the digest is - * what binds the build. + * what names the build. */ function claudeReportedVersion(metadata: ExecutableMetadata): string | undefined { const output = answered(metadata, "version"); diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index 784ff67f5..4d2106795 100644 --- a/packages/acp/src/provider.ts +++ b/packages/acp/src/provider.ts @@ -337,7 +337,9 @@ export interface AcpxProviderDependencies { } /** - * One observed build, ready to be bound to a session. + * One observed build: what this run may do with the executable it is about to + * spawn, and the account it writes down if this observation is the one that + * first accepts an identity. * * `livePath` is the canonical path this run spawns and hands to the matching * ACP child through `environment`. It appears in no record, route, diagnostic, @@ -877,12 +879,16 @@ function* useAcpxProviderState( ); /** - * One ACP runtime per `(agent command, executable build)`, plus the unbound + * One ACP runtime per `(agent command, live executable)`, plus the unbound * one ordinary ACP-first work has always used. * - * Sessions established against different builds never share an ACP child. - * That is what observing a build is for: a child running the wrong Claude - * accepts the session identity and disagrees silently about what it names. + * Two live releases never share an ACP child. That is what observing a build + * is for here, and it is a fact about the executable this work runs rather + * than about the one a session was established against: a child is a running + * thing, and two of them running different Claudes accept the same session + * identity and disagree silently about what it names. Sessions whose durable + * accounts record different builds share this child freely once one installed + * release serves them both. */ let unbound: RuntimeEntry | undefined; const runtimes = new Map(); @@ -1756,7 +1762,8 @@ function* useAcpxProviderState( binding: { schema: "executable-build.v1", // Present only when this build said something the adapter recognized. - // A quiet build is bound by its bytes, which is what binds either way. + // A quiet build is named by its bytes, which is what names one either + // way. ...(version === undefined ? {} : { reportedVersion: version }), executableDigest: observed.digest, }, @@ -1816,6 +1823,55 @@ function* useAcpxProviderState( }; } + /** + * Whether this adapter speaks the protocol a V2 route naming its launcher + * fixes. + * + * The single reading of the pin, so "the protocol this session is" means one + * thing whether it is asked of a route that exists or of the route this run + * is about to publish. Deliberately not read off the adapter's own + * declaration: an adapter is registered, and a registration is a live fact — + * taking its word would let anything filed under this launcher speak for the + * launcher, and a host policy that had proved that newcomer's own protocol + * would then call it admitted. + */ + function speaksPinnedProtocol(adapter: NativeAdapter): boolean { + const pinned = pinnedRouteProtocol(adapter.launcher); + return pinned !== undefined && adapter.protocol === pinned; + } + + /** + * Whether this run may construct a V2 route under this adapter at all. + * + * Asked before the executable is observed, before an identity is allocated, + * and before anything is published — because a publication is the durable + * account of which conversation this is, and an adapter whose protocol this + * build fixes no route contract for cannot be the thing that account names. + * Deciding it afterwards would mean reaching a concurrent winner having + * already observed a build, allocated an identity and written a candidate for + * a session this run was never entitled to construct. + * + * A launcher with no compiled interpretation is refused rather than trusted. + * There is no protocol migration and no default: what a session is was fixed + * when it was published, and a build that fixes nothing for this launcher has + * no account of that to offer. + */ + function admitConstructionContract( + sessionKey: string, + adapter: NativeAdapter, + ): LaunchFailure | undefined { + if (speaksPinnedProtocol(adapter)) { + return undefined; + } + return { + class: "unsupported-capability", + message: + `session "${sessionKey}" would be constructed under a native protocol this build fixes ` + + `no construction route for, and a route it cannot interpret is one it must not publish. ` + + `Launch it with the adapter this build knows that launcher by.`, + }; + } + /** * Whether the adapter this run would use is the one a published V2 route * names. @@ -1828,14 +1884,6 @@ function* useAcpxProviderState( * has to be this adapter's, and the protocol this adapter speaks has to be the * one that contract fixes. * - * Deliberately not read off the adapter. An adapter is registered, and a - * registration is a live fact — accepting whatever protocol it declares would - * let anything filed under this launcher adopt the session, and a host policy - * that had proved that newcomer's own protocol would then call it admitted. - * The pin is compiled in, so the admission below answers "can this build do - * the thing" for the protocol the session already has rather than for whatever - * protocol happens to be asking. - * * A refusal here reads nothing further and writes nothing at all: the route * and the journal keep the identity and the build evidence they were published * with, and no identity, private file, child, ensure or turn follows. @@ -1845,12 +1893,10 @@ function* useAcpxProviderState( route: AgentSessionRouteV2, adapter: NativeAdapter, ): LaunchFailure | undefined { - const pinned = - route.provider === ACPX_PROVIDER ? pinnedRouteProtocol(route.launcher) : undefined; if ( - pinned !== undefined && + route.provider === ACPX_PROVIDER && adapter.launcher === route.launcher && - adapter.protocol === pinned + speaksPinnedProtocol(adapter) ) { return undefined; } @@ -1925,9 +1971,11 @@ function* useAcpxProviderState( return undefined; } if (route.schema === "session-route.v1") { - // Constructed before any build was recorded. A build observed now says - // which build is installed today, not which one established this - // conversation, so there is nothing to compare and nothing to attach to. + // Constructed under the native-only contract, which published no account + // of the build that accepted the identity. Attaching was released + // alongside that account and is not retroactive: writing one now from a + // build observed today would claim knowledge of a publication XMD never + // witnessed, so a legacy session stays a native-only one. throw new AttachmentRefused({ class: "executable-binding-refused", message: @@ -2391,9 +2439,15 @@ function* useAcpxProviderState( * The order is the contract, and every step happens while the coordinator * holds this session: * - * route + existing ACPX state -> refuse conversion -> allocate a UUID -> + * route + existing ACPX state -> refuse conversion -> settle which protocol + * this session is -> observe and admit the build -> allocate a UUID -> * publish or adopt the route -> retain a record that matches it exactly. * + * The protocol is settled first because it is the only step that says which + * conversation this is. Everything after it acts on that answer, and a run + * that reached the publication before asking would already have observed a + * build and reached for an identity on a session's behalf. + * * Nothing is created through ACP here. A client-native session is * materialized by the native process itself, which is why the route has to be * settled before that process exists: two accounts of one session, published @@ -2483,6 +2537,17 @@ function* useAcpxProviderState( } } + // And when the read found none, the same pin asked prospectively. The read + // is not the last word on whether this session already exists — a concurrent + // publication is revealed by publishing — so an adapter that could not + // account for a route it met cannot be allowed to observe a build, allocate + // an identity and write a candidate first and find that out afterwards. + // Refused here, the race has nothing of this run's in it to lose. + const unfixed = admitConstructionContract(sessionKey, adapter); + if (unfixed) { + return refusal(unfixed.class, unfixed.message, known); + } + // Observed before an identity exists, so a build this run cannot name stops // the launch before anything durable is written. let build: BoundBuild; @@ -2561,11 +2626,12 @@ function* useAcpxProviderState( return retained(agentName, adapter, winner, instructions, sessionCwd, "resumed"); } - // The winner is the account that governs, so it is the account whose - // protocol this adapter has to speak. A concurrent publication that reached - // the namespace first describes a session this launch did not construct, and - // adopting it is acting on it: the same contract check the read above makes, - // asked again of the record that actually won. + // The winner is the account that governs, and it is not this run's + // candidate: a publication that reached the namespace first describes a + // session this launch did not construct, under whatever contract its writer + // gave it. This adapter having a fixed protocol says nothing about that + // record, so the contract is asked again — of the route that actually won. + // Adopting one is acting on it. const foreign = admitRouteContract(sessionKey, winner, adapter); if (foreign) { return refusal(foreign.class, foreign.message, known); @@ -3115,9 +3181,10 @@ function* useAcpxProviderState( ); } // A launch that never got as far as the native process, prepared under a - // contract that recorded no build. Nothing here can show which build has - // this session's history, and resuming anyway would be answering the - // question by ignoring it. A completed launch never reaches this code. + // contract that published no account of the build that accepted the + // identity. The cross-check below is what makes an incomplete launch + // resumable, and with only one account there is nothing to hold to + // anything. A completed launch never reaches this code. if (route.schema === "session-route.v1" || prepared.executableBinding === undefined) { return { class: "executable-binding-refused", diff --git a/packages/acp/src/session-route.ts b/packages/acp/src/session-route.ts index eed913f8a..d3a417de8 100644 --- a/packages/acp/src/session-route.ts +++ b/packages/acp/src/session-route.ts @@ -162,10 +162,13 @@ function declared( /** * Read a retained build binding strictly. * - * The digest is required and exact — it is what binds a session to a build. - * A present `reportedVersion` must still be a real one: a member written as an - * empty string is a claim about a release nobody can reproduce, which is not - * the same as having made no claim. + * The digest is required and exact — it is what names the build this record + * describes. Reading it strictly is not a gate on the executable installed now: + * this account is audit evidence, and the only thing it is ever held to is the + * prepared journal derived from the same observation. A present + * `reportedVersion` must still be a real one: a member written as an empty + * string is a claim about a release nobody can reproduce, which is not the same + * as having made no claim. */ function parseExecutableBinding(value: unknown): ExecutableBuildBindingV1 | undefined { if (!isRecord(value) || !declared(value, BINDING_MEMBERS, BINDING_OPTIONAL_MEMBERS)) { diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index a92d4d82e..27c3b0c41 100644 --- a/packages/acp/tests/native-launch.test.ts +++ b/packages/acp/tests/native-launch.test.ts @@ -5464,6 +5464,13 @@ describe("Tier NP — proved native capability admissions", () => { * launcher speaking a protocol the route never named — that last one with the * host's policy admitting exactly what the newcomer declares, so the only thing * left refusing is the contract the route itself fixes. + * + * The contract is asked twice, because a read that finds no route settles + * nothing: a publication is where a concurrent one is revealed. So it is asked + * prospectively, before a build is observed or an identity exists, and again of + * the record that actually won — and the two are discriminated apart, by a case + * that reaches the race with an adapter this build fixes nothing for and one + * that reaches it entitled to construct and meets someone else's account. */ describe("Tier XR — one session across two releases", () => { const ALLOCATED = "5eed0000-1111-2222-3333-444444444444"; @@ -5901,24 +5908,42 @@ describe("Tier XR — one session across two releases", () => { } yield* scoped(function* () { - // Adoption, where the route arrives after the read that found none. The - // winner is the account that governs, so it is the account this adapter - // has to have been constructed under — and it was not. + // The race, from the side that reads no route. An empty read is not a + // session nobody has — a concurrent publication is revealed by publishing + // — so a run that met this adapter here is a run that could not have + // accounted for whatever the publication returns. It is refused + // prospectively, and the whole point is what that costs: nothing at all. const inner = createMemorySessionRouteStore(); + const publications: AgentSessionRoute[] = []; const routes: AgentSessionRouteStore = { // deno-lint-ignore require-yield *read() { return undefined; }, - publish: (candidate) => inner.publish(candidate), + *publish(candidate) { + publications.push(candidate); + return yield* inner.publish(candidate); + }, }; const space = yield* continuing({ ...foreign, routes, published: route() }); + // The one publication this case made before the provider existed. + expect(publications).toEqual([route()]); const failure = yield* attempt(space.trace, INSTRUCTIONS); expect(failure?.class).toBe("unsupported-capability"); + // Nothing was asked of the executable: not which file it is, and not one + // of the read-only questions the adapter would have put to it. + expect([space.observer.observed, space.observer.queried]).toEqual([[], []]); + // Nothing was reached for on the session's behalf, and nothing offered to + // the namespace — so the candidate that would have raced does not exist. + expect(space.counts.allocations).toBe(0); + expect(publications).toEqual([route()]); + // And no live effect on either side of the handoff. expect(space.trace.launches).toEqual([]); expect([space.counts.resumes, space.counts.creates]).toEqual([0, 0]); + expect(space.harness.ensureCalls).toEqual([]); + expect(space.harness.createdOptions).toEqual([]); expect(JSON.stringify(space.trace.records)).not.toContain(CANDIDATE); // The winner kept its identity and the evidence it was written with. expect(yield* inner.read(KEY)).toEqual(route()); @@ -5945,4 +5970,65 @@ describe("Tier XR — one session across two releases", () => { expect(space.observer.observed).toEqual([]); yield* unchanged(space); }); + + it("XR11: a winner this run cannot account for is not adopted, however it got there", function* () { + // The other half of the race, and the one the prospective check cannot + // stand in for: this adapter is exactly what this build fixes for its + // launcher, so it was entitled to observe a build and prepare a route of + // its own — and the account that reached the namespace first is still not + // one it can read. That account names another provider, which is the + // difference a candidate's own contract says nothing about: same launcher, + // same instruction layer, same schema, published by something else. + // + // Losing a race is ordinary, and XR6 adopts its winner whole. What + // separates them is whose account the winner is, so this run refuses before + // it acts through the winner at all. + const inner = createMemorySessionRouteStore(); + const foreignKey = { ...KEY, provider: "other-provider" }; + const foreignWinner: AgentSessionRoute = { ...route(), provider: "other-provider" }; + yield* inner.publish(foreignWinner); + + const publications: AgentSessionRoute[] = []; + const routes: AgentSessionRouteStore = { + // deno-lint-ignore require-yield + *read() { + return undefined; + }, + *publish(candidate) { + // The publication is where the concurrent account is revealed, and it + // is read back rather than remembered, so a rewrite would show here. + publications.push(candidate); + const winner = yield* inner.read(foreignKey); + return winner ?? candidate; + }, + }; + const space = yield* continuing({ routes }); + // The one publication this case made before the provider existed. + expect(publications).toEqual([route()]); + + const failure = yield* attempt(space.trace, INSTRUCTIONS); + + expect(failure?.class).toBe("unsupported-capability"); + // The observation this run was entitled to make is allowed to have + // happened; what may not is anything downstream of adopting the winner. + expect(space.trace.launches).toEqual([]); + expect([space.counts.resumes, space.counts.creates]).toEqual([0, 0]); + expect(space.harness.ensureCalls).toEqual([]); + expect(space.harness.createdOptions).toEqual([]); + // Neither identity was acted on: not the winner's, and not the candidate + // this run offered and lost with. + expect(JSON.stringify(space.trace.records)).not.toContain(ALLOCATED); + expect(JSON.stringify(space.trace.records)).not.toContain(CANDIDATE); + // This run's own candidate, offered exactly once under its own provider — + // and never offered again to make its account the true one. + expect(publications.length).toBe(2); + expect(publications[1]).toMatchObject({ + schema: "session-route.v2", + provider: "acpx", + nativeSessionId: CANDIDATE, + launcher: "claude", + }); + // Read back through the same strict reader the durable store uses. + expect(yield* inner.read(foreignKey)).toEqual(foreignWinner); + }); }); diff --git a/packages/core/src/agent/launch-journal.ts b/packages/core/src/agent/launch-journal.ts index 6a38ee181..071af4f83 100644 --- a/packages/core/src/agent/launch-journal.ts +++ b/packages/core/src/agent/launch-journal.ts @@ -166,10 +166,12 @@ function declaredMembers( /** * Read a retained build binding strictly. * - * The digest is required and exact — it is what binds a session to a build. - * A present `reportedVersion` must still be a real one: a member written as an - * empty string is a claim about a release nobody can reproduce, which is not - * the same as having made no claim. + * The digest is required and exact — it is what names the build this account + * describes, and the account is only ever held to the other durable record of + * the same observation, never to a build installed later. A present + * `reportedVersion` must still be a real one: a member written as an empty + * string is a claim about a release nobody can reproduce, which is not the same + * as having made no claim. */ function executableBinding(value: unknown): ExecutableBuildBindingV1 | undefined { if (!isRecord(value) || !declaredMembers(value, BINDING_MEMBERS, BINDING_OPTIONAL_MEMBERS)) { diff --git a/packages/core/src/agent/launch.ts b/packages/core/src/agent/launch.ts index e94316cd8..c8b45d066 100644 --- a/packages/core/src/agent/launch.ts +++ b/packages/core/src/agent/launch.ts @@ -71,12 +71,18 @@ export type LaunchFailureClass = * last owner never proved it stopped, so nothing here can say the session is * free, and no elapsed time, pid or released lock changes that. * - * `executable-binding-refused` is the build question: the session was - * established by one build of a provider executable, and this run could not - * show it is talking to that same build. Resolution, canonicalization, - * executable-file validation, version parsing, digesting, schema recognition, - * equality, and a session established before any build was recorded all end - * here. + * `executable-binding-refused` is the question of whether this run can account + * for the build behind a provider command at all — never whether that build is + * the one a session was opened by. Failing to see it ends here: no observer on + * this host, resolution, canonicalization, executable-file validation, and + * digesting. So does a client-allocated session whose durable records were + * written before one of them said which build accepted the identity, because a + * launch nobody completed is resumed by holding those two records to each + * other, and there is nothing there to hold. + * + * Whether an executable this run *can* see may act is a different question with + * a different answer: it is admitted on its own terms, and refusing it is + * `unsupported-capability`. */ export interface LaunchFailure { class: LaunchFailureClass; @@ -112,10 +118,10 @@ export type IdentityProvenance = "provider-returned" | "client-allocated"; * which stops being true, while a digest says which build it was, which does * not. That also keeps the record free of host layout. * - * The digest is what binds. `reportedVersion` is optional evidence beside it: - * an executable that will not say which release it is, says something this - * provider does not recognize, or says several things is bound by its bytes - * alone rather than refused for being quiet. + * The digest is what names the build. `reportedVersion` is optional evidence + * beside it: an executable that will not say which release it is, says + * something this provider does not recognize, or says several things is + * described by its bytes alone rather than refused for being quiet. */ export interface ExecutableBuildBindingV1 { readonly schema: "executable-build.v1"; @@ -187,8 +193,14 @@ export interface PreparedLaunchRecord { identityProvenance: IdentityProvenance; /** * Which build accepted the client-allocated identity, present exactly when - * this provider binds one. A provider that returns its own identity owns its - * own session lifetime and binds nothing, so it carries none. + * this provider records one. Copied from the construction route rather than + * observed here, so the journal and the route are two accounts of a single + * observation instead of two observations. A provider that returns its own + * identity owns its own session lifetime and records none. + * + * It is never held against a build installed later; the one comparison it + * takes part in is with the route it was copied from, which is how an + * incomplete launch proves which session it is resuming. * * Optional because the client-allocated path was released before any build * was observed. A record without it is legacy history: readable, resumable by From 6049184bb3766a3d300d4ec9b0fca559f71690e8 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 16:05:01 -0400 Subject: [PATCH 12/22] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20Compose=20Codex?= =?UTF-8?q?=20native=20launch=20with=20terminal=20grids?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 11 +- specs/acp-client-spec.md | 78 ++-- specs/decisions.md | 130 ++++++- specs/executable-mdx-spec.md | 3 +- specs/native-agent-session-launch-spec.md | 448 ++++++++++++++++------ 5 files changed, 498 insertions(+), 172 deletions(-) diff --git a/architecture.md b/architecture.md index 939c4f7ac..0cea2a8f6 100644 --- a/architecture.md +++ b/architecture.md @@ -102,10 +102,10 @@ Existing documents and code get aligned to this section retroactively. | host profile | a named production assembly a trusted host offers a testing harness — `run` today — reused after argument parsing rather than restated; a host that cannot offer one refuses rather than approximating it | | execution outcome | what one nested document execution ended as, as the harness publishes it: a settlement carrying the exact completion `Result`, or, for a workflow attempt, a durable suspension carrying the run and suspension identities. A suspension is neither success nor failure | | trusted host | the code that decides what an execution is for — a CLI entrypoint or a workflow runner — as distinct from the document, the components it expands, and the middleware packages composed around it | -| native session launch | preparing one durable coding-agent session from a document and then handing its provider's own interactive UI the terminal for that exact session. It is session construction followed by an ownership handoff, not a chat frontend: XMD performs no model turn, proxies no native turn, and journals no native conversation | +| native session launch | preparing one durable coding-agent session from a document and then handing its provider's own interactive UI the terminal for that exact session. It is session construction followed by an ownership handoff, not a chat frontend: XMD proxies no native turn and journals no native conversation; it performs no model turn except a protocol-declared materialization turn for a conversation that this launch just created | | provider-native session identity | the identity an agent asserts for its own durable session state, distinct from the logical XMD session key and from the ACP/ACPX record identity. Only an asserted one crosses a handoff; XMD never infers that a string a native CLI would accept is one | | pending session placement | an exact provider-issued `Session` capability carrying where a logical session will live and which agent it is compatible with, and nothing more: no construction route, no provider handle, no resumable identity. A fresh `` produces one, and the first consuming operation chooses how the session is constructed | -| session materialization | the transition that makes a placement's chosen route and its backend history resumable. ACP-first materialization happens only when the backend reports that it accepted the session's first turn; client-native materialization is the native launch's existing retained construction. Nothing else promotes a placement — not a returning ensure, a first output, a terminal result, a checkpoint token, an error code or a diagnostic | +| session materialization | the transition that makes a placement's chosen route and its backend history resumable. Ordinary ACP-first materialization happens only when the backend reports that it accepted the authored session's first turn; a provider-returned native launch may instead spend its protocol's one fixed, tool-free materialization turn for the conversation it just created, and client-native materialization is the native launch's existing retained construction. Nothing else promotes a placement — not a returning ensure, a first output, a terminal result, a checkpoint token, an error code or a diagnostic | | established session | a placement whose immutable construction route and durable provider or native identity both already exist, and which is therefore validated eagerly: reattached, compared against its retained history, and refused when either is missing or names another conversation | | instruction layer | the provider-native session, system or developer instructions a launch installs before the native UI accepts its first user turn. It is not a user message, and it is not conversation history | | foreground-terminal lease | the one exclusive claim on a document execution's foreground experience. A root native launch holds it for one inherited terminal; a terminal grid holds it for one composite presentation. A host with no terminal refuses it, and no second root launch or grid can hold it concurrently | @@ -116,11 +116,12 @@ Existing documents and code get aligned to this section retroactively. | launch request | the frozen, one-use value public launch middleware routes. It carries the facts of one launch and `with()`, and nothing that can settle one. Identity is object identity: a rebuilt look-alike describes the same ask and authorizes none of it | | provider authority | what core delivers to the provider factory it installs, as an argument that factory closes over. It validates the routed request, runs each absent phase once, cross-checks and retains what comes back, and derives the result. There is no reader for one, no context holding one, and no request member carrying one | | session coordinator | the host-built capability that answers who owns one logical agent session right now, across processes. It is passed directly into the provider that needs it and is deliberately not contextual: a decision document middleware could replace is not one. Acquisition never waits | -| construction route | how one logical agent session was first constructed — through ACP, or under an identity XMD chose — as strict create-once durable state beside the coordinator's own records. It answers a different question from ownership and grants no right to act: the coordinator remains the single live authority. A route never converts, and a session ACP already established is recorded as ACP-first rather than reclassified. Two schemas are readable: the released `session-route.v1`, and `session-route.v2`, which exists only for `client-native` and adds a required executable build binding. New client-native construction publishes V2; a V1 client-native route stays legacy-unbound, is never upgraded in place, and authorizes native resume only | -| executable build binding | immutable audit evidence about the provider executable observed when a client-allocated identity was first published: a schema, the lowercase SHA-256 of the canonical executable target and an optional adapter-canonical reported version. The V2 route and its prepared journal record retain the same value exactly and never rewrite it, but neither digest nor version authorizes or denies a later continuation. It contains no path: a path says where a build was, which stops being true, and names host layout besides | +| construction route | how one logical agent session was first constructed — through ACP, or under an identity XMD chose — as strict create-once durable state beside the coordinator's own records. It answers a different question from ownership and grants no right to act: the coordinator remains the single live authority. A route never converts, and a session ACP already established is recorded as ACP-first rather than reclassified. Three schemas are readable: released unbound `session-route.v1`; `session-route.v2`, which exists only for the `claude-client-native.v1` contract; and `session-route.v3`, which exists only for the bound `codex-provider-returned.v1` ACP-first contract. New Claude client-native construction publishes V2, new Codex provider-returned construction publishes V3, and V1 routes stay legacy-unbound and are never upgraded in place | +| executable build binding | immutable audit evidence about the provider executable observed when a bound construction route was first published: a schema, the lowercase SHA-256 of the canonical executable target and an optional adapter-canonical reported version. Its V2 or V3 route and agreeing launch journal retain the same value exactly and never rewrite it, but neither digest nor version authorizes or denies a later continuation. It contains no path: a path says where a build was, which stops being true, and names host layout besides | | native capability admission profile | the adapter protocol and observable command shape for which one native capability's real-CLI proof passed, inside its proved host operating system and architecture. The exact executable used by each live continuation must independently advertise the requested profile through a read-only metadata query; the Agent name, reported version, executable digest and a predecessor's admission authorize nothing by themselves | | executable observer | the host-built capability that resolves a launcher command through the host's real execution environment, canonicalizes the target, requires an executable regular file, hashes that target and runs the adapter's declared read-only metadata queries against that same path. Passed directly into the provider like the coordinator beside it, and deliberately not contextual: executable validation decides which retained history may be accepted, and a resolver document middleware could replace could point the observation at one binary while the run spawns another | | client-native attachment | joining a conversation a native process constructed, through ACP, under the identity the route already names. It is attachment, never conversion: the route stays client-native, `resumeSessionId` receives that exact identity, and the provider's canonical assertion must equal it before the first turn. Advertised separately from native launch, because handing a session to a native UI and later joining it prove different things | +| provider-native continuation | rejoining through ACP a provider-returned conversation that a native launch handed to the provider UI. It is admitted separately from native launch, uses the exact retained provider assertion, and never allocates, substitutes or converts an identity | | identity provenance | who chose a provider-native session identity: the provider returned it, or the client allocated it before the provider existed. Retained rather than inferred, because after the fact both are just a string in the record. A released record written before the distinction existed reads as provider-returned, which is the only compatibility inference | | quiescence | the one-use acknowledgement an ownership body gives. It does not mean "I finished" — it means nothing this owner started can still act on the session, which is why a handoff that could not release its handle never gives it | | recovery tombstone | an ownership record left active because its owner never proved it stopped. A crash releases the kernel lock and not this, and no pid, elapsed time, released lock or empty transcript clears it | @@ -4647,7 +4648,7 @@ Status is measured against main. | nested run-profile Agent and elicitation declarations | lets one `` declare one child-scoped `` scenario set and one non-delegating `` matcher set; only frozen test data crosses the harness request, the trusted host constructs both providers inside the isolated child, siblings share no session or provider state, ordinary component shadowing remains in force, and the child journal retains only the selected Prompt and Elicit components' ordinary results. A controlled `` may author an exact scenario label that this host alone maps to Plan's derived conversation identity; declaration selection uses the label while runtime state stays keyed by the opaque identity and child, with no matcher or fallback added to ordinary TestAgent sessions | built on the #641 stack; controlled Plan routing added on the #728 stack | | `Config` run deadline / exec default / Fetch default / verbosity | three independently owned contextual timeouts, absent unless configured, each read by exactly one consumer, and contextual verbosity — a boolean that is false unless configured, seeded by the command line and overridable for a lexical subtree, bounding nothing and owning no authority | built on this stack | | terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified and implemented in #732, which is what gives a pane's `` that pane's terminal rather than the root's; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts — one invocation-private server per grid, authenticated persistent pane workers carrying exact argv, cwd and environment outside tmux parsing, explicit row-major layout imposed by pane swaps, a required composite `launch()` that gives a pane's `` its own terminal rather than the root's, and one ordered teardown that proves worker quiescence, channel closure and server disappearance before the document continues; its evidence uses a fake tmux with real workers and real sockets, and real tmux behaviour on macOS remains #726's; Node and Bun catalog and validate the same grids and install neither the provider nor the process observer, refusing before pane start; DEC-016 specifies the final behavior-preserving extraction into `@executablemd/terminal` and `@executablemd/terminal-tmux`, with every repository import moved to the canonical packages and the unshipped old terminal paths deleted | -| native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session retains immutable audit evidence for the executable observed before its identity was allocated: that binding is published with the V2 route and copied into each agreeing prepared record, while every live create, resume, attachment and incomplete replay observes the executable it will actually use and independently admits the requested capability before a process, an ensure or a turn. The trusted host admits only when the route's stable adapter protocol, the independently requested capability, the live executable's positive CLI shape, and the proved host operating system and architecture agree; neither the Agent name, reported version, executable digest nor a predecessor's admission authorizes or denies the continuation. A compatible Claude upgrade therefore resumes the exact retained native identity without allocating, converting, republishing or rewriting the route or journal. A route/journal disagreement still refuses because the two durable accounts no longer describe one preparation, not because the live build changed. The live canonical path replaces the launcher name only for that invocation. A `` or `` meeting a bound client-native route similarly admits attachment against the current executable, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on a protocol, profile, capability or host mismatch, missing history or a differing assertion without creating a substitute conversation. A route whose exact provider identity is absent remains authoritative: the provider's explicit refusal is an accepted zero-turn outcome and XMD neither allocates, creates, converts, deletes, rewrites nor substitutes a conversation. ACP runtimes are partitioned by the resolved agent command and live executable observation serving them, never by the route's historical binding; each handle is closed by the partition that created it, a live partition is never migrated after an upgrade, and a bound partition is torn down when its last handle and claimed work settle. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store, executable observer, capability-profile admission and live platform facts — and Node and Bun assemble none of that authority, so every advertised operation refuses before provider work; the Claude client-native protocol is admitted independently for launch and attachment when its required help shape is observed on the proved macOS/arm64 host envelope, including compatible versions after 2.1.241 and builds which omit version output; an existing V2 session continues across compatible Claude releases; an absent or changed required shape, a different protocol or an unproved platform refuses, while an exact missing-session refusal remains an accepted client-allocated outcome; Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | +| native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of ``, hands the provider's native UI the selected root or pane terminal for that exact session, and continues after it exits. Claude uses the client-allocated `claude-client-native.v1` protocol: XMD allocates the identity, publishes V2, and the native process creates it from a private instruction file. Codex uses the provider-returned `codex-provider-returned.v1` protocol: ACP creates and canonically asserts the identity, XMD publishes V3, and one fixed tool-free `codex-materialization.v1` turn makes a newly created thread resumable before ownership is released. No other turn is performed. `prepared`, optional `materialized`, `detached` and `exited` phases retain the handoff; uncertain materialization is never repeated and completed replay performs no live work. V2 and V3 retain their original executable binding as exact route/journal audit evidence, while every live construction, native resume, ACP continuation and incomplete replay independently admits the current executable's stable protocol, requested capability, structural CLI shape and proved host envelope. Reported version and digest never lock a compatible release. V2 fixes the Claude protocol and V3 fixes the Codex protocol without a replaceable pin; neither route converts, rewrites, reallocates or substitutes an identity. ACP runtimes partition by the live executable and protocol, not historical binding, and each handle closes through its creator. Every advertised operation retains the existing coordinator ownership, privacy, cancellation, quiescence and terminal-grid pane-worker teardown contracts. Neutral launch and notification behavior stays in `@executablemd/terminal`, tmux transport stays in `@executablemd/terminal-tmux`, and no deleted core, runtime or CLI terminal path returns | Claude and Codex are advertised independently for the capabilities their installed-CLI proofs established on macOS arm64: Claude native launch and client-native attachment from the 2.1.241 proof family, and Codex native launch plus provider-native continuation from the `codex-cli` 0.153.2 / vendored ACP bridge proof. Compatible later releases remain admitted only when their current positive shape matches; protocol/profile/host/capability or exact-identity disagreement fails closed. Deno and compiled hosts assemble the authority; Node and Bun do not. Exact V1, V2 and V3 routes are readable; new Claude writes V2, new Codex writes V3, and older V1/V2-only builds correctly refuse V3 as unknown rather than treating the occupied key as absent. `Agent.AddDir` remains unbuilt | | `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack | | `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 | | `` | removes one file the document names, inside the contextual working directory. An ordinary overridable core default with a closed schema of one required non-empty `path`, **self-closing only** — a paired spelling never enters its body, because the component declares its one form and canonical invocation-form dispatch enters that body only for the form the scan recorded, before `Env.cwd` is read and before the provider is reached. Neither the composable `Component.hasContent()` chain nor a method on whatever object a caller handed over takes part. It renders the empty string, declares no `returns` and hands back no receipt, so an ordinary `as` captures that empty string; absence is the same success, so deleting a path twice succeeds twice. One regular file or one final symbolic link goes — the link rather than its target, inside or outside — and every directory is refused, an empty one included. Empty, absolute, lexically escaping and parent-link-escaping paths are refused before any removal, and a printed error names only the path the document wrote. One semantic `API.Files.deleteFile` call and no filesystem access of its own; under a workflow run it is one `workspace_file` effect retaining `{ kind: "deleted" }`. The standard Deno workflow profile admits it to generated XMD as the exact self-closing identity `@executablemd/core#File.Delete`, third in the write table, where it performs that same ordinary effect and contributes no evaluator result | built on the #567 stack | diff --git a/specs/acp-client-spec.md b/specs/acp-client-spec.md index 9714575d1..8e44eb954 100644 --- a/specs/acp-client-spec.md +++ b/specs/acp-client-spec.md @@ -59,16 +59,17 @@ routes an opaque request and cannot manufacture that result. its `SessionLaunchResult`; the route is where public middleware sees the ask, and authority to perform it reaches the installed provider directly rather than travelling on this chain - (specs/native-agent-session-launch-spec.md). A launch performs no model turn, - and a provider that answers `prompt()` does not thereby answer it: native - session launch is its own capability, installed on its own. -- Built-in **`claude` is advertised**, for two separate capabilities: native - launch, and attaching ACP to a session a native process constructed. Its - sessions are named by XMD and created by the native process - (specs/native-agent-session-launch-spec.md). So the ownership, - construction-route and executable-observation requirements below apply to it - on every host, and only the hosts that assemble all three — Deno and the - compiled binary — can serve it. `codex` remains unadvertised for both. + (specs/native-agent-session-launch-spec.md). A launch returns no model answer + and performs no model turn except the one fixed materialization turn declared + by `codex-provider-returned.v1`; a provider that answers `prompt()` does not + thereby answer native launch, which remains its own capability. +- Built-in **`claude` and `codex` are advertised** for native launch under their + independently admitted live profiles. Claude's client-native protocol is also + advertised for client-native ACP attachment. Codex's provider-returned + protocol is advertised for provider-native continuation after native handoff. + The ownership, construction-route and executable-observation requirements + below apply to both agents, and only the hosts that assemble all three — Deno + and the compiled binary — can serve them. - Every operation that can act on an **advertised** session — `session()`, a subscribed `prompt()` stream, a launch, and an incomplete launch replay — takes exclusive ownership of it first, through the session coordinator its @@ -79,26 +80,33 @@ routes an opaque request and cannot manufacture that result. install none, so default Claude session work refuses there before any provider effect — no availability probe, no runtime, no route read, no identity, no child. An agent that is not advertised keeps ordinary ACP behavior on every - host, Codex included. + host. - The host also passes in a **construction-route store** and an **executable observer**, directly and from the same trusted root as the coordinator. Both - are required only for an advertised agent whose adapter names its own - sessions: a provider that returns the identity constructs nothing a route - governs and binds no build, and keeps its behavior unchanged on a host that - has neither. A host missing either refuses that agent before any provider - effect, on the same terms as a missing coordinator. The observer is not a - contextual Api for the same reason the coordinator is not: executable + are required for every advertised bound adapter. Claude publishes V2 before + client-native construction; Codex publishes V3 before provider-returned ACP + construction. A host missing either dependency refuses that agent before any + provider effect, on the same terms as a missing coordinator. The observer is + not a contextual Api for the same reason the coordinator is not: executable validation decides which retained history may be accepted. - While ownership is held and before any provider construction effect, the provider reconciles the route. A first `session()` or subscribed `prompt()` publishes or adopts `acp-first` before runtime creation, `ensureSession()` or a turn, and an ensure that fails afterwards leaves that route standing. A - launch by an adapter that names its own sessions publishes `client-native` - under an identity that adapter allocated, unless existing durable ACPX state - or an existing route says the session was constructed through ACP — in which - case it publishes or adopts `acp-first` and retains `identity-unavailable`. + bound provider-returned launch admits its current executable and publishes or + adopts V3 before ACP construction. A launch by an adapter that names its own + sessions publishes V2 `client-native` under an identity that adapter + allocated, unless existing durable ACPX state or an existing route says the + session was constructed through ACP — in which case it publishes or adopts + V1 `acp-first` and retains `identity-unavailable`. Publication is create-once, so the loser of either order adopts the winner, and no route converts. +- A `session()` or subscribed `prompt()` meeting V3 validates the compiled + `codex-provider-returned.v1` interpretation, observes and independently admits + the live provider-native-continuation profile, then ensures through the live + executable partition. It requires the exact provider assertion retained by + the launch before a turn. Missing or different identity, protocol, capability, + shape or host refuses without allocation, conversion or materialization. - A `session()` or subscribed `prompt()` meeting a **bound** `client-native` route **attaches** to it. Under the same ownership it reobserves the executable and independently admits the route's stable adapter protocol, attachment @@ -704,9 +712,12 @@ none. life, so a second `` naming the same placement is answered with it and a `` given it is acting on the thing that was pinned. -- **Materialization.** A pending ACP-first placement is constructed by the first - subscribed ``, and only the backend's acceptance of that turn makes it - a conversation. The provider ensures with +- **Materialization.** An ordinary pending ACP-first placement is constructed by + the first subscribed ``, and only the backend's acceptance of that + turn makes it a conversation. A V3 provider-returned native launch uses the + same acceptance boundary for its protocol's one fixed internal turn, retains + that prompt before sending it, and exposes neither its response nor usage as + document output. The provider ensures with `materialization: "first-turn-acceptance"`, which makes ACPX persist a provisional record: the key is occupied and the serialized `agentSessionId` is absent, so the record is occupancy rather than an assertion. @@ -735,7 +746,9 @@ none. The durable order is therefore: route, provisional non-asserting record, backend acceptance, asserting record, host mapping, and only then the events - the turn produced. If materialization fails the provider removes no route, + the turn produced. A native-launch materialization additionally requires the + canonical provider assertion and refuses any tool call or non-`end_turn` + result. If materialization fails the provider removes no route, publishes no identity, calls no host mapping, gives up the handle so the next attempt creates rather than resumes, and leaves the exact `Session` value pending for a retry. If it succeeds and the turn then fails, the session stays @@ -766,10 +779,14 @@ none. Promise-returning leaves are consumed with `until`; the provider's only Promise-producing adapter is the `onPermissionRequest` callback, and the bridge itself is operation-based. -- **Runtime partitions.** Ordinary ACP-first work uses one unbound runtime. A - bound attachment uses one runtime per `(resolved agent command, executable - build binding)`, created with the observed path in `agentProcessEnv` and torn - down when its last handle closes. Acquiring a runtime to ensure through claims +- **Runtime partitions.** Ordinary unbound ACP-first work uses one unbound + runtime. Bound client-native attachment and V3 provider-returned construction + or continuation use one runtime per `(resolved agent command, live executable + observation, adapter protocol)`, never the historical route binding. The + runtime is created with the observed path in `agentProcessEnv` and torn down + when its last handle closes. An upgrade never rekeys a live partition; a + later operation observes again and enters the partition for the current + executable. Acquiring a runtime to ensure through claims the partition, and an `ensureSession()` that rejects gives that claim up — so the next attempt observes again rather than inheriting a live path nothing is holding, while a sibling ensure still in flight keeps the partition alive. @@ -791,7 +808,8 @@ none. exists and what removes it. - **Host-owned dependencies.** `AcpxProviderDependencies` carries what a host, rather than a document, decides: `advertiseNativeLaunch` and - `advertiseClientNativeAttachment` are two separate lists, and a profile whose + the client-native-attachment and provider-native-continuation advertisements + are separate lists, and a profile whose session authority differs from ordinary `xmd run` states both explicitly rather than inheriting the package's defaults by omission; `executableObserver` says how this host observes the build behind an diff --git a/specs/decisions.md b/specs/decisions.md index c490aff52..00489aa2c 100644 --- a/specs/decisions.md +++ b/specs/decisions.md @@ -835,12 +835,12 @@ cross-check between the route and the prepared journal. It does not establish whether another release implements the same operation. That question belongs to the live capability profile. -The client-allocated contract deliberately accepts two zero-turn outcomes: the -same identity resumes, or the provider refuses that exact absent identity and -XMD fails closed. A provider which persists no conversation until its first user -turn therefore does not, by that fact alone, violate the contract. Adding a -hidden materialization turn would instead violate the accepted rule that native -launch performs no model turn. +The client-allocated Claude contract deliberately accepts two zero-turn +outcomes: the same identity resumes, or the provider refuses that exact absent +identity and XMD fails closed. A provider which persists no conversation until +its first user turn therefore does not, by that fact alone, violate that +contract. A materialization turn is never inferred from this observation; +DEC-018 records the separately proved Codex exception explicitly. ### Decision @@ -916,10 +916,10 @@ child teardown, cleanup and handle release acknowledge quiescence even when the child exited unsuccessfully; any unproved teardown keeps the recovery tombstone active. -No XMD-owned materialization turn is added. A provider-native creation protocol -which retains the exact identity without a turn may replace the current command -only after its own real-CLI proof. Allowing a fixed bootstrap/model turn is a -separate product decision and specification change. +Capability admission never adds a materialization turn. Claude remains +zero-turn. A provider protocol requiring a fixed turn must declare and prove it +as a separate product contract; DEC-018 does so for Codex without changing the +Claude or generic admission rule. ### Consequences @@ -952,3 +952,113 @@ This is a repair to the unmerged terminal/native-session stack and lands directly atop its delivery head. It is not a separate Story: the terminal grid made the native-launch path observable but did not create this capability boundary. + +## DEC-018: Codex native launch materializes one provider-returned conversation + +**Status:** Decided + +**Date:** 2026-09-06 + +### Context + +The accepted two-agent terminal-grid document needs both Claude and Codex to be +real product paths. Claude can create a named conversation directly in its +native process. Codex cannot: its ACP App Server chooses the thread identity, +and `codex resume ` cannot read that thread until its first accepted turn +has written a rollout. + +The #755 installed-CLI proof established the complete provider-returned path on +macOS arm64 with `codex-cli 0.153.2` and the vendored +`@agentclientprotocol/codex-acp` 1.6.2 snapshot: ACP created one conversation, +the adapter canonically asserted its App Server thread, one fixed turn made it +resumable, the native UI resumed that exact identity, and ACP later rejoined the +same conversation. The version and digest identify the evidence. DEC-017 means +they cannot become a release allow-list or continuity lock. + +The sibling implementation wrote strict `session-route.v3` records into the +shared machine store. A V1/V2 reader refuses those records as unknown. Treating +them as absent would permit a second conversation under an occupied logical key, +which is precisely what strict route parsing prevents. + +### Decision + +Codex is advertised for `native-launch` and the separate +`provider-native-continuation` capability. Its stable protocol is +`codex-provider-returned.v1`. The Deno and compiled hosts admit each capability +only when the prospective live adapter has that compiled non-replaceable +protocol interpretation, the resolved executable structurally declares the +Codex product and required `resume` identity semantics, and the macOS arm64 host +envelope matches. A reported version and executable digest remain optional audit +evidence and never authorize or deny a compatible cross-release continuation. + +New Codex native construction publishes strict V3 before ACP runtime creation: + +```ts +interface AgentSessionRouteV3 { + schema: "session-route.v3"; + route: "acp-first"; + provider: string; + agent: string; + sessionKey: string; + executableBinding: ExecutableBuildBindingV1; +} +``` + +The exact V3 schema/route/provider/agent form fixes +`codex-provider-returned.v1`; it has no replaceable protocol field. Its original +binding and the agreeing launch journal remain immutable audit evidence. A +future incompatible provider-returned protocol requires a new route schema. +The integrated reader accepts exact V1, V2 and V3, writes V2 for new Claude +client-native construction and V3 for new Codex provider-returned construction, +and never upgrades or rewrites an existing route. Older V1/V2-only builds must +continue refusing V3 rather than deleting it, treating it as absent or creating +another session. + +`codex-provider-returned.v1` declares exactly one +`codex-materialization.v1` turn for a conversation the launch just created. Its +prompt bytes are fixed in the native-session-launch specification, contain no +authored instructions or host values, deny every tool and external action, and +are announced before the cost is incurred. Core retains the ordinary prompt +operation before sending it, then retains `materialized` with the provider's +exact canonical identity and filtered response/usage evidence. That response is +not document output. A tool call, failed or cancelled turn, non-`end_turn` stop, +missing acceptance signal, missing or changed identity, or failed checkpoint +fails the launch before native execution. + +Replay never risks spending the turn twice. A retained prompt outcome can +reconstruct a missing `materialized` phase; a prior invocation with only the +prepared plan is `session-recovery-required`; a materialized or detached launch +continues the exact retained identity; a completed launch observes and executes +nothing. Deliberately stopped work is never revived. + +Every live V3 construction, native resume, provider-native ACP continuation and +incomplete replay independently validates the stable protocol and admits the +current executable before ensure, materialization, native execution or a turn. +Route/journal disagreement, provider identity absence or substitution, protocol, +profile, capability or host mismatch remains fail-closed. Compatible Codex +releases continue the exact retained identity without allocation, route rewrite +or same-build comparison. ACP runtime partitions use the current resolved agent +command, executable observation and protocol rather than the historical V3 +binding; a live partition is never migrated and each handle closes through its +creator. + +The materialization notification and native child use the selected root or pane +terminal. Neutral notification and launch mechanics belong to +`@executablemd/terminal`; tmux-specific transport remains in +`@executablemd/terminal-tmux`. No deleted runtime, core or CLI terminal path is +restored. + +### Consequences + +A newly constructed Codex native session costs one explicit model turn before +its native UI appears. Claude remains zero-turn, an established Codex session +receives no second materialization turn, and ordinary ACP-first prompting keeps +its existing semantics. The authored two-agent terminal-grid document is +therefore preserved without adding provider syntax or a demo-only fallback. + +The #755 implementation is ported semantically on top of the terminal-package +and cross-release stack; it is not cherry-picked as a sibling commit. Its +same-build comparisons and old core/runtime terminal paths are superseded. The +frozen real-session proofs remain evidence and are not rerun for this +integration. Controlled fixtures prove protocol/shape discrimination, V3 +compatibility, replay, mixed Claude/Codex pane launch, ownership and teardown. diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index a794797a3..c71cade5c 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -11308,7 +11308,7 @@ Each row names the derivation it kills. | AF16-AF20 | Prompt-failure policy | Absent by default; forces `throwOnError` when it says yes; an explicit `throwOnError` wins without consulting it; a repository `Prompt` never consults it | | AF21 | `` is a caller, not a second implementation | It renders its body and calls `launchAgentSession()` with what it rendered; the phase sequencing, retention and result belong to that one canonical operation, and the component holds none of them | | AF22 | A refused launch is an observation | The failure is raised as an error segment whose cause carries `phase` and `failureClass`, so `` binds a value an author can assert which refusal it was on, rather than the wording of a message | -| AF23 | A `` joins what a launch constructed | The same named `` after a client-native `` attaches to the conversation the native process made, and a `` in it answers from that conversation's history rather than from a new one. The route is unchanged: not republished, not converted, and no second identity allocated. Authored whole in `packages/test-agent/src/NativeSessionLaunch.test.md`; a provider that reported another conversation fails it | +| AF23 | A `` joins what a launch constructed | The same named `` after a client-native or provider-returned `` attaches to the exact conversation the launch retained, and a `` in it answers from that conversation's history rather than from a new one. V2 and V3 routes are unchanged: not republished, converted, rebound or given a second identity. Authored whole in `packages/test-agent/src/NativeSessionLaunch.test.md`; a provider that reports another conversation fails it | | AF24 | A Session pins the exact value it was issued | A fresh `` calls `session()` once and hands the same object — by identity, not by key — to every `` nested inside it. A provider decides whether a session may be acted on by that identity, so a rebuilt look-alike is a value nobody issued | | AF25 | A fresh Session performs no provider effect | A self-closing `` places one and renders nothing: no prompt is started, and nothing about the placement appears in the document where the element stood | @@ -11341,6 +11341,7 @@ test derives a core result from a provider identifier. | TG19 | Reader close crossed with parent cancellation | A controlled live pane enters a signal-held finalizer after reader close takes effect. Parent cancellation begins while teardown is blocked; releasing the finalizer lets pane and provider teardown complete, retains the pane as `closed` and the grid with its reader-close result, and only then delivers cancellation to the parent. A continuation neither contacts the provider nor enters pane work, does not hang, and proceeds from the retained grid outcome. Provider-resource and following-sibling observations prove both sides of the ordering; no elapsed duration is evidence | | TG20 | Pane-native physical endpoint | A paired pane's native launch passes through nearer launcher middleware and then the required composite operation for its authored ordinal. Production tmux evidence observes the exact argv, cwd, and environment at that pane's authenticated worker while a root-foreground-launcher sentinel is never entered. Distinct pane workers accept concurrent launches. Cancellation settles only after worker-reported child settlement and pane-terminal quiescence. A root launch still enters the root foreground launcher unchanged, and a composite unable to execute a pane launch refuses without fallback | | TG21 | Package boundary and canonical imports | Static dependency evidence proves terminal imports neither runtime, core, CLI nor terminal-tmux; terminal-tmux imports terminal and none of runtime, core or CLI; runtime has no terminal dependency; and CLI alone composes the document engine with the provider and host. The old runtime, core and CLI terminal modules and exports are absent, every repository terminal import names a canonical package surface, and each contextual descriptor and public error constructor has one definition. The relocated neutral, tmux, cross-package Agent and Deno/compiled host suites retain TG1–TG20 without changing syntax, provider identity, hidden-worker grammar, protocol, durable records or diagnostics; Node and Bun still install neither observer nor provider | +| TG22 | Mixed native providers | One controlled grid launches Claude and Codex concurrently through distinct pane endpoints. Codex's fixed materialization completes before its native child spawn; neither that turn nor Claude preparation is pane readiness. Both launches reach their authenticated workers, the root launcher is not entered, and close settles materialization/ACP handles, children, worker quiescence and Agent-session ownership before the grid completes. No real Agent session or demo run supplies this evidence | ### Tier CR — Component registration and resolution diff --git a/specs/native-agent-session-launch-spec.md b/specs/native-agent-session-launch-spec.md index 65397c9ff..30d48ca58 100644 --- a/specs/native-agent-session-launch-spec.md +++ b/specs/native-agent-session-launch-spec.md @@ -161,11 +161,13 @@ nonzero exit, signal, and cancellation do not produce it. It contains only filtered stable evidence and exposes no process handle, ACP client, credential, raw environment, or executable argument vector. -`launchAgentSession()` is distinct from `prompt()`: +`launchAgentSession()` is distinct from an authored `prompt()`: - `prompt()` performs one model turn through ACP and returns the agent response; -- `launchAgentSession()` performs no model turn, transfers the session to a - native UI, and returns only after that UI exits. +- `launchAgentSession()` returns no agent response. It transfers the session to + a native UI and returns only after that UI exits. It performs no model turn + except a protocol-declared materialization turn for a provider-returned + conversation this launch just created. The base `Agent.launch(request)` routing handler fails. A provider must install the route explicitly; availability of `agent()`, `session()`, and `prompt()` @@ -193,12 +195,16 @@ source position and expansion identity ``` The provider applies the instructions as a provider-native session, system, or -developer instruction layer before the native UI accepts its first user turn. -It does not send the instructions as a user message and does not invoke a model. +developer instruction layer before the native UI accepts its first authored +user turn. It does not send the instructions as a user message. A protocol may +declare one fixed materialization turn solely to make a newly created +provider-returned conversation resumable; that turn carries none of the +prepared instructions and cannot perform the prepared task. -A provider that cannot establish instructions without a model turn refuses the -launch. V1 has no bootstrap-prompt fallback. An explicit, observable bootstrap -mode is a separate future feature tracked by issue #514. +A provider that cannot establish the instruction layer before that first +authored turn refuses the launch. V1 has no author-supplied bootstrap-prompt +fallback. An explicit bootstrap mode remains a separate feature tracked by +issue #514. Prepared instructions are the current attachment's instruction layer. A relaunch with the same layer resumes the existing provider session and does not @@ -224,7 +230,9 @@ An empty cached transcript is not proof that a retained or previously handed-off session has no conversation. Native UI turns are provider-owned and need not be mirrored into ACPX or XMD state. Relaunch therefore never silently keeps a stale layer, discards unobserved native history, substitutes a new provider session -for retained continuity, or performs a bootstrap turn. +for retained continuity, or performs a bootstrap turn. A materialization turn +is not a bootstrap turn: it runs only for a conversation this launch just +created, carries no authored content, and never runs against a retained one. The prepared text and filesystem authority are different capabilities: @@ -291,10 +299,12 @@ provenance, because the two constructions make different claims. A **provider-returned** adapter proves all seven of the following: -1. session creation materializes durable state the native UI can resume; +1. session creation materializes durable state the native UI can resume, + either at creation or through the adapter's declared materialization turn; 2. its returned native ID names that exact state; -3. prepared instructions are effective on the first native user turn without a - bootstrap model turn; +3. prepared instructions are effective on the first native user turn without + an authored bootstrap turn, and a declared materialization turn cannot see + or act on those instructions; 4. cwd and permissions survive the handoff without being widened; 5. the ACP owner can release the session before native attachment; 6. the native process can exit without deleting the resumable session; and @@ -345,8 +355,9 @@ codex resume ### Executable build binding -A new client-allocated session records the executable observed before its -identity was published. This is immutable audit evidence and a durable +A new bound session records the executable observed before its route is +published and before either its client-allocated identity or provider-returned +conversation is created. This is immutable audit evidence and a durable cross-check, not a release lock: ```ts @@ -366,14 +377,14 @@ member absent and does not make an otherwise observable executable or capability unsupported. A path is never a member: it says where a build was, which stops being true, and it names host layout besides. -The V2 construction route and every prepared journal record derived from it +The V2 or V3 construction route and every launch journal record derived from it carry the same original binding exactly. They never replace it with a later observation. Exact agreement between those durable accounts detects a partial, foreign or inconsistent preparation; digest or version equality with the live executable is not required. A compatible later release continues the retained -native identity after it independently passes the same live capability gate. -The retained digest and version may be inspected as audit evidence, but neither -by itself authorizes or denies continuation. +native identity after it independently passes the applicable live capability +gate. The retained digest and version may be inspected as audit evidence, but +neither by itself authorizes or denies continuation. For the current V2 route, the exact provider, agent and launcher contract pins the stable `claude-client-native.v1` protocol under which its identity was @@ -381,6 +392,11 @@ published. A future incompatible protocol cannot reinterpret that route; it requires a route contract which names the new protocol. Executable releases may therefore change without allowing the session to cross protocol boundaries. +For V3, the exact schema, `acp-first` route, provider and Codex agent contract +pins `codex-provider-returned.v1`. V3 gains no optional protocol member whose +meaning a loaded copy could replace. Another provider-returned protocol requires +a new route schema rather than reinterpreting V3 history. + The host supplies an executable observer directly to the provider, alongside the coordinator and the route store. It resolves the launcher command through the host's real execution environment, canonicalizes the target, requires an @@ -429,7 +445,10 @@ current run. ```ts interface NativeCapabilityAdmission { readonly adapterProtocol: string; - readonly capability: "native-launch" | "client-native-attachment"; + readonly capability: + | "native-launch" + | "client-native-attachment" + | "provider-native-continuation"; readonly probeProfile: string; readonly platform: string; readonly architecture: string; @@ -474,14 +493,29 @@ the same help shape on another operating system or architecture is insufficient until the applicable proof passes there. Shared provider code detects no runtime. -After resolving and hashing a client-allocated adapter's executable, the -provider runs the read-only probe against that exact path and requires the -requested capability in its result. This precedes identity allocation, route -publication, private-file creation and native child start. Existing bound -routes are checked again before native resume, client-native attachment or -incomplete replay can contact the provider. A canonical version line and digest -are evidence about the observed build, not capability admission or a -cross-release refusal. +The built-in Codex profile recognizes the Codex product and structurally parses +the root command and `resume` subcommand help. Native launch requires the first +declared positional to be `[SESSION_ID]`, with semantics that accept a UUID as +the session identity, before the optional `[PROMPT]` positional. Its optional +spelling is intentional: omitting it enters Codex's picker, while the adapter +always supplies the exact retained identity. Provider-native continuation +independently requires the vendored ACP bridge's pinned +`codex-provider-returned.v1` assertion and resume contract. A substring mention +of `resume`, `SESSION_ID` or UUID, a renamed or reordered positional, changed +identity semantics, a different product, or an injected adapter protocol admits +nothing. The applicable proof passed with `codex-cli 0.153.2` on macOS arm64 and the vendored +`@agentclientprotocol/codex-acp` 1.6.2 snapshot; a later Codex release continues +only when its live executable independently advertises the same required shape +on that proved host envelope. + +After resolving and hashing a bound adapter's executable, the provider runs the +read-only probe against that exact path and requires the requested capability +in its result. This precedes identity allocation, route publication, ACP ensure +or creation, materialization, private-file creation and native child start. +Existing bound routes are checked again before native resume, client-native +attachment, provider-native continuation or incomplete replay can contact the +provider. A canonical version line and digest are evidence about the observed +build, not capability admission or a cross-release refusal. A protocol, shape, capability or host mismatch is `unsupported-capability`. The diagnostic may identify the agent, capability, adapter protocol and @@ -491,30 +525,30 @@ probe facts remain live admission inputs. A live digest or version different from the route's retained audit evidence is not a mismatch. The version query is optional and independent of the help probe. A future -Claude build with the same admitted protocol shape on macOS arm64 remains +Claude or Codex build with the same admitted protocol shape on macOS arm64 remains usable when its version changes, its version wording changes, or it omits -version output. It may establish a new session or continue an existing V2 +version output. It may establish a new session or continue an existing V2 or V3 session. Continuation adopts only the route's exact native identity and leaves the route, journal and provider history unchanged. The observed zero-turn behavior of a newer Claude that retains no conversation is not by itself a regression: claim 5 explicitly permits the provider to refuse that exact identity, provided XMD fails closed without substitution. -Compatibility admission adds no materialization turn. Launch still performs no -model turn, and bootstrap turns remain outside this contract. The metadata -query is not a disposable session and proves no hidden implementation behavior; -it is the CLI's side-effect-free declaration that the previously proved -protocol remains present. A provider that cannot make that declaration, or -needs a model turn to establish it, remains unsupported. Enabling a different -protocol requires its own real-CLI proof and admission profile; a version string -or adapter name is never enough. - -### Attachment capability - -Native launch and client-native ACP attachment are separate trusted-host -choices. Both may inspect one adapter protocol shape, but neither capability is -inferred from the other or from the Agent name. An adapter may be proven to hand -a session to a native UI without being proven to join that conversation +Compatibility admission never implies a materialization turn. Claude remains +zero-turn. Only `codex-provider-returned.v1` declares +`codex-materialization.v1`, and bootstrap turns remain outside this contract. +The metadata query is not a disposable session and proves no hidden +implementation behavior; it is the CLI's side-effect-free declaration that the +previously proved protocol remains present. Enabling a different protocol +requires its own real-CLI proof and admission profile; a version string or +adapter name is never enough. + +### Continuation capabilities + +Native launch, client-native ACP attachment and provider-native continuation are +separate trusted-host choices. They may inspect one adapter protocol shape, but +none is inferred from another or from the Agent name. An adapter may be proven +to hand a session to a native UI without being proven to join that conversation afterwards. `claude` is advertised for both only when each capability's live profile matches. @@ -543,9 +577,28 @@ the failure this contract asks for rather than a hopeful spawn. `claude` is advertised only when the applicable profile is observed inside the proved **macOS arm64** host envelope. Its client-allocated protocol claims were established through the production CLI at Claude Code 2.1.241; that version is -evidence for the profile, not its upper or lower bound. `codex` is unadvertised: -its command shape and adapter contract tests exist, and nothing has run its -provider-returned claims against an installed Codex. +evidence for the profile, not its upper or lower bound. `codex` is advertised +for native launch and provider-native continuation under the same host envelope. +Its provider-returned claims passed against `codex-cli 0.153.2` and the pinned +ACP bridge snapshot, including a later ACP rejoin to the exact native +conversation. Those versions identify the proof, not an allow-list. + +Only Codex owes a materialization turn. The App Server writes the rollout that +`codex resume ` reads at a thread's first accepted turn, so an ACP-created +conversation with no turn is not yet resumable by name. The stable +`codex-materialization.v1` protocol sends exactly this fixed text: + +```text +This turn only makes the Codex conversation resumable. Do not perform the prepared task, inspect or modify files, call tools, or take any external action. Reply with a brief acknowledgement only. +``` + +It contains no authored instruction, path, identity or environment value. XMD +announces the cost before sending it, denies every tool and external action, +requires the provider's canonical accepted-turn signal and exact native identity, +retains its response and reported usage as filtered evidence, and exposes +neither as document output. A tool call, failed or cancelled turn, non-`end_turn` +stop, missing acceptance signal, missing or changed identity, or failed +checkpoint retains `materialization-failed`; the native UI never starts. ## Runtime sequence @@ -571,13 +624,20 @@ session one of them constructs through ACP and the other does not. **Provider-returned.** ACP owns the session first and has to hand it over: -9. The provider creates or resumes the durable provider session and applies the - prepared instruction layer and contextual cwd configuration. -10. The provider verifies a native-resume capability and obtains the exact - native session ID. -11. XMD commits the prepared launch record before releasing ownership. -12. The provider closes or detaches the ACP session and waits for that owner to - terminate, and XMD commits that too. +9. A bound adapter first observes the exact executable it will use, admits + `native-launch` for its stable protocol and host envelope, and publishes or + adopts its create-once route. The observation precedes route publication, + runtime construction, ensure and every turn. +10. The provider creates or resumes the durable provider session, applies the + prepared instruction layer and contextual cwd configuration, and accepts + only its canonical provider-returned identity. +11. XMD commits the prepared launch record. When that record declares a + materialization plan, core retains the fixed turn as its ordinary durable + prompt before the provider runs it, then retains `materialized` with the + exact asserted identity before releasing ACP ownership. A protocol with no + plan spends no turn and writes no `materialized` phase. +12. The provider closes or detaches the ACP session, waits for that owner to + terminate, and XMD commits `detached` before any native child can start. **Client-allocated.** Nothing is created through ACP at all, so there is no owner to release — what has to be settled first is which conversation this is: @@ -692,6 +752,16 @@ this sequence, and every step happens while the coordinator holds the session: disagreement closes the handle and refuses before a turn. 10. Only then return a `Session`, or start the subscribed turn. +A `` or `` meeting V3 follows the ordinary ACP-first shape with +one extra gate. While holding the same coordinator ownership, it validates the +non-replaceable `codex-provider-returned.v1` interpretation, observes the +current executable, independently admits `provider-native-continuation`, and +selects the runtime for that live observation before ensure or a turn. The +ensured provider assertion must equal the exact native identity retained by the +materialized launch. Missing history, another identity, an incompatible live +protocol/profile/host, or an unavailable provider fails closed without +allocation, route conversion, materialization or substitution. + Runtime partitions are scope-owned and follow the executable serving live work, not the historical binding in a route. Different live builds never share an ACP child. Sessions carrying different historical bindings may share when the same @@ -964,7 +1034,7 @@ route grants no right to ensure, prompt, detach, spawn or accept history. It says only which kind of thing this session is, so a later operation cannot quietly treat a conversation that already exists as one it may name. -Two schemas are readable. The exact V1 record is: +Three schemas are readable. The exact V1 record is: ```ts type AgentSessionRouteV1 = @@ -1005,13 +1075,37 @@ interface AgentSessionRouteV2 { } ``` -There is no V2 `acp-first`: ACP-first construction gained no fact, and a second -schema for it would be a version number with nothing behind it. New +There is no V2 `acp-first`: ordinary unbound ACP-first construction gained no +fact, and a second schema for it would be a version number with nothing behind +it. New client-native construction publishes V2 and observes the build before it allocates an identity. Serialization preserves the schema it was given, so nothing here upgrades a route. -Every member of both schemas is exact. A path, adapter command, environment, +V3 is the bound ACP-first route for `codex-provider-returned.v1`, and only that +protocol: + +```ts +interface AgentSessionRouteV3 { + schema: "session-route.v3"; + route: "acp-first"; + provider: string; + agent: string; + sessionKey: string; + executableBinding: ExecutableBuildBindingV1; +} +``` + +It carries no native identity because the provider asserts that later, and no +launcher because its exact schema/route/provider/agent form already fixes the +non-replaceable protocol interpretation. New Codex provider-returned +construction publishes V3 after live admission and before runtime construction. +Existing V3 records written by the #755 proof are read under this same protocol; +their reported version and digest remain immutable audit evidence, not a live +release lock. Another provider or incompatible Codex protocol requires a new +route schema. + +Every member of all three schemas is exact. A path, adapter command, environment, argv, instruction text, credential, transcript, process fact or temporary path is not a member, and a record carrying one is refused rather than read partially. A binding beside a V1 record is such a member, which is what keeps a @@ -1019,10 +1113,20 @@ V1 record from being read as a V2 one. So are missing, malformed, unknown-schema, moved and natural-key-mismatched records. Only a file that is not there means the session has not been constructed yet. -A V1 `client-native` route is legacy-unbound. It remains valid for native resume -under the contract that created it, authorizes no ACP attachment, and is never -overwritten, supplemented or upgraded. A user who needs attachment creates a -differently named logical session under the bound contract. +That strictness is the intended shared-store behavior across unmerged branches. +A V1/V2-only build meeting V3 refuses the unknown record rather than treating +the occupied key as absent, deleting it or creating another session. The +integrated build reads V1, V2 and V3, writes V2 for new Claude client-native +construction and V3 for new Codex provider-returned construction, and never +rewrites a route merely because another executable release now serves it. + +A V1 route is legacy-unbound. A V1 `client-native` route remains valid for +native resume under the contract that created it, authorizes no ACP attachment, +and is never overwritten, supplemented or upgraded. A V1 `acp-first` route +remains ordinary ACP history, but a bound provider-returned adapter cannot +reinterpret it as V3 or use it for native launch. A user who needs either bound +continuation creates a differently named logical session under the bound +contract. The route shares the coordinator's namespace, natural key and digest, so one session names one lease, one ownership record and one route. The route directory @@ -1045,12 +1149,18 @@ construction effect: caller observed the failure, and preserving the route is what stops that uncertainty from later being reclassified. An `acp-first` route by itself is not establishment; -3. a launch by an adapter that names its own sessions reads both the route and +3. a launch by a bound provider-returned adapter admits the live executable, + reads both durable accounts, and publishes or adopts V3 before runtime + construction; existing V1 ACP-first history refuses rather than being + upgraded, while an exact V3 winner is adopted only after its stable protocol + is independently validated; +4. a launch by an adapter that names its own sessions reads both the route and existing durable ACPX state; existing state publishes or adopts `acp-first`, and otherwise the adapter allocates a candidate and publishes `client-native`; -4. a launch that adopts `acp-first` retains `identity-unavailable` at `prepared`, +5. a client-allocated launch that adopts `acp-first` retains + `identity-unavailable` at `prepared`, before allocation, private-file creation, detach or spawn; -5. a `session()` or subscribed prompt that meets `client-native` raises the +6. a `session()` or subscribed prompt that meets `client-native` raises the provider's typed route error before runtime creation, ensure, turn, close or accepted history. It retains no launch failure, because no launch was asked for. @@ -1088,7 +1198,7 @@ expansion identity `` derives. Its records contain preparation and lifecycle phases rather than the native conversation: ```text -prepared -> detached -> launched -> exited +prepared -> [materialized] -> detached -> launched -> exited ``` Each phase the launch completes is one retained record under that identity, @@ -1104,6 +1214,18 @@ deliberately not retained: an interrupted native process leaves `detached` as the last retained phase, and resuming reattaches the native UI to that same provider session. +`materialized` exists only when the prepared record carries a versioned +materialization plan. Core gives that fixed request an ordinary durable +`agent_prompt` identity and retains the prompt outcome before it retains the +launch phase, so replay cannot infer that an unrecorded paid turn is safe to +repeat. If the prompt outcome is retained but `materialized` is absent, replay +reconstructs the phase from the exact prompt identity and provider assertion. +If a prior invocation retained only the plan and no prompt outcome, replay +retains `session-recovery-required` and performs no turn: it cannot distinguish +"not sent" from "accepted before interruption." A turn completed and retained +is never sent again. Completed launch replay still reads only its journal and +performs no observation or provider work. + For a session XMD named, the retained phase is what decides the only safe continuation, because `detached` is retained before the exit phase is invoked: @@ -1117,31 +1239,42 @@ continuation, because `detached` is retained before the exit phase is invoked: allocates nothing at all. A prepared record carries `executableBinding` exactly when the route it agrees -with is bound. It is optional for compatibility: the client-allocated path was -released before any build was observed, so a record without it is legacy history -— readable, and resumable only under the native-only contract that wrote it. A -provider-returned preparation carries none, and a refusal that prepared no -identity invents none. - -Every incomplete replay requires exact agreement between its journal and its -route on identity, provenance, instruction digest, launcher and the original -build binding before its first live effect. It then observes the executable it -will use and independently requires the native-launch capability for the same -stable adapter protocol, positive CLI shape and proved host envelope. The live -digest and version need not equal the retained evidence. Neither durable account -repairs, republishes or rewrites the other: a replay that found a disagreement -has discovered that the session it was going to continue is not the session it -prepared, and retains `identity-unavailable` without starting a child. Equal -instructions may continue the retained identity; different instructions retain -`instructions-refused` and replace neither the layer, the route, the identity, -nor any provider state. - -A `prepared`-only replay still creates under the exact retained identity because -its handoff never began. A `detached` replay still resumes that identity and -never falls back to creation because a predecessor may have started. Both use -the current admitted executable path, allocate nothing and leave the historical -binding unchanged. A completed replay still observes no executable and performs -no live work. +with is bound. It is optional for compatibility: both construction paths were +released before every bound form existed, so a record without it is legacy +history and gains no current bound capability. A bound provider-returned +preparation carries the V3 route's exact original binding; a refusal that +prepared no identity invents none. + +Every incomplete replay requires exact agreement between its journal and route +on every fact both carry before its first live effect: provider, agent, logical +session, construction/provenance and original build binding. V2 additionally +agrees on native identity, instruction digest and launcher. V3's schema form +fixes the exact provider-returned protocol; its materialized journal identity +must agree with the canonical ACPX assertion and host mapping rather than with a +route that deliberately carries no native identity. Replay then observes the +executable it will use and independently +requires the applicable capability for the same stable adapter protocol, +positive CLI shape and proved host envelope. The live digest and version need +not equal the retained evidence. Neither durable account repairs, republishes or +rewrites the other: disagreement retains `identity-unavailable` without a turn, +ensure or child. Equal instructions may continue the retained identity; +different instructions retain `instructions-refused` and replace neither the +layer, route, identity nor provider state. + +For client-allocated history, a `prepared`-only replay still creates under the +exact retained identity because its handoff never began. A `detached` replay +still resumes that identity and never falls back to creation because a +predecessor may have started. + +For V3 provider-returned history, a materialized identity is the only identity +native launch may resume. A retained materialization outcome supplies that exact +assertion without another turn; a retained `detached` phase resumes it directly. +A prepared-only uncertain turn refuses as described above. Each live V3 +continuation validates the compiled non-replaceable protocol first, then observes +and admits the current executable before ACP ensure, materialization or native +resume. Both provenances leave the historical binding unchanged and allocate no +replacement. Completed replay still observes no executable and performs no live +work. An incomplete replay of a legacy unbound client-allocated launch retains `executable-binding-refused` before any live work: nothing available to it can @@ -1269,6 +1402,14 @@ adapter that cannot resume by name, a retained provider arrangement asserting another conversation or none, and a returned identity that differs from the route's are one answer, and none of them creates a substitute conversation. +A declared materialization that calls a tool, lacks the canonical acceptance +signal or exact provider identity, ends unsuccessfully, is cancelled, reports a +non-`end_turn` stop, or cannot retain its checkpoint fails with +`materialization-failed`. The prepared V3 route and provider state remain +authoritative, the native UI does not start, and replay never guesses that the +turn is safe to spend again. Cancellation settles the turn and its ACP handle +before session quiescence. + A native resume which the provider refuses because the exact retained identity has no conversation is the other permitted zero-turn answer. Its child outcome is retained normally and the authoritative route stays in place; a later @@ -1422,7 +1563,9 @@ Focused tests prove: 1. help discovers roles and performs no preparation or launch; 2. selecting one role excludes sibling preparation; 3. rendered instructions exactly match the selected files and computations; -4. no Agent prompt occurs during preparation or launch; +4. Claude preparation and launch perform no Agent prompt; Codex performs only + the exact retained `codex-materialization.v1` turn for a freshly created + conversation and never repeats it; 5. cwd, the explicit empty additional-directory list, and permission configuration reach the provider exactly; 6. a provider without native-launch capability fails before detach; @@ -1445,8 +1588,8 @@ Focused tests prove: natural key, contention refuses instead of queueing, a crashed owner leaves a recovery tombstone, and a host with no coordinator refuses before contacting an agent; -18. a build binding remains immutable audit evidence shared exactly by the V2 - route and its prepared journal record; a changed live digest, changed or +18. a build binding remains immutable audit evidence shared exactly by its V2 + or V3 route and launch journal; a changed live digest, changed or absent version and moved executable are accepted after fresh capability admission, while malformed evidence or disagreement between the two durable accounts refuses rather than being read past or repaired; @@ -1483,18 +1626,21 @@ Focused tests prove: requested capability, required read-only CLI shape, operating system and architecture; neither an Agent name nor version string admits it. A newer canonical version, omitted version and additive unrelated help are accepted - when that shape and host envelope match, including for an existing V2 route + when that shape and host envelope match, including for an existing V2 or V3 + route carrying a different digest or version, while a missing or ambiguous required member, another adapter protocol, or an unproved host is refused; 26. the metadata probe runs against the exact resolved executable and carries no terminal, stdin, session identity, instructions, credential or provider - state. It completes before identity allocation, route publication, - private-file creation, native child start, attachment ensure and incomplete - replay; its raw output reaches no route, journal, result or diagnostic, and + state. It completes before identity allocation, route publication, ACP + ensure or creation, materialization, private-file creation, native child + start, attachment ensure and incomplete replay; its raw output reaches no + route, journal, result or diagnostic, and completed replay runs no probe; -27. native launch and client-native attachment are admitted independently. A - launch-only observation cannot attach, an attachment-only observation - cannot launch, and a bound route whose exact identity is absent remains +27. native launch, client-native attachment and provider-native continuation are + admitted independently. A launch-only observation cannot continue through + ACP, an attachment- or continuation-only observation cannot launch, and a + bound route whose exact identity is absent remains authoritative and is never substituted. A settled exact-resume refusal acknowledges quiescence after cleanup, while a planted unproved teardown leaves the recovery tombstone active; and @@ -1507,7 +1653,29 @@ Focused tests prove: close without a turn or substitution when the provider reports an absent or different identity. Prepared-only and detached incomplete replays prove create-versus-resume remains phase-driven across the same upgrade, while - completed replay probes nothing. + completed replay probes nothing; and +29. V3 is parsed strictly and only as `codex-provider-returned.v1`; an existing + #755 record remains readable, its route and launch journal binding must agree + exactly, and a compatible Codex release continues the exact retained + provider identity without rewriting either. A V1/V2-only reader refuses V3 + as unknown rather than treating the key as absent; and +30. Codex materialization spends exactly one fixed tool-free turn for a new + conversation, retains the prompt before `materialized`, refuses every + unsafe outcome, and does not repeat the turn after cancellation or replay. + Prompt-retained replay reconstructs the phase, prepared-only uncertainty + refuses, detached replay resumes the retained identity, and completed replay + performs no observation, runtime or turn; and +31. Codex product and `resume` positional declarations are structurally parsed + from the frozen real help fixture. The first positional is the optional + `[SESSION_ID]` whose declared value accepts a UUID, before optional + `[PROMPT]`; mutations of product identity, order, identity value semantics, + protocol, capability and host each refuse, while a compatible version change + and unrelated additive help do not; and +32. one controlled mixed terminal grid launches Claude and Codex concurrently + through distinct pane endpoints, reports readiness only at each native child + spawn, never enters the root foreground launcher, and on close settles the + materialization/ACP handles, native children, pane workers and session + ownership before the grid completes. The authored half of this is one executable Markdown document, `packages/test-agent/src/NativeSessionLaunch.test.md`, run whole. It authors the @@ -1560,6 +1728,14 @@ documents own the schemas, the assertions and everything an operator reads, and no verdict may carry terminal output, argv, environment, prepared text, the history marker or a private path. +Codex's frozen proofs are `packages/acp/src/CodexNativeLaunch.test.md` and +`packages/acp/src/CodexZeroNativeTurnExit.test.md`. They establish the native +handoff, fixed materialization turn, exact App Server thread assertion, native +resume and later ACP rejoin against `codex-cli 0.153.2` on macOS arm64 with the +pinned adapter snapshot. This integration ports and preserves that evidence; its +focused test battery uses captured fixtures and controlled providers and does +not rerun a real agent session or the terminal-grid demo. + A proof also removes what it created, and that is the harness's own act rather than anything the product does. Production keeps the construction route and the retained phases — a launch that deleted them would break the continuity claim 4 @@ -1587,8 +1763,10 @@ and the immutable audit binding it produces on first publication; proof-scoped capability admission over the stable adapter protocol, independently requested capability, live observed CLI shape and live host platform on every continuation; compatible cross-release native resume and ACP attachment to a -bound client-native session under its exact retained identity, through runtime -partitions keyed by agent command and the live executable observation; +bound client-native session, plus compatible cross-release Codex native resume +and provider-native ACP continuation, under each route's exact retained +identity, through runtime partitions keyed by agent command and the live +executable observation; an inherited root- or pane-terminal interactive child with cancellation and bounded reaping; composition with the terminal grid's independent pane leases without changing session ownership or durable launch identity; @@ -1598,14 +1776,13 @@ model. The following capabilities remain outside V1 and fail closed rather than degrading: -- **Only the Claude client-native protocol has admitted profiles**, and - separately for each capability. Its applicable profiles recognize the - required CLI help shape on macOS arm64, where the claims under - *Provider-native identity* passed at Claude Code 2.1.241. A compatible newer +- **Only the Claude client-native and Codex provider-returned protocols have + admitted profiles**, separately for each capability. Their applicable + profiles recognize the required CLI help shape on macOS arm64, where the + claims under *Provider-native identity* passed at Claude Code 2.1.241 and + `codex-cli` 0.153.2 with the pinned ACP bridge snapshot. A compatible newer version remains admitted; a missing required shape or different platform does - not. `codex` has a command shape and contract tests and is not launch-capable, - because nothing has proven its provider-returned claims against an installed - Codex. A launch naming an unadvertised protocol or profile is refused with + not. A launch naming an unadvertised protocol or profile is refused with `unsupported-capability` before anything of the session moves, and so is an attachment whose own capability was not proved. - **`Agent.AddDir` is unbuilt**, so a launch declares no additional roots. The @@ -1617,11 +1794,12 @@ degrading: and `Session.Launch` expose no model prop or launch option. A provider may report the current model as observational evidence, but native launch neither selects nor changes it. -- **Protocol migration is unbuilt.** A V2 route fixes the stable - `claude-client-native.v1` protocol through its provider, agent and launcher - contract. A compatible executable release may continue it, but another - protocol cannot reinterpret, rewrite or adopt it. Supporting that transition - requires a route contract which names the new protocol. +- **Protocol migration is unbuilt.** V2 fixes `claude-client-native.v1` through + its exact provider, agent and launcher contract, and V3 fixes + `codex-provider-returned.v1` through its exact schema, route, provider and + agent contract. A compatible executable release may continue either, but + another protocol cannot reinterpret, rewrite or adopt one. Supporting that + transition requires a route contract which names the new protocol. - **A legacy unbound client-native session never attaches.** It was constructed before XMD recorded which build accepted its identity, so nothing available now can show this run is talking to that build. It keeps native resume and @@ -1634,15 +1812,17 @@ stays unsupported rather than weakening `Session.Launch` semantics. Native UI event mirroring, XMD-rendered interactive chat, simultaneous root foreground sessions outside a terminal grid, automatic nested `AGENTS.md` -discovery, bootstrap model turns, and workflow role scheduling are outside this -contract. +discovery, authored bootstrap model turns, and workflow role scheduling are +outside this contract. ## Structural checklist Implementation review checks these frozen invariants: 1. Only explicitly rendered `Session.Launch` content crosses as instructions. -2. Launch performs no model turn. +2. Launch performs no model turn beyond a protocol-declared materialization + turn, which runs at most once, only for a provider-returned conversation the + launch just created, with exact fixed bytes and no tool authority. 3. Provider-native identity is asserted, never inferred. 4. ACP and the native UI never concurrently own one session. 5. XMD remains the supervising parent and the document stays suspended. @@ -1673,8 +1853,8 @@ Implementation review checks these frozen invariants: reaches neither argv nor environment. 17. Private setup and child-creation failures are normalized before they cross a public or durable boundary. -18. A new client-native session retains immutable evidence for the executable - observed before its identity is published. The V2 route and prepared journal +18. A new bound session retains immutable evidence for the executable observed + before its route is published. The V2 or V3 route and prepared journal agree on that evidence and never rewrite it, while every later create, resume, attachment and incomplete replay independently admits the live executable without requiring its digest or version to equal the evidence. @@ -1683,8 +1863,9 @@ Implementation review checks these frozen invariants: and no partition that outlives its last handle. 20. Attachment supplies the route's exact identity as the resume identity, and the provider's canonical assertion must equal it before the first turn. -21. Native-launch advertisement and client-native attachment advertisement are - separate trusted-host choices, and neither is inferred from the other. +21. Native-launch, client-native attachment and provider-native-continuation + advertisement are separate trusted-host choices, and none is inferred from + another. 22. A released V1 route and a completed legacy journal remain readable, and neither authorizes ACP attachment or incomplete live replay. 23. A failed acquisition retains no partition and no live path; a partition is @@ -1725,14 +1906,29 @@ Implementation review checks these frozen invariants: 31. A real-CLI proof admits only its adapter protocol, independently requested capability, required observable CLI shape, operating system and architecture. Version output is optional evidence, not an allow-list key. A - client-allocated profile mismatch is refused after read-only observation but - before allocation or any provider or session-state mutation; its + bound profile mismatch is refused after read-only observation but before + allocation, route publication or any provider/session-state mutation; its identity-free launch refusal is retained. The profile is checked again before bound resume, attachment and incomplete replay, and a compatible live release continues the exact retained identity without allocating, republishing or rewriting durable evidence. An exact absent identity remains authoritative and unavailable; neither that refusal nor an unproved profile - creates a replacement conversation or a materialization turn. + creates a replacement conversation or an undeclared materialization turn. +32. V3 has one compiled, non-replaceable `codex-provider-returned.v1` + interpretation. Its original binding remains exact route/journal audit + evidence, while every new construction, native resume, provider-native + continuation and incomplete replay independently admits the current Codex + executable before observation-dependent provider work; completed replay + observes nothing. +33. `codex-materialization.v1` is retained before it can be spent and is never + repeated. A retained prompt outcome reconstructs the launch phase, an + uncertain prepared-only replay refuses, and no failure, cancellation or + concurrent winner can allocate, substitute or rewrite the provider identity. +34. Codex launch notification and native execution use the same root or pane + terminal capability. Terminal-domain code remains in + `@executablemd/terminal` and provider-specific tmux behavior remains in + `@executablemd/terminal-tmux`; the integration restores no deleted core, + runtime or CLI terminal path. Item 12 is the 2026-08-20 architecture amendment. ACPX fixes `systemPrompt` at session creation, while native turns are not authoritative in its cached From 2ba2da8c626623686858d6cd32984b1871952a74 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 7 Sep 2026 04:54:03 -0400 Subject: [PATCH 13/22] =?UTF-8?q?=F0=9F=90=9B=20Align=20the=20extracted=20?= =?UTF-8?q?terminal=20package's=20@effectionx/node=20pin=20(#717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages/terminal` is created by this stack's extraction commit, which was authored while the repository pinned `@effectionx/node` 0.2.4. Main has since moved to 0.2.5 (#748). A file a commit creates conflicts with nothing, so the rebase carried the stale pin forward beside a root that now asks for 0.2.5, and `deno task setup` refused: the frozen lockfile carries no `npm:@effectionx/node@0.2.4` specifier for a workspace member to resolve through. The pin now matches the one every other package and the root already use. The 0.2.4 entries that remain in `deno.lock` are `@effectionx/process@0.8.1`'s own transitive dependency, and they are present on main unchanged. No other dependency version moves. --- deno.lock | 2 +- packages/terminal/package.json | 2 +- pnpm-lock.yaml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/deno.lock b/deno.lock index f04adcbe3..d1d801112 100644 --- a/deno.lock +++ b/deno.lock @@ -4134,7 +4134,7 @@ "dependencies": [ "npm:@effectionx/context-api@0.6.0", "npm:@effectionx/fs@0.3.0", - "npm:@effectionx/node@0.2.4", + "npm:@effectionx/node@0.2.5", "npm:@effectionx/process@0.8.1", "npm:effection@4.1.0" ] diff --git a/packages/terminal/package.json b/packages/terminal/package.json index 568dd92ab..50fd6b489 100644 --- a/packages/terminal/package.json +++ b/packages/terminal/package.json @@ -13,7 +13,7 @@ "dependencies": { "@effectionx/context-api": "0.6.0", "@effectionx/fs": "0.3.0", - "@effectionx/node": "0.2.4", + "@effectionx/node": "0.2.5", "@effectionx/process": "0.8.1", "@executablemd/durable-streams": "workspace:*", "effection": "4.1.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2987519c7..f6b70e210 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -336,8 +336,8 @@ importers: specifier: 0.3.0 version: 0.3.0(effection@4.1.0) '@effectionx/node': - specifier: 0.2.4 - version: 0.2.4(effection@4.1.0) + specifier: 0.2.5 + version: 0.2.5(effection@4.1.0) '@effectionx/process': specifier: 0.8.1 version: 0.8.1(effection@4.1.0) From 1cc1dad0c83c050a0197a841942b08a5f86416b0 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 7 Sep 2026 05:02:11 -0400 Subject: [PATCH 14/22] =?UTF-8?q?=F0=9F=90=9B=20Reconcile=20the=20terminal?= =?UTF-8?q?=20stack=20with=20main's=20renamed=20surfaces=20(#717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three call sites this stack added kept compiling against surfaces main has since changed. Each pair of edits touched different lines, so the rebase merged both sides without a conflict and the mismatch only surfaced under `deno check`. `xmd plan` used to settle a whole agent stack through a local three-argument helper. Main replaced that with the authorship-only resolver, which takes two arguments because the command starts no program, so the terminal-grid installer this stack threaded through no longer has a parameter to occupy. `xmd run` is unchanged and still installs it: the installer reaches `installRunAgentStack` through `resolveAgentStack`, which is the one path with a terminal to give away. The syntax suite's two terminal-grid rows still named `syntaxCatalog` and the local `parseCatalog`, which main renamed to `syntaxSymbols` and `parseSymbols`. Only the names move; both rows assert the same origins, authored forms, descriptions and reserved-name exclusions they always did. The launcher suite imported `reap` from the provider-neutral contract after this stack split the module in two. `reap` ends a POSIX child, so it comes from `posix-launcher.ts` beside `installForegroundLauncher`, and the contract import keeps only what a consumer that never reaches a process needs. --- packages/cli/src/cli.ts | 1 - packages/cli/tests/syntax-cli.test.ts | 4 ++-- packages/terminal/tests/native-launcher.test.ts | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 9ad13d20a..8f274409c 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -2511,7 +2511,6 @@ function* dispatch( const authorship = yield* resolveAuthorshipStack( { agentProvider: config.agentProvider, defaultAgent: config.defaultAgent }, sessions, - installTerminalGrid, ); if (!authorship.ok) { console.error(authorship.error.message); diff --git a/packages/cli/tests/syntax-cli.test.ts b/packages/cli/tests/syntax-cli.test.ts index bf719c5ec..19d7afbcb 100644 --- a/packages/cli/tests/syntax-cli.test.ts +++ b/packages/cli/tests/syntax-cli.test.ts @@ -305,7 +305,7 @@ describe("Tier SX — the run profile the command describes", () => { throw new Error(`describing the syntax ran ${JSON.stringify(options.command)}`); }, }); - return yield* syntaxCatalog([]); + return yield* syntaxSymbols([]); }); const [structural, builtIn] = catalog.categories; @@ -573,7 +573,7 @@ describe("Tier SX — the command line", { sanitizeOps: false, sanitizeResources expect(markdown.stdout).toContain(''); const json = yield* runCli(["syntax", "--json"], { cwd }).expect(); - const structural = parseCatalog(json.stdout).categories[0].entries; + const structural = parseSymbols(json.stdout).categories[0].entries; expect(names(structural)).toContain("Terminal.Grid"); expect(names(structural)).toContain("Terminal"); }); diff --git a/packages/terminal/tests/native-launcher.test.ts b/packages/terminal/tests/native-launcher.test.ts index 76f4584ea..4662e2db0 100644 --- a/packages/terminal/tests/native-launcher.test.ts +++ b/packages/terminal/tests/native-launcher.test.ts @@ -28,10 +28,9 @@ import { nativeLaunch, NativeLauncher, NO_TERMINAL, - reap, reserveTerminal, } from "../src/native-launcher.ts"; -import { installForegroundLauncher } from "../src/posix-launcher.ts"; +import { installForegroundLauncher, reap } from "../src/posix-launcher.ts"; const SENTINEL = "SENTINEL-PREPARED-CONTEXT-4b17"; From 44826a982965c3456710754565a52647e730e051 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 7 Sep 2026 07:34:50 -0400 Subject: [PATCH 15/22] =?UTF-8?q?=F0=9F=90=9B=20Bind=20the=20terminal=20st?= =?UTF-8?q?ack's=20event=20listeners=20to=20their=20owners=20(#717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase reintroduced fourteen listeners the ownership rule refuses: a raw `once()` in the POSIX launcher's spawned callback, subscriptions whose `ensure()` was yielded after them — itself a suspension an owner can be halted in — removals hidden behind a helper the pairing cannot be read from, a self-removing socket handler a cancelled scope never reaches, and three process signals registered under a computed name. Every one of them now names its receiver, event and handler on both sides, with the removal established before the subscription or held by a `finally` around it. The launcher keeps its inline exit/error race, its complete reap and a spawned callback that fires exactly once: the handler comes off when the start arrives and again on every way out, so a cancelled or failed launch publishes no readiness at all. `usePaneChannels` remembers each accepted socket with the handler it carries and detaches every one of them at teardown, including a socket that closed on its own. FL10 reads the child's own listener counts after delivery, after a startup failure and after cancellation, then replays `spawn` on it: restore the raw `once()` and the row fails. TW14 already held the tmux half, and fails if the new channel detach is removed. Terminal-grid TG3 expects the catalog version main's Syntax v2 migration publishes. Co-Authored-By: Claude --- packages/cli/src/grid-host.ts | 8 +- packages/core/tests/syntax-catalog.test.ts | 2 +- packages/terminal-tmux/src/attach-client.ts | 15 ++-- packages/terminal-tmux/src/pane-channel.ts | 28 ++++-- packages/terminal-tmux/src/pane-child.ts | 16 ++-- packages/terminal-tmux/src/pane-protocol.ts | 13 ++- packages/terminal-tmux/src/pane-worker.ts | 19 ++-- .../tests/terminal-grid-tmux.test.ts | 72 +++++++++------ packages/terminal/src/posix-launcher.ts | 62 ++++++++----- .../terminal/tests/native-launcher.test.ts | 87 +++++++++++++++++++ 10 files changed, 238 insertions(+), 84 deletions(-) diff --git a/packages/cli/src/grid-host.ts b/packages/cli/src/grid-host.ts index 591d773b3..2842bf8a3 100644 --- a/packages/cli/src/grid-host.ts +++ b/packages/cli/src/grid-host.ts @@ -167,12 +167,14 @@ export function useHangup(): Operation> { return resource>(function* (provide) { const hung = withResolvers(); const onHangup = (): void => hung.resolve(); - process.on("SIGHUP", onHangup); + // Removed with the run that installed it. A listener that outlived its + // grid would answer for a terminal the next one is using — and the removal + // is established before the subscription, because entering an ensure() is + // itself a suspension. yield* ensure(() => { - // Removed with the run that installed it. A listener that outlived its - // grid would answer for a terminal the next one is using. process.off("SIGHUP", onHangup); }); + process.on("SIGHUP", onHangup); yield* provide(hung.operation); }); } diff --git a/packages/core/tests/syntax-catalog.test.ts b/packages/core/tests/syntax-catalog.test.ts index 83baec843..f840b6356 100644 --- a/packages/core/tests/syntax-catalog.test.ts +++ b/packages/core/tests/syntax-catalog.test.ts @@ -352,7 +352,7 @@ describe("Tier SY: structural vocabulary", () => { const catalog = yield* catalogFor({}, []); const entries = structural(catalog); - expect(catalog.version).toBe(1); + expect(catalog.version).toBe(2); expect(find(entries, "Terminal.Grid")).toEqual({ kind: "structural", name: "Terminal.Grid", diff --git a/packages/terminal-tmux/src/attach-client.ts b/packages/terminal-tmux/src/attach-client.ts index 33eeda07d..b0717be28 100644 --- a/packages/terminal-tmux/src/attach-client.ts +++ b/packages/terminal-tmux/src/attach-client.ts @@ -142,8 +142,7 @@ export function useAttachClient(options: { // Named, and removed by this scope. `exit` stays through the wait that // establishes the client is gone, which is exactly why it is removed with // the resource rather than after one delivery. - // One of the two arrives, and whichever does takes both off. `exit` stays: - // establishing this client is gone is what waits on it. + // One of the startup pair arrives, and whichever does takes both off. const settleStartup = (): void => { child?.off("spawn", onSpawn); child?.off("error", onError); @@ -162,13 +161,17 @@ export function useAttachClient(options: { gone = true; exited.resolve(); }; - child.on("spawn", onSpawn); - child.on("error", onError); - child.on("exit", onExit); + // Established before the subscriptions and naming every one of them: + // entering an ensure() is itself a suspension, so a scope halted while it + // registers unwinds with nothing on it at all. yield* ensure(() => { - settleStartup(); + child?.off("spawn", onSpawn); + child?.off("error", onError); child?.off("exit", onExit); }); + child.on("spawn", onSpawn); + child.on("error", onError); + child.on("exit", onExit); // The pid, or whatever arrived instead of a start. const pid = yield* race([started.operation, failed.operation]); diff --git a/packages/terminal-tmux/src/pane-channel.ts b/packages/terminal-tmux/src/pane-channel.ts index 907781614..50f1457c9 100644 --- a/packages/terminal-tmux/src/pane-channel.ts +++ b/packages/terminal-tmux/src/pane-channel.ts @@ -126,6 +126,8 @@ export function usePaneChannels( const slots = new Map(); const servers: Server[] = []; const live = new Set(); + /** Every socket ever accepted, with the `close` listener this scope put on it. */ + const watching = new Map void>(); const refusals: string[] = []; const arrivals = createSignal<{ ordinal: number; socket: Socket }, never>(); /** Closures that have actually happened, by their own events. */ @@ -209,6 +211,14 @@ export function usePaneChannels( } yield* ensure(function* () { + // One accepted connection cannot be named at teardown, so each is + // remembered with the handler it carries and removed from the socket it + // was recorded against. Every socket ever accepted, not just the ones + // still open: a socket that closed on its own is off `live` and still + // carries what this scope put on it. + for (const [socket, onSocketClose] of watching) { + socket.off("close", onSocketClose); + } yield* closeAll(); }); @@ -225,24 +235,28 @@ export function usePaneChannels( // Named, every one of them. `createServer(cb)` and `listen(cb)` both // register anonymous listeners that nothing can take off again. const server = net.createServer(); + servers.push(server); + options.onServer?.(server); + closable++; const onConnection = (socket: Socket): void => { - live.add(socket); - closable++; const onSocketClose = (): void => { + // A socket that closed is nobody's to close again. live.delete(socket); - socket.off("close", onSocketClose); }; + live.add(socket); + watching.set(socket, onSocketClose); + closable++; socket.on("close", onSocketClose); options.onSocket?.(socket); arrivals.send({ ordinal, socket }); }; - server.on("connection", onConnection); - servers.push(server); - options.onServer?.(server); - closable++; + // Established before the subscription, because entering an ensure() is + // itself a suspension: a scope halted while it registers unwinds with + // nothing on it at all. yield* ensure(() => { server.off("connection", onConnection); }); + server.on("connection", onConnection); const listening = withResolvers(); const onListening = (): void => listening.resolve(); diff --git a/packages/terminal-tmux/src/pane-child.ts b/packages/terminal-tmux/src/pane-child.ts index d96b3db70..f8ce9b47d 100644 --- a/packages/terminal-tmux/src/pane-child.ts +++ b/packages/terminal-tmux/src/pane-child.ts @@ -168,15 +168,19 @@ export function usePaneChild( exited.resolve(settled); }; observe?.(child); - child.on("spawn", onSpawn); - child.on("error", onError); - child.on("exit", onExit); + // Established before the subscriptions and naming every one of them: + // entering an ensure() is itself a suspension, so a scope halted while it + // registers unwinds with nothing on it at all. The startup pair is usually + // gone by then; `exit` is this scope's until the end, because a settlement + // may still be waiting on it. yield* ensure(() => { - // The startup pair is usually gone already; `exit` is this scope's until - // the end, because a settlement may still be waiting on it. - settleStartup(); + child?.off("spawn", onSpawn); + child?.off("error", onError); child?.off("exit", onExit); }); + child.on("spawn", onSpawn); + child.on("error", onError); + child.on("exit", onExit); yield* provide({ started: started.operation, exited: exited.operation, settle }); }); diff --git a/packages/terminal-tmux/src/pane-protocol.ts b/packages/terminal-tmux/src/pane-protocol.ts index 61ece9ffc..8ac9c3206 100644 --- a/packages/terminal-tmux/src/pane-protocol.ts +++ b/packages/terminal-tmux/src/pane-protocol.ts @@ -295,12 +295,19 @@ export function readFrames( socket.destroy(); } + // Still the resource's, for the paths that terminate nothing: a cancelled + // scope, and a socket that simply never says anything. Established before + // the subscriptions, because entering an ensure() is itself a suspension, + // and naming each pair at the teardown that makes it good rather than + // behind `detach`. + yield* ensure(() => { + socket.off("data", onData); + socket.off("close", onClose); + socket.off("error", onError); + }); socket.on("data", onData); socket.on("close", onClose); socket.on("error", onError); - // Still the resource's, for the paths that terminate nothing: a cancelled - // scope, and a socket that simply never says anything. - yield* ensure(detach); yield* provide(queue); }); diff --git a/packages/terminal-tmux/src/pane-worker.ts b/packages/terminal-tmux/src/pane-worker.ts index b4d7d283e..0d7910f34 100644 --- a/packages/terminal-tmux/src/pane-worker.ts +++ b/packages/terminal-tmux/src/pane-worker.ts @@ -154,18 +154,19 @@ interface Live { */ export function useForegroundSignals(): Operation { return resource(function* (provide) { - const foreground: NodeJS.Signals[] = ["SIGINT", "SIGQUIT", "SIGTSTP"]; const ignore = (): void => {}; - for (const name of foreground) { - process.on(name, ignore); - } + // Installed and removed by the scope that runs this worker, so a worker + // that has finished stops answering for a pane it no longer owns. Each + // signal is named on both sides, and the removals are established before + // the first registration: entering an ensure() is itself a suspension. yield* ensure(() => { - // Installed and removed by the scope that runs this worker, so a worker - // that has finished stops answering for a pane it no longer owns. - for (const name of foreground) { - process.off(name, ignore); - } + process.off("SIGINT", ignore); + process.off("SIGQUIT", ignore); + process.off("SIGTSTP", ignore); }); + process.on("SIGINT", ignore); + process.on("SIGQUIT", ignore); + process.on("SIGTSTP", ignore); yield* provide(); }); } diff --git a/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts b/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts index 3b8341878..ffaf47034 100644 --- a/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts +++ b/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts @@ -30,6 +30,7 @@ import { withResolvers, } from "effection"; import type { Operation } from "effection"; +import { once } from "@effectionx/node/events"; import { spawn as spawnChild } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import net from "node:net"; @@ -344,15 +345,9 @@ function useWorker(directory: string, ordinal: number): Operation }); yield* ensure(function* () { child.kill("SIGKILL"); - yield* until( - new Promise((resolve) => { - if (child.exitCode !== null || child.signalCode !== null) { - resolve(); - return; - } - child.once("exit", () => resolve()); - }), - ); + if (child.exitCode === null && child.signalCode === null) { + yield* once(child, "exit"); + } }); yield* provide(child); }); @@ -376,14 +371,25 @@ function untilFrame(link: PaneLink, type: FromWorker["type"]): Operation { return resource(function* (provide) { - const socket = net.createConnection(paneSocketPath(directory, ordinal)); - const connected = withResolvers(); - socket.once("connect", () => connected.resolve()); - socket.once("error", (error: Error) => connected.reject(error)); - yield* connected.operation; + let opened: net.Socket | undefined; + // Registered before the connection exists, so a halt between opening a + // socket and registering its cleanup cannot leave one open. yield* ensure(() => { - socket.destroy(); + opened?.destroy(); }); + const socket = net.createConnection(paneSocketPath(directory, ordinal)); + opened = socket; + // Both waits are interpreted inline, in the same synchronous run as the + // connection, so whichever the socket reports is already being waited for. + yield* race([ + (function* (): Operation { + yield* once(socket, "connect"); + })(), + (function* (): Operation { + const [error] = yield* once<[Error]>(socket, "error"); + throw error; + })(), + ]); yield* provide(socket); }); } @@ -391,13 +397,14 @@ function useImpostor(directory: string, ordinal: number): Operation /** Settle when a socket closes, or say it did not within the grace given. */ function closedWithin(socket: net.Socket, limitMs: number): Operation { return (function* (): Operation { - const closed = withResolvers(); if (socket.destroyed) { return true; } - socket.once("close", () => closed.resolve(true)); return yield* race([ - closed.operation, + (function* (): Operation { + yield* once(socket, "close"); + return true; + })(), (function* (): Operation { yield* sleep(limitMs); return false; @@ -2142,38 +2149,51 @@ function useTeardown(options: { // this row freezes is per-pane rather than per-event. let panes = 0; const belongs = new Map(); - const detachments: (() => void)[] = []; + /** Each socket this row watched, with the `close` listener it put on it. */ + const socketClosings = new Map void>(); + /** The same, for the servers. */ + const serverClosings = new Map void>(); + /** The same, for the servers whose connections this row attributes. */ + const connections = new Map void>(); const noteSocket = (socket: Socket, what: () => string): void => { handles.push(socket); const onClose = (): void => { log.push(what()); }; + socketClosings.set(socket, onClose); socket.on("close", onClose); - detachments.push(() => socket.off("close", onClose)); }; const noteServer = (server: Server, what: () => string): void => { handles.push(server); const onClose = (): void => { log.push(what()); }; + serverClosings.set(server, onClose); server.on("close", onClose); - detachments.push(() => server.off("close", onClose)); }; + // Established before a single handle exists. A listener this row installed + // is this row's, and a cancelled row never sees the closes it was waiting + // for, so each pair is removed from the emitter it was recorded against. yield* ensure(() => { - // This row's own listeners, off the emitters this row put them on. - for (const detach of detachments) { - detach(); + for (const [socket, onClose] of socketClosings) { + socket.off("close", onClose); + } + for (const [server, onClose] of serverClosings) { + server.off("close", onClose); + } + for (const [server, onConnection] of connections) { + server.off("connection", onConnection); } }); const channels = yield* usePaneChannels(options.workers.length, { onSocket: (socket) => noteSocket(socket, () => `socket-closed:${belongs.get(socket) ?? -1}`), - onServer: (server) => { + onServer: (server: Server) => { const ordinal = panes++; const onConnection = (socket: Socket): void => { belongs.set(socket, ordinal); }; + connections.set(server, onConnection); server.on("connection", onConnection); - detachments.push(() => server.off("connection", onConnection)); noteServer(server, () => `server-closed:${ordinal}`); }, }); diff --git a/packages/terminal/src/posix-launcher.ts b/packages/terminal/src/posix-launcher.ts index 75d6029ee..de3523d34 100644 --- a/packages/terminal/src/posix-launcher.ts +++ b/packages/terminal/src/posix-launcher.ts @@ -45,6 +45,8 @@ export interface ForegroundLauncherOptions { isTerminal?: () => boolean; /** Everything this host has still to show the reader. */ drain?: () => Operation; + /** Handed each process, so a suite can ask the emitter what it still holds. */ + observe?: (child: ChildProcess) => void; } /** @@ -90,7 +92,7 @@ export function* installForegroundLauncher( yield* drainStream(process.stderr); }, *launch([request, spawned]) { - return yield* runForeground(request, spawned); + return yield* runForeground(request, spawned, options.observe); }, }, { at: "min" }, @@ -125,6 +127,7 @@ function drainStream(stream: DrainableStream): Operation { function runForeground( request: NativeLaunchRequest, spawned: () => void, + observe?: (child: ChildProcess) => void, ): Operation { return scoped(function* (): Operation { const [command, ...args] = request.command; @@ -150,32 +153,45 @@ function runForeground( stdio: "inherit", }); child = started; + observe?.(started); // The runtime's own start event, and the only thing reported as one. A // spawn that fails emits `error` instead, so a child that never ran never - // reports having started. - started.once("spawn", () => spawned()); + // reports having started. Off on arrival and off again in the `finally`, + // so one start is reported exactly once and a launch that was cancelled or + // never started reports none at all. + const onSpawn = (): void => { + started.off("spawn", onSpawn); + spawned(); + }; - // Raced inline, in the same synchronous run as the spawn, so both arms are - // attached before the child can report anything — a spawned race attaches - // a turn later. Whichever loses is halted, which is what detaches it. - return yield* race([ - (function* (): Operation { - const [code, signal] = yield* once<[number | null, string | null]>(started, "exit"); - const outcome: NativeLaunchOutcome = {}; - if (code !== null) { - outcome.exitCode = code; - } - if (signal !== null) { - outcome.signal = signal; - } - return outcome; - })(), - (function* (): Operation { - const [error] = yield* once<[Error]>(started, "error"); - throw error; - })(), - ]); + try { + started.on("spawn", onSpawn); + + // Raced inline, in the same synchronous run as the spawn, so both arms + // are attached before the child can report anything — a spawned race + // attaches a turn later. Whichever loses is halted, which is what + // detaches it. + return yield* race([ + (function* (): Operation { + const [code, signal] = yield* once<[number | null, string | null]>(started, "exit"); + const outcome: NativeLaunchOutcome = {}; + if (code !== null) { + outcome.exitCode = code; + } + if (signal !== null) { + outcome.signal = signal; + } + return outcome; + })(), + (function* (): Operation { + const [error] = yield* once<[Error]>(started, "error"); + throw error; + })(), + ]); + } finally { + started.off("spawn", onSpawn); + } }); } diff --git a/packages/terminal/tests/native-launcher.test.ts b/packages/terminal/tests/native-launcher.test.ts index 4662e2db0..41ab2d7c7 100644 --- a/packages/terminal/tests/native-launcher.test.ts +++ b/packages/terminal/tests/native-launcher.test.ts @@ -23,6 +23,7 @@ import * as path from "node:path"; import * as os from "node:os"; import process from "node:process"; import { spawn as spawnChild } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; import { flushOutput, nativeLaunch, @@ -281,6 +282,92 @@ describe("Tier FL — the foreground native launcher", () => { yield* sleep(200); expect(yield* beats(heartbeat)).toBe(before); }); + + /** + * Counted on the child itself, after each launch has ended, which is when the + * removal is supposed to have happened — and then the start event is replayed + * on it, because a handler left attached is one that would report a start for + * a launch nobody is waiting on any more. + */ + it("FL10: a launch leaves nothing on its child, and none of them reports a late start", function* () { + const dir = yield* useTempDir(); + const children: ChildProcess[] = []; + const order: string[] = []; + const listeners = (): number => + children.reduce( + (total, child) => + total + + (["spawn", "error", "exit"] as const).reduce( + (count, name) => count + child.listenerCount(name), + 0, + ), + 0, + ); + const watch = (child: ChildProcess): void => { + children.push(child); + }; + + // Delivery: a child that starts and exits. + const fake = yield* useFake(dir, "claude", { exitCode: 0 }); + yield* scoped(function* () { + yield* installForegroundLauncher({ isTerminal: () => true, observe: watch }); + yield* reserveTerminal(); + yield* NativeLauncher.operations.launch({ command: [fake.command], cwd: dir }, () => + order.push("started"), + ); + }); + expect(children.length).toBe(1); + expect(listeners()).toBe(0); + expect(order).toEqual(["started"]); + + // Startup failure: `error` arrives and `spawn` never will, so the handler + // that would report a start is one only the launch's own end takes off. + children.length = 0; + order.length = 0; + yield* scoped(function* () { + yield* installForegroundLauncher({ isTerminal: () => true, observe: watch }); + yield* reserveTerminal(); + try { + yield* NativeLauncher.operations.launch( + { command: [path.join(dir, "not-a-program")], cwd: dir }, + () => order.push("started"), + ); + } catch { + // That it refuses is FL9's claim; this row reads what it left behind. + } + }); + expect(children.length).toBe(1); + expect(listeners()).toBe(0); + // A child that never ran does not become one that started, however late + // the event arrives. + children[0]?.emit("spawn"); + expect(order).toEqual([]); + + // Cancellation, while the child is live and may not yet have been reported. + children.length = 0; + order.length = 0; + const hanging = yield* useFake(dir, "hangs", { hang: true }); + yield* scoped(function* () { + yield* installForegroundLauncher({ isTerminal: () => true, observe: watch }); + yield* reserveTerminal(); + const running = yield* spawn(function* () { + yield* NativeLauncher.operations.launch({ command: [hanging.command], cwd: dir }, () => + order.push("started"), + ); + }); + // Coordinated by the child existing, never by a duration. + while (children.length === 0) { + yield* sleep(15); + } + yield* running.halt(); + }); + expect(children.length).toBe(1); + expect(listeners()).toBe(0); + // Whatever this launch reported before it was cancelled, it reports no more. + const reported = [...order]; + children[0]?.emit("spawn"); + expect(order).toEqual(reported); + }); }); describe("native launcher — the reaper's own listener", () => { From c7b2fbb3d4b7f502ea5038c0cda667ac0f7079c0 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 7 Sep 2026 09:20:34 -0400 Subject: [PATCH 16/22] feat: certify Codex native sessions on the terminal stack (#755) --- packages/acp/mod.ts | 10 +- packages/acp/src/CodexNativeLaunch.test.md | 601 ++ .../acp/src/CodexZeroNativeTurnExit.test.md | 430 ++ packages/acp/src/native-capability.ts | 10 +- packages/acp/src/native-launch.ts | 187 +- packages/acp/src/provider.ts | 852 ++- packages/acp/src/session-route.ts | 46 +- packages/acp/tests/acceptance.test.ts | 18 +- packages/acp/tests/adapter-protocol.test.ts | 73 + packages/acp/tests/codex-surfaces.test.ts | 1176 ++++ .../fixtures/codex-native-launch-proof.ts | 2346 +++++++ .../fixtures/codex-native-to-acp-prompt.md | 16 + packages/acp/tests/fixtures/codex-surfaces.ts | 693 +++ .../tests/fixtures/fake-codex-app-server.cjs | 25 + .../acp/tests/fixtures/terminal-screen.ts | 1087 ++++ packages/acp/tests/native-capability.test.ts | 292 + packages/acp/tests/native-launch.test.ts | 1375 +++- packages/acp/tests/provider.test.ts | 114 +- packages/acp/tests/session-route.test.ts | 126 +- packages/acp/tests/terminal-screen.test.ts | 706 +++ packages/acp/vendor/adapters/MANIFEST.json | 8 +- packages/acp/vendor/adapters/PROVENANCE.md | 29 +- .../agentclientprotocol-codex-acp-1.6.2.tgz | Bin 208429 -> 208660 bytes .../vendor/adapters/generated/snapshots.ts | 5534 +++++++++-------- packages/cli/src/agent-stack.ts | 3 +- packages/cli/src/session-coordinator.ts | 6 +- packages/cli/src/workflow-agent.ts | 1 + .../tests/agent-session-coordinator.test.ts | 36 +- packages/core/mod.ts | 3 + packages/core/src/agent/checkpoint.ts | 21 + packages/core/src/agent/journal.ts | 42 +- packages/core/src/agent/launch-authority.ts | 101 +- packages/core/src/agent/launch-journal.ts | 408 +- packages/core/src/agent/launch-owner.ts | 22 +- packages/core/src/agent/launch.ts | 144 +- packages/core/src/expand.ts | 7 +- .../core/tests/agent-session-launch.test.ts | 539 +- packages/terminal/mod.ts | 1 + packages/terminal/src/controlled-launcher.ts | 6 + packages/terminal/src/native-launcher.ts | 24 + packages/terminal/src/pane-launcher.ts | 6 + packages/terminal/src/posix-launcher.ts | 6 + packages/test-agent/src/provider.ts | 1 + .../tests/terminal-grid-native-launch.test.ts | 304 +- scripts/tests/adapter-vendor.test.ts | 18 +- specs/acp-client-spec.md | 2 +- 46 files changed, 14347 insertions(+), 3108 deletions(-) create mode 100644 packages/acp/src/CodexNativeLaunch.test.md create mode 100644 packages/acp/src/CodexZeroNativeTurnExit.test.md create mode 100644 packages/acp/tests/codex-surfaces.test.ts create mode 100644 packages/acp/tests/fixtures/codex-native-launch-proof.ts create mode 100644 packages/acp/tests/fixtures/codex-native-to-acp-prompt.md create mode 100644 packages/acp/tests/fixtures/codex-surfaces.ts create mode 100644 packages/acp/tests/fixtures/terminal-screen.ts create mode 100644 packages/acp/tests/native-capability.test.ts create mode 100644 packages/acp/tests/terminal-screen.test.ts diff --git a/packages/acp/mod.ts b/packages/acp/mod.ts index 24dd7aea8..f2763d2ca 100644 --- a/packages/acp/mod.ts +++ b/packages/acp/mod.ts @@ -34,19 +34,22 @@ export { DEFAULT_AGENT_NAME } from "./src/acpx-runtime.ts"; /** * Native session launch: the adapters whose resume command shape this package - * knows, and the two separate sets it is willing to use them for — handing a - * session to a native UI, and attaching ACP to one a native process made. + * knows, and the separate capabilities each host may admit. */ export { ADVERTISED_CLIENT_NATIVE_ATTACHMENT, ADVERTISED_NATIVE_LAUNCH, + ADVERTISED_PROVIDER_NATIVE_CONTINUATION, knownNativeAdapters, nativeAdapterFor, nativeCapabilityPolicy, } from "./src/native-launch.ts"; -export { allocatesIdentity } from "./src/native-launch.ts"; +export { allocatesIdentity, bindsBuild } from "./src/native-launch.ts"; export type { ClientAllocatedAdapter, + BoundProviderReturnedAdapter, + BuildBoundAdapter, + MaterializationContract, NativeAdapter, NativeBinding, NativeCapabilityProbe, @@ -129,4 +132,5 @@ export type { AgentSessionRouteStore, AgentSessionRouteV1, AgentSessionRouteV2, + AgentSessionRouteV3, } from "./src/session-route.ts"; diff --git a/packages/acp/src/CodexNativeLaunch.test.md b/packages/acp/src/CodexNativeLaunch.test.md new file mode 100644 index 000000000..fee0096e7 --- /dev/null +++ b/packages/acp/src/CodexNativeLaunch.test.md @@ -0,0 +1,601 @@ +# A real Codex session, prepared, materialized and then rejoined + +`xmd run AGENTS.md#Implementor --default-agent codex` is the whole product for +Codex: it prepares one Codex conversation from this repository's own Implementor +contract and hands you Codex's own interactive UI for that exact conversation. +This document runs that command — the literal one, through the built binary — +and asks the two questions that decide whether the conversation is real. + +**Did the prepared contract govern the first thing you say?** The first native +turn asks for the opening sentence of the role contract without ever sending +that sentence. A conversation that did not receive the prepared layer cannot +produce it, and neither can an agreeable model, because nothing in the question +says what the sentence is. + +**Is what you said natively part of the same conversation?** That first native +turn also plants a one-time marker. A second, independent invocation then +rejoins the same conversation over ACP — from an authored document that has +never seen the marker — and asks for it. Recovering it is what makes the native +UI and the ACP session one conversation rather than two wearing the same name. + +Getting there costs one turn nobody asked for. Codex CLI 0.153.2 does not write +a conversation anywhere `codex resume` can find it until a turn has completed in +it, so XMD spends exactly one of its own first: `codex-materialization.v1`, +whose only job is to make the conversation openable. That the operator can then +leave without ever speaking — and come back without buying a second one — is the +subject of `CodexZeroNativeTurnExit.test.md`, so that question can be corrected +without respending the three turns this document is authorized to spend. + +## What this costs, and what it touches + +Three real model turns against the operator's own Codex credentials: XMD's own +materialization turn, one native turn that answers from the prepared layer and +plants the marker, and one ACP turn that rejoins and recalls it. Nothing else is +sent. The materialization turn's bytes are fixed by the product, are shown to +the operator before they are spent, and carry no path, identity, environment or +authored content. + +It is opt-in twice over. Without `XMD_CODEX_NATIVE_PROOF=1` the fixture refuses +before starting any Codex process; without a separate +`XMD_CODEX_MODEL_TURNS_AUTHORIZED=3` it refuses before spending a turn. It also +refuses on a machine outside the frozen compatibility tuple below, because the +finding this feature rests on is a fact about one Codex build. + +Everything sent besides those three turns is terminal control: Codex's own +directory trust dialog is answered at most once with the choice it pre-selects, +and the exit is two Ctrl-C bytes. The command runs with an operator's +environment rather than this process's, so anything an enclosing agent session +exported is dropped. + +The command runs in a fresh temporary directory holding a byte-for-byte copy of +this repository's own `AGENTS.md` and `.agents/implementor.md`, so the literal +production target resolves without creating Codex state for the repository. +Afterwards the conversation is removed through Codex's own +`codex delete --force `, naming the exact identity this run created. +`CODEX_HOME` is left alone — relocating it de-authenticates Codex — and nothing +beneath Codex's configuration, history or rollout files is ever opened. + +Run it with: + +```sh +XMD_CODEX_NATIVE_PROOF=1 XMD_CODEX_MODEL_TURNS_AUTHORIZED=3 \ + deno task xmd test packages/acp/src/CodexNativeLaunch.test.md --raw +``` + +## What a verdict may say + +The schema is the disclosure boundary, not a convenience. It accepts versions +and digests of public executables, the session identity and who chose it, +booleans, counts, phase and failure classes, usage field *names*, and cleanup +outcomes. The role contract's text, the history marker, the materialization +reply, raw terminal output, argument vectors, the environment and private paths +have nowhere to go in it, so a fixture that tried to report one would fail this +document. + + + +```json +{ + "type": "object", + "additionalProperties": false, + "required": [ + "mode", "verdict", "authorized", "turnsAuthorized", "authorizedTurnBudget", + "ran", "refusal", "detail", + "codexVersion", "codexDigest", "platform", "architecture", + "adapterPackage", "adapterVersion", "adapterDigest", "compatibilityTupleFrozen", + "target", "projectCopyVerified", "implementorMarkerRendered", "siblingMarkersRendered", + "nativeSessionId", "identityProvenance", "reentryNativeSessionId", + "substitutedIdentity", "routeConverted", + "firstXmdExitCode", "secondXmdExitCode", "instructionChannel", + "modelTurns", "materializationTurns", "nativeUserTurns", "acpReattachTurns", + "answerObserverInvocations", "conversationInputByteCount", + "consentInputBytes", "consentSurfaces", "exitControlBytes", + "reentryConsentInputBytes", "reentryConsentSurfaces", "reentryExitControlBytes", + "inheritedAgentMarkersRemoved", + "noticedBeforeSpending", "noticedAfterSpending", + "openingSentenceExact", "markerRecovered", "acpDocumentCarriesMarker", + "answerSurface", "outcome", + "materialization", "route", "journal", "cleanup", "privateStateInspected" + ], + "properties": { + "mode": { "type": "string" }, + "verdict": { "enum": ["PASS", "REFUSED", "ENVIRONMENT_BLOCKED", "PRODUCT_FAILED", "HARNESS_FAILED"] }, + "authorized": { "type": "boolean" }, + "turnsAuthorized": { "type": "boolean" }, + "authorizedTurnBudget": { "type": "integer" }, + "ran": { "type": "boolean" }, + "refusal": { "type": "string" }, + "detail": { "type": "string" }, + "codexVersion": { "type": "string" }, + "codexDigest": { "type": "string" }, + "platform": { "type": "string" }, + "architecture": { "type": "string" }, + "adapterPackage": { "type": "string" }, + "adapterVersion": { "type": "string" }, + "adapterDigest": { "type": "string" }, + "compatibilityTupleFrozen": { "type": "boolean" }, + "target": { "type": "string" }, + "projectCopyVerified": { "type": "boolean" }, + "implementorMarkerRendered": { "type": "boolean" }, + "siblingMarkersRendered": { "type": "integer" }, + "nativeSessionId": { "type": "string" }, + "identityProvenance": { "type": "string" }, + "reentryNativeSessionId": { "type": "string" }, + "substitutedIdentity": { "type": "boolean" }, + "routeConverted": { "type": "boolean" }, + "firstXmdExitCode": { "type": "integer" }, + "secondXmdExitCode": { "type": "integer" }, + "instructionChannel": { "type": "string" }, + "modelTurns": { "type": "integer" }, + "materializationTurns": { "type": "integer" }, + "nativeUserTurns": { "type": "integer" }, + "acpReattachTurns": { "type": "integer" }, + "answerObserverInvocations": { "type": "integer" }, + "conversationInputByteCount": { "type": "integer" }, + "consentInputBytes": { "type": "string" }, + "consentSurfaces": { "type": "array", "items": { "type": "string" } }, + "exitControlBytes": { "type": "string" }, + "reentryConsentInputBytes": { "type": "string" }, + "reentryConsentSurfaces": { "type": "array", "items": { "type": "string" } }, + "reentryExitControlBytes": { "type": "string" }, + "inheritedAgentMarkersRemoved": { "type": "integer" }, + "noticedBeforeSpending": { "type": "boolean" }, + "noticedAfterSpending": { "type": "boolean" }, + "openingSentenceExact": { "type": "boolean" }, + "markerRecovered": { "type": "boolean" }, + "acpDocumentCarriesMarker": { "type": "boolean" }, + "answerSurface": { "type": "string" }, + "outcome": { "enum": ["same-identity", "no-session", "unresolved"] }, + "privateStateInspected": { "type": "boolean" }, + "materialization": { + "type": "object", + "additionalProperties": false, + "required": [ + "promptVersion", "requestIdStable", "promptExact", "turnNamed", "turnProvider", + "durationReported", "responsePresent", "stopReason", + "reportedUsageFields", "unreportedUsageFields", "failureClasses" + ], + "properties": { + "promptVersion": { "type": "string" }, + "requestIdStable": { "type": "boolean" }, + "promptExact": { "type": "boolean" }, + "turnNamed": { "type": "boolean" }, + "turnProvider": { "type": "string" }, + "durationReported": { "type": "boolean" }, + "responsePresent": { "type": "boolean" }, + "stopReason": { "type": "string" }, + "reportedUsageFields": { "type": "array", "items": { "type": "string" } }, + "unreportedUsageFields": { "type": "array", "items": { "type": "string" } }, + "failureClasses": { "type": "array", "items": { "type": "string" } } + } + }, + "route": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "provider", "buildVersion", "buildDigest"], + "properties": { + "kind": { "type": "string" }, + "provider": { "type": "string" }, + "buildVersion": { "type": "string" }, + "buildDigest": { "type": "string" } + } + }, + "journal": { + "type": "object", + "additionalProperties": false, + "required": [ + "provider", "agent", "launcher", "provenance", "nativeSessionId", "cwdIsProject", + "sessionState", "instructionsDigestPresent", + "firstPhases", "secondPhases", "failureClasses" + ], + "properties": { + "provider": { "type": "string" }, + "agent": { "type": "string" }, + "launcher": { "type": "string" }, + "provenance": { "type": "string" }, + "nativeSessionId": { "type": "string" }, + "cwdIsProject": { "type": "boolean" }, + "sessionState": { "type": "array", "items": { "type": "string" } }, + "instructionsDigestPresent": { "type": "boolean" }, + "firstPhases": { "type": "array", "items": { "type": "string" } }, + "secondPhases": { "type": "array", "items": { "type": "string" } }, + "failureClasses": { "type": "array", "items": { "type": "string" } } + } + }, + "cleanup": { + "type": "object", + "additionalProperties": false, + "required": [ + "liveChildren", "journalsRemoved", "routeRecordsRemoved", + "sessionDeleteOutcome", "temporaryRootRemoved" + ], + "properties": { + "liveChildren": { "type": "integer" }, + "journalsRemoved": { "type": "boolean" }, + "routeRecordsRemoved": { "type": "boolean" }, + "sessionDeleteOutcome": { "enum": ["deleted", "nothing-to-delete", "failed"] }, + "temporaryRootRemoved": { "type": "boolean" } + } + } + } +} +``` + + + + +```sh timeout=40min exec as="run" +deno run --allow-all --frozen packages/acp/tests/fixtures/codex-native-launch-proof.ts native-launch +``` + +The fixture returns a structured verdict even when the product fails one of the +two questions, so a nonzero exit means the harness broke rather than a question +being answered. + + + + +{run.stdout} + + +The whole result is shown before anything is judged. It carries no contract +text, no marker and no transcript content — `openingSentenceExact` and +`markerRecovered` are the only things about the conversation that crossed the +fixture boundary. + +```json +{run.stdout} +``` + +## Nothing private was read + +True whether or not the proof was opted into. Codex's rollout files are the one +place that could answer this document cheaply, and they are exactly what a proof +of the public contract may not open. + + + +## What the verdict decides + +The fixture classifies its own outcome, and that classification — not this +document's arithmetic — chooses what is worth asserting. A run its environment +blocked may have reached the first question and even spent the turn that asks +it, but it establishes neither answer, so demanding both would convict the +product of the environment's failure. Each branch below asserts only what its +verdict makes true, and every authorized verdict other than `PASS` ends by +stopping the document with the fixture's own reason, so a proof that did not +pass says why in one sentence instead of failing on an accounting mismatch that +was never the point. The one outcome that passes without proving anything is the +unarmed refusal: with the opt-ins absent nothing is spent and nothing is +observed, and that zero-turn skip is how this document runs on an ordinary +machine and in CI. + + + + +A pass is a claim about a journey that happened, so the first thing it owes is +that one did. + + + +## The frozen compatibility point + +The materialization turn exists because of a fact about one Codex build. This +records which one, so a later build that stopped needing it — or started needing +something else — cannot pass as this one. + + + + + + + + + + +## Three turns, and only one of them was XMD's + +The authorized budget is three, and the accounts name each one: one +materialization the product spent, one native turn the operator typed, one ACP +turn the second invocation asked. There is no fourth, and there is no second +materialization. + + + + + + + 0} /> + + +Reading an answer off a terminal is something the harness does, and it does it +for the one invocation that submits that native turn and for no other. The +invocations that type nothing reconstruct nothing, so the paths this document +does not test are the paths they already were. + + + +## The operator was told before it was spent + +A billable turn nobody was warned about is a bill, not a feature. The notice +appears on the terminal that acquired the launch before the turn starts, and the +completion summary appears before the native UI opens. + + + + +## The turn was the exact one the contract names + +Its version, its bytes and its request identity are fixed by the product, not +composed at the call site, and the retained preparation named the same request +the retained turn later reported against. + + + + + + + + + + + +Usage is recorded as the provider reported it. Which fields arrived is a fact +about Codex, not about this document, so both lists are shown and only their sum +is fixed: every field is either reported or explicitly unreported, and none is +quietly inferred to be zero. + + + +## The prepared contract governed the first native turn + +The question named neither the sentence nor any file, and the materialization +turn before it said nothing about either. The answer carried the opening +sentence of `.agents/implementor.md` exactly, which only the prepared layer +could have supplied — which also settles that materialization did not consume +the layer on its way past. + + + +## ACP rejoined the conversation the native UI was in + +The marker existed only in that first native turn — never in the instruction +layer, the argument vector, the environment or the session key — and the +document that asked for it does not contain it. It came back behind a prefix +that appeared in no earlier turn, so only an answer could have produced it. + + + + + + + + + +## The identity is the one Codex named + +Codex chose it and told the adapter through its own metadata. XMD never parsed +it out of anything, and never substituted a fresh one when the first was +inconvenient. + + + + +## The production target ran, and its siblings did not + + + + + + +## Route and journal agree + +The route names the construction and the Codex build the tuple above froze, and +nothing else: an ACP-first route deliberately records no conversation identity, +launcher or instruction layer, so those are read from the journal instead. A +route that had converted to anything but `acp-first` would mean the launch +stopped being the one this document describes. + + + + + + + + + + + + + + +One launch, one materialization, one detachment, one exit. The ACP reattachment +is not a launch, so it adds no phases of its own. + + + + + + +## Nothing was left behind + + + + + + + + + + +## The environment stopped it + +Something outside the product prevented an answer. Whether that happened before +the journey started or partway through it decides what may still be believed, so +the two are judged apart rather than together. + + + + +Nothing was launched. No conversation exists, no turn was bought, and the +accounts are all still zero — which is the only thing worth checking about a run +that never began. + + + + + + + + + + + + + + +The journey began and turns were spent, so what the accounts say about them has +to hold even though the questions went unanswered. Every turn is attributable to +one of the three the budget names, and no more were bought than were authorized. +What this branch may not do is demand the three the budget allows, the second +invocation, or either answer: a bound reached is the harness giving up, and +reading that as a product result would convict the product of the environment's +failure. + + + + +A conversation was created, so it still has to be cleaned up. This is the part +of a blocked run that costs money if it is wrong. + + + + + + + + + + + + + + + +## The product answered, and the answer was wrong + +The journey ran and the fixture reached a finding, so the accounts and the +cleanup are the two things that decide whether the finding can be trusted. They +are checked and nothing else is: the finding itself is the fixture's to state. + + + + + + + + + + + + + + +## The harness broke before the product could be judged + +Nothing is asserted here on purpose. A harness that stopped may have left its +own accounts half-written, and an assertion about them would fail first and +report the wrong thing — burying the sentence that says what actually broke. + + + + + + +## It refused before any conversation existed + +The refusal happens before any Codex conversation exists, so a refused run +spends no turn and observes nothing. + + + + + + + + + + + + + + + +Both opt-ins were given, so this run asked to spend turns and was turned away +anyway. That is a refusal the operator has to resolve, not a suite that may go +green around it. + + + + + + +Without both opt-ins this is the ordinary way the document runs: on a developer +machine and in CI, where it must cost nothing and still be read. Reaching here +is the pass. + + + + + + + + + + + + + diff --git a/packages/acp/src/CodexZeroNativeTurnExit.test.md b/packages/acp/src/CodexZeroNativeTurnExit.test.md new file mode 100644 index 000000000..73b706bac --- /dev/null +++ b/packages/acp/src/CodexZeroNativeTurnExit.test.md @@ -0,0 +1,430 @@ +# Leaving a prepared Codex session without saying anything + +A prepared session you never speak in should still be there tomorrow. For Claude +that costs nothing, and `ClaudeZeroTurnExit.test.md` proves it: the session the +launch constructs is openable the moment it exists. + +Codex is not like that. Codex CLI 0.153.2 does not write a conversation anywhere +`codex resume` can find it until a turn has completed in it, so a launch that +handed you the native UI without one would hand you a name Codex disowns. XMD +therefore spends exactly one model turn of its own — `codex-materialization.v1`, +whose only job is to make the conversation openable — and then gets out of the +way. + +That turn is the whole subject of this document, and it asks the two questions +that decide whether it was worth spending. + +**Did one turn actually make the conversation openable?** The launch opens the +native UI, and the operator leaves without typing anything at all. Reaching +Codex's own composer rather than `No saved session found with ID` is the answer. + +**Is it still openable without buying another one?** A second, independent +invocation of the same command re-enters. If it materialized again, the reader +paid twice for something already done, so the durable accounts must retain +exactly one materialization across both invocations. + +Whether the prepared contract governs what you say once you are in there is a +different question, and it lives in `CodexNativeLaunch.test.md` so that this one +can be corrected without ever respending its turns. + +## What this costs, and what it touches + +One real model turn against the operator's own Codex credentials: XMD's own +materialization turn, in the session the first launch constructed. Nothing is +typed into either native UI. The exact bytes of that turn are fixed by the +product, are shown to the operator before they are spent, and carry no path, +identity, environment or authored content. + +It is opt-in twice over. Without `XMD_CODEX_NATIVE_PROOF=1` the fixture refuses +before starting any Codex process; without a separate +`XMD_CODEX_MODEL_TURNS_AUTHORIZED=1` it refuses before spending the turn. It +also refuses on a machine outside the frozen compatibility tuple below, because +the finding this feature rests on is a fact about one Codex build. + +Everything sent besides that turn is terminal control: Codex's own directory +trust dialog is answered at most once with the choice it pre-selects, and the +exit is two Ctrl-C bytes. The command runs with an operator's environment rather +than this process's, so anything an enclosing agent session exported is dropped. + +The command runs in a fresh temporary directory holding a byte-for-byte copy of +this repository's own `AGENTS.md` and `.agents/implementor.md`. Afterwards both +conversations are removed through Codex's own `codex delete --force `, each +named by the exact identity this run created. `CODEX_HOME` +is left alone — relocating it de-authenticates Codex — and nothing beneath +Codex's configuration, history or rollout files is ever opened. + +Run it with: + +```sh +XMD_CODEX_NATIVE_PROOF=1 XMD_CODEX_MODEL_TURNS_AUTHORIZED=1 \ + deno task xmd test packages/acp/src/CodexZeroNativeTurnExit.test.md --raw +``` + +## What a verdict may say + +The schema is the disclosure boundary, not a convenience. It accepts versions +and digests of public executables, the session identity and who chose it, +booleans, counts, phase and failure classes, usage field *names*, and cleanup +outcomes. The role contract's text, the materialization turn's reply, raw +terminal output, argument vectors, the environment and private paths have +nowhere to go in it, so a fixture that tried to report one would fail this +document. + + + +```json +{ + "type": "object", + "additionalProperties": false, + "required": [ + "mode", "verdict", "authorized", "turnsAuthorized", "authorizedTurnBudget", + "ran", "refusal", "detail", + "codexVersion", "codexDigest", "platform", "architecture", + "adapterPackage", "adapterVersion", "adapterDigest", "compatibilityTupleFrozen", + "target", "projectCopyVerified", "implementorMarkerRendered", "siblingMarkersRendered", + "nativeSessionId", "identityProvenance", "reentryNativeSessionId", + "substitutedIdentity", "routeConverted", + "firstXmdExitCode", "secondXmdExitCode", "instructionChannel", + "modelTurns", "materializationTurns", "nativeUserTurns", "acpReattachTurns", + "answerObserverInvocations", "conversationInputByteCount", + "consentInputBytes", "consentSurfaces", "exitControlBytes", + "reentryConsentInputBytes", "reentryConsentSurfaces", "reentryExitControlBytes", + "inheritedAgentMarkersRemoved", + "noticedBeforeSpending", "noticedAfterSpending", + "openingSentenceExact", "markerRecovered", "acpDocumentCarriesMarker", + "answerSurface", "outcome", + "materialization", "route", "journal", "cleanup", "privateStateInspected" + ], + "properties": { + "mode": { "type": "string" }, + "verdict": { "enum": ["PASS", "REFUSED", "ENVIRONMENT_BLOCKED", "PRODUCT_FAILED", "HARNESS_FAILED"] }, + "authorized": { "type": "boolean" }, + "turnsAuthorized": { "type": "boolean" }, + "authorizedTurnBudget": { "type": "integer" }, + "ran": { "type": "boolean" }, + "refusal": { "type": "string" }, + "detail": { "type": "string" }, + "codexVersion": { "type": "string" }, + "codexDigest": { "type": "string" }, + "platform": { "type": "string" }, + "architecture": { "type": "string" }, + "adapterPackage": { "type": "string" }, + "adapterVersion": { "type": "string" }, + "adapterDigest": { "type": "string" }, + "compatibilityTupleFrozen": { "type": "boolean" }, + "target": { "type": "string" }, + "projectCopyVerified": { "type": "boolean" }, + "implementorMarkerRendered": { "type": "boolean" }, + "siblingMarkersRendered": { "type": "integer" }, + "nativeSessionId": { "type": "string" }, + "identityProvenance": { "type": "string" }, + "reentryNativeSessionId": { "type": "string" }, + "substitutedIdentity": { "type": "boolean" }, + "routeConverted": { "type": "boolean" }, + "firstXmdExitCode": { "type": "integer" }, + "secondXmdExitCode": { "type": "integer" }, + "instructionChannel": { "type": "string" }, + "modelTurns": { "type": "integer" }, + "materializationTurns": { "type": "integer" }, + "nativeUserTurns": { "type": "integer" }, + "acpReattachTurns": { "type": "integer" }, + "answerObserverInvocations": { "type": "integer" }, + "conversationInputByteCount": { "type": "integer" }, + "consentInputBytes": { "type": "string" }, + "consentSurfaces": { "type": "array", "items": { "type": "string" } }, + "exitControlBytes": { "type": "string" }, + "reentryConsentInputBytes": { "type": "string" }, + "reentryConsentSurfaces": { "type": "array", "items": { "type": "string" } }, + "reentryExitControlBytes": { "type": "string" }, + "inheritedAgentMarkersRemoved": { "type": "integer" }, + "noticedBeforeSpending": { "type": "boolean" }, + "noticedAfterSpending": { "type": "boolean" }, + "openingSentenceExact": { "type": "boolean" }, + "markerRecovered": { "type": "boolean" }, + "acpDocumentCarriesMarker": { "type": "boolean" }, + "answerSurface": { "type": "string" }, + "outcome": { "enum": ["same-identity", "no-session", "unresolved"] }, + "privateStateInspected": { "type": "boolean" }, + "materialization": { + "type": "object", + "additionalProperties": false, + "required": [ + "promptVersion", "requestIdStable", "promptExact", "turnNamed", "turnProvider", + "durationReported", "responsePresent", "stopReason", + "reportedUsageFields", "unreportedUsageFields", "failureClasses" + ], + "properties": { + "promptVersion": { "type": "string" }, + "requestIdStable": { "type": "boolean" }, + "promptExact": { "type": "boolean" }, + "turnNamed": { "type": "boolean" }, + "turnProvider": { "type": "string" }, + "durationReported": { "type": "boolean" }, + "responsePresent": { "type": "boolean" }, + "stopReason": { "type": "string" }, + "reportedUsageFields": { "type": "array", "items": { "type": "string" } }, + "unreportedUsageFields": { "type": "array", "items": { "type": "string" } }, + "failureClasses": { "type": "array", "items": { "type": "string" } } + } + }, + "route": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "provider", "buildVersion", "buildDigest"], + "properties": { + "kind": { "type": "string" }, + "provider": { "type": "string" }, + "buildVersion": { "type": "string" }, + "buildDigest": { "type": "string" } + } + }, + "journal": { + "type": "object", + "additionalProperties": false, + "required": [ + "provider", "agent", "launcher", "provenance", "nativeSessionId", "cwdIsProject", + "sessionState", "instructionsDigestPresent", + "firstPhases", "secondPhases", "failureClasses" + ], + "properties": { + "provider": { "type": "string" }, + "agent": { "type": "string" }, + "launcher": { "type": "string" }, + "provenance": { "type": "string" }, + "nativeSessionId": { "type": "string" }, + "cwdIsProject": { "type": "boolean" }, + "sessionState": { "type": "array", "items": { "type": "string" } }, + "instructionsDigestPresent": { "type": "boolean" }, + "firstPhases": { "type": "array", "items": { "type": "string" } }, + "secondPhases": { "type": "array", "items": { "type": "string" } }, + "failureClasses": { "type": "array", "items": { "type": "string" } } + } + }, + "cleanup": { + "type": "object", + "additionalProperties": false, + "required": [ + "liveChildren", "journalsRemoved", "routeRecordsRemoved", + "sessionDeleteOutcome", "temporaryRootRemoved" + ], + "properties": { + "liveChildren": { "type": "integer" }, + "journalsRemoved": { "type": "boolean" }, + "routeRecordsRemoved": { "type": "boolean" }, + "sessionDeleteOutcome": { "enum": ["deleted", "nothing-to-delete", "failed"] }, + "temporaryRootRemoved": { "type": "boolean" } + } + } + } +} +``` + + + + +```sh timeout=40min exec as="run" +deno run --allow-all --frozen packages/acp/tests/fixtures/codex-native-launch-proof.ts zero-native-turn +``` + +The fixture returns a structured verdict even when the product fails one of the +two questions, so a nonzero exit means the harness broke rather than a question +being answered. + + + + +{run.stdout} + + +The whole result is shown before anything is judged. It carries no contract +text, no transcript content and no reply — `promptExact` and `responsePresent` +are the only things about the turn that crossed the fixture boundary. + +```json +{run.stdout} +``` + +## Nothing private was read + +True whether or not the proof was opted into. Codex's rollout files are the one +place that could answer this document cheaply, and they are exactly what a proof +of the public contract may not open. + + + + + +## The frozen compatibility point + +The materialization turn exists because of a fact about one Codex build. This +records which one, so a later build that stopped needing it — or started needing +something else — cannot pass as this one. + + + + + + + + + + +## Exactly one model turn, and it was XMD's + +The authorized budget is one, and one is what the durable accounts retain across +both invocations. Nothing was typed into either native UI, so every byte the +conversation received was the product's own. + + + + + + + + + + +Nothing was typed, so nothing was watched for. The harness reconstructs a +terminal only for an invocation that submits a turn and waits for its answer, +and neither of these two invocations does. + + + +## The operator was told before it was spent + +A billable turn nobody was warned about is a bill, not a feature. The notice +appears on the terminal that acquired the launch before the turn starts, and the +completion summary appears before the native UI opens. + + + + +## The turn was the exact one the contract names + +Its version, its bytes and its request identity are fixed by the product, not +composed at the call site, and the retained preparation named the same request +the retained turn later reported against. + + + + + + + + + + + +Usage is recorded as the provider reported it. Which fields arrived is a fact +about Codex, not about this document, so both lists are shown and only their +sum is fixed: every field is either reported or explicitly unreported, and none +is quietly inferred to be zero. + + + +## The session was openable, and stayed openable + +The first launch left Codex's own composer rather than its refusal. The second +invocation reached the same conversation, under the same identity, without a +second materialization — which the count above already settled. + + + + + + + + +## The identity is the one Codex named + +Codex chose it and told the adapter through its own metadata. XMD never parsed +it out of anything, and never substituted a fresh one when the first was +inconvenient. + + + + +## The production target ran, and its siblings did not + + + + + + +## Route and journal agree + +The route names the construction and the Codex build the tuple above froze, and +nothing else: an ACP-first route deliberately records no conversation identity, +launcher or instruction layer, so those are read from the journal instead. A +route that had converted to anything but `acp-first` would mean the launch +stopped being the one this document describes. + + + + + + + + + + + + + + +The phases are the whole story. The first invocation materializes; the second, +finding a conversation that is already openable, does not. + + + + + + +## Nothing was left behind + + + + + + + + + + + +## Without the authorization, or the build, nothing started + +The refusal happens before any Codex conversation exists, so an unauthorized run +spends no turn and observes nothing. This is the branch that runs on an ordinary +machine, and the branch that runs on a machine outside the frozen tuple. + + + + + + + + + + + + + + diff --git a/packages/acp/src/native-capability.ts b/packages/acp/src/native-capability.ts index b2c7332ef..6d9c8417a 100644 --- a/packages/acp/src/native-capability.ts +++ b/packages/acp/src/native-capability.ts @@ -26,11 +26,13 @@ /** * Which behavior a proof established. * - * Two, not one, because they are proved separately: handing a session to a - * native UI and later joining that same conversation through ACP are different - * things that can be true independently. + * Native launch, joining a client-created conversation, and continuing one + * returned by a provider each have independent proofs. */ -export type NativeCapability = "native-launch" | "client-native-attachment"; +export type NativeCapability = + | "native-launch" + | "client-native-attachment" + | "provider-native-continuation"; /** The machine a host is actually running on, as that host states it. */ export interface NativeCapabilityHost { diff --git a/packages/acp/src/native-launch.ts b/packages/acp/src/native-launch.ts index 3aa30bc04..3cbcc92ee 100644 --- a/packages/acp/src/native-launch.ts +++ b/packages/acp/src/native-launch.ts @@ -462,6 +462,133 @@ function claudeReportedVersion(metadata: ExecutableMetadata): string | undefined return canonical.length === 1 ? canonical[0] : undefined; } +const CODEX_PROBE_PROFILE = "codex-help-native-session.v1"; +const CODEX_PROVIDER_PROTOCOL = "codex-provider-returned.v1"; + +function helpSection(help: string, heading: string): string[] { + const lines: string[] = []; + let inside = false; + for (const line of help.split("\n")) { + if (/^\S.*:$/.test(line)) { + inside = line === `${heading}:`; + } else if (inside) { + lines.push(line); + } + } + return lines; +} + +interface PositionalDeclaration { + readonly name: string; + readonly optional: boolean; + description: string; +} + +function positionalDeclarations(help: string): PositionalDeclaration[] { + const positions: PositionalDeclaration[] = []; + let current: PositionalDeclaration | undefined; + for (const line of helpSection(help, "Arguments")) { + const head = /^ {2}(?:\[([^\[\]]+)\]|<([^<>]+)>)(?:\s+(.*))?$/.exec(line); + if (head) { + current = { + name: head[1] ?? head[2], + optional: head[1] !== undefined, + description: normalized(head[3] ?? "").toLowerCase(), + }; + positions.push(current); + } else if (current && /^ {3,}\S/.test(line)) { + current.description += ` ${normalized(line).toLowerCase()}`; + } else if (line.trim().length > 0) { + return []; + } + } + return positions; +} + +function usageDeclarations(help: string): string[] { + const entries: string[] = []; + let inside = false; + for (const line of help.split("\n")) { + if (/^Usage:/.test(line)) { + entries.push(normalized(line)); + inside = true; + } else if (inside && /^ {3,}[[<]/.test(line)) { + entries[entries.length - 1] += ` ${normalized(line)}`; + } else { + inside = false; + } + } + return entries; +} + +function declaresCodexResume(root: string, resume: string): boolean { + const product = root.split("\n").some((line) => /^Codex CLI\s*(?:[-–—:]|$)/.test(line)); + const rootUsage = root.split("\n").some((line) => /^Usage: codex(?:\s|$)/.test(line)); + const commands = helpSection(root, "Commands").filter((line) => /^ {2}resume(?:\s|$)/.test(line)); + const usages = usageDeclarations(resume); + const usage = + usages.length === 1 + ? /^Usage: codex resume \[OPTIONS\] \[SESSION_ID\](?: \[PROMPT\])?$/.exec( + normalized(usages[0]), + ) + : null; + const positions = positionalDeclarations(resume); + const identity = positions[0]; + const optionalPrompt = + positions.length === 1 || + (positions.length === 2 && positions[1].name === "PROMPT" && positions[1].optional); + const uuidIdentity = + identity !== undefined && + states( + identity.description, + /\b(?:session|conversation) (?:id|identity|identifier)\s*\(\s*uuid\s*\)/, + ); + return ( + product && + rootUsage && + commands.length === 1 && + usage !== null && + identity?.name === "SESSION_ID" && + identity.optional && + optionalPrompt && + uuidIdentity + ); +} + +function codexNativeProbe(pinnedBridgeProtocol: string | undefined): NativeCapabilityProbe { + return (metadata) => { + const capabilities: NativeCapability[] = []; + const root = answered(metadata, "help"); + const resume = answered(metadata, "resume-help"); + if (root !== undefined && resume !== undefined && declaresCodexResume(root, resume)) { + capabilities.push("native-launch"); + // This assertion belongs to the compiled vendored bridge contract. An + // executable declaration alone cannot confer ACP continuation. + if (pinnedBridgeProtocol === CODEX_PROVIDER_PROTOCOL) { + capabilities.push("provider-native-continuation"); + } + } + return { probeProfile: CODEX_PROBE_PROFILE, capabilities }; + }; +} + +function codexReportedVersion(metadata: ExecutableMetadata): string | undefined { + const output = answered(metadata, "version"); + if (output === undefined) { + return undefined; + } + const canonical = output + .split("\n") + .map((line) => line.trim()) + .filter((line) => /^codex-cli \d+\.\d+\.\d+$/.test(line)); + return canonical.length === 1 ? canonical[0] : undefined; +} + +export interface MaterializationContract { + readonly promptVersion: string; + readonly prompt: string; +} + interface AdapterCommands { /** Stable adapter identity — `claude`, `codex`. Never an executable path. */ launcher: string; @@ -478,6 +605,7 @@ interface AdapterCommands { identity: IdentityProvenance; /** The argv that resumes this exact provider-native session. */ resume(nativeSessionId: string): string[]; + materialization?: MaterializationContract; /** * The exact builds and machines a real-CLI proof of this adapter ran on. * @@ -523,13 +651,22 @@ export interface ProviderReturnedAdapter extends AdapterCommands { identity: "provider-returned"; } -export type NativeAdapter = ProviderReturnedAdapter | ClientAllocatedAdapter; +export interface BoundProviderReturnedAdapter extends ProviderReturnedAdapter { + binding: NativeBinding; +} + +export type BuildBoundAdapter = BoundProviderReturnedAdapter | ClientAllocatedAdapter; +export type NativeAdapter = ProviderReturnedAdapter | BuildBoundAdapter; /** Whether this adapter names its own sessions. */ export function allocatesIdentity(adapter: NativeAdapter): adapter is ClientAllocatedAdapter { return adapter.identity === "client-allocated"; } +export function bindsBuild(adapter: NativeAdapter): adapter is BuildBoundAdapter { + return "binding" in adapter; +} + /** * The one shape and machine Claude's proofs ran on. * @@ -549,6 +686,12 @@ const CLAUDE_PROVED_BUILD = { architecture: "arm64", } as const; +const CODEX_PROVED_HOST = { + probeProfile: CODEX_PROBE_PROFILE, + platform: "darwin", + architecture: "arm64", +}; + const ADAPTERS: Readonly> = { claude: { launcher: "claude", @@ -595,8 +738,32 @@ const ADAPTERS: Readonly> = { }, codex: { launcher: "codex", - protocol: "codex-provider-returned.v1", + protocol: CODEX_PROVIDER_PROTOCOL, identity: "provider-returned", + proved: [ + { capability: "native-launch", ...CODEX_PROVED_HOST }, + { capability: "provider-native-continuation", ...CODEX_PROVED_HOST }, + ], + binding: { + command: "codex", + metadata: [ + { name: "help", args: ["--help"] }, + { name: "resume-help", args: ["resume", "--help"] }, + { name: "version", args: ["--version"] }, + ], + probe: codexNativeProbe(CODEX_PROVIDER_PROTOCOL), + reportedVersion: codexReportedVersion, + // The host registry supplies the vendored bridge. Replacing its command + // here would bypass the snapshot whose identity contract is proved. + environment: (livePath) => ({ CODEX_PATH: livePath }), + }, + materialization: { + promptVersion: "codex-materialization.v1", + prompt: + "This turn only makes the Codex conversation resumable. Do not perform the prepared " + + "task, inspect or modify files, call tools, or take any external action. Reply with a " + + "brief acknowledgement only.", + }, resume: (nativeSessionId) => ["codex", "resume", nativeSessionId], }, }; @@ -631,6 +798,11 @@ export function pinnedRouteProtocol(launcher: string): string | undefined { return Object.hasOwn(ROUTE_PROTOCOLS, launcher) ? ROUTE_PROTOCOLS[launcher] : undefined; } +/** V3 has one compiled interpretation, independent of a registered adapter. */ +export function pinnedProviderRouteProtocol(launcher: string): string | undefined { + return launcher === "codex" ? CODEX_PROVIDER_PROTOCOL : undefined; +} + /** * The adapters this host will consider for native launch at all. * @@ -640,12 +812,11 @@ export function pinnedRouteProtocol(launcher: string): string | undefined { * probe recognized in the executable actually found, and the machine actually * running. A name reaches the question; it does not answer it. * - * `codex` is absent. Its command shape is known and its adapter contract tests - * pass, and neither is the proof: nothing has run it against an installed - * Codex. A host may still name an adapter itself by passing it through - * `AcpxProviderDependencies.advertiseNativeLaunch`. + * Codex's provider-returned proof used codex-cli 0.153.2 on macOS arm64. That + * release identifies the evidence; each current executable still has to pass + * the live protocol, shape, capability and host admission independently. */ -export const ADVERTISED_NATIVE_LAUNCH: readonly string[] = ["claude"]; +export const ADVERTISED_NATIVE_LAUNCH: readonly string[] = ["claude", "codex"]; /** * The adapters this host will consider for client-native ACP attachment. @@ -657,6 +828,8 @@ export const ADVERTISED_NATIVE_LAUNCH: readonly string[] = ["claude"]; */ export const ADVERTISED_CLIENT_NATIVE_ATTACHMENT: readonly string[] = ["claude"]; +export const ADVERTISED_PROVIDER_NATIVE_CONTINUATION: readonly string[] = ["codex"]; + /** * What this build's adapters have proved, on the machine a host says it is. * diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index 4d2106795..47f46aa22 100644 --- a/packages/acp/src/provider.ts +++ b/packages/acp/src/provider.ts @@ -23,11 +23,13 @@ import { createChannel, createScope, + each, ensure, Err, Ok, scoped, spawn, + stream, suspend, until, useScope, @@ -51,19 +53,29 @@ import type { InstructionReconciliation, ExitedLaunchRecord, LaunchFailure, + LaunchFailureClass, LaunchOptions, + MaterializationPlan, + MaterializationUsage, + MaterializedLaunchRecord, PreparedLaunchRecord, PromptOptions, Session, SessionLaunchResult, } from "@executablemd/core"; -import { allocatesIdentity } from "./native-launch.ts"; -import type { ClientAllocatedAdapter, NativeBinding } from "./native-launch.ts"; +import { allocatesIdentity, bindsBuild } from "./native-launch.ts"; +import type { + BoundProviderReturnedAdapter, + BuildBoundAdapter, + ClientAllocatedAdapter, + NativeBinding, +} from "./native-launch.ts"; import { AgentSessionRouteError } from "./session-route.ts"; import type { AgentSessionRoute, AgentSessionRouteStore, AgentSessionRouteV2, + AgentSessionRouteV3, } from "./session-route.ts"; import { createAcpRuntime, createAgentRegistry, createRuntimeStore } from "./acpx-runtime.ts"; import type { @@ -75,6 +87,9 @@ import type { AcpRuntimeMaterialization, AcpRuntimeOptions, AcpRuntimeTurn, + AcpRuntimeTurnResult, + AcpRuntimeUsageBreakdown, + AcpRuntimeUsageCost, AcpSessionRecord, AcpSessionStore, SessionAgentOptions, @@ -85,6 +100,7 @@ import { strictPermissions, } from "./permission-bridge.ts"; import { consumeTurn } from "./events.ts"; +import { checkpointFromResult } from "./checkpoint.ts"; import { resolveSessionPlacement } from "./session-key.ts"; import { useSerialQueues } from "./serial-queue.ts"; import { @@ -93,7 +109,7 @@ import { cwd, ExecutableObservationError, } from "@executablemd/runtime"; -import { nativeLaunch } from "@executablemd/terminal"; +import { nativeLaunch, notifyTerminal } from "@executablemd/terminal"; import type { AgentSessionCoordinator, AgentSessionKey, @@ -104,9 +120,11 @@ import type { import { ADVERTISED_CLIENT_NATIVE_ATTACHMENT, ADVERTISED_NATIVE_LAUNCH, + ADVERTISED_PROVIDER_NATIVE_CONTINUATION, knownNativeAdapters, nativeAdapterFor, pinnedRouteProtocol, + pinnedProviderRouteProtocol, } from "./native-launch.ts"; import type { NativeAdapter } from "./native-launch.ts"; import { admitsNativeCapability } from "./native-capability.ts"; @@ -235,6 +253,7 @@ export interface AcpxProviderDependencies { * prove different things. Absent means none. */ advertiseClientNativeAttachment?: readonly string[]; + advertiseProviderNativeContinuation?: readonly string[]; /** * How this host observes the build behind an executable. * @@ -534,7 +553,7 @@ interface LaunchInvocation { * path is only true for the run that observed it. A later replay reobserves * rather than finding one lying about. */ - readonly bound: Map; + readonly bound: Map; /** Sessions whose detach phase ran live in this invocation. */ readonly detachedLive: Set; /** @@ -838,6 +857,9 @@ function* useAcpxProviderState( dependencies?.withSessionRoute ?? ((_c: SessionRouteContext, op: () => Operation) => op()); const launchAdvertised = new Set(dependencies?.advertiseNativeLaunch ?? ADVERTISED_NATIVE_LAUNCH); + const continuationAdvertised = new Set( + dependencies?.advertiseProviderNativeContinuation ?? ADVERTISED_PROVIDER_NATIVE_CONTINUATION, + ); const attachAdvertised = new Set( dependencies?.advertiseClientNativeAttachment ?? ADVERTISED_CLIENT_NATIVE_ATTACHMENT, ); @@ -936,6 +958,10 @@ function* useAcpxProviderState( function partitionOf(build: BoundBuild): string { return [ build.agentCommand, + build.adapterProtocol, + build.probeProfile, + build.livePath, + build.adapterCommand ?? "", build.binding.reportedVersion ?? "", build.binding.executableDigest.algorithm, build.binding.executableDigest.value, @@ -1221,14 +1247,16 @@ function* useAcpxProviderState( if (prepareAgent !== undefined) { yield* prepareAgent(selected); } - // Resolution is read-only for an agent whose sessions XMD names. Probing - // spawns an ACP child, and that is provider work on a session whose + // Resolution is read-only for an agent whose sessions are bound to a build. + // Probing spawns an ACP child, and that is provider work on a session whose // construction has not been settled yet — it would run before the route is - // published, before a client-native route can refuse this surface, and - // before a host missing either capability has said so. Nothing on that path - // needs the answer: the session is created by a native process, and where - // ACP does serve one, the establishment itself reports being unable to. - if (!validatedAgents.has(selected) && !namesOwnSessions(selected)) { + // published, before a route this surface cannot serve can refuse it, and + // before a host missing either capability has said so. It would also spawn a + // child of a build nothing has observed, which is the one thing a bound + // session may not talk to. Nothing on that path needs the answer: a + // client-native session is created by a native process, and where ACP does + // serve one, the establishment itself reports being unable to. + if (!validatedAgents.has(selected) && !boundSessions(selected)) { const base = yield* runtimeOptions(); const probe = createRuntime({ ...base, probeAgent: selected }); const report = yield* until(probe.doctor()); @@ -1409,7 +1437,16 @@ function* useAcpxProviderState( /** What one ensure is for, beyond the placement it is for. */ interface EnsureIntent { - attachment?: { build: BoundBuild; resumeSessionId: string }; + /** + * The observed build this ensure's ACP child must be. + * + * Separate from `attachment`, because the two answer different questions + * and not every session that has one has the other: a session the provider + * named is bound to a build without any identity for ACP to be told to + * reopen. + */ + build?: BoundBuild; + attachment?: { resumeSessionId: string }; /** The placement's state, as the caller resolved it. */ state: "pending" | "established"; /** @@ -1445,7 +1482,7 @@ function* useAcpxProviderState( let managedEntry: ManagedSession; try { managedEntry = yield* ensureThrough( - attachment?.build, + intent.build, { sessionKey: prepared.placement.sessionKey, agent: agentName, @@ -1558,7 +1595,9 @@ function* useAcpxProviderState( */ function ownable(agentName: string): boolean { return ( - (launchAdvertised.has(agentName) || attachAdvertised.has(agentName)) && + (launchAdvertised.has(agentName) || + attachAdvertised.has(agentName) || + continuationAdvertised.has(agentName)) && adapterFor(agentName) !== undefined ); } @@ -1599,24 +1638,31 @@ function* useAcpxProviderState( /** * Whether this agent's sessions are constructed under an identity XMD names. - * - * Only that shape needs a construction route: a provider that returns its own - * identity constructs nothing this route governs, and keeps exactly its - * merged behavior on a host that installs no route store. */ function namesOwnSessions(agentName: string): boolean { const adapter = adapterFor(agentName); return ownable(agentName) && adapter !== undefined && allocatesIdentity(adapter); } + /** + * Whether this agent's sessions carry build-described construction routes. + * + * The retained observation describes construction. Admission of the current + * executable is a separate capability decision, not a comparison to it. + */ + function boundSessions(agentName: string): boolean { + const adapter = adapterFor(agentName); + return ownable(agentName) && adapter !== undefined && bindsBuild(adapter); + } + /** * Refuse an advertised agent this host is not assembled to serve. * * Fail-closed, and closed means what the agent actually needs: every - * advertised session needs a coordinator to say who owns it, and one whose - * sessions XMD names also needs a route store to say how it was constructed. - * A host that can answer one question but not the other cannot act on a - * session a native UI may be in. + * advertised session needs a coordinator to say who owns it, and one bound to + * a build also needs a route store to say how it was constructed and an + * observer to say which build that was. A host that can answer one question + * but not the other cannot act on a session a native UI may be in. */ function requireAssembly(agentName: string, sessionKey: string): void { if (!ownable(agentName)) { @@ -1626,7 +1672,7 @@ function* useAcpxProviderState( if (!coordinator) { missing.push("exclusive ownership"); } - if (namesOwnSessions(agentName)) { + if (boundSessions(agentName)) { if (!routeStore) { missing.push("construction routes"); } @@ -1700,6 +1746,22 @@ function* useAcpxProviderState( }; } + /** The same route, for a session whose build was observed as it was bound. */ + function boundAcpFirstRoute( + agentCommand: string, + sessionKey: string, + executableBinding: AgentSessionRouteV3["executableBinding"], + ): AgentSessionRouteV3 { + return { + schema: "session-route.v3", + route: "acp-first", + provider: ACPX_PROVIDER, + agent: agentCommand, + sessionKey, + executableBinding, + }; + } + /** * Publish or adopt `acp-first` for an agent whose sessions XMD would * otherwise name, and refuse if this session already has an identity. @@ -1948,14 +2010,122 @@ function* useAcpxProviderState( * when this host has proven that capability for the executable it is about to * run, whichever release that has become. */ + function providerRouteContract( + agentCommand: string, + sessionKey: string, + adapter: BoundProviderReturnedAdapter, + route?: AgentSessionRoute, + ): LaunchFailure | undefined { + const protocol = pinnedProviderRouteProtocol(adapter.launcher); + if (protocol === undefined || adapter.protocol !== protocol) { + return { + class: "unsupported-capability", + message: "this build fixes no provider-returned construction contract for this adapter", + }; + } + if (route === undefined) { + return undefined; + } + if ( + route.provider !== ACPX_PROVIDER || + route.agent !== agentCommand || + route.sessionKey !== sessionKey || + route.route !== "acp-first" + ) { + return { + class: "identity-unavailable", + message: `session "${sessionKey}" has a construction route for a different conversation`, + }; + } + if (route.schema !== "session-route.v3") { + return { + class: "executable-binding-refused", + message: `session "${sessionKey}" was constructed before XMD recorded its provider-returned contract; name a different `, + }; + } + return undefined; + } + + function* bindProviderNamed( + agentName: string, + agentCommand: string, + adapter: BoundProviderReturnedAdapter, + sessionKey: string, + hasProviderState: boolean, + capability: NativeCapability, + ): Operation<{ build: BoundBuild; route: AgentSessionRouteV3 }> { + let found: AgentSessionRoute | undefined; + try { + found = yield* routeStore!.read(sessionKeyOf(agentCommand, sessionKey)); + } catch (error) { + throw new AttachmentRefused({ class: "identity-unavailable", message: routeMessage(error) }); + } + const foreign = providerRouteContract(agentCommand, sessionKey, adapter, found); + if (foreign) { + throw new AttachmentRefused(foreign); + } + const build = yield* observeBuild(agentName, agentCommand, adapter.protocol, adapter.binding); + const unproved = admitCapability(agentName, capability, build); + if (unproved) { + throw new AttachmentRefused(unproved); + } + let route: AgentSessionRoute; + try { + route = yield* reconcileRoute( + agentCommand, + sessionKey, + function* () { + return boundAcpFirstRoute(agentCommand, sessionKey, build.binding); + }, + hasProviderState, + ); + } catch (error) { + throw new AttachmentRefused({ class: "identity-unavailable", message: routeMessage(error) }); + } + const winner = providerRouteContract(agentCommand, sessionKey, adapter, route); + if (winner) { + throw new AttachmentRefused(winner); + } + if (route.schema !== "session-route.v3") { + throw new AttachmentRefused({ + class: "identity-unavailable", + message: "the provider route is not readable", + }); + } + return { build, route }; + } + function* constructRoute( agentName: string, prepared: Prepared, - ): Operation<{ build: BoundBuild; resumeSessionId: string } | undefined> { - if (!namesOwnSessions(agentName)) { + ): Operation<{ build: BoundBuild; resumeSessionId?: string } | undefined> { + const adapter = adapterFor(agentName); + if (!ownable(agentName) || adapter === undefined) { return undefined; } const agentCommand = agentCommandOf(prepared); + const constructed = + prepared.kind === "existing" || (yield* until(store.load(prepared.sessionKey))) !== undefined; + if (!allocatesIdentity(adapter)) { + if (!bindsBuild(adapter)) { + return undefined; + } + if (!continuationAdvertised.has(agentName)) { + throw new AttachmentRefused({ + class: "unsupported-capability", + message: `agent "${agentName}" is not advertised as provider-native-continuation capable`, + }); + } + const { build } = yield* bindProviderNamed( + agentName, + agentCommand, + adapter, + prepared.sessionKey, + constructed, + "provider-native-continuation", + ); + return { build }; + } const route = yield* reconcileRoute( agentCommand, prepared.sessionKey, @@ -1963,9 +2133,7 @@ function* useAcpxProviderState( function* () { return acpFirstRoute(agentCommand, prepared.sessionKey); }, - // An existing managed entry, or a durable record ACPX already kept, is - // provider state — and existing history is never reclassified. - prepared.kind === "existing" || (yield* until(store.load(prepared.sessionKey))) !== undefined, + constructed, ); if (route.route !== "client-native") { return undefined; @@ -1993,7 +2161,7 @@ function* useAcpxProviderState( `.`, }); } - const attaching = adapterFor(agentName) as ClientAllocatedAdapter; + const attaching = adapter; // Which conversation this is, before anything about which build is // installed. Joining through ACP acts on the session a native process was // handed, so the adapter doing the joining has to be the one that contract @@ -2277,14 +2445,18 @@ function* useAcpxProviderState( // first Prompt constructs this session through ACP, so that is what // its construction route says — and a session a native process // constructed is attached to under the identity it already has. - const attachment = yield* constructRoute(agentName, prepared); + const construction = yield* constructRoute(agentName, prepared); + const resumeSessionId = construction?.resumeSessionId; // The pending ACP-first branch, and the only one that defers: an // attachment resumes an identity that already exists, and an - // established placement has one of its own. + // established placement has one of its own. A build alone does not + // defer anything — a session the provider names is still constructed + // by this ensure, bound to the build that names it. const constructing = - state === "pending" && attachment === undefined && prepared.kind === "placement"; + state === "pending" && resumeSessionId === undefined && prepared.kind === "placement"; const entry = yield* ensureFromPrepared(agentName, prepared, { - ...(attachment === undefined ? {} : { attachment }), + ...(construction === undefined ? {} : { build: construction.build }), + ...(resumeSessionId === undefined ? {} : { attachment: { resumeSessionId } }), state, materialization: constructing, deferEstablished: constructing, @@ -2744,9 +2916,18 @@ function* useAcpxProviderState( // return — a store read that fails, an instruction layer this provider will // not replace — and a claim taken before them is one those exits would have // to remember to give back. - const existing = yield* until(store.load(sessionKey)); - let sessionState: "created" | "resumed" = existing ? "resumed" : "created"; - let reconciliation: InstructionReconciliation = existing ? "resumed" : "installed"; + const stored = yield* until(store.load(sessionKey)); + // A pending record is occupancy an earlier attempt left behind: ACPX will + // not reuse it, no backend ever accepted a turn in it, and treating it as a + // conversation would skip the very turn that would make one openable. So + // this launch is creating, whatever is on disk under the same key. + const existing = + stored !== undefined && stored.sessionMaterialization?.state !== "pending" + ? stored + : undefined; + const sessionState: "created" | "resumed" = existing ? "resumed" : "created"; + const reconciliation: InstructionReconciliation = existing ? "resumed" : "installed"; + const known = { agent: agentName, sessionKey, cwd: sessionCwd, launcher: adapter.launcher }; if (existing && storedSystemPrompt(existing) !== instructions) { // ACPX fixes a session's instruction layer when its ACP session is @@ -2764,44 +2945,95 @@ function* useAcpxProviderState( `session "${sessionKey}" already carries a different XMD instruction layer, and ` + `this provider does not replace one. Launch a differently named , or ` + `launch the same prepared instructions again.`, - { agent: agentName, sessionKey, cwd: sessionCwd, launcher: adapter.launcher }, + known, ); } + let build: BoundBuild | undefined; + let retainedBinding: ExecutableBuildBindingV1 | undefined; + if (bindsBuild(adapter)) { + try { + const construction = yield* bindProviderNamed( + agentName, + agentCommand, + adapter, + sessionKey, + existing !== undefined, + "native-launch", + ); + build = construction.build; + retainedBinding = construction.route.executableBinding; + } catch (error) { + if (error instanceof AttachmentRefused) { + return refusal(error.failure.class, error.failure.message, known); + } + throw error; + } + invocation.bound.set(sessionKey, { build, adapter }); + // The check a replay performs at its own first live phase, performed here + // against a live observation. Recording it is what keeps the detach or the + // spawn below from reading the same two accounts a second time. + invocation.reconciled.add(sessionKey); + } + + // Planned before the ensure it decides the shape of, and only for the + // conversation this launch creates through an adapter that says one is + // owed. A resumed session has already been spoken in — whatever made it + // openable happened before this run — so taking a turn in it would be + // spending someone's model turn to learn nothing. The request id is minted + // here rather than at the turn so a replay reading the retained record is + // looking for the turn the first attempt committed to. + const plan: MaterializationPlan | undefined = + sessionState === "created" && adapter.materialization + ? { + promptVersion: adapter.materialization.promptVersion, + requestId: randomUUID(), + prompt: adapter.materialization.prompt, + } + : undefined; + + const ensureInput: AcpRuntimeEnsureInput = { + sessionKey, + agent: agentName, + mode: "persistent", + cwd: sessionCwd, + sessionOptions: { systemPrompt: instructions }, + }; + if (plan !== undefined) { + // A conversation this adapter cannot open until something has been said + // in it is not one the record may assert. ACPX holds it as occupancy + // instead, and only the adapter's own acceptance of the turn below + // promotes it — which is what makes that acceptance decide whether this + // launch reaches a native UI at all. + ensureInput.materialization = "first-turn-acceptance"; + } + // Claimed here, one line before the ensure it is for, and settled by the // same scope-owned cleanup an attachment uses. The handle enters the ledger // the moment it exists, bound to the runtime that made it: everything below // can refuse, and a handle only the managed map knew about is one teardown // could not close through its creator. - const managedEntry = yield* ensureThrough( - undefined, - { - sessionKey, - agent: agentName, - mode: "persistent", + const managedEntry = yield* ensureThrough(build, ensureInput, (handle, entry) => { + const session: Session = { sessionKey, cwd: sessionCwd }; + if (handle.agentSessionId !== undefined) { + session.agentSessionId = handle.agentSessionId; + } + // Established by construction when nothing is owed: this adapter's + // provider returns the identity, so the session exists the moment the + // ensure answers. A launch that owes a turn holds a placement instead, + // and the accepted turn is what establishes it. + return { + handle, + runtime: entry, + agentCommand, cwd: sessionCwd, - sessionOptions: { systemPrompt: instructions }, - }, - (handle, entry) => { - const session: Session = { sessionKey, cwd: sessionCwd }; - if (handle.agentSessionId !== undefined) { - session.agentSessionId = handle.agentSessionId; - } - // Established by construction: this adapter's provider returns the - // identity, so the session exists the moment the ensure answers. - return { - handle, - runtime: entry, - agentCommand, - cwd: sessionCwd, - session, - state: "established" as const, - }; - }, - ); + session, + state: plan === undefined ? "established" : "pending", + }; + }); managed.set(sessionKey, managedEntry); - const nativeSessionId = managedEntry.handle.agentSessionId; + const nativeSessionId = plan === undefined ? managedEntry.handle.agentSessionId : ""; if (nativeSessionId === undefined) { // An ACP session id and an ACPX record id are not native identities, and // neither is a string that merely looks like one. @@ -2814,7 +3046,7 @@ function* useAcpxProviderState( "identity-unavailable", `agent "${agentName}" created a session but asserted no provider-native ` + `session identity, so there is nothing ${adapter.launcher} can resume`, - { agent: agentName, sessionKey, cwd: sessionCwd, launcher: adapter.launcher }, + known, ); } @@ -2836,6 +3068,12 @@ function* useAcpxProviderState( permissionMode: providerOptions.permissionMode, launcher: adapter.launcher, }; + if (retainedBinding) { + record.executableBinding = retainedBinding; + } + if (plan !== undefined) { + record.materialization = plan; + } let model: string | undefined; try { model = yield* effectiveModel(managedEntry.runtime.runtime, managedEntry.handle); @@ -2862,6 +3100,310 @@ function* useAcpxProviderState( return status.models?.currentModelId; } + /** + * What the provider said this turn cost, and nothing else. + * + * A member the provider did not report is left absent rather than copied as + * `undefined`, because these are merged over what earlier events reported: an + * explicit `undefined` would overwrite a figure the provider did give with the + * claim that it gave none. Zero is a figure; saying nothing is not. + */ + function reportedUsage( + breakdown: AcpRuntimeUsageBreakdown | undefined, + cost: AcpRuntimeUsageCost | undefined, + ): MaterializationUsage { + const usage: { + -readonly [K in keyof MaterializationUsage]: MaterializationUsage[K]; + } = {}; + if (breakdown?.inputTokens !== undefined) { + usage.inputTokens = breakdown.inputTokens; + } + if (breakdown?.outputTokens !== undefined) { + usage.outputTokens = breakdown.outputTokens; + } + if (breakdown?.cachedReadTokens !== undefined) { + usage.cachedReadTokens = breakdown.cachedReadTokens; + } + if (breakdown?.cachedWriteTokens !== undefined) { + usage.cachedWriteTokens = breakdown.cachedWriteTokens; + } + if (breakdown?.thoughtTokens !== undefined) { + usage.thoughtTokens = breakdown.thoughtTokens; + } + if (breakdown?.totalTokens !== undefined) { + usage.totalTokens = breakdown.totalTokens; + } + if (cost?.amount !== undefined) { + usage.costAmount = cost.amount; + } + if (cost?.currency !== undefined) { + usage.costCurrency = cost.currency; + } + return usage; + } + + /** One line per member, saying `provider did not report` where it did not. */ + function usageLines(usage: MaterializationUsage): string { + const figure = (value: number | undefined): string => + value === undefined ? "provider did not report" : String(value); + const cost = + usage.costAmount === undefined + ? "provider did not report" + : `${usage.costAmount}${usage.costCurrency === undefined ? "" : ` ${usage.costCurrency}`}`; + return [ + ` input tokens: ${figure(usage.inputTokens)}`, + ` output tokens: ${figure(usage.outputTokens)}`, + ` cached read tokens: ${figure(usage.cachedReadTokens)}`, + ` cached write tokens: ${figure(usage.cachedWriteTokens)}`, + ` thought tokens: ${figure(usage.thoughtTokens)}`, + ` total tokens: ${figure(usage.totalTokens)}`, + ` cost: ${cost}`, + ].join("\n"); + } + + /** + * Spend the one turn this launch planned, and report what it cost. + * + * Reached only for a preparation that planned one, which is only ever a + * conversation this launch created through an adapter that says a fresh one is + * not yet openable. Everything about it is fixed before it runs: the exact + * prompt and the request id come from the retained plan, so a replay is + * looking for this turn rather than authorizing another. + * + * It is not a Prompt. It publishes no event stream, reaches no authored + * middleware, and is deliberately not registered with the permission bridge — + * an unregistered turn's permission request is refused by the bridge's own + * fail-closed answer, so there is no composed handler that could grant this + * turn the tool authority its prompt already forbids. A tool call that arrives + * anyway fails materialization, and the session is never handed over. + * + * It is also what settles the session's identity. The preparation held + * occupancy and asserted nothing, so the conversation this launch hands over + * is named here — by the backend's own acceptance of this turn, and by + * nothing else. + */ + function materializeSession( + placed: Prepared, + prepared: PreparedLaunchRecord, + plan: MaterializationPlan, + ): Operation { + return scoped(function* (): Operation { + const base: Pick = { + phase: "materialized", + promptVersion: plan.promptVersion, + requestId: plan.requestId, + }; + const refused = (message: string): MaterializedLaunchRecord => ({ + ...base, + usage: {}, + response: "", + failure: { class: "materialization-failed", message }, + }); + + const entry = managed.get(prepared.sessionKey); + if (!entry || !isLive(entry)) { + return refused( + `session "${prepared.sessionKey}" is no longer held by this provider, so the turn ` + + `that would make it resumable cannot be taken here`, + ); + } + // Occupancy, and only occupancy. The preparation asserted no identity + // because no backend had accepted a turn here, so a handle that already + // names a conversation is not the placement this launch prepared — and + // spending a turn in it would be spending one in someone else's. + if (entry.handle.agentSessionId !== undefined) { + return refused( + `session "${prepared.sessionKey}" is held under a provider-native identity this ` + + `launch never prepared`, + ); + } + + // Before the turn, never after: what is about to be spent is the reader's + // model turn, and telling them once it is gone is telling them too late. + yield* notifyTerminal( + `${prepared.agent}: spending one model turn in session "${prepared.sessionKey}" so ` + + `${prepared.launcher} can open it (${plan.promptVersion}).`, + ); + + // Registered before the turn exists, and reaching it through a slot the + // start fills. Registering afterwards puts a suspension point between a + // live turn and the only thing that would stop it, and a cancellation + // landing there — a reader closing the grid this launch is running in is + // one — leaves a model turn running in their conversation that nothing + // is waiting for and nothing cancels until this whole provider comes + // down. There is no such gap this way round: `startTurn` answers without + // suspending, so the slot is filled in the step that creates the turn. + let turn: AcpRuntimeTurn | undefined; + let settled = false; + yield* ensure(function* () { + if (!turn || settled) { + return; + } + activeTurns.delete(turn); + try { + yield* until(turn.cancel()); + } catch (error) { + cleanupErrors.push(toError(error)); + } + }); + turn = entry.runtime.runtime.startTurn({ + handle: entry.handle, + text: plan.prompt, + mode: "prompt", + requestId: plan.requestId, + }); + activeTurns.add(turn); + + const started = Date.now(); + let response = ""; + let usage: MaterializationUsage = {}; + let acted = false; + let outcome: Result; + try { + for (const event of yield* each(stream(turn.events))) { + if (event.type === "text_delta" && (event.stream ?? "output") === "output") { + response += event.text; + } else if (event.type === "tool_call") { + // The prompt forbids this, so an arriving tool call means the turn + // did something other than acknowledge. Recorded rather than acted + // on: the turn is already running, and what this decides is that no + // native UI opens on what it left behind. + acted = true; + } else if (event.type === "status") { + usage = { ...usage, ...reportedUsage(event.breakdown, event.cost) }; + } + yield* each.next(); + } + outcome = Ok(yield* until(turn.result)); + } catch (error) { + outcome = Err(toError(error)); + } + settled = true; + const durationMs = Date.now() - started; + + const stopped = ( + message: string, + stopReason?: string, + failureClass: LaunchFailureClass = "materialization-failed", + ): MaterializedLaunchRecord => { + const record: MaterializedLaunchRecord = { + ...base, + durationMs, + usage, + response, + failure: { class: failureClass, message }, + }; + if (stopReason !== undefined) { + record.stopReason = stopReason; + } + return record; + }; + + if (!outcome.ok) { + return stopped(outcome.error.message); + } + const result = outcome.value; + if (result.status === "cancelled") { + return stopped("the materialization turn was cancelled", result.stopReason); + } + if (result.status === "failed") { + return stopped(result.error.message); + } + // ACP defines end_turn as the only successful stop reason, and an adapter + // that omits it on a normal completion means that one. + const stopReason = result.stopReason ?? "end_turn"; + if (stopReason !== "end_turn") { + return stopped( + `the materialization turn ended with stop reason "${stopReason}"`, + stopReason, + ); + } + if (acted) { + return stopped( + "the materialization turn called a tool, which its prompt forbids, so this launch " + + "will not hand the session to a native UI", + stopReason, + ); + } + // The backend's own acceptance, asked for after the turn completed. A + // turn that failed, was cancelled, or simply ended without the adapter + // saying so rejects here, and a turn nothing accepted materialized + // nothing — so there is no identity, and nothing to hand over. + const accepted: Result = yield* until( + turn.materialized.then( + (value): Result => Ok(value), + (error: unknown): Result => Err(toError(error)), + ), + ); + if (!accepted.ok) { + return stopped(accepted.error.message, stopReason); + } + const nativeSessionId = accepted.value.agentSessionId; + if (nativeSessionId === undefined || nativeSessionId.length === 0) { + // The turn itself reached the backend and was accepted; what is absent + // is the conversation's name, which is the same thing an adapter that + // asserts no identity leaves absent before any turn. Classed as that + // rather than as a failed turn, because the turn did not fail. + return stopped( + "the backend accepted the materialization turn without naming the session it made " + + "openable, so there is nothing a native UI could resume", + stopReason, + "identity-unavailable", + ); + } + // The provider's own name for the turn, read from the response metadata + // this package recognizes and from nothing else. Without it there is no + // evidence the exchange reached the backend rather than a socket. + const named = checkpointFromResult(result); + if (named === undefined) { + return stopped( + "the materialization turn completed without naming the provider turn it was", + stopReason, + ); + } + if (response.length === 0) { + return stopped( + "the materialization turn produced no assistant response, so nothing was said in " + + "the conversation this launch was making openable", + stopReason, + ); + } + + // The durable order the prompt path uses, for the same reason: ACPX's own + // record asserted the identity as it promoted this session, the host's + // mapping commits second, and the placement becomes established last. A + // host that refuses to retain it leaves a session nobody may prompt + // through, so the handle is given up rather than held under a mapping + // that does not exist. + const identity: AcpxSessionIdentity = { + acpxRecordId: accepted.value.acpxRecordId, + agentSessionId: nativeSessionId, + }; + if (sessions?.established && placed.kind === "placement") { + try { + yield* sessions.established(placed.placement, identity); + } catch (error) { + yield* abandonHandle(entry, "session retention refused"); + if (!holding(prepared.sessionKey)) { + detachPlacement(prepared.sessionKey, entry); + } + return stopped(toError(error).message, stopReason); + } + } + entry.session.agentSessionId = nativeSessionId; + entry.state = "established"; + + yield* notifyTerminal( + `${prepared.agent}: materialization turn completed in ${durationMs}ms ` + + `(${named.provider} ${named.kind} ${named.value}).\n` + + `${response}\n` + + `${usageLines(usage)}`, + ); + + return { ...base, nativeSessionId, turn: named, durationMs, usage, response, stopReason }; + }); + } + /** * Give up this provider's ACP handle for `sessionKey`, keeping its record. * @@ -2907,12 +3449,14 @@ function* useAcpxProviderState( if (invocation.fresh.has(sessionKey)) { return { phase: "detached" }; } - // Nothing was prepared live, so this is a replay — and for a session XMD - // named, this detach is its first live phase. The two durable accounts are - // checked here rather than at the spawn, because retaining a detach is - // itself advancing the launch: a journal that says the handoff began is not - // something to write about a session this run cannot confirm. - if (prepared.identityProvenance === "client-allocated") { + // For a session bound to a build, this detach may be the first live phase of + // a replay. The durable accounts are checked here rather than at the spawn, + // because retaining a detach is itself advancing the launch: a journal that + // says the handoff began is not something to write about a session this run + // cannot confirm. A launch that prepared live has already checked them, and + // this reads them once per invocation. + const bound = adapterFor(prepared.agent); + if (bound && bindsBuild(bound)) { const refused = yield* reconcile(invocation, prepared, agentCommand); if (refused) { return { phase: "detached", failure: refused }; @@ -3048,6 +3592,7 @@ function* useAcpxProviderState( placement, ), ), + materialize: (prepared, plan) => materializeSession(placement, prepared, plan), detach: (prepared) => detachSession(invocation, prepared, agentCommandOf(placement)), exit: (prepared) => runNativeUi(invocation, prepared, agentCommandOf(placement)), @@ -3086,11 +3631,11 @@ function* useAcpxProviderState( function* nativeCommand( invocation: LaunchInvocation, prepared: PreparedLaunchRecord, - adapter: ClientAllocatedAdapter, + adapter: NativeAdapter, agentCommand: string, ): Operation> { const publishedHere = invocation.fresh.get(prepared.sessionKey); - if (publishedHere === undefined) { + if (publishedHere === undefined && bindsBuild(adapter)) { // A replay. Whichever suffix it holds, the two durable accounts are // checked before this run does anything a native process could act on — // a detached replay reaches here as its first live phase, and a @@ -3100,13 +3645,21 @@ function* useAcpxProviderState( return Err(new RetainedRefusal(refused)); } } - const creating = publishedHere ?? invocation.detachedLive.has(prepared.sessionKey); - const argv = creating - ? adapter.create( - prepared.nativeSessionId, - yield* privateInstructionFile(prepared.instructions), - ) - : adapter.resume(prepared.nativeSessionId); + // Creation is a question only for an identity XMD chose. A session the + // provider named already exists — ACP made it — so there is nothing here to + // create and no instruction layer to install through argv. + let argv: string[]; + if ( + allocatesIdentity(adapter) && + (publishedHere ?? invocation.detachedLive.has(prepared.sessionKey)) + ) { + argv = adapter.create( + prepared.nativeSessionId, + yield* privateInstructionFile(prepared.instructions), + ); + } else { + argv = adapter.resume(prepared.nativeSessionId); + } // The exact file this run observed, in place of the launcher name the // adapter writes. The name is what durable records carry; the path is what // this invocation spawns, and it is live only. A legacy session observed no @@ -3147,15 +3700,20 @@ function* useAcpxProviderState( agentCommand: string, ): Operation { const stop = (message: string): LaunchFailure => ({ class: "identity-unavailable", message }); - // Which provider retained this preparation, before anything is compared - // against it. A record another provider wrote describes a session this one - // does not own, and an ACPX route that happens to agree about a UUID is not - // evidence that it does — it is two providers naming one string. - if (prepared.provider !== ACPX_PROVIDER) { - return stop( - `session "${prepared.sessionKey}" was prepared by a different provider, so this one ` + - `cannot confirm the conversation it names`, + const disagree = (): LaunchFailure => + stop( + `session "${prepared.sessionKey}" is described differently by its journal and its construction route, and neither account repairs the other`, ); + const unrecorded = (): LaunchFailure => ({ + class: "executable-binding-refused", + message: `session "${prepared.sessionKey}" was prepared before XMD recorded which build its identity belongs to, so this run cannot confirm the conversation it names`, + }); + if (prepared.provider !== ACPX_PROVIDER) { + return stop(`session "${prepared.sessionKey}" was prepared by a different provider`); + } + const adapter = adapterFor(prepared.agent); + if (!adapter || !bindsBuild(adapter)) { + return unrecorded(); } let route; try { @@ -3163,66 +3721,64 @@ function* useAcpxProviderState( } catch (error) { return stop(routeMessage(error)); } - if (!route || route.route !== "client-native") { - return stop( - `session "${prepared.sessionKey}" has no client-allocated construction route, so the ` + - `conversation this launch prepared cannot be confirmed`, - ); - } if ( - route.nativeSessionId !== prepared.nativeSessionId || - route.identityProvenance !== prepared.identityProvenance || - route.instructionsDigest !== prepared.instructionsDigest || - route.launcher !== prepared.launcher + !route || + route.provider !== ACPX_PROVIDER || + route.agent !== agentCommand || + route.sessionKey !== prepared.sessionKey ) { - return stop( - `session "${prepared.sessionKey}" is described differently by its journal and its ` + - `construction route, and neither account repairs the other`, - ); + return disagree(); } - // A launch that never got as far as the native process, prepared under a - // contract that published no account of the build that accepted the - // identity. The cross-check below is what makes an incomplete launch - // resumable, and with only one account there is nothing to hold to - // anything. A completed launch never reaches this code. - if (route.schema === "session-route.v1" || prepared.executableBinding === undefined) { - return { - class: "executable-binding-refused", - message: - `session "${prepared.sessionKey}" was prepared before XMD recorded which build ` + - `accepted its identity, so this run cannot confirm the conversation it names`, - }; + let retainedBinding: ExecutableBuildBindingV1; + if (allocatesIdentity(adapter)) { + if ( + route.route !== "client-native" || + route.nativeSessionId !== prepared.nativeSessionId || + route.identityProvenance !== prepared.identityProvenance || + route.instructionsDigest !== prepared.instructionsDigest || + route.launcher !== prepared.launcher + ) { + return disagree(); + } + if (route.schema === "session-route.v1") { + return unrecorded(); + } + const foreign = admitRouteContract(prepared.sessionKey, route, adapter); + if (foreign) { + return foreign; + } + retainedBinding = route.executableBinding; + } else { + const foreign = providerRouteContract(agentCommand, prepared.sessionKey, adapter, route); + if (foreign) { + return foreign; + } + if ( + route.schema !== "session-route.v3" || + prepared.identityProvenance !== "provider-returned" || + prepared.launcher !== adapter.launcher + ) { + return disagree(); + } + retainedBinding = route.executableBinding; + try { + yield* retainedAssertion(prepared.sessionKey, prepared.nativeSessionId); + } catch (error) { + if (error instanceof AttachmentRefused) { + return error.failure; + } + throw error; + } } - // Both directions, because neither of these is the live build: they are two - // durable accounts of one observation, so one saying less than the other is - // already a disagreement rather than a build that has gone quiet. + if (prepared.executableBinding === undefined) { + return unrecorded(); + } + // These are two durable accounts, not a retained-versus-live admission test. if ( - !sameExecutableBuild(route.executableBinding, prepared.executableBinding) || - !sameExecutableBuild(prepared.executableBinding, route.executableBinding) + !sameExecutableBuild(retainedBinding, prepared.executableBinding) || + !sameExecutableBuild(prepared.executableBinding, retainedBinding) ) { - return stop( - `session "${prepared.sessionKey}" is described differently by its journal and its ` + - `construction route, and neither account repairs the other`, - ); - } - // The live half, before the first effect a native process could act on. - const adapter = adapterFor(prepared.agent); - if (!adapter || !allocatesIdentity(adapter)) { - return { - class: "executable-binding-refused", - message: - `session "${prepared.sessionKey}" names a launcher this build has no way to observe, ` + - `so the conversation it prepared cannot be confirmed`, - }; - } - // Still ahead of the observation, and ahead of every phase a replay could - // act with. A replay resumes a conversation someone else's process may - // already be in, so what it must establish first is that this adapter is - // what that conversation was constructed under — not that some adapter under - // this launcher is admitted for something. - const foreign = admitRouteContract(prepared.sessionKey, route, adapter); - if (foreign) { - return foreign; + return disagree(); } let build: BoundBuild; try { @@ -3233,10 +3789,6 @@ function* useAcpxProviderState( } throw error; } - // The replay's own first live phase, and this is still ahead of it. A - // predecessor that was admitted proves nothing about this run: the build - // under the same command may have been replaced since, and a capability is - // a claim about the build rather than about the session. const unproved = admitCapability(prepared.agent, "native-launch", build); if (unproved) { return unproved; @@ -3263,9 +3815,7 @@ function* useAcpxProviderState( } let resolved: Result; try { - resolved = allocatesIdentity(adapter) - ? yield* nativeCommand(invocation, prepared, adapter, agentCommand) - : Ok(adapter.resume(prepared.nativeSessionId)); + resolved = yield* nativeCommand(invocation, prepared, adapter, agentCommand); } catch { // Anything raised here came from private setup — an adapter's own code, a // temporary directory, a write. Whatever shape it has, it says nothing @@ -3385,11 +3935,13 @@ function* useAcpxProviderState( // before the caller saw the failure, and preserving the route is what // stops that uncertainty from later being reclassified as // client-native. - const attachment = yield* constructRoute(agentName, prepared); + const construction = yield* constructRoute(agentName, prepared); + const resumeSessionId = construction?.resumeSessionId; const session = yield* turns.withSlot(prepared.sessionKey, () => withSessionRoute(context, function* () { const entry = yield* ensureFromPrepared(agentName, prepared, { - ...(attachment === undefined ? {} : { attachment }), + ...(construction === undefined ? {} : { build: construction.build }), + ...(resumeSessionId === undefined ? {} : { attachment: { resumeSessionId } }), state, }); return entry.session; diff --git a/packages/acp/src/session-route.ts b/packages/acp/src/session-route.ts index d3a417de8..ad1ea910c 100644 --- a/packages/acp/src/session-route.ts +++ b/packages/acp/src/session-route.ts @@ -99,8 +99,22 @@ export interface AgentSessionRouteV2 { executableBinding: ExecutableBuildBindingV1; } +/** + * V3 fixes the Codex provider-returned construction protocol. Its original + * binding stays audit evidence even when a later compatible executable resumes + * the conversation. Provider identity is asserted later and never belongs here. + */ +export interface AgentSessionRouteV3 { + schema: "session-route.v3"; + route: "acp-first"; + provider: string; + agent: string; + sessionKey: string; + executableBinding: ExecutableBuildBindingV1; +} + /** Every route form this build accepts. */ -export type AgentSessionRoute = AgentSessionRouteV1 | AgentSessionRouteV2; +export type AgentSessionRoute = AgentSessionRouteV1 | AgentSessionRouteV2 | AgentSessionRouteV3; /** Why a route could not be used. Never carries a path or provider-private state. */ export class AgentSessionRouteError extends Error { @@ -116,6 +130,7 @@ const CLIENT_NATIVE_MEMBERS = [ "launcher", ]; const BOUND_CLIENT_NATIVE_MEMBERS = [...CLIENT_NATIVE_MEMBERS, "executableBinding"]; +const BOUND_ACP_FIRST_MEMBERS = [...ACP_FIRST_MEMBERS, "executableBinding"]; const BINDING_MEMBERS = ["schema", "executableDigest"]; /** * The one member a binding may omit. @@ -201,10 +216,15 @@ export function parseAgentSessionRoute(value: unknown): AgentSessionRoute | unde if (!isRecord(value)) { return undefined; } - const bound = value.schema === "session-route.v2"; - if (!bound && value.schema !== "session-route.v1") { + const { schema } = value; + if ( + schema !== "session-route.v1" && + schema !== "session-route.v2" && + schema !== "session-route.v3" + ) { return undefined; } + const bound = schema === "session-route.v2"; const { provider, agent, sessionKey } = value; if (typeof provider !== "string" || provider.length === 0) { return undefined; @@ -216,12 +236,22 @@ export function parseAgentSessionRoute(value: unknown): AgentSessionRoute | unde return undefined; } if (value.route === "acp-first") { - // There is no bound ACP-first form, so a V2 record claiming one is state - // this build cannot account for rather than a route with a spare member. - return !bound && exactly(value, ACP_FIRST_MEMBERS) - ? { schema: "session-route.v1", route: "acp-first", provider, agent, sessionKey } + if (schema === "session-route.v1") { + return exactly(value, ACP_FIRST_MEMBERS) + ? { schema, route: "acp-first", provider, agent, sessionKey } + : undefined; + } + if (schema !== "session-route.v3" || !exactly(value, BOUND_ACP_FIRST_MEMBERS)) { + return undefined; + } + const executableBinding = parseExecutableBinding(value.executableBinding); + return executableBinding + ? { schema, route: "acp-first", provider, agent, sessionKey, executableBinding } : undefined; } + if (schema === "session-route.v3") { + return undefined; + } const members = bound ? BOUND_CLIENT_NATIVE_MEMBERS : CLIENT_NATIVE_MEMBERS; if (value.route !== "client-native" || !exactly(value, members)) { return undefined; @@ -285,7 +315,7 @@ export function serializeAgentSessionRoute(route: AgentSessionRoute): string { payload.instructionsDigest = route.instructionsDigest; payload.launcher = route.launcher; } - if (route.schema === "session-route.v2") { + if (route.schema === "session-route.v2" || route.schema === "session-route.v3") { payload.executableBinding = { schema: route.executableBinding.schema, ...(route.executableBinding.reportedVersion === undefined diff --git a/packages/acp/tests/acceptance.test.ts b/packages/acp/tests/acceptance.test.ts index 83efa70a3..cdfede119 100644 --- a/packages/acp/tests/acceptance.test.ts +++ b/packages/acp/tests/acceptance.test.ts @@ -19,7 +19,7 @@ import { createFakeRuntime, makeRegistry, makeStore, useFlatWorld } from "./help const CWD = "/work"; const DOC = [ - '', + '', '', '', "", @@ -44,9 +44,9 @@ describe("Tier XA — ACPX provider through the rootProvider seam", () => { factory: createAcpxProvider({ createRuntime: harness.create, sessionStore: makeStore(), - agentRegistry: makeRegistry({ codex: "codex-cmd" }), + agentRegistry: makeRegistry({ gemini: "gemini-cmd" }), }), - options: { defaultAgent: "codex", permissionMode: "deny-all" }, + options: { defaultAgent: "gemini", permissionMode: "deny-all" }, }, }); @@ -102,7 +102,7 @@ describe("Tier WAP — same-named sibling Sessions", () => { yield* writeTextFile( docPath, [ - '', + '', '', "first", "", @@ -122,7 +122,7 @@ describe("Tier WAP — same-named sibling Sessions", () => { factory: createAcpxProvider({ createRuntime: harness.create, sessionStore: makeStore(), - agentRegistry: makeRegistry({ codex: "codex-cmd" }), + agentRegistry: makeRegistry({ gemini: "gemini-cmd" }), sessions: { // deno-lint-ignore require-yield *place(context) { @@ -140,7 +140,7 @@ describe("Tier WAP — same-named sibling Sessions", () => { }, }, }), - options: { defaultAgent: "codex", permissionMode: "deny-all" }, + options: { defaultAgent: "gemini", permissionMode: "deny-all" }, }, }); // Middleware that rewrites the descriptive name on its way through, which @@ -194,7 +194,7 @@ describe("Tier WAP — same-named sibling Sessions", () => { yield* writeTextFile( docPath, [ - '', + '', '', "first", "", @@ -214,7 +214,7 @@ describe("Tier WAP — same-named sibling Sessions", () => { factory: createAcpxProvider({ createRuntime: harness.create, sessionStore: makeStore(), - agentRegistry: makeRegistry({ codex: "codex-cmd" }), + agentRegistry: makeRegistry({ gemini: "gemini-cmd" }), sessions: { // deno-lint-ignore require-yield *place(context) { @@ -227,7 +227,7 @@ describe("Tier WAP — same-named sibling Sessions", () => { }, }, }), - options: { defaultAgent: "codex", permissionMode: "deny-all" }, + options: { defaultAgent: "gemini", permissionMode: "deny-all" }, }, }); diff --git a/packages/acp/tests/adapter-protocol.test.ts b/packages/acp/tests/adapter-protocol.test.ts index e5cb3ce02..7ee9bfb5d 100644 --- a/packages/acp/tests/adapter-protocol.test.ts +++ b/packages/acp/tests/adapter-protocol.test.ts @@ -352,6 +352,79 @@ describe("Tier EA — the embedded adapters' prompt-response metadata", () => { expect(metaOf(first, "codex")).toEqual({ turnId: `turn:${sessionId}:1` }); expect(metaOf(second, "codex")).toEqual({ turnId: `turn:${sessionId}:2` }); }); + it("EA9: interleaved Codex sessions each report the thread they opened", function* () { + const adapter = yield* useAdapter("codex", CODEX_ENV); + yield* initialize(adapter); + + const alpha = yield* effectionSpawn(() => + adapter.request("session/new", { cwd: process.cwd(), mcpServers: [] }), + ); + const beta = yield* effectionSpawn(() => + adapter.request("session/new", { cwd: process.cwd(), mcpServers: [] }), + ); + const [one, two] = yield* all([alpha, beta]); + + // The App Server named each thread, so this is the identity that surface + // emitted compared with what the adapter reported on `_meta`. A top-level + // session id cannot stand in: a client reading that cannot tell an identity + // the provider allocated from one it invented itself. + expect(metaOf(one, "agentSessionId")).toBe(one["sessionId"]); + expect(metaOf(two, "agentSessionId")).toBe(two["sessionId"]); + // Two at once, so an adapter reporting anything session-global — the last + // thread it opened, say — hands at least one of them the other's answer. + expect(metaOf(one, "agentSessionId")).not.toBe(metaOf(two, "agentSessionId")); + }); + + it("EA10: reopening a Codex session reports the thread the App Server reopened", function* () { + const adapter = yield* useAdapter("codex", CODEX_ENV); + yield* initialize(adapter); + // A thread opened first, so the reopened answers below are distinguishable + // from the most recent thread this adapter process created. + const opened = yield* openSession(adapter); + + const resuming = yield* effectionSpawn(() => + adapter.request("session/resume", { + sessionId: "thread-resumed", + cwd: process.cwd(), + mcpServers: [], + }), + ); + const loading = yield* effectionSpawn(() => + adapter.request("session/load", { + sessionId: "thread-loaded", + cwd: process.cwd(), + mcpServers: [], + }), + ); + const [resumed, loaded] = yield* all([resuming, loading]); + + // Continuing a conversation needs its identity as much as starting one + // does, and reopening is the route a run takes on every turn after the + // first. + expect(metaOf(resumed, "agentSessionId")).toBe("canonical:thread-resumed"); + expect(metaOf(loaded, "agentSessionId")).toBe("canonical:thread-loaded"); + expect(metaOf(resumed, "agentSessionId")).not.toBe(opened); + expect(metaOf(loaded, "agentSessionId")).not.toBe(opened); + }); + + it("EA11: the Claude adapter reports no native session identity", function* () { + const adapter = yield* useAdapter("claude", CLAUDE_ENV); + yield* initialize(adapter); + + const session = yield* adapter.request("session/new", { + cwd: process.cwd(), + mcpServers: [], + }); + + // Claude is handed the identity it uses rather than allocating one, so + // there is nothing for its adapter to report back. This is the absence the + // client-allocated route depends on: were a value to appear here, a client + // choosing a route by whether the provider named the session would start + // taking the wrong one. + expect(typeof session["sessionId"]).toBe("string"); + expect(metaOf(session, "agentSessionId")).toBeUndefined(); + }); + /** * The adapter resource reads one child's stdout for as long as it holds it. * The count is read after the resource has been torn down and before the diff --git a/packages/acp/tests/codex-surfaces.test.ts b/packages/acp/tests/codex-surfaces.test.ts new file mode 100644 index 000000000..7a9d260e0 --- /dev/null +++ b/packages/acp/tests/codex-surfaces.test.ts @@ -0,0 +1,1176 @@ +/** + * Tier CS — Codex surface classification and submission readiness + * (specs/native-agent-session-launch-spec.md §Provider-returned adapters). + * + * Two decisions in the Codex native proof are worth more than the turns they + * guard. One reads a screen that produced no answer and says whether the + * environment refused; the other decides whether a composer will take a turn at + * all, and so whether a model turn is spent. Both were wrong in ways only a paid + * run could show: + * + * - A bare `usage limit` substring matched Codex's own benign banner — + * `You have 2 usage limit resets available` — which an account with headroom + * to spare shows. Two authorized runs were reported as blocked by a quota that + * a `/status` on the same account said was 42% unspent. + * - Readiness was read off the byte stream, so a composer whose bytes had + * arrived counted as ready even while `model: loading` was still painted + * behind it, and the confirmation that the turn had been typed swallowed its + * own timeout and pressed Enter anyway. + * + * These cases drive the exact predicates and the exact driver the fixture + * consumes, against hand-written frames. No Codex process starts and no model + * turn is spent. + */ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import type { Operation } from "effection"; +import { + presentedDuring, + TerminalReader, + TerminalScreen, + waitForPresented, +} from "./fixtures/terminal-screen.ts"; +import type { ScreenSnapshot } from "./fixtures/terminal-screen.ts"; +import { + classifyInitialSurface, + classifyStall, + COMPOSER, + composerReady, + exhaustedUsage, + MISSING_SESSION, + MODEL_LOADING, + PROMPT_TAIL, + promptPresented, + reachComposer, + submitWhenReady, + TRUST_DIALOG, +} from "./fixtures/codex-surfaces.ts"; +import type { + SubmissionBounds, + SubmissionPort, + SurfaceBounds, + SurfacePort, +} from "./fixtures/codex-surfaces.ts"; + +/** The pty the proof opens, so these frames wrap where that one wraps. */ +const ROWS = 40; +const COLUMNS = 120; + +const ESC = String.fromCharCode(0x1b); +const csi = (body: string): string => `${ESC}[${body}`; +const at = (row: number, column: number): string => csi(`${row};${column}H`); +const ALT_ON = csi("?1049h"); +const CLEAR = csi("2J"); +/** Hold the drawing back, and put the finished frame on show. */ +const SYNC_ON = csi("?2026h"); +const SYNC_OFF = csi("?2026l"); + +function paint(rows: readonly string[]): string { + return rows.map((row, index) => `${at(index + 1, 1)}${csi("2K")}${row}`).join(""); +} + +/** A bordered pane row, padded to its right border as a TUI pads one. */ +function pane(text: string, width = 80): string { + return `│ ${text.padEnd(width)} │`; +} + +/** A whole frame, drawn over whatever was there. */ +function frame(rows: readonly string[]): string { + return `${CLEAR}${paint(rows)}`; +} + +/** + * One read of a pty, carrying several finished frames. + * + * Each is committed in its own right, so every one of them is a frame a terminal + * put on show — and all of them arrive together, because a `data` event is + * however many bytes happened to be waiting rather than however much of a + * drawing was finished. + */ +function oneRead(...frames: readonly (readonly string[])[]): string { + return frames.map((rows) => `${SYNC_ON}${frame(rows)}${SYNC_OFF}`).join(""); +} + +function showing(rows: readonly string[]): ScreenSnapshot { + const terminal = new TerminalScreen({ rows: ROWS, columns: COLUMNS }); + terminal.write(`${ALT_ON}${frame(rows)}`); + return terminal.presented(); +} + +/** Codex's own banner, offering the resets an account still has in hand. */ +const BENIGN_RESETS = "You have 2 usage limit resets available. Run /usage to use one."; +/** What `/status` reports on the same account: headroom, in the same words. */ +const BENIGN_STATUS = "Usage limit: 42% left this week (resets Monday)"; +/** Help text about the refusal, which is not the refusal. */ +const CONDITIONAL_HELP = "If you've hit your usage limit, run /usage to see when it resets."; +/** What an account with nothing left is actually told. */ +const GENUINE_REFUSAL = "You've hit your usage limit."; + +/** + * The wording the previous round accepted, none of which anybody has witnessed. + * + * Kept as cases rather than deleted quietly: each one was a way for a screen + * that is not the refusal to reach `ENVIRONMENT_BLOCKED`, and a list that names + * them fails loudly if one is ever put back. + */ +/** + * The refusal's own words, inside a sentence that is somebody else's. + * + * What separates these from the refusal is where the sentence begins, and + * nothing else — the wording, the punctuation and the whole of the phrase are + * all present. They are the cases the run-prefix rule exists for; a containment + * test over the same run accepts every one of them. + */ +const EMBEDDED: readonly string[] = [ + "Note: You've hit your usage limit.", + "We don't think you've hit your usage limit.", + "It looks like you've hit your usage limit.", +]; + +const UNWITNESSED: readonly string[] = [ + "You've hit your weekly limit.", + "You've reached your usage limit.", + "You've reached your weekly limit.", + "You have hit your usage limit.", + "You have reached your usage limit.", + "Usage limit reached", + // The sentence's own opening, carrying on into a limit that is not the one + // this proof was refused by. Where it ends is part of what was witnessed. + "You've hit your usage limit for images.", +]; + +const READY = [pane("OpenAI Codex (v0.153.2)"), "", pane(COMPOSER), pane("model: gpt-5.6-sol")]; +const LOADING = [pane("OpenAI Codex (v0.153.2)"), "", pane(COMPOSER), pane(MODEL_LOADING)]; +const TRUSTING = [pane(TRUST_DIALOG), pane("1. Yes, continue"), "", pane(COMPOSER)]; + +describe("Tier CS — Codex surface classification", () => { + it("CS1: the resets banner is not exhaustion, composer or no composer", function* () { + // Bare, as it lands over a session that is about to work perfectly well. + expect(exhaustedUsage(showing([pane(BENIGN_RESETS)]))).toBe(false); + expect(classifyStall(showing([pane(BENIGN_RESETS)]))).toEqual({ + tag: "unknown", + verdict: "HARNESS_FAILED", + }); + + // And where it actually appears: above a composer waiting for a turn. The + // classification is the composer's resting state, which explains nothing. + const withComposer = showing([pane(BENIGN_RESETS), "", pane(COMPOSER)]); + expect(exhaustedUsage(withComposer)).toBe(false); + expect(classifyStall(withComposer)).toEqual({ + tag: "composer-idle", + verdict: "HARNESS_FAILED", + }); + }); + + it("CS2: status and headroom text is not exhaustion", function* () { + const status = showing([pane(BENIGN_STATUS), pane("Weekly limit: 42% left")]); + expect(exhaustedUsage(status)).toBe(false); + expect(classifyStall(status).verdict).toBe("HARNESS_FAILED"); + + // The words are on the screen. It is the sentence that decides, not them. + const terminal = new TerminalScreen({ rows: ROWS, columns: COLUMNS }); + terminal.write(`${ALT_ON}${frame([pane(BENIGN_STATUS)])}`); + expect(terminal.presented().rows.join("")).toContain("Usage limit"); + }); + + it("CS3: the exhausted-limit sentence is exhaustion", function* () { + const refused = showing([pane(GENUINE_REFUSAL), "", pane(COMPOSER)]); + expect(exhaustedUsage(refused)).toBe(true); + // Ahead of the idle composer drawn under it, which is the surface a refusal + // leaves behind and would otherwise be all this reported. + expect(classifyStall(refused)).toEqual({ + tag: "usage-limited", + verdict: "ENVIRONMENT_BLOCKED", + }); + + // Case and typographic apostrophes are the terminal's, not the sentence's. + expect(exhaustedUsage(showing([pane("you’ve hit your usage limit.")]))).toBe(true); + // Wrapped across two rows by a 40-column pane, which is where a substring + // search on a single row would lose it. + expect(exhaustedUsage(showing([pane("You've hit your usage", 20), pane("limit.", 20)]))).toBe( + true, + ); + }); + + it("CS4: a refusal inside an uncommitted frame is not classified until shown", function* () { + const terminal = new TerminalScreen({ rows: ROWS, columns: COLUMNS }); + terminal.write(`${ALT_ON}${frame([pane("Working"), pane("esc to interrupt")])}`); + expect(classifyStall(terminal.presented()).tag).toBe("still-working"); + + // The refusal is drawn inside a hold: cells this screen has and no terminal + // ever put on show. Reading them would blame an environment for a frame + // nobody was looking at. + terminal.write(`${SYNC_ON}${frame([pane(GENUINE_REFUSAL), "", pane(COMPOSER)])}`); + expect(exhaustedUsage(terminal.presented())).toBe(false); + expect(classifyStall(terminal.presented())).toEqual({ + tag: "still-working", + verdict: "HARNESS_FAILED", + }); + + // It counts from the commit, and not before. + terminal.write(SYNC_OFF); + expect(classifyStall(terminal.presented())).toEqual({ + tag: "usage-limited", + verdict: "ENVIRONMENT_BLOCKED", + }); + }); + + it("CS5: only the witnessed refusal may blame the environment", function* () { + // Reading a screen is the only way to get here. There is no exported + // tag-to-verdict step, so no caller can hand this proof a `usage-limited` + // it decided on for itself — the refusal has to be on a screen. + expect(classifyStall(showing([pane(GENUINE_REFUSAL)])).verdict).toBe("ENVIRONMENT_BLOCKED"); + + // Everything else describes where the terminal got to, and a proof that read + // any of them as an explanation would name a cause the screen never gave. + for (const rows of [ + [pane(BENIGN_RESETS), "", pane(COMPOSER)], + [pane(BENIGN_STATUS)], + [pane(CONDITIONAL_HELP), "", pane(COMPOSER)], + [pane("Working"), pane("esc to interrupt")], + [pane("Allow command"), pane("codex wants to run something")], + [pane(COMPOSER)], + [pane("something no version of this proof has ever seen")], + ]) { + expect(exhaustedUsage(showing(rows))).toBe(false); + expect(classifyStall(showing(rows)).verdict).toBe("HARNESS_FAILED"); + } + + // The refusal is the sentence, not the words in it. A run that begins + // somewhere else began somewhere else. + for (const sentence of EMBEDDED) { + expect(exhaustedUsage(showing([pane(sentence)]))).toBe(false); + expect(classifyStall(showing([pane(sentence)])).verdict).toBe("HARNESS_FAILED"); + } + + // Wording nobody has seen stays wording nobody has seen. An unrecognized + // screen costs an investigation; a wrongly recognized one costs the truth. + for (const wording of UNWITNESSED) { + expect(exhaustedUsage(showing([pane(wording)]))).toBe(false); + expect(classifyStall(showing([pane(wording)])).verdict).toBe("HARNESS_FAILED"); + } + }); +}); + +/** + * A port over scripted *reads*, built out of the pieces the pty is built out of. + * + * The same {@link TerminalReader} and the same {@link waitForPresented} the live + * path uses, for the same reason {@link surfaceFake} uses them: one entry of the + * script is one read, and a read can finish several frames. Submission needs + * that as much as reaching a composer does — Codex draws its refusal above a + * composer, so the refusal and the ready composer it must not be typed into can + * arrive in one read, with only the last of them left on screen. + */ +function fakePort(script: readonly string[]): { + port: SubmissionPort; + log: { typed: string[]; sent: string[]; waited: string[]; paused: number[]; charged: number }; +} { + const reader = new TerminalReader({ rows: ROWS, columns: COLUMNS }, false); + reader.write(ALT_ON); + const log: { + typed: string[]; + sent: string[]; + waited: string[]; + paused: number[]; + charged: number; + } = { typed: [], sent: [], waited: [], paused: [], charged: 0 }; + let next = 0; + const advance = (): boolean => { + const chunk = script[next]; + if (chunk === undefined) { + return false; + } + next += 1; + reader.write(chunk); + return true; + }; + return { + log, + port: { + screen: () => reader.snapshot(), + waitForScreen(name, _ms, predicate) { + log.waited.push(name); + return waitForPresented(reader, predicate, function* (wait): Operation { + // A script that runs out is a bound that ran out: no further byte is + // ever going to arrive, which is what a spent bound means. + while (!wait.settled() && advance()) { + // Reads, until one of them answers or there are none left. + } + }); + }, + type(text) { + log.typed.push(text); + }, + send(typed) { + // Ordered as the pty orders it: the turn is charged at the keystroke, + // ahead of the byte, so a run that reaches here has spent one either way. + log.charged += 1; + log.sent.push(typed); + }, + pause(ms, predicate) { + log.paused.push(ms); + // Time passing is frames arriving, which is how an overlay or a model + // that is still loading gets its chance to show up — and how a refusal + // gets its chance to be drawn and painted back over with nobody waiting. + return presentedDuring( + reader, + predicate, + (function* (): Operation { + advance(); + })(), + ); + }, + }, + }; +} + +const BOUNDS: SubmissionBounds = { ready: 1_000, startupGrace: 10, presented: 1_000 }; + +/** Minted for one run, and so the only text on the screen unique to that run. */ +const MARKER = "MK4A17BE0C9D31"; + +/** Shaped like the proof's turn: long, and ending in a marker minted per run. */ +const TURN = + "Do not use any tools. Reply with exactly two lines. First line: the first " + + "sentence of the body text under the 'Implementor' heading of your contract, " + + "copied verbatim. Second line: remember this marker for later, MK4A17BE0C9D31"; + +/** The composer with the turn in it — placeholder gone, as a real one is. */ +const TYPED_IN = [pane("OpenAI Codex (v0.153.2)"), "", pane(TURN.slice(-40)), pane("Send with ⏎")]; + +/** + * The refusal where Codex draws it: above a composer, rather than instead of one. + * + * The shape that makes this path dangerous, and the same one CS15 pins for the + * surface classifier. A screen can carry the refusal and a composer at once, so + * every predicate about the composer is satisfied by a screen that has already + * said the session does not exist. + */ +const REFUSED_READY = [ + pane("OpenAI Codex (v0.153.2)"), + pane(`${MISSING_SESSION} 019a-…`), + "", + pane(COMPOSER), +]; + +/** + * The refusal once the composer has gone with it. + * + * The other half of why a batch is not the whole answer. This screen fails + * {@link composerReady}, so a wait collecting on readiness can never be holding + * it — it can only be the screen a read ended on, which nothing but a read of + * the screen itself will find. + */ +const REFUSED_ALONE = [pane("OpenAI Codex (v0.153.2)"), pane(`${MISSING_SESSION} 019a-…`)]; + +/** The same refusal, over a composer holding the turn this run typed. */ +const REFUSED_TYPED = [ + pane("OpenAI Codex (v0.153.2)"), + pane(`${MISSING_SESSION} 019a-…`), + "", + pane(TURN.slice(-40)), + pane("Send with ⏎"), +]; + +describe("Tier CS — submission readiness", () => { + it("CS6: a model still loading takes no input at all", function* () { + expect(composerReady(showing(LOADING))).toBe(false); + // The composer is drawn and its bytes have arrived, which is exactly what + // the stream-reading version accepted. + expect(showing(LOADING).rows.join("")).toContain("Ask Codex to do anything"); + + const { port, log } = fakePort([frame(LOADING), frame(LOADING)]); + const outcome = yield* submitWhenReady(port, TURN, BOUNDS); + + expect(outcome).toEqual({ submitted: false, reason: "composer-never-ready" }); + // Nothing was typed, so nothing was queued into a composer that could take + // it later, and Enter was never reached. + expect(log.typed).toEqual([]); + expect(log.sent).toEqual([]); + }); + + it("CS7: a later ready frame permits exactly one submission", function* () { + const { port, log } = fakePort([ + frame(LOADING), + frame(READY), + frame(READY), + frame(TYPED_IN), + frame(TYPED_IN), + ]); + const outcome = yield* submitWhenReady(port, TURN, BOUNDS); + + expect(outcome).toEqual({ submitted: true }); + expect(log.typed).toEqual([TURN]); + expect(log.sent).toEqual([TURN]); + // The grace was taken, and the readiness read on both sides of it. + expect(log.paused).toEqual([BOUNDS.startupGrace]); + expect(log.waited).toEqual(["composer-ready", "prompt-presented"]); + }); + + it("CS8: a turn that never appears is never sent", function* () { + const { port, log } = fakePort([frame(READY), frame(READY), frame(READY)]); + const outcome = yield* submitWhenReady(port, TURN, BOUNDS); + + expect(outcome).toEqual({ submitted: false, reason: "prompt-never-presented" }); + // Typed, and that is all: the keystroke that spends the turn is the one + // thing this path may not reach. + expect(log.typed).toEqual([TURN]); + expect(log.sent).toEqual([]); + }); + + it("CS9: a composer under a dialog is not ready, and is waited for again", function* () { + expect(composerReady(showing(TRUSTING))).toBe(false); + + // Ready, then a dialog over it during the grace — which is the window the + // recheck exists for — then ready again. + const { port, log } = fakePort([frame(READY), frame(TRUSTING), frame(READY), frame(TYPED_IN)]); + expect(yield* submitWhenReady(port, TURN, BOUNDS)).toEqual({ submitted: true }); + expect(log.waited).toEqual(["composer-ready", "composer-ready-again", "prompt-presented"]); + expect(log.sent).toEqual([TURN]); + + // And when it does not come back, nothing is typed and nothing is sent. + const stuck = fakePort([frame(READY), frame(TRUSTING), frame(TRUSTING)]); + expect(yield* submitWhenReady(stuck.port, TURN, BOUNDS)).toEqual({ + submitted: false, + reason: "composer-unready-after-grace", + }); + expect(stuck.log.typed).toEqual([]); + expect(stuck.log.sent).toEqual([]); + }); + + it("CS10: readiness is read off the presented screen, not the held one", function* () { + // The whole ready frame is drawn inside a hold. Its bytes have arrived; no + // terminal has shown them. + const { port, log } = fakePort([ + `${SYNC_ON}${frame(READY)}`, + SYNC_OFF, + frame(READY), + frame(TYPED_IN), + ]); + expect(composerReady(port.screen())).toBe(false); + + expect(yield* submitWhenReady(port, TURN, BOUNDS)).toEqual({ submitted: true }); + expect(log.sent).toEqual([TURN]); + + // The same is true of the turn's own appearance: a composer showing it only + // in cells that were never committed has not presented it. + const held = new TerminalScreen({ rows: ROWS, columns: COLUMNS }); + held.write(`${ALT_ON}${frame(READY)}${SYNC_ON}${frame(TYPED_IN)}`); + expect(promptPresented(held.presented(), TURN)).toBe(false); + held.write(SYNC_OFF); + expect(promptPresented(held.presented(), TURN)).toBe(true); + }); + + it("CS11: every refusal on this path charges no turn at all", function* () { + // The two ways a submission can be refused, and the accounting each owes. + // `sent` is the byte; `charged` is what the run reports having spent, and + // the pty counts it before writing the byte so neither can outrun the other. + const unready = fakePort([frame(LOADING), frame(LOADING)]); + expect(yield* submitWhenReady(unready.port, TURN, BOUNDS)).toEqual({ + submitted: false, + reason: "composer-never-ready", + }); + expect(unready.log.charged).toBe(0); + + const unpresented = fakePort([frame(READY), frame(READY), frame(READY)]); + expect(yield* submitWhenReady(unpresented.port, TURN, BOUNDS)).toEqual({ + submitted: false, + reason: "prompt-never-presented", + }); + expect(unpresented.log.charged).toBe(0); + + // And the one path that does spend a turn spends exactly one. + const sending = fakePort([frame(READY), frame(READY), frame(TYPED_IN)]); + expect(yield* submitWhenReady(sending.port, TURN, BOUNDS)).toEqual({ submitted: true }); + expect(sending.log.charged).toBe(1); + }); + + it("CS12: the marker is absent before typing and presented before Enter", function* () { + // The tail is what `promptPresented` looks for, so the marker has to fit + // inside it or the thing being recognized is shared with every other run. + expect(TURN.slice(-PROMPT_TAIL)).toContain(MARKER); + + // Before typing, the composer is empty and there is nowhere else on screen + // for the marker to be: nothing has been sent, so no transcript carries it. + const ready = showing(READY); + expect(composerReady(ready)).toBe(true); + expect(ready.rows.join("")).not.toContain(MARKER); + expect(promptPresented(ready, TURN)).toBe(false); + + // Between typing and Enter it is on the screen, in the composer, which is + // what makes the turn about to be charged the turn this run prepared. + const composed = showing(TYPED_IN); + expect(composed.rows.join("")).toContain(MARKER); + expect(promptPresented(composed, TURN)).toBe(true); + + // A composer holding some other run's turn is not this run's turn presented. + const stale = showing([pane("OpenAI Codex (v0.153.2)"), "", pane("MK0000000000000")]); + expect(promptPresented(stale, TURN)).toBe(false); + }); + + it("CS22: a refusal reached while readying the turn charges nothing", function* () { + // Reaching a composer establishes the session was not refused, but only for + // as long as its grace was willing to wait — and Codex draws the composer + // before it resolves the thread. A refusal slower than that grace lands + // here, where the turn is already being readied and Enter is next. + // + // What makes it dangerous is that it is invisible to every predicate on this + // path. Codex draws `No saved session found with ID` *above* a composer + // rather than in place of one, so a refused screen is one the readiness test + // calls ready and the presentation test calls presented. + expect(composerReady(showing(REFUSED_READY))).toBe(true); + expect(promptPresented(showing(REFUSED_TYPED), TURN)).toBe(true); + // Only the surface classifier orders the refusal above them, which is why + // it, and not readiness, is what the gates below ask. + expect(classifyInitialSurface(showing(REFUSED_READY))).toBe("missing-session"); + expect(classifyInitialSurface(showing(REFUSED_TYPED))).toBe("missing-session"); + + // Gate one: the refusal is in the batch that satisfied the readiness wait, + // and the read ends on an ordinary composer, so the screen afterwards shows + // nothing wrong. + const atReady = fakePort([oneRead(REFUSED_READY, READY)]); + expect(yield* submitWhenReady(atReady.port, TURN, BOUNDS)).toEqual({ + submitted: false, + reason: "session-refused", + }); + expect(atReady.log.charged).toBe(0); + expect(atReady.log.sent).toEqual([]); + // Nothing was even typed: the refusal was read before there was any reason to. + expect(atReady.log.typed).toEqual([]); + + // Gate two: the composer came undone over the grace, and the refusal rides + // in the batch that answers the second readiness wait. + const atReadyAgain = fakePort([frame(READY), frame(LOADING), oneRead(REFUSED_READY, READY)]); + expect(yield* submitWhenReady(atReadyAgain.port, TURN, BOUNDS)).toEqual({ + submitted: false, + reason: "session-refused", + }); + expect(atReadyAgain.log.charged).toBe(0); + expect(atReadyAgain.log.waited).toEqual(["composer-ready", "composer-ready-again"]); + + // Gate three: the turn is typed, and the read that confirms it on screen is + // the read carrying the refusal. Typing spends nothing, so this still costs + // no turn — but it is one Enter away from costing one. + const atPresented = fakePort([frame(READY), frame(READY), oneRead(REFUSED_TYPED, TYPED_IN)]); + expect(yield* submitWhenReady(atPresented.port, TURN, BOUNDS)).toEqual({ + submitted: false, + reason: "session-refused", + }); + expect(atPresented.log.charged).toBe(0); + expect(atPresented.log.sent).toEqual([]); + expect(atPresented.log.typed).toEqual([TURN]); + + // Gate four: the refusal is in no batch at all. It arrives in the same read + // as the confirmed turn but *after* it, so it satisfies no predicate on this + // path and is collected by nothing — it is only there to be seen by reading + // the screen as it stands, which is what the last thing before Enter does. + const atEnter = fakePort([frame(READY), frame(READY), oneRead(TYPED_IN, REFUSED_READY)]); + expect(yield* submitWhenReady(atEnter.port, TURN, BOUNDS)).toEqual({ + submitted: false, + reason: "session-refused", + }); + expect(atEnter.log.charged).toBe(0); + expect(atEnter.log.sent).toEqual([]); + + // And the priority is the refusal's alone. The same shape with nothing + // refused still submits, so what was added is a finding and not a new way + // for an ordinary run to lose its turn. + const clean = fakePort([frame(READY), frame(READY), frame(TYPED_IN)]); + expect(yield* submitWhenReady(clean.port, TURN, BOUNDS)).toEqual({ submitted: true }); + expect(clean.log.charged).toBe(1); + expect(clean.log.sent).toEqual([TURN]); + }); + + it("CS23: a refusal left on the screen stops the turn at either decision taken against it", function* () { + // Every check CS22 added reads a batch, and a batch is the frames that + // answered a wait rather than the screen the read ended on. Two decisions + // here are taken against the screen instead — whether the composer needs a + // second wait, and whether it survived one — and both are followed by + // typing. Neither had anything collecting a refusal for it. + + // After the startup grace. The refusal drawn during the pause leaves + // `composerReady` true, which is the shape this whole tier turns on, so the + // second wait is not taken, no batch exists to be checked, and the read that + // confirms the turn repaints a clean composer over the refusal. + const duringGrace = fakePort([frame(READY), frame(REFUSED_READY), frame(TYPED_IN)]); + expect(yield* submitWhenReady(duringGrace.port, TURN, BOUNDS)).toEqual({ + submitted: false, + reason: "session-refused", + }); + expect(duringGrace.log.charged).toBe(0); + expect(duringGrace.log.sent).toEqual([]); + // Nothing was typed either: this stops before the composer is touched, not + // at the last moment before Enter. + expect(duringGrace.log.typed).toEqual([]); + expect(duringGrace.log.waited).toEqual(["composer-ready"]); + + // After a `composer-ready-again` batch. Here the refusal is what the read + // ended on and it carries no composer — which is what sent this run to a + // second wait to begin with, and is exactly what keeps it out of a batch + // collected on readiness. An earlier frame in the same read satisfied the + // wait, so the batch is honestly ready and honestly not refused. + const afterAgain = fakePort([ + frame(READY), + frame(LOADING), + oneRead(READY, REFUSED_ALONE), + frame(TYPED_IN), + ]); + expect(yield* submitWhenReady(afterAgain.port, TURN, BOUNDS)).toEqual({ + submitted: false, + reason: "session-refused", + }); + expect(afterAgain.log.charged).toBe(0); + expect(afterAgain.log.typed).toEqual([]); + expect(afterAgain.log.waited).toEqual(["composer-ready", "composer-ready-again"]); + + // The two refusals are opposite shapes, and that is the point: one is caught + // because it passes for ready, the other because it cannot be collected by a + // wait that asks for ready. A batch check answers for neither. + expect(composerReady(showing(REFUSED_READY))).toBe(true); + expect(composerReady(showing(REFUSED_ALONE))).toBe(false); + + // A composer that simply came back after the grace still types and still + // submits, so the second wait keeps its ordinary ending. + const recovered = fakePort([ + frame(READY), + frame(LOADING), + oneRead(LOADING, READY), + frame(TYPED_IN), + ]); + expect(yield* submitWhenReady(recovered.port, TURN, BOUNDS)).toEqual({ submitted: true }); + expect(recovered.log.charged).toBe(1); + expect(recovered.log.sent).toEqual([TURN]); + expect(recovered.log.waited).toEqual([ + "composer-ready", + "composer-ready-again", + "prompt-presented", + ]); + }); + + it("CS24: a refusal a wait was not watching for still ends that wait", function* () { + // The third place a refusal can hide, and the one neither a batch nor the + // screen afterwards reaches. A wait ends at the read that satisfied it, so a + // frame its predicate says nothing about does not merely go uncollected — it + // does not stop the wait at all, and the next read repaints over it. This + // refusal carries no composer, so `composerReady` and `promptPresented` are + // both false on it, and both of these waits ran straight past it. + expect(composerReady(showing(REFUSED_ALONE))).toBe(false); + expect(promptPresented(showing(REFUSED_ALONE), TURN)).toBe(false); + expect(classifyInitialSurface(showing(REFUSED_ALONE))).toBe("missing-session"); + + // Waiting for the composer to come back. The refusal is presented, then a + // composer is drawn over it in a read of its own, and the batch that answers + // is honestly ready while the screen afterwards is honestly clean. + const whileWaitingReady = fakePort([ + frame(READY), + frame(LOADING), + frame(REFUSED_ALONE), + frame(READY), + frame(TYPED_IN), + ]); + expect(yield* submitWhenReady(whileWaitingReady.port, TURN, BOUNDS)).toEqual({ + submitted: false, + reason: "session-refused", + }); + // Nothing typed: this wait is upstream of the composer being touched, so the + // refusal that ends it ends the run before there is a turn in the box. + expect(whileWaitingReady.log.typed).toEqual([]); + expect(whileWaitingReady.log.sent).toEqual([]); + expect(whileWaitingReady.log.charged).toBe(0); + + // Waiting for the typed turn to appear. Here the turn is already in the + // composer, so what is left to protect is Enter — and the refusal is again + // presented in a read the wait had no reason to stop at. + const whileWaitingPresented = fakePort([ + frame(READY), + frame(READY), + frame(REFUSED_ALONE), + frame(TYPED_IN), + ]); + expect(yield* submitWhenReady(whileWaitingPresented.port, TURN, BOUNDS)).toEqual({ + submitted: false, + reason: "session-refused", + }); + // The typing stands — it happened before anything was refused, and typing + // into a composer costs nothing. Enter is what must not follow it. + expect(whileWaitingPresented.log.typed).toEqual([TURN]); + expect(whileWaitingPresented.log.sent).toEqual([]); + expect(whileWaitingPresented.log.charged).toBe(0); + + // Neither wait is now a wait for anything that happens to end it: a run + // where nothing is refused still waits out its full script and submits. + const patient = fakePort([ + frame(READY), + frame(LOADING), + frame(LOADING), + frame(READY), + frame(TYPED_IN), + ]); + expect(yield* submitWhenReady(patient.port, TURN, BOUNDS)).toEqual({ submitted: true }); + expect(patient.log.charged).toBe(1); + expect(patient.log.sent).toEqual([TURN]); + }); + + it("CS26: a refusal presented during the startup grace is seen, and charges nothing", function* () { + // The grace exists because Codex draws its composer before it has finished + // deciding anything, so it is the window a late refusal is most likely to + // land in — and it was the one window nothing was watching. Presented and + // painted over inside a single read, the refusal leaves a ready composer + // behind it, which is what every question asked after the grace sees. + const refusedDuringGrace = fakePort([ + frame(READY), + oneRead(REFUSED_READY, READY), + frame(TYPED_IN), + ]); + expect(yield* submitWhenReady(refusedDuringGrace.port, TURN, BOUNDS)).toEqual({ + submitted: false, + reason: "session-refused", + }); + expect(refusedDuringGrace.log.typed).toEqual([]); + expect(refusedDuringGrace.log.sent).toEqual([]); + expect(refusedDuringGrace.log.charged).toBe(0); + // Decided against something the screen no longer shows: afterwards it is an + // ordinary ready composer, and asking it would have typed a turn. + expect(composerReady(refusedDuringGrace.port.screen())).toBe(true); + + // The refusal need not be repainted to count, and a grace that ends on one + // is still the same finding rather than a composer that came undone. + const refusedAndLeft = fakePort([frame(READY), frame(REFUSED_ALONE), frame(TYPED_IN)]); + expect(yield* submitWhenReady(refusedAndLeft.port, TURN, BOUNDS)).toEqual({ + submitted: false, + reason: "session-refused", + }); + expect(refusedAndLeft.log.charged).toBe(0); + + // An ordinary repaint during the grace still submits, so what the grace now + // watches for is the refusal and not change in general. + const repaintedDuringGrace = fakePort([frame(READY), oneRead(LOADING, READY), frame(TYPED_IN)]); + expect(yield* submitWhenReady(repaintedDuringGrace.port, TURN, BOUNDS)).toEqual({ + submitted: true, + }); + expect(repaintedDuringGrace.log.charged).toBe(1); + expect(repaintedDuringGrace.log.sent).toEqual([TURN]); + + // A refusal drawn during the grace and held back was never on show, so the + // grace has nothing to report and the turn is typed. The run then ends + // waiting for a composer this hold never lets it see — not as a refusal, + // which is what a hold counting would have made it — and sends nothing. + const heldDuringGrace = fakePort([ + frame(READY), + `${SYNC_ON}${frame(REFUSED_ALONE)}`, + frame(TYPED_IN), + ]); + expect(yield* submitWhenReady(heldDuringGrace.port, TURN, BOUNDS)).toEqual({ + submitted: false, + reason: "prompt-never-presented", + }); + expect(heldDuringGrace.log.typed).toEqual([TURN]); + expect(heldDuringGrace.log.charged).toBe(0); + }); +}); + +/** + * A port over scripted *reads*, built out of the pieces the pty is built out of. + * + * Deliberately not a screen with a predicate checked beside it. The live path is + * a {@link TerminalReader} fed one read at a time, with waits woken from inside + * it by {@link waitForPresented}; this is the same reader and the same wait, and + * differs only in what makes a read arrive. That is what lets one entry of the + * script carry several finished frames — as a pty's `data` event routinely does + * — and have this driver see each of them, rather than only the one the read + * ended on. + */ +function surfaceFake(script: readonly string[]): { + port: SurfacePort; + log: { consents: number; waited: string[]; paused: number[] }; +} { + const reader = new TerminalReader({ rows: ROWS, columns: COLUMNS }, false); + reader.write(ALT_ON); + const log: { + consents: number; + waited: string[]; + paused: number[]; + } = { consents: 0, waited: [], paused: [] }; + let next = 0; + const advance = (): boolean => { + const chunk = script[next]; + if (chunk === undefined) { + return false; + } + next += 1; + reader.write(chunk); + return true; + }; + return { + log, + port: { + screen: () => reader.snapshot(), + waitForScreen(name, _ms, predicate) { + log.waited.push(name); + return waitForPresented(reader, predicate, function* (wait): Operation { + // Nothing happens between reads here, as nothing happens between a + // pty's. A script that runs out is a bound that ran out: no further + // byte is ever going to arrive, which is what a spent bound means. + while (!wait.settled() && advance()) { + // Reads, until one of them answers or there are none left. + } + }); + }, + consent() { + log.consents += 1; + }, + pause(ms, predicate) { + log.paused.push(ms); + return presentedDuring( + reader, + predicate, + (function* (): Operation { + advance(); + })(), + ); + }, + }, + }; +} + +const SURFACE: SurfaceBounds = { surface: 1_000, settle: 10, grace: 10 }; + +const MISSING = [pane("OpenAI Codex (v0.153.2)"), "", pane(`${MISSING_SESSION} 019a-…`)]; +const STARTING = [pane("OpenAI Codex (v0.153.2)"), pane(MODEL_LOADING)]; + +describe("Tier CS — reaching a composer", () => { + it("CS13: the trust dialog is answered once, and only while it is up", function* () { + const { port, log } = surfaceFake([frame(TRUSTING), frame(TRUSTING), frame(READY)]); + expect(yield* reachComposer(port, SURFACE)).toBe("composer-ready"); + expect(log.consents).toBe(1); + // The settle came before the Enter, and the answered dialog was waited out + // rather than read again as a second question. + expect(log.paused).toEqual([SURFACE.settle]); + expect(log.waited).toEqual(["surface", "trust-answered", "surface", "readiness-grace"]); + + // A dialog that goes away during the settle is not answered at all: the + // Enter would land in whatever replaced it. + const vanishing = surfaceFake([frame(TRUSTING), frame(READY), frame(READY)]); + expect(yield* reachComposer(vanishing.port, SURFACE)).toBe("composer-ready"); + expect(vanishing.log.consents).toBe(0); + }); + + it("CS14: a directory Codex already trusts is consented to zero times", function* () { + const { port, log } = surfaceFake([frame(READY), frame(READY)]); + expect(yield* reachComposer(port, SURFACE)).toBe("composer-ready"); + expect(log.consents).toBe(0); + // The grace was still spent watching for the readiness to come undone. + expect(log.waited).toEqual(["surface", "readiness-grace"]); + + // And when one does arrive over the composer during that grace, it is + // answered — which is the ordering Codex actually draws. + const late = surfaceFake([frame(READY), frame(TRUSTING), frame(TRUSTING), frame(READY)]); + expect(yield* reachComposer(late.port, SURFACE)).toBe("composer-ready"); + expect(late.log.consents).toBe(1); + }); + + it("CS15: a refused identity is a finding, not something to press Enter at", function* () { + const { port, log } = surfaceFake([frame(MISSING)]); + expect(yield* reachComposer(port, SURFACE)).toBe("missing-session"); + expect(log.consents).toBe(0); + + // Codex draws its composer while still opening the thread, so the refusal + // and a ready-looking composer share a screen. The refusal is what it is. + expect(classifyInitialSurface(showing([...MISSING, "", pane(COMPOSER)]))).toBe( + "missing-session", + ); + }); + + it("CS16: a held frame decides nothing until the terminal commits it", function* () { + const cases: [readonly string[], string][] = [ + [TRUSTING, "trust-dialog"], + [MISSING, "missing-session"], + [READY, "composer-ready"], + // A loading model is not a surface either way. What it must never do is + // become readiness, and cells nobody has been shown are the one place + // that could happen without anybody seeing it. + [LOADING, "starting"], + ]; + for (const [held, committed] of cases) { + const terminal = new TerminalScreen({ rows: ROWS, columns: COLUMNS }); + terminal.write(`${ALT_ON}${frame(STARTING)}`); + terminal.write(`${SYNC_ON}${frame(held)}`); + // The bytes are in. Nothing is on show but the frame from before them. + expect(classifyInitialSurface(terminal.presented())).toBe("starting"); + expect(composerReady(terminal.presented())).toBe(false); + terminal.write(SYNC_OFF); + expect(classifyInitialSurface(terminal.presented())).toBe(committed); + } + + // Driven end to end: a dialog that is only ever held is a dialog this never + // answers, and the same dialog committed is answered exactly once. + const uncommitted = surfaceFake([`${SYNC_ON}${frame(TRUSTING)}`]); + expect(yield* reachComposer(uncommitted.port, SURFACE)).toBe("unresolved"); + expect(uncommitted.log.consents).toBe(0); + + const shown = surfaceFake([ + `${SYNC_ON}${frame(TRUSTING)}`, + SYNC_OFF, + frame(TRUSTING), + frame(READY), + ]); + expect(yield* reachComposer(shown.port, SURFACE)).toBe("composer-ready"); + expect(shown.log.consents).toBe(1); + }); + + it("CS17: readiness that comes undone during the grace is not readiness", function* () { + // Codex draws its composer before it reports that it holds no such thread, + // so the refusal can land on a screen this driver has already read as ready. + // A grace watching only for the trust dialog runs out in front of it and + // returns the composer — sending a turn at a session that does not exist. + const refused = surfaceFake([frame(READY), frame(MISSING)]); + expect(yield* reachComposer(refused.port, SURFACE)).toBe("missing-session"); + expect(refused.log.consents).toBe(0); + + // The grace ends at the first screen that is no longer ready, so a refusal + // it sees is the answer even where a composer is drawn over it afterwards. + // A grace that ran on past it would report the composer and lose the + // finding this whole feature exists to establish. + const repainted = surfaceFake([frame(READY), frame(MISSING), frame(READY)]); + expect(yield* reachComposer(repainted.port, SURFACE)).toBe("missing-session"); + + // The same for a model that goes back to loading: input typed there may be + // queued rather than taken, so this is not a composer to submit into. + const relapsed = surfaceFake([frame(READY), frame(LOADING)]); + expect(yield* reachComposer(relapsed.port, SURFACE)).toBe("unresolved"); + expect(relapsed.log.consents).toBe(0); + + // Unless the model finishes loading, at which point it is ready again and + // this says so. The grace refuses a screen, not a session. + const recovered = surfaceFake([frame(READY), frame(LOADING), frame(READY)]); + expect(yield* reachComposer(recovered.port, SURFACE)).toBe("composer-ready"); + expect(recovered.log.consents).toBe(0); + + // And a composer that simply stays ready is still reached, which is what + // every accepted run of this proof has done. + const stable = surfaceFake([frame(READY), frame(READY), frame(READY)]); + expect(yield* reachComposer(stable.port, SURFACE)).toBe("composer-ready"); + expect(stable.log.consents).toBe(0); + }); + + it("CS18: a transition held back during the grace decides at its commit", function* () { + // The refusal is drawn inside a hold that never commits. Its bytes arrived + // during the grace; no terminal showed them, so the screen is still the + // ready one and that is what this returns. + const held = surfaceFake([frame(READY), `${SYNC_ON}${frame(MISSING)}`]); + expect(yield* reachComposer(held.port, SURFACE)).toBe("composer-ready"); + + // Commit the same frame and the grace has something to see. + const committed = surfaceFake([frame(READY), `${SYNC_ON}${frame(MISSING)}`, SYNC_OFF]); + expect(yield* reachComposer(committed.port, SURFACE)).toBe("missing-session"); + expect(committed.log.consents).toBe(0); + + // A loading model held back and then committed reads the same way round. + const loading = surfaceFake([frame(READY), `${SYNC_ON}${frame(LOADING)}`, SYNC_OFF]); + expect(yield* reachComposer(loading.port, SURFACE)).toBe("unresolved"); + expect(loading.log.consents).toBe(0); + }); + + it("CS19: a surface presented and repainted inside one read is still presented", function* () { + // Codex commits its refusal and draws the composer over it within + // milliseconds of each other, and a pty hands over however many bytes were + // waiting — so both frames routinely arrive in one read, with nothing in + // between for a waiter outside the reader to be woken by. Everything above + // scripts a read per frame, which is the one arrangement that cannot show + // this. + const refusedThenDrawn = surfaceFake([oneRead(MISSING, READY)]); + expect(yield* reachComposer(refusedThenDrawn.port, SURFACE)).toBe("missing-session"); + expect(refusedThenDrawn.log.consents).toBe(0); + // And the screen the read ended on is a ready composer — which is what a + // driver reading only the current screen would have found, and typed into. + expect(classifyInitialSurface(refusedThenDrawn.port.screen())).toBe("composer-ready"); + + // The same read arriving during the grace, which is the other place a + // refusal lands: after this driver has already read a composer as ready. + const refusedDuringGrace = surfaceFake([frame(READY), oneRead(MISSING, READY)]); + expect(yield* reachComposer(refusedDuringGrace.port, SURFACE)).toBe("missing-session"); + expect(classifyInitialSurface(refusedDuringGrace.port.screen())).toBe("composer-ready"); + + // A model that goes back to loading and finishes inside one read is the + // mirror of that, and settles the other way. The loading frame ends the + // grace, and then readiness is read off the screen as it stands — because + // the composer about to be typed into is this one, not the frame that woke + // the wait. + const relapsedThenLoaded = surfaceFake([frame(READY), oneRead(LOADING, READY)]); + expect(yield* reachComposer(relapsedThenLoaded.port, SURFACE)).toBe("composer-ready"); + expect(relapsedThenLoaded.log.consents).toBe(0); + + // And a trust dialog that comes and goes inside one read is never answered. + // It was presented, so the grace ends at it; but Enter is an act against + // whatever is on screen when it is pressed, and by then the dialog is gone. + const askedThenGone = surfaceFake([frame(READY), oneRead(TRUSTING, READY)]); + expect(yield* reachComposer(askedThenGone.port, SURFACE)).toBe("composer-ready"); + expect(askedThenGone.log.consents).toBe(0); + + // A dialog that is still up when the read ends is answered exactly once, + // so the case above turns on the dialog having gone and not on the shape. + const stillAsking = surfaceFake([ + frame(READY), + oneRead(READY, TRUSTING), + frame(TRUSTING), + frame(READY), + ]); + expect(yield* reachComposer(stillAsking.port, SURFACE)).toBe("composer-ready"); + expect(stillAsking.log.consents).toBe(1); + + // The same dialog inside the *first* read, before any composer has been read + // as ready. The dialog is what the wait matched, and the driver never goes to + // answer it at all: it waits out no settle, because a settle is time given to + // a dialog that is still finishing drawing, and this one is already gone. + const goneBeforeLookedAt = surfaceFake([oneRead(TRUSTING, READY)]); + expect(yield* reachComposer(goneBeforeLookedAt.port, SURFACE)).toBe("composer-ready"); + expect(goneBeforeLookedAt.log.consents).toBe(0); + expect(goneBeforeLookedAt.log.paused).toEqual([]); + expect(goneBeforeLookedAt.log.waited).toEqual(["surface", "readiness-grace"]); + + // A read carrying three frames, with the refusal in the middle of it. Neither + // end of the read shows it: the model was still loading when the read began + // and a composer was up by the time it ended, so this is only found by + // examining every commit rather than the first or the last. + const refusedMidRead = surfaceFake([oneRead(LOADING, MISSING, READY)]); + expect(yield* reachComposer(refusedMidRead.port, SURFACE)).toBe("missing-session"); + expect(refusedMidRead.log.consents).toBe(0); + expect(classifyInitialSurface(refusedMidRead.port.screen())).toBe("composer-ready"); + }); + + it("CS20: a refusal behind an earlier match in the same read is still the finding", function* () { + // The case above finds the refusal because nothing ahead of it in the read + // satisfied the wait. That is not the general shape. Both waits here ask a + // question a benign frame can also answer — the grace watches for the + // composer becoming *anything* else, and the initial wait for any surface + // worth acting on — so a read can satisfy one at its first frame and carry + // the refusal behind it. A wait that stopped looking at the frame that woke + // it would report the benign one and never see the refusal at all. + + // Codex reloading a model, refusing the identity, and drawing its composer + // back: three commits, one read, arriving after this driver has already read + // a composer as ready. The grace matches at `loading`, and the refusal is + // two frames further in. + const refusedBehindLoading = surfaceFake([frame(READY), oneRead(LOADING, MISSING, READY)]); + expect(yield* reachComposer(refusedBehindLoading.port, SURFACE)).toBe("missing-session"); + expect(refusedBehindLoading.log.consents).toBe(0); + // The screen the read ended on is a ready composer, which is what this + // driver would otherwise have typed a turn into. + expect(classifyInitialSurface(refusedBehindLoading.port.screen())).toBe("composer-ready"); + + // The same ordering on the initial wait, where the trust dialog is the + // actionable frame that answers it first and the refusal follows. + const refusedBehindTrust = surfaceFake([oneRead(TRUSTING, MISSING, READY)]); + expect(yield* reachComposer(refusedBehindTrust.port, SURFACE)).toBe("missing-session"); + // Never consented to: a refused session is a finding, not a directory to + // take a standing permission on. + expect(refusedBehindTrust.log.consents).toBe(0); + expect(classifyInitialSurface(refusedBehindTrust.port.screen())).toBe("composer-ready"); + + // And the priority is the refusal's alone. A benign frame behind another + // benign frame decides nothing extra: the grace still ends at the first, and + // readiness is still read off the screen as it stands. + const loadedBehindTrust = surfaceFake([frame(READY), oneRead(TRUSTING, LOADING, READY)]); + expect(yield* reachComposer(loadedBehindTrust.port, SURFACE)).toBe("composer-ready"); + expect(loadedBehindTrust.log.consents).toBe(0); + }); + + it("CS21: a refusal presented after the consent is the finding, and no turn follows", function* () { + // The third wait this driver consumes a batch from, and the one CS20 never + // reaches. Answering the dialog is not the end of the startup: Codex goes on + // to load a model and to report what it holds, and this wait is what it is + // watched through. The wait asks only that the dialog be gone, which the + // model reloading already satisfies — so the refusal arrives behind a frame + // that answered the wait, in the read that answered it. + const refusedAfterConsent = surfaceFake([ + frame(TRUSTING), + frame(TRUSTING), + oneRead(LOADING, MISSING, READY), + ]); + expect(yield* reachComposer(refusedAfterConsent.port, SURFACE)).toBe("missing-session"); + // The consent stands, and is neither repeated nor taken back: it was pressed + // at a dialog that was up and asking, about a directory, before anything had + // been refused. What must not follow it is a turn, and the finding is what + // stops one — this returns instead of going round to type into the composer + // the same read drew. + expect(refusedAfterConsent.log.consents).toBe(1); + expect(refusedAfterConsent.log.waited).toEqual(["surface", "trust-answered"]); + expect(classifyInitialSurface(refusedAfterConsent.port.screen())).toBe("composer-ready"); + + // A benign batch here still continues, so what was added is the refusal's + // priority and not a general distrust of whatever follows a consent. + const startedAfterConsent = surfaceFake([ + frame(TRUSTING), + frame(TRUSTING), + oneRead(LOADING, READY), + frame(READY), + ]); + expect(yield* reachComposer(startedAfterConsent.port, SURFACE)).toBe("composer-ready"); + expect(startedAfterConsent.log.consents).toBe(1); + expect(startedAfterConsent.log.waited).toEqual([ + "surface", + "trust-answered", + "surface", + "readiness-grace", + ]); + }); + + it("CS25: a refusal presented during the settle is seen, and nothing is consented to", function* () { + // The settle is a window like any other — the terminal goes on drawing for + // its whole duration — but nothing was watching it. A refusal presented and + // painted over inside one read during the settle leaves the dialog on screen + // afterwards, and the recheck before Enter is a question about the screen, + // which by then says the dialog is still up and still asking. + const refusedDuringSettle = surfaceFake([ + frame(TRUSTING), + oneRead(MISSING, TRUSTING), + frame(READY), + frame(READY), + ]); + expect(yield* reachComposer(refusedDuringSettle.port, SURFACE)).toBe("missing-session"); + // Not consented to at all. This refusal was presented *before* the Enter, + // unlike CS21's, so there is no question of a consent standing: the + // directory was never answered for, because the session it was being + // answered for had already been refused. + expect(refusedDuringSettle.log.consents).toBe(0); + // And the screen it was decided against still shows the dialog, which is + // exactly why the screen alone could not decide it. + expect(classifyInitialSurface(refusedDuringSettle.port.screen())).toBe("trust-dialog"); + + // An ordinary repaint during the settle still consents. The dialog redrawing + // itself, or a model loading behind it, is a settle doing its job. + const redrawnDuringSettle = surfaceFake([ + frame(TRUSTING), + oneRead(STARTING, TRUSTING), + frame(READY), + frame(READY), + ]); + expect(yield* reachComposer(redrawnDuringSettle.port, SURFACE)).toBe("composer-ready"); + expect(redrawnDuringSettle.log.consents).toBe(1); + + // And a dialog that simply goes away during the settle is still not answered, + // which is the behaviour the settle existed for in the first place. + const departedDuringSettle = surfaceFake([ + frame(TRUSTING), + frame(READY), + frame(READY), + frame(READY), + ]); + expect(yield* reachComposer(departedDuringSettle.port, SURFACE)).toBe("composer-ready"); + expect(departedDuringSettle.log.consents).toBe(0); + + // A refusal drawn during the settle and never committed is not something the + // terminal ever put on show, and observing the settle does not change that. + // The consent goes ahead — the dialog is what is presented — and the run ends + // waiting for an answer that this held drawing never lets arrive, which is a + // different outcome from the refusal it would be if a hold counted. + const heldDuringSettle = surfaceFake([ + frame(TRUSTING), + `${SYNC_ON}${frame(MISSING)}`, + frame(READY), + ]); + expect(yield* reachComposer(heldDuringSettle.port, SURFACE)).toBe("unresolved"); + expect(heldDuringSettle.log.consents).toBe(1); + }); +}); diff --git a/packages/acp/tests/fixtures/codex-native-launch-proof.ts b/packages/acp/tests/fixtures/codex-native-launch-proof.ts new file mode 100644 index 000000000..d504c1622 --- /dev/null +++ b/packages/acp/tests/fixtures/codex-native-launch-proof.ts @@ -0,0 +1,2346 @@ +/** + * Issue #755 — the parts of a real Codex launch Markdown cannot observe. + * + * `CodexNativeLaunch.test.md` and `CodexZeroNativeTurnExit.test.md` own the + * sequencing, the gating, the schemas, the assertions and everything an + * operator reads. This fixture owns only what an authored document cannot + * reach: a sized pseudo-terminal, child lifecycle, structured route and journal + * reads, the vendored adapter's own snapshot identity, and exact-path cleanup. + * + * What it does not own is the product. The thing under test is the ordinary + * production command: + * + * dist/xmd run AGENTS.md#Implementor --default-agent codex --journal … --raw + * + * run inside a byte-for-byte copy of the repository's own `AGENTS.md` and + * `.agents/implementor.md`. No role Markdown is built, interpolated or + * rewritten here — a proof that assembled the document in TypeScript would be + * proving something nobody runs. + * + * Three boundaries keep the evidence honest: + * + * - **Nothing provider-private is read.** Every observation comes from a value + * this fixture supplied, a process outcome, XMD's own diagnostic journal, the + * exact XMD route record for this run's natural key, or the terminal output + * of the production command. Nothing beneath `~/.codex` is opened — not + * rollouts, not history, not configuration — and `CODEX_HOME` is left exactly + * as the operator has it, because relocating it de-authenticates Codex. + * - **No shim stands in front of the executable.** The Claude proofs put a + * recording wrapper on `PATH` to read the native argv. Doing that here would + * defeat the thing #755 freezes: the observer canonicalizes and hashes what it + * resolves, so a wrapper would bind the session to the wrapper's digest rather + * than to the Codex build in the compatibility tuple. The native identity is + * established from Codex's own terminal output instead — it prints + * `Session ID: ` as it leaves — which is stronger than an argv anyway: a + * picker or `--last` would print a different one. + * - **Nothing sensitive is rendered.** One filtered JSON verdict reaches stdout: + * versions, digests this repository already checks in, the exact identity and + * its provenance, booleans, counts, phase and failure classes, usage figures, + * and cleanup outcomes. Raw terminal buffers, argv, environment and prepared + * instruction text never leave this file. + * + * Model turns are the one number nobody may be wrong about, so every one of them + * is counted at the keystroke or at the record that proves it happened, never + * summarized on the way out. + */ + +import { ensure, main, race, scoped, sleep, until, withResolvers } from "effection"; +import type { Operation } from "effection"; +import { copyFile, exists, readTextFile, rm } from "@effectionx/fs"; +import { spawn as spawnChild } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { chmod, mkdir, mkdtemp, readFile, realpath } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import process from "node:process"; +import { createEmbeddedAdapters, embeddedAdapterIdentities } from "../../src/adapter-snapshots.ts"; +import { ADVERTISED_NATIVE_LAUNCH } from "../../src/native-launch.ts"; +import { agentSessionKeyDigest } from "@executablemd/runtime"; +import type { AnswerQuestion, ScreenSnapshot, TerminalReader } from "./terminal-screen.ts"; +import { + presentedDuring, + terminalChannels, + terminalReader, + waitForPresented, +} from "./terminal-screen.ts"; +import type { StallClassification, SubmissionPort, SurfacePort } from "./codex-surfaces.ts"; +import type { SubmissionRefusal } from "./codex-surfaces.ts"; +import { + classifyInitialSurface, + classifyStall, + reachComposer, + refusalDetail, + SCREEN_CHROME, + submitWhenReady, +} from "./codex-surfaces.ts"; + +/** Opting in at all. Absent, every mode refuses before a Codex process. */ +const PROOF_ENV = "XMD_CODEX_NATIVE_PROOF"; +/** The separate, exact grant a mode that spends model turns needs. */ +const TURNS_ENV = "XMD_CODEX_MODEL_TURNS_AUTHORIZED"; + +/** The production target, exactly as an operator would type it. */ +const TARGET = "AGENTS.md#Implementor"; + +/** The document that reattaches to the launched session through ACP. */ +const ACP_DOCUMENT = fileURLToPath(new URL("./codex-native-to-acp-prompt.md", import.meta.url)); + +/** + * The compatibility point these journeys are only meaningful against + * (`issue-755-codex-materialization-turn-implementor-handoff.md`). + * + * Frozen rather than read from the machine, because the whole claim is that one + * exact build was proven. A run against another build is not a weaker pass; it + * is a different question, and it says so. + */ +const REQUIRED_PLATFORM = "darwin"; +const REQUIRED_ARCHITECTURE = "arm64"; +const REQUIRED_CODEX_VERSION = "codex-cli 0.153.2"; +const REQUIRED_CODEX_DIGEST = "195ace4100a634a9df39147f493e730e666b5bd87795f3c9f3251d8542400424"; +const REQUIRED_ADAPTER_PACKAGE = "@agentclientprotocol/codex-acp"; +const REQUIRED_ADAPTER_VERSION = "1.6.2"; +const REQUIRED_ADAPTER_DIGEST = "3ee22bc6b1649d02fcef80b352516f395fe774e63b459193195a41c42930dd8b"; + +/** The one prompt version a Codex launch may spend a turn on. */ +const PROMPT_VERSION = "codex-materialization.v1"; + +/** + * The role contract's opening sentence. + * + * Never sent to the model. It reaches the session only as the prepared + * instruction layer the launch installed, so a native answer carrying it is + * evidence the layer governed that turn — evidence that needs no test-only token + * planted in a production role document. + */ +const OPENING_SENTENCE = "The Implementor delivers an accepted plan as a focused, verified change."; + +/** What a recovered marker has to be wrapped in, so a repaint cannot supply it. */ +const RECALL_PREFIX = "RECOVERED-"; + +/** Rendered-output markers: one per role section, each unique to its section. */ +const IMPLEMENTOR_MARKER = "Running this target prepares the session"; +const SIBLING_MARKERS: readonly string[] = [ + ".agents/planner.md", + ".agents/architect.md", + "It has no contract document of its own", +]; + +const ENTER = "\r"; +const INTERRUPT = "\u0003"; +/** Two interrupts: the first asks the TUI to leave, the second insists. */ +const EXIT_CONTROL_BYTES = "0303"; + +/** + * How the one dialog Codex may put in front of a session is answered. + * + * It is not a conversation. It asks the person at the terminal for a standing + * permission — may Codex load project-local configuration from this directory — + * reaches no model and creates no user turn, so answering it is terminal control + * and is reported on its own channel. The answer is the pre-selected + * `1. Yes, continue` the dialog itself offers. + */ +const TRUST_SURFACE = "directory-trust"; +const TRUST_BYTES = "0d"; + +/** What Codex prints as it leaves, naming the conversation it was in. */ +const SESSION_ID_LINE = /Session ID:\s*([0-9a-fA-F-]{36})/u; + +/** What XMD says on the terminal before it spends the reader's model turn. */ +const NOTICE_BEFORE = "spending one model turn in session"; +/** And what it says once that turn has been spent. */ +const NOTICE_AFTER = "materialization turn completed in"; + +/** + * Environment names an enclosing coding-agent session exports. + * + * A proof that runs inside one inherits them, and a provider behaves + * differently when it does. `CODEX_PATH` in particular is the variable XMD's own + * binding sets for the adapter child, so inheriting one would let this process + * choose the build the product is supposed to resolve for itself. + */ +function inheritedAgentMarker(name: string): boolean { + return ( + name === "CLAUDECODE" || + name === "CLAUDE_PID" || + name === "CLAUDE_EFFORT" || + name.startsWith("CLAUDE_CODE_") || + name.startsWith("CODEX_") + ); +} + +/** Every phase is bounded on its own, well inside the document's block timeout. */ +const VERSION_MS = 30_000; +const SURFACE_MS = 240_000; +/** + * How long one native answer is waited for. + * + * Twice the Claude proof's bound, because two Codex answers reached that one and + * the second was still showing a working state when the classifier read the + * screen afterwards. A bound is not a claim about the product: reaching it says + * this harness stopped waiting, and `answerSurface` says what was on screen. + */ +const ANSWER_MS = 600_000; +const SETTLE_MS = 120_000; +const DELETE_MS = 60_000; +const ADAPTER_MS = 120_000; +/** How long a surface is given to settle before a key is sent into it. */ +const SETTLE_DELAY_MS = 3_000; + +/** + * How long the composer is given to turn out to have a dialog over it. + * + * Codex draws the composer first and the trust dialog over it, so a reader that + * stops at the composer stops in front of a question nobody answered. This is + * the window in which that turns out to be what happened. + */ +const TRUST_GRACE_MS = 20_000; + +/** The pseudo-terminal's size. A zero-column terminal draws no TUI at all. */ +const PTY_ROWS = 40; +const PTY_COLUMNS = 120; + +type Mode = "preflight" | "zero-native-turn" | "native-launch"; + +/** + * The exact model-turn budget each mode may spend, and the value its grant must + * carry. + * + * `zero-native-turn` spends the one XMD-owned materialization turn and no native + * user turn — which is what "zero native turn" names. `native-launch` spends + * that one, plus the first native user turn, plus the ACP turn that asks the + * reattached session what the native turn said. + */ +const AUTHORIZED_TURNS: Readonly> = { + preflight: "0", + "zero-native-turn": "1", + "native-launch": "3", +}; + +/** + * `UNDECIDED` is the state a journey starts in and never ends in: it says the + * body classified nothing, which is the one state a decider may speak from. It + * is a distinct value rather than a reused failure so that a body which does + * classify — including one reporting that this harness stopped waiting — is not + * mistaken for one that stayed silent and then overruled. + */ +type Verdict = + | "UNDECIDED" + | "PASS" + | "REFUSED" + | "ENVIRONMENT_BLOCKED" + | "PRODUCT_FAILED" + | "HARNESS_FAILED"; + +class PhaseTimeout extends Error { + override name = "PhaseTimeout"; + constructor(readonly phase: string) { + super(`phase "${phase}" exceeded its bound`); + } +} + +/** Run `op` under a bound. The loser is halted with the surrounding scope. */ +function* bounded(phase: string, ms: number, op: () => Operation): Operation { + const outcome = yield* race([ + (function* (): Operation<{ done: true; value: T }> { + return { done: true, value: yield* op() }; + })(), + (function* (): Operation<{ done: false }> { + yield* sleep(ms); + return { done: false }; + })(), + ]); + if (!outcome.done) { + throw new PhaseTimeout(phase); + } + return outcome.value; +} + +/** A fixed-category description carrying no path, credential or transcript. */ +function classify(error: unknown): string { + if (error instanceof PhaseTimeout) { + return `timeout in ${error.phase}`; + } + if (error instanceof Error) { + return error.name; + } + return "unknown"; +} + +function isReachable(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function killed(pid: number): void { + try { + process.kill(pid, "SIGKILL"); + } catch { + // Already gone, which is the state this was asking for. + } +} + +function interrupted(pid: number): void { + try { + process.kill(pid, "SIGINT"); + } catch { + // Already gone. + } +} + +const TEARDOWN_GRACE_MS = 8_000; + +function* settledWithin(pid: number, ms: number): Operation { + const started = Date.now(); + while (isReachable(pid) && Date.now() - started < ms) { + yield* sleep(100); + } +} + +/** Collapse every run of whitespace, so a TUI's own layout cannot hide a word. */ +function squeeze(text: string): string { + return text.replaceAll(/\s+/gu, ""); +} + +const CONTROL_SEQUENCE = + // deno-lint-ignore no-control-regex + /\x1B\[[0-9;?]*[ -\/]*[@-~]|\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)|\x1B[()][B0]|\x1B[<>=]|[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/gu; + +/** + * The glyphs a TUI draws its frame out of. + * + * Codex wraps a long answer inside a bordered pane, so a sentence can arrive + * with a border between two of its words. No model emits these mid-sentence, + * so removing them recovers the text without letting anything else through. + */ +const FRAME_GLYPH = /[─-╿▀-▟]/gu; + +function readable(text: string): string { + return text.replaceAll(CONTROL_SEQUENCE, "").replaceAll(FRAME_GLYPH, ""); +} + +/** Whether `text` shows `marker`, ignoring the layout a TUI drew around it. */ +function shows(text: string, marker: string): boolean { + return squeeze(readable(text)).includes(squeeze(marker)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +interface ChildOutcome { + code: number | null; + signal: string | null; + stdout: string; + stderr: string; +} + +/** Every child a mode started, so cleanup can prove none outlived it. */ +type LiveSet = Set; + +/** Run one captured child to settlement. Never a TUI. */ +function runChild( + command: string, + args: string[], + options: { cwd: string; input?: string; live: LiveSet; env?: Record }, +): Operation { + return scoped(function* (): Operation { + const settled = withResolvers(); + const failed = withResolvers(); + const closed = withResolvers(); + let didClose = false; + let child: ChildProcess | undefined; + let stdout = ""; + let stderr = ""; + const onStdout = (chunk: Buffer): void => { + stdout += chunk.toString(); + }; + const onStderr = (chunk: Buffer): void => { + stderr += chunk.toString(); + }; + const onError = (error: Error): void => failed.reject(error); + const onClose = (code: number | null, signal: string | null): void => { + didClose = true; + if (child?.pid) { + options.live.delete(child.pid); + } + closed.resolve(); + settled.resolve({ code, signal, stdout, stderr }); + }; + + // Registered before the spawn: a halt between acquiring a process and + // registering its cleanup leaks the process. + yield* ensure(function* () { + if (!child) { + return; + } + try { + if (!didClose) { + if (child.pid) { + killed(child.pid); + } + yield* closed.operation; + } + } finally { + child.stdout?.off("data", onStdout); + child.stderr?.off("data", onStderr); + child.off("error", onError); + child.off("close", onClose); + } + }); + + child = spawnChild(command, args, { + cwd: options.cwd, + stdio: ["pipe", "pipe", "pipe"], + ...(options.env === undefined ? {} : { env: options.env }), + }); + if (child.pid) { + options.live.add(child.pid); + } + child.stdout?.on("data", onStdout); + child.stderr?.on("data", onStderr); + child.on("error", onError); + child.on("close", onClose); + + if (options.input !== undefined) { + child.stdin?.write(options.input); + } + child.stdin?.end(); + + return yield* race([settled.operation, failed.operation]); + }); +} + +/** + * A path this fixture may write into a generated shell command. + * + * Everything here is built from `tmpdir()`, the repository root and a UUID, so + * this can only fail if one of them holds a quote or a newline. Refusing is the + * only safe answer: a command assembled around one would run something else. + */ +function literal(path: string): string { + if (/['\n\r]/u.test(path)) { + throw new Error("a fixture path holds a character a shell command cannot carry safely"); + } + return `'${path}'`; +} + +/** + * One production command running under a sized pseudo-terminal. + * + * `` takes the run's foreground-terminal lease and refuses a + * host with no terminal, which is what a real operator gets and the reason this + * cannot be a piped child. `/usr/bin/script` is the terminal boundary, so no PTY + * dependency is added for a test. + * + * The size is set inside the pty and not by an environment variable, because a + * TUI asks the terminal rather than the environment. `script` leaves a pty at + * zero columns, and Codex draws nothing at all into one — a proof reading that + * empty screen would conclude the surface never appeared. + */ +interface Pty { + /** + * Wait until output arriving *since the last handled surface* satisfies + * `predicate`, then mark it handled. + * + * Windowed on purpose: a dialog that has already been answered stays in the + * buffer, and re-reading it would answer it twice. + */ + waitForAny( + name: string, + ms: number, + tags: readonly { tag: string; marker: string }[], + ): Operation; + /** + * Wait until the reconstructed screen is showing the expected answer. + * + * Separate from {@link waitForAny} because an answer is not a surface + * appearing in the byte stream. Codex addresses the cursor: it wraps, erases + * and redraws what it has already drawn, so the expectation can arrive split + * across rows, or be moved somewhere else by a later repaint, and the bytes + * retain text that is no longer on the screen at all. The bytes are applied to + * a terminal here and the terminal is what gets read. + * + * Available only to a run that submits a turn. A run that spoke none has no + * reply to attribute, and its reader refuses to be asked for one. + */ + waitForAnswer(ms: number, question: AnswerQuestion): Operation; + /** + * Wait until what the terminal is *showing* satisfies `predicate`, and answer + * with the frame that did. + * + * The stream answers a different question than the screen does, and every + * surface decision is a question about the screen: bytes that drew a composer + * are retained after a dialog was drawn over it, bytes that drew a refusal are + * retained after a repaint wiped it, and a frame held back by `CSI ?2026h` has + * arrived without having been shown to anybody. + * + * The frames rather than a flag, because one `data` event from the pty applies + * every frame its bytes finished before anybody out here is woken. A caller + * told only that a surface arrived would look at the screen and find whatever + * that read ended on, which for a refusal Codex repaints over is a composer. + * + * Every frame that matched rather than the first, because a wait is answered + * by a read and not by a frame: what satisfied it first is one of the things + * that read presented, and the caller is the only thing that knows which of + * them it was actually asking about. + * + * Empty when the bound is spent, because a surface that never arrived is + * something to report rather than an error. + */ + waitForScreen( + name: string, + ms: number, + predicate: (snapshot: ScreenSnapshot) => boolean, + ): Operation; + /** + * Wait out a fixed delay, and answer with what was presented during it. + * + * Nothing shortens it — that is what makes it a delay rather than a wait — but + * the terminal does not stop drawing because a driver has stopped looking, so + * the same answer a wait gives is owed here too. A caller reading the screen + * once the delay is out is asking what survived it, which is a different + * question from what happened during it. + */ + pause( + ms: number, + predicate: (snapshot: ScreenSnapshot) => boolean, + ): Operation; + /** Whether everything seen so far contains `marker`, layout ignored. */ + saw(marker: string): boolean; + /** The first match of `pattern` anywhere in what has been drawn. */ + captured(pattern: RegExp): string; + /** What the terminal last presented. Never a frame still being drawn. */ + screen(): ScreenSnapshot; + /** Write terminal-control bytes — never conversation. */ + control(bytes: string): void; + /** + * Type one conversation turn, without sending it. + * + * Separate from `send()` because a TUI reads text and a newline arriving + * together as a pasted line rather than as a submitted one — the newline lands + * in the box and nothing is sent. + */ + type(text: string): void; + /** Press Enter. Charged here: this is the keystroke that spends the turn. */ + send(typed: string): void; + /** Wait for the process to leave. */ + settle(ms: number): Operation; + exitCode(): number; +} + +/** + * The three input channels, kept apart because they mean different things — + * and each recorded at the moment it is written. + * + * These are callbacks rather than counters a caller reads afterwards, because + * the phase that sends a turn is also the phase that can time out. A run that + * spent a turn and then hung must still report the turn. + */ +interface PtyChannels { + /** One conversation turn was submitted: this many bytes, and one turn. */ + charged(bytes: number): void; + /** A consent dialog was answered with these bytes. Never a conversation. */ + consented(surface: string, bytes: string): void; + /** Exit control was sent. */ + exited(bytes: string): void; +} + +function ptyRun( + command: string, + args: string[], + options: { + cwd: string; + env: Record; + live: LiveSet; + channels: PtyChannels; + /** + * The terminal this run draws on, supplied by its caller. + * + * Every run gets one, because every run decides which surface it is looking + * at and that is a fact about the screen rather than about the bytes. + * Whether it also watches for an answer is stated at the call site, so a + * reply can only be attributed by a run that submitted a turn. + */ + reader: TerminalReader; + }, + body: (pty: Pty) => Operation, +): Operation { + return scoped(function* (): Operation { + const settled = withResolvers(); + const failed = withResolvers(); + let didClose = false; + let child: ChildProcess | undefined; + let text = ""; + let consumed = 0; + let code = -1; + // The waiter reports which tag it saw rather than only that it saw one, so + // the tag it answers with is the one the screen actually showed. + let pending: + | { match: (fresh: string) => string | undefined; resolve: (tag: string) => void } + | undefined; + let answered: (() => void) | undefined; + const onStdout = (chunk: Buffer): void => { + if (channels.display(chunk) && answered !== undefined) { + const resolve = answered; + answered = undefined; + resolve(); + } + }; + const onStderr = (chunk: Buffer): void => { + channels.diagnostic(chunk); + }; + const onError = (error: Error): void => failed.reject(error); + const onClose = (status: number | null): void => { + didClose = true; + code = status ?? -1; + if (child?.pid) { + options.live.delete(child.pid); + } + settled.resolve(); + }; + + // Interrupt, then insist. A harness that reaches straight for SIGKILL takes + // away the product's chance to retain its own exit and then reports what it + // stranded as a product failure. + yield* ensure(function* () { + if (!child) { + return; + } + try { + if (!didClose) { + if (child.pid) { + interrupted(child.pid); + yield* settledWithin(child.pid, TEARDOWN_GRACE_MS); + if (isReachable(child.pid)) { + killed(child.pid); + } + } + yield* settled.operation; + } + } finally { + child.stdout?.off("data", onStdout); + child.stderr?.off("data", onStderr); + child.off("error", onError); + child.off("close", onClose); + } + }); + + const line = [ + `stty rows ${PTY_ROWS} columns ${PTY_COLUMNS}`, + `exec ${[command, ...args].map(literal).join(" ")}`, + ].join("; "); + child = spawnChild("/usr/bin/script", ["-q", "/dev/null", "/bin/sh", "-c", line], { + cwd: options.cwd, + env: options.env, + stdio: ["pipe", "pipe", "pipe"], + }); + if (child.pid) { + options.live.add(child.pid); + } + + // Everything the run said, whichever channel said it. Surface detection + // reads this, because a surface arriving is a thing the stream says. + const record = (chunk: string | Uint8Array) => { + text += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + const waiter = pending; + if (!waiter) { + return; + } + const tag = waiter.match(text.slice(consumed)); + if (tag === undefined) { + return; + } + pending = undefined; + consumed = text.length; + waiter.resolve(tag); + }; + const channels = terminalChannels(record, options.reader); + + // Only stdout went through the pty, so only stdout is what the terminal + // showed. `script` and the shell beneath it report their own troubles on + // stderr, which reached this process without ever being displayed — + // evidence about the run, and not a thing anybody could have read on screen. + // Screen waits are woken from inside the reader, at the commit that answered + // them, rather than out here once a whole read has been applied. Where a + // read carried several finished frames, out here is too late to tell them + // apart: only the last one is still on the screen. + child.stdout?.on("data", onStdout); + child.stderr?.on("data", onStderr); + child.on("error", onError); + child.on("close", onClose); + + const write = (bytes: string) => { + // Everything already on screen belongs to the surface being answered, so + // the next wait reads only what this write provoked. + consumed = text.length; + child?.stdin?.write(bytes); + }; + + const pty: Pty = { + waitForAny(name, ms, tags) { + return bounded(name, ms, function* (): Operation { + const match = (fresh: string): string | undefined => + tags.find((entry) => shows(fresh, entry.marker))?.tag; + const already = match(text.slice(consumed)); + if (already !== undefined) { + consumed = text.length; + return already; + } + const waiter = withResolvers(); + pending = { match, resolve: waiter.resolve }; + return yield* race([waiter.operation, failed.operation]); + }); + }, + waitForAnswer(ms, question) { + return bounded("answer", ms, function* (): Operation { + // Arming answers against what is already showing, because the reply + // can be complete before this is reached and no further byte arrive. + // A reader that is not watching refuses the question outright. + if (options.reader.ask(question)) { + return; + } + const waiter = withResolvers(); + answered = waiter.resolve; + yield* race([waiter.operation, failed.operation]); + }); + }, + waitForScreen(name, ms, predicate) { + return waitForPresented(options.reader, predicate, function* (wait): Operation { + try { + yield* bounded(name, ms, () => race([wait.matched, failed.operation])); + } catch (error) { + if (!(error instanceof PhaseTimeout)) { + throw error; + } + // A bound that ran out is a surface that never arrived, which the + // caller reports rather than raises. + } + }); + }, + pause(ms, predicate) { + return presentedDuring(options.reader, predicate, sleep(ms)); + }, + saw(marker) { + return shows(text, marker); + }, + captured(pattern) { + return readable(text).match(pattern)?.[1] ?? ""; + }, + screen() { + return options.reader.snapshot(); + }, + control(bytes) { + write(bytes); + }, + type(spoken) { + write(spoken); + }, + send(typed) { + // Charged before the write returns, so an answer that never arrives + // cannot make this look like a turn nobody spent. + options.channels.charged(Buffer.byteLength(`${typed}${ENTER}`, "utf8")); + write(ENTER); + }, + settle(ms) { + return bounded("settle", ms, () => race([settled.operation, failed.operation])); + }, + exitCode: () => code, + }; + + return yield* body(pty); + }); +} + +/** One journey's owned paths. Every removal below names an exact one. */ +interface Journey { + root: string; + /** Canonical, because a launch records the directory it resolved. */ + project: string; + journals: [string, string, string]; + live: LiveSet; + codex: string; + /** Every native identity this journey caused to exist, for exact deletion. */ + created: Set; +} + +interface CleanupReport { + liveChildren: number; + journalsRemoved: boolean; + routeRecordsRemoved: boolean; + sessionDeleteOutcome: string; + temporaryRootRemoved: boolean; +} + +/** What one production invocation established. */ +interface InvocationOutcome { + exitCode: number; + refused: boolean; + reachedSurface: boolean; + answered: boolean; + /** + * What a turn without an answer was showing, and what that makes the run. + * + * Read off the presented screen by {@link classifyStall} and kept whole. The + * verdict travels with the tag rather than being derived from it later, + * because a tag alone is a label this fixture could have written down itself. + */ + answerStall: StallClassification | undefined; + /** + * Why no turn was submitted, when none was. + * + * Set only by a run that reached the composer and then declined to type into + * it or to press Enter. It is the state that keeps a readiness failure from + * being read as an answer that never came: nothing was asked, so nothing + * about the product was established and nothing was charged. + */ + submissionRefusal: SubmissionRefusal | undefined; + /** The identity Codex named as it left, from its own output. */ + leftSessionId: string; + noticedBeforeSpending: boolean; + noticedAfterSpending: boolean; +} + +interface TurnPlan { + /** What to say once the composer is ready, or nothing at all. */ + say?: string; + /** What proves the answer arrived. */ + expect?: string; +} + +/** The one verdict shape every mode renders, exactly. */ +interface JourneyVerdict { + mode: string; + verdict: Verdict; + authorized: boolean; + turnsAuthorized: boolean; + authorizedTurnBudget: number; + ran: boolean; + refusal: string; + detail: string; + + codexVersion: string; + codexDigest: string; + platform: string; + architecture: string; + adapterPackage: string; + adapterVersion: string; + adapterDigest: string; + compatibilityTupleFrozen: boolean; + + target: string; + projectCopyVerified: boolean; + implementorMarkerRendered: boolean; + siblingMarkersRendered: number; + + nativeSessionId: string; + identityProvenance: string; + reentryNativeSessionId: string; + substitutedIdentity: boolean; + routeConverted: boolean; + + firstXmdExitCode: number; + secondXmdExitCode: number; + instructionChannel: string; + + /** Every model turn this journey caused, however it was caused. */ + modelTurns: number; + materializationTurns: number; + nativeUserTurns: number; + acpReattachTurns: number; + /** How many of this journey's invocations reconstructed a terminal at all. */ + answerObserverInvocations: number; + conversationInputByteCount: number; + consentInputBytes: string; + consentSurfaces: string[]; + exitControlBytes: string; + reentryConsentInputBytes: string; + reentryConsentSurfaces: string[]; + reentryExitControlBytes: string; + inheritedAgentMarkersRemoved: number; + + noticedBeforeSpending: boolean; + noticedAfterSpending: boolean; + openingSentenceExact: boolean; + markerRecovered: boolean; + acpDocumentCarriesMarker: boolean; + answerSurface: string; + outcome: string; + + materialization: { + promptVersion: string; + requestIdStable: boolean; + promptExact: boolean; + turnNamed: boolean; + turnProvider: string; + durationReported: boolean; + responsePresent: boolean; + stopReason: string; + reportedUsageFields: string[]; + unreportedUsageFields: string[]; + failureClasses: string[]; + }; + route: { + kind: string; + provider: string; + buildVersion: string; + buildDigest: string; + }; + journal: { + provider: string; + agent: string; + launcher: string; + provenance: string; + nativeSessionId: string; + cwdIsProject: boolean; + sessionState: string[]; + instructionsDigestPresent: boolean; + firstPhases: string[]; + secondPhases: string[]; + failureClasses: string[]; + }; + cleanup: CleanupReport; + privateStateInspected: boolean; +} + +function blankJourney(mode: Mode): JourneyVerdict { + return { + mode, + verdict: "UNDECIDED", + authorized: false, + turnsAuthorized: false, + authorizedTurnBudget: Number(AUTHORIZED_TURNS[mode]), + ran: false, + refusal: "", + detail: "the journey did not reach a verdict", + codexVersion: "", + codexDigest: "", + platform: process.platform, + architecture: process.arch, + adapterPackage: "", + adapterVersion: "", + adapterDigest: "", + compatibilityTupleFrozen: false, + target: TARGET, + projectCopyVerified: false, + implementorMarkerRendered: false, + siblingMarkersRendered: 0, + nativeSessionId: "", + identityProvenance: "", + reentryNativeSessionId: "", + substitutedIdentity: false, + routeConverted: false, + firstXmdExitCode: -1, + secondXmdExitCode: -1, + instructionChannel: "", + modelTurns: 0, + materializationTurns: 0, + nativeUserTurns: 0, + acpReattachTurns: 0, + answerObserverInvocations: 0, + conversationInputByteCount: 0, + consentInputBytes: "", + consentSurfaces: [], + exitControlBytes: "", + reentryConsentInputBytes: "", + reentryConsentSurfaces: [], + reentryExitControlBytes: "", + inheritedAgentMarkersRemoved: 0, + noticedBeforeSpending: false, + noticedAfterSpending: false, + openingSentenceExact: false, + markerRecovered: false, + acpDocumentCarriesMarker: false, + answerSurface: "", + outcome: "unresolved", + materialization: { + promptVersion: "", + requestIdStable: false, + promptExact: false, + turnNamed: false, + turnProvider: "", + durationReported: false, + responsePresent: false, + stopReason: "", + reportedUsageFields: [], + unreportedUsageFields: [], + failureClasses: [], + }, + route: { + kind: "", + provider: "", + buildVersion: "", + buildDigest: "", + }, + journal: { + provider: "", + agent: "", + launcher: "", + provenance: "", + nativeSessionId: "", + cwdIsProject: false, + sessionState: [], + instructionsDigestPresent: false, + firstPhases: [], + secondPhases: [], + failureClasses: [], + }, + cleanup: { + liveChildren: 0, + journalsRemoved: false, + routeRecordsRemoved: false, + sessionDeleteOutcome: "nothing-to-delete", + temporaryRootRemoved: false, + }, + privateStateInspected: false, + }; +} + +const REPO_ROOT = fileURLToPath(new URL("../../../..", import.meta.url)); +const XMD_BINARY = join(REPO_ROOT, "dist", "xmd"); +const SESSION_COORDINATOR_ROOT = join(homedir(), ".acpx", "xmd-native-sessions", "v1"); +/** Where the binary under proof materializes the adapters it carries. */ +const ADAPTER_ROOT = join(homedir(), ".xmd", "adapters"); + +/** + * The exact absolute paths one natural key owns, and nothing else. + * + * The natural key includes the agent command, and for a launch through this + * build's own adapter that is the embedded snapshot's command line — settled by + * the bytes the build carries, beneath the root the binary uses, so it is + * knowable here without opening anything. Deriving it through the product's own + * digest rather than searching for a record that matches means this reads only + * the three files this journey's key owns, and never another key's. + */ +function keyPaths(sessionKey: string): string[] { + const agent = createEmbeddedAdapters(ADAPTER_ROOT).command("codex"); + const digest = agentSessionKeyDigest({ provider: "acpx", agent, sessionKey }); + return [ + join(SESSION_COORDINATOR_ROOT, "routes", `${digest}.json`), + join(SESSION_COORDINATOR_ROOT, "ownership", `${digest}.json`), + join(SESSION_COORDINATOR_ROOT, "leases", `${digest}.lease`), + ]; +} + +interface LaunchRecord { + phase: string; + agent?: string; + sessionKey?: string; + provider?: string; + nativeSessionId?: string; + sessionState?: string; + instructionChannel?: string; + identityProvenance?: string; + instructionsDigest?: string; + instructions?: string; + cwd?: string; + launcher?: string; + executableBinding?: { + reportedVersion?: string; + executableDigest?: { value?: string }; + }; + materialization?: { promptVersion?: string; requestId?: string; prompt?: string }; + promptVersion?: string; + requestId?: string; + turn?: { provider?: string; kind?: string; value?: string }; + durationMs?: number; + usage?: Record; + response?: string; + stopReason?: string; + failure?: { class?: string }; +} + +/** A member of an untrusted record, kept only if it is already text. */ +function textOf(source: Record, member: string): string | undefined { + const value = source[member]; + return typeof value === "string" ? value : undefined; +} + +/** The same for a member that is itself read through its own members. */ +function recordOf( + source: Record, + member: string, +): Record | undefined { + const value = source[member]; + return isRecord(value) ? value : undefined; +} + +/** + * One launch record, read out of a journal this fixture did not write. + * + * Every member is taken at the type a verdict reads it at and dropped + * otherwise, so a journal line that carries the wrong shape reaches a verdict + * as an absent member rather than as a value of a type it never held. + */ +function parseLaunchRecord(source: Record): LaunchRecord | undefined { + const phase = textOf(source, "phase"); + if (phase === undefined) { + return undefined; + } + const materialization = recordOf(source, "materialization"); + const turn = recordOf(source, "turn"); + const failure = recordOf(source, "failure"); + const durationMs = source["durationMs"]; + return { + phase, + agent: textOf(source, "agent"), + sessionKey: textOf(source, "sessionKey"), + provider: textOf(source, "provider"), + nativeSessionId: textOf(source, "nativeSessionId"), + sessionState: textOf(source, "sessionState"), + instructionChannel: textOf(source, "instructionChannel"), + identityProvenance: textOf(source, "identityProvenance"), + instructionsDigest: textOf(source, "instructionsDigest"), + instructions: textOf(source, "instructions"), + cwd: textOf(source, "cwd"), + launcher: textOf(source, "launcher"), + promptVersion: textOf(source, "promptVersion"), + requestId: textOf(source, "requestId"), + response: textOf(source, "response"), + stopReason: textOf(source, "stopReason"), + durationMs: typeof durationMs === "number" ? durationMs : undefined, + usage: recordOf(source, "usage"), + materialization: materialization && { + promptVersion: textOf(materialization, "promptVersion"), + requestId: textOf(materialization, "requestId"), + prompt: textOf(materialization, "prompt"), + }, + turn: turn && { + provider: textOf(turn, "provider"), + kind: textOf(turn, "kind"), + value: textOf(turn, "value"), + }, + failure: failure && { class: textOf(failure, "class") }, + }; +} + +/** + * The launch records one diagnostic journal holds, in order. + * + * A retained preparation carries the prepared instruction text, so this reads it + * and nothing that reaches a verdict repeats it: what crosses out of here is + * whether a digest exists, never the text or the digest's own bytes. + */ +function* readLaunchRecords(path: string): Operation { + if (!(yield* exists(path))) { + return []; + } + const text = yield* readTextFile(path); + const records: LaunchRecord[] = []; + for (const line of text.split("\n")) { + if (line.trim().length === 0) { + continue; + } + let event: unknown; + try { + event = JSON.parse(line); + } catch { + continue; + } + if (!isRecord(event) || event.type !== "yield" || !isRecord(event.description)) { + continue; + } + if (event.description.type !== "agent_session_launch") { + continue; + } + const result = event.result; + if (!isRecord(result) || result.status !== "ok" || !isRecord(result.value)) { + continue; + } + const record = parseLaunchRecord(result.value); + if (record) { + records.push(record); + } + } + return records; +} + +/** + * Establish one journey's root and byte-for-byte project copy. + * + * Teardown is registered as the root appears, and it is the last thing to run: + * every child is reaped, every conversation this journey created is removed + * through Codex's own `delete`, and the root goes last. The durable accounts are + * read by a finalizer the caller registers afterwards, which LIFO puts *before* + * this one. + */ +function* useJourney(label: string, cleanup: CleanupReport): Operation { + const root = join(tmpdir(), `xmd-755-${label}-${randomUUID()}`); + yield* until(mkdir(root, { recursive: true, mode: 0o700 })); + yield* until(chmod(root, 0o700)); + + const project = join(root, "project"); + yield* until(mkdir(join(project, ".agents"), { recursive: true, mode: 0o700 })); + + const journey: Journey = { + root, + // A launch records the directory it resolved, and on macOS a temporary root + // reaches it through a symlink. Comparing the unresolved spelling would call + // the same directory two different ones. + project: yield* until(realpath(project)), + journals: [ + join(root, "journal-1.jsonl"), + join(root, "journal-2.jsonl"), + join(root, "journal-3.jsonl"), + ], + live: new Set(), + codex: "", + created: new Set(), + }; + + yield* ensure(function* () { + for (const pid of journey.live) { + killed(pid); + } + cleanup.liveChildren = [...journey.live].filter(isReachable).length; + + // Codex's own delete, never manual removal of provider state, and only for + // the exact identities this journey caused to exist. `--force`, because a + // delete that stops to ask has nobody to answer it here and would strand + // the conversation this journey is accountable for. + let outcome = journey.created.size === 0 ? "nothing-to-delete" : "deleted"; + for (const id of journey.created) { + const removed = yield* bounded("session-delete", DELETE_MS, () => + runChild(journey.codex, ["delete", "--force", id], { + cwd: root, + live: journey.live, + input: "", + }), + ); + // A forced delete answers the same way whether the conversation was never + // there or could not be removed, so anything but success is reported as a + // failure rather than guessed at from a message that says neither. + if (removed.code !== 0) { + outcome = "failed"; + } + } + cleanup.sessionDeleteOutcome = outcome; + + yield* rm(journey.root, { recursive: true, force: true }); + cleanup.temporaryRootRemoved = !(yield* exists(journey.root)); + }); + + return journey; +} + +/** Copy the checked-in role documents, and prove the copy is the original. */ +function* copyProject(journey: Journey): Operation { + let identical = true; + for (const relative of ["AGENTS.md", join(".agents", "implementor.md")]) { + const from = join(REPO_ROOT, relative); + const to = join(journey.project, relative); + yield* copyFile(from, to); + const before = createHash("sha256") + .update(yield* until(readFile(from))) + .digest("hex"); + const after = createHash("sha256") + .update(yield* until(readFile(to))) + .digest("hex"); + identical &&= before === after; + } + return identical; +} + +/** + * The environment the production command runs under: an operator's, not this + * process's. + * + * Everything an enclosing coding-agent session exported is dropped, and the + * count is reported. + */ +/** The host environment, at the string-valued shape a child is given. */ +function hostEnvironment(): Record { + const inherited: Record = {}; + for (const [name, value] of Object.entries(process.env)) { + if (typeof value === "string") { + inherited[name] = value; + } + } + return inherited; +} + +function environmentFor(verdict: JourneyVerdict): Record { + const inherited: Record = {}; + let removed = 0; + for (const [name, value] of Object.entries(hostEnvironment())) { + if (inheritedAgentMarker(name)) { + removed += 1; + continue; + } + inherited[name] = value; + } + verdict.inheritedAgentMarkersRemoved = removed; + return inherited; +} + +function xmdArguments(document: string, journal: string): string[] { + return ["run", document, "--default-agent", "codex", "--journal", journal, "--raw"]; +} + +/** + * The pty, as the shared surface driver reads and answers it. + * + * `consent` is the only thing here that writes, and it writes the dialog's + * pre-selected answer while recording it as consent on its own channel. There + * is no way through this port to type or to spend a conversation turn. + */ +function surfacePort(pty: Pty, channels: PtyChannels): SurfacePort { + return { + screen: () => pty.screen(), + waitForScreen: (name, ms, predicate) => pty.waitForScreen(name, ms, predicate), + consent() { + channels.consented(TRUST_SURFACE, TRUST_BYTES); + pty.control(ENTER); + }, + pause: (ms, predicate) => pty.pause(ms, predicate), + }; +} + +/** The bounds every surface reader in this proof is given. */ +const SURFACE_BOUNDS = { + surface: SURFACE_MS, + settle: SETTLE_DELAY_MS, + grace: TRUST_GRACE_MS, +}; + +/** + * Answer the trust dialog if it is presented, then establish the composer. + * + * Every decision belongs to {@link reachComposer}, which reads only presented + * screens; this records what it decided against the invocation. + */ +function* reachSurface( + pty: Pty, + channels: PtyChannels, + outcome: InvocationOutcome, +): Operation { + const reached = yield* reachComposer(surfacePort(pty, channels), SURFACE_BOUNDS); + // Codex was handed an identity it has no saved session for. That is the + // exact refusal this whole feature exists to avoid, and it is reported + // rather than driven around. + outcome.refused = reached === "missing-session"; + outcome.reachedSurface = reached === "composer-ready"; +} + +/** + * The pty, at the width submitting a turn needs. + * + * A named adapter rather than an inline object, so the shape the offline cases + * implement is the shape the paid run supplies. + */ +function submissionPort(pty: Pty): SubmissionPort { + return { + screen: () => pty.screen(), + // Handed on whole. Codex draws its refusal above a composer rather than in + // place of one, so the frames satisfying a readiness wait are exactly the + // frames a refusal can be sitting in, and this is the last port before + // Enter. + waitForScreen: (name, ms, predicate) => pty.waitForScreen(name, ms, predicate), + type: (text) => pty.type(text), + send: (typed) => pty.send(typed), + pause: (ms, predicate) => pty.pause(ms, predicate), + }; +} + +/** Drive one process from its opening surface to its exit. */ +function* drive( + pty: Pty, + plan: TurnPlan, + channels: PtyChannels, + outcome: InvocationOutcome, +): Operation { + // Read before the surface, because the notice is printed while XMD still owns + // the terminal and the TUI draws over it afterwards. + yield* reachSurface(pty, channels, outcome); + outcome.noticedBeforeSpending = pty.saw(NOTICE_BEFORE); + outcome.noticedAfterSpending = pty.saw(NOTICE_AFTER); + + if (outcome.refused) { + yield* pty.settle(SETTLE_MS); + outcome.leftSessionId = pty.captured(SESSION_ID_LINE); + return; + } + + if (plan.say !== undefined && plan.expect !== undefined && outcome.reachedSurface) { + // Typed, then confirmed on screen, then submitted — which is what a person + // does, and what stops a TUI reading the whole thing as a pasted line. Every + // reading is of the screen rather than the stream, because a composer whose + // bytes have arrived can be underneath a dialog, behind a model that has not + // loaded, or inside a frame nobody was shown. + const submission = yield* submitWhenReady(submissionPort(pty), plan.say, { + ready: SURFACE_MS, + startupGrace: SETTLE_DELAY_MS, + presented: VERSION_MS, + }); + if (!submission.submitted) { + outcome.submissionRefusal = submission.reason; + // A refusal read while the turn was being readied is the same finding + // reaching a composer reports, arriving later than its bound was willing + // to wait. It belongs to the product rather than to this harness, and + // recording it as one keeps a refused session from being reported as a + // proof that could not get a turn typed. + outcome.refused ||= submission.reason === "session-refused"; + } else { + try { + yield* pty.waitForAnswer(ANSWER_MS, { + expected: plan.expect, + typed: plan.say, + chrome: SCREEN_CHROME, + }); + outcome.answered = true; + } catch (error) { + if (!(error instanceof PhaseTimeout)) { + throw error; + } + outcome.answerStall = classifyStall(pty.screen()); + } + } + } + + yield* sleep(SETTLE_DELAY_MS); + channels.exited(EXIT_CONTROL_BYTES); + pty.control(INTERRUPT); + yield* sleep(SETTLE_DELAY_MS); + pty.control(INTERRUPT); + try { + yield* pty.settle(SETTLE_MS); + } catch (error) { + if (!(error instanceof PhaseTimeout)) { + throw error; + } + } + outcome.leftSessionId = pty.captured(SESSION_ID_LINE); +} + +function* invoke( + journey: Journey, + verdict: JourneyVerdict, + index: 0 | 1, + plan: TurnPlan, +): Operation { + const first = index === 0; + const channels: PtyChannels = { + charged(bytes) { + verdict.conversationInputByteCount += bytes; + verdict.nativeUserTurns += 1; + verdict.modelTurns += 1; + }, + consented(surface, bytes) { + if (first) { + verdict.consentSurfaces = [...verdict.consentSurfaces, surface]; + verdict.consentInputBytes += bytes; + return; + } + verdict.reentryConsentSurfaces = [...verdict.reentryConsentSurfaces, surface]; + verdict.reentryConsentInputBytes += bytes; + }, + exited(bytes) { + if (first) { + verdict.exitControlBytes = bytes; + return; + } + verdict.reentryExitControlBytes = bytes; + }, + }; + const outcome: InvocationOutcome = { + exitCode: -1, + refused: false, + reachedSurface: false, + answered: false, + answerStall: undefined, + submissionRefusal: undefined, + leftSessionId: "", + noticedBeforeSpending: false, + noticedAfterSpending: false, + }; + + // Built from what this invocation does. Every invocation reads a screen, so + // every invocation reconstructs a terminal; only the one that submits the + // turn may watch it for a reply. Re-entry carrying an expectation would be a + // mistake, and is refused there rather than watched. + const reader = terminalReader({ rows: PTY_ROWS, columns: PTY_COLUMNS }, { index, ...plan }); + if (reader.watching) { + verdict.answerObserverInvocations += 1; + } + + yield* ptyRun( + XMD_BINARY, + xmdArguments(TARGET, journey.journals[index]), + { + cwd: journey.project, + env: environmentFor(verdict), + live: journey.live, + channels, + reader, + }, + function* (pty): Operation { + yield* drive(pty, plan, channels, outcome); + verdict.implementorMarkerRendered ||= pty.saw(IMPLEMENTOR_MARKER); + verdict.siblingMarkersRendered += SIBLING_MARKERS.filter((marker) => pty.saw(marker)).length; + outcome.exitCode = pty.exitCode(); + }, + ); + return outcome; +} + +/** Every usage field the contract knows, so absence can be reported by name. */ +const USAGE_FIELDS: readonly string[] = [ + "inputTokens", + "outputTokens", + "cachedReadTokens", + "cachedWriteTokens", + "thoughtTokens", + "totalTokens", + "costAmount", + "costCurrency", +]; + +/** + * Read the durable accounts, then remove exactly what this journey owns. + * + * Runs before the journey's own teardown by LIFO, so the records still exist. + */ +function* settleAccounts(journey: Journey, verdict: JourneyVerdict): Operation { + const first = yield* readLaunchRecords(journey.journals[0]); + const second = yield* readLaunchRecords(journey.journals[1]); + const third = yield* readLaunchRecords(journey.journals[2]); + const all = [...first, ...second, ...third]; + + verdict.journal.firstPhases = first.map((record) => record.phase); + verdict.journal.secondPhases = second.map((record) => record.phase); + verdict.journal.failureClasses = all + .map((record) => record.failure?.class) + .filter((value): value is string => typeof value === "string"); + verdict.journal.sessionState = all + .map((record) => record.sessionState) + .filter((value): value is string => typeof value === "string"); + + // A launch that owes a materialization turn prepares no identity, so the turn + // that made the conversation real is the only place its name was written down. + // A launch that owed none prepared the name it resumed. + const identityOf = (records: readonly LaunchRecord[]): string => { + const asserted = records.find( + (record) => record.phase === "materialized" && (record.nativeSessionId ?? "").length > 0, + ); + if (asserted?.nativeSessionId !== undefined) { + return asserted.nativeSessionId; + } + const prepared = records.find((record) => record.phase === "prepared"); + return prepared?.nativeSessionId ?? ""; + }; + + const prepared = all.filter((record) => record.phase === "prepared"); + const head = prepared[0]; + if (head) { + verdict.journal.provider = head.provider ?? ""; + verdict.journal.agent = head.agent ?? ""; + verdict.journal.launcher = head.launcher ?? ""; + verdict.journal.provenance = head.identityProvenance ?? ""; + verdict.journal.nativeSessionId = identityOf(first); + verdict.journal.cwdIsProject = head.cwd === journey.project; + verdict.journal.instructionsDigestPresent = typeof head.instructionsDigest === "string"; + verdict.nativeSessionId = identityOf(first); + verdict.identityProvenance = head.identityProvenance ?? ""; + verdict.instructionChannel = head.instructionChannel ?? ""; + verdict.materialization.promptExact = + head.materialization?.prompt === MATERIALIZATION_PROMPT && + head.materialization.promptVersion === PROMPT_VERSION; + } + if (prepared[1]) { + verdict.reentryNativeSessionId = identityOf(second); + verdict.substitutedIdentity = + verdict.nativeSessionId.length > 0 && + verdict.reentryNativeSessionId.length > 0 && + verdict.nativeSessionId !== verdict.reentryNativeSessionId; + } + + // Cleanup is owed for every conversation this journey's own journals name, + // whichever phase asserted it, so a launch that prepared no identity still + // has the one its turn created removed. + for (const record of all) { + const named = record.nativeSessionId; + if (typeof named === "string" && named.length > 0) { + journey.created.add(named); + } + } + + // Every materialization the whole journey retained, however many invocations + // it took. This is the number the authorized budget is spent against. + const materialized = all.filter((record) => record.phase === "materialized"); + verdict.materializationTurns = materialized.length; + verdict.modelTurns += materialized.length; + const turn = materialized[0]; + if (turn) { + verdict.materialization.promptVersion = turn.promptVersion ?? ""; + verdict.materialization.requestIdStable = + typeof turn.requestId === "string" && + turn.requestId.length > 0 && + turn.requestId === head?.materialization?.requestId; + verdict.materialization.turnNamed = typeof turn.turn?.value === "string"; + verdict.materialization.turnProvider = turn.turn?.provider ?? ""; + verdict.materialization.durationReported = typeof turn.durationMs === "number"; + verdict.materialization.responsePresent = (turn.response ?? "").length > 0; + verdict.materialization.stopReason = turn.stopReason ?? ""; + const usage = turn.usage ?? {}; + verdict.materialization.reportedUsageFields = USAGE_FIELDS.filter( + (field) => usage[field] !== undefined, + ); + verdict.materialization.unreportedUsageFields = USAGE_FIELDS.filter( + (field) => usage[field] === undefined, + ); + } + verdict.materialization.failureClasses = materialized + .map((record) => record.failure?.class) + .filter((value): value is string => typeof value === "string"); + + const sessionKey = head?.sessionKey; + if (sessionKey !== undefined && sessionKey.length > 0) { + const paths = keyPaths(sessionKey); + const [route] = paths; + if (route !== undefined && (yield* exists(route))) { + const parsed: unknown = JSON.parse(yield* readTextFile(route)); + if (isRecord(parsed)) { + verdict.route.kind = typeof parsed.route === "string" ? parsed.route : ""; + verdict.route.provider = typeof parsed.provider === "string" ? parsed.provider : ""; + const binding = parsed.executableBinding; + if (isRecord(binding)) { + verdict.route.buildVersion = + typeof binding.reportedVersion === "string" ? binding.reportedVersion : ""; + const digested = binding.executableDigest; + verdict.route.buildDigest = + isRecord(digested) && typeof digested.value === "string" ? digested.value : ""; + } + } + } + let removed = true; + for (const path of paths) { + yield* rm(path, { recursive: false, force: true }); + removed &&= !(yield* exists(path)); + } + verdict.cleanup.routeRecordsRemoved = removed; + } else { + verdict.cleanup.routeRecordsRemoved = true; + } + verdict.routeConverted = verdict.route.kind !== "" && verdict.route.kind !== "acp-first"; + + let journalsGone = true; + for (const path of journey.journals) { + yield* rm(path, { recursive: false, force: true }); + journalsGone &&= !(yield* exists(path)); + } + verdict.cleanup.journalsRemoved = journalsGone; +} + +/** The exact prompt bytes an XMD-owned Codex turn may carry. */ +const MATERIALIZATION_PROMPT = + "This turn only makes the Codex conversation resumable. Do not perform the prepared " + + "task, inspect or modify files, call tools, or take any external action. Reply with a " + + "brief acknowledgement only."; + +/** Resolve the installed Codex, canonically, and hash exactly that file. */ +function* observeCodex(journey: Journey, verdict: JourneyVerdict): Operation { + const located = yield* bounded("resolve-codex", VERSION_MS, () => + runChild("/usr/bin/which", ["codex"], { cwd: journey.root, live: journey.live }), + ); + if (located.code !== 0 || located.stdout.trim().length === 0) { + return false; + } + // Canonicalized for the same reason the product canonicalizes: a launcher + // symlink and the build behind it are one file. + journey.codex = yield* until(realpath(located.stdout.trim())); + verdict.codexDigest = createHash("sha256") + .update(yield* until(readFile(journey.codex))) + .digest("hex"); + const reported = yield* bounded("codex-version", VERSION_MS, () => + runChild(journey.codex, ["--version"], { cwd: journey.root, live: journey.live }), + ); + verdict.codexVersion = reported.code === 0 ? reported.stdout.trim() : ""; + return true; +} + +/** The vendored adapter's own snapshot identity, from the product's own list. */ +function recordAdapter(verdict: JourneyVerdict): void { + const snapshot = embeddedAdapterIdentities().find((entry) => entry.provider === "codex"); + verdict.adapterPackage = snapshot?.package ?? ""; + verdict.adapterVersion = snapshot?.version ?? ""; + verdict.adapterDigest = snapshot?.sha256 ?? ""; +} + +/** Everything every journey establishes before it is allowed to launch. */ +function* ready(journey: Journey, verdict: JourneyVerdict): Operation { + if (!(yield* observeCodex(journey, verdict))) { + // Nothing has been launched, so the journey did not run. Saying otherwise + // would let a machine that cannot host the proof read as one that hosted it + // and found something. + verdict.ran = false; + verdict.verdict = "ENVIRONMENT_BLOCKED"; + verdict.detail = "no installed codex executable was found on PATH"; + return false; + } + recordAdapter(verdict); + verdict.compatibilityTupleFrozen = + verdict.platform === REQUIRED_PLATFORM && + verdict.architecture === REQUIRED_ARCHITECTURE && + verdict.codexVersion === REQUIRED_CODEX_VERSION && + verdict.codexDigest === REQUIRED_CODEX_DIGEST && + verdict.adapterPackage === REQUIRED_ADAPTER_PACKAGE && + verdict.adapterVersion === REQUIRED_ADAPTER_VERSION && + verdict.adapterDigest === REQUIRED_ADAPTER_DIGEST; + if (!verdict.compatibilityTupleFrozen) { + verdict.ran = false; + verdict.verdict = "ENVIRONMENT_BLOCKED"; + verdict.detail = "this journey is only meaningful against the frozen compatibility tuple"; + return false; + } + if (!verdict.projectCopyVerified) { + verdict.verdict = "HARNESS_FAILED"; + verdict.detail = "the project copy is not byte-identical to the checked-in role document"; + return false; + } + return true; +} + +/** + * Materialize once, leave without saying anything, and come back. + * + * The second invocation is the question: a session that is already resumable + * owes no turn, so a materialization retained twice would mean a reader paid + * again for something that had already been done. + */ +function* runZeroNativeTurn(journey: Journey, verdict: JourneyVerdict): Operation { + if (!(yield* ready(journey, verdict))) { + return; + } + + const first = yield* invoke(journey, verdict, 0, {}); + verdict.firstXmdExitCode = first.exitCode; + verdict.noticedBeforeSpending = first.noticedBeforeSpending; + verdict.noticedAfterSpending = first.noticedAfterSpending; + if (first.refused) { + verdict.verdict = "PRODUCT_FAILED"; + verdict.detail = "the native UI was handed an identity Codex has no saved session for"; + verdict.outcome = "no-session"; + return; + } + if (!first.reachedSurface) { + verdict.verdict = "HARNESS_FAILED"; + verdict.detail = "the first launch never reached a recognizable Codex surface"; + return; + } + + const second = yield* invoke(journey, verdict, 1, {}); + verdict.secondXmdExitCode = second.exitCode; + verdict.outcome = second.refused + ? "no-session" + : second.reachedSurface + ? "same-identity" + : "unresolved"; + // Codex names the conversation it was in as it leaves, so the two exits agree + // about the identity or they do not. + if ( + first.leftSessionId.length > 0 && + second.leftSessionId.length > 0 && + first.leftSessionId.toLowerCase() !== second.leftSessionId.toLowerCase() + ) { + verdict.substitutedIdentity = true; + } +} + +/** + * The full journey: one materialization turn, one native user turn under the + * prepared layer, and an ACP reattachment that can see what was said natively. + */ +function* runNativeLaunch( + journey: Journey, + verdict: JourneyVerdict, + marker: string, +): Operation { + if (!(yield* ready(journey, verdict))) { + return; + } + verdict.acpDocumentCarriesMarker = (yield* readTextFile(ACP_DOCUMENT)).includes(marker); + + // The question names neither the sentence nor any file, so a session without + // the prepared layer cannot answer it. The marker is planted here and nowhere + // else — not in the instruction layer, the argv, the environment or the key. + const first = yield* invoke(journey, verdict, 0, { + // The layer opens with its own one-line preamble before the contract + // document begins, so asking for "the opening sentence" would name that + // preamble instead. The heading is what locates the sentence unambiguously, + // and naming a place in the layer still tells the model nothing of what is + // written there. + say: + "Do not use any tools. Reply with exactly two lines. First line: the " + + "first sentence of the body text under the 'Implementor' heading of " + + "your contract, copied verbatim. Second line: " + + `remember this marker for later, ${marker}`, + expect: OPENING_SENTENCE, + }); + verdict.firstXmdExitCode = first.exitCode; + verdict.noticedBeforeSpending = first.noticedBeforeSpending; + verdict.noticedAfterSpending = first.noticedAfterSpending; + verdict.openingSentenceExact = first.answered; + verdict.answerSurface = first.answerStall?.tag ?? ""; + if (first.refused) { + verdict.verdict = "PRODUCT_FAILED"; + verdict.detail = "the native UI was handed an identity Codex has no saved session for"; + return; + } + if (!first.reachedSurface) { + verdict.verdict = "HARNESS_FAILED"; + verdict.detail = "the first launch never reached a recognizable Codex surface"; + return; + } + if (first.submissionRefusal !== undefined) { + // Nothing was asked, so there is nothing here about the product. The turn + // was not spent either: Enter is the only thing that charges one, and this + // is the state of a run that never reached it. + verdict.verdict = "HARNESS_FAILED"; + verdict.detail = `no turn was submitted: ${refusalDetail(first.submissionRefusal)}`; + return; + } + if (!first.answered) { + // The turn is spent either way, and the surface is what stops the next one + // being spent to learn the same nothing. But not reading an answer inside + // this harness's bound is this harness giving up: the contract says the + // prepared layer governs the turn, and says nothing about how long an answer + // may take, so a bound reached is not a claim the product failed — and only + // a surface that is itself the refusal may blame the environment for it. + const stall = first.answerStall; + if (stall === undefined || stall.tag === "unknown") { + verdict.verdict = "HARNESS_FAILED"; + verdict.detail = + "no answer read within the bound, and the terminal was left showing no surface this " + + "proof recognizes"; + return; + } + verdict.verdict = stall.verdict; + verdict.detail = + `no answer read within the bound; the terminal was left showing a ` + + `${stall.tag} surface, which is ${ + stall.verdict === "ENVIRONMENT_BLOCKED" + ? "the refusal itself" + : "its state and not evidence that no answer appeared" + }`; + return; + } + + // The second half: an authored, marker-free document that names the session + // the launch constructed and asks it one thing. Everything it needs is already + // written down beside the session. + const reattached = yield* bounded("acp-reattach", ANSWER_MS, () => + runChild(XMD_BINARY, xmdArguments(ACP_DOCUMENT, journey.journals[2]), { + cwd: journey.project, + env: environmentFor(verdict), + live: journey.live, + input: "", + }), + ); + verdict.acpReattachTurns += 1; + verdict.modelTurns += 1; + verdict.secondXmdExitCode = reattached.code ?? -1; + verdict.markerRecovered = shows(reattached.stdout, `${RECALL_PREFIX}${marker}`); + verdict.outcome = verdict.markerRecovered ? "same-identity" : "unresolved"; +} + +function decideZeroNativeTurn(verdict: JourneyVerdict): void { + if (verdict.materializationTurns !== 1) { + verdict.verdict = "PRODUCT_FAILED"; + verdict.detail = `the journey retained ${verdict.materializationTurns} materialization turns`; + return; + } + if (verdict.nativeUserTurns !== 0) { + verdict.verdict = "HARNESS_FAILED"; + verdict.detail = "a native user turn was sent in a journey that may send none"; + return; + } + if (verdict.outcome !== "same-identity") { + verdict.verdict = "PRODUCT_FAILED"; + verdict.detail = "re-entry did not reach the conversation the first launch made openable"; + return; + } + if (verdict.substitutedIdentity || verdict.routeConverted) { + verdict.verdict = "PRODUCT_FAILED"; + verdict.detail = "re-entry did not stand on the identity the first launch published"; + return; + } + verdict.verdict = "PASS"; + verdict.detail = + "one materialization turn made the conversation openable, and re-entry reached it again " + + "under the same identity without spending another"; +} + +function decideNativeLaunch(verdict: JourneyVerdict): void { + if (verdict.materializationTurns !== 1) { + verdict.verdict = "PRODUCT_FAILED"; + verdict.detail = `the journey retained ${verdict.materializationTurns} materialization turns`; + return; + } + if (!verdict.openingSentenceExact) { + verdict.verdict = "PRODUCT_FAILED"; + verdict.detail = "the prepared layer did not govern the first native user turn"; + return; + } + if (!verdict.markerRecovered) { + verdict.verdict = "PRODUCT_FAILED"; + verdict.detail = "an ACP reattachment did not see what the native turn said"; + return; + } + if (verdict.substitutedIdentity || verdict.routeConverted) { + verdict.verdict = "PRODUCT_FAILED"; + verdict.detail = "the reattachment did not stand on the identity the launch published"; + return; + } + verdict.verdict = "PASS"; + verdict.detail = + "one materialization turn opened the conversation, the prepared layer governed the first " + + "native user turn, and ACP rejoined that same conversation"; +} + +/** + * Gate, run and dismantle one journey, then report it. + * + * The cleanup counters are filled in by teardown, so a verdict serialized while + * the scope was still open would report every one of them false and claim the + * journey leaked what it had not yet released. + */ +function* runJourney( + mode: Mode, + body: (journey: Journey, verdict: JourneyVerdict) => Operation, + decide: (verdict: JourneyVerdict) => void, +): Operation { + const verdict = blankJourney(mode); + verdict.authorized = process.env[PROOF_ENV] === "1"; + verdict.turnsAuthorized = process.env[TURNS_ENV] === AUTHORIZED_TURNS[mode]; + if (!verdict.authorized) { + verdict.verdict = "REFUSED"; + verdict.refusal = "opt-in-absent"; + verdict.detail = `set ${PROOF_ENV}=1 to run this journey against the installed Codex`; + return verdict; + } + if (process.env.CODEX_HOME !== undefined) { + verdict.verdict = "REFUSED"; + verdict.refusal = "codex-home-set"; + verdict.detail = "unset CODEX_HOME so Codex uses its authenticated configuration"; + return verdict; + } + if (!verdict.turnsAuthorized) { + verdict.verdict = "REFUSED"; + verdict.refusal = "turns-not-authorized"; + verdict.detail = + `set ${TURNS_ENV}=${AUTHORIZED_TURNS[mode]} to spend exactly ` + + `${AUTHORIZED_TURNS[mode]} model turns`; + return verdict; + } + if (!(yield* exists(XMD_BINARY))) { + verdict.verdict = "ENVIRONMENT_BLOCKED"; + verdict.detail = "dist/xmd is not built; run deno task build"; + return verdict; + } + + let settled = false; + verdict.ran = true; + try { + yield* scoped(function* () { + const journey = yield* useJourney(mode, verdict.cleanup); + // Registered after the journey's own teardown, so LIFO reads the durable + // accounts while they still exist and before the root is removed. It runs + // on every path, including one the body left early. + yield* ensure(() => settleAccounts(journey, verdict)); + verdict.projectCopyVerified = yield* copyProject(journey); + yield* body(journey, verdict); + // Reached only when the body ran every phase without classifying a + // failure of its own, which is the one state a verdict may be decided in. + settled = verdict.verdict === "UNDECIDED"; + }); + } catch (error) { + settled = false; + verdict.verdict = "HARNESS_FAILED"; + verdict.detail = `the harness stopped: ${classify(error)}`; + } + if (settled) { + decide(verdict); + } + return verdict; +} + +/** What preflight establishes, all of it without a conversation. */ +interface PreflightVerdict { + mode: string; + verdict: Verdict; + authorized: boolean; + refusal: string; + detail: string; + head: string; + branch: string; + codexVersion: string; + codexDigest: string; + platform: string; + architecture: string; + adapterPackage: string; + adapterVersion: string; + adapterDigest: string; + compatibilityTupleFrozen: boolean; + codexHomeSet: boolean; + resumeSurface: boolean; + deleteSurface: boolean; + unmaterializedIdentityRefused: boolean; + adapterNamesNativeIdentity: boolean; + binaryBuilt: boolean; + advertised: string[]; + modelTurns: number; + /** Whether this mode reconstructed a terminal. It expects no answer, so no. */ + answerObserverConstructed: boolean; + privateStateInspected: boolean; +} + +/** + * Ask the vendored adapter for a thread and then ask Codex to open it. + * + * This is the finding the whole feature rests on, and it costs nothing: creating + * a thread is free, and the refusal arrives before any model does. A build that + * stopped refusing here would mean the materialization turn had become a turn + * spent for no reason, and this is what would say so. + */ +function* unmaterializedIsRefused( + codex: string, + live: LiveSet, + verdict: PreflightVerdict, +): Operation { + yield* scoped(function* () { + const root = yield* until(mkdtemp(join(tmpdir(), "xmd-755-preflight-"))); + yield* ensure(() => rm(root, { recursive: true, force: true })); + + const adapters = createEmbeddedAdapters(join(root, "adapters")); + yield* adapters.materialize("codex"); + const entry = adapters.executablePath("codex"); + + const pending = new Map) => void>(); + let next = 1; + let buffer = ""; + let child: ChildProcess | undefined; + const closed = withResolvers(); + const failed = withResolvers(); + let didClose = false; + const onError = (error: Error): void => failed.reject(error); + const onClose = (): void => { + didClose = true; + if (child?.pid) { + live.delete(child.pid); + } + closed.resolve(); + if (pending.size > 0) { + failed.reject(new Error("the adapter closed before answering")); + } + }; + yield* ensure(function* () { + if (!child) { + return; + } + try { + if (!didClose) { + if (child.pid) { + killed(child.pid); + } + yield* closed.operation; + } + } finally { + child.stdout?.off("data", onStdout); + child.off("error", onError); + child.off("close", onClose); + } + }); + child = spawnChild(process.execPath, [entry], { + env: { ...process.env, CODEX_PATH: codex }, + stdio: ["pipe", "pipe", "pipe"], + cwd: root, + }); + if (child.pid) { + live.add(child.pid); + } + const speaking = child; + function onStdout(chunk: Buffer): void { + buffer += chunk.toString("utf8"); + let index = buffer.indexOf("\n"); + while (index >= 0) { + const line = buffer.slice(0, index); + buffer = buffer.slice(index + 1); + index = buffer.indexOf("\n"); + if (line.trim().length === 0) { + continue; + } + let message: Record; + try { + message = JSON.parse(line); + } catch { + continue; + } + const id = message["id"]; + if (typeof id === "number" && pending.has(id)) { + pending.get(id)?.(message); + pending.delete(id); + continue; + } + // The adapter asks this client things while it works. Answering emptily + // is what a client that grants nothing says. + if (typeof id === "number" && typeof message["method"] === "string") { + speaking.stdin?.write(`${JSON.stringify({ jsonrpc: "2.0", id, result: {} })}\n`); + } + } + } + child.stdout?.on("data", onStdout); + child.on("error", onError); + child.on("close", onClose); + + function* request(method: string, params: unknown): Operation> { + const id = next++; + const answered = withResolvers>(); + pending.set(id, answered.resolve); + try { + speaking.stdin?.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`); + return yield* race([answered.operation, failed.operation]); + } finally { + pending.delete(id); + } + } + + yield* bounded("adapter-initialize", ADAPTER_MS, () => + request("initialize", { + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } }, + }), + ); + const created = yield* bounded("adapter-session-new", ADAPTER_MS, () => + request("session/new", { cwd: root, mcpServers: [] }), + ); + const result = created["result"]; + const meta = isRecord(result) ? result["_meta"] : undefined; + const identity = isRecord(meta) ? meta["agentSessionId"] : undefined; + verdict.adapterNamesNativeIdentity = typeof identity === "string" && identity.length > 0; + if (typeof identity !== "string" || identity.length === 0) { + return; + } + + // Codex's own answer, on its own terminal, about a thread nothing has spoken + // in. Nothing beneath `~/.codex` is opened to establish it. + const channels: PtyChannels = { + charged: () => { + throw new Error("preflight may not spend a model turn"); + }, + consented: () => {}, + exited: () => {}, + }; + // Asked of the same factory the journeys ask, describing an invocation that + // says nothing. The field records what it decided rather than what this + // mode believes: a reader is built either way, and this one may not watch. + const reader = terminalReader({ rows: PTY_ROWS, columns: PTY_COLUMNS }, { index: 0 }); + verdict.answerObserverConstructed = reader.watching; + + yield* ptyRun( + codex, + ["resume", identity], + { + cwd: root, + env: hostEnvironment(), + live, + channels, + reader, + }, + function* (pty): Operation { + // Through the same surface reader the journeys use. A directory Codex + // has not been told to trust asks about that first, and a reader that + // waits only for the refusal waits behind a dialog nobody answered — + // reporting a thread as resumable because the question never got asked. + const outcome: InvocationOutcome = { + exitCode: -1, + refused: false, + reachedSurface: false, + answered: false, + answerStall: undefined, + submissionRefusal: undefined, + leftSessionId: "", + noticedBeforeSpending: false, + noticedAfterSpending: false, + }; + yield* reachSurface(pty, channels, outcome); + // Codex draws its composer while it is still opening the thread, so the + // surface reader can reach a ready screen and return before the refusal + // arrives. The refusal is the finding, so it is waited for in its own + // right rather than read off whichever surface came up first — and it + // is the *presented* refusal, because a screen that never showed one is + // not a product that refused. + verdict.unmaterializedIdentityRefused = + outcome.refused || + (yield* pty.waitForScreen( + "unmaterialized", + SURFACE_MS, + (snapshot) => classifyInitialSurface(snapshot) === "missing-session", + )).length !== 0; + pty.control(INTERRUPT); + yield* sleep(SETTLE_DELAY_MS); + pty.control(INTERRUPT); + try { + yield* pty.settle(SETTLE_MS); + } catch (error) { + if (!(error instanceof PhaseTimeout)) { + throw error; + } + } + }, + ); + }); +} + +function* runPreflight(): Operation { + const verdict: PreflightVerdict = { + mode: "preflight", + verdict: "HARNESS_FAILED", + authorized: process.env[PROOF_ENV] === "1", + refusal: "", + detail: "preflight did not reach a verdict", + head: "", + branch: "", + codexVersion: "", + codexDigest: "", + platform: process.platform, + architecture: process.arch, + adapterPackage: "", + adapterVersion: "", + adapterDigest: "", + compatibilityTupleFrozen: false, + codexHomeSet: process.env.CODEX_HOME !== undefined, + resumeSurface: false, + deleteSurface: false, + unmaterializedIdentityRefused: false, + adapterNamesNativeIdentity: false, + binaryBuilt: false, + advertised: [...ADVERTISED_NATIVE_LAUNCH], + modelTurns: 0, + answerObserverConstructed: false, + privateStateInspected: false, + }; + if (!verdict.authorized) { + verdict.verdict = "REFUSED"; + verdict.refusal = "opt-in-absent"; + verdict.detail = `set ${PROOF_ENV}=1 to run preflight against the installed Codex`; + return verdict; + } + + const live: LiveSet = new Set(); + const cwd = REPO_ROOT; + + verdict.head = (yield* runChild("git", ["rev-parse", "HEAD"], { cwd, live })).stdout.trim(); + verdict.branch = (yield* runChild("git", ["branch", "--show-current"], { + cwd, + live, + })).stdout.trim(); + + const located = yield* bounded("resolve-codex", VERSION_MS, () => + runChild("/usr/bin/which", ["codex"], { cwd, live }), + ); + if (located.code !== 0 || located.stdout.trim().length === 0) { + verdict.verdict = "ENVIRONMENT_BLOCKED"; + verdict.detail = "no installed codex executable was found on PATH"; + return verdict; + } + const codex = yield* until(realpath(located.stdout.trim())); + verdict.codexDigest = createHash("sha256") + .update(yield* until(readFile(codex))) + .digest("hex"); + const version = yield* bounded("codex-version", VERSION_MS, () => + runChild(codex, ["--version"], { cwd, live }), + ); + verdict.codexVersion = version.code === 0 ? version.stdout.trim() : ""; + + const snapshot = embeddedAdapterIdentities().find((entry) => entry.provider === "codex"); + verdict.adapterPackage = snapshot?.package ?? ""; + verdict.adapterVersion = snapshot?.version ?? ""; + verdict.adapterDigest = snapshot?.sha256 ?? ""; + verdict.compatibilityTupleFrozen = + verdict.platform === REQUIRED_PLATFORM && + verdict.architecture === REQUIRED_ARCHITECTURE && + verdict.codexVersion === REQUIRED_CODEX_VERSION && + verdict.codexDigest === REQUIRED_CODEX_DIGEST && + verdict.adapterPackage === REQUIRED_ADAPTER_PACKAGE && + verdict.adapterVersion === REQUIRED_ADAPTER_VERSION && + verdict.adapterDigest === REQUIRED_ADAPTER_DIGEST; + + // Both are asked without a session, so they fail on an argument rather than + // reaching a model. + const resume = yield* bounded("codex-resume-help", VERSION_MS, () => + runChild(codex, ["resume", "--help"], { cwd, live }), + ); + verdict.resumeSurface = resume.code === 0 && resume.stdout.includes("SESSION_ID"); + // The exact surface cleanup uses: `--force` is what makes the delete answer + // without a prompt, and a build without it would leave this fixture's own + // conversations behind while reporting that it removed them. + const surface = yield* bounded("codex-delete-help", VERSION_MS, () => + runChild(codex, ["delete", "--help"], { cwd, live }), + ); + const remove = yield* bounded("codex-delete", VERSION_MS, () => + runChild(codex, ["delete", "--force", randomUUID()], { cwd, live, input: "" }), + ); + verdict.deleteSurface = + surface.code === 0 && + shows(surface.stdout, "--force") && + remove.code !== 0 && + shows(`${remove.stdout}${remove.stderr}`, "failed to delete session"); + + verdict.binaryBuilt = yield* exists(XMD_BINARY); + + if (verdict.compatibilityTupleFrozen) { + yield* unmaterializedIsRefused(codex, live, verdict); + } + + const established = + verdict.compatibilityTupleFrozen && + !verdict.codexHomeSet && + verdict.resumeSurface && + verdict.deleteSurface && + verdict.adapterNamesNativeIdentity && + verdict.unmaterializedIdentityRefused && + verdict.binaryBuilt; + verdict.verdict = established ? "PASS" : "ENVIRONMENT_BLOCKED"; + verdict.detail = established + ? "the frozen compatibility point is established, and no model turn was spent" + : "the frozen compatibility point is not established"; + return verdict; +} + +function render(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +main(function* () { + const mode = process.argv[2]; + + if (mode === "preflight") { + render(yield* runPreflight()); + return; + } + if (mode === "zero-native-turn") { + render(yield* runJourney("zero-native-turn", runZeroNativeTurn, decideZeroNativeTurn)); + return; + } + if (mode === "native-launch") { + // Planted only in the first native user turn. Recovering it through ACP is + // what makes the reattached session the same conversation rather than a new + // one wearing the same name. + const marker = `MK${randomUUID().replaceAll("-", "").slice(0, 12).toUpperCase()}`; + render( + yield* runJourney( + "native-launch", + (journey, verdict) => runNativeLaunch(journey, verdict, marker), + decideNativeLaunch, + ), + ); + return; + } + + throw new Error(`codex-native-launch-proof: unknown mode "${mode ?? ""}"`); +}); diff --git a/packages/acp/tests/fixtures/codex-native-to-acp-prompt.md b/packages/acp/tests/fixtures/codex-native-to-acp-prompt.md new file mode 100644 index 000000000..75ced339d --- /dev/null +++ b/packages/acp/tests/fixtures/codex-native-to-acp-prompt.md @@ -0,0 +1,16 @@ +# Continue the implementer session + +This document is the second half of `packages/acp/src/CodexNativeLaunch.test.md` +and is run verbatim, through the built binary, from the same directory the +native launch ran in. + +It names the session that launch constructed and says one thing to it. There is +no marker here, no identity, and nothing about a build: everything this needs is +already written down beside the session, and a document that carried any of it +would be answering its own question. + + + +Do not use any tools. Reply with exactly one line and nothing else: RECOVERED- followed immediately, with no space, by the marker you were asked to remember in the preceding user turn. + + diff --git a/packages/acp/tests/fixtures/codex-surfaces.ts b/packages/acp/tests/fixtures/codex-surfaces.ts new file mode 100644 index 000000000..2cb73ae45 --- /dev/null +++ b/packages/acp/tests/fixtures/codex-surfaces.ts @@ -0,0 +1,693 @@ +/** + * What Codex's own surfaces say, and what a screen showing one of them means. + * + * `terminal-screen.ts` reconstructs a terminal and stays deliberately ignorant + * of whose terminal it is. This is the other half: the wording one build of + * Codex draws, and the two judgements a proof makes from it — whether the + * composer will accept a turn, and what a screen was showing when no answer + * arrived. Both are here rather than in the proof so the offline cases drive the + * exact predicates the paid run consumes, instead of a copy that agrees with + * them until the day it does not. + * + * Every judgement is asked of a *presented* screen. Codex withholds its drawing + * with `CSI ?2026h` while it repaints, so cells in between are ones no operator + * was ever shown, and a predicate that read them would decide on a frame that + * never existed. + */ + +import type { Operation } from "effection"; +import { runsOf, screenShows } from "./terminal-screen.ts"; +import type { ScreenSnapshot } from "./terminal-screen.ts"; + +/** Codex's own banner, and the strongest signal its surface is up. */ +export const BANNER = "OpenAI Codex (v"; + +/** The composer, which is where a conversation turn can be typed. */ +export const COMPOSER = "Ask Codex to do anything"; + +/** + * The one dialog Codex may put in front of a session. + * + * It asks for a standing permission rather than anything of the conversation, + * so a composer showing underneath one is not a composer that will take a turn. + */ +export const TRUST_DIALOG = "Do you trust the contents of this directory"; + +/** + * What Codex shows in place of its model name until startup finishes. + * + * It draws the composer before it has a model, and input typed into that + * composer may be queued rather than taken. A turn typed here is a turn whose + * fate nobody can read off the screen. + */ +export const MODEL_LOADING = "model: loading"; + +/** + * Codex refusing an identity it holds no thread for. + * + * The refusal this whole feature exists to avoid, so a screen presenting it is + * a finding about the product rather than something to drive around. + */ +export const MISSING_SESSION = "No saved session found with ID"; + +/** + * The markers naming Codex's own furniture rather than anything it said. + * + * Handed to {@link runsOf} so a line of chrome ends the run above it instead of + * joining onto the sentence that follows. + */ +export const SCREEN_CHROME: readonly string[] = [COMPOSER, "esc to interrupt", BANNER]; + +/** Fold away everything a terminal's layout and typography may vary. */ +function fold(text: string): string { + return text.toLowerCase().replaceAll(/[‘’ʼ]/gu, "'"); +} + +/** Fold, and close up the wrapping a terminal chose. */ +function normalize(text: string): string { + return fold(text).replaceAll(/\s+/gu, ""); +} + +/** Whether the presented screen shows `phrase`, layout and case ignored. */ +function shows(snapshot: ScreenSnapshot, phrase: string): boolean { + return screenShows( + { alternate: snapshot.alternate, rows: snapshot.rows.map(fold) }, + fold(phrase), + ); +} + +/** + * The one sentence this proof has witnessed Codex refuse a turn with. + * + * Witnessed, and so exactly one. Variants nobody has seen Codex draw would be + * guesses dressed as recognition, and each one widens what can be mistaken for + * a refusal while proving nothing about the product. + */ +const WITNESSED_REFUSAL = "You've hit your usage limit."; + +/** + * Whether the presented screen is refusing for want of remaining usage. + * + * Asked of a *run* — the text between the layout's own cuts — and asked whether + * that run begins with the refusal, rather than whether the screen contains the + * words anywhere. Codex says `usage limit` in wording that means the opposite: + * `/status` reports the headroom that remains, the startup banner offers + * `You have 2 usage limit resets available`, and its help text explains what to + * do `If you've hit your usage limit`. A containment test cannot tell any of + * those from the refusal, and one that could not reported two paid runs as + * quota-blocked on an account with 42% of its week unspent — sending the next + * reader off to wait for a limit nobody hit, and burying the real cause. + * + * Requiring the run to *start* with the sentence is what rejects the + * conditional: `If you've hit your usage limit, …` is one run, and it begins + * with `If`. Wrapping is closed up first, because a terminal may break the + * sentence across rows and that is not a different sentence. + * + * Wording this does not recognize is not exhaustion here, even if it is + * exhaustion in fact. That direction is the safe one: an unrecognized surface + * is reported as a harness that could not read the screen, which costs an + * investigation, where the other direction costs a false claim about the + * product's environment. + */ +export function exhaustedUsage(snapshot: ScreenSnapshot): boolean { + const refusal = normalize(WITNESSED_REFUSAL); + return runsOf(snapshot.rows, SCREEN_CHROME).some((run) => normalize(run).startsWith(refusal)); +} + +/** + * What the terminal was showing instead, when no answer was read within a bound. + * + * Terminal state, and nothing more. `composer-idle` in particular says the + * composer is back to its resting form; it is not evidence that no answer + * appeared, because an answer sits above a composer that has gone idle and + * `observeAnswer` is the only thing that decides whether one is there. + */ +export type StallTag = + | "approval-request" + | "still-working" + | "usage-limited" + | "composer-idle" + | "unknown"; + +const STALLS: readonly { tag: StallTag; matches: (snapshot: ScreenSnapshot) => boolean }[] = [ + { tag: "approval-request", matches: (screen) => shows(screen, "Allow command") }, + { tag: "approval-request", matches: (screen) => shows(screen, "wants to run") }, + { tag: "still-working", matches: (screen) => shows(screen, "esc to interrupt") }, + { tag: "usage-limited", matches: exhaustedUsage }, + { tag: "composer-idle", matches: (screen) => shows(screen, COMPOSER) }, +]; + +/** + * What the screen was showing, and what a run that read no answer therefore is. + * + * The two travel together because they are one judgement. Nothing here converts + * a tag into a verdict, so there is no function a caller could hand a + * `usage-limited` it made up — the only way to reach `ENVIRONMENT_BLOCKED` is to + * put the witnessed refusal on a screen and classify it. A tag on its own is a + * label for a report; it carries no authority to blame anything. + */ +export interface StallClassification { + /** What the terminal was showing. Reported as-is, and decides nothing alone. */ + readonly tag: StallTag; + /** + * What a run that spent a turn and read no answer is. + * + * `ENVIRONMENT_BLOCKED` only where {@link exhaustedUsage} recognized the + * refusal. Every other surface describes where the terminal got to and + * explains nothing: still working, waiting on an approval, sitting at an idle + * composer, or showing wording nothing here recognizes is this harness having + * stopped waiting in front of something it could not account for, which is a + * harness failure however ordinary the screen. + */ + readonly verdict: "ENVIRONMENT_BLOCKED" | "HARNESS_FAILED"; +} + +/** Read the presented screen, and say both things about it at once. */ +export function classifyStall(snapshot: ScreenSnapshot): StallClassification { + const tag = STALLS.find((stall) => stall.matches(snapshot))?.tag ?? "unknown"; + return { tag, verdict: tag === "usage-limited" ? "ENVIRONMENT_BLOCKED" : "HARNESS_FAILED" }; +} + +/** Whether the composer is up, unobstructed, and backed by a loaded model. */ +export function composerReady(snapshot: ScreenSnapshot): boolean { + return ( + shows(snapshot, COMPOSER) && !shows(snapshot, TRUST_DIALOG) && !shows(snapshot, MODEL_LOADING) + ); +} + +/** + * What a session is showing on its way to a composer that will take a turn. + * + * `starting` and `blank` are the two ways a screen can be nothing to act on: + * one has Codex on it and no surface to answer or type into yet, the other has + * nothing recognizable at all. Neither is a decision, and keeping them apart + * from the three that are is what lets a wait resume rather than conclude. + */ +export type InitialSurface = + | "missing-session" + | "trust-dialog" + | "composer-ready" + | "starting" + | "blank"; + +/** + * Which of those the presented screen is showing. + * + * Ordered by what covers what. The refusal comes first because it is the + * finding and Codex draws its composer while still opening the thread, so a + * screen carrying both is a refused one. The dialog comes before readiness + * because it is drawn *over* a composer, and a composer nobody can reach is not + * a composer that will take a turn. + * + * Asked of a presented snapshot in every case. The byte stream retains a + * composer drawn before a dialog covered it and a refusal erased by the repaint + * after it, so a reader of the stream answers a question about the past; and + * cells inside a `CSI ?2026h` hold were never shown to anybody, so a reader of + * those answers a question about a frame that did not exist. + */ +export function classifyInitialSurface(snapshot: ScreenSnapshot): InitialSurface { + if (shows(snapshot, MISSING_SESSION)) { + return "missing-session"; + } + if (shows(snapshot, TRUST_DIALOG)) { + return "trust-dialog"; + } + if (composerReady(snapshot)) { + return "composer-ready"; + } + if (shows(snapshot, BANNER) || shows(snapshot, COMPOSER) || shows(snapshot, MODEL_LOADING)) { + return "starting"; + } + return "blank"; +} + +/** + * Whether Codex refused the identity at any point in the read that was waited on. + * + * Any point, because the refusal outranks everything else a read presented. The + * frames either side of it are Codex getting on with a startup it has already + * decided the outcome of, and acting on one of those means driving a session the + * product has said it does not hold. + */ +function refused(presented: readonly ScreenSnapshot[]): boolean { + return presented.some(isRefusal); +} + +/** Whether this one screen is Codex saying it holds no such thread. */ +export function isRefusal(snapshot: ScreenSnapshot): boolean { + return classifyInitialSurface(snapshot) === "missing-session"; +} + +/** + * Watch for what this wait is for, and for the refusal that means stop waiting. + * + * A wait ends at the read that satisfied it, so a frame its predicate says + * nothing about is not merely left out of the batch — it does not stop the wait, + * and the read after it repaints over it. The refusal is then in no batch and on + * no screen, and the only trace it left is that it was once presented. + * + * That is survivable for a wait watching for a surface, because every surface + * predicate in this file is already satisfied by a refusal: reaching a composer + * waits for something {@link actionable}, for the trust dialog to be gone, or + * for readiness to be lost, and `missing-session` answers all three. It is not + * survivable for the waits that ask whether a composer is ready or whether a + * turn is in it, because Codex can draw the refusal where the composer was — + * making both of those predicates false on the one screen that matters most. + * + * So the waits whose next act is typing or Enter watch for the refusal too. It + * belongs in the predicate rather than in a check afterwards: a wait that ends + * on the refusal is one whose batch holds it, which is what {@link refused} then + * finds, and it ends there rather than waiting out a bound for a composer that + * has already been spoken for. + */ +function stoppingAtRefusal( + predicate: (snapshot: ScreenSnapshot) => boolean, +): (snapshot: ScreenSnapshot) => boolean { + return (snapshot) => predicate(snapshot) || isRefusal(snapshot); +} + +/** Whether this surface is one a driver may act on. */ +function actionable(surface: InitialSurface): boolean { + return ( + surface === "missing-session" || surface === "trust-dialog" || surface === "composer-ready" + ); +} + +/** Where reaching for a composer got to. */ +export type SurfaceOutcome = + /** Codex refused the identity. A finding, and not something to drive around. */ + | "missing-session" + /** A composer is up, unobstructed, and backed by a loaded model. */ + | "composer-ready" + /** No surface this recognizes settled within the bounds it was given. */ + | "unresolved"; + +export interface SurfaceBounds { + /** How long each round waits for a surface it can act on. */ + readonly surface: number; + /** How long a dialog is given to finish drawing its options. */ + readonly settle: number; + /** How long a ready composer is watched for a dialog arriving over it. */ + readonly grace: number; +} + +/** + * Everything reaching a composer needs from a terminal, and nothing else. + * + * `consent` rather than a raw keystroke, because the one Enter this driver may + * press is a consent that has to be recorded as one — a port that could be + * handed a bare `send` could spend a conversation turn through this path. + */ +export interface SurfacePort { + /** What the terminal last put on show. Never a frame still being drawn. */ + screen(): ScreenSnapshot; + /** + * Wait for a read presenting `predicate`, and answer with every frame that did. + * + * The screens that matched, rather than a flag saying one did, because the two + * questions have different answers. A read from a pty carries as many finished + * frames as its bytes finished, so a surface can be presented and repainted + * over inside one — and a caller handed a flag has nothing left to look at but + * whichever frame that read ended on. + * + * All of them, because both questions asked here are wide: whether a ready + * composer stopped being one, and whether any surface worth acting on is up. + * A refusal satisfies either, and so does a model reloading or a dialog going + * up — so the read that carries the refusal often answers the wait a frame or + * two before it. + * + * Empty where the bound was spent, because a surface that never arrived is + * something to report rather than an error. + */ + waitForScreen( + name: string, + ms: number, + predicate: (snapshot: ScreenSnapshot) => boolean, + ): Operation; + /** Answer the trust dialog: record the consent, and press Enter. */ + consent(): void; + /** + * Wait out a fixed delay, and answer with what was presented during it. + * + * A delay rather than a wait — nothing shortens it — but the same answer a + * wait gives, because the terminal does not stop drawing while a driver is + * deliberately not looking. The screen afterwards says what survived the + * delay, which is a different question from what happened during it, and a + * refusal is routinely only in the second. + */ + pause( + ms: number, + predicate: (snapshot: ScreenSnapshot) => boolean, + ): Operation; +} + +/** + * How many surfaces one session is followed through. + * + * A composer that goes back to loading, the dialog that follows it, the composer + * that dialog was covering, and one spare. A session that needs more than that + * is one this driver does not understand, and saying so is better than pressing + * Enter into it until something happens. + */ +const SURFACE_ROUNDS = 4; + +/** + * Answer the trust dialog if it is presented, then establish the composer. + * + * Whether the dialog appears belongs to the machine — a directory Codex already + * trusts shows none — so this reacts to what is on screen rather than following + * a script, and consents at most once. + * + * Every Enter is preceded by reading the presented screen again. The dialog is + * still drawing its options as it announces itself, so a settle is waited out + * first; and if the screen is no longer showing the dialog after it, no Enter is + * pressed. That is the difference between consenting to a question and pressing + * Enter into whatever replaced it. + */ +export function* reachComposer( + port: SurfacePort, + bounds: SurfaceBounds, +): Operation { + let consented = false; + for (let round = 0; round < SURFACE_ROUNDS; round++) { + const arrived = yield* port.waitForScreen("surface", bounds.surface, (snapshot) => + actionable(classifyInitialSurface(snapshot)), + ); + if (arrived.length === 0) { + return "unresolved"; + } + // A refusal the terminal presented is the finding, whatever it drew next and + // whatever it drew before. Codex commits the refusal and repaints a composer + // over it, and one read from a pty can carry both — so the screen as it now + // stands is not enough to see it, and a driver that only looked there would + // type a turn into a composer for a session the product had already said it + // does not hold. Asked of the whole read because the dialog Codex may draw + // ahead of the refusal answers this wait too, and the refusal outranks it. + if (refused(arrived)) { + return "missing-session"; + } + // Otherwise read again rather than trusting the wait: what satisfied the + // predicate may have been repainted over, and consenting or typing is an act + // against whatever is on show now. + const surface = classifyInitialSurface(port.screen()); + if (surface === "missing-session") { + return "missing-session"; + } + if (surface === "trust-dialog") { + if (consented) { + // Asked twice about one directory. Answering again would be consenting + // twice to a question that was only asked once. + return "unresolved"; + } + // Settling lets a dialog drawn in pieces finish being drawn, and a session + // Codex is still resolving refuse. The screen afterwards cannot tell those + // apart from a refusal that was shown and painted back over, so the settle + // is asked what it saw: consenting to a directory on behalf of a session + // that no longer exists answers a question nobody is asking any more. + const duringSettle = yield* port.pause(bounds.settle, isRefusal); + if (refused(duringSettle)) { + return "missing-session"; + } + if (classifyInitialSurface(port.screen()) !== "trust-dialog") { + continue; + } + consented = true; + port.consent(); + // The dialog stays on screen until Codex repaints over it. Waiting for it + // to go is what stops the next round reading an answered question as a + // second one and giving up in front of a session that is coming up fine. + const answered = yield* port.waitForScreen( + "trust-answered", + bounds.surface, + (snapshot) => classifyInitialSurface(snapshot) !== "trust-dialog", + ); + // Answering the dialog is not the end of the startup, so this is a third + // read a refusal can be presented in — and this wait asks only that the + // dialog be gone, which the model Codex loads next already satisfies. The + // refusal then arrives behind a frame that has answered this wait, inside + // the read that answered it, and going round without looking would find + // only the composer that read ended on. The consent already pressed + // stands: it answered a question about a directory that was up and asking + // at the time. What it must not be followed by is a turn. + if (refused(answered)) { + return "missing-session"; + } + continue; + } + if (surface === "composer-ready") { + // Codex draws its composer early — before it asks about the directory, + // before a model has finished loading, and before it reports that it holds + // no such thread. So a first ready screen is a screen that may still be + // about to become one of those, and the grace watches for it becoming + // *anything* else rather than for the dialog alone. Watching only for the + // dialog is how a refusal or a loading model arriving during the grace + // used to run out the bound unnoticed and be returned as readiness. + const departed = yield* port.waitForScreen( + "readiness-grace", + bounds.grace, + (snapshot) => classifyInitialSurface(snapshot) !== "composer-ready", + ); + // The refusal ends it here too, and for the same reason: it was presented, + // and a repaint in the same read does not take that back. This is the wait + // it most often arrives behind — Codex puts its model back to loading + // before it says it holds no such thread — and a loading frame satisfies + // this predicate as readily as the refusal two frames later does. + if (refused(departed)) { + return "missing-session"; + } + // Otherwise the decision is the screen as it now stands: a surface that + // arrived inside a hold counts from its commit, one that came and went + // never displaced what is on show, and readiness is a claim about a + // composer this driver is about to type into. + const settled = classifyInitialSurface(port.screen()); + if (settled === "missing-session") { + return "missing-session"; + } + if (settled !== "composer-ready") { + continue; + } + return "composer-ready"; + } + } + return "unresolved"; +} + +/** + * How much of the typed turn has to be on screen for it to count as presented. + * + * The tail, because a composer scrolls a long turn and shows its end. The + * proof's turn ends in a marker minted for that one run, so the tail is unique + * to it and no earlier repaint can supply one — provided the whole marker fits + * inside it, which is a thing about the two lengths together and so is proven + * rather than assumed. + */ +export const PROMPT_TAIL = 24; + +/** + * Whether the composer is showing the turn this harness typed into it. + * + * Asked of the typed text alone, and deliberately not of {@link COMPOSER} as + * well. That string is the composer's placeholder, which is what an *empty* + * composer shows — the thing {@link composerReady} reads — and it is gone the + * moment there is a turn in the box. Requiring both would be requiring the + * composer to be simultaneously empty and full, and every run would report a + * turn it never managed to type. + * + * Before submission there is nowhere else on the screen for this text to be: + * nothing has been sent, so no transcript carries it back. + */ +export function promptPresented(snapshot: ScreenSnapshot, typed: string): boolean { + const tail = typed.slice(-PROMPT_TAIL); + return tail.length > 0 && shows(snapshot, tail); +} + +/** The bounds a submission is given, supplied by whoever owns the run. */ +export interface SubmissionBounds { + /** How long the composer is waited for, before and after the grace. */ + readonly ready: number; + /** + * How long a ready-looking composer is given to turn out to have something + * over it, or a model still loading behind it. + * + * Its length matters less than the recheck it precedes: a composer that stops + * being ready during the grace is waited for again rather than typed into. + */ + readonly startupGrace: number; + /** How long the typed turn is given to appear in the composer. */ + readonly presented: number; +} + +/** + * Everything submitting a turn needs from a terminal, and nothing else. + * + * Narrow on purpose: the offline cases implement this against scripted frames, + * so what they decide is what the pty path does. + */ +export interface SubmissionPort { + /** What the terminal last put on show. Never a frame still being drawn. */ + screen(): ScreenSnapshot; + /** + * Wait for a read presenting `predicate`, and answer with every frame that did. + * + * The frames rather than a flag, and for a sharper reason than reaching a + * composer had. Codex draws its refusal *above* a composer rather than in + * place of one, so a screen carrying `No saved session found with ID` and an + * empty composer both is a screen {@link composerReady} calls ready and + * {@link promptPresented} calls presented. A refusal therefore does not merely + * ride along in these batches — it satisfies them. Answering `true` here would + * hand back the one bit that cannot distinguish the composer this driver is + * waiting for from the refusal that means it must not type into it, and the + * next thing down that path is Enter. + * + * Empty where the bound was spent, because a surface that never arrived is + * something to report rather than an error. + */ + waitForScreen( + name: string, + ms: number, + predicate: (snapshot: ScreenSnapshot) => boolean, + ): Operation; + /** Type one turn into the composer, without sending it. */ + type(text: string): void; + /** Press Enter. This is the keystroke that spends the turn. */ + send(typed: string): void; + /** Wait out a fixed delay, and answer with what was presented during it. */ + pause( + ms: number, + predicate: (snapshot: ScreenSnapshot) => boolean, + ): Operation; +} + +export type SubmissionRefusal = + | "session-refused" + | "composer-never-ready" + | "composer-unready-after-grace" + | "prompt-never-presented"; + +export type SubmissionOutcome = + | { readonly submitted: true } + | { readonly submitted: false; readonly reason: SubmissionRefusal }; + +const REFUSALS: Readonly> = { + "session-refused": + "Codex refused the identity while the turn was being readied, so the composer on screen " + + "belonged to a session it had already said it does not hold", + "composer-never-ready": + "the composer never came up ready — it was absent, covered by the trust dialog, or still " + + "showing a model that had not loaded", + "composer-unready-after-grace": + "the composer stopped being ready during the startup grace and did not come back", + "prompt-never-presented": + "the turn was typed but never appeared in the composer, so what Codex would have received " + + "could not be established", +}; + +/** The sentence naming why nothing was submitted. Carries no typed content. */ +export function refusalDetail(reason: SubmissionRefusal): string { + return REFUSALS[reason]; +} + +/** + * Type one turn and submit it, or establish that it must not be submitted. + * + * Enter is reached from exactly one place, past three screen readings that each + * have to hold: the composer is ready, it is still ready after the grace in + * which an overlay or a loading model would have shown itself, and the turn is + * on screen in the composer. A run that fails any of them returns having typed + * at most — no Enter, and so no turn to account for. + * + * None of those three is a question about a refusal, though, and `No saved + * session found with ID` can be drawn either above the composer or where the + * composer was. The first shape passes every test here — ready, and holding the + * turn that was typed. The second fails them all, which is worse: a wait + * watching only for readiness does not stop at it, and the next read paints over + * it. So the refusal is watched for by each wait as well as asked of the batch + * that answers it, and asked again of the screen as it stands wherever this + * decides something without a wait: after the startup grace, after a composer is + * waited for a second time, and immediately before Enter. + * + * Reaching a composer has already established that the session was not refused, + * but only up to the bound it was willing to wait — and Codex draws its composer + * before it resolves the thread, so a refusal slower than that bound arrives + * here, with the turn already typed and Enter next. + */ +export function* submitWhenReady( + port: SubmissionPort, + typed: string, + bounds: SubmissionBounds, +): Operation { + const ready = yield* port.waitForScreen( + "composer-ready", + bounds.ready, + stoppingAtRefusal(composerReady), + ); + if (refused(ready)) { + return { submitted: false, reason: "session-refused" }; + } + if (ready.length === 0) { + return { submitted: false, reason: "composer-never-ready" }; + } + + // The grace is a window a refusal can be drawn in, and what it leaves behind + // is a screen rather than a batch: nothing else is watching it. Asked for + // readiness alone the screen afterwards answers yes, because the refusal is + // drawn above a composer that is otherwise up — so the second wait would not + // even be taken, and the read that later confirms the turn repaints a clean + // composer over the only frame that ever said the session does not exist. A + // refusal repainted before the grace is out leaves no trace on that screen at + // all, so the grace is asked what it saw as well as what it left. + const duringGrace = yield* port.pause(bounds.startupGrace, isRefusal); + if (refused(duringGrace)) { + return { submitted: false, reason: "session-refused" }; + } + const settled = port.screen(); + if (classifyInitialSurface(settled) === "missing-session") { + return { submitted: false, reason: "session-refused" }; + } + if (!composerReady(settled)) { + const again = yield* port.waitForScreen( + "composer-ready-again", + bounds.ready, + stoppingAtRefusal(composerReady), + ); + if (refused(again)) { + return { submitted: false, reason: "session-refused" }; + } + if (again.length === 0) { + return { submitted: false, reason: "composer-unready-after-grace" }; + } + // A batch says a ready composer was presented during the read, not that it + // is what the read ended on. The refusal that arrives after it takes the + // composer with it, and a screen with no composer is precisely what this + // wait does not collect — so the honestly ready batch above can sit in front + // of a refusal that is on show right now, with typing next. + if (classifyInitialSurface(port.screen()) === "missing-session") { + return { submitted: false, reason: "session-refused" }; + } + } + + port.type(typed); + const presented = yield* port.waitForScreen( + "prompt-presented", + bounds.presented, + stoppingAtRefusal((snapshot) => promptPresented(snapshot, typed)), + ); + if (refused(presented)) { + return { submitted: false, reason: "session-refused" }; + } + if (presented.length === 0) { + return { submitted: false, reason: "prompt-never-presented" }; + } + + // Enter is the act this whole file exists to place correctly, so it is taken + // against the screen as it stands and not against a batch that answered a + // wait. A refusal arriving after the turn was confirmed on screen — in a read + // of its own, with nothing left to satisfy any predicate above — is invisible + // to all three waits and would otherwise be charged for. + if (classifyInitialSurface(port.screen()) === "missing-session") { + return { submitted: false, reason: "session-refused" }; + } + + port.send(typed); + return { submitted: true }; +} diff --git a/packages/acp/tests/fixtures/fake-codex-app-server.cjs b/packages/acp/tests/fixtures/fake-codex-app-server.cjs index d86fa9498..b5c4abe6a 100755 --- a/packages/acp/tests/fixtures/fake-codex-app-server.cjs +++ b/packages/acp/tests/fixtures/fake-codex-app-server.cjs @@ -119,6 +119,31 @@ function handle(message) { }); return; } + case "thread/resume": { + // Reopening answers with the thread the App Server settled on. That + // answer, not the id the request carried, is what names the conversation + // a client would go on to address directly. + const threadId = `canonical:${message.params?.threadId ?? "thread-1"}`; + reply(message.id, { + thread: { id: threadId }, + model: "fake-model", + modelProvider: "fake", + serviceTier: null, + cwd: process.cwd(), + instructionSources: [], + approvalPolicy: "never", + approvalsReviewer: "user", + sandboxPolicy: { mode: "danger-full-access" }, + reasoningEffort: null, + skills: [], + }); + return; + } + case "thread/read": { + const threadId = message.params?.threadId ?? "thread-1"; + reply(message.id, { thread: { id: threadId, title: null, turns: [] } }); + return; + } case "turn/start": { const threadId = message.params?.threadId ?? "thread-1"; const turnId = nextTurn(threadId); diff --git a/packages/acp/tests/fixtures/terminal-screen.ts b/packages/acp/tests/fixtures/terminal-screen.ts new file mode 100644 index 000000000..074b99bb2 --- /dev/null +++ b/packages/acp/tests/fixtures/terminal-screen.ts @@ -0,0 +1,1087 @@ +/** + * What a cursor-addressed terminal is showing, reconstructed from its bytes. + * + * A TUI does not append its output. It moves the cursor, erases, and draws over + * what it drew before, so the byte stream a pty emitted and the screen an + * operator is looking at are two different things. Searching the stream answers + * the wrong question twice over: it finds text that has since been erased, text + * the program printed before it took the screen, and text no repaint left + * standing — while missing an answer whose only surviving form is the cells it + * was last drawn into. + * + * So the bytes are applied to a screen here, and the screen is what gets read. + * Nothing in this module is Codex-specific: it is a terminal, and the questions + * asked of it are supplied by the caller. + */ + +import { withResolvers } from "effection"; +import type { Operation } from "effection"; + +const ESC = String.fromCharCode(0x1b); +const BEL = String.fromCharCode(0x07); +const DEL = String.fromCharCode(0x7f); +const BLANK = " "; + +/** + * Where one piece of text stops, marked in a row before the row is cut up. + * + * A noncharacter, so no terminal output can supply one and be mistaken for a + * cut this module made. + */ +const BOUNDARY = String.fromCharCode(0xffff); + +/** + * The glyphs a TUI draws its frame out of — box drawing and block elements. + * + * A pane border can land between two words of a wrapped sentence, so these have + * to come out before the words either side of one can be read as adjacent. No + * model emits them mid-sentence, so removing them recovers the text without + * letting anything else through. + */ +const FRAME_GLYPH = /[─-▟]/gu; + +/** Collapse every run of whitespace, so a TUI's own layout cannot hide a word. */ +function squeeze(text: string): string { + return text.replaceAll(/\s+/gu, ""); +} + +export interface ScreenSize { + readonly rows: number; + readonly columns: number; +} + +export interface ScreenSnapshot { + /** Whether the alternate buffer is showing, which is a full-screen TUI's. */ + readonly alternate: boolean; + /** The visible rows of whichever buffer is showing, trailing blanks removed. */ + readonly rows: readonly string[]; +} + +function blankRow(columns: number): string[] { + return new Array(columns).fill(BLANK); +} + +function blankCells(size: ScreenSize): string[][] { + return Array.from({ length: size.rows }, () => blankRow(size.columns)); +} + +export class TerminalScreen { + readonly #rows: number; + readonly #columns: number; + readonly #decoder = new TextDecoder(); + #primary: string[][]; + #alternate: string[][]; + #onAlternate = false; + #row = 0; + #column = 0; + /** + * Whether the last glyph filled the final column. + * + * A terminal does not move to the next row when the last column is written; + * it waits to see whether another glyph arrives. Without that delay a + * sentence ending exactly at the right margin drags a blank row after it, and + * a cursor move that follows one lands a row too low. + */ + #wrapPending = false; + #savedRow = 0; + #savedColumn = 0; + #top = 0; + #bottom: number; + /** An escape sequence split across two reads, held until the rest arrives. */ + #partial = ""; + /** + * What was on show when the program asked for its drawing to be held back. + * + * `CSI ?2026h` tells the terminal to stop presenting until `CSI ?2026l`, so a + * program can erase and redraw a region without the reader ever seeing the + * half-drawn state. Codex asks for this, which means the rows in between are + * cells this screen holds and a terminal never showed anybody — while this + * stays what it showed last, so a reader has something true to read. + * + * Taken where the hold begins rather than at the end of the read carrying it: + * bytes before that point were presented, and bytes after it were not. + */ + #held: ScreenSnapshot | undefined; + readonly #onFrame: (() => void) | undefined; + + /** + * @param onFrame Called each time a synchronized update commits, at the point + * in the byte stream where it commits rather than at the end of the read that + * carried it — a frame is finished by `CSI ?2026l` and not by an operating + * system deciding where one `data` event stops. + */ + constructor(size: ScreenSize, onFrame?: () => void) { + this.#rows = size.rows; + this.#columns = size.columns; + this.#primary = blankCells(size); + this.#alternate = blankCells(size); + this.#bottom = size.rows - 1; + this.#onFrame = onFrame; + } + + /** Whether a frame is part-drawn, so what the cells hold is not on show. */ + get synchronized(): boolean { + return this.#held !== undefined; + } + + /** Apply one read from the terminal. Byte chunks may split any sequence. */ + write(chunk: string | Uint8Array): void { + const text = typeof chunk === "string" ? chunk : this.#decoder.decode(chunk, { stream: true }); + this.#consume(`${this.#partial}${text}`); + } + + /** The cells as they stand, whether or not a terminal has shown them. */ + snapshot(): ScreenSnapshot { + return { + alternate: this.#onAlternate, + rows: this.#cells().map((row) => row.join("").replace(/\s+$/u, "")), + }; + } + + /** + * What a terminal last put on show, which is the only thing anybody read. + * + * The same as {@link snapshot} except while a frame is being held back, when + * it stays the frame before it until `CSI ?2026l` commits the new one. Any + * question about what was on the screen — what it answered, and what it was + * showing when it did not — has to be asked of this. + */ + presented(): ScreenSnapshot { + return this.#held ?? this.snapshot(); + } + + #cells(): string[][] { + return this.#onAlternate ? this.#alternate : this.#primary; + } + + #consume(input: string): void { + this.#partial = ""; + let index = 0; + while (index < input.length) { + const char = input[index] ?? ""; + if (char !== ESC) { + this.#put(char); + index += 1; + continue; + } + const next = this.#escape(input, index); + if (next === undefined) { + this.#partial = input.slice(index); + return; + } + index = next; + } + } + + /** Consume one escape sequence, or report that it has not all arrived. */ + #escape(input: string, start: number): number | undefined { + const kind = input[start + 1]; + if (kind === undefined) { + return undefined; + } + if (kind === "[") { + return this.#csi(input, start); + } + if (kind === "]" || kind === "P" || kind === "^" || kind === "_") { + return this.#stringSequence(input, start); + } + if (kind === "(" || kind === ")" || kind === "*" || kind === "+") { + return input[start + 2] === undefined ? undefined : start + 3; + } + if (kind === "7") { + this.#save(); + } else if (kind === "8") { + this.#restore(); + } else if (kind === "M") { + this.#reverseIndex(); + } else if (kind === "D") { + this.#index(); + } else if (kind === "E") { + this.#index(); + this.#column = 0; + } else if (kind === "c") { + this.#reset(); + } + return start + 2; + } + + #csi(input: string, start: number): number | undefined { + let index = start + 2; + while (index < input.length && /[0-9;:?<>=!]/u.test(input[index] ?? "")) { + index += 1; + } + while (index < input.length && /[ -/]/u.test(input[index] ?? "")) { + index += 1; + } + const final = input[index]; + if (final === undefined) { + return undefined; + } + this.#dispatch(input.slice(start + 2, index), final); + return index + 1; + } + + /** OSC, DCS, APC and PM all run to a BEL or a string terminator. */ + #stringSequence(input: string, start: number): number | undefined { + let index = start + 2; + while (index < input.length) { + const char = input[index]; + if (char === BEL) { + return index + 1; + } + if (char === ESC) { + const after = input[index + 1]; + if (after === undefined) { + return undefined; + } + if (after === "\\") { + return index + 2; + } + } + index += 1; + } + return undefined; + } + + #dispatch(body: string, final: string): void { + const priv = body.startsWith("?"); + const params = (priv ? body.slice(1) : body).split(";").map((part) => { + const value = Number.parseInt(part, 10); + return Number.isNaN(value) ? 0 : value; + }); + if (priv) { + if (final === "h" || final === "l") { + this.#mode(params, final === "h"); + } + return; + } + /** A parameter a terminal reads as "how many", where zero means one. */ + const count = (position: number, fallback: number): number => { + const value = params[position]; + return value === undefined || value === 0 ? fallback : value; + }; + /** A parameter a terminal reads as "which", where zero is a real choice. */ + const choice = params[0] ?? 0; + switch (final) { + case "A": + this.#moveTo(this.#row - count(0, 1), this.#column); + break; + case "B": + this.#moveTo(this.#row + count(0, 1), this.#column); + break; + case "C": + this.#moveTo(this.#row, this.#column + count(0, 1)); + break; + case "D": + this.#moveTo(this.#row, this.#column - count(0, 1)); + break; + case "E": + this.#moveTo(this.#row + count(0, 1), 0); + break; + case "F": + this.#moveTo(this.#row - count(0, 1), 0); + break; + case "G": + case "`": + this.#moveTo(this.#row, count(0, 1) - 1); + break; + case "d": + this.#moveTo(count(0, 1) - 1, this.#column); + break; + case "H": + case "f": + this.#moveTo(count(0, 1) - 1, count(1, 1) - 1); + break; + case "J": + this.#eraseDisplay(choice); + break; + case "K": + this.#eraseLine(choice); + break; + case "L": + this.#insertLines(count(0, 1)); + break; + case "M": + this.#deleteLines(count(0, 1)); + break; + case "@": + this.#insertChars(count(0, 1)); + break; + case "P": + this.#deleteChars(count(0, 1)); + break; + case "X": + this.#eraseChars(count(0, 1)); + break; + case "S": + this.#scrollUp(count(0, 1)); + break; + case "T": + this.#scrollDown(count(0, 1)); + break; + case "r": + this.#margins(count(0, 1) - 1, count(1, this.#rows) - 1); + break; + case "s": + this.#save(); + break; + case "u": + this.#restore(); + break; + default: + // Colour, weight, cursor visibility, bracketed paste: no cell changes. + break; + } + } + + #mode(params: readonly number[], set: boolean): void { + for (const value of params) { + if (value === 2026) { + if (set) { + // A hold inside a hold is still the same hold: what was on show is + // what was on show when the drawing first stopped. + this.#held ??= this.snapshot(); + continue; + } + this.#held = undefined; + this.#onFrame?.(); + continue; + } + if (value !== 1049 && value !== 47 && value !== 1047) { + continue; + } + // 1049 hands a TUI a screen with nothing of the shell's on it and gives + // the shell's cursor back on the way out. 47 and 1047 only switch. + const owns = value === 1049; + if (set) { + this.#enter(owns); + } else { + this.#leave(owns); + } + } + } + + #enter(owns: boolean): void { + if (this.#onAlternate) { + return; + } + if (owns) { + this.#save(); + } + this.#onAlternate = true; + if (owns) { + this.#alternate = blankCells({ rows: this.#rows, columns: this.#columns }); + this.#moveTo(0, 0); + } + } + + #leave(owns: boolean): void { + if (!this.#onAlternate) { + return; + } + this.#onAlternate = false; + if (owns) { + this.#restore(); + } + } + + #put(char: string): void { + if (char === "\n") { + this.#index(); + return; + } + if (char === "\r") { + this.#column = 0; + this.#wrapPending = false; + return; + } + if (char === "\b") { + this.#moveTo(this.#row, this.#column - 1); + return; + } + if (char === "\t") { + this.#moveTo(this.#row, (Math.floor(this.#column / 8) + 1) * 8); + return; + } + if (char < BLANK || char === DEL) { + return; + } + if (this.#wrapPending) { + this.#index(); + this.#column = 0; + this.#wrapPending = false; + } + const row = this.#cells()[this.#row]; + if (row === undefined) { + return; + } + row[this.#column] = char; + if (this.#column + 1 >= this.#columns) { + this.#wrapPending = true; + return; + } + this.#column += 1; + } + + #index(): void { + this.#wrapPending = false; + if (this.#row === this.#bottom) { + this.#scrollUp(1); + return; + } + if (this.#row < this.#rows - 1) { + this.#row += 1; + } + } + + #reverseIndex(): void { + this.#wrapPending = false; + if (this.#row === this.#top) { + this.#scrollDown(1); + return; + } + if (this.#row > 0) { + this.#row -= 1; + } + } + + #scrollUp(lines: number): void { + const cells = this.#cells(); + for (let step = 0; step < lines; step += 1) { + cells.splice(this.#top, 1); + cells.splice(this.#bottom, 0, blankRow(this.#columns)); + } + } + + #scrollDown(lines: number): void { + const cells = this.#cells(); + for (let step = 0; step < lines; step += 1) { + cells.splice(this.#bottom, 1); + cells.splice(this.#top, 0, blankRow(this.#columns)); + } + } + + #insertLines(lines: number): void { + if (this.#row < this.#top || this.#row > this.#bottom) { + return; + } + const cells = this.#cells(); + for (let step = 0; step < lines; step += 1) { + cells.splice(this.#bottom, 1); + cells.splice(this.#row, 0, blankRow(this.#columns)); + } + this.#column = 0; + this.#wrapPending = false; + } + + #deleteLines(lines: number): void { + if (this.#row < this.#top || this.#row > this.#bottom) { + return; + } + const cells = this.#cells(); + for (let step = 0; step < lines; step += 1) { + cells.splice(this.#row, 1); + cells.splice(this.#bottom, 0, blankRow(this.#columns)); + } + this.#column = 0; + this.#wrapPending = false; + } + + #insertChars(chars: number): void { + const row = this.#cells()[this.#row]; + if (row === undefined) { + return; + } + for (let step = 0; step < chars; step += 1) { + row.splice(this.#column, 0, BLANK); + row.length = this.#columns; + } + } + + #deleteChars(chars: number): void { + const row = this.#cells()[this.#row]; + if (row === undefined) { + return; + } + for (let step = 0; step < chars; step += 1) { + row.splice(this.#column, 1); + row.push(BLANK); + } + } + + #eraseChars(chars: number): void { + const row = this.#cells()[this.#row]; + if (row === undefined) { + return; + } + const end = Math.min(this.#columns, this.#column + chars); + for (let column = this.#column; column < end; column += 1) { + row[column] = BLANK; + } + } + + #eraseDisplay(choice: number): void { + const cells = this.#cells(); + this.#wrapPending = false; + if (choice === 0) { + this.#eraseLine(0); + for (let row = this.#row + 1; row < this.#rows; row += 1) { + cells[row] = blankRow(this.#columns); + } + return; + } + if (choice === 1) { + this.#eraseLine(1); + for (let row = 0; row < this.#row; row += 1) { + cells[row] = blankRow(this.#columns); + } + return; + } + if (choice !== 2) { + // `3J` clears scrollback, which this screen does not keep, and anything + // else is a mode this terminal does not implement. Neither may fall + // through to a whole-screen erase: a proof that blanked the visible rows + // on an unrecognized parameter would report the answer it was looking at + // as absent. + return; + } + for (let row = 0; row < this.#rows; row += 1) { + cells[row] = blankRow(this.#columns); + } + } + + #eraseLine(choice: number): void { + const row = this.#cells()[this.#row]; + if (row === undefined) { + return; + } + this.#wrapPending = false; + const from = choice === 0 ? this.#column : 0; + const to = choice === 1 ? this.#column + 1 : this.#columns; + for (let column = from; column < Math.min(to, this.#columns); column += 1) { + row[column] = BLANK; + } + } + + #margins(top: number, bottom: number): void { + const first = Math.max(0, Math.min(top, this.#rows - 1)); + const last = Math.max(first, Math.min(bottom, this.#rows - 1)); + this.#top = first; + this.#bottom = last; + this.#moveTo(0, 0); + } + + #moveTo(row: number, column: number): void { + this.#row = Math.max(0, Math.min(row, this.#rows - 1)); + this.#column = Math.max(0, Math.min(column, this.#columns - 1)); + this.#wrapPending = false; + } + + #save(): void { + this.#savedRow = this.#row; + this.#savedColumn = this.#column; + } + + #restore(): void { + this.#moveTo(this.#savedRow, this.#savedColumn); + } + + #reset(): void { + this.#primary = blankCells({ rows: this.#rows, columns: this.#columns }); + this.#alternate = blankCells({ rows: this.#rows, columns: this.#columns }); + this.#onAlternate = false; + this.#top = 0; + this.#bottom = this.#rows - 1; + // A terminal reset ends any frame that was being withheld. Leaving it held + // would suspend observation for the rest of the run, and go on reporting a + // screen this reset has just blanked. + this.#held = undefined; + this.#moveTo(0, 0); + } +} + +/** + * The screen's text, cut where the layout says one piece of text ends. + * + * Rows are joined rather than kept apart, because a wrapped sentence continues + * from the end of one row into the start of the next — with no space where the + * terminal wrapped it mid-word, and one where the program wrapped it between + * words. Joining and then squeezing reads both. + * + * A run ends at anything that says the text does not continue: a blank row, a + * row of the program's own furniture, a pane border, or a gap of two or more + * cells inside a row. That last cut is what keeps erasure honest — cells an + * erase blanked in the middle of a sentence would otherwise close up and let + * the words either side of the hole read as one. + */ +export function runsOf(rows: readonly string[], chrome: readonly string[]): string[] { + const furniture = chrome.map(squeeze).filter((marker) => marker.length > 0); + const runs: string[] = []; + let open = ""; + const flush = (): void => { + if (open.length > 0) { + runs.push(open); + } + open = ""; + }; + for (const row of rows) { + const framed = row.replaceAll(FRAME_GLYPH, BOUNDARY); + const bare = squeeze(framed.replaceAll(BOUNDARY, "")); + if (bare.length === 0 || furniture.some((marker) => bare.includes(marker))) { + flush(); + continue; + } + const segments = framed + .split(BOUNDARY) + .flatMap((pane) => pane.trim().split(/ {2,}/u)) + .filter((segment) => segment.length > 0); + const [first, ...rest] = segments; + if (first === undefined) { + flush(); + continue; + } + open += first; + for (const segment of rest) { + flush(); + open = segment; + } + } + flush(); + return runs; +} + +export interface AnswerQuestion { + /** The exact text an answer has to show. */ + readonly expected: string; + /** + * What this harness typed. + * + * A TUI shows the turn it was given as well as the reply, so the screen + * carries this harness's own words back. Reading the expectation out of them + * would let a question that quoted its answer satisfy itself. + */ + readonly typed: string; + /** Markers naming the program's own furniture rather than anything it said. */ + readonly chrome: readonly string[]; +} + +/** + * Why the screen was or was not read as carrying the answer. + * + * `only-typed` is kept apart from `absent` because they are different facts: one + * says the words are on screen but every copy of them is this harness's own, and + * the other says they are not there at all. + */ +export type AnswerReason = "answer" | "absent" | "only-typed"; + +export interface AnswerObservation { + readonly found: boolean; + readonly reason: AnswerReason; +} + +function rangesOf(text: string, needle: string): { start: number; end: number }[] { + const spans: { start: number; end: number }[] = []; + let index = text.indexOf(needle); + while (index !== -1) { + spans.push({ start: index, end: index + needle.length }); + index = text.indexOf(needle, index + 1); + } + return spans; +} + +/** Whether the screen is showing `question.expected` as something it said. */ +export function observeAnswer( + snapshot: ScreenSnapshot, + question: AnswerQuestion, +): AnswerObservation { + const wanted = squeeze(question.expected); + if (wanted.length === 0) { + return { found: false, reason: "absent" }; + } + const echoed = squeeze(question.typed); + let masked = false; + for (const run of runsOf(snapshot.rows, question.chrome)) { + const text = squeeze(run); + // Where this harness's own turn is showing, so a copy of the expectation + // that lies inside one is read as the echo it is. Scoped to the occurrence + // rather than the run, because a run that carries the echo can carry the + // reply after it, and a reply that happens to be a fragment of the question + // is still a reply. + const spans = echoed.length === 0 ? [] : rangesOf(text, echoed); + let index = text.indexOf(wanted); + while (index !== -1) { + const inside = spans.some((span) => index >= span.start && index + wanted.length <= span.end); + if (!inside) { + return { found: true, reason: "answer" }; + } + masked = true; + index = text.indexOf(wanted, index + 1); + } + } + return { found: false, reason: masked ? "only-typed" : "absent" }; +} + +/** + * Whether the screen — furniture included — is showing `marker`. + * + * Deliberately unlike {@link observeAnswer}: this is how the terminal's state is + * described after a wait gave up, and furniture is exactly what that describes. + */ +export function screenShows(snapshot: ScreenSnapshot, marker: string): boolean { + const text = snapshot.rows.join("\n").replaceAll(FRAME_GLYPH, ""); + return squeeze(text).includes(squeeze(marker)); +} + +/** + * The terminal one invocation drew, across the reads that built it. + * + * The one stateful thing the live proof and the offline streams both drive, so + * what the offline cases decide is what the pty path does — the alternative is + * two readers that agree until the day they do not. + * + * Every invocation gets one, because every invocation decides what surface it + * is looking at and a surface is a fact about the screen: the byte stream + * retains a composer drawn before a dialog covered it, and text an erase wiped + * out. Only an invocation that submits a turn also *watches* — see + * {@link watching} — because latching an answer is a claim about a reply to a + * turn, and a run that spoke none has no reply to attribute. + * + * The latch latches. Once the answer has been on the screen it stays observed, + * because the question is whether the agent answered and a TUI is entitled to + * repaint over its own reply the moment it finishes. Nothing un-answers a turn. + * + * Nothing is latched until {@link ask}, which is the point in a run where the + * turn has been submitted. Reads before then still build the screen — an answer + * arrives on a terminal the whole session drew — but they cannot latch, so text + * from before the turn is not a reply to it. + * + * Nothing is read out of a part-drawn frame either. A program that holds its + * drawing back with `CSI ?2026h` is saying the cells in between were never + * presented, and a reader of those would decide on a frame no terminal + * displayed and no operator could have seen. + */ +export class TerminalReader { + readonly #screen: TerminalScreen; + readonly #watching: boolean; + readonly #watches = new Set(); + #question: AnswerQuestion | undefined; + #found = false; + + constructor(size: ScreenSize, watching: boolean) { + this.#watching = watching; + // Committed frames are collected where they commit, so what a wait is + // answered with does not depend on where the operating system happened to + // cut a read. + this.#screen = new TerminalScreen(size, () => { + this.#look(); + this.#collect(); + }); + } + + /** Whether this invocation may latch an answer at all. */ + get watching(): boolean { + return this.#watching; + } + + /** Feed one read, and say whether the answer has been observed. */ + write(chunk: string | Uint8Array): boolean { + this.#screen.write(chunk); + // The screen this read ended on, which the commits inside it did not offer. + // A read need not end on a commit at all — a frame drawn with no + // synchronization is finished by the bytes stopping and by nothing else. + this.#collect(); + // Waits are woken here rather than at the commit that first satisfied them, + // so what one is answered with is every frame the read presented and not + // merely the frames up to the first match. Nothing can be acted on before a + // read has been applied in any case, and waking part of the way through one + // is what would let a refusal drawn behind an earlier match go unseen. + this.#wake(); + return this.#settled(); + } + + /** + * Watch presented frames for `predicate`, and keep every one that satisfies it. + * + * Kept, because what a terminal presented is not undone by what it presents + * next. One read from a pty can finish several frames, and a caller told only + * *that* a surface arrived would have to look at the screen again to find out + * which — by which time the frame it is being asked about may be two repaints + * old. Codex commits its refusal and draws a composer over it inside a single + * read, so that gap is where a refusal goes missing. + * + * Every one, and not just the first, because the first is not always the one + * that matters. A predicate wide enough to catch what a caller is watching for + * is wide enough to be satisfied by something ordinary ahead of it in the same + * read: Codex reloads a model, refuses the identity and repaints its composer + * in that order and in one read, and a watch that closed at the reload would + * report a reload and lose the refusal entirely. + * + * Answered against what is already on show before anything else, since a frame + * that satisfies this may have committed before a caller thought to ask. No + * read follows in that case, so nothing further is collected and `notify` is + * never called: the caller is still here, and has the frame in its hand. + */ + watch(predicate: (snapshot: ScreenSnapshot) => boolean, notify: () => void): PresentedWatch { + const entry: Watch = { test: predicate, notify, matches: [] }; + const handle: PresentedWatch = { + get matches(): readonly ScreenSnapshot[] { + return entry.matches; + }, + close: () => { + this.#watches.delete(entry); + }, + }; + const presented = this.#screen.presented(); + if (predicate(presented)) { + entry.matches.push(presented); + return handle; + } + this.#watches.add(entry); + return handle; + } + + /** + * Watch for this answer from now on, against what is already showing. + * + * Answered immediately as well as latched, because the reply can be complete + * before a caller gets here and no further read need arrive. + * + * Refused outright on a reader that is not watching. Such a reader belongs to + * an invocation that submitted no turn, so an answer it reported would be a + * reply attributed to a turn nobody spent. + */ + ask(question: AnswerQuestion): boolean { + if (!this.#watching) { + throw new Error("this invocation submits no turn and cannot watch for an answer"); + } + this.#question = question; + return this.#settled(); + } + + /** Whether the answer has been observed at any point since {@link ask}. */ + get found(): boolean { + return this.#found; + } + + /** + * What the terminal has put on show — furniture included. + * + * Never a frame it is still drawing. A caller that reaches here after a bound + * expired is asking what the operator would have been looking at, and cells + * held back for a repaint are not that: reporting them would name a surface + * as the reason no answer arrived when nobody was ever shown it. + */ + snapshot(): ScreenSnapshot { + return this.#screen.presented(); + } + + /** Look, unless a frame is part-drawn — then the last commit still stands. */ + #settled(): boolean { + if (this.#screen.synchronized) { + return this.#found; + } + return this.#look(); + } + + #look(): boolean { + if (this.#found || this.#question === undefined) { + return this.#found; + } + this.#found = observeAnswer(this.#screen.snapshot(), this.#question).found; + return this.#found; + } + + /** Offer the frame now on show to every open watch, keeping what satisfies. */ + #collect(): void { + if (this.#watches.size === 0) { + return; + } + const presented = this.#screen.presented(); + for (const entry of this.#watches) { + // A read ending on a commit reaches here twice with the same frame — once + // where it committed and once as the screen the read ended on — and that + // is one thing the terminal presented rather than two. + if (sameCells(entry.matches[entry.matches.length - 1], presented)) { + continue; + } + if (entry.test(presented)) { + entry.matches.push(presented); + } + } + } + + /** Wake every watch this read answered, and stop collecting for it. */ + #wake(): void { + for (const entry of this.#watches) { + if (entry.matches.length === 0) { + continue; + } + this.#watches.delete(entry); + entry.notify(); + } + } +} + +/** Whether these are the same cells, so one presentation rather than two. */ +function sameCells(one: ScreenSnapshot | undefined, two: ScreenSnapshot): boolean { + return ( + one !== undefined && + one.alternate === two.alternate && + one.rows.length === two.rows.length && + one.rows.every((row, index) => row === two.rows[index]) + ); +} + +interface Watch { + readonly test: (snapshot: ScreenSnapshot) => boolean; + readonly notify: () => void; + readonly matches: ScreenSnapshot[]; +} + +/** A standing question about the presented screen, answered by one read. */ +export interface PresentedWatch { + /** The frames that satisfied the predicate, in the order they were presented. */ + readonly matches: readonly ScreenSnapshot[]; + /** Stop watching. Whatever was matched is kept. */ + close(): void; +} + +/** How a wait learns that a read has answered it. */ +export interface PresentedWait { + /** Settles once a read has presented a frame satisfying the predicate. */ + readonly matched: Operation; + /** Whether one already has. */ + settled(): boolean; +} + +/** + * Wait for a read presenting `predicate`, and answer with every frame that did. + * + * The frames themselves, and never the screen as it stands when the wait + * returns. Those are routinely different things: one read from a pty commits + * however many frames its bytes finished, so a surface can be presented and + * painted over without a single byte arriving in between for anybody to be woken + * by. + * + * All of them, in the order they were presented, because a caller asking a + * question broad enough to catch what it is watching for gets ordinary frames + * answering it too — and which of them decides is the caller's to say, not + * whichever happened to come first. + * + * Empty where the bound was spent, because a surface that never arrived is + * something to report rather than an error. + * + * `read` owns everything about waiting that is not about the screen — how long, + * and what else may end it — so the live pty and the offline scripts differ only + * in that, and what decides is this. + */ +export function* waitForPresented( + reader: TerminalReader, + predicate: (snapshot: ScreenSnapshot) => boolean, + read: (wait: PresentedWait) => Operation, +): Operation { + const waiter = withResolvers(); + const watch = reader.watch(predicate, waiter.resolve); + try { + if (watch.matches.length === 0) { + yield* read({ + matched: waiter.operation, + settled: () => watch.matches.length !== 0, + }); + } + } finally { + watch.close(); + } + return watch.matches; +} + +/** + * Wait out a fixed delay, and answer with every frame presented during it. + * + * A pause is a window like any other. Nothing shortens it and nothing is being + * waited for, but the terminal goes on drawing for its whole duration all the + * same — so a settle or a grace that only sleeps is a window in which something + * can be shown and taken back, and a caller reading the screen afterwards is + * asking what survived rather than what happened. Between those two questions + * sits every refusal Codex presented and repainted over while a driver was + * deliberately not looking. + * + * Held frames stay invisible here as everywhere else: a watch is offered frames + * at their commit, and what a terminal never put on show was never presented. + * + * `delay` owns the waiting entirely, so a pause cancelled or timed out partway + * keeps whatever it had collected by then and closes its watch with the scope + * that owned it. + */ +export function* presentedDuring( + reader: TerminalReader, + predicate: (snapshot: ScreenSnapshot) => boolean, + delay: Operation, +): Operation { + // Nothing to notify: a pause runs for as long as it was asked to, and a frame + // arriving early is collected rather than acted on. + const watch = reader.watch(predicate, () => {}); + try { + yield* delay; + } finally { + watch.close(); + } + return watch.matches; +} + +/** What one invocation of the product does, as far as observation goes. */ +export interface ObservedInvocation { + /** Which invocation of its journey this is. Only the first submits a turn. */ + readonly index: number; + /** What it types into the composer, if it types anything. */ + readonly say?: string; + /** What proves the answer to that arrived, if an answer is expected. */ + readonly expect?: string; +} + +/** + * The reader an invocation gets, which is always one — watching only if the + * invocation submits a turn and expects an answer to it. + * + * The submitting invocation is a journey's first, so a later one carrying an + * expectation is a mistake rather than a second thing to watch; saying so here + * keeps that out of reach of an edit to any single call site. + */ +export function terminalReader(size: ScreenSize, invocation: ObservedInvocation): TerminalReader { + const { index, say, expect } = invocation; + const watching = index === 0 && say !== undefined && expect !== undefined && expect.length > 0; + return new TerminalReader(size, watching); +} + +/** + * Where each thing a pty says may be read. + * + * `script` puts the emulated terminal on stdout. Its stderr is this harness's + * own out-of-band channel — the shell's complaints, `script`'s own diagnostics — + * which never went through the terminal and so is not part of what was + * displayed. Both are kept as stream evidence; only one may reach the screen. + */ +export interface TerminalChannels { + /** Whether an answer is being watched for at all. */ + readonly observing: boolean; + /** Bytes the emulated terminal presented. Says whether that answered. */ + display(chunk: string | Uint8Array): boolean; + /** Bytes that reached this process beside the terminal. Answers nothing. */ + diagnostic(chunk: string | Uint8Array): void; +} + +export function terminalChannels( + record: (chunk: string | Uint8Array) => void, + reader: TerminalReader, +): TerminalChannels { + return { + observing: reader.watching, + display(chunk) { + record(chunk); + return reader.write(chunk); + }, + diagnostic(chunk) { + record(chunk); + }, + }; +} diff --git a/packages/acp/tests/native-capability.test.ts b/packages/acp/tests/native-capability.test.ts new file mode 100644 index 000000000..8be2443fb --- /dev/null +++ b/packages/acp/tests/native-capability.test.ts @@ -0,0 +1,292 @@ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import type { ExecutableMetadata, ExecutableMetadataObservation } from "@executablemd/runtime"; +import { + ADVERTISED_CLIENT_NATIVE_ATTACHMENT, + ADVERTISED_NATIVE_LAUNCH, + ADVERTISED_PROVIDER_NATIVE_CONTINUATION, + bindsBuild, + nativeAdapterFor, + nativeCapabilityPolicy, + pinnedProviderRouteProtocol, + pinnedRouteProtocol, +} from "../src/native-launch.ts"; +import type { NativeCapability, NativeCapabilityPolicy } from "../src/native-capability.ts"; +import { admitsNativeCapability } from "../src/native-capability.ts"; + +const ROOT_HELP = `Codex CLI + +Usage: codex [OPTIONS] [PROMPT] + codex [OPTIONS] [ARGS] + +Commands: + exec Run Codex non-interactively + resume Resume a previous interactive session (picker by default) + help Print this message + +Arguments: + [PROMPT] + Optional user prompt to start the session + +Options: + -h, --help + Print help +`; + +const RESUME_HELP = `Resume a previous interactive session + +Usage: codex resume [OPTIONS] [SESSION_ID] [PROMPT] + +Arguments: + [SESSION_ID] + Session id (UUID) or session name. UUIDs take precedence if it parses. + If omitted, use --last to pick the most recent recorded session + + [PROMPT] + Optional user prompt to start the session + +Options: + --last + Continue the most recent session + -h, --help + Print help +`; + +function answered(stdout: string): ExecutableMetadataObservation { + return { settled: true, code: 0, stdout, stderr: "" }; +} + +function metadata( + root = ROOT_HELP, + resume = RESUME_HELP, + version = "codex-cli 0.153.2", +): ExecutableMetadata { + return { help: answered(root), "resume-help": answered(resume), version: answered(version) }; +} + +function codexAdapter() { + const adapter = nativeAdapterFor("codex"); + if (adapter === undefined || !bindsBuild(adapter)) { + throw new Error("the Codex adapter must observe its executable"); + } + return adapter; +} + +const CAPABILITIES: readonly NativeCapability[] = ["native-launch", "provider-native-continuation"]; + +describe("Tier CDP — Codex capability admission", () => { + it("CDP1: the built-in adapter names the exact provider-returned command and bridge contract", function* () { + const adapter = codexAdapter(); + expect(adapter.identity).toBe("provider-returned"); + expect(adapter.protocol).toBe("codex-provider-returned.v1"); + expect(adapter.binding.metadata).toEqual([ + { name: "help", args: ["--help"] }, + { name: "resume-help", args: ["resume", "--help"] }, + { name: "version", args: ["--version"] }, + ]); + expect(adapter.binding.environment("/canonical/codex")).toEqual({ + CODEX_PATH: "/canonical/codex", + }); + expect(adapter.binding.adapterCommand).toBe(undefined); + expect(adapter.resume("provider-issued-id")).toEqual(["codex", "resume", "provider-issued-id"]); + expect(adapter.materialization).toEqual({ + promptVersion: "codex-materialization.v1", + prompt: + "This turn only makes the Codex conversation resumable. Do not perform the prepared " + + "task, inspect or modify files, call tools, or take any external action. Reply with a " + + "brief acknowledgement only.", + }); + expect(ADVERTISED_NATIVE_LAUNCH).toEqual(["claude", "codex"]); + expect(ADVERTISED_CLIENT_NATIVE_ATTACHMENT).toEqual(["claude"]); + expect(ADVERTISED_PROVIDER_NATIVE_CONTINUATION).toEqual(["codex"]); + }); + + it("CDP2: the positive root and resume shapes survive unrelated options, wrapping and release changes", function* () { + const binding = codexAdapter().binding; + for (const version of [ + "codex-cli 0.153.2", + "codex-cli 99.12.3", + "", + "Version wording changed", + ]) { + const root = ROOT_HELP.replace(" exec", " new-command Something new\n exec"); + const resume = RESUME_HELP.replace( + "[OPTIONS] [SESSION_ID] [PROMPT]", + "[OPTIONS]\n [SESSION_ID] [PROMPT]", + ).replace("Session id (UUID)", "Session\n id (UUID)"); + expect(binding.probe(metadata(root, resume, version))).toEqual({ + probeProfile: "codex-help-native-session.v1", + capabilities: CAPABILITIES, + }); + } + expect( + binding.probe({ help: answered(ROOT_HELP), "resume-help": answered(RESUME_HELP) }) + .capabilities, + ).toEqual(CAPABILITIES); + }); + + it("CDP3: mentions, changed identity semantics and reordered positionals grant no capability", function* () { + const cases: [string, string, string][] = [ + ["another product", ROOT_HELP.replace("Codex CLI", "Another CLI"), RESUME_HELP], + [ + "compatibility wrapper", + ROOT_HELP.replace("Codex CLI", "Codex CLI compatibility wrapper"), + RESUME_HELP, + ], + [ + "product mentioned in an option", + ROOT_HELP.replace("Codex CLI\n", "") + " --compat Codex CLI\n", + RESUME_HELP, + ], + [ + "root usage names another program", + ROOT_HELP.replace("Usage: codex", "Usage: other"), + RESUME_HELP, + ], + [ + "resume mentioned but not declared", + ROOT_HELP.replace(" resume Resume", " help-resume Resume"), + RESUME_HELP, + ], + [ + "duplicate resume command", + ROOT_HELP.replace(" resume", " resume Another declaration\n resume"), + RESUME_HELP, + ], + [ + "another subcommand", + ROOT_HELP, + RESUME_HELP.replace("Usage: codex resume", "Usage: codex fork"), + ], + [ + "reordered usage", + ROOT_HELP, + RESUME_HELP.replace("[SESSION_ID] [PROMPT]", "[PROMPT] [SESSION_ID]"), + ], + ["renamed identity", ROOT_HELP, RESUME_HELP.replaceAll("SESSION_ID", "SESSION_NAME")], + ["required identity", ROOT_HELP, RESUME_HELP.replaceAll("[SESSION_ID]", "")], + [ + "usage only mentions identity", + ROOT_HELP, + RESUME_HELP.replace(" [SESSION_ID]", " [SESSION_NAME]"), + ], + ["identity accepts URLs", ROOT_HELP, RESUME_HELP.replace("Session id (UUID)", "Session URL")], + [ + "identity refuses UUID", + ROOT_HELP, + RESUME_HELP.replace("Session id (UUID)", "Session id (UUID) is not supported"), + ], + [ + "UUID mentioned in another argument", + ROOT_HELP, + RESUME_HELP.replace("Session id (UUID)", "Session name").replace( + "Optional user prompt", + "Session id (UUID). Optional user prompt", + ), + ], + [ + "extra positional before identity", + ROOT_HELP, + RESUME_HELP.replace("Arguments:\n", "Arguments:\n [FILE]\n A file\n"), + ], + [ + "duplicate identity", + ROOT_HELP, + RESUME_HELP.replace( + " [SESSION_ID]\n", + " [SESSION_ID]\n Session id (UUID)\n [SESSION_ID]\n", + ), + ], + [ + "duplicate usage", + ROOT_HELP, + RESUME_HELP + "Usage: codex resume [OPTIONS] [SESSION_ID] [PROMPT]\n", + ], + ]; + for (const [name, root, resume] of cases) { + expect([name, codexAdapter().binding.probe(metadata(root, resume)).capabilities]).toEqual([ + name, + [], + ]); + } + }); + + it("CDP4: missing or failed metadata is no declaration, and version evidence is optional", function* () { + const binding = codexAdapter().binding; + for (const name of ["help", "resume-help"]) { + const absent = { ...metadata() }; + delete absent[name]; + expect(binding.probe(absent).capabilities).toEqual([]); + for (const failure of [{ settled: false }, { code: 1 }]) { + expect( + binding.probe({ ...metadata(), [name]: { ...metadata()[name], ...failure } }) + .capabilities, + ).toEqual([]); + } + } + expect(binding.reportedVersion(metadata())).toBe("codex-cli 0.153.2"); + expect(binding.reportedVersion(metadata(ROOT_HELP, RESUME_HELP, "codex-cli 99.1.2\n"))).toBe( + "codex-cli 99.1.2", + ); + for (const version of [ + "", + "0.153.2", + "other-cli 0.153.2", + "codex-cli 0.153.2\ncodex-cli 0.153.3", + ]) { + expect(binding.reportedVersion(metadata(ROOT_HELP, RESUME_HELP, version))).toBe(undefined); + } + expect( + binding.reportedVersion({ + ...metadata(), + version: { ...answered("codex-cli 0.153.2"), code: 1 }, + }), + ).toBe(undefined); + }); + + it("CDP5: capability, protocol, profile and host are independent admission requirements", function* () { + const adapter = codexAdapter(); + const observed = adapter.binding.probe(metadata()); + const policy = nativeCapabilityPolicy({ platform: "darwin", architecture: "arm64" }); + for (const capability of CAPABILITIES) { + const request = { + adapterProtocol: adapter.protocol, + capability, + probeProfile: observed.probeProfile, + }; + expect(admitsNativeCapability(policy, request)).toBe(true); + expect(admitsNativeCapability(undefined, request)).toBe(false); + expect( + admitsNativeCapability(policy, { + ...request, + adapterProtocol: "codex-provider-returned.v2", + }), + ).toBe(false); + expect( + admitsNativeCapability(policy, { + ...request, + probeProfile: "codex-help-native-session.v2", + }), + ).toBe(false); + expect( + admitsNativeCapability(policy, { ...request, capability: "client-native-attachment" }), + ).toBe(false); + for (const host of [ + { platform: "linux", architecture: "arm64" }, + { platform: "darwin", architecture: "x64" }, + ]) { + expect(admitsNativeCapability({ ...policy, host }, request)).toBe(false); + } + const otherOnly: NativeCapabilityPolicy = { + ...policy, + admissions: policy.admissions.filter((entry) => entry.capability !== capability), + }; + expect(admitsNativeCapability(otherOnly, request)).toBe(false); + } + expect(pinnedProviderRouteProtocol("codex")).toBe("codex-provider-returned.v1"); + expect(pinnedProviderRouteProtocol("claude")).toBe(undefined); + expect(pinnedProviderRouteProtocol("injected")).toBe(undefined); + expect(pinnedRouteProtocol("claude")).toBe("claude-client-native.v1"); + expect(pinnedRouteProtocol("codex")).toBe(undefined); + }); +}); diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index 27c3b0c41..d0b1d83f4 100644 --- a/packages/acp/tests/native-launch.test.ts +++ b/packages/acp/tests/native-launch.test.ts @@ -25,6 +25,7 @@ import type { DetachedLaunchRecord, ExitedLaunchRecord, LaunchRecord, + MaterializedLaunchRecord, PreparedLaunchRecord, Session, } from "@executablemd/core"; @@ -37,6 +38,7 @@ import type { AcpxProviderDependencies } from "../src/provider.ts"; import { ADVERTISED_NATIVE_LAUNCH, allocatesIdentity, + bindsBuild, knownNativeAdapters, nativeAdapterFor, nativeCapabilityPolicy, @@ -72,11 +74,12 @@ import type { FakeObservation, FakeObserverHarness, FakeRuntimeHarness, + ScriptedTurn, } from "./helpers.ts"; import type { ExecutableBuildBindingV1 } from "@executablemd/core"; import type { AcpxSessionPolicy } from "../src/provider.ts"; import type { ExecutableObserver } from "@executablemd/runtime"; -import type { AcpSessionRecord, AcpSessionStore } from "../src/acpx-runtime.ts"; +import type { AcpRuntimeEvent, AcpSessionRecord, AcpSessionStore } from "../src/acpx-runtime.ts"; const CWD = "/work"; const AGENT_COMMAND = "claude-cmd"; @@ -287,6 +290,7 @@ function admitting( interface Trace { records: LaunchRecord[]; launches: NativeLaunchRequest[]; + notices: string[]; /** Provider and launcher events interleaved, so ordering is provable. */ order: string[]; /** Who owned the session, and when. */ @@ -315,7 +319,9 @@ interface Trace { } interface ProviderOptions { + defaultAgent?: string; advertise?: readonly string[]; + continuation?: readonly string[]; /** Advertised for client-native attachment; defaults to `advertise`. */ attach?: readonly string[]; /** `false` gives this host no way to observe a build at all. */ @@ -453,13 +459,44 @@ function traceAuthority(trace: Trace): AgentProviderAuthority { if (prepared.failure) { return; } - const detached = yield* phases.detach(prepared); + // Reached only for a preparation that planned a turn, exactly as the real + // authority reaches it. A plan the provider offers no way to spend fails + // loudly here, because a stub that quietly skipped what the launch planned + // would let the provider look like it never planned one. + const plan = prepared.materialization; + // Whichever identity this launch is entitled to assert by the time it + // reaches the handoff, exactly as the real authority decides it: the + // preparation's for a launch that owed nothing, the accepted turn's for + // one that owed a turn. + let handed = prepared; + if (plan) { + if (!phases.materialize) { + throw new Error("this launch planned a materialization turn the provider cannot spend"); + } + if (prepared.nativeSessionId.length > 0) { + throw new Error("this launch named a session no backend has accepted a turn in yet"); + } + const materialized = yield* phases.materialize(prepared, plan); + trace.records.push(materialized); + trace.order.push("materialized"); + if (materialized.failure) { + return; + } + const asserted = materialized.nativeSessionId; + if (asserted === undefined || asserted.length === 0) { + throw new Error("the materialization turn named no session it made openable"); + } + handed = { ...prepared, nativeSessionId: asserted }; + } else if (prepared.nativeSessionId.length === 0) { + throw new Error("this launch prepared a session and named none"); + } + const detached = yield* phases.detach(handed); trace.records.push(detached); trace.order.push("detached"); if (detached.failure) { return; } - const exited = yield* phases.exit(prepared); + const exited = yield* phases.exit(handed); trace.records.push(exited); trace.order.push("exited"); }, @@ -483,6 +520,10 @@ function* installLaunchStack( trace.launches.push(request); trace.order.push("spawn"); }, + onNotify: (text) => { + trace.notices.push(text); + trace.order.push("notify"); + }, ...(options.hold ? { wait: () => options.hold! } : {}), outcome: () => ({ exitCode: options.exitCode ?? 0 }), }); @@ -504,8 +545,13 @@ function* installLaunchStack( const factory = createAcpxProvider({ createRuntime: harness.create, sessionStore: options.store ?? makeStore(), - agentRegistry: makeRegistry({ claude: AGENT_COMMAND, mystery: "mystery-cmd" }), + agentRegistry: makeRegistry({ + claude: AGENT_COMMAND, + codex: "codex-cmd", + mystery: "mystery-cmd", + }), advertiseNativeLaunch: options.advertise ?? ["claude"], + advertiseProviderNativeContinuation: options.continuation ?? [], advertiseClientNativeAttachment: options.attach ?? options.advertise ?? ["claude"], ...(options.observer === false ? {} @@ -524,13 +570,33 @@ function* installLaunchStack( nativeAdapters: options.adapters ?? { claude: PROVIDER_RETURNED_CLAUDE }, }); yield* factory( - { defaultAgent: "claude", permissionMode: "approve-reads" }, + { defaultAgent: options.defaultAgent ?? "claude", permissionMode: "approve-reads" }, traceAuthority(trace), ); } function newTrace(): Trace { - return { records: [], launches: [], order: [], ownership: makeCoordinator() }; + return { records: [], launches: [], notices: [], order: [], ownership: makeCoordinator() }; +} + +/** The preparation a launch retained, refusing a trace that holds none. */ +function preparedOf(trace: Trace): PreparedLaunchRecord { + const record = trace.records.find( + (candidate): candidate is PreparedLaunchRecord => candidate.phase === "prepared", + ); + if (!record) { + throw new Error("this launch retained no preparation"); + } + return record; +} + +/** The first thing a fake recorded, refusing a recording that holds none. */ +function only(recorded: readonly T[], what: string): T { + const [first] = recorded; + if (first === undefined) { + throw new Error(`nothing was recorded as ${what}`); + } + return first; } /** @@ -1013,22 +1079,64 @@ describe("Tier NL — native session launch", () => { expect(nativeAdapterFor("gemini")).toBe(undefined); }); - it("NL19: claude is the only advertised adapter, and it names its own sessions", function* () { + it("NL20: codex binds a build without naming its own sessions", function* () { + const codex = nativeAdapterFor("codex"); + if (codex === undefined || !bindsBuild(codex)) { + throw new Error("codex must bind a build"); + } + + // Provider identity and executable observation are independent. The route + // keeps its original build evidence; each live operation admits its current build. + expect(codex.identity).toBe("provider-returned"); + expect(allocatesIdentity(codex)).toBe(false); + expect("create" in codex).toBe(false); + + expect(codex.binding.command).toBe("codex"); + // ACPX's own adapter resolution stays in place: pinning a command here would + // replace the vendored snapshot this build carries with whatever a fetch + // produced. + expect(codex.binding.adapterCommand).toBe(undefined); + expect(codex.binding.environment("/usr/local/bin/codex")).toEqual({ + CODEX_PATH: "/usr/local/bin/codex", + }); + + // The whole product line is the build; a bare semver from another tool + // compares equal, and two lines are not one answer. + expect(codex.binding.reportedVersion({ version: answered("codex-cli 0.153.2\n") })).toBe( + "codex-cli 0.153.2", + ); + expect(codex.binding.reportedVersion({ version: answered("0.153.2") })).toBe(undefined); + expect( + codex.binding.reportedVersion({ version: answered("codex-cli 0.153.2\ncodex-cli 0.154.0") }), + ).toBe(undefined); + expect(codex.binding.reportedVersion({ version: answered("") })).toBe(undefined); + }); + + it("NL19: every known adapter is advertised, and each names sessions its own way", function* () { // Advertisement is a claim about what has been proven against an installed - // CLI, and the two documents beside this package's source are what proved - // it: `ClaudeNativeLaunch.test.md` and `ClaudeZeroTurnExit.test.md`, run - // through the built binary against Claude Code 2.1.241 on macOS arm64. - expect([...ADVERTISED_NATIVE_LAUNCH]).toEqual(["claude"]); + // CLI, and the four documents beside this package's source are what proved + // it: `ClaudeNativeLaunch.test.md` and `ClaudeZeroTurnExit.test.md` against + // Claude Code 2.1.241, `CodexNativeLaunch.test.md` and + // `CodexZeroNativeTurnExit.test.md` against `codex-cli 0.153.2`, all run + // through the built binary on macOS arm64. + expect([...ADVERTISED_NATIVE_LAUNCH]).toEqual(["claude", "codex"]); + expect(knownNativeAdapters()).toEqual(["claude", "codex"]); + // Being launch-capable does not make the two alike. Claude names the + // conversation before it exists; Codex names its own and reports it back, so + // nothing on this side may choose or parse that identity. const claude = nativeAdapterFor("claude"); expect(claude !== undefined && allocatesIdentity(claude)).toBe(true); expect(claude?.identity).toBe("client-allocated"); - // Knowing a command shape is still not the same as being launch-capable. - // Codex keeps its adapter and its contract tests, and nothing has run it - // against an installed Codex — so it stays off the list. - expect(knownNativeAdapters()).toEqual(["claude", "codex"]); - expect(ADVERTISED_NATIVE_LAUNCH).not.toContain("codex"); + const codex = nativeAdapterFor("codex"); + expect(codex !== undefined && allocatesIdentity(codex)).toBe(false); + expect(codex?.identity).toBe("provider-returned"); + + // And only Codex owes a turn to become resumable, because only Codex writes + // the rollout `codex resume ` reads at a thread's first turn. + expect(claude?.materialization).toBe(undefined); + expect(codex?.materialization?.promptVersion).toBe("codex-materialization.v1"); }); }); @@ -4791,6 +4899,18 @@ describe("Tier NP — proved native capability admissions", () => { probeProfile: CLAUDE_PROBE_PROFILE, ...PROVED_HOST, }, + { + adapterProtocol: "codex-provider-returned.v1", + capability: "native-launch", + probeProfile: "codex-help-native-session.v1", + ...PROVED_HOST, + }, + { + adapterProtocol: "codex-provider-returned.v1", + capability: "provider-native-continuation", + probeProfile: "codex-help-native-session.v1", + ...PROVED_HOST, + }, ]); expect(JSON.stringify(nativeCapabilityPolicy(PROVED_HOST))).not.toContain("Claude Code"); }); @@ -6032,3 +6152,1226 @@ describe("Tier XR — one session across two releases", () => { expect(yield* inner.read(foreignKey)).toEqual(foreignWinner); }); }); + +function shippedCodex() { + const adapter = nativeAdapterFor("codex"); + if (adapter === undefined || !bindsBuild(adapter) || adapter.identity !== "provider-returned") { + throw new Error("Codex must return its identity and observe its executable"); + } + return adapter; +} + +const CODEX_TEST_BINDING = shippedCodex().binding; +const CODEX_AGENT_COMMAND = "codex-cmd"; +const CODEX_SESSION_KEY = deriveSessionKey(CODEX_AGENT_COMMAND, CWD); +const CODEX_OBSERVED_PATH = "/opt/builds/codex"; +const CODEX_OBSERVED_BUILD: ExecutableBuildBindingV1 = { + schema: "executable-build.v1", + reportedVersion: "codex-cli 0.153.2", + executableDigest: { algorithm: "sha256", value: "a".repeat(64) }, +}; +const CODEX_HELP = + "Codex CLI\n\nUsage: codex [OPTIONS] [PROMPT]\n\nCommands:\n resume Resume a previous interactive session\n"; +const CODEX_RESUME_HELP = + "Resume a previous interactive session\n\nUsage: codex resume [OPTIONS] [SESSION_ID] [PROMPT]\n\nArguments:\n [SESSION_ID]\n Session id (UUID) or session name. UUIDs take precedence if it parses.\n [PROMPT]\n Optional user prompt\n"; +function codexObservation( + overrides: { path?: string; digest?: string; version?: string; resume?: string } = {}, +): FakeObservation { + return { + path: overrides.path ?? CODEX_OBSERVED_PATH, + digest: overrides.digest ?? "a".repeat(64), + metadata: { + help: answered(CODEX_HELP), + "resume-help": answered(overrides.resume ?? CODEX_RESUME_HELP), + version: answered(overrides.version ?? "codex-cli 0.153.2"), + }, + }; +} +function codexOptions(): ProviderOptions { + return { + defaultAgent: "codex", + advertise: ["codex"], + attach: [], + continuation: ["codex"], + observer: createFakeObserver(codexObservation()).observer, + }; +} +function launchCodex(instructions: string): Operation { + return Agent.operations.launch(launchRequest(instructions, { agent: "codex" })); +} +function* attemptCodex(trace: Trace, instructions: string) { + yield* launchCodex(instructions); + return trace.records.findLast((record) => record.failure)?.failure; +} +/** + * Tier BA — bound ACP-first construction + * (specs/native-agent-session-launch-spec.md §Executable binding). + * + * The other half of the binding contract: who names the session and whether the + * executable is observed are independent. The route retains construction + * evidence while each operation admits its live executable afresh. + * Tier CN is the same claim from the client-allocated + * side, and Tier NL is what an adapter binding nothing still does. + */ +describe("Tier BA — bound ACP-first construction", () => { + /** The provider names the session; the adapter observes the live executable. */ + const BOUND: NativeAdapter = { + launcher: "codex", + protocol: "codex-provider-returned.v1", + identity: "provider-returned", + binding: CODEX_TEST_BINDING, + resume: (nativeSessionId) => ["codex", "resume", nativeSessionId], + }; + + /** What the fake runtime asserts as this session's conversation. */ + const ASSERTED = `agent-session:${CODEX_SESSION_KEY}`; + + const BOUND_ROUTE: AgentSessionRoute = { + schema: "session-route.v3", + route: "acp-first", + provider: "acpx", + agent: CODEX_AGENT_COMMAND, + sessionKey: CODEX_SESSION_KEY, + executableBinding: CODEX_OBSERVED_BUILD, + }; + + function* installBound( + harness: FakeRuntimeHarness, + trace: Trace, + options: ProviderOptions = {}, + ): Operation { + yield* installLaunchStack(harness, trace, { + ...codexOptions(), + adapters: { codex: BOUND }, + routeStore: options.routeStore ?? createMemorySessionRouteStore(), + ...options, + }); + } + + function* routeOf(store: AgentSessionRouteStore): Operation { + return yield* store.read({ + provider: "acpx", + agent: CODEX_AGENT_COMMAND, + sessionKey: CODEX_SESSION_KEY, + }); + } + + it("BA1: the bound route is published before ACP creates anything", function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const routes = createMemorySessionRouteStore(); + // What had already been asked of ACP when the route was written down. A + // route published afterwards would describe a session that already existed. + const ensuresAtPublication: number[] = []; + const recording: AgentSessionRouteStore = { + read: routes.read, + *publish(candidate) { + ensuresAtPublication.push(harness.ensureCalls.length); + return yield* routes.publish(candidate); + }, + }; + yield* installBound(harness, trace, { routeStore: recording }); + + yield* launchCodex(INSTRUCTIONS); + + expect(ensuresAtPublication).toEqual([0]); + expect(yield* routeOf(routes)).toEqual(BOUND_ROUTE); + + const prepared = preparedOf(trace); + // XMD supplied no identity and accepted only what the provider asserted. + expect(prepared.identityProvenance).toBe("provider-returned"); + expect(prepared.nativeSessionId).toBe(ASSERTED); + expect(prepared.executableBinding).toEqual(CODEX_OBSERVED_BUILD); + // The launch spends no model turn. + expect(harness.turns).toEqual([]); + }); + + it("BA2: ACP creation and the native resume go through one observed build", function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const observer = createFakeObserver(codexObservation()); + yield* installBound(harness, trace, { observer: observer.observer }); + + yield* launchCodex(INSTRUCTIONS); + + // Observed once, under ownership, and both sides of the handoff run it: the + // ACP child through its transient environment, the native child in place of + // the launcher name the adapter returned. + expect(observer.observed).toEqual(["codex"]); + const created = only(harness.createdOptions, "a created runtime"); + const launched = only(trace.launches, "a native launch"); + expect(created.agentProcessEnv?.CODEX_PATH).toBe(CODEX_OBSERVED_PATH); + expect(launched.command).toEqual([CODEX_OBSERVED_PATH, "resume", ASSERTED]); + // The transient environment is the child's alone: it reaches no durable + // record and no native argv. + expect(JSON.stringify(launched.env ?? {})).not.toContain(CODEX_OBSERVED_PATH); + }); + + it("BA3: entering the native UI carries no permission-mode flag", function* () { + // The handoff is interactive, and a translated mode would be XMD claiming + // to have preserved a setting it does not own. + const harness = createFakeRuntime(); + const trace = newTrace(); + yield* installBound(harness, trace); + + yield* launchCodex(INSTRUCTIONS); + + const launched = only(trace.launches, "a native launch"); + expect(launched.command).toEqual([CODEX_OBSERVED_PATH, "resume", ASSERTED]); + for (const mode of ["approve-reads", "approve-all", "deny-all"]) { + expect(launched.command.join(" ")).not.toContain(mode); + expect(JSON.stringify(launched.env ?? {})).not.toContain(mode); + } + }); + + it("BA4: a newer capable build resumes the exact identity without rewriting its route", function* () { + const first = createFakeRuntime(); + const trace = newTrace(); + const routes = createMemorySessionRouteStore(); + const store = makeStore(); + yield* installBound(first, trace, { routeStore: routes, store }); + yield* launchCodex(INSTRUCTIONS); + const published = yield* routeOf(routes); + const later = createFakeRuntime(); + const second = newTrace(); + yield* scoped(function* () { + yield* installBound(later, second, { + routeStore: routes, + store, + observer: createFakeObserver( + codexObservation({ + path: "/opt/builds/codex-next", + digest: "b".repeat(64), + version: "codex-cli 0.999.0", + }), + ).observer, + }); + yield* launchCodex(INSTRUCTIONS); + }); + expect(preparedOf(second).sessionState).toBe("resumed"); + expect(preparedOf(second).nativeSessionId).toBe(ASSERTED); + expect(preparedOf(second).executableBinding).toEqual(CODEX_OBSERVED_BUILD); + expect(second.launches[0]?.command).toEqual(["/opt/builds/codex-next", "resume", ASSERTED]); + expect(yield* routeOf(routes)).toEqual(published); + }); + + it("BA5: a legacy unbound acp-first route refuses rather than gaining a build", function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const routes = createMemorySessionRouteStore(); + const legacy: AgentSessionRoute = { + schema: "session-route.v1", + route: "acp-first", + provider: "acpx", + agent: CODEX_AGENT_COMMAND, + sessionKey: CODEX_SESSION_KEY, + }; + yield* routes.publish(legacy); + yield* installBound(harness, trace, { routeStore: routes }); + + const refusal = yield* attemptCodex(trace, INSTRUCTIONS); + + expect(refusal?.class).toBe("executable-binding-refused"); + expect(harness.ensureCalls).toEqual([]); + expect(trace.launches).toEqual([]); + // Unbound is what it stays. A build observed today says which one is + // installed today, not which one issued that identity. + expect(yield* routeOf(routes)).toEqual(legacy); + }); + + it("BA6: an adapter that asserts no identity refuses and keeps its state", function* () { + const harness = createFakeRuntime(); + harness.omitAgentSessionId = true; + const trace = newTrace(); + const routes = createMemorySessionRouteStore(); + yield* installBound(harness, trace, { routeStore: routes }); + + const refusal = yield* attemptCodex(trace, INSTRUCTIONS); + + // An ACP session id and an ACPX record id are not native identities, and a + // launch that read one as the conversation to resume would be substituting + // exactly the value this route exists to require from the provider. + expect(refusal?.class).toBe("identity-unavailable"); + expect(trace.launches).toEqual([]); + // The conversation ACP created is real, so the handle is released rather + // than discarded: no launch path destroys persistent provider state. + expect(harness.ensureCalls.length).toBe(1); + expect(harness.closeInputs.length).toBe(1); + expect(only(harness.closeInputs, "a close").discardPersistentState).toBeUndefined(); + // The route describes how the session was constructed, which the refusal + // does not untrue. + expect(yield* routeOf(routes)).toEqual(BOUND_ROUTE); + }); + + it("BA7: a host with no route store refuses this agent before provider work", function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + yield* installLaunchStack(harness, trace, { ...codexOptions(), adapters: { codex: BOUND } }); + + const refusal = yield* attemptCodex(trace, INSTRUCTIONS); + + // A host that cannot say how a session was constructed cannot serve an + // adapter whose sessions are bound to one build, whoever named them. + expect(refusal?.class).toBe("unsupported-capability"); + expect(harness.ensureCalls).toEqual([]); + expect(trace.launches).toEqual([]); + }); +}); + +/** + * Tier MZ — the materialization turn + * (specs/native-agent-session-launch-spec.md §Materialization). + * + * The one model turn a launch may owe, and everything that keeps it to one. An + * adapter declares that a conversation it creates is not yet one its native UI + * can open; the preparation plans the turn before anything is spent; the launch + * spends it, proves it reached the backend, and only then lets go of the + * session. + * + * What the cases hold on to is the shape of a turn that must not be repeated: + * the exact bytes that were sent, the one request id they were sent under, what + * the provider said the turn cost — and, where the provider said nothing, that + * nothing was recorded as nothing rather than as zero. + */ +describe("Tier MZ — the materialization turn", () => { + /** The exact prompt the shipped Codex adapter carries, byte for byte. */ + const PROMPT = + "This turn only makes the Codex conversation resumable. Do not perform the prepared " + + "task, inspect or modify files, call tools, or take any external action. Reply with a " + + "brief acknowledgement only."; + + /** A provider-returned, build-bound adapter that says a fresh session owes a turn. */ + const OWES: NativeAdapter = { + launcher: "codex", + protocol: "codex-provider-returned.v1", + identity: "provider-returned", + binding: CODEX_TEST_BINDING, + materialization: { promptVersion: "codex-materialization.v1", prompt: PROMPT }, + resume: (nativeSessionId) => ["codex", "resume", nativeSessionId], + }; + + const ASSERTED = `agent-session:${CODEX_SESSION_KEY}`; + + /** What a Codex-shaped adapter names its completed turn. */ + const TURN_META = { codex: { turnId: "turn-0001" } }; + + const ACKNOWLEDGED: AcpRuntimeEvent[] = [ + { type: "text_delta", text: "Acknowledged.", stream: "output" }, + ]; + + function* installOwing( + harness: FakeRuntimeHarness, + trace: Trace, + options: ProviderOptions = {}, + ): Operation { + yield* installLaunchStack(harness, trace, { + ...codexOptions(), + adapters: { codex: OWES }, + routeStore: options.routeStore ?? createMemorySessionRouteStore(), + ...options, + }); + } + + /** The one turn a successful materialization runs. */ + function acknowledges(harness: FakeRuntimeHarness, script: Partial = {}): void { + harness.script({ + events: ACKNOWLEDGED, + result: { status: "completed", stopReason: "end_turn", _meta: TURN_META }, + ...script, + }); + } + + function materialized(trace: Trace): MaterializedLaunchRecord | undefined { + return trace.records.find( + (record): record is MaterializedLaunchRecord => record.phase === "materialized", + ); + } + + it("MZ1: the planned turn is the adapter's exact prompt, sent once under one request id", function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + acknowledges(harness); + yield* installOwing(harness, trace); + + yield* launchCodex(INSTRUCTIONS); + + const plan = preparedOf(trace).materialization; + if (!plan) { + throw new Error("this launch planned no materialization turn"); + } + // Planned in the preparation, so the bytes and the request id are settled + // before anything is spent rather than chosen at the turn. + expect(plan.promptVersion).toBe("codex-materialization.v1"); + expect(plan.prompt).toBe(PROMPT); + expect(plan.requestId).toEqual(expect.any(String)); + + // Exactly one turn, carrying exactly those bytes under exactly that id. + expect(harness.turns.length).toBe(1); + const turn = only(harness.turns, "a turn"); + expect(turn.input.text).toBe(PROMPT); + expect(turn.input.requestId).toBe(plan.requestId); + expect(turn.input.mode).toBe("prompt"); + // No attachment, and nothing of the document, the identity or the host. + expect(turn.input.attachments).toBeUndefined(); + expect(PROMPT).not.toContain(INSTRUCTIONS); + expect(PROMPT).not.toContain(ASSERTED); + expect(PROMPT).not.toContain(CWD); + + // And the launch went on to hand the session over. + expect(trace.order).toEqual([ + "prepared", + "notify", + "notify", + "materialized", + "detached", + "spawn", + "exited", + ]); + }); + + it("MZ2: the turn is spent before the handle is released and before any child", function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + acknowledges(harness); + yield* installOwing(harness, trace); + + yield* launchCodex(INSTRUCTIONS); + + // ACP still owned the session when the turn ran: a turn taken after the + // detach would be one taken through a handle this provider gave up. + expect(harness.closeCalls.length).toBe(1); + expect(trace.order.indexOf("materialized")).toBeLessThan(trace.order.indexOf("detached")); + expect(trace.order.indexOf("detached")).toBeLessThan(trace.order.indexOf("spawn")); + // And nothing was discarded to make room for it. + expect(only(harness.closeInputs, "a close").discardPersistentState).toBeUndefined(); + }); + + it("MZ3: the reader is told before the turn runs, not after it is gone", function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + acknowledges(harness); + const startedTurns: number[] = []; + yield* installOwing(harness, trace, { + // Reading the turn count at each notice is what orders them against the + // spend: a warning issued once the turn exists is a warning issued late. + routeStore: createMemorySessionRouteStore(), + }); + yield* NativeLauncher.around({ + *notify([text], next) { + startedTurns.push(harness.turns.length); + return yield* next(text); + }, + }); + + yield* launchCodex(INSTRUCTIONS); + + expect(startedTurns[0]).toBe(0); + expect(trace.notices[0]).toContain("one model turn"); + expect(trace.notices[0]).toContain("codex-materialization.v1"); + }); + + it("MZ4: the assistant response is displayed whole, and retained whole", function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + harness.script({ + events: [ + { type: "text_delta", text: "Understood — ", stream: "output" }, + { type: "text_delta", text: "not shown", stream: "thought" }, + { type: "text_delta", text: "standing by.", stream: "output" }, + ], + result: { status: "completed", stopReason: "end_turn", _meta: TURN_META }, + }); + yield* installOwing(harness, trace); + + yield* launchCodex(INSTRUCTIONS); + + const record = materialized(trace); + // Output only. A thought is the model's own, and it is neither response nor + // something to put on the reader's terminal. + expect(record?.response).toBe("Understood — standing by."); + expect(trace.notices.join("\n")).toContain("Understood — standing by."); + expect(trace.notices.join("\n")).not.toContain("not shown"); + expect(record?.turn).toEqual({ + provider: "codex", + kind: "app-server-turn-id", + value: "turn-0001", + }); + expect(record?.stopReason).toBe("end_turn"); + expect(record?.durationMs).toEqual(expect.any(Number)); + }); + + it("MZ5: what the provider reported is recorded, and what it did not is not zero", function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + harness.script({ + events: [ + ...ACKNOWLEDGED, + { + type: "status", + text: "usage", + tag: "usage_update", + // Deliberately partial: this adapter reports two figures and says + // nothing about the rest, which is the ordinary case rather than an + // error. + breakdown: { inputTokens: 812, outputTokens: 9 }, + }, + ], + result: { status: "completed", stopReason: "end_turn", _meta: TURN_META }, + }); + yield* installOwing(harness, trace); + + yield* launchCodex(INSTRUCTIONS); + + const record = materialized(trace); + // Exactly the two members the provider reported, and no others invented + // beside them. + expect(record?.usage).toEqual({ inputTokens: 812, outputTokens: 9 }); + const shown = trace.notices.join("\n"); + expect(shown).toContain("input tokens: 812"); + expect(shown).toContain("output tokens: 9"); + // The rest is reported as unreported. Displaying `0` would be an + // observation nobody made. + expect(shown).toContain("total tokens: provider did not report"); + expect(shown).toContain("cost: provider did not report"); + expect(shown).not.toContain("total tokens: 0"); + }); + + it("MZ6: a reported cost is shown with the currency the provider named", function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + harness.script({ + events: [ + ...ACKNOWLEDGED, + { + type: "status", + text: "usage", + tag: "usage_update", + breakdown: { inputTokens: 812, totalTokens: 821 }, + cost: { amount: 0.0031, currency: "USD" }, + }, + ], + result: { status: "completed", stopReason: "end_turn", _meta: TURN_META }, + }); + yield* installOwing(harness, trace); + + yield* launchCodex(INSTRUCTIONS); + + expect(materialized(trace)?.usage).toEqual({ + inputTokens: 812, + totalTokens: 821, + costAmount: 0.0031, + costCurrency: "USD", + }); + expect(trace.notices.join("\n")).toContain("cost: 0.0031 USD"); + }); + + it("MZ7: a later report adds to what an earlier one said and erases nothing", function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + harness.script({ + events: [ + ...ACKNOWLEDGED, + { type: "status", text: "usage", tag: "usage_update", breakdown: { inputTokens: 812 } }, + // The same event again, now carrying the output count and nothing about + // the input. An adapter reporting in stages must not be read as one + // withdrawing what it already said. + { type: "status", text: "usage", tag: "usage_update", breakdown: { outputTokens: 9 } }, + ], + result: { status: "completed", stopReason: "end_turn", _meta: TURN_META }, + }); + yield* installOwing(harness, trace); + + yield* launchCodex(INSTRUCTIONS); + + expect(materialized(trace)?.usage).toEqual({ inputTokens: 812, outputTokens: 9 }); + }); + + it("MZ8: a tool call fails materialization, and no native UI opens", function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + harness.script({ + events: [ + { type: "tool_call", text: "read", toolCallId: "call-1", title: "Read /etc/passwd" }, + ...ACKNOWLEDGED, + ], + result: { status: "completed", stopReason: "end_turn", _meta: TURN_META }, + }); + yield* installOwing(harness, trace); + + const refusal = yield* attemptCodex(trace, INSTRUCTIONS); + + // The prompt forbids it, so a turn that called a tool did something other + // than make the conversation openable — and the launch stops rather than + // handing a native UI a session it does not understand the state of. + expect(refusal?.class).toBe("materialization-failed"); + expect(trace.launches).toEqual([]); + expect(trace.order).not.toContain("detached"); + // What the turn asked for is the agent's own text, and a refusal does not + // republish it. + expect(refusal?.message).not.toContain("/etc/passwd"); + }); + + it("MZ9: a turn that names no provider turn is not evidence it reached a backend", function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + harness.script({ + events: ACKNOWLEDGED, + // Completed, with text, and naming nothing. A socket accepted this; the + // App Server may never have. + result: { status: "completed", stopReason: "end_turn" }, + }); + yield* installOwing(harness, trace); + + const refusal = yield* attemptCodex(trace, INSTRUCTIONS); + + expect(refusal?.class).toBe("materialization-failed"); + expect(trace.launches).toEqual([]); + }); + + it("MZ10: an empty response materializes nothing", function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + harness.script({ + events: [{ type: "text_delta", text: "thinking", stream: "thought" }], + result: { status: "completed", stopReason: "end_turn", _meta: TURN_META }, + }); + yield* installOwing(harness, trace); + + const refusal = yield* attemptCodex(trace, INSTRUCTIONS); + + // Nothing was said in the conversation this launch was making openable. + expect(refusal?.class).toBe("materialization-failed"); + expect(trace.launches).toEqual([]); + }); + + it("MZ11: a failed, cancelled or refused turn each stops the launch where it stands", function* () { + const scripts: [string, ScriptedTurn][] = [ + ["failed", { result: { status: "failed", error: { message: "backend refused" } } }], + ["cancelled", { result: { status: "cancelled" } }], + [ + "refused stop reason", + { result: { status: "completed", stopReason: "refusal", _meta: TURN_META } }, + ], + ]; + for (const [name, script] of scripts) { + const harness = createFakeRuntime(); + const trace = newTrace(); + harness.script({ events: ACKNOWLEDGED, ...script }); + yield* scoped(function* () { + yield* installOwing(harness, trace); + const refusal = yield* attemptCodex(trace, INSTRUCTIONS); + expect([name, refusal?.class]).toEqual([name, "materialization-failed"]); + }); + expect([name, trace.launches]).toEqual([name, []]); + } + }); + + it("MZ12: a resumed session owes nothing, and nothing is spent on it", function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const routes = createMemorySessionRouteStore(); + const store = makeStore(); + acknowledges(harness); + yield* installOwing(harness, trace, { routeStore: routes, store }); + + yield* launchCodex(INSTRUCTIONS); + expect(harness.turns.length).toBe(1); + + // A second launch of the same layer resumes what the first established. + const second = createFakeRuntime(); + const later = newTrace(); + yield* scoped(function* () { + yield* installOwing(second, later, { routeStore: routes, store }); + yield* launchCodex(INSTRUCTIONS); + }); + + const prepared = preparedOf(later); + expect(prepared.sessionState).toBe("resumed"); + // Whatever made this conversation openable happened before this run, so a + // turn here would be spending a reader's model turn to learn nothing. + expect(prepared.materialization).toBeUndefined(); + expect(second.turns).toEqual([]); + expect(later.records.some((record) => record.phase === "materialized")).toBe(false); + expect(later.launches.length).toBe(1); + }); + + it("MZ13: an adapter that owes no turn plans none and spends none", function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const unowing: NativeAdapter = { + launcher: "codex", + protocol: "codex-provider-returned.v1", + identity: "provider-returned", + binding: CODEX_TEST_BINDING, + resume: (nativeSessionId) => ["codex", "resume", nativeSessionId], + }; + yield* installLaunchStack(harness, trace, { + ...codexOptions(), + adapters: { codex: unowing }, + routeStore: createMemorySessionRouteStore(), + }); + + yield* launchCodex(INSTRUCTIONS); + + expect(preparedOf(trace).materialization).toBeUndefined(); + expect(harness.turns).toEqual([]); + expect(trace.notices).toEqual([]); + expect(trace.order).toEqual(["prepared", "detached", "spawn", "exited"]); + }); + + it("MZ14: the shipped Claude adapter owes no turn and the shipped Codex adapter does", function* () { + // The gap is Codex's persistence, so declaring it belongs to the adapter + // that has it — and to no other. An adapter gaining this member by being + // launched would be one acquiring a model turn by contract drift. + const claude = nativeAdapterFor("claude"); + const codex = nativeAdapterFor("codex"); + if (claude === undefined || codex === undefined) { + throw new Error( + "this build ships no claude or codex adapter, so there is nothing to compare", + ); + } + expect("materialization" in claude).toBe(false); + expect(codex.materialization).toEqual({ + promptVersion: "codex-materialization.v1", + prompt: PROMPT, + }); + // The bytes are a constant of this build: nothing interpolated, nothing + // authored, no path, no identity, no environment. + expect(PROMPT).toBe( + "This turn only makes the Codex conversation resumable. Do not perform the prepared " + + "task, inspect or modify files, call tools, or take any external action. Reply with a " + + "brief acknowledgement only.", + ); + }); + + it("MZ15: a missing resume capability or identity refuses before anything is spent", function* () { + // A conversation this build cannot name is a conversation this build cannot + // send to. Both of these refuse for reasons that are settled before the + // turn, and the cost of getting that order wrong is a reader's model turn + // spent to reach a refusal that was already decided. + const first = createFakeRuntime(); + const routes = createMemorySessionRouteStore(); + acknowledges(first); + yield* installOwing(first, newTrace(), { routeStore: routes }); + yield* launchCodex(INSTRUCTIONS); + expect(first.turns.length).toBe(1); + + // A current executable that no longer declares exact-identity resume. + const drifted = createFakeRuntime(); + const afterDrift = newTrace(); + acknowledges(drifted); + yield* scoped(function* () { + yield* installOwing(drifted, afterDrift, { + routeStore: routes, + observer: createFakeObserver( + codexObservation({ resume: "Usage: codex resume [OPTIONS] [PROMPT]" }), + ).observer, + }); + expect((yield* attemptCodex(afterDrift, INSTRUCTIONS))?.class).toBe("unsupported-capability"); + }); + expect(drifted.turns).toEqual([]); + expect(afterDrift.notices).toEqual([]); + expect(afterDrift.launches).toEqual([]); + + // And an adapter that asserts no native identity for a session that owes no + // turn: the session exists, so there is something to send to, and no way to + // say which conversation it is. The handle is released rather than prompted. + const nameless = createFakeRuntime(); + nameless.omitAgentSessionId = true; + const afterNameless = newTrace(); + acknowledges(nameless); + yield* scoped(function* () { + yield* installLaunchStack(nameless, afterNameless, { + ...codexOptions(), + adapters: { + codex: { + launcher: "codex", + protocol: "codex-provider-returned.v1", + identity: "provider-returned", + binding: CODEX_TEST_BINDING, + resume: (nativeSessionId) => ["codex", "resume", nativeSessionId], + }, + }, + routeStore: createMemorySessionRouteStore(), + }); + expect((yield* attemptCodex(afterNameless, INSTRUCTIONS))?.class).toBe( + "identity-unavailable", + ); + }); + expect(nameless.turns).toEqual([]); + expect(afterNameless.notices).toEqual([]); + expect(nameless.closeInputs[0]?.discardPersistentState).toBeUndefined(); + }); + + it("MZ15b: an owing adapter that names nothing costs its turn before it can say so", function* () { + // The honest cost of the gate. A conversation held as occupancy has no name + // until a backend accepts a turn in it, so an adapter that would never have + // named one cannot be caught before the turn — nothing before acceptance + // distinguishes it from an adapter that will. + const harness = createFakeRuntime(); + harness.omitAgentSessionId = true; + const trace = newTrace(); + acknowledges(harness); + yield* installOwing(harness, trace); + + const refusal = yield* attemptCodex(trace, INSTRUCTIONS); + + // The turn succeeded and the identity is what is missing, so this is the + // same refusal a nameless session gets — not a failed turn. + expect(refusal?.class).toBe("identity-unavailable"); + expect(harness.turns.length).toBe(1); + // And the launch still stops: nothing is detached, nothing is opened. + expect(trace.launches).toEqual([]); + expect(trace.order).not.toContain("detached"); + }); + + it("MZ16: a launch halted during its turn cancels it and opens nothing", function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + // Withheld until this case releases it, so the halt lands while the turn is + // genuinely in flight rather than in whatever gap a delay happens to hit. + harness.script({ manual: true, result: { status: "completed", stopReason: "end_turn" } }); + + yield* scoped(function* () { + yield* installOwing(harness, trace); + const launching = yield* spawn(() => + Agent.operations.launch(launchRequest(INSTRUCTIONS, { agent: "codex" })), + ); + yield* harness.startedTurns(1); + + yield* launching.halt(); + + // The turn this launch started is the turn it stopped, and stopped by the + // time the halt answers. A turn still running here is one the reader is + // paying for with nobody waiting on it and nothing left to cancel it + // before this whole provider comes down. + expect(harness.turns[0]?.cancelled).toBe(true); + // Nothing downstream of the turn happened. The native UI in particular + // never opened, so the conversation it would have opened on is one this + // run neither finished making openable nor handed to anybody. + expect(trace.launches).toEqual([]); + expect(trace.order).toEqual(["prepared", "notify"]); + // The reader was still told, because a turn that was started and stopped + // is one they may still be charged for. + expect(trace.notices.length).toBe(1); + + // And the session is not quietly free. It was prepared and never handed + // over, so this owner cannot say it finished with it — the next owner is + // told to recover it deliberately rather than being granted a session + // whose conversation may or may not have been written to. + let next: string | undefined; + try { + yield* Agent.operations.session(); + } catch (error) { + next = error instanceof Error ? error.name : typeof error; + } + expect(next).toBe("AgentSessionRecoveryRequired"); + }); + }); + + it("MZ17: the session is created as occupancy, and the accepted turn is what names it", function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const store = makeStore(); + acknowledges(harness); + yield* installOwing(harness, trace, { store }); + + yield* launchCodex(INSTRUCTIONS); + + // The gate is asked for at the ensure. Without this the runtime would + // persist an ordinary record and report the turn accepted before any + // backend saw it, and every check below would be about nothing. + expect(harness.ensureCalls.length).toBe(1); + expect(harness.ensureCalls[0]?.materialization).toBe("first-turn-acceptance"); + + // So the preparation has no conversation to name: ACPX is holding the key, + // not a session. + const prepared = preparedOf(trace); + expect(prepared.materialization?.promptVersion).toBe("codex-materialization.v1"); + expect(prepared.nativeSessionId).toBe(""); + + // The accepted turn is what names it, and that is the name handed over. + expect(materialized(trace)?.nativeSessionId).toBe(ASSERTED); + expect(trace.launches[0]?.command).toEqual([CODEX_OBSERVED_PATH, "resume", ASSERTED]); + + // And acceptance promoted the record: it no longer awaits a first turn, and + // it now asserts the conversation that turn made openable. + const stored = store.records.get(CODEX_SESSION_KEY); + expect(stored?.sessionMaterialization).toBeUndefined(); + expect(stored?.agentSessionId).toBe(ASSERTED); + }); + + it("MZ18: a turn no backend accepts opens nothing, whatever the adapter said", function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const store = makeStore(); + // Text, a stop reason, a named turn — and no acceptance. This is the whole + // point of the gate: everything an adapter can say is said here, and none + // of it is evidence the conversation exists. + acknowledges(harness, { accepted: false }); + yield* installOwing(harness, trace, { store }); + + const refusal = yield* attemptCodex(trace, INSTRUCTIONS); + + expect(refusal?.class).toBe("materialization-failed"); + expect(trace.launches).toEqual([]); + expect(trace.order).not.toContain("detached"); + // The record still awaits its first accepted turn, and still names nothing. + const stored = store.records.get(CODEX_SESSION_KEY); + expect(stored?.sessionMaterialization?.state).toBe("pending"); + expect(stored?.agentSessionId).toBeUndefined(); + }); + + it("MZ19: a record still awaiting its first turn is not a session to resume", function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + // What an interrupted first attempt leaves behind: the key is occupied, the + // layer matches, and no backend ever accepted a turn in it. Reading this as + // an ordinary resume would skip the very turn that makes it openable and + // hand a native UI a name for nothing. + const pending: AcpSessionRecord = { + ...makeRecord(CODEX_AGENT_COMMAND, CWD), + acpxRecordId: CODEX_SESSION_KEY, + acpx: { session_options: { system_prompt: INSTRUCTIONS } }, + sessionMaterialization: { + state: "pending", + contract: "executablemd.session-materialization/v1", + }, + }; + const store = makeStore({ [CODEX_SESSION_KEY]: pending }); + acknowledges(harness); + yield* installOwing(harness, trace, { store }); + + yield* launchCodex(INSTRUCTIONS); + + const prepared = preparedOf(trace); + expect(prepared.sessionState).toBe("created"); + expect(prepared.materialization?.promptVersion).toBe("codex-materialization.v1"); + expect(harness.ensureCalls[0]?.materialization).toBe("first-turn-acceptance"); + expect(harness.turns.length).toBe(1); + expect(store.records.get(CODEX_SESSION_KEY)?.sessionMaterialization).toBeUndefined(); + }); + + it("MZ20: once promoted, the record resumes and the gate is never asked for again", function* () { + const harness = createFakeRuntime(); + const store = makeStore(); + const routes = createMemorySessionRouteStore(); + acknowledges(harness); + yield* installOwing(harness, newTrace(), { store, routeStore: routes }); + yield* launchCodex(INSTRUCTIONS); + + const second = createFakeRuntime(); + const later = newTrace(); + yield* scoped(function* () { + yield* installOwing(second, later, { store, routeStore: routes }); + yield* launchCodex(INSTRUCTIONS); + }); + + // A promoted record is an ordinary session, so nothing about materialization + // is asked for and nothing is spent. + expect(second.ensureCalls[0]?.materialization).toBeUndefined(); + expect(second.turns).toEqual([]); + expect(preparedOf(later).nativeSessionId).toBe(ASSERTED); + expect(later.launches[0]?.command).toEqual([CODEX_OBSERVED_PATH, "resume", ASSERTED]); + }); +}); + +describe("Tier PV — provider-native continuation authority", () => { + const PROTOCOL = "codex-provider-returned.v1"; + const PROFILE = "codex-help-native-session.v1"; + function adapter(protocol = PROTOCOL): NativeAdapter { + return { + launcher: "codex", + protocol, + identity: "provider-returned", + binding: CODEX_TEST_BINDING, + resume: (id) => ["codex", "resume", id], + }; + } + function policy( + capabilities: readonly NativeCapability[], + protocol = PROTOCOL, + ): NativeCapabilityPolicy { + return { + host: PROVED_HOST, + admissions: capabilities.map((capability) => ({ + ...PROVED_HOST, + adapterProtocol: protocol, + probeProfile: PROFILE, + capability, + })), + }; + } + function route( + sessionKey = CODEX_SESSION_KEY, + executableBinding = CODEX_OBSERVED_BUILD, + ): AgentSessionRoute { + return { + schema: "session-route.v3", + route: "acp-first", + provider: "acpx", + agent: CODEX_AGENT_COMMAND, + sessionKey, + executableBinding, + }; + } + function install( + harness: FakeRuntimeHarness, + trace: Trace, + options: ProviderOptions = {}, + ): Operation { + return installLaunchStack(harness, trace, { + ...codexOptions(), + adapters: { codex: adapter() }, + routeStore: createMemorySessionRouteStore(), + ...options, + }); + } + function* promptRefusal(): Operation { + try { + yield* prompt("continue the conversation"); + return undefined; + } catch (error) { + if (!(error instanceof Error)) { + throw error; + } + return error; + } + } + + it("PV1: launch, client attachment and provider continuation grant none of one another", function* () { + const denied: ProviderOptions[] = [ + { continuation: [] }, + { nativeCapabilityPolicy: policy(["native-launch"]) }, + { nativeCapabilityPolicy: policy(["client-native-attachment"]) }, + ]; + for (const options of denied) { + yield* scoped(function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + yield* install(harness, trace, options); + const error = yield* promptRefusal(); + expect(error?.name).toBe("AttachmentRefused"); + expect(error?.message).toContain("provider-native-continuation"); + expect(harness.createdOptions).toEqual([]); + expect(harness.ensureCalls).toEqual([]); + expect(trace.launches).toEqual([]); + }); + } + yield* scoped(function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + yield* install(harness, trace, { + advertise: [], + attach: [], + nativeCapabilityPolicy: policy(["provider-native-continuation"]), + }); + yield* prompt("continue the conversation"); + expect(harness.ensureCalls).toHaveLength(1); + expect((yield* attemptCodex(trace, INSTRUCTIONS))?.class).toBe("unsupported-capability"); + expect(trace.launches).toEqual([]); + }); + }); + + it("PV2: even a proved foreign protocol cannot reinterpret or publish V3", function* () { + for (const published of [false, true]) { + for (const operation of ["launch", "prompt"]) { + yield* scoped(function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const observer = createFakeObserver(codexObservation()); + const routes = createMemorySessionRouteStore(); + if (published) { + yield* routes.publish(route()); + } + yield* install(harness, trace, { + routeStore: routes, + observer: observer.observer, + adapters: { codex: adapter("codex-fork.v1") }, + nativeCapabilityPolicy: policy( + ["native-launch", "provider-native-continuation"], + "codex-fork.v1", + ), + }); + if (operation === "launch") { + expect((yield* attemptCodex(trace, INSTRUCTIONS))?.class).toBe( + "unsupported-capability", + ); + } else { + expect((yield* promptRefusal())?.name).toBe("AttachmentRefused"); + } + expect(observer.observed).toEqual([]); + expect(harness.createdOptions).toEqual([]); + expect(harness.ensureCalls).toEqual([]); + expect(trace.launches).toEqual([]); + expect( + yield* routes.read({ + provider: "acpx", + agent: CODEX_AGENT_COMMAND, + sessionKey: CODEX_SESSION_KEY, + }), + ).toEqual(published ? route() : undefined); + }); + } + } + }); + + it("PV3: every continuation observes current shape while retaining original build evidence", function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const observer = createFakeObserver(codexObservation()); + const routes = createMemorySessionRouteStore(); + yield* routes.publish(route()); + yield* install(harness, trace, { routeStore: routes, observer: observer.observer }); + yield* prompt("continue the conversation"); + observer.observation = codexObservation({ + version: "codex-cli 9.2.1", + digest: "b".repeat(64), + path: "/new/codex", + }); + yield* prompt("continue the conversation"); + expect(harness.createdOptions.map((value) => value.agentProcessEnv?.CODEX_PATH)).toEqual([ + CODEX_OBSERVED_PATH, + "/new/codex", + ]); + observer.observation = codexObservation({ resume: "Usage: codex resume [OPTIONS] [PROMPT]" }); + expect((yield* promptRefusal())?.name).toBe("AttachmentRefused"); + expect(observer.observed).toEqual(["codex", "codex", "codex"]); + expect(harness.ensureCalls).toHaveLength(2); + expect( + yield* routes.read({ + provider: "acpx", + agent: CODEX_AGENT_COMMAND, + sessionKey: CODEX_SESSION_KEY, + }), + ).toEqual(route()); + }); + + it("PV4: live observations partition concurrent handles, never retained provenance", function* () { + for (const differentLive of [false, true]) { + yield* scoped(function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const observer = createFakeObserver(codexObservation()); + const routes = createMemorySessionRouteStore(); + const held = deriveSessionKey(CODEX_AGENT_COMMAND, CWD, "held"); + const beside = deriveSessionKey(CODEX_AGENT_COMMAND, CWD, "beside"); + const historical: ExecutableBuildBindingV1 = { + schema: "executable-build.v1", + reportedVersion: "codex-cli 0.1.0", + executableDigest: { algorithm: "sha256", value: "e".repeat(64) }, + }; + yield* routes.publish(route(held)); + yield* routes.publish(route(beside, differentLive ? CODEX_OBSERVED_BUILD : historical)); + yield* install(harness, trace, { routeStore: routes, observer: observer.observer }); + const arrived = withResolvers(); + const release = withResolvers(); + harness.ensureGate = (input) => { + if (input.sessionKey === held) { + arrived.resolve(); + return release.operation; + } + return undefined; + }; + const first = yield* spawn(() => prompt("continue held", { session: "held" })); + yield* arrived.operation; + if (differentLive) { + // Same bytes and release, another executable path: the live child must still differ. + observer.observation = codexObservation({ path: "/other/codex" }); + } + yield* prompt("continue beside", { session: "beside" }); + const built = harness.createdOptions.length; + release.resolve(); + yield* first; + expect(built).toBe(differentLive ? 2 : 1); + expect(harness.closeRuntimeIndexes).toEqual(differentLive ? [1, 0] : [0, 0]); + expect(observer.observed).toEqual(["codex", "codex"]); + expect( + yield* routes.read({ provider: "acpx", agent: CODEX_AGENT_COMMAND, sessionKey: held }), + ).toEqual(route(held)); + expect( + yield* routes.read({ provider: "acpx", agent: CODEX_AGENT_COMMAND, sessionKey: beside }), + ).toEqual(route(beside, differentLive ? CODEX_OBSERVED_BUILD : historical)); + }); + } + }); + + it("PV5: incomplete replay refuses journal-route disagreement before live provider work", function* () { + const first = createFakeRuntime(); + const trace = newTrace(); + const routes = createMemorySessionRouteStore(); + yield* install(first, trace, { routeStore: routes }); + yield* launchCodex(INSTRUCTIONS); + const original = preparedOf(trace); + const suffixes: Replay["suffix"][] = ["prepared", "prepared+detached"]; + const wrong: ExecutableBuildBindingV1 = { + schema: "executable-build.v1", + executableDigest: { algorithm: "sha256", value: "f".repeat(64) }, + }; + for (const suffix of suffixes) { + for (const binding of [wrong, undefined]) { + yield* scoped(function* () { + const harness = createFakeRuntime(); + const replay = newTrace(); + const prepared = { ...original }; + if (binding === undefined) { + delete prepared.executableBinding; + } else { + prepared.executableBinding = binding; + } + replay.replay = { prepared, suffix }; + yield* install(harness, replay, { routeStore: routes }); + expect((yield* attemptCodex(replay, INSTRUCTIONS))?.class).toBe( + binding === undefined ? "executable-binding-refused" : "identity-unavailable", + ); + expect(harness.ensureCalls).toEqual([]); + expect(replay.launches).toEqual([]); + expect( + yield* routes.read({ + provider: "acpx", + agent: CODEX_AGENT_COMMAND, + sessionKey: CODEX_SESSION_KEY, + }), + ).toEqual(route()); + }); + } + } + }); + + it("PV6: completed provider-returned replay asks for neither build nor provider", function* () { + const harness = createFakeRuntime(); + const first = newTrace(); + yield* install(harness, first); + yield* launchCodex(INSTRUCTIONS); + const detached = first.records.find( + (record): record is DetachedLaunchRecord => record.phase === "detached", + ); + const exited = first.records.find( + (record): record is ExitedLaunchRecord => record.phase === "exited", + ); + if (detached === undefined || exited === undefined) { + throw new Error("the control launch did not complete"); + } + yield* scoped(function* () { + const replayed = createFakeRuntime(); + const trace = newTrace(); + trace.completed = { prepared: preparedOf(first), detached, exited }; + const observer = createFakeObserver(codexObservation()); + observer.observer = { + *observe() { + throw new Error("completed replay observed an executable"); + }, + }; + yield* install(replayed, trace, { + observer: observer.observer, + nativeCapabilityPolicy: false, + }); + yield* launchCodex(INSTRUCTIONS); + expect(observer.observed).toEqual([]); + expect(replayed.ensureCalls).toEqual([]); + expect(replayed.turns).toEqual([]); + expect(trace.launches).toEqual([]); + expect(trace.records).toEqual(first.records); + }); + }); +}); diff --git a/packages/acp/tests/provider.test.ts b/packages/acp/tests/provider.test.ts index b6b693f27..84649c4a5 100644 --- a/packages/acp/tests/provider.test.ts +++ b/packages/acp/tests/provider.test.ts @@ -49,9 +49,9 @@ function* installProvider(harness: FakeRuntimeHarness): Operation { const factory = createAcpxProvider({ createRuntime: harness.create, sessionStore: makeStore(), - agentRegistry: makeRegistry({ codex: "codex-cmd", other: "other-cmd" }), + agentRegistry: makeRegistry({ scribe: "scribe-cmd", other: "other-cmd" }), }); - yield* factory({ defaultAgent: "codex", permissionMode: "deny-all" }, stubAuthority()); + yield* factory({ defaultAgent: "scribe", permissionMode: "deny-all" }, stubAuthority()); } function* collectPrompt( @@ -78,10 +78,10 @@ describe("Tier AP — ACPX provider", () => { yield* installProvider(harness); const { events, close } = yield* collectPrompt("describe this repo"); - expect(events[0]).toMatchObject({ type: "started", agent: "codex" }); + expect(events[0]).toMatchObject({ type: "started", agent: "scribe" }); const started = events[0]!; if (started.type === "started") { - expect(started.session.sessionKey).toBe(deriveSessionKey("codex-cmd", CWD)); + expect(started.session.sessionKey).toBe(deriveSessionKey("scribe-cmd", CWD)); expect(started.session.cwd).toBe(CWD); } const deltas = events.filter((event) => event.type === "text_delta"); @@ -93,8 +93,8 @@ describe("Tier AP — ACPX provider", () => { expect(close).toBe("hello world"); expect(harness.ensureCalls[0]).toMatchObject({ - sessionKey: deriveSessionKey("codex-cmd", CWD), - agent: "codex", + sessionKey: deriveSessionKey("scribe-cmd", CWD), + agent: "scribe", mode: "persistent", cwd: CWD, }); @@ -176,14 +176,14 @@ describe("Tier AP — ACPX provider", () => { const factory = createAcpxProvider({ createRuntime: harness.create, sessionStore: store, - agentRegistry: makeRegistry({ codex: "codex-cmd" }), + agentRegistry: makeRegistry({ scribe: "scribe-cmd" }), }); - yield* factory({ defaultAgent: "codex", permissionMode: "deny-all" }, stubAuthority()); + yield* factory({ defaultAgent: "scribe", permissionMode: "deny-all" }, stubAuthority()); // Simulate a prior turn's reconnect: the persisted record now // carries a replaced ACP session id that the handle predates. - const sessionKey = deriveSessionKey("codex-cmd", CWD); - const record = makeRecord("codex-cmd", CWD); + const sessionKey = deriveSessionKey("scribe-cmd", CWD); + const record = makeRecord("scribe-cmd", CWD); record.acpxRecordId = `record:${sessionKey}`; record.acpSessionId = "replaced-id"; store.records.set(record.acpxRecordId, record); @@ -236,14 +236,14 @@ describe("Tier AP — ACPX provider", () => { const factory = createAcpxProvider({ createRuntime: harness.create, sessionStore: store, - agentRegistry: makeRegistry({ codex: "codex-cmd" }), + agentRegistry: makeRegistry({ scribe: "scribe-cmd" }), }); - yield* factory({ defaultAgent: "codex", permissionMode: "deny-all" }, stubAuthority()); + yield* factory({ defaultAgent: "scribe", permissionMode: "deny-all" }, stubAuthority()); // The persisted record carries the authoritative ids after a // prior reconnect replaced both. - const sessionKey = deriveSessionKey("codex-cmd", CWD); - const record = makeRecord("codex-cmd", CWD); + const sessionKey = deriveSessionKey("scribe-cmd", CWD); + const record = makeRecord("scribe-cmd", CWD); record.acpxRecordId = `record:${sessionKey}`; record.acpSessionId = "sid-2"; record.agentSessionId = "agent-2"; @@ -252,7 +252,7 @@ describe("Tier AP — ACPX provider", () => { // resolves to. Without it this prompt would be constructing the session // rather than reconnecting to one, and there would be no earlier // conversation whose ids a reconnect could have replaced. - const placed = makeRecord("codex-cmd", CWD); + const placed = makeRecord("scribe-cmd", CWD); placed.acpxRecordId = sessionKey; placed.acpSessionId = "sid-2"; placed.agentSessionId = "agent-2"; @@ -319,12 +319,12 @@ describe("Tier AP — ACPX provider", () => { const factory = createAcpxProvider({ createRuntime: harness.create, sessionStore: store, - agentRegistry: makeRegistry({ codex: "codex-cmd" }), + agentRegistry: makeRegistry({ scribe: "scribe-cmd" }), }); - yield* factory({ defaultAgent: "codex", permissionMode: "deny-all" }, stubAuthority()); + yield* factory({ defaultAgent: "scribe", permissionMode: "deny-all" }, stubAuthority()); - const sessionKey = deriveSessionKey("codex-cmd", CWD); - const record = makeRecord("codex-cmd", CWD); + const sessionKey = deriveSessionKey("scribe-cmd", CWD); + const record = makeRecord("scribe-cmd", CWD); record.acpxRecordId = `record:${sessionKey}`; record.acpSessionId = "id-A"; record.agentSessionId = "agent-A"; @@ -398,13 +398,13 @@ describe("Tier AP — ACPX provider", () => { const factory = createAcpxProvider({ createRuntime: harness.create, sessionStore: store, - agentRegistry: makeRegistry({ codex: "codex-cmd" }), + agentRegistry: makeRegistry({ scribe: "scribe-cmd" }), }); - yield* factory({ defaultAgent: "codex", permissionMode: "deny-all" }, stubAuthority()); + yield* factory({ defaultAgent: "scribe", permissionMode: "deny-all" }, stubAuthority()); // Pre-seed the repo-root session so the walk from a subdir reuses it. - const rootKey = deriveSessionKey("codex-cmd", "/repo"); - const rootRecord = makeRecord("codex-cmd", "/repo"); + const rootKey = deriveSessionKey("scribe-cmd", "/repo"); + const rootRecord = makeRecord("scribe-cmd", "/repo"); rootRecord.acpxRecordId = `record:${rootKey}`; store.records.set(rootKey, rootRecord); @@ -448,10 +448,10 @@ describe("Tier AP — ACPX provider", () => { const factory = createAcpxProvider({ createRuntime: harness.create, sessionStore: makeStore(), - agentRegistry: makeRegistry({ codex: "codex-cmd" }), + agentRegistry: makeRegistry({ scribe: "scribe-cmd" }), withSessionRoute: (_context, op) => routeQueue.withSlot("route", op), }); - yield* factory({ defaultAgent: "codex", permissionMode: "deny-all" }, stubAuthority()); + yield* factory({ defaultAgent: "scribe", permissionMode: "deny-all" }, stubAuthority()); const a1 = yield* spawn(() => collectPrompt("a1")); yield* sleep(10); @@ -598,26 +598,26 @@ describe("Tier AP — ACPX provider", () => { harness.doctorReports.push({ ok: false, code: "ACP_BACKEND_UNAVAILABLE", - message: "codex not installed", - details: ["agent=codex"], + message: "scribe not installed", + details: ["agent=scribe"], }); yield* scoped(function* () { yield* installProvider(harness); let thrown: unknown; try { - yield* Agent.operations.agent("codex"); + yield* Agent.operations.agent("scribe"); } catch (error) { thrown = error; } expect(thrown).toBeInstanceOf(Error); if (thrown instanceof Error) { expect(thrown.message).toContain("ACP_BACKEND_UNAVAILABLE"); - expect(thrown.message).toContain("codex not installed"); + expect(thrown.message).toContain("scribe not installed"); } // The failed probe is not cached; the next probe succeeds and is. - expect(yield* Agent.operations.agent("codex")).toBe("codex"); - expect(yield* Agent.operations.agent("codex")).toBe("codex"); + expect(yield* Agent.operations.agent("scribe")).toBe("scribe"); + expect(yield* Agent.operations.agent("scribe")).toBe("scribe"); expect(harness.doctorCalls).toBe(2); }); }); @@ -629,11 +629,11 @@ describe("Tier AP — ACPX provider", () => { function* installState(harness: FakeRuntimeHarness): Operation { const state = yield* useAcpxProvider( - { defaultAgent: "codex", permissionMode: "deny-all" }, + { defaultAgent: "scribe", permissionMode: "deny-all" }, { createRuntime: harness.create, sessionStore: makeStore(), - agentRegistry: makeRegistry({ codex: "codex-cmd" }), + agentRegistry: makeRegistry({ scribe: "scribe-cmd" }), }, ); yield* Agent.around( @@ -712,11 +712,11 @@ interface Partition { function* usePartition(harness: FakeRuntimeHarness): Operation { const handle = yield* useAcpxProvider( - { defaultAgent: "codex", permissionMode: "deny-all" }, + { defaultAgent: "scribe", permissionMode: "deny-all" }, { createRuntime: harness.create, sessionStore: makeStore(), - agentRegistry: makeRegistry({ codex: "codex-cmd", other: "other-cmd" }), + agentRegistry: makeRegistry({ scribe: "scribe-cmd", other: "other-cmd" }), }, ); return { handle, absent: () => undefined }; @@ -766,7 +766,7 @@ function stubAuthority(): AuthorityLog { function fakeRequest(): AgentLaunchRequest { const request = { instructions: "You are the implementor.", - agent: "codex", + agent: "scribe", cwd: CWD, additionalDirectories: [], permissionMode: "deny-all", @@ -793,7 +793,7 @@ function* installPartitioned( return options.select ? options.select() : owned!.handle; }); const authority = stubAuthority(); - yield* factory({ defaultAgent: "codex", permissionMode: "deny-all" }, authority); + yield* factory({ defaultAgent: "scribe", permissionMode: "deny-all" }, authority); return { authority, selections: () => selections }; } @@ -833,13 +833,13 @@ describe("Tier SM — session placement and materialization", () => { const factory = createAcpxProvider({ createRuntime: harness.create, sessionStore: store, - agentRegistry: makeRegistry({ codex: "codex-cmd" }), + agentRegistry: makeRegistry({ scribe: "scribe-cmd" }), }); - yield* factory({ defaultAgent: "codex", permissionMode: "deny-all" }, stubAuthority()); + yield* factory({ defaultAgent: "scribe", permissionMode: "deny-all" }, stubAuthority()); const session = yield* Agent.operations.session(); - expect(session.sessionKey).toBe(deriveSessionKey("codex-cmd", CWD)); + expect(session.sessionKey).toBe(deriveSessionKey("scribe-cmd", CWD)); expect(session.cwd).toBe(CWD); // Placement, and nothing else: no session was ensured, no handle exists, // no record was written, no turn was started, and nothing is asserted @@ -1091,7 +1091,7 @@ describe("Tier PT — partitioned provider installation", () => { return grown; }; - yield* Agent.operations.agent("codex"); + yield* Agent.operations.agent("scribe"); grew.push(since()); yield* Agent.operations.session("one"); grew.push(since()); @@ -1296,7 +1296,7 @@ const HOST_DIR = "/runs/sessions/host"; /** One logical session's directory, as the host places it. */ const SESSION_DIR = "/runs/sessions/cwd/8f2a"; -const WORKFLOW_SESSION_KEY = "xmd:workflow:v1:run:acpx:codex-cmd:default"; +const WORKFLOW_SESSION_KEY = "xmd:workflow:v1:run:acpx:scribe-cmd:default"; const INSTRUCTIONS = "You have no native tool authority here."; @@ -1344,7 +1344,7 @@ function* installStrictProvider( const factory = createAcpxProvider({ createRuntime: harness.create, sessionStore: store, - agentRegistry: makeRegistry({ codex: "codex-cmd" }), + agentRegistry: makeRegistry({ scribe: "scribe-cmd" }), // deno-lint-ignore require-yield agentCwd: function* () { return HOST_DIR; @@ -1354,7 +1354,7 @@ function* installStrictProvider( permissions: "strict", sessions, }); - yield* factory({ defaultAgent: "codex", permissionMode: "deny-all" }, stubAuthority()); + yield* factory({ defaultAgent: "scribe", permissionMode: "deny-all" }, stubAuthority()); // An authored approve-all scope, installed the way `` installs // one. Under this profile it must reach no decision at all. yield* Agent.around({ @@ -1378,7 +1378,7 @@ function* installStrictProvider( * checkpoints one before running a prompt. */ function seedRoutedRecord(store: ReturnType, sessionKey: string, cwd: string) { - const record = makeRecord("codex-cmd", cwd); + const record = makeRecord("scribe-cmd", cwd); record.acpxRecordId = `record:${sessionKey}`; record.acpSessionId = ACP_SESSION_ID; store.records.set(record.acpxRecordId, record); @@ -1420,7 +1420,7 @@ describe("Tier WAP — strict workflow Agent profile", () => { expect(harness.ensureCalls).toHaveLength(1); expect(harness.ensureCalls[0]).toMatchObject({ sessionKey: WORKFLOW_SESSION_KEY, - agent: "codex", + agent: "scribe", mode: "persistent", cwd: SESSION_DIR, }); @@ -1430,7 +1430,7 @@ describe("Tier WAP — strict workflow Agent profile", () => { // The host placed the session; nothing walked the contextual directory. expect(log.places).toEqual([ - { agentName: "codex", agentCommand: "codex-cmd", session: undefined }, + { agentName: "scribe", agentCommand: "scribe-cmd", session: undefined }, ]); expect(log.established).toEqual([ { @@ -1527,9 +1527,9 @@ describe("Tier WAP — strict workflow Agent profile", () => { const factory = createAcpxProvider({ createRuntime: harness.create, sessionStore: store, - agentRegistry: makeRegistry({ codex: "codex-cmd" }), + agentRegistry: makeRegistry({ scribe: "scribe-cmd" }), }); - yield* factory({ defaultAgent: "codex", permissionMode: "deny-all" }, stubAuthority()); + yield* factory({ defaultAgent: "scribe", permissionMode: "deny-all" }, stubAuthority()); yield* Agent.around({ // deno-lint-ignore require-yield *requestPermission([request]) { @@ -1540,7 +1540,7 @@ describe("Tier WAP — strict workflow Agent profile", () => { : { outcome: "selected", optionId: allow.optionId }; }, }); - const sessionKey = deriveSessionKey("codex-cmd", CWD); + const sessionKey = deriveSessionKey("scribe-cmd", CWD); seedRoutedRecord(store, sessionKey, CWD); const prompt = yield* spawn(() => collectPrompt("ordinary run")); yield* sleep(20); @@ -1646,25 +1646,25 @@ describe("Tier APR — preparing an agent before it is probed", () => { return harness.create(options); }, sessionStore: makeStore(), - agentRegistry: makeRegistry({ codex: "codex-cmd" }), + agentRegistry: makeRegistry({ scribe: "scribe-cmd" }), *prepareAgent(agentName: string) { order.push(`prepare:${agentName}`); }, }); - yield* factory({ defaultAgent: "codex", permissionMode: "deny-all" }, stubAuthority()); + yield* factory({ defaultAgent: "scribe", permissionMode: "deny-all" }, stubAuthority()); // Nothing yet: installing a provider resolves no agent. expect(order).toEqual([]); - yield* Agent.operations.agent("codex"); + yield* Agent.operations.agent("scribe"); // Preparation first. The probe spawns the agent's command, so a host that // has to put that command on disk must have done so by now — otherwise the // probe reports the agent unavailable for a reason that names the wrong // cause, and no amount of later preparation is reached. - expect(order[0]).toBe("prepare:codex"); + expect(order[0]).toBe("prepare:scribe"); expect(order).toContain("probe"); - expect(order.indexOf("prepare:codex")).toBeLessThan(order.indexOf("probe")); + expect(order.indexOf("prepare:scribe")).toBeLessThan(order.indexOf("probe")); }); }); @@ -1679,9 +1679,9 @@ describe("Tier APC — Prompt checkpoint metadata", () => { const factory = createAcpxProvider({ createRuntime: harness.create, sessionStore: makeStore(), - agentRegistry: makeRegistry({ codex: "codex-cmd" }), + agentRegistry: makeRegistry({ scribe: "scribe-cmd" }), }); - yield* factory({ defaultAgent: "codex", permissionMode: "deny-all" }, authority); + yield* factory({ defaultAgent: "scribe", permissionMode: "deny-all" }, authority); const { events } = yield* collectPrompt("hello"); return { checkpoints: authority.checkpoints, events }; } diff --git a/packages/acp/tests/session-route.test.ts b/packages/acp/tests/session-route.test.ts index beeb658ef..488e68e89 100644 --- a/packages/acp/tests/session-route.test.ts +++ b/packages/acp/tests/session-route.test.ts @@ -37,6 +37,7 @@ import type { AgentSessionRouteStore, AgentSessionRouteV1, AgentSessionRouteV2, + AgentSessionRouteV3, } from "../src/session-route.ts"; import type { ExecutableBuildBindingV1 } from "@executablemd/core"; import { cliBase } from "@executablemd/test-support/launch"; @@ -90,6 +91,22 @@ function boundClientNative(overrides: Partial = {}): AgentS }; } +function boundProviderReturned(overrides: Partial = {}): AgentSessionRouteV3 { + return { + schema: "session-route.v3", + route: "acp-first", + provider: "acpx", + agent: "codex-cmd", + sessionKey: KEY.sessionKey, + executableBinding: { + schema: "executable-build.v1", + reportedVersion: "codex-cli 0.153.2", + executableDigest: { algorithm: "sha256", value: "d".repeat(64) }, + }, + ...overrides, + }; +} + function* workspace(): Operation { const root = path.join(os.tmpdir(), `xmd-sr-${randomUUID()}`); yield* ensureDir(root); @@ -111,8 +128,13 @@ function* stores(): Operation<{ name: string; store: AgentSessionRouteStore }[]> } describe("Tier SR — the construction route", () => { - it("SR1: both members round-trip through the strict reader", function* () { - for (const route of [acpFirst(), clientNative(), boundClientNative()]) { + it("SR1: every schema round-trips through the strict reader", function* () { + for (const route of [ + acpFirst(), + clientNative(), + boundClientNative(), + boundProviderReturned(), + ]) { const text = serializeAgentSessionRoute(route); expect(parseAgentSessionRoute(JSON.parse(text))).toEqual(route); } @@ -151,7 +173,7 @@ describe("Tier SR — the construction route", () => { const good = clientNative() as Record; const cases: [string, unknown][] = [ ["not an object", "session-route.v1"], - ["unknown schema", { ...good, schema: "session-route.v3" }], + ["unknown schema", { ...good, schema: "session-route.v4" }], ["unknown route", { ...good, route: "native-first" }], ["extra member", { ...good, origin: "migrated" }], ["missing member", (({ launcher: _l, ...rest }) => rest)(good)], @@ -426,7 +448,7 @@ describe("Tier SV — the bound construction route", () => { expect(text).not.toContain("/"); }); - it("SV2: there is no bound acp-first form", function* () { + it("SV2: V2 never admits the acp-first form", function* () { // A second schema for a shape that gained no fact would be a version number // with nothing behind it. expect(parseAgentSessionRoute({ ...acpFirst(), schema: "session-route.v2" })).toBe(undefined); @@ -531,3 +553,99 @@ describe("Tier SV — the bound construction route", () => { } }); }); + +describe("Tier SPV — the bound provider-returned construction route", () => { + it("SPV1: V3 carries exactly its natural key, construction and original binding", function* () { + const route = boundProviderReturned(); + const serialized = serializeAgentSessionRoute(route); + expect(Object.keys(JSON.parse(serialized)).sort()).toEqual([ + "agent", + "executableBinding", + "provider", + "route", + "schema", + "sessionKey", + ]); + expect(parseAgentSessionRoute(JSON.parse(serialized))).toEqual(route); + const withoutVersion = boundProviderReturned({ + executableBinding: { + schema: "executable-build.v1", + executableDigest: { algorithm: "sha256", value: "a".repeat(64) }, + }, + }); + expect(serializeAgentSessionRoute(withoutVersion)).not.toContain("reportedVersion"); + expect(parseAgentSessionRoute(JSON.parse(serializeAgentSessionRoute(withoutVersion)))).toEqual( + withoutVersion, + ); + }); + + it("SPV2: malformed, foreign and partially read V3 records refuse", function* () { + const good = boundProviderReturned(); + const { executableBinding: _binding, ...unbound } = good; + const cases: [string, unknown][] = [ + ["no binding", unbound], + ["client-native arm", { ...boundClientNative(), schema: "session-route.v3" }], + ["V1 carrying a binding", { ...good, schema: "session-route.v1" }], + ["V2 carrying ACP-first", { ...good, schema: "session-route.v2" }], + ["unknown schema", { ...good, schema: "session-route.v4" }], + ["inline protocol", { ...good, adapterProtocol: "codex-provider-returned.v1" }], + ["provider-native identity", { ...good, nativeSessionId: "provider-id" }], + ["launcher", { ...good, launcher: "codex" }], + ["private path", { ...good, path: "/private/provider" }], + ["empty provider", { ...good, provider: "" }], + ["empty agent", { ...good, agent: "" }], + ["empty key", { ...good, sessionKey: "" }], + ["unknown binding", { ...good, executableBinding: { ...BUILD, schema: "unknown" } }], + ["extra binding data", { ...good, executableBinding: { ...BUILD, environment: {} } }], + [ + "unrecognized version member", + { ...good, executableBinding: { ...BUILD, reportedVersion: 1 } }, + ], + [ + "malformed digest", + { + ...good, + executableBinding: { + ...BUILD, + executableDigest: { algorithm: "sha256", value: "short" }, + }, + }, + ], + ]; + for (const [name, record] of cases) { + expect([name, parseAgentSessionRoute(record)]).toEqual([name, undefined]); + } + }); + + it("SPV3: both stores preserve V3's original binding across a later release", function* () { + const original = boundProviderReturned(); + const key: AgentSessionKey = { + provider: original.provider, + agent: original.agent, + sessionKey: original.sessionKey, + }; + for (const { name, store } of yield* stores()) { + expect([name, yield* store.publish(original)]).toEqual([name, original]); + const later = boundProviderReturned({ + executableBinding: { + schema: "executable-build.v1", + reportedVersion: "codex-cli 99.0.0", + executableDigest: { algorithm: "sha256", value: "f".repeat(64) }, + }, + }); + expect([name, yield* store.publish(later)]).toEqual([name, original]); + expect([name, yield* store.read(key)]).toEqual([name, original]); + expect([name, yield* store.publish(acpFirst(key))]).toEqual([name, original]); + } + }); + + it("SPV4: an existing V1 is adopted unchanged rather than upgraded to V3", function* () { + const candidate = boundProviderReturned(); + const original = acpFirst(candidate); + for (const { name, store } of yield* stores()) { + yield* store.publish(original); + expect([name, yield* store.publish(candidate)]).toEqual([name, original]); + expect([name, yield* store.read(candidate)]).toEqual([name, original]); + } + }); +}); diff --git a/packages/acp/tests/terminal-screen.test.ts b/packages/acp/tests/terminal-screen.test.ts new file mode 100644 index 000000000..d2b2b8194 --- /dev/null +++ b/packages/acp/tests/terminal-screen.test.ts @@ -0,0 +1,706 @@ +/** + * Tier NA — native answer observation + * (specs/native-agent-session-launch-spec.md §Provider-returned adapters). + * + * The Codex native proof has to decide whether the agent answered, and the only + * thing it can read is a pseudo-terminal. A TUI draws by moving the cursor and + * repainting, so the bytes it emitted and the screen it is showing are two + * different things — which means a proof that searches the byte stream can find + * text that has since been erased, or text the program printed before it took + * the screen, while missing an answer the screen is showing right now. + * + * These cases drive fake TUI streams through the reconstruction the proof uses + * and check both halves: that a real answer is read however it was drawn, and + * that everything an answer is not is refused. No Codex process starts and no + * model turn is spent — the streams here are written by hand, so what the + * observer does with each shape is decided rather than sampled. + */ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { + observeAnswer, + screenShows, + terminalChannels, + TerminalReader, + terminalReader, + TerminalScreen, +} from "./fixtures/terminal-screen.ts"; +import type { AnswerQuestion } from "./fixtures/terminal-screen.ts"; + +/** The pty the proof opens, so these streams wrap where that one wraps. */ +const ROWS = 40; +const COLUMNS = 120; + +const ESC = String.fromCharCode(0x1b); +const csi = (body: string): string => `${ESC}[${body}`; +/** Cursor position, one-based as a terminal counts. */ +const at = (row: number, column: number): string => csi(`${row};${column}H`); +/** The TUI takes the screen, and gives the shell's back when it leaves. */ +const ALT_ON = csi("?1049h"); +const ALT_OFF = csi("?1049l"); +const CLEAR = csi("2J"); +/** Hold the drawing back, and put the finished frame on show. */ +const SYNC_ON = csi("?2026h"); +const SYNC_OFF = csi("?2026l"); + +/** Repaint the whole screen by addressing each row, as a TUI does. */ +function paint(rows: readonly string[]): string { + return rows.map((row, index) => `${at(index + 1, 1)}${csi("2K")}${row}`).join(""); +} + +/** A bordered pane row, padded to its right border as a TUI pads one. */ +function pane(text: string, width = 60): string { + return `│ ${text.padEnd(width)} │`; +} + +function border(width = 60, left = "╭", right = "╮"): string { + return `${left}${"─".repeat(width + 2)}${right}`; +} + +/** + * The sentence the native proof asks for. Chosen there because it is the first + * sentence of the instruction layer the launch installed, so it is text only a + * governed turn produces. + */ +const ANSWER = "The Implementor delivers an accepted plan as a focused, verified change."; + +/** A turn shaped like the proof's, which does not contain its own answer. */ +const TYPED = + "Do not use any tools. Reply with exactly two lines. First line: " + + "the first sentence of the body text under the 'Implementor' heading."; + +/** Codex's own furniture, named the same way the proof names it. */ +const CHROME: readonly string[] = ["Ask Codex to do anything", "esc to interrupt"]; + +function screen(): TerminalScreen { + return new TerminalScreen({ rows: ROWS, columns: COLUMNS }); +} + +function asked(over: Partial = {}): AnswerQuestion { + return { expected: ANSWER, typed: TYPED, chrome: CHROME, ...over }; +} + +describe("Tier NA — native answer observation", () => { + it("NA1: text printed before the TUI took the screen is not an answer", function* () { + const terminal = screen(); + terminal.write(`${ANSWER}\r\n`); + + // On the shell's own screen the words are genuinely there, which is why a + // proof reading retained bytes would have accepted them. + expect(observeAnswer(terminal.snapshot(), asked()).found).toBe(true); + + terminal.write(ALT_ON); + + const observed = observeAnswer(terminal.snapshot(), asked()); + expect(terminal.snapshot().alternate).toBe(true); + expect(observed).toEqual({ found: false, reason: "absent" }); + }); + + it("NA2: an answer the terminal wrapped mid-word is read as one sentence", function* () { + const terminal = screen(); + // Started 21 columns from the right margin, so the terminal itself breaks + // the word `delivers` across two rows with no space where it broke. + terminal.write(`${ALT_ON}${at(5, COLUMNS - 20)}${ANSWER}`); + + const rows = terminal.snapshot().rows; + expect(rows[4]?.endsWith("The Implementor deliv")).toBe(true); + expect(rows[5]).toBe("ers an accepted plan as a focused, verified change."); + expect(observeAnswer(terminal.snapshot(), asked())).toEqual({ + found: true, + reason: "answer", + }); + }); + + it("NA3: an answer a repaint moved is read from where it now is", function* () { + const terminal = screen(); + terminal.write(`${ALT_ON}${paint(["", "", ANSWER])}`); + expect(observeAnswer(terminal.snapshot(), asked()).found).toBe(true); + + terminal.write(`${CLEAR}${paint([...Array.from({ length: 19 }, () => ""), ANSWER])}`); + + expect(terminal.snapshot().rows[2]).toBe(""); + expect(terminal.snapshot().rows[19]).toBe(ANSWER); + expect(observeAnswer(terminal.snapshot(), asked()).found).toBe(true); + }); + + it("NA4: a repaint that replaces the answer is refused, though the bytes remain", function* () { + const terminal = screen(); + terminal.write(`${ALT_ON}${paint(["", "", ANSWER])}`); + expect(observeAnswer(terminal.snapshot(), asked()).found).toBe(true); + + terminal.write(paint(["", "", "Working on it."])); + + // The stream still carries every byte of the answer; the screen does not, + // and the screen is what an operator would be looking at. + expect(observeAnswer(terminal.snapshot(), asked())).toEqual({ + found: false, + reason: "absent", + }); + }); + + it("NA5: an answer wrapped inside a bordered pane is read across the border", function* () { + const terminal = screen(); + terminal.write( + `${ALT_ON}${paint([ + border(), + pane("The Implementor delivers an accepted plan as a"), + pane("focused, verified change."), + border(60, "╰", "╯"), + ])}`, + ); + + expect(terminal.snapshot().rows[1]?.startsWith("│ The Implementor")).toBe(true); + expect(observeAnswer(terminal.snapshot(), asked())).toEqual({ + found: true, + reason: "answer", + }); + }); + + it("NA6: cells an erase blanked do not close up into an answer", function* () { + const terminal = screen(); + terminal.write(`${ALT_ON}${at(5, 1)}${ANSWER}`); + expect(observeAnswer(terminal.snapshot(), asked()).found).toBe(true); + + // Twenty cells out of the middle of the sentence, which is what a redraw + // that has not finished repainting leaves behind. + terminal.write(`${at(5, 25)}${csi("20X")}`); + + expect(terminal.snapshot().rows[4]).toBe( + `The Implementor delivers${" ".repeat(21)}a focused, verified change.`, + ); + expect(observeAnswer(terminal.snapshot(), asked())).toEqual({ + found: false, + reason: "absent", + }); + }); + + it("NA7: a gap inside a row is a gap, not a join", function* () { + const terminal = screen(); + // Two columns that would read as the sentence if the blank cells between + // them closed up — which is what erasing part of a row, or laying two panes + // side by side, leaves on the screen. + terminal.write( + `${ALT_ON}${at(5, 1)}The Implementor delivers an accepted plan` + + `${at(5, 60)}as a focused, verified change.`, + ); + + expect(observeAnswer(terminal.snapshot(), asked())).toEqual({ + found: false, + reason: "absent", + }); + }); + + it("NA8: two panes on one row are two pieces of text", function* () { + const terminal = screen(); + // Borders with no padding either side, so nothing but the border itself + // says where one pane's text stops and the next one's starts. + terminal.write( + `${ALT_ON}${at(5, 1)}` + + "│The Implementor delivers an accepted plan│as a focused, verified change.│", + ); + + expect(observeAnswer(terminal.snapshot(), asked())).toEqual({ + found: false, + reason: "absent", + }); + }); + + it("NA9: the harness reading its own turn back is not an answer", function* () { + const terminal = screen(); + const quoting = `Reply with exactly this: ${ANSWER}`; + terminal.write(`${ALT_ON}${paint(["", quoting])}`); + + expect(observeAnswer(terminal.snapshot(), asked({ typed: quoting }))).toEqual({ + found: false, + reason: "only-typed", + }); + + // And the echo does not go on refusing once the agent has actually replied. + terminal.write(paint(["", quoting, "", ANSWER])); + expect(observeAnswer(terminal.snapshot(), asked({ typed: quoting }))).toEqual({ + found: true, + reason: "answer", + }); + }); + + it("NA10: an answer still being drawn is not an answer yet", function* () { + const terminal = screen(); + terminal.write(`${ALT_ON}${at(5, 1)}The Implementor delivers an accepted plan as a foc`); + + expect(observeAnswer(terminal.snapshot(), asked())).toEqual({ + found: false, + reason: "absent", + }); + + terminal.write("used, verified change."); + expect(observeAnswer(terminal.snapshot(), asked()).found).toBe(true); + }); + + it("NA11: text the TUI drew as its own furniture is not an answer", function* () { + const onOneRow = screen(); + onOneRow.write(`${ALT_ON}${at(38, 1)}Ask Codex to do anything · ${ANSWER}`); + expect(observeAnswer(onOneRow.snapshot(), asked())).toEqual({ + found: false, + reason: "absent", + }); + + const across = screen(); + across.write( + `${ALT_ON}${paint([ + "The Implementor delivers an accepted plan as a", + "Ask Codex to do anything", + "focused, verified change.", + ])}`, + ); + expect(observeAnswer(across.snapshot(), asked())).toEqual({ + found: false, + reason: "absent", + }); + }); + + it("NA12: an idle composer is the terminal's state, not the absence of an answer", function* () { + const terminal = screen(); + terminal.write( + `${ALT_ON}${paint([ + "› Do not use any tools.", + "", + ANSWER, + "", + border(), + pane("Ask Codex to do anything"), + border(60, "╰", "╯"), + ])}`, + ); + + const snapshot = terminal.snapshot(); + expect(screenShows(snapshot, "Ask Codex to do anything")).toBe(true); + expect(observeAnswer(snapshot, asked())).toEqual({ found: true, reason: "answer" }); + }); + + it("NA13: an answer that scrolled off the screen is gone with it", function* () { + const terminal = screen(); + terminal.write(`${ALT_ON}${at(ROWS, 1)}${ANSWER}`); + expect(observeAnswer(terminal.snapshot(), asked()).found).toBe(true); + + terminal.write("\r\n".repeat(ROWS)); + + expect(observeAnswer(terminal.snapshot(), asked())).toEqual({ + found: false, + reason: "absent", + }); + }); + + it("NA14: a control sequence split across two reads is still one sequence", function* () { + const terminal = screen(); + for (const chunk of [`${ESC}[?10`, "49h", `${ESC}[5`, `;1H${ANSWER}`]) { + terminal.write(chunk); + } + + const snapshot = terminal.snapshot(); + expect(snapshot.alternate).toBe(true); + expect(snapshot.rows[4]).toBe(ANSWER); + expect(snapshot.rows.some((row) => row.includes("49h") || row.includes(";1H"))).toBe(false); + expect(observeAnswer(snapshot, asked()).found).toBe(true); + }); + + it("NA15: leaving the alternate screen gives back what the shell had", function* () { + const terminal = screen(); + terminal.write("checking codex\r\n"); + terminal.write(`${ALT_ON}${paint(["", "", ANSWER])}`); + expect(observeAnswer(terminal.snapshot(), asked()).found).toBe(true); + + terminal.write(ALT_OFF); + + const snapshot = terminal.snapshot(); + expect(snapshot.alternate).toBe(false); + expect(snapshot.rows[0]).toBe("checking codex"); + expect(observeAnswer(snapshot, asked())).toEqual({ found: false, reason: "absent" }); + }); + + it("NA16: bytes arriving one at a time reconstruct the same screen", function* () { + const whole = screen(); + const stream = `${ALT_ON}${paint([ + border(), + pane(ANSWER.slice(0, 45)), + pane(ANSWER.slice(45).trim()), + ])}`; + whole.write(stream); + + const dribbled = screen(); + for (const char of stream) { + dribbled.write(char); + } + + expect(dribbled.snapshot()).toEqual(whole.snapshot()); + expect(observeAnswer(dribbled.snapshot(), asked()).found).toBe(true); + }); + + it("NA17: a pane border split down the middle of its bytes is still one border", function* () { + // What the proof actually hands over: a pty delivers bytes, and a pane + // border is three of them per glyph. A read that ends inside one would + // otherwise put a replacement character on the screen where the border + // goes, and the run boundaries are drawn from those borders. + const stream = `${ALT_ON}${paint([ + border(), + pane(ANSWER.slice(0, 45)), + pane(ANSWER.slice(45).trim()), + ])}`; + const bytes = new TextEncoder().encode(stream); + + const whole = screen(); + whole.write(bytes); + + const split = screen(); + for (let index = 0; index < bytes.length; index += 1) { + split.write(bytes.subarray(index, index + 1)); + } + + expect(split.snapshot()).toEqual(whole.snapshot()); + expect(split.snapshot().rows.join("")).not.toContain("�"); + expect(observeAnswer(split.snapshot(), asked()).found).toBe(true); + }); + + it("NA18: clearing the scrollback leaves the answer on the screen", function* () { + const drawn = (): TerminalScreen => { + const terminal = screen(); + terminal.write(`${ALT_ON}${paint(["", "", ANSWER])}`); + return terminal; + }; + + // `3J` is the scrollback, which is above the screen rather than on it, and + // an unimplemented parameter is not an instruction to blank anything. Both + // are here because either one falling through to a whole-screen erase would + // make this observer report the answer it is looking at as absent. + const scrollback = drawn(); + scrollback.write(csi("3J")); + expect(scrollback.snapshot().rows[2]).toBe(ANSWER); + expect(observeAnswer(scrollback.snapshot(), asked()).found).toBe(true); + + const unknown = drawn(); + unknown.write(csi("9J")); + expect(observeAnswer(unknown.snapshot(), asked()).found).toBe(true); + + // And the mode that does mean the visible screen still means it. + const visible = drawn(); + visible.write(csi("2J")); + expect(visible.snapshot().rows.join("")).toBe(""); + expect(observeAnswer(visible.snapshot(), asked()).found).toBe(false); + }); + + it("NA19: the watch latches on the read that finishes the answer", function* () { + const watch = new TerminalReader({ rows: ROWS, columns: COLUMNS }, true); + expect(watch.write(`${ALT_ON}${paint([pane("Ask Codex to do anything")])}`)).toBe(false); + expect(watch.ask(asked())).toBe(false); + + // Read by read, as the pty hands them over: the answer is not answered + // until the read that puts the last of it on the screen. + expect(watch.write(paint(["", "", "The Implementor delivers an accepted"]))).toBe(false); + expect(watch.write(paint(["", "", ANSWER]))).toBe(true); + expect(watch.found).toBe(true); + }); + + it("NA20: a redraw over the answer does not un-answer the turn", function* () { + const watch = new TerminalReader({ rows: ROWS, columns: COLUMNS }, true); + watch.write(ALT_ON); + watch.ask(asked()); + expect(watch.write(paint(["", "", ANSWER]))).toBe(true); + + watch.write(`${CLEAR}${paint(["", "", "", "", border(), pane("Ask Codex to do anything")])}`); + + // The screen has moved on, and the screen is right about what it is + // showing — but the question was whether the agent answered, and a TUI + // repainting over its own reply is not the agent taking it back. + expect(observeAnswer(watch.snapshot(), asked()).found).toBe(false); + expect(watch.found).toBe(true); + expect(watch.write("")).toBe(true); + }); + + it("NA21: an occurrence from before the turn does not latch", function* () { + const watch = new TerminalReader({ rows: ROWS, columns: COLUMNS }, true); + // The words are genuinely on the shell's screen here, which is exactly why + // a watch armed from the start of the process would have latched on them. + watch.write(`${ANSWER}\r\n`); + watch.write(`${ALT_ON}${paint([pane("Ask Codex to do anything")])}`); + + expect(watch.ask(asked())).toBe(false); + expect(watch.write(paint([pane("esc to interrupt")]))).toBe(false); + expect(watch.found).toBe(false); + }); + + it("NA22: the turn echoing back in the composer does not latch", function* () { + const quoting = `Reply with exactly this: ${ANSWER}`; + const watch = new TerminalReader({ rows: ROWS, columns: COLUMNS }, true); + watch.write(ALT_ON); + watch.ask(asked({ typed: quoting })); + + expect(watch.write(paint(["", `› ${quoting}`]))).toBe(false); + expect(watch.found).toBe(false); + + expect(watch.write(paint(["", `› ${quoting}`, "", ANSWER]))).toBe(true); + }); + + it("NA23: an answer still being drawn does not latch until it is whole", function* () { + const watch = new TerminalReader({ rows: ROWS, columns: COLUMNS }, true); + watch.write(`${ALT_ON}${at(5, 1)}`); + watch.ask(asked()); + + expect(watch.write("The Implementor delivers an accepted plan as a foc")).toBe(false); + expect(watch.write("used, verified change.")).toBe(true); + }); + + it("NA24: an answer erased within the read that drew it was never on the screen", function* () { + const watch = new TerminalReader({ rows: ROWS, columns: COLUMNS }, true); + watch.write(ALT_ON); + watch.ask(asked()); + + const chunk = `${at(5, 1)}${ANSWER}${at(5, 25)}${csi("20X")}`; + expect(chunk).toContain(ANSWER); + expect(watch.write(chunk)).toBe(false); + + // Every byte of the answer went through the watch, and no read boundary + // ever exposed it — which is the whole difference between reading the + // stream and reading the terminal. + expect(watch.found).toBe(false); + }); + + it("NA25: the TUI's own furniture does not latch", function* () { + const watch = new TerminalReader({ rows: ROWS, columns: COLUMNS }, true); + watch.write(ALT_ON); + watch.ask(asked()); + + for (const line of CHROME) { + expect(watch.write(paint(["", line]))).toBe(false); + } + expect(watch.found).toBe(false); + + expect(watch.write(paint(["", CHROME[0] ?? "", "", ANSWER]))).toBe(true); + }); + + it("NA26: the watch latches an answer whose glyphs were split across reads", function* () { + const watch = new TerminalReader({ rows: ROWS, columns: COLUMNS }, true); + const bytes = new TextEncoder().encode( + `${ALT_ON}${paint([ + border(), + pane(ANSWER.slice(0, 45)), + pane(ANSWER.slice(45).trim()), + border(60, "╰", "╯"), + ])}`, + ); + watch.ask(asked()); + + let latched = false; + for (let index = 0; index < bytes.length; index += 1) { + latched = watch.write(bytes.subarray(index, index + 1)); + } + + expect(latched).toBe(true); + expect(watch.snapshot().rows.join("")).not.toContain("�"); + }); + + it("NA27: a frame that erased the answer before showing it does not latch", function* () { + const watch = new TerminalReader({ rows: ROWS, columns: COLUMNS }, true); + watch.write(ALT_ON); + watch.ask(asked()); + + // What a repaint of a scrolling transcript looks like from inside: the + // sentence is laid down and taken away again while the terminal is holding + // its output back, so nobody watching the pty was ever shown it. + expect(watch.write(`${SYNC_ON}${paint(["", "", ANSWER])}`)).toBe(false); + expect(watch.write(paint(["", "", "Working on it."]))).toBe(false); + expect(watch.write(SYNC_OFF)).toBe(false); + expect(watch.found).toBe(false); + }); + + it("NA28: an answer the committed frame is showing latches at the commit", function* () { + const watch = new TerminalReader({ rows: ROWS, columns: COLUMNS }, true); + watch.write(ALT_ON); + watch.ask(asked()); + + expect(watch.write(`${SYNC_ON}${paint(["", "", ANSWER])}`)).toBe(false); + expect(watch.write(SYNC_OFF)).toBe(true); + expect(watch.found).toBe(true); + }); + + it("NA29: the commit decides, not wherever the read happened to stop", function* () { + // The same bytes cut three ways. A frame is finished by `?2026l` and not by + // an operating system choosing where one `data` event ends, so an observer + // that evaluated per read would answer differently for each of these. + const held = `${SYNC_ON}${paint(["", "", ANSWER])}`; + const replaced = paint(["", "", "Working on it."]); + const cuts: [string, readonly string[], boolean][] = [ + ["one read", [`${held}${replaced}${SYNC_OFF}`], false], + ["cut mid-frame", [held, `${replaced}${SYNC_OFF}`], false], + ["committed, then overdrawn in the next read", [`${held}${SYNC_OFF}`, replaced], true], + // The one that can only be got right where the frame ends: the answer was + // committed and then drawn over, and the read carrying both stops after. + ["committed and overdrawn inside one read", [`${held}${SYNC_OFF}${replaced}`], true], + ]; + + for (const [name, reads, latches] of cuts) { + const watch = new TerminalReader({ rows: ROWS, columns: COLUMNS }, true); + watch.write(ALT_ON); + watch.ask(asked()); + for (const read of reads) { + watch.write(read); + } + expect(`${name}: ${watch.found}`).toBe(`${name}: ${latches}`); + } + }); + + it("NA30: begin and end split across byte reads still bound the frame", function* () { + const watch = new TerminalReader({ rows: ROWS, columns: COLUMNS }, true); + watch.write(ALT_ON); + watch.ask(asked()); + + const bytes = new TextEncoder().encode( + `${SYNC_ON}${paint(["", "", ANSWER])}${paint(["", "", "Working on it."])}${SYNC_OFF}` + + `${SYNC_ON}${paint(["", "", ANSWER])}${SYNC_OFF}`, + ); + // One byte at a time, which splits `?2026h` and `?2026l` down the middle + // repeatedly. The first frame erased its answer and the second kept it. + const answeredAt: number[] = []; + for (let index = 0; index < bytes.length; index += 1) { + if (watch.write(bytes.subarray(index, index + 1))) { + answeredAt.push(index); + } + } + + expect(watch.found).toBe(true); + // Nothing latched until the second frame committed, which is the last byte. + expect(answeredAt[0]).toBe(bytes.length - 1); + }); + + it("NA31: bytes that never went through the terminal cannot answer", function* () { + const seen: string[] = []; + const watch = new TerminalReader({ rows: ROWS, columns: COLUMNS }, true); + const channels = terminalChannels((chunk) => { + seen.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + }, watch); + channels.display(ALT_ON); + watch.ask(asked()); + + // `script` reports its own troubles beside the pty. Those bytes reached + // this process without ever being displayed, so however exactly they read, + // they are not something an operator could have been looking at. + const beside = `${paint(["", "", ANSWER])}\n${ANSWER}\n`; + const before = watch.snapshot(); + channels.diagnostic(beside); + + expect(channels.observing).toBe(true); + expect(watch.found).toBe(false); + expect(watch.snapshot()).toEqual(before); + // Still evidence about the run, which is the other half of the split. + expect(seen.join("")).toContain(ANSWER); + + // And the same bytes on the channel the terminal presented do answer. + expect(channels.display(paint(["", "", ANSWER]))).toBe(true); + }); + + it("NA32: every invocation reads a terminal, only the submitting one watches", function* () { + const size = { rows: ROWS, columns: COLUMNS }; + const submitted = { say: TYPED, expect: ANSWER }; + // The one invocation that submits: a journey's first, saying something and + // expecting an answer to it. + expect(terminalReader(size, { index: 0, ...submitted }).watching).toBe(true); + + // Preflight and both zero-native-turn invocations, which pass an empty plan. + expect(terminalReader(size, { index: 0 }).watching).toBe(false); + expect(terminalReader(size, { index: 1 }).watching).toBe(false); + // Re-entry is not the invocation that submits, so an expectation reaching it + // is a mistake and not a second thing to watch. + expect(terminalReader(size, { index: 1, ...submitted }).watching).toBe(false); + // Neither half is enough on its own: expecting without saying observes a + // turn nobody sent, and saying without expecting has nothing to look for. + expect(terminalReader(size, { index: 0, expect: ANSWER }).watching).toBe(false); + expect(terminalReader(size, { index: 0, say: TYPED }).watching).toBe(false); + expect(terminalReader(size, { index: 0, say: TYPED, expect: "" }).watching).toBe(false); + + const seen: string[] = []; + const reader = terminalReader(size, { index: 0 }); + const channels = terminalChannels( + (chunk) => seen.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)), + reader, + ); + + expect(channels.observing).toBe(false); + expect(channels.display(`${ALT_ON}${paint(["", "", ANSWER])}`)).toBe(false); + channels.diagnostic(ANSWER); + // The stream is still kept whole, because it is this run's evidence. + expect(seen.join("")).toContain(ANSWER); + + // The screen is built all the same, because this invocation still has to + // decide what surface it is looking at. + expect(screenShows(reader.snapshot(), ANSWER)).toBe(true); + // And it cannot be asked for an answer: it submitted no turn, so a reply it + // reported would be attributed to one nobody spent. + expect(() => reader.ask(asked())).toThrow("submits no turn"); + expect(reader.found).toBe(false); + }); + + it("NA33: a surface only a held frame contains is not what the terminal showed", function* () { + const watch = new TerminalReader({ rows: ROWS, columns: COLUMNS }, true); + watch.write(ALT_ON); + watch.ask(asked()); + + // What the operator is looking at when a wait for an answer gives up, and + // what naming the terminal's state means: `still-working`. + watch.write(paint(["", pane("esc to interrupt")])); + const presented = watch.snapshot(); + expect(screenShows(presented, "esc to interrupt")).toBe(true); + + // Now a repaint begins. Inside it the pane is erased and a refusal is laid + // down — cells the terminal is holding, which nobody was shown. A reader of + // those would report `usage-limited` and send the next reader off to check + // a plan that was never at fault. + watch.write(`${SYNC_ON}${CLEAR}${paint(["", pane("You've hit your usage limit.")])}`); + expect(watch.snapshot()).toEqual(presented); + expect(screenShows(watch.snapshot(), "usage limit")).toBe(false); + + // Still the frame before, however many reads the repaint takes. + watch.write(paint(["", "", pane("Working on it.")])); + expect(watch.snapshot()).toEqual(presented); + expect(screenShows(watch.snapshot(), "esc to interrupt")).toBe(true); + + // And it advances at the commit, to exactly what the commit put on show. + watch.write(SYNC_OFF); + expect(screenShows(watch.snapshot(), "Working on it.")).toBe(true); + expect(screenShows(watch.snapshot(), "esc to interrupt")).toBe(false); + expect(screenShows(watch.snapshot(), "usage limit")).toBe(false); + // The answer was never on any of it, held or shown. + expect(watch.found).toBe(false); + }); + + it("NA34: a hold beginning mid-read presents the bytes before it, not after", function* () { + const watch = new TerminalReader({ rows: ROWS, columns: COLUMNS }, true); + watch.write(ALT_ON); + watch.ask(asked()); + + // One read that finishes drawing one surface and then starts withholding + // the next. What was presented is where the hold began, not where the + // operating system stopped the read. + watch.write( + `${paint(["", pane("esc to interrupt")])}` + + `${SYNC_ON}${CLEAR}${paint(["", pane("Ask Codex to do anything")])}`, + ); + expect(screenShows(watch.snapshot(), "esc to interrupt")).toBe(true); + expect(screenShows(watch.snapshot(), "Ask Codex to do anything")).toBe(false); + + // The commit, and the next hold, one byte at a time — which splits both + // sequences down the middle. The composer frame is presented as it commits, + // and the answer drawn after it is held back by the frame that follows. + const bytes = new TextEncoder().encode( + `${SYNC_OFF}${SYNC_ON}${CLEAR}${paint(["", "", ANSWER])}`, + ); + for (let index = 0; index < bytes.length; index += 1) { + watch.write(bytes.subarray(index, index + 1)); + } + + expect(screenShows(watch.snapshot(), "Ask Codex to do anything")).toBe(true); + expect(screenShows(watch.snapshot(), ANSWER)).toBe(false); + expect(watch.found).toBe(false); + + // Committing it shows both what it drew and that it answered. + expect(watch.write(SYNC_OFF)).toBe(true); + expect(screenShows(watch.snapshot(), ANSWER)).toBe(true); + }); +}); diff --git a/packages/acp/vendor/adapters/MANIFEST.json b/packages/acp/vendor/adapters/MANIFEST.json index 70f3589e3..95aaa6af3 100644 --- a/packages/acp/vendor/adapters/MANIFEST.json +++ b/packages/acp/vendor/adapters/MANIFEST.json @@ -8,14 +8,16 @@ "tarball": "codex/agentclientprotocol-codex-acp-1.6.2.tgz", "repository": "https://github.com/agentclientprotocol/codex-acp", "upstreamBase": "50f69e57ca761ccafd2ca29de7fb591068277516", - "patchedCommit": "fadc0a690e96c276629be8a34be980d35e821637", + "patchedCommit": "e1a15637c13e85cca05cd8ba398c5cef92dcaf33", + "patchedBranch": "https://github.com/taras/codex-acp/tree/feat/session-native-identity", "pullRequest": "https://github.com/agentclientprotocol/codex-acp/pull/438", "buildCommand": "npm run build && npm pack", "license": "Apache-2.0", "contract": "_meta.codex.turnId", "materializationContract": "session_info_update _meta[executablemd.session-materialization/v1]", - "byteLength": 208429, - "sha256": "448d007e00c47cf5f4d5167dc817d7621857f298778227cc1e4b0f490469e387", + "identityContract": "_meta.agentSessionId", + "byteLength": 208660, + "sha256": "3ee22bc6b1649d02fcef80b352516f395fe774e63b459193195a41c42930dd8b", "files": [ "package/LICENSE", "package/README.md", diff --git a/packages/acp/vendor/adapters/PROVENANCE.md b/packages/acp/vendor/adapters/PROVENANCE.md index 241167a31..b9342c3cc 100644 --- a/packages/acp/vendor/adapters/PROVENANCE.md +++ b/packages/acp/vendor/adapters/PROVENANCE.md @@ -6,7 +6,7 @@ those instead of the adapter `npx` would resolve. | Provider | Package | Version | Contracts | | --- | --- | --- | --- | -| `codex` | `@agentclientprotocol/codex-acp` | 1.6.2 | `_meta.codex.turnId`, `executablemd.session-materialization/v1` | +| `codex` | `@agentclientprotocol/codex-acp` | 1.6.2 | `_meta.codex.turnId`, `executablemd.session-materialization/v1`, `_meta.agentSessionId` | | `claude` | `@agentclientprotocol/claude-agent-acp` | 0.70.0 | `_meta.claudeCode.assistantMessageUuid`, `executablemd.session-materialization/v1` | `MANIFEST.json` records, for each: the exact upstream base and patched commit, @@ -15,7 +15,7 @@ list. `generated/snapshots.ts` carries the same bytes as a module. ## Why this exists -Two facts XMD needs, neither of which a published adapter reports. +Three facts XMD needs, none of which a published adapter reports. ### Which turn a Prompt completed @@ -60,6 +60,31 @@ own, so a client not waiting for it reads nothing. `contract` above: which turn completed and whether a backend accepted one are different claims, and an adapter could carry either without the other. +### Which conversation the provider actually opened + +Handing a running session to the provider's own CLI needs the identity that CLI +addresses — for Codex, the App Server thread the adapter created or reopened. +Codex allocates it, so a client cannot know it without being told; Claude's +adapter is handed one, so nothing there needs reporting. + +Codex 1.6.2 returns that thread id only as the top-level ACP `sessionId`, which +is the ACP session's own name. A client cannot tell from it whether the provider +allocated the identity or the client did, and reading it as native identity would +mean asserting a thread the App Server may never have confirmed. The snapshot +therefore reports it explicitly on the response `_meta` of `session/new`, +`session/load` and `session/resume`, as `{"agentSessionId": ""}`, +taken from the thread the App Server answered with rather than the one requested. + +That change is not part of PR #438, which carries the turn identity. It is +reachable at +, recorded +as `patchedBranch` beside the commit it names. + +`identityContract` in `MANIFEST.json` records this third claim on its own, for +the same reason `materializationContract` is separate: an adapter could report a +turn identity, an acceptance, or a native session identity without the others, +and only `codex` declares this one. + These snapshots are what stands in until both land upstream. ## What is carried, and what is not diff --git a/packages/acp/vendor/adapters/codex/agentclientprotocol-codex-acp-1.6.2.tgz b/packages/acp/vendor/adapters/codex/agentclientprotocol-codex-acp-1.6.2.tgz index 13554acd28281c26c93596956285e047f7d18b33..dac0139d3d725f4e60ec9a72544ab976db44151f 100644 GIT binary patch delta 193538 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
    8L+fbazY_+HEoU`e{StJ|dhi7Z~>QKycXZR%_Cp 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 H0Im`Mz8>o= delta 193160 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�Oq;svnn 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

    P;M#YBN; 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�Rb~sf=E#I*X944DJaF-lsn6IZs_$v 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