mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
feat(runner): add rich ACP transport and durable interaction foundation (#14430)
Add shared rich ACP transport, durable questions and permissions, verified provider packaging, and bounded activity and plan presentation. Keep Cursor, Copilot, and Pi pending their separate provider qualification. Persist interaction settlement before publication, fence failed writes until fresh recovery, and preserve owned-process cleanup. Incorporate reviewed mainline integration with extended harness coverage. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
c9b93d7e8c
commit
24beb00575
177 files changed
+10463
-625
No files matched your search
@@ -6,6 +6,10 @@ XAI_API_KEY=
|
||||
OPENROUTER_API_KEY=
|
||||
DAYTONA_API_KEY=
|
||||
|
||||
# Explicit-only extended-harnesses candidates.
|
||||
CURSOR_AUTH_TOKEN=
|
||||
COPILOT_GITHUB_TOKEN=
|
||||
|
||||
# Required only for Daytona cells. Use an immutable, anonymously pullable
|
||||
# digest from the Runner Full-Stack E2E image job or a locally published image.
|
||||
PAPERCLIP_E2E_DAYTONA_IMAGE=ghcr.io/paperclipai/paperclip-daytona-runner@sha256:REPLACE_ME
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
# Rich ACP integration and qualification report
|
||||
|
||||
Updated: 2026-09-29. Base: `c65fc9e3c81c41aafe421aa90a00514b84343285`.
|
||||
Mainline integration: `3ca196b0a642aa21b8feba1fcd89edb53d1c622e`.
|
||||
Status: implementation is split into a shared foundation and three provider PRs.
|
||||
Provider review and qualification remain separate from foundation acceptance.
|
||||
All three new profiles remain **pending qualification**. Cursor passed all five
|
||||
local semantic Product E2E cases. Copilot passed completion, file validation,
|
||||
plan approval and controller restart; its question case failed the required final
|
||||
marker. Pi passed completion, file validation and semantic question continuation,
|
||||
plus real native denial, active steering and queued follow-up probes on v2/v3/v4.
|
||||
Pi completion/file/question passes retain their v2/v3 identities. The latest
|
||||
Pi candidate is profile v5 with a patched dependency closure; its paid native
|
||||
control probe passed, but its Product qualification remains blocked. Copilot
|
||||
passed real native denial and detached-command settlement probes. Failed attempts remain in the evidence;
|
||||
no Daytona resources have been started. This report does not certify a provider
|
||||
from its ACP listing or a partial run.
|
||||
|
||||
The [harness priorities report](https://pages.paperclip.ing/2026-09-25-harness-priorities/)
|
||||
recommends Cursor and Copilot, followed by Pi, using the existing qualified ACPX
|
||||
path. Codex app-server is the richness benchmark. The legacy Cursor and Pi
|
||||
adapters are outside this change.
|
||||
|
||||
## Branches and evidence ownership
|
||||
|
||||
| Branch | Deliverable |
|
||||
| --- | --- |
|
||||
| [`codex/runner-rich-acp` / #14430](https://github.com/paperclipai/paperclip/pull/14430) | Shared ACPX extension boundary, durable permissions, canonical display events, provider pack infrastructure, configuration and UI |
|
||||
| [`codex/runner-cursor-acp` / #14435](https://github.com/paperclipai/paperclip/pull/14435) | Cursor native distribution, questions/plans, child activity, policy admission, wire fixtures |
|
||||
| [`codex/runner-copilot-acp` / #14434](https://github.com/paperclipai/paperclip/pull/14434) | Copilot native distribution, event inventory/projections, permission and settlement probes |
|
||||
| [`codex/runner-pi-acp` / #14436](https://github.com/paperclipai/paperclip/pull/14436) | Patched wrapper, owned extension, MCP/tools, permissions/input, portable dependency closure |
|
||||
| [`codex/rich-acp-extended-harness-evals` / evals #29](https://github.com/paperclipai/paperclip-evals/pull/29) | Explicit 21-cell Runner Eval campaign, semantic assertions, provenance and fail-closed budget accounting |
|
||||
|
||||
The provider branches were implemented in parallel from the foundation. Final
|
||||
shared registration and packaging conflicts are resolved in dependency order:
|
||||
foundation → Cursor → Copilot → Pi. They remain four separate worktrees and PR
|
||||
review units; the later PR bases include their prerequisite providers. Foundation
|
||||
acceptance requires Apex review and CI to pass. Provider PRs remain unmerged
|
||||
pending qualification. Source reports on the provider branches are
|
||||
`doc/architecture/runner-cursor-capabilities.md`,
|
||||
`doc/architecture/runner-copilot-capabilities.md`, and
|
||||
`doc/architecture/runner-pi-capabilities.md`. Those reports retain versioned
|
||||
source references, per-field dispositions, fixture paths, and narrower claims.
|
||||
The Copilot inventory enumerates all 150 pinned native event types. Read the
|
||||
[Cursor inventory](https://github.com/paperclipai/paperclip/blob/codex/runner-cursor-acp/doc/architecture/runner-cursor-capabilities.md),
|
||||
[Copilot inventory](https://github.com/paperclipai/paperclip/blob/codex/runner-copilot-acp/doc/architecture/runner-copilot-capabilities.md),
|
||||
and [Pi inventory](https://github.com/paperclipai/paperclip/blob/codex/runner-pi-acp/doc/architecture/runner-pi-capabilities.md)
|
||||
for the complete per-provider source audit.
|
||||
|
||||
[Retained browser evidence](../../ui/storybook/fixtures/evidence/rich-acp-browser-proof.darwin-arm64.json)
|
||||
records the production renderer's full native plan, accept/reject/cancel,
|
||||
single/multiple selection, typed input and activity-details checks. Its Cursor
|
||||
transport is a canonical fixture, not a paid provider session. The browser
|
||||
renders a 99,724-character plan and verifies its final paragraph before approval.
|
||||
The JSON records screenshot hashes; screenshots remain outside the source tree.
|
||||
|
||||
## Capability matrix
|
||||
|
||||
“Candidate” means implemented or observed in deterministic tests. It does not
|
||||
mean the required paid local and Daytona product cases have passed. “Not exposed”
|
||||
means the pinned interface was inspected; “unverified” is a separate finding.
|
||||
Codex's row is the existing app-server integration, not the Codex ACP bridge.
|
||||
|
||||
The provider reports above are the method/event and field inventories. In the
|
||||
table below, a method that is absent from a pinned implementation is distinct
|
||||
from an exposed method with no Paperclip control. Shared surfaces and their
|
||||
deterministic evidence are mapped separately after the comparison.
|
||||
|
||||
| Capability | Codex app-server benchmark | Cursor ACP candidate | Copilot ACP candidate | Pi ACP candidate |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Exact model | Selected and reported model | Explicit ID required; exact echo, paid semantic protocol and five local semantic product cases passed | Explicit ID required; exact `gpt-5.6-luna` echo, paid semantic protocol and local completion/file/plan/restart passed | Exact `openrouter/deepseek/deepseek-v4-flash-0731`; model echo and v2/v3 paid Product completion/file/question cases passed; v5 native controls passed |
|
||||
| Text and tools | Typed thread/turn/item events | Standard ACP updates; child activity kept separate | Standard ACP plus opt-in native session events | Wrapper text/tool updates and owned tool gates |
|
||||
| Active steering | Dedicated `turn/steer` | Concurrent prompt replaces/cancels, so it is not steering | Concurrent prompt replaces/cancels, so it is not steering | Owned `pi/steer` requires handshake, exact active turn and acknowledgment; real active-turn probe passed |
|
||||
| Queued follow-up | Product continuation controls | Controller can schedule a later prompt; native queue not established | Native pending-message activity exists; no qualified ACP queue responder | Owned `pi/follow_up`, separately named and ordered; real queued marker followed the steered current response |
|
||||
| Cancellation | Typed interrupt and process lifecycle | ACP cancel; paid command cleanup pending | ACP cancel; bounded live detached-command settlement and cleanup passed | Native abort; wrapper waits for `agent_settled` and treats provider errors as failure |
|
||||
| Session continuity | Read/load/history/fork and durable identity | Session load/list observed; paid semantic warm continuation passed; native history replay unverified and fork absent in tested methods | Session load plus native history events; semantic pending-question controller recovery passed | Private Pi JSONL mapping/load; native RPC `fork`, `clone`, `get_fork_messages` are not mapped through this ACP wrapper; unresolved UI promises cannot survive provider death |
|
||||
| Questions | Typed input requests and response correlation | `cursor/ask_question`, option identity and multiple selection preserved | Native ask-user capability exists, but pinned ACP does not wire its responder; do not display a false answerable form | `select`, `confirm`, `input`, `editor` through typed form elicitation |
|
||||
| Permissions | Durable typed approvals | Standard ACP permission options; denied shell write had no observed side effects; separately labeled exact-correlation assessment | Standard ACP; real native reject_once prevented marker creation; session decision scope inspected | Native pre-tool gate; allow once, exact-operation session grant, deny; paths rechecked after wait |
|
||||
| Plans | Typed plan and collaboration mode | `cursor/create_plan` includes full plan and revision-bound accept/reject/cancel; todo activity separate | Native plan events displayed; native plan-decision callback not exposed in ACP | No native structured plan event; authenticated Paperclip planning tools available |
|
||||
| Authenticated tools | Runner bridge and governed operations | ACP HTTP MCP binding; paid context/history reads passed | ACP HTTP MCP binding; paid context read passed | Owned extension registers exact bound MCP tools; four authenticated semantic reads succeeded in a paid partial run; no ambient servers |
|
||||
| Delegation | Typed agent roles and lifecycle | Opt-in subagent lifecycle, nested ownership and bounded child activity; never parent transcript flattening | Native delegation/session events projected with role/model/agent provenance | No built-in ACP delegation protocol; arbitrary extensions are excluded |
|
||||
| Files/diffs | Typed file changes and artifact references | Standard tool changes plus validated image references | File/workspace events retained; provider session files are not silently treated as task files | Wrapper-retained read/write/edit diffs; common typed/UI projection remains partial; semantic artifact tools |
|
||||
| Images/artifacts | Typed references and registered work products | Existing contained files only, provenance, `registered:false` | Contained task references only; external/session-store paths become descriptive notices | Image/resource tool blocks preserved by wrapper; dedicated artifact channel absent |
|
||||
| Image/attachment input | Typed input conversion | ACP advertises images, but the runner turn converter currently forwards text only | ACP advertises images/embedded context, but the runner turn converter currently forwards text only | Native Pi/ACP image input exists, but the runner turn converter currently forwards text only |
|
||||
| Usage | Per-request receipt and model context | Pinned ACP omitted receipts on denied and successful turns; account UI confirms included usage separately | ACP/native tokens retained; account UI confirms included credits separately, without a per-run USD receipt | Assistant-message and compaction token receipts; dollar cost is a catalog pricing estimate, never authoritative billing |
|
||||
| Config/model changes | Typed configurable controls | Known modes/model interfaces researched; runtime policy cannot be changed by a display event | Model/reasoning/mode options exist; runtime policy remains authoritative | Exact pinned candidate model; arbitrary slash commands/config/extensions disabled |
|
||||
| Reconnect/restart | Durable controller replay and qualified provider restoration | Semantic pending question survived a real controller restart; exact native callback restoration remains unverified | Semantic pending question survived controller restart with the same interaction and provider session; native callback restoration remains unverified | Wrapper explicitly advertises live-process-only pending-input recovery |
|
||||
|
||||
| Shared capability | User-visible surface | Inspectable implementation / deterministic evidence |
|
||||
| --- | --- | --- |
|
||||
| Text, tools and child activity | Task transcript; bounded activity details preserve provider/session/tool attribution | `src/drivers/acpx/codex-runtime-adapter.test.ts`, `runner/crates/runner-core/tests/acpx_rich_events.rs`, `ui/src/components/task-chat/TaskChatProtocolActivityRow.test.tsx` |
|
||||
| Native questions and permissions | Existing question/confirmation cards; only offered decisions can be submitted | `src/protocol/permission-request.test.ts`, `src/drivers/acpx/acp-permission-adapter.test.ts`, `server/src/services/native-runtime/native-question-bridge.test.ts`, `ui/src/components/task-chat/TaskChatProtocolCard.test.tsx` |
|
||||
| Native plan decisions | Full plan description followed by a revision-bound decision; no implicit plan acceptance | Cursor provider fixtures; `ui/storybook/fixtures/evidence/rich-acp-browser-proof.darwin-arm64.json` records the real renderer with a canonical fixture |
|
||||
| Active steering and queued follow-up | Existing active-turn control where a bound method is negotiated; no native Pi queue selector yet | `src/drivers/acpx/turn-controls.test.ts`; provider reports state which wire method is absent or not surfaced |
|
||||
| Files, diffs and images | Workspace file/artifact cards plus contained provider reference notices; raw provider diffs are still partial | `src/drivers/acpx/profile-extensions.test.ts`; provider field audits; Product `file-edit-validate` uses an independent exact-byte oracle |
|
||||
| Usage and model identity | Exact configured model, per-provider token/accounting fields, explicit incomplete cost coverage | `src/drivers/acpx/usage-accounting.test.ts`, `src/cli/eval-session-contract.test.ts`, `server/src/services/native-runtime/native-session-executor.test.ts` |
|
||||
| Durable input, reconnect and provider death | Pending interaction cards survive controller recovery; unsafe replacement expires unresolved requests; delivered settlement survives a crash before journaling | `src/control-plane/durable-prp-control-plane.test.ts`, `runner/crates/runner-core/tests/acpx_provider_resolutions.rs`, `runner/crates/runner-core/tests/native_provider_backend.rs`, `src/live/runnerd-codex-transport.test.ts` |
|
||||
| Session list/fork, generic configuration and commands | No added operator surface; exact owned session recovery and configured model remain available | Provider inventories identify native-only, ACP-exposed, confirmed-absent and unverified methods with follow-ups |
|
||||
|
||||
Paths starting with `src/` or `runner/` in this evidence table are relative to
|
||||
`packages/paperclip-runner/`; other paths are repository-relative. Deterministic
|
||||
fixtures establish contract behavior, not successful paid provider execution.
|
||||
|
||||
## Shared event and interaction contract
|
||||
|
||||
The active connection, wire session, normalized turn and original request identity
|
||||
bind every extension callback. The allowlist is per provider. An omitted wire
|
||||
session can only acquire the verified active connection's session; an explicit
|
||||
mismatch is rejected. Retired streams and inactive turns cannot emit new activity.
|
||||
|
||||
Requests enter durable runtime state before the UI presents them. The response
|
||||
must match an outstanding request and an offered action or valid typed answer.
|
||||
The direct driver and sidecar await a receipt for the exact JSON-RPC pipe write before the runtime settles its durable record.
|
||||
The successful resolution enters the retained event outbox in the same atomic
|
||||
state save that removes the pending request. A restart before journal delivery
|
||||
retains that resolution for normal event replay and acknowledgment. A restart
|
||||
before this state save instead expires the unresolved request; neither path sends
|
||||
the provider response again.
|
||||
If persistence fails, the current executor stops accepting commands and exposing
|
||||
or acknowledging retained events. Cleanup still terminates its owned provider,
|
||||
but leaves the uncertain snapshot untouched. A fresh executor reads the complete
|
||||
atomic snapshot that survived; it cannot publish an in-memory resolution that
|
||||
conflicts with a later recovery expiry.
|
||||
Standard ACP does not acknowledge application of a permission reply; a lost
|
||||
transport acknowledgment is not proof of exactly-once external effects. A
|
||||
replacement provider process cannot inherit an old approval promise. A bounded durable ledger expires pending requests after provider loss or unsafe restart, including requests whose creation events were already acknowledged. No tool
|
||||
mutation or approval is automatically replayed into a replacement.
|
||||
|
||||
Full plan documents have a bounded 100,000-character description and a 196 KiB
|
||||
question-set envelope. Oversized plans fail rather than approve an unseen suffix.
|
||||
Display redaction remains visible. Decision descriptions render image references as
|
||||
inert text and Mermaid diagrams as source, so reviewing a plan does not fetch
|
||||
provider-selected media. Automatic issue-reference linking is disabled for these
|
||||
descriptions, so a long provider plan cannot start an issue-detail query for every
|
||||
identifier. The rich event channel has exact canonical
|
||||
schemas and a bounded envelope. It cannot create terminal outcomes, dispatch a
|
||||
semantic tool, register an artifact, synchronize a durable plan, or supply source
|
||||
authority. Notices retain useful bounded fields and provenance in expandable UI
|
||||
details. Provider references remain unregistered until a control-plane operation
|
||||
registers them.
|
||||
|
||||
Permission labels are derived from offered option kinds. An unknown or duplicate
|
||||
option is rejected. A provider's “always” decision is not relabeled “this session”
|
||||
unless the pinned implementation proves that scope. Restrictive execution policy
|
||||
is separate from automatic approval and company governance. Fresh permission
|
||||
configuration defaults to full auto; it never overrides read-only task policy.
|
||||
|
||||
## Distribution and isolation
|
||||
|
||||
Cursor pins `2026.09.26-dd393fe`; Copilot pins `1.0.88`; Pi pins wrapper `0.0.33`,
|
||||
runtime `0.84.2`, portable Node and its full npm lock. Native distribution hashes
|
||||
cover macOS ARM64, macOS x64 and Linux x64. Source-owned closure pins remain
|
||||
separate from profile declaration digests. Native admission reads held files,
|
||||
verifies every admitted byte, creates a private immutable snapshot and retains
|
||||
the existing process guardian. A manifest cannot supply its own trusted pin.
|
||||
|
||||
Candidate credentials are only read from explicitly bound run environments:
|
||||
Cursor `CURSOR_API_KEY`/`CURSOR_AUTH_TOKEN`, Copilot `COPILOT_GITHUB_TOKEN`, Pi
|
||||
`OPENROUTER_API_KEY`. Ambient GitHub login variables, provider configuration,
|
||||
extensions and MCP discovery do not establish authority. Homes/config/cache are
|
||||
private. Updates are disabled. Pi launches only its owned extension and assigned
|
||||
skills, with a startup sentinel before a prompt can run.
|
||||
|
||||
Build candidate packs explicitly with `--candidate-providers=<name>` on that
|
||||
provider's branch. Local and remote pack verification includes the complete
|
||||
candidate asset tree. The corresponding Daytona build argument is documented in
|
||||
`docker/daytona-runner/README.md`. Candidate packaging never promotes a profile.
|
||||
|
||||
ACP is not an OS sandbox. Cursor still reads project MCP/hooks beyond its
|
||||
`--disable-project-configs` flag; the adapter rejects known ambient execution
|
||||
configuration. Mutations during a run and remote team hooks still require
|
||||
qualification. Pi's tool policy supplements the execution boundary; arbitrary
|
||||
shell commands and filesystem races require the host boundary. These are
|
||||
qualification gates, not claims that a JavaScript path check confines a shell.
|
||||
|
||||
## Explicit gaps and follow-ups
|
||||
|
||||
| Priority | Exposed but unused, partial, or unverified | Reason and next proof |
|
||||
| --- | --- | --- |
|
||||
| P0 | Remaining paid product cases on local and Daytona | Credentials are explicitly bound. Cursor's five local semantic cases passed. Copilot completion/file/plan/restart cases and Pi completion/file/question passed. Copilot question continuation reached the correct UI and warm session but failed its final marker. Copilot question qualification, remaining Pi cases and all remote cases remain open. The 30-cell Product E2E and private 21-cell Runner Eval extended suites are explicit-only. Daytona billing access still requires user email verification. |
|
||||
| P0 | Pi fresh-profile Product verification blocked by host resources | The next paid plan case failed during embedded PostgreSQL initialization before any provider prompt. A disposable reproduction found 16 free SysV semaphores when Postgres required a set of 17. Resume the unchanged canonical case only after task-owned cleanup or a supported isolated test host restores capacity; do not remove unrelated IPC objects or substitute an unqualified database fixture. |
|
||||
| P0 | Copilot native ask-user and plan-decision callbacks | Pinned ACP does not install native responders. Prove no blocking request is exposed, or add a qualified responder/wrapper; never swallow the request. |
|
||||
| P0 | Broader Copilot denial and background settlement qualification | Two bounded real-service probes passed on pinned 1.0.88: native reject_once prevented a requested file write, and an observed detach:true command completed before end_turn. These establish the selected cases only; retain broader local/Daytona qualification before promotion. |
|
||||
| P0 | Cursor native question availability | A real default-mode prompt with no semantic MCP tools reported that native AskQuestion was unavailable. No native request arrived. The pinned client implements the RPC and mode controls, but inspected local guards do not explain the negotiated tool availability. Inspect the actual catalog/flags before another live probe; do not infer permanent harness absence or native success from semantic question tests. |
|
||||
| P0 | Typed Cursor entitlement failure | Exact models and successful inference are observed on the selected paid accounts. Cursor's first-account entitlement denial became ordinary text and normal completion. Preserve this failure and qualify typed failure handling; never infer a successful task from terminal status alone. |
|
||||
| P0 | Cursor project and remote hooks; native shell boundaries | Configuration flags do not cover every native source. Demonstrate policy cannot be bypassed before production qualification. |
|
||||
| P1 | Native Pi queue selection in the product UI | The runner API exposes negotiated `follow_up` separately from active steering. The current composer has no native queue selector; add one without confusing it with controller-scheduled later turns. |
|
||||
| P1 | Pi `queue_update` contents and delivery state | RPC acknowledgment proves acceptance, not model consumption. Add bounded queued/delivered events and durable message correlation with explicit retention rules for user content; preserve this distinction on reconnect. The selected live marker-order probe proves its own consumed messages only. |
|
||||
| P1 | Pi structured retry and compaction progress | Current text projection drops some `attempt`, `maxAttempts`, `delayMs`, `errorMessage`, `success`, `finalError`, `reason` and `willRetry` fields. Add bounded redacted provider notices with source-event provenance and outcome regressions; keep terminal failure authority separate. |
|
||||
| P1 | Native Cursor/Copilot active steering and queues | ACP prompt replacement is not steering; native SDK capabilities may be richer. Require a dedicated bound method plus acknowledgment before advertising. |
|
||||
| P1 | Child tool media/diff/raw payloads | Bounded delegation summaries preserve lifecycle and identity. Large nested payloads need a child-owned canonical item model; current omission is a visible notice and provider report entry. |
|
||||
| P1 | Standard ACP parent tool `content` diffs/images, secondary locations and raw input bodies | The existing common normalizer projects bounded output text, input-presence, and the first safe relative location. Raw argument bodies can contain secrets; richer content needs bounded typed blocks and a separately validated workspace binding for each file. Provider-specific image/file notices do not close this standard-tool gap. |
|
||||
| P1 | User attachments and image prompting | `AcpxRuntimeTurnInput` and the common runtime adapter currently forward text only, despite underlying image-input support. Implement validated attachment-to-ACP content conversion and model-specific capability admission, then qualify real local/Daytona image prompts. This is an implementation gap as well as a live-verification gap. |
|
||||
| P1 | Copilot session-store files and export/artifact URIs | Provider paths are not task-workspace paths. Add a separately authorized export flow with validated bytes and provenance; do not resolve arbitrary URLs or auto-register. |
|
||||
| P1 | Pi native fork/history/export interfaces | Pinned Pi 0.84.2 native RPC exposes `fork(entryId)`, `clone`, `get_fork_messages` and `export_html`. The wrapper does not map them to runner controls. Add durable branch lineage for fork/clone and an authorized, contained artifact flow for HTML export before exposing them; do not label these native capabilities absent. |
|
||||
| P1 | Complete usage/billing provenance | Missing cache fields remain unknown. Pi price estimates are displayed separately. Budget qualification requires actual spend coverage, not an estimate presented as a bill. |
|
||||
| P1 | Fork/history/model/mode controls not exposed by Paperclip | Research documents the native and ACP methods separately. Add governance-aware controls and durable lineage before enabling them. |
|
||||
| P1 | Exact pending-request restoration after process death | Session transcript restoration does not restore callbacks. Expire unresolved requests unless a provider proves exact restoration. |
|
||||
| P1 | Persistent agent-directory access through ACPX | Mainline `3ca196b0a` supplies `AGENT_HOME`, but its existing ACPX environment allowlist does not forward it. Pi also confines native writes to the task workspace, while the local persistent agent directory lives outside it. The disabled candidates do not claim this new capability. Bind and validate the company/agent/run-owned directory explicitly through launch and native-tool policy, including warm-run rebinding and cleanup-before-collection tests; do not widen ambient environment or filesystem access. |
|
||||
| P2 | Remaining Copilot native diagnostic/config/account events | The provider inventory records every event and field, its projection or reason for omission. Preserve bounded useful context; avoid credentials, raw environment or unbounded blobs. |
|
||||
| P2 | Pi native extension surfaces and unsupported slash commands | Arbitrary extensions/templates/themes may execute ambient code. Only reviewed runner-owned capabilities are admitted; structured native plan/goals are not fabricated. Native fork/clone/export are separate unmapped capabilities above. |
|
||||
| P2 | Pi status/widget/title/editor and session/configuration notifications | `setStatus`, `setWidget`, `setTitle` and `set_editor_text` have no UI projection and are unused by the owned extension. Native session-name and thinking-level events also lack a dedicated projection. Add reviewed bounded notice schemas and governed configuration controls before exposing these fields; `notify` and interactive input already have separate bridges. |
|
||||
| P2 | Pi invocation and history provenance metadata | The v4 wrapper retains `nativeToolCallId`, `modelIteration`, `historyMessageIndex` and `identityScope` in private ACP-wire metadata. Closed common tool and permission projections omit them from the UI. Normalized IDs still correlate live tool, MCP and permission events. Add bounded, redacted display-only provenance fields and parity tests before surfacing the native metadata. |
|
||||
| P2 | Conditional native-plan follow-up fields | Cursor's optional rejection-reason field also appears for accept/cancel. The current question renderer has no conditional fields; add conditional presentation without changing the revision-bound decision receipt. |
|
||||
| P2 | Cursor command exit code projection | The paid file case preserved native `exitCode: 0` inside output text, while the canonical command field remained null. Normalize a typed, correlated exit code without parsing arbitrary prose; current independent file assertions do not prove this field. |
|
||||
|
||||
## Qualification ledger
|
||||
|
||||
Combined ceiling: **$100**, including retries and infrastructure. Cursor allocation:
|
||||
$25; Copilot: $25; Pi: $25; coordinated diagnosis reserve: $25.
|
||||
Initial reservations are $2 per provider. Measured cumulative OpenRouter key-usage
|
||||
delta through Pi's native denial, both file attempts, question, three plan attempts and native controls: **$0.028858372**, including failures.
|
||||
The first model-backed Pi attempt cost **$0.005748807**. Cursor's first account was
|
||||
not entitled; its dashboard was unchanged at the displayed precision, with no
|
||||
per-request receipt. Copilot's first session-start failure left its dashboard at
|
||||
0/1,500 included AI credits and $0 incremental charges. After its protocol and
|
||||
Product question cases, GitHub displayed 3/1,500 included credits with additional
|
||||
usage disabled and a $0 budget. It still displayed 3 immediately after the native
|
||||
denial and detached-command probes, then 5 after the two-turn plan batch. These
|
||||
are delayed aggregate snapshots; exact allocation among calls is unverified.
|
||||
After its denial probe, the selected Cursor account displayed 482.5K included
|
||||
tokens across twelve qualification requests and zero on-demand tokens. The restart
|
||||
rows showed 23.8K and 41.7K included tokens; denial used 24.4K. Displayed credit precision
|
||||
and delayed accounting do not establish
|
||||
a per-run zero receipt. These measurements are
|
||||
partial, not a final all-provider total. No Daytona leases have been started.
|
||||
Registry downloads, fake-model fixtures and metadata-only authenticated discovery
|
||||
are separate from model inference.
|
||||
|
||||
The first Pi canonical `get-task-context` attempt used source `788105248a3ba594b30b5bcec3fa266d8a51d8d4`
|
||||
and provider-pack digest `sha256:4de47c31a8131424495366741bd491ff5fa10723ab9a3918c07a3e42982dbe3c`.
|
||||
It observed successful `get_task_context`, `get_task_history`, `list_documents`
|
||||
and `read_document` calls, then hit its 120-second turn deadline without a
|
||||
terminal receipt. This is a retained failed attempt, not semantic qualification.
|
||||
Earlier launch/interpreter failures are retained separately. The initial Cursor
|
||||
failure exposed a native wrapper gap: a typed entitlement error becomes an
|
||||
ordinary message and normal completion. The runner does not infer authorization
|
||||
from that message or fabricate a zero-cost usage receipt.
|
||||
|
||||
The selected paid Cursor account passed canonical `get-task-context` at source
|
||||
`01959b8a602683f13706807983f02c3cba9d36a0`, pack
|
||||
`sha256:f0b622e9151c1c0886e6220c48ea71993b65887baa88b1600b27462074748ddb`,
|
||||
using exact `gpt-5.6-luna[context=272k,reasoning=medium,fast=false]`.
|
||||
Its context/history reads and all four semantic checks passed in 29.291 seconds.
|
||||
The account usage row attributes 56K tokens to this run, included in its existing
|
||||
Pro+ subscription; incremental cash is zero. ACP supplied no token or USD receipt.
|
||||
A conservative list-price bound of $0.07 is an estimate, not an invoice.
|
||||
|
||||
Copilot's first real completed attempt used source `92fcaf0c`, the same immutable
|
||||
runnerd SHA-256 `986060810ba7377c6ddd64a5d89e322d0a434e1a9c80400e6d4acf5934bfc421`,
|
||||
and exact `gpt-5.6-luna`. One context read and all four canonical semantic checks
|
||||
passed. The original post-run package/provenance failure is retained; offline
|
||||
scoring recovered the same artifact with zero additional provider calls. Its
|
||||
24,258 input, 11,781 cached-input and 441 output tokens yield a $0.00326022 catalog
|
||||
estimate. GitHub still displayed 0/1,500 included credits and additional billing
|
||||
disabled with a $0 budget after the run. UI delay/rounding leaves the exact credit
|
||||
delta unverified; this is not a provider USD receipt. Neither protocol case proves
|
||||
Product E2E, restrictive permissions, restart recovery, or Daytona qualification.
|
||||
|
||||
Separate paid Product E2E evidence now records:
|
||||
|
||||
| Provider / local case | Exact source revision | Observed result |
|
||||
| --- | --- | --- |
|
||||
| Cursor / completion | `edf538e61e712dddb6b4d59045c3dcfd445686c7` | 6/6 assertions; committed finalization, one completion marker, cleanup passed |
|
||||
| Cursor / file edit and validation | `fe132224c2b30a8d9ce7b46cea38b8760af233fc` | 7/7 assertions; independent final file bytes, visible downloadable workspace artifact, cleanup passed |
|
||||
| Copilot / completion | `bcc9c638a25b91b84065f12633f083bd4f7a689f` | 6/6 assertions and cleanup passed; original accounting projection failed independently |
|
||||
| Cursor / question continuation | `a7e01a0cec397dd5048f5d5b5825658dc6e91450` | UI answer Cobalt, continuation and 6/6 terminal assertions passed; two expected provider runs, no retry |
|
||||
| Cursor / semantic plan approval | `a7e01a0cec397dd5048f5d5b5825658dc6e91450` | Displayed plan revision matched the confirmation target; accept/continue and 6/6 terminal assertions passed |
|
||||
| Copilot / file edit and validation | `ee9536001fbe733b2386dd3379730a4e0be59488` | 7/7 assertions and cleanup passed; independent bytes validated; GitHub biller and unpriced receipt verified |
|
||||
| Copilot / question continuation | `ee9536001fbe733b2386dd3379730a4e0be59488` | Question/answer and warm session reuse worked; 4/6 terminal assertions passed because the provider returned a literal placeholder instead of the required marker; failed attempt retained |
|
||||
| Cursor / controller restart | `d35b83074a018537f5475568d7410e3b1d676789` | 6/6 assertions in 57.865 seconds; pending semantic question survived server restart, Cobalt answer continued to Done, cleanup passed; earlier failed attempts retained |
|
||||
| Pi / completion | `dd78df1ef8b279c30c710c9b7a7f9fda22e321d6` | 6/6 assertions and cleanup passed in 32.668 seconds; one authenticated `paperclip_finish`, `turn.completed` and `run.terminal`; exclusive-key delta $0.000654767 |
|
||||
| Copilot / semantic plan approval | `c06fc5fccc88f5816450434493451b9d2d339125` | 6/6 assertions in 45.625 seconds; accepted decision bound to displayed revision 1, exact marker and Done; both paused/completed receipts are GitHub/unpriced; cleanup passed |
|
||||
| Copilot / controller restart | `19ca0f558` (final runtime remains `8aa867b64d5fc2fd62cff110bd000addf5dc54de`) | 6/6 assertions in 50.875 seconds; same pending interaction survived restart and same persisted provider session continued; exact marker once, Done and cleanup passed |
|
||||
| Pi / file edit and validation | Runtime `dd78df1ef8b279c30c710c9b7a7f9fda22e321d6` | Failed at the 120-second active deadline after five rejected `paperclip_finish` calls; cleanup passed. The extension discarded validation details. Final bytes and completion arguments cannot be reconstructed from the retained projection. |
|
||||
| Pi / file edit and validation, v3 | Pack source `7710736ca3924c655c5b0efd172cfd3c0173766a`; execution fix `06cf356a8bc94f709fcd15606fe05e17933fe3b2` | 7/7 assertions in 49.427 seconds, exact file bytes and cleanup passed. One rejected completion exposed the missing registered deliverable; Pi corrected it, registered the artifact and finished. Exclusive-key delta $0.003324737. |
|
||||
| Pi / semantic question, v3 | Pack source `7710736ca3924c655c5b0efd172cfd3c0173766a` | 6/6 assertions in 158.388 seconds across two bounded turns; exact question/option IDs, Cobalt answer, warm continuation, final marker and cleanup passed. First waiting run has no usage receipt; exclusive-key delta covers both runs ($0.001413697). |
|
||||
| Pi / semantic plan, v3 first attempt | Pack source `7710736ca3924c655c5b0efd172cfd3c0173766a` | Failed the native write-boundary UI assertion in 119.553 seconds. The saved two-step plan, matching revision and confirmation controls were visible, but all retained DOM snapshots showed the fallback Plan card. Rust lost the MCP display name/namespace used for placement. Cleanup passed; exclusive-key delta $0.003090504. Matcher remains unchanged. |
|
||||
| Pi / semantic plan, v3 second attempt | Combined pack source `7ab463697037c9456a4ad2b83ea0e9c28b353f8a`; daemon source `e35b11db21b8da8527fa0b6c6f5fe186bdced111` | Displayed Plan placement and revision-bound acceptance passed. The resumed turn exceeded the unchanged 120-second active deadline; total case duration 260.246 seconds, cleanup passed. Pi reused native `call_0` for different tool executions, so the bridge correctly rejected conflicting identities. No terminal usage receipt; exclusive-key delta $0.002440082. |
|
||||
| Pi / semantic plan, v4 first attempt | Frozen combined runtime `f556110d588a9de9fefe676a9a62bf09e98afb85`; fresh pack `sha256:4df7e9fa164929c7ae7d8e8711f0bf7d587d9fa6a246d0a8340f318f57168855` | Infrastructure failure during embedded PostgreSQL initialization in 6.679 seconds; no provider process or prompt. Disposable reproduction confirms host SysV semaphore exhaustion (16 free, 17 required). Cleanup passed, exclusive-key delta $0, and all original graders/deadlines remain unchanged. |
|
||||
|
||||
Pi's immediately preceding `hello05` consumed $0.000351509 and failed evidence
|
||||
packaging after a confirmed 888-second host Maintenance Sleep. It reached
|
||||
authenticated prompt acceptance but retained no semantic-tool or terminal event.
|
||||
An incomplete Playwright archive could not be inspected (`unzip` exit 9), so the
|
||||
existing scanner failed closed with its `secret_leak` classification; no credential
|
||||
match was observed. That canonical failure remains unchanged. The deliberate
|
||||
`hello06` repeat used the same runtime source, a task-owned idle-sleep hold, and
|
||||
both wall-clock and monotonic outer deadlines. Canonical Pi cost remains unpriced;
|
||||
the exclusive-key billing delta is separate evidence, not a fabricated receipt.
|
||||
|
||||
Separate real native probes used final immutable packs without a Product database:
|
||||
|
||||
| Provider / probe | Runtime source | Observed result |
|
||||
| --- | --- | --- |
|
||||
| Cursor / native AskQuestion | `25fb1b5b317e52a8ad50208d7681a1ee34bd939c` | Failed: zero native input events, normal terminal and cleanup; the provider reported that the tool was unavailable. This does not qualify the native question bridge. |
|
||||
| Copilot / denied write | `8aa867b64d5fc2fd62cff110bd000addf5dc54de` | Passed: actual native permission request ID 0, original `reject_once`, forbidden marker absent in all 79 observations through process cleanup. |
|
||||
| Copilot / detached command | `8aa867b64d5fc2fd62cff110bd000addf5dc54de` | Passed: actual `mode:async` and `detach:true`, original `allow_once`; command completion at 10.083 seconds and marker at 10.098 preceded `end_turn` at 10.675; five-second late-effect check and process cleanup passed. |
|
||||
| Pi / denied write | `dd78df1ef8b279c30c710c9b7a7f9fda22e321d6` | Passed in 20.438 seconds: actual native permission ID 0 persisted before original reject_once reply, pipe delivery acknowledged, correlated tool failed, prompt settled end_turn, forbidden file absent and cleanup passed. |
|
||||
| Cursor / denied shell write | `25fb1b5b317e52a8ad50208d7681a1ee34bd939c` | Original grader failed because permission omitted rawInput. Separate offline assessment passed from the preceding exact-command tool_call bound to the same toolCallId, original reject-once reply and 98 independent absent-file observations through terminal and cleanup; no repeat prompt. |
|
||||
| Pi / active steering and queued follow-up, v3 | Pack source `7710736ca3924c655c5b0efd172cfd3c0173766a` | Passed in 21.342 seconds. Both active-session operations acknowledged while a persisted native write permission waited; original reject_once denied the tool, visible STEERED_CURRENT preceded QUEUED_NEXT, forbidden file stayed absent, end_turn settled, stale steer was rejected and cleanup passed. |
|
||||
| Pi / active steering and queued follow-up, v4 | Frozen combined runtime `f556110d588a9de9fefe676a9a62bf09e98afb85`; verified pack `sha256:4df7e9fa164929c7ae7d8e8711f0bf7d587d9fa6a246d0a8340f318f57168855` | Passed in 20.255 seconds: normalized invocation identity matched the denied tool update; both controls acknowledged before the original denial; exact STEERED_CURRENT then QUEUED_NEXT, stale steering rejected, end_turn and cleanup passed, forbidden file absent. Settled exclusive-key delta $0.000546502; separate Pi estimate $0.000803936. |
|
||||
| Pi / active steering and queued follow-up, v5 | Runtime `aec26ad83f1d082f0d0a5eaffbd615a9e2e26155`; pack `sha256:cf7d0998bbc2bed7b893c726c2b6455ba8a683d9cff7d92afedbff2d5900d500` | Passed in 19.199 seconds: one real normalized write invocation, original reject_once persisted before reply, both controls acknowledged before denial, exact STEERED_CURRENT then QUEUED_NEXT, stale steering rejected, end_turn, forbidden file absent and cleanup passed. Settled exclusive-key delta $0.000151739; its separate Pi estimate is $0.000611156. |
|
||||
|
||||
These narrow native probes do not replace durable Product interaction, restart,
|
||||
or Daytona coverage. Their private wire evidence remains separate from sanitized
|
||||
public summaries. A private durable retention manifest also records hashes for the
|
||||
logs, screenshots, wire evidence and failed attempts; no credentials are published. The semantic Cursor question and plan cases above exercise
|
||||
Paperclip tools, not the native `cursor/ask_question` or `cursor/create_plan` RPCs.
|
||||
The failed Pi file case exposed two wrapper losses. Profile version 3 at
|
||||
`06cf356a8bc94f709fcd15606fe05e17933fe3b2` preserves bounded, redacted MCP validation
|
||||
errors and bounded Bash arguments/output; oversized values have an explicit
|
||||
omission marker. Version 1 and 2 warm snapshots are rejected. Installed-wrapper
|
||||
regressions pass. A fresh v3 pack then passed the paid file case: the model received
|
||||
the actual missing-deliverable receipt error, registered its deliverable and
|
||||
completed. The original v2 failure remains retained.
|
||||
Canonical command exit codes and typed generic diff/media projection remain partial.
|
||||
|
||||
The Pi denial/file batch cost $0.010995043 by exclusive-key delta. Delayed billing
|
||||
prevents exact per-call attribution, so the aggregate remains separate from Pi's
|
||||
catalog estimates and its missing terminal receipt on the file case.
|
||||
|
||||
The Cursor file case proves the workspace artifact surface, not complete native
|
||||
file/diff projection. The Copilot result incorrectly projected missing native cost
|
||||
as USD zero and attributed its biller to OpenAI. The original result is retained;
|
||||
the shared fix identifies GitHub, Cursor and OpenRouter correctly and keeps absent
|
||||
candidate USD receipts unpriced. The separate Copilot file case verifies that fix.
|
||||
The question case exposed a second ledger edge: zero normalized token counters
|
||||
were treated as a reported cost despite no cost field. Ledger classification now
|
||||
requires an explicit finite nonnegative cost; an explicit zero remains reported.
|
||||
Protocol evals now fail their cost gate when spend is unknown, preserving completed
|
||||
behavior and semantic evidence in a separate accounting-failure result. The
|
||||
maintained campaign stops subsequent cells on unknown accounting and never turns
|
||||
an unavailable receipt into a zero-dollar measurement.
|
||||
|
||||
Initial Product attempts exposed local PostgreSQL postinstall hydration and a
|
||||
server candidate-admission gap before any model prompt. Both failed attempts are
|
||||
retained. The package's own hydration repairs local installation; exact host
|
||||
qualification now applies consistently at agent creation, runtime selection,
|
||||
native input and process construction. Agent configuration cannot grant itself
|
||||
qualification authority. The obsolete unconditional Pi executor rejection is
|
||||
replaced by the same closed host authorization. Candidate active turns are bounded
|
||||
to 120 seconds and automatic infrastructure retries remain disabled.
|
||||
|
||||
Pi's next local startup attempt timed out before any prompt, with zero exclusive
|
||||
key usage delta. A credential-free reproduction isolated a 37.423-second immutable
|
||||
copy of its 13,827-file, 234,019,683-byte distribution, after 5.168 seconds of
|
||||
verification, against the 30-second session-open deadline. Bounded parallel copying
|
||||
reduced that same copy to 5.851 seconds without changing a timeout. Ten tests cover
|
||||
the eight-file / 32 MiB batch bounds, unchanged per-file integrity checks, stable
|
||||
digest order, mutation rejection, and draining pending copies before cleanup.
|
||||
The later `hello06` paid Product case above proves successful inference and
|
||||
settlement after this startup fix. Its final screenshot shows one readable answer,
|
||||
a visible Done status and a usable composer, with no duplicate response or error.
|
||||
|
||||
The maintained Product E2E `extended-harnesses` suite covers local and Daytona
|
||||
completion, question/answer, semantic plan approval, pending-input restart and
|
||||
file edit/validation. It has no automatic retries and does not enable candidates
|
||||
outside exact operator-authorized provider/model pairs. The private Runner Eval
|
||||
campaign is complementary: seven semantic protocol cases per provider. Neither
|
||||
suite's membership is a qualification claim.
|
||||
|
||||
Before each paid batch, record source SHA, executable and closure/profile digests,
|
||||
exact model, OS/architecture or Daytona image, selected cases, prior spend,
|
||||
maximum batch spend and authoritative billing coverage. Stop before the shared
|
||||
ceiling. Missing spend coverage blocks a run rather than treating unknown cost as
|
||||
zero. Retain screenshots and wire evidence without credentials. Never convert a
|
||||
provider to supported solely because a test suite or packaging check passed.
|
||||
|
||||
Verification commands and final results are recorded with the prerequisite and
|
||||
provider PRs. The full handoff requires runner checks, token gates, recursive
|
||||
typecheck, `pnpm test:run`, and `pnpm build`. Until that evidence is recorded, this
|
||||
report is an implementation report rather than a PR-ready certification.
|
||||
|
||||
At foundation `5aeebb20c`, recursive typecheck, build, token gates, full Rust runner
|
||||
checks, conformance/replay parity and API-authority checks passed. Approval
|
||||
verification includes 18 real database integration tests, 15 projector cases,
|
||||
87 transcript/UI cases, 82 route/websocket cases and eight provider receipt cases.
|
||||
The local full root test attempt initially failed because embedded Postgres's
|
||||
install-time library links were missing. Its official package postinstall restored
|
||||
them; all 38 affected suites (743 tests) then passed. The workspace streaming stress test initially exceeded macOS path limits
|
||||
(`ENAMETOOLONG`); the later portable fixture correction is recorded below. The full runner TypeScript repeat passed 2,164 tests in 156 files, with ten
|
||||
skipped tests. The full UI and CLI suites passed 6,772 and 502 tests. Remaining
|
||||
source workspace checks passed 2,882 tests; two macOS path-alias fixture failures
|
||||
were corrected with an explicit injection assertion (all 89 sandbox tests pass),
|
||||
and a database timeout passed in an isolated repeat. Failed attempts and the
|
||||
latest CI state remain recorded in the PR. The decision-media review fix passes
|
||||
87 focused tests, UI typecheck, token gates and the UI build.
|
||||
|
||||
At execution source `f063fbf2b`, recursive typecheck, full build, token gates,
|
||||
13 accounting tests, 10 immutable-distribution tests and seven image-contract tests
|
||||
pass. Both full UI/CLI shard partitions pass (6,848 UI and 502 CLI tests).
|
||||
The remaining local test partitions run sequentially after paid Product cells to
|
||||
avoid the machine's observed PostgreSQL semaphore exhaustion. Earlier failures,
|
||||
including the unchanged macOS path-length stress case, remain retained. Foundation
|
||||
CI at this source passed typecheck, build, Rust, both runner test lanes, all twelve
|
||||
general server shards and all eight browser shards. Six other jobs received a
|
||||
coordinated runner-shutdown signal; their cancellation is not a passing result.
|
||||
Greptile reviewed this source at 5/5. Later documentation/build-pin updates still
|
||||
require their own final check status.
|
||||
|
||||
Initial macOS ARM64 candidate packs were independently built and launched through
|
||||
the generic installation registry: Cursor source `1055c13f8`, Copilot `5d8829add`,
|
||||
Pi `f58cfa1cb`. Review fixes that change execution bytes require fresh packs and
|
||||
launch proofs; the latest source SHA, manifest/profile/closure digests and
|
||||
sanitized wire evidence are retained in each provider PR. Earlier proofs retain
|
||||
their original source identity. These probes send no model prompt.
|
||||
|
||||
Final native Linux packaging probes also run with no network, no credentials and
|
||||
no model prompt. Cursor source `25fb1b5b317e52a8ad50208d7681a1ee34bd939c` initialized
|
||||
from image `sha256:5fa7951d1d6dd99305555fe00a5baf2bf5b834d16f021053737301975b93986f`;
|
||||
its native EOF does not settle within five seconds, so explicit process-group
|
||||
cleanup is required and verified. Copilot source
|
||||
`8aa867b64d5fc2fd62cff110bd000addf5dc54de` initialized as version 1.0.88 from image
|
||||
`sha256:5457769683fd310223d3b0d4f1ed9a6cf341bdb16514746b3aaeabca2e888fee` and exited
|
||||
zero on EOF. Its metadata-only fixture received zero requests. The provider PRs
|
||||
retain complete pack/executable digests and sanitized initialization responses.
|
||||
Pi v3 source `06cf356a8bc94f709fcd15606fe05e17933fe3b2` also initializes and
|
||||
exits zero from image `sha256:c5fa7976bba92a186a2f70dc8b8ddc58ab86606b80a819c89bf550d2d057c832`,
|
||||
with profile digest `sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472`.
|
||||
The earlier Pi v2 proof remains historical.
|
||||
These are packaging proofs, not paid Daytona or model-availability evidence.
|
||||
|
||||
At `e8dee462e2fd09cf858be0a02368805346ec4f3b`, versioned profile contract tests
|
||||
pass 36 cases and full runner TypeScript checking passes. Nine additional source
|
||||
workspace suites pass 2,757 tests with 19 skips. An isolated repeat of the unchanged
|
||||
HTTP redaction suite passes all 59 tests after its earlier concurrent-run timeout.
|
||||
The feedback-route mock fix retains both company-boundary assertions and passes
|
||||
two independent fresh-environment repeats (4/4 each). Current source and any
|
||||
remaining partition failures are recorded in the final prerequisite PR evidence.
|
||||
|
||||
The portable real-Git stress fixture passes on macOS in 234.43 seconds, retaining
|
||||
40,000 files, four independently checked 34,988,890-byte filename lanes, real
|
||||
staging/deletion and cleanup. Its timeout is 300 seconds on macOS and remains
|
||||
180 seconds elsewhere. Both earlier failures are retained. All seven remaining
|
||||
serialized root partitions pass; database-project and previously skipped-file
|
||||
remediation later passed all ten partitions, including 1,042 chat integration tests.
|
||||
|
||||
The combined provider stack passes 616 ACP/profile/backend tests with eight
|
||||
skips, 34 packaging and actual-wrapper tests, and a TypeScript build. The
|
||||
Copilot registry regression now verifies dispatch to separate native factories;
|
||||
the pack test no longer assumes another implemented provider is absent.
|
||||
The complete database project passes 161 tests in 44 files after serialization;
|
||||
all ten remediation partitions completed without failure, including the full
|
||||
1,042-test chat integration lane.
|
||||
|
||||
The first Pi plan attempt exposed a shared Rust display-mapping gap: prefixed MCP
|
||||
tool names were retained as builtin names, preventing the saved Plan from anchoring
|
||||
to its write event. All four retained browser snapshots showed the fallback Plan
|
||||
card inside a settled turn, so the existing Product matcher remains unchanged.
|
||||
Foundation `5aa02662b34fc70f5fe9ffd5ac8f7ae81b5fe3bd` parses both ACP MCP name forms,
|
||||
preserves bounded/redacted namespace and name, and leaves semantic authorization,
|
||||
operation and read-only classification unchanged. The original Rust regression
|
||||
fails before the fix; 55 Rust, 28 TypeScript provider and 158 UI tests pass afterward.
|
||||
The UI regression distinguishes the proper write-boundary card from the original
|
||||
fallback. A rebuilt daemon is required for the retained paid retry.
|
||||
|
||||
The rebuilt daemon passed the Plan placement and revision-acceptance boundary in
|
||||
the second paid attempt. Its continuation exposed a separate Pi identity defect:
|
||||
the owned extension reused native `call_0` as the MCP request ID for different
|
||||
executions. A rejected finish was followed by a corrected finish and context read,
|
||||
both correctly rejected by shared duplicate-call protection. Further paid cases
|
||||
stopped. Pi v4 at `f556110d588a9de9fefe676a9a62bf09e98afb85` binds live identities
|
||||
to a private launch namespace and a lifetime model-iteration ordinal. Exact
|
||||
request retries retain their identity; conflicting or duplicate native invocations
|
||||
within one iteration fail closed. History uses a separate display-only identity.
|
||||
Bridge idempotence and durable call tombstones stay intact. Raw native IDs remain
|
||||
private-wire provenance, with the UI omission recorded above.
|
||||
|
||||
The corrective-call regression failed before the fix and passes against the real
|
||||
authenticated loopback tool bridge afterward. The pinned native SDK test covers
|
||||
six iterations across two warm prompts and four executions that reuse `call_0`;
|
||||
its native turn index resets, while the owned ordinal remains monotonic. This
|
||||
check uses an in-memory model stream and no inference. Independent review and
|
||||
profile-digest verification pass. At that v4 checkpoint, the profile digest was:
|
||||
`sha256:2324d9b47650c12b16f8e2c44dc33637d52f1b22ba8e914623eac4049e7e1991`.
|
||||
Earlier v1-v3 snapshots could not establish that runtime identity. The extended
|
||||
Runner Eval configuration then pinned v4 and rejected those historical identities;
|
||||
all 132 eval tests and seven-cell Pi campaign validation passed without provider
|
||||
calls at that checkpoint. The later v5 identity is recorded below.
|
||||
|
||||
Recursive typecheck and the full build both pass at shared repair source
|
||||
`5aa02662b34fc70f5fe9ffd5ac8f7ae81b5fe3bd` and again at version-admission source
|
||||
`1545c7692`. The latter adds closed profile-version-4 admission, with 22 contract
|
||||
tests and full runner TypeScript checks passing. Exact profile digest matching
|
||||
remains mandatory. Recursive typecheck and the full repository build also pass
|
||||
on the frozen combined provider source `f556110d588a9de9fefe676a9a62bf09e98afb85`,
|
||||
including Pi v4. The combined macOS ARM64 provider pack
|
||||
at `7ab463697037c9456a4ad2b83ea0e9c28b353f8a` independently verifies all three
|
||||
complete candidate trees (446 Cursor files, one Copilot executable and 13,827 Pi
|
||||
files), manifest digest
|
||||
`sha256:0800f10114e3d8e2e981ea27f0ab47f1da9349dbcc22bf55a76ebbc4af00601d`.
|
||||
All three initialize through the verified registry without credentials or model
|
||||
prompts. The combined Linux image at source
|
||||
`e35b11db21b8da8527fa0b6c6f5fe186bdced111` also passes all three initialization
|
||||
probes under network-none and a read-only root. Image digest:
|
||||
`sha256:17c228d3b9744d6bf375ead57918c755d0cc44fc3db44d238cc898317cea25ce`;
|
||||
pack digest: `sha256:4e0ddb39a34e7f55d7520a8a2d9074009589f3f95f4c10bb837e54ec58d70ba1`.
|
||||
These combined proofs contain Pi v3 and retain that historical identity when the
|
||||
invocation-identity repair produces a new profile. They are not paid Daytona proof.
|
||||
All candidates remain pending; verification and packaging do not grant support.
|
||||
|
||||
The fresh v4 macOS ARM64 pack at the frozen combined source has digest
|
||||
`sha256:4df7e9fa164929c7ae7d8e8711f0bf7d587d9fa6a246d0a8340f318f57168855`.
|
||||
All 13,827 Pi runtime files independently match the pinned closure. Native
|
||||
initialization under denied networking exits cleanly; the complete Runner path
|
||||
returns the expected typed missing-key rejection without inference. The rebuilt
|
||||
daemon has SHA-256 `373848d2d7287b2c47b2cee422cb7bd25073a594a620a9ad574f2d3d51cd1670`.
|
||||
The next canonical paid plan attempt stopped in PostgreSQL initialization before
|
||||
provider startup. Its unchanged billing and exact disposable initdb failure are
|
||||
retained. The diagnostic created no server, removed its temporary directory, and
|
||||
left IPC totals unchanged. Unrelated database processes and IPC objects were not
|
||||
modified. Canonical Product E2E does not currently admit an external database
|
||||
substitute; introducing one requires its own ownership, isolation and cleanup
|
||||
proof rather than bypassing the existing guard.
|
||||
|
||||
The v4 native probe above does not need a database and passed independently of
|
||||
the host PostgreSQL blocker. A read-only IPC attribution audit found no semaphore
|
||||
set with sufficient task provenance for safe cleanup; no unrelated set was
|
||||
removed. The paid Product and Daytona qualification gaps remain open.
|
||||
|
||||
Dependency review subsequently identified Pi's pinned `undici@8.9.0` in
|
||||
[GHSA-3wwx-pv8p-q78v](https://github.com/advisories/GHSA-3wwx-pv8p-q78v),
|
||||
indexed in GitHub's advisory database on September 28. Pi's owned Node 24.19.0
|
||||
also embeds affected Undici 7.29.0. The repair requires both the exact npm override
|
||||
and the provider-local patched Node distribution; it cannot be represented by a
|
||||
waived check or a claim that updating npm replaces Node's builtin implementation.
|
||||
Shared contract source `e6163e16a` admits closed profile version 5, with 23 contract
|
||||
cases and full runner TypeScript checking passing; unknown versions remain
|
||||
rejected and exact command digests remain mandatory.
|
||||
|
||||
The combined v4 Linux image at frozen source `f556110d588a9de9fefe676a9a62bf09e98afb85`
|
||||
was built and all three providers passed credential-free initialization under
|
||||
network-none with a read-only root. Image digest:
|
||||
`sha256:4c4e2fb7eb8ef3681b14c3e21dd28eace5ef920d6d08d572fb9c9c7e9a5f290a`;
|
||||
pack digest: `sha256:8f263ed5c309b47cdc74a37d8217bb49e57129fca393498f54f6dd461657a9b9`.
|
||||
The daemon SHA-256 is `5914ed1ad0235aaee9af8731af2cbf28d7b16b0fda231a13963f1d86c3220daa`.
|
||||
Cursor still needs explicit cleanup after EOF; Copilot and Pi exit zero. No model
|
||||
prompt or credential was supplied. This remains a historical packaging proof
|
||||
when the dependency repair changes Pi's profile and executable closure.
|
||||
|
||||
Recursive `pnpm -r typecheck` and `pnpm build` also pass at shared version-5
|
||||
admission source `e6163e16a`; no database or model request is used by those checks.
|
||||
|
||||
Pi v5 repair source `aec26ad83f1d082f0d0a5eaffbd615a9e2e26155` pins private
|
||||
Node 24.21.0 (bundled Undici 7.29.1) and nested npm Undici 8.10.2. Because npm
|
||||
retains the vulnerable shrinkwrapped package despite overrides, materialization
|
||||
verifies the exact published old tuple, replaces only that package from a bounded,
|
||||
integrity-checked fixed archive, and then verifies the complete trusted closure.
|
||||
The upstream Pi package and shrinkwrap bytes remain unchanged; the exception is
|
||||
explicit and closed. Profile digest:
|
||||
`sha256:020d96ccbd3c45c3f62680814776394ed5a56d9572a1a4dccda56a74d16c7803`.
|
||||
Old versions 1–4 are rejected. Forty-three focused TypeScript tests, 19 package
|
||||
checks, eight materializer checks and 13 Rust tests pass. Independent review verified all 13,827 installed
|
||||
files, both patched dependency copies, eight materializer and twelve profile/
|
||||
recovery cases. Recursive typecheck and the full build pass at the same source.
|
||||
|
||||
Shared Docker pin source `3a5637732` uses the official Linux x64
|
||||
`node:24.21.0-bookworm@sha256:5a750d3be5e5c80275f8c9a5367c3aed99c2875656590c8d0701c7ee687f5f0a`
|
||||
for the outer provider pack. A network-disabled launch independently reports
|
||||
Node 24.21.0 and Undici 7.29.1, matching the
|
||||
[official Node release](https://nodejs.org/en/blog/release/v24.21.0). All seven
|
||||
image-contract tests pass. The first local test invocation failed because the
|
||||
root has no `tsx` executable; the retained retry used the maintained Vitest
|
||||
configuration. No provider call was made by either command. Pack authority
|
||||
includes the outer Node bytes and is bound into the persisted launch profile;
|
||||
changes invalidate incompatible warm recovery independently of Pi's profile bump.
|
||||
|
||||
The fresh v5 macOS ARM64 combined pack has manifest digest
|
||||
`sha256:cf7d0998bbc2bed7b893c726c2b6455ba8a683d9cff7d92afedbff2d5900d500`
|
||||
and source `aec26ad83f1d082f0d0a5eaffbd615a9e2e26155`. Independent initialization
|
||||
through the verified registry passes for Cursor, Copilot and Pi with no provider
|
||||
credentials or prompts. The outer interpreter reports Node 24.21.0 and Undici
|
||||
7.29.1. Cursor still requires explicit process-group cleanup after EOF; Copilot
|
||||
and Pi exit zero. All owned processes and private supervisor directories are
|
||||
cleaned. Cursor/Copilot profile and closure identities match their prior pack.
|
||||
The proof index has SHA-256
|
||||
`a07394e3215eb0a153c4a9443762a5d84ddfe0e70b92e1d47ae8d574239e104c`.
|
||||
|
||||
The private Runner Eval campaign now pins v5 at eval source
|
||||
`18f06b9391838d637c1588f372c7f1397ec6f9a7`. All 133 Python tests and the seven-cell
|
||||
Pi validation pass without provider calls. Explicit tests reject each historical
|
||||
v1–v4 version, digest and paired identity. Cases, model, scoring, budgets and
|
||||
deadlines remain unchanged.
|
||||
|
||||
Current Linux image identities and exact-head CI/review status are recorded in
|
||||
the [Pi PR verification](https://github.com/paperclipai/paperclip/pull/14436) and
|
||||
[foundation PR verification](https://github.com/paperclipai/paperclip/pull/14430).
|
||||
Their immutable source and dependency identities remain distinct from the
|
||||
historical packaging proofs above. Image initialization does not certify paid
|
||||
Daytona execution. The unresolved local database and remote account-verification
|
||||
blockers prevent support promotion.
|
||||
@@ -284,3 +284,9 @@ downloaded proof on local native/legacy Codex and native Daytona. See the
|
||||
The explicit-only Product E2E `api-response-reading` suite verifies retrieval of
|
||||
large saved API responses on local and Daytona native Codex runs. See the
|
||||
[Runner E2E guide](../tests/runner-e2e/README.md#bounded-api-response-reading).
|
||||
|
||||
The explicit-only Product E2E `extended-harnesses` suite covers pending Cursor,
|
||||
Copilot and Pi ACP profiles on local and Daytona. See the
|
||||
[fixture admission, credentials and budget contract](../tests/runner-e2e/README.md#extended-acp-harnesses-explicit-only).
|
||||
The private Runner Evals campaign of the same name provides complementary
|
||||
semantic protocol cases; catalog membership is not live qualification.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Cursor, Copilot and Pi through rich ACP
|
||||
|
||||
Approved implementation plan, 2026-09-28. Shared base:
|
||||
`c65fc9e3c81c41aafe421aa90a00514b84343285`.
|
||||
|
||||
Keep the existing ACPX runner transport and Codex app-server richness benchmark.
|
||||
Implement a foundation prerequisite, then three separate provider branches and
|
||||
PRs. Do not merge automatically. Preserve legacy Cursor/Pi adapters.
|
||||
|
||||
1. Investigate each pinned harness, its native API, ACP adapter, observed wire,
|
||||
runner normalization and UI surface. Inventory every meaningful event field.
|
||||
2. Extend shared profile declarations, durable permissions/input, extension
|
||||
dispatch, steering/queue distinctions, artifact provenance and bounded display
|
||||
activity. Preserve existing replay and exact warm-session identities.
|
||||
3. Pin complete provider distributions for macOS ARM64/x64 and Linux x64. Use
|
||||
verified launches, isolated credentials/configuration and build-time assets.
|
||||
4. Implement Cursor native ACP and its question/plan/subagent extensions;
|
||||
Copilot ACP authentication/events/permission and settlement handling; Pi
|
||||
wrapper patch and owned extension with authenticated tools and input policy.
|
||||
5. Run deterministic, recovery, isolation and richness conformance. Qualify each
|
||||
provider locally and in Daytona with product E2E evidence before advertising
|
||||
support. Use a combined $100 ceiling: $25 each plus $25 diagnosis reserve.
|
||||
6. Run required full checks, prepare one foundation and three dependent PRs,
|
||||
retain evidence and deliver the comparative capability report. List every
|
||||
unused/partial capability with reason and prioritized follow-up.
|
||||
|
||||
Implementation and qualification status is maintained in
|
||||
[the capability report](../architecture/runner-rich-acp-capabilities.md).
|
||||
Missing credentials or spend verification leave qualification pending; they do
|
||||
not authorize a weaker supported profile or an implicit model fallback.
|
||||
@@ -9,10 +9,10 @@ WORKDIR /workspace/packages/paperclip-runner/runner
|
||||
COPY packages/paperclip-runner/runner/Cargo.toml packages/paperclip-runner/runner/Cargo.lock ./
|
||||
COPY packages/paperclip-runner/runner/crates ./crates
|
||||
COPY packages/paperclip-runner/protocol ../protocol
|
||||
RUN cargo build --locked --release -p paperclip-runner-core --bin paperclip-runnerd \
|
||||
RUN cargo build --locked --release -j 2 -p paperclip-runner-core --bin paperclip-runnerd \
|
||||
&& strip /workspace/packages/paperclip-runner/runner/target/release/paperclip-runnerd
|
||||
|
||||
FROM node:24-bookworm@sha256:9137a20e25879e0b557227b57e3ee4e9af4bde29eb3db66134cd1723e84f830b AS provider-pack-build
|
||||
FROM node:24.21.0-bookworm@sha256:5a750d3be5e5c80275f8c9a5367c3aed99c2875656590c8d0701c7ee687f5f0a AS provider-pack-build
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
WORKDIR /workspace
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc tsconfig.base.json ./
|
||||
@@ -28,15 +28,18 @@ COPY cli/package.json ./cli/package.json
|
||||
# The complete resolved lock (including transitive integrity hashes) is reviewed.
|
||||
# Reject registry-time drift BEFORE installing packages or running lifecycle code.
|
||||
# Refresh this digest together with source/provider dependency changes.
|
||||
ARG PAPERCLIP_RUNNER_LOCK_SHA256=0c06ab38c1d4b6b58e1c9f5ee85eb79f97cecd83e751b5d27c3b262faf5ab265
|
||||
ARG PAPERCLIP_RUNNER_LOCK_SHA256=2c46a68811ba1d504a41b6f4d3f642bc93f4a1c615097754c9c6486881dba8e6
|
||||
RUN printf '%s pnpm-lock.yaml\n' "${PAPERCLIP_RUNNER_LOCK_SHA256}" > /tmp/provider-lock.sha256 \
|
||||
&& sha256sum -c /tmp/provider-lock.sha256 \
|
||||
&& pnpm install --frozen-lockfile --filter '@paperclipai/paperclip-runner...'
|
||||
ARG PAPERCLIP_RUNNER_SOURCE_REVISION
|
||||
# Optional qualification assets; this never promotes candidate profiles.
|
||||
ARG PAPERCLIP_RUNNER_CANDIDATE_PROVIDERS=
|
||||
RUN test -n "${PAPERCLIP_RUNNER_SOURCE_REVISION}"
|
||||
RUN pnpm --filter @paperclipai/paperclip-runner build:typescript \
|
||||
&& PAPERCLIP_RUNNER_SOURCE_REVISION="${PAPERCLIP_RUNNER_SOURCE_REVISION}" \
|
||||
node packages/paperclip-runner/scripts/build-provider-pack.mjs /provider-pack \
|
||||
"--candidate-providers=${PAPERCLIP_RUNNER_CANDIDATE_PROVIDERS}" \
|
||||
&& chmod -R a+rX /provider-pack
|
||||
|
||||
# Fleet sandbox base image. Keep this section aligned with
|
||||
|
||||
@@ -94,6 +94,12 @@ full Git SHA as `PAPERCLIP_RUNNER_SOURCE_REVISION`.
|
||||
Do not bake provider credentials, Paperclip bootstrap tickets, or Daytona
|
||||
preview tokens into this image. They remain per-run secret material.
|
||||
|
||||
The provider-pack build pins the official Linux x64 Node 24.21.0 image by
|
||||
manifest digest. Its bundled Undici is 7.29.1, which fixes
|
||||
[GHSA-3wwx-pv8p-q78v](https://github.com/advisories/GHSA-3wwx-pv8p-q78v).
|
||||
Pi separately verifies its private Node executable and nested npm dependency;
|
||||
changing the outer interpreter does not replace either provider-owned pin.
|
||||
|
||||
Provider CLI updates are manifest-only changes: repository CI owns the root
|
||||
lockfile. Resolve the complete workspace manifest graph in the build context
|
||||
before invoking Docker, matching CI when a source commit precedes the lockfile
|
||||
@@ -106,6 +112,37 @@ verifies the downloaded artifact, then passes that artifact's SHA-256 as the
|
||||
`PAPERCLIP_RUNNER_LOCK_SHA256` build argument. The Dockerfile checks the resolved
|
||||
lock against this value before installation. The fixed Dockerfile default is
|
||||
for standalone builds; it must not replace a campaign's verified lock digest.
|
||||
Refresh the default from the clean tracked lockfile using the exact
|
||||
`pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile` command,
|
||||
and verify a second resolution preserves the digest. A lockfile left by a
|
||||
filtered or incremental install can retain stale importer patch identities.
|
||||
Refresh exact runtime versions and qualification digests together; do not
|
||||
download dependencies when a task starts.
|
||||
|
||||
## Candidate ACP qualification assets
|
||||
|
||||
Provider branches can build their pinned assets with
|
||||
`node packages/paperclip-runner/scripts/build-provider-pack.mjs /absolute/pack --candidate-providers=cursor`
|
||||
(or `copilot` or `pi`). The source revision must include the named provider's
|
||||
builder. Assets are installed at build time under `provider-assets/<provider>/<platform>-<architecture>`.
|
||||
The pack manifest binds each complete asset tree. Runtime admission separately
|
||||
checks the provider's source-owned closure pins and copies a verified launch snapshot.
|
||||
A pack with candidate assets does not qualify or enable that provider.
|
||||
|
||||
For an isolated Linux x64 Daytona qualification image, pass
|
||||
`--build-arg PAPERCLIP_RUNNER_CANDIDATE_PROVIDERS=cursor` with the normal build arguments.
|
||||
Compute its content ID with the same selection:
|
||||
`pnpm --silent test:e2e:runner:image-id --candidate-providers=cursor`.
|
||||
Candidate assets and the default pack have distinct image identities. Never reuse
|
||||
the default image's content ID for a candidate build.
|
||||
Use each provider branch's recorded version and explicit model. Keep credentials
|
||||
out of images. Paid qualification requires bound provider and Daytona credentials,
|
||||
inspectable spend, and the shared $100 ceiling recorded in the capability report.
|
||||
|
||||
The pack builder tests its copied Node interpreter after relocation. Use a
|
||||
standalone Node distribution if the host interpreter depends on a package manager's
|
||||
private shared libraries. Pi additionally pins its complete portable interpreter
|
||||
and npm dependency graph.
|
||||
Refresh exact runtime versions and qualification digests together; never download
|
||||
dependencies when a task starts. Grok's additive native ACP profile keeps its
|
||||
qualified 1.0.13 executable at the verified sandbox prerequisite path. It does not replace the
|
||||
|
||||
@@ -117,6 +117,7 @@ export {
|
||||
PAPERCLIP_RUNNER_IDLE_TIMEOUT_MAX_MS,
|
||||
PAPERCLIP_RUNNER_DEFAULT_MODELS,
|
||||
PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES,
|
||||
PAPERCLIP_RUNNER_ACPX_PROFILES,
|
||||
isPaperclipRunnerProvider,
|
||||
resolvePaperclipRunnerIdleTimeoutMs,
|
||||
resolvePaperclipRunnerModel,
|
||||
|
||||
@@ -125,7 +125,7 @@ export const PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES = {
|
||||
value: "approve-reads",
|
||||
label: "Allow Paperclip reads",
|
||||
description:
|
||||
"Automatically allow assigned Paperclip read tools. Other operations stop with an approval-required message because this runner has no interactive approval handler.",
|
||||
"Automatically allow assigned Paperclip read tools. Other operations request a supported permission decision. Company permissions and execution boundaries still apply.",
|
||||
},
|
||||
{
|
||||
value: "deny-all",
|
||||
@@ -223,3 +223,12 @@ export function normalizeLegacyRunnerProvider(
|
||||
} = config;
|
||||
return { ...rest, provider: "codex", codexPermissionMode: "never" };
|
||||
}
|
||||
|
||||
/** Qualification is a release property, never an operator-configurable bypass. */
|
||||
export const PAPERCLIP_RUNNER_ACPX_PROFILES = Object.freeze([
|
||||
{ value: "grok", label: "Grok Build", qualified: true, credentialEnvironment: ["XAI_API_KEY"] },
|
||||
{ value: "claude", label: "Claude", qualified: true, credentialEnvironment: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] },
|
||||
{ value: "cursor", label: "Cursor", qualified: false, credentialEnvironment: ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"] },
|
||||
{ value: "copilot", label: "GitHub Copilot", qualified: false, credentialEnvironment: ["COPILOT_GITHUB_TOKEN"] },
|
||||
{ value: "pi", label: "Pi", qualified: false, credentialEnvironment: ["OPENROUTER_API_KEY"] },
|
||||
] as const);
|
||||
@@ -1212,7 +1212,9 @@ describe("sandbox managed runtime", () => {
|
||||
});
|
||||
|
||||
it.each(["symlink", "root_symlink", "root_alias", "root_alias_retarget", "case_alias", "EACCES", "EIO", "ENOENT"])("handles an overlay source changed after the snapshot: %s", async (change) => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-overlay-source-"));
|
||||
// The runtime pins a realpath root before staging. Match that spelling so
|
||||
// injected I/O errors also reach the selected file on macOS's /var alias.
|
||||
const rootDir = await fsPromises.realpath(await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-overlay-source-")));
|
||||
cleanupDirs.push(rootDir);
|
||||
const workspaceLocalDir = path.join(rootDir, "workspace");
|
||||
await initGitRepo(workspaceLocalDir);
|
||||
@@ -1301,6 +1303,7 @@ describe("sandbox managed runtime", () => {
|
||||
expect(syncIn).toHaveBeenCalledOnce();
|
||||
} else {
|
||||
await expect(preparing).rejects.toThrow(change.endsWith("symlink") ? /overlay.*directory/i : failure.message);
|
||||
if (statSpy) expect(statSpy).toHaveBeenCalledWith(selectedPath);
|
||||
expect(syncIn).not.toHaveBeenCalled();
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -54,7 +54,11 @@ leave the board showing an active run after the provider turn has already ended.
|
||||
The package also builds `paperclip-runner-acpx-sidecar`. This bounded v2
|
||||
stdin/stdout bridge admits the pinned Claude and Codex ACPX profiles. It
|
||||
validates the exact model, session identity, tool catalog, structured input,
|
||||
and terminal settlement at the process boundary. Pi remains unavailable.
|
||||
and terminal settlement at the process boundary. Cursor, Copilot and Pi have
|
||||
separate candidate branches and remain unavailable in production until local
|
||||
and Daytona qualification passes. Their verified distributions are build-owned;
|
||||
no candidate accepts an arbitrary executable. See
|
||||
[the rich ACP capability report](../../doc/architecture/runner-rich-acp-capabilities.md).
|
||||
|
||||
Remote Codex sessions relay assigned app tools through the server's configured
|
||||
gateway. Small catalogs are sent directly. When a catalog would exceed the
|
||||
@@ -96,19 +100,22 @@ do not change workspace isolation or grant credentials or connection access.
|
||||
task tools; `approve-reads` allows assigned reads; `deny-all` rejects requests.
|
||||
None of these restrictive modes is the default.
|
||||
|
||||
Restrictive profiles route supported permission decisions through durable runtime
|
||||
requests and the existing task interaction controls. Requests are persisted
|
||||
before presentation; answers are checked against the offered decisions and
|
||||
acknowledged by the sidecar before settlement. Unknown, stale and duplicate
|
||||
responses fail. Missing provider decision support remains a blocked disposition,
|
||||
not implicit approval. Company access checks still run for each Paperclip tool.
|
||||
Provider death expires pending promises; approvals are never replayed into a
|
||||
replacement process.
|
||||
|
||||
Automatic Paperclip/read allowances currently require the Claude SDK dispatch
|
||||
boundary. Grok preserves these restricted settings, but its ACP requests lack
|
||||
independently bound tool authority. They therefore stop with
|
||||
`approval_required`, including Paperclip tool requests. Use an explicitly
|
||||
selected `approve-all` policy for unattended Grok work in an assigned sandbox;
|
||||
Paperclip authorization and governed approvals still apply.
|
||||
|
||||
This runtime has no interactive permission handler. An operation that still
|
||||
requires approval stops the turn with `approval_required`. The server marks the
|
||||
task blocked, exposes the permission action to the operator, and disables
|
||||
automatic retry. The operator must review the operation and the agent's
|
||||
permission setting before retrying. Company access checks still run when each
|
||||
Paperclip tool executes.
|
||||
independently bound tool authority. Those operations require a supported operator
|
||||
permission decision; a missing interactive responder stops with
|
||||
`approval_required`. An explicitly selected `approve-all` policy permits unattended
|
||||
Grok work in an assigned sandbox. Paperclip authorization and governed approvals
|
||||
still apply.
|
||||
|
||||
Runnerd selects only qualified provider profiles. Claude Managed and AWS
|
||||
AgentCore receive immutable company-profile snapshots with explicit retention,
|
||||
@@ -285,7 +292,11 @@ pnpm --filter @paperclipai/paperclip-runner report:runner-chaos-evals
|
||||
`report:runner-live-evals` is a paid, provider-backed command. Native Codex
|
||||
requires `OPENAI_API_KEY`; ACPX Claude requires
|
||||
`ANTHROPIC_API_KEY`; OpenCode candidates require `OPENROUTER_API_KEY`. The live
|
||||
matrix admits no Pi profile and does not persist credential values. Set
|
||||
matrix remains qualified-only and does not persist credential values. Candidate
|
||||
qualification uses `eval-session --candidate-profile <pi|cursor|copilot>` with an
|
||||
explicit model and a separately materialized pinned candidate pack. This option
|
||||
is a constructor-bound diagnostic opt-in; session JSON cannot enable a candidate.
|
||||
Missing credentials or unverifiable spend block paid qualification. Set
|
||||
`PAPERCLIP_EVAL_MAX_CAMPAIGN_COST_USD` to a positive finite number to bound
|
||||
additional scheduling after the observed campaign total reaches that value:
|
||||
|
||||
|
||||
@@ -172,6 +172,7 @@ function ProviderActivity({ item, callbacks }: {
|
||||
{children.length > 0 ? <ul className="pit-provider-tree">{children.map((child, index) => <li key={String(child.childId ?? index)}><strong>{String(child.role ?? "Child agent")}</strong><span>{String(child.status ?? "unknown")}</span><small>{String(child.summary ?? "")}</small></li>)}</ul> : null}
|
||||
{sources.length > 0 ? <ul className="pit-provider-sources">{sources.map((source, index) => { const url = typeof source.url === "string" && /^https?:\/\//.test(source.url) ? source.url : null; return <li key={String(source.sourceId ?? index)}>{url === null ? <span>{String(source.title ?? "Unavailable source")}</span> : <a href={url} target="_blank" rel="noreferrer">{String(source.title ?? url)}</a>}<small>Provider-reported</small></li>; })}</ul> : null}
|
||||
{output.length > 0 ? <pre className="pit-provider-output" aria-label="Command output">{output}</pre> : null}
|
||||
{item.family === "provider_notice" && Array.isArray(payload.details) ? <dl className="pit-provider-fields">{payload.details.slice(0, 64).map((value, index) => { const detail = valueRecord(value); return <div key={index}><dt>{String(detail.name ?? "Detail")}</dt><dd>{String(detail.value ?? "")}</dd></div>; })}</dl> : null}
|
||||
{item.family === "model_identity" ? <dl className="pit-provider-fields"><dt>Requested</dt><dd>{String(payload.requestedModel ?? "—")}</dd><dt>Effective</dt><dd>{String(payload.effectiveModel ?? "—")}</dd></dl> : null}
|
||||
{item.family === "artifact" && typeof payload.reference === "string" ? <button type="button" className="pit-link-button">Open {payload.reference}</button> : null}
|
||||
<button type="button" className="pit-link-button" onClick={() => callbacks.onOpenEvidence(item.evidenceRef.section, item.evidenceRef.recordId)}>View in Evidence</button>
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
"typecheck:browser": "tsc -p tsconfig.browser.json --noEmit",
|
||||
"test": "pnpm run test:typescript && pnpm run test:rust",
|
||||
"test:typescript": "pnpm run test:typescript:prep && vitest run",
|
||||
"test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs",
|
||||
"test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs",
|
||||
"test:typescript:vitest": "node ./scripts/run-pr-vitest-lane.mjs",
|
||||
"test:rust": "cargo test --release --manifest-path runner/Cargo.toml --locked --workspace",
|
||||
"test:codex": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --test codex_provider",
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
{
|
||||
"path": "schemas/event-v2.schema.json",
|
||||
"id": "https://paperclip.dev/schemas/prp/v2/event.schema.json",
|
||||
"sha256": "d7528ee2a46ad20de831fd4ee4a0a51e8325a9855fed5a9b8c428dcebc39cc11"
|
||||
"sha256": "9dcf149bdb8e10041ca2212037231e7ceff11be08a0b8ee5dbd0f21974daa294"
|
||||
},
|
||||
{
|
||||
"path": "schemas/event.schema.json",
|
||||
@@ -65,12 +65,12 @@
|
||||
{
|
||||
"path": "schemas/provider-descriptor.schema.json",
|
||||
"id": "https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json",
|
||||
"sha256": "a56c0c8501171c34bb72f6fba08d3872839a8b45bbc10daa93620afc9dc2b669"
|
||||
"sha256": "f5badf70748d6a761a3487f001ce8282582b161cb5b80fabb6bb5f683d28c512"
|
||||
},
|
||||
{
|
||||
"path": "schemas/provider-event.schema.json",
|
||||
"id": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json",
|
||||
"sha256": "0b138a41bd3bd0b1a115f5536a2d7349537e79e36ede3b51d003f9f21ce73b50"
|
||||
"sha256": "14143d53f28969fa3f940def7331a45ee0eed560dd343506ac41b6a3dfe59985"
|
||||
},
|
||||
{
|
||||
"path": "schemas/question-adapter-fixture.schema.json",
|
||||
@@ -85,12 +85,12 @@
|
||||
{
|
||||
"path": "schemas/question-set.schema.json",
|
||||
"id": "https://paperclip.dev/schemas/prp/v1/question-set.schema.json",
|
||||
"sha256": "42b5441a3d388851dacb6e4500dfd4a17d878eded2e724228078b647e7440d3f"
|
||||
"sha256": "6aeb2b880a60356cb48acfe26605acd9412a1df65f5308397d3ec521db5ea70f"
|
||||
},
|
||||
{
|
||||
"path": "schemas/request.schema.json",
|
||||
"id": "https://paperclip.dev/schemas/prp/v1/request.schema.json",
|
||||
"sha256": "c8d00e10150c90963f19641c075f50da7f67617dd817f41b356287ad85c53f2b"
|
||||
"sha256": "985a3b0a6ae36ae5ad4cf7d708dc401db7c108f683a81cd44a03093fc4ca7862"
|
||||
},
|
||||
{
|
||||
"path": "schemas/result.schema.json",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"run.attach",
|
||||
"turn.start",
|
||||
"turn.cancel",
|
||||
"turn.steer",
|
||||
"permission.resolve",
|
||||
"input.resolve",
|
||||
"tool.resolve",
|
||||
@@ -31,6 +32,7 @@
|
||||
"eventType": {
|
||||
"enum": [
|
||||
"runtime.event",
|
||||
"runtime.rich_event",
|
||||
"runtime.permission_requested",
|
||||
"runtime.input_requested",
|
||||
"runtime.tool_called",
|
||||
@@ -49,7 +51,11 @@
|
||||
"command": { "$ref": "#/$defs/command" },
|
||||
"params": { "type": "object" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
"additionalProperties": false,
|
||||
"allOf": [{
|
||||
"if": { "properties": { "command": { "const": "turn.steer" } } },
|
||||
"then": { "properties": { "params": { "$ref": "#/$defs/turnControl" } } }
|
||||
}]
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
@@ -108,6 +114,102 @@
|
||||
"turnId": { "type": ["string", "null"], "maxLength": 240, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" },
|
||||
"payload": { "type": "object" }
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"allOf": [
|
||||
{
|
||||
"if": { "properties": { "eventType": { "const": "runtime.rich_event" } } },
|
||||
"then": {
|
||||
"properties": {
|
||||
"runId": { "type": "string" },
|
||||
"turnId": { "type": "string" },
|
||||
"payload": { "$ref": "#/$defs/richEvent" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"richEvent": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"eventType",
|
||||
"itemId",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"eventType": {
|
||||
"enum": [
|
||||
"plan.updated",
|
||||
"tool.execution.started",
|
||||
"tool.execution.progressed",
|
||||
"tool.execution.completed",
|
||||
"research.started",
|
||||
"research.progressed",
|
||||
"research.completed",
|
||||
"delegation.started",
|
||||
"delegation.updated",
|
||||
"delegation.completed",
|
||||
"model.route.changed",
|
||||
"model.verification.updated",
|
||||
"context.compacted",
|
||||
"artifact.viewed",
|
||||
"artifact.generated",
|
||||
"review.mode.changed",
|
||||
"hook.started",
|
||||
"hook.completed",
|
||||
"memory.citation.referenced",
|
||||
"safety.review.started",
|
||||
"safety.review.completed",
|
||||
"terminal.input.sent",
|
||||
"wait.started",
|
||||
"wait.completed",
|
||||
"provider.notice.recorded"
|
||||
]
|
||||
},
|
||||
"itemId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
|
||||
},
|
||||
"payload": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"turnControl": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"turnId",
|
||||
"controlId",
|
||||
"mode",
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"turnId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
|
||||
},
|
||||
"controlId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"steer",
|
||||
"follow_up"
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 65536
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,8 @@
|
||||
"type": "object",
|
||||
"required": ["sessionGoals"],
|
||||
"properties": {
|
||||
"sessionGoals": { "$ref": "https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability" }
|
||||
"sessionGoals": { "$ref": "https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability" },
|
||||
"turnControls": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
|
||||
@@ -18,14 +18,15 @@
|
||||
"endpointQualifier": { "type": "string", "minLength": 1, "maxLength": 240 },
|
||||
"memoryId": { "type": "string", "minLength": 1, "maxLength": 240 },
|
||||
"eventExpiryDays": { "const": 90 },
|
||||
"agent": { "enum": ["pi", "claude", "codex"] },
|
||||
"agent": { "enum": ["pi", "claude", "codex", "cursor", "copilot"] },
|
||||
"requestedModel": { "type": "string", "minLength": 1, "maxLength": 240 },
|
||||
"acpProtocolVersion": { "const": 1 },
|
||||
"agentServerPackage": { "type": "string", "minLength": 1, "maxLength": 240 },
|
||||
"agentServerVersion": { "type": "string", "minLength": 1, "maxLength": 120 },
|
||||
"agentRuntimePackage": { "type": ["string", "null"], "maxLength": 240 },
|
||||
"agentRuntimeVersion": { "type": ["string", "null"], "maxLength": 120 },
|
||||
"acpxRecordId": { "type": ["string", "null"], "maxLength": 240 }
|
||||
"acpxRecordId": { "type": ["string", "null"], "maxLength": 240 },
|
||||
"turnControls": { "$ref": "#/$defs/turnControls" }
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
@@ -96,5 +97,16 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"$defs": {
|
||||
"turnControls": {
|
||||
"type": "object",
|
||||
"required": ["steering", "queuedFollowUp"],
|
||||
"properties": {
|
||||
"steering": { "type": "boolean" },
|
||||
"queuedFollowUp": { "type": "boolean" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -162,7 +162,7 @@
|
||||
"providerNotice": {
|
||||
"type": "object",
|
||||
"required": ["schema", "noticeId", "severity", "category", "scope", "recoverable", "userActionable", "summary"],
|
||||
"properties": { "schema": { "const": "paperclip.provider.notice.v1" }, "noticeId": { "$ref": "#/$defs/id" }, "severity": { "enum": ["info", "warning", "error"] }, "category": { "type": "string", "minLength": 1, "maxLength": 160 }, "scope": { "enum": ["turn", "session", "environment", "account"] }, "recoverable": { "type": "boolean" }, "userActionable": { "type": "boolean" }, "summary": { "$ref": "#/$defs/shortText" } },
|
||||
"properties": { "schema": { "const": "paperclip.provider.notice.v1" }, "noticeId": { "$ref": "#/$defs/id" }, "severity": { "enum": ["info", "warning", "error"] }, "category": { "type": "string", "minLength": 1, "maxLength": 160 }, "scope": { "enum": ["turn", "session", "environment", "account"] }, "recoverable": { "type": "boolean" }, "userActionable": { "type": "boolean" }, "summary": { "$ref": "#/$defs/shortText" }, "provenance": {"type":"object","required":["method","eventType","sessionId","turnId"],"properties":{"method":{"type":"string","maxLength":160},"eventType":{"type":"string","maxLength":160},"sessionId":{"type":"string","maxLength":240},"turnId":{"type":"string","maxLength":240},"agentId":{"type":"string","maxLength":240},"timestamp":{"type":"string","maxLength":80}},"additionalProperties":false}, "details": {"type":"array","maxItems":64,"items":{"type":"object","required":["name","value"],"properties":{"name":{"type":"string","minLength":1,"maxLength":160},"value":{"type":"string","maxLength":4000}},"additionalProperties":false}} },
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"properties": {
|
||||
"schema": { "const": "paperclip.question_set.v1" },
|
||||
"title": { "type": "string", "maxLength": 1000 },
|
||||
"description": { "type": "string", "maxLength": 4000 },
|
||||
"description": { "type": "string", "maxLength": 100000 },
|
||||
"submitLabel": { "type": "string", "maxLength": 200 },
|
||||
"questions": {
|
||||
"type": "array",
|
||||
|
||||
@@ -3,6 +3,181 @@
|
||||
"$id": "https://paperclip.dev/schemas/prp/v1/request.schema.json",
|
||||
"title": "PRP request",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schema",
|
||||
"requestKind",
|
||||
"requestId",
|
||||
"type",
|
||||
"status",
|
||||
"prompt",
|
||||
"choices",
|
||||
"origin",
|
||||
"turnId"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {
|
||||
"const": "paperclip.runtime_request.v2"
|
||||
},
|
||||
"requestKind": {
|
||||
"const": "permission_approval"
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160
|
||||
},
|
||||
"type": {
|
||||
"const": "permission"
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"pending",
|
||||
"resolved",
|
||||
"expired",
|
||||
"cancelled"
|
||||
]
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4000
|
||||
},
|
||||
"choices": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 4,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"key",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"key": {
|
||||
"enum": [
|
||||
"accept",
|
||||
"accept_for_session",
|
||||
"decline",
|
||||
"cancel"
|
||||
]
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 500
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"contains": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"key"
|
||||
],
|
||||
"properties": {
|
||||
"key": {
|
||||
"const": "accept"
|
||||
}
|
||||
}
|
||||
},
|
||||
"minContains": 0,
|
||||
"maxContains": 1
|
||||
},
|
||||
{
|
||||
"contains": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"key"
|
||||
],
|
||||
"properties": {
|
||||
"key": {
|
||||
"const": "accept_for_session"
|
||||
}
|
||||
}
|
||||
},
|
||||
"minContains": 0,
|
||||
"maxContains": 1
|
||||
},
|
||||
{
|
||||
"contains": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"key"
|
||||
],
|
||||
"properties": {
|
||||
"key": {
|
||||
"const": "decline"
|
||||
}
|
||||
}
|
||||
},
|
||||
"minContains": 0,
|
||||
"maxContains": 1
|
||||
},
|
||||
{
|
||||
"contains": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"key"
|
||||
],
|
||||
"properties": {
|
||||
"key": {
|
||||
"const": "cancel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"minContains": 0,
|
||||
"maxContains": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"details": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"origin": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"adapter"
|
||||
],
|
||||
"properties": {
|
||||
"adapter": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 500
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"turnId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"itemId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["schema", "requestKind", "requestId", "type", "status", "prompt", "input"],
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::acpx_event_scope::AcpxEventScope;
|
||||
use crate::acpx_sidecar_transport::AcpxSidecarEvent;
|
||||
use crate::durable::{redact_text, sanitize_semantic_tool_input, sanitize_value};
|
||||
use crate::durable::{
|
||||
redact_sensitive_text_values, redact_text, sanitize_semantic_tool_input, sanitize_value,
|
||||
};
|
||||
use crate::generated_acpx_sidecar_contract::{
|
||||
classify_generated_acpx_tool_operation, GeneratedAcpxSidecarEventType,
|
||||
};
|
||||
use crate::local_runner::LocalRunnerError;
|
||||
use crate::provider_bridge::semantic_value_digest;
|
||||
use crate::stable_identity::{is_stable_id, SHORT_STABLE_ID_CHARS};
|
||||
|
||||
const MAX_EVENT_PAYLOAD_BYTES: usize = 256 * 1024;
|
||||
const MAX_ID_CHARS: usize = 160;
|
||||
@@ -40,6 +44,10 @@ pub enum AcpxTurnStatus {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum AcpxEventPayload {
|
||||
RichActivity {
|
||||
event_type: String,
|
||||
payload: Value,
|
||||
},
|
||||
Runtime {
|
||||
kind: AcpxRuntimeEventKind,
|
||||
tool_operation: Option<&'static str>,
|
||||
@@ -95,6 +103,7 @@ pub fn decode_acpx_event(
|
||||
}
|
||||
|
||||
match event.event_type {
|
||||
GeneratedAcpxSidecarEventType::RuntimeRichEvent => decode_rich_event(&event.payload),
|
||||
GeneratedAcpxSidecarEventType::RuntimeEvent => decode_runtime_event(&event.payload),
|
||||
GeneratedAcpxSidecarEventType::RuntimePermissionRequested => {
|
||||
Ok(AcpxEventPayload::PermissionRequested {
|
||||
@@ -113,7 +122,7 @@ pub fn decode_acpx_event(
|
||||
LocalRunnerError::invalid("ACPX input request omitted its question set")
|
||||
})?;
|
||||
validate_question_set(&question_set)?;
|
||||
let question_set = sanitize_question_set(question_set);
|
||||
let question_set = sanitize_question_set(question_set)?;
|
||||
let origin = optional_object(&event.payload, "origin", "input request origin")?;
|
||||
Ok(AcpxEventPayload::InputRequested {
|
||||
request_id: required_id_with_limit(
|
||||
@@ -192,9 +201,173 @@ pub fn decode_acpx_event(
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_question_set(mut value: Value) -> Value {
|
||||
// This boundary accepts display activity only. Terminal outcomes, tool dispatch,
|
||||
// runtime requests and source references retain their separate authority paths.
|
||||
fn rich_event_schema(event_type: &str) -> Option<&'static str> {
|
||||
Some(match event_type {
|
||||
"plan.updated" => "plan",
|
||||
"tool.execution.started" | "tool.execution.progressed" | "tool.execution.completed" => {
|
||||
"toolExecution"
|
||||
}
|
||||
"research.started" | "research.progressed" | "research.completed" => "research",
|
||||
"delegation.started" | "delegation.updated" | "delegation.completed" => "delegation",
|
||||
"model.route.changed" => "modelRoute",
|
||||
"model.verification.updated" => "modelVerification",
|
||||
"context.compacted" => "contextCompacted",
|
||||
"artifact.viewed" => "artifactViewed",
|
||||
"artifact.generated" => "artifactGenerated",
|
||||
"review.mode.changed" => "reviewMode",
|
||||
"hook.started" | "hook.completed" => "hook",
|
||||
"memory.citation.referenced" => "memoryCitation",
|
||||
"safety.review.started" | "safety.review.completed" => "safetyReview",
|
||||
"terminal.input.sent" => "terminalInput",
|
||||
"wait.started" | "wait.completed" => "wait",
|
||||
"provider.notice.recorded" => "providerNotice",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn rich_event_validators(
|
||||
) -> Result<&'static BTreeMap<String, jsonschema::Validator>, LocalRunnerError> {
|
||||
static VALIDATORS: OnceLock<Result<BTreeMap<String, jsonschema::Validator>, String>> =
|
||||
OnceLock::new();
|
||||
VALIDATORS.get_or_init(|| {
|
||||
let schema: Value = serde_json::from_str(include_str!("../../../../protocol/schemas/provider-event.schema.json"))
|
||||
.map_err(|_| "embedded provider-event schema is invalid".to_owned())?;
|
||||
fn internal_refs(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::Object(object) => object.iter().all(|(key, value)| {
|
||||
(key != "$ref" || value.as_str().is_some_and(|reference| reference.starts_with("#/"))) && internal_refs(value)
|
||||
}),
|
||||
Value::Array(values) => values.iter().all(internal_refs),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
if !internal_refs(&schema) { return Err("provider-event schema may use only internal references".to_owned()); }
|
||||
let definitions = schema.get("$defs").and_then(Value::as_object).ok_or_else(|| "provider-event schema has no definitions".to_owned())?;
|
||||
let mut validators = BTreeMap::new();
|
||||
for name in ["plan", "toolExecution", "research", "delegation", "modelRoute", "modelVerification", "contextCompacted", "artifactViewed", "artifactGenerated", "reviewMode", "hook", "memoryCitation", "safetyReview", "terminalInput", "wait", "providerNotice"] {
|
||||
if !definitions.contains_key(name) { return Err("provider-event schema is missing an admitted definition".to_owned()); }
|
||||
let selected = serde_json::json!({"$schema":"https://json-schema.org/draft/2020-12/schema", "$ref":format!("#/$defs/{name}"), "$defs":definitions});
|
||||
let validator = jsonschema::validator_for(&selected).map_err(|_| "embedded provider-event schema cannot compile".to_owned())?;
|
||||
validators.insert(name.to_owned(), validator);
|
||||
}
|
||||
Ok(validators)
|
||||
}).as_ref().map_err(|message| LocalRunnerError::invalid(message.clone()))
|
||||
}
|
||||
|
||||
fn decode_rich_event(value: &Value) -> Result<AcpxEventPayload, LocalRunnerError> {
|
||||
let wrapper = value
|
||||
.as_object()
|
||||
.ok_or_else(|| LocalRunnerError::invalid("ACPX rich event must be an object"))?;
|
||||
if wrapper
|
||||
.keys()
|
||||
.any(|key| !matches!(key.as_str(), "eventType" | "itemId" | "payload"))
|
||||
{
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX rich event cannot supply source references or authority fields",
|
||||
));
|
||||
}
|
||||
let item_id = wrapper
|
||||
.get("itemId")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| LocalRunnerError::invalid("ACPX rich event omitted its display identity"))?;
|
||||
if !is_stable_id(item_id, SHORT_STABLE_ID_CHARS) {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX rich event display identity is invalid",
|
||||
));
|
||||
}
|
||||
let event_type = wrapper
|
||||
.get("eventType")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| LocalRunnerError::invalid("ACPX rich event omitted its type"))?;
|
||||
let schema = rich_event_schema(event_type).ok_or_else(|| {
|
||||
LocalRunnerError::invalid("ACPX rich event type is not admitted display activity")
|
||||
})?;
|
||||
let payload = wrapper
|
||||
.get("payload")
|
||||
.ok_or_else(|| LocalRunnerError::invalid("ACPX rich event omitted its payload"))?;
|
||||
let validators = rich_event_validators()?;
|
||||
let validator = validators
|
||||
.get(schema)
|
||||
.ok_or_else(|| LocalRunnerError::invalid("ACPX rich event schema is unavailable"))?;
|
||||
if !validator.is_valid(payload) {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX rich event failed its exact provider-event schema",
|
||||
));
|
||||
}
|
||||
if event_type == "artifact.generated" && payload.get("registered") != Some(&Value::Bool(false))
|
||||
{
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX display activity cannot register an artifact",
|
||||
));
|
||||
}
|
||||
if event_type == "plan.updated"
|
||||
&& (payload.get("syncStatus").and_then(Value::as_str) != Some("not_applicable")
|
||||
|| payload
|
||||
.get("documentRevision")
|
||||
.is_some_and(|revision| !revision.is_null()))
|
||||
{
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX display activity cannot synchronize a durable plan",
|
||||
));
|
||||
}
|
||||
for field in ["reference", "target"] {
|
||||
if payload
|
||||
.get(field)
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|path| {
|
||||
path.contains('\\') || path.contains(':') || path.chars().any(char::is_control)
|
||||
})
|
||||
{
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX activity reference must be a workspace-relative path",
|
||||
));
|
||||
}
|
||||
}
|
||||
fn sanitize_display(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::String(text) => Value::String(redact_sensitive_text_values(text)),
|
||||
Value::Array(values) => Value::Array(values.iter().map(sanitize_display).collect()),
|
||||
Value::Object(object) => Value::Object(
|
||||
object
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), sanitize_display(value)))
|
||||
.collect(),
|
||||
),
|
||||
value => value.clone(),
|
||||
}
|
||||
}
|
||||
let payload = sanitize_display(payload);
|
||||
if !validator.is_valid(&payload) {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX rich event redaction exceeded its schema bounds",
|
||||
));
|
||||
}
|
||||
Ok(AcpxEventPayload::RichActivity {
|
||||
event_type: event_type.to_owned(),
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
// Durable storage uses this same exact schema gate before lifting diagnostic
|
||||
// preview limits. This does not admit an event or grant mutation authority.
|
||||
pub(crate) fn has_bounded_rich_display_shape(event_type: &str, payload: &Value) -> bool {
|
||||
let Some(schema) = rich_event_schema(event_type) else {
|
||||
return false;
|
||||
};
|
||||
serde_json::to_vec(payload).is_ok_and(|bytes| bytes.len() <= MAX_EVENT_PAYLOAD_BYTES)
|
||||
&& rich_event_validators().is_ok_and(|validators| {
|
||||
validators
|
||||
.get(schema)
|
||||
.is_some_and(|validator| validator.is_valid(payload))
|
||||
})
|
||||
}
|
||||
|
||||
fn sanitize_question_set(mut value: Value) -> Result<Value, LocalRunnerError> {
|
||||
let original = value.clone();
|
||||
let Some(question_set) = value.as_object_mut() else {
|
||||
return value;
|
||||
return Ok(value);
|
||||
};
|
||||
redact_object_text(question_set, &["title", "description", "submitLabel"]);
|
||||
if let Some(questions) = question_set
|
||||
@@ -223,7 +396,17 @@ fn sanitize_question_set(mut value: Value) -> Value {
|
||||
}
|
||||
}
|
||||
}
|
||||
value
|
||||
if value != original {
|
||||
let description = value
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
value["description"] = Value::String(format!(
|
||||
"Sensitive values were redacted from this request.\n\n{description}"
|
||||
));
|
||||
}
|
||||
validate_question_set(&value)?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn redact_object_text(object: &mut serde_json::Map<String, Value>, keys: &[&str]) {
|
||||
@@ -231,7 +414,7 @@ fn redact_object_text(object: &mut serde_json::Map<String, Value>, keys: &[&str]
|
||||
let Some(text) = object.get(*key).and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let redacted = redact_text(text);
|
||||
let redacted = redact_sensitive_text_values(text);
|
||||
object.insert((*key).to_owned(), Value::String(redacted));
|
||||
}
|
||||
}
|
||||
@@ -397,7 +580,16 @@ fn validate_plan(payload: &Value) -> Result<(), LocalRunnerError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_question_set(value: &Value) -> Result<(), LocalRunnerError> {
|
||||
pub(crate) fn validate_question_set(value: &Value) -> Result<(), LocalRunnerError> {
|
||||
if serde_json::to_vec(value)
|
||||
.map_err(|_| LocalRunnerError::invalid("ACPX question set is invalid"))?
|
||||
.len()
|
||||
> 196 * 1024
|
||||
{
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX question set exceeds its 196 KiB durable byte bound",
|
||||
));
|
||||
}
|
||||
let schema: Value = serde_json::from_str(include_str!(
|
||||
"../../../../protocol/schemas/question-set.schema.json"
|
||||
))
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -51,6 +51,12 @@ pub struct AcpxProviderSessionIdentity {
|
||||
pub provider_lifetime_fence_candidates: [u16; 3],
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AcpxProviderRuntimePolicy {
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AcpxProviderSessionConfig {
|
||||
pub transport: AcpxSidecarTransportConfig,
|
||||
@@ -63,6 +69,7 @@ pub struct AcpxProviderSessionConfig {
|
||||
pub working_directory: PathBuf,
|
||||
pub permission_mode: AcpxPermissionMode,
|
||||
pub permission_mode_pinned: bool,
|
||||
pub provider_policy: Option<AcpxProviderRuntimePolicy>,
|
||||
pub system_instructions: String,
|
||||
pub runtime_context: Value,
|
||||
pub tool_set: AuthorizedToolSet,
|
||||
@@ -76,9 +83,11 @@ impl AcpxProviderSessionConfig {
|
||||
"claude" => "claude-sonnet-5",
|
||||
"grok" => "grok-4.7",
|
||||
"codex" => "gpt-5.6-sol",
|
||||
"pi" => "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
"cursor" | "copilot" => self.model.as_str(),
|
||||
_ => {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX agent must be claude, codex, or grok",
|
||||
"ACPX agent must name a known immutable profile",
|
||||
))
|
||||
}
|
||||
};
|
||||
@@ -89,6 +98,13 @@ impl AcpxProviderSessionConfig {
|
||||
)));
|
||||
}
|
||||
validate_text(&self.model, MAX_MODEL_CHARS, "ACPX model")?;
|
||||
if matches!(self.agent.as_str(), "pi" | "cursor" | "copilot")
|
||||
&& self.provider_policy.is_none()
|
||||
{
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX candidate requires explicit provider read-only policy",
|
||||
));
|
||||
}
|
||||
validate_stable_id(&self.run_id, SHORT_STABLE_ID_CHARS, "ACPX run id")?;
|
||||
validate_stable_id(
|
||||
&self.normalized_session_id,
|
||||
@@ -209,6 +225,30 @@ impl AcpxProviderSessionIdentity {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AcpxTurnControlCapabilities {
|
||||
pub steering: bool,
|
||||
pub queued_follow_up: bool,
|
||||
}
|
||||
|
||||
fn verified_turn_controls(
|
||||
value: Option<&Value>,
|
||||
agent: &str,
|
||||
) -> Result<AcpxTurnControlCapabilities, LocalRunnerError> {
|
||||
let Some(value) = value else {
|
||||
return Ok(AcpxTurnControlCapabilities::default());
|
||||
};
|
||||
let controls: AcpxTurnControlCapabilities = serde_json::from_value(value.clone())
|
||||
.map_err(|_| LocalRunnerError::invalid("ACPX negotiated turn controls are malformed"))?;
|
||||
if agent != "pi" && (controls.steering || controls.queued_follow_up) {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX profile cannot advertise these turn controls",
|
||||
));
|
||||
}
|
||||
Ok(controls)
|
||||
}
|
||||
|
||||
pub struct AcpxProviderSession {
|
||||
transport: AcpxSidecarTransport,
|
||||
config: AcpxProviderSessionConfig,
|
||||
@@ -216,6 +256,7 @@ pub struct AcpxProviderSession {
|
||||
tool_bridge: ProviderToolBridge,
|
||||
reserved_tool_bridge: ProviderToolBridge,
|
||||
identity: AcpxProviderSessionIdentity,
|
||||
turn_controls: AcpxTurnControlCapabilities,
|
||||
catalog_revision: u64,
|
||||
working_directory: PathBuf,
|
||||
closed: bool,
|
||||
@@ -235,7 +276,7 @@ impl AcpxProviderSession {
|
||||
let mut transport =
|
||||
AcpxSidecarTransport::start_for_agent(&config.transport, &config.agent)?;
|
||||
let bootstrap = bootstrap(&mut transport, config);
|
||||
let (identity, state) = match bootstrap {
|
||||
let (identity, state, turn_controls) = match bootstrap {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
let cleanup = transport.shutdown();
|
||||
@@ -249,6 +290,7 @@ impl AcpxProviderSession {
|
||||
tool_bridge,
|
||||
reserved_tool_bridge,
|
||||
identity,
|
||||
turn_controls,
|
||||
catalog_revision: config.catalog_revision,
|
||||
working_directory: config.working_directory.clone(),
|
||||
closed: false,
|
||||
@@ -264,6 +306,10 @@ impl AcpxProviderSession {
|
||||
&self.identity
|
||||
}
|
||||
|
||||
pub fn turn_control_capabilities(&self) -> AcpxTurnControlCapabilities {
|
||||
self.turn_controls
|
||||
}
|
||||
|
||||
pub fn state(&self) -> &AcpxProviderState {
|
||||
&self.state
|
||||
}
|
||||
@@ -378,6 +424,11 @@ impl AcpxProviderSession {
|
||||
"ACPX sidecar did not confirm the requested turn",
|
||||
)));
|
||||
}
|
||||
self.turn_controls =
|
||||
match verified_turn_controls(response.get("turnControls"), &self.config.agent) {
|
||||
Ok(controls) => controls,
|
||||
Err(error) => return Err(self.fail_closed(error)),
|
||||
};
|
||||
if let Err(error) = self.state.begin_turn(turn_id) {
|
||||
return Err(self.fail_closed(error));
|
||||
}
|
||||
@@ -386,6 +437,54 @@ impl AcpxProviderSession {
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub fn steer_turn(
|
||||
&mut self,
|
||||
turn_id: &str,
|
||||
control_id: &str,
|
||||
mode: &str,
|
||||
message: &str,
|
||||
) -> Result<Value, LocalRunnerError> {
|
||||
self.ensure_open()?;
|
||||
validate_stable_id(turn_id, DURABLE_STABLE_ID_CHARS, "ACPX turn id")?;
|
||||
validate_stable_id(control_id, SHORT_STABLE_ID_CHARS, "ACPX control id")?;
|
||||
if !matches!(mode, "steer" | "follow_up")
|
||||
|| message.trim().is_empty()
|
||||
|| message.len() > 65_536
|
||||
|| message.contains('\0')
|
||||
{
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX turn control violates its bounded contract",
|
||||
));
|
||||
}
|
||||
let supported = if mode == "steer" {
|
||||
self.turn_controls.steering
|
||||
} else {
|
||||
self.turn_controls.queued_follow_up
|
||||
};
|
||||
if !supported {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX turn control was not negotiated",
|
||||
));
|
||||
}
|
||||
self.state.reserve_turn_control(turn_id, control_id)?;
|
||||
// The sidecar checks the negotiated live capability. An error may follow
|
||||
// delivery, so the reservation must never be released for automatic retry.
|
||||
let response = self.transport.request(
|
||||
GeneratedAcpxSidecarCommand::TurnSteer,
|
||||
json!({"turnId": turn_id, "controlId": control_id, "mode": mode, "message": message}),
|
||||
)?;
|
||||
if response.get("accepted").and_then(Value::as_bool) != Some(true)
|
||||
|| response.get("turnId").and_then(Value::as_str) != Some(turn_id)
|
||||
|| response.get("controlId").and_then(Value::as_str) != Some(control_id)
|
||||
|| response.get("mode").and_then(Value::as_str) != Some(mode)
|
||||
{
|
||||
return Err(self.fail_closed(LocalRunnerError::invalid(
|
||||
"ACPX sidecar did not acknowledge the exact turn control",
|
||||
)));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub fn interrupt_turn(
|
||||
&mut self,
|
||||
turn_id: &str,
|
||||
@@ -562,9 +661,16 @@ impl AcpxProviderSession {
|
||||
);
|
||||
}
|
||||
AcpxProviderStateEvent::PermissionRequest { .. } => {
|
||||
return Err(self.fail_closed(LocalRunnerError::invalid(
|
||||
"ACPX permission request violated the pinned runner policy",
|
||||
)));
|
||||
if self.config.agent == "codex"
|
||||
|| matches!(
|
||||
self.config.permission_mode,
|
||||
AcpxPermissionMode::ApproveAll | AcpxPermissionMode::DenyAll
|
||||
)
|
||||
{
|
||||
return Err(self.fail_closed(LocalRunnerError::invalid(
|
||||
"ACPX permission request violated the pinned runner policy",
|
||||
)));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -629,6 +735,52 @@ impl AcpxProviderSession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn resolve_permission(
|
||||
&mut self,
|
||||
request_id: &str,
|
||||
turn_id: &str,
|
||||
resolution: &Value,
|
||||
) -> Result<(), LocalRunnerError> {
|
||||
self.ensure_bound_turn(turn_id)?;
|
||||
let details = self.state.pending_permission(request_id).ok_or_else(|| {
|
||||
LocalRunnerError::invalid("ACPX permission request is stale or unknown")
|
||||
})?;
|
||||
let object = resolution.as_object().ok_or_else(|| {
|
||||
LocalRunnerError::invalid("ACPX permission resolution must be an object")
|
||||
})?;
|
||||
let action = object.get("action").and_then(Value::as_str).unwrap_or("");
|
||||
if object.len() != 1
|
||||
|| !matches!(
|
||||
action,
|
||||
"accept" | "accept_for_session" | "decline" | "cancel"
|
||||
)
|
||||
|| !details
|
||||
.get("choices")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|choices| {
|
||||
choices
|
||||
.iter()
|
||||
.any(|choice| choice.get("key").and_then(Value::as_str) == Some(action))
|
||||
})
|
||||
{
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX permission resolution is not an offered choice",
|
||||
));
|
||||
}
|
||||
let mut next_state = self.state.clone();
|
||||
next_state.complete_permission(request_id)?;
|
||||
let response = match self.transport.request(
|
||||
GeneratedAcpxSidecarCommand::PermissionResolve,
|
||||
json!({"requestId":request_id,"turnId":turn_id,"resolution":resolution}),
|
||||
) {
|
||||
Ok(response) => response,
|
||||
Err(error) => return Err(self.fail_closed(error)),
|
||||
};
|
||||
self.verify_resolution(&response, "permission")?;
|
||||
self.state = next_state;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn resolve_input(
|
||||
&mut self,
|
||||
request_id: &str,
|
||||
@@ -786,12 +938,13 @@ impl AcpxProviderSession {
|
||||
&restart_config.transport,
|
||||
&restart_config.agent,
|
||||
)?;
|
||||
let (replacement_identity, _) = match bootstrap(&mut replacement, &restart_config) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
return Err(self.reject_replacement(replacement, error));
|
||||
}
|
||||
};
|
||||
let (replacement_identity, _, turn_controls) =
|
||||
match bootstrap(&mut replacement, &restart_config) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
return Err(self.reject_replacement(replacement, error));
|
||||
}
|
||||
};
|
||||
if replacement_identity != self.identity {
|
||||
return Err(self.reject_replacement(
|
||||
replacement,
|
||||
@@ -801,6 +954,7 @@ impl AcpxProviderSession {
|
||||
));
|
||||
}
|
||||
self.transport = replacement;
|
||||
self.turn_controls = turn_controls;
|
||||
self.transport_terminated = false;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1037,7 +1191,14 @@ impl Drop for AcpxProviderSession {
|
||||
fn bootstrap(
|
||||
transport: &mut AcpxSidecarTransport,
|
||||
config: &AcpxProviderSessionConfig,
|
||||
) -> Result<(AcpxProviderSessionIdentity, AcpxProviderState), LocalRunnerError> {
|
||||
) -> Result<
|
||||
(
|
||||
AcpxProviderSessionIdentity,
|
||||
AcpxProviderState,
|
||||
AcpxTurnControlCapabilities,
|
||||
),
|
||||
LocalRunnerError,
|
||||
> {
|
||||
let sidecar_tools = sidecar_run_tool_operations(&config.tool_set);
|
||||
let initialized = transport.request(
|
||||
GeneratedAcpxSidecarCommand::Initialize,
|
||||
@@ -1055,6 +1216,7 @@ fn bootstrap(
|
||||
"model": config.model,
|
||||
"permissionMode": config.permission_mode,
|
||||
"permissionModePinned": config.permission_mode_pinned,
|
||||
"providerPolicy": config.provider_policy,
|
||||
"systemInstructions": config.system_instructions,
|
||||
"runtimeContext": config.runtime_context,
|
||||
"tools": &sidecar_tools,
|
||||
@@ -1062,6 +1224,7 @@ fn bootstrap(
|
||||
}),
|
||||
)?;
|
||||
let identity = verify_open_response(&opened, transport.process_id(), config)?;
|
||||
let turn_controls = verified_turn_controls(opened.get("turnControls"), &config.agent)?;
|
||||
|
||||
let attached = transport.request(
|
||||
GeneratedAcpxSidecarCommand::RunAttach,
|
||||
@@ -1078,7 +1241,11 @@ fn bootstrap(
|
||||
"ACPX sidecar did not confirm the requested run attachment",
|
||||
));
|
||||
}
|
||||
Ok((identity, AcpxProviderState::new(&config.run_id)?))
|
||||
Ok((
|
||||
identity,
|
||||
AcpxProviderState::new(&config.run_id)?,
|
||||
turn_controls,
|
||||
))
|
||||
}
|
||||
|
||||
fn verify_initialize_response(value: &Value, process_id: u32) -> Result<(), LocalRunnerError> {
|
||||
@@ -1270,6 +1437,35 @@ fn with_cleanup_error(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn turn_controls_require_exact_live_pi_capability_fields() {
|
||||
use super::*;
|
||||
assert_eq!(
|
||||
verified_turn_controls(None, "pi").unwrap(),
|
||||
AcpxTurnControlCapabilities::default()
|
||||
);
|
||||
assert!(
|
||||
verified_turn_controls(Some(&json!({"steering":true,"queuedFollowUp":true})), "pi")
|
||||
.unwrap()
|
||||
.steering
|
||||
);
|
||||
for value in [
|
||||
Value::Null,
|
||||
json!({}),
|
||||
json!({"steering":1,"queuedFollowUp":false}),
|
||||
json!({"steering":true,"queuedFollowUp":true,"extra":true}),
|
||||
] {
|
||||
assert!(verified_turn_controls(Some(&value), "pi").is_err());
|
||||
}
|
||||
for agent in ["codex", "claude", "cursor", "copilot"] {
|
||||
assert!(verified_turn_controls(
|
||||
Some(&json!({"steering":true,"queuedFollowUp":false})),
|
||||
agent
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -61,6 +61,12 @@ pub enum AcpxProviderStateEvent {
|
||||
question_set: Value,
|
||||
origin: Option<Value>,
|
||||
},
|
||||
RuntimeRequestEnded {
|
||||
request_id: String,
|
||||
question_set: Option<Value>,
|
||||
origin: Option<Value>,
|
||||
status: AcpxTurnStatus,
|
||||
},
|
||||
SemanticResult(AcpxSemanticResult),
|
||||
AssistantMessage {
|
||||
turn_id: String,
|
||||
@@ -84,6 +90,7 @@ struct PendingInput {
|
||||
runtime_request_id: String,
|
||||
value_bytes: usize,
|
||||
question_set: Value,
|
||||
origin: Option<Value>,
|
||||
}
|
||||
|
||||
/// Reduces validated sidecar events into bounded provider state.
|
||||
@@ -101,10 +108,11 @@ pub struct AcpxProviderState {
|
||||
assistant_message_id: Option<String>,
|
||||
pending_tools: BTreeMap<String, AcpxPendingTool>,
|
||||
pending_tool_input_bytes: usize,
|
||||
pending_permissions: BTreeMap<String, usize>,
|
||||
pending_permissions: BTreeMap<String, (usize, Value)>,
|
||||
pending_inputs: BTreeMap<String, PendingInput>,
|
||||
pending_runtime_request_bytes: usize,
|
||||
semantic_result: Option<AcpxSemanticResult>,
|
||||
attempted_turn_controls: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl AcpxProviderState {
|
||||
@@ -121,6 +129,7 @@ impl AcpxProviderState {
|
||||
pending_inputs: BTreeMap::new(),
|
||||
pending_runtime_request_bytes: 0,
|
||||
semantic_result: None,
|
||||
attempted_turn_controls: BTreeSet::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -165,6 +174,28 @@ impl AcpxProviderState {
|
||||
|| !self.pending_inputs.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn reserve_turn_control(
|
||||
&mut self,
|
||||
turn_id: &str,
|
||||
control_id: &str,
|
||||
) -> Result<(), LocalRunnerError> {
|
||||
if self.active_turn_id() != Some(turn_id) {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX turn control named a stale or inactive turn",
|
||||
));
|
||||
}
|
||||
if self.attempted_turn_controls.contains(control_id) {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX turn control was already attempted",
|
||||
));
|
||||
}
|
||||
if self.attempted_turn_controls.len() >= 1024 {
|
||||
return Err(LocalRunnerError::invalid("ACPX turn control limit reached"));
|
||||
}
|
||||
self.attempted_turn_controls.insert(control_id.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn begin_turn(&mut self, turn_id: impl Into<String>) -> Result<(), LocalRunnerError> {
|
||||
if self.scope.active_turn_id().is_some()
|
||||
|| !self.pending_tools.is_empty()
|
||||
@@ -185,6 +216,7 @@ impl AcpxProviderState {
|
||||
self.assistant_text.clear();
|
||||
self.assistant_message_id = None;
|
||||
self.semantic_result = None;
|
||||
self.attempted_turn_controls.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -194,6 +226,16 @@ impl AcpxProviderState {
|
||||
) -> Result<Vec<AcpxProviderStateEvent>, LocalRunnerError> {
|
||||
let payload = decode_acpx_event(&self.scope, event)?;
|
||||
match payload {
|
||||
AcpxEventPayload::RichActivity {
|
||||
event_type,
|
||||
payload,
|
||||
} => Ok(vec![AcpxProviderStateEvent::Activity(
|
||||
NormalizedProviderEvent {
|
||||
event_type,
|
||||
priority: crate::durable::EventPriority::P1,
|
||||
payload,
|
||||
},
|
||||
)]),
|
||||
AcpxEventPayload::Runtime {
|
||||
kind,
|
||||
tool_operation,
|
||||
@@ -224,7 +266,7 @@ impl AcpxProviderState {
|
||||
));
|
||||
}
|
||||
self.pending_permissions
|
||||
.insert(request_id.clone(), value_bytes);
|
||||
.insert(request_id.clone(), (value_bytes, details.clone()));
|
||||
self.pending_runtime_request_bytes += value_bytes;
|
||||
Ok(vec![AcpxProviderStateEvent::PermissionRequest {
|
||||
request_id,
|
||||
@@ -238,7 +280,8 @@ impl AcpxProviderState {
|
||||
question_set,
|
||||
origin,
|
||||
} => {
|
||||
let value_bytes = value_bytes(&question_set)?;
|
||||
let value_bytes = value_bytes(&question_set)?
|
||||
+ origin.as_ref().map(value_bytes).transpose()?.unwrap_or(0);
|
||||
self.admit_runtime_request(&request_id, value_bytes)?;
|
||||
let runtime_request_id = project_acpx_runtime_request_id(&request_id)
|
||||
.expect("a decoded ACPX input request has a bounded identity");
|
||||
@@ -260,6 +303,7 @@ impl AcpxProviderState {
|
||||
runtime_request_id,
|
||||
value_bytes,
|
||||
question_set: question_set.clone(),
|
||||
origin: origin.clone(),
|
||||
},
|
||||
)
|
||||
.is_some()
|
||||
@@ -320,8 +364,8 @@ impl AcpxProviderState {
|
||||
.expect("a decoded terminal event has a turn binding")
|
||||
.to_owned();
|
||||
self.scope.clear_turn(&turn_id)?;
|
||||
let mut events = self.end_pending_runtime_requests(status);
|
||||
self.clear_pending_requests();
|
||||
let mut events = Vec::new();
|
||||
if status == AcpxTurnStatus::Completed && !self.assistant_text.is_empty() {
|
||||
events.push(AcpxProviderStateEvent::AssistantMessage {
|
||||
turn_id: turn_id.clone(),
|
||||
@@ -379,7 +423,7 @@ impl AcpxProviderState {
|
||||
}
|
||||
|
||||
pub fn complete_permission(&mut self, request_id: &str) -> Result<(), LocalRunnerError> {
|
||||
let value_bytes = self.pending_permissions.remove(request_id).ok_or_else(|| {
|
||||
let (value_bytes, _) = self.pending_permissions.remove(request_id).ok_or_else(|| {
|
||||
LocalRunnerError::invalid("ACPX permission result has no pending request")
|
||||
})?;
|
||||
self.pending_runtime_request_bytes = self
|
||||
@@ -388,6 +432,12 @@ impl AcpxProviderState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn pending_permission(&self, request_id: &str) -> Option<&Value> {
|
||||
self.pending_permissions
|
||||
.get(request_id)
|
||||
.map(|(_, details)| details)
|
||||
}
|
||||
|
||||
pub fn complete_input(&mut self, request_id: &str) -> Result<(), LocalRunnerError> {
|
||||
let provider_request_id = self
|
||||
.pending_inputs
|
||||
@@ -571,6 +621,31 @@ impl AcpxProviderState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn end_pending_runtime_requests(
|
||||
&mut self,
|
||||
status: AcpxTurnStatus,
|
||||
) -> Vec<AcpxProviderStateEvent> {
|
||||
let mut events = Vec::new();
|
||||
for (request_id, _) in std::mem::take(&mut self.pending_permissions) {
|
||||
events.push(AcpxProviderStateEvent::RuntimeRequestEnded {
|
||||
request_id,
|
||||
question_set: None,
|
||||
origin: None,
|
||||
status,
|
||||
});
|
||||
}
|
||||
for (request_id, pending) in std::mem::take(&mut self.pending_inputs) {
|
||||
events.push(AcpxProviderStateEvent::RuntimeRequestEnded {
|
||||
request_id,
|
||||
question_set: Some(pending.question_set),
|
||||
origin: pending.origin,
|
||||
status,
|
||||
});
|
||||
}
|
||||
self.pending_runtime_request_bytes = 0;
|
||||
events
|
||||
}
|
||||
|
||||
fn clear_pending_requests(&mut self) {
|
||||
self.pending_tools.clear();
|
||||
self.pending_tool_input_bytes = 0;
|
||||
|
||||
@@ -99,9 +99,12 @@ impl AcpxSidecarTransport {
|
||||
"claude" => &["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"],
|
||||
"codex" => &["OPENAI_API_KEY", "CODEX_API_KEY"],
|
||||
"grok" => &["XAI_API_KEY", "PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET"],
|
||||
"pi" => &["OPENROUTER_API_KEY"],
|
||||
"cursor" => &["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"],
|
||||
"copilot" => &["COPILOT_GITHUB_TOKEN"],
|
||||
_ => {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX sidecar credentials require a qualified claude, codex, or grok agent",
|
||||
"ACPX sidecar credentials require a known agent profile",
|
||||
))
|
||||
}
|
||||
};
|
||||
@@ -128,6 +131,11 @@ impl AcpxSidecarTransport {
|
||||
"PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT",
|
||||
"PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST",
|
||||
];
|
||||
if matches!(agent, "pi" | "cursor" | "copilot") {
|
||||
// Credential values alone are not proof of an explicit task binding.
|
||||
// The sidecar checks this controller-minted provider/session marker.
|
||||
keys.push("PAPERCLIP_ACPX_CREDENTIAL_BINDING");
|
||||
}
|
||||
keys.extend_from_slice(credential_keys);
|
||||
Self::start_with_environment_keys(config, &keys)
|
||||
}
|
||||
@@ -688,6 +696,9 @@ fn response_error_classification(error: &ResponseError) -> &'static str {
|
||||
"AGENT_STARTUP_FAILED.OTHER" => return "agent_startup_other",
|
||||
"AGENT_DISCONNECTED" => return "agent_disconnected",
|
||||
"AUTH_REQUIRED" => return "authentication_required",
|
||||
"COPILOT_AUTH_REQUIRED" => return "authentication_required",
|
||||
"COPILOT_ENTITLEMENT_DENIED" => return "provider_entitlement_denied",
|
||||
"COPILOT_MODEL_UNAVAILABLE" => return "requested_model_unsupported",
|
||||
"SESSION_RESUME_REQUIRED" => return "session_resume_required",
|
||||
"SESSION_MODE_REPLAY_FAILED" => return "session_mode_replay_failed",
|
||||
"SESSION_MODEL_REPLAY_FAILED" => return "session_model_replay_failed",
|
||||
@@ -798,6 +809,30 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_auth_diagnostics_use_only_closed_codes_and_never_provider_text() {
|
||||
for (code, expected) in [
|
||||
("COPILOT_AUTH_REQUIRED", "authentication_required"),
|
||||
("COPILOT_ENTITLEMENT_DENIED", "provider_entitlement_denied"),
|
||||
("COPILOT_MODEL_UNAVAILABLE", "requested_model_unsupported"),
|
||||
("COPILOT_AUTH_REQUIRED_EXTRA", "unclassified"),
|
||||
("COPILOT_REQUEST_FAILED", "unclassified"),
|
||||
("UNKNOWN_CANDIDATE_FAILURE", "unclassified"),
|
||||
] {
|
||||
let error = ResponseError {
|
||||
code: code.to_owned(),
|
||||
message:
|
||||
"private-token-canary COPILOT_AUTH_REQUIRED https://user:secret@example.invalid"
|
||||
.to_owned(),
|
||||
retryable: false,
|
||||
};
|
||||
let classification = response_error_classification(&error);
|
||||
assert_eq!(classification, expected);
|
||||
assert!(!classification.contains("canary"));
|
||||
assert!(!classification.contains("secret"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_only_allowlisted_internal_sidecar_failures() {
|
||||
let error = |code: &str, message: &str| ResponseError {
|
||||
|
||||
@@ -148,6 +148,8 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
"url": std::env::var("PAPERCLIP_NATIVE_MCP_URL").ok(),
|
||||
"hasToken": std::env::var("PAPERCLIP_NATIVE_MCP_TOKEN").is_ok(),
|
||||
"hasUnrelatedSecret": std::env::var("UNRELATED_EVAL_SECRET").is_ok(),
|
||||
"credentialBinding": std::env::var("PAPERCLIP_ACPX_CREDENTIAL_BINDING").ok(),
|
||||
"credentialKeys": (["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", "OPENAI_API_KEY", "CODEX_API_KEY", "OPENROUTER_API_KEY", "CURSOR_API_KEY", "CURSOR_AUTH_TOKEN", "COPILOT_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"].into_iter().filter(|key| std::env::var(key).is_ok()).collect::<Vec<_>>()),
|
||||
}
|
||||
}),
|
||||
)?;
|
||||
@@ -204,6 +206,10 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
| "goals"
|
||||
| "bootstrap-wrong-model"
|
||||
| "bootstrap-wrong-run"
|
||||
| "controls"
|
||||
| "controls-wrong-ack"
|
||||
| "controls-lazy"
|
||||
| "controls-downgrade"
|
||||
| "turns"
|
||||
| "turns-wrong-turn"
|
||||
| "turns-wrong-cancel"
|
||||
@@ -226,6 +232,8 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
| "turns-mismatched-reserved-result-terminal"
|
||||
| "turns-unauthorized-tool"
|
||||
| "turns-permission"
|
||||
| "permissions-interactive"
|
||||
| "permissions-wrong-ack"
|
||||
| "resolutions"
|
||||
| "resolutions-error-redaction"
|
||||
| "resolutions-projected-id"
|
||||
@@ -387,7 +395,12 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
)?;
|
||||
next_sequence += 1;
|
||||
}
|
||||
if command == "turn.start" && mode == "turns-permission" {
|
||||
if command == "turn.start"
|
||||
&& matches!(
|
||||
mode,
|
||||
"turns-permission" | "permissions-interactive" | "permissions-wrong-ack"
|
||||
)
|
||||
{
|
||||
write_turn_event(
|
||||
&mut stdout,
|
||||
next_sequence,
|
||||
@@ -398,6 +411,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
"requestId":"permission-1",
|
||||
"kind":"execute",
|
||||
"title":"Run a command?",
|
||||
"choices":[{"key":"accept","label":"Allow once"},{"key":"cancel","label":"Cancel"}],
|
||||
}),
|
||||
)?;
|
||||
next_sequence += 1;
|
||||
@@ -671,6 +685,9 @@ fn bootstrap_success(
|
||||
profile_digest: &str,
|
||||
) -> Value {
|
||||
if command == "permission.resolve" {
|
||||
if mode.starts_with("permissions-") {
|
||||
return json!({"protocolVersion": GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION, "id": id, "ok":true, "result":{"resolved": mode != "permissions-wrong-ack"}});
|
||||
}
|
||||
return json!({
|
||||
"protocolVersion": GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,
|
||||
"id": id,
|
||||
@@ -717,6 +734,7 @@ fn bootstrap_success(
|
||||
"providerLifetimeFenceCandidates": [60001, 60002, 60003],
|
||||
},
|
||||
"status": {},
|
||||
"turnControls": {"steering": matches!(mode, "controls" | "controls-wrong-ack" | "controls-downgrade"), "queuedFollowUp":matches!(mode, "controls" | "controls-wrong-ack" | "controls-downgrade")},
|
||||
})
|
||||
}
|
||||
"run.attach" => json!({
|
||||
@@ -724,8 +742,13 @@ fn bootstrap_success(
|
||||
"catalogRevision": params.get("catalogRevision"),
|
||||
}),
|
||||
"turn.start" => json!({
|
||||
"turnControls": {"steering": matches!(mode, "controls" | "controls-wrong-ack" | "controls-lazy"), "queuedFollowUp":matches!(mode, "controls" | "controls-wrong-ack" | "controls-lazy")},
|
||||
"turnId": if mode == "turns-wrong-turn" { "wrong-turn" } else { params.get("turnId").and_then(Value::as_str).unwrap_or("missing") },
|
||||
}),
|
||||
"turn.steer" => json!({
|
||||
"accepted": true, "turnId": params.get("turnId"), "controlId": params.get("controlId"),
|
||||
"mode": if mode == "controls-wrong-ack" { json!("wrong") } else { params["mode"].clone() },
|
||||
}),
|
||||
"turn.cancel" => json!({"cancelled":mode != "turns-wrong-cancel"}),
|
||||
"session.suspend" => json!({
|
||||
"suspended":mode != "suspend-wrong-ack",
|
||||
|
||||
+10
-3
@@ -33,9 +33,9 @@ fn send(value: Value) -> io::Result<()> {
|
||||
stdout.flush()
|
||||
}
|
||||
|
||||
fn send_split_event_burst(state: &FakeState) -> io::Result<()> {
|
||||
fn send_split_event_burst(state: &FakeState, count: usize) -> io::Result<()> {
|
||||
let turn_id = state.active_turn_id.as_deref().unwrap_or("provider-turn-1");
|
||||
for index in 0..96 {
|
||||
for index in 0..count {
|
||||
send(json!({
|
||||
"method": "item/agentMessage/delta",
|
||||
"params": {
|
||||
@@ -619,6 +619,13 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let emit_runtime_elicitation = args.iter().any(|value| value == "--runtime-elicitation");
|
||||
let emit_structured_activity = args.iter().any(|value| value == "--structured-activity");
|
||||
let emit_split_event_burst = args.iter().any(|value| value == "--split-event-burst");
|
||||
let split_event_prefix_count = argument(&args, "--split-event-prefix-count")
|
||||
.map(|value| value.parse::<usize>())
|
||||
.transpose()?
|
||||
.unwrap_or(96);
|
||||
if !(1..=4096).contains(&split_event_prefix_count) {
|
||||
return Err("split event prefix count must be between 1 and 4096".into());
|
||||
}
|
||||
let split_event_suffix_count = argument(&args, "--split-event-suffix-count")
|
||||
.map(|value| value.parse::<usize>())
|
||||
.transpose()?
|
||||
@@ -1669,7 +1676,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
send_structured_activity(&state)?;
|
||||
finish_turn(&state_path, &mut state, "completed")?;
|
||||
} else if emit_split_event_burst {
|
||||
send_split_event_burst(&state)?;
|
||||
send_split_event_burst(&state, split_event_prefix_count)?;
|
||||
} else if emit_question {
|
||||
send_question(&state)?;
|
||||
} else if !hold_turn {
|
||||
|
||||
@@ -3787,11 +3787,17 @@ fn codex_question_set(
|
||||
}
|
||||
let option_id = format!("option-{}", index + 1);
|
||||
labels.insert(option_id.clone(), label.chars().take(240).collect());
|
||||
Some(json!({
|
||||
let mut canonical_option = json!({
|
||||
"id": option_id,
|
||||
"label": label.chars().take(240).collect::<String>(),
|
||||
"description": option.get("description").and_then(Value::as_str).map(|value| value.chars().take(1000).collect::<String>()),
|
||||
}))
|
||||
});
|
||||
// Native descriptions are optional/nullable. Canonical input
|
||||
// permits an omitted description or a string, never null.
|
||||
if let Some(description) = option.get("description").and_then(Value::as_str) {
|
||||
canonical_option["description"] =
|
||||
json!(description.chars().take(1000).collect::<String>());
|
||||
}
|
||||
Some(canonical_option)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !options.is_empty() && canonical_options.len() != options.len() {
|
||||
@@ -4628,6 +4634,44 @@ done
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_optional_option_descriptions_produce_schema_valid_resolvable_input() {
|
||||
let (_, question_set, option_labels) = codex_question_set(
|
||||
&json!(42),
|
||||
&json!({"questions":[{
|
||||
"id":"environment", "question":"Where?",
|
||||
"options":[
|
||||
{"label":"Staging"},
|
||||
{"label":"Production", "description":null},
|
||||
{"label":"Preview", "description":"Temporary deployment"}
|
||||
]
|
||||
}]}),
|
||||
)
|
||||
.unwrap();
|
||||
let options = question_set["questions"][0]["options"].as_array().unwrap();
|
||||
assert!(options[0].get("description").is_none());
|
||||
assert!(options[1].get("description").is_none());
|
||||
assert_eq!(options[2]["description"], "Temporary deployment");
|
||||
let pending = PendingRuntimeRequest {
|
||||
rpc_id: json!(42),
|
||||
turn_id: "turn-1".to_owned(),
|
||||
method: "item/tool/requestUserInput".to_owned(),
|
||||
params: Value::Null,
|
||||
question_set,
|
||||
option_labels,
|
||||
retained_bytes: 0,
|
||||
};
|
||||
for (index, label) in ["Staging", "Production", "Preview"].iter().enumerate() {
|
||||
// The canonical response validator also validates the retained
|
||||
// question-set schema, just as durable presentation does.
|
||||
let native = codex_question_response(&pending, &json!({
|
||||
"schema":"paperclip.question_response.v1",
|
||||
"answers":{"environment":{"selectedOptionIds":[format!("option-{}", index + 1)]}}
|
||||
})).unwrap();
|
||||
assert_eq!(native["answers"]["environment"]["answers"], json!([label]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_codex_questions_and_responses_without_provider_leakage() {
|
||||
let (request_id, question_set, labels) = codex_question_set(
|
||||
|
||||
@@ -16,8 +16,8 @@ pub use runner::{
|
||||
TerminalDeliveryReconciliation,
|
||||
};
|
||||
pub(crate) use state::{
|
||||
create_private_temporary_file, open_private_regular_file, redact_text,
|
||||
sanitize_semantic_tool_input, sanitize_value, verify_private_directory,
|
||||
create_private_temporary_file, open_private_regular_file, redact_sensitive_text_values,
|
||||
redact_text, sanitize_semantic_tool_input, sanitize_value, verify_private_directory,
|
||||
};
|
||||
pub use state::{
|
||||
Command, CommandDisposition, DurableState, DurableStateStore, EventPriority,
|
||||
|
||||
@@ -1996,6 +1996,125 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_persistence_failure_with_blocked_drain_remains_indeterminate_on_replay() {
|
||||
struct PersistenceFailedResponseExecutor {
|
||||
delivered_responses: usize,
|
||||
retained_reads: usize,
|
||||
}
|
||||
impl CommandExecutor for PersistenceFailedResponseExecutor {
|
||||
fn execute(
|
||||
&mut self,
|
||||
command: &Command,
|
||||
) -> Result<CommandExecution, DurableRunnerError> {
|
||||
assert_eq!(command.command_type, "request.resolve");
|
||||
assert_eq!(command.payload["requestId"], "input-1");
|
||||
// Model a real pipe delivery ACK followed by an uncertain
|
||||
// provider-state write. Its staged settlement is not durable.
|
||||
self.delivered_responses += 1;
|
||||
Err(DurableRunnerError::invalid(
|
||||
"ACPX response state persistence failed after delivery",
|
||||
))
|
||||
}
|
||||
|
||||
fn retained_events(&mut self) -> Result<Vec<PolledEvent>, DurableRunnerError> {
|
||||
self.retained_reads += 1;
|
||||
Err(DurableRunnerError::invalid(
|
||||
"ACPX persistence latch blocks retained settlement",
|
||||
))
|
||||
}
|
||||
|
||||
fn poll_events(&mut self) -> Result<Vec<PolledEvent>, DurableRunnerError> {
|
||||
panic!("failed response settlement must not poll or restore the provider")
|
||||
}
|
||||
|
||||
fn acknowledge_events(&mut self, _: usize) -> Result<(), DurableRunnerError> {
|
||||
panic!("an uncertain settlement must not receive a journal ACK")
|
||||
}
|
||||
}
|
||||
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
"paperclip-response-persistence-failure-{}",
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
let config = config(directory.clone());
|
||||
let store = DurableStateStore::new(&directory).unwrap();
|
||||
let (mut state, _) = store.load_or_create(&config).unwrap();
|
||||
let mut executor = PersistenceFailedResponseExecutor {
|
||||
delivered_responses: 0,
|
||||
retained_reads: 0,
|
||||
};
|
||||
let mut resolve = command("request.resolve");
|
||||
resolve.payload = json!({
|
||||
"requestId": "input-1",
|
||||
"resolution": {"action": "submit", "response": {"answer": "accepted"}},
|
||||
});
|
||||
|
||||
let failure =
|
||||
process_command(&mut state, &store, &config, &mut executor, &resolve).unwrap_err();
|
||||
assert!(failure
|
||||
.to_string()
|
||||
.starts_with("ACPX response state persistence failed after delivery"));
|
||||
assert!(failure
|
||||
.to_string()
|
||||
.contains("retained failure evidence remains uncommitted"));
|
||||
assert!(failure
|
||||
.to_string()
|
||||
.contains("ACPX persistence latch blocks retained settlement"));
|
||||
assert_eq!(executor.delivered_responses, 1);
|
||||
assert_eq!(executor.retained_reads, 1);
|
||||
assert_eq!(
|
||||
state.processed_commands[&resolve.command_id].status,
|
||||
"pending"
|
||||
);
|
||||
assert!(state.outbox.is_empty());
|
||||
assert_eq!(state.highest_source_seq(), 0);
|
||||
|
||||
// Inspect the actual journal before recovery changes pending into
|
||||
// indeterminate. Neither a failed/completed result nor a resolved event
|
||||
// may replace the pre-effect marker while the provider snapshot is unsure.
|
||||
let journal: DurableState =
|
||||
serde_json::from_slice(&fs::read(store.path()).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
journal.processed_commands[&resolve.command_id].status,
|
||||
"pending"
|
||||
);
|
||||
assert!(journal.outbox.is_empty());
|
||||
assert_eq!(journal.highest_source_seq(), 0);
|
||||
|
||||
let pending = process_command(&mut state, &store, &config, &mut executor, &resolve)
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(pending.status, "pending");
|
||||
assert_eq!(executor.delivered_responses, 1);
|
||||
assert_eq!(executor.retained_reads, 1);
|
||||
|
||||
let (mut recovered, existed) = store.load_or_create(&config).unwrap();
|
||||
assert!(existed);
|
||||
assert_eq!(
|
||||
recovered.processed_commands[&resolve.command_id].status,
|
||||
"indeterminate"
|
||||
);
|
||||
let mut replacement = CountingExecutor { calls: 0 };
|
||||
let replay = process_command(&mut recovered, &store, &config, &mut replacement, &resolve)
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(replay.status, "indeterminate");
|
||||
assert_eq!(replay.result["code"], "execution_indeterminate");
|
||||
assert_eq!(
|
||||
replacement.calls, 0,
|
||||
"recovery must never resend the response"
|
||||
);
|
||||
assert!(recovered.outbox.is_empty());
|
||||
let (reloaded_again, _) = store.load_or_create(&config).unwrap();
|
||||
assert_eq!(
|
||||
reloaded_again.processed_commands[&resolve.command_id],
|
||||
replay
|
||||
);
|
||||
assert!(reloaded_again.outbox.is_empty());
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlimited_lifetime_keeps_attempts_bounded_after_weeks_of_work() {
|
||||
let mut config = config(std::env::temp_dir());
|
||||
|
||||
@@ -532,6 +532,8 @@ impl DurableState {
|
||||
"durable identity or validation semantics contain credential-shaped material",
|
||||
));
|
||||
}
|
||||
let sanitized_payload =
|
||||
preserve_bounded_display_content(event_type.as_str(), &payload, sanitized_payload)?;
|
||||
let sanitized_payload =
|
||||
finalize_semantic_tool_input_payload(event_type.as_str(), &payload, sanitized_payload)?;
|
||||
|
||||
@@ -1592,6 +1594,79 @@ pub(crate) fn sanitize_value(value: &Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_bounded_display_value(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Object(object) => Value::Object(
|
||||
object
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
let value = if protocol_authorization_boundary(key, value) {
|
||||
value.clone()
|
||||
} else if sensitive_key(key, value) {
|
||||
Value::String("[REDACTED]".to_owned())
|
||||
} else {
|
||||
sanitize_bounded_display_value(value)
|
||||
};
|
||||
(key.clone(), value)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
Value::Array(values) => {
|
||||
Value::Array(values.iter().map(sanitize_bounded_display_value).collect())
|
||||
}
|
||||
Value::String(text) => Value::String(redact_sensitive_text_values(text)),
|
||||
value => value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn preserve_bounded_display_content(
|
||||
event_type: &str,
|
||||
original: &Value,
|
||||
mut sanitized: Value,
|
||||
) -> Result<Value, DurableRunnerError> {
|
||||
use crate::acpx_event_payload::{has_bounded_rich_display_shape, validate_question_set};
|
||||
|
||||
// Rich display payloads are already closed and byte-bounded by their exact
|
||||
// protocol schemas. Applying a diagnostic preview cap again would silently
|
||||
// change complete output into a truncated artifact without its metadata.
|
||||
if has_bounded_rich_display_shape(event_type, original) {
|
||||
let preserved = sanitize_bounded_display_value(original);
|
||||
if !has_bounded_rich_display_shape(event_type, &preserved) {
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"redacted display activity exceeds its schema bounds",
|
||||
));
|
||||
}
|
||||
return Ok(preserved);
|
||||
}
|
||||
if matches!(
|
||||
event_type,
|
||||
"runtime_request.created" | "runtime_request.expired" | "runtime_request.cancelled"
|
||||
) {
|
||||
if let Some(input) = original.pointer("/request/input") {
|
||||
if input.get("schema").and_then(Value::as_str) == Some("paperclip.question_set.v1") {
|
||||
validate_question_set(input)
|
||||
.map_err(|error| DurableRunnerError::invalid(error.to_string()))?;
|
||||
let mut preserved = sanitize_bounded_display_value(input);
|
||||
if &preserved != input {
|
||||
let description = preserved
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
preserved["description"] = Value::String(format!(
|
||||
"Sensitive values were redacted from this request.\n\n{description}"
|
||||
));
|
||||
}
|
||||
validate_question_set(&preserved)
|
||||
.map_err(|error| DurableRunnerError::invalid(error.to_string()))?;
|
||||
if let Some(target) = sanitized.pointer_mut("/request/input") {
|
||||
*target = preserved;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(sanitized)
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_semantic_tool_input(
|
||||
operation_id: &str,
|
||||
input: &Value,
|
||||
@@ -1741,7 +1816,7 @@ pub(crate) fn redact_text(input: &str) -> String {
|
||||
redacted
|
||||
}
|
||||
|
||||
fn redact_sensitive_text_values(input: &str) -> String {
|
||||
pub(crate) fn redact_sensitive_text_values(input: &str) -> String {
|
||||
redact_sensitive_text_values_with_context(input, false)
|
||||
}
|
||||
|
||||
@@ -3146,6 +3221,78 @@ mod tests {
|
||||
assert_eq!(payload["diagnostic"], json!("token=[REDACTED]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_display_content_preserves_complete_utf8_plans_and_tool_output() {
|
||||
let mut config = config(PathBuf::from("unused"));
|
||||
config.max_outbox_bytes = 512 * 1024;
|
||||
config.max_frame_bytes = 256 * 1024;
|
||||
let mut state = DurableState::new(&config);
|
||||
let text = "Review 漢字\n".repeat(2_000);
|
||||
let input = json!({
|
||||
"schema":"paperclip.question_set.v1",
|
||||
"description":text,
|
||||
"questions":[{"id":"revision-123","prompt":"Approve?","required":true,"answerMode":"single_select","options":[{"id":"accept","label":"Accept"},{"id":"reject","label":"Reject"}]}]
|
||||
});
|
||||
state.enqueue_event(&config, "runtime_request.created", EventPriority::P0, json!({
|
||||
"request":{"schema":"paperclip.runtime_request.v2","requestId":"request-1","type":"input","status":"pending","input":input},
|
||||
"diagnostic":text
|
||||
})).unwrap();
|
||||
let stored = state.outbox[0]
|
||||
.envelope
|
||||
.pointer("/payload/payload")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
stored.pointer("/request/input/description"),
|
||||
Some(&json!(text))
|
||||
);
|
||||
assert!(stored["diagnostic"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.ends_with("…[truncated]"));
|
||||
|
||||
state.enqueue_event(&config, "tool.execution.completed", EventPriority::P1, json!({
|
||||
"schema":"paperclip.tool.execution.v1","executionId":"tool-1","transport":"builtin","operation":"read","status":"completed",
|
||||
"output":text,"outputBytes":text.len(),"outputTruncated":false,"outputDigest":null
|
||||
})).unwrap();
|
||||
let stored = state.outbox[1]
|
||||
.envelope
|
||||
.pointer("/payload/payload")
|
||||
.unwrap();
|
||||
assert_eq!(stored["output"], text);
|
||||
assert_eq!(stored["outputTruncated"], false);
|
||||
|
||||
for event_type in ["runtime_request.expired", "runtime_request.cancelled"] {
|
||||
state.enqueue_event(&config, event_type, EventPriority::P0, json!({
|
||||
"requestId":"request-1", "replayAllowed":false,
|
||||
"request":{"schema":"paperclip.runtime_request.v2","requestId":"request-1","type":"input","status":"pending","input":input}
|
||||
})).unwrap();
|
||||
assert_eq!(
|
||||
state
|
||||
.outbox
|
||||
.last()
|
||||
.unwrap()
|
||||
.envelope
|
||||
.pointer("/payload/payload/request/input/description"),
|
||||
Some(&json!(text))
|
||||
);
|
||||
}
|
||||
|
||||
let mut oversized = input;
|
||||
oversized["description"] = json!("漢".repeat(70_000));
|
||||
assert!(state
|
||||
.enqueue_event(
|
||||
&config,
|
||||
"runtime_request.created",
|
||||
EventPriority::P0,
|
||||
json!({
|
||||
"request":{"input":oversized}
|
||||
})
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("196 KiB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_shaped_question_identity_and_validation_fail_closed() {
|
||||
let config = config(PathBuf::from("unused"));
|
||||
|
||||
+4
@@ -32,6 +32,7 @@ pub enum GeneratedAcpxSidecarCommand {
|
||||
RunAttach,
|
||||
TurnStart,
|
||||
TurnCancel,
|
||||
TurnSteer,
|
||||
PermissionResolve,
|
||||
InputResolve,
|
||||
ToolResolve,
|
||||
@@ -52,6 +53,7 @@ impl GeneratedAcpxSidecarCommand {
|
||||
Self::RunAttach => "run.attach",
|
||||
Self::TurnStart => "turn.start",
|
||||
Self::TurnCancel => "turn.cancel",
|
||||
Self::TurnSteer => "turn.steer",
|
||||
Self::PermissionResolve => "permission.resolve",
|
||||
Self::InputResolve => "input.resolve",
|
||||
Self::ToolResolve => "tool.resolve",
|
||||
@@ -70,6 +72,8 @@ impl GeneratedAcpxSidecarCommand {
|
||||
pub enum GeneratedAcpxSidecarEventType {
|
||||
#[serde(rename = "runtime.event")]
|
||||
RuntimeEvent,
|
||||
#[serde(rename = "runtime.rich_event")]
|
||||
RuntimeRichEvent,
|
||||
#[serde(rename = "runtime.permission_requested")]
|
||||
RuntimePermissionRequested,
|
||||
#[serde(rename = "runtime.input_requested")]
|
||||
|
||||
@@ -3185,6 +3185,14 @@ impl CodexCommandExecutor {
|
||||
}
|
||||
|
||||
fn steer_turn(&mut self, payload: &Value) -> Result<CommandExecution, DurableRunnerError> {
|
||||
if payload
|
||||
.get("mode")
|
||||
.is_some_and(|mode| mode.as_str() != Some("steer"))
|
||||
{
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"Codex does not expose queued follow-up through turn.steer",
|
||||
));
|
||||
}
|
||||
let text = payload
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
|
||||
@@ -195,9 +195,47 @@ pub fn project_acpx_state_event(
|
||||
AcpxProviderStateEvent::ToolResult(result) => {
|
||||
Ok(vec![project_acpx_tool_result(context, result)?])
|
||||
}
|
||||
AcpxProviderStateEvent::PermissionRequest { .. } => Err(LocalRunnerError::invalid(
|
||||
"ACPX permission request reached projection outside the pinned runner policy",
|
||||
)),
|
||||
AcpxProviderStateEvent::PermissionRequest {
|
||||
request_id,
|
||||
title,
|
||||
details,
|
||||
..
|
||||
} => {
|
||||
validate_projection_identity(request_id, "permission request", SHORT_STABLE_ID_CHARS)?;
|
||||
let choices = details
|
||||
.get("choices")
|
||||
.and_then(Value::as_array)
|
||||
.filter(|choices| !choices.is_empty() && choices.len() <= 4)
|
||||
.ok_or_else(|| {
|
||||
LocalRunnerError::invalid("ACPX permission request omitted its choices")
|
||||
})?;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for choice in choices {
|
||||
let key = choice.get("key").and_then(Value::as_str).unwrap_or("");
|
||||
if !matches!(key, "accept" | "accept_for_session" | "decline" | "cancel")
|
||||
|| !seen.insert(key)
|
||||
|| choice
|
||||
.get("label")
|
||||
.and_then(Value::as_str)
|
||||
.is_none_or(|label| label.is_empty() || label.len() > 500)
|
||||
{
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX permission request contains invalid choices",
|
||||
));
|
||||
}
|
||||
}
|
||||
one(
|
||||
"runtime_request.created",
|
||||
EventPriority::P0,
|
||||
json!({"request": {
|
||||
"schema":"paperclip.runtime_request.v2", "requestKind":"permission_approval",
|
||||
"requestId":request_id, "turnId":context.turn_id, "itemId":context.item_id,
|
||||
"type":"permission", "status":"pending", "prompt":title, "choices":choices,
|
||||
"details":details,
|
||||
"origin":{"adapter":"acpx-runtime-sidecar","provider":"acpx","method":"session/request_permission"},
|
||||
}}),
|
||||
)
|
||||
}
|
||||
AcpxProviderStateEvent::InputRequest {
|
||||
request_id,
|
||||
question_set,
|
||||
@@ -216,7 +254,7 @@ pub fn project_acpx_state_event(
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.map(|value| bounded_text(value, MAX_TEXT_CHARS))
|
||||
.unwrap_or_else(|| "Codex needs your input".to_owned());
|
||||
.unwrap_or_else(|| "Provider needs your input".to_owned());
|
||||
let origin = project_runtime_request_origin(origin.as_ref())?;
|
||||
one(
|
||||
"runtime_request.created",
|
||||
@@ -237,6 +275,60 @@ pub fn project_acpx_state_event(
|
||||
}),
|
||||
)
|
||||
}
|
||||
AcpxProviderStateEvent::RuntimeRequestEnded {
|
||||
request_id,
|
||||
question_set,
|
||||
origin,
|
||||
status,
|
||||
} => {
|
||||
let cancelled = matches!(
|
||||
status,
|
||||
AcpxTurnStatus::Cancelled | AcpxTurnStatus::Interrupted
|
||||
);
|
||||
let reason = match status {
|
||||
AcpxTurnStatus::Completed => "turn_completed",
|
||||
AcpxTurnStatus::Failed => "provider_process_lost",
|
||||
_ => "explicit_cancellation",
|
||||
};
|
||||
let (request_id, request) = if let Some(question_set) = question_set {
|
||||
let created = project_acpx_state_event(
|
||||
context,
|
||||
&AcpxProviderStateEvent::InputRequest {
|
||||
request_id: request_id.clone(),
|
||||
question_set: question_set.clone(),
|
||||
origin: origin.clone(),
|
||||
},
|
||||
)?;
|
||||
let request = created[0].payload["request"].clone();
|
||||
(request["requestId"].clone(), Some(request))
|
||||
} else {
|
||||
validate_projection_identity(
|
||||
request_id,
|
||||
"permission request",
|
||||
SHORT_STABLE_ID_CHARS,
|
||||
)?;
|
||||
(json!(request_id), None)
|
||||
};
|
||||
let mut payload = json!({
|
||||
"provider":"acpx", "requestId":request_id,
|
||||
"requestKind": if request.is_some() {"runtime"} else {"permission_approval"},
|
||||
"requestType": if request.is_some() {"input"} else {"permission"},
|
||||
"turnId":context.turn_id, "itemId":context.item_id,
|
||||
"reason":reason, "replayAllowed":false, "adapter":"acpx-runtime-sidecar",
|
||||
});
|
||||
if let Some(request) = request {
|
||||
payload["request"] = request;
|
||||
}
|
||||
one(
|
||||
if cancelled {
|
||||
"runtime_request.cancelled"
|
||||
} else {
|
||||
"runtime_request.expired"
|
||||
},
|
||||
EventPriority::P0,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
AcpxProviderStateEvent::SemanticResult(result) => {
|
||||
validate_semantic_projection_identity(&result.call_id, &result.operation_id)?;
|
||||
if is_reserved_terminal_operation(&result.operation_id) {
|
||||
@@ -1130,6 +1222,40 @@ fn normalize_acpx_status(
|
||||
)]
|
||||
}
|
||||
|
||||
/// Matches the TypeScript ACP display-name parser. This is presentation metadata;
|
||||
/// semantic dispatch and the preclassified operation never derive authority from it.
|
||||
fn acpx_mcp_tool_identity(value: &str) -> Option<(&str, &str)> {
|
||||
let has_line_terminator = |text: &str| text.contains(['\n', '\r', '\u{2028}', '\u{2029}']);
|
||||
if value
|
||||
.get(..5)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("mcp__"))
|
||||
{
|
||||
let rest = &value[5..];
|
||||
if !has_line_terminator(rest) {
|
||||
// The namespace is nonempty and the first eligible separator wins,
|
||||
// including overlapping separators when the namespace starts with `_`.
|
||||
for (index, _) in rest.char_indices().skip(1) {
|
||||
if let Some(name) = rest[index..]
|
||||
.strip_prefix("__")
|
||||
.filter(|name| !name.is_empty())
|
||||
{
|
||||
return Some((&rest[..index], name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if value
|
||||
.get(..4)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("mcp."))
|
||||
{
|
||||
let (namespace, name) = value[4..].split_once('.')?;
|
||||
if !namespace.is_empty() && !name.is_empty() && !has_line_terminator(name) {
|
||||
return Some((namespace, name));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn normalize_acpx_tool_call(
|
||||
payload: &Value,
|
||||
item_id: &str,
|
||||
@@ -1138,8 +1264,9 @@ fn normalize_acpx_tool_call(
|
||||
let native_status = string(payload.get("status"));
|
||||
let status = provider_status(native_status, native_status == "completed");
|
||||
let terminal = status != "running";
|
||||
let raw_title = string(payload.get("title"));
|
||||
let title = bounded_text(raw_title, 240);
|
||||
let raw_title = string(payload.get("title")).trim();
|
||||
let mcp = acpx_mcp_tool_identity(raw_title);
|
||||
let name = bounded_text(mcp.map_or(raw_title, |(_, name)| name), 240);
|
||||
let output = match payload.get("rawOutput").or_else(|| payload.get("output")) {
|
||||
Some(Value::String(value)) => value.clone(),
|
||||
Some(value) => serde_json::to_string(value).unwrap_or_default(),
|
||||
@@ -1148,11 +1275,11 @@ fn normalize_acpx_tool_call(
|
||||
let mut normalized = json!({
|
||||
"schema": "paperclip.tool.execution.v1",
|
||||
"executionId": item_id,
|
||||
"transport": "builtin",
|
||||
"transport": if mcp.is_some() { "mcp" } else { "builtin" },
|
||||
"operation": operation,
|
||||
"name": if title.is_empty() { Value::Null } else { Value::String(title) },
|
||||
"name": if name.is_empty() { Value::Null } else { Value::String(name) },
|
||||
"target": safe_acpx_location(payload.pointer("/locations/0"), operation == "edit"),
|
||||
"namespace": Value::Null,
|
||||
"namespace": mcp.map(|(namespace, _)| bounded_text(namespace, 240)),
|
||||
"readOnly": matches!(operation, "read" | "search" | "list"),
|
||||
"status": status,
|
||||
"durationMs": Value::Null,
|
||||
|
||||
@@ -23,6 +23,24 @@ fn project(event: AcpxProviderStateEvent) -> Vec<NormalizedProviderEvent> {
|
||||
project_acpx_state_event(&context(), &event).unwrap()
|
||||
}
|
||||
|
||||
fn canonical_request_branch(request_kind: &str, request_type: &str) -> Value {
|
||||
let schema: Value = serde_json::from_str(include_str!(
|
||||
"../../../../protocol/schemas/request.schema.json"
|
||||
))
|
||||
.unwrap();
|
||||
schema["oneOf"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|branch| {
|
||||
branch["properties"]["schema"]["const"] == "paperclip.runtime_request.v2"
|
||||
&& branch["properties"]["requestKind"]["const"] == request_kind
|
||||
&& branch["properties"]["type"]["const"] == request_type
|
||||
})
|
||||
.expect("canonical v2 request branch")
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn reduced_semantic_result(
|
||||
call_id: &str,
|
||||
operation_id: &str,
|
||||
@@ -366,7 +384,32 @@ fn projects_assistant_terminal_and_diagnostic_events_fail_closed() {
|
||||
assert!(project_acpx_state_event(&context(), &permission)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("pinned runner policy"));
|
||||
.contains("omitted its choices"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_only_the_permission_choices_offered_by_the_provider() {
|
||||
let events = project(AcpxProviderStateEvent::PermissionRequest {
|
||||
request_id: "permission-1".to_owned(),
|
||||
kind: "write".to_owned(),
|
||||
title: "Edit source".to_owned(),
|
||||
details: json!({"choices":[{"key":"accept","label":"Allow once"},{"key":"cancel","label":"Cancel"}]}),
|
||||
});
|
||||
assert_eq!(events[0].event_type, "runtime_request.created");
|
||||
let schema = canonical_request_branch("permission_approval", "permission");
|
||||
let validator = jsonschema::validator_for(&schema).unwrap();
|
||||
assert!(validator.is_valid(&events[0].payload["request"]));
|
||||
assert_eq!(
|
||||
events[0].payload["request"]["requestKind"],
|
||||
"permission_approval"
|
||||
);
|
||||
assert_eq!(
|
||||
events[0].payload["request"]["choices"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -506,11 +549,7 @@ fn deterministically_projects_bounded_upstream_request_ids() {
|
||||
|
||||
#[test]
|
||||
fn runtime_request_projection_preserves_durable_identity_boundaries() {
|
||||
let canonical_request_schema: Value = serde_json::from_str(include_str!(
|
||||
"../../../../protocol/schemas/request.schema.json"
|
||||
))
|
||||
.unwrap();
|
||||
let mut runtime_request_schema = canonical_request_schema["oneOf"][0].clone();
|
||||
let mut runtime_request_schema = canonical_request_branch("runtime", "input");
|
||||
// This test owns identity projection. The question-set validator has its
|
||||
// own coverage, so replace its remote reference with an unconstrained
|
||||
// local schema before compiling the canonical runtime-request branch.
|
||||
|
||||
@@ -51,6 +51,7 @@ fn config(directory: &std::path::Path) -> AcpxProviderSessionConfig {
|
||||
working_directory: directory.to_owned(),
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
permission_mode_pinned: true,
|
||||
provider_policy: None,
|
||||
system_instructions: "Complete the supplied task.".to_owned(),
|
||||
runtime_context: serde_json::Value::Null,
|
||||
tool_set: AuthorizedToolSet {
|
||||
|
||||
@@ -336,6 +336,131 @@ fn does_not_label_non_usd_acpx_cost_as_usd() {
|
||||
assert_eq!(usage[0].payload["cumulative"]["providerCostUsd"], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_typescript_mcp_display_identity_across_tool_lifecycle() {
|
||||
for (title, namespace, name) in [
|
||||
(
|
||||
"mcp__paperclip__write_document",
|
||||
"paperclip",
|
||||
"write_document",
|
||||
),
|
||||
(
|
||||
"mcp.paperclip.write_document",
|
||||
"paperclip",
|
||||
"write_document",
|
||||
),
|
||||
(
|
||||
" MCP__Paperclip__write_document ",
|
||||
"Paperclip",
|
||||
"write_document",
|
||||
),
|
||||
("McP.other.read_document", "other", "read_document"),
|
||||
("mcp__paperclip__nested__tool", "paperclip", "nested__tool"),
|
||||
("mcp.paperclip.nested.tool", "paperclip", "nested.tool"),
|
||||
("mcp_____write_document", "_", "write_document"),
|
||||
("mcp__é__write_document", "é", "write_document"),
|
||||
] {
|
||||
for (tag, status, event_type) in [
|
||||
("tool_call", "pending", "tool.execution.started"),
|
||||
(
|
||||
"tool_call_update",
|
||||
"in_progress",
|
||||
"tool.execution.progressed",
|
||||
),
|
||||
("tool_call_update", "completed", "tool.execution.completed"),
|
||||
] {
|
||||
let events = normalize_acpx_runtime_event(
|
||||
AcpxRuntimeEventKind::ToolCall,
|
||||
&json!({
|
||||
"type": "tool_call", "tag": tag, "status": status,
|
||||
"toolCallId": "write-plan", "title": title,
|
||||
"rawInput": {"private": "not-for-the-log"},
|
||||
"operationId": "finish_task", "semantic_tool": {"phase": "result"}
|
||||
}),
|
||||
Some("execute"),
|
||||
"event-7",
|
||||
"turn-1",
|
||||
3,
|
||||
);
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].event_type, event_type);
|
||||
let payload = &events[0].payload;
|
||||
assert_eq!(payload["executionId"], "write-plan");
|
||||
assert_eq!(payload["transport"], "mcp");
|
||||
assert_eq!(payload["namespace"], namespace);
|
||||
assert_eq!(payload["name"], name);
|
||||
// Parsing the display title never changes preclassified operation authority.
|
||||
assert_eq!(payload["operation"], "execute");
|
||||
assert_eq!(payload["readOnly"], false);
|
||||
for dropped in ["rawInput", "operationId", "semantic_tool"] {
|
||||
assert!(payload.get(dropped).is_none());
|
||||
}
|
||||
assert!(!payload.to_string().contains("not-for-the-log"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_mcp_namespace_and_name_independently_after_parsing() {
|
||||
let namespace = "é".repeat(300);
|
||||
let events = normalize(
|
||||
AcpxRuntimeEventKind::ToolCall,
|
||||
json!({"type": "tool_call", "title": format!("mcp__{namespace}__write_document")}),
|
||||
);
|
||||
assert_eq!(events[0].payload["transport"], "mcp");
|
||||
assert_eq!(events[0].payload["name"], "write_document");
|
||||
assert_eq!(
|
||||
events[0].payload["namespace"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.chars()
|
||||
.count(),
|
||||
240
|
||||
);
|
||||
let events = normalize(
|
||||
AcpxRuntimeEventKind::ToolCall,
|
||||
json!({"type": "tool_call", "title": format!("mcp.paperclip.{}", "雪".repeat(300))}),
|
||||
);
|
||||
assert_eq!(events[0].payload["namespace"], "paperclip");
|
||||
assert_eq!(
|
||||
events[0].payload["name"].as_str().unwrap().chars().count(),
|
||||
240
|
||||
);
|
||||
for title in [
|
||||
"mcp__Authorization: Bearer namespace-secret__write_document",
|
||||
"mcp.paperclip.Authorization: Bearer tool-secret",
|
||||
] {
|
||||
let events = normalize(
|
||||
AcpxRuntimeEventKind::ToolCall,
|
||||
json!({"type": "tool_call", "title": title}),
|
||||
);
|
||||
assert!(!events[0].payload.to_string().contains("namespace-secret"));
|
||||
assert!(!events[0].payload.to_string().contains("tool-secret"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_builtin_and_malformed_mcp_titles_as_display_only_builtin_names() {
|
||||
for title in [
|
||||
"Read file",
|
||||
"write_document",
|
||||
"paperclip__write_document",
|
||||
"mcp____write_document",
|
||||
"mcp__paperclip__",
|
||||
"mcp..write_document",
|
||||
"mcp.paperclip.",
|
||||
"mcp__paperclip__write\ndocument",
|
||||
] {
|
||||
let events = normalize(
|
||||
AcpxRuntimeEventKind::ToolCall,
|
||||
json!({"type": "tool_call", "title": title}),
|
||||
);
|
||||
assert_eq!(events[0].payload["transport"], "builtin");
|
||||
assert!(events[0].payload["namespace"].is_null());
|
||||
assert_eq!(events[0].payload["name"], title);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_tool_lifecycle_and_preserves_safe_display_paths() {
|
||||
let started = normalize(
|
||||
|
||||
+40
@@ -50,6 +50,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig {
|
||||
working_directory: std::env::temp_dir(),
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
permission_mode_pinned: true,
|
||||
provider_policy: None,
|
||||
system_instructions: "Complete the supplied task.".to_owned(),
|
||||
runtime_context: serde_json::Value::Null,
|
||||
tool_set: tool_set(),
|
||||
@@ -260,3 +261,42 @@ fn fails_closed_when_a_resolution_is_not_acknowledged() {
|
||||
assert!(error.contains("did not confirm tool resolution"), "{error}");
|
||||
assert!(session.shutdown("already closed").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_permission_requires_an_offered_action_and_acknowledgement() {
|
||||
for mode in ["permissions-interactive", "permissions-wrong-ack"] {
|
||||
let mut cfg = config(mode);
|
||||
cfg.agent = "claude".to_owned();
|
||||
cfg.model = "claude-sonnet-5".to_owned();
|
||||
let mut session = AcpxProviderSession::start(&cfg).unwrap();
|
||||
session
|
||||
.start_turn("turn-1", "Run validation", &std::env::temp_dir())
|
||||
.unwrap();
|
||||
session.poll_event(Duration::from_secs(1)).unwrap().unwrap();
|
||||
assert!(session.state().pending_permission("permission-1").is_some());
|
||||
assert!(session
|
||||
.resolve_permission("permission-1", "old-turn", &json!({"action":"accept"}))
|
||||
.is_err());
|
||||
assert!(session
|
||||
.resolve_permission(
|
||||
"permission-1",
|
||||
"turn-1",
|
||||
&json!({"action":"accept_for_session"})
|
||||
)
|
||||
.is_err());
|
||||
assert!(session.state().pending_permission("permission-1").is_some());
|
||||
let result =
|
||||
session.resolve_permission("permission-1", "turn-1", &json!({"action":"accept"}));
|
||||
if mode == "permissions-wrong-ack" {
|
||||
assert!(result.is_err());
|
||||
assert!(session.state().pending_permission("permission-1").is_some());
|
||||
} else {
|
||||
result.unwrap();
|
||||
assert!(session.state().pending_permission("permission-1").is_none());
|
||||
assert!(session
|
||||
.resolve_permission("permission-1", "turn-1", &json!({"action":"accept"}))
|
||||
.is_err());
|
||||
}
|
||||
session.shutdown("verified").unwrap();
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig {
|
||||
working_directory: std::env::temp_dir(),
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
permission_mode_pinned: true,
|
||||
provider_policy: None,
|
||||
system_instructions: "Complete the supplied task.".to_owned(),
|
||||
runtime_context: serde_json::Value::Null,
|
||||
tool_set: tool_set(),
|
||||
@@ -130,8 +131,12 @@ fn bootstraps_a_codex_session_and_confirms_run_identity() {
|
||||
#[test]
|
||||
fn validates_qualified_policy_and_tool_catalog_before_spawning() {
|
||||
let mut invalid_agent = config("bootstrap");
|
||||
invalid_agent.agent = "pi".to_owned();
|
||||
assert!(start_error(&invalid_agent).contains("claude, codex, or grok"));
|
||||
invalid_agent.agent = "unknown".to_owned();
|
||||
assert!(start_error(&invalid_agent).contains("known immutable profile"));
|
||||
|
||||
let mut missing_policy = config("bootstrap");
|
||||
missing_policy.agent = "copilot".to_owned();
|
||||
assert!(start_error(&missing_policy).contains("explicit provider read-only policy"));
|
||||
|
||||
let mut unpinned = config("bootstrap");
|
||||
unpinned.permission_mode_pinned = false;
|
||||
|
||||
@@ -608,3 +608,72 @@ fn mutation_prose_survives_sidecar_decode_pending_state_and_semantic_projection(
|
||||
assert!(!input.to_string().contains("actual-credential"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_requests_expire_with_their_projected_identity_before_terminal_and_never_replay() {
|
||||
for status in ["failed", "cancelled", "interrupted", "completed"] {
|
||||
let mut state = AcpxProviderState::new("run-1").unwrap();
|
||||
state.begin_turn("turn-1").unwrap();
|
||||
let long_id = format!("input-{}", "x".repeat(200));
|
||||
let input = state.accept_event(&event(1, GeneratedAcpxSidecarEventType::RuntimeInputRequested, Some("turn-1"),
|
||||
json!({"requestId":long_id,"questionSet":question_set(),"origin":{"adapter":"acpx-runtime-sidecar","provider":"cursor","method":"cursor/create_plan"}}))).unwrap();
|
||||
state.accept_event(&event(2, GeneratedAcpxSidecarEventType::RuntimePermissionRequested, Some("turn-1"),
|
||||
json!({"requestId":"permission-1","title":"Run validation","choices":[{"key":"cancel","label":"Cancel"}]}))).unwrap();
|
||||
let context = AcpxEventProjectionContext {
|
||||
run_id: "run-1".into(),
|
||||
normalized_session_id: "session-1".into(),
|
||||
turn_id: "turn-1".into(),
|
||||
provider_turn_id: Some("turn-1".into()),
|
||||
item_id: "item-1".into(),
|
||||
};
|
||||
let created = project_acpx_state_event(&context, &input[0]).unwrap();
|
||||
let projected_id = created[0].payload["request"]["requestId"].clone();
|
||||
let terminal = state
|
||||
.accept_event(&event(
|
||||
3,
|
||||
GeneratedAcpxSidecarEventType::RuntimeTurnTerminal,
|
||||
Some("turn-1"),
|
||||
json!({"status":status}),
|
||||
))
|
||||
.unwrap();
|
||||
let projected: Vec<_> = terminal
|
||||
.iter()
|
||||
.flat_map(|event| project_acpx_state_event(&context, event).unwrap())
|
||||
.collect();
|
||||
assert_eq!(projected.len(), 3);
|
||||
assert_eq!(projected[0].payload["requestId"], "permission-1");
|
||||
assert_eq!(projected[1].payload["requestId"], projected_id);
|
||||
assert_eq!(projected[1].payload["request"]["input"], question_set());
|
||||
assert_eq!(
|
||||
projected[1].payload["request"]["origin"]["method"],
|
||||
"cursor/create_plan"
|
||||
);
|
||||
for ended in &projected[..2] {
|
||||
assert_eq!(ended.payload["replayAllowed"], false);
|
||||
assert_eq!(
|
||||
ended.event_type,
|
||||
if matches!(status, "cancelled" | "interrupted") {
|
||||
"runtime_request.cancelled"
|
||||
} else {
|
||||
"runtime_request.expired"
|
||||
}
|
||||
);
|
||||
}
|
||||
assert!(projected[2].event_type.starts_with("turn."));
|
||||
assert!(state
|
||||
.pending_question_set(projected_id.as_str().unwrap())
|
||||
.is_none());
|
||||
assert!(state.complete_permission("permission-1").is_err());
|
||||
assert!(state
|
||||
.complete_input(projected_id.as_str().unwrap())
|
||||
.is_err());
|
||||
assert!(state
|
||||
.accept_event(&event(
|
||||
4,
|
||||
GeneratedAcpxSidecarEventType::RuntimeTurnTerminal,
|
||||
Some("turn-1"),
|
||||
json!({"status":status})
|
||||
))
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig {
|
||||
working_directory: std::env::temp_dir(),
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
permission_mode_pinned: true,
|
||||
provider_policy: None,
|
||||
system_instructions: "Complete the supplied task.".to_owned(),
|
||||
runtime_context: serde_json::Value::Null,
|
||||
tool_set: AuthorizedToolSet {
|
||||
|
||||
@@ -36,8 +36,18 @@ fn config(mode: &str) -> AcpxProviderSessionConfig {
|
||||
request_timeout: Duration::from_secs(1),
|
||||
shutdown_grace: Duration::from_millis(100),
|
||||
},
|
||||
agent: "codex".to_owned(),
|
||||
model: "gpt-5.6-sol".to_owned(),
|
||||
agent: if mode.starts_with("controls") {
|
||||
"pi"
|
||||
} else {
|
||||
"codex"
|
||||
}
|
||||
.to_owned(),
|
||||
model: if mode.starts_with("controls") {
|
||||
"openrouter/deepseek/deepseek-v4-flash-0731"
|
||||
} else {
|
||||
"gpt-5.6-sol"
|
||||
}
|
||||
.to_owned(),
|
||||
run_id: "run-1".to_owned(),
|
||||
catalog_revision: 1,
|
||||
runtime_directory: std::env::temp_dir(),
|
||||
@@ -45,6 +55,15 @@ fn config(mode: &str) -> AcpxProviderSessionConfig {
|
||||
working_directory: std::env::temp_dir(),
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
permission_mode_pinned: true,
|
||||
provider_policy: if mode.starts_with("controls") {
|
||||
Some(
|
||||
paperclip_runner_core::acpx_provider_session::AcpxProviderRuntimePolicy {
|
||||
read_only: false,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
system_instructions: "Complete the supplied task.".to_owned(),
|
||||
runtime_context: serde_json::Value::Null,
|
||||
tool_set: tool_set(),
|
||||
@@ -693,3 +712,69 @@ fn fails_closed_before_returning_an_unauthorized_tool_call() {
|
||||
assert!(session.state().pending_tool("call-1").is_none());
|
||||
assert!(session.shutdown("already closed").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_controls_preserve_mode_and_reject_stale_duplicate_and_oversized_delivery() {
|
||||
let mut session = AcpxProviderSession::start(&config("controls")).unwrap();
|
||||
assert!(session
|
||||
.steer_turn("turn-1", "control-1", "steer", "message")
|
||||
.is_err());
|
||||
session
|
||||
.start_turn("turn-1", "Work", &std::env::temp_dir())
|
||||
.unwrap();
|
||||
let first = session
|
||||
.steer_turn("turn-1", "control-1", "steer", "Change focus")
|
||||
.unwrap();
|
||||
assert_eq!(first["mode"], "steer");
|
||||
assert!(session
|
||||
.steer_turn("turn-1", "control-1", "follow_up", "Duplicate")
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("already attempted"));
|
||||
let queued = session
|
||||
.steer_turn("turn-1", "control-2", "follow_up", "Then validate")
|
||||
.unwrap();
|
||||
assert_eq!(queued["mode"], "follow_up");
|
||||
assert!(session
|
||||
.steer_turn("turn-2", "control-3", "steer", "Stale")
|
||||
.is_err());
|
||||
assert!(session
|
||||
.steer_turn("turn-1", "control-3", "cancel", "Wrong")
|
||||
.is_err());
|
||||
assert!(session
|
||||
.steer_turn("turn-1", "control-3", "steer", &"a".repeat(65_537))
|
||||
.is_err());
|
||||
session.shutdown("verified controls").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_controls_fail_closed_on_mismatched_acknowledgement() {
|
||||
let mut session = AcpxProviderSession::start(&config("controls-wrong-ack")).unwrap();
|
||||
session
|
||||
.start_turn("turn-1", "Work", &std::env::temp_dir())
|
||||
.unwrap();
|
||||
assert!(session
|
||||
.steer_turn("turn-1", "control-1", "steer", "Change focus")
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("exact turn control"));
|
||||
assert!(session
|
||||
.steer_turn("turn-1", "control-2", "follow_up", "Do not replay")
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lazy_warm_handshake_updates_live_turn_control_discovery() {
|
||||
let mut session = AcpxProviderSession::start(&config("controls-lazy")).unwrap();
|
||||
assert!(!session.turn_control_capabilities().steering);
|
||||
assert!(!session.turn_control_capabilities().queued_follow_up);
|
||||
session
|
||||
.start_turn("turn-lazy", "Work", &std::env::temp_dir())
|
||||
.unwrap();
|
||||
assert!(session.turn_control_capabilities().steering);
|
||||
assert!(session.turn_control_capabilities().queued_follow_up);
|
||||
session
|
||||
.steer_turn("turn-lazy", "control-1", "follow_up", "Then validate")
|
||||
.unwrap();
|
||||
session.shutdown("verified live handshake").unwrap();
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
use paperclip_runner_core::acpx_event_payload::{decode_acpx_event, AcpxEventPayload};
|
||||
use paperclip_runner_core::acpx_event_scope::AcpxEventScope;
|
||||
use paperclip_runner_core::acpx_provider_state::{AcpxProviderState, AcpxProviderStateEvent};
|
||||
use paperclip_runner_core::acpx_sidecar_transport::AcpxSidecarEvent;
|
||||
use paperclip_runner_core::durable::EventPriority;
|
||||
use paperclip_runner_core::generated_acpx_sidecar_contract::GeneratedAcpxSidecarEventType;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn scope() -> AcpxEventScope {
|
||||
let mut scope = AcpxEventScope::new("run-1").unwrap();
|
||||
scope.bind_turn("turn-1").unwrap();
|
||||
scope
|
||||
}
|
||||
fn event(event_type: &str, payload: Value) -> AcpxSidecarEvent {
|
||||
AcpxSidecarEvent {
|
||||
sequence: 1,
|
||||
event_type: GeneratedAcpxSidecarEventType::RuntimeRichEvent,
|
||||
run_id: Some("run-1".to_owned()),
|
||||
turn_id: Some("turn-1".to_owned()),
|
||||
payload: json!({"eventType":event_type,"itemId":"display-1","payload":payload}),
|
||||
}
|
||||
}
|
||||
fn plan() -> Value {
|
||||
json!({"schema":"paperclip.plan.updated.v1","planId":"plan-1","revision":2,"explanation":"Changed order","steps":[{"stepId":"step-1","body":"Inspect","status":"completed"},{"stepId":"step-2","body":"Build","status":"in_progress"}],"complete":false,"syncStatus":"not_applicable","documentRevision":null})
|
||||
}
|
||||
fn artifact() -> Value {
|
||||
json!({"schema":"paperclip.artifact.generated.v1","artifactId":"image-1","status":"completed","reference":"outputs/diagram.png","mediaType":"image/png","registered":false,"failure":null})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_activity_preserves_plans_children_and_artifact_metadata_without_mutation_authority() {
|
||||
let mut state = AcpxProviderState::new("run-1").unwrap();
|
||||
state.begin_turn("turn-1").unwrap();
|
||||
for (event_type, payload) in [
|
||||
("plan.updated", plan()),
|
||||
("artifact.generated", artifact()),
|
||||
(
|
||||
"delegation.completed",
|
||||
json!({"schema":"paperclip.delegation.v1","delegationId":"task-1","action":"spawn","status":"completed","children":[{"childId":"child-1","role":"explore","model":"small-model","status":"completed","summary":"Found the implementation","activitySummary":"Searched src; duration 310 ms"}]}),
|
||||
),
|
||||
] {
|
||||
let projected = state
|
||||
.accept_event(&event(event_type, payload.clone()))
|
||||
.unwrap();
|
||||
assert_eq!(projected.len(), 1);
|
||||
let AcpxProviderStateEvent::Activity(activity) = &projected[0] else {
|
||||
panic!("rich event became an authority-bearing event");
|
||||
};
|
||||
assert_eq!(activity.event_type, event_type);
|
||||
assert_eq!(activity.priority, EventPriority::P1);
|
||||
assert_eq!(activity.payload, payload);
|
||||
assert!(!state.has_pending_requests());
|
||||
assert_eq!(state.active_turn_id(), Some("turn-1"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_activity_rejects_terminal_semantic_source_and_schema_injection() {
|
||||
for event_type in [
|
||||
"turn.completed",
|
||||
"run.result",
|
||||
"run.completed",
|
||||
"semantic_tool.input",
|
||||
"semantic_tool.result",
|
||||
"runtime.input_requested",
|
||||
"workspace.changed",
|
||||
"harness.diagnostic",
|
||||
"unknown.event",
|
||||
] {
|
||||
assert!(
|
||||
decode_acpx_event(&scope(), &event(event_type, plan())).is_err(),
|
||||
"{event_type}"
|
||||
);
|
||||
}
|
||||
assert!(decode_acpx_event(&scope(), &event("artifact.generated", plan())).is_err());
|
||||
for field in ["source", "sourceRef", "runId", "priority"] {
|
||||
let mut injected = event("plan.updated", plan());
|
||||
injected.payload[field] = json!("forged");
|
||||
assert!(decode_acpx_event(&scope(), &injected).is_err());
|
||||
}
|
||||
let mut extra = plan();
|
||||
extra["sourceRef"] = json!("file:///private/key");
|
||||
assert!(decode_acpx_event(&scope(), &event("plan.updated", extra)).is_err());
|
||||
let mut registered = artifact();
|
||||
registered["registered"] = json!(true);
|
||||
assert!(decode_acpx_event(&scope(), &event("artifact.generated", registered)).is_err());
|
||||
let mut synchronized = plan();
|
||||
synchronized["syncStatus"] = json!("synchronized");
|
||||
synchronized["documentRevision"] = json!(1);
|
||||
assert!(decode_acpx_event(&scope(), &event("plan.updated", synchronized)).is_err());
|
||||
for path in [
|
||||
"/private/key",
|
||||
"../outside",
|
||||
"file:///private/key",
|
||||
"C:\\private\\key",
|
||||
"outputs/evil\u{0000}.png",
|
||||
] {
|
||||
let mut escaped = artifact();
|
||||
escaped["reference"] = json!(path);
|
||||
assert!(
|
||||
decode_acpx_event(&scope(), &event("artifact.generated", escaped)).is_err(),
|
||||
"{path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_activity_requires_the_current_run_and_active_turn() {
|
||||
let mut event = event("plan.updated", plan());
|
||||
event.run_id = Some("old-run".to_owned());
|
||||
assert!(decode_acpx_event(&scope(), &event).is_err());
|
||||
event.run_id = None;
|
||||
assert!(decode_acpx_event(&scope(), &event).is_err());
|
||||
event.run_id = Some("run-1".to_owned());
|
||||
event.turn_id = Some("old-turn".to_owned());
|
||||
assert!(decode_acpx_event(&scope(), &event).is_err());
|
||||
event.turn_id = None;
|
||||
assert!(decode_acpx_event(&scope(), &event).is_err());
|
||||
event.turn_id = Some("turn-1".to_owned());
|
||||
let mut settled = scope();
|
||||
settled.clear_turn("turn-1").unwrap();
|
||||
assert!(decode_acpx_event(&settled, &event).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_activity_is_byte_bounded_and_preserves_long_schema_bounded_output() {
|
||||
let text = "Unicode 漢字\n".repeat(1_000);
|
||||
let payload = json!({"schema":"paperclip.tool.execution.v1","executionId":"tool-1","transport":"builtin","operation":"read","status":"completed","output":text,"outputBytes":text.len(),"outputTruncated":false,"outputDigest":null});
|
||||
let AcpxEventPayload::RichActivity { payload, .. } =
|
||||
decode_acpx_event(&scope(), &event("tool.execution.completed", payload)).unwrap()
|
||||
else {
|
||||
panic!("not rich activity");
|
||||
};
|
||||
assert_eq!(payload["output"], text);
|
||||
let mut oversized = plan();
|
||||
oversized["steps"] = json!((0..100).map(|index|json!({"stepId":format!("step-{index}"),"body":"漢".repeat(3_000),"status":"pending"})).collect::<Vec<_>>());
|
||||
assert!(
|
||||
decode_acpx_event(&scope(), &event("plan.updated", oversized))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("256 KiB")
|
||||
);
|
||||
}
|
||||
|
||||
fn question_event(description: String) -> AcpxSidecarEvent {
|
||||
AcpxSidecarEvent {
|
||||
sequence: 1,
|
||||
event_type: GeneratedAcpxSidecarEventType::RuntimeInputRequested,
|
||||
run_id: Some("run-1".to_owned()),
|
||||
turn_id: Some("turn-1".to_owned()),
|
||||
payload: json!({"requestId":"plan-request","questionSet":{"schema":"paperclip.question_set.v1","description":description,"questions":[{"id":"revision-123","prompt":"Approve?","required":true,"answerMode":"single_select","options":[{"id":"accept","label":"Accept"},{"id":"reject","label":"Reject"}]}]}}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_plan_presentation_survives_utf8_redaction_without_diagnostic_truncation() {
|
||||
let description = "Review 漢字\n".repeat(2_000);
|
||||
let AcpxEventPayload::InputRequested { question_set, .. } =
|
||||
decode_acpx_event(&scope(), &question_event(description.clone())).unwrap()
|
||||
else {
|
||||
panic!("not input");
|
||||
};
|
||||
assert_eq!(question_set["description"], description);
|
||||
let secret = format!("{description}\napi_key=topsecret\nFinal action");
|
||||
let AcpxEventPayload::InputRequested { question_set, .. } =
|
||||
decode_acpx_event(&scope(), &question_event(secret)).unwrap()
|
||||
else {
|
||||
panic!("not input");
|
||||
};
|
||||
let displayed = question_set["description"].as_str().unwrap();
|
||||
assert!(displayed.starts_with("Sensitive values were redacted"));
|
||||
assert!(displayed.ends_with("Final action"));
|
||||
assert!(!displayed.contains("topsecret"));
|
||||
assert!(!displayed.contains("[truncated]"));
|
||||
assert!(
|
||||
decode_acpx_event(&scope(), &question_event("漢".repeat(70_000)))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("196 KiB")
|
||||
);
|
||||
}
|
||||
+38
-1
@@ -233,12 +233,32 @@ fn assigned_gateway_binding_reaches_qualified_sidecar_without_unrelated_secrets(
|
||||
"fixture-token-never-returned-in-test-output",
|
||||
)
|
||||
.env("UNRELATED_EVAL_SECRET", "must-not-cross-boundary")
|
||||
.env(
|
||||
"PAPERCLIP_ACPX_CREDENTIAL_BINDING",
|
||||
"controller-session-binding",
|
||||
)
|
||||
.envs(
|
||||
[
|
||||
"ANTHROPIC_API_KEY",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"OPENAI_API_KEY",
|
||||
"CODEX_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"CURSOR_API_KEY",
|
||||
"CURSOR_AUTH_TOKEN",
|
||||
"COPILOT_GITHUB_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
"GH_TOKEN",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|key| (key, "fixture-credential")),
|
||||
)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success(), "isolated gateway environment test failed");
|
||||
return;
|
||||
}
|
||||
for agent in ["claude", "codex"] {
|
||||
for agent in ["claude", "codex", "pi", "cursor", "copilot"] {
|
||||
let mut sidecar = AcpxSidecarTransport::start_for_agent(
|
||||
&AcpxSidecarTransportConfig {
|
||||
command: PathBuf::from(env!("CARGO_BIN_EXE_fake-acpx-sidecar")),
|
||||
@@ -260,6 +280,23 @@ fn assigned_gateway_binding_reaches_qualified_sidecar_without_unrelated_secrets(
|
||||
"assigned gateway credential was dropped"
|
||||
);
|
||||
assert_eq!(response["hasUnrelatedSecret"], false);
|
||||
let expected = match agent {
|
||||
"claude" => vec!["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"],
|
||||
"codex" => vec!["OPENAI_API_KEY", "CODEX_API_KEY"],
|
||||
"pi" => vec!["OPENROUTER_API_KEY"],
|
||||
"cursor" => vec!["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"],
|
||||
"copilot" => vec!["COPILOT_GITHUB_TOKEN"],
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert_eq!(response["credentialKeys"], json!(expected));
|
||||
assert_eq!(
|
||||
response["credentialBinding"],
|
||||
if matches!(agent, "pi" | "cursor" | "copilot") {
|
||||
json!("controller-session-binding")
|
||||
} else {
|
||||
serde_json::Value::Null
|
||||
}
|
||||
);
|
||||
sidecar.shutdown().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -851,16 +851,21 @@ fn codex_rejects_replay_of_a_completed_tool_call_id_in_the_same_turn() {
|
||||
.start_turn("Inspect the fake task once.", &config.cwd)
|
||||
.expect("start provider turn");
|
||||
|
||||
let first_call = (0..32)
|
||||
.find_map(|_| match provider.poll().expect("poll first tool call") {
|
||||
Some(CodexProviderEvent::ToolCall {
|
||||
call_id,
|
||||
operation_id,
|
||||
..
|
||||
}) => Some((call_id, operation_id)),
|
||||
_ => None,
|
||||
})
|
||||
.expect("observe the first semantic tool call");
|
||||
let first_call_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
let first_call =
|
||||
std::iter::from_fn(|| (std::time::Instant::now() < first_call_deadline).then_some(()))
|
||||
.find_map(|_| match provider.poll().expect("poll first tool call") {
|
||||
Some(CodexProviderEvent::ToolCall {
|
||||
call_id,
|
||||
operation_id,
|
||||
..
|
||||
}) => Some((call_id, operation_id)),
|
||||
_ => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
None
|
||||
}
|
||||
})
|
||||
.expect("observe the first semantic tool call");
|
||||
provider
|
||||
.deliver_tool_result(&ToolResult {
|
||||
call_id: first_call.0,
|
||||
@@ -870,9 +875,17 @@ fn codex_rejects_replay_of_a_completed_tool_call_id_in_the_same_turn() {
|
||||
})
|
||||
.expect("deliver the first semantic result");
|
||||
|
||||
let replay_error = (0..32)
|
||||
.find_map(|_| provider.poll().err())
|
||||
.expect("same-turn replay of the completed call id is rejected");
|
||||
let replay_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
let replay_error =
|
||||
std::iter::from_fn(|| (std::time::Instant::now() < replay_deadline).then_some(()))
|
||||
.find_map(|_| {
|
||||
let error = provider.poll().err();
|
||||
if error.is_none() {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
error
|
||||
})
|
||||
.expect("same-turn replay of the completed call id is rejected");
|
||||
assert!(
|
||||
replay_error
|
||||
.to_string()
|
||||
|
||||
+609
-9
@@ -16,6 +16,8 @@ use sha2::{Digest, Sha256};
|
||||
|
||||
const CODEX_ACPX_DIGEST: &str =
|
||||
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3";
|
||||
const PI_ACPX_DIGEST: &str =
|
||||
"sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f";
|
||||
|
||||
fn temporary_directory(label: &str) -> PathBuf {
|
||||
let nonce = SystemTime::now()
|
||||
@@ -76,6 +78,18 @@ fn acpx_config(state_dir: &Path, mode: &str) -> DurableRunnerConfig {
|
||||
config
|
||||
}
|
||||
|
||||
fn pi_acpx_config(state_dir: &Path, mode: &str) -> DurableRunnerConfig {
|
||||
let mut config = acpx_config(state_dir, mode);
|
||||
*config
|
||||
.acpx_launch_profile
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.args
|
||||
.last_mut()
|
||||
.unwrap() = PI_ACPX_DIGEST.to_owned();
|
||||
config
|
||||
}
|
||||
|
||||
fn qualified_artifact(path: PathBuf) -> QualifiedLaunchArtifact {
|
||||
QualifiedLaunchArtifact {
|
||||
sha256: format!("sha256:{:x}", Sha256::digest(fs::read(&path).unwrap())),
|
||||
@@ -211,6 +225,527 @@ fn prepare_payload_with_mode(directory: &Path, agent: &str, mode: &str) -> Value
|
||||
})
|
||||
}
|
||||
|
||||
fn pi_prepare_payload(directory: &Path, mode: &str) -> Value {
|
||||
let mut payload = prepare_payload_with_mode(directory, "pi", mode);
|
||||
let provider = &mut payload["provider"];
|
||||
provider["model"] = json!("openrouter/deepseek/deepseek-v4-flash-0731");
|
||||
provider["agentServerPackage"] = json!("pi-acp");
|
||||
provider["agentServerVersion"] = json!("0.0.33");
|
||||
provider["agentRuntimePackage"] = json!("@earendil-works/pi-coding-agent");
|
||||
provider["agentRuntimeVersion"] = json!("0.84.2");
|
||||
provider["commandDigest"] = json!(PI_ACPX_DIGEST);
|
||||
provider["sidecarArgs"][3] = json!(PI_ACPX_DIGEST);
|
||||
provider["providerPolicy"] = json!({"readOnly":true});
|
||||
payload
|
||||
}
|
||||
|
||||
fn pending_acpx_runtime_request(
|
||||
directory: &Path,
|
||||
mode: &str,
|
||||
) -> (
|
||||
NativeProviderCommandExecutor,
|
||||
DurableRunnerConfig,
|
||||
String,
|
||||
u32,
|
||||
) {
|
||||
let mut config = acpx_config(directory, mode);
|
||||
let mut payload = prepare_payload_with_mode(directory, "codex", mode);
|
||||
if mode.starts_with("permissions-") {
|
||||
// Codex's pinned runner policy does not allow interactive ACP permissions.
|
||||
let digest = "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a";
|
||||
*config
|
||||
.acpx_launch_profile
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.args
|
||||
.last_mut()
|
||||
.unwrap() = digest.into();
|
||||
let provider = &mut payload["provider"];
|
||||
provider["agent"] = json!("claude");
|
||||
provider["model"] = json!("claude-sonnet-5");
|
||||
provider["agentServerPackage"] = json!("@agentclientprotocol/claude-agent-acp");
|
||||
provider["agentServerVersion"] = json!("0.73.0");
|
||||
provider["agentRuntimePackage"] = json!("@anthropic-ai/claude-agent-sdk");
|
||||
provider["agentRuntimeVersion"] = json!("0.3.280");
|
||||
provider["commandDigest"] = json!(digest);
|
||||
provider["sidecarArgs"][3] = json!(digest);
|
||||
} else {
|
||||
let operations = vec![AuthorizedTool {
|
||||
operation_id: "issues.read".into(),
|
||||
version: 1,
|
||||
description: "Read an issue.".into(),
|
||||
input_schema: json!({"type":"object"}),
|
||||
response_schema: json!({"type":"object"}),
|
||||
}];
|
||||
payload["authorizedTools"]["catalogDigest"] =
|
||||
json!(authorized_tool_catalog_digest(&operations).unwrap());
|
||||
payload["authorizedTools"]["operations"] = json!(operations);
|
||||
}
|
||||
let mut executor = NativeProviderCommandExecutor::with_runner_config(directory, &config);
|
||||
executor
|
||||
.execute(&command(1, "run.prepare", payload))
|
||||
.unwrap();
|
||||
let opened = executor
|
||||
.execute(&command(2, "session.open", json!({})))
|
||||
.unwrap();
|
||||
let process_id = opened.result["processId"].as_u64().unwrap() as u32;
|
||||
executor
|
||||
.execute(&command(
|
||||
3,
|
||||
"turn.start",
|
||||
json!({"text":"Ask before continuing", "turnId":"turn-1"}),
|
||||
))
|
||||
.unwrap();
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(2);
|
||||
loop {
|
||||
let events = executor.poll_events().unwrap();
|
||||
let request_id = events
|
||||
.iter()
|
||||
.find(|event| event.event_type == "runtime_request.created")
|
||||
.and_then(|event| event.payload.pointer("/request/requestId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
executor.acknowledge_events(events.len()).unwrap();
|
||||
if let Some(request_id) = request_id {
|
||||
return (executor, config, request_id, process_id);
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"request was not presented"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn acpx_runtime_resolution(mode: &str, request_id: &str) -> Value {
|
||||
json!({"requestId":request_id, "turnId":"turn-1", "resolution":
|
||||
if mode.starts_with("permissions-") { json!({"action":"accept"}) }
|
||||
else { json!({"action":"submit", "response":{"schema":"paperclip.question_response.v1",
|
||||
"answers":{"target":{"selectedOptionIds":["first"]}}}}) }
|
||||
})
|
||||
}
|
||||
|
||||
fn shutdown_recovered_acpx_fixture(executor: &mut NativeProviderCommandExecutor, directory: &Path) {
|
||||
// The fake sidecar advertises the same three lifetime-fence ports for all
|
||||
// fixtures. Parallel recovered shutdowns hold a quorum through state fsync,
|
||||
// temporarily preventing another fixture from proving cleanup. Retry only
|
||||
// that rejection, using the real production quorum proof on every attempt.
|
||||
// This is fixture synchronization, not a production cleanup workaround.
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
let mut blocked_attempts = 0;
|
||||
loop {
|
||||
match executor.shutdown() {
|
||||
Ok(()) => break,
|
||||
Err(error) => {
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"ACPX original provider lifetime remains active; cleanup is not yet proven",
|
||||
"unexpected recovered fixture shutdown failure"
|
||||
);
|
||||
let persisted: Value = serde_json::from_slice(
|
||||
&fs::read(directory.join("acpx-provider-state.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(persisted["providerExitUnconfirmed"], true);
|
||||
blocked_attempts += 1;
|
||||
if blocked_attempts == 1 {
|
||||
eprintln!("recovered fixture cleanup waiting for the shared lifetime quorum");
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"recovered fixture cleanup never acquired the lifetime quorum"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
if blocked_attempts > 0 {
|
||||
eprintln!("recovered fixture cleanup proved after {blocked_attempts} quorum rejections");
|
||||
}
|
||||
let persisted: Value =
|
||||
serde_json::from_slice(&fs::read(directory.join("acpx-provider-state.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(persisted["providerExitUnconfirmed"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acpx_response_delivery_survives_crash_before_journaling_without_replaying_the_response() {
|
||||
for mode in [
|
||||
"permissions-interactive",
|
||||
"resolutions",
|
||||
"resolutions-projected-id",
|
||||
] {
|
||||
let directory = temporary_directory(mode);
|
||||
let (mut executor, config, request_id, _) = pending_acpx_runtime_request(&directory, mode);
|
||||
let resolve = command(
|
||||
4,
|
||||
"request.resolve",
|
||||
acpx_runtime_resolution(mode, &request_id),
|
||||
);
|
||||
let delivered = executor.execute(&resolve).unwrap();
|
||||
assert_eq!(delivered.result["status"], "delivered");
|
||||
assert!(
|
||||
delivered.events.is_empty(),
|
||||
"settlement must use the durable provider outbox"
|
||||
);
|
||||
let persisted: Value =
|
||||
serde_json::from_slice(&fs::read(directory.join("acpx-provider-state.json")).unwrap())
|
||||
.unwrap();
|
||||
assert!(persisted["pendingRuntimeRequests"]
|
||||
.get(&request_id)
|
||||
.is_none());
|
||||
let retained = executor.retained_events().unwrap();
|
||||
assert_eq!(retained.len(), 1);
|
||||
assert_eq!(retained[0].event_type, "runtime_request.resolved");
|
||||
assert_eq!(retained[0].payload["requestId"], request_id);
|
||||
assert_eq!(
|
||||
persisted["pendingEvents"][0]["executorEventId"],
|
||||
retained[0].executor_event_id
|
||||
);
|
||||
assert!(executor
|
||||
.execute(&command(5, "request.resolve", resolve.payload.clone()))
|
||||
.is_err());
|
||||
assert_eq!(executor.retained_events().unwrap(), retained);
|
||||
|
||||
// Simulate death after the sidecar ACK/state fsync, before the runner
|
||||
// journals this event or acknowledges the provider's retained prefix.
|
||||
drop(executor);
|
||||
let mut recovered = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
let events = recovered.poll_events().unwrap();
|
||||
assert_eq!(events[0], retained[0]);
|
||||
assert_eq!(
|
||||
events
|
||||
.iter()
|
||||
.filter(|event| event.event_type.starts_with("runtime_request."))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(recovered
|
||||
.execute(&command(6, "request.resolve", resolve.payload))
|
||||
.is_err());
|
||||
assert_eq!(recovered.poll_events().unwrap(), events);
|
||||
assert_eq!(
|
||||
recovered
|
||||
.execute(&command(7, "session.snapshot", json!({})))
|
||||
.unwrap()
|
||||
.result["status"],
|
||||
"closed"
|
||||
);
|
||||
// Polling is not an ACK; only the journal's ACK retires the event.
|
||||
recovered.acknowledge_events(events.len()).unwrap();
|
||||
assert!(recovered.poll_events().unwrap().is_empty());
|
||||
drop(recovered);
|
||||
let mut recovered_again =
|
||||
NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
assert!(recovered_again.poll_events().unwrap().is_empty());
|
||||
shutdown_recovered_acpx_fixture(&mut recovered_again, &directory);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acpx_delivered_response_is_acknowledged_once_before_session_close() {
|
||||
for mode in ["permissions-interactive", "resolutions"] {
|
||||
let directory = temporary_directory("response-ack");
|
||||
let (mut executor, _, request_id, _) = pending_acpx_runtime_request(&directory, mode);
|
||||
let response = acpx_runtime_resolution(mode, &request_id);
|
||||
executor
|
||||
.execute(&command(4, "request.resolve", response.clone()))
|
||||
.unwrap();
|
||||
let events = executor.poll_events().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].event_type, "runtime_request.resolved");
|
||||
assert_eq!(executor.poll_events().unwrap(), events);
|
||||
executor.acknowledge_events(1).unwrap();
|
||||
assert!(executor
|
||||
.execute(&command(5, "request.resolve", response))
|
||||
.is_err());
|
||||
executor
|
||||
.execute(&command(6, "session.close", json!({})))
|
||||
.unwrap();
|
||||
assert!(
|
||||
executor.poll_events().unwrap().is_empty(),
|
||||
"close must not expire a delivered request"
|
||||
);
|
||||
executor.shutdown().unwrap();
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acpx_uncommitted_delivery_expires_after_failed_state_write_without_replay() {
|
||||
for mode in ["permissions-interactive", "resolutions"] {
|
||||
let directory = temporary_directory("response-save-failure");
|
||||
let (mut executor, config, request_id, process_id) =
|
||||
pending_acpx_runtime_request(&directory, mode);
|
||||
let state_path = directory.join("acpx-provider-state.json");
|
||||
let previous_path = directory.join("previous-state.json");
|
||||
let previous = fs::read(&state_path).unwrap();
|
||||
fs::rename(&state_path, &previous_path).unwrap();
|
||||
// Force the atomic state replacement to fail after the real sidecar
|
||||
// has acknowledged delivery, without adding production fault hooks.
|
||||
fs::create_dir(&state_path).unwrap();
|
||||
let response = acpx_runtime_resolution(mode, &request_id);
|
||||
let error = executor
|
||||
.execute(&command(4, "request.resolve", response.clone()))
|
||||
.unwrap_err();
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("atomically replace ACPX provider state"));
|
||||
assert_eq!(fs::read(&previous_path).unwrap(), previous);
|
||||
// Neither ordinary polling nor the explicit drain/ACK path may publish
|
||||
// a delivered response whose settlement failed to persist.
|
||||
for result in [
|
||||
executor.retained_events().map(|_| ()),
|
||||
executor.poll_events().map(|_| ()),
|
||||
executor.acknowledge_events(0),
|
||||
executor.acknowledge_events(1),
|
||||
executor
|
||||
.execute(&command(5, "runner.drain", json!({})))
|
||||
.map(|_| ()),
|
||||
executor
|
||||
.execute(&command(6, "request.resolve", response.clone()))
|
||||
.map(|_| ()),
|
||||
executor.shutdown(),
|
||||
] {
|
||||
assert_eq!(result.unwrap_err().to_string(), error.to_string());
|
||||
}
|
||||
#[cfg(unix)]
|
||||
assert!(
|
||||
!std::process::Command::new("kill")
|
||||
.args(["-0", &process_id.to_string()])
|
||||
.env_clear()
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.unwrap()
|
||||
.success(),
|
||||
"failed durable cleanup must still reap the owned sidecar"
|
||||
);
|
||||
assert_eq!(fs::read(&previous_path).unwrap(), previous);
|
||||
fs::remove_dir(&state_path).unwrap();
|
||||
fs::rename(&previous_path, &state_path).unwrap();
|
||||
// Repairing the file path does not authorize this uncertain executor
|
||||
// to resume, retry an approval, or overwrite the recovery snapshot.
|
||||
assert!(executor.poll_events().is_err());
|
||||
assert!(executor.retained_events().is_err());
|
||||
assert!(executor.shutdown().is_err());
|
||||
assert_eq!(fs::read(&state_path).unwrap(), previous);
|
||||
drop(executor);
|
||||
|
||||
let mut recovered = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
let events = recovered.poll_events().unwrap();
|
||||
let requests: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|event| event.event_type.starts_with("runtime_request."))
|
||||
.collect();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].event_type, "runtime_request.expired");
|
||||
assert_eq!(requests[0].payload["requestId"], request_id);
|
||||
assert_eq!(requests[0].payload["replayAllowed"], false);
|
||||
assert!(recovered
|
||||
.execute(&command(5, "request.resolve", response))
|
||||
.is_err());
|
||||
assert_eq!(recovered.poll_events().unwrap(), events);
|
||||
recovered.acknowledge_events(events.len()).unwrap();
|
||||
shutdown_recovered_acpx_fixture(&mut recovered, &directory);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acpx_failed_settlement_ack_replays_the_same_delivered_event_without_expiry() {
|
||||
for mode in ["permissions-interactive", "resolutions"] {
|
||||
let directory = temporary_directory("response-ack-save-failure");
|
||||
let (mut executor, config, request_id, _) = pending_acpx_runtime_request(&directory, mode);
|
||||
let response = acpx_runtime_resolution(mode, &request_id);
|
||||
executor
|
||||
.execute(&command(4, "request.resolve", response.clone()))
|
||||
.unwrap();
|
||||
let committed = executor.poll_events().unwrap();
|
||||
assert_eq!(committed.len(), 1);
|
||||
assert_eq!(committed[0].event_type, "runtime_request.resolved");
|
||||
let state_path = directory.join("acpx-provider-state.json");
|
||||
let previous_path = directory.join("previous-state.json");
|
||||
let previous = fs::read(&state_path).unwrap();
|
||||
fs::rename(&state_path, &previous_path).unwrap();
|
||||
fs::create_dir(&state_path).unwrap();
|
||||
// The journal has committed this executorEventId, but retiring the
|
||||
// provider's retained prefix must not escape a failed state write.
|
||||
let error = executor.acknowledge_events(1).unwrap_err();
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("atomically replace ACPX provider state"));
|
||||
for result in [
|
||||
executor.retained_events().map(|_| ()),
|
||||
executor.poll_events().map(|_| ()),
|
||||
executor.acknowledge_events(0),
|
||||
executor
|
||||
.execute(&command(5, "request.resolve", response.clone()))
|
||||
.map(|_| ()),
|
||||
executor.shutdown(),
|
||||
] {
|
||||
assert_eq!(result.unwrap_err().to_string(), error.to_string());
|
||||
}
|
||||
assert_eq!(fs::read(&previous_path).unwrap(), previous);
|
||||
fs::remove_dir(&state_path).unwrap();
|
||||
fs::rename(&previous_path, &state_path).unwrap();
|
||||
drop(executor);
|
||||
|
||||
let mut recovered = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
let events = recovered.poll_events().unwrap();
|
||||
assert_eq!(events[0], committed[0]);
|
||||
assert_eq!(
|
||||
events
|
||||
.iter()
|
||||
.filter(|event| event.event_type.starts_with("runtime_request."))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(recovered
|
||||
.execute(&command(6, "request.resolve", response))
|
||||
.is_err());
|
||||
assert_eq!(recovered.poll_events().unwrap(), events);
|
||||
recovered.acknowledge_events(events.len()).unwrap();
|
||||
assert!(recovered.poll_events().unwrap().is_empty());
|
||||
shutdown_recovered_acpx_fixture(&mut recovered, &directory);
|
||||
drop(recovered);
|
||||
let mut recovered_again =
|
||||
NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
assert!(recovered_again.poll_events().unwrap().is_empty());
|
||||
recovered_again.shutdown().unwrap();
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acpx_uncommitted_expiry_and_cancellation_cannot_escape_through_poll_or_drain() {
|
||||
for (mode, close) in [
|
||||
("permissions-wrong-ack", false),
|
||||
("permissions-interactive", true),
|
||||
("resolutions", true),
|
||||
] {
|
||||
let directory = temporary_directory("expiry-save-failure");
|
||||
let (mut executor, config, request_id, _) = pending_acpx_runtime_request(&directory, mode);
|
||||
let state_path = directory.join("acpx-provider-state.json");
|
||||
let previous_path = directory.join("previous-state.json");
|
||||
let previous = fs::read(&state_path).unwrap();
|
||||
fs::rename(&state_path, &previous_path).unwrap();
|
||||
fs::create_dir(&state_path).unwrap();
|
||||
let response = acpx_runtime_resolution(mode, &request_id);
|
||||
let error = if close {
|
||||
executor
|
||||
.execute(&command(4, "session.close", json!({})))
|
||||
.unwrap_err()
|
||||
} else {
|
||||
assert!(executor
|
||||
.execute(&command(4, "request.resolve", response.clone()))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("did not confirm permission resolution"));
|
||||
executor.poll_events().unwrap_err()
|
||||
};
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("atomically replace ACPX provider state"));
|
||||
for result in [
|
||||
executor.retained_events().map(|_| ()),
|
||||
executor.poll_events().map(|_| ()),
|
||||
executor.acknowledge_events(1),
|
||||
executor
|
||||
.execute(&command(5, "runner.drain", json!({})))
|
||||
.map(|_| ()),
|
||||
executor
|
||||
.execute(&command(6, "request.resolve", response.clone()))
|
||||
.map(|_| ()),
|
||||
executor.shutdown(),
|
||||
] {
|
||||
assert_eq!(result.unwrap_err().to_string(), error.to_string());
|
||||
}
|
||||
assert_eq!(fs::read(&previous_path).unwrap(), previous);
|
||||
fs::remove_dir(&state_path).unwrap();
|
||||
fs::rename(&previous_path, &state_path).unwrap();
|
||||
assert!(executor.shutdown().is_err());
|
||||
assert_eq!(fs::read(&state_path).unwrap(), previous);
|
||||
drop(executor);
|
||||
|
||||
let mut recovered = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
let events = recovered.poll_events().unwrap();
|
||||
let settlements: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|event| event.event_type.starts_with("runtime_request."))
|
||||
.collect();
|
||||
assert_eq!(settlements.len(), 1);
|
||||
assert_eq!(settlements[0].event_type, "runtime_request.expired");
|
||||
assert_eq!(settlements[0].payload["requestId"], request_id);
|
||||
assert_eq!(settlements[0].payload["replayAllowed"], false);
|
||||
assert!(recovered
|
||||
.execute(&command(7, "request.resolve", response))
|
||||
.is_err());
|
||||
assert_eq!(recovered.poll_events().unwrap(), events);
|
||||
recovered.acknowledge_events(events.len()).unwrap();
|
||||
shutdown_recovered_acpx_fixture(&mut recovered, &directory);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acpx_failed_or_cancelled_responses_retain_one_durable_settlement() {
|
||||
for (mode, close) in [
|
||||
("permissions-wrong-ack", false),
|
||||
("permissions-interactive", true),
|
||||
("resolutions", true),
|
||||
] {
|
||||
let directory = temporary_directory("response-expiry");
|
||||
let (mut executor, config, request_id, _) = pending_acpx_runtime_request(&directory, mode);
|
||||
if close {
|
||||
executor
|
||||
.execute(&command(4, "session.close", json!({})))
|
||||
.unwrap();
|
||||
} else {
|
||||
let error = executor
|
||||
.execute(&command(
|
||||
4,
|
||||
"request.resolve",
|
||||
acpx_runtime_resolution(mode, &request_id),
|
||||
))
|
||||
.unwrap_err();
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("did not confirm permission resolution"));
|
||||
}
|
||||
let events = executor.poll_events().unwrap();
|
||||
let requests: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|event| event.event_type.starts_with("runtime_request."))
|
||||
.collect();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(
|
||||
requests[0].event_type,
|
||||
if close {
|
||||
"runtime_request.cancelled"
|
||||
} else {
|
||||
"runtime_request.expired"
|
||||
}
|
||||
);
|
||||
assert_eq!(requests[0].payload["requestId"], request_id);
|
||||
assert_eq!(requests[0].payload["replayAllowed"], false);
|
||||
drop(executor);
|
||||
let mut recovered = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
assert_eq!(recovered.poll_events().unwrap(), events);
|
||||
assert!(recovered
|
||||
.execute(&command(
|
||||
5,
|
||||
"request.resolve",
|
||||
acpx_runtime_resolution(mode, &request_id)
|
||||
))
|
||||
.is_err());
|
||||
recovered.acknowledge_events(events.len()).unwrap();
|
||||
assert!(recovered.poll_events().unwrap().is_empty());
|
||||
shutdown_recovered_acpx_fixture(&mut recovered, &directory);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_acpx_semantic_disposition_in_the_run_terminal() {
|
||||
let directory = temporary_directory("acpx-blocked");
|
||||
@@ -820,18 +1355,83 @@ fn rejects_opencode_launch_profile_drift_across_fresh_recovery() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_pi_before_starting_a_sidecar() {
|
||||
let directory = temporary_directory("pi");
|
||||
let config = config(&directory);
|
||||
fn rejects_pi_with_an_unqualified_model_before_starting_a_sidecar() {
|
||||
let directory = temporary_directory("pi-model");
|
||||
let config = pi_acpx_config(&directory, "bootstrap");
|
||||
let mut payload = pi_prepare_payload(&directory, "bootstrap");
|
||||
// All Pi distribution and policy fields are correct. Admission must reject
|
||||
// the model itself, not rely on the old blanket exclusion of this harness.
|
||||
payload["provider"]["model"] = json!("gpt-5.6-sol");
|
||||
let mut executor = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
let error = executor
|
||||
.execute(&command(
|
||||
1,
|
||||
"run.prepare",
|
||||
prepare_payload(&directory, "pi"),
|
||||
))
|
||||
.execute(&command(1, "run.prepare", payload))
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("agent pi is not executable"));
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("does not match a qualified immutable profile"));
|
||||
assert!(!directory.join("acpx-runtime").exists());
|
||||
assert!(!directory.join("acpx-provider-state.json").exists());
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishes_only_changed_pi_controls_before_the_new_turn() {
|
||||
for (mode, initially_available, turn_available) in [
|
||||
("controls-lazy", false, true),
|
||||
("controls-downgrade", true, false),
|
||||
("controls", true, true),
|
||||
] {
|
||||
let directory = temporary_directory(mode);
|
||||
let config = pi_acpx_config(&directory, mode);
|
||||
let mut executor = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
executor
|
||||
.execute(&command(
|
||||
1,
|
||||
"run.prepare",
|
||||
pi_prepare_payload(&directory, mode),
|
||||
))
|
||||
.unwrap();
|
||||
let opened = executor
|
||||
.execute(&command(2, "session.open", json!({})))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
opened.events[0].2["providerDescriptor"]["turnControls"],
|
||||
json!({"steering":initially_available, "queuedFollowUp":initially_available})
|
||||
);
|
||||
|
||||
let started = executor
|
||||
.execute(&command(
|
||||
3,
|
||||
"turn.start",
|
||||
json!({
|
||||
"text":"Work", "turnId":"provider-turn-controls"
|
||||
}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(started.result["providerTurnId"], "provider-turn-controls");
|
||||
let expected_types = if initially_available != turn_available {
|
||||
vec!["session.capabilities.updated", "turn.started"]
|
||||
} else {
|
||||
vec!["turn.started"]
|
||||
};
|
||||
assert_eq!(
|
||||
started
|
||||
.events
|
||||
.iter()
|
||||
.map(|event| event.0.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
expected_types
|
||||
);
|
||||
if initially_available != turn_available {
|
||||
assert_eq!(
|
||||
started.events[0].2["turnControls"],
|
||||
json!({"steering":turn_available, "queuedFollowUp":turn_available})
|
||||
);
|
||||
}
|
||||
let turn = &started.events.last().unwrap().2;
|
||||
assert_eq!(turn["providerTurnId"], "provider-turn-controls");
|
||||
assert_eq!(turn["turn"]["id"], "provider-turn-controls");
|
||||
executor.shutdown().unwrap();
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,11 @@ import { tmpdir } from "node:os";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseProviderPackArguments, materializeCandidateProviderPack } from "./candidate-provider-pack.mjs";
|
||||
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const workspaceRoot = resolve(packageRoot, "../..");
|
||||
const outputArgument = process.argv.slice(2).find((value) => value !== "--");
|
||||
const { output: outputArgument, candidates } = parseProviderPackArguments(process.argv.slice(2));
|
||||
const outputRoot = resolve(
|
||||
process.cwd(),
|
||||
outputArgument ?? join(packageRoot, "provider-pack"),
|
||||
@@ -34,7 +35,7 @@ if (
|
||||
throw new Error(`Refusing unsafe provider-pack output path: ${outputRoot}`);
|
||||
}
|
||||
|
||||
const temporaryParent = mkdtempSync(join(tmpdir(), "paperclip-provider-pack-"));
|
||||
const temporaryParent = realpathSync(mkdtempSync(join(tmpdir(), "paperclip-provider-pack-")));
|
||||
const temporaryRoot = join(temporaryParent, "pack");
|
||||
|
||||
function canonicalJson(value) {
|
||||
@@ -163,6 +164,24 @@ try {
|
||||
mkdirSync(dirname(stableNodeCommand), { recursive: true, mode: 0o755 });
|
||||
copyFileSync(process.execPath, stableNodeCommand);
|
||||
chmodSync(stableNodeCommand, 0o755);
|
||||
const relocatedNode = spawnSync(stableNodeCommand, ["--version"], {
|
||||
cwd: temporaryRoot, env: { PATH: "/usr/bin:/bin" }, encoding: "utf8", timeout: 10_000,
|
||||
});
|
||||
if (relocatedNode.status !== 0 || relocatedNode.stdout.trim() !== `v${process.versions.node}`) {
|
||||
throw new Error("Provider pack Node is not portable after relocation; build with a standalone Node distribution");
|
||||
}
|
||||
|
||||
const candidateProviders = {};
|
||||
for (const provider of candidates) {
|
||||
const assetPath = `provider-assets/${provider}/${process.platform}-${process.arch}`;
|
||||
const metadata = await materializeCandidateProviderPack({ provider, outputRoot: join(temporaryRoot, assetPath) });
|
||||
if (typeof metadata?.version !== "string" || !metadata.version || metadata.version.length > 120
|
||||
|| !/^sha256:[a-f0-9]{64}$/.test(metadata.profileDigest)
|
||||
|| !/^sha256:[a-f0-9]{64}$/.test(metadata.closureDigest)) throw new Error("Candidate builder omitted its pinned identity");
|
||||
candidateProviders[provider] = { version: metadata.version, profileDigest: metadata.profileDigest,
|
||||
closureDigest: metadata.closureDigest, qualification: "pending", path: assetPath,
|
||||
sha256: sha256Tree(join(temporaryRoot, assetPath)) };
|
||||
}
|
||||
|
||||
// pnpm's generated .bin shims embed the temporary deployment directory in
|
||||
// NODE_PATH. That makes an otherwise identical provider pack hash differ on
|
||||
@@ -314,6 +333,7 @@ try {
|
||||
codex:
|
||||
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3",
|
||||
},
|
||||
...(candidates.length ? { candidateProviders } : {}),
|
||||
artifacts: {
|
||||
grokLauncher: {
|
||||
path: "dist/providers/grok/launcher.cjs",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const CANDIDATES = new Set(["cursor", "copilot", "pi"]);
|
||||
|
||||
export function parseProviderPackArguments(args) {
|
||||
let output;
|
||||
const candidates = [];
|
||||
for (const value of args.filter(value => value !== "--")) {
|
||||
if (value.startsWith("--candidate-providers=")) {
|
||||
for (const provider of value.slice("--candidate-providers=".length).split(",").filter(Boolean)) {
|
||||
if (!CANDIDATES.has(provider) || candidates.includes(provider)) throw new Error("Unknown or duplicate candidate provider");
|
||||
candidates.push(provider);
|
||||
}
|
||||
} else if (value.startsWith("--") || output !== undefined) throw new Error("Invalid provider-pack arguments");
|
||||
else output = value;
|
||||
}
|
||||
return { output, candidates };
|
||||
}
|
||||
|
||||
/** Closed source-owned builder registry; provider branches add their exact pins. */
|
||||
export async function materializeCandidateProviderPack({ provider, outputRoot }) {
|
||||
if (!CANDIDATES.has(provider)) throw new Error("Unknown candidate provider");
|
||||
throw new Error(`The ${provider} candidate distribution builder is not included in this source revision`);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseProviderPackArguments, materializeCandidateProviderPack } from "./candidate-provider-pack.mjs";
|
||||
|
||||
test("candidate builds are explicit and leave qualified-only builds unchanged", () => {
|
||||
assert.deepEqual(parseProviderPackArguments(["--", "/pack"]), { output: "/pack", candidates: [] });
|
||||
assert.deepEqual(parseProviderPackArguments(["/pack", "--candidate-providers=pi,cursor"]), { output: "/pack", candidates: ["pi", "cursor"] });
|
||||
});
|
||||
test("candidate builder cannot admit unknown providers, options or duplicate assets", async () => {
|
||||
for (const args of [["--candidate-providers=cursor,cursor"], ["--candidate-providers=other"], ["--executable=/tmp/x"], ["/one", "/two"]]) {
|
||||
assert.throws(() => parseProviderPackArguments(args));
|
||||
}
|
||||
await assert.rejects(materializeCandidateProviderPack({ provider: "arbitrary", outputRoot: "/tmp/unused" }), /Unknown candidate/);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type CodexAcpxDriverOptions,
|
||||
} from "../drivers/acpx/codex-acpx-driver.js";
|
||||
import { resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles.js";
|
||||
import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js";
|
||||
import { HarnessDriverBackend } from "./harness-driver-backend.js";
|
||||
import {
|
||||
nativeSystemInstructions,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
|
||||
export interface CodexAcpxNativeSessionBackendOptions extends Omit<
|
||||
CodexAcpxDriverOptions,
|
||||
"model" | "permissionMode" | "systemInstructions"
|
||||
"model" | "permissionMode" | "systemInstructions" | "providerPolicy"
|
||||
> {}
|
||||
|
||||
export type AcpxNativeSessionBackendOptions =
|
||||
@@ -30,11 +31,6 @@ export function createAcpxNativeSessionBackend(
|
||||
if (input.provider.kind !== "acpx") {
|
||||
throw new Error("ACPX backend requires provider kind acpx");
|
||||
}
|
||||
if (input.provider.agent === "pi") {
|
||||
throw new Error(
|
||||
"Pi ACPX backend is unavailable until descriptor-confined verified launch is implemented",
|
||||
);
|
||||
}
|
||||
const qualifiedProfile = resolveQualifiedAcpxProfile(
|
||||
input.provider.agent,
|
||||
input.provider.model,
|
||||
@@ -58,6 +54,10 @@ export function createAcpxNativeSessionBackend(
|
||||
}
|
||||
}
|
||||
|
||||
if (ACPX_CAPABILITY_PROFILES[input.provider.agent].qualification !== "qualified") {
|
||||
throw new Error("ACPX candidate direct execution requires completed qualification; use the host-controlled runnerd evaluation path");
|
||||
}
|
||||
|
||||
const constraints = nativeTaskConstraints(input);
|
||||
const systemInstructions = [
|
||||
nativeSystemInstructions(input),
|
||||
@@ -73,6 +73,7 @@ export function createAcpxNativeSessionBackend(
|
||||
model: input.provider.model,
|
||||
permissionMode: input.provider.permissionMode ?? "approve-reads",
|
||||
systemInstructions,
|
||||
providerPolicy: { readOnly: "executionMode" in input && input.executionMode === "plan" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createCodexTaskEnvelope } from "../contracts/codex.js";
|
||||
import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js";
|
||||
import { NATIVE_EXECUTION_INPUT_SCHEMA } from "../contracts/native-execution.js";
|
||||
import type { NativeExecutionInput } from "../contracts/native-execution.js";
|
||||
import type { PersistedHarnessSession } from "../contracts/harness-driver.js";
|
||||
@@ -85,14 +86,9 @@ function transportDriverIdentity(input: NativeExecutionInput): {
|
||||
version: input.provider.agentCoreProfile.qualificationRevision,
|
||||
};
|
||||
case "acpx":
|
||||
if (input.provider.agent === "pi") {
|
||||
throw new Error(
|
||||
"Native ACPX backend for pi is unavailable until descriptor-confined verified launch is implemented",
|
||||
);
|
||||
}
|
||||
return {
|
||||
kind: "acpx_runtime",
|
||||
displayName: `${input.provider.agent === "grok" ? "Grok Build" : input.provider.agent === "claude" ? "Claude" : "Codex"} via ACPX`,
|
||||
displayName: `${ACPX_CAPABILITY_PROFILES[input.provider.agent].displayName} via ACPX`,
|
||||
version: "0.13.1",
|
||||
};
|
||||
default:
|
||||
|
||||
@@ -97,6 +97,24 @@ const driver: HarnessDriver = {
|
||||
};
|
||||
|
||||
describe("HarnessDriverBackend", () => {
|
||||
it("refreshes negotiated controls after a warm handshake and separates follow-ups", async () => {
|
||||
let controls = { steering: false, queuedFollowUp: false };
|
||||
const steer = vi.fn(async () => {});
|
||||
const session = Object.assign(new FakeHarnessSession(), { steer, turnControlCapabilities: () => ({ ...controls }) });
|
||||
const backend = new HarnessDriverBackend({ ...driver, openSession: async () => session });
|
||||
const opened = await backend.openSession({ identity: {
|
||||
runId: "run-1", sessionId: "session-1", companyId: "company-1", issueId: "issue-1", agentId: "agent-1",
|
||||
} });
|
||||
expect(await opened.capabilities()).toMatchObject({ steering: false, queuedFollowUp: false });
|
||||
controls = { steering: false, queuedFollowUp: true };
|
||||
expect(await opened.capabilities()).toMatchObject(controls);
|
||||
expect(() => opened.steer!({ turnId: "turn-1", message: { role: "user", text: "steer" }, correlationId: "c-1" })).toThrow();
|
||||
await opened.steer!({ turnId: "turn-1", message: { role: "user", text: "follow" }, correlationId: "c-2", mode: "follow_up" });
|
||||
expect(steer).toHaveBeenCalledWith(expect.objectContaining({ mode: "follow_up" }));
|
||||
controls = { steering: true, queuedFollowUp: false };
|
||||
expect(await opened.capabilities()).toMatchObject(controls);
|
||||
expect(() => opened.steer!({ turnId: "turn-1", message: { role: "user", text: "follow" }, correlationId: "c-3", mode: "follow_up" })).toThrow();
|
||||
});
|
||||
it.each([false, true])("honors driver steering support even when the transport exposes a steer method (%s)", async supported => {
|
||||
const steer = vi.fn(async () => { throw new Error("provider does not support steering"); });
|
||||
const session = Object.assign(new FakeHarnessSession(), { steer });
|
||||
|
||||
@@ -450,10 +450,12 @@ class HarnessNativeSession implements NativeSession {
|
||||
}
|
||||
|
||||
async capabilities() {
|
||||
const negotiated = this.#session.turnControlCapabilities?.();
|
||||
return {
|
||||
resume: true,
|
||||
typedEvents: true,
|
||||
steering: this.#steeringSupported && this.#session.steer !== undefined,
|
||||
steering: (negotiated?.steering ?? this.#steeringSupported) && this.#session.steer !== undefined,
|
||||
queuedFollowUp: negotiated?.queuedFollowUp === true && this.#session.steer !== undefined,
|
||||
interruption: this.#session.interrupt !== undefined,
|
||||
structuredResult: true,
|
||||
read: this.#session.read !== undefined,
|
||||
@@ -682,13 +684,15 @@ class HarnessNativeSession implements NativeSession {
|
||||
}
|
||||
|
||||
steer(input: {
|
||||
mode?: "steer" | "follow_up";
|
||||
turnId: string;
|
||||
message: { role: "user"; text: string };
|
||||
correlationId?: string;
|
||||
}) {
|
||||
this.#assertProtocolIntegrity();
|
||||
if (!this.#steeringSupported || this.#session.steer === undefined)
|
||||
throw new Error("steering is unavailable");
|
||||
const negotiated = this.#session.turnControlCapabilities?.();
|
||||
const supported = input.mode === "follow_up" ? negotiated?.queuedFollowUp === true : (negotiated?.steering ?? this.#steeringSupported);
|
||||
if (!supported || this.#session.steer === undefined) throw new Error("steering or queued follow-up is unavailable");
|
||||
return this.#withProtocolIntegrity(() => this.#session.steer!(input));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { QUALIFIED_ACPX_PROFILES, resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles.js";
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -497,6 +498,16 @@ describe("native backend factory", () => {
|
||||
"requires an instance runtime directory",
|
||||
);
|
||||
});
|
||||
it.each(["pi", "cursor", "copilot"] as const)("constructs %s identity on the supplied runnerd transport without starting a provider", async agent => {
|
||||
const input = acpxExecution();
|
||||
if (input.provider.kind !== "acpx") throw new Error("invalid fixture");
|
||||
const model = agent === "pi" ? QUALIFIED_ACPX_PROFILES.pi.qualificationModel : "explicit-fixture-model";
|
||||
Object.assign(input.provider, { agent, model, profile: resolveQualifiedAcpxProfile(agent, model) });
|
||||
const backend = createNativeSessionBackend(input, {
|
||||
codexTransportFactory: () => { throw new Error("descriptor must not launch the transport"); },
|
||||
});
|
||||
await expect(backend.descriptor()).resolves.toMatchObject({ name: "acpx_runtime", version: "0.13.1" });
|
||||
});
|
||||
|
||||
it.each(["claude" as const])(
|
||||
"constructs the qualified %s ACPX backend",
|
||||
@@ -512,12 +523,15 @@ describe("native backend factory", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects Pi before constructing an ACPX backend", () => {
|
||||
expect(() =>
|
||||
createNativeSessionBackend(acpxExecution("pi"), {
|
||||
acpxRuntimeDirectory: "/runtime",
|
||||
}),
|
||||
).toThrow("descriptor-confined verified launch");
|
||||
it.each(["pi", "cursor", "copilot"] as const)("rejects unqualified %s direct execution even with an exact persisted profile", agent => {
|
||||
const input = acpxExecution();
|
||||
if (input.provider.kind !== "acpx") throw new Error("invalid fixture");
|
||||
const model = agent === "pi" ? QUALIFIED_ACPX_PROFILES.pi.qualificationModel : "explicit-fixture-model";
|
||||
const profile = resolveQualifiedAcpxProfile(agent, model);
|
||||
Object.assign(input.provider, { agent, model, profile });
|
||||
expect(() => createNativeSessionBackend(input, { acpxRuntimeDirectory: "/runtime",
|
||||
acpxEnvironment: { COPILOT_GITHUB_TOKEN: "explicit-fixture", CURSOR_API_KEY: "explicit-fixture", OPENROUTER_API_KEY: "explicit-fixture" },
|
||||
})).toThrow("ACPX candidate direct execution requires completed qualification");
|
||||
});
|
||||
|
||||
it("rejects a Codex ACPX snapshot that drifts from its qualified profile", () => {
|
||||
|
||||
@@ -77,11 +77,6 @@ export function createNativeSessionBackend(
|
||||
});
|
||||
}
|
||||
if (input.provider.kind === "acpx") {
|
||||
if (input.provider.agent === "pi") {
|
||||
throw new Error(
|
||||
"Native ACPX backend for pi is unavailable until descriptor-confined verified launch is implemented",
|
||||
);
|
||||
}
|
||||
if (!options.acpxRuntimeDirectory?.trim()) {
|
||||
throw new Error("ACPX backend requires an instance runtime directory");
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { normalizeAcpxPermission } from "../drivers/acpx/acp-permission-adapter.js";
|
||||
import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js";
|
||||
import { resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles.js";
|
||||
import { ACPX_SIDECAR_PROTOCOL_VERSION } from "../drivers/acpx/sidecar-protocol.js";
|
||||
import { canonicalProviderEventsFromAcpxRuntimeEvent } from "../provider-events.js";
|
||||
import {
|
||||
@@ -31,6 +34,29 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("qualified ACPX runtime sidecar", () => {
|
||||
it.each(["pi", "copilot", "cursor", "codex", "claude"])("offers verified session permission grants only for %s", async agent => {
|
||||
const source = readFileSync(fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url)), "utf8");
|
||||
const start = source.indexOf(" const { signal } = context;", source.indexOf("async function waitForPermission"));
|
||||
const end = source.indexOf("\nasync function waitForInput", start);
|
||||
expect(start).toBeGreaterThan(0);
|
||||
const permissions = new Map<string, unknown>();
|
||||
const emitted: Array<{ choices: Array<{ key: string }> }> = [];
|
||||
const wait = new Function("permissions", "openParams", "normalizeAcpxPermission", "emit",
|
||||
`let turnId = "turn-1", requestSequence = 0; const MAX_PENDING_INPUTS = 512;
|
||||
const stableRequestId = () => "request-1"; const requireAcpxResponseDelivery = c => c.responseDelivery;
|
||||
return async function(activeTurnId, request, context) { ${source.slice(start, end)}`)(
|
||||
permissions, { agent }, normalizeAcpxPermission, (_event: string, payload: { choices: Array<{ key: string }> }) => emitted.push(payload),
|
||||
);
|
||||
const abort = new AbortController();
|
||||
const pending = wait("turn-1", { sessionId: "session", inferredKind: "edit", raw: {
|
||||
sessionId: "session", toolCall: { toolCallId: "call", title: "Edit file" },
|
||||
options: ["allow_once", "allow_always", "reject_once"].map(kind => ({ kind, optionId: kind, name: kind })),
|
||||
} }, { signal: abort.signal, responseDelivery: Promise.resolve() });
|
||||
expect(emitted[0]!.choices.some(choice => choice.key === "accept_for_session")).toBe(agent === "pi" || agent === "copilot");
|
||||
abort.abort();
|
||||
await expect(pending).resolves.toEqual({ outcome: "cancel" });
|
||||
expect(permissions.size).toBe(0);
|
||||
});
|
||||
it("preserves ACP input presence through the bounded sidecar handoff", () => {
|
||||
const source = readFileSync(
|
||||
fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url)),
|
||||
@@ -528,12 +554,27 @@ describe("qualified ACPX runtime sidecar", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["pi", "openrouter/deepseek/deepseek-v4-flash-0731"],
|
||||
["cursor", "explicit-cursor-model"],
|
||||
["copilot", "explicit-copilot-model"],
|
||||
] as const)("initializes the declared %s candidate without promoting its profile", async (agent, model) => {
|
||||
const sidecar = startSidecar();
|
||||
sidecar.write(initializeRequest(1, agent, model));
|
||||
const frame = await sidecar.next((value) => value.id === 1);
|
||||
expect(frame).toMatchObject({ id: 1, ok: true });
|
||||
const result = frame.result as Record<string, unknown>;
|
||||
expect(result.profile).toEqual(resolveQualifiedAcpxProfile(agent, model));
|
||||
expect(result.profile).toMatchObject({ reportedModelId: model });
|
||||
expect(ACPX_CAPABILITY_PROFILES[agent].qualification).toBe("pending");
|
||||
});
|
||||
|
||||
it("fails closed after an unsupported provider bootstrap", async () => {
|
||||
const sidecar = startSidecar();
|
||||
sidecar.write(
|
||||
initializeRequest(
|
||||
1,
|
||||
"pi",
|
||||
"unknown-provider",
|
||||
"openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
),
|
||||
);
|
||||
@@ -545,7 +586,7 @@ describe("qualified ACPX runtime sidecar", () => {
|
||||
ok: false,
|
||||
error: {
|
||||
code: "acpx_sidecar_command_failed",
|
||||
message: "ACPX agent must be claude, codex, or grok",
|
||||
message: "ACPX agent must be claude, codex, grok, cursor, copilot, or pi",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
@@ -681,7 +722,7 @@ function loadWaitForTool(input: {
|
||||
"utf8",
|
||||
);
|
||||
const start = source.indexOf("async function waitForTool");
|
||||
const end = source.indexOf("\nasync function waitForInput", start);
|
||||
const end = source.indexOf("\nasync function waitForPermission", start);
|
||||
if (start < 0 || end < 0) throw new Error("waitForTool source not found");
|
||||
const functionSource = source
|
||||
.slice(start, end)
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { createInterface } from "node:readline";
|
||||
import { deliverAcpxResponse, requireAcpxResponseDelivery } from "../drivers/acpx/response-delivery.js";
|
||||
import { createAcpxSidecarHostEnvironment } from "../drivers/acpx/environment.js";
|
||||
|
||||
import type {
|
||||
AcpElicitationContext,
|
||||
AcpElicitationRequest,
|
||||
AcpElicitationResponse,
|
||||
AcpRuntimeEvent,
|
||||
AcpPermissionRequest,
|
||||
AcpPermissionDecision,
|
||||
} from "acpx/runtime";
|
||||
|
||||
import { acpxProfileClientCapabilities, bindAcpxExtensionTurn, validateAcpxRichEvent, createAcpxProfileExtensionAdapter, type AcpxExtensionInput } from "../drivers/acpx/profile-extensions.js";
|
||||
import type { PaperclipQuestionSet } from "../contracts/question-set.js";
|
||||
import { createAcpxToolEventNormalizer, createGrokMessageNormalizer } from "../provider-events.js";
|
||||
import { parseNativeRuntimeContext } from "../contracts/runtime-context.js";
|
||||
import {
|
||||
@@ -25,6 +31,7 @@ import {
|
||||
} from "../drivers/acpx/acp-question-adapter.js";
|
||||
import { openCodexAcpxRuntime } from "../drivers/acpx/codex-runtime-adapter.js";
|
||||
import { AcpxApprovalRequiredError } from "../drivers/acpx/permission-policy.js";
|
||||
import { normalizeAcpxPermission, type NormalizedAcpxPermission } from "../drivers/acpx/acp-permission-adapter.js";
|
||||
import { acpxGoalProjection } from "../drivers/acpx/session-goals.js";
|
||||
import { acpxProviderSessionIdentity } from "../drivers/acpx/recovery-identity.js";
|
||||
import {
|
||||
@@ -56,6 +63,7 @@ import {
|
||||
import { safeAcpxLocations } from "./acpx-sidecar-locations.js";
|
||||
import {
|
||||
persistedAcpxTurnUsage,
|
||||
acpxUsageEstimateNotice,
|
||||
qualifiedAcpxUsageBreakdown,
|
||||
} from "../drivers/acpx/usage-accounting.js";
|
||||
import { validatePrpStructuredRunResult } from "../protocol/replay-contract.js";
|
||||
@@ -80,6 +88,9 @@ import {
|
||||
verifyOpenedAcpxSidecarHost,
|
||||
} from "./acpx-sidecar-lifecycle.js";
|
||||
|
||||
import { AcpxTurnControlLedger, parseAcpxTurnControl } from "../drivers/acpx/turn-controls.js";
|
||||
|
||||
const turnControls = new AcpxTurnControlLedger();
|
||||
const MAX_PENDING_TOOLS = 512;
|
||||
const MAX_PENDING_INPUTS = 16;
|
||||
let goalSourceRevision = 0;
|
||||
@@ -114,8 +125,18 @@ interface PendingTool {
|
||||
|
||||
interface PendingInput {
|
||||
turnId: string;
|
||||
normalized: NormalizedAcpForm;
|
||||
settle(response: AcpElicitationResponse): void;
|
||||
responseDelivery: Promise<void>;
|
||||
questionSet: PaperclipQuestionSet;
|
||||
prepareResolution(resolution: HarnessRuntimeRequestResolution): () => void;
|
||||
cancel(): void;
|
||||
cleanup(): void;
|
||||
}
|
||||
|
||||
interface PendingPermission {
|
||||
turnId: string;
|
||||
responseDelivery: Promise<void>;
|
||||
normalized: NormalizedAcpxPermission;
|
||||
settle(response: AcpPermissionDecision): void;
|
||||
cleanup(): void;
|
||||
}
|
||||
|
||||
@@ -136,6 +157,7 @@ let initializedModel: string | null = null;
|
||||
let inputClosed = false;
|
||||
const tools = new Map<string, PendingTool>();
|
||||
const inputs = new Map<string, PendingInput>();
|
||||
const permissions = new Map<string, PendingPermission>();
|
||||
|
||||
const lines = createInterface({
|
||||
input: process.stdin,
|
||||
@@ -260,11 +282,13 @@ async function dispatch(
|
||||
normalizedSessionId: params.normalizedSessionId,
|
||||
workingDirectory: params.workingDirectory,
|
||||
agent: params.agent,
|
||||
clientCapabilities: acpxProfileClientCapabilities(params.agent),
|
||||
model: params.model,
|
||||
permissionMode: params.permissionMode,
|
||||
providerPolicy: params.providerPolicy,
|
||||
systemInstructions: params.systemInstructions,
|
||||
runtimeContext: params.runtimeContext,
|
||||
environment: process.env,
|
||||
environment: createAcpxSidecarHostEnvironment(process.env, params.agent, params.normalizedSessionId),
|
||||
expectedIdentity: params.expectedIdentity,
|
||||
semanticTools: {
|
||||
tools: params.tools,
|
||||
@@ -306,6 +330,7 @@ async function dispatch(
|
||||
),
|
||||
sidecarPid: process.pid,
|
||||
status: opened.status,
|
||||
turnControls: openedHost.steeringCapability() ?? { steering: false, queuedFollowUp: false },
|
||||
};
|
||||
}
|
||||
if (request.command === "run.attach") {
|
||||
@@ -330,22 +355,54 @@ async function dispatch(
|
||||
if (turnId) throw new Error("ACPX sidecar already has an active turn");
|
||||
const currentTurnId = boundedIdentity(request.params.turnId, "turnId");
|
||||
turnId = currentTurnId;
|
||||
turnControls.begin(currentTurnId);
|
||||
let runtimeTurn: AcpxRuntimeTurn;
|
||||
const extensions = bindAcpxExtensionTurn({
|
||||
adapter: createAcpxProfileExtensionAdapter(openParams!.agent, {
|
||||
workspacePath: openParams!.workingDirectory, sessionId: activeHost.identity().backendSessionId, turnId: currentTurnId,
|
||||
}),
|
||||
active: () => turnId === currentTurnId && host === activeHost,
|
||||
sessionId: activeHost.identity().backendSessionId,
|
||||
waitForInput: (input, context) => waitForExtensionInput(currentTurnId, input, context),
|
||||
emit: event => emit("runtime.rich_event", { ...event }, currentTurnId),
|
||||
});
|
||||
let usageBefore: unknown;
|
||||
try {
|
||||
usageBefore = await readSidecarHostStatusWithin(activeHost);
|
||||
runtimeTurn = activeHost.startTurn({
|
||||
requestId: `${runId}:${currentTurnId}`,
|
||||
text: boundedText(request.params.message, "message", 1024 * 1024),
|
||||
onExtensionRequest: extensions.onExtensionRequest,
|
||||
onExtensionNotification: extensions.onExtensionNotification,
|
||||
onElicitation: (providerRequest, context) =>
|
||||
waitForInput(currentTurnId, providerRequest, context),
|
||||
onPermissionRequest: (providerRequest, context) =>
|
||||
waitForPermission(currentTurnId, providerRequest, context),
|
||||
});
|
||||
} catch (error) {
|
||||
turnId = null;
|
||||
throw error;
|
||||
}
|
||||
void pumpTurn(currentTurnId, runtimeTurn, activeHost, usageBefore);
|
||||
return { turnId: currentTurnId };
|
||||
void pumpTurn(currentTurnId, runtimeTurn, activeHost, usageBefore, extensions.drain);
|
||||
// Warm sessions may defer initialize until their first prompt. Publish only
|
||||
// the capabilities of that live initialized connection, never old disk state.
|
||||
await runtimeTurn.promptStarted;
|
||||
return { turnId: currentTurnId, turnControls: activeHost.steeringCapability() ?? { steering: false, queuedFollowUp: false } };
|
||||
}
|
||||
if (request.command === "turn.steer") {
|
||||
const control = parseAcpxTurnControl(request.params);
|
||||
const activeHost = requireHost();
|
||||
if (!runId || control.turnId !== turnId) throw new Error("cannot control a stale ACPX turn");
|
||||
const capability = activeHost.steeringCapability();
|
||||
if (!(control.mode === "steer" ? capability?.steering : capability?.queuedFollowUp)) {
|
||||
throw new Error("ACP provider did not negotiate this turn control");
|
||||
}
|
||||
turnControls.reserve(control, turnId);
|
||||
const runtimeRequestId = `${runId}:${control.turnId}`;
|
||||
if (control.mode === "steer") await activeHost.steerActiveTurn(control.message, runtimeRequestId);
|
||||
else await activeHost.queueFollowUp(control.message, runtimeRequestId);
|
||||
if (host !== activeHost || turnId !== control.turnId) throw new Error("ACP turn settled before control acknowledgement");
|
||||
return { accepted: true, turnId: control.turnId, controlId: control.controlId, mode: control.mode };
|
||||
}
|
||||
if (request.command === "turn.cancel") {
|
||||
const expected = boundedIdentity(request.params.turnId, "turnId");
|
||||
@@ -360,9 +417,17 @@ async function dispatch(
|
||||
return { cancelled: true };
|
||||
}
|
||||
if (request.command === "permission.resolve") {
|
||||
throw new Error(
|
||||
"ACPX permissions are resolved by the admitted runner policy",
|
||||
);
|
||||
const requestId = boundedIdentity(request.params.requestId, "requestId");
|
||||
const expectedTurnId = boundedIdentity(request.params.turnId, "turnId");
|
||||
const pending = permissions.get(requestId);
|
||||
if (!pending || pending.turnId !== expectedTurnId || turnId !== expectedTurnId) {
|
||||
throw new Error("permission request is stale or unknown");
|
||||
}
|
||||
const resolution = parseHarnessRuntimeRequestResolution("permission_approval", request.params.resolution);
|
||||
const decision = pending.normalized.resolve(resolution);
|
||||
if (!permissions.delete(requestId)) throw new Error("permission request lost its settlement race");
|
||||
pending.cleanup();
|
||||
return await deliverAcpxResponse(pending.responseDelivery, () => pending.settle(decision));
|
||||
}
|
||||
if (request.command === "input.resolve") {
|
||||
const requestId = boundedIdentity(request.params.requestId, "requestId");
|
||||
@@ -378,17 +443,13 @@ async function dispatch(
|
||||
const resolution = parseHarnessRuntimeRequestResolution(
|
||||
"elicitation",
|
||||
request.params.resolution,
|
||||
pending.normalized.questionSet,
|
||||
);
|
||||
const providerResponse = elicitationResponse(
|
||||
pending.normalized,
|
||||
resolution,
|
||||
pending.questionSet,
|
||||
);
|
||||
const deliver = pending.prepareResolution(resolution);
|
||||
if (!inputs.delete(requestId))
|
||||
throw new Error("input request lost its settlement race");
|
||||
pending.cleanup();
|
||||
pending.settle(providerResponse);
|
||||
return { resolved: true };
|
||||
return await deliverAcpxResponse(pending.responseDelivery, deliver);
|
||||
}
|
||||
if (request.command === "tool.resolve") {
|
||||
const callId = boundedIdentity(request.params.callId, "callId");
|
||||
@@ -527,6 +588,7 @@ async function pumpTurn(
|
||||
runtimeTurn: AcpxRuntimeTurn,
|
||||
activeHost: AcpxRuntimeHost,
|
||||
usageBefore: unknown,
|
||||
drainExtensions: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
let terminal: Record<string, unknown>;
|
||||
try {
|
||||
@@ -546,13 +608,17 @@ async function pumpTurn(
|
||||
);
|
||||
}
|
||||
const result = await runtimeTurn.result;
|
||||
await drainExtensions();
|
||||
try {
|
||||
const usage = persistedAcpxTurnUsage(
|
||||
usageBefore,
|
||||
await readSidecarHostStatusWithin(activeHost),
|
||||
runtimeTurn.requestId,
|
||||
openParams?.agent,
|
||||
);
|
||||
if (usage) {
|
||||
const estimate = acpxUsageEstimateNotice(usage, `${currentTurnId}:usage-estimate`);
|
||||
if (estimate) { validateAcpxRichEvent(estimate); emit("runtime.rich_event", { ...estimate }, currentTurnId); }
|
||||
emit(
|
||||
"runtime.event",
|
||||
sanitizeRuntimeEvent(usage as unknown as AcpRuntimeEvent),
|
||||
@@ -670,6 +736,37 @@ async function waitForTool(call: RunnerToolCall): Promise<unknown> {
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForPermission(
|
||||
activeTurnId: string,
|
||||
request: AcpPermissionRequest,
|
||||
context: { signal: AbortSignal; responseDelivery?: Promise<void> },
|
||||
): Promise<AcpPermissionDecision> {
|
||||
const { signal } = context;
|
||||
if (turnId !== activeTurnId || signal.aborted || permissions.size >= MAX_PENDING_INPUTS) {
|
||||
return { outcome: "cancel" };
|
||||
}
|
||||
const normalized = normalizeAcpxPermission(request, ["pi", "copilot"].includes(openParams?.agent ?? "") ? { allowAlwaysScope: "session" } : {});
|
||||
const responseDelivery = requireAcpxResponseDelivery(context);
|
||||
const requestId = stableRequestId(activeTurnId, ++requestSequence, normalized.toolCallId);
|
||||
return await new Promise((settle) => {
|
||||
const abort = () => {
|
||||
if (!permissions.delete(requestId)) return;
|
||||
signal.removeEventListener("abort", abort);
|
||||
settle({ outcome: "cancel" });
|
||||
};
|
||||
permissions.set(requestId, {
|
||||
turnId: activeTurnId, normalized, settle, responseDelivery,
|
||||
cleanup: () => signal.removeEventListener("abort", abort),
|
||||
});
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
if (signal.aborted) { abort(); return; }
|
||||
emit("runtime.permission_requested", {
|
||||
requestId, kind: normalized.kind, title: normalized.title,
|
||||
toolCallId: normalized.toolCallId, choices: normalized.choices,
|
||||
}, activeTurnId);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForInput(
|
||||
activeTurnId: string,
|
||||
request: AcpElicitationRequest,
|
||||
@@ -710,38 +807,42 @@ async function waitForInput(
|
||||
);
|
||||
return { action: "cancel" };
|
||||
}
|
||||
const requestId = stableRequestId(
|
||||
activeTurnId,
|
||||
++requestSequence,
|
||||
context.requestId,
|
||||
);
|
||||
emit(
|
||||
"runtime.input_requested",
|
||||
{
|
||||
requestId,
|
||||
questionSet: normalized.questionSet,
|
||||
origin: {
|
||||
adapter: "acpx-runtime-sidecar",
|
||||
provider: openParams?.agent ?? initializedAgent ?? "unknown",
|
||||
method: "elicitation/create",
|
||||
},
|
||||
},
|
||||
activeTurnId,
|
||||
);
|
||||
return await waitForExtensionInput(activeTurnId, {
|
||||
method: "elicitation/create", questionSet: normalized.questionSet,
|
||||
resolve: resolution => elicitationResponse(normalized, resolution) as unknown as Record<string, unknown>,
|
||||
cancel: () => ({ action: "cancel" }),
|
||||
}, context) as unknown as AcpElicitationResponse;
|
||||
}
|
||||
|
||||
async function waitForExtensionInput(
|
||||
activeTurnId: string,
|
||||
input: AcpxExtensionInput,
|
||||
context: { requestId: string | number | null; signal: AbortSignal; responseDelivery?: Promise<void> },
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (turnId !== activeTurnId || context.signal.aborted || inputs.size >= MAX_PENDING_INPUTS) return input.cancel();
|
||||
const responseDelivery = requireAcpxResponseDelivery(context);
|
||||
const requestId = stableRequestId(activeTurnId, ++requestSequence, context.requestId);
|
||||
return await new Promise((settle) => {
|
||||
const abort = () => {
|
||||
const pending = inputs.get(requestId);
|
||||
if (!pending || !inputs.delete(requestId)) return;
|
||||
pending.cleanup();
|
||||
settle({ action: "cancel" });
|
||||
settle(input.cancel());
|
||||
};
|
||||
context.signal.addEventListener("abort", abort, { once: true });
|
||||
inputs.set(requestId, {
|
||||
turnId: activeTurnId,
|
||||
normalized,
|
||||
settle,
|
||||
turnId: activeTurnId, questionSet: input.questionSet, responseDelivery,
|
||||
prepareResolution: resolution => {
|
||||
const response = input.resolve(resolution);
|
||||
return () => settle(response);
|
||||
},
|
||||
cancel: () => settle(input.cancel()),
|
||||
cleanup: () => context.signal.removeEventListener("abort", abort),
|
||||
});
|
||||
context.signal.addEventListener("abort", abort, { once: true });
|
||||
emit("runtime.input_requested", {
|
||||
requestId, questionSet: input.questionSet,
|
||||
origin: { adapter: "acpx-runtime-sidecar", provider: openParams?.agent ?? initializedAgent ?? "unknown", method: input.method },
|
||||
}, activeTurnId);
|
||||
if (context.signal.aborted) abort();
|
||||
});
|
||||
}
|
||||
@@ -763,6 +864,11 @@ function elicitationResponse(
|
||||
}
|
||||
|
||||
function rejectTurnWaiters(terminalTurnId: string, message: string): void {
|
||||
for (const [requestId, pending] of permissions) {
|
||||
if (pending.turnId !== terminalTurnId || !permissions.delete(requestId)) continue;
|
||||
pending.cleanup();
|
||||
pending.settle({ outcome: "cancel" });
|
||||
}
|
||||
for (const [callId, pending] of tools) {
|
||||
if (pending.turnId !== terminalTurnId || !tools.delete(callId)) continue;
|
||||
pending.cleanup();
|
||||
@@ -772,7 +878,7 @@ function rejectTurnWaiters(terminalTurnId: string, message: string): void {
|
||||
if (pending.turnId !== terminalTurnId || !inputs.delete(requestId))
|
||||
continue;
|
||||
pending.cleanup();
|
||||
pending.settle({ action: "cancel" });
|
||||
pending.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1054,6 +1160,7 @@ function parseOpenParams(
|
||||
model,
|
||||
permissionMode: requiredPermissionMode(value.permissionMode),
|
||||
permissionModePinned: value.permissionModePinned === true,
|
||||
...(value.providerPolicy == null ? {} : { providerPolicy: parseProviderPolicy(value.providerPolicy) }),
|
||||
systemInstructions: boundedText(
|
||||
value.systemInstructions,
|
||||
"systemInstructions",
|
||||
@@ -1069,6 +1176,14 @@ function parseOpenParams(
|
||||
};
|
||||
}
|
||||
|
||||
function parseProviderPolicy(value: unknown): { readOnly: boolean } {
|
||||
const policy = record(value);
|
||||
if (typeof policy.readOnly !== "boolean" || Object.keys(policy).some(key => key !== "readOnly")) {
|
||||
throw new Error("providerPolicy requires only an explicit readOnly boolean");
|
||||
}
|
||||
return { readOnly: policy.readOnly };
|
||||
}
|
||||
|
||||
function parseTools(value: unknown): Readonly<Record<string, unknown>>[] {
|
||||
if (!Array.isArray(value) || value.length > 512) {
|
||||
throw new Error("tools must be an array with at most 512 entries");
|
||||
@@ -1208,8 +1323,8 @@ function requireHost(
|
||||
}
|
||||
|
||||
function requireQualifiedAgent(value: unknown): QualifiedAcpxAgent {
|
||||
if (value !== "codex" && value !== "claude" && value !== "grok") {
|
||||
throw new Error("ACPX agent must be claude, codex, or grok");
|
||||
if (value !== "grok" && value !== "codex" && value !== "claude" && value !== "pi" && value !== "cursor" && value !== "copilot") {
|
||||
throw new Error("ACPX agent must be claude, codex, grok, cursor, copilot, or pi");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { chmod, mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmod, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createCapabilityFixtureState } from "../mock-core/capability-control-plane-types.js";
|
||||
|
||||
import { parseNativeRuntimeContext } from "../contracts/runtime-context.js";
|
||||
import type { CapabilityLiveSessionSnapshot } from "../live/live-session.js";
|
||||
@@ -12,9 +14,11 @@ import {
|
||||
} from "./eval-session-contract.js";
|
||||
import {
|
||||
boundedEvalSessionUsage,
|
||||
runEvalSessionCli,
|
||||
evalRuntimeSystemInstructions,
|
||||
evalSessionProviderVersion,
|
||||
prepareEvalRuntimeContext,
|
||||
parseEvalSessionCliArgs,
|
||||
} from "./eval-session.js";
|
||||
|
||||
function request(overrides: Record<string, unknown> = {}): unknown {
|
||||
@@ -62,6 +66,25 @@ function agentCoreProfile(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
describe("eval-session request contract", () => {
|
||||
it.each(["pi", "cursor", "copilot"] as const)("admits %s only with the matching CLI diagnostic opt-in", (agent) => {
|
||||
const value = request({ provider: "acpx", acpxAgent: agent, model: "explicit-provider-model" });
|
||||
expect(() => parseEvalSessionRequest(value)).toThrow("--candidate-profile");
|
||||
expect(parseEvalSessionRequest(value, { candidateProfile: agent })).toMatchObject({ acpxAgent: agent, model: "explicit-provider-model" });
|
||||
expect(() => parseEvalSessionRequest(value, { candidateProfile: agent === "pi" ? "cursor" : "pi" })).toThrow("must match");
|
||||
expect(() => parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: agent, model: "" }), { candidateProfile: agent })).toThrow("request.model");
|
||||
expect(() => parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: "codex", session: { acpxAgent: agent } }))).toThrow("session.acpxAgent must match");
|
||||
expect(() => parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: agent, candidateProfile: agent }))).toThrow("--candidate-profile");
|
||||
});
|
||||
|
||||
it("accepts only known diagnostic flags and rejects ambiguous repeated arguments", () => {
|
||||
const args = ["--request", "/tmp/request.json", "--output", "/tmp/result.json"];
|
||||
expect(parseEvalSessionCliArgs([...args, "--candidate-profile", "pi"])).toMatchObject({ candidateProfile: "pi" });
|
||||
expect(() => parseEvalSessionCliArgs([...args, "--candidate-profile", "codex"])).toThrow("must be pi, cursor, or copilot");
|
||||
expect(() => parseEvalSessionCliArgs([...args, "--candidate-profile"])).toThrow("missing");
|
||||
expect(() => parseEvalSessionCliArgs([...args, "--request", "/tmp/other.json"])).toThrow("duplicate");
|
||||
expect(() => parseEvalSessionRequest(request(), { candidateProfile: "pi" })).toThrow("must match");
|
||||
});
|
||||
|
||||
it("materializes a production-v3 runtime context for direct live providers", async () => {
|
||||
const workspace = await mkdtemp(join(tmpdir(), "paperclip-eval-context-"));
|
||||
let instructionRoot: string | null = null;
|
||||
@@ -145,11 +168,11 @@ describe("eval-session request contract", () => {
|
||||
})))).toBe("17");
|
||||
});
|
||||
|
||||
it("rejects Pi and accepts both qualified remote provider profiles", () => {
|
||||
it("requires explicit Pi diagnosis and accepts both qualified remote provider profiles", () => {
|
||||
expect(() => parseEvalSessionRequest(request({
|
||||
provider: "acpx",
|
||||
acpxAgent: "pi",
|
||||
}))).toThrow("Pi ACPX profile is not available");
|
||||
}))).toThrow("--candidate-profile");
|
||||
expect(parseEvalSessionRequest(request({
|
||||
provider: "aws_agentcore",
|
||||
driver: "aws_agentcore_harness_api",
|
||||
@@ -226,7 +249,9 @@ describe("eval-session usage", () => {
|
||||
it("preserves Grok's missing usage as unknown instead of manufacturing zero tokens or cost", () => {
|
||||
const parsed = { ...parseEvalSessionRequest(request()), provider: "acpx" as const, acpxAgent: "grok" as const };
|
||||
const turn = { turnId: "turn-1", status: "completed" as const, assistantText: "done", snapshot: { usageLedger: [] } as unknown as CapabilityLiveSessionSnapshot };
|
||||
expect(boundedEvalSessionUsage(parsed, turn)).toBeNull();
|
||||
expect(() => boundedEvalSessionUsage(parsed, turn)).toThrow("budget cost coverage is unavailable");
|
||||
expect(turn.status).toBe("completed");
|
||||
expect(turn.snapshot.usageLedger).toEqual([]);
|
||||
expect(() => boundedEvalSessionUsage({ ...parsed, provider: "codex", acpxAgent: undefined }, turn)).toThrow();
|
||||
});
|
||||
|
||||
@@ -265,6 +290,60 @@ describe("eval-session usage", () => {
|
||||
})).toThrow("agent turn limit exceeded");
|
||||
});
|
||||
|
||||
it.each(["pi", "cursor", "copilot"] as const)("keeps %s oracle results when the current turn explicitly has unavailable usage", (agent) => {
|
||||
const parsed = parseEvalSessionRequest(request({
|
||||
provider: "acpx", acpxAgent: agent, model: "exact-provider-model",
|
||||
}), { candidateProfile: agent });
|
||||
const unavailable = {
|
||||
turnId: "turn-candidate", attemptId: "attempt-1", agent,
|
||||
reason: "provider_did_not_report_usage" as const,
|
||||
tokenUsage: null, costNanodollars: null, observedAt: "2026-09-28T19:18:10.000Z",
|
||||
};
|
||||
const snapshot = {
|
||||
config: { provider: "acpx", acpxAgent: agent },
|
||||
usageLedger: [], usageUnavailable: [unavailable],
|
||||
} as unknown as CapabilityLiveSessionSnapshot;
|
||||
const turn = { turnId: unavailable.turnId, status: "completed" as const, assistantText: "Upgrade your plan to continue", snapshot };
|
||||
expect(() => boundedEvalSessionUsage(parsed, turn)).toThrow("budget cost coverage is unavailable");
|
||||
expect(turn.assistantText).toBe("Upgrade your plan to continue");
|
||||
expect(snapshot.usageLedger).toEqual([]);
|
||||
expect(() => boundedEvalSessionUsage(parsed, { ...turn, turnId: "other-turn" }))
|
||||
.toThrow("omitted usage accounting");
|
||||
expect(() => boundedEvalSessionUsage(parsed, {
|
||||
...turn, snapshot: { ...snapshot, usageUnavailable: [{ ...unavailable, agent: agent === "pi" ? "cursor" : "pi" }] },
|
||||
})).toThrow("omitted usage accounting");
|
||||
expect(() => boundedEvalSessionUsage(parsed, {
|
||||
...turn, snapshot: { ...snapshot, usageUnavailable: [unavailable, { ...unavailable, turnId: "second-turn" }] },
|
||||
})).toThrow("agent turn limit exceeded");
|
||||
});
|
||||
|
||||
it("counts prior unavailable candidate turns alongside a later measured turn without claiming a complete total", () => {
|
||||
const parsed = parseEvalSessionRequest(request({
|
||||
provider: "acpx", acpxAgent: "cursor", model: "gpt-5.6-sol",
|
||||
}), { candidateProfile: "cursor" });
|
||||
const unavailable = {
|
||||
turnId: "prior-turn", attemptId: "attempt-1", agent: "cursor" as const,
|
||||
reason: "provider_did_not_report_usage" as const,
|
||||
tokenUsage: null, costNanodollars: null, observedAt: "2026-09-28T19:18:10.000Z",
|
||||
};
|
||||
const snapshot = {
|
||||
config: { provider: "acpx", acpxAgent: "cursor" },
|
||||
usageUnavailable: [unavailable, { ...unavailable }],
|
||||
usageLedger: [{
|
||||
receiptId: "measured-receipt", turnId: "measured-turn", providerCalls: 1,
|
||||
providerRequests: 1, inputTokens: 100, outputTokens: 10,
|
||||
cachedInputTokens: 0, reasoningTokens: 0, costNanodollars: 0,
|
||||
}],
|
||||
} as unknown as CapabilityLiveSessionSnapshot;
|
||||
const turn = { turnId: "measured-turn", status: "completed" as const, assistantText: "Done", snapshot };
|
||||
expect(() => boundedEvalSessionUsage(parsed, turn)).toThrow("agent turn limit exceeded");
|
||||
expect(() => boundedEvalSessionUsage({ ...parsed, limits: { ...parsed.limits, maxAgentTurns: 2 } }, turn)).toThrow("budget cost coverage is unavailable");
|
||||
expect(boundedEvalSessionUsage(parsed, {
|
||||
...turn,
|
||||
snapshot: { ...snapshot, usageUnavailable: [{ ...unavailable, turnId: "measured-turn" }] },
|
||||
})).toMatchObject({ agentTurns: 1, inputTokens: 100, outputTokens: 10 });
|
||||
});
|
||||
|
||||
it("deduplicates receipts and applies the versioned model price", () => {
|
||||
const receipt = {
|
||||
receiptId: "receipt-1",
|
||||
@@ -301,4 +380,94 @@ describe("eval-session usage", () => {
|
||||
{ usageLedger: [] } as unknown as CapabilityLiveSessionSnapshot,
|
||||
)).toThrow("omitted usage accounting");
|
||||
});
|
||||
|
||||
it.each(["pi", "cursor", "copilot"])("retains unpriced %s usage without inventing an invoice or substituting a model", (agent) => {
|
||||
const snapshot = {
|
||||
config: { provider: "acpx", acpxAgent: agent },
|
||||
usageLedger: [{
|
||||
receiptId: "candidate-turn-receipt", providerCalls: 1, providerRequests: 2,
|
||||
inputTokens: 1_000, outputTokens: 100, cachedInputTokens: 400,
|
||||
reasoningTokens: 50, costNanodollars: 0,
|
||||
}],
|
||||
} as unknown as CapabilityLiveSessionSnapshot;
|
||||
expect(evalSessionUsage("exact-provider-model[context=272k]", snapshot)).toMatchObject({
|
||||
agentTurns: 1, providerRequests: 2, inputTokens: 1_000, outputTokens: 100,
|
||||
providerReportedCostNanodollars: null,
|
||||
providerReportedCostProvenance: "unavailable",
|
||||
estimatedCostNanodollars: null, pricingVersion: null, ratesUsdPerMillionTokens: null,
|
||||
costCoverage: "unpriced",
|
||||
});
|
||||
const parsed = parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: agent, model: "exact-provider-model[context=272k]" }), { candidateProfile: agent as "pi" | "cursor" | "copilot" });
|
||||
expect(() => boundedEvalSessionUsage(parsed, {
|
||||
turnId: "candidate-turn", status: "completed", assistantText: "Completed provider response", snapshot,
|
||||
})).toThrow("budget cost coverage is unavailable");
|
||||
const priced = evalSessionUsage("openrouter/deepseek/deepseek-v4-flash-0731", snapshot);
|
||||
expect(priced.providerReportedCostNanodollars).toBeNull();
|
||||
expect(priced.costCoverage).toBe("estimated");
|
||||
expect(priced.estimatedCostNanodollars).toBeGreaterThan(0);
|
||||
expect(priced.pricingVersion).toBe("provider-list-prices-2026-09-07-openrouter");
|
||||
expect(priced.ratesUsdPerMillionTokens).toEqual({ input: 0.14, cachedInput: 0.028, output: 0.28 });
|
||||
expect(() => evalSessionUsage("unpriced-qualified-model", {
|
||||
...snapshot, config: { provider: "codex" },
|
||||
} as unknown as CapabilityLiveSessionSnapshot)).toThrow("model pricing unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("eval-session budget settlement", () => {
|
||||
it.each(["no_receipt", "grok_no_receipt", "unpriced", "mixed", "over_limit"] as const)("retains the completed provider outcome while failing %s accounting", async (kind) => {
|
||||
const workspace = await mkdtemp(join(tmpdir(), "eval-budget-settlement-"));
|
||||
try {
|
||||
const binary = join(workspace, "runnerd");
|
||||
await writeFile(binary, "unused fake runner");
|
||||
const agent = kind === "grok_no_receipt" ? "grok" : "copilot";
|
||||
const noReceipt = kind === "no_receipt" || kind === "grok_no_receipt";
|
||||
const model = kind === "grok_no_receipt" ? "grok-4.7" : kind === "unpriced" || kind === "no_receipt" ? "exact-unpriced-candidate" : "gpt-5.6-sol";
|
||||
const unavailable = { turnId: kind === "mixed" ? "prior-turn" : "turn-1", attemptId: "attempt-1", agent: "copilot", reason: "provider_did_not_report_usage", tokenUsage: null, costNanodollars: null, observedAt: "2026-09-28T19:00:00.000Z" };
|
||||
const state = JSON.stringify(createCapabilityFixtureState({}));
|
||||
const snapshot = {
|
||||
sessionId: "session-1", revision: 1, providerThreadId: "provider-1", providerSessionId: "provider-1",
|
||||
providerModel: { id: model, provider: agent === "grok" ? "xai" : "github" }, status: "idle", activeTurnId: null,
|
||||
createdAt: "2026-09-28T19:00:00.000Z", updatedAt: "2026-09-28T19:00:01.000Z",
|
||||
authority: { companyId: "company-1", actorId: "actor-1", taskId: "task-1", runId: "run-1", sessionId: "session-1", scenarioId: "budget-test" },
|
||||
config: { provider: "acpx", acpxAgent: agent, driver: "acpx_runtime", seedState: state, workingDirectory: workspace, scenario: { id: "budget-test" }, capabilities: [], explicitClaims: [], turnTimeoutMs: 1000 },
|
||||
mockState: state, process: null, networkEvidence: { realPaperclipRequests: 0, childPaperclipEnvironmentKeys: [] },
|
||||
transcript: [{ id: "assistant-1", role: "assistant", text: "Retained actual provider response", turnId: "turn-1", at: "2026-09-28T19:00:01.000Z" }],
|
||||
evidence: [], authorizationRecords: [], attempts: [], terminalTurns: [{ turnId: "turn-1", status: "completed" }],
|
||||
usageLedger: noReceipt ? [] : [{ receiptId: "receipt-1", turnId: "turn-1", providerCalls: 1, providerRequests: 1, inputTokens: 100, outputTokens: kind === "over_limit" ? 1_000_000 : 10, cachedInputTokens: 0, reasoningTokens: 0, costNanodollars: 0 }],
|
||||
usageUnavailable: kind === "no_receipt" || kind === "mixed" ? [unavailable] : [],
|
||||
} as unknown as CapabilityLiveSessionSnapshot;
|
||||
const sendMessage = vi.fn(async () => ({ turnId: "turn-1", status: "completed", assistantText: "Retained actual provider response", snapshot }));
|
||||
const completeAttempt = vi.fn(async () => undefined);
|
||||
const shutdown = vi.fn(async () => { snapshot.status = "closed"; });
|
||||
const input = request({ provider: "acpx", acpxAgent: agent, model,
|
||||
runnerd: { path: binary, sha256: createHash("sha256").update("unused fake runner").digest("hex") },
|
||||
session: { workingDirectory: workspace }, limits: { turnTimeoutMs: 1000, maxAgentTurns: 2, maxEstimatedCostNanodollars: 100_000_000 },
|
||||
});
|
||||
const requestPath = join(workspace, "request.json");
|
||||
const outputPath = join(workspace, "output.json");
|
||||
await writeFile(requestPath, JSON.stringify(input));
|
||||
const exitCode = await runEvalSessionCli(["--request", requestPath, "--output", outputPath, ...(agent === "grok" ? [] : ["--candidate-profile", "copilot"])], {
|
||||
serviceFactory: () => ({ create: async () => ({ sendMessage, completeAttempt, shutdown, snapshot: () => snapshot }) }) as never,
|
||||
});
|
||||
const artifact = JSON.parse(await readFile(outputPath, "utf8"));
|
||||
expect(exitCode).toBe(2);
|
||||
expect(artifact).not.toHaveProperty("infrastructureError");
|
||||
expect(artifact.accountingFailure).toMatchObject({ class: kind === "over_limit" ? "provider_budget_reached" : "provider_budget_coverage_unknown", retryable: false });
|
||||
expect(artifact.turn).toMatchObject({ status: "completed", assistantText: "Retained actual provider response" });
|
||||
expect(artifact.snapshot.status).toBe("closed");
|
||||
expect(artifact.acpxProfile.agent).toBe(agent);
|
||||
expect(artifact.devtools).toBeDefined();
|
||||
expect(artifact.issueThread).toBeDefined();
|
||||
if (noReceipt) expect(artifact).not.toHaveProperty("usage");
|
||||
else expect(artifact.usage.providerReportedCostNanodollars).toBeNull();
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(shutdown).toHaveBeenCalledTimes(1);
|
||||
expect(completeAttempt).toHaveBeenCalledWith("failed", artifact.accountingFailure.class);
|
||||
} finally {
|
||||
for (const entry of await readdir(workspace)) {
|
||||
if (entry.startsWith(".paperclip-eval-runtime-context-")) await chmod(join(workspace, entry, "instructions"), 0o700).catch(() => undefined);
|
||||
}
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,8 @@ export interface EvalSessionAgentCoreProfile {
|
||||
timeoutSeconds: number;
|
||||
}
|
||||
|
||||
export type EvalCandidateProfile = "pi" | "cursor" | "copilot";
|
||||
|
||||
export interface EvalSessionRequest {
|
||||
schema: typeof EVAL_SESSION_REQUEST_SCHEMA;
|
||||
attemptId: string;
|
||||
@@ -66,7 +68,7 @@ export interface EvalSessionRequest {
|
||||
provider?: EvalSessionProvider;
|
||||
driver?: EvalSessionDriver;
|
||||
opencodeVersion?: string;
|
||||
acpxAgent?: Exclude<QualifiedAcpxAgent, "pi">;
|
||||
acpxAgent?: QualifiedAcpxAgent;
|
||||
managedProfile?: EvalSessionManagedProfile;
|
||||
agentCoreProfile?: EvalSessionAgentCoreProfile;
|
||||
runnerd: { path: string; sha256: string };
|
||||
@@ -81,14 +83,19 @@ export interface EvalSessionRequest {
|
||||
includeCollaborationModeInstructions?: true;
|
||||
}
|
||||
|
||||
export interface EvalSessionUsage extends EstimatedModelCost {
|
||||
export interface EvalSessionUsage {
|
||||
agentTurns: number;
|
||||
providerRequests: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
reasoningTokens: number;
|
||||
providerReportedCostNanodollars: number;
|
||||
providerReportedCostNanodollars: number | null;
|
||||
providerReportedCostProvenance: "legacy_runner_usage_ledger" | "unavailable";
|
||||
estimatedCostNanodollars: number | null;
|
||||
pricingVersion: EstimatedModelCost["pricingVersion"] | null;
|
||||
ratesUsdPerMillionTokens: EstimatedModelCost["ratesUsdPerMillionTokens"] | null;
|
||||
costCoverage: "estimated" | "unpriced" | "legacy_ledger_and_estimate";
|
||||
}
|
||||
|
||||
function object(value: unknown, path: string): Record<string, unknown> {
|
||||
@@ -211,7 +218,10 @@ function parseAgentCoreProfile(value: unknown): EvalSessionAgentCoreProfile {
|
||||
}
|
||||
|
||||
/** Fail-closed validation for the executable boundary. */
|
||||
export function parseEvalSessionRequest(value: unknown): EvalSessionRequest {
|
||||
export function parseEvalSessionRequest(
|
||||
value: unknown,
|
||||
options: { candidateProfile?: EvalCandidateProfile } = {},
|
||||
): EvalSessionRequest {
|
||||
const input = object(value, "request");
|
||||
if (input.schema !== EVAL_SESSION_REQUEST_SCHEMA) {
|
||||
throw new Error("unsupported request schema");
|
||||
@@ -237,17 +247,24 @@ export function parseEvalSessionRequest(value: unknown): EvalSessionRequest {
|
||||
// options as JSON null. Preserve compatibility with those immutable request
|
||||
// artifacts while continuing to reject non-null values for the wrong lane.
|
||||
const acpxAgent = input.acpxAgent === null ? undefined : input.acpxAgent;
|
||||
if (acpxAgent === "pi") throw new Error("The Pi ACPX profile is not available");
|
||||
if (
|
||||
acpxAgent !== undefined &&
|
||||
acpxAgent !== "codex" &&
|
||||
acpxAgent !== "claude" && acpxAgent !== "grok"
|
||||
acpxAgent !== "claude" && acpxAgent !== "grok" &&
|
||||
acpxAgent !== "pi" && acpxAgent !== "cursor" && acpxAgent !== "copilot"
|
||||
) {
|
||||
throw new Error("eval-session acpxAgent must be codex, claude, or grok");
|
||||
throw new Error("eval-session acpxAgent must be a registered ACPX profile");
|
||||
}
|
||||
if (provider !== "acpx" && acpxAgent !== undefined) {
|
||||
throw new Error("eval-session acpxAgent requires provider acpx");
|
||||
}
|
||||
const candidate = acpxAgent === "pi" || acpxAgent === "cursor" || acpxAgent === "copilot";
|
||||
if (options.candidateProfile !== undefined && (provider !== "acpx" || acpxAgent !== options.candidateProfile || !candidate)) {
|
||||
throw new Error("--candidate-profile must match the request's registered candidate ACPX agent");
|
||||
}
|
||||
if (candidate && options.candidateProfile !== acpxAgent) {
|
||||
throw new Error("Candidate ACPX profiles require an explicit matching --candidate-profile diagnostic flag");
|
||||
}
|
||||
const managedProfileInput = input.managedProfile === null
|
||||
? undefined
|
||||
: input.managedProfile;
|
||||
@@ -306,8 +323,8 @@ export function parseEvalSessionRequest(value: unknown): EvalSessionRequest {
|
||||
) {
|
||||
throw new Error("request.session.requestedModel must match request.model");
|
||||
}
|
||||
if (session.acpxAgent === "pi") {
|
||||
throw new Error("The Pi ACPX profile is not available");
|
||||
if (session.acpxAgent !== undefined && session.acpxAgent !== acpxAgent) {
|
||||
throw new Error("request.session.acpxAgent must match request.acpxAgent");
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -376,8 +393,28 @@ export function evalSessionUsage(
|
||||
if (unique.size === 0) {
|
||||
throw new Error("completed turn omitted usage accounting");
|
||||
}
|
||||
const candidate = snapshot.config?.provider === "acpx"
|
||||
&& ["pi", "cursor", "copilot"].includes(snapshot.config.acpxAgent ?? "");
|
||||
let estimate: EstimatedModelCost | null;
|
||||
try {
|
||||
estimate = estimateModelCostNanodollars(model, totals);
|
||||
} catch (error) {
|
||||
// An exact candidate model can be advertised before our pricing catalog
|
||||
// contains it. Preserve unpriced usage for billing reconciliation instead
|
||||
// of substituting a model or converting unknown spend into zero.
|
||||
if (!candidate || !(error instanceof Error) || !error.message.startsWith("model pricing unavailable for ")) throw error;
|
||||
estimate = null;
|
||||
}
|
||||
return {
|
||||
...totals,
|
||||
...estimateModelCostNanodollars(model, totals),
|
||||
// Candidate ACP adapters do not supply authenticated USD receipts. The
|
||||
// shared legacy ledger fills absent cost with zero, so its numeric value
|
||||
// cannot establish an invoice amount for these profiles.
|
||||
providerReportedCostNanodollars: candidate ? null : totals.providerReportedCostNanodollars,
|
||||
providerReportedCostProvenance: candidate ? "unavailable" : "legacy_runner_usage_ledger",
|
||||
estimatedCostNanodollars: estimate?.estimatedCostNanodollars ?? null,
|
||||
pricingVersion: estimate?.pricingVersion ?? null,
|
||||
ratesUsdPerMillionTokens: estimate?.ratesUsdPerMillionTokens ?? null,
|
||||
costCoverage: candidate ? (estimate === null ? "unpriced" : "estimated") : "legacy_ledger_and_estimate",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, realpath, rm, symlink } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const tsx = fileURLToPath(new URL("../../node_modules/.bin/tsx", import.meta.url));
|
||||
|
||||
it("runs the eval CLI parser through both canonical and symlinked paths", async () => {
|
||||
const cli = await realpath(fileURLToPath(new URL("./eval-session.ts", import.meta.url)));
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-eval-cli-entrypoint-"));
|
||||
try {
|
||||
const alias = join(root, "cli-alias");
|
||||
await symlink(dirname(cli), alias, "dir");
|
||||
for (const path of [cli, join(alias, "eval-session.ts")]) {
|
||||
// A skipped main guard silently exits zero. Rejecting the invalid option
|
||||
// proves actual CLI execution without loading credentials or a provider.
|
||||
await expect(execute(tsx, [path, "--unknown", "value"], { timeout: 15_000 }))
|
||||
.rejects.toMatchObject({ code: 1, stderr: "unknown argument: --unknown\n" });
|
||||
}
|
||||
const imported = await execute(tsx, ["--eval", `import(${JSON.stringify(cli)}).then(() => console.log("imported"))`], { timeout: 15_000 });
|
||||
expect(imported.stdout.trim()).toBe("imported");
|
||||
expect(imported.stderr).toBe("");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 45_000);
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { realpathSync } from "node:fs";
|
||||
import { chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
expectedEvalSessionDriver,
|
||||
parseEvalSessionRequest,
|
||||
type EvalSessionRequest,
|
||||
type EvalCandidateProfile,
|
||||
type EvalSessionUsage,
|
||||
} from "./eval-session-contract.js";
|
||||
import { evalProviderTransportOptions } from "./eval-provider-runtime.js";
|
||||
@@ -37,6 +39,7 @@ import { evalProviderTransportOptions } from "./eval-provider-runtime.js";
|
||||
interface EvalSessionCliOptions {
|
||||
requestPath: string;
|
||||
outputPath: string;
|
||||
candidateProfile?: EvalCandidateProfile;
|
||||
}
|
||||
|
||||
function argument(args: string[], name: string): string {
|
||||
@@ -47,14 +50,23 @@ function argument(args: string[], name: string): string {
|
||||
}
|
||||
|
||||
export function parseEvalSessionCliArgs(args: string[]): EvalSessionCliOptions {
|
||||
const allowed = new Set(["--request", "--output"]);
|
||||
const allowed = new Set(["--request", "--output", "--candidate-profile"]);
|
||||
const seen = new Set<string>();
|
||||
for (let index = 0; index < args.length; index += 2) {
|
||||
if (!allowed.has(args[index] ?? "")) {
|
||||
throw new Error(`unknown argument: ${args[index] ?? ""}`);
|
||||
}
|
||||
if (args[index + 1] === undefined) throw new Error(`missing ${args[index]}`);
|
||||
if (seen.has(args[index]!)) throw new Error(`duplicate argument: ${args[index]}`);
|
||||
seen.add(args[index]!);
|
||||
if (args[index + 1] === undefined || args[index + 1]!.startsWith("--")) throw new Error(`missing ${args[index]}`);
|
||||
}
|
||||
const candidateIndex = args.indexOf("--candidate-profile");
|
||||
const candidateProfile = candidateIndex < 0 ? undefined : args[candidateIndex + 1];
|
||||
if (candidateProfile !== undefined && candidateProfile !== "pi" && candidateProfile !== "cursor" && candidateProfile !== "copilot") {
|
||||
throw new Error("--candidate-profile must be pi, cursor, or copilot");
|
||||
}
|
||||
return {
|
||||
...(candidateProfile === undefined ? {} : { candidateProfile }),
|
||||
requestPath: argument(args, "--request"),
|
||||
outputPath: argument(args, "--output"),
|
||||
};
|
||||
@@ -180,12 +192,22 @@ export function evalSessionProviderVersion(
|
||||
return null;
|
||||
}
|
||||
|
||||
class EvalSessionBudgetError extends Error {
|
||||
constructor(message: string, readonly coverageUnknown = false) {
|
||||
super(message);
|
||||
this.name = "EvalSessionBudgetError";
|
||||
}
|
||||
}
|
||||
|
||||
function failureClass(error: unknown): {
|
||||
class: string;
|
||||
category: string;
|
||||
retryable: boolean;
|
||||
diagnostics: Record<string, never>;
|
||||
} {
|
||||
if (error instanceof EvalSessionBudgetError && error.coverageUnknown) {
|
||||
return { class: "provider_budget_coverage_unknown", category: "provider_budget", retryable: false, diagnostics: {} };
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (/timed? ?out|timeout/i.test(message)) {
|
||||
return {
|
||||
@@ -235,25 +257,48 @@ export function boundedEvalSessionUsage(
|
||||
request: EvalSessionRequest,
|
||||
turn: CapabilityLiveTurnResult,
|
||||
): EvalSessionUsage | null {
|
||||
if (turn.status !== "completed" ||
|
||||
(request.provider === "acpx" && request.acpxAgent === "grok" && !turn.snapshot.usageLedger?.length)) {
|
||||
if (turn.status !== "completed") {
|
||||
return usageIfAvailable(request, turn.snapshot);
|
||||
}
|
||||
const usage = evalSessionUsage(request.model, turn.snapshot);
|
||||
if (usage.agentTurns > request.limits.maxAgentTurns) {
|
||||
throw new Error("agent turn limit exceeded");
|
||||
if (request.provider === "acpx" && request.acpxAgent === "grok" && !turn.snapshot.usageLedger?.length) {
|
||||
throw new EvalSessionBudgetError("budget cost coverage is unavailable for the completed Grok turn", true);
|
||||
}
|
||||
const candidate = request.provider === "acpx"
|
||||
&& request.acpxAgent !== undefined
|
||||
&& ["pi", "cursor", "copilot"].includes(request.acpxAgent)
|
||||
&& turn.snapshot.config.provider === "acpx"
|
||||
&& turn.snapshot.config.acpxAgent === request.acpxAgent;
|
||||
const measuredTurns = new Set(turn.snapshot.usageLedger?.map((entry) => entry.turnId));
|
||||
const unavailableTurnIds = new Set(candidate
|
||||
? turn.snapshot.usageUnavailable?.filter((entry) => entry.agent === request.acpxAgent
|
||||
&& entry.reason === "provider_did_not_report_usage"
|
||||
&& entry.tokenUsage === null && entry.costNanodollars === null
|
||||
&& !measuredTurns.has(entry.turnId)).map((entry) => entry.turnId)
|
||||
: []);
|
||||
const unavailable = unavailableTurnIds.size > 0;
|
||||
const usage = unavailable && (unavailableTurnIds.has(turn.turnId) || turn.snapshot.usageLedger?.length)
|
||||
? usageIfAvailable(request, turn.snapshot)
|
||||
: evalSessionUsage(request.model, turn.snapshot);
|
||||
const unavailableTurns = unavailableTurnIds.size;
|
||||
if ((usage?.agentTurns ?? 0) + unavailableTurns > request.limits.maxAgentTurns) {
|
||||
throw new EvalSessionBudgetError("agent turn limit exceeded");
|
||||
}
|
||||
if (
|
||||
usage !== null && usage.estimatedCostNanodollars !== null &&
|
||||
usage.estimatedCostNanodollars >
|
||||
request.limits.maxEstimatedCostNanodollars
|
||||
) {
|
||||
throw new Error("estimated cost limit exceeded");
|
||||
throw new EvalSessionBudgetError("estimated cost limit exceeded");
|
||||
}
|
||||
if (
|
||||
usage !== null && usage.providerReportedCostNanodollars !== null &&
|
||||
usage.providerReportedCostNanodollars >
|
||||
request.limits.maxEstimatedCostNanodollars
|
||||
) {
|
||||
throw new Error("provider-reported cost limit exceeded");
|
||||
throw new EvalSessionBudgetError("provider-reported cost limit exceeded");
|
||||
}
|
||||
if (unavailable || usage === null || (usage.estimatedCostNanodollars === null && usage.providerReportedCostNanodollars === null)) {
|
||||
throw new EvalSessionBudgetError("budget cost coverage is unavailable for one or more completed turns", true);
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
@@ -277,6 +322,7 @@ export async function runEvalSessionCli(
|
||||
const cli = parseEvalSessionCliArgs(args);
|
||||
const request = parseEvalSessionRequest(
|
||||
JSON.parse(await readFile(cli.requestPath, "utf8")),
|
||||
{ candidateProfile: cli.candidateProfile },
|
||||
);
|
||||
const runnerdPath = resolve(request.runnerd.path);
|
||||
const actualDigest = await sha256(runnerdPath);
|
||||
@@ -300,6 +346,7 @@ export async function runEvalSessionCli(
|
||||
transportOptions: {
|
||||
...evalProviderTransportOptions(requestedProvider, request.limits.turnTimeoutMs),
|
||||
runnerBinary: runnerdPath,
|
||||
...(cli.candidateProfile === undefined ? {} : { acpxCandidateProfile: cli.candidateProfile }),
|
||||
runtimeContext,
|
||||
baseInstructions: evalRuntimeSystemInstructions(runtimeContext),
|
||||
// The transport performs the provider-specific allowlisting. Supplying
|
||||
@@ -338,10 +385,21 @@ export async function runEvalSessionCli(
|
||||
} as unknown as CreateCapabilityLiveSessionInput;
|
||||
session = await service.create(createInput);
|
||||
turn = await session.sendMessage(request.prompt);
|
||||
const usage = boundedEvalSessionUsage(request, turn);
|
||||
let usage: EvalSessionUsage | null;
|
||||
let accountingError: EvalSessionBudgetError | null = null;
|
||||
try {
|
||||
usage = boundedEvalSessionUsage(request, turn);
|
||||
} catch (error) {
|
||||
if (!(error instanceof EvalSessionBudgetError)) throw error;
|
||||
// A completed provider outcome remains inspectable even when its budget
|
||||
// cannot be verified. Never turn unknown cost into a successful CLI run.
|
||||
accountingError = error;
|
||||
usage = usageIfAvailable(request, turn.snapshot);
|
||||
}
|
||||
await session.completeAttempt(
|
||||
turn.status === "completed" ? "succeeded" : "failed",
|
||||
turn.status === "completed" ? null : `provider_turn_${turn.status}`,
|
||||
accountingError === null && turn.status === "completed" ? "succeeded" : "failed",
|
||||
accountingError !== null ? failureClass(accountingError).class
|
||||
: turn.status === "completed" ? null : `provider_turn_${turn.status}`,
|
||||
);
|
||||
await closeSession(session, "eval session complete");
|
||||
snapshot = session.snapshot();
|
||||
@@ -352,6 +410,7 @@ export async function runEvalSessionCli(
|
||||
build: PAPERCLIP_RUNNER_BUILD_METADATA,
|
||||
runnerd: { path: "[withheld]", sha256: `sha256:${actualDigest}` },
|
||||
requestedModel: request.model,
|
||||
...(cli.candidateProfile === undefined ? {} : { diagnosticCandidateProfile: cli.candidateProfile }),
|
||||
provider: requestedProvider,
|
||||
driver: requestedDriver,
|
||||
providerVersion: requestedProviderVersion,
|
||||
@@ -379,6 +438,10 @@ export async function runEvalSessionCli(
|
||||
: {}),
|
||||
turn,
|
||||
snapshot,
|
||||
...(accountingError === null ? {} : {
|
||||
accountingError: accountingError.message,
|
||||
accountingFailure: failureClass(accountingError),
|
||||
}),
|
||||
devtools: projectCapabilityDevtools(snapshot),
|
||||
issueThread: projectCapabilityIssueThread({
|
||||
snapshot,
|
||||
@@ -392,7 +455,7 @@ export async function runEvalSessionCli(
|
||||
durationMs: Date.now() - startedAtMs,
|
||||
},
|
||||
}, null, 2)}\n`);
|
||||
return 0;
|
||||
return accountingError === null ? 0 : 2;
|
||||
} catch (error) {
|
||||
if (session !== null) {
|
||||
snapshot = session.snapshot();
|
||||
@@ -425,6 +488,7 @@ export async function runEvalSessionCli(
|
||||
build: PAPERCLIP_RUNNER_BUILD_METADATA,
|
||||
runnerd: { path: "[withheld]", sha256: `sha256:${actualDigest}` },
|
||||
requestedModel: request.model,
|
||||
...(cli.candidateProfile === undefined ? {} : { diagnosticCandidateProfile: cli.candidateProfile }),
|
||||
provider: requestedProvider,
|
||||
driver: requestedDriver,
|
||||
providerVersion: requestedProviderVersion,
|
||||
@@ -453,10 +517,18 @@ export async function runEvalSessionCli(
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] !== undefined &&
|
||||
resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))
|
||||
) {
|
||||
function isEvalSessionEntrypoint(): boolean {
|
||||
if (process.argv[1] === undefined) return false;
|
||||
try {
|
||||
// Node resolves module URLs through symlinks, including /tmp on macOS and
|
||||
// package-manager bin links. Compare the same physical paths on both sides.
|
||||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isEvalSessionEntrypoint()) {
|
||||
void runEvalSessionCli(process.argv.slice(2))
|
||||
.then((code) => {
|
||||
process.exitCode = code;
|
||||
|
||||
@@ -2,7 +2,7 @@ import type {
|
||||
PrpEvent,
|
||||
PrpStructuredRunResult,
|
||||
} from "../protocol/replay-contract.js";
|
||||
import type { NativeSessionCapabilities, NativeUserMessage } from "./types.js";
|
||||
import type { NativeSessionCapabilities, NativeTurnControlCapabilities, NativeUserMessage } from "./types.js";
|
||||
import {
|
||||
PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2,
|
||||
parsePaperclipQuestionResponse,
|
||||
@@ -495,6 +495,7 @@ export interface HarnessSessionRecoveryResult {
|
||||
}
|
||||
|
||||
export interface HarnessSession {
|
||||
turnControlCapabilities?(): NativeTurnControlCapabilities | null;
|
||||
ids(): {
|
||||
driverSessionId: string;
|
||||
providerSessionId?: string | null;
|
||||
@@ -512,6 +513,8 @@ export interface HarnessSession {
|
||||
effectiveCollaborationMode?: "default" | "plan";
|
||||
}>;
|
||||
steer?(input: {
|
||||
/** Queued follow-ups remain distinct from active-turn steering. */
|
||||
mode?: "steer" | "follow_up";
|
||||
turnId: string;
|
||||
message: NativeUserMessage;
|
||||
correlationId?: string;
|
||||
|
||||
@@ -280,7 +280,7 @@ describe("NativeExecutionInputV1", () => {
|
||||
})).toThrow("eventExpiryDays");
|
||||
});
|
||||
|
||||
it("accepts only a closed ACPX profile matching the driver and agent", () => {
|
||||
it.each([1, 2, 3, 4, 5] as const)("accepts only a closed ACPX profile matching the driver and agent at profile version %s", (agentProfileVersion) => {
|
||||
const provider = {
|
||||
kind: "acpx",
|
||||
agent: "pi",
|
||||
@@ -291,7 +291,7 @@ describe("NativeExecutionInputV1", () => {
|
||||
protocolVersion: 1,
|
||||
acpxVersion: "0.13.1",
|
||||
agent: "pi",
|
||||
agentProfileVersion: 1,
|
||||
agentProfileVersion,
|
||||
agentServerPackage: "pi-acp",
|
||||
agentServerVersion: "0.0.33",
|
||||
agentRuntimePackage: "@earendil-works/pi-coding-agent",
|
||||
@@ -312,6 +312,13 @@ describe("NativeExecutionInputV1", () => {
|
||||
profile: provider.profile,
|
||||
});
|
||||
expect(parseNativeExecutionInput(parsed)).toEqual(parsed);
|
||||
for (const unsupportedVersion of [0, 6, 1.5, "5", null]) {
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...input,
|
||||
session: { ...input.session, driverKind: "acpx_runtime" },
|
||||
provider: { ...provider, profile: { ...provider.profile, agentProfileVersion: unsupportedVersion } },
|
||||
})).toThrow("qualified ACPX v1 profile");
|
||||
}
|
||||
expect(buildNativeModelEnvelope(parsed).workspace).toEqual({ cwd: "/safe/workspace" });
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...input,
|
||||
|
||||
@@ -80,7 +80,7 @@ export interface NativeAwsAgentCoreProfileSnapshot {
|
||||
eventExpiryDays: 90;
|
||||
}
|
||||
|
||||
export type NativeAcpxAgent = "pi" | "claude" | "codex" | "grok";
|
||||
export type NativeAcpxAgent = "pi" | "claude" | "codex" | "grok" | "cursor" | "copilot";
|
||||
export type NativeCodexApprovalPolicy = "never" | "on-request" | "untrusted";
|
||||
export type NativeOpenCodePermissionMode = "allow" | "ask" | "deny";
|
||||
export type NativeAcpxPermissionMode = "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all";
|
||||
@@ -90,7 +90,7 @@ export interface NativeAcpxProfileSnapshot {
|
||||
protocolVersion: 1;
|
||||
acpxVersion: "0.13.1";
|
||||
agent: NativeAcpxAgent;
|
||||
agentProfileVersion: 1;
|
||||
agentProfileVersion: 1 | 2 | 3 | 4 | 5;
|
||||
agentServerPackage: string;
|
||||
agentServerVersion: string;
|
||||
agentRuntimePackage: string | null;
|
||||
@@ -568,8 +568,8 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|
||||
if (providerModel === null) {
|
||||
throw new NativeExecutionInputError("input.provider.model is required for acpx");
|
||||
}
|
||||
if (provider.agent !== "pi" && provider.agent !== "claude" && provider.agent !== "codex" && provider.agent !== "grok") {
|
||||
throw new NativeExecutionInputError("input.provider.agent must be pi, claude, codex, or grok");
|
||||
if (provider.agent !== "pi" && provider.agent !== "claude" && provider.agent !== "codex" && provider.agent !== "grok" && provider.agent !== "cursor" && provider.agent !== "copilot") {
|
||||
throw new NativeExecutionInputError("input.provider.agent must be pi, claude, codex, grok, cursor, or copilot");
|
||||
}
|
||||
if (isV4) {
|
||||
if (provider.permissionMode !== "approve-all" && provider.permissionMode !== "approve-paperclip" && provider.permissionMode !== "approve-reads" && provider.permissionMode !== "deny-all") {
|
||||
@@ -596,7 +596,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|
||||
|| profile.protocolVersion !== 1
|
||||
|| profile.acpxVersion !== "0.13.1"
|
||||
|| profile.agent !== provider.agent
|
||||
|| profile.agentProfileVersion !== 1
|
||||
|| (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2 && profile.agentProfileVersion !== 3 && profile.agentProfileVersion !== 4 && profile.agentProfileVersion !== 5)
|
||||
) {
|
||||
throw new NativeExecutionInputError("input.provider.profile does not match the qualified ACPX v1 profile");
|
||||
}
|
||||
@@ -617,7 +617,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|
||||
protocolVersion: 1,
|
||||
acpxVersion: "0.13.1",
|
||||
agent: provider.agent,
|
||||
agentProfileVersion: 1,
|
||||
agentProfileVersion: profile.agentProfileVersion,
|
||||
agentServerPackage: text(profile.agentServerPackage, "input.provider.profile.agentServerPackage"),
|
||||
agentServerVersion: text(profile.agentServerVersion, "input.provider.profile.agentServerVersion"),
|
||||
agentRuntimePackage: runtimePackage,
|
||||
|
||||
@@ -154,6 +154,7 @@ export interface NativeSession {
|
||||
effectiveCollaborationMode?: "default" | "plan";
|
||||
}>;
|
||||
steer?(input: {
|
||||
mode?: "steer" | "follow_up";
|
||||
turnId: string;
|
||||
message: NativeUserMessage;
|
||||
correlationId?: string;
|
||||
|
||||
@@ -262,8 +262,8 @@ export function parsePaperclipQuestionSet(value: unknown): PaperclipQuestionSet
|
||||
...(optionalText(candidate.title, "/input/title", 1_000) !== undefined
|
||||
? { title: optionalText(candidate.title, "/input/title", 1_000) }
|
||||
: {}),
|
||||
...(optionalText(candidate.description, "/input/description") !== undefined
|
||||
? { description: optionalText(candidate.description, "/input/description") }
|
||||
...(optionalText(candidate.description, "/input/description", 100_000) !== undefined
|
||||
? { description: optionalText(candidate.description, "/input/description", 100_000) }
|
||||
: {}),
|
||||
...(optionalText(candidate.submitLabel, "/input/submitLabel", 200) !== undefined
|
||||
? { submitLabel: optionalText(candidate.submitLabel, "/input/submitLabel", 200) }
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface NativeSessionCapabilities {
|
||||
typedEvents: boolean;
|
||||
typedEventFamilies?: TypedEventFamilyCapability[];
|
||||
steering: boolean;
|
||||
queuedFollowUp?: boolean;
|
||||
interruption: boolean;
|
||||
structuredResult: boolean;
|
||||
read?: boolean;
|
||||
@@ -47,3 +48,9 @@ export interface NativeUserMessage {
|
||||
text: string;
|
||||
}
|
||||
import type { TypedEventFamilyCapability } from "../provider-events.js";
|
||||
|
||||
/** Live provider handshake; queued follow-up is distinct from active steering. */
|
||||
export interface NativeTurnControlCapabilities {
|
||||
steering: boolean;
|
||||
queuedFollowUp: boolean;
|
||||
}
|
||||
@@ -20,7 +20,8 @@ import { resolve } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { NativeSessionProtocolIntegrityError } from "../contracts/native-session-backend.js";
|
||||
import { createCapabilityRunnerdCodexTransport } from "../live/runnerd-codex-transport.js";
|
||||
import { createCapabilityRunnerdCodexTransport, createCapabilityRunnerdProviderEnvironment } from "../live/runnerd-codex-transport.js";
|
||||
import { ACPX_CREDENTIAL_BINDING_ENV, createAcpxSidecarHostEnvironment } from "../drivers/acpx/environment.js";
|
||||
import { validatePrpEvent } from "../protocol/replay-contract.js";
|
||||
import { digestPaperclipSemanticContent } from "../semantic-tools/receipts.js";
|
||||
import {
|
||||
@@ -533,6 +534,53 @@ it("preserves the controller-selected ACPX provider package root", () => {
|
||||
expect(launches[0]!.environment.NODE_PATH).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["pi", "OPENROUTER_API_KEY"],
|
||||
["cursor", "CURSOR_API_KEY"],
|
||||
["cursor", "CURSOR_AUTH_TOKEN"],
|
||||
["copilot", "COPILOT_GITHUB_TOKEN"],
|
||||
] as const)("preserves explicit %s %s binding through the actual runner launch boundary", (agent, key) => {
|
||||
const launches: RunnerProcessLaunchSpec[] = [];
|
||||
vi.stubEnv(key, "ambient-must-not-cross");
|
||||
vi.stubEnv(ACPX_CREDENTIAL_BINDING_ENV, "ambient-forged-marker");
|
||||
try {
|
||||
for (const explicit of [true, false]) {
|
||||
const environment = createCapabilityRunnerdProviderEnvironment({
|
||||
provider: "acpx", identity, codexHome: "/fixture/home",
|
||||
runtimeContextPath: "/fixture/context.json", hasRuntimeContext: false,
|
||||
options: { acpxAgent: agent,
|
||||
environment: explicit ? { [key]: "explicit-fixture-credential", [ACPX_CREDENTIAL_BINDING_ENV]: "caller-forged-marker", DATABASE_URL: "must-not-cross" } : undefined },
|
||||
});
|
||||
const handle = spawnRunner({
|
||||
connection: { mode: "connect", connectUrl: "ws://127.0.0.1:43127" },
|
||||
stateDirectory: "/tmp/paperclip-runner-test", identity,
|
||||
ticket: "bootstrap-ticket", maxOutboxBytes: 256 * 1024, p0ReserveBytes: 64 * 1024,
|
||||
runnerVersion: expectedRunnerVersion, runnerDigest: expectedRunnerDigest, environment,
|
||||
processLauncher: spec => {
|
||||
launches.push(spec);
|
||||
return { child: { pid: 42, exitCode: null, signalCode: null, kill: () => true },
|
||||
completion: Promise.resolve({ code: 0, signal: null, stdout: "", stderr: "" }) };
|
||||
},
|
||||
});
|
||||
handle.restart("replacement-ticket");
|
||||
for (const launch of launches.splice(0)) {
|
||||
expect(launch.environment[key]).toBe(explicit ? "explicit-fixture-credential" : undefined);
|
||||
const receipt = launch.environment[ACPX_CREDENTIAL_BINDING_ENV];
|
||||
expect(receipt).toBeDefined();
|
||||
expect(JSON.parse(receipt!)).toEqual({ schema: "paperclip.acpx_credential_binding.v1", agent,
|
||||
sessionId: identity.normalizedSessionId, names: explicit ? [key] : [] });
|
||||
expect(receipt).not.toContain("fixture-credential");
|
||||
expect(launch.environment.DATABASE_URL).toBeUndefined();
|
||||
const provider = createAcpxSidecarHostEnvironment(launch.environment, agent, identity.normalizedSessionId);
|
||||
expect(provider[key]).toBe(explicit ? "explicit-fixture-credential" : undefined);
|
||||
expect(provider[ACPX_CREDENTIAL_BINDING_ENV]).toBeUndefined();
|
||||
expect(() => createAcpxSidecarHostEnvironment(launch.environment, agent, "stale-session"))
|
||||
.toThrow("explicit matching session binding");
|
||||
}
|
||||
}
|
||||
} finally { vi.unstubAllEnvs(); }
|
||||
});
|
||||
|
||||
it("preserves file-backed AWS workload identity at the runner spawn boundary", () => {
|
||||
const launches: RunnerProcessLaunchSpec[] = [];
|
||||
spawnRunner({
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { Duplex } from "node:stream";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { NativeSessionProtocolIntegrityError } from "../contracts/native-session-backend.js";
|
||||
import { ACPX_CREDENTIAL_BINDING_ENV, ACPX_CREDENTIAL_NAMES } from "../drivers/acpx/environment.js";
|
||||
import { githubCredentialEnvironment } from "../github-credential-environment.js";
|
||||
import {
|
||||
validatePrpEvent,
|
||||
@@ -3277,7 +3278,10 @@ const runnerPlatformEnvironmentKeys = [
|
||||
] as const;
|
||||
|
||||
const runnerExplicitProviderEnvironmentKeys = [
|
||||
"OPENROUTER_API_KEY",
|
||||
...ACPX_CREDENTIAL_NAMES.pi,
|
||||
...ACPX_CREDENTIAL_NAMES.cursor,
|
||||
...ACPX_CREDENTIAL_NAMES.copilot,
|
||||
ACPX_CREDENTIAL_BINDING_ENV,
|
||||
"ANTHROPIC_API_KEY",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"OPENAI_API_KEY",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AcpPermissionRequest } from "acpx/runtime";
|
||||
import { normalizeAcpxPermission } from "./acp-permission-adapter.js";
|
||||
|
||||
function request(kinds = ["allow_once", "allow_always", "reject_once"]): AcpPermissionRequest {
|
||||
return { sessionId: "session", inferredKind: "edit", raw: {
|
||||
sessionId: "session", toolCall: { toolCallId: "call", title: "Edit source" },
|
||||
options: kinds.map((kind, i) => ({ kind, optionId: `option-${i}`, name: kind })),
|
||||
} } as AcpPermissionRequest;
|
||||
}
|
||||
describe("ACP permission normalization", () => {
|
||||
it("keeps requests answerable when a provider omits its operation title", () => {
|
||||
for (const title of [undefined, "", " "]) {
|
||||
const value = request();
|
||||
value.raw.toolCall.title = title;
|
||||
expect(normalizeAcpxPermission(value).title).toBe("Approve provider operation");
|
||||
}
|
||||
});
|
||||
it("offers only native choices and preserves cancellation", () => {
|
||||
const value = normalizeAcpxPermission(request(["allow_once"]));
|
||||
expect(value.choices.map(c => c.key)).toEqual(["accept", "cancel"]);
|
||||
expect(value.resolve({ action: "accept" })).toEqual({ outcome: "allow_once" });
|
||||
expect(value.resolve({ action: "cancel" })).toEqual({ outcome: "cancel" });
|
||||
expect(() => value.resolve({ action: "accept_for_session" })).toThrow("offered choice");
|
||||
expect(() => value.resolve({ action: "submit", content: { accept: true } })).toThrow("offered choice");
|
||||
});
|
||||
it("maps once, session and denial distinctly", () => {
|
||||
const value = normalizeAcpxPermission(request(), { allowAlwaysScope: "session" });
|
||||
expect(value.resolve({ action: "accept_for_session" })).toEqual({ outcome: "allow_always" });
|
||||
expect(value.resolve({ action: "decline" })).toEqual({ outcome: "reject_once" });
|
||||
});
|
||||
it("does not label an unverified persistent allowance as session-only", () => {
|
||||
const value = normalizeAcpxPermission(request());
|
||||
expect(value.choices.map(choice => choice.key)).toEqual(["accept", "decline", "cancel"]);
|
||||
expect(() => value.resolve({ action: "accept_for_session" })).toThrow("offered choice");
|
||||
});
|
||||
it("does not turn a one-time denial into permanent rejection", () => {
|
||||
expect(normalizeAcpxPermission(request(["reject_always"])).choices).toEqual([{ key: "cancel", label: "Cancel" }]);
|
||||
});
|
||||
it("rejects ambiguous provider choices before any resolution", () => {
|
||||
expect(() => normalizeAcpxPermission(request(["allow_once", "allow_once"]))).toThrow("ambiguous");
|
||||
const duplicate = request(); duplicate.raw.options[1]!.optionId = duplicate.raw.options[0]!.optionId;
|
||||
expect(() => normalizeAcpxPermission(duplicate)).toThrow("ambiguous");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { AcpPermissionDecision, AcpPermissionRequest } from "acpx/runtime";
|
||||
import type { HarnessRuntimeRequestResolution } from "../../contracts/harness-driver.js";
|
||||
|
||||
export type AcpxPermissionAction = "accept" | "accept_for_session" | "decline" | "cancel";
|
||||
export interface NormalizedAcpxPermission {
|
||||
title: string;
|
||||
kind: string;
|
||||
toolCallId: string;
|
||||
choices: Array<{ key: AcpxPermissionAction; label: string }>;
|
||||
resolve(resolution: HarnessRuntimeRequestResolution): AcpPermissionDecision;
|
||||
}
|
||||
|
||||
/** Provider options describe choices, never authority to execute an operation. */
|
||||
export function normalizeAcpxPermission(request: AcpPermissionRequest, options: { allowAlwaysScope?: "session" } = {}): NormalizedAcpxPermission {
|
||||
const raw = request.raw;
|
||||
if (!raw || !Array.isArray(raw.options) || raw.options.length > 32) {
|
||||
throw new Error("ACP permission request has invalid options");
|
||||
}
|
||||
const byKind = new Map<string, { optionId: string; name: string }>();
|
||||
const ids = new Set<string>();
|
||||
for (const option of raw.options) {
|
||||
if (!option || !["allow_once", "allow_always", "reject_once", "reject_always"].includes(option.kind)
|
||||
|| typeof option.optionId !== "string" || !option.optionId || option.optionId.length > 160
|
||||
|| typeof option.name !== "string" || !option.name || option.name.length > 500
|
||||
|| ids.has(option.optionId) || byKind.has(option.kind)) {
|
||||
// ACPX resolves by kind. Ambiguous kinds must not select a different option.
|
||||
throw new Error("ACP permission request has ambiguous or unsupported options");
|
||||
}
|
||||
ids.add(option.optionId);
|
||||
byKind.set(option.kind, { optionId: option.optionId, name: option.name });
|
||||
}
|
||||
const bindings = new Map<AcpxPermissionAction, AcpPermissionDecision["outcome"]>();
|
||||
const choices: NormalizedAcpxPermission["choices"] = [];
|
||||
for (const [key, kind, label] of [
|
||||
["accept", "allow_once", "Allow once"],
|
||||
["accept_for_session", "allow_always", "Allow for session"],
|
||||
["decline", "reject_once", "Deny"],
|
||||
] as const) {
|
||||
if (kind === "allow_always" && options.allowAlwaysScope !== "session") continue;
|
||||
if (byKind.has(kind)) { bindings.set(key, kind); choices.push({ key, label }); }
|
||||
}
|
||||
// A permanent rejection is not silently substituted for a one-time denial.
|
||||
bindings.set("cancel", "cancel");
|
||||
choices.push({ key: "cancel", label: "Cancel" });
|
||||
const call = raw.toolCall;
|
||||
if (!call || typeof call.toolCallId !== "string" || !call.toolCallId || call.toolCallId.length > 240) {
|
||||
throw new Error("ACP permission request omitted its tool identity");
|
||||
}
|
||||
return {
|
||||
title: typeof call.title === "string" && call.title.trim()
|
||||
? call.title.slice(0, 4_000) : "Approve provider operation",
|
||||
kind: request.inferredKind ?? "other",
|
||||
toolCallId: call.toolCallId,
|
||||
choices,
|
||||
resolve(resolution) {
|
||||
const outcome = bindings.get(resolution.action as AcpxPermissionAction);
|
||||
if (!outcome || "answers" in resolution || "content" in resolution || "response" in resolution) {
|
||||
throw new Error("ACP permission resolution is not an offered choice");
|
||||
}
|
||||
return { outcome } as AcpPermissionDecision;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { QualifiedAcpxAgent } from "./qualified-profiles.js";
|
||||
|
||||
export interface AcpxCapabilityProfile {
|
||||
readonly displayName: string;
|
||||
readonly qualification: "qualified" | "pending";
|
||||
readonly models: "explicit-provider-verified" | "exact-qualified";
|
||||
readonly permissions: "runner-policy" | "interactive";
|
||||
readonly questions: "form" | "cursor-extension" | "not-exposed";
|
||||
readonly plans: "native" | "cursor-decision" | "semantic-only";
|
||||
readonly tools: "authenticated-mcp" | "owned-extension";
|
||||
readonly recovery: "session-load" | "unverified";
|
||||
readonly usage: "reported" | "unverified";
|
||||
readonly steering: "unsupported" | "owned-extension-pending";
|
||||
readonly followUp: "controller-queue" | "owned-extension-pending";
|
||||
readonly artifacts: "policy_disabled" | "references-pending";
|
||||
readonly extensionRequests: readonly string[];
|
||||
readonly extensionNotifications: readonly string[];
|
||||
}
|
||||
|
||||
/** These are runner integration claims, not a proxy for everything a harness can do. */
|
||||
export const ACPX_CAPABILITY_PROFILES: Readonly<Record<QualifiedAcpxAgent, AcpxCapabilityProfile>> = {
|
||||
claude: {
|
||||
displayName: "Claude", qualification: "qualified", models: "explicit-provider-verified",
|
||||
permissions: "interactive", questions: "form", plans: "native", tools: "authenticated-mcp",
|
||||
recovery: "session-load", usage: "reported", steering: "unsupported", followUp: "controller-queue",
|
||||
artifacts: "policy_disabled", extensionRequests: [], extensionNotifications: [],
|
||||
},
|
||||
codex: {
|
||||
displayName: "Codex", qualification: "qualified", models: "exact-qualified",
|
||||
permissions: "runner-policy", questions: "form", plans: "native", tools: "authenticated-mcp",
|
||||
recovery: "session-load", usage: "reported", steering: "unsupported", followUp: "controller-queue",
|
||||
artifacts: "policy_disabled", extensionRequests: [], extensionNotifications: [],
|
||||
},
|
||||
grok: {
|
||||
displayName: "Grok Build", qualification: "qualified", models: "explicit-provider-verified",
|
||||
permissions: "interactive", questions: "form", plans: "native", tools: "authenticated-mcp",
|
||||
recovery: "session-load", usage: "unverified", steering: "unsupported", followUp: "controller-queue",
|
||||
artifacts: "policy_disabled", extensionRequests: [], extensionNotifications: [],
|
||||
},
|
||||
cursor: {
|
||||
displayName: "Cursor", qualification: "pending", models: "explicit-provider-verified",
|
||||
permissions: "interactive", questions: "cursor-extension", plans: "cursor-decision", tools: "authenticated-mcp",
|
||||
recovery: "session-load", usage: "unverified", steering: "unsupported", followUp: "controller-queue",
|
||||
artifacts: "references-pending",
|
||||
extensionRequests: ["cursor/ask_question", "cursor/create_plan", "cursor/update_todos", "cursor/task", "cursor/generate_image"],
|
||||
extensionNotifications: ["cursor/update_todos", "cursor/task", "cursor/generate_image"],
|
||||
},
|
||||
copilot: {
|
||||
displayName: "GitHub Copilot", qualification: "pending", models: "explicit-provider-verified",
|
||||
permissions: "interactive", questions: "not-exposed", plans: "native", tools: "authenticated-mcp",
|
||||
recovery: "session-load", usage: "reported", steering: "unsupported", followUp: "controller-queue",
|
||||
artifacts: "references-pending", extensionRequests: [],
|
||||
extensionNotifications: ["github.com/copilot/sessionEvent"],
|
||||
},
|
||||
pi: {
|
||||
displayName: "Pi", qualification: "pending", models: "exact-qualified",
|
||||
permissions: "interactive", questions: "form", plans: "semantic-only", tools: "owned-extension",
|
||||
recovery: "session-load", usage: "reported", steering: "owned-extension-pending", followUp: "owned-extension-pending",
|
||||
artifacts: "references-pending", extensionRequests: [], extensionNotifications: [],
|
||||
},
|
||||
};
|
||||
for (const profile of Object.values(ACPX_CAPABILITY_PROFILES)) {
|
||||
Object.freeze(profile.extensionRequests);
|
||||
Object.freeze(profile.extensionNotifications);
|
||||
Object.freeze(profile);
|
||||
}
|
||||
Object.freeze(ACPX_CAPABILITY_PROFILES);
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type CodexAcpxDriverOptions,
|
||||
} from "./codex-acpx-driver.js";
|
||||
import type {
|
||||
AcpxRuntimeSteeringCapability,
|
||||
AcpxRuntimeTurnInput,
|
||||
AcpxRuntimeTurn,
|
||||
OpenAcpxRuntimeHostOptions,
|
||||
@@ -21,6 +22,47 @@ import type {
|
||||
import type { AcpxRecoveryWorkspaceLease } from "./runtime-sandbox.js";
|
||||
|
||||
describe("Codex ACPX harness driver", () => {
|
||||
it("delivers negotiated steering and follow-up distinctly, once, for the active turn", async () => {
|
||||
const fixture = driverFixture({ agent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731", providerPolicy: { readOnly: true } });
|
||||
fixture.host.steeringCapability.mockReturnValue({ steering: true, queuedFollowUp: true });
|
||||
const session = await fixture.driver.openSession({ runId: "run-controls", normalizedSessionId: "session-1", workingDirectory: "/workspace" });
|
||||
expect(fixture.hostOptions?.providerPolicy).toEqual({ readOnly: true });
|
||||
expect(session.turnControlCapabilities?.()).toEqual({ steering: true, queuedFollowUp: true });
|
||||
const { turnId } = await session.startTurn({ message: { text: "Work" } });
|
||||
const turnInput = fixture.host.startTurn.mock.calls[0]![0];
|
||||
const context = { requestId: 0, signal: new AbortController().signal };
|
||||
await expect(turnInput.onExtensionRequest!("provider/request", { sessionId: "agent-1" }, context)).rejects.toThrow(/session mismatch/);
|
||||
await expect(turnInput.onExtensionRequest!("provider/request", { sessionId: "backend-1" }, context)).rejects.toThrow(/adapter is unavailable/);
|
||||
await session.steer!({ turnId, correlationId: "control-1", message: { text: "Change focus" } });
|
||||
await session.steer!({ turnId, correlationId: "control-2", mode: "follow_up", message: { text: "Then validate" } });
|
||||
expect(fixture.host.steerActiveTurn).toHaveBeenCalledExactlyOnceWith("Change focus", `run-controls:${turnId}`);
|
||||
expect(fixture.host.queueFollowUp).toHaveBeenCalledExactlyOnceWith("Then validate", `run-controls:${turnId}`);
|
||||
expect(fixture.host.interruptActiveTurn).not.toHaveBeenCalled();
|
||||
await expect(session.steer!({ turnId, correlationId: "control-2", message: { text: "Duplicate" } })).rejects.toThrow(/already attempted/);
|
||||
await expect(session.steer!({ turnId: "wrong-turn", message: { text: "Stale" } })).rejects.toThrow();
|
||||
fixture.finishTurn({ status: "completed" });
|
||||
const events = await collectUntil(session.events(), "turn.completed");
|
||||
expect(events.filter(event => event.payload.kind === "steering_acknowledgement").map(event => event.payload.mode)).toEqual(["steer", "follow_up"]);
|
||||
for (const event of events) expect(validatePrpEvent(event).ok).toBe(true);
|
||||
await expect(session.steer!({ turnId, message: { text: "Already settled" } })).rejects.toThrow();
|
||||
await session.close({ reason: "controls verified" });
|
||||
});
|
||||
|
||||
it("rejects unnegotiated controls and retains ambiguous delivery attempts", async () => {
|
||||
const fixture = driverFixture({ agent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731", providerPolicy: { readOnly: true } });
|
||||
const session = await fixture.driver.openSession({ runId: "run-controls", normalizedSessionId: "session-1", workingDirectory: "/workspace" });
|
||||
const { turnId } = await session.startTurn({ message: { text: "Work" } });
|
||||
await expect(session.steer!({ turnId, message: { text: "No handshake" } })).rejects.toThrow(/did not negotiate/);
|
||||
expect(fixture.host.steerActiveTurn).not.toHaveBeenCalled();
|
||||
fixture.host.steeringCapability.mockReturnValue({ steering: true, queuedFollowUp: false });
|
||||
fixture.host.steerActiveTurn.mockRejectedValueOnce(new Error("delivery acknowledgement lost"));
|
||||
const input = { turnId, correlationId: "ambiguous", message: { text: "Maybe delivered" } };
|
||||
await expect(session.steer!(input)).rejects.toThrow(/acknowledgement lost/);
|
||||
await expect(session.steer!(input)).rejects.toThrow(/already attempted/);
|
||||
expect(fixture.host.steerActiveTurn).toHaveBeenCalledOnce();
|
||||
await session.close({ reason: "ambiguous control verified" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["claude", "claude-sonnet-5"], ["codex", "gpt-5.6-sol"],
|
||||
] as const)("launches %s in full auto when no mode is supplied", async (agent, model) => {
|
||||
@@ -1459,6 +1501,73 @@ describe("Codex ACPX harness driver", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers only offered permission choices and rejects stale or duplicate answers", async () => {
|
||||
const fixture = driverFixture({ agent: "claude", model: "claude-sonnet-5" });
|
||||
const session = await fixture.driver.openSession({
|
||||
runId: "run-permission", normalizedSessionId: "session-1", workingDirectory: "/workspace",
|
||||
});
|
||||
const created = collectUntil(session.events(), "runtime_request.created");
|
||||
const { turnId } = await session.startTurn({ message: { role: "user", text: "Run validation." } });
|
||||
const callback = fixture.host.startTurn.mock.calls[0]![0].onPermissionRequest!;
|
||||
const response = callback({
|
||||
inferredKind: "execute", raw: {
|
||||
sessionId: "agent-session-1", toolCall: { toolCallId: "tool-permission", title: "Run validation" },
|
||||
options: [{ optionId: "once", kind: "allow_once", name: "Allow once" }],
|
||||
},
|
||||
} as Parameters<typeof callback>[0], { signal: new AbortController().signal, responseDelivery: Promise.resolve() });
|
||||
const events = await created;
|
||||
const request = session.pendingRuntimeRequests!()[0]!;
|
||||
expect(events.at(-1)?.payload).toMatchObject({ request: {
|
||||
type: "permission", choices: [{ key: "accept" }, { key: "cancel" }],
|
||||
origin: { method: "session/request_permission" },
|
||||
} });
|
||||
await expect(session.resolveRuntimeRequest!({ requestId: request.requestId, turnId: "old-turn",
|
||||
resolution: { action: "accept" } })).rejects.toThrow();
|
||||
await expect(session.resolveRuntimeRequest!({ requestId: request.requestId, turnId,
|
||||
resolution: { action: "accept_for_session" } })).rejects.toThrow("offered choice");
|
||||
expect(session.pendingRuntimeRequests!()).toHaveLength(1);
|
||||
await session.resolveRuntimeRequest!({ requestId: request.requestId, turnId, resolution: { action: "accept" } });
|
||||
await expect(response).resolves.toEqual({ outcome: "allow_once" });
|
||||
await expect(session.resolveRuntimeRequest!({ requestId: request.requestId, turnId,
|
||||
resolution: { action: "accept" } })).rejects.toThrow("no longer pending");
|
||||
fixture.finishTurn({ status: "completed", stopReason: "end_turn" });
|
||||
await collectUntil(session.events(), "turn.completed");
|
||||
await session.close({ reason: "permission verified" });
|
||||
});
|
||||
|
||||
it.each(["written", "failed"] as const)("waits for the exact provider permission reply receipt: %s", async outcome => {
|
||||
const fixture = driverFixture({ agent: "copilot", model: "explicit-test-model", providerPolicy: { readOnly: false } });
|
||||
const session = await fixture.driver.openSession({ runId: "run-receipt", normalizedSessionId: "session-1", workingDirectory: "/workspace" });
|
||||
const created = collectUntil(session.events(), "runtime_request.created");
|
||||
const { turnId } = await session.startTurn({ message: { role: "user", text: "Approve with receipt." } });
|
||||
const callback = fixture.host.startTurn.mock.calls[0]![0].onPermissionRequest!;
|
||||
const receipt = deferred<void>();
|
||||
const providerResponse = callback({ inferredKind: "execute", raw: {
|
||||
sessionId: "agent-session-1", toolCall: { toolCallId: "receipt-tool", title: "Run validation" },
|
||||
options: [{ optionId: "session", kind: "allow_always", name: "Allow for session" }],
|
||||
} } as Parameters<typeof callback>[0], { signal: new AbortController().signal, responseDelivery: receipt.promise });
|
||||
await created;
|
||||
const request = session.pendingRuntimeRequests!()[0]!;
|
||||
expect(request.details).toMatchObject({ choices: [{ key: "accept_for_session" }, { key: "cancel" }] });
|
||||
let acknowledged = false;
|
||||
const emitted = collectUntil(session.events(), outcome === "written" ? "runtime_request.resolved" : "runtime_request.expired");
|
||||
const resolution = session.resolveRuntimeRequest!({ requestId: request.requestId, turnId, resolution: { action: "accept_for_session" } })
|
||||
.then(() => { acknowledged = true; });
|
||||
await expect(providerResponse).resolves.toEqual({ outcome: "allow_always" });
|
||||
expect(acknowledged).toBe(false);
|
||||
if (outcome === "written") { receipt.resolve(); await resolution; }
|
||||
else {
|
||||
const rejected = expect(resolution).rejects.toThrow("pipe failed");
|
||||
receipt.reject(new Error("pipe failed")); await rejected;
|
||||
}
|
||||
const events = await emitted;
|
||||
expect(events.filter(event => event.eventType === "runtime_request.resolved")).toHaveLength(outcome === "written" ? 1 : 0);
|
||||
if (outcome === "failed") expect(events.at(-1)?.payload).toMatchObject({ replayAllowed: false, reason: "response_delivery_failed" });
|
||||
expect(session.pendingRuntimeRequests!()).toHaveLength(0);
|
||||
if (outcome === "written") fixture.finishTurn({ status: "completed", stopReason: "end_turn" });
|
||||
await session.close({ reason: "receipt checked" });
|
||||
});
|
||||
|
||||
it("round-trips a provider-neutral ACP form through the runtime request boundary", async () => {
|
||||
const fixture = driverFixture();
|
||||
const session = await fixture.driver.openSession({
|
||||
@@ -1493,7 +1602,7 @@ describe("Codex ACPX harness driver", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
{ requestId: "rpc-question-1", signal: controller.signal },
|
||||
{ requestId: "rpc-question-1", signal: controller.signal, responseDelivery: Promise.resolve() },
|
||||
);
|
||||
const events = await createdEvent;
|
||||
const request = session.pendingRuntimeRequests!()[0]!;
|
||||
@@ -1575,7 +1684,7 @@ describe("Codex ACPX harness driver", () => {
|
||||
},
|
||||
{
|
||||
requestId: "rpc-question-created-pressure",
|
||||
signal: new AbortController().signal,
|
||||
signal: new AbortController().signal, responseDelivery: Promise.resolve(),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual({ action: "cancel" });
|
||||
@@ -1609,7 +1718,7 @@ describe("Codex ACPX harness driver", () => {
|
||||
properties: { value: { type: "string" } },
|
||||
},
|
||||
},
|
||||
{ requestId: "rpc-question-abort", signal: controller.signal },
|
||||
{ requestId: "rpc-question-abort", signal: controller.signal, responseDelivery: Promise.resolve() },
|
||||
);
|
||||
await createdEvent;
|
||||
const cancelledEvent = collectUntil(
|
||||
@@ -1658,7 +1767,7 @@ describe("Codex ACPX harness driver", () => {
|
||||
},
|
||||
{
|
||||
requestId: "rpc-question-stream-failure",
|
||||
signal: new AbortController().signal,
|
||||
signal: new AbortController().signal, responseDelivery: Promise.resolve(),
|
||||
},
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
@@ -1704,7 +1813,7 @@ describe("Codex ACPX harness driver", () => {
|
||||
},
|
||||
{
|
||||
requestId: "rpc-question-handoff",
|
||||
signal: new AbortController().signal,
|
||||
signal: new AbortController().signal, responseDelivery: Promise.resolve(),
|
||||
},
|
||||
);
|
||||
await createdEvent;
|
||||
@@ -1772,7 +1881,7 @@ describe("Codex ACPX harness driver", () => {
|
||||
},
|
||||
{
|
||||
requestId: "rpc-question-handoff-pressure",
|
||||
signal: new AbortController().signal,
|
||||
signal: new AbortController().signal, responseDelivery: Promise.resolve(),
|
||||
},
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
@@ -2724,6 +2833,9 @@ function fakeHost(createTurn: () => AcpxRuntimeTurn, onClose: () => void) {
|
||||
},
|
||||
})),
|
||||
startTurn: vi.fn((_input: AcpxRuntimeTurnInput) => createTurn()),
|
||||
steeringCapability: vi.fn<() => AcpxRuntimeSteeringCapability | null>(() => null),
|
||||
steerActiveTurn: vi.fn(async (_text: string, _requestId?: string) => undefined),
|
||||
queueFollowUp: vi.fn(async (_text: string, _requestId?: string) => undefined),
|
||||
interruptActiveTurn: vi.fn(async () => undefined),
|
||||
close: vi.fn(async () => {
|
||||
onClose();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { requireAcpxResponseDelivery } from "./response-delivery.js";
|
||||
import { acpxProfileClientCapabilities, bindAcpxExtensionTurn, validateAcpxRichEvent, createAcpxProfileExtensionAdapter, type AcpxExtensionInput } from "./profile-extensions.js";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
import type {
|
||||
@@ -5,6 +7,8 @@ import type {
|
||||
AcpElicitationRequest,
|
||||
AcpElicitationResponse,
|
||||
AcpRuntimeEvent,
|
||||
AcpPermissionRequest,
|
||||
AcpPermissionDecision,
|
||||
} from "acpx/runtime";
|
||||
|
||||
import {
|
||||
@@ -52,12 +56,14 @@ import {
|
||||
DEFAULT_CODEX_ACPX_RUNTIME_SHUTDOWN_BOUND_MS,
|
||||
openCodexAcpxRuntime,
|
||||
} from "./codex-runtime-adapter.js";
|
||||
import { normalizeAcpxPermission } from "./acp-permission-adapter.js";
|
||||
import {
|
||||
normalizeAcpFormElicitation,
|
||||
type NormalizedAcpForm,
|
||||
} from "./acp-question-adapter.js";
|
||||
import {
|
||||
acpxDriverDescriptor,
|
||||
acpxCapabilities,
|
||||
validateAcpxDriverConfig,
|
||||
} from "./driver-profile.js";
|
||||
import {
|
||||
@@ -76,6 +82,9 @@ import {
|
||||
type AcpxRecoveryWorkspaceLease,
|
||||
} from "./runtime-sandbox.js";
|
||||
|
||||
import { AcpxTurnControlLedger, parseAcpxTurnControl, type AcpxTurnControlMode } from "./turn-controls.js";
|
||||
import { acpxUsageEstimateNotice, persistedAcpxTurnUsage } from "./usage-accounting.js";
|
||||
|
||||
const MAX_BUFFERED_EVENTS = 512;
|
||||
const TERMINAL_EVENT_RESERVE = 3;
|
||||
const TURN_START_EVENT_COUNT = 3;
|
||||
@@ -109,8 +118,9 @@ const QUARANTINED_HOST_ADMISSION_GRACE_MS =
|
||||
|
||||
interface PendingAcpxRuntimeRequest {
|
||||
request: HarnessRuntimeRequest;
|
||||
normalized: NormalizedAcpForm;
|
||||
settle(response: AcpElicitationResponse): void;
|
||||
responseDelivery: Promise<void>;
|
||||
prepareResolution(resolution: HarnessRuntimeRequestResolution): () => void;
|
||||
cancel(): void;
|
||||
cleanup(): void;
|
||||
settling: boolean;
|
||||
}
|
||||
@@ -130,6 +140,7 @@ export interface CodexAcpxDriverOptions {
|
||||
runtimeDirectory: string;
|
||||
model: string;
|
||||
permissionMode?: NativeAcpxPermissionMode;
|
||||
providerPolicy?: { readOnly: boolean };
|
||||
systemInstructions?: string;
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
managedCodexCredentialSourcePath?: string;
|
||||
@@ -147,6 +158,9 @@ interface CodexAcpxHost {
|
||||
startTurn(
|
||||
input: Parameters<AcpxRuntimeHost["startTurn"]>[0],
|
||||
): AcpxRuntimeTurn;
|
||||
steeringCapability?: AcpxRuntimeHost["steeringCapability"];
|
||||
steerActiveTurn?: AcpxRuntimeHost["steerActiveTurn"];
|
||||
queueFollowUp?: AcpxRuntimeHost["queueFollowUp"];
|
||||
interruptActiveTurn(reason: string): Promise<void>;
|
||||
close(input: { reason: string }): Promise<void>;
|
||||
}
|
||||
@@ -200,6 +214,7 @@ export async function probeQualifiedAcpxEnvironment(
|
||||
agent: options.agent,
|
||||
model: options.model,
|
||||
permissionMode: "deny-all",
|
||||
providerPolicy: { readOnly: true },
|
||||
systemInstructions:
|
||||
"Paperclip Runner environment qualification probe. Do not execute a provider turn.",
|
||||
...(options.environment === undefined
|
||||
@@ -259,14 +274,13 @@ export class CodexAcpxDriver implements HarnessDriver {
|
||||
options: CodexAcpxDriverOptions,
|
||||
dependencies: CodexAcpxDriverDependencies = {},
|
||||
) {
|
||||
if (options.agent === "pi") {
|
||||
throw new Error(
|
||||
"Pi ACPX driver is unavailable until descriptor-confined verified launch is implemented",
|
||||
);
|
||||
if (["pi", "cursor", "copilot"].includes(options.agent ?? "codex") && typeof options.providerPolicy?.readOnly !== "boolean") {
|
||||
throw new Error("ACP candidate requires an explicit provider read-only policy");
|
||||
}
|
||||
this.#options = {
|
||||
...options,
|
||||
agent: options.agent ?? "codex",
|
||||
...(options.providerPolicy ? { providerPolicy: { readOnly: options.providerPolicy.readOnly } } : {}),
|
||||
...(options.environment
|
||||
? { environment: { ...options.environment } }
|
||||
: {}),
|
||||
@@ -303,7 +317,7 @@ export class CodexAcpxDriver implements HarnessDriver {
|
||||
resume: true,
|
||||
runtimeRequestResolution: true,
|
||||
runtimeRequestHandoff: true,
|
||||
unsupported: ["steering", "goals", "threadLineage"],
|
||||
unsupported: descriptor.capabilities.unsupported,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -435,8 +449,10 @@ export class CodexAcpxDriver implements HarnessDriver {
|
||||
normalizedSessionId: input.normalizedSessionId,
|
||||
workingDirectory: input.workingDirectory,
|
||||
agent: this.#options.agent ?? "codex",
|
||||
clientCapabilities: acpxProfileClientCapabilities(this.#options.agent ?? "codex"),
|
||||
model: this.#options.model,
|
||||
permissionMode: this.#options.permissionMode ?? "approve-all",
|
||||
providerPolicy: this.#options.providerPolicy,
|
||||
systemInstructions: this.#options.systemInstructions,
|
||||
environment: this.#options.environment,
|
||||
managedCodexCredentialSourcePath:
|
||||
@@ -761,6 +777,7 @@ class CodexAcpxSession implements HarnessSession {
|
||||
payload: Record<string, unknown>;
|
||||
} | null = null;
|
||||
#runtimeRequestSequence = 0;
|
||||
readonly #turnControls = new AcpxTurnControlLedger();
|
||||
|
||||
constructor(input: {
|
||||
host: CodexAcpxHost;
|
||||
@@ -828,6 +845,11 @@ class CodexAcpxSession implements HarnessSession {
|
||||
return this.#events;
|
||||
}
|
||||
|
||||
turnControlCapabilities() {
|
||||
const capability = acpxCapabilities(this.#agent, this.#host.steeringCapability?.());
|
||||
return { steering: capability.steering, queuedFollowUp: capability.queuedFollowUp === true };
|
||||
}
|
||||
|
||||
async startTurn(input: {
|
||||
message: NativeUserMessage;
|
||||
}): Promise<{ turnId: string }> {
|
||||
@@ -853,16 +875,37 @@ class CodexAcpxSession implements HarnessSession {
|
||||
}
|
||||
const turnId = `turn-${randomBytes(12).toString("hex")}`;
|
||||
this.#activeTurnId = turnId;
|
||||
this.#turnControls.begin(turnId);
|
||||
this.#assistantText = "";
|
||||
this.#assistantMessageId = null;
|
||||
this.#emit("turn.submitted", { text: input.message.text }, { turnId });
|
||||
this.#emit("turn.accepted", { turnId }, { turnId });
|
||||
this.#emit("turn.started", { status: "inProgress" }, { turnId });
|
||||
const extensions = bindAcpxExtensionTurn({
|
||||
adapter: createAcpxProfileExtensionAdapter(this.#agent, {
|
||||
workspacePath: this.#input.workingDirectory, sessionId: this.#host.identity().backendSessionId, turnId,
|
||||
}),
|
||||
active: () => this.#activeTurnId === turnId && !this.#closingStarted,
|
||||
sessionId: this.#host.identity().backendSessionId,
|
||||
waitForInput: (input, context) => this.#handleExtensionInput(turnId, input, context),
|
||||
emit: event => {
|
||||
if (!this.#emit(event.eventType, event.payload, { turnId, itemId: event.itemId })) {
|
||||
throw new Error("ACP rich activity could not be retained");
|
||||
}
|
||||
},
|
||||
});
|
||||
let turn: AcpxRuntimeTurn;
|
||||
const usageBefore = await readUsageStatus(this.#host);
|
||||
try {
|
||||
this.#assertOpen();
|
||||
if (this.#activeTurnId !== turnId) throw new HarnessStaleTurnError(turnId);
|
||||
turn = this.#host.startTurn({
|
||||
text: input.message.text,
|
||||
requestId: `${safeId(this.#input.runId, "run")}:${turnId}`,
|
||||
onExtensionRequest: extensions.onExtensionRequest,
|
||||
onExtensionNotification: extensions.onExtensionNotification,
|
||||
onPermissionRequest: (request, context) =>
|
||||
this.#handlePermission(turnId, request, context),
|
||||
onElicitation: (request, context) =>
|
||||
this.#handleElicitation(turnId, request, context),
|
||||
});
|
||||
@@ -875,7 +918,7 @@ class CodexAcpxSession implements HarnessSession {
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
const pump = this.#pumpTurn(turnId, turn);
|
||||
const pump = this.#pumpTurn(turnId, turn, extensions.drain, usageBefore);
|
||||
this.#activePump = pump;
|
||||
void pump
|
||||
.finally(() => {
|
||||
@@ -885,6 +928,25 @@ class CodexAcpxSession implements HarnessSession {
|
||||
return { turnId };
|
||||
}
|
||||
|
||||
async steer(input: { turnId: string; message: NativeUserMessage; correlationId?: string; mode?: AcpxTurnControlMode }): Promise<void> {
|
||||
this.#assertOpen();
|
||||
if (input.turnId !== this.#activeTurnId || this.#pendingTerminal) throw new HarnessStaleTurnError(input.turnId);
|
||||
const mode = input.mode ?? "steer";
|
||||
const capability = this.turnControlCapabilities();
|
||||
const operation = mode === "steer" ? this.#host.steerActiveTurn : this.#host.queueFollowUp;
|
||||
if (!(mode === "steer" ? capability?.steering : capability?.queuedFollowUp) || !operation) {
|
||||
throw new HarnessCapabilityUnavailableError(mode, "the ACP provider did not negotiate this turn control");
|
||||
}
|
||||
const control = parseAcpxTurnControl({ turnId: input.turnId, message: input.message.text, mode,
|
||||
controlId: input.correlationId ?? `control-${randomBytes(12).toString("hex")}` });
|
||||
this.#turnControls.reserve(control, this.#activeTurnId);
|
||||
await operation.call(this.#host, control.message, `${safeId(this.#input.runId, "run")}:${control.turnId}`);
|
||||
if (this.#activeTurnId !== control.turnId || this.#closingStarted) throw new HarnessStaleTurnError(control.turnId);
|
||||
this.#emit("item.completed", { kind: "steering_acknowledgement", mode,
|
||||
text: mode === "steer" ? "Steering acknowledged for the active turn." : "Follow-up queued after the active work.", status: "acknowledged" },
|
||||
{ turnId: control.turnId, itemId: stableId("acpx-control", `${control.turnId}:${control.controlId}`) });
|
||||
}
|
||||
|
||||
async interrupt(input: { turnId?: string; reason?: string }): Promise<void> {
|
||||
this.#assertOpen();
|
||||
if (input.turnId && input.turnId !== this.#activeTurnId) {
|
||||
@@ -937,18 +999,19 @@ class CodexAcpxSession implements HarnessSession {
|
||||
);
|
||||
}
|
||||
pending.settling = true;
|
||||
let dispatched = false;
|
||||
try {
|
||||
const resolution = parseHarnessRuntimeRequestResolution(
|
||||
pending.request.requestKind,
|
||||
input.resolution,
|
||||
pending.request.input,
|
||||
);
|
||||
const providerResponse = acpElicitationResponse(
|
||||
pending.normalized,
|
||||
resolution,
|
||||
);
|
||||
const deliver = pending.prepareResolution(resolution);
|
||||
if (!this.#pendingRuntimeRequests.delete(input.requestId)) return;
|
||||
pending.cleanup();
|
||||
dispatched = true;
|
||||
deliver();
|
||||
await pending.responseDelivery;
|
||||
this.#emit(
|
||||
"runtime_request.resolved",
|
||||
harnessRuntimeRequestOutcome(pending.request, {
|
||||
@@ -959,9 +1022,14 @@ class CodexAcpxSession implements HarnessSession {
|
||||
}),
|
||||
{ turnId: input.turnId, itemId: pending.request.itemId },
|
||||
);
|
||||
pending.settle(providerResponse);
|
||||
} catch (error) {
|
||||
pending.settling = false;
|
||||
if (!dispatched) { pending.settling = false; throw error; }
|
||||
this.#emit("runtime_request.expired", {
|
||||
...harnessRuntimeRequestOutcome(pending.request, { reason: "response_delivery_failed" }),
|
||||
replayAllowed: false,
|
||||
}, { turnId: input.turnId, itemId: pending.request.itemId });
|
||||
try { await this.close({ reason: "ACP response delivery failed" }); }
|
||||
catch (cleanupError) { throw new AggregateError([error, cleanupError], "ACP response delivery and provider cleanup failed"); }
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1003,7 +1071,7 @@ class CodexAcpxSession implements HarnessSession {
|
||||
);
|
||||
}
|
||||
pending.cleanup();
|
||||
pending.settle({ action: "cancel" });
|
||||
pending.cancel();
|
||||
const cleanup = Promise.resolve()
|
||||
.then(() =>
|
||||
this.#host.interruptActiveTurn(
|
||||
@@ -1353,7 +1421,7 @@ class CodexAcpxSession implements HarnessSession {
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
async #pumpTurn(turnId: string, turn: AcpxRuntimeTurn): Promise<void> {
|
||||
async #pumpTurn(turnId: string, turn: AcpxRuntimeTurn, drainExtensions: () => Promise<void>, usageBefore: unknown): Promise<void> {
|
||||
try {
|
||||
let index = 0;
|
||||
const normalizeToolEvent =
|
||||
@@ -1364,6 +1432,13 @@ class CodexAcpxSession implements HarnessSession {
|
||||
this.#mapRuntimeEvent(normalizeMessage(normalizeToolEvent(event)), turnId, ++index);
|
||||
}
|
||||
const result = await turn.result;
|
||||
await drainExtensions();
|
||||
const receipt = persistedAcpxTurnUsage(usageBefore, await readUsageStatus(this.#host), turn.requestId, this.#agent);
|
||||
if (receipt) {
|
||||
this.#mapRuntimeEvent(receipt as unknown as AcpRuntimeEvent, turnId, ++index);
|
||||
const estimate = acpxUsageEstimateNotice(receipt, `${turnId}:usage-estimate`);
|
||||
if (estimate) { validateAcpxRichEvent(estimate); this.#emit(estimate.eventType, estimate.payload, { turnId, itemId: estimate.itemId }); }
|
||||
}
|
||||
this.#cancelPendingRuntimeRequests("provider turn settled", turnId);
|
||||
if (this.#terminalTurns.has(turnId)) return;
|
||||
if (result.status === "completed") {
|
||||
@@ -1587,6 +1662,96 @@ class CodexAcpxSession implements HarnessSession {
|
||||
}
|
||||
}
|
||||
|
||||
async #handleExtensionInput(
|
||||
turnId: string,
|
||||
input: AcpxExtensionInput,
|
||||
context: { requestId: string | number; signal: AbortSignal; responseDelivery?: Promise<void> },
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (this.#closed || this.#activeTurnId !== turnId || context.signal.aborted
|
||||
|| this.#pendingRuntimeRequests.size >= MAX_PENDING_RUNTIME_REQUESTS) return input.cancel();
|
||||
const responseDelivery = requireAcpxResponseDelivery(context);
|
||||
const requestId = stableId("acpx-request", `${turnId}:${++this.#runtimeRequestSequence}:${typeof context.requestId}:${context.requestId}`);
|
||||
const request: HarnessRuntimeRequest = {
|
||||
requestId, requestKind: "elicitation", method: input.method, turnId, itemId: requestId,
|
||||
status: "pending", prompt: boundedText(input.questionSet.title ?? "Provider needs input", 1_000),
|
||||
details: input.details ?? {}, input: structuredClone(input.questionSet),
|
||||
origin: { adapter: "acpx-runtime", provider: this.#agent, method: input.method },
|
||||
};
|
||||
return await new Promise((settle) => {
|
||||
const cancel = () => {
|
||||
const pending = this.#pendingRuntimeRequests.get(requestId);
|
||||
if (!pending || pending.settling || !this.#pendingRuntimeRequests.delete(requestId)) return;
|
||||
pending.cleanup();
|
||||
this.#emit("runtime_request.cancelled", harnessRuntimeRequestOutcome(request, {
|
||||
action: "cancel", reason: "provider request aborted",
|
||||
}), { turnId, itemId: requestId });
|
||||
settle(input.cancel());
|
||||
};
|
||||
this.#pendingRuntimeRequests.set(requestId, {
|
||||
request, responseDelivery,
|
||||
prepareResolution: resolution => {
|
||||
const response = input.resolve(resolution);
|
||||
return () => settle(response);
|
||||
},
|
||||
cancel: () => settle(input.cancel()),
|
||||
cleanup: () => context.signal.removeEventListener("abort", cancel),
|
||||
settling: false,
|
||||
});
|
||||
context.signal.addEventListener("abort", cancel, { once: true });
|
||||
if (!this.#emit("runtime_request.created", { request: runtimeInputProtocolPayload(request) },
|
||||
{ turnId, itemId: requestId }) || context.signal.aborted) cancel();
|
||||
});
|
||||
}
|
||||
|
||||
async #handlePermission(
|
||||
turnId: string,
|
||||
request: AcpPermissionRequest,
|
||||
context: { signal: AbortSignal; responseDelivery?: Promise<void> },
|
||||
): Promise<AcpPermissionDecision> {
|
||||
const { signal } = context;
|
||||
if (this.#closed || this.#activeTurnId !== turnId || signal.aborted
|
||||
|| this.#pendingRuntimeRequests.size >= MAX_PENDING_RUNTIME_REQUESTS) {
|
||||
return { outcome: "cancel" };
|
||||
}
|
||||
const responseDelivery = requireAcpxResponseDelivery(context);
|
||||
const normalized = normalizeAcpxPermission(request, ["pi", "copilot"].includes(this.#agent) ? { allowAlwaysScope: "session" } : {});
|
||||
const requestId = stableId("acpx-permission", `${turnId}:${++this.#runtimeRequestSequence}:${normalized.toolCallId}`);
|
||||
const runtimeRequest: HarnessRuntimeRequest = {
|
||||
requestId, requestKind: "permission_approval", method: "session/request_permission",
|
||||
turnId, itemId: requestId, status: "pending", prompt: normalized.title,
|
||||
details: { choices: normalized.choices, toolCallId: normalized.toolCallId, kind: normalized.kind },
|
||||
origin: { adapter: "acpx-runtime", provider: this.#agent, method: "session/request_permission" },
|
||||
};
|
||||
return await new Promise<AcpPermissionDecision>((settle) => {
|
||||
const cancel = () => {
|
||||
const pending = this.#pendingRuntimeRequests.get(requestId);
|
||||
if (!pending || pending.settling || !this.#pendingRuntimeRequests.delete(requestId)) return;
|
||||
pending.cleanup();
|
||||
this.#emit("runtime_request.cancelled", harnessRuntimeRequestOutcome(runtimeRequest, {
|
||||
action: "cancel", reason: "provider request aborted",
|
||||
}), { turnId, itemId: requestId });
|
||||
settle({ outcome: "cancel" });
|
||||
};
|
||||
this.#pendingRuntimeRequests.set(requestId, {
|
||||
request: runtimeRequest, responseDelivery,
|
||||
prepareResolution: (resolution) => {
|
||||
const response = normalized.resolve(resolution);
|
||||
return () => settle(response);
|
||||
},
|
||||
cancel: () => settle({ outcome: "cancel" }),
|
||||
cleanup: () => signal.removeEventListener("abort", cancel),
|
||||
settling: false,
|
||||
});
|
||||
signal.addEventListener("abort", cancel, { once: true });
|
||||
if (!this.#emit("runtime_request.created", { request: {
|
||||
schema: PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2, requestKind: "permission_approval",
|
||||
requestId, type: "permission", status: "pending", prompt: normalized.title,
|
||||
choices: normalized.choices, details: runtimeRequest.details,
|
||||
origin: runtimeRequest.origin, turnId, itemId: requestId,
|
||||
} }, { turnId, itemId: requestId }) || signal.aborted) cancel();
|
||||
});
|
||||
}
|
||||
|
||||
async #handleElicitation(
|
||||
turnId: string,
|
||||
request: AcpElicitationRequest,
|
||||
@@ -1655,6 +1820,7 @@ class CodexAcpxSession implements HarnessSession {
|
||||
);
|
||||
return { action: "cancel" };
|
||||
}
|
||||
const responseDelivery = requireAcpxResponseDelivery(context);
|
||||
const requestId = stableId(
|
||||
"acpx-request",
|
||||
`${turnId}:${++this.#runtimeRequestSequence}:${typeof context.requestId}:${String(context.requestId)}`,
|
||||
@@ -1707,9 +1873,12 @@ class CodexAcpxSession implements HarnessSession {
|
||||
};
|
||||
context.signal.addEventListener("abort", cancel, { once: true });
|
||||
this.#pendingRuntimeRequests.set(requestId, {
|
||||
request: runtimeRequest,
|
||||
normalized,
|
||||
settle,
|
||||
request: runtimeRequest, responseDelivery,
|
||||
prepareResolution: (resolution) => {
|
||||
const response = acpElicitationResponse(normalized, resolution);
|
||||
return () => settle(response);
|
||||
},
|
||||
cancel: () => settle({ action: "cancel" }),
|
||||
cleanup: () => context.signal.removeEventListener("abort", cancel),
|
||||
settling: false,
|
||||
});
|
||||
@@ -1733,7 +1902,7 @@ class CodexAcpxSession implements HarnessSession {
|
||||
itemId: pending.request.itemId,
|
||||
},
|
||||
);
|
||||
pending.settle({ action: "cancel" });
|
||||
pending.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2310,3 +2479,15 @@ async function settlesWithin(
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Accounting failure cannot replace the provider's terminal result. */
|
||||
async function readUsageStatus(host: CodexAcpxHost): Promise<unknown> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.resolve().then(() => host.status()),
|
||||
new Promise<undefined>(resolve => { timer = setTimeout(() => resolve(undefined), 1_000); timer.unref?.(); }),
|
||||
]);
|
||||
} catch { return undefined; }
|
||||
finally { if (timer) clearTimeout(timer); }
|
||||
}
|
||||
@@ -28,6 +28,124 @@ const HANDLE: AcpRuntimeHandle = {
|
||||
};
|
||||
|
||||
describe("Codex ACPX runtime adapter", () => {
|
||||
it("rejects forged permission session identifiers before delegating or applying full-auto policy", async () => {
|
||||
const pending = pendingExtensionTurn("turn-1");
|
||||
const runtime = fakeRuntime(); vi.mocked(runtime.startTurn).mockReturnValue(pending.turn);
|
||||
let created!: AcpRuntimeOptions;
|
||||
const options = openOptions(fakeCommand());
|
||||
const port = await openCodexAcpxRuntime(options, {
|
||||
createRegistry: () => registry(), createStore: () => store(), createRuntime: (value) => { created = value; return runtime; },
|
||||
});
|
||||
const handler = vi.fn(async () => ({ outcome: "allow_once" as const }));
|
||||
const turn = port.startTurn({ text: "Write", requestId: "turn-1", onPermissionRequest: handler });
|
||||
const signal = new AbortController().signal;
|
||||
for (const [sessionId, rawSessionId] of [["forged", "backend-1"], ["backend-1", "forged"]]) {
|
||||
const request = { sessionId, raw: { sessionId: rawSessionId }, inferredKind: "edit" as const };
|
||||
await expect(created.onPermissionRequest!(request as never, { signal })).resolves.toEqual({ outcome: "reject_once" });
|
||||
}
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
const responseDelivery = Promise.resolve();
|
||||
await expect(created.onPermissionRequest!({ sessionId: "backend-1", raw: { sessionId: "backend-1" }, inferredKind: "edit" } as never, { signal, responseDelivery })).resolves.toEqual({ outcome: "allow_once" });
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
expect(handler).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ responseDelivery }));
|
||||
options.permissionMode = "approve-all";
|
||||
await expect(created.onPermissionRequest!({ sessionId: "forged", raw: {}, inferredKind: "edit" } as never, { signal })).resolves.toEqual({ outcome: "reject_once" });
|
||||
pending.settle(); await turn.result; await port.close({ reason: "session checks complete" });
|
||||
});
|
||||
it("routes only profile-allowed extensions to the owning turn and expires late responses", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const first = pendingExtensionTurn("turn-1");
|
||||
const second = pendingExtensionTurn("turn-2");
|
||||
vi.mocked(runtime.startTurn).mockReturnValueOnce(first.turn).mockReturnValueOnce(second.turn);
|
||||
let created!: AcpRuntimeOptions;
|
||||
const options = openOptions(fakeCommand());
|
||||
options.profile = { ...options.profile, agent: "cursor" };
|
||||
options.clientCapabilities = { _meta: { cursor: { test: true } } };
|
||||
const port = await openCodexAcpxRuntime(options, {
|
||||
createRegistry: () => registry(), createStore: () => store(),
|
||||
createRuntime: (value) => { created = value; return runtime; },
|
||||
});
|
||||
expect(created.clientCapabilities).toEqual(options.clientCapabilities);
|
||||
expect(JSON.stringify(vi.mocked(runtime.ensureSession).mock.calls)).not.toContain("clientCapabilities");
|
||||
const notification = vi.fn();
|
||||
const request = vi.fn(async (_method, _params, context) => ({ requestId: context.requestId }));
|
||||
const admittedFirst = port.startTurn({ text: "First", requestId: "turn-1", onExtensionRequest: request, onExtensionNotification: notification });
|
||||
const signal = new AbortController().signal;
|
||||
await expect(created.onExtensionRequest!("cursor/ask_question", { sessionId: "backend-1" }, { requestId: 0, signal })).resolves.toEqual({ requestId: 0 });
|
||||
const sessionless = { toolCallId: "native-cursor-plan", plan: "Review the complete plan" };
|
||||
const responseDelivery = Promise.resolve();
|
||||
await expect(created.onExtensionRequest!("cursor/create_plan", sessionless, { requestId: "plan", signal, responseDelivery })).resolves.toEqual({ requestId: "plan" });
|
||||
expect(request).toHaveBeenLastCalledWith("cursor/create_plan", { ...sessionless, sessionId: "backend-1" }, expect.objectContaining({ requestId: "plan", responseDelivery }));
|
||||
expect(sessionless).not.toHaveProperty("sessionId");
|
||||
await expect(created.onExtensionRequest!("pi/steer", {}, { requestId: 1, signal })).rejects.toThrow("admitted active turn");
|
||||
await expect(created.onExtensionRequest!("cursor/ask_question", { sessionId: "other" }, { requestId: 1, signal })).rejects.toThrow("admitted active turn");
|
||||
created.onExtensionNotification!("cursor/task", { sessionId: "backend-1", taskId: "t" });
|
||||
created.onExtensionNotification!("cursor/task", { sessionId: "other" });
|
||||
created.onExtensionNotification!("cursor/ask_question", { sessionId: "backend-1" });
|
||||
expect(notification).toHaveBeenCalledExactlyOnceWith("cursor/task", { sessionId: "backend-1", taskId: "t" });
|
||||
created.onExtensionNotification!("cursor/task", { toolCallId: "native-child" });
|
||||
expect(notification).toHaveBeenLastCalledWith("cursor/task", { sessionId: "backend-1", toolCallId: "native-child" });
|
||||
created.onExtensionNotification!("cursor/task", { sessionId: "agent-1" });
|
||||
expect(notification).toHaveBeenCalledTimes(2);
|
||||
request.mockImplementationOnce(() => new Promise(() => undefined));
|
||||
const pending = created.onExtensionRequest!("cursor/ask_question", {}, { requestId: 2, signal });
|
||||
const rejected = expect(pending).rejects.toThrow("expired");
|
||||
first.settle(); await admittedFirst.result; await rejected;
|
||||
const nextRequest = vi.fn(async () => ({ accepted: true }));
|
||||
const admittedSecond = port.startTurn({ text: "Second", requestId: "turn-2", onExtensionRequest: nextRequest });
|
||||
expect(nextRequest).not.toHaveBeenCalled();
|
||||
await admittedSecond.cancel({ reason: "cancelled" });
|
||||
await expect(created.onExtensionRequest!("cursor/ask_question", {}, { requestId: 3, signal })).rejects.toThrow("admitted active turn");
|
||||
second.settle(); await admittedSecond.result; await port.close({ reason: "test finished" });
|
||||
});
|
||||
|
||||
it("requires the Pi capability handshake and acknowledgments, preserves follow-up order, and never starts a second prompt", async () => {
|
||||
const pending = pendingExtensionTurn("turn-1");
|
||||
const runtime = Object.assign(fakeRuntime(), { requestExtension: vi.fn(async () => ({ accepted: true })) });
|
||||
vi.mocked(runtime.startTurn).mockReturnValue(pending.turn);
|
||||
let created!: AcpRuntimeOptions & { onAgentInitialize?: (result: unknown) => void };
|
||||
const options = openOptions(fakeCommand());
|
||||
options.profile = { ...options.profile, agent: "pi" };
|
||||
const port = await openCodexAcpxRuntime(options, {
|
||||
createRegistry: () => registry(), createStore: () => store(),
|
||||
createRuntime: (value) => { created = value; return runtime; },
|
||||
});
|
||||
const turn = port.startTurn({ text: "Initial", requestId: "turn-1" });
|
||||
await expect(port.steerActiveTurn!("Update", "turn-1")).rejects.toThrow("supported active turn");
|
||||
created.onAgentInitialize!({ agentCapabilities: { _meta: { paperclipPi: { version: 1, steering: true, queuedFollowUp: true } } } });
|
||||
expect(port.steeringCapability!()).toEqual({ steering: true, queuedFollowUp: true });
|
||||
await expect(port.steerActiveTurn!("Update", "wrong")).rejects.toThrow("supported active turn");
|
||||
await port.steerActiveTurn!("Update", "turn-1");
|
||||
await Promise.all([port.queueFollowUp!("One", "turn-1"), port.queueFollowUp!("Two", "turn-1")]);
|
||||
expect(runtime.requestExtension.mock.calls.map((call) => call[0])).toEqual([
|
||||
{ handle: HANDLE, method: "pi/steer", params: { sessionId: "backend-1", message: "Update" }, sessionMode: "persistent" },
|
||||
{ handle: HANDLE, method: "pi/follow_up", params: { sessionId: "backend-1", message: "One" }, sessionMode: "persistent" },
|
||||
{ handle: HANDLE, method: "pi/follow_up", params: { sessionId: "backend-1", message: "Two" }, sessionMode: "persistent" },
|
||||
]);
|
||||
expect(runtime.startTurn).toHaveBeenCalledTimes(1);
|
||||
runtime.requestExtension.mockResolvedValueOnce({ accepted: false });
|
||||
await expect(port.steerActiveTurn!("Denied", "turn-1")).rejects.toThrow("not acknowledged");
|
||||
runtime.requestExtension.mockImplementationOnce(() => new Promise(() => undefined));
|
||||
const stale = port.queueFollowUp!("Stale", "turn-1");
|
||||
const rejected = expect(stale).rejects.toThrow("expired");
|
||||
pending.settle(); await turn.result; await rejected;
|
||||
await expect(port.queueFollowUp!("Late", "turn-1")).rejects.toThrow("supported active turn");
|
||||
await port.close({ reason: "test finished" });
|
||||
});
|
||||
|
||||
it("does not grant steering to Copilot or Cursor when an untrusted initialize result claims Pi support", async () => {
|
||||
const options = openOptions(fakeCommand()); options.profile = { ...options.profile, agent: "copilot" };
|
||||
const runtime = fakeRuntime();
|
||||
const port = await openCodexAcpxRuntime(options, {
|
||||
createRegistry: () => registry(), createStore: () => store(),
|
||||
createRuntime: (created) => {
|
||||
(created as AcpRuntimeOptions & { onAgentInitialize: (result: unknown) => void }).onAgentInitialize({ agentCapabilities: { _meta: { paperclipPi: { version: 1, steering: true, queuedFollowUp: true } } } });
|
||||
return runtime;
|
||||
},
|
||||
});
|
||||
expect(port.steeringCapability!()).toBeNull();
|
||||
await port.close({ reason: "test finished" });
|
||||
});
|
||||
it.each(["approve-reads", "approve-paperclip"] as const)("%s stops unassigned operations when approval has no handler", async (permissionMode) => {
|
||||
const runtime = fakeRuntime();
|
||||
let runtimeOptions: AcpRuntimeOptions | undefined;
|
||||
@@ -1681,7 +1799,7 @@ describe("Codex ACPX runtime adapter", () => {
|
||||
await expect(
|
||||
runtimeOptions?.onPermissionRequest?.(
|
||||
{
|
||||
sessionId: "session-1",
|
||||
sessionId: "backend-1",
|
||||
inferredKind: "write",
|
||||
raw: {},
|
||||
},
|
||||
@@ -2828,6 +2946,17 @@ function fakeRuntime(handle: AcpRuntimeHandle = HANDLE): AcpRuntime {
|
||||
};
|
||||
}
|
||||
|
||||
function pendingExtensionTurn(requestId: string) {
|
||||
let settle!: () => void;
|
||||
const result = new Promise<void>((resolve) => { settle = resolve; });
|
||||
return { settle, turn: {
|
||||
requestId, promptStarted: Promise.resolve(),
|
||||
events: (async function* () { await result; })(),
|
||||
result: result.then(() => ({ status: "completed" as const })),
|
||||
cancel: async () => undefined, closeStream: async () => undefined,
|
||||
} };
|
||||
}
|
||||
|
||||
function runtimeWithProvider(
|
||||
runtime: AcpRuntime,
|
||||
options: AcpRuntimeOptions,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import type {
|
||||
AcpxRuntimeGoalCapability,
|
||||
AcpxRuntimeGoalSnapshot,
|
||||
AcpxRuntimeSteeringCapability,
|
||||
AcpxRuntimePort,
|
||||
AcpxRuntimePortIdentity,
|
||||
AcpxRuntimePortOpenOptions,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
} from "./installation-integrity.js";
|
||||
import type { AcpxModelStatus } from "./model-verification.js";
|
||||
import { AcpxApprovalRequiredError, decideAcpxPermission } from "./permission-policy.js";
|
||||
import { ACPX_CAPABILITY_PROFILES } from "./capability-profiles.js";
|
||||
|
||||
const VERIFIED_COMMAND_SENTINEL = "paperclip-verified-acpx-command";
|
||||
const DEFAULT_RUNTIME_CLOSE_TIMEOUT_MS = 2_000;
|
||||
@@ -80,6 +82,22 @@ interface AcpxRuntimeGoalState {
|
||||
observedSnapshot: boolean;
|
||||
}
|
||||
|
||||
interface AcpxRuntimeExtensionTurn {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
controller: AbortController;
|
||||
signal: AbortSignal;
|
||||
promptStarted: Promise<void>;
|
||||
onRequest?: AcpRuntimeOptions["onExtensionRequest"];
|
||||
onNotification?: AcpRuntimeOptions["onExtensionNotification"];
|
||||
}
|
||||
|
||||
interface AcpxRuntimeExtensionBoundary {
|
||||
active: AcpxRuntimeExtensionTurn | null;
|
||||
sessionIds: Set<string>;
|
||||
steering: AcpxRuntimeSteeringCapability | null;
|
||||
}
|
||||
|
||||
class AcpxRuntimeCloseTimeoutError extends Error {
|
||||
constructor() {
|
||||
super("ACPX runtime close timed out");
|
||||
@@ -245,7 +263,19 @@ export async function openQualifiedAcpxRuntime(
|
||||
.filter((server) => server.runnerOwned)
|
||||
.map((server) => server.name),
|
||||
);
|
||||
const permissionBoundary: { active: AbortController | null } = { active: null };
|
||||
const permissionBoundary: {
|
||||
active: AbortController | null;
|
||||
handler?: AcpRuntimeOptions["onPermissionRequest"];
|
||||
} = { active: null };
|
||||
const extensionProfile = ACPX_CAPABILITY_PROFILES[options.profile.agent];
|
||||
const extensionRequests = new Set(extensionProfile.extensionRequests);
|
||||
const extensionNotifications = new Set(extensionProfile.extensionNotifications);
|
||||
const extensionBoundary: AcpxRuntimeExtensionBoundary = {
|
||||
active: null, sessionIds: new Set(), steering: null,
|
||||
};
|
||||
const ownsExtensionTurn = (active: AcpxRuntimeExtensionTurn, params: Record<string, unknown>): boolean =>
|
||||
extensionBoundary.active === active && !active.signal.aborted
|
||||
&& (params.sessionId === undefined || (typeof params.sessionId === "string" && extensionBoundary.sessionIds.has(params.sessionId)));
|
||||
const goalState: AcpxRuntimeGoalState = {
|
||||
capability: null,
|
||||
snapshot: null,
|
||||
@@ -283,6 +313,27 @@ export async function openQualifiedAcpxRuntime(
|
||||
? "approve-reads"
|
||||
: options.permissionMode,
|
||||
elicitationModes: ["form"],
|
||||
...(options.clientCapabilities === undefined ? {} : { clientCapabilities: structuredClone(options.clientCapabilities) }),
|
||||
extensionMethods: [...new Set([...extensionRequests, ...extensionNotifications])],
|
||||
onExtensionRequest: async (method, params, context) => {
|
||||
const active = extensionBoundary.active;
|
||||
if (!extensionRequests.has(method) || !active?.onRequest || !ownsExtensionTurn(active, params) || context.signal.aborted) {
|
||||
throw new Error("ACPX extension request does not own an admitted active turn");
|
||||
}
|
||||
const signal = AbortSignal.any([active.signal, context.signal]);
|
||||
// Cursor's native extensions omit sessionId. Only after admission may
|
||||
// that omission inherit this exact prompt's ACP wire session identity.
|
||||
const boundParams = params.sessionId === undefined ? { ...params, sessionId: active.sessionId } : params;
|
||||
const response = await abortableExtensionResult(active.onRequest(method, boundParams, { requestId: context.requestId, signal, responseDelivery: context.responseDelivery }), signal);
|
||||
signal.throwIfAborted();
|
||||
if (!ownsExtensionTurn(active, params)) throw new Error("ACPX extension request turn expired");
|
||||
return response;
|
||||
},
|
||||
onExtensionNotification: (method, params) => {
|
||||
const active = extensionBoundary.active;
|
||||
if (!extensionNotifications.has(method) || !active?.onNotification || !ownsExtensionTurn(active, params)) return;
|
||||
active.onNotification(method, params.sessionId === undefined ? { ...params, sessionId: active.sessionId } : params);
|
||||
},
|
||||
nonInteractivePermissions: "fail",
|
||||
permissionPolicy: {
|
||||
...options.permissionPolicy,
|
||||
@@ -301,7 +352,12 @@ export async function openQualifiedAcpxRuntime(
|
||||
{ name: "Authorization", value: `Bearer ${server.bearerToken}` },
|
||||
],
|
||||
})),
|
||||
onPermissionRequest: async (request) => {
|
||||
onPermissionRequest: async (request, context) => {
|
||||
const rawSessionId = objectRecord(request.raw).sessionId;
|
||||
if (!extensionBoundary.sessionIds.has(request.sessionId)
|
||||
|| (rawSessionId !== undefined && rawSessionId !== request.sessionId)) {
|
||||
return { outcome: "reject_once" };
|
||||
}
|
||||
const disposition = decideAcpxPermission(
|
||||
options.profile.agent,
|
||||
options.permissionMode,
|
||||
@@ -314,6 +370,20 @@ export async function openQualifiedAcpxRuntime(
|
||||
},
|
||||
);
|
||||
if (disposition === "delegate") {
|
||||
const active = permissionBoundary.active;
|
||||
const handler = permissionBoundary.handler;
|
||||
if (active && handler && !active.signal.aborted && !context.signal.aborted) {
|
||||
// Capture this turn's callback before awaiting. A session-lifetime
|
||||
// callback must never acquire the next turn's approval authority.
|
||||
const decision = await handler(request, {
|
||||
signal: AbortSignal.any([active.signal, context.signal]),
|
||||
responseDelivery: context.responseDelivery,
|
||||
});
|
||||
if (permissionBoundary.active !== active || active.signal.aborted || context.signal.aborted) {
|
||||
return { outcome: "cancel" };
|
||||
}
|
||||
return decision ?? { outcome: "cancel" };
|
||||
}
|
||||
// This runtime has no interactive approval bridge. Stop the active
|
||||
// turn instead of asking the model to recover from an unexplained
|
||||
// denial or wait for an approval that nobody can answer.
|
||||
@@ -325,6 +395,12 @@ export async function openQualifiedAcpxRuntime(
|
||||
onAgentInitialize: (result) => {
|
||||
const capability = goalCapabilityFromAcpMessage({ result });
|
||||
if (capability) goalState.capability = capability;
|
||||
// Only the runner-owned Pi wrapper has an admitted steering contract.
|
||||
// Never infer it from generic ACP prompt support or native-only features.
|
||||
const pi = objectRecord(objectRecord(objectRecord(objectRecord(result).agentCapabilities)._meta).paperclipPi);
|
||||
extensionBoundary.steering = options.profile.agent === "pi" && pi.version === 1
|
||||
? { steering: pi.steering === true, queuedFollowUp: pi.queuedFollowUp === true }
|
||||
: null;
|
||||
},
|
||||
onSessionNotification: acceptGoalNotification,
|
||||
spawnEnvironment: () => ({
|
||||
@@ -454,6 +530,7 @@ export async function openQualifiedAcpxRuntime(
|
||||
goalState,
|
||||
commandLaunches,
|
||||
permissionBoundary,
|
||||
extensionBoundary,
|
||||
);
|
||||
} catch (error) {
|
||||
const cleanupReason = "ACPX runtime identity validation failed";
|
||||
@@ -881,8 +958,37 @@ function runtimePort(
|
||||
runtimeCloseTimeoutMs: number,
|
||||
goalState: AcpxRuntimeGoalState,
|
||||
commandLaunches: { count: number; refreshConsumedCommand?: () => Promise<void> },
|
||||
permissionBoundary: { active: AbortController | null },
|
||||
permissionBoundary: { active: AbortController | null; handler?: AcpRuntimeOptions["onPermissionRequest"] },
|
||||
extensionBoundary: AcpxRuntimeExtensionBoundary,
|
||||
): AcpxRuntimePort {
|
||||
extensionBoundary.sessionIds = new Set([identity.backendSessionId]);
|
||||
let extensionControls: Promise<void> = Promise.resolve();
|
||||
const controlActiveTurn = (method: "pi/steer" | "pi/follow_up", text: string, requestId: string): Promise<void> => {
|
||||
const active = extensionBoundary.active;
|
||||
const supported = method === "pi/steer"
|
||||
? extensionBoundary.steering?.steering : extensionBoundary.steering?.queuedFollowUp;
|
||||
if (!supported || !runtime.requestExtension || !active || active.requestId !== requestId || active.signal.aborted) {
|
||||
return Promise.reject(new Error("ACPX extension control does not own a supported active turn"));
|
||||
}
|
||||
if (!text.trim() || Buffer.byteLength(text, "utf8") > 65_536 || text.includes("\0")) {
|
||||
return Promise.reject(new Error("ACPX extension control message is invalid"));
|
||||
}
|
||||
const send = async (): Promise<void> => {
|
||||
await abortableExtensionResult(active.promptStarted, active.signal);
|
||||
active.signal.throwIfAborted();
|
||||
if (extensionBoundary.active !== active) throw new Error("ACPX extension control turn expired");
|
||||
const response = await abortableExtensionResult(runtime.requestExtension!({
|
||||
handle, method, params: { sessionId: identity.backendSessionId, message: text }, sessionMode: "persistent",
|
||||
}), active.signal);
|
||||
active.signal.throwIfAborted();
|
||||
if (extensionBoundary.active !== active) throw new Error("ACPX extension control turn expired");
|
||||
if (response.accepted !== true) throw new Error("ACPX extension control was not acknowledged");
|
||||
};
|
||||
const delivery = extensionControls.then(send);
|
||||
// Preserve caller ordering without letting one rejection deadlock later calls.
|
||||
extensionControls = delivery.catch(() => undefined);
|
||||
return delivery;
|
||||
};
|
||||
type RuntimeCloseAttempt = {
|
||||
readonly outcome: Promise<unknown | null>;
|
||||
readonly reconciliationGeneration: number;
|
||||
@@ -1026,6 +1132,7 @@ function runtimePort(
|
||||
attemptNumber: number;
|
||||
};
|
||||
}): Promise<void> {
|
||||
extensionBoundary.active?.controller.abort(new Error("ACPX runtime is closing"));
|
||||
if (runtimeClosed) return;
|
||||
if (
|
||||
runtimeCloseAttempt?.origin.kind === "reconciliation" &&
|
||||
@@ -1112,6 +1219,11 @@ function runtimePort(
|
||||
async getStatus() {
|
||||
return await persistedRuntimeStatus(sessionStore, handle, identity);
|
||||
},
|
||||
steeringCapability() {
|
||||
return extensionBoundary.steering === null ? null : structuredClone(extensionBoundary.steering);
|
||||
},
|
||||
steerActiveTurn: (text, requestId) => controlActiveTurn("pi/steer", text, requestId),
|
||||
queueFollowUp: (text, requestId) => controlActiveTurn("pi/follow_up", text, requestId),
|
||||
goalCapability() {
|
||||
return goalState.capability === null
|
||||
? null
|
||||
@@ -1205,8 +1317,22 @@ function runtimePort(
|
||||
}
|
||||
: {}),
|
||||
startTurn(input) {
|
||||
if (extensionBoundary.active) throw new Error("ACPX runtime already has an active turn");
|
||||
const approval = new AbortController();
|
||||
const controller = new AbortController();
|
||||
const extensionTurn: AcpxRuntimeExtensionTurn = {
|
||||
requestId: input.requestId, sessionId: identity.backendSessionId, controller,
|
||||
signal: input.signal ? AbortSignal.any([controller.signal, input.signal, approval.signal]) : AbortSignal.any([controller.signal, approval.signal]),
|
||||
promptStarted: Promise.resolve(),
|
||||
onRequest: input.onExtensionRequest, onNotification: input.onExtensionNotification,
|
||||
};
|
||||
extensionBoundary.active = extensionTurn;
|
||||
const releaseExtensionTurn = (): void => {
|
||||
controller.abort(new Error("ACPX extension turn expired"));
|
||||
if (extensionBoundary.active === extensionTurn) extensionBoundary.active = null;
|
||||
};
|
||||
permissionBoundary.active = approval;
|
||||
permissionBoundary.handler = input.onPermissionRequest;
|
||||
const finishOwnershipAdmission =
|
||||
children.beginLifetimeOwnershipAdmission();
|
||||
let turn: AcpxRuntimeTurn;
|
||||
@@ -1224,11 +1350,13 @@ function runtimePort(
|
||||
: {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (permissionBoundary.active === approval) permissionBoundary.active = null;
|
||||
releaseExtensionTurn();
|
||||
if (permissionBoundary.active === approval) { permissionBoundary.active = null; permissionBoundary.handler = undefined; }
|
||||
void finishOwnershipAdmission().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
const guarded = turnWithVerifiedLifetimeOwnership(turn, finishOwnershipAdmission);
|
||||
extensionTurn.promptStarted = guarded.promptStarted;
|
||||
const result = guarded.result.then(
|
||||
(value) => {
|
||||
approval.signal.throwIfAborted();
|
||||
@@ -1241,12 +1369,21 @@ function runtimePort(
|
||||
},
|
||||
(error: unknown) => { approval.signal.throwIfAborted(); throw error; },
|
||||
).finally(() => {
|
||||
if (permissionBoundary.active === approval) permissionBoundary.active = null;
|
||||
releaseExtensionTurn();
|
||||
if (permissionBoundary.active === approval) { permissionBoundary.active = null; permissionBoundary.handler = undefined; approval.abort(); }
|
||||
});
|
||||
void result.catch(() => undefined);
|
||||
return {
|
||||
...guarded,
|
||||
result,
|
||||
cancel: (input) => {
|
||||
controller.abort(new Error("ACPX extension turn cancelled"));
|
||||
return guarded.cancel(input);
|
||||
},
|
||||
closeStream: (input) => {
|
||||
controller.abort(new Error("ACPX extension stream closed"));
|
||||
return guarded.closeStream(input);
|
||||
},
|
||||
events: (async function* () {
|
||||
try {
|
||||
for await (const event of guarded.events) {
|
||||
@@ -1802,6 +1939,17 @@ function objectRecord(value: unknown): Record<string, unknown> {
|
||||
: {};
|
||||
}
|
||||
|
||||
async function abortableExtensionResult<T>(result: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
signal.throwIfAborted();
|
||||
let rejectAbort: (() => void) | undefined;
|
||||
const cancellation = new Promise<never>((_resolve, reject) => {
|
||||
rejectAbort = () => reject(signal.reason ?? new Error("ACPX extension turn expired"));
|
||||
signal.addEventListener("abort", rejectAbort, { once: true });
|
||||
});
|
||||
try { return await Promise.race([result, cancellation]); }
|
||||
finally { if (rejectAbort) signal.removeEventListener("abort", rejectAbort); }
|
||||
}
|
||||
|
||||
function goalCapabilityFromAcpMessage(
|
||||
message: unknown,
|
||||
): AcpxRuntimeGoalCapability | null {
|
||||
|
||||
@@ -7,6 +7,14 @@ import {
|
||||
} from "./driver-profile.js";
|
||||
|
||||
describe("ACPX driver profile", () => {
|
||||
it("enables only negotiated Pi controls and keeps the static profile conservative", () => {
|
||||
expect(acpxCapabilities("pi")).toMatchObject({ steering: false, queuedFollowUp: false });
|
||||
expect(acpxCapabilities("pi", { steering: true, queuedFollowUp: false })).toMatchObject({ steering: true, queuedFollowUp: false });
|
||||
expect(acpxCapabilities("pi", { steering: false, queuedFollowUp: true })).toMatchObject({ steering: false, queuedFollowUp: true });
|
||||
for (const agent of ["cursor", "copilot", "codex", "claude"] as const) {
|
||||
expect(acpxCapabilities(agent, { steering: true, queuedFollowUp: true })).toMatchObject({ steering: false, queuedFollowUp: false });
|
||||
}
|
||||
});
|
||||
it.each([
|
||||
["codex", "available"],
|
||||
["grok", "available"],
|
||||
@@ -86,7 +94,7 @@ describe("ACPX driver profile", () => {
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
issues: [{ path: "agent", code: "invalid_agent" }],
|
||||
issues: [{ path: "agent", code: "qualification_pending" }],
|
||||
});
|
||||
expect(
|
||||
validateAcpxDriverConfig({
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
HarnessDriverDescriptor,
|
||||
} from "../../contracts/harness-driver.js";
|
||||
import type { NativeAcpxPermissionMode } from "../../contracts/native-execution.js";
|
||||
import type { NativeSessionCapabilities } from "../../contracts/types.js";
|
||||
import type { NativeSessionCapabilities, NativeTurnControlCapabilities } from "../../contracts/types.js";
|
||||
import { providerFamilyCapabilities } from "../../provider-events.js";
|
||||
import {
|
||||
ACPX_DRIVER_KIND,
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
type QualifiedAcpxAgent,
|
||||
} from "./qualified-profiles.js";
|
||||
|
||||
const ACPX_AGENTS = ["claude", "codex", "grok"] as const;
|
||||
import { ACPX_CAPABILITY_PROFILES } from "./capability-profiles.js";
|
||||
|
||||
const ACPX_AGENTS = ["claude", "codex", "grok", "pi", "cursor", "copilot"] as const;
|
||||
const ACPX_PERMISSION_MODES = [
|
||||
"approve-all",
|
||||
"approve-paperclip",
|
||||
@@ -29,30 +31,34 @@ export interface ValidatedAcpxDriverConfig extends Record<string, unknown> {
|
||||
|
||||
export function acpxCapabilities(
|
||||
agent: QualifiedAcpxAgent,
|
||||
negotiated?: NativeTurnControlCapabilities | null,
|
||||
): NativeSessionCapabilities {
|
||||
const controls = agent === "pi" ? negotiated : null;
|
||||
const profile = ACPX_CAPABILITY_PROFILES[agent];
|
||||
return {
|
||||
resume: true,
|
||||
resume: profile.recovery === "session-load",
|
||||
typedEvents: true,
|
||||
typedEventFamilies: providerFamilyCapabilities({
|
||||
plan: agent === "pi" ? "unsupported" : "available",
|
||||
plan: profile.plans === "semantic-only" ? "unsupported" : "available",
|
||||
tool_execution: "available",
|
||||
model_identity: "available",
|
||||
review: agent === "grok" ? "unsupported" : "available",
|
||||
provider_notice: agent === "grok" ? "unsupported" : "available",
|
||||
artifact: "policy_disabled",
|
||||
}),
|
||||
steering: false,
|
||||
steering: controls?.steering === true,
|
||||
queuedFollowUp: controls?.queuedFollowUp === true,
|
||||
interruption: true,
|
||||
structuredResult: true,
|
||||
read: true,
|
||||
reconciliation: true,
|
||||
usage: agent !== "grok",
|
||||
usage: profile.usage === "reported",
|
||||
dynamicTools: true,
|
||||
runtimeRequestResolution: true,
|
||||
runtimeRequestResolution: profile.permissions === "interactive" || profile.questions === "form",
|
||||
runtimeRequestHandoff: true,
|
||||
goals: false,
|
||||
threadLineage: false,
|
||||
unsupported: ["steering", "goals", "threadLineage"],
|
||||
unsupported: [...(controls?.steering ? [] : ["steering"]), "goals", "threadLineage"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,9 +102,12 @@ export function validateAcpxDriverConfig(
|
||||
return invalid(
|
||||
"agent",
|
||||
"invalid_agent",
|
||||
"ACPX agent must be claude, codex, or grok.",
|
||||
"ACPX agent must be claude, codex, grok, cursor, copilot, or pi.",
|
||||
);
|
||||
}
|
||||
if (ACPX_CAPABILITY_PROFILES[agent].qualification !== "qualified") {
|
||||
return invalid("agent", "qualification_pending", `${ACPX_CAPABILITY_PROFILES[agent].displayName} requires local and Daytona qualification before use.`);
|
||||
}
|
||||
const model = text(config.model);
|
||||
try {
|
||||
resolveQualifiedAcpxProfile(agent, model);
|
||||
@@ -144,10 +153,7 @@ function isPermissionMode(value: string): value is NativeAcpxPermissionMode {
|
||||
}
|
||||
|
||||
function displayAgent(agent: QualifiedAcpxAgent): string {
|
||||
if (agent === "grok") return "Grok Build";
|
||||
if (agent === "pi") return "Pi";
|
||||
if (agent === "claude") return "Claude";
|
||||
return "Codex";
|
||||
return ACPX_CAPABILITY_PROFILES[agent].displayName;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createSanitizedAcpxSpawnInput } from "./environment.js";
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
import { ACPX_CREDENTIAL_BINDING_ENV, createAcpxCredentialBinding, createAcpxSidecarHostEnvironment, createSanitizedAcpxSpawnInput } from "./environment.js";
|
||||
|
||||
describe("ACPX launch environment", () => {
|
||||
it("projects only the selected agent's credentials and runtime allowlist", () => {
|
||||
@@ -45,6 +47,64 @@ describe("ACPX launch environment", () => {
|
||||
expect(Object.isFrozen(codex.env)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["pi", "OPENROUTER_API_KEY"], ["cursor", "CURSOR_API_KEY"],
|
||||
["cursor", "CURSOR_AUTH_TOKEN"], ["copilot", "COPILOT_GITHUB_TOKEN"],
|
||||
] as const)("requires an explicit %s credential binding for %s", (agent, name) => {
|
||||
vi.stubEnv(name, "ambient-secret");
|
||||
expect(createSanitizedAcpxSpawnInput(undefined, agent).env[name]).toBeUndefined();
|
||||
expect(createSanitizedAcpxSpawnInput({ [name]: "bound-secret" }, agent).env[name]).toBe("bound-secret");
|
||||
});
|
||||
|
||||
it("drops alternate credentials, provider injection and configuration overrides", () => {
|
||||
const source = {
|
||||
CURSOR_API_KEY: "cursor-key", CURSOR_AUTH_TOKEN: "cursor-token", COPILOT_GITHUB_TOKEN: "copilot-key",
|
||||
GH_TOKEN: "gh-key", GITHUB_TOKEN: "github-key", COPILOT_PROVIDER_BASE_URL: "https://unapproved.invalid",
|
||||
COPILOT_MODEL: "ambient-model", CURSOR_CONFIG_DIR: "/ambient/cursor", PI_CODING_AGENT_DIR: "/ambient/pi",
|
||||
PAPERCLIP_PI_ENTRYPOINT: "/unverified/pi.js", NODE_OPTIONS: "--import=/unverified/hook.js",
|
||||
COPILOT_PKG_CACHE_HOME: "/unverified/cache", COPILOT_ALLOW_ALL: "true",
|
||||
};
|
||||
expect(createSanitizedAcpxSpawnInput(source, "cursor").env).toEqual({ CURSOR_API_KEY: "cursor-key", CURSOR_AUTH_TOKEN: "cursor-token" });
|
||||
expect(createSanitizedAcpxSpawnInput(source, "copilot").env).toEqual({ COPILOT_GITHUB_TOKEN: "copilot-key" });
|
||||
expect(createSanitizedAcpxSpawnInput(source, "pi").env).toEqual({});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["pi", "OPENROUTER_API_KEY"], ["cursor", "CURSOR_API_KEY"],
|
||||
["cursor", "CURSOR_AUTH_TOKEN"], ["copilot", "COPILOT_GITHUB_TOKEN"],
|
||||
] as const)("admits only session-bound %s sidecar credentials for %s", (agent, name) => {
|
||||
const source = { [name]: "task-secret" };
|
||||
const binding = createAcpxCredentialBinding(source, agent, "session-1");
|
||||
expect(binding).not.toContain("task-secret");
|
||||
const explicit = createAcpxSidecarHostEnvironment({ ...source, [ACPX_CREDENTIAL_BINDING_ENV]: binding }, agent, "session-1");
|
||||
expect(createSanitizedAcpxSpawnInput(explicit, agent).env).toEqual(source);
|
||||
expect(explicit[ACPX_CREDENTIAL_BINDING_ENV]).toBeUndefined();
|
||||
expect(() => createAcpxSidecarHostEnvironment(source, agent, "session-1")).toThrow("explicit matching session binding");
|
||||
expect(() => createAcpxSidecarHostEnvironment({ ...source, [ACPX_CREDENTIAL_BINDING_ENV]: binding }, agent, "stale-session")).toThrow("explicit matching session binding");
|
||||
});
|
||||
|
||||
it("rejects malformed, wrong-provider, duplicate and missing sidecar credential bindings", () => {
|
||||
const source = { CURSOR_API_KEY: "do-not-include-in-errors" };
|
||||
const valid = JSON.parse(createAcpxCredentialBinding(source, "cursor", "session-1")!);
|
||||
for (const binding of ["invalid-json", JSON.stringify({ ...valid, agent: "copilot" }),
|
||||
JSON.stringify({ ...valid, names: ["COPILOT_GITHUB_TOKEN"] }),
|
||||
JSON.stringify({ ...valid, names: ["CURSOR_API_KEY", "CURSOR_API_KEY"] }),
|
||||
JSON.stringify({ ...valid, names: [] }), JSON.stringify({ ...valid, extra: true })]) {
|
||||
expect(() => createAcpxSidecarHostEnvironment({ ...source, [ACPX_CREDENTIAL_BINDING_ENV]: binding }, "cursor", "session-1"))
|
||||
.toThrow(/^Candidate ACPX credentials require an explicit matching session binding$/);
|
||||
}
|
||||
expect(() => createAcpxSidecarHostEnvironment({ [ACPX_CREDENTIAL_BINDING_ENV]: JSON.stringify(valid) }, "cursor", "session-1")).toThrow("explicit matching session binding");
|
||||
expect(() => createAcpxSidecarHostEnvironment({ ...source, CURSOR_AUTH_TOKEN: "unbound", [ACPX_CREDENTIAL_BINDING_ENV]: JSON.stringify(valid) }, "cursor", "session-1")).toThrow("explicit matching session binding");
|
||||
});
|
||||
|
||||
it("does not reinterpret legacy profile credential environments", () => {
|
||||
const source = { ANTHROPIC_API_KEY: "legacy", OPENAI_API_KEY: "legacy", [ACPX_CREDENTIAL_BINDING_ENV]: "irrelevant" };
|
||||
for (const agent of ["claude", "codex"] as const) {
|
||||
expect(createAcpxCredentialBinding(source, agent, "session-1")).toBeUndefined();
|
||||
expect(createAcpxSidecarHostEnvironment(source, agent, "session-1")).toBe(source);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unsafe or unbounded retained values", () => {
|
||||
expect(() =>
|
||||
createSanitizedAcpxSpawnInput({ PATH: "bad\0path" }, "codex"),
|
||||
|
||||
@@ -1,5 +1,67 @@
|
||||
import type { QualifiedAcpxAgent } from "./qualified-profiles.js";
|
||||
|
||||
export const ACPX_CREDENTIAL_BINDING_ENV = "PAPERCLIP_ACPX_CREDENTIAL_BINDING";
|
||||
export const ACPX_CREDENTIAL_NAMES: Readonly<Record<QualifiedAcpxAgent, readonly string[]>> = {
|
||||
grok: ["XAI_API_KEY"],
|
||||
pi: ["OPENROUTER_API_KEY"],
|
||||
cursor: ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"],
|
||||
copilot: ["COPILOT_GITHUB_TOKEN"],
|
||||
claude: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"],
|
||||
codex: ["OPENAI_API_KEY", "CODEX_API_KEY"],
|
||||
};
|
||||
const isCandidate = (agent: QualifiedAcpxAgent) => agent === "pi" || agent === "cursor" || agent === "copilot";
|
||||
|
||||
/** Mint only at the controller's explicit task-environment boundary, never by copying a marker. */
|
||||
export function createAcpxCredentialBinding(
|
||||
environment: NodeJS.ProcessEnv | undefined,
|
||||
agent: QualifiedAcpxAgent,
|
||||
sessionId: string,
|
||||
): string | undefined {
|
||||
if (!isCandidate(agent)) return undefined;
|
||||
return JSON.stringify({
|
||||
schema: "paperclip.acpx_credential_binding.v1", agent, sessionId,
|
||||
names: ACPX_CREDENTIAL_NAMES[agent].filter(name => environment !== undefined
|
||||
&& Object.hasOwn(environment, name) && Boolean(environment[name]?.trim())),
|
||||
});
|
||||
}
|
||||
|
||||
/** process.env at the sidecar is not itself proof of a task credential binding. */
|
||||
export function createAcpxSidecarHostEnvironment(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
agent: QualifiedAcpxAgent,
|
||||
sessionId: string,
|
||||
): NodeJS.ProcessEnv {
|
||||
if (!isCandidate(agent)) return environment;
|
||||
const invalid = () => new Error("Candidate ACPX credentials require an explicit matching session binding");
|
||||
const raw = environment[ACPX_CREDENTIAL_BINDING_ENV];
|
||||
let names: string[] = [];
|
||||
if (raw !== undefined) {
|
||||
if (Buffer.byteLength(raw) > 4_096) throw invalid();
|
||||
let binding: unknown;
|
||||
try { binding = JSON.parse(raw); } catch { throw invalid(); }
|
||||
if (binding === null || typeof binding !== "object" || Array.isArray(binding)) throw invalid();
|
||||
const value = binding as Record<string, unknown>;
|
||||
if (Object.keys(value).sort().join(",") !== "agent,names,schema,sessionId"
|
||||
|| value.schema !== "paperclip.acpx_credential_binding.v1" || value.agent !== agent
|
||||
|| value.sessionId !== sessionId || !Array.isArray(value.names)
|
||||
|| value.names.length > ACPX_CREDENTIAL_NAMES[agent].length
|
||||
|| value.names.some(name => typeof name !== "string" || !ACPX_CREDENTIAL_NAMES[agent].includes(name))
|
||||
|| new Set(value.names).size !== value.names.length) throw invalid();
|
||||
names = value.names as string[];
|
||||
}
|
||||
const result = { ...environment };
|
||||
delete result[ACPX_CREDENTIAL_BINDING_ENV];
|
||||
for (const name of ACPX_CREDENTIAL_NAMES[agent]) {
|
||||
if (names.includes(name)) {
|
||||
if (!Object.hasOwn(environment, name) || !environment[name]?.trim()) throw invalid();
|
||||
} else {
|
||||
if (environment[name]?.trim()) throw invalid();
|
||||
delete result[name];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
declare const sanitizedAcpxSpawnInputBrand: unique symbol;
|
||||
|
||||
/**
|
||||
@@ -23,18 +85,9 @@ export function createSanitizedAcpxSpawnInput(
|
||||
agent: QualifiedAcpxAgent,
|
||||
): SanitizedAcpxSpawnInput {
|
||||
const source = environment ?? process.env;
|
||||
const candidate = isCandidate(agent);
|
||||
const result: NodeJS.ProcessEnv = {};
|
||||
const credentialNames =
|
||||
agent === "grok"
|
||||
? ["XAI_API_KEY"]
|
||||
: agent === "pi"
|
||||
? ["OPENROUTER_API_KEY"]
|
||||
: agent === "claude"
|
||||
? ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"]
|
||||
: [
|
||||
"OPENAI_API_KEY",
|
||||
"CODEX_API_KEY",
|
||||
];
|
||||
const credentialNames = ACPX_CREDENTIAL_NAMES[agent];
|
||||
const allowed = new Set([
|
||||
"PATH",
|
||||
"LANG",
|
||||
@@ -62,6 +115,9 @@ export function createSanitizedAcpxSpawnInput(
|
||||
let retainedBytes = 0;
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (typeof value !== "string") continue;
|
||||
// Candidate credentials must be explicitly bound to this run. Locale and
|
||||
// transport settings may come from the host; login state never does.
|
||||
if (candidate && environment === undefined && credentialNames.includes(key)) continue;
|
||||
if (!allowed.has(key) && !/^LC_[A-Z0-9_]{1,32}$/.test(key)) continue;
|
||||
if (key.includes("\0") || value.includes("\0")) {
|
||||
throw new Error("ACPX environment contains a null byte");
|
||||
|
||||
@@ -7,6 +7,7 @@ export const GENERATED_ACPX_SIDECAR_COMMANDS = [
|
||||
"run.attach",
|
||||
"turn.start",
|
||||
"turn.cancel",
|
||||
"turn.steer",
|
||||
"permission.resolve",
|
||||
"input.resolve",
|
||||
"tool.resolve",
|
||||
@@ -23,6 +24,7 @@ export type GeneratedAcpxSidecarCommand =
|
||||
|
||||
export const GENERATED_ACPX_SIDECAR_EVENT_TYPES = [
|
||||
"runtime.event",
|
||||
"runtime.rich_event",
|
||||
"runtime.permission_requested",
|
||||
"runtime.input_requested",
|
||||
"runtime.tool_called",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { MAX_ACPX_RUNTIME_EXECUTABLE_BYTES, ACPX_PRIVATE_SNAPSHOT_ENV, createAcpxPrivateSnapshot, type AcpxPrivateSnapshot } from "./private-snapshot.js";
|
||||
import { createNativeAcpxDistributionSnapshot, NATIVE_ACPX_BOOTSTRAP_NAME, readNativeAcpxDistributionEntries, type NativeAcpxDistributionInput } from "./native-distribution-integrity.js";
|
||||
export type { NativeAcpxDistributionInput } from "./native-distribution-integrity.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
spawn as spawnChildProcess,
|
||||
@@ -407,6 +409,43 @@ export interface VerifiedAcpxInstallation {
|
||||
openCommand(): Promise<VerifiedAcpxCommandLease>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native candidate distribution primitive. This verifies bytes and lifetime
|
||||
* ownership; it does not qualify or enable a provider profile. The caller must
|
||||
* source every expected value from its trusted, versioned profile declaration.
|
||||
*/
|
||||
export async function verifyNativeAcpxInstallation(
|
||||
input: NativeAcpxDistributionInput,
|
||||
): Promise<VerifiedAcpxInstallation> {
|
||||
const declaration: NativeAcpxDistributionInput = Object.freeze({
|
||||
...input, fixedArguments: Object.freeze([...input.fixedArguments]),
|
||||
});
|
||||
const entries = await readNativeAcpxDistributionEntries(declaration);
|
||||
return Object.freeze({
|
||||
commandDigest: `sha256:${declaration.expectedClosureSha256}`,
|
||||
agentServerPackageJsonPath: declaration.manifestPath,
|
||||
agentRuntimePackageJsonPath: null,
|
||||
async openCommand(): Promise<VerifiedAcpxCommandLease> {
|
||||
const native = await createNativeAcpxDistributionSnapshot(declaration, entries);
|
||||
const lease = commandLease(
|
||||
native.snapshot.roots[0]!, NATIVE_ACPX_BOOTSTRAP_NAME, "commonjs",
|
||||
native.bootstrap, native.commandDirectory, [], 0, "commonjs", [],
|
||||
null, null, native.snapshot,
|
||||
);
|
||||
return {
|
||||
spawn(args = [], options = {}, lifetime) {
|
||||
if (args.length !== 0) throw new Error("Native ACPX distribution accepts only its fixed profile arguments");
|
||||
return lease.spawn([], { ...options, env: {
|
||||
...(options.env ?? process.env),
|
||||
PAPERCLIP_ACPX_NATIVE_GUARDED: lifetime === undefined ? "0" : "1",
|
||||
} }, lifetime);
|
||||
},
|
||||
close: () => lease.close(),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface VerifiedAcpxCommandLease {
|
||||
/**
|
||||
* Launch with a Linux descriptor-backed entry identity. Callers must not
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import { chmod, copyFile, mkdtemp, open, readFile, readdir, rm, stat, symlink, writeFile, type FileHandle } from "node:fs/promises";
|
||||
import { createServer, type Server } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { awaitVerifiedAcpxProviderExit, awaitVerifiedAcpxProviderOwnership, verifyNativeAcpxInstallation } from "./installation-integrity.js";
|
||||
import { createNativeAcpxDistributionSnapshot, readNativeAcpxDistributionEntries, parseNativeAcpxDistributionEntries, type NativeAcpxDistributionInput, type NativeAcpxDistributionEntry } from "./native-distribution-integrity.js";
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("node:fs/promises")>();
|
||||
return { ...original, rm: vi.fn(original.rm) };
|
||||
});
|
||||
|
||||
const roots: string[] = [];
|
||||
const hash = (value: string | Buffer) => createHash("sha256").update(value).digest("hex");
|
||||
afterEach(async () => { vi.restoreAllMocks(); await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
|
||||
|
||||
async function fixture(options: { node?: boolean; script?: string } = {}): Promise<NativeAcpxDistributionInput> {
|
||||
const root = await mkdtemp(join(tmpdir(), "native-acpx-test-")); roots.push(root);
|
||||
const entries: NativeAcpxDistributionEntry[] = [];
|
||||
if (options.node) {
|
||||
await copyFile(process.execPath, join(root, "runtime")); await chmod(join(root, "runtime"), 0o700);
|
||||
const bytes = await readFile(join(root, "runtime")); entries.push({ path: "runtime", sha256: hash(bytes), size: bytes.length, executable: true });
|
||||
// Homebrew's test-host Node uses an @rpath libnode dylib. Include that
|
||||
// dependency in this fixture's closure instead of relying on its old path.
|
||||
const libraryRoot = join(dirname(process.execPath), "..", "lib");
|
||||
for (const name of (await readdir(libraryRoot).catch(() => [] as string[])).filter(name => /^libnode\..*\.dylib$/.test(name))) {
|
||||
await copyFile(join(libraryRoot, name), join(root, name)); await chmod(join(root, name), 0o600);
|
||||
const library = await readFile(join(root, name)); entries.push({ path: name, sha256: hash(library), size: library.length, executable: false });
|
||||
}
|
||||
const script = options.script ?? 'console.log("qualified-entry");';
|
||||
await writeFile(join(root, "entry.cjs"), script, { mode: 0o600 });
|
||||
entries.push({ path: "entry.cjs", sha256: hash(script), size: Buffer.byteLength(script), executable: false });
|
||||
} else {
|
||||
const script = options.script ?? '#!/bin/sh\nprintf "native:%s:%s" "$1" "${COPILOT_PKG_CACHE_HOME:-none}"\n';
|
||||
await writeFile(join(root, "runtime"), script, { mode: 0o700 });
|
||||
entries.push({ path: "runtime", sha256: hash(script), size: Buffer.byteLength(script), executable: true });
|
||||
}
|
||||
entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
||||
const manifestPath = join(root, "closure.json");
|
||||
await writeFile(manifestPath, JSON.stringify({ entries }));
|
||||
return { distributionRoot: root, manifestPath, expectedClosureSha256: hash(JSON.stringify(entries)), executable: "runtime", ...(options.node ? { entrypoint: "entry.cjs" } : {}), fixedArguments: options.node ? [] : ["fixed"] };
|
||||
}
|
||||
async function manyFileFixture(sizes: number[]) {
|
||||
const declaration = await fixture();
|
||||
const entries = await readNativeAcpxDistributionEntries(declaration);
|
||||
for (let index = 0; index < sizes.length; index++) {
|
||||
const path = `file-${String(index).padStart(2, "0")}`;
|
||||
const bytes = Buffer.alloc(sizes[index]!, index + 1);
|
||||
await writeFile(join(declaration.distributionRoot, path), bytes, { mode: 0o600 });
|
||||
entries.push({ path, sha256: hash(bytes), size: bytes.length, executable: false });
|
||||
}
|
||||
entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
||||
await writeFile(declaration.manifestPath, JSON.stringify({ entries }));
|
||||
return { declaration: { ...declaration, expectedClosureSha256: hash(JSON.stringify(entries)) }, entries };
|
||||
}
|
||||
async function filePrototype(path: string): Promise<FileHandle> {
|
||||
const probe = await open(path, "r"); const prototype = Object.getPrototypeOf(probe) as FileHandle;
|
||||
await probe.close(); return prototype;
|
||||
}
|
||||
function gate() {
|
||||
let release!: () => void;
|
||||
const promise = new Promise<void>((resolve) => { release = resolve; });
|
||||
return { promise, release };
|
||||
}
|
||||
|
||||
async function output(child: ChildProcess): Promise<{ text: string; error: string; code: number | null }> {
|
||||
let text = ""; let error = "";
|
||||
child.stdout!.on("data", value => { text += String(value); });
|
||||
child.stderr!.on("data", value => { error += String(value); });
|
||||
const [code] = await once(child, "close") as [number | null];
|
||||
return { text, error, code };
|
||||
}
|
||||
|
||||
describe("native ACPX execution closure", () => {
|
||||
it("rejects paths, ordering, oversized files and altered manifest pins", () => {
|
||||
const entry = { path: "runtime", sha256: "a".repeat(64), size: 10, executable: true };
|
||||
for (const entries of [[{ ...entry, path: "../escape" }], [{ ...entry, path: "/absolute" }], [entry, entry], [{ ...entry, size: 2 ** 40 }], [{ ...entry, unknown: 1 }]]) {
|
||||
expect(() => parseNativeAcpxDistributionEntries({ entries }, hash(JSON.stringify(entries)))).toThrow();
|
||||
}
|
||||
expect(() => parseNativeAcpxDistributionEntries({ entries: [entry] }, "b".repeat(64))).toThrow("manifest digest mismatch");
|
||||
});
|
||||
it("checks every file digest and refuses links at admission", async () => {
|
||||
const declaration = await fixture(); const installation = await verifyNativeAcpxInstallation(declaration);
|
||||
await writeFile(join(declaration.distributionRoot, "runtime"), "altered");
|
||||
await expect(installation.openCommand()).rejects.toThrow();
|
||||
await rm(join(declaration.distributionRoot, "runtime"));
|
||||
await symlink("closure.json", join(declaration.distributionRoot, "runtime"));
|
||||
await expect(installation.openCommand()).rejects.toThrow("symbolic link");
|
||||
});
|
||||
it("launches only fixed arguments from a frozen snapshot after installed files change", async () => {
|
||||
const declaration = await fixture(); const lease = await (await verifyNativeAcpxInstallation(declaration)).openCommand();
|
||||
expect(() => lease.spawn(["--untrusted-override"])).toThrow("fixed profile arguments");
|
||||
await writeFile(join(declaration.distributionRoot, "runtime"), "changed after lease");
|
||||
const result = await output(lease.spawn());
|
||||
expect(result).toEqual({ text: "native:fixed:none", error: "", code: 0 });
|
||||
expect(() => lease.spawn()).toThrow("closed");
|
||||
await lease.close();
|
||||
});
|
||||
it("gives packaged executables a fresh private extraction cache each launch", async () => {
|
||||
const declaration = { ...await fixture(), isolatedCacheEnvironmentName: "COPILOT_PKG_CACHE_HOME" as const };
|
||||
const install = await verifyNativeAcpxInstallation(declaration);
|
||||
const first = await output((await install.openCommand()).spawn([], { env: { COPILOT_PKG_CACHE_HOME: "/ambient/cache" } }));
|
||||
const second = await output((await install.openCommand()).spawn());
|
||||
expect(first.text).toMatch(/^native:fixed:.*paperclip-acpx-native-.*\/state$/);
|
||||
expect(first.text).not.toBe(second.text);
|
||||
});
|
||||
it("loads a pinned Node entrypoint while rejecting unqualified external modules", async () => {
|
||||
const declaration = await fixture({ node: true });
|
||||
const result = await output((await (await verifyNativeAcpxInstallation(declaration)).openCommand()).spawn());
|
||||
expect(result.code, result.error).toBe(0); expect(result.text).toBe("qualified-entry\n");
|
||||
const outside = join(declaration.distributionRoot, "outside.cjs"); await writeFile(outside, "module.exports='ambient';");
|
||||
const evil = await fixture({ node: true, script: `require(${JSON.stringify(outside)});` });
|
||||
const denied = await output((await (await verifyNativeAcpxInstallation(evil)).openCommand()).spawn());
|
||||
expect(denied.code).not.toBe(0); expect(denied.error).toContain("escaped its closed distribution");
|
||||
}, 30_000);
|
||||
it("copies concurrently within its descriptor bound and retains canonical manifest order", async () => {
|
||||
const { declaration, entries } = await manyFileFixture(Array.from({ length: 12 }, (_, index) => 100 + index));
|
||||
const prototype = await filePrototype(join(declaration.distributionRoot, "runtime"));
|
||||
const originalRead = prototype.read;
|
||||
const hold = gate(); const sizes: number[] = []; let active = 0; let peak = 0;
|
||||
vi.spyOn(prototype, "read").mockImplementation(async function (this: FileHandle, ...args: any[]): Promise<any> {
|
||||
sizes.push(args[0].length); active++; peak = Math.max(peak, active);
|
||||
try { await hold.promise; return await originalRead.apply(this, args as never); }
|
||||
finally { active--; }
|
||||
});
|
||||
const creating = createNativeAcpxDistributionSnapshot(declaration, entries);
|
||||
try {
|
||||
await vi.waitFor(() => expect(sizes).toHaveLength(8));
|
||||
expect(sizes.toSorted()).toEqual(Array.from({ length: 8 }, (_, index) => 100 + index));
|
||||
} finally { hold.release(); }
|
||||
const created = await creating;
|
||||
try {
|
||||
expect(peak).toBe(8);
|
||||
expect(Object.keys(created.snapshot.digests).slice(0, entries.length)).toEqual(entries.map(entry => join(created.snapshot.roots[0]!, entry.path)));
|
||||
for (const entry of entries) expect(hash(await readFile(join(created.snapshot.roots[0]!, entry.path)))).toBe(entry.sha256);
|
||||
} finally { await created.commandDirectory.close(); await created.snapshot.close(); }
|
||||
});
|
||||
it("bounds simultaneous buffers and gives an oversized admitted file exclusive capacity", async () => {
|
||||
const mib = 1024 * 1024;
|
||||
const { declaration, entries } = await manyFileFixture([17 * mib, 17 * mib, 33 * mib]);
|
||||
const prototype = await filePrototype(join(declaration.distributionRoot, "runtime"));
|
||||
const originalRead = prototype.read;
|
||||
const retained = new Map<FileHandle, number>(); const observed: number[] = [];
|
||||
vi.spyOn(prototype, "read").mockImplementation(async function (this: FileHandle, ...args: any[]): Promise<any> {
|
||||
if (!retained.has(this)) {
|
||||
const file = this; const originalClose = file.close.bind(file);
|
||||
file.close = async () => { try { await originalClose(); } finally { retained.delete(file); } };
|
||||
}
|
||||
retained.set(this, args[0].length);
|
||||
const total = [...retained.values()].reduce((sum, size) => sum + size, 0); observed.push(total);
|
||||
expect(total).toBeLessThanOrEqual(Math.max(32 * mib, args[0].length));
|
||||
if (args[0].length > 32 * mib) expect(retained.size).toBe(1);
|
||||
return originalRead.apply(this, args as never);
|
||||
});
|
||||
const created = await createNativeAcpxDistributionSnapshot(declaration, entries);
|
||||
try { expect(Math.max(...observed)).toBe(33 * mib); }
|
||||
finally { await created.commandDirectory.close(); await created.snapshot.close(); }
|
||||
});
|
||||
it("drains every admitted copy before failed-snapshot cleanup and schedules no later batch", async () => {
|
||||
const { declaration, entries } = await manyFileFixture(Array.from({ length: 10 }, (_, index) => 91 + index));
|
||||
await writeFile(join(declaration.distributionRoot, "file-00"), Buffer.alloc(91, 99));
|
||||
const prototype = await filePrototype(join(declaration.distributionRoot, "runtime"));
|
||||
const originalRead = prototype.read;
|
||||
const hold = gate(); const entered = gate(); const invalidClosed = gate();
|
||||
const readSizes: number[] = []; const removalStart = vi.mocked(rm).mock.calls.length;
|
||||
vi.spyOn(prototype, "read").mockImplementation(async function (this: FileHandle, ...args: any[]): Promise<any> {
|
||||
const size = args[0].length; readSizes.push(size);
|
||||
if (size === 91) {
|
||||
const originalClose = this.close.bind(this);
|
||||
this.close = async () => { await originalClose(); invalidClosed.release(); };
|
||||
await entered.promise;
|
||||
}
|
||||
if (size === 92) { entered.release(); await hold.promise; }
|
||||
return originalRead.apply(this, args as never);
|
||||
});
|
||||
const creating = createNativeAcpxDistributionSnapshot(declaration, entries);
|
||||
let settled = false;
|
||||
void creating.then(() => { settled = true; }, () => { settled = true; });
|
||||
try {
|
||||
await entered.promise; await invalidClosed.promise;
|
||||
expect(settled).toBe(false);
|
||||
} finally { hold.release(); }
|
||||
await expect(creating).rejects.toThrow("digest mismatch: file-00");
|
||||
expect(readSizes).not.toContain(99);
|
||||
const removals = vi.mocked(rm).mock.calls.slice(removalStart).map(([path]) => String(path)).filter(path => /paperclip-acpx-native-/.test(path));
|
||||
expect(removals).toHaveLength(1);
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
await expect(stat(removals[0]!)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
it("rejects a source mutation while another file is being copied", async () => {
|
||||
const { declaration, entries } = await manyFileFixture([101, 102]);
|
||||
const prototype = await filePrototype(join(declaration.distributionRoot, "runtime"));
|
||||
const originalRead = prototype.read; const entered = gate(); const hold = gate();
|
||||
vi.spyOn(prototype, "read").mockImplementation(async function (this: FileHandle, ...args: any[]): Promise<any> {
|
||||
if (args[0].length === 101) { entered.release(); await hold.promise; }
|
||||
return originalRead.apply(this, args as never);
|
||||
});
|
||||
const creating = createNativeAcpxDistributionSnapshot(declaration, entries);
|
||||
const rejected = expect(creating).rejects.toThrow("changed while read");
|
||||
try {
|
||||
await entered.promise;
|
||||
await writeFile(join(declaration.distributionRoot, "file-00"), Buffer.alloc(101, 7));
|
||||
} finally { hold.release(); }
|
||||
await rejected;
|
||||
});
|
||||
it("uses the existing guardian ownership and provider-exit proof for native children", async () => {
|
||||
const declaration = await fixture({ script: '#!/bin/sh\nprintf "ready"\nwhile :; do sleep 1; done\n' });
|
||||
const fences = await Promise.all([listen(), listen()]);
|
||||
const fds = fences.map(server => (server as Server & { _handle?: { fd?: number } })._handle!.fd!);
|
||||
const owners: number[] = [];
|
||||
const child = (await (await verifyNativeAcpxInstallation(declaration)).openCommand()).spawn([], {}, {
|
||||
credentialFenceFds: [fds[0]!, fds[1]!], activateCredentialFenceOwner: async pid => { owners.push(pid); },
|
||||
});
|
||||
let stderr = ""; child.stderr!.on("data", value => { stderr += String(value); });
|
||||
try {
|
||||
await awaitVerifiedAcpxProviderOwnership(child);
|
||||
expect(owners).toEqual([child.pid]);
|
||||
const [chunk] = await once(child.stdout!, "data"); expect(String(chunk), stderr).toBe("ready");
|
||||
const exited = once(child, "exit"); child.kill(); await exited;
|
||||
await awaitVerifiedAcpxProviderExit(child);
|
||||
} finally {
|
||||
child.kill(); await Promise.all(fences.map(server => new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))));
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
async function listen(): Promise<Server> {
|
||||
const server = createServer(); server.listen(0, "127.0.0.1"); await once(server, "listening"); return server;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { chmod, lstat, mkdir, mkdtemp, open, realpath, rm, writeFile, type FileHandle } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import type { AcpxPrivateSnapshot } from "./private-snapshot.js";
|
||||
|
||||
export interface NativeAcpxDistributionEntry { path: string; sha256: string; size: number; executable: boolean }
|
||||
export interface NativeAcpxDistributionInput {
|
||||
distributionRoot: string;
|
||||
manifestPath: string;
|
||||
/** Trusted profile pin, never taken from the installed manifest itself. */
|
||||
expectedClosureSha256: string;
|
||||
executable: string;
|
||||
/** When present, execute this JS entry with the distribution's bundled Node. */
|
||||
entrypoint?: string;
|
||||
fixedArguments: readonly string[];
|
||||
isolatedCacheEnvironmentName?: "COPILOT_PKG_CACHE_HOME";
|
||||
}
|
||||
export interface NativeAcpxDistributionSnapshot {
|
||||
snapshot: AcpxPrivateSnapshot;
|
||||
commandDirectory: FileHandle;
|
||||
bootstrap: Buffer;
|
||||
}
|
||||
|
||||
const MAX_NATIVE_TREE_BYTES = 1024 * 1024 * 1024;
|
||||
const MAX_NATIVE_FILE_BYTES = 384 * 1024 * 1024;
|
||||
const MAX_NATIVE_MANIFEST_BYTES = 4 * 1024 * 1024;
|
||||
// Bound both file descriptors and buffers. A single larger admitted file runs
|
||||
// alone and remains subject to MAX_NATIVE_FILE_BYTES.
|
||||
const NATIVE_COPY_CONCURRENCY = 8;
|
||||
const NATIVE_COPY_BUFFER_BYTES = 32 * 1024 * 1024;
|
||||
const BOOTSTRAP = ".paperclip-native-entry.cjs";
|
||||
const GUARD = ".paperclip-native-module-guard.cjs";
|
||||
export const NATIVE_ACPX_BOOTSTRAP_NAME = BOOTSTRAP;
|
||||
const sha256 = (value: string | Buffer) => createHash("sha256").update(value).digest("hex");
|
||||
const validPath = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 4_096 && !isAbsolute(value) && !/[\u0000-\u001f\u007f\\]/.test(value) && value.split("/").every(part => part.length > 0 && part !== "." && part !== "..");
|
||||
|
||||
/** Parse a closed, canonical execution inventory before opening any file in it. */
|
||||
export function parseNativeAcpxDistributionEntries(value: unknown, expectedSha256: string): NativeAcpxDistributionEntry[] {
|
||||
if (!/^[a-f0-9]{64}$/.test(expectedSha256)) throw new Error("Native ACPX closure pin is invalid");
|
||||
const entries = value !== null && typeof value === "object" ? (value as { entries?: unknown }).entries : undefined;
|
||||
if (!Array.isArray(entries) || entries.length === 0 || entries.length > 30_000) throw new Error("Native ACPX closure inventory is invalid");
|
||||
let total = 0;
|
||||
let previous = "";
|
||||
const parsed = entries.map(raw => {
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Native ACPX closure entry is invalid");
|
||||
const entry = raw as Record<string, unknown>;
|
||||
if (Object.keys(entry).some(key => !["path", "sha256", "size", "executable"].includes(key)) || !validPath(entry.path) || entry.path <= previous || entry.path === BOOTSTRAP || entry.path === GUARD || entry.path === "manifest.json" || entry.path.startsWith(".paperclip-")) throw new Error("Native ACPX closure paths must be safe, unique and sorted");
|
||||
if (typeof entry.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(entry.sha256) || !Number.isSafeInteger(entry.size) || Number(entry.size) < 0 || Number(entry.size) > MAX_NATIVE_FILE_BYTES || typeof entry.executable !== "boolean") throw new Error("Native ACPX closure file metadata is invalid");
|
||||
previous = entry.path;
|
||||
total += Number(entry.size);
|
||||
if (total > MAX_NATIVE_TREE_BYTES) throw new Error("Native ACPX closure exceeds its byte bound");
|
||||
return { path: entry.path, sha256: entry.sha256, size: Number(entry.size), executable: entry.executable };
|
||||
});
|
||||
if (sha256(JSON.stringify(parsed)) !== expectedSha256) throw new Error("Native ACPX closure manifest digest mismatch");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function readNativeAcpxDistributionEntries(input: NativeAcpxDistributionInput): Promise<NativeAcpxDistributionEntry[]> {
|
||||
if (!validPath(input.executable) || (input.entrypoint !== undefined && !validPath(input.entrypoint)) || input.fixedArguments.length > 128 || input.fixedArguments.some(arg => typeof arg !== "string" || arg.length > 16_384 || arg.includes("\0")) || (input.isolatedCacheEnvironmentName !== undefined && input.isolatedCacheEnvironmentName !== "COPILOT_PKG_CACHE_HOME")) throw new Error("Native ACPX launch declaration is invalid");
|
||||
const file = await open(input.manifestPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const before = await file.stat({ bigint: true });
|
||||
if (!before.isFile() || before.size < 1n || before.size > BigInt(MAX_NATIVE_MANIFEST_BYTES)) throw new Error("Native ACPX manifest must be a bounded regular file");
|
||||
const bytes = await file.readFile();
|
||||
if (!same(before, await file.stat({ bigint: true })) || bytes.length !== Number(before.size)) throw new Error("Native ACPX manifest changed while read");
|
||||
const entries = parseNativeAcpxDistributionEntries(JSON.parse(bytes.toString("utf8")), input.expectedClosureSha256);
|
||||
if (!entries.some(entry => entry.path === input.executable && entry.executable && entry.size > 0) || (input.entrypoint !== undefined && !entries.some(entry => entry.path === input.entrypoint && entry.size > 0))) throw new Error("Native ACPX executable or entrypoint is absent from its closure");
|
||||
return entries;
|
||||
} finally { await file.close(); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Freeze only manifest-admitted bytes. Native trees deliberately have a separate
|
||||
* bound; the tighter JavaScript/npm snapshot limits remain unchanged.
|
||||
*/
|
||||
export async function createNativeAcpxDistributionSnapshot(input: NativeAcpxDistributionInput, entries: NativeAcpxDistributionEntry[]): Promise<NativeAcpxDistributionSnapshot> {
|
||||
// Callers cannot substitute entries between manifest validation and copying.
|
||||
entries = parseNativeAcpxDistributionEntries({ entries }, input.expectedClosureSha256);
|
||||
if (process.platform !== "linux" && process.platform !== "darwin") throw new Error("Native ACPX snapshots require Linux or macOS");
|
||||
const source = await realpath(input.distributionRoot);
|
||||
const rootBefore = await lstat(source, { bigint: true });
|
||||
if (!rootBefore.isDirectory() || rootBefore.isSymbolicLink()) throw new Error("Native ACPX distribution must be a directory");
|
||||
const heldRoot = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_DIRECTORY);
|
||||
const privateRoot = await realpath(await mkdtemp(join(tmpdir(), "paperclip-acpx-native-")));
|
||||
const packageRoot = join(privateRoot, "distribution");
|
||||
const cacheRoot = join(privateRoot, "state");
|
||||
const directories = new Set([privateRoot, packageRoot]);
|
||||
const digests: Record<string, string> = {};
|
||||
const close = async () => {
|
||||
for (const path of directories) await chmod(path, 0o700).catch(() => undefined);
|
||||
await rm(privateRoot, { recursive: true, force: true });
|
||||
};
|
||||
let commandDirectory: FileHandle | undefined;
|
||||
try {
|
||||
if (!same(rootBefore, await heldRoot.stat({ bigint: true }))) throw new Error("Native ACPX distribution root changed before snapshot");
|
||||
await mkdir(packageRoot, { mode: 0o700 });
|
||||
if (input.isolatedCacheEnvironmentName) await mkdir(cacheRoot, { mode: 0o700 });
|
||||
const copyEntry = async (entry: NativeAcpxDistributionEntry): Promise<void> => {
|
||||
const path = join(source, ...entry.path.split("/"));
|
||||
if (await realpath(path) !== path) throw new Error("Native ACPX closure contains a symbolic link");
|
||||
const before = await lstat(path, { bigint: true });
|
||||
if (!before.isFile() || before.nlink !== 1n || before.size !== BigInt(entry.size) || Boolean(before.mode & 0o111n) !== entry.executable) throw new Error("Native ACPX closure file identity is invalid");
|
||||
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
if (!same(before, await file.stat({ bigint: true }))) throw new Error("Native ACPX closure file changed before read");
|
||||
const bytes = Buffer.alloc(entry.size);
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
const read = await file.read(bytes, offset, bytes.length - offset, offset);
|
||||
if (read.bytesRead === 0) throw new Error("Native ACPX closure file ended during snapshot");
|
||||
offset += read.bytesRead;
|
||||
}
|
||||
if (!same(before, await file.stat({ bigint: true })) || !same(before, await lstat(path, { bigint: true })) || await realpath(path) !== path) throw new Error("Native ACPX closure file changed while read");
|
||||
if (sha256(bytes) !== entry.sha256) throw new Error(`Native ACPX closure file digest mismatch: ${entry.path}`);
|
||||
const target = join(packageRoot, ...entry.path.split("/"));
|
||||
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
||||
let directory = dirname(target);
|
||||
while (directory !== privateRoot) { directories.add(directory); directory = dirname(directory); }
|
||||
await writeFile(target, bytes, { mode: entry.executable ? 0o500 : 0o400, flag: "wx" });
|
||||
} finally { await file.close(); }
|
||||
};
|
||||
for (let start = 0; start < entries.length;) {
|
||||
let end = start;
|
||||
let bytes = 0;
|
||||
while (end < entries.length && end - start < NATIVE_COPY_CONCURRENCY) {
|
||||
const size = entries[end]!.size;
|
||||
if (end > start && bytes + size > NATIVE_COPY_BUFFER_BYTES) break;
|
||||
bytes += size;
|
||||
end++;
|
||||
}
|
||||
const batch = entries.slice(start, end);
|
||||
// Never clean the private root while another worker can still write or
|
||||
// close a descriptor. Stop scheduling new batches after any rejection.
|
||||
const copied = await Promise.allSettled(batch.map(copyEntry));
|
||||
const failure = copied.find((result) => result.status === "rejected");
|
||||
if (failure?.status === "rejected") throw failure.reason;
|
||||
// Worker completion order must not change the module guard or manifest.
|
||||
for (const entry of batch) digests[join(packageRoot, ...entry.path.split("/"))] = entry.sha256;
|
||||
start = end;
|
||||
}
|
||||
if (!same(rootBefore, await heldRoot.stat({ bigint: true })) || !same(rootBefore, await lstat(source, { bigint: true }))) throw new Error("Native ACPX distribution root changed during snapshot");
|
||||
const executable = join(packageRoot, ...input.executable.split("/"));
|
||||
const args = [...(input.entrypoint === undefined ? [] : ["--require", join(packageRoot, GUARD), join(packageRoot, ...input.entrypoint.split("/"))]), ...input.fixedArguments];
|
||||
if (input.entrypoint !== undefined) {
|
||||
const guard = Buffer.from(nativeModuleGuard(digests));
|
||||
await writeFile(join(packageRoot, GUARD), guard, { mode: 0o400, flag: "wx" });
|
||||
digests[join(packageRoot, GUARD)] = sha256(guard);
|
||||
}
|
||||
const bootstrap = Buffer.from(nativeBootstrap(executable, digests[executable]!, args, input.isolatedCacheEnvironmentName ? { name: input.isolatedCacheEnvironmentName, value: cacheRoot } : undefined));
|
||||
await writeFile(join(packageRoot, BOOTSTRAP), bootstrap, { mode: 0o400, flag: "wx" });
|
||||
digests[join(packageRoot, BOOTSTRAP)] = sha256(bootstrap);
|
||||
// executable is null: commandLease's separately-qualified provider runtime
|
||||
// slot is unused; this distribution is spawned by the trusted bootstrap.
|
||||
const manifest = Buffer.from(JSON.stringify({ roots: [packageRoot], executable: null, digests }));
|
||||
const manifestPath = join(privateRoot, "manifest.json");
|
||||
await writeFile(manifestPath, manifest, { mode: 0o400, flag: "wx" });
|
||||
for (const directory of directories) await chmod(directory, 0o500);
|
||||
commandDirectory = await open(packageRoot, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_DIRECTORY);
|
||||
return { commandDirectory, bootstrap, snapshot: { roots: [packageRoot], executable: null, digests, handoff: { path: manifestPath, digest: sha256(manifest) }, close } };
|
||||
} catch (error) {
|
||||
await commandDirectory?.close(); await close(); throw error;
|
||||
} finally { await heldRoot.close(); }
|
||||
}
|
||||
|
||||
function nativeBootstrap(executable: string, executableDigest: string, args: string[], cache?: { name: string; value: string }): string {
|
||||
return [
|
||||
'const {spawn}=require("node:child_process");',
|
||||
'const fs=require("node:fs");',
|
||||
// The commandLease's guarded bootstrap owns FDs 5 (guardian), 6 (exit),
|
||||
// 7/8 (credential fences). Keep them open in the native process too.
|
||||
'const guarded=process.env.PAPERCLIP_ACPX_NATIVE_GUARDED==="1";',
|
||||
'const env={...process.env}; delete env.PAPERCLIP_ACPX_NATIVE_GUARDED; delete env.PAPERCLIP_ACPX_PRIVATE_SNAPSHOT; delete env.PAPERCLIP_VERIFIED_RUNTIME_EXECUTABLE;',
|
||||
'env.NODE_DISABLE_COMPILE_CACHE="1"; delete env.NODE_COMPILE_CACHE;',
|
||||
...(cache ? [`env[${JSON.stringify(cache.name)}]=${JSON.stringify(cache.value)};`] : []),
|
||||
'if(guarded)for(const fd of [5,6,7,8])fs.fstatSync(fd);',
|
||||
`const executable=${JSON.stringify(executable)};const fd=fs.openSync(executable,fs.constants.O_RDONLY|fs.constants.O_NOFOLLOW);`,
|
||||
`if(require("node:crypto").createHash("sha256").update(fs.readFileSync(fd)).digest("hex")!==${JSON.stringify(executableDigest)})throw new Error("Native ACPX executable changed before spawn");`,
|
||||
'const stdio=guarded?[0,1,2,5,6,7,8]:[0,1,2]; const childExecutable=process.platform==="linux"?"/proc/self/fd/"+stdio.length:executable;stdio.push(fd);',
|
||||
`const child=spawn(childExecutable,${JSON.stringify(args)},{env,cwd:process.cwd(),shell:false,detached:false,stdio});fs.closeSync(fd);`,
|
||||
'child.once("error",()=>process.exit(1)); child.once("exit",(code,signal)=>process.exit(signal?1:code??1));',
|
||||
'for(const signal of ["SIGTERM","SIGINT","SIGHUP"])process.on(signal,()=>child.kill(signal));',
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function nativeModuleGuard(digests: Record<string, string>): string {
|
||||
return [
|
||||
'const {registerHooks,isBuiltin}=require("node:module");',
|
||||
'const {readFileSync,realpathSync}=require("node:fs");',
|
||||
'const {fileURLToPath}=require("node:url"); const {createHash}=require("node:crypto");',
|
||||
`const digests=${JSON.stringify(digests)};`,
|
||||
'const verify=url=>{if(!url.startsWith("file:"))throw new Error("Native ACPX module must be a qualified file or builtin");const path=fileURLToPath(url);if(realpathSync(path)!==path||!Object.hasOwn(digests,path))throw new Error("Native ACPX module escaped its closed distribution");const bytes=readFileSync(path);if(createHash("sha256").update(bytes).digest("hex")!==digests[path])throw new Error("Native ACPX module digest mismatch");return bytes;};',
|
||||
'registerHooks({resolve(specifier,context,next){const result=next(specifier,context);if(!isBuiltin(specifier)&&!result.url.startsWith("node:"))verify(result.url);return result;},load(url,context,next){if(url.startsWith("node:"))return next(url,context);const bytes=verify(url);if(["module","commonjs","json"].includes(context.format))return {format:context.format,source:bytes,shortCircuit:true};return next(url,context);}});',
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function same(a: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint; ctimeNs: bigint }, b: typeof a): boolean {
|
||||
return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { bindAcpxExtensionTurn, validateAcpxExtensionInput, validateAcpxRichEvent } from "./profile-extensions.js";
|
||||
import type { CanonicalProviderEvent } from "../../provider-events.js";
|
||||
|
||||
const event: CanonicalProviderEvent = { eventType: "plan.updated", itemId: "plan-1", payload: {
|
||||
schema: "paperclip.plan.updated.v1", planId: "plan-1", revision: 1,
|
||||
steps: [{ stepId: "one", body: "Validate", status: "pending" }], complete: false, syncStatus: "not_applicable",
|
||||
} };
|
||||
const input = { method: "cursor/create_plan", questionSet: {
|
||||
schema: "paperclip.question_set.v1" as const, description: "complete plan".repeat(1000),
|
||||
questions: [{ id: "plan-revision", prompt: "Approve?", answerMode: "single_select" as const, required: true,
|
||||
options: [{ id: "accept", label: "Accept" }, { id: "cancel", label: "Cancel" }] }],
|
||||
}, resolve: () => ({ outcome: "accepted" }), cancel: () => ({ outcome: "cancelled" }) };
|
||||
|
||||
describe("ACP extension turn binding", () => {
|
||||
it("preserves bounded full plans and rejects oversized documents", () => {
|
||||
expect(() => validateAcpxExtensionInput(input)).not.toThrow();
|
||||
expect(() => validateAcpxExtensionInput({ ...input, questionSet: { ...input.questionSet, description: "a".repeat(100001) } })).toThrow();
|
||||
});
|
||||
it("admits canonical activity but cannot mint a terminal or semantic mutation", () => {
|
||||
expect(() => validateAcpxRichEvent(event)).not.toThrow();
|
||||
expect(() => validateAcpxRichEvent({ ...event, eventType: "turn.completed" } as unknown as CanonicalProviderEvent)).toThrow();
|
||||
expect(() => validateAcpxRichEvent({ ...event, payload: { ...event.payload, extra: "unbounded" } })).toThrow();
|
||||
});
|
||||
it("waits for notification normalization before terminal settlement and preserves order", async () => {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
const emitted: string[] = [];
|
||||
const binding = bindAcpxExtensionTurn({
|
||||
adapter: { request: async () => ({ events: [event], response: {} }),
|
||||
notification: async (method) => { if (method === "first") await gate; emitted.push(method); return [event]; } },
|
||||
sessionId: "session", active: () => true, waitForInput: vi.fn(), emit: vi.fn(),
|
||||
});
|
||||
binding.onExtensionNotification("first", { sessionId: "session" });
|
||||
binding.onExtensionNotification("second", { sessionId: "session" });
|
||||
let drained = false;
|
||||
const drain = binding.drain().then(() => { drained = true; });
|
||||
await Promise.resolve();
|
||||
expect(drained).toBe(false);
|
||||
release(); await drain;
|
||||
expect(emitted).toEqual(["first", "second"]);
|
||||
});
|
||||
it("retains request ID zero and rejects cross-session or stale events", async () => {
|
||||
let active = true;
|
||||
const waitForInput = vi.fn(async () => ({ outcome: "accepted" }));
|
||||
const binding = bindAcpxExtensionTurn({
|
||||
adapter: { request: async () => ({ input }), notification: async () => [event] },
|
||||
sessionId: "session", active: () => active, waitForInput, emit: vi.fn(),
|
||||
});
|
||||
const context = { requestId: 0, signal: new AbortController().signal };
|
||||
await binding.onExtensionRequest("cursor/create_plan", { sessionId: "session" }, context);
|
||||
expect(waitForInput).toHaveBeenCalledWith(input, context);
|
||||
await expect(binding.onExtensionRequest("cursor/create_plan", { sessionId: "another" }, context)).rejects.toThrow("mismatch");
|
||||
active = false;
|
||||
binding.onExtensionNotification("event", { sessionId: "session" });
|
||||
await expect(binding.drain()).rejects.toThrow("stale turn");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { HarnessRuntimeRequestResolution } from "../../contracts/harness-driver.js";
|
||||
import { parsePaperclipQuestionSet, type PaperclipQuestionSet } from "../../contracts/question-set.js";
|
||||
import { isCanonicalProviderEventType, type CanonicalProviderEvent } from "../../provider-events.js";
|
||||
import { validatePrpEvent } from "../../protocol/replay-contract.js";
|
||||
import type { QualifiedAcpxAgent } from "./qualified-profiles.js";
|
||||
|
||||
export const ACPX_CANONICAL_INPUT_METHODS = [
|
||||
"elicitation/create", "cursor/ask_question", "cursor/create_plan",
|
||||
] as const;
|
||||
export function isAcpxCanonicalInputMethod(method: string): boolean {
|
||||
return (ACPX_CANONICAL_INPUT_METHODS as readonly string[]).includes(method);
|
||||
}
|
||||
|
||||
export interface AcpxExtensionInput {
|
||||
method: string;
|
||||
questionSet: PaperclipQuestionSet;
|
||||
details?: Record<string, unknown>;
|
||||
resolve(resolution: HarnessRuntimeRequestResolution): Record<string, unknown>;
|
||||
cancel(): Record<string, unknown>;
|
||||
}
|
||||
export type AcpxExtensionRequestResult =
|
||||
| { input: AcpxExtensionInput }
|
||||
| { events: CanonicalProviderEvent[]; response: Record<string, unknown> };
|
||||
export interface AcpxProfileExtensionAdapter {
|
||||
request(method: string, params: Record<string, unknown>): Promise<AcpxExtensionRequestResult>;
|
||||
notification(method: string, params: Record<string, unknown>): Promise<CanonicalProviderEvent[]>;
|
||||
}
|
||||
export interface AcpxProfileExtensionContext {
|
||||
workspacePath: string;
|
||||
sessionId: string;
|
||||
turnId: string;
|
||||
}
|
||||
|
||||
/** Provider branches install their closed, pinned adapters here after qualification research. */
|
||||
export function createAcpxProfileExtensionAdapter(
|
||||
_agent: QualifiedAcpxAgent,
|
||||
_context: AcpxProfileExtensionContext,
|
||||
): AcpxProfileExtensionAdapter | null {
|
||||
return null;
|
||||
}
|
||||
export function acpxProfileClientCapabilities(_agent: QualifiedAcpxAgent): Record<string, unknown> {
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Reject an oversized approval document; never silently approve a truncated revision. */
|
||||
export function validateAcpxExtensionInput(input: AcpxExtensionInput): void {
|
||||
if (!isAcpxCanonicalInputMethod(input.method)) throw new Error("Unsupported ACP input method");
|
||||
parsePaperclipQuestionSet(input.questionSet);
|
||||
if (Buffer.byteLength(JSON.stringify(input.questionSet)) > 196 * 1024) {
|
||||
throw new Error("ACP input exceeds its complete-document byte limit");
|
||||
}
|
||||
}
|
||||
|
||||
/** Display-only extension channel cannot mint terminal events, tools, or approvals. */
|
||||
export function validateAcpxRichEvent(event: CanonicalProviderEvent): void {
|
||||
if (!isCanonicalProviderEventType(event.eventType) || event.eventType === "harness.diagnostic"
|
||||
|| !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(event.itemId)
|
||||
|| Buffer.byteLength(JSON.stringify(event)) > 240 * 1024) {
|
||||
throw new Error("Unsupported ACP rich activity event");
|
||||
}
|
||||
if ((event.eventType === "artifact.generated" && event.payload.registered !== false)
|
||||
|| (event.eventType === "plan.updated" && (event.payload.syncStatus !== "not_applicable"
|
||||
|| (event.payload.documentRevision !== undefined && event.payload.documentRevision !== null)))) {
|
||||
throw new Error("ACP display activity cannot claim a control-plane mutation");
|
||||
}
|
||||
const validation = validatePrpEvent({
|
||||
schema: "paperclip.prp.event.v1",
|
||||
sourceEventId: "validation:1", sourceSeq: 1, sourceInstanceId: "validation",
|
||||
sourceKind: "runner", runId: "validation", normalizedSessionId: "validation",
|
||||
turnId: "validation", itemId: event.itemId, eventType: event.eventType,
|
||||
schemaVersion: 1, priority: 1, emittedAt: "2026-09-28T00:00:00.000Z", payload: event.payload,
|
||||
});
|
||||
if (!validation.ok) throw new Error("ACP rich activity failed its canonical schema");
|
||||
}
|
||||
|
||||
export function bindAcpxExtensionTurn(input: {
|
||||
adapter: AcpxProfileExtensionAdapter | null;
|
||||
active(): boolean;
|
||||
sessionId: string;
|
||||
waitForInput(input: AcpxExtensionInput, context: { requestId: string | number; signal: AbortSignal; responseDelivery?: Promise<void> }): Promise<Record<string, unknown>>;
|
||||
emit(event: CanonicalProviderEvent): void;
|
||||
}) {
|
||||
let queue = Promise.resolve();
|
||||
let pending = 0;
|
||||
let failure: unknown;
|
||||
const enqueue = <T>(work: () => Promise<T>): Promise<T> => {
|
||||
if (++pending > 64) {
|
||||
--pending;
|
||||
failure ??= new Error("ACP extension work queue exceeded its bound");
|
||||
return Promise.reject(failure);
|
||||
}
|
||||
const job = queue.then(async () => {
|
||||
if (failure) throw failure;
|
||||
if (!input.active()) throw new Error("ACP extension belongs to a stale turn");
|
||||
return work();
|
||||
}).finally(() => { --pending; });
|
||||
queue = job.then(() => {}, error => { failure ??= error; });
|
||||
return job;
|
||||
};
|
||||
const emit = (events: CanonicalProviderEvent[]) => {
|
||||
if (!Array.isArray(events) || events.length > 256) throw new Error("ACP extension event batch exceeds its bound");
|
||||
if (!input.active()) throw new Error("ACP extension belongs to a stale turn");
|
||||
for (const event of events) {
|
||||
validateAcpxRichEvent(event);
|
||||
input.emit(event);
|
||||
}
|
||||
};
|
||||
const assertSession = (params: Record<string, unknown>) => {
|
||||
if (params.sessionId !== input.sessionId) throw new Error("ACP extension session mismatch");
|
||||
if (!input.adapter) throw new Error("ACP extension adapter is unavailable");
|
||||
};
|
||||
return {
|
||||
async onExtensionRequest(method: string, params: Record<string, unknown>, context: { requestId: string | number; signal: AbortSignal; responseDelivery?: Promise<void> }) {
|
||||
assertSession(params);
|
||||
if (context.signal.aborted) throw new Error("ACP extension was cancelled");
|
||||
const result = await enqueue(() => input.adapter!.request(method, params));
|
||||
if (context.signal.aborted || !input.active()) {
|
||||
if ("input" in result) return result.input.cancel();
|
||||
throw new Error("ACP extension was cancelled");
|
||||
}
|
||||
if ("input" in result) {
|
||||
validateAcpxExtensionInput(result.input);
|
||||
return input.waitForInput(result.input, context);
|
||||
}
|
||||
emit(result.events);
|
||||
return result.response;
|
||||
},
|
||||
onExtensionNotification(method: string, params: Record<string, unknown>) {
|
||||
void enqueue(async () => {
|
||||
assertSession(params);
|
||||
emit(await input.adapter!.notification(method, params));
|
||||
}).catch(() => {});
|
||||
},
|
||||
async drain() {
|
||||
await queue;
|
||||
if (failure) throw failure;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { QualifiedAcpxAgent, QualifiedAcpxProfile } from "./qualified-profiles.js";
|
||||
import { verifyQualifiedAcpxInstallation, type VerifiedAcpxInstallation } from "./installation-integrity.js";
|
||||
|
||||
/** Closed build-owned registry. Provider branches add their pinned installations here. */
|
||||
export async function verifyAcpxProfileInstallation(profile: QualifiedAcpxProfile): Promise<VerifiedAcpxInstallation> {
|
||||
if (profile.agent !== "claude" && profile.agent !== "codex" && profile.agent !== "grok") {
|
||||
throw new Error(`ACPX ${profile.agent} verified candidate distribution is not installed in this build`);
|
||||
}
|
||||
return verifyQualifiedAcpxInstallation(profile);
|
||||
}
|
||||
|
||||
/** Provider policy admission is repeated immediately before each process launch. */
|
||||
export async function assertAcpxProfileWorkspace(_agent: QualifiedAcpxAgent, _workspace: string): Promise<void> {}
|
||||
|
||||
/** Candidate branches validate only explicitly bound, sanitized launch credentials. */
|
||||
export function assertAcpxProfileEnvironment(_agent: QualifiedAcpxAgent, _environment: Readonly<NodeJS.ProcessEnv>): void {}
|
||||
|
||||
/** Optional provider-specific classification; never changes whether admission succeeded. */
|
||||
export function classifyAcpxProfileError(_agent: QualifiedAcpxAgent, _error: unknown): Error | null {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { resolveRunnerProviderAssetsRoot } from "./provider-assets-root.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
|
||||
async function root() { const path = await mkdtemp(join(tmpdir(), "runner-provider-assets-")); roots.push(path); return path; }
|
||||
|
||||
it("uses one fixed provider directory for source, compiled and bundled runner layouts", async () => {
|
||||
vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", undefined);
|
||||
vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", undefined);
|
||||
const path = await root();
|
||||
for (const relative of ["src/drivers/acpx/cursor-installation.ts", "dist/drivers/acpx/cursor-installation.js", "dist/cli/acpx-runtime-sidecar.cjs"]) {
|
||||
expect(resolveRunnerProviderAssetsRoot(pathToFileURL(join(path, relative)).href, "cursor")).toMatch(/provider-assets\/cursor$/);
|
||||
}
|
||||
expect(() => resolveRunnerProviderAssetsRoot(pathToFileURL(join(path, "workspace/arbitrary.ts")).href, "cursor")).toThrow("verified package layout");
|
||||
});
|
||||
|
||||
it("resolves descriptor sidecars only through the runner-bound canonical package manifest", async () => {
|
||||
const path = await root();
|
||||
await writeFile(join(path, "package.json"), JSON.stringify({ name: "@paperclipai/paperclip-runner" }));
|
||||
vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", path);
|
||||
vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", join(path, "package.json"));
|
||||
expect(resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "pi")).toMatch(/provider-assets\/pi$/);
|
||||
await writeFile(join(path, "package.json"), JSON.stringify({ name: "untrusted" }));
|
||||
expect(() => resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "pi")).toThrow("runner package");
|
||||
vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", "relative");
|
||||
expect(() => resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "pi")).toThrow("normalized absolute");
|
||||
});
|
||||
|
||||
it("rejects external manifests, links, asset escapes and incomplete authority", async () => {
|
||||
const path = await root(); const outside = await root();
|
||||
await writeFile(join(outside, "package.json"), JSON.stringify({ name: "@paperclipai/paperclip-runner" }));
|
||||
vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", path);
|
||||
vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", join(outside, "package.json"));
|
||||
expect(() => resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "copilot")).toThrow("escapes");
|
||||
await writeFile(join(path, "package.json"), JSON.stringify({ name: "@paperclipai/paperclip-runner" }));
|
||||
vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", join(path, "package.json"));
|
||||
await mkdir(join(path, "provider-assets"));
|
||||
await symlink(outside, join(path, "provider-assets/copilot"));
|
||||
expect(() => resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "copilot")).toThrow("contained");
|
||||
vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", undefined);
|
||||
expect(() => resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "copilot")).toThrow("no bound");
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { constants, closeSync, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from "node:fs";
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
type NativeProvider = "cursor" | "copilot" | "pi";
|
||||
const RUNNER_PACKAGE_NAME = "@paperclipai/paperclip-runner";
|
||||
|
||||
/** Resolve only runner-owned package assets, including descriptor-loaded sidecars. */
|
||||
export function resolveRunnerProviderAssetsRoot(moduleUrl: string, provider: NativeProvider): string {
|
||||
if (!["cursor", "copilot", "pi"].includes(provider)) throw new Error("Unknown native ACP provider assets");
|
||||
const boundRoot = process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT;
|
||||
const boundManifest = process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST;
|
||||
let packageRoot: string;
|
||||
if (boundRoot !== undefined) {
|
||||
const root = normalizedAbsolute(boundRoot);
|
||||
const manifest = normalizedAbsolute(boundManifest ?? join(root, "package.json"));
|
||||
packageRoot = realpathSync(root);
|
||||
const canonicalManifest = realpathSync(manifest);
|
||||
if (!inside(packageRoot, canonicalManifest)) throw new Error("Runner provider manifest escapes its bound package root");
|
||||
// The authority comes from runnerd's selected provider pack, never provider
|
||||
// environment. Verify the selected manifest itself before deriving assets.
|
||||
const fd = openSync(manifest, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const before = fstatSync(fd, { bigint: true });
|
||||
if (!before.isFile() || before.size < 1n || before.size > 64n * 1024n) throw new Error("Runner provider manifest is not a bounded regular file");
|
||||
const bytes = readFileSync(fd);
|
||||
const after = fstatSync(fd, { bigint: true });
|
||||
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs
|
||||
|| bytes.length !== Number(before.size) || realpathSync(manifest) !== canonicalManifest) throw new Error("Runner provider manifest changed during admission");
|
||||
const value = JSON.parse(bytes.toString("utf8")) as { name?: unknown };
|
||||
if (value?.name !== RUNNER_PACKAGE_NAME) throw new Error("Runner provider manifest does not name the runner package");
|
||||
} finally { closeSync(fd); }
|
||||
packageRoot = dirname(canonicalManifest);
|
||||
} else {
|
||||
if (boundManifest !== undefined) throw new Error("Runner provider manifest has no bound package root");
|
||||
const url = new URL(moduleUrl);
|
||||
if (url.protocol !== "file:" || url.search || url.hash) throw new Error("Provider factory is outside a verified package layout");
|
||||
if (new RegExp(`/(?:src|dist)/drivers/acpx/${provider}-installation\\.(?:ts|js)$`).test(url.pathname)) packageRoot = fileURLToPath(new URL("../../../", url));
|
||||
else if (/\/dist\/cli\/acpx-runtime-sidecar\.(?:cjs|js)$/.test(url.pathname)) packageRoot = fileURLToPath(new URL("../../", url));
|
||||
else throw new Error("Provider factory is outside a verified package layout");
|
||||
packageRoot = realpathSync(packageRoot);
|
||||
}
|
||||
const assets = join(packageRoot, "provider-assets", provider);
|
||||
// Missing materialization is diagnosed by the installation verifier. Existing
|
||||
// directory components must not redirect discovery outside this package.
|
||||
for (const path of [join(packageRoot, "provider-assets"), assets]) {
|
||||
try {
|
||||
const stat = lstatSync(path);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || !inside(packageRoot, realpathSync(path))) throw new Error("Runner provider asset directory is not contained by its package");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
function normalizedAbsolute(value: string): string {
|
||||
if (!isAbsolute(value) || value.includes("\0") || resolve(value) !== value) throw new Error("Runner provider package authority must be a normalized absolute path");
|
||||
return value;
|
||||
}
|
||||
function inside(root: string, path: string): boolean {
|
||||
const rel = relative(root, path);
|
||||
return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
||||
}
|
||||
@@ -11,7 +11,9 @@ export interface QualifiedAcpxProfile {
|
||||
readonly protocolVersion: typeof ACPX_DRIVER_PROTOCOL_VERSION;
|
||||
readonly acpxVersion: typeof QUALIFIED_ACPX_VERSION;
|
||||
readonly agent: QualifiedAcpxAgent;
|
||||
readonly agentProfileVersion: 1;
|
||||
readonly agentProfileVersion: 1 | 2 | 3 | 4 | 5;
|
||||
readonly qualificationStatus?: "pending";
|
||||
readonly modelPolicy?: "explicit-provider-verified";
|
||||
/** Wire identity: an npm package name or a runner-owned builtin: identifier. */
|
||||
readonly agentServerPackage: string;
|
||||
readonly agentServerVersion: string;
|
||||
@@ -56,6 +58,28 @@ export const QUALIFIED_ACPX_PROFILES: Readonly<
|
||||
reportedModelId: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
permissionPolicy: "interactive",
|
||||
},
|
||||
cursor: {
|
||||
driverKind: ACPX_DRIVER_KIND, protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION,
|
||||
acpxVersion: QUALIFIED_ACPX_VERSION, agent: "cursor", agentProfileVersion: 2,
|
||||
agentServerPackage: "cursor-agent", agentServerVersion: "2026.09.26-dd393fe",
|
||||
agentRuntimePackage: null, agentRuntimeVersion: null,
|
||||
commandDigest: "sha256:1157a5d071abbd57ab132f22bace75c65e84cc47a045b0023475488755e14899",
|
||||
// Authenticated discovery has not established a qualification model. Never
|
||||
// turn this empty declaration into a default; callers must select an ID.
|
||||
qualificationModel: "", reportedModelId: "", permissionPolicy: "interactive",
|
||||
modelPolicy: "explicit-provider-verified", qualificationStatus: "pending",
|
||||
},
|
||||
copilot: {
|
||||
driverKind: ACPX_DRIVER_KIND, protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION,
|
||||
acpxVersion: QUALIFIED_ACPX_VERSION, agent: "copilot", agentProfileVersion: 2,
|
||||
agentServerPackage: "@github/copilot", agentServerVersion: "1.0.88",
|
||||
agentRuntimePackage: null, agentRuntimeVersion: null,
|
||||
commandDigest: "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457",
|
||||
// Authenticated discovery has not established a qualification model. Never
|
||||
// turn this empty declaration into a default; callers must select an ID.
|
||||
qualificationModel: "", reportedModelId: "", permissionPolicy: "interactive",
|
||||
modelPolicy: "explicit-provider-verified", qualificationStatus: "pending",
|
||||
},
|
||||
claude: {
|
||||
driverKind: ACPX_DRIVER_KIND,
|
||||
protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION,
|
||||
@@ -96,7 +120,7 @@ export function resolveQualifiedAcpxProfile(
|
||||
): QualifiedAcpxProfile {
|
||||
const profile = QUALIFIED_ACPX_PROFILES[agent];
|
||||
if (!requestedModel.trim()) throw new Error("ACPX model must not be empty");
|
||||
if (agent !== "claude" && agent !== "grok" && requestedModel !== profile.qualificationModel) {
|
||||
if (agent !== "claude" && agent !== "grok" && profile.modelPolicy !== "explicit-provider-verified" && requestedModel !== profile.qualificationModel) {
|
||||
throw new Error(
|
||||
`ACPX ${agent} profile requires exact model ${profile.qualificationModel}; received ${requestedModel}`,
|
||||
);
|
||||
|
||||
@@ -76,6 +76,21 @@ describe("ACPX recovery identity", () => {
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects restoration across task execution-policy changes", async () => {
|
||||
const fixture = await recoveryFixture();
|
||||
const readonly = await createAcpxRecoveryBinding({ ...fixture.input, providerPolicy: { readOnly: true } });
|
||||
const writable = await createAcpxRecoveryBinding({ ...fixture.input, providerPolicy: { readOnly: false } });
|
||||
const record = createAcpxIdentityRecord(fixture.expected, readonly);
|
||||
expect(readonly.profileSessionKey).not.toBe(writable.profileSessionKey);
|
||||
expect(readonly.profileDigest).not.toBe(writable.profileDigest);
|
||||
expect(() => verifyExpectedAcpxIdentity(fixture.expected, writable, record)).toThrow("persisted runtime record");
|
||||
const changedRoots = await createAcpxRecoveryBinding({ ...fixture.input,
|
||||
providerPolicy: { readOnly: true, protectedPaths: ["/another-protected-root"] } });
|
||||
expect(changedRoots.profileSessionKey).not.toBe(readonly.profileSessionKey);
|
||||
await expect(createAcpxRecoveryBinding({ ...fixture.input,
|
||||
providerPolicy: { readOnly: "yes" as never } })).rejects.toThrow("valid task execution policy");
|
||||
});
|
||||
|
||||
it("uses collision-resistant roots and policy-bound provider keys", async () => {
|
||||
const fixture = await recoveryFixture();
|
||||
const otherSession = await createAcpxRecoveryBinding({
|
||||
|
||||
@@ -43,8 +43,10 @@ export async function createAcpxRecoveryBinding(input: {
|
||||
profile: QualifiedAcpxProfile;
|
||||
requestedModel: string;
|
||||
permissionMode: NativeAcpxPermissionMode;
|
||||
providerPolicy?: { readOnly: boolean; readRoots?: readonly string[]; protectedPaths?: readonly string[] };
|
||||
}): Promise<AcpxRecoveryBinding> {
|
||||
validateIdentity(input.normalizedSessionId, "normalized session");
|
||||
if (input.providerPolicy !== undefined && typeof input.providerPolicy.readOnly !== "boolean") throw new Error("ACPX recovery requires a valid task execution policy");
|
||||
if (input.requestedModel !== input.profile.qualificationModel) {
|
||||
throw new Error("ACPX recovery requested an unqualified model");
|
||||
}
|
||||
@@ -72,6 +74,11 @@ export async function createAcpxRecoveryBinding(input: {
|
||||
qualificationModel: input.profile.qualificationModel,
|
||||
reportedModelId: input.profile.reportedModelId,
|
||||
permissionPolicy: input.profile.permissionPolicy,
|
||||
...(input.providerPolicy === undefined ? {} : { executionPolicy: {
|
||||
readOnly: input.providerPolicy.readOnly,
|
||||
readRoots: input.providerPolicy.readRoots ?? [],
|
||||
protectedPaths: input.providerPolicy.protectedPaths ?? [],
|
||||
} }),
|
||||
}),
|
||||
);
|
||||
const profileSessionKey = digest(
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { deliverAcpxResponse, requireAcpxResponseDelivery } from "./response-delivery.js";
|
||||
|
||||
describe("ACP interaction response delivery", () => {
|
||||
it("does not acknowledge callback settlement before the provider pipe write", async () => {
|
||||
const written = Promise.withResolvers<void>();
|
||||
const settle = vi.fn();
|
||||
let acknowledged = false;
|
||||
const pending = deliverAcpxResponse(requireAcpxResponseDelivery({ responseDelivery: written.promise }), settle)
|
||||
.then(value => { acknowledged = true; return value; });
|
||||
expect(settle).toHaveBeenCalledOnce();
|
||||
await Promise.resolve();
|
||||
expect(acknowledged).toBe(false);
|
||||
written.resolve();
|
||||
await expect(pending).resolves.toEqual({ resolved: true });
|
||||
});
|
||||
|
||||
it.each(["cancelled", "disconnected", "write failed"])("does not report delivery when the response is %s", async reason => {
|
||||
const written = Promise.withResolvers<void>();
|
||||
const pending = deliverAcpxResponse(written.promise, () => {});
|
||||
const failed = expect(pending).rejects.toThrow(reason);
|
||||
written.reject(new Error(reason));
|
||||
await failed;
|
||||
});
|
||||
|
||||
it("requires a transport-owned receipt before presenting an interaction", () => {
|
||||
expect(() => requireAcpxResponseDelivery({})).toThrow("no bound provider response");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/** A local callback result is not evidence that the provider received its reply. */
|
||||
export function requireAcpxResponseDelivery(context: { responseDelivery?: Promise<void> }): Promise<void> {
|
||||
if (!context.responseDelivery || typeof context.responseDelivery.then !== "function") {
|
||||
throw new Error("ACP interaction has no bound provider response delivery receipt");
|
||||
}
|
||||
return context.responseDelivery;
|
||||
}
|
||||
|
||||
/** Settle the callback first: the SDK can only write after that callback returns. */
|
||||
export async function deliverAcpxResponse(
|
||||
responseDelivery: Promise<void>,
|
||||
settle: () => void,
|
||||
): Promise<{ resolved: true }> {
|
||||
settle();
|
||||
await responseDelivery;
|
||||
return { resolved: true };
|
||||
}
|
||||
@@ -783,7 +783,7 @@ describe("ACPX runtime host", () => {
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects Pi before installation or runtime launch", async () => {
|
||||
it("rejects Pi without an explicit task policy before installation or runtime launch", async () => {
|
||||
const fixture = await hostFixture();
|
||||
const verifyInstallation = vi.fn();
|
||||
const openRuntime = vi.fn();
|
||||
@@ -801,11 +801,37 @@ describe("ACPX runtime host", () => {
|
||||
reportRetainedCleanupFailure: vi.fn(),
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("descriptor-confined verified launch");
|
||||
).rejects.toThrow("explicit task execution policy");
|
||||
expect(verifyInstallation).not.toHaveBeenCalled();
|
||||
expect(openRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("binds Pi task policy independently of permissions and rejects an uninstalled candidate", async () => {
|
||||
const fixture = await hostFixture();
|
||||
const model = "openrouter/deepseek/deepseek-v4-flash-0731";
|
||||
const profile = resolveQualifiedAcpxProfile("pi", model);
|
||||
const openRuntime = vi.fn(async (options: AcpxRuntimePortOpenOptions) => {
|
||||
expect(options.launchEnvironment.PAPERCLIP_PI_READ_ONLY).toBe("1");
|
||||
expect(options.launchEnvironment.PAPERCLIP_PI_SYSTEM_INSTRUCTIONS).toBe("Bound instructions");
|
||||
expect(JSON.parse(options.launchEnvironment.PAPERCLIP_PI_READ_ROOTS!)).toEqual([]);
|
||||
expect(options.permissionMode).toBe("approve-all");
|
||||
return runtimePort({ getStatus: async () => ({ models: { currentModelId: model } }) });
|
||||
});
|
||||
const options = { ...fixture.options, agent: "pi" as const, model,
|
||||
permissionMode: "approve-all" as const, providerPolicy: { readOnly: true }, systemInstructions: "Bound instructions" };
|
||||
await expect(AcpxRuntimeHost.open(options, { openRuntime, reportRetainedCleanupFailure: vi.fn() }))
|
||||
.rejects.toThrow("verified candidate distribution is not installed");
|
||||
expect(openRuntime).not.toHaveBeenCalled();
|
||||
const host = await AcpxRuntimeHost.open(options, fixture.dependencies({
|
||||
verifyInstallation: async () => ({ commandDigest: profile.commandDigest,
|
||||
agentServerPackageJsonPath: join(fixture.root, "package.json"), agentRuntimePackageJsonPath: null,
|
||||
openCommand: async () => ({ spawn: () => { throw new Error("not used"); }, close: async () => {} }),
|
||||
}), openRuntime,
|
||||
}));
|
||||
await host.close({ reason: "policy verified" });
|
||||
expect(openRuntime).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("selects and verifies Claude's qualified reported model", async () => {
|
||||
const fixture = await hostFixture();
|
||||
let selected = false;
|
||||
@@ -1265,6 +1291,8 @@ describe("ACPX runtime host", () => {
|
||||
const turn = runtimeTurn();
|
||||
const startTurn = vi.fn(() => turn);
|
||||
const onElicitation = vi.fn();
|
||||
const onExtensionRequest = vi.fn();
|
||||
const onExtensionNotification = vi.fn();
|
||||
const runtime = runtimePort({ startTurn });
|
||||
const host = await AcpxRuntimeHost.open(
|
||||
{
|
||||
@@ -1282,12 +1310,16 @@ describe("ACPX runtime host", () => {
|
||||
text: "Complete the task.",
|
||||
requestId: "turn-1",
|
||||
onElicitation,
|
||||
onExtensionRequest,
|
||||
onExtensionNotification,
|
||||
}),
|
||||
).toBe(turn);
|
||||
expect(startTurn).toHaveBeenCalledWith({
|
||||
text: "Complete the task.",
|
||||
requestId: "turn-1",
|
||||
onElicitation,
|
||||
onExtensionRequest,
|
||||
onExtensionNotification,
|
||||
});
|
||||
expect(() =>
|
||||
host.startTurn({ text: "Concurrent", requestId: "turn-2" }),
|
||||
@@ -1304,6 +1336,34 @@ describe("ACPX runtime host", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("clones ephemeral capabilities and fences steering controls to an acknowledged active turn", async () => {
|
||||
const fixture = await hostFixture();
|
||||
const turn = runtimeTurn();
|
||||
const runtime = Object.assign(runtimePort({ startTurn: () => turn }), {
|
||||
steeringCapability: () => ({ steering: true, queuedFollowUp: true }),
|
||||
steerActiveTurn: vi.fn(async () => undefined),
|
||||
queueFollowUp: vi.fn(async () => undefined),
|
||||
});
|
||||
const clientCapabilities = { _meta: { fixture: { enabled: true } } };
|
||||
let observed: Record<string, unknown> | undefined;
|
||||
const host = await AcpxRuntimeHost.open({
|
||||
...fixture.options, agent: "codex", model: "gpt-5.6-sol", permissionMode: "approve-reads",
|
||||
environment: { PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET: "{}" }, clientCapabilities,
|
||||
}, fixture.dependencies({ openRuntime: async (options) => { observed = options.clientCapabilities; return runtime; } }));
|
||||
expect(observed).toEqual(clientCapabilities);
|
||||
expect(observed).not.toBe(clientCapabilities);
|
||||
await expect(host.steerActiveTurn("Early")).rejects.toThrow("active turn");
|
||||
host.startTurn({ text: "Start", requestId: "turn-1" });
|
||||
await expect(host.steerActiveTurn("Wrong turn", "turn-other")).rejects.toThrow("active turn");
|
||||
await host.steerActiveTurn("Steer", "turn-1");
|
||||
await host.queueFollowUp("Next", "turn-1");
|
||||
expect(runtime.steerActiveTurn).toHaveBeenCalledExactlyOnceWith("Steer", "turn-1");
|
||||
expect(runtime.queueFollowUp).toHaveBeenCalledExactlyOnceWith("Next", "turn-1");
|
||||
expect(runtime.startTurn).toHaveBeenCalledOnce();
|
||||
await host.close({ reason: "controls tested" });
|
||||
await expect(host.queueFollowUp("Too late", "turn-1")).rejects.toThrow("active turn");
|
||||
});
|
||||
|
||||
it("rejects oversized turn inputs before calling the runtime", async () => {
|
||||
const fixture = await hostFixture();
|
||||
const runtime = runtimePort();
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { dirname, join } from "node:path";
|
||||
import { assertAcpxProfileEnvironment, assertAcpxProfileWorkspace, classifyAcpxProfileError, verifyAcpxProfileInstallation } from "./profile-installation.js";
|
||||
import { createAcpxRuntimeSkillLease } from "./runtime-skill-lease.js";
|
||||
import { stageManagedGrokCredential } from "./grok-credentials.js";
|
||||
import { join } from "node:path";
|
||||
import { claudeNativeSkillPrompt } from "./native-skill-prompt.js";
|
||||
import { nativeMcpLaunchBinding } from "../native-mcp.js";
|
||||
|
||||
import type {
|
||||
AcpElicitationHandler,
|
||||
AcpRuntimeOptions,
|
||||
AcpRuntimeEvent,
|
||||
AcpRuntimeTurnResult,
|
||||
} from "acpx/runtime";
|
||||
@@ -26,7 +29,6 @@ import {
|
||||
type AcpxProviderLifetimeLease,
|
||||
} from "./codex-credentials.js";
|
||||
import {
|
||||
verifyQualifiedAcpxInstallation,
|
||||
type VerifiedAcpxCommandLease,
|
||||
type VerifiedAcpxInstallation,
|
||||
} from "./installation-integrity.js";
|
||||
@@ -51,6 +53,7 @@ import {
|
||||
import {
|
||||
prepareAcpxRuntimeSandbox,
|
||||
type AcpxRuntimeSandbox,
|
||||
type AcpxProviderRuntimePolicy,
|
||||
} from "./runtime-sandbox.js";
|
||||
import type { AcpxExpectedSessionIdentity } from "./sidecar-protocol.js";
|
||||
|
||||
@@ -83,6 +86,11 @@ export interface AcpxRuntimeGoalCapability {
|
||||
actions: Array<"set" | "pause" | "resume" | "clear">;
|
||||
}
|
||||
|
||||
export interface AcpxRuntimeSteeringCapability {
|
||||
steering: boolean;
|
||||
queuedFollowUp: boolean;
|
||||
}
|
||||
|
||||
export interface AcpxRuntimeGoalSnapshot {
|
||||
objective: string;
|
||||
status: "active" | "paused" | "blocked" | "limited" | "complete";
|
||||
@@ -100,6 +108,9 @@ export interface AcpxRuntimeTurnInput {
|
||||
requestId: string;
|
||||
signal?: AbortSignal;
|
||||
onElicitation?: AcpElicitationHandler;
|
||||
onPermissionRequest?: AcpRuntimeOptions["onPermissionRequest"];
|
||||
onExtensionRequest?: AcpRuntimeOptions["onExtensionRequest"];
|
||||
onExtensionNotification?: AcpRuntimeOptions["onExtensionNotification"];
|
||||
}
|
||||
|
||||
export interface AcpxRuntimeTurn {
|
||||
@@ -116,6 +127,9 @@ export interface AcpxRuntimePort {
|
||||
identity(): Promise<AcpxRuntimePortIdentity>;
|
||||
getStatus(): Promise<AcpxModelStatus>;
|
||||
setModel?(model: string): Promise<void>;
|
||||
steeringCapability?(): AcpxRuntimeSteeringCapability | null;
|
||||
steerActiveTurn?(text: string, requestId: string): Promise<void>;
|
||||
queueFollowUp?(text: string, requestId: string): Promise<void>;
|
||||
goalCapability?(): AcpxRuntimeGoalCapability | null;
|
||||
goalSnapshot?(): AcpxRuntimeGoalSnapshot | null;
|
||||
controlGoal?(
|
||||
@@ -127,6 +141,8 @@ export interface AcpxRuntimePort {
|
||||
}
|
||||
|
||||
export interface AcpxRuntimePortOpenOptions {
|
||||
/** Ephemeral provider extension metadata; mandatory ACP caps remain host-owned. */
|
||||
clientCapabilities?: Record<string, unknown>;
|
||||
command: VerifiedAcpxCommandLease;
|
||||
/** Replace a consumed launch snapshot after an ephemeral control session. */
|
||||
refreshConsumedCommand?: () => Promise<void>;
|
||||
@@ -166,7 +182,7 @@ export type AcpxSemanticToolSession = Omit<RunnerToolBridgeOptions, "secret">;
|
||||
|
||||
export interface AcpxRetainedCleanupFailure {
|
||||
resource:
|
||||
"credential" | "provider_lifetime" | "command" | "runtime" | "tool_bridge";
|
||||
"credential" | "provider_lifetime" | "command" | "runtime" | "tool_bridge" | "skills";
|
||||
attempt: number;
|
||||
error: unknown;
|
||||
}
|
||||
@@ -198,6 +214,10 @@ export interface AcpxRuntimeHostDependencies {
|
||||
}
|
||||
|
||||
export interface OpenAcpxRuntimeHostOptions {
|
||||
/** Explicit task execution policy; never inferred from auto-approval. Required for Pi. */
|
||||
providerPolicy?: AcpxProviderRuntimePolicy;
|
||||
/** Never persisted as session options or recovery identity. */
|
||||
clientCapabilities?: Record<string, unknown>;
|
||||
runtimeDirectory: string;
|
||||
normalizedSessionId: string;
|
||||
workingDirectory: string;
|
||||
@@ -288,10 +308,8 @@ export class AcpxRuntimeHost {
|
||||
dependencies: AcpxRuntimeHostDependencies,
|
||||
): Promise<AcpxRuntimeHost> {
|
||||
options.signal?.throwIfAborted();
|
||||
if (options.agent === "pi") {
|
||||
throw new Error(
|
||||
"ACPX pi is unavailable until its runtime has descriptor-confined verified launch",
|
||||
);
|
||||
if (options.agent === "pi" && typeof options.providerPolicy?.readOnly !== "boolean") {
|
||||
throw new Error("Pi admission requires an explicit task execution policy");
|
||||
}
|
||||
const nativeMcp = nativeMcpLaunchBinding(options.environment);
|
||||
if (nativeMcp?.name === "paperclip") {
|
||||
@@ -311,6 +329,8 @@ export class AcpxRuntimeHost {
|
||||
profile,
|
||||
requestedModel: options.model,
|
||||
permissionMode: options.permissionMode,
|
||||
...(["cursor", "copilot", "pi"].includes(options.agent) && options.providerPolicy !== undefined
|
||||
? { providerPolicy: options.providerPolicy } : {}),
|
||||
}),
|
||||
dependencies.retainAdmissionCleanup,
|
||||
);
|
||||
@@ -330,7 +350,7 @@ export class AcpxRuntimeHost {
|
||||
const installation = await runAbortableAdmissionStage(
|
||||
options.signal,
|
||||
() =>
|
||||
(dependencies.verifyInstallation ?? verifyQualifiedAcpxInstallation)(
|
||||
(dependencies.verifyInstallation ?? verifyAcpxProfileInstallation)(
|
||||
profile,
|
||||
),
|
||||
dependencies.retainAdmissionCleanup,
|
||||
@@ -389,6 +409,16 @@ export class AcpxRuntimeHost {
|
||||
agent: options.agent,
|
||||
environment: options.environment,
|
||||
tools: options.semanticTools?.tools,
|
||||
...(options.providerPolicy === undefined ? {} : { providerPolicy: {
|
||||
...options.providerPolicy,
|
||||
systemInstructions: boundedInstructions(options.systemInstructions),
|
||||
protectedPaths: [
|
||||
...(options.providerPolicy.protectedPaths ?? []),
|
||||
...(installation.agentServerPackageJsonPath === null
|
||||
? []
|
||||
: [dirname(installation.agentServerPackageJsonPath)]),
|
||||
],
|
||||
} }),
|
||||
}),
|
||||
dependencies.retainAdmissionCleanup,
|
||||
);
|
||||
@@ -426,6 +456,30 @@ export class AcpxRuntimeHost {
|
||||
dependencies.reportRetainedCleanupFailure(failure),
|
||||
});
|
||||
}
|
||||
assertAcpxProfileEnvironment(options.agent, sandbox.launchEnvironment);
|
||||
let launchEnvironment = sandbox.launchEnvironment;
|
||||
if (options.agent === "pi") {
|
||||
const skills = await acquireAbortableAdmissionResource({
|
||||
signal: options.signal,
|
||||
acquire: () => createAcpxRuntimeSkillLease(options.runtimeContext ?? null),
|
||||
resource: "skills", releaseLate: value => value.close(),
|
||||
reportFailure: dependencies.reportRetainedCleanupFailure,
|
||||
});
|
||||
const providerLifetime = credential;
|
||||
credential = {
|
||||
lifetimeFenceCandidates: providerLifetime.lifetimeFenceCandidates,
|
||||
lifetimeFenceFds: providerLifetime.lifetimeFenceFds,
|
||||
activateLifetimeOwner: pid => providerLifetime.activateLifetimeOwner(pid),
|
||||
async close() {
|
||||
// Only release assigned files after the provider lifetime quorum closes.
|
||||
await providerLifetime.close();
|
||||
await skills.close();
|
||||
},
|
||||
};
|
||||
launchEnvironment = Object.freeze({ ...sandbox.launchEnvironment,
|
||||
PAPERCLIP_PI_READ_ROOTS: JSON.stringify([...(options.providerPolicy?.readRoots ?? []), ...skills.readRoots]),
|
||||
});
|
||||
}
|
||||
if (options.agent === "claude" || options.agent === "grok") {
|
||||
// The lifetime lease proves the previous provider has stopped. Refresh
|
||||
// the assigned snapshot before every launch, including durable resume;
|
||||
@@ -467,9 +521,13 @@ export class AcpxRuntimeHost {
|
||||
}
|
||||
runtime = await acquireAbortableAdmissionResource({
|
||||
signal: options.signal,
|
||||
acquire: () => {
|
||||
acquire: async () => {
|
||||
options.assertWorkspaceHeld?.();
|
||||
await assertAcpxProfileWorkspace(options.agent, binding.workspacePath);
|
||||
return dependencies.openRuntime({
|
||||
...(options.clientCapabilities === undefined ? {} : {
|
||||
clientCapabilities: structuredClone(options.clientCapabilities),
|
||||
}),
|
||||
command: command!,
|
||||
refreshConsumedCommand: commandOwner.refreshConsumedCommand,
|
||||
profile,
|
||||
@@ -480,7 +538,7 @@ export class AcpxRuntimeHost {
|
||||
permissionPolicy: acpxRuntimePermissionPolicy(
|
||||
binding.permissionMode,
|
||||
),
|
||||
launchEnvironment: sandbox.launchEnvironment,
|
||||
launchEnvironment,
|
||||
credentialFenceFds: admittedLifetime.lifetimeFenceFds,
|
||||
activateCredentialFenceOwner:
|
||||
admittedLifetime.activateLifetimeOwner.bind(admittedLifetime),
|
||||
@@ -565,6 +623,7 @@ export class AcpxRuntimeHost {
|
||||
: [],
|
||||
});
|
||||
} catch (error) {
|
||||
error = classifyAcpxProfileError(options.agent, error) ?? error;
|
||||
const cleanup = cleanupRuntimeResources(
|
||||
runtime,
|
||||
toolBridge,
|
||||
@@ -637,6 +696,36 @@ export class AcpxRuntimeHost {
|
||||
return this.#runtime.goalCapability?.() ?? null;
|
||||
}
|
||||
|
||||
steeringCapability(): AcpxRuntimeSteeringCapability | null {
|
||||
return this.#runtime.steeringCapability?.() ?? null;
|
||||
}
|
||||
|
||||
async steerActiveTurn(text: string, turnId?: string): Promise<void> {
|
||||
const turn = this.#activeTurn;
|
||||
if (this.#closed || this.#closingStarted || !turn || (turnId !== undefined && turn.requestId !== turnId)) {
|
||||
throw new Error("ACPX steering does not own an active turn");
|
||||
}
|
||||
if (!this.steeringCapability()?.steering || !this.#runtime.steerActiveTurn) {
|
||||
throw new Error("ACPX active steering is unavailable");
|
||||
}
|
||||
await turn.promptStarted;
|
||||
if (this.#activeTurn !== turn || this.#closingStarted) throw new Error("ACPX steering turn expired");
|
||||
await this.#runtime.steerActiveTurn(boundedTurnText(text), turn.requestId);
|
||||
}
|
||||
|
||||
async queueFollowUp(text: string, turnId?: string): Promise<void> {
|
||||
const turn = this.#activeTurn;
|
||||
if (this.#closed || this.#closingStarted || !turn || (turnId !== undefined && turn.requestId !== turnId)) {
|
||||
throw new Error("ACPX follow-up does not own an active turn");
|
||||
}
|
||||
if (!this.steeringCapability()?.queuedFollowUp || !this.#runtime.queueFollowUp) {
|
||||
throw new Error("ACPX queued follow-up is unavailable");
|
||||
}
|
||||
await turn.promptStarted;
|
||||
if (this.#activeTurn !== turn || this.#closingStarted) throw new Error("ACPX follow-up turn expired");
|
||||
await this.#runtime.queueFollowUp(boundedTurnText(text), turn.requestId);
|
||||
}
|
||||
|
||||
goalSnapshot(): AcpxRuntimeGoalSnapshot | null {
|
||||
return this.#runtime.goalSnapshot?.() ?? null;
|
||||
}
|
||||
@@ -670,6 +759,9 @@ export class AcpxRuntimeHost {
|
||||
requestId,
|
||||
...(input.signal ? { signal: input.signal } : {}),
|
||||
...(input.onElicitation ? { onElicitation: input.onElicitation } : {}),
|
||||
...(input.onPermissionRequest ? { onPermissionRequest: input.onPermissionRequest } : {}),
|
||||
...(input.onExtensionRequest ? { onExtensionRequest: input.onExtensionRequest } : {}),
|
||||
...(input.onExtensionNotification ? { onExtensionNotification: input.onExtensionNotification } : {}),
|
||||
});
|
||||
this.#activeTurn = turn;
|
||||
void turn.result
|
||||
|
||||
@@ -85,6 +85,8 @@ describe("ACPX runtime sandbox", () => {
|
||||
["pi", "OPENROUTER_API_KEY", "pi-home"],
|
||||
["claude", "ANTHROPIC_API_KEY", "claude-home"],
|
||||
["codex", "OPENAI_API_KEY", "codex-home"],
|
||||
["cursor", "CURSOR_API_KEY", "cursor-home"],
|
||||
["copilot", "COPILOT_GITHUB_TOKEN", "copilot-home"],
|
||||
] as const)(
|
||||
"creates a private %s filesystem and split environment",
|
||||
async (agent, credentialName, homeSuffix) => {
|
||||
@@ -92,10 +94,14 @@ describe("ACPX runtime sandbox", () => {
|
||||
const sandbox = await prepareAcpxRuntimeSandbox({
|
||||
binding: fixture.binding,
|
||||
agent,
|
||||
providerPolicy: { readOnly: false, systemInstructions: "bound instructions" },
|
||||
environment: {
|
||||
PATH: process.env.PATH,
|
||||
[credentialName]: "provider-secret",
|
||||
UNRELATED_SECRET: "must-not-enter",
|
||||
HOME: "/ambient/home", XDG_CONFIG_HOME: "/ambient/config", CURSOR_CONFIG_DIR: "/ambient/cursor",
|
||||
COPILOT_HOME: "/ambient/copilot", COPILOT_PKG_CACHE_HOME: "/ambient/extraction-cache", COPILOT_ALLOW_ALL: "true",
|
||||
PAPERCLIP_PI_READ_ONLY: "1", PAPERCLIP_PI_READ_ROOTS: '["/"]', PAPERCLIP_PI_ENTRYPOINT: "/ambient/pi.js",
|
||||
HTTPS_PROXY: "https://proxy-user:proxy-password@example.test",
|
||||
PAPERCLIP_NATIVE_MCP_URL:
|
||||
"https://mcp.example.test/connect?ticket=secret",
|
||||
@@ -155,7 +161,32 @@ describe("ACPX runtime sandbox", () => {
|
||||
0o600,
|
||||
);
|
||||
}
|
||||
expect(sandbox.protectedPaths).toEqual([dirname(dirname(sandbox.root))]);
|
||||
expect(sandbox.launchEnvironment.PAPERCLIP_PI_ENTRYPOINT).toBeUndefined();
|
||||
expect(sandbox.launchEnvironment.COPILOT_PKG_CACHE_HOME).toBeUndefined();
|
||||
if (agent === "cursor") {
|
||||
expect(sandbox.launchEnvironment).toMatchObject({
|
||||
CURSOR_CONFIG_DIR: sandbox.agentHomeDirectory, CURSOR_DATA_DIR: sandbox.dataDirectory,
|
||||
AGENT_CLI_CREDENTIAL_STORE: "memory", NO_OPEN_BROWSER: "1", NODE_DISABLE_COMPILE_CACHE: "1",
|
||||
});
|
||||
}
|
||||
if (agent === "copilot") {
|
||||
expect(sandbox.launchEnvironment).toMatchObject({
|
||||
COPILOT_HOME: sandbox.agentHomeDirectory, COPILOT_CACHE_HOME: sandbox.cacheDirectory,
|
||||
COPILOT_AUTO_UPDATE: "false", COPILOT_ALLOW_ALL: "false", COPILOT_DISABLE_TERMINAL_TITLE: "true", NO_COLOR: "1",
|
||||
});
|
||||
expect(JSON.parse(await readFile(join(sandbox.agentHomeDirectory, "config.json"), "utf8"))).toEqual({
|
||||
autoUpdate: false, trustedFolders: [], disableAllHooks: true, memory: false,
|
||||
ide: { autoConnect: false, openDiffOnEdit: false },
|
||||
});
|
||||
}
|
||||
if (agent === "pi") {
|
||||
expect(sandbox.launchEnvironment).toMatchObject({
|
||||
PAPERCLIP_PI_READ_ONLY: "0", PAPERCLIP_PI_READ_ROOTS: "[]",
|
||||
PAPERCLIP_PI_PROTECTED_ROOTS: JSON.stringify(sandbox.protectedPaths),
|
||||
PAPERCLIP_PI_SYSTEM_INSTRUCTIONS: "bound instructions",
|
||||
});
|
||||
expect(sandbox.persistedEnvironment.PAPERCLIP_PI_SYSTEM_INSTRUCTIONS).toBeUndefined();
|
||||
await expect(
|
||||
readFile(join(sandbox.agentHomeDirectory, "settings.json"), "utf8"),
|
||||
).resolves.toContain('"defaultProjectTrust":"never"');
|
||||
@@ -163,6 +194,24 @@ describe("ACPX runtime sandbox", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("requires Pi task policy and validates assigned roots independently from approval mode", async () => {
|
||||
const fixture = await sandboxFixture("pi");
|
||||
await expect(prepareAcpxRuntimeSandbox({ binding: fixture.binding, agent: "pi" })).rejects.toThrow("explicit task execution policy");
|
||||
const readRoot = join(fixture.root, "assigned-skills");
|
||||
await mkdir(readRoot);
|
||||
const sandbox = await prepareAcpxRuntimeSandbox({
|
||||
binding: { ...fixture.binding, permissionMode: "approve-all" }, agent: "pi",
|
||||
providerPolicy: { readOnly: true, readRoots: [readRoot], protectedPaths: ["/verified/provider-pack"] },
|
||||
});
|
||||
expect(sandbox.launchEnvironment.PAPERCLIP_PI_READ_ONLY).toBe("1");
|
||||
expect(JSON.parse(sandbox.launchEnvironment.PAPERCLIP_PI_READ_ROOTS!)).toHaveLength(1);
|
||||
expect(sandbox.protectedPaths).toContain("/verified/provider-pack");
|
||||
for (const readRoots of [["/"], ["relative"], ["/missing/assigned-skills"]]) {
|
||||
await expect(prepareAcpxRuntimeSandbox({ binding: fixture.binding, agent: "pi", providerPolicy: { readOnly: false, readRoots } })).rejects.toThrow();
|
||||
}
|
||||
await expect(prepareAcpxRuntimeSandbox({ binding: fixture.binding, agent: "pi", providerPolicy: { readOnly: false, systemInstructions: "x".repeat(32 * 1024 + 1) } })).rejects.toThrow("bounded size");
|
||||
});
|
||||
|
||||
it("re-prepares and re-synchronizes an existing private sandbox", async () => {
|
||||
const fixture = await sandboxFixture("claude");
|
||||
const first = await prepareAcpxRuntimeSandbox({
|
||||
@@ -336,7 +385,7 @@ describe("ACPX runtime sandbox", () => {
|
||||
);
|
||||
});
|
||||
|
||||
async function sandboxFixture(agent: "pi" | "claude" | "codex" | "grok") {
|
||||
async function sandboxFixture(agent: "pi" | "claude" | "codex" | "grok" | "cursor" | "copilot") {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-acpx-sandbox-"));
|
||||
temporaryDirectories.push(root);
|
||||
const workspace = join(root, "workspace");
|
||||
@@ -347,6 +396,8 @@ async function sandboxFixture(agent: "pi" | "claude" | "codex" | "grok") {
|
||||
pi: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
claude: "claude-sonnet-5",
|
||||
codex: "gpt-5.6-sol",
|
||||
cursor: "provider-model-cursor",
|
||||
copilot: "provider-model-copilot",
|
||||
} as const;
|
||||
const binding = await createAcpxRecoveryBinding({
|
||||
runtimeDirectory,
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface AcpxRuntimeSandbox {
|
||||
workspaceRecordPath: string;
|
||||
launchEnvironment: Readonly<NodeJS.ProcessEnv>;
|
||||
persistedEnvironment: Readonly<NodeJS.ProcessEnv>;
|
||||
/** The host sandbox must protect these runtime/config paths from tools. */
|
||||
protectedPaths: readonly string[];
|
||||
}
|
||||
|
||||
/** A recovered workspace pinned until the provider process is admitted. */
|
||||
@@ -320,6 +322,15 @@ async function closeRecoveryHandles(
|
||||
}
|
||||
}
|
||||
|
||||
export interface AcpxProviderRuntimePolicy {
|
||||
/** Task execution policy, independent of provider permission auto-approval. */
|
||||
readOnly: boolean;
|
||||
/** Assigned, verified skill roots. Never taken from ambient provider config. */
|
||||
readRoots?: readonly string[];
|
||||
systemInstructions?: string;
|
||||
protectedPaths?: readonly string[];
|
||||
}
|
||||
|
||||
/** Prepare the private filesystem and environment visible to an ACPX agent. */
|
||||
export async function prepareAcpxRuntimeSandbox(input: {
|
||||
binding: AcpxRecoveryBinding;
|
||||
@@ -327,7 +338,12 @@ export async function prepareAcpxRuntimeSandbox(input: {
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
/** Public operations on the runner-owned Paperclip MCP bridge only. */
|
||||
tools?: readonly Readonly<Record<string, unknown>>[];
|
||||
providerPolicy?: AcpxProviderRuntimePolicy;
|
||||
}): Promise<AcpxRuntimeSandbox> {
|
||||
if (input.agent === "pi" && (input.providerPolicy === undefined || typeof input.providerPolicy.readOnly !== "boolean")) {
|
||||
throw new Error("Pi admission requires an explicit task execution policy");
|
||||
}
|
||||
const policy = await validateProviderPolicy(input.providerPolicy);
|
||||
const expectedRoot = input.binding.runtimeRoot;
|
||||
if (resolve(expectedRoot) !== expectedRoot) {
|
||||
throw new Error("ACPX runtime root must be an absolute normalized path");
|
||||
@@ -364,6 +380,7 @@ export async function prepareAcpxRuntimeSandbox(input: {
|
||||
join(root, `${input.agent}-home`),
|
||||
root,
|
||||
);
|
||||
const protectedPaths = Object.freeze([...new Set([physicalRuntimeDirectory, ...policy.protectedPaths])]);
|
||||
const workspaceRecordPath = join(root, "workspace");
|
||||
await writePrivateFile(
|
||||
workspaceRecordPath,
|
||||
@@ -415,6 +432,15 @@ export async function prepareAcpxRuntimeSandbox(input: {
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
if (input.agent === "copilot") {
|
||||
await writePrivateFile(join(agentHomeDirectory, "config.json"), `${JSON.stringify({
|
||||
autoUpdate: false,
|
||||
trustedFolders: [],
|
||||
disableAllHooks: true,
|
||||
memory: false,
|
||||
ide: { autoConnect: false, openDiffOnEdit: false },
|
||||
})}\n`);
|
||||
}
|
||||
if (input.agent === "codex") {
|
||||
await writePrivateFile(
|
||||
join(agentHomeDirectory, "config.toml"),
|
||||
@@ -455,6 +481,31 @@ export async function prepareAcpxRuntimeSandbox(input: {
|
||||
PI_CODING_AGENT_DIR: agentHomeDirectory,
|
||||
PI_SKIP_VERSION_CHECK: "1",
|
||||
PI_TELEMETRY: "0",
|
||||
PAPERCLIP_PI_READ_ONLY: policy.readOnly ? "1" : "0",
|
||||
PAPERCLIP_PI_READ_ROOTS: JSON.stringify(policy.readRoots),
|
||||
PAPERCLIP_PI_PROTECTED_ROOTS: JSON.stringify(protectedPaths),
|
||||
PAPERCLIP_PI_SYSTEM_INSTRUCTIONS: policy.systemInstructions,
|
||||
}
|
||||
: {}),
|
||||
...(input.agent === "cursor"
|
||||
? {
|
||||
CURSOR_CONFIG_DIR: agentHomeDirectory,
|
||||
CURSOR_DATA_DIR: dataDirectory,
|
||||
AGENT_CLI_CREDENTIAL_STORE: "memory",
|
||||
NO_OPEN_BROWSER: "1",
|
||||
NODE_DISABLE_COMPILE_CACHE: "1",
|
||||
}
|
||||
: {}),
|
||||
...(input.agent === "copilot"
|
||||
? {
|
||||
COPILOT_HOME: agentHomeDirectory,
|
||||
COPILOT_CACHE_HOME: cacheDirectory,
|
||||
COPILOT_AUTO_UPDATE: "false",
|
||||
COPILOT_ALLOW_ALL: "false",
|
||||
COPILOT_DISABLE_TERMINAL_TITLE: "true",
|
||||
NO_COLOR: "1",
|
||||
// COPILOT_PKG_CACHE_HOME belongs to the per-spawn immutable command
|
||||
// lease. Reusing a persistent extraction cache invalidates its proof.
|
||||
}
|
||||
: {}),
|
||||
...(input.agent === "claude"
|
||||
@@ -487,11 +538,34 @@ export async function prepareAcpxRuntimeSandbox(input: {
|
||||
cacheDirectory,
|
||||
agentHomeDirectory,
|
||||
workspaceRecordPath,
|
||||
protectedPaths,
|
||||
launchEnvironment: Object.freeze({ ...launchEnvironment }),
|
||||
persistedEnvironment: Object.freeze(persistedEnvironment),
|
||||
};
|
||||
}
|
||||
|
||||
async function validateProviderPolicy(policy: AcpxProviderRuntimePolicy | undefined): Promise<{
|
||||
readOnly: boolean; readRoots: string[]; protectedPaths: string[]; systemInstructions: string;
|
||||
}> {
|
||||
if (policy !== undefined && typeof policy.readOnly !== "boolean") throw new Error("Provider task execution policy is invalid");
|
||||
const paths = async (values: readonly string[] | undefined, requireExisting: boolean): Promise<string[]> => {
|
||||
if (values !== undefined && (!Array.isArray(values) || values.length > 128)) throw new Error("Provider runtime paths are invalid");
|
||||
return await Promise.all((values ?? []).map(async (value) => {
|
||||
if (typeof value !== "string" || !isAbsolute(value) || value === dirname(value) || /[\0\r\n]/.test(value)) throw new Error("Provider runtime path is invalid");
|
||||
const normalized = resolve(value);
|
||||
return requireExisting ? await realpath(normalized) : normalized;
|
||||
}));
|
||||
};
|
||||
const systemInstructions = policy?.systemInstructions ?? "";
|
||||
if (typeof systemInstructions !== "string" || systemInstructions.includes("\0") || Buffer.byteLength(systemInstructions) > 32 * 1024) throw new Error("Provider runtime instructions exceed their bounded size");
|
||||
return {
|
||||
readOnly: policy?.readOnly ?? true,
|
||||
readRoots: await paths(policy?.readRoots, true),
|
||||
protectedPaths: await paths(policy?.protectedPaths, false),
|
||||
systemInstructions,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensurePrivateDirectory(
|
||||
directory: string,
|
||||
physicalParent: string,
|
||||
@@ -617,6 +691,8 @@ function isPersistableEnvironmentName(name: string): boolean {
|
||||
/^(?:HOME|XDG_CONFIG_HOME|XDG_DATA_HOME|XDG_CACHE_HOME)$/.test(name) ||
|
||||
/^(?:PAPERCLIP_ACPX_PROFILE|PAPERCLIP_ACPX_ISOLATED_CONTEXT)$/.test(name) ||
|
||||
/^(?:PI_CODING_AGENT_DIR|PI_SKIP_VERSION_CHECK|PI_TELEMETRY)$/.test(name) ||
|
||||
/^(?:CURSOR_CONFIG_DIR|CURSOR_DATA_DIR|AGENT_CLI_CREDENTIAL_STORE|NO_OPEN_BROWSER|NODE_DISABLE_COMPILE_CACHE)$/.test(name) ||
|
||||
/^(?:COPILOT_HOME|COPILOT_CACHE_HOME|COPILOT_AUTO_UPDATE|COPILOT_ALLOW_ALL|COPILOT_DISABLE_TERMINAL_TITLE|NO_COLOR)$/.test(name) ||
|
||||
/^(?:CLAUDE_CONFIG_DIR|CODEX_HOME|NO_BROWSER|DEFAULT_AUTH_REQUEST)$/.test(
|
||||
name,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { mkdtemp, realpath, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { NativeRuntimeContextSnapshot } from "../../contracts/runtime-context.js";
|
||||
import { materializeNativeRuntimeSkills, releaseMaterializedNativeRuntimeSkills } from "../runtime-context-materializer.js";
|
||||
|
||||
/** Assigned assets live outside the protected credential/runtime tree. */
|
||||
export async function createAcpxRuntimeSkillLease(context: NativeRuntimeContextSnapshot | null) {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-acpx-skills-")));
|
||||
const skills = join(root, "skills");
|
||||
const close = async () => {
|
||||
await releaseMaterializedNativeRuntimeSkills(skills);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
};
|
||||
try {
|
||||
await materializeNativeRuntimeSkills(context, skills);
|
||||
return { readRoots: context?.skills.length ? [await realpath(skills)] : [], close };
|
||||
} catch (error) {
|
||||
await close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ export interface AcpxSidecarOpenParams {
|
||||
model: string;
|
||||
permissionMode: NativeAcpxPermissionMode;
|
||||
permissionModePinned: boolean;
|
||||
providerPolicy?: { readOnly: boolean };
|
||||
systemInstructions: string;
|
||||
runtimeContext: NativeRuntimeContextSnapshot | null;
|
||||
tools: readonly Readonly<Record<string, unknown>>[];
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AcpxTurnControlLedger, parseAcpxTurnControl } from "./turn-controls.js";
|
||||
|
||||
const control = { turnId: "turn-1", controlId: "control-1", mode: "follow_up", message: "Then validate it" };
|
||||
describe("ACP active turn controls", () => {
|
||||
it("preserves explicit queued follow-up and rejects unbounded or ambiguous mutations", () => {
|
||||
expect(parseAcpxTurnControl(control)).toEqual(control);
|
||||
for (const invalid of [ { mode: "cancel" }, { mode: undefined }, { message: " " }, { message: "x\0y" },
|
||||
{ message: "🙂".repeat(16_385) }, { turnId: "stale id" }, { controlId: "" }, { executable: "ambient" } ]) {
|
||||
expect(() => parseAcpxTurnControl({ ...control, ...invalid })).toThrow();
|
||||
}
|
||||
});
|
||||
it("tombstones ambiguous attempts, fences stale turns and bounds per-turn retention", () => {
|
||||
const ledger = new AcpxTurnControlLedger();
|
||||
const parsed = parseAcpxTurnControl(control);
|
||||
ledger.begin("turn-1");
|
||||
expect(() => ledger.reserve(parsed, null)).toThrow(/stale/);
|
||||
ledger.reserve(parsed, "turn-1");
|
||||
expect(() => ledger.reserve(parsed, "turn-1")).toThrow(/already attempted/);
|
||||
for (let index = 1; index < 1024; index++) ledger.reserve({ ...parsed, controlId: `control-${index + 1}` }, "turn-1");
|
||||
expect(() => ledger.reserve({ ...parsed, controlId: "overflow" }, "turn-1")).toThrow(/limit/);
|
||||
ledger.begin("turn-2");
|
||||
expect(() => ledger.reserve(parsed, "turn-2")).toThrow(/stale/);
|
||||
expect(() => ledger.reserve({ ...parsed, turnId: "turn-2" }, "turn-2")).not.toThrow();
|
||||
});
|
||||
});
|
||||
Loaded 100 of 177 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user