Integrate final rich ACP foundation for production qualification

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* codex/runner-cursor-acp:
  fix(runner): stop settlement publication after persistence failure
  feat(agents): persist agent files across tasks without revision history (#14420)
  test(runner): synchronize recovered ACP fixture cleanup
  fix(runner): persist ACP response settlement across crashes
  feat(storage): add plain directory sync with conflict preflight (#14416)
  fix: retry sandbox ACP input delivery after gateway failures (#14485)
  fix: retain project defaults in partial workspace overrides (#14502)
  test: control Telegram subscription retry timing (#14501)
  fix: retain diagnostic reasons for native runner failures (#14481)
  fix: prevent run identity locks from blocking audit checks (#14478)
  fix: preserve company context across browser hot reload (#14482)
  feat(ui): improve task composer controls and pending input (#14322)
  Improve task artifacts with rich cards and editable stories (#14469)
This commit is contained in:
DottaandPaperclip committed 2026-09-29 09:33:14 -05:00
commit 55fa510d5e
217 files changed
+16436 -1472

No files matched your search

+5
View File
@@ -56,6 +56,11 @@ paused.” and “Resume this task to send a message.” with a “Resume task
Subtrees use “Subtree is paused.” and “Resume subtree.” The takeover cannot be
dismissed, retains drafts, and hides message inputs until the pause is released.
Pending questions, confirmations, and other task-thread inputs appear in a separate
card directly above the ordinary composer. The composer stays available for new
messages while the card is open. Dismissing a card leaves a pending indicator that
can reopen it; resolving or skipping the input removes that indicator.
## Enforcement (what "compliant" means for the extraction run)
- **Zero visual change is proven, not promised:** Storybook visual snapshots are baselined before any refactor, and all snapshots match baseline after it. A change that alters rendered output must be intentional and human-approved.
+20
View File
@@ -165,6 +165,15 @@ idempotent actor synchronization operations, not arbitrary transactions. A
persistent outage still fails the request after the bounded retries; each
connection attempt remains subject to the configured database connect timeout.
## Execution identity row locks
Identity initialization, credential acquisition, and steering reconciliation lock
the task before its run. These operations use `FOR NO KEY UPDATE`: they change
identity state, not parent keys. The lock still serializes identity writers and
blocks concurrent task or run updates. It allows audit inserts to retain their
foreign-key `KEY SHARE` locks without waiting on identity acquisition. The audit
foreign keys and their deletion behavior remain enforced.
## Switching between modes
The database mode is controlled by `DATABASE_URL`:
@@ -433,6 +442,17 @@ revokes the previous boot identity with a conditional update. Its own claim also
expires so another sweep can finish cleanup after a restart. Historical rows keep
null ownership fields and follow the previous recovery path.
## Agent file persistence and legacy revisions
Managed agent files are current filesystem contents, using the same persistent
instance storage as other workspaces. `agent_instruction_revisions` and
`agent_instruction_heads` are retained as read-only upgrade input. Their heads
are adopted once into the managed directory; new saves never append revisions.
`agent_instruction_working_copies` holds per-run baseline hashes, state, and
capture receipts. New receipts identify `paperclip.agent-files.v1`; historical
rows retain the instruction-only format. Completed directory runs discard their
baseline and private copies. See [Persistent agent files](agent-files.md).
## Large API response snapshots
`assets.byte_size` uses PostgreSQL `bigint` so saved responses and byte ranges can
+54
View File
@@ -106,6 +106,16 @@ next question. Reduced-motion mode advances without animation.
Multi-select and custom answers wait for Next, and the final page waits for
Submit answers. The adjacent **Verified** story exercises the full flow.
Use **Composer → Interaction above composer** to review the production
pending-input layout. The stories cover questions, confirmations, checkbox
choices, item verdicts, suggested tasks, tool reviews, runtime questions, and phone layouts with
the bottom navigation. The normal message composer remains usable below the
pending card.
Use **Composer → Model and effort picker** to review harness-specific model
choices. Codex uses the curated adapter catalog unless the instance declares
`PAPERCLIP_ADAPTER_MODELS`; general OpenAI API models are not Codex choices.
The Storybook visual regression suite uses external PNG baselines instead of
committed screenshots:
@@ -982,6 +992,8 @@ eval "$(npx paperclipai worktree env)"
For project execution worktrees, Paperclip can also run a project-defined provision command after it creates or reuses an isolated git worktree. Configure this on the project's execution workspace policy (`workspaceStrategy.provisionCommand`). The command runs inside the derived worktree and receives `PAPERCLIP_WORKSPACE_*`, `PAPERCLIP_PROJECT_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_ISSUE_*` environment variables so each repo can bootstrap itself however it wants.
An issue's partial `workspaceStrategy` inherits omitted fields from the enabled project's strategy when both use the same type. For example, an issue can override `baseRef` without losing the project's provision, runtime provision, or teardown commands. An explicit value, including `null` or an empty string, replaces the project value. Clearing a command restores the runtime's usual default behavior; use `provisionCommand: "true"` for an explicit no-op. A different strategy type or a disabled project policy does not supply these defaults. An issue's `existingBranch` pin also excludes the project's `branchTemplate`.
An issue can pin its isolated worktree to an exact pre-existing branch instead of a template-derived one — the contract PR-preparation tasks use. Set the issue's `executionWorkspaceSettings` to `{ "mode": "isolated_workspace", "workspaceStrategy": { "type": "git_worktree", "existingBranch": "<branch>" } }`. The validator requires isolated mode plus a `git_worktree` strategy and rejects `branchTemplate` alongside `existingBranch`. At dispatch the runtime attaches (never creates, renames, fast-forwards, or resets) that branch: it reuses a registered worktree that already has the branch checked out (including legacy `.worktrees/` paths), otherwise it attaches the branch under the managed worktree parent. A missing branch, an occupied worktree path on another branch, or a non-worktree strategy fails closed with a `workspace_validation_failed` error instead of falling back to the shared checkout or a derived branch, and an inherited `reuse_existing` workspace binding on a different branch is ignored in favor of realizing the pinned branch.
Heavier setup that is only needed by a managed runtime service can use `workspaceStrategy.runtimeProvisionCommand`. Paperclip runs this command lazily before spawning the first service in a start batch, serializes concurrent provisioning for the same workspace, and records the attempt as `workspace_runtime_provision`. The command receives the same workspace environment as `provisionCommand` and should be idempotent because later service-start batches invoke it again.
@@ -1097,6 +1109,35 @@ agent workspace. The host `HOME` itself, a directory that contains it, a
filesystem root, a `CODEX_HOME` overlap, or a canonical path outside the
assigned workspace is rejected before provider startup.
### Sandbox ACP input delivery
The legacy sandbox process bridge retries recognized Daytona and Cloudflare
HTTP 502, 503, and 504 failures while writing an input message, with at most
three attempts and a short backoff.
Retries keep the message sequence and use separate temporary upload files.
The remote wrapper discards already-consumed sequences, so a lost provider
response cannot send the same input bytes twice. Messages remain ordered.
This does not restart an agent turn or replay a tool call. Authentication and
shell errors fail immediately; exhausted input delivery closes the bridge and
records a fixed diagnostic without logging the input payload. Persisting that
failure diagnostic does not block bridge teardown.
Run-log finalization closes its write handle and waits for accepted file
appends before computing the size, hash, and durable copy. Writes submitted
after finalization starts are ignored; later progress persistence is not part
of that file-write barrier. If accepted writes remain stalled after three
seconds, finalization returns unknown size/hash metadata and skips the final
durable copy so the run can reach a terminal state. A late write cannot restart
mirroring or produce a claimed verified snapshot.
Readers still attempt bounded reads when size is unknown. Legacy comment
attribution retains its existing 2 MB scan limit and allows three seconds per
log. Storage errors or timeouts preserve any evidence already read and leave
the comments available without additional derived attribution.
The read deadline requests cancellation of local file streams, S3 HEAD and GET
requests, and S3 response streams. The listing stops waiting at the deadline
even if filesystem I/O delays cancellation. Late results cannot add evidence
or start another page. Each listing retains its existing batches of eight reads;
concurrent listings do not skip healthy logs because another listing is busy.
### Preinstalled remote runner runtime
For fast sandbox startup, bake `paperclip-runnerd` and the latest stable agent
@@ -1631,3 +1672,16 @@ disconnected, visible active queries refresh every 15 seconds. This fallback
stops when the socket opens, the tab is hidden, or the provider unmounts. A
reconnected socket also refreshes visible queries to recover missed events.
Run log views retain their existing HTTP polling fallback.
### Company context during hot reload
The company React context retains only its object identity in Vite's per-module
`hot.data`. Provider values remain in the mounted React tree and keep their normal
account scope. This lets a refreshed consumer read a provider from the preceding
module version. Production builds do not use the development cache.
Run `pnpm test:e2e:browser-context` to test this with real Vite modules and
Chromium. The test starts its own loopback Vite server and mocks API responses;
it needs no running Paperclip instance or provider credentials. The same spec lives
in the default `test:e2e` discovery tree, so the existing Chrome CI shards run it
on pull requests.
+24 -1
View File
@@ -698,6 +698,11 @@ Issue-thread interactions are coordination records, not grants of authority. Eve
interaction kind defaults to resolver policy `anyone` when the create request omits
`resolverPolicy`. Restrictions are opt-in.
Question, confirmation, checkbox confirmation, and item verdict cards stay pending
when a user sends an ordinary task comment. Their `supersedeOnUserComment` flag
defaults to `false`. A creator may set it to `true` when a comment should replace
the pending request, as the opening onboarding question does.
Canonical resolver policies are:
- `anyone`: any authenticated actor in the interaction's company who can read the
@@ -1348,7 +1353,8 @@ Board can at any time:
Ask-first connection calls use a server-owned tool-action confirmation linked to
the authoritative action request. The task feed retains a stable record; dismissal
only hides the composer takeover. Task and Connections decisions share one
only hides the pending card above the composer. The ordinary composer remains
available while the card is open. Task and Connections decisions share one
transaction. Approval runs stored, signed arguments once; decline runs nothing.
The human decision remains distinct from provider execution success or failure.
@@ -1719,6 +1725,23 @@ read or change these preferences. The legacy instance general setting is retaine
for API compatibility but no longer controls shortcut behavior in the app;
users opt in individually after the upgrade.
### Persistent managed agent files (2026-09-28)
The Instructions Editor and agent execution share one current agent-owned
directory, scoped by company and agent. The configured instruction entry is one
file in this directory. Registered private copies synchronize supported files
across tasks and sessions, separately from task workspace persistence. Saves
require verified provider stop and current authorization, then synchronize changed
files using per-file last-sync-wins;
new content is not stored as revision history. Existing deployed revisions and
saved execution formats remain compatible during adoption. See
[Persistent agent files](agent-files.md).
Persistent-file storage limits are advisory for execution: a full folder cannot
pause the agent, fail its run, or prevent later runs. Show a warning on each run
while storage remains full, restore existing files so the agent can remove them,
and enforce the limits on saves. Cleanup clears the warning for future runs.
### Unsafe native workspace exports
An unsafe workspace link does not fail an accepted native task result. Retry
+12
View File
@@ -620,6 +620,18 @@ read or change these preferences. The legacy instance general setting is retaine
for API compatibility but no longer controls shortcut behavior in the app;
users opt in individually after the upgrade.
Managed agents own a persistent file directory across tasks and sessions. The
Instructions Editor and stopped agent execution synchronize the same current
files, including AGENTS.md and its supporting files. Task working directories and
provider home directories remain separate concepts. Concurrent runs synchronize only
the files they change, with the last sync winning for the same file. Temporary
copies are cleaned up; this storage does not add a revision-history system. See
[agent-files.md](agent-files.md) for lifecycle and upgrade compatibility.
Full agent storage produces a run warning without stopping current or future
work. Storage limits constrain saved file changes, not the agent's ability to run
and remove files to recover space.
### Unsafe native workspace exports
An unsafe workspace link does not fail an accepted native task result. Retry
+177
View File
@@ -0,0 +1,177 @@
# Persistent agent files
Each managed agent has one current directory, scoped by company and agent. The
Instructions Editor reads and writes this directory. `AGENTS.md` (or the
configured entry) is one file in it. Agents may create ordinary files and nested
folders for notes, memory, and other personal working material. Files in the
task working directory remain task files.
Agents can edit their own managed files under the responsible user’s current
target permissions. Access to another agent’s files additionally requires the
caller’s own target-scoped configuration permission; shared company membership
or a responsible user alone does not grant peer access.
## Layout
The canonical host directory keeps its existing physical location:
```
<instance>/companies/<company>/agents/<agent>/
instructions/ current agent files (editor)
AGENTS.md
notes/
any-supported-file
file-sync/ controller-only operational state
adopted.json
runs/<run>/live/ isolated writable copy while a run executes
```
The process starts in its existing task workspace. `AGENT_HOME` points to the
registered writable agent copy. Adapter `HOME` and `CODEX_HOME` keep their
existing meanings and are not personal-file storage. Local copies are outside
the task workspace. Remote providers currently confine file sync to their
workspace: their independent agent copy therefore lives under the excluded
`.paperclip-runtime/agent-files/<agent>/<run>/` area. It is not included in task
workspace sync, Git staging, or task deliverables.
Regular files (including binary bytes) and directories are supported, up to
100,000 entries (files and folders), 256 MiB per file and 2 GiB total. Symlinks,
hardlinks, and special
files are rejected, rather than followed or silently skipped. The instruction
entry remains valid UTF-8, at most 1 MiB, and cannot be deleted. The editor edits
text up to 1 MiB and offers downloads for binary or larger files. The reserved
`.paperclip-runtime` directory and the compatibility-only virtual file
`promptTemplate.legacy.md` are not user storage. Task cache and Git ignore
exclusions do not apply to this directory.
These storage limits are separate from the 1 MiB instruction/editor limit. Large
files are hashed and downloaded as streams; listings bound concurrent reads,
and only editor-sized text is buffered. Storage counts uncompressed file bytes,
not allocated disk
blocks. These are sync validation limits, not live filesystem quotas: an agent
can write beyond them while running. Storage limits never pause an agent, fail
a provider run, or block future task admission. A folder at or above a limit
produces a warning on each run until enough files have been removed or shrunk.
Existing saved files are restored even when already over quota, so the agent
can continue working and clean them up with ordinary filesystem tools. Unsafe
paths and links still fail validation; bypassing a storage quota does not
bypass those checks.
An API save above a storage limit returns 422 without changing the saved files.
If a stopped run exceeds a storage limit, none of its agent-folder changes are
saved. The run shows a nonblocking storage warning and its save receipt reports
`AGENT_FILES_LIMIT_EXCEEDED` with the specific limit
and, for an oversized file, its path. The previous saved folder is used on the
next run. The temporary run copy is discarded, including on a limit failure;
there is no retained recovery archive or partial-save option. Transient sync
failures get up to three attempts at the stop boundary before cleanup and an
explicit failure receipt. Individual file writes are atomic, but an I/O failure
partway through a sync can leave some files updated; a failed receipt does not
claim whole-folder success.
Sync failures are diagnostics for the affected run, not errors on the current
files in the Instructions Editor. Historical failures remain in the run log;
the run detail also shows warnings from its save receipt. The editor only shows
preserved instruction-only candidates that may need review, alongside errors
from the current browser edit. Later successful saves do not erase run history.
Larger folders take longer to hash, copy, and transfer on each run. There is one
canonical folder plus temporary working copies for currently active runs (and
remote staging when the transport needs it). No additional captured tree is
created. Terminal runs remove their private trees and baseline metadata, keeping
only a small receipt. Restart recovery retries interrupted cleanup without
removing a running provider's files. These are not aggregate disk quotas; the
operator still provisions storage for agents and the configured run concurrency.
## Run lifecycle
1. Under the agent lock, restore current files into a private run copy and save
a baseline of paths, kinds, modes, and hashes. This is sync metadata, not a
revision history.
2. Stage the copy through the existing workspace transport. Point `AGENT_HOME`
and instruction guidance at that registered root.
3. At the provider's verified checkpoint-and-stop boundary, retrieve the entire
directory into the existing working copy before releasing its environment.
4. Recheck the responsible user's current authorization. Under the same agent
lock used by editor writes, apply only files changed or deleted relative to
the starting baseline. For a competing edit or deletion of the same file,
the last synchronization to acquire the lock wins. Unchanged files do not
overwrite another run's changes; newly added unrelated files survive.
5. Record the outcome and remove temporary copies for successful and failed
runs. No per-run file versions, conflict copies, or review queue accumulate.
The next run starts with the current directory.
The whole-directory contract closes the provider process to establish a safe
collection boundary, including child processes. It preserves the provider's
resumable conversation. Only the loaded instruction entry participates in the
new runtime instruction digest; adding or editing another file does not change
that digest. Relative supporting files are read from `AGENT_HOME`, not from the
read-only prompt snapshot.
The editor supplies the hash of the file it read. A stale browser save returns
409 and retains the user's unsaved draft. Run synchronization itself uses
per-file last-sync-wins: a later run can overwrite a saved browser edit to the
same file. There is no text merge or historical copy to recover the overwritten
version. Ordinary task files continue using their existing workspace contract.
## Upgrade and recovery
Migration 0287 creates the preview tables idempotently after master’s 0285/0286.
Existing preview receipts, rows, constraints, and pending captures are retained.
On first use, while holding
the agent row lock, import any deployed revision heads into the existing managed
directory once. A controller-owned marker outside agent files prevents any
later replay of those heads. Existing revision rows remain readable for recovery;
new saves never append to them. Old UUID-based clients receive content tokens
and can still submit their previously recorded revision IDs, which are checked
against the corresponding bytes before a write.
Working-copy receipts and native runtime inputs record the new file contract.
A restored native session with no contract field keeps the old instruction-only
copy shape, prompt digest, paths, and collector. Its writes use the compatibility
bridge into current files, with the original baseline fence. Existing pending
legacy candidates remain resolvable. Neither old task workspaces nor arbitrary
external instruction roots are imported as agent directories.
Stock-agent and plugin resets update their declared files while retaining unrelated
personal files and formerly configured entries. Automatic stock upgrades first
record baseline hashes in the existing resource binding, then apply and finalize
under the agent lock. A failed file write or database commit retries against
those hashes and already-applied bytes. Removed, unchanged stock files are
removed; intervening personal edits stop the retry. This pending operation
metadata is cleared on success and does not retain file revisions.
External bundles retain their existing behavior. Their migration to managed
storage is an explicit configuration action. Historical task cwd, provider-home,
checkpoint, and workspace restoration formats are not rewritten.
Backups must include the persistent instance filesystem as well as the database.
New current-file bytes are not database revision rows. Old instruction-only
candidates are retained solely for upgrade compatibility.
Crash recovery can collect a stopped working copy without starting a model.
Missing stop proof or lost remote bytes produce a visible diagnostic, never a
save receipt. An interrupted apply can replay its changed files with the same
last-sync-wins rule. Cleanup resumes for terminal runs; no copy is retained as
an archive after cleanup succeeds.
## Verification
`agent-directory-working-copies.test.ts` exercises nested/binary files, directory
isolation, last-sync-wins edits and deletions, terminal cleanup, link rejection, old-head
adoption, and stable prompt digests. The legacy working-copy and native-tool
suites exercise compatibility. Workspace merge tests exercise preflight and
interrupted replay.
The explicit Product E2E `instruction-persistence` suite creates a file through
the browser editor, runs an agent that changes instructions and supporting files,
checks exact binary bytes via the public download route, restarts the server,
and asks a fresh task to prove restored contents using an independent nonce. A
third task edits its entry while the browser saves that same file; the later
run sync wins while a separate browser-created file survives, with no conflict
candidate or manual resolution. Three more tasks save a sparse file at its
256 MiB boundary, exceed that boundary with a nonfatal save rejection, then
remove it and save a new small file. All tasks must succeed, with warnings
visible in run details while full and cleared after cleanup.
Run results, including unavailable credentials, must be reported separately from
unit or matcher results; a passing matcher does not prove a live run.
+15
View File
@@ -0,0 +1,15 @@
# Legacy instruction revision compatibility
The revision-based design in the first version of PR #14325 has been superseded
by [persistent agent files](agent-files.md). Current managed files are authoritative;
new edits do not create revision or head rows.
Published migrations 0285 and 0286 and existing revision records are retained so
preview installations and saved sessions can upgrade safely. Old heads are
adopted once. Legacy collectors and UUID-based APIs compare their baseline bytes
against current files before writing. Read-only history and restore endpoints
remain for pre-upgrade records; they are not advertised as new agent capabilities
or shown as a history feature in the current file editor.
See the current document for directory layout, synchronization, conflicts,
authorization, recovery, and verification.
@@ -1,9 +1,9 @@
# Rich ACP integration and qualification report
Updated: 2026-09-28. Base: `c65fc9e3c81c41aafe421aa90a00514b84343285`.
Mainline integration: `18e8c121d99fee1d4038610531ab57ae3f824fdd`.
Status: implementation is available as four draft PRs. The shared foundation's
local checks pass, and provider review and qualification remain in progress.
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
@@ -34,8 +34,9 @@ adapters are outside this change.
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. No GitHub
PR is merged automatically. Source reports on the provider branches are
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
@@ -93,7 +94,7 @@ deterministic evidence are mapped separately after the comparison.
| 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 | `src/control-plane/durable-prp-control-plane.test.ts`, `runner/crates/runner-core/tests/acpx_provider_resolutions.rs`, `src/live/runnerd-codex-transport.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
@@ -110,6 +111,16 @@ mismatch is rejected. Retired streams and inactive turns cannot emit new activit
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
@@ -119,7 +130,9 @@ 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. The rich event channel has exact canonical
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
@@ -183,6 +196,7 @@ qualification gates, not claims that a JavaScript path check confines a shell.
| 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. |
+2 -2
View File
@@ -110,8 +110,8 @@ Run from the worktree:
pnpm --filter @paperclipai/ui exec storybook dev -p 6016 -c storybook/.storybook --no-open
```
Open `http://localhost:6016/?path=/story/tasks-execution-controls--running-empty`.
The `Tasks / Execution Controls` stories compose the production composer and
Open `http://localhost:6016/?path=/story/composer-execution-controls--running-empty`.
The `Composer / Execution controls` stories compose the production composer and
menu/dialog controls together. They cover text switching, attachment-only,
idle, stopping, paused, errors, cancellation preview/loading, and mobile/light
presentations. The Storybook state transitions simulate requests; runner
+5
View File
@@ -276,6 +276,11 @@ results and follow-up coverage are recorded in that suite's guide.
Continuation accounting has an explicit-only eight-cell Product E2E [baseline suite](../tests/runner-e2e/CONTINUATION-ACCOUNTING.md), complementing the deterministic lifecycle inventory.
The explicit Product E2E `instruction-persistence` suite verifies private file
edits, nested and binary agent files, stopped-provider directory saves, server restart, and a fresh task's
downloaded proof on local native/legacy Codex and native Daytona. See the
[Product E2E runbook](../tests/runner-e2e/README.md).
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).
+21 -2
View File
@@ -418,13 +418,21 @@ Application and route error-boundary reports also include:
URLs, arguments, and unrecognized lines are omitted. Parsing examines at
most 16 KiB of input. Production builds preserve function names so this
trace remains useful after minification; this adds some bundle size.
All browser error reports, including global promise rejections, include:
- `browser_build_mode`: `development` or `production`, from the loaded bundle.
- `browser_rejection_kind`: the primitive type of an unhandled rejected value
(or `null`). The diagnostic does not read object properties or copy the value.
- `browser_state`: document readiness, visibility, and a boolean indicating
the `translated-ltr` or `translated-rtl` root class used by browser translation.
The marker is evidence of DOM translation, not proof of the error's cause;
its absence does not exclude other translators or DOM-changing extensions.
These fields are captured at the failure, before asynchronous reporting, and
attached only to that event. They include no component props, DOM text, HTML,
Boundary state is captured at the failure, before asynchronous reporting.
Global reports without that snapshot read document state before sending.
All fields are attached only to that event. They include no component props,
DOM text, HTML,
element identifiers, arbitrary CSS classes, route, or query string. Failed
diagnostic reads do not prevent the original exception from being reported.
The monitoring gate and sign-out behavior still apply. This context does not
@@ -478,6 +486,17 @@ These fields contain build identifiers; they add no tenant or user identity.
`errorCode`, and `agentAdapter`. The server redacts the error message and
the error code before it sends the event.
Native runner identity and harness failures retain their existing error prefixes.
Their terminal messages now include a bounded guard reason, such as
`session_scope_mismatch`, `durable_identity_unreadable`, or
`backup_without_reusable_lease`. Provider-pack read failures distinguish a missing
file, invalid JSON, permission denial, invalid path type, and other I/O errors.
These reasons contain no session identifiers, provider output, or filesystem
paths. They help diagnose recurrence; they do not authorize a retry, quarantine,
replacement, or a weaker identity check. Existing chat recovery recognizes the
same failure category with or without a reason suffix; it still requires the
exact cleanup receipt, checkpoint, and absence of provider work.
**Server events the default integrations add**
- `OnUncaughtException` — each uncaught exception on the main thread, at
+1
View File
@@ -71,6 +71,7 @@
"test:e2e:runner:models:update": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/openrouter-models-update.ts",
"test:e2e:runner:history:publish": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/history-publish.ts",
"test:runner-recovery": "vitest run server/src/services/native-runtime/native-replacement-evidence.test.ts server/src/services/native-runtime/stopped-codex-turn.test.ts server/src/services/native-runtime/native-safe-replacement.test.ts",
"test:e2e:browser-context": "playwright test --config tests/e2e/playwright-company-context.config.ts",
"test:e2e:runner:browser-support": "playwright test --config tests/runner-e2e/playwright-support.config.ts",
"test:e2e:runner:unit": "vitest run --config tests/runner-e2e/vitest.config.ts",
"test:e2e:runner:typecheck": "tsc -p tests/runner-e2e/tsconfig.json",
@@ -1601,6 +1601,55 @@ describe("shared ACPX engine runtime behavior", () => {
expect(path.resolve(path.dirname(managedAuth), await fs.readlink(managedAuth))).toBe(sourceAuth);
});
it.each(["OPENAI_API_KEY", "CODEX_API_KEY"] as const)(
"uses isolated API-key auth instead of the host ChatGPT login for %s",
async (keyName) => {
const root = await makeTempRoot();
const sourceCodexHome = path.join(root, "source-codex-home");
const paperclipHome = path.join(root, "paperclip-home");
await fs.mkdir(sourceCodexHome, { recursive: true });
const sourceAuth = path.join(sourceCodexHome, "auth.json");
await fs.writeFile(sourceAuth, JSON.stringify({ tokens: "host-login" }), "utf8");
const managedHome = path.join(
paperclipHome, "instances", "test-instance", "companies", "company-1",
"acp-engine", "agents", "agent-1", "codex-home",
);
await fs.mkdir(managedHome, { recursive: true });
const managedAuth = path.join(managedHome, "auth.json");
if (process.platform === "win32") {
await fs.writeFile(managedAuth, JSON.stringify({ tokens: "stale-login" }), "utf8");
} else {
await fs.symlink(sourceAuth, managedAuth);
}
vi.stubEnv("CODEX_HOME", sourceCodexHome);
vi.stubEnv("PAPERCLIP_HOME", paperclipHome);
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "test-instance");
vi.stubEnv("OPENAI_API_KEY", "");
vi.stubEnv("CODEX_API_KEY", "");
try {
const { sessionInputs } = await runExecutor({
agent: "codex",
stateDir: path.join(root, "state"),
env: { [keyName]: "sk-acp-test-key" },
paperclipRuntimeSkills: [],
paperclipSkillSync: { desiredSkills: [] },
});
const sessionEnv = (sessionInputs[0]!.sessionOptions as { env: Record<string, string> }).env;
expect(sessionEnv.CODEX_HOME).toBe(managedHome);
expect(sessionEnv.DEFAULT_AUTH_REQUEST).toBe(JSON.stringify({ methodId: "api-key" }));
expect((await fs.lstat(managedAuth)).isSymbolicLink()).toBe(false);
expect(JSON.parse(await fs.readFile(managedAuth, "utf8"))).toEqual({ OPENAI_API_KEY: "sk-acp-test-key" });
expect(await fs.readFile(sourceAuth, "utf8")).toBe(JSON.stringify({ tokens: "host-login" }));
if (process.platform !== "win32") {
expect((await fs.stat(managedAuth)).mode & 0o777).toBe(0o600);
}
} finally {
vi.unstubAllEnvs();
}
},
);
it("sets GROK_HOME for a Grok run from the company Grok home, and leaves CODEX_HOME unchanged for a Codex run", async () => {
const root = await makeTempRoot();
const paperclipHome = path.join(root, "paperclip-home");
@@ -808,6 +808,10 @@ function resolveManagedCodexHomeDir(companyId: string): string {
return path.join(defaultPaperclipInstanceDir(), "companies", companyId, "codex-home");
}
function resolveManagedCodexApiKeyHomeDir(companyId: string, agentId: string): string {
return path.join(defaultStateDir(companyId, agentId), "codex-home");
}
// Mirrors `resolveManagedGrokHomeDir` in
// `packages/adapters/grok-local/src/server/grok-home.ts` — this package
// cannot import that adapter package (it would invert the dependency
@@ -1002,15 +1006,30 @@ async function prepareManagedCodexHome(input: {
companyId: string;
sourceHome: string;
targetHome: string;
apiKey?: string;
onLog: AdapterExecutionContext["onLog"];
}): Promise<string> {
const { sourceHome, targetHome, onLog } = input;
const { sourceHome, targetHome, apiKey, onLog } = input;
if (path.resolve(sourceHome) === path.resolve(targetHome)) return targetHome;
await fs.mkdir(targetHome, { recursive: true });
const authJson = path.join(sourceHome, "auth.json");
if (await pathExists(authJson)) await ensureSymlink(path.join(targetHome, "auth.json"), authJson);
const targetAuth = path.join(targetHome, "auth.json");
if (apiKey) {
// Codex reads auth.json ahead of the process environment. Never leave a
// shared ChatGPT-login symlink in an API-key agent's managed home, and never
// write through that symlink into the operator's own Codex credentials.
// Atomic replacement also keeps concurrent turns from seeing a missing or
// partially written credential file.
await writeFileAtomically({
target: targetAuth,
contents: JSON.stringify({ OPENAI_API_KEY: apiKey }),
mode: 0o600,
});
} else {
const sourceAuth = path.join(sourceHome, "auth.json");
if (await pathExists(sourceAuth)) await ensureSymlink(targetAuth, sourceAuth);
}
for (const name of ["config.json", "config.toml", "instructions.md"]) {
const source = path.join(sourceHome, name);
@@ -1255,6 +1274,7 @@ async function reconcileManagedCodexSkills(input: {
async function prepareCodexSkillRuntime(input: {
companyId: string;
agentId: string;
config: Record<string, unknown>;
env: Record<string, string>;
moduleDir: string;
@@ -1282,12 +1302,19 @@ async function prepareCodexSkillRuntime(input: {
typeof process.env.CODEX_HOME === "string" && process.env.CODEX_HOME.trim().length > 0
? path.resolve(process.env.CODEX_HOME.trim())
: path.join(os.homedir(), ".codex");
const managedCodexHome = resolveManagedCodexHomeDir(input.companyId);
const apiKey = input.env.OPENAI_API_KEY?.trim() || input.env.CODEX_API_KEY?.trim()
|| process.env.OPENAI_API_KEY?.trim() || process.env.CODEX_API_KEY?.trim();
// Keep API-key auth separate from the company home used by subscription
// agents, so one agent cannot switch another agent's login on its next turn.
const managedCodexHome = apiKey
? resolveManagedCodexApiKeyHomeDir(input.companyId, input.agentId)
: resolveManagedCodexHomeDir(input.companyId);
const effectiveCodexHome = configuredCodexHome ??
await prepareManagedCodexHome({
companyId: input.companyId,
sourceHome: sourceCodexHome,
targetHome: managedCodexHome,
apiKey,
onLog: input.onLog,
});
const { allSkills, selectedSkills, desiredSkillNames } = await resolveSelectedRuntimeSkills(input.config, input.moduleDir);
@@ -2031,7 +2058,11 @@ async function buildRuntime(input: {
paperclipClaudeSettings = await writePaperclipClaudeSettings({
cwd,
stateDir,
agentHome,
// A run's writable AGENT_HOME moves, but the existing permission root
// must not invalidate its resumable conversation. Run copies are covered
// by the company root already granted in these settings; the process env
// still receives this run's actual AGENT_HOME.
agentHome: asString(workspaceContext.agentHomeForPermissions, agentHome),
companyId: agent.companyId,
});
skillCommandNotes.push(
@@ -2046,6 +2077,7 @@ async function buildRuntime(input: {
const preparedSkills = await measureStartupStep(input.ctx, nowMs, "codex-home.seed", () =>
prepareCodexSkillRuntime({
companyId: agent.companyId,
agentId: agent.id,
config,
env,
moduleDir: input.engine.moduleDir,
@@ -544,6 +544,8 @@ export async function prepareCommandManagedRuntime(input: {
workspaceBaseline?: DirectorySnapshot;
workspaceGitSnapshot?: GitWorkspaceSnapshot | null;
workspaceExclude?: string[];
/** Plain persistent directories include all files, independent of Git and task cache exclusions. */
workspaceFileMode?: "all";
preserveAbsentOnRestore?: string[];
assets?: CommandManagedRuntimeAsset[];
/** Referenced (additional) projects to stage into the sandbox as plain, read-only trees. */
@@ -609,6 +611,7 @@ export async function prepareCommandManagedRuntime(input: {
workspaceBaseline: input.workspaceBaseline,
workspaceGitSnapshot: input.workspaceGitSnapshot,
workspaceExclude: mergeRuntimeExcludes(input.workspaceExclude),
workspaceFileMode: input.workspaceFileMode,
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
assets: input.assets,
additionalSources: input.additionalSources,
@@ -652,6 +655,7 @@ export async function prepareCommandManagedRuntime(input: {
workspaceBaseline: input.workspaceBaseline,
workspaceGitSnapshot: input.workspaceGitSnapshot,
workspaceExclude: mergeRuntimeExcludes(input.workspaceExclude),
workspaceFileMode: input.workspaceFileMode,
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
assets: input.assets,
additionalSources: input.additionalSources,
@@ -609,7 +609,7 @@ describe("sandbox adapter execution targets", () => {
target: {
kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir,
runner: { execute: async (input) => {
if (input.args?.[1]?.includes("command.b64.paperclip-upload.b64") && input.args[1].includes(">>")) {
if (/command\.b64\.[^/]+\.paperclip-upload\.b64/.test(input.args?.[1] ?? "") && input.args![1].includes(">>")) {
throw new Error("Upload interrupted");
}
return delegate.execute(input);
@@ -122,10 +122,10 @@ describe("stdin file race (parent PAP-4037)", () => {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitFor(check: () => boolean, timeoutMs = 4_000): Promise<void> {
async function waitFor(check: () => boolean | Promise<boolean>, timeoutMs = 4_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (check()) return;
if (await check()) return;
await delay(20);
}
throw new Error("Timed out waiting for condition.");
@@ -492,8 +492,247 @@ describe("stdin file race (parent PAP-4037)", () => {
}
});
it.each([
...["prepare", "append", "finalize", "late-finalize"].map((stage) =>
[stage, "Request failed with status code 502"] as const),
...[502, 503, 504].map((status) =>
["finalize", `Cloudflare sandbox bridge request failed with HTTP ${status}.`] as const),
])(
"recovers a transient %s failure (%s) without repeating or reordering stdin",
async (stage, failure) => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-stdin-retry-"));
cleanupDirs.push(rootDir);
const childPath = path.join(rootDir, "echo-child.mjs");
await writeFile(childPath, "process.stdin.on('data', (c) => process.stdout.write(c));\n", "utf8");
const first = "first-" + "x".repeat(70_000);
let delivered = "";
let injected = false;
let lateFinalize: (() => Promise<RunProcessResult>) | undefined;
const local = createLocalSandboxRunner();
const runner = {
execute: async (input: Parameters<typeof local.execute>[0]) => {
const script = input.args?.[1] ?? "";
const matches = script.includes("/stdin/000000000001.json") && (
stage === "prepare" ? script.includes("mkdir -p") :
stage === "append" ? script.startsWith("printf") : script.startsWith("base64 -d")
);
if (matches && !injected) {
injected = true;
if (stage === "late-finalize") lateFinalize = () => local.execute(input);
else if (stage !== "prepare") await local.execute(input);
// The provider can lose the response after the receiver consumed
// the file. A retry must not repeat those bytes on the ACP stream.
if (stage === "finalize") await waitFor(() => delivered === first, 8_000);
throw new Error(failure);
}
return local.execute(input);
},
};
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "run-stdin-retry",
target: { kind: "remote", transport: "sandbox", remoteCwd: rootDir, runner },
runtimeRootDir: path.join(rootDir, "runtime"),
adapterKey: "acpx", command: process.execPath, args: [childPath], cwd: rootDir, env: {},
});
let peer: net.Socket | undefined;
try {
const source = await readFile(bridge!.agentCommand, "utf8");
const port = Number(/port: (\d+)/.exec(source)![1]);
const token = JSON.parse(/const token = (".*?");/.exec(source)![1]) as string;
peer = net.createConnection({ host: "127.0.0.1", port });
peer.on("error", () => {});
peer.setEncoding("utf8");
let buffer = "";
peer.on("data", (chunk) => {
buffer += chunk;
const lines = buffer.split("\n");
buffer = lines.pop()!;
for (const line of lines) {
const frame = JSON.parse(line) as DeliveredFrame;
delivered += collectDelivered([frame]);
}
});
await new Promise<void>((resolve) => peer!.once("connect", resolve));
for (const text of [first, "-second"])
peer.write(JSON.stringify({ token, type: "stdin", data: Buffer.from(text).toString("base64") }) + "\n");
await waitFor(() => delivered.endsWith("-second"), 10_000);
expect(injected).toBe(true);
expect(delivered).toBe(first + "-second");
if (lateFinalize) {
// A provider can return 502 while its original finalize still runs.
// Cleanup may invalidate its private upload, but it cannot touch
// the retry's data or repeat input after newer messages arrived.
await lateFinalize();
peer.write(JSON.stringify({ token, type: "stdin", data: Buffer.from("-third").toString("base64") }) + "\n");
await waitFor(() => delivered.endsWith("-third"), 8_000);
expect(delivered).toBe(first + "-second-third");
}
await waitFor(async () => {
const files = await readdir(path.join(rootDir, "runtime", "process-sessions"), { recursive: true });
return files.every((file) => !file.endsWith(".paperclip-upload.b64") && !file.endsWith(".paperclip-upload.decoded"));
});
} finally {
peer?.destroy();
await bridge?.stop();
}
},
20_000,
);
it.each([
["Request failed with status code 502", 3],
["Request failed with status code 503", 3],
["Request failed with status code 504", 3],
["Cloudflare sandbox bridge request failed with HTTP 502.", 3],
["Cloudflare sandbox bridge request failed with HTTP 503.", 3],
["Cloudflare sandbox bridge request failed with HTTP 504.", 3],
["Request failed with status code 403", 1],
["Cloudflare sandbox bridge request failed with HTTP 403.", 1],
["Remote command failed: Request failed with status code 502", 1],
["Cloudflare sandbox bridge request failed with HTTP 502. sensitive-input", 1],
["Remote command failed: sensitive-input", 1],
] as const)("bounds input failure %s to %i attempts and stops later writes", async (failure, expectedAttempts) => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-stdin-failed-"));
cleanupDirs.push(rootDir);
let attempts = 0;
let laterWrite = false;
let stderr = "";
const runner = createLocalSandboxRunner(async (script) => {
if (!script.startsWith("mkdir -p")) return;
if (script.includes("/stdin/000000000002.json")) laterWrite = true;
if (script.includes("/stdin/000000000001.json")) {
attempts += 1;
throw new Error(failure);
}
});
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "run-stdin-failed",
target: { kind: "remote", transport: "sandbox", remoteCwd: rootDir, runner },
runtimeRootDir: path.join(rootDir, "runtime"),
adapterKey: "acpx", command: "cat", args: [], cwd: rootDir, env: {},
onLog: async (stream, chunk) => { if (stream === "stderr") stderr += chunk; },
});
let peer: net.Socket | undefined;
try {
const source = await readFile(bridge!.agentCommand, "utf8");
const port = Number(/port: (\d+)/.exec(source)![1]);
const token = JSON.parse(/const token = (".*?");/.exec(source)![1]) as string;
peer = net.createConnection({ host: "127.0.0.1", port });
peer.setEncoding("utf8");
peer.on("error", () => {});
let output = "";
peer.on("data", (chunk) => { output += chunk; });
const closed = new Promise<void>((resolve) => peer!.on("close", () => resolve()));
await new Promise<void>((resolve) => peer!.once("connect", resolve));
for (const text of ["first", "second"])
peer.write(JSON.stringify({ token, type: "stdin", data: Buffer.from(text).toString("base64") }) + "\n");
await closed;
expect(attempts).toBe(expectedAttempts);
expect(laterWrite).toBe(false);
expect(JSON.parse(output)).toEqual({ type: "error", message: "ACP process session input delivery failed." });
expect(stderr).toContain("ACP process session input delivery failed.");
expect(stderr).not.toContain(failure);
} finally {
peer?.destroy();
await bridge?.stop();
}
}, 15_000);
it("stops after exhausted input retries even when failure logging stalls", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-stdin-log-stall-"));
cleanupDirs.push(rootDir);
let attempts = 0;
let loggingStarted = false;
let releaseLog!: () => void;
const stalledLog = new Promise<void>((resolve) => { releaseLog = resolve; });
const runner = createLocalSandboxRunner(async (script) => {
if (script.startsWith("mkdir -p") && script.includes("/stdin/000000000001.json")) {
attempts += 1;
throw new Error("Request failed with status code 502");
}
});
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "run-stdin-log-stall",
target: { kind: "remote", transport: "sandbox", remoteCwd: rootDir, runner },
runtimeRootDir: path.join(rootDir, "runtime"),
adapterKey: "acpx", command: "cat", args: [], cwd: rootDir, env: {},
onLog: async (stream) => {
if (stream === "stderr") {
loggingStarted = true;
await stalledLog;
}
},
});
let peer: net.Socket | undefined;
let stop: Promise<void> | undefined;
try {
const source = await readFile(bridge!.agentCommand, "utf8");
const port = Number(/port: (\d+)/.exec(source)![1]);
const token = JSON.parse(/const token = (".*?");/.exec(source)![1]) as string;
peer = net.createConnection({ host: "127.0.0.1", port });
peer.setEncoding("utf8");
peer.on("error", () => {});
let output = "";
peer.on("data", (chunk) => { output += chunk; });
const closed = new Promise<void>((resolve) => peer!.once("close", resolve));
await new Promise<void>((resolve) => peer!.once("connect", resolve));
peer.write(JSON.stringify({ token, type: "stdin", data: Buffer.from("input").toString("base64") }) + "\n");
await closed;
expect(attempts).toBe(3);
expect(loggingStarted).toBe(true);
expect(JSON.parse(output)).toEqual({ type: "error", message: "ACP process session input delivery failed." });
let stopped = false;
stop = bridge!.stop().then(() => { stopped = true; });
// Teardown has a three-second acknowledgement budget. It must finish
// while the run-log promise remains unresolved, including local cleanup.
await waitFor(() => stopped, 6_000);
await expect(lstat(bridge!.agentCommand)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
releaseLog();
peer?.destroy();
await (stop ?? bridge?.stop());
}
}, 15_000);
// ---- Host atomic-write tests ------------------------------------------
it.each(["fails", "stalls"])("preserves the upload failure when best-effort cleanup %s", async (cleanupMode) => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-upload-cleanup-"));
cleanupDirs.push(rootDir);
const local = createLocalSandboxRunner();
const uploadFailure = new Error("Request failed with status code 502");
let cleanupAttempted = false;
let rejectCleanup!: (error: Error) => void;
const stalledCleanup = new Promise<never>((_resolve, reject) => { rejectCleanup = reject; });
// Observe the test-owned promise even in the immediate-failure case.
void stalledCleanup.catch(() => {});
const client = createCommandManagedSandboxCallbackBridgeQueueClient({
remoteCwd: rootDir,
runner: {
execute: async (input) => {
const script = input.args?.[1] ?? "";
if (script.startsWith("rm -f")) {
cleanupAttempted = true;
if (cleanupMode === "stalls") return stalledCleanup;
throw new Error("Request failed with status code 403");
}
const result = await local.execute(input);
if (script.startsWith("printf")) throw uploadFailure;
return result;
},
},
});
try {
await expect(Promise.race([
client.writeTextFile(path.join(rootDir, "message.json"), "test input"),
delay(1_000).then(() => { throw new Error("Upload waited for stalled cleanup"); }),
])).rejects.toBe(uploadFailure);
expect(cleanupAttempted).toBe(true);
} finally {
rejectCleanup(new Error("Cleanup unavailable"));
}
});
// A runner that executes each bridge shell script on the local filesystem,
// so the test exercises the real command-managed `writeTextFile` script.
function createLocalShellRunner(scripts: string[]) {
@@ -572,7 +811,7 @@ describe("stdin file race (parent PAP-4037)", () => {
expect(finalizeScript).toBeDefined();
expect(finalizeScript).toContain(`mv `);
expect(finalizeScript).not.toContain(`> '${jsonPath}'`);
expect(finalizeScript).toContain(`> '${jsonPath}.paperclip-upload.decoded'`);
expect(finalizeScript).toMatch(/> '[^']+\.paperclip-upload\.decoded'/);
});
it("never exposes a partial .json file under a concurrent reader (command-managed host write)", async () => {
+60 -17
View File
@@ -1437,6 +1437,8 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
workspaceBaseline?: DirectorySnapshot;
workspaceGitSnapshot?: GitWorkspaceSnapshot | null;
workspaceExclude?: string[];
/** Plain persistent directories include all files, independent of Git and task cache exclusions. */
workspaceFileMode?: "all";
preserveAbsentOnRestore?: string[];
assets?: AdapterManagedRuntimeAsset[];
/** Referenced (additional) projects to stage into the sandbox as plain, read-only trees. */
@@ -1478,6 +1480,8 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
workspaceLocalDir: input.workspaceLocalDir,
workspaceRemoteDir: input.workspaceRemoteDir,
syncWorkspace: input.syncWorkspace,
workspaceFileMode: input.workspaceFileMode,
workspaceExclude: input.workspaceExclude,
assets: input.assets,
additionalSources: input.additionalSources,
onProgress: input.onProgress,
@@ -1517,6 +1521,7 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
workspaceBaseline: input.workspaceBaseline,
workspaceGitSnapshot: input.workspaceGitSnapshot,
workspaceExclude: input.workspaceExclude,
workspaceFileMode: input.workspaceFileMode,
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
assets: input.assets,
additionalSources: input.additionalSources,
@@ -1985,6 +1990,11 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
const target = input.target;
const onLog = input.onLog ?? (async () => {});
// Failure diagnostics are best effort: stalled or failed run-log persistence
// must not prevent sending shutdown or removing the bridge's session files.
const logFailureWithoutWaiting = (message: string) => {
void Promise.resolve().then(() => onLog("stderr", message)).catch(() => undefined);
};
const runner = requireSandboxRunner(target);
// Run one unit of run-time work under its named wrapper span when a span
// runner is injected. Without a runner, run the work under the current run
@@ -2135,6 +2145,28 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
// a big earlier chunk, so the wrapper reads the stdin bytes out of order and
// corrupts a large prompt on the stdin path.
let stdinWriteChain: Promise<void> = Promise.resolve();
let stdinDeliveryFailed = false;
const writeStdinFile = async (filePath: string, body: string) => {
// Retry the same sequence, never the ACP request or the tool itself. Each
// upload uses private temporary paths, and the wrapper drops sequences it
// already consumed when a provider loses the final rename's response.
for (let attempt = 1; ; attempt += 1) {
try {
await client.writeTextFile(filePath, body);
return;
} catch (error) {
// Plugin RPC preserves provider messages but not HTTP error classes.
// Match the Daytona SDK and Cloudflare bridge's gateway diagnostics
// exactly; shell failures and auth errors must still fail immediately.
const gatewayFailure = error instanceof Error && (
/^Request failed with status code (502|503|504)$/.test(error.message) ||
/^Cloudflare sandbox bridge request failed with HTTP (502|503|504)\.$/.test(error.message)
);
if (!gatewayFailure || attempt >= 3) throw error;
await new Promise((resolve) => setTimeout(resolve, attempt * 250));
}
}
};
let pollTimer: NodeJS.Timeout | null = null;
const pendingRemoteEvents: Array<{
type?: string;
@@ -2266,19 +2298,24 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
// Chain this write after the previous one, so the atomic rename for
// file N finishes before the write for file N+1 starts. Keep the
// per-message `sandbox.agentSession.sendInput` span inside the chain.
const write = stdinWriteChain.then(() =>
runRuntimeWork(AGENT_SESSION_SEND_INPUT_SPAN, () =>
client.writeTextFile(filePath, jsonLine(stdinPayload)),
),
);
// The next message chains after this write on success or failure, so a
// failed write never blocks the chain. This mirrors the wrapper
// `writeChain` pattern for its event files.
stdinWriteChain = write.then(() => undefined, () => undefined);
// Keep the failure behavior: send one error line, then destroy the socket.
write.catch((error) => {
nextSocket.write(jsonLine({ type: "error", message: error instanceof Error ? error.message : String(error) }));
nextSocket.destroy();
stdinWriteChain = stdinWriteChain.then(async () => {
if (stdinDeliveryFailed) return;
try {
await runRuntimeWork(AGENT_SESSION_SEND_INPUT_SPAN, () =>
writeStdinFile(filePath, jsonLine(stdinPayload)),
);
} catch {
stdinDeliveryFailed = true;
stopping = true;
const message = "ACP process session input delivery failed.";
// Flush the diagnostic before closing; destroy() can discard it
// and leave only ACP's generic connection_close error. Do not
// expose provider error text, which may contain a command payload.
nextSocket.end(jsonLine({ type: "error", message }));
// stop() awaits this input chain before sending shutdown. Run-log
// persistence must not hold teardown open when it stalls or fails.
logFailureWithoutWaiting(`[paperclip] ${message}\n`);
}
});
}
}
@@ -2549,10 +2586,9 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
]);
stopReadingForShutdownAck = true;
if (!acknowledgedInTime) {
await onLog(
"stderr",
logFailureWithoutWaiting(
`[paperclip] ACP process session wrapper did not acknowledge shutdown within ${DEFAULT_PROCESS_SESSION_SHUTDOWN_WAIT_MS}ms; removing the session directory anyway.\n`,
).catch(() => undefined);
);
}
// Unconditional: this removal runs whether or not the wrapper
// acknowledged, and whether or not any event (real or forged) arrived
@@ -2983,6 +3019,14 @@ async function pollStdin() {
for (const name of entries) {
if (shuttingDown) break;
const entrySeq = Number.parseInt(name, 10);
const file = path.posix.join(stdinDir, name);
// A successful publication can be retried after its provider response
// was lost, even after we consumed it. Never send those bytes twice or
// move the expected sequence backwards. This also handles late uploads.
if (Number.isFinite(entrySeq) && entrySeq < stdinExpectedSeq) {
await fs.rm(file, { force: true }).catch(() => undefined);
continue;
}
// Hold the send order when an earlier file has not appeared. Do not consume
// this later file: wait for the missing file on a later cycle, bounded by
// the retry budget. After the budget, fail loud and advance past the gap,
@@ -3001,7 +3045,6 @@ async function pollStdin() {
stdinGapRetries = 0;
stdinExpectedSeq = entrySeq;
}
const file = path.posix.join(stdinDir, name);
let message;
try {
// Hardening (I3): open with O_NOFOLLOW where the platform defines it,
@@ -32,6 +32,33 @@ import { setExpensiveWorkspaceGitExecutor } from "./git-workspace-sync.js";
describe("remote managed runtime", () => {
const cleanupDirs: string[] = [];
it("stages all files over SSH without Git or cache exclusions and restores the same baseline", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-plain-"));
cleanupDirs.push(root);
await mkdir(path.join(root, "node_modules"));
await writeFile(path.join(root, ".gitignore"), "node_modules/\n");
await writeFile(path.join(root, "node_modules", "personal.bin"), Buffer.from([0, 255, 1]));
const prepared = await prepareRemoteManagedRuntime({
spec: { host: "127.0.0.1", port: 2222, username: "fixture", remoteWorkspacePath: "/app", remoteCwd: "/app",
privateKey: "PRIVATE KEY", knownHosts: "KNOWN HOSTS", strictHostKeyChecking: true },
runId: "plain", adapterKey: "test", workspaceLocalDir: root,
workspaceFileMode: "all", workspaceExclude: ["explicitly-excluded"],
});
expect(prepareWorkspaceForSshExecution).toHaveBeenCalledWith(expect.objectContaining({
localDir: root, remoteDir: prepared.workspaceRemoteDir, workspaceFileMode: "all", workspaceExclude: ["explicitly-excluded"],
}));
await prepared.restoreWorkspace();
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledWith(expect.objectContaining({
restoreGitHistory: false, baselineSnapshot: expect.objectContaining({
exclude: [".paperclip-runtime", "explicitly-excluded"],
entries: expect.any(Map),
}),
}));
const args = vi.mocked(restoreWorkspaceFromSshExecution).mock.calls[0] as unknown as [{ baselineSnapshot: { entries: Map<string, unknown> } }];
expect(args[0].baselineSnapshot.entries.has("node_modules/personal.bin")).toBe(true);
});
afterEach(async () => {
vi.clearAllMocks();
while (cleanupDirs.length > 0) {
@@ -113,6 +113,8 @@ export async function prepareRemoteManagedRuntime(input: {
workspaceLocalDir: string;
workspaceRemoteDir?: string;
syncWorkspace?: boolean;
workspaceFileMode?: "all";
workspaceExclude?: string[];
assets?: RemoteManagedRuntimeAsset[];
/** Referenced (additional) projects to stage as plain, read-only trees. */
additionalSources?: SandboxAdditionalSource[];
@@ -139,13 +141,15 @@ export async function prepareRemoteManagedRuntime(input: {
localDir: input.workspaceLocalDir,
remoteDir: workspaceRemoteDir,
onProgress: input.onProgress,
workspaceFileMode: input.workspaceFileMode,
workspaceExclude: input.workspaceExclude,
})
: null;
const baselineSnapshot = preparedWorkspace
? await captureDirectorySnapshot(input.workspaceLocalDir, {
exclude: preparedWorkspace.gitBacked
? [...GIT_ARCHIVE_EXCLUDES, ".paperclip-runtime"]
: [".paperclip-runtime"],
: [".paperclip-runtime", ...(input.workspaceFileMode === "all" ? input.workspaceExclude ?? [] : [])],
})
: null;
@@ -701,23 +701,40 @@ export function createCommandManagedSandboxCallbackBridgeQueueClient(input: {
// then moves the complete decoded content onto the final `.json` path.
// A direct `> remotePath` redirect truncates the final path before the
// decode writes it, so a reader can see an empty or partial file.
const tempPath = `${remotePath}.paperclip-upload.b64`;
const decodedPath = `${remotePath}.paperclip-upload.decoded`;
await runChecked(
`prepare upload ${remotePath}`,
`mkdir -p ${shellQuote(remoteDir)} && rm -f ${shellQuote(tempPath)} ${shellQuote(decodedPath)} && : > ${shellQuote(tempPath)}`,
);
const base64Body = toBuffer(Buffer.from(body, "utf8")).toString("base64");
for (const chunk of base64Chunks(base64Body)) {
// A failed provider response does not prove the remote command stopped.
// Keep concurrent or retried uploads from truncating each other's bytes.
const uploadPath = `${remotePath}.${randomUUID()}.paperclip-upload`;
const tempPath = `${uploadPath}.b64`;
const decodedPath = `${uploadPath}.decoded`;
try {
await runChecked(
`append upload chunk ${remotePath}`,
`printf '%s' ${shellQuote(chunk)} >> ${shellQuote(tempPath)}`,
`prepare upload ${remotePath}`,
`mkdir -p ${shellQuote(remoteDir)} && rm -f ${shellQuote(tempPath)} ${shellQuote(decodedPath)} && : > ${shellQuote(tempPath)}`,
);
const base64Body = toBuffer(Buffer.from(body, "utf8")).toString("base64");
for (const chunk of base64Chunks(base64Body)) {
await runChecked(
`append upload chunk ${remotePath}`,
`printf '%s' ${shellQuote(chunk)} >> ${shellQuote(tempPath)}`,
);
}
await runChecked(
`finalize upload ${remotePath}`,
`base64 -d < ${shellQuote(tempPath)} > ${shellQuote(decodedPath)} && mv ${shellQuote(decodedPath)} ${shellQuote(remotePath)} && rm -f ${shellQuote(tempPath)}`,
);
} catch (error) {
// Abandon only this attempt's intermediates, never the published file
// or another attempt. A late finalize may fail or finish publishing;
// either is safe for a sequence-aware caller. Preserve the original
// failure even when the provider is still unavailable for cleanup.
// Cleanup must not put another provider timeout on the retry/shutdown
// path. Its unique paths stay safe to remove after this call returns.
void runChecked(
`clean failed upload ${remotePath}`,
`rm -f ${shellQuote(tempPath)} ${shellQuote(decodedPath)}`,
).catch(() => undefined);
throw error;
}
await runChecked(
`finalize upload ${remotePath}`,
`base64 -d < ${shellQuote(tempPath)} > ${shellQuote(decodedPath)} && mv ${shellQuote(decodedPath)} ${shellQuote(remotePath)} && rm -f ${shellQuote(tempPath)}`,
);
},
writeResponseFile: async (responsePath, body, options = {}) => {
const responseDir = path.posix.dirname(responsePath);
@@ -1102,6 +1102,8 @@ export async function prepareSandboxManagedRuntime(input: {
workspaceBaseline?: DirectorySnapshot;
workspaceGitSnapshot?: GitWorkspaceSnapshot | null;
workspaceExclude?: string[];
/** Plain persistent directories include all files, independent of Git and task cache exclusions. */
workspaceFileMode?: "all";
preserveAbsentOnRestore?: string[];
assets?: SandboxManagedRuntimeAsset[];
/**
@@ -1179,7 +1181,7 @@ export async function prepareSandboxManagedRuntime(input: {
// The git enumeration (`git status --ignored`, the HEAD diffs, `ls-files`).
// It reads git's own bookkeeping to decide what to include/exclude, so it is
// usually fast, but on a large working tree the `--ignored` walk is not free.
const gitSnapshot = syncWorkspace
const gitSnapshot = syncWorkspace && input.workspaceFileMode !== "all"
? input.workspaceGitSnapshot !== undefined
? input.workspaceGitSnapshot
: await runStepSpan("snapshot.git", () =>
@@ -1195,7 +1197,7 @@ export async function prepareSandboxManagedRuntime(input: {
// A selected subfolder has no cloneable Git snapshot, but its parent
// repository's ignore rules still govern which files may leave the host.
// Use the same bounded, path-relative resolver as referenced project trees.
const directoryIgnore = syncWorkspace && !gitSnapshot
const directoryIgnore = syncWorkspace && !gitSnapshot && input.workspaceFileMode !== "all"
? await resolveReferencedSourceIgnore(input.workspaceLocalDir)
: null;
if (directoryIgnore?.kind === "failed") {
@@ -1203,14 +1205,14 @@ export async function prepareSandboxManagedRuntime(input: {
}
const gitIgnoredExcludes = directoryIgnore?.kind === "git" ? directoryIgnore.ignoredPaths : undefined;
const workspaceArchiveExclude = mergeExcludes(
SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES,
[...GIT_ARCHIVE_EXCLUDES],
input.workspaceFileMode === "all" ? [] : SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES,
input.workspaceFileMode === "all" ? [] : [...GIT_ARCHIVE_EXCLUDES],
input.workspaceExclude,
gitIgnoredExcludes,
);
const restoreExclude = mergeExcludes(
SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES,
[...GIT_ARCHIVE_EXCLUDES],
input.workspaceFileMode === "all" ? [] : SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES,
input.workspaceFileMode === "all" ? [] : [...GIT_ARCHIVE_EXCLUDES],
[".paperclip-runtime"],
input.preserveAbsentOnRestore,
input.workspaceExclude,
@@ -712,6 +712,28 @@ describe("ssh env-lab fixture", () => {
expect(result.stdout).toContain("{\"token\":\"secret\"}");
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("clears stale files when plain SSH preparation is retried", async () => {
const rootDir = await createFixtureRootDir();
const localDir = path.join(rootDir, "plain-local");
await mkdir(path.join(localDir, "node_modules"), { recursive: true });
await git(localDir, ["init"]);
await writeFile(path.join(localDir, ".gitignore"), "node_modules/\n");
await writeFile(path.join(localDir, "removed.txt"), "remove on retry");
const binary = Buffer.from([0, 255, 1]);
await writeFile(path.join(localDir, "node_modules", "personal.bin"), binary);
const started = await startSshEnvLabFixtureOrSkip(path.join(rootDir, "state.json"), "SSH plain retry");
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const input = { spec: { ...config, remoteCwd: started.workspaceDir }, localDir,
remoteDir: started.workspaceDir, workspaceFileMode: "all" as const };
expect(await prepareWorkspaceForSshExecution(input)).toEqual({ gitBacked: false });
expect(await readFile(path.join(started.workspaceDir, "node_modules", "personal.bin"))).toEqual(binary);
await rm(path.join(localDir, "removed.txt"));
await prepareWorkspaceForSshExecution(input);
await expect(stat(path.join(started.workspaceDir, "removed.txt"))).rejects.toMatchObject({ code: "ENOENT" });
expect(await readFile(path.join(started.workspaceDir, "node_modules", "personal.bin"))).toEqual(binary);
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("round-trips a git workspace through the SSH fixture", async () => {
const rootDir = await createFixtureRootDir();
const statePath = path.join(rootDir, "state.json");
+4 -2
View File
@@ -1558,9 +1558,11 @@ export async function prepareWorkspaceForSshExecution(input: {
localDir: string;
remoteDir?: string;
onProgress?: RuntimeProgressSink;
workspaceFileMode?: "all";
workspaceExclude?: string[];
}): Promise<{ gitBacked: boolean }> {
const remoteDir = input.remoteDir ?? input.spec.remoteCwd;
const gitSnapshot = await readLocalGitWorkspaceSnapshot(input.localDir);
const gitSnapshot = input.workspaceFileMode === "all" ? null : await readLocalGitWorkspaceSnapshot(input.localDir);
if (gitSnapshot) {
await importGitWorkspaceToSsh({
@@ -1595,7 +1597,7 @@ export async function prepareWorkspaceForSshExecution(input: {
spec: input.spec,
localDir: input.localDir,
remoteDir,
exclude: [".paperclip-runtime"],
exclude: [".paperclip-runtime", ...(input.workspaceFileMode === "all" ? input.workspaceExclude ?? [] : [])],
onProgress: input.onProgress,
progressLabel: "workspace",
});
@@ -605,3 +605,56 @@ describe("workspace restore merge", () => {
});
});
});
describe("conflict-preserving directory restore", () => {
it("preflights competing edits before applying any other change and deduplicates replay", async () => {
const root = await fsPromises.realpath(await mkdtemp(path.join(os.tmpdir(), "directory-cas-")));
const source = path.join(root, "source"), target = path.join(root, "target");
try {
await mkdir(target);
await writeFile(path.join(target, "conflict"), "baseline");
const baseline = await captureDirectorySnapshot(target);
await fsPromises.cp(target, source, { recursive: true });
await writeFile(path.join(source, "conflict"), "incoming");
await writeFile(path.join(source, "independent"), "also incoming");
await writeFile(path.join(target, "conflict"), "board");
await expect(mergeDirectoryWithBaseline({ baseline, sourceDir: source, targetDir: target, conflictPolicy: "reject" })).rejects.toMatchObject({ code: "DIRECTORY_MERGE_CONFLICT", paths: ["conflict"] });
await expect(stat(path.join(target, "independent"))).rejects.toMatchObject({ code: "ENOENT" });
await writeFile(path.join(target, "conflict"), "baseline");
await expect(mergeDirectoryWithBaseline({ baseline, sourceDir: source, targetDir: target, conflictPolicy: "reject", afterApply: async () => { throw new Error("receipt interrupted"); } })).rejects.toThrow("receipt interrupted");
await mergeDirectoryWithBaseline({ baseline, sourceDir: source, targetDir: target, conflictPolicy: "reject" });
expect(await readFile(path.join(target, "independent"), "utf8")).toBe("also incoming");
} finally { await rm(root, { recursive: true, force: true }); }
});
it("preserves a newly added child when another run removes or replaces its parent", async () => {
const root = await fsPromises.realpath(await mkdtemp(path.join(os.tmpdir(), "directory-delete-cas-")));
const source = path.join(root, "source"), target = path.join(root, "target");
try {
await mkdir(path.join(target, "folder"), { recursive: true });
const baseline = await captureDirectorySnapshot(target);
await mkdir(source);
await writeFile(path.join(target, "folder", "new"), "concurrent");
await expect(mergeDirectoryWithBaseline({ baseline, sourceDir: source, targetDir: target, conflictPolicy: "reject" })).rejects.toMatchObject({ paths: ["folder/new"] });
expect(await readFile(path.join(target, "folder", "new"), "utf8")).toBe("concurrent");
} finally { await rm(root, { recursive: true, force: true }); }
});
});
it("strict preflight preserves excluded descendants when a directory becomes a file", async () => {
const root = await fsPromises.realpath(await mkdtemp(path.join(os.tmpdir(), "directory-excluded-cas-")));
const target = path.join(root, "target"), source = path.join(root, "source");
await mkdir(path.join(target, "folder", "node_modules"), { recursive: true });
await writeFile(path.join(target, "folder", "node_modules", "keep"), "excluded contents");
const baseline = await captureDirectorySnapshot(target, { exclude: ["*/node_modules"], diskBacked: true });
try {
await mkdir(source);
await writeFile(path.join(source, "folder"), "replacement");
await writeFile(path.join(source, "independent"), "must not partially apply");
await expect(mergeDirectoryWithBaseline({ baseline, sourceDir: source, targetDir: target, conflictPolicy: "reject" }))
.rejects.toMatchObject({ code: "DIRECTORY_MERGE_CONFLICT", paths: expect.arrayContaining(["folder/node_modules/keep"]) });
expect(await readFile(path.join(target, "folder", "node_modules", "keep"), "utf8")).toBe("excluded contents");
await expect(stat(path.join(target, "independent"))).rejects.toMatchObject({ code: "ENOENT" });
} finally { await disposeDirectorySnapshot(baseline); await rm(root, { recursive: true, force: true }); }
});
@@ -1,4 +1,4 @@
import { createHash } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { constants as fsConstants, promises as fs } from "node:fs";
import path from "node:path";
@@ -442,16 +442,26 @@ async function copySnapshotEntry(sourceDir: string, targetDir: string, relative:
}
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.rm(targetPath, { recursive: true, force: true }).catch(() => undefined);
if (entry.kind === "symlink") {
await fs.rm(targetPath, { recursive: true, force: true });
await fs.symlink(entry.target, targetPath);
return;
}
// An interrupted restore must not leave a truncated current file. Keep the
// incoming tree until its owner records success; exact retries deduplicate.
const temporary = path.join(path.dirname(targetPath), `.paperclip-merge-${randomUUID()}`);
try {
await fs.copyFile(sourcePath, temporary, fsConstants.COPYFILE_FICLONE).catch(async () => {
await fs.copyFile(sourcePath, temporary);
});
await fs.chmod(temporary, entry.mode);
const file = await fs.open(temporary, "r");
try { await file.sync(); } finally { await file.close(); }
const existing = await fs.lstat(targetPath).catch(() => null);
if (existing?.isDirectory()) await fs.rm(targetPath, { recursive: true, force: true });
await fs.rename(temporary, targetPath);
} finally { await fs.rm(temporary, { force: true }); }
await fs.copyFile(sourcePath, targetPath, fsConstants.COPYFILE_FICLONE).catch(async () => {
await fs.copyFile(sourcePath, targetPath);
});
await fs.chmod(targetPath, entry.mode);
}
export async function captureDirectorySnapshot(
@@ -511,10 +521,54 @@ function orderedEntries(snapshot: DirectorySnapshot, reverse = false): Iterable<
return [...snapshot.entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0) * (reverse ? -1 : 1));
}
export class DirectoryMergeConflict extends Error {
readonly code = "DIRECTORY_MERGE_CONFLICT";
constructor(readonly paths: string[]) {
super("Directory contents changed concurrently");
}
}
/** Preflight the entire delta before writing. Identical replays are safe after
* an interrupted apply; unrelated edits are left alone. No history is retained. */
export function directoryMergeConflicts(baseline: DirectorySnapshot, source: DirectorySnapshot, current: DirectorySnapshot): string[] {
const same = (a: SnapshotEntry | undefined, b: SnapshotEntry | undefined) =>
(!a && !b) || entriesMatch(a, b);
const conflicts = new Set<string>();
function* changedPaths() {
for (const [name] of baseline.entries) yield name;
for (const [name] of source.entries) if (!baseline.entries.has(name)) yield name;
}
for (const relative of changedPaths()) {
const before = baseline.entries.get(relative);
const incoming = source.entries.get(relative);
const present = current.entries.get(relative);
if (same(before, incoming) || same(incoming, present)) continue;
if (!same(before, present)) conflicts.add(relative);
// A parent removed/replaced by another writer must never be traversed.
for (let parent = path.posix.dirname(relative); parent !== "."; parent = path.posix.dirname(parent)) {
if (current.entries.get(parent)?.kind !== "dir" &&
!same(current.entries.get(parent), baseline.entries.get(parent))) conflicts.add(parent);
}
}
// Stream each current entry once. A replacement must not remove children
// omitted from the baseline, including excluded or newly created files.
for (const [child, entry] of current.entries) {
if (same(entry, baseline.entries.get(child)) || same(entry, source.entries.get(child))) continue;
for (let parent = path.posix.dirname(child); parent !== "."; parent = path.posix.dirname(parent)) {
if (baseline.entries.get(parent)?.kind === "dir" && source.entries.get(parent)?.kind !== "dir") {
conflicts.add(child);
break;
}
}
}
return [...conflicts].sort();
}
export async function mergeDirectoryWithBaseline(input: {
baseline: DirectorySnapshot;
sourceDir: string;
targetDir: string;
conflictPolicy?: "reject";
beforeApply?: () => Promise<void>;
afterApply?: () => Promise<void>;
}): Promise<void> {
@@ -523,8 +577,15 @@ export async function mergeDirectoryWithBaseline(input: {
try {
await withDirectoryMergeLock(input.targetDir, async (canonicalTargetDir) => {
await input.beforeApply?.();
const current = await captureDirectorySnapshot(canonicalTargetDir, options);
// Strict preflight must see excluded children before a directory is
// replaced. The merge still applies only the filtered source/baseline.
const current = await captureDirectorySnapshot(canonicalTargetDir,
input.conflictPolicy === "reject" ? { exclude: [], diskBacked: true } : options);
try {
if (input.conflictPolicy === "reject") {
const conflicts = directoryMergeConflicts(input.baseline, source, current);
if (conflicts.length) throw new DirectoryMergeConflict(conflicts);
}
for (const [relative, baselineEntry] of orderedEntries(input.baseline)) {
if (baselineEntry.kind === "dir" || source.entries.has(relative)) continue;
if (!entriesMatch(current.entries.get(relative), baselineEntry)) continue;
@@ -537,7 +598,8 @@ export async function mergeDirectoryWithBaseline(input: {
});
}
for (const [relative, entry] of orderedEntries(source)) {
if (!entriesMatch(input.baseline.entries.get(relative), entry)) await copySnapshotEntry(input.sourceDir, canonicalTargetDir, relative, entry);
if (!entriesMatch(input.baseline.entries.get(relative), entry) &&
!(input.conflictPolicy === "reject" && entriesMatch(current.entries.get(relative), entry))) await copySnapshotEntry(input.sourceDir, canonicalTargetDir, relative, entry);
}
await input.afterApply?.();
} finally { await disposeDirectorySnapshot(current); }
@@ -114,6 +114,7 @@ export const models = [
{ id: "gpt-6-luna", label: "gpt-6-luna" },
{ id: "gpt-5.6-terra", label: "gpt-5.6-terra" },
{ id: "gpt-5.6-luna", label: "gpt-5.6-luna" },
{ id: "gpt-5.5", label: "gpt-5.5" },
{ id: "gpt-5.4", label: "gpt-5.4" },
{ id: "gpt-5.4-mini", label: "gpt-5.4-mini" },
{ id: "gpt-5", label: "gpt-5" },
@@ -1042,6 +1042,54 @@ describe("codex_local ACP lane", () => {
expect(mode).toBe(0o600);
});
it("does not copy an API-key run's sandbox auth into the shared subscription home", async () => {
const root = await makeTempRoot("paperclip-codex-acp-key-copyback-");
const localCwd = path.join(root, "worktree");
const remoteCwd = path.join(root, "remote-workspace");
const keyHome = path.join(root, "api-key-home");
const sharedHostHome = path.join(root, "shared-codex-home");
await Promise.all([localCwd, remoteCwd, keyHome, sharedHostHome].map((dir) => fs.mkdir(dir, { recursive: true })));
const sharedAuth = subscriptionAuthJson("acct-same", OLDER_REFRESH, "host-older");
await fs.writeFile(path.join(sharedHostHome, "auth.json"), sharedAuth, { mode: 0o600 });
// A subscription-shaped sandbox credential must never be considered for
// the shared home when this run explicitly authenticates with an API key.
await fs.writeFile(
path.join(keyHome, "auth.json"),
subscriptionAuthJson("acct-same", NEWER_REFRESH, "sandbox-newer"),
{ mode: 0o600 },
);
process.env.CODEX_HOME = sharedHostHome;
const execute = createCodexAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => new FakeRuntime(options) as never,
});
const result = await execute(buildContext(localCwd, {
config: {
engine: "acp",
cwd: localCwd,
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
env: { CODEX_HOME: keyHome, OPENAI_API_KEY: "sk-test-key" },
promptTemplate: "Do the assigned work.",
},
context: {
issueId: "issue-1",
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd,
runner: createLocalSandboxRunner(),
} as never,
authToken: "real-run-jwt",
}));
expect(result.exitCode).toBe(0);
expect(await fs.readFile(path.join(sharedHostHome, "auth.json"), "utf8")).toBe(sharedAuth);
});
it("keeps the shared host Codex auth when the sandbox copy is not strictly newer", async () => {
const root = await makeTempRoot("paperclip-codex-acp-copyback-older-");
const localCwd = path.join(root, "worktree");
+21 -15
View File
@@ -166,17 +166,16 @@ export function buildCodexAcpConfig(config: Record<string, unknown>): Record<str
* Codex remote managed-home seed + auth copy-back for the runner-backed remote
* sandbox ACP lane. Mirrors the codex CLI lane (`codex-local/execute.ts`): stage
* the managed `CODEX_HOME` (auth.json + config.toml + skills) into the sandbox
* as the `home` asset — carrying the inbound auth-merge `provision` and the
* outbound `restore` copy-back seams — then repoint `CODEX_HOME` onto the
* in-sandbox `assetDirs.home` path. The copy-back rides the asset `restore`,
* which fires inside `restoreWorkspace()` at teardown.
* as the `home` asset, then repoint `CODEX_HOME` onto the in-sandbox
* `assetDirs.home` path. Subscription runs also use inbound auth merge and
* outbound copy-back through `restoreWorkspace()` at teardown.
*
* The engine already resolved+seeded the host managed Codex home and set
* `env.CODEX_HOME` to it (a HOST path) before this seam runs, so `env.CODEX_HOME`
* is exactly the home to stage. Seed inbound and copy-back outbound land together
* (never seed-without-copy-back): Codex refresh tokens are single-use, so a
* refreshed sandbox token that is never copied back would spend the host's token
* and corrupt the host credential.
* is exactly the home to stage. Subscription auth merge and copy-back stay
* paired: Codex refresh tokens are single-use, so a refreshed sandbox token
* that is never copied back would spend the host's token. API-key runs skip
* both auth seams and never touch the shared subscription credential.
*/
async function prepareCodexRemoteManagedHome(
input: AcpxRemoteManagedHomeContext,
@@ -189,6 +188,12 @@ async function prepareCodexRemoteManagedHome(
// workspace with no home asset, identical to the no-seam fallback.
return { stagedRuntime: await input.stage([]) };
}
// API-key runs do not rotate a subscription refresh token. Keep their
// sandbox auth out of the shared subscription copy-back path altogether.
const apiKeyAuth = Boolean(
env.OPENAI_API_KEY?.trim() || env.CODEX_API_KEY?.trim()
|| process.env.OPENAI_API_KEY?.trim() || process.env.CODEX_API_KEY?.trim(),
);
// Curated allowlist temp dir (auth/config/skills only); caller owns cleanup.
const stagedCodexHomeDir = await stageCodexHomeForSync(effectiveCodexHome, { runId });
let stagedRuntime;
@@ -198,14 +203,13 @@ async function prepareCodexRemoteManagedHome(
key: "home",
localDir: stagedCodexHomeDir,
followSymlinks: true,
// Inbound (host→sandbox) auth-merge: keeps whichever credential is newer
// when the sandbox image already carries a Codex auth.json.
provision: buildCodexAuthInboundProvision(),
// Outbound (sandbox→host) copy-back at teardown, under the same
// Subscription-only inbound auth merge keeps the newer credential.
provision: apiKeyAuth ? undefined : buildCodexAuthInboundProvision(),
// Subscription-only outbound copy-back at teardown, under the same
// direction-agnostic decision predicate + directory merge-lock +
// atomic-rename + 0600 guard. Target is the SHARED host auth.json
// (the symlink source managed homes point at), never an in-sandbox copy.
restore: async ({ assetDir, readFile }) =>
restore: apiKeyAuth ? undefined : async ({ assetDir, readFile }) =>
void (await copyBackCodexAuth({
readSandboxAuth: () => readFile(path.posix.join(assetDir, "auth.json")),
hostAuthPath: path.join(input.config.managedAiConnection ? effectiveCodexHome : resolveSharedCodexHomeDir(process.env), "auth.json"),
@@ -224,7 +228,7 @@ async function prepareCodexRemoteManagedHome(
return {
stagedRuntime,
// Per-run copy-back: fires on EVERY run's teardown (including a compatible
// Subscription copy-back fires on EVERY run's teardown (including a compatible
// resume that reuses this staged runtime). It reads the sandbox auth.json /
// workspace live and copies back to the host; it does NOT remove the staged
// in-sandbox home, so re-running it across resumes can't leave a later run
@@ -238,7 +242,9 @@ async function prepareCodexRemoteManagedHome(
teardown: createWorkspaceRestoreTeardown({
stagedRuntime,
onLog,
startMessage: "[paperclip] Restoring workspace changes and Codex auth from the sandbox.\n",
startMessage: apiKeyAuth
? "[paperclip] Restoring workspace changes from the sandbox.\n"
: "[paperclip] Restoring workspace changes and Codex auth from the sandbox.\n",
failurePrefix: "[paperclip] Codex ACP teardown restore/copy-back failed",
}),
// One-time cleanup of the HOST staged home temp dir. Fired ONLY when the
@@ -0,0 +1,141 @@
import { createHash, randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import postgres from "postgres";
import { describe, expect, it } from "vitest";
import { applyPendingMigrations, inspectMigrations } from "./client.js";
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
// Exact SQL from PR #14325 before master assigned 0285/0286 to API response storage.
// Exercise real preview tables/receipts, not a second application of the new schema.
const previewMigrations = [
{ when: 1790474441205, sql: `CREATE TABLE "agent_instruction_heads" (
"company_id" uuid NOT NULL,
"agent_id" uuid NOT NULL,
"entry_file" text NOT NULL,
"revision_id" uuid NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "agent_instruction_heads_identity_uq" UNIQUE("company_id","agent_id","entry_file")
);
--> statement-breakpoint
CREATE TABLE "agent_instruction_revisions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"agent_id" uuid NOT NULL,
"entry_file" text NOT NULL,
"content_base64" text NOT NULL,
"content_hash" text NOT NULL,
"byte_length" integer NOT NULL,
"parent_revision_id" uuid,
"base_revision_id" uuid,
"restored_from_revision_id" uuid,
"actor_agent_id" uuid,
"actor_user_id" text,
"responsible_user_id" text,
"source_run_id" uuid,
"source" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "agent_instruction_revisions_identity_uq" UNIQUE("company_id","agent_id","entry_file","id")
);
--> statement-breakpoint
ALTER TABLE "agent_instruction_heads" ADD CONSTRAINT "agent_instruction_heads_company_id_agent_id_entry_file_revision_id_agent_instruction_revisions_company_id_agent_id_entry_file_id_fk" FOREIGN KEY ("company_id","agent_id","entry_file","revision_id") REFERENCES "public"."agent_instruction_revisions"("company_id","agent_id","entry_file","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "agent_instruction_revisions" ADD CONSTRAINT "agent_instruction_revisions_company_id_agent_id_agents_company_id_id_fk" FOREIGN KEY ("company_id","agent_id") REFERENCES "public"."agents"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "agent_instruction_revisions_history_idx" ON "agent_instruction_revisions" USING btree ("company_id","agent_id","entry_file","created_at","id");` },
{ when: 1790543348340, sql: `CREATE TABLE "agent_instruction_working_copies" (
"run_id" uuid PRIMARY KEY NOT NULL,
"company_id" uuid NOT NULL,
"agent_id" uuid NOT NULL,
"responsible_user_id" text NOT NULL,
"entry_file" text NOT NULL,
"base_revision_id" uuid,
"base_hash" text NOT NULL,
"local_root" text NOT NULL,
"execution_root" text NOT NULL,
"location" text NOT NULL,
"state" text DEFAULT 'prepared' NOT NULL,
"candidate_base64" text,
"candidate_hash" text,
"error_code" text,
"error_message" text,
"receipt" jsonb,
"process_stopped_at" timestamp with time zone,
"attempts" integer DEFAULT 0 NOT NULL,
"next_attempt_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "agent_instruction_working_copies" ADD CONSTRAINT "agent_instruction_working_copies_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "agent_instruction_working_copies" ADD CONSTRAINT "agent_instruction_working_copies_company_id_agent_id_agents_company_id_id_fk" FOREIGN KEY ("company_id","agent_id") REFERENCES "public"."agents"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "agent_instruction_copies_pending_idx" ON "agent_instruction_working_copies" USING btree ("state","next_attempt_at");--> statement-breakpoint
CREATE INDEX "agent_instruction_copies_agent_idx" ON "agent_instruction_working_copies" USING btree ("company_id","agent_id","created_at");` },
];
const migration = await readFile(new URL("./migrations/0287_serious_tinkerer.sql", import.meta.url), "utf8");
const migrationHash = createHash("sha256").update(migration).digest("hex");
const support = await getEmbeddedPostgresTestSupport();
const describePostgres = support.supported ? describe : describe.skip;
describePostgres("instruction revision migrations", () => {
it("keeps applied migration receipts, revisions, and pending copies intact on repeated upgrades", async () => {
const database = await startEmbeddedPostgresTestDatabase("instruction-migration-upgrade-");
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
try {
await sql`DROP TABLE agent_instruction_heads, agent_instruction_working_copies, agent_instruction_revisions`;
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${migrationHash}`;
for (const preview of previewMigrations) {
for (const statement of preview.sql.split("--> statement-breakpoint")) await sql.unsafe(statement);
await sql`INSERT INTO drizzle.__drizzle_migrations (hash, created_at)
VALUES (${createHash("sha256").update(preview.sql).digest("hex")}, ${preview.when})`;
}
const companyId = randomUUID(), otherCompanyId = randomUUID(), agentId = randomUUID();
const runId = randomUUID(), revisionId = randomUUID();
const bytes = Buffer.from("\uFEFF# Saved\r\nExact bytes ☃\n");
const contentBase64 = bytes.toString("base64");
const contentHash = createHash("sha256").update(bytes).digest("hex");
await sql`INSERT INTO companies (id, name, issue_prefix) VALUES
(${companyId}, 'Instructions', 'INS'), (${otherCompanyId}, 'Other', 'OTH')`;
await sql`INSERT INTO agents (id, company_id, name) VALUES (${agentId}, ${companyId}, 'Writer')`;
await sql`INSERT INTO heartbeat_runs (id, company_id, agent_id) VALUES (${runId}, ${companyId}, ${agentId})`;
await sql`INSERT INTO agent_instruction_revisions
(id, company_id, agent_id, entry_file, content_base64, content_hash, byte_length, source, source_run_id)
VALUES (${revisionId}, ${companyId}, ${agentId}, 'AGENTS.md', ${contentBase64}, ${contentHash}, ${bytes.length}, 'cleanup', ${runId})`;
await sql`INSERT INTO agent_instruction_heads (company_id, agent_id, entry_file, revision_id)
VALUES (${companyId}, ${agentId}, 'AGENTS.md', ${revisionId})`;
await sql`INSERT INTO agent_instruction_working_copies
(run_id, company_id, agent_id, responsible_user_id, entry_file, base_revision_id, base_hash, local_root, execution_root, location, state, candidate_base64, candidate_hash)
VALUES (${runId}, ${companyId}, ${agentId}, 'editor', 'AGENTS.md', ${revisionId}, ${contentHash}, '/private/copy', '/private/copy', 'local', 'pending_commit', ${contentBase64}, ${contentHash})`;
const snapshot = async () => ({
journal: await sql`SELECT * FROM drizzle.__drizzle_migrations ORDER BY id`,
relations: await sql`SELECT oid::text, relname FROM pg_class WHERE relname IN
('agent_instruction_revisions', 'agent_instruction_heads', 'agent_instruction_working_copies') ORDER BY relname`,
constraints: await sql`SELECT oid::text, conname, pg_get_constraintdef(oid) AS definition FROM pg_constraint
WHERE conrelid IN ('agent_instruction_revisions'::regclass, 'agent_instruction_heads'::regclass,
'agent_instruction_working_copies'::regclass) ORDER BY conname`,
revisions: await sql`SELECT * FROM agent_instruction_revisions`,
heads: await sql`SELECT * FROM agent_instruction_heads`,
copies: await sql`SELECT * FROM agent_instruction_working_copies`,
});
const before = await snapshot();
expect(before.relations).toHaveLength(3);
expect(await inspectMigrations(database.connectionString)).toMatchObject({
status: "needsMigrations", pendingMigrations: ["0287_serious_tinkerer.sql"],
});
await applyPendingMigrations(database.connectionString);
const upgraded = await snapshot();
expect(upgraded).toEqual({ ...before, journal: expect.arrayContaining(before.journal) });
expect(upgraded.journal).toHaveLength(before.journal.length + 1);
for (let attempt = 0; attempt < 2; attempt += 1) {
expect(await inspectMigrations(database.connectionString)).toMatchObject({ status: "upToDate" });
await applyPendingMigrations(database.connectionString);
expect(await snapshot()).toEqual(upgraded);
}
await expect(sql`UPDATE agent_instruction_heads SET company_id = ${otherCompanyId} WHERE revision_id = ${revisionId}`)
.rejects.toMatchObject({ code: "23503" });
await expect(sql`UPDATE agent_instruction_working_copies SET company_id = ${otherCompanyId} WHERE run_id = ${runId}`)
.rejects.toMatchObject({ code: "23503" });
} finally {
await sql.end();
await database.cleanup();
}
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
});
@@ -0,0 +1,61 @@
-- Retain preview instruction rows and pending captures when upgrading PR #14325.
CREATE TABLE IF NOT EXISTS "agent_instruction_heads" (
"company_id" uuid NOT NULL,
"agent_id" uuid NOT NULL,
"entry_file" text NOT NULL,
"revision_id" uuid NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "agent_instruction_heads_identity_uq" UNIQUE("company_id","agent_id","entry_file")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "agent_instruction_revisions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"agent_id" uuid NOT NULL,
"entry_file" text NOT NULL,
"content_base64" text NOT NULL,
"content_hash" text NOT NULL,
"byte_length" integer NOT NULL,
"parent_revision_id" uuid,
"base_revision_id" uuid,
"restored_from_revision_id" uuid,
"actor_agent_id" uuid,
"actor_user_id" text,
"responsible_user_id" text,
"source_run_id" uuid,
"source" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "agent_instruction_revisions_identity_uq" UNIQUE("company_id","agent_id","entry_file","id")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "agent_instruction_working_copies" (
"run_id" uuid PRIMARY KEY NOT NULL,
"company_id" uuid NOT NULL,
"agent_id" uuid NOT NULL,
"responsible_user_id" text NOT NULL,
"entry_file" text NOT NULL,
"base_revision_id" uuid,
"base_hash" text NOT NULL,
"local_root" text NOT NULL,
"execution_root" text NOT NULL,
"location" text NOT NULL,
"state" text DEFAULT 'prepared' NOT NULL,
"candidate_base64" text,
"candidate_hash" text,
"error_code" text,
"error_message" text,
"receipt" jsonb,
"process_stopped_at" timestamp with time zone,
"attempts" integer DEFAULT 0 NOT NULL,
"next_attempt_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "agent_instruction_heads" ADD CONSTRAINT "agent_instruction_heads_company_id_agent_id_entry_file_revision_id_agent_instruction_revisions_company_id_agent_id_entry_file_id_fk" FOREIGN KEY ("company_id","agent_id","entry_file","revision_id") REFERENCES "public"."agent_instruction_revisions"("company_id","agent_id","entry_file","id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "agent_instruction_revisions" ADD CONSTRAINT "agent_instruction_revisions_company_id_agent_id_agents_company_id_id_fk" FOREIGN KEY ("company_id","agent_id") REFERENCES "public"."agents"("company_id","id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "agent_instruction_working_copies" ADD CONSTRAINT "agent_instruction_working_copies_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "agent_instruction_working_copies" ADD CONSTRAINT "agent_instruction_working_copies_company_id_agent_id_agents_company_id_id_fk" FOREIGN KEY ("company_id","agent_id") REFERENCES "public"."agents"("company_id","id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "agent_instruction_revisions_history_idx" ON "agent_instruction_revisions" USING btree ("company_id","agent_id","entry_file","created_at","id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "agent_instruction_copies_pending_idx" ON "agent_instruction_working_copies" USING btree ("state","next_attempt_at");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "agent_instruction_copies_agent_idx" ON "agent_instruction_working_copies" USING btree ("company_id","agent_id","created_at");
+7
View File
@@ -1996,6 +1996,13 @@
"when": 1790607525758,
"tag": "0286_complete_alice",
"breakpoints": true
},
{
"idx": 287,
"version": "7",
"when": 1790611116286,
"tag": "0287_serious_tinkerer",
"breakpoints": true
}
]
}
@@ -0,0 +1,119 @@
import {
pgTable,
uuid,
text,
integer,
timestamp,
index,
unique,
foreignKey,
jsonb,
} from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
/** Append-only content. Base64 preserves every UTF-8 byte, including NUL and BOM. */
export const agentInstructionRevisions = pgTable(
"agent_instruction_revisions",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull(),
agentId: uuid("agent_id").notNull(),
entryFile: text("entry_file").notNull(),
contentBase64: text("content_base64").notNull(),
contentHash: text("content_hash").notNull(),
byteLength: integer("byte_length").notNull(),
parentRevisionId: uuid("parent_revision_id"),
baseRevisionId: uuid("base_revision_id"),
restoredFromRevisionId: uuid("restored_from_revision_id"),
actorAgentId: uuid("actor_agent_id"),
actorUserId: text("actor_user_id"),
responsibleUserId: text("responsible_user_id"),
sourceRunId: uuid("source_run_id"),
source: text("source").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
owner: foreignKey({
columns: [t.companyId, t.agentId],
foreignColumns: [agents.companyId, agents.id],
}).onDelete("cascade"),
identity: unique("agent_instruction_revisions_identity_uq").on(
t.companyId,
t.agentId,
t.entryFile,
t.id,
),
history: index("agent_instruction_revisions_history_idx").on(
t.companyId,
t.agentId,
t.entryFile,
t.createdAt,
t.id,
),
}),
);
export const agentInstructionHeads = pgTable(
"agent_instruction_heads",
{
companyId: uuid("company_id").notNull(),
agentId: uuid("agent_id").notNull(),
entryFile: text("entry_file").notNull(),
revisionId: uuid("revision_id").notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
identity: unique("agent_instruction_heads_identity_uq").on(
t.companyId,
t.agentId,
t.entryFile,
),
revision: foreignKey({
columns: [t.companyId, t.agentId, t.entryFile, t.revisionId],
foreignColumns: [
agentInstructionRevisions.companyId,
agentInstructionRevisions.agentId,
agentInstructionRevisions.entryFile,
agentInstructionRevisions.id,
],
}).onDelete("cascade"),
}),
);
/** A server-owned collection receipt, retained even when canonical CAS rejects an edit. */
export const agentInstructionWorkingCopies = pgTable(
"agent_instruction_working_copies",
{
runId: uuid("run_id").primaryKey().references(() => heartbeatRuns.id, { onDelete: "cascade" }),
companyId: uuid("company_id").notNull(),
agentId: uuid("agent_id").notNull(),
responsibleUserId: text("responsible_user_id").notNull(),
entryFile: text("entry_file").notNull(),
baseRevisionId: uuid("base_revision_id"),
baseHash: text("base_hash").notNull(),
localRoot: text("local_root").notNull(),
executionRoot: text("execution_root").notNull(),
location: text("location").notNull(),
state: text("state").notNull().default("prepared"),
candidateBase64: text("candidate_base64"),
candidateHash: text("candidate_hash"),
errorCode: text("error_code"),
errorMessage: text("error_message"),
receipt: jsonb("receipt").$type<Record<string, unknown>>(),
processStoppedAt: timestamp("process_stopped_at", { withTimezone: true }),
attempts: integer("attempts").notNull().default(0),
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
owner: foreignKey({ columns: [t.companyId, t.agentId], foreignColumns: [agents.companyId, agents.id] }).onDelete("cascade"),
pending: index("agent_instruction_copies_pending_idx").on(t.state, t.nextAttemptAt),
history: index("agent_instruction_copies_agent_idx").on(t.companyId, t.agentId, t.createdAt),
}),
);
+2
View File
@@ -211,3 +211,5 @@ export { aiConnectionDefaults } from "./ai_connection_defaults.js";
export { aiProviderDefaults } from "./ai_provider_defaults.js";
export * from "./email.js";
export { announcementDismissals, announcementPublications } from "./announcement_dismissals.js";
export { agentInstructionRevisions, agentInstructionHeads, agentInstructionWorkingCopies } from "./agent_instruction_revisions.js";
@@ -1 +1 @@
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
@@ -24,7 +24,7 @@
"prpVersion": 1,
"nativeExecutionVersion": 1,
"catalogVersion": 1,
"catalogSha256": "sha256:a19534304480dfd3d357cc902e785af05158ce1496f4d324184112732ff94c9c",
"catalogSha256": "sha256:61c6ba99926c14285ca6a89a05fe316848b9e5bc38840d43dd19b0990dc482d0",
"driverContractVersion": 1,
"driverKind": "paperclip-deterministic",
"driverVersion": "1.0.0"
@@ -160,7 +160,7 @@
},
{
"path": "fixtures/evals/native-execution-seeded.json",
"sha256": "c6367c7c7e3dd01fed341b7a83b0008c9d5d1efdfa85bc70e1013bc304a008c7",
"sha256": "c9c56cc11b420302be2d0044aeee30302e43da74c076e13a31addfbcaacd0458",
"expectation": "accept",
"compatibilityCase": "canonical"
},
@@ -585,6 +585,12 @@ impl AcpxDurableState {
"ACPX provider event backlog exceeds its durable limit",
));
}
// Check every fallible admission condition before changing the request
// ledger. A rejected settlement must leave its pending request intact.
let sequence = self.next_event_sequence;
let next_sequence = sequence
.checked_add(1)
.ok_or_else(|| DurableRunnerError::invalid("ACPX event sequence exhausted"))?;
if event.event_type == "runtime_request.created" {
let request = event.payload.get("request").cloned().ok_or_else(|| {
DurableRunnerError::invalid("ACPX runtime request omitted its payload")
@@ -612,10 +618,7 @@ impl AcpxDurableState {
self.pending_runtime_requests.remove(id);
}
}
let sequence = self.next_event_sequence;
self.next_event_sequence = sequence
.checked_add(1)
.ok_or_else(|| DurableRunnerError::invalid("ACPX event sequence exhausted"))?;
self.next_event_sequence = next_sequence;
self.pending_events.push_back(PolledEvent {
executor_event_id: event_id(sequence),
event_type: event.event_type,
@@ -680,6 +683,9 @@ pub struct AcpxCommandExecutor {
session: Option<AcpxProviderSession>,
restore_checked: bool,
restore_error: Option<DurableRunnerError>,
persistence_error: Option<DurableRunnerError>,
#[cfg(test)]
fail_after_state_rename: bool,
launch_profile: Option<AcpxLaunchProfile>,
}
@@ -698,6 +704,9 @@ impl AcpxCommandExecutor {
session: None,
restore_checked: false,
restore_error: None,
persistence_error: None,
#[cfg(test)]
fail_after_state_rename: false,
launch_profile: config.acpx_launch_profile.clone(),
}
}
@@ -718,6 +727,7 @@ impl AcpxCommandExecutor {
}
fn restore(&mut self) -> Result<(), DurableRunnerError> {
self.ensure_persistence_healthy()?;
if self.restore_checked {
return Ok(());
}
@@ -873,11 +883,46 @@ impl AcpxCommandExecutor {
Ok(session)
}
fn save_state(&self) -> Result<(), DurableRunnerError> {
let state = self
fn ensure_persistence_healthy(&self) -> Result<(), DurableRunnerError> {
match self.persistence_error.as_ref() {
Some(error) => Err(error.clone()),
None => Ok(()),
}
}
fn record_state_write(
&mut self,
result: Result<(), DurableRunnerError>,
) -> Result<(), DurableRunnerError> {
if let Err(error) = result.as_ref() {
// A rename may have succeeded before directory fsync or protection
// failed. Neither rolling back nor continuing from memory can prove
// which state survived. Stop publication and mutations until a fresh
// executor reloads the complete atomic snapshot; never resend input.
self.persistence_error = Some(error.clone());
}
result
}
fn save_state(&mut self) -> Result<(), DurableRunnerError> {
self.ensure_persistence_healthy()?;
let result = self
.state
.as_ref()
.ok_or_else(|| DurableRunnerError::invalid("ACPX provider state is unavailable"))?;
.ok_or_else(|| DurableRunnerError::invalid("ACPX provider state is unavailable"))
.and_then(|state| self.write_state(state));
self.record_state_write(result)
}
fn commit_staged_state(&mut self, state: AcpxDurableState) -> Result<(), DurableRunnerError> {
self.ensure_persistence_healthy()?;
let result = self.write_state(&state);
self.record_state_write(result)?;
self.state = Some(state);
Ok(())
}
fn write_state(&self, state: &AcpxDurableState) -> Result<(), DurableRunnerError> {
let launch_profile_digest = self.launch_profile_digest()?;
state.validate(&self.context, &launch_profile_digest)?;
secure_directory(&self.state_dir, "provider state")?;
@@ -896,6 +941,10 @@ impl AcpxCommandExecutor {
file.sync_all()?;
drop(file);
fs::rename(&temporary, &path)?;
#[cfg(test)]
if self.fail_after_state_rename {
return Err(std::io::Error::other("injected post-rename sync failure"));
}
#[cfg(unix)]
File::open(&self.state_dir)?.sync_all()?;
Ok(())
@@ -1532,6 +1581,19 @@ impl AcpxCommandExecutor {
DurableRunnerError::invalid("request.resolve requires resolution or response")
})?;
let permission = session.state().pending_permission(request_id).is_some();
// Admit the event before sending the response, but keep its request
// retirement and outbox entry private until the state write succeeds.
let mut staged = self
.state
.clone()
.expect("ACPX response has an active durable turn");
staged.push(NormalizedProviderEvent {
event_type: "runtime_request.resolved".to_owned(),
priority: EventPriority::P0,
payload: json!({"provider": "acpx", "requestId": request_id, "status": "delivered",
"requestKind": if permission {"permission_approval"} else {"runtime"},
"turnId":self.context.turn_id,"itemId":self.context.item_id,"action":resolution.get("action")}),
})?;
(if permission {
session.resolve_permission(request_id, &turn_id, &resolution)
} else {
@@ -1540,20 +1602,14 @@ impl AcpxCommandExecutor {
.map_err(|error| {
DurableRunnerError::invalid(format!("ACPX runtime response failed: {error}"))
})?;
if let Some(state) = self.state.as_mut() {
state.pending_runtime_requests.remove(request_id);
}
self.save_state()?;
Ok(CommandExecution {
result: json!({"status": "delivered", "requestId": request_id}),
events: vec![(
"runtime_request.resolved".to_owned(),
EventPriority::P0,
json!({"provider": "acpx", "requestId": request_id, "status": "delivered",
"requestKind": if permission {"permission_approval"} else {"runtime"},
"turnId":self.context.turn_id,"itemId":self.context.item_id,"action":resolution.get("action")}),
)],
})
// The sidecar ACK proves delivery, not durable controller settlement.
// Retain that fact in the same atomic state write that retires the
// pending request. A crash before this write expires the old request;
// a crash after it replays this event, never the provider response.
self.commit_staged_state(staged)?;
Ok(CommandExecution::result(
json!({"status": "delivered", "requestId": request_id}),
))
}
fn deliver_tool_result(
@@ -1928,6 +1984,7 @@ impl CommandExecutor for AcpxCommandExecutor {
}
fn retained_events(&mut self) -> Result<Vec<PolledEvent>, DurableRunnerError> {
self.ensure_persistence_healthy()?;
// Explicit drain runs while control traffic suppresses provider polling.
// Expose the already-retained suffix so runnerd can commit and ACK it
// before suspension, without restoring or advancing the provider.
@@ -1941,6 +1998,7 @@ impl CommandExecutor for AcpxCommandExecutor {
}
fn acknowledge_events(&mut self, count: usize) -> Result<(), DurableRunnerError> {
self.ensure_persistence_healthy()?;
if count == 0 {
return Ok(());
}
@@ -1958,6 +2016,20 @@ impl CommandExecutor for AcpxCommandExecutor {
}
fn shutdown(&mut self) -> Result<(), DurableRunnerError> {
if let Some(error) = self.persistence_error.clone() {
// Reap our owned process even when durable cleanup is uncertain,
// but do not overwrite the last atomic snapshot or claim success.
if let Some(mut session) = self.session.take() {
session
.shutdown("ACPX provider state persistence failed")
.map_err(|cleanup| {
DurableRunnerError::invalid(format!(
"{error}; failed to stop ACPX provider: {cleanup}"
))
})?;
}
return Err(error);
}
// A replacement durable runner may reach terminal reconciliation
// before any provider command or event poll. Restore the persisted
// session first so cleanup cannot succeed merely because this process
@@ -2293,6 +2365,213 @@ mod tests {
.is_empty());
}
#[test]
fn rejected_runtime_settlement_preserves_the_pending_request() {
for event_type in [
"runtime_request.resolved",
"runtime_request.expired",
"runtime_request.cancelled",
] {
for exhausted_sequence in [false, true] {
let operations = Vec::new();
let mut state = AcpxDurableState::new(
serde_json::from_value(descriptor("codex")).unwrap(),
AuthorizedToolSet {
schema: TOOL_SET_SCHEMA.into(),
schema_version: 1,
catalog_digest: authorized_tool_catalog_digest(&operations).unwrap(),
operations,
},
"test".into(),
);
state
.push(NormalizedProviderEvent {
event_type: "runtime_request.created".into(),
priority: EventPriority::P0,
payload: json!({"request":pending_input_request("input-1")}),
})
.unwrap();
if exhausted_sequence {
state.next_event_sequence = u64::MAX;
} else {
state
.pending_events
.resize(MAX_PENDING_EVENTS, state.pending_events[0].clone());
}
let before = state.clone();
assert!(state
.push(NormalizedProviderEvent {
event_type: event_type.into(),
priority: EventPriority::P0,
payload: json!({"requestId":"input-1"}),
})
.is_err());
assert_eq!(
state, before,
"{event_type} must not retire a request without its event"
);
}
}
}
#[test]
fn post_rename_failure_blocks_settlement_publication_until_fresh_recovery() {
for event_type in [
"runtime_request.resolved",
"runtime_request.expired",
"runtime_request.cancelled",
] {
let directory = temporary_directory("post-rename-settlement");
let marker = directory.join("must-not-start-provider");
let command_path = directory.join("sidecar");
write_artifact(
&command_path,
format!("#!/bin/sh\ntouch '{}'\n", marker.display()).as_bytes(),
true,
);
let launch_profile = AcpxLaunchProfile {
authority_digest: format!("sha256:{}", "d".repeat(64)),
command: command_path.clone(),
args: Vec::new(),
artifacts: vec![artifact(&command_path)],
};
let mut value = descriptor("codex");
value["sidecarCommand"] = json!(command_path);
value["sidecarArgs"] = json!([]);
let descriptor: AcpxProviderDescriptor = serde_json::from_value(value).unwrap();
let identity = AcpxProviderSessionIdentity {
kind: "acpx".into(),
normalized_session_id: "session-1".into(),
acpx_record_id: "record-1".into(),
backend_session_id: "backend-1".into(),
agent_session_id: "agent-1".into(),
profile_digest: descriptor.command_digest.clone(),
workspace_digest: format!("sha256:{}", "a".repeat(64)),
requested_model: descriptor.model.clone(),
effective_model: descriptor.model.clone(),
permission_mode: Some(descriptor.permission_mode),
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
};
let operations = Vec::new();
let mut state = AcpxDurableState::new(
descriptor,
AuthorizedToolSet {
schema: TOOL_SET_SCHEMA.into(),
schema_version: 1,
catalog_digest: authorized_tool_catalog_digest(&operations).unwrap(),
operations,
},
launch_profile.canonical_digest().unwrap(),
);
state.identity = Some(identity);
state.lifecycle = "turn_active".into();
state.active_turn_id = Some("turn-1".into());
for request_id in ["input-1", "input-2"] {
state
.push(NormalizedProviderEvent {
event_type: "runtime_request.created".into(),
priority: EventPriority::P0,
payload: json!({"request":pending_input_request(request_id)}),
})
.unwrap();
}
state.pending_events.clear();
let config = test_config(&directory, Some(launch_profile));
let mut original = AcpxCommandExecutor::with_runner_config(&directory, &config);
original.restore_checked = true;
original.state = Some(state.clone());
original.save_state().unwrap();
let mut staged = state.clone();
staged
.push(NormalizedProviderEvent {
event_type: event_type.into(),
priority: EventPriority::P0,
payload: json!({"requestId":"input-1"}),
})
.unwrap();
original.fail_after_state_rename = true;
let error = if event_type == "runtime_request.resolved" {
let error = original.commit_staged_state(staged.clone()).unwrap_err();
assert_eq!(original.state.as_ref(), Some(&state));
error
} else {
// Existing cancellation/expiry paths mutate their working
// state before save; the same latch must quarantine them.
original.state = Some(staged.clone());
original.save_state().unwrap_err()
};
assert!(error
.to_string()
.contains("injected post-rename sync failure"));
original.fail_after_state_rename = false;
let disk = fs::read(original.state_path()).unwrap();
assert_eq!(
serde_json::from_slice::<AcpxDurableState>(&disk).unwrap(),
staged
);
for result in [
original.retained_events().map(|_| ()),
original.poll_events().map(|_| ()),
original.acknowledge_events(0),
original.acknowledge_events(1),
original.restore(),
original.save_state(),
original.shutdown(),
] {
assert_eq!(result.unwrap_err().to_string(), error.to_string());
}
for command_type in ["request.resolve", "runner.drain", "runner.suspend"] {
let blocked = original.execute(&Command {
schema: "paperclip.prp.command.v1".into(),
command_id: "blocked-command".into(),
controller_seq: 1,
command_type: command_type.into(),
issued_at: "2026-09-01T00:00:00.000Z".into(),
deadline_at: None,
precondition: None,
payload: json!({"requestId":"input-1", "resolution":{"action":"submit"}}),
});
assert_eq!(blocked.unwrap_err().to_string(), error.to_string());
}
assert_eq!(fs::read(original.state_path()).unwrap(), disk);
drop(original);
let mut recovered = AcpxCommandExecutor::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(), 2);
assert_eq!(settlements[0].event_type, event_type);
assert_eq!(settlements[0].payload["requestId"], "input-1");
assert_eq!(
settlements[0].executor_event_id,
staged.pending_events[0].executor_event_id
);
assert_eq!(settlements[1].event_type, "runtime_request.expired");
assert_eq!(settlements[1].payload["requestId"], "input-2");
assert_eq!(settlements[1].payload["replayAllowed"], false);
assert!(
event_sequence(&settlements[0].executor_event_id).unwrap()
< event_sequence(&settlements[1].executor_event_id).unwrap()
);
assert!(recovered
.resolve_request(&json!({"requestId":"input-1", "resolution":{"action":"submit"}}))
.is_err());
assert_eq!(recovered.poll_events().unwrap(), events);
recovered.acknowledge_events(events.len()).unwrap();
drop(recovered);
let mut recovered_again = AcpxCommandExecutor::with_runner_config(&directory, &config);
assert!(recovered_again.poll_events().unwrap().is_empty());
assert!(
!marker.exists(),
"recovery must not restart a provider or replay input"
);
fs::remove_dir_all(directory).unwrap();
}
}
#[test]
fn turn_control_rejects_nonstring_mode_before_provider_access() {
let directory = temporary_directory("control-mode");
@@ -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());
@@ -239,6 +239,513 @@ fn pi_prepare_payload(directory: &Path, mode: &str) -> Value {
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");
@@ -7,10 +7,86 @@
"src/scenarios/scenario-plan.ts"
],
"counts": {
"actions": 48,
"actions": 52,
"legacyRequirements": 106
},
"actions": [
{
"id": "read_agent_instructions",
"ownership": "always_agent_tool",
"surfaces": [
"live"
],
"legacyAliases": [],
"contractCase": "protocol-action:read_agent_instructions",
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::read_agent_instructions has a schema-valid canonical example and every declared projection",
"legacyBehavioralCases": [],
"deterministicCases": [
"protocol-action:read_agent_instructions"
],
"legacyRequirementCases": [],
"deterministicOwners": [
"src/catalog/protocol-action-contracts.test.ts::read_agent_instructions has a schema-valid canonical example and every declared projection",
"src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
]
},
{
"id": "update_agent_instructions",
"ownership": "always_agent_tool",
"surfaces": [
"live"
],
"legacyAliases": [],
"contractCase": "protocol-action:update_agent_instructions",
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::update_agent_instructions has a schema-valid canonical example and every declared projection",
"legacyBehavioralCases": [],
"deterministicCases": [
"protocol-action:update_agent_instructions"
],
"legacyRequirementCases": [],
"deterministicOwners": [
"src/catalog/protocol-action-contracts.test.ts::update_agent_instructions has a schema-valid canonical example and every declared projection",
"src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
]
},
{
"id": "get_agent_instruction_history",
"ownership": "always_agent_tool",
"surfaces": [
"live"
],
"legacyAliases": [],
"contractCase": "protocol-action:get_agent_instruction_history",
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::get_agent_instruction_history has a schema-valid canonical example and every declared projection",
"legacyBehavioralCases": [],
"deterministicCases": [
"protocol-action:get_agent_instruction_history"
],
"legacyRequirementCases": [],
"deterministicOwners": [
"src/catalog/protocol-action-contracts.test.ts::get_agent_instruction_history has a schema-valid canonical example and every declared projection",
"src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
]
},
{
"id": "restore_agent_instructions",
"ownership": "always_agent_tool",
"surfaces": [
"live"
],
"legacyAliases": [],
"contractCase": "protocol-action:restore_agent_instructions",
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::restore_agent_instructions has a schema-valid canonical example and every declared projection",
"legacyBehavioralCases": [],
"deterministicCases": [
"protocol-action:restore_agent_instructions"
],
"legacyRequirementCases": [],
"deterministicOwners": [
"src/catalog/protocol-action-contracts.test.ts::restore_agent_instructions has a schema-valid canonical example and every declared projection",
"src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
]
},
{
"id": "create_skill",
"ownership": "optional_agent_tool",
@@ -263,13 +263,13 @@
"id": "rf",
"legacyGroup": 13,
"name": "Reference files",
"owner": "optional domain tools + test-only escape hatch",
"operationIds": ["list_cases", "upsert_case", "list_routines", "manage_routine", "create_skill", "list_company_skills", "sync_company_skills", "list_secret_metadata", "read_secret_value", "export_company", "administer_company", "generic_api_request", "search_api", "call_api", "create_project"],
"owner": "always instruction tools + optional domain tools + test-only escape hatch",
"operationIds": ["list_cases", "upsert_case", "list_routines", "manage_routine", "create_skill", "list_company_skills", "sync_company_skills", "list_secret_metadata", "read_secret_value", "export_company", "administer_company", "generic_api_request", "search_api", "call_api", "create_project", "read_agent_instructions", "update_agent_instructions", "get_agent_instruction_history", "restore_agent_instructions"],
"controlPlaneOperationIds": ["append_audit_record"],
"realSurface": "project, case, routine, company-skill, secret, portability, and administration services",
"realSurface": "agent instruction revisions, project, case, routine, company-skill, secret, portability, and administration services",
"mockStateDomains": ["company", "cases", "routines", "skills", "secrets", "audit", "fault"],
"prpEvidence": "bounded domain projections, redacted broker receipts, company diffs, and audit references",
"gap": "Project and skill creation and API tools require the live server authority; generic_api_request is test-only and the remaining domain operations are scenario-only. Broad administer_company is deferred and cannot claim product coverage. Production API and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage."
"gap": "Instruction read/update/history/restore require the live canonical revision service, current responsible-user permissions, and pinned base revisions for writes. Their service, authority, and product E2E tests are separate from the legacy mock scenarios. Project and skill creation and API tools require the live server authority; generic_api_request is test-only and the remaining domain operations are scenario-only. Broad administer_company is deferred and cannot claim product coverage. Production API and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage."
},
{
"id": "mh",
@@ -6,7 +6,7 @@ Status: canonical explanatory contract for the Paperclip runner V1 surface.
This document keeps three independent meanings of **group** separate. PRP families describe wire evidence and controller commands; capability placement decides who owns an operation; behavioral eval groups organize the 106 scenario corpus. None of the three axes can be used as a substitute for another.
The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **48 reconciled semantic operations** (14 always, 34 optional), and **106 scenarios in 16 behavior groups**.
The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **52 reconciled semantic operations** (18 always, 34 optional), and **106 scenarios in 16 behavior groups**.
## Axis 1: PRP v1 event and command families
@@ -83,9 +83,9 @@ Placement has exactly three outcomes:
| `schedule_blocker_wake` | Dependency-resolution wake scheduling. |
| `reconcile_run` | Work assessment, terminal status arbitration, and recovery reconciliation. |
### Always-agent operations (14)
### Always-agent operations (18)
`answer_status_question`, `block_task`, `finish_task`, `get_task_context`, `get_task_history`, `inspect_operation_result`, `list_document_revisions`, `list_documents`, `read_document`, `register_deliverable`, `report_progress`, `request_human_input`, `request_review`, `write_document`.
`answer_status_question`, `block_task`, `finish_task`, `get_agent_instruction_history`, `get_task_context`, `get_task_history`, `inspect_operation_result`, `list_document_revisions`, `list_documents`, `read_agent_instructions`, `read_document`, `register_deliverable`, `report_progress`, `request_human_input`, `request_review`, `restore_agent_instructions`, `update_agent_instructions`, `write_document`.
### Optional operations (34) and grant groups (13)
@@ -127,6 +127,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| `finish_task` | `always_agent_tool` | none | `standard`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:finish_task` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
| `generic_api_request` | `optional_agent_tool` | `test:generic_api_request` | `skill_test` | `test_escape_hatch` | `required` | yes | `mock_extension:test.generic_api` | `scenario` + `live`<br>`test_only` | `unbound`<br>test-only; excluded from product PRP evidence<br>catalog PRP status: `audit_pending` |
| `get_agent` | `optional_agent_tool` | `discovery:agents:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `get_agent_instruction_history` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `agentInstructionRevisionService`<br>tool-result item event plus canonical revision receipt for writes; no scenario mock coverage<br>catalog PRP status: `bound` |
| `get_approval` | `optional_agent_tool` | `governance:approvals:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `get_approval_context` | `optional_agent_tool` | `governance:approvals:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `get_task_context` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `context_read:active_task` | `scenario` + `live`<br>`live_codex` | `PaperclipRunnerToolAuthority active issue/run + accepted plan revision`<br>bound company/assignment query plus exact accepted document revision projection<br>catalog PRP status: `bound` |
@@ -146,6 +147,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| `list_routines` | `optional_agent_tool` | `routines:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:routines.list` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `list_secret_metadata` | `optional_agent_tool` | `secrets:metadata:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:secrets.metadata` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `manage_routine` | `optional_agent_tool` | `routines:write` | `standard`<br>`skill_test` | `admin` | `required` | no | `mock_extension:routines.manage` | `scenario`<br>`scenario_mock` | `unbound`<br>company admin/portability item event plus audit record<br>catalog PRP status: `audit_pending` |
| `read_agent_instructions` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `agentInstructionRevisionService`<br>tool-result item event plus canonical revision receipt for writes; no scenario mock coverage<br>catalog PRP status: `bound` |
| `read_document` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_document` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `read_secret_value` | `optional_agent_tool` | `secrets:values:read` | `standard`<br>`skill_test` | `secret_read` | `none` | yes | `mock_extension:secrets.value` | `scenario`<br>`scenario_mock` | `unbound`<br>redacted tool-result item event; secret value never reaches the wire<br>catalog PRP status: `audit_pending` |
| `reassign_task` | `optional_agent_tool` | `delegation:tasks:assign` | `standard` | `company_write` | `required` | no | `semantic_command:reassign_task` | `scenario` + `live`<br>`live_codex` | `PaperclipRunnerToolAuthority.reassign_task`<br>semantic-operation item event plus company-entity state diff and audit record<br>catalog PRP status: `bound` |
@@ -154,11 +156,13 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| `request_approval` | `optional_agent_tool` | `governance:approvals:request` | `standard`<br>`skill_test` | `governance` | `required` | no | `semantic_command:request_approval` | `scenario` + `live`<br>`live_codex` | `unbound`<br>approval lifecycle plus governed-wait continuation and audit events<br>catalog PRP status: `audit_pending` |
| `request_human_input` | `always_agent_tool` | none | `standard`<br>`planning`<br>`ask`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:request_human_input` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
| `request_review` | `always_agent_tool` | none | `standard`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:request_review` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
| `restore_agent_instructions` | `always_agent_tool` | none | `standard`<br>`planning` | `company_write` | `recommended` | no | inline/no mapping | `live`<br>`live_codex` | `agentInstructionRevisionService`<br>tool-result item event plus canonical revision receipt for writes; no scenario mock coverage<br>catalog PRP status: `bound` |
| `schedule_wake` | `optional_agent_tool` | `control_plane:wakes` | `standard`<br>`skill_test` | `task_write` | `required` | no | inline/no mapping | `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
| `search_api` | `optional_agent_tool` | `api:discover` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated PRP tool input/result and existing HTTP route authorization/activity records.<br>catalog PRP status: `bound` |
| `search_tasks` | `optional_agent_tool` | `discovery:tasks:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:company_tasks` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `set_dependencies` | `optional_agent_tool` | `dependencies:write` | `standard`<br>`skill_test` | `company_write` | `required` | no | `semantic_command:set_dependencies` | `scenario` + `live`<br>`live_codex` | `issues.update.blockedByIssueIds`<br>semantic-operation item event plus company-entity state diff and audit record<br>catalog PRP status: `bound` |
| `sync_company_skills` | `optional_agent_tool` | `company_skills:write` | `standard`<br>`skill_test` | `admin` | `required` | no | `mock_extension:company_skills.sync` | `scenario`<br>`scenario_mock` | `unbound`<br>company admin/portability item event plus audit record<br>catalog PRP status: `audit_pending` |
| `update_agent_instructions` | `always_agent_tool` | none | `standard`<br>`planning` | `company_write` | `recommended` | no | inline/no mapping | `live`<br>`live_codex` | `agentInstructionRevisionService`<br>tool-result item event plus canonical revision receipt for writes; no scenario mock coverage<br>catalog PRP status: `bound` |
| `upsert_case` | `optional_agent_tool` | `cases:write` | `standard`<br>`skill_test` | `company_write` | `required` | no | `mock_extension:cases.upsert` | `scenario`<br>`scenario_mock` | `unbound`<br>semantic-operation item event plus company-entity state diff and audit record<br>catalog PRP status: `audit_pending` |
| `write_document` | `always_agent_tool` | none | `standard`<br>`planning`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:write_document` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
@@ -182,7 +186,7 @@ Behavior groups describe expected outcomes and trajectories. They do not grant t
| [`ap` — Approvals](#behavior-group-ap-approvals) | optional governance tools + governed approver | `list_approvals`<br>`get_approval`<br>`get_approval_context`<br>`request_approval`<br>`decide_approval`<br>`comment_on_approval` | `route_wake`<br>`append_audit_record` | company approval, decision, issue-link, comment, and governed-action services | `company`<br>`task`<br>`approvals`<br>`actor`<br>`wake`<br>`audit`<br>`idempotency` | 6 | governed semantic receipts, audit references, and attention/continuation linkage | Production binding and additive governed-action receipts are unbound; board-only authority stays outside grants. |
| [`ar` — Artifacts](#behavior-group-ar-artifacts) | always tools + artifact/work-product services | `register_deliverable` | `append_audit_record` | attachment upload and issue work-product routes | `task`<br>`artifacts`<br>`workProducts`<br>`workspace`<br>`audit`<br>`idempotency` | 4 | artifact/work-product reference and durable inspectability receipt; never binary bytes | Production upload/register composite and additive durable-reference receipt are unbound. |
| [`er` — Errors and critical rules](#behavior-group-er-errors-and-critical-rules) | runner/control plane + optional workspace/wake tools | `get_workspace_runtime`<br>`control_workspace_service`<br>`schedule_wake`<br>`inspect_operation_result` | `release_task`<br>`enforce_budget`<br>`persist_run`<br>`replay_run`<br>`reconcile_run` | workspace runtime, monitor/recovery, budget, run persistence/replay, release, and terminal services | `workspace`<br>`budget`<br>`run`<br>`wake`<br>`audit`<br>`idempotency`<br>`fault` | 9 | runtime/workspace/attention/run lifecycle, typed denials, replay facts, and terminal causality | Budget stop reasons and semantic denial/conflict receipts require additive v1 envelopes; inspect_operation_result remains scenario-only. |
| [`rf` — Reference files](#behavior-group-rf-reference-files) | optional domain tools + test-only escape hatch | `list_cases`<br>`upsert_case`<br>`list_routines`<br>`manage_routine`<br>`create_skill`<br>`list_company_skills`<br>`sync_company_skills`<br>`list_secret_metadata`<br>`read_secret_value`<br>`export_company`<br>`administer_company`<br>`generic_api_request`<br>`search_api`<br>`call_api`<br>`create_project` | `append_audit_record` | project, case, routine, company-skill, secret, portability, and administration services | `company`<br>`cases`<br>`routines`<br>`skills`<br>`secrets`<br>`audit`<br>`fault` | 22 | bounded domain projections, redacted broker receipts, company diffs, and audit references | Project and skill creation and API tools require the live server authority; generic_api_request is test-only and the remaining domain operations are scenario-only. Broad administer_company is deferred and cannot claim product coverage. Production API and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage. |
| [`rf` — Reference files](#behavior-group-rf-reference-files) | always instruction tools + optional domain tools + test-only escape hatch | `list_cases`<br>`upsert_case`<br>`list_routines`<br>`manage_routine`<br>`create_skill`<br>`list_company_skills`<br>`sync_company_skills`<br>`list_secret_metadata`<br>`read_secret_value`<br>`export_company`<br>`administer_company`<br>`generic_api_request`<br>`search_api`<br>`call_api`<br>`create_project`<br>`read_agent_instructions`<br>`update_agent_instructions`<br>`get_agent_instruction_history`<br>`restore_agent_instructions` | `append_audit_record` | agent instruction revisions, project, case, routine, company-skill, secret, portability, and administration services | `company`<br>`cases`<br>`routines`<br>`skills`<br>`secrets`<br>`audit`<br>`fault` | 22 | bounded domain projections, redacted broker receipts, company diffs, and audit references | Instruction read/update/history/restore require the live canonical revision service, current responsible-user permissions, and pinned base revisions for writes. Their service, authority, and product E2E tests are separate from the legacy mock scenarios. Project and skill creation and API tools require the live server authority; generic_api_request is test-only and the remaining domain operations are scenario-only. Broad administer_company is deferred and cannot claim product coverage. Production API and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage. |
| [`mh` — Multi-hop](#behavior-group-mh-multi-hop) | composed semantic operations + control-plane continuation | `create_task`<br>`set_dependencies`<br>`request_human_input`<br>`request_approval`<br>`register_deliverable` | `route_wake`<br>`reconcile_run` | delegation, dependency, interaction, approval, artifact, and terminal orchestration services | `task`<br>`blockers`<br>`interactions`<br>`approvals`<br>`artifacts`<br>`wake`<br>`run`<br>`audit` | 4 | correlated operation receipts, state diffs, attention hops, work assessment, status decision, and terminal outcome | No generic transaction tool is allowed; shared mock/real conformance must prove each composed effect. |
| [`rs` — Restraint and no-call](#behavior-group-rs-restraint-and-no-call) | policy/exposure layer | `answer_status_question`<br>`read_secret_value`<br>`generic_api_request` | `enforce_budget` | task-mode, secret-broker, test-scope, pause, and budget policy checks | `actor`<br>`task`<br>`budget`<br>`secrets`<br>`audit`<br>`fault` | 3 | absence of forbidden effects plus typed policy denial/redaction receipts when a call is attempted | Typed redaction/authorization receipts need additive v1 evidence; generic_api_request is never a product fallback. |
| [`wk` — Wake situations](#behavior-group-wk-wake-situations) | control plane + always context/history tools | `get_task_context`<br>`get_task_history`<br>`schedule_wake` | `select_work`<br>`route_wake` | wakeup requests, heartbeat context, comment/interaction/approval/blocker wake routing, and scheduled wake services | `wake`<br>`task`<br>`comments`<br>`interactions`<br>`approvals`<br>`blockers`<br>`run` | 8 | attention request routing/resolution plus resumed session/run causality | Production scheduling binding is unbound; control-plane routing remains non-callable. |
@@ -459,10 +463,10 @@ Current responsibility-based paths are normative. Numbered `phase-*` or mileston
### Catalog split and deliberate replacement
- Scenario/eval catalog: **39** operations.
- Live dispatcher catalog: **36** operations.
- Shared: **27**; union/canonical authority: **48**.
- Live dispatcher catalog: **40** operations.
- Shared: **27**; union/canonical authority: **52**.
- Scenario-only: `administer_company`, `export_company`, `inspect_operation_result`, `list_cases`, `list_company_skills`, `list_goals`, `list_routines`, `list_secret_metadata`, `manage_routine`, `read_secret_value`, `sync_company_skills`, `upsert_case`.
- Live-only: `call_api`, `create_project`, `get_agent`, `get_approval`, `get_approval_context`, `hire_agent`, `list_project_repositories`, `schedule_wake`, `search_api`.
- Live-only: `call_api`, `create_project`, `get_agent`, `get_agent_instruction_history`, `get_approval`, `get_approval_context`, `hire_agent`, `list_project_repositories`, `read_agent_instructions`, `restore_agent_instructions`, `schedule_wake`, `search_api`, `update_agent_instructions`.
- The generated provider contract contains exactly the live catalog; the canonical union remains the migration authority until all scenario-only operations are either implemented, deferred, or removed by an explicit reconciliation decision.
- `generic_api_request` stays exported only for controlled tests and cannot be cited as real-surface, mock-parity, or PRP product coverage.
@@ -152,6 +152,7 @@ function createTransportBackedNativeSessionBackend(
? (input.provider.approvalPolicy ?? "never")
: "never",
baseInstructions: nativeSystemInstructions(input),
instructionWorkingCopyRoot: "runtimeContext" in input ? input.runtimeContext.instructions.workingCopy?.rootPath : undefined,
includeSkillInstructions: isCodex && "runtimeContext" in input,
skillInputs: isCodex
? nativeTaskSkillInputs(
@@ -104,6 +104,11 @@ export function nativeTaskConstraints(input: NativeExecutionInput): string[] {
}
return [
"Use only the assigned skills and provider-native tools.",
...(input.runtimeContext.instructions.workingCopy ? [
input.runtimeContext.instructions.workingCopy.kind === "agent_files"
? `For this turn, AGENT_HOME is ${input.runtimeContext.instructions.workingCopy.rootPath}. This replaces any prior turn's agent directory path. It contains your instructions and persistent personal files, separate from the task working directory. Changes save after the provider stops; check the save receipt.`
: `For this turn, the editable agent instruction file is ${input.runtimeContext.instructions.workingCopy.rootPath}/${input.runtimeContext.instructions.workingCopy.entryPath}. This replaces any private working-copy path from a previous turn. Ordinary edits save after the provider stops and only with a durable revision receipt. Use the agent instruction tools for immediate saves. Shared instruction assets and repository instructions are not collected.`,
] : []),
"Use Paperclip semantic tools for coordination and finalization.",
"Save requested plans and Paperclip documents directly with write_document. A saved Paperclip document is already a durable deliverable. Do not create a local file, compute file hashes, or call register_deliverable for it unless the user also requests a downloadable file. Cite the saved document in your completion evidence and final response.",
"When the requested result is a file, use register_deliverable before paperclip_finish. Compute its exact byte size and SHA-256, register the workspace-relative file, cite deliverable:<attachmentId> from the receipt as completion evidence, and include /api/attachments/<attachmentId>/content as the download link in your answer. A bare workspace filename is not a delivered result. For repository edits, cite an accessible PR or registered work product. Preserve existing work; do not upload unrelated files. If file publication fails, fix it or report the concrete blocker instead of claiming the file is delivered.",
@@ -20,21 +20,25 @@ describe("canonical semantic-catalog reconciliation authority", () => {
it("pins the reconciled op-set relationship between the two catalogs", () => {
const summary = capabilityCatalogReconciliation();
expect(summary.scenarioCount).toBe(39);
expect(summary.liveCount).toBe(36);
expect(summary.liveCount).toBe(40);
expect(summary.sharedCount).toBe(27);
expect(summary.unionCount).toBe(48);
expect(summary.unionCount).toBe(52);
// Any operation added to or removed from either catalog without a
// reconciliation decision changes these exact sets and fails the gate.
expect(summary.liveOnly).toEqual([
"call_api",
"create_project",
"get_agent",
"get_agent_instruction_history",
"get_approval",
"get_approval_context",
"hire_agent",
"list_project_repositories",
"read_agent_instructions",
"restore_agent_instructions",
"schedule_wake",
"search_api",
"update_agent_instructions",
]);
expect(summary.scenarioOnly).toEqual([
"administer_company",
@@ -53,7 +57,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
});
it("is the single source both catalogs derive their operation set from", () => {
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(48);
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(52);
const canonicalIds = new Set(CAPABILITY_CANONICAL_OPERATIONS.map((operation) => operation.operationId));
// Neither catalog may contain an operation absent from the canonical source.
for (const tool of SCENARIO_CATALOG) expect(canonicalIds.has(tool.operationId)).toBe(true);
@@ -87,7 +91,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
});
it("names placement, claims, task modes, side-effect class, idempotency, redaction, mock mapping, real binding status, and PRP evidence for every operation", () => {
expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(48);
expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(52);
for (const operation of CAPABILITY_CANONICAL_CATALOG) {
expect(operation.placement).toMatch(/^(always|optional)_agent_tool$/);
expect(Array.isArray(operation.requiredClaims)).toBe(true);
@@ -113,7 +117,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
it("classifies real binding status so generic_api_request is never product coverage", () => {
const summary = capabilityCatalogReconciliation();
expect(summary.byRealBindingStatus).toEqual({
live_codex: 35,
live_codex: 39,
scenario_mock: 12,
test_only: 1,
});
@@ -15,7 +15,12 @@ export interface NativeRuntimeAssetReference {
export interface NativeRuntimeContextSnapshot {
prompt: { revision: typeof PAPERCLIP_EXECUTION_PROMPT_REVISION; text: typeof PAPERCLIP_EXECUTION_PROMPT; digest: string };
instructions: { entryPath: string; bundle: NativeRuntimeAssetReference };
instructions: {
entryPath: string;
bundle: NativeRuntimeAssetReference;
/** Server-registered writable copy; excluded from the pinned prompt digest. */
workingCopy?: { rootPath: string; entryPath: string; kind?: "agent_files" };
};
skills: Array<{ key: string; runtimeName: string; versionId: string | null; bundle: NativeRuntimeAssetReference }>;
mcp: { assignmentSetId: string; digest: string; bindingId: string | null };
aggregateDigest: string;
@@ -103,7 +108,10 @@ export function parseNativeRuntimeContext(value: unknown): NativeRuntimeContextS
throw new NativeRuntimeContextError("input.runtimeContext.prompt.digest does not match prompt text");
}
const instructions = object(context.instructions, "input.runtimeContext.instructions");
exact(instructions, ["entryPath", "bundle"], "input.runtimeContext.instructions");
exact(instructions, ["entryPath", "bundle", "workingCopy"], "input.runtimeContext.instructions");
const workingCopy = instructions.workingCopy === undefined ? undefined : object(instructions.workingCopy, "input.runtimeContext.instructions.workingCopy");
if (workingCopy) exact(workingCopy, ["rootPath", "entryPath", "kind"], "input.runtimeContext.instructions.workingCopy");
if (workingCopy?.kind !== undefined && workingCopy.kind !== "agent_files") throw new NativeRuntimeContextError("Unknown agent file contract");
if (!Array.isArray(context.skills)) throw new NativeRuntimeContextError("input.runtimeContext.skills must be an array");
const skills = context.skills.map((value, index) => {
const skill = object(value, `input.runtimeContext.skills[${index}]`);
@@ -122,7 +130,15 @@ export function parseNativeRuntimeContext(value: unknown): NativeRuntimeContextS
exact(mcp, ["assignmentSetId", "digest", "bindingId"], "input.runtimeContext.mcp");
const parsed = {
prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
instructions: { entryPath: safeRelativePath(instructions.entryPath, "input.runtimeContext.instructions.entryPath"), bundle: parseAsset(instructions.bundle, "input.runtimeContext.instructions.bundle") },
instructions: {
entryPath: safeRelativePath(instructions.entryPath, "input.runtimeContext.instructions.entryPath"),
bundle: parseAsset(instructions.bundle, "input.runtimeContext.instructions.bundle"),
...(workingCopy ? { workingCopy: {
...(workingCopy.kind === "agent_files" ? { kind: "agent_files" as const } : {}),
rootPath: text(workingCopy.rootPath, "input.runtimeContext.instructions.workingCopy.rootPath"),
entryPath: safeRelativePath(workingCopy.entryPath, "input.runtimeContext.instructions.workingCopy.entryPath"),
} } : {}),
},
skills,
mcp: {
assignmentSetId: text(mcp.assignmentSetId, "input.runtimeContext.mcp.assignmentSetId"),
@@ -141,6 +157,12 @@ export function composeNativeSystemInstructions(context: NativeRuntimeContextSna
return [
context.prompt.text,
entryContent.trim(),
context.instructions.workingCopy?.kind === "agent_files"
? `Your persistent agent directory (AGENT_HOME) is ${context.instructions.workingCopy.rootPath}. Your instruction entry is ${context.instructions.workingCopy.entryPath}, relative to that directory. All supported files and subfolders there are restored across tasks and sessions, and collected after this provider stops. Write task deliverables in the task working directory. Only changed or deleted files synchronize; the last sync wins for the same file. Temporary copies are cleaned up without retaining file history. Check the save receipt before claiming persistence.`
: context.instructions.workingCopy
? `Your editable agent instruction file is ${context.instructions.workingCopy.rootPath}/${context.instructions.workingCopy.entryPath}. Edit this registered private copy normally. After this run stops, Paperclip saves changed content as a persistent revision if your responsible user still has permission and the baseline has not changed. Check the run's instruction-save receipt before claiming persistence. Conflicts are preserved for explicit resolution. Repository instruction files, skills, and this run's loaded prompt are separate and are not collected.`
: null,
// Keep this canonical suffix intact for provider-specific asset remapping.
`Read-only instruction sibling root: ${context.instructions.bundle.rootPath}`,
].filter(Boolean).join("\n\n");
}
@@ -197,6 +197,7 @@ class BoundedLineDecoder {
}
const SAFE_ENVIRONMENT_KEYS = [
"AGENT_HOME",
"ALL_PROXY",
"CODEX_HOME",
"HOME",
@@ -660,7 +660,7 @@ export class CodexAppServerDriver implements HarnessDriver {
this.#options.transportFactory?.(context) ??
new ProcessCodexAppServerTransport({
workingDirectory,
args: createIsolatedCodexAppServerArgs(this.#options.environment, codexExecutableReadOnlyRoots(this.#options.environment ?? process.env)),
args: createIsolatedCodexAppServerArgs(this.#options.environment, codexExecutableReadOnlyRoots(this.#options.environment ?? process.env), this.#options.instructionWorkingCopyRoot),
environment: createSanitizedCodexEnvironment(this.#options.environment),
onDiagnostic: this.#options.onDiagnostic,
processGroup: true,
@@ -20,6 +20,8 @@ export interface CodexAppServerDriverOptions {
approvalPolicy?: "never" | "on-request" | "untrusted";
baseInstructions?: string;
includeSkillInstructions?: boolean;
/** Private instruction directory registered by the control plane for this run. */
instructionWorkingCopyRoot?: string;
/** Explicit selected skills, resolved from this task's assigned runtime assets. */
skillInputs?: readonly import("../../contracts/runtime-context.js").NativeSkillInput[];
conversationMode?: "task" | "direct" | "prepared";
@@ -12,6 +12,25 @@ import {
} from "./codex-security-config.js";
describe("Codex security configuration", () => {
it("allows only the registered private instruction directory while keeping shared context read-only", () => {
const args = createIsolatedCodexAppServerArgs({ HOME: "/host/home" }, ["/runtime/immutable-context"], "/runtime/instruction-edits/run-1").join("\n");
expect(args).toContain('"/runtime/instruction-edits/run-1"="write"');
expect(args).toContain('"/runtime/immutable-context"="read"');
expect(args).not.toContain('"/runtime"="write"');
expect(args).toContain('"/host/home"="none"');
expect(createIsolatedCodexAppServerArgs({ PAPERCLIP_INSTRUCTION_ROOT: "/host/home" }).join("\n"))
.not.toContain('"/host/home"="write"');
});
it("exposes AGENT_HOME only when it matches the controller-registered writable directory", () => {
const root = "/agent-files/run-1";
const registered = createIsolatedCodexAppServerArgs({ AGENT_HOME: root, HOME: "/provider" }, [], root).join("\n");
expect(registered).toContain('AGENT_HOME="/agent-files/run-1"');
expect(registered).toContain('"/provider"="none"');
expect(createIsolatedCodexAppServerArgs({ AGENT_HOME: "/arbitrary" }, [], root).join("\n")).not.toContain("AGENT_HOME");
expect(createIsolatedCodexAppServerArgs({ AGENT_HOME: root }).join("\n")).not.toContain("AGENT_HOME");
});
it("makes the installed npm Codex native sandbox executable readable without exposing its parent workspace", () => {
const command = evalProviderTransportOptions("codex").codexCommand!;
const manifest = createRequire(command).resolve(`@openai/codex-${process.platform}-${process.arch}/package.json`);
@@ -169,6 +169,8 @@ function tomlString(value: string): string {
export function createIsolatedCodexAppServerArgs(
source: NodeJS.ProcessEnv = process.env,
readOnlyRoots: string[] = [],
/** Server-registered run copy, never an environment/config-supplied root. */
instructionWorkingCopyRoot?: string,
): string[] {
const gitRoots = gitFilesystemRoots(source);
readOnlyRoots = [...new Set([...readOnlyRoots, ...codexNetworkReadOnlyRoots(source)])];
@@ -179,6 +181,7 @@ export function createIsolatedCodexAppServerArgs(
// Codex filters the configured `set` values through include_only as well.
// Retain the explicit command PATH/HOME/locale settings, not ambient secrets.
const commandEnvironment = codexCommandEnvironment(source);
if (instructionWorkingCopyRoot && source.AGENT_HOME === instructionWorkingCopyRoot) commandEnvironment.AGENT_HOME = instructionWorkingCopyRoot;
const shellEnvironmentKeys = [...new Set([...inheritedGitHubKeys, ...Object.keys(commandEnvironment)])].sort();
if (source.PAPERCLIP_GITHUB_LAUNCHER_DIR) readOnlyRoots = [...readOnlyRoots, source.PAPERCLIP_GITHUB_LAUNCHER_DIR];
const deniedHostRoots = [
@@ -199,6 +202,7 @@ export function createIsolatedCodexAppServerArgs(
...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`),
...gitRoots.read.map((path) => `${tomlString(path)}="read"`),
...gitRoots.write.map((path) => `${tomlString(path)}="write"`),
...(instructionWorkingCopyRoot ? [`${tomlString(resolve(instructionWorkingCopyRoot))}="write"`] : []),
...(source.PAPERCLIP_GITHUB_BROKER_TOKEN && source.GH_CONFIG_DIR
? [`${tomlString(resolve(source.GH_CONFIG_DIR))}="write"`] : []),
`":workspace_roots"={"."="write"}`,
@@ -210,6 +214,7 @@ export function createIsolatedCodexAppServerArgs(
...deniedHostRoots.map((path) => `${tomlString(path)}="none"`),
...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`),
...[...gitRoots.read, ...gitRoots.write].map((path) => `${tomlString(path)}="read"`),
...(instructionWorkingCopyRoot ? [`${tomlString(resolve(instructionWorkingCopyRoot))}="read"`] : []),
...(source.PAPERCLIP_GITHUB_BROKER_TOKEN && source.GH_CONFIG_DIR
? [`${tomlString(resolve(source.GH_CONFIG_DIR))}="write"`] : []),
`":workspace_roots"={"."="read"}`,
@@ -20,6 +20,7 @@ import {
PAPERCLIP_EXECUTION_PROMPT_REVISION,
canonicalNativeRuntimeContextDigest,
nativeRuntimePromptDigest,
parseNativeRuntimeContext,
type NativeRuntimeContextSnapshot,
} from "../contracts/runtime-context.js";
import { nativeMcpLaunchBinding } from "./native-mcp.js";
@@ -92,6 +93,17 @@ function context(
}
describe("runtime context materialization", () => {
it("restores the legacy working-copy contract without changing its digest or adding new fields", () => {
const old = context("/skills", "/instructions");
old.instructions.workingCopy = { rootPath: "/old-run/copy", entryPath: "AGENTS.md" };
old.aggregateDigest = canonicalNativeRuntimeContextDigest(old);
expect(parseNativeRuntimeContext(JSON.parse(JSON.stringify(old)))).toEqual(old);
expect(parseNativeRuntimeContext(old).instructions.workingCopy).not.toHaveProperty("kind");
const next = { ...old, instructions: { ...old.instructions, workingCopy: { ...old.instructions.workingCopy, kind: "agent_files" as const } } };
next.aggregateDigest = canonicalNativeRuntimeContextDigest(next);
expect(parseNativeRuntimeContext(next).instructions.workingCopy?.kind).toBe("agent_files");
});
it("validates native MCP launch bindings before they reach Codex", () => {
expect(nativeMcpLaunchBinding({})).toBeNull();
expect(() => nativeMcpLaunchBinding({
@@ -466,13 +466,13 @@ describe("workflow reports and stress traceability", () => {
candidateFailures: 36,
});
expect(report.coverage).toMatchObject({
canonicalOperations: 48,
canonicalOperations: 52,
capabilityCases: 106,
workflows: 12,
stressFindings: 44,
stressExclusions: 1,
});
expect(report.coverage.operations).toHaveLength(48);
expect(report.coverage.operations).toHaveLength(52);
expect(report.coverage.composedWorkflows).toHaveLength(12);
expect(
report.coverage.operations.find(
@@ -480,7 +480,7 @@ describe("workflow reports and stress traceability", () => {
)?.workflowIds.length,
).toBeGreaterThan(0);
expect(renderRunnerWorkflowMarkdown(report)).toContain(
"48 operations · 106 capability cases · 12 workflows",
"52 operations · 106 capability cases · 12 workflows",
);
expect(renderRunnerWorkflowJUnit(report)).toContain(
'tests="36" failures="36" skipped="0"',
@@ -1,4 +1,5 @@
import {
chmod,
cp,
mkdir,
lstat,
@@ -7462,6 +7463,8 @@ it("preserves prepared input through runnerd and the real OpenCode proxy boundar
execFileSync("cc", ["-x", "c", "-o", executable, "-"], {
input: `#include <unistd.h>\n#include <stdlib.h>\nint main(int argc, char **argv) { char **args = calloc(argc + 2, sizeof(char *)); args[0] = ${JSON.stringify(process.execPath)}; args[1] = ${JSON.stringify(fixture)}; for (int i = 1; i < argc; i++) args[i + 1] = argv[i]; execv(args[0], args); return 127; }`,
});
// CI may use umask 0002; qualified executables cannot be group-writable.
await chmod(executable, 0o755);
// Use the production bundler without depending on (or mutating) shared dist
// artifacts. The Vitest CI lane builds Rust but does not build TypeScript.
const proxy = join(root, "opencode-app-server-proxy.cjs");
@@ -3285,6 +3285,7 @@ export function createRunnerdCodexAppServerArgs(input: {
codexHome: string;
codexCommand?: string;
readOnlyRoots?: string[];
instructionWorkingCopyRoot?: string;
}): string[] {
// The filesystem policy denies HOME and CODEX_HOME to keep credentials and
// runner state outside provider reach. Always bind those names to the actual
@@ -3297,6 +3298,7 @@ export function createRunnerdCodexAppServerArgs(input: {
CODEX_HOME: input.codexHome,
},
[...(input.readOnlyRoots ?? []), ...codexExecutableReadOnlyRoots(input.environment ?? {}, input.codexCommand)],
input.instructionWorkingCopyRoot,
);
}
@@ -4669,6 +4671,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
environment: this.options.environment,
codexHome,
codexCommand: this.options.codexCommand,
instructionWorkingCopyRoot: runtimeContext?.instructions.workingCopy?.rootPath,
readOnlyRoots: [
...trustedRuntimeReadOnlyRoots(
this.options.environment,
@@ -5147,6 +5150,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
environment: this.options.environment,
codexHome,
codexCommand: this.options.codexCommand,
instructionWorkingCopyRoot: runtimeContext?.instructions.workingCopy?.rootPath,
readOnlyRoots: [
...trustedRuntimeReadOnlyRoots(this.options.environment),
...(runtimeContext
@@ -4014,6 +4014,45 @@ describe("executeNativeSession recovery", () => {
},
);
it("collects a failed run's private instructions only after its owned provider close joins", async () => {
const scopedIdentity = { ...identity, companyId: "instruction-close-company", runId: "instruction-close-run" };
let finishClose!: () => void;
const closed = new Promise<void>((resolve) => { finishClose = resolve; });
const order: string[] = [];
const capabilities = { resume: true, typedEvents: true, steering: false, interruption: false, structuredResult: true };
const session: NativeSession = {
identity: () => scopedIdentity,
capabilities: async () => capabilities,
async *events() { throw new Error("provider failed after editing instructions"); },
startTurn: async () => ({ turnId: "instruction-turn" }),
result: async () => null,
snapshot: async () => ({ backendKind: "local", sessionId: "instruction-session", identity: scopedIdentity,
providerSessionId: "instruction-provider", cursor: null, activeTurnId: null, pendingRuntimeRequests: [], lineage: [] }),
close: async () => { order.push("closing"); await closed; order.push("stopped"); },
};
const backend: NativeSessionBackend = {
descriptor: async () => ({ kind: "local", name: "instruction-close-test", version: "1", capabilities }),
openSession: async () => session,
};
const controlPlane: ControlPlanePort = {
openRun: async () => {}, checkpointSession: async () => {},
appendEvent: async () => ({ cursor: 0, highestContiguousSourceSeq: 0, disposition: "committed" }),
replayEvents: async () => ({ events: [], highestContiguousSourceSeq: 0 }), completeRun: async () => {},
};
const options = {
input: { ...input, binding: { ...input.binding, companyId: scopedIdentity.companyId, runId: scopedIdentity.runId } },
backend, controlPlane, runnerInstanceId: "instruction-runner", controlPlaneInstanceId: "control",
requireSessionCloseBeforeReturn: true,
onSessionClosed: async () => { order.push("collected"); },
};
const execution = executeNativeSession(options);
const failed = expect(execution).rejects.toThrow("provider failed after editing instructions");
await vi.waitFor(() => expect(order).toEqual(["closing"]));
finishClose();
await failed;
expect(order).toEqual(["closing", "stopped", "collected"]);
});
it("retires only the terminated remote resource, including two sandboxes for one run", async () => {
const scopedIdentity = { ...identity, companyId: "remote-stop-company", runId: "remote-stop-run" };
const binding = { ...scopedIdentity, remoteCleanupScope: "first-sandbox" };
@@ -4107,6 +4146,7 @@ describe("executeNativeSession recovery", () => {
vi.useFakeTimers();
try {
const closeFailure = new Error("required remote checkpoint close failed");
const onSessionClosed = vi.fn(async () => {});
const close = vi.fn(({ reason }: { reason: string }) =>
reason === "native session quarantined cleanup recovery"
? Promise.resolve()
@@ -4191,11 +4231,13 @@ describe("executeNativeSession recovery", () => {
runnerInstanceId: "runner-recovery",
controlPlaneInstanceId: "control-recovery",
requireSessionCloseBeforeReturn: true,
onSessionClosed,
}),
).rejects.toThrow(closeFailure);
await vi.advanceTimersByTimeAsync(3_000);
await execution;
expect(close).toHaveBeenCalledTimes(5);
expect(onSessionClosed).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
@@ -220,6 +220,9 @@ export interface ExecuteNativeSessionOptions {
* carries required persistence (for example, a remote runner checkpoint).
*/
requireSessionCloseBeforeReturn?: boolean;
/** Trusted cleanup observer, called only after the owned close completes.
* Requires requireSessionCloseBeforeReturn; never called for a live warm session. */
onSessionClosed?: () => Promise<void>;
onCheckpoint?: (
snapshot: PersistedNativeSession,
options?: CheckpointControlPlaneSessionOptions,
@@ -2756,6 +2759,7 @@ export async function executeNativeSession(
// generic retryable transport failure at the control-plane boundary.
throw protocolIntegrityFailure ?? closeError;
}
await options.onSessionClosed?.();
}
} else if (shouldClose && !failedCleanupDeferred) {
// A provider that ignores close must not keep execution pending forever.
@@ -1,3 +1,7 @@
import { readAgentInstructionsAction } from "./read-agent-instructions.js";
import { updateAgentInstructionsAction } from "./update-agent-instructions.js";
import { getAgentInstructionHistoryAction } from "./get-agent-instruction-history.js";
import { restoreAgentInstructionsAction } from "./restore-agent-instructions.js";
import { answerStatusQuestionAction } from "./answer-status-question.js";
import { blockTaskAction } from "./block-task.js";
import { finishTaskAction } from "./finish-task.js";
@@ -16,6 +20,11 @@ import { deepFreezeProtocolAction } from "./freeze.js";
/** Core actions that are always present in an authorized runner projection. */
export const PAPERCLIP_CORE_PROTOCOL_ACTIONS = deepFreezeProtocolAction([
readAgentInstructionsAction,
updateAgentInstructionsAction,
getAgentInstructionHistoryAction,
restoreAgentInstructionsAction,
answerStatusQuestionAction,
blockTaskAction,
finishTaskAction,
@@ -0,0 +1,99 @@
/** Canonical definition for `get_agent_instruction_history`. */
export const getAgentInstructionHistoryAction = {
"id": "get_agent_instruction_history",
"canonical": {
"operationId": "get_agent_instruction_history",
"surfaces": [
"live"
],
"placement": "always_agent_tool",
"optionalGroup": null,
"requiredClaims": [],
"taskModes": [
"standard",
"ask",
"planning",
"skill_test"
],
"sideEffectClass": "read",
"idempotency": "none",
"disabledByDefault": false,
"realBindingStatus": "live_codex",
"realServiceBinding": "agentInstructionRevisionService",
"prpEvidence": "tool-result item event plus canonical revision receipt for writes; no scenario mock coverage",
"prpBindingStatus": "bound",
"legacyAliases": [],
"note": "Live canonical instruction service; company scope and current responsible-user authorization are checked at invocation."
},
"documentation": {
"title": "Get agent instruction history",
"description": "List bounded canonical instruction revision metadata, including actor, source run, and restore origin. Use read_agent_instructions with entryFile and revisionId to inspect exact historical content.",
"note": null
},
"examples": {
"call": {
"operationId": "get_agent_instruction_history",
"input": {
"entryFile": "AGENTS.md"
}
},
"success": {
"ok": true,
"operationId": "get_agent_instruction_history",
"result": {}
}
},
"live": {
"order": 42,
"descriptor": {
"schema": "paperclip.semantic-tool.v1",
"operationId": "get_agent_instruction_history",
"version": 1,
"title": "Get agent instruction history",
"description": "List bounded canonical instruction revision metadata, including actor, source run, and restore origin. Use read_agent_instructions with entryFile and revisionId to inspect exact historical content.",
"exposure": "always",
"requiredClaims": [],
"allowedModes": [
"standard",
"ask",
"planning",
"skill_test"
],
"inputSchema": {
"type": "object",
"properties": {
"targetAgentId": {
"type": "string",
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
"description": "Same-company target agent. Omit to use the calling agent."
},
"entryFile": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"description": "Configured relative entry filename returned by read_agent_instructions; retain it with the revision."
},
"cursor": {
"type": "string",
"minLength": 1,
"maxLength": 2048
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100
}
},
"required": [
"entryFile"
],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"additionalProperties": true
}
}
},
"scenario": null
} as const;
@@ -1,3 +1,7 @@
import { readAgentInstructionsAction } from "./read-agent-instructions.js";
import { updateAgentInstructionsAction } from "./update-agent-instructions.js";
import { getAgentInstructionHistoryAction } from "./get-agent-instruction-history.js";
import { restoreAgentInstructionsAction } from "./restore-agent-instructions.js";
import { reassignTaskAction } from "./reassign-task.js";
import { createSkillAction } from "./create-skill.js";
import { createProjectAction } from "./create-project.js";
@@ -49,6 +53,11 @@ import { writeDocumentAction } from "./write-document.js";
import { deepFreezeProtocolAction } from "./freeze.js";
export const PAPERCLIP_PROTOCOL_ACTIONS = deepFreezeProtocolAction([
readAgentInstructionsAction,
updateAgentInstructionsAction,
getAgentInstructionHistoryAction,
restoreAgentInstructionsAction,
createSkillAction,
createProjectAction,
listProjectRepositoriesAction,
@@ -0,0 +1,90 @@
/** Canonical definition for `read_agent_instructions`. */
export const readAgentInstructionsAction = {
"id": "read_agent_instructions",
"canonical": {
"operationId": "read_agent_instructions",
"surfaces": [
"live"
],
"placement": "always_agent_tool",
"optionalGroup": null,
"requiredClaims": [],
"taskModes": [
"standard",
"ask",
"planning",
"skill_test"
],
"sideEffectClass": "read",
"idempotency": "none",
"disabledByDefault": false,
"realBindingStatus": "live_codex",
"realServiceBinding": "agentInstructionRevisionService",
"prpEvidence": "tool-result item event plus canonical revision receipt for writes; no scenario mock coverage",
"prpBindingStatus": "bound",
"legacyAliases": [],
"note": "Live canonical instruction service; company scope and current responsible-user authorization are checked at invocation."
},
"documentation": {
"title": "Read canonical agent instructions",
"description": "Read the current canonical instruction entry and its revision, or inspect a historical revision by supplying both entryFile and revisionId. Read before updating; do not edit shared instruction caches.",
"note": null
},
"examples": {
"call": {
"operationId": "read_agent_instructions",
"input": {}
},
"success": {
"ok": true,
"operationId": "read_agent_instructions",
"result": {}
}
},
"live": {
"order": 40,
"descriptor": {
"schema": "paperclip.semantic-tool.v1",
"operationId": "read_agent_instructions",
"version": 1,
"title": "Read canonical agent instructions",
"description": "Read the current canonical instruction entry and its revision, or inspect a historical revision by supplying both entryFile and revisionId. Read before updating; do not edit shared instruction caches.",
"exposure": "always",
"requiredClaims": [],
"allowedModes": [
"standard",
"ask",
"planning",
"skill_test"
],
"inputSchema": {
"type": "object",
"properties": {
"targetAgentId": {
"type": "string",
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
"description": "Same-company target agent. Omit to use the calling agent."
},
"entryFile": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"description": "Configured relative entry filename returned by read_agent_instructions; retain it with the revision."
},
"revisionId": {
"type": "string",
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
"description": "Historical revision to inspect; also provide its entryFile."
}
},
"required": [],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"additionalProperties": true
}
}
},
"scenario": null
} as const;
@@ -0,0 +1,99 @@
/** Canonical definition for `restore_agent_instructions`. */
export const restoreAgentInstructionsAction = {
"id": "restore_agent_instructions",
"canonical": {
"operationId": "restore_agent_instructions",
"surfaces": [
"live"
],
"placement": "always_agent_tool",
"optionalGroup": null,
"requiredClaims": [],
"taskModes": [
"standard",
"planning"
],
"sideEffectClass": "company_write",
"idempotency": "recommended",
"disabledByDefault": false,
"realBindingStatus": "live_codex",
"realServiceBinding": "agentInstructionRevisionService",
"prpEvidence": "tool-result item event plus canonical revision receipt for writes; no scenario mock coverage",
"prpBindingStatus": "bound",
"legacyAliases": [],
"note": "Live canonical instruction service; company scope and current responsible-user authorization are checked at invocation."
},
"documentation": {
"title": "Restore canonical agent instructions",
"description": "Append a historical instruction revision as the current content using the current baseRevisionId. Requires the responsible user\u2019s current target edit permission. History remains intact; conflicts require an explicit resolution.",
"note": null
},
"examples": {
"call": {
"operationId": "restore_agent_instructions",
"input": {
"entryFile": "AGENTS.md",
"revisionId": "00000000-0000-4000-8000-000000000001",
"baseRevisionId": "00000000-0000-4000-8000-000000000002"
}
},
"success": {
"ok": true,
"operationId": "restore_agent_instructions",
"result": {}
}
},
"live": {
"order": 43,
"descriptor": {
"schema": "paperclip.semantic-tool.v1",
"operationId": "restore_agent_instructions",
"version": 1,
"title": "Restore canonical agent instructions",
"description": "Append a historical instruction revision as the current content using the current baseRevisionId. Requires the responsible user\u2019s current target edit permission. History remains intact; conflicts require an explicit resolution.",
"exposure": "always",
"requiredClaims": [],
"allowedModes": [
"standard",
"planning"
],
"inputSchema": {
"type": "object",
"properties": {
"targetAgentId": {
"type": "string",
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
"description": "Same-company target agent. Omit to use the calling agent."
},
"entryFile": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"description": "Configured relative entry filename returned by read_agent_instructions; retain it with the revision."
},
"revisionId": {
"type": "string",
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
"description": "Historical revision whose exact content should be restored."
},
"baseRevisionId": {
"type": "string",
"description": "Current revision read before restoring. Never replace a stale base silently.",
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
}
},
"required": [
"entryFile",
"revisionId",
"baseRevisionId"
],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"additionalProperties": true
}
}
},
"scenario": null
} as const;
@@ -0,0 +1,102 @@
/** Canonical definition for `update_agent_instructions`. */
export const updateAgentInstructionsAction = {
"id": "update_agent_instructions",
"canonical": {
"operationId": "update_agent_instructions",
"surfaces": [
"live"
],
"placement": "always_agent_tool",
"optionalGroup": null,
"requiredClaims": [],
"taskModes": [
"standard",
"planning"
],
"sideEffectClass": "company_write",
"idempotency": "recommended",
"disabledByDefault": false,
"realBindingStatus": "live_codex",
"realServiceBinding": "agentInstructionRevisionService",
"prpEvidence": "tool-result item event plus canonical revision receipt for writes; no scenario mock coverage",
"prpBindingStatus": "bound",
"legacyAliases": [],
"note": "Live canonical instruction service; company scope and current responsible-user authorization are checked at invocation."
},
"documentation": {
"title": "Update canonical agent instructions",
"description": "Commit instruction content with the exact entryFile and baseRevisionId from a prior read. Requires the responsible user\u2019s current target edit permission. On conflict, preserve your candidate and explicitly resolve it; never overwrite the newer head.",
"note": null
},
"examples": {
"call": {
"operationId": "update_agent_instructions",
"input": {
"entryFile": "AGENTS.md",
"content": "# Instructions\n",
"baseRevisionId": null
}
},
"success": {
"ok": true,
"operationId": "update_agent_instructions",
"result": {}
}
},
"live": {
"order": 41,
"descriptor": {
"schema": "paperclip.semantic-tool.v1",
"operationId": "update_agent_instructions",
"version": 1,
"title": "Update canonical agent instructions",
"description": "Commit instruction content with the exact entryFile and baseRevisionId from a prior read. Requires the responsible user\u2019s current target edit permission. On conflict, preserve your candidate and explicitly resolve it; never overwrite the newer head.",
"exposure": "always",
"requiredClaims": [],
"allowedModes": [
"standard",
"planning"
],
"inputSchema": {
"type": "object",
"properties": {
"targetAgentId": {
"type": "string",
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
"description": "Same-company target agent. Omit to use the calling agent."
},
"entryFile": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"description": "Configured relative entry filename returned by read_agent_instructions; retain it with the revision."
},
"content": {
"type": "string",
"maxLength": 1048576,
"description": "Complete UTF-8 instruction content, at most 1 MiB; empty content is valid."
},
"baseRevisionId": {
"type": [
"string",
"null"
],
"description": "Revision read before editing; null only when no canonical entry exists. Never replace a stale base silently.",
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
}
},
"required": [
"entryFile",
"content",
"baseRevisionId"
],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"additionalProperties": true
}
}
},
"scenario": null
} as const;
@@ -27,7 +27,12 @@ const NAMESPACE: Readonly<Record<CapabilitySemanticOperationId, string>> = Objec
report_progress: "active_task", answer_status_question: "active_task", write_document: "documents",
request_human_input: "documents", register_deliverable: "documents", finish_task: "active_task",
block_task: "active_task", request_review: "active_task", search_tasks: "discovery",
list_agents: "discovery", get_agent: "discovery", create_skill: "skills", create_task: "delegation", create_project: "projects", list_project_repositories: "projects", list_projects: "projects",
list_agents: "discovery", get_agent: "discovery",
read_agent_instructions: "agent_instructions",
update_agent_instructions: "agent_instructions",
get_agent_instruction_history: "agent_instructions",
restore_agent_instructions: "agent_instructions",
create_skill: "skills", create_task: "delegation", create_project: "projects", list_project_repositories: "projects", list_projects: "projects",
set_dependencies: "delegation", reassign_task: "delegation", list_approvals: "governance", get_approval: "governance",
get_approval_context: "governance", request_approval: "governance",
decide_approval: "governance", comment_on_approval: "governance",
@@ -102,6 +107,9 @@ export function discoverCapabilityDefinitions(
const tokens = normalized.split(/[^a-z0-9]+/).filter((token) => token.length > 1);
const permitted = CAPABILITY_SEMANTIC_TOOL_CATALOG
.filter((descriptor) => descriptor.exposure === "optional")
// Retain dispatch support for pre-upgrade sessions, but do not advertise
// revision history/restore as a capability of current agent directories.
.filter((descriptor) => descriptor.operationId !== "get_agent_instruction_history" && descriptor.operationId !== "restore_agent_instructions")
.filter((descriptor) => options.namespace === undefined || capabilityToolNamespace(descriptor.operationId) === options.namespace)
.filter((descriptor) => decideCapabilitySemanticAuthorization(descriptor, context, "exposure").allowed)
.map((descriptor) => ({ descriptor, score: score(descriptor, tokens, normalized) }))
@@ -94,7 +94,7 @@ describe("Capability semantic catalog and authorization", () => {
it("publishes a stable narrow catalog without credentials or control-plane-owned tools", () => {
const names = CAPABILITY_SEMANTIC_TOOL_CATALOG.map((tool) => tool.operationId);
expect(new Set(names).size).toBe(names.length);
expect(names).toHaveLength(36);
expect(names).toHaveLength(40);
expect(names).toContain("get_task_context");
expect(names).toContain("finish_task");
expect(names).not.toContain("checkout_task");
@@ -33,6 +33,10 @@ export type CapabilitySemanticOperationId =
| "request_review"
| "list_agents"
| "get_agent"
| "read_agent_instructions"
| "update_agent_instructions"
| "get_agent_instruction_history"
| "restore_agent_instructions"
| "search_tasks"
| "list_approvals"
| "get_approval"
+7
View File
@@ -2789,3 +2789,10 @@ export { MEMORY_CONNECTOR_IDS, isMemoryConnectorId, type MemoryConnectorId } fro
export * from "./connection-routing.js";
export { WORKSPACE_RESTORE_FAILURE_CODES, hasWorkspaceRestoreFailure, safeWorkspaceRestorePath, isNativeWorkspaceExportRepairCause } from "./workspace-restore.js";
export type { AgentInstructionErrorCode, AgentInstructionErrorDetails, AgentInstructionSource, AgentInstructionRevision, AgentInstructionSnapshot, AgentInstructionCommitReceipt, AgentInstructionHistory, AgentInstructionDiff } from "./types/agent.js";
export { restoreAgentInstructionSchema } from "./validators/agent.js";
export type { AgentInstructionCandidate } from "./types/agent.js";
export { resolveAgentInstructionCandidateSchema, type ResolveAgentInstructionCandidate } from "./validators/agent.js";
+79
View File
@@ -32,6 +32,8 @@ export type AgentInstructionsBundleMode = "managed" | "external";
export interface AgentInstructionsFileSummary {
path: string;
contentHash?: string;
binary?: boolean;
size: number;
language: string;
markdown: boolean;
@@ -43,11 +45,14 @@ export interface AgentInstructionsFileSummary {
export interface AgentInstructionsFileDetail extends AgentInstructionsFileSummary {
content: string;
revision?: AgentInstructionRevision;
receipt?: AgentInstructionCommitReceipt;
}
export interface AgentInstructionsBundle {
agentId: string;
companyId: string;
persistence?: "agent_files";
mode: AgentInstructionsBundleMode | null;
rootPath: string | null;
managedRootPath: string;
@@ -336,3 +341,77 @@ export type AdapterAuthSignal = "present" | "absent" | "unknown";
export interface AdapterAuthSignalResponse {
status: AdapterAuthSignal;
}
export type AgentInstructionSource = "seed" | "board" | "api" | "tool" | "cleanup" | "restore";
export interface AgentInstructionRevision {
id: string;
companyId: string;
agentId: string;
entryFile: string;
contentHash: string;
byteLength: number;
parentRevisionId: string | null;
baseRevisionId: string | null;
restoredFromRevisionId: string | null;
actorAgentId: string | null;
actorUserId: string | null;
responsibleUserId: string | null;
sourceRunId: string | null;
source: AgentInstructionSource;
createdAt: string;
}
export interface AgentInstructionSnapshot {
revision: AgentInstructionRevision;
content: string;
}
export interface AgentInstructionCommitReceipt extends AgentInstructionSnapshot {
changed: boolean;
materialization: "current" | "pending";
}
export interface AgentInstructionHistory {
revisions: AgentInstructionRevision[];
nextCursor: string | null;
}
export interface AgentInstructionDiff {
from: AgentInstructionSnapshot;
to: AgentInstructionSnapshot;
prefix: string;
removed: string;
added: string;
suffix: string;
}
export type AgentInstructionErrorCode =
| "INSTRUCTION_BASE_REQUIRED"
| "INSTRUCTION_REVISION_REQUIRED"
| "INSTRUCTION_REVISION_CONFLICT"
| "INSTRUCTION_ENTRY_CHANGED"
| "INSTRUCTION_MANAGED_BUNDLE_REQUIRED"
| "INSTRUCTION_IDENTITY_INVALID"
| "INSTRUCTION_CONTENT_INVALID"
| "INSTRUCTION_PATH_INVALID"
| "RESPONSIBLE_USER_UNAVAILABLE"
| "RESPONSIBLE_USER_UNAUTHORIZED";
export interface AgentInstructionErrorDetails {
code: AgentInstructionErrorCode;
baseRevisionId?: string | null;
currentRevisionId?: string | null;
entryFile?: string;
reason?: string;
}
/** Preserved instruction edits; filesystem locations and responsible identity stay server-side. */
export interface AgentInstructionCandidate {
contract?: "agent_files" | "legacy";
runId: string;
entryFile: string;
baseRevisionId: string | null;
baseHash: string;
state: "conflict" | "pending_collection" | "pending_commit" | "unavailable";
candidateHash: string | null;
content: string | null;
errorCode: string | null;
errorMessage: string | null;
createdAt: string;
updatedAt: string;
}
+4
View File
@@ -1069,3 +1069,7 @@ export * from "./chat-channels.js";
export * from "./chat-github.js";
export * from "./email.js";
export type { AgentInstructionErrorCode, AgentInstructionErrorDetails, AgentInstructionSource, AgentInstructionRevision, AgentInstructionSnapshot, AgentInstructionCommitReceipt, AgentInstructionHistory, AgentInstructionDiff } from "./agent.js";
export type { AgentInstructionCandidate } from "./agent.js";
+17 -3
View File
@@ -34,10 +34,18 @@ export const updateAgentInstructionsBundleSchema = z.object({
export type UpdateAgentInstructionsBundle = z.infer<typeof updateAgentInstructionsBundleSchema>;
export const upsertAgentInstructionsFileSchema = z.object({
path: z.string().trim().min(1),
content: z.string(),
path: z.string().min(1).max(512),
content: z.string().max(1024 * 1024),
baseRevisionId: z.string().uuid().nullable().optional(),
baseHash: z.string().regex(/^[a-f0-9]{64}$/).nullable().optional(),
clearLegacyPromptTemplate: z.boolean().optional().default(false),
});
}).strict();
export const restoreAgentInstructionSchema = z.object({
path: z.string().min(1).max(512),
revisionId: z.string().uuid(),
baseRevisionId: z.string().uuid(),
}).strict();
export type UpsertAgentInstructionsFile = z.infer<typeof upsertAgentInstructionsFileSchema>;
@@ -289,3 +297,9 @@ export const updateAgentPermissionsSchema = z.object({
});
export type UpdateAgentPermissions = z.infer<typeof updateAgentPermissionsSchema>;
export const resolveAgentInstructionCandidateSchema = z.object({
baseRevisionId: z.string().uuid().nullable(),
content: z.string().max(1024 * 1024),
}).strict();
export type ResolveAgentInstructionCandidate = z.infer<typeof resolveAgentInstructionCandidateSchema>;
+2
View File
@@ -985,3 +985,5 @@ export * from "./chat-channels.js";
export * from "./chat-github.js";
export * from "./email.js";
export { restoreAgentInstructionSchema } from "./agent.js";
+45 -2
View File
@@ -1,11 +1,54 @@
import { createRequire } from "node:module";
import { createHash } from "node:crypto";
/** Build-only: reuse the API worker and finite preset contract, never a browser renderer. */
/** Render avatar presets with the API worker in dev and package them in builds. */
export function storybookAgentAvatarAssets() {
return {
name: "storybook-agent-avatar-assets",
apply: "build",
configureServer(server) {
let rendererPromise;
const images = new Map();
const renderer = () => rendererPromise ??= (async () => {
const serverRequire = createRequire(new URL("../server/package.json", import.meta.url));
const { tsImport } = await import(serverRequire.resolve("tsx/esm/api"));
const { createAgentAvatarPool } = await tsImport("../server/src/services/agent-avatar-pool.ts", import.meta.url);
const { AGENT_PALETTE_IDS, AGENT_AVATAR_SIZES, CHARACTER_STATES, appearanceForPalette } =
await tsImport("../packages/shared/src/agent-appearance.ts", import.meta.url);
return { pool: createAgentAvatarPool(2), AGENT_PALETTE_IDS, AGENT_AVATAR_SIZES, CHARACTER_STATES, appearanceForPalette };
})();
server.middlewares.use((req, res, next) => {
const pathname = new URL(req.url ?? "/", "http://storybook.local").pathname;
const match = /^\/agent-avatar-images\/cap-v1\/([^/]+)\/([^/]+)-(\d+)-([12])\.png$/.exec(pathname);
if (!match) return next();
void (async () => {
const [, palette, pose, sizeText, scaleText] = match;
const { pool, AGENT_PALETTE_IDS, AGENT_AVATAR_SIZES, CHARACTER_STATES, appearanceForPalette } = await renderer();
const size = Number(sizeText);
const scale = Number(scaleText);
const muted = palette === "muted-dream";
if (!(muted || AGENT_PALETTE_IDS.includes(palette)) || !CHARACTER_STATES.includes(pose) || !AGENT_AVATAR_SIZES.includes(size)) {
res.statusCode = 404;
res.end();
return;
}
if (!images.has(pathname)) {
const appearance = appearanceForPalette(muted ? AGENT_PALETTE_IDS[0] : palette);
images.set(pathname, pool.render({ appearance, size, scale, pose, muted }).catch((error) => {
images.delete(pathname);
throw error;
}));
}
const png = await images.get(pathname);
res.setHeader("Content-Type", "image/png");
res.setHeader("Cache-Control", "public, max-age=3600");
res.end(png);
})().catch(() => {
res.statusCode = 500;
res.end("Avatar rendering failed");
});
});
server.httpServer?.once("close", () => { void rendererPromise?.then(({ pool }) => pool.close()); });
},
async generateBundle() {
const serverRequire = createRequire(new URL("../server/package.json", import.meta.url));
const { tsImport } = await import(serverRequire.resolve("tsx/esm/api"));
+51 -28
View File
@@ -5,7 +5,7 @@ import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local"
import { models as cursorFallbackModels } from "@paperclipai/adapter-cursor-local";
import { models as opencodeFallbackModels } from "@paperclipai/adapter-opencode-local";
import { resetOpenCodeModelsCacheForTests } from "@paperclipai/adapter-opencode-local/server";
import { listAdapterModels, listServerAdapters, refreshAdapterModels } from "../adapters/index.js";
import { listAdapterModels, listServerAdapters, refreshAdapterModels, registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js";
import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js";
import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from "../adapters/cursor-models.js";
@@ -195,14 +195,14 @@ describe("adapter model listing", () => {
]));
});
it("loads codex models dynamically and merges fallback options", async () => {
it("keeps general OpenAI API models out of the Codex catalog", async () => {
process.env.OPENAI_API_KEY = "sk-test";
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
data: [
{ id: "gpt-5-pro" },
{ id: "gpt-5" },
{ id: "gpt-image-1" },
{ id: "text-embedding-3-large" },
],
}),
} as Response);
@@ -210,40 +210,26 @@ describe("adapter model listing", () => {
const first = await listAdapterModels("codex_local");
const second = await listAdapterModels("codex_local");
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(first).toEqual(second);
expect(first.some((model) => model.id === "gpt-5-pro")).toBe(true);
expect(first.some((model) => model.id === "codex-mini-latest")).toBe(true);
expect(fetchSpy).not.toHaveBeenCalled();
expect(first).toEqual(codexFallbackModels);
expect(second).toEqual(codexFallbackModels);
});
it("refreshes cached codex models on demand", async () => {
it("keeps the curated Codex list when refreshing with an OpenAI key", async () => {
process.env.OPENAI_API_KEY = "sk-test";
const fetchSpy = vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [{ id: "gpt-5" }],
}),
} as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [{ id: "gpt-5.6-terra" }],
}),
} as Response);
const fetchSpy = vi.spyOn(globalThis, "fetch");
const initial = await listAdapterModels("codex_local");
const refreshed = await refreshAdapterModels("codex_local");
expect(fetchSpy).toHaveBeenCalledTimes(2);
expect(initial.some((model) => model.id === "gpt-5")).toBe(true);
expect(refreshed.some((model) => model.id === "gpt-5.6-terra")).toBe(true);
expect(refreshed.some((model) => model.id === "gpt-5.6-luna")).toBe(true);
expect(fetchSpy).not.toHaveBeenCalled();
expect(initial).toEqual(codexFallbackModels);
expect(refreshed).toEqual(codexFallbackModels);
});
it("falls back to static codex models when OpenAI model discovery fails", async () => {
it("uses static Codex models without calling OpenAI model discovery", async () => {
process.env.OPENAI_API_KEY = "sk-test";
vi.spyOn(globalThis, "fetch").mockResolvedValue({
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status: 401,
json: async () => ({}),
@@ -251,6 +237,33 @@ describe("adapter model listing", () => {
const models = await listAdapterModels("codex_local");
expect(models).toEqual(codexFallbackModels);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("uses a custom Codex adapter's model and refresh hooks", async () => {
const builtin = listServerAdapters().find((adapter) => adapter.type === "codex_local")!;
const customModels = [{ id: "plugin-codex", label: "Plugin Codex" }];
const listModels = vi.fn(async () => customModels);
const refreshModels = vi.fn(async () => customModels);
registerServerAdapter({ ...builtin, models: [], listModels, refreshModels });
try {
await expect(listAdapterModels("codex_local")).resolves.toEqual(customModels);
await expect(refreshAdapterModels("codex_local")).resolves.toEqual(customModels);
expect(listModels).toHaveBeenCalledOnce();
expect(refreshModels).toHaveBeenCalledOnce();
process.env.PAPERCLIP_ADAPTER_MODELS = JSON.stringify({
codex_local: [{ id: "declared-codex", label: "Declared Codex" }],
});
const declared = [{ id: "declared-codex", label: "Declared Codex" }];
await expect(listAdapterModels("codex_local")).resolves.toEqual(declared);
await expect(refreshAdapterModels("codex_local")).resolves.toEqual(declared);
expect(listModels).toHaveBeenCalledOnce();
expect(refreshModels).toHaveBeenCalledOnce();
} finally {
delete process.env.PAPERCLIP_ADAPTER_MODELS;
unregisterServerAdapter("codex_local");
}
});
@@ -315,6 +328,16 @@ describe("adapter model listing", () => {
]);
});
it("uses declared Codex models for both listing and refresh", async () => {
process.env.PAPERCLIP_ADAPTER_MODELS = JSON.stringify({
codex_local: [{ id: "private-codex", label: "Private Codex" }],
});
const declared = [{ id: "private-codex", label: "Private Codex" }];
await expect(listAdapterModels("codex_local")).resolves.toEqual(declared);
await expect(refreshAdapterModels("codex_local")).resolves.toEqual(declared);
});
it("observes env changes between calls (memo keyed by raw env value)", async () => {
process.env.PAPERCLIP_ADAPTER_MODELS = JSON.stringify({
opencode_local: [{ id: "model-a" }],
@@ -0,0 +1,539 @@
import fs from "node:fs/promises";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import * as executionTargetTools from "@paperclipai/adapter-utils/execution-target";
import * as ssh from "@paperclipai/adapter-utils/ssh";
const execFile = promisify(execFileCallback);
import os from "node:os";
import path from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { agentFileStore, fileHash, inspectAgentFile, snapshotAgentFiles, MAX_AGENT_FILE_BYTES, MAX_AGENT_DIRECTORY_BYTES, MAX_AGENT_DIRECTORY_ENTRIES } from "../services/agent-file-store.js";
import { agents, companies, authUsers, companyMemberships, principalPermissionGrants, heartbeatRuns, environmentLeases, environments, agentInstructionWorkingCopies, agentInstructionRevisions, agentInstructionHeads, createDb } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
import { agentInstructionWorkingCopyService, instructionWorkingCopyGuidance } from "../services/agent-instruction-working-copies.js";
import { resolveManagedInstructionsRoot } from "../services/agent-instructions.js";
import { buildNativeRuntimeContext } from "../services/native-runtime/runtime-context.js";
import type { EnvironmentRuntimeService } from "../services/environment-runtime.js";
import { remoteTerminationReceipt } from "../services/remote-execution-termination.js";
describe("persistent agent directories", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let copies: ReturnType<typeof agentInstructionWorkingCopyService>;
let revisions: ReturnType<typeof agentInstructionRevisionService>;
const previousHome = process.env.PAPERCLIP_HOME;
let home: string;
let companyId: string, agentId: string, userId: string, root: string;
const entryFile = "policy/INSTRUCTIONS.txt";
const initial = "\uFEFF# Original\r\n☃\n";
const target = () => ({ companyId, agentId });
const board = () => ({ type: "board" as const, userId, source: "session" as const });
async function run() {
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
return (await copies.prepare({ ...target(), runId, cwd: home }))!;
}
beforeAll(async () => {
home = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "instruction-working-copies-")));
process.env.PAPERCLIP_HOME = home;
database = await startEmbeddedPostgresTestDatabase("instruction-copies-db-");
db = createDb(database.connectionString);
copies = agentInstructionWorkingCopyService(db);
revisions = agentInstructionRevisionService(db);
}, 90_000);
afterAll(async () => {
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
await database?.cleanup();
if (home) {
const writable = async (dir: string) => {
await fs.chmod(dir, 0o700);
for (const entry of await fs.readdir(dir, { withFileTypes: true })) if (entry.isDirectory()) await writable(path.join(dir, entry.name));
};
await writable(home);
await fs.rm(home, { recursive: true, force: true });
}
});
beforeEach(async () => {
companyId = randomUUID(); agentId = randomUUID(); userId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Instruction tests", issuePrefix: randomUUID().slice(0, 8) });
await db.insert(authUsers).values({ id: userId, name: "Editor", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
root = resolveManagedInstructionsRoot({ ...target(), id: agentId, name: "Target", adapterConfig: {} });
await db.insert(agents).values({ id: agentId, companyId, name: "Target", adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root, instructionsEntryFile: entryFile } });
await db.insert(companyMemberships).values([
{ companyId, principalType: "user", principalId: userId, membershipRole: "operator" },
{ companyId, principalType: "agent", principalId: agentId, membershipRole: "member" },
]);
await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", principalId: userId, permissionKey: "agents:configure", scope: { agentIds: [agentId] } });
await fs.mkdir(path.dirname(path.join(root, entryFile)), { recursive: true });
await fs.writeFile(path.join(root, entryFile), initial);
});
it.each([".paperclip-runtime/state", "notes/.paperclip-runtime/state", "promptTemplate.legacy.md"])("rejects reserved board path %s before mutation", async (reserved) => {
await expect(agentFileStore(db).write({ ...target(), path: reserved, bytes: Buffer.from("reserved"), baseHash: null }, board())).rejects.toMatchObject({ status: 422 });
await expect(fs.stat(path.join(root, reserved))).rejects.toMatchObject({ code: "ENOENT" });
expect(await run()).toBeTruthy();
});
it("round trips nested text, empty directories and binary bytes independently of task files", async () => {
const first = await run();
expect(first.localRoot.startsWith(path.join(home, ".paperclip-runtime"))).toBe(false);
await fs.mkdir(path.join(first.localRoot, "notes", "empty"), { recursive: true });
await fs.writeFile(path.join(first.localRoot, "notes", "fact.txt"), "remember me");
const bytes = Buffer.from([0, 255, 17, 128, 9]);
await fs.writeFile(path.join(first.localRoot, "image.bin"), bytes);
await fs.writeFile(path.join(home, "task-only.txt"), "not personal");
expect((await copies.collectStopped({ companyId, runId: first.runId }))?.state).toBe("saved");
copies = agentInstructionWorkingCopyService(db);
const next = await run();
expect(await fs.readFile(path.join(next.localRoot, "notes", "fact.txt"), "utf8")).toBe("remember me");
expect(await fs.readFile(path.join(next.localRoot, "image.bin"))).toEqual(bytes);
expect((await fs.stat(path.join(next.localRoot, "notes", "empty"))).isDirectory()).toBe(true);
await expect(fs.stat(path.join(next.localRoot, "task-only.txt"))).rejects.toMatchObject({ code: "ENOENT" });
expect(await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, agentId))).toHaveLength(0);
});
async function sparseFile(filename: string, size: number) {
const handle = await fs.open(filename, "w");
try { await handle.truncate(size); } finally { await handle.close(); }
}
it("saves and restores files beyond the former file and folder limits, with streaming inspection and download", async () => {
const first = await run();
const size = 66 * 1024 * 1024;
await sparseFile(path.join(first.localRoot, "large.bin"), size);
expect((await copies.collectStopped({ companyId, runId: first.runId }))?.state).toBe("saved");
await copies.release(companyId, first.runId);
const next = await run();
const restored = await inspectAgentFile(next.localRoot, "large.bin");
expect(restored).toMatchObject({ size, bytes: null });
const store = agentFileStore(db);
const download = (await store.download(companyId, agentId, "large.bin", board()))!;
expect(download.size).toBe(size);
// The lock is released before consuming the stream. An editor replacement
// must not change the already opened download's bytes.
await store.write({ ...target(), path: "large.bin", bytes: Buffer.from("replacement"), baseHash: restored!.hash }, board());
const hash = createHash("sha256");
let downloaded = 0;
for await (const chunk of download.stream) { downloaded += chunk.length; hash.update(chunk); }
expect(downloaded).toBe(size);
expect(hash.digest("hex")).toBe(restored!.hash);
expect(await fs.readFile(path.join(root, "large.bin"), "utf8")).toBe("replacement");
await store.write({ ...target(), path: "empty.bin", bytes: Buffer.alloc(0), baseHash: null }, board());
const empty = (await store.download(companyId, agentId, "empty.bin", board()))!;
expect(empty.size).toBe(0);
for await (const _chunk of empty.stream) throw new Error("Empty download must have no chunks");
}, 30_000);
it("accepts the exact 256 MiB file boundary without retaining a text buffer", async () => {
expect(MAX_AGENT_FILE_BYTES).toBe(256 * 1024 * 1024);
await sparseFile(path.join(root, "boundary.bin"), MAX_AGENT_FILE_BYTES);
expect(await inspectAgentFile(root, "boundary.bin")).toMatchObject({ size: MAX_AGENT_FILE_BYTES, bytes: null });
});
it("checks quota for a small editor save without reading unrelated file bytes", async () => {
const asset = path.join(root, "large.bin");
await sparseFile(asset, MAX_AGENT_FILE_BYTES);
const open = vi.spyOn(fs, "open");
try {
await agentFileStore(db).write({ ...target(), path: "note.txt", bytes: Buffer.from("small edit"), baseHash: null }, board());
expect(open.mock.calls.some(([filename]) => filename === asset)).toBe(false);
expect(await fs.readFile(path.join(root, "note.txt"), "utf8")).toBe("small edit");
} finally { open.mockRestore(); }
});
it("reports an oversized run file without a partial save and removes its temporary copy", async () => {
const copy = await run();
await sparseFile(path.join(copy.localRoot, "too-large.bin"), MAX_AGENT_FILE_BYTES + 1);
await fs.writeFile(path.join(copy.localRoot, entryFile), "changed instructions");
const failed = await copies.collectStopped({ companyId, runId: copy.runId });
expect(failed).toMatchObject({ state: "unavailable", errorCode: "AGENT_FILES_LIMIT_EXCEEDED", candidateHash: null, nextAttemptAt: null, attempts: 1 });
expect(failed?.receipt?.storageWarning).toContain("Runs can continue");
expect(failed?.errorMessage).toContain('"too-large.bin" exceeds the 256 MiB');
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
await expect(fs.stat(path.join(root, "too-large.bin"))).rejects.toMatchObject({ code: "ENOENT" });
expect((await copies.reportUnavailable(companyId, copy.runId))?.errorCode).toBe("AGENT_FILES_LIMIT_EXCEEDED");
await copies.release(companyId, copy.runId);
await expect(fs.stat(copy.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
expect((await copies.get(companyId, copy.runId))?.receipt?.baseline).toBeUndefined();
const next = await run();
expect(await fs.readFile(path.join(next.localRoot, entryFile), "utf8")).toBe(initial);
await fs.writeFile(path.join(next.localRoot, "next-task.txt"), "still working");
expect((await copies.collectStopped({ companyId, runId: next.runId }))?.state).toBe("saved");
expect(await fs.readFile(path.join(root, "next-task.txt"), "utf8")).toBe("still working");
});
it("warns on each run at the file limit and clears the warning after ordinary agent cleanup", async () => {
await sparseFile(path.join(root, "full.bin"), MAX_AGENT_FILE_BYTES);
const first = await run();
expect(first.receipt?.storageWarning).toContain("256 MiB");
expect(instructionWorkingCopyGuidance(first)).toContain("Runs can continue");
const unchanged = await copies.collectStopped({ companyId, runId: first.runId });
expect(unchanged).toMatchObject({ state: "unchanged", errorCode: null });
expect(unchanged?.receipt?.storageWarning).toContain("Agent storage is full");
copies = agentInstructionWorkingCopyService(db);
const second = await run();
expect(second.receipt?.storageWarning).toContain("Agent storage is full");
await fs.unlink(path.join(second.localRoot, "full.bin"));
await fs.writeFile(path.join(second.localRoot, "task-output.txt"), "work continues");
expect(await copies.collectStopped({ companyId, runId: second.runId })).toMatchObject({ state: "saved", receipt: { storageWarning: null } });
const next = await run();
expect(next.receipt?.storageWarning).toBeNull();
expect(await fs.readFile(path.join(next.localRoot, "task-output.txt"), "utf8")).toBe("work continues");
}, 30_000);
it("starts successive runs with an already oversized saved file and lets the agent remove it", async () => {
await sparseFile(path.join(root, "old-large.bin"), MAX_AGENT_FILE_BYTES + 1);
const first = await run();
expect(first.state).toBe("prepared");
expect(first.receipt?.storageWarning).toContain("256 MiB");
expect(await fs.readFile(path.join(first.localRoot, entryFile), "utf8")).toBe(initial);
const stopped = await copies.collectStopped({ companyId, runId: first.runId });
expect(stopped).toMatchObject({ state: "unavailable", errorCode: "AGENT_FILES_LIMIT_EXCEEDED" });
await expect(fs.stat(first.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
copies = agentInstructionWorkingCopyService(db);
const cleanup = await run();
expect(cleanup.state).toBe("prepared");
await fs.unlink(path.join(cleanup.localRoot, "old-large.bin"));
expect(await copies.collectStopped({ companyId, runId: cleanup.runId })).toMatchObject({ state: "saved", receipt: { storageWarning: null } });
expect((await run()).receipt?.storageWarning).toBeNull();
}, 30_000);
it("starts when the existing folder exceeds its total quota and accepts agent cleanup", async () => {
for (let index = 0; index < 8; index++) await fs.writeFile(path.join(root, `quota-part-${index}.bin`), "fixture");
const lstat = fs.lstat.bind(fs);
// Keep this regression small on disk while exercising the real filesystem,
// snapshots, database receipts, restore, and merge with 2 GiB of metadata.
const sizes = vi.spyOn(fs, "lstat").mockImplementation(async (...args: Parameters<typeof fs.lstat>) => {
const stat = await lstat(...args);
if (path.basename(String(args[0])).startsWith("quota-part-")) Object.assign(stat, { size: MAX_AGENT_FILE_BYTES });
return stat;
});
try {
const first = await run();
expect(first.receipt?.storageWarning).toContain("2 GiB");
expect((await copies.collectStopped({ companyId, runId: first.runId }))?.errorCode).toBe("AGENT_FILES_LIMIT_EXCEEDED");
const cleanup = await run();
for (let index = 0; index < 8; index++) await fs.unlink(path.join(cleanup.localRoot, `quota-part-${index}.bin`));
await fs.writeFile(path.join(cleanup.localRoot, "small.txt"), "recovered");
expect(await copies.collectStopped({ companyId, runId: cleanup.runId })).toMatchObject({ state: "saved", receipt: { storageWarning: null } });
expect((await run()).receipt?.storageWarning).toBeNull();
} finally { sizes.mockRestore(); }
});
it("rejects more than 2 GiB in aggregate before hashing or saving any file", async () => {
expect(MAX_AGENT_DIRECTORY_BYTES).toBe(2 * 1024 * 1024 * 1024);
const copy = await run();
for (let index = 0; index < 8; index++) await sparseFile(path.join(copy.localRoot, `part-${index}.bin`), MAX_AGENT_FILE_BYTES);
// The eight allowed files exactly fill the quota; the entry is additional.
const result = await copies.collectStopped({ companyId, runId: copy.runId });
expect(result).toMatchObject({ state: "unavailable", errorCode: "AGENT_FILES_LIMIT_EXCEEDED" });
expect(result?.errorMessage).toContain("2 GiB total storage limit");
expect(await fs.readdir(root)).toEqual(["policy"]);
});
it("rejects excessive entry count before snapshotting", async () => {
const directory = path.join(home, "many-entries");
await fs.mkdir(directory);
await fs.writeFile(path.join(directory, "empty"), "");
const entries = await fs.readdir(directory, { withFileTypes: true });
// Exercise the walker bound without creating 100,001 physical files.
const readdir = vi.spyOn(fs, "readdir").mockResolvedValue(Array(MAX_AGENT_DIRECTORY_ENTRIES + 1).fill(entries[0]));
try {
await expect(snapshotAgentFiles(directory)).rejects.toMatchObject({ status: 422, message: expect.stringContaining("100,000-entry limit") });
} finally { readdir.mockRestore(); }
}, 30_000);
it("keeps the entry's 1 MiB limit and cleans up failed synchronization", async () => {
const copy = await run();
await fs.writeFile(path.join(copy.localRoot, entryFile), "a".repeat(1024 * 1024 + 1));
const result = await copies.collectStopped({ companyId, runId: copy.runId });
expect(result?.state).toBe("unavailable");
await expect(fs.stat(copy.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
expect(result?.errorMessage).toContain("at most 1 MiB");
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
});
it("merges independent changes and uses the last synchronization for same-file edits", async () => {
const a = await run(), b = await run();
await fs.writeFile(path.join(a.localRoot, "a.txt"), "A");
await fs.writeFile(path.join(b.localRoot, "b.txt"), "B");
await Promise.all([a, b].map(copy => copies.collectStopped({ companyId, runId: copy.runId })));
expect(await fs.readFile(path.join(root, "a.txt"), "utf8")).toBe("A");
expect(await fs.readFile(path.join(root, "b.txt"), "utf8")).toBe("B");
const c = await run(), d = await run();
await fs.writeFile(path.join(c.localRoot, "a.txt"), "C");
await fs.writeFile(path.join(d.localRoot, "a.txt"), "D");
await fs.writeFile(path.join(c.localRoot, "b.txt"), "new B");
expect((await copies.collectStopped({ companyId, runId: c.runId }))?.state).toBe("saved");
expect((await copies.collectStopped({ companyId, runId: d.runId }))?.state).toBe("saved");
expect(await fs.readFile(path.join(root, "a.txt"), "utf8")).toBe("D");
expect(await fs.readFile(path.join(root, "b.txt"), "utf8")).toBe("new B");
expect(await copies.list(companyId, agentId, board())).toEqual([]);
for (const copy of [a, b, c, d]) await expect(fs.stat(path.dirname(copy.localRoot))).rejects.toMatchObject({ code: "ENOENT" });
expect(await fs.readdir(path.join(path.dirname(root), "file-sync", "runs"))).toEqual([]);
});
it("makes concurrent edit/delete races last-sync-wins and preserves unrelated new files", async () => {
await fs.mkdir(path.join(root, "notes"));
await fs.writeFile(path.join(root, "notes", "old.txt"), "old");
const deleting = await run(), editing = await run();
await fs.rm(path.join(deleting.localRoot, "notes"), { recursive: true });
await fs.writeFile(path.join(editing.localRoot, "notes", "old.txt"), "edited");
await fs.writeFile(path.join(editing.localRoot, "notes", "new.txt"), "unrelated");
await copies.collectStopped({ companyId, runId: editing.runId });
expect((await copies.collectStopped({ companyId, runId: deleting.runId }))?.state).toBe("saved");
await expect(fs.stat(path.join(root, "notes", "old.txt"))).rejects.toMatchObject({ code: "ENOENT" });
expect(await fs.readFile(path.join(root, "notes", "new.txt"), "utf8")).toBe("unrelated");
const remove = await run(), change = await run();
await fs.unlink(path.join(remove.localRoot, "notes", "new.txt"));
await fs.writeFile(path.join(change.localRoot, "notes", "new.txt"), "restored by later edit");
await copies.collectStopped({ companyId, runId: remove.runId });
await copies.collectStopped({ companyId, runId: change.runId });
expect(await fs.readFile(path.join(root, "notes", "new.txt"), "utf8")).toBe("restored by later edit");
});
it("handles a concurrently replaced parent and keeps the later file change", async () => {
await fs.mkdir(path.join(root, "notes"));
await fs.writeFile(path.join(root, "notes", "fact.txt"), "old");
const replacing = await run(), editing = await run();
await fs.rm(path.join(replacing.localRoot, "notes"), { recursive: true });
await fs.writeFile(path.join(replacing.localRoot, "notes"), "now a file");
await fs.writeFile(path.join(editing.localRoot, "notes", "fact.txt"), "new fact");
await copies.collectStopped({ companyId, runId: replacing.runId });
expect(await fs.readFile(path.join(root, "notes"), "utf8")).toBe("now a file");
expect((await copies.collectStopped({ companyId, runId: editing.runId }))?.state).toBe("saved");
expect(await fs.readFile(path.join(root, "notes", "fact.txt"), "utf8")).toBe("new fact");
});
it("cleans stopped copies after a crash during cleanup without touching active copies", async () => {
const finished = await run(), active = await run();
await db.update(agentInstructionWorkingCopies).set({ state: "saved", processStoppedAt: new Date() }).where(eq(agentInstructionWorkingCopies.runId, finished.runId));
copies = agentInstructionWorkingCopyService(db);
await copies.recoverCaptured();
await expect(fs.stat(finished.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
expect((await fs.stat(active.localRoot)).isDirectory()).toBe(true);
});
it("cleans up a staging failure before any provider starts", async () => {
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
const shell = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 1, signal: null, timedOut: false, stdout: "", stderr: "fixture failure" });
try {
await expect(copies.prepare({ ...target(), runId, cwd: home, target: { kind: "remote", transport: "ssh", environmentId: randomUUID(), remoteCwd: "/fixture/task",
spec: { host: "unused.invalid", port: 22, username: "test", remoteCwd: "/fixture/task" } } })).rejects.toThrow("Could not exclude");
const row = (await copies.get(companyId, runId))!;
expect(row.state).toBe("unavailable");
expect(row.receipt?.baseline).toBeUndefined();
await expect(fs.stat(path.dirname(row.localRoot))).rejects.toMatchObject({ code: "ENOENT" });
} finally { shell.mockRestore(); }
});
it.each(["succeeded", "failed", "cancelled", "timed_out", "interrupted"])("registers staging ownership before copying and cleans preparation for a %s run after restart", async (status) => {
const original = fs.cp.bind(fs);
const copy = vi.spyOn(fs, "cp").mockImplementationOnce(async (source, destination, options) => {
const rows = await db.select().from(agentInstructionWorkingCopies).where(eq(agentInstructionWorkingCopies.agentId, agentId));
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ state: "preparing", localRoot: destination });
return original(source, destination, options);
});
const interrupted = await run();
copy.mockRestore();
const active = await run();
for (const row of [interrupted, active]) {
await db.update(agentInstructionWorkingCopies).set({ state: "preparing", baseHash: "preparing", receipt: { schema: "paperclip.agent-files.v1" } }).where(eq(agentInstructionWorkingCopies.runId, row.runId));
}
await db.update(heartbeatRuns).set({ status }).where(eq(heartbeatRuns.id, interrupted.runId));
copies = agentInstructionWorkingCopyService(db);
await copies.recoverStopped();
expect((await copies.get(companyId, interrupted.runId))?.state).toBe("unavailable");
await expect(fs.stat(path.dirname(interrupted.localRoot))).rejects.toMatchObject({ code: "ENOENT" });
expect((await fs.stat(active.localRoot)).isDirectory()).toBe(true);
});
it("retains remote cleanup across restart and failed retries until the original lease is cleaned", async () => {
const copy = await run();
const environmentId = randomUUID(), leaseId = randomUUID(), remoteCwd = "/fixture/task";
const executionRoot = path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", agentId, copy.runId);
await db.insert(environments).values({ id: environmentId, name: environmentId, driver: "sandbox" });
await db.insert(environmentLeases).values({ id: leaseId, companyId, environmentId, heartbeatRunId: copy.runId, provider: "daytona", providerLeaseId: "original-sandbox" });
await db.update(agentInstructionWorkingCopies).set({ state: "saved", processStoppedAt: new Date(), location: `remote:${environmentId}`, executionRoot,
receipt: { ...copy.receipt, cleanup: { leaseId, remoteCwd } } }).where(eq(agentInstructionWorkingCopies.runId, copy.runId));
copies = agentInstructionWorkingCopyService(db);
await copies.recoverCaptured();
const pending = (await copies.get(companyId, copy.runId))!;
expect(pending.receipt).toMatchObject({ cleanupPending: true, cleanup: { leaseId, remoteCwd } });
expect(pending.receipt?.baseline).toBeUndefined();
await expect(fs.stat(copy.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
const execute = vi.fn().mockRejectedValueOnce(new Error("provider temporarily unavailable")).mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" });
copies = agentInstructionWorkingCopyService(db, { environmentRuntime: { execute } as unknown as EnvironmentRuntimeService });
for (const expectedPending of [true, false]) {
await db.update(agentInstructionWorkingCopies).set({ nextAttemptAt: null }).where(eq(agentInstructionWorkingCopies.runId, copy.runId));
await copies.recoverCaptured();
expect((await copies.get(companyId, copy.runId))?.receipt?.cleanupPending).toBe(expectedPending);
}
expect(execute).toHaveBeenCalledTimes(2);
expect(execute).toHaveBeenLastCalledWith(expect.objectContaining({ lease: expect.objectContaining({ id: leaseId, providerLeaseId: "original-sandbox" }),
command: "rm", args: ["-rf", "--", executionRoot], bypassSession: true }));
await copies.recoverCaptured();
expect(execute).toHaveBeenCalledTimes(2);
await copies.release(companyId, copy.runId);
expect((await copies.get(companyId, copy.runId))?.receipt?.cleanupPending).toBe(false);
expect(execute).toHaveBeenCalledTimes(2);
});
it("finishes remote cleanup from a destruction receipt after the environment is deleted", async () => {
const copy = await run();
const environmentId = randomUUID(), leaseId = randomUUID(), remoteCwd = "/fixture/task";
const lease = { id: leaseId, companyId, environmentId, heartbeatRunId: copy.runId, provider: "daytona", providerLeaseId: "destroyed-sandbox" };
await db.insert(environments).values({ id: environmentId, name: environmentId, driver: "sandbox" });
await db.insert(environmentLeases).values({ ...lease, status: "released", releasedAt: new Date(), cleanupStatus: "success",
metadata: { remoteExecutionTermination: remoteTerminationReceipt(lease, { providerLeaseId: lease.providerLeaseId, state: "destroyed" }) } });
await db.update(agentInstructionWorkingCopies).set({ state: "saved", processStoppedAt: new Date(), location: `remote:${environmentId}`,
executionRoot: path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", agentId, copy.runId),
receipt: { ...copy.receipt, cleanupPending: true, cleanup: { leaseId, remoteCwd } } }).where(eq(agentInstructionWorkingCopies.runId, copy.runId));
await db.delete(environments).where(eq(environments.id, environmentId));
copies = agentInstructionWorkingCopyService(db);
await copies.recoverCaptured();
expect((await copies.get(companyId, copy.runId))?.receipt?.cleanupPending).toBe(false);
await expect(fs.stat(copy.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
});
it.each([false, true])("rejects symlinks without saving any part of the tree, preserving an existing full-storage warning: %s", async (full) => {
if (full) await sparseFile(path.join(root, "full.bin"), MAX_AGENT_FILE_BYTES);
const copy = await run();
await fs.writeFile(path.join(copy.localRoot, "innocent.txt"), "changed");
await fs.symlink(path.join(home, "outside"), path.join(copy.localRoot, "escape"));
const result = await copies.collectStopped({ companyId, runId: copy.runId });
expect(result?.state).toBe("unavailable");
expect(result?.errorCode).toBe("AGENT_FILES_SAVE_FAILED");
expect(result?.receipt?.storageWarning).toBe(copy.receipt?.storageWarning);
if (full) expect(result?.receipt?.storageWarning).toContain("Agent storage is full");
await expect(fs.stat(copy.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
await expect(fs.stat(path.join(root, "innocent.txt"))).rejects.toMatchObject({ code: "ENOENT" });
}, 30_000);
it("adopts the last deployed head once and bridges an old collector without appending history", async () => {
const id = randomUUID();
const content = "last deployed revision";
await db.insert(agentInstructionRevisions).values({ id, ...target(), entryFile, contentBase64: Buffer.from(content).toString("base64"), contentHash: fileHash(Buffer.from(content)), byteLength: content.length, source: "board" });
await db.insert(agentInstructionHeads).values({ ...target(), entryFile, revisionId: id });
const current = await revisions.readCurrent(target(), board());
expect(current?.content).toBe(content);
await revisions.commit({ ...target(), entryFile, content: "new directory contents", baseRevisionId: id, source: "cleanup" }, board());
await revisions.materializeCurrent(target());
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe("new directory contents");
await expect(revisions.commit({ ...target(), entryFile, content: "stale legacy run", baseRevisionId: id, source: "cleanup" }, board())).rejects.toMatchObject({ status: 409 });
expect(await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, agentId))).toHaveLength(1);
});
it("ordinary file edits leave the loaded instruction digest unchanged", async () => {
const make = async () => {
const copy = await run();
const context = await buildNativeRuntimeContext({ db, agent: { id: agentId, companyId, name: "Target", adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root, instructionsEntryFile: entryFile } },
runId: copy.runId, runtimeConfig: {}, runtimeSkillEntries: [], instructionWorkingCopy: { rootPath: copy.executionRoot, entryPath: entryFile, kind: "agent_files" } });
return { copy, context };
};
const before = await make();
await fs.writeFile(path.join(before.copy.localRoot, "notes.txt"), "new personal knowledge");
await copies.collectStopped({ companyId, runId: before.copy.runId });
const after = await make();
expect(after.context.aggregateDigest).toBe(before.context.aggregateDigest);
expect(after.context.instructions.bundle.fileCount).toBe(1);
});
it("uses the workspace transport to restore after destruction of the remote filesystem", async () => {
const remoteCwd = path.join(home, "remote-task");
await fs.mkdir(remoteCwd, { recursive: true });
await execFile("git", ["init", remoteCwd]);
const runner: import("@paperclipai/adapter-utils/command-managed-runtime").CommandManagedRuntimeRunner = {
execute: async input => {
const startedAt = new Date().toISOString();
const env = { ...process.env, ...input.env };
const args = [...(input.args ?? [])];
if (input.stdin != null && (args[0] === "-c" || args[0] === "-lc")) {
env.PAPERCLIP_TEST_STDIN = input.stdin;
args[1] = `printf '%s' "$PAPERCLIP_TEST_STDIN" | (${args[1]})`;
}
try {
const result = await execFile(input.command, args, { cwd: input.cwd, env, timeout: input.timeoutMs, maxBuffer: 32 * 1024 * 1024 });
return { exitCode: 0, signal: null, timedOut: false, stdout: result.stdout, stderr: result.stderr, pid: null, startedAt };
} catch (error) {
const e = error as { code?: number; signal?: NodeJS.Signals; stdout?: string; stderr?: string };
return { exitCode: typeof e.code === "number" ? e.code : 1, signal: e.signal ?? null, timedOut: false, stdout: e.stdout ?? "", stderr: e.stderr ?? "", pid: null, startedAt };
}
},
};
const executionTarget = { kind: "remote" as const, transport: "sandbox" as const, environmentId: randomUUID(), remoteCwd, runner };
const prepare = async () => {
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
return (await copies.prepare({ ...target(), runId, cwd: home, target: executionTarget }))!;
};
await fs.mkdir(path.join(root, "build"));
await fs.writeFile(path.join(root, "build", "personal.txt"), "cache-like names are still agent files");
const first = await prepare();
expect(await fs.readFile(path.join(first.executionRoot, "build", "personal.txt"), "utf8")).toBe("cache-like names are still agent files");
await fs.mkdir(path.join(first.executionRoot, "notes"));
await fs.writeFile(path.join(first.executionRoot, "notes", "bytes.bin"), Buffer.from([0, 128, 255]));
await fs.mkdir(path.join(first.executionRoot, "node_modules"));
await fs.writeFile(path.join(first.executionRoot, "node_modules", "personal.txt"), "retain this too");
await fs.writeFile(path.join(remoteCwd, "task-only.txt"), "task");
expect((await execFile("git", ["-C", remoteCwd, "status", "--porcelain", "--untracked-files=all"])).stdout).toBe("?? task-only.txt\n");
expect((await copies.collectStopped({ companyId, runId: first.runId, target: executionTarget }))?.state).toBe("saved");
await copies.release(companyId, first.runId);
expect((await copies.get(companyId, first.runId))?.receipt?.baseline).toBeUndefined();
await expect(fs.stat(first.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
await fs.rm(remoteCwd, { recursive: true });
await fs.mkdir(remoteCwd);
copies = agentInstructionWorkingCopyService(db);
const second = await prepare();
expect(await fs.readFile(path.join(second.executionRoot, "notes", "bytes.bin"))).toEqual(Buffer.from([0,128,255]));
expect(await fs.readFile(path.join(second.executionRoot, "node_modules", "personal.txt"), "utf8")).toBe("retain this too");
await expect(fs.stat(path.join(second.executionRoot, "task-only.txt"))).rejects.toMatchObject({ code: "ENOENT" });
await copies.collectStopped({ companyId, runId: second.runId, target: executionTarget });
await copies.release(companyId, second.runId);
});
it("stages SSH agent files at the registered root without a nested task workspace", async () => {
const remoteCwd = path.join(home, "ssh-task");
const exclude = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "" });
const stage = vi.spyOn(ssh, "syncDirectoryToSsh").mockImplementation(async input => {
await fs.mkdir(path.dirname(input.remoteDir), { recursive: true });
await fs.cp(input.localDir, input.remoteDir, { recursive: true });
});
const restore = vi.spyOn(ssh, "restoreWorkspaceFromSshExecution").mockImplementation(async input => {
await fs.cp(input.remoteDir!, input.localDir, { recursive: true });
});
try {
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
const target = { kind: "remote" as const, transport: "ssh" as const, environmentId: randomUUID(), remoteCwd,
spec: { host: "unused.invalid", port: 22, username: "test", remoteCwd } };
const copy = (await copies.prepare({ companyId, agentId, runId, cwd: home, target }))!;
expect(stage).toHaveBeenCalledWith(expect.objectContaining({ remoteDir: copy.executionRoot }));
expect(await fs.readFile(path.join(copy.executionRoot, entryFile), "utf8")).toBe(initial);
await fs.writeFile(path.join(copy.executionRoot, "ssh-note.txt"), "persistent SSH file");
expect((await copies.collectStopped({ companyId, runId, target }))?.state).toBe("saved");
expect(restore).toHaveBeenCalledWith(expect.objectContaining({ remoteDir: copy.executionRoot, restoreGitHistory: false }));
expect(await fs.readFile(path.join(root, "ssh-note.txt"), "utf8")).toBe("persistent SSH file");
} finally { stage.mockRestore(); restore.mockRestore(); exclude.mockRestore(); }
});
it("deduplicates concurrent stopped callbacks and discards completed operational snapshots", async () => {
const copy = await run();
await fs.writeFile(path.join(copy.localRoot, "note.txt"), "one write");
const results = await Promise.all([copies.collectStopped({ companyId, runId: copy.runId }), copies.collectStopped({ companyId, runId: copy.runId })]);
expect(results.every(result => result?.state === "saved")).toBe(true);
// Simulate a crash after save but before release. The restart sweeper must
// reclaim operational baselines without touching canonical files.
copies = agentInstructionWorkingCopyService(db);
await copies.recoverCaptured();
const row = await copies.get(companyId, copy.runId);
expect(row?.receipt?.schema).toBe("paperclip.agent-files.v1");
expect(row?.receipt?.baseline).toBeUndefined();
expect(await fs.readFile(path.join(root, "note.txt"), "utf8")).toBe("one write");
});
});
@@ -0,0 +1,87 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import { afterEach, beforeEach, expect, it } from "vitest";
import { instructionGitExcludeProgram } from "../services/agent-instruction-files.js";
const execFile = promisify(execFileCallback);
let root: string;
beforeEach(async () => { root = await fs.mkdtemp(path.join(os.tmpdir(), "agent-git-exclusion-")); });
afterEach(async () => { await fs.rm(root, { recursive: true, force: true }); });
const git = (cwd: string, ...args: string[]) => execFile("git", ["-C", cwd, ...args]);
const exclude = (cwd: string) => execFile(process.execPath, ["-e", instructionGitExcludeProgram, cwd]);
async function assertPrivateFilesIgnored(cwd: string) {
const runtime = path.join(cwd, ".paperclip-runtime");
await fs.writeFile(path.join(runtime, "private.md"), "agent instructions");
await git(cwd, "add", "-A");
expect((await git(cwd, "diff", "--cached", "--name-only")).stdout).not.toContain(".paperclip-runtime");
expect((await git(cwd, "check-ignore", "--", ".paperclip-runtime/private.md", ".paperclip-runtime/.gitignore")).stdout.trim().split("\n")).toHaveLength(2);
}
it("excludes runtime files without changing tracked ignore rules or Git metadata", async () => {
await git(root, "init");
await fs.writeFile(path.join(root, ".gitignore"), "keep-me\n");
const metadata = path.join(root, ".git", "info", "exclude");
const before = await fs.readFile(metadata, "utf8");
await exclude(root);
await exclude(root);
await assertPrivateFilesIgnored(root);
expect(await fs.readFile(path.join(root, ".gitignore"), "utf8")).toBe("keep-me\n");
expect(await fs.readFile(metadata, "utf8")).toBe(before);
});
it("never writes to an external gitdir selected by task-controlled metadata", async () => {
const workspace = path.join(root, "workspace"), outside = path.join(root, "outside.git");
await fs.mkdir(workspace);
await git(workspace, "init", `--separate-git-dir=${outside}`);
const metadata = path.join(outside, "info", "exclude");
const before = await fs.readFile(metadata, "utf8");
await exclude(workspace);
await assertPrivateFilesIgnored(workspace);
expect(await fs.readFile(metadata, "utf8")).toBe(before);
});
it("supports linked worktrees and nested task directories without changing the common gitdir", async () => {
const repository = path.join(root, "repository"), worktree = path.join(root, "worktree");
await fs.mkdir(repository);
await git(repository, "init");
await git(repository, "-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "--allow-empty", "-m", "initial");
await git(repository, "worktree", "add", "--detach", worktree);
const nested = path.join(worktree, "nested");
await fs.mkdir(nested);
const metadata = path.join(repository, ".git", "info", "exclude");
const before = await fs.readFile(metadata, "utf8");
await exclude(nested);
await assertPrivateFilesIgnored(nested);
expect(await fs.readFile(metadata, "utf8")).toBe(before);
});
it.each(["directory", "file"])("rejects a symlink at the runtime exclusion %s", async (kind) => {
const workspace = path.join(root, "workspace"), outside = path.join(root, "outside");
await fs.mkdir(workspace);
await fs.mkdir(outside);
const externalFile = path.join(outside, ".gitignore");
await fs.writeFile(externalFile, "untouched\n");
const runtime = path.join(workspace, ".paperclip-runtime");
if (kind === "directory") await fs.symlink(outside, runtime);
else {
await fs.mkdir(runtime);
await fs.symlink(externalFile, path.join(runtime, ".gitignore"));
}
await expect(exclude(workspace)).rejects.toThrow("Unsafe runtime exclusion");
expect(await fs.readFile(externalFile, "utf8")).toBe("untouched\n");
});
it("replaces a linked ignore file without writing through its external inode", async () => {
const externalFile = path.join(root, "outside-ignore"), workspace = path.join(root, "workspace");
const runtime = path.join(workspace, ".paperclip-runtime");
await fs.mkdir(runtime, { recursive: true });
await fs.writeFile(externalFile, "untouched\n");
await fs.link(externalFile, path.join(runtime, ".gitignore"));
await exclude(workspace);
expect(await fs.readFile(externalFile, "utf8")).toBe("untouched\n");
expect(await fs.readFile(path.join(runtime, ".gitignore"), "utf8")).toBe("*\n");
});
@@ -0,0 +1,247 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { beforeAll, afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { and, eq, sql } from "drizzle-orm";
import { agents, agentApiKeys, companies, authUsers, companyMemberships, principalPermissionGrants, heartbeatRuns,
agentInstructionRevisions, agentInstructionHeads, issueThreadInteractions, issues, createDb } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
import { agentInstructionWorkingCopyService } from "../services/agent-instruction-working-copies.js";
import { instructionBytes, instructionPath, materializeInstructionBytes, readInstructionBytes } from "../services/agent-instruction-files.js";
import { agentInstructionsService, resolveManagedInstructionsRoot } from "../services/agent-instructions.js";
import type { AuthorizationActor } from "../services/authorization.js";
import { upsertAgentInstructionsFileSchema } from "@paperclipai/shared";
describe("canonical instruction revisions", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let service: ReturnType<typeof agentInstructionRevisionService>;
const previousHome = process.env.PAPERCLIP_HOME;
let home: string;
let companyId: string, agentId: string, userId: string, actorId: string, runId: string, root: string;
let actor: AuthorizationActor;
const entryFile = "policy/INSTRUCTIONS.txt";
const initial = "\uFEFF# Original\r\n☃\0\n";
beforeAll(async () => {
home = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "instruction-revisions-")));
process.env.PAPERCLIP_HOME = home;
database = await startEmbeddedPostgresTestDatabase("instruction-revisions-db-");
db = createDb(database.connectionString);
service = agentInstructionRevisionService(db);
}, 90_000);
afterAll(async () => {
vi.restoreAllMocks();
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
await database?.cleanup();
if (home) await fs.rm(home, { recursive: true, force: true });
});
beforeEach(async () => {
companyId = randomUUID(); agentId = randomUUID(); actorId = randomUUID(); userId = randomUUID(); runId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Instruction tests", issuePrefix: randomUUID().slice(0, 8) });
await db.insert(authUsers).values({ id: userId, name: "Editor", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
root = resolveManagedInstructionsRoot({ id: agentId, companyId, name: "Target", adapterConfig: {} });
await db.insert(agents).values([
{ id: agentId, companyId, name: "Target", adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root, instructionsEntryFile: entryFile } },
{ id: actorId, companyId, name: "Writer" },
]);
await db.insert(companyMemberships).values([
{ companyId, principalType: "user", principalId: userId, membershipRole: "operator" },
{ companyId, principalType: "agent", principalId: actorId, membershipRole: "member" },
]);
await db.insert(principalPermissionGrants).values([
{ companyId, principalType: "user", principalId: userId, permissionKey: "agents:configure", scope: { agentIds: [agentId] } },
{ companyId, principalType: "agent", principalId: actorId, permissionKey: "agents:configure", scope: { agentIds: [agentId] } },
]);
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId: actorId, invocationSource: "on_demand", responsibleUserId: userId });
actor = { type: "agent", companyId, agentId: actorId, runId, source: "agent_jwt" };
await fs.mkdir(path.dirname(path.join(root, entryFile)), { recursive: true });
await fs.writeFile(path.join(root, entryFile), initial);
});
const target = () => ({ companyId, agentId });
async function base() { return (await service.readCurrent(target(), actor))!; }
async function save(content: string, baseRevisionId: string | null) {
return service.commit({ ...target(), entryFile, content, baseRevisionId, source: "cleanup" }, actor);
}
it.each(["current", "history", "revision", "diff", "candidates"])("denies peer %s reads without a configuration grant or responsible-user target access", async (operation) => {
const first = await base();
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, userId));
const read = () => {
if (operation === "current") return service.readCurrent(target(), actor);
if (operation === "history") return service.history({ ...target(), entryFile }, actor);
if (operation === "revision") return service.readRevision({ ...target(), entryFile, revisionId: first.revision.id }, actor);
if (operation === "diff") return service.diff({ ...target(), entryFile, fromRevisionId: first.revision.id, toRevisionId: first.revision.id }, actor);
return agentInstructionWorkingCopyService(db).list(companyId, agentId, actor);
};
await expect(read()).rejects.toMatchObject({ status: 403 });
await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", principalId: userId, permissionKey: "agents:configure", scope: { agentIds: [actorId] } });
await expect(read()).rejects.toMatchObject({ status: 403 });
});
it.each(["current", "history", "revision", "diff", "candidates", "write"])("requires the caller's own target-scoped grant for peer %s access", async (operation) => {
const first = await base();
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, actorId));
const access = () => {
if (operation === "current") return service.readCurrent(target(), actor);
if (operation === "history") return service.history({ ...target(), entryFile }, actor);
if (operation === "revision") return service.readRevision({ ...target(), entryFile, revisionId: first.revision.id }, actor);
if (operation === "diff") return service.diff({ ...target(), entryFile, fromRevisionId: first.revision.id, toRevisionId: first.revision.id }, actor);
if (operation === "write") return save("authorized peer edit", first.revision.id);
return agentInstructionWorkingCopyService(db).list(companyId, agentId, actor);
};
// The responsible user retains permission on the target throughout.
await expect(access()).rejects.toMatchObject({ status: 403 });
await db.insert(principalPermissionGrants).values({ companyId, principalType: "agent", principalId: actorId, permissionKey: "agents:configure", scope: { agentIds: [actorId] } });
await expect(access()).rejects.toMatchObject({ status: 403 });
await db.update(principalPermissionGrants).set({ scope: { agentIds: [agentId] } }).where(eq(principalPermissionGrants.principalId, actorId));
// New saves have no historical revision row; authorized history lookups
// reach the normal not-found response rather than an authorization denial.
if (operation === "revision" || operation === "diff") await expect(access()).rejects.toMatchObject({ status: 404 });
else await expect(access()).resolves.toBeDefined();
});
it("allows self and board reads without requiring instruction edit permission", async () => {
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, userId));
const selfRoot = resolveManagedInstructionsRoot({ id: actorId, companyId, name: "Writer", adapterConfig: {} });
await fs.mkdir(selfRoot, { recursive: true });
await fs.writeFile(path.join(selfRoot, "AGENTS.md"), "Self instructions");
expect((await service.readCurrent({ companyId, agentId: actorId }, actor))?.content).toBe("Self instructions");
expect((await service.readCurrent(target(), { type: "board", userId, source: "session" }))?.content).toBe(initial);
});
it("reads and replaces current bytes using content tokens without storing revisions", async () => {
const first = await base();
expect(first.content).toBe(initial);
const saved = await save("# Changed\r\n", first.revision.id);
expect(saved).toMatchObject({ changed: true, materialization: "current" });
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(saved.content);
expect((await save(saved.content, first.revision.id)).changed).toBe(false);
expect((await service.history({ ...target(), entryFile }, actor)).revisions).toHaveLength(0);
expect(await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, agentId))).toHaveLength(0);
});
it("requires a matching initial content token and serializes competing saves", async () => {
await expect(save("without reading", null)).rejects.toMatchObject({ status: 409 });
const first = await base();
const results = await Promise.allSettled([save("one", first.revision.id), save("two", first.revision.id)]);
expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1);
expect(results.find(result => result.status === "rejected")).toMatchObject({ reason: { status: 409 } });
});
it("does not claim a save when atomic file replacement fails", async () => {
const first = await base();
const rename = vi.spyOn(fs, "rename").mockRejectedValueOnce(new Error("interrupted replacement"));
await expect(save("new content", first.revision.id)).rejects.toThrow("interrupted replacement");
rename.mockRestore();
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
service = agentInstructionRevisionService(db);
await service.materializeCurrent(target());
expect((await base()).content).toBe(initial);
expect((await save("new content", first.revision.id)).content).toBe("new content");
});
it("allows authorized peer edits but rejects missing, forged and revoked responsible identity", async () => {
const first = await base();
await save("peer save", first.revision.id);
await expect(service.commit({ ...target(), entryFile, content: "forged", baseRevisionId: first.revision.id, source: "tool" }, { ...actor, onBehalfOfUserId: "forged" })).rejects.toMatchObject({ status: 403 });
await db.update(heartbeatRuns).set({ responsibleUserId: null }).where(eq(heartbeatRuns.id, runId));
await expect(save("missing", first.revision.id)).rejects.toMatchObject({ status: 403 });
await db.update(heartbeatRuns).set({ responsibleUserId: userId }).where(eq(heartbeatRuns.id, runId));
await db.update(companyMemberships).set({ status: "suspended" }).where(and(eq(companyMemberships.principalId, userId), eq(companyMemberships.companyId, companyId)));
await expect(save("revoked", first.revision.id)).rejects.toMatchObject({ status: 403 });
});
it("rechecks user target scope and agent containment, even with shadow authorization enabled", async () => {
const first = await base();
const previousShadow = process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_SHADOW;
process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_SHADOW = "true";
await db.update(principalPermissionGrants).set({ scope: { agentIds: [actorId] } }).where(eq(principalPermissionGrants.principalId, userId));
await expect(save("denied", first.revision.id)).rejects.toMatchObject({ status: 403 });
await db.update(principalPermissionGrants).set({ scope: null }).where(eq(principalPermissionGrants.principalId, userId));
await db.update(agents).set({ permissions: { trustPreset: "low_trust_review" } }).where(eq(agents.id, actorId));
await expect(save("restricted", first.revision.id)).rejects.toMatchObject({ status: 403 });
if (previousShadow === undefined) delete process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_SHADOW; else process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_SHADOW = previousShadow;
});
it("keeps suggest-only protected-change consent and explicit configure scope restrictions", async () => {
const first = await base();
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, actorId));
await db.insert(principalPermissionGrants).values({ companyId, principalType: "agent", principalId: actorId, permissionKey: "agents:suggest-changes" });
await expect(save("needs approval", first.revision.id)).rejects.toMatchObject({ status: 403 });
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, actorId));
await db.insert(principalPermissionGrants).values({ companyId, principalType: "agent", principalId: actorId, permissionKey: "agents:configure", scope: { agentIds: [actorId] } });
await expect(save("out of scope", first.revision.id)).rejects.toMatchObject({ status: 403 });
});
it("consumes accepted protected-change approval only with a successful CAS commit", async () => {
const first = await base();
const sourceRunId = randomUUID(), issueId = randomUUID(), interactionId = randomUUID();
await db.insert(heartbeatRuns).values({ id: sourceRunId, companyId, agentId: actorId, status: "succeeded" });
await db.insert(issues).values({ id: issueId, companyId, title: "Review instructions" });
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, actorId));
await db.insert(principalPermissionGrants).values({ companyId, principalType: "agent", principalId: actorId, permissionKey: "agents:suggest-changes" });
await db.insert(issueThreadInteractions).values({ id: interactionId, companyId, issueId, kind: "request_confirmation", status: "accepted", sourceRunId, createdByAgentId: actorId,
payload: { version: 1, prompt: "Apply the instruction change?", detailsMarkdown: "```diff\n+approved\n```", target: { type: "custom", key: `agent:${agentId}:instructions`, revisionId: "proposal" } },
result: { version: 1, outcome: "accepted" }, resolvedByUserId: userId, resolvedAt: new Date(),
});
expect((await service.readCurrent(target(), actor))?.revision.id).toBe(first.revision.id);
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId)))[0].result).not.toHaveProperty("consumedAt");
await expect(save("approved", randomUUID())).rejects.toMatchObject({ status: 409 });
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId)))[0].result).not.toHaveProperty("consumedAt");
const receipt = await save("approved", first.revision.id);
expect(receipt.changed).toBe(true);
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId)))[0].result).toMatchObject({ consumedByRunId: runId });
await expect(save("another unapproved change", receipt.revision.id)).rejects.toMatchObject({ status: 403 });
});
it("shares head/CAS and attribution between board and agent writers", async () => {
const first = await base();
const board = { type: "board", userId, source: "session" } as const;
const saved = await service.commit({ ...target(), entryFile, content: "board edit", baseRevisionId: first.revision.id, source: "board" }, board);
expect(saved.revision).toMatchObject({ actorUserId: userId, responsibleUserId: userId, actorAgentId: null, sourceRunId: null, source: "api" });
await expect(save("stale agent edit", first.revision.id)).rejects.toMatchObject({ status: 409 });
});
it("seeds a nested legacy managed entry and never imports a different AGENTS.md", async () => {
await fs.writeFile(path.join(root, "AGENTS.md"), "unrelated");
await db.update(agents).set({ adapterConfig: { instructionsFilePath: path.join(root, entryFile) } }).where(eq(agents.id, agentId));
const first = await base();
expect(first.content).toBe(initial);
expect(first.revision.entryFile).toBe(entryFile);
await db.update(agents).set({ adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root, instructionsEntryFile: "missing.md" } }).where(eq(agents.id, agentId));
expect(await service.readCurrent(target(), actor)).toBeNull();
await expect(save("wrong entry", first.revision.id)).rejects.toMatchObject({ status: 409, details: { code: "INSTRUCTION_ENTRY_CHANGED" } });
});
it("rejects cross-company targets, foreign revisions, and revoked API keys", async () => {
const first = await base();
await expect(service.readRevision({ ...target(), entryFile, revisionId: randomUUID() }, actor)).rejects.toMatchObject({ status: 404 });
await expect(service.readCurrent({ companyId: randomUUID(), agentId }, actor)).rejects.toMatchObject({ status: 404 });
const keyId = randomUUID();
await db.insert(agentApiKeys).values({ id: keyId, companyId, agentId: actorId, name: "test", keyHash: randomUUID(), responsibleUserId: userId, revokedAt: new Date() });
await expect(service.commit({ ...target(), entryFile, content: "key", baseRevisionId: first.revision.id, source: "api" }, { ...actor, keyId })).rejects.toMatchObject({ status: 403 });
await expect(db.insert(agentInstructionHeads).values({ companyId: randomUUID(), agentId, entryFile, revisionId: first.revision.id })).rejects.toThrow();
});
it("rejects invalid content, symlinks, traversal and external bundles without changing canonical bytes", async () => {
const first = await base();
expect(() => instructionBytes("\ud800")).toThrow("UTF-8");
expect(() => instructionBytes(new Uint8Array([0xff]))).toThrow("UTF-8");
expect(() => instructionBytes("a".repeat(1024 * 1024 + 1))).toThrow("1 MiB");
for (const value of ["../AGENTS.md", "/etc/passwd", "a/../AGENTS.md", "a\\b", "a//b"]) expect(() => instructionPath(value)).toThrow();
await fs.unlink(path.join(root, entryFile));
await fs.symlink(path.join(home, "outside"), path.join(root, entryFile));
await expect(save("symlink", first.revision.id)).rejects.toMatchObject({ status: 422 });
await fs.unlink(path.join(root, entryFile));
await fs.rm(path.dirname(path.join(root, entryFile)), { recursive: true });
await fs.symlink(home, path.dirname(path.join(root, entryFile)));
await expect(readInstructionBytes(root, entryFile)).rejects.toMatchObject({ status: 422 });
await db.update(agents).set({ adapterConfig: { instructionsBundleMode: "external", instructionsRootPath: home } }).where(eq(agents.id, agentId));
await expect(save("external", first.revision.id)).rejects.toMatchObject({ details: { code: "INSTRUCTION_MANAGED_BUNDLE_REQUIRED" } });
expect((await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, agentId)))).toHaveLength(0);
});
it.skipIf(process.platform !== "darwin")("accepts macOS temporary directory aliases while rejecting links within storage", async () => {
const aliasRoot = home.replace(/^\/private\/(var|tmp)\//, "/$1/");
expect(aliasRoot).not.toBe(home);
await materializeInstructionBytes(aliasRoot, "alias/AGENTS.md", Buffer.from("persisted"));
expect(await readInstructionBytes(aliasRoot, "alias/AGENTS.md")).toEqual(Buffer.from("persisted"));
await fs.symlink(path.join(home, "alias"), path.join(home, "linked-agent"));
await expect(readInstructionBytes(path.join(aliasRoot, "linked-agent"), "AGENTS.md"))
.rejects.toMatchObject({ status: 422 });
});
it("requires the content API for the configured entry and rejects forged fields", async () => {
await base();
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
await expect(agentInstructionsService(db).writeFile(agent, entryFile, "bypass")).rejects.toMatchObject({ details: { code: "INSTRUCTION_REVISION_REQUIRED" } });
await expect(agentInstructionsService(db).deleteFile(agent, entryFile)).rejects.toMatchObject({ status: 422 });
expect(upsertAgentInstructionsFileSchema.safeParse({ path: entryFile, content: "bad", responsibleUserId: userId }).success).toBe(false);
});
});
@@ -0,0 +1,345 @@
import fs from "node:fs/promises";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import * as gitWorkspaceSync from "@paperclipai/adapter-utils/git-workspace-sync";
import { DatabaseSync } from "node:sqlite";
import { captureDirectorySnapshot } from "@paperclipai/adapter-utils/workspace-restore-merge";
const execFile = promisify(execFileCallback);
import os from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { agents, companies, authUsers, companyMemberships, principalPermissionGrants, heartbeatRuns, agentInstructionWorkingCopies, createDb } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
import { agentInstructionWorkingCopyService } from "../services/agent-instruction-working-copies.js";
import { appendHeartbeatRunEvent } from "../services/heartbeat-run-events.js";
import { resolveManagedInstructionsRoot } from "../services/agent-instructions.js";
import { buildNativeRuntimeContext } from "../services/native-runtime/runtime-context.js";
describe("registered run instruction copies", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let copies: ReturnType<typeof agentInstructionWorkingCopyService>;
let revisions: ReturnType<typeof agentInstructionRevisionService>;
const previousHome = process.env.PAPERCLIP_HOME;
let home: string;
let companyId: string, agentId: string, userId: string, root: string;
const entryFile = "policy/INSTRUCTIONS.txt";
const initial = "\uFEFF# Original\r\n☃\n";
const target = () => ({ companyId, agentId });
const board = () => ({ type: "board" as const, userId, source: "session" as const });
async function run() {
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
return (await copies.prepare({ legacy: true, ...target(), runId, cwd: home }))!;
}
beforeAll(async () => {
home = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "instruction-working-copies-")));
process.env.PAPERCLIP_HOME = home;
database = await startEmbeddedPostgresTestDatabase("instruction-copies-db-");
db = createDb(database.connectionString);
copies = agentInstructionWorkingCopyService(db);
revisions = agentInstructionRevisionService(db);
}, 90_000);
afterAll(async () => {
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
await database?.cleanup();
if (home) {
const writable = async (dir: string) => {
await fs.chmod(dir, 0o700);
for (const entry of await fs.readdir(dir, { withFileTypes: true })) if (entry.isDirectory()) await writable(path.join(dir, entry.name));
};
await writable(home);
await fs.rm(home, { recursive: true, force: true });
}
});
beforeEach(async () => {
companyId = randomUUID(); agentId = randomUUID(); userId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Instruction tests", issuePrefix: randomUUID().slice(0, 8) });
await db.insert(authUsers).values({ id: userId, name: "Editor", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
root = resolveManagedInstructionsRoot({ ...target(), id: agentId, name: "Target", adapterConfig: {} });
await db.insert(agents).values({ id: agentId, companyId, name: "Target", adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root, instructionsEntryFile: entryFile } });
await db.insert(companyMemberships).values([
{ companyId, principalType: "user", principalId: userId, membershipRole: "operator" },
{ companyId, principalType: "agent", principalId: agentId, membershipRole: "member" },
]);
await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", principalId: userId, permissionKey: "agents:configure", scope: { agentIds: [agentId] } });
await fs.mkdir(path.dirname(path.join(root, entryFile)), { recursive: true });
await fs.writeFile(path.join(root, entryFile), initial);
});
it("persists an atomic editor replacement, ignores repository instructions, and gives the next run exact bytes", async () => {
const copy = await run();
await fs.writeFile(path.join(home, "AGENTS.md"), "Repository text must not become agent instructions");
const edited = "\uFEFF# Updated\r\nKeep this persistent. ☃\n";
const replacement = path.join(copy.localRoot, "edited.tmp");
await fs.writeFile(replacement, edited);
await fs.rename(replacement, path.join(copy.localRoot, entryFile));
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
const saved = await copies.collectStopped({ companyId, runId: copy.runId });
expect(saved).toMatchObject({ state: "saved", candidateHash: expect.any(String), processStoppedAt: expect.any(Date) });
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(edited);
copies = agentInstructionWorkingCopyService(db);
expect((await copies.collectStopped({ companyId, runId: copy.runId }))?.receipt).toEqual(saved?.receipt);
expect((await revisions.history({ ...target(), entryFile }, board())).revisions).toHaveLength(0);
const next = await run();
expect(await fs.readFile(path.join(next.localRoot, entryFile), "utf8")).toBe(edited);
});
it.each(["failed", "cancelled", "timed_out"])("retains edits from stopped %s runs without restarting execution", async (status) => {
const copy = await run();
await fs.writeFile(path.join(copy.localRoot, entryFile), status);
await db.update(heartbeatRuns).set({ status, finishedAt: new Date() }).where(eq(heartbeatRuns.id, copy.runId));
expect((await copies.collectStopped({ companyId, runId: copy.runId }))?.state).toBe("saved");
expect((await revisions.readCurrent(target(), board()))?.content).toBe(status);
const [unchangedRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, copy.runId));
expect(unchangedRun.status).toBe(status);
});
it.each(["saved", "unchanged"])("refreshes a stopped %s copy from the current canonical revision before retry", async (state) => {
const copy = await run();
if (state === "saved") await fs.writeFile(path.join(copy.localRoot, entryFile), "previous saved turn");
expect((await copies.collectStopped({ companyId, runId: copy.runId }))?.state).toBe(state);
const before = (await revisions.readCurrent(target(), board()))!;
const current = await revisions.commit({ ...target(), entryFile, baseRevisionId: before.revision.id, content: "new board instructions", source: "api" }, board());
const retried = (await copies.prepare({ legacy: true, ...target(), runId: copy.runId, cwd: home }))!;
expect(retried).toMatchObject({ state: "prepared", baseRevisionId: current.revision.id, baseHash: current.revision.contentHash, processStoppedAt: null });
expect(await fs.readFile(path.join(retried.localRoot, entryFile), "utf8")).toBe("new board instructions");
await fs.writeFile(path.join(retried.localRoot, entryFile), "retry adds a change");
expect((await copies.collectStopped({ companyId, runId: copy.runId }))?.state).toBe("saved");
});
it("never rebases uncollected private bytes or a live warm copy onto a newer head", async () => {
const stopped = await run();
await copies.collectStopped({ companyId, runId: stopped.runId });
await fs.writeFile(path.join(stopped.localRoot, entryFile), "uncollected private edit");
const warm = await run();
expect(await copies.hasChanges({ companyId, runId: warm.runId })).toBe(false);
const prior = (await revisions.readCurrent(target(), board()))!;
await revisions.commit({ ...target(), entryFile, baseRevisionId: prior.revision.id, content: "board change", source: "api" }, board());
for (const copy of [stopped, warm]) {
const retried = (await copies.prepare({ legacy: true, ...target(), runId: copy.runId, cwd: home }))!;
expect(retried.baseRevisionId).toBe(copy.baseRevisionId);
}
expect(await fs.readFile(path.join(stopped.localRoot, entryFile), "utf8")).toBe("uncollected private edit");
expect(await fs.readFile(path.join(warm.localRoot, entryFile), "utf8")).toBe(initial);
expect((await copies.collectStopped({ companyId, runId: stopped.runId }))?.state).toBe("conflict");
expect((await revisions.readCurrent(target(), board()))?.content).toBe("board change");
});
it.each(["before replacement", "after replacement"])("preserves the completed receipt when retry staging fails %s", async (point) => {
const copy = await run();
await fs.writeFile(path.join(copy.localRoot, entryFile), "completed turn");
const saved = (await copies.collectStopped({ companyId, runId: copy.runId }))!;
expect(saved.state).toBe("saved");
const prior = (await revisions.readCurrent(target(), board()))!;
const current = await revisions.commit({ ...target(), entryFile, baseRevisionId: prior.revision.id, content: "new board instructions", source: "api" }, board());
const originalMkdir = fs.mkdir.bind(fs);
const originalChmod = fs.chmod.bind(fs);
const mkdir = vi.spyOn(fs, "mkdir").mockImplementation(async (...args: Parameters<typeof fs.mkdir>) => {
if (point === "before replacement" && args[0] === copy.localRoot) throw new Error("retry staging failed");
return originalMkdir(...args);
});
const chmod = vi.spyOn(fs, "chmod").mockImplementation(async (...args) => {
if (point === "after replacement" && args[0] === path.join(copy.localRoot, entryFile)) throw new Error("retry staging failed");
return originalChmod(...args);
});
try {
await expect(copies.prepare({ legacy: true, ...target(), runId: copy.runId, cwd: home })).rejects.toThrow("retry staging failed");
} finally { mkdir.mockRestore(); chmod.mockRestore(); }
expect(await copies.get(companyId, copy.runId)).toEqual(saved);
copies = agentInstructionWorkingCopyService(db);
await copies.recoverStopped();
expect(await copies.get(companyId, copy.runId)).toEqual(saved);
expect((await revisions.readCurrent(target(), board()))?.revision.id).toBe(current.revision.id);
const retried = (await copies.prepare({ legacy: true, ...target(), runId: copy.runId, cwd: home }))!;
expect(retried).toMatchObject({ state: "prepared", baseRevisionId: current.revision.id, processStoppedAt: null });
expect(await fs.readFile(path.join(retried.localRoot, entryFile), "utf8")).toBe(current.content);
});
it("reads an existing canonical runtime snapshot without seeding or changing instruction bytes", async () => {
expect((await revisions.readCommittedForRuntime(target()))?.content).toBe(initial);
const copy = await run();
await fs.writeFile(path.join(copy.localRoot, entryFile), "saved runtime content");
await copies.collectStopped({ companyId, runId: copy.runId });
const current = (await revisions.readCurrent(target(), board()))!;
await fs.writeFile(path.join(root, entryFile), "current filesystem contents");
expect((await revisions.readCommittedForRuntime(target()))?.content).toBe("current filesystem contents");
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
const context = await buildNativeRuntimeContext({ db, agent, runId: copy.runId, runtimeConfig: {}, runtimeSkillEntries: [] });
try {
expect(context.instructions.entryPath).toBe(entryFile);
expect(await fs.readFile(path.join(context.instructions.bundle.rootPath, entryFile), "utf8"))
.toBe("current filesystem contents");
} finally {
// Runtime assets are immutable, so make only this fixture's directories
// removable before the ordinary temporary-home cleanup.
const makeRemovable = async (directory: string): Promise<void> => {
await fs.chmod(directory, 0o700);
for (const child of await fs.readdir(directory, { withFileTypes: true })) {
if (child.isDirectory()) await makeRemovable(path.join(directory, child.name));
}
};
await makeRemovable(context.instructions.bundle.rootPath);
}
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe("current filesystem contents");
expect((await revisions.history({ ...target(), entryFile }, board())).revisions).toHaveLength(0);
await expect(revisions.readCommittedForRuntime({ companyId: randomUUID(), agentId })).rejects.toThrow("Agent not found");
});
it("preserves a competing stale candidate and requires explicit resolution against the new head", async () => {
const first = await run();
const second = await run();
await fs.writeFile(path.join(first.localRoot, entryFile), "first run");
await fs.writeFile(path.join(second.localRoot, entryFile), "second run");
await copies.collectStopped({ companyId, runId: first.runId });
const conflict = await copies.collectStopped({ companyId, runId: second.runId });
expect(conflict).toMatchObject({ state: "conflict", errorCode: "INSTRUCTION_REVISION_CONFLICT" });
expect(Buffer.from(conflict!.candidateBase64!, "base64").toString("utf8")).toBe("second run");
expect((await revisions.readCurrent(target(), board()))?.content).toBe("first run");
copies = agentInstructionWorkingCopyService(db);
expect(await copies.list(companyId, agentId, board())).toHaveLength(1);
const current = (await revisions.readCurrent(target(), board()))!;
await copies.resolve({ ...target(), runId: second.runId, baseRevisionId: current.revision.id, content: "explicitly combined" }, board());
expect((await revisions.readCurrent(target(), board()))?.content).toBe("explicitly combined");
expect((await copies.collectStopped({ companyId, runId: second.runId }))?.state).toBe("resolved");
const resumed = (await copies.prepare({ legacy: true, ...target(), runId: second.runId, cwd: home }))!;
const resolved = (await revisions.readCurrent(target(), board()))!;
expect(resumed.baseRevisionId).toBe(resolved.revision.id);
expect(await fs.readFile(path.join(resumed.localRoot, entryFile), "utf8")).toBe("explicitly combined");
});
it("rechecks responsible-user permission at collection and preserves denied edits", async () => {
const copy = await run();
await fs.writeFile(path.join(copy.localRoot, entryFile), "candidate after revocation");
await db.update(companyMemberships).set({ status: "suspended" }).where(eq(companyMemberships.principalId, userId));
const denied = await copies.collectStopped({ companyId, runId: copy.runId });
expect(denied).toMatchObject({ state: "conflict", errorCode: "RESPONSIBLE_USER_UNAVAILABLE" });
expect(denied?.candidateBase64).not.toBeNull();
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
});
it("does not import a symlink or confuse a removed entry with an empty file", async () => {
const linked = await run();
await fs.unlink(path.join(linked.localRoot, entryFile));
await fs.symlink(path.join(home, "AGENTS.md"), path.join(linked.localRoot, entryFile));
expect((await copies.collectStopped({ companyId, runId: linked.runId }))?.state).toBe("pending_collection");
const missing = await run();
await fs.unlink(path.join(missing.localRoot, entryFile));
expect((await copies.collectStopped({ companyId, runId: missing.runId }))?.state).toBe("unavailable");
const empty = await run();
await fs.writeFile(path.join(empty.localRoot, entryFile), "");
expect((await copies.collectStopped({ companyId, runId: empty.runId }))?.state).toBe("saved");
expect((await revisions.readCurrent(target(), board()))?.content).toBe("");
});
it("keeps unchanged warm copies reusable without a content revision", async () => {
const copy = await run();
expect(await copies.hasChanges({ companyId, runId: copy.runId })).toBe(false);
expect((await copies.get(companyId, copy.runId))?.processStoppedAt).toBeNull();
expect((await revisions.history({ ...target(), entryFile }, board())).revisions).toHaveLength(0);
await fs.writeFile(path.join(copy.localRoot, entryFile), "changed after turn");
expect(await copies.hasChanges({ companyId, runId: copy.runId })).toBe(true);
expect((await revisions.readCurrent(target(), board()))?.content).toBe(initial);
});
it("advances an explicit save baseline only when the private copy matches that revision", async () => {
const copy = await run();
const local = path.join(copy.localRoot, entryFile);
await fs.writeFile(local, "explicit self-save");
const first = await revisions.commit({ ...target(), entryFile, baseRevisionId: copy.baseRevisionId, content: "explicit self-save", source: "tool" }, board());
await copies.acknowledgeExplicitSave({ ...target(), runId: copy.runId, entryFile, revisionId: first.revision.id, contentHash: first.revision.contentHash });
await fs.writeFile(local, "local edit after explicit save");
expect((await copies.collectStopped({ companyId, runId: copy.runId }))?.state).toBe("saved");
expect((await revisions.readCurrent(target(), board()))?.content).toBe("local edit after explicit save");
const competing = await run();
await fs.writeFile(path.join(competing.localRoot, entryFile), "older local draft");
const separate = await revisions.commit({ ...target(), entryFile, baseRevisionId: competing.baseRevisionId, content: "different explicit content", source: "tool" }, board());
await copies.acknowledgeExplicitSave({ ...target(), runId: competing.runId, entryFile, revisionId: separate.revision.id, contentHash: separate.revision.contentHash });
expect((await copies.collectStopped({ companyId, runId: competing.runId }))?.state).toBe("conflict");
expect((await revisions.readCurrent(target(), board()))?.content).toBe("different explicit content");
expect((await copies.list(companyId, agentId, board()))[0]).not.toHaveProperty("localRoot");
});
it("recovers captured bytes after restart and deduplicates concurrent cleanup callbacks", async () => {
const copy = await run();
await db.update(agentInstructionWorkingCopies).set({ state: "pending_commit", candidateBase64: Buffer.from("captured before restart").toString("base64"), processStoppedAt: new Date() })
.where(eq(agentInstructionWorkingCopies.runId, copy.runId));
copies = agentInstructionWorkingCopyService(db);
await copies.recoverCaptured();
expect((await revisions.readCurrent(target(), board()))?.content).toBe("captured before restart");
const duplicate = await run();
await fs.writeFile(path.join(duplicate.localRoot, entryFile), "one final edit");
await Promise.all([copies.collectStopped({ companyId, runId: duplicate.runId }), copies.collectStopped({ companyId, runId: duplicate.runId })]);
expect((await copies.get(companyId, duplicate.runId))?.state).toBe("saved");
expect((await revisions.history({ ...target(), entryFile }, board())).revisions).toHaveLength(0);
});
it("requires durable stop evidence before recovering an uncaptured copy", async () => {
const copy = await run();
await fs.writeFile(path.join(copy.localRoot, entryFile), "edit before controller restart");
await db.update(heartbeatRuns).set({ runtimeMode: "native", status: "failed", finishedAt: new Date() }).where(eq(heartbeatRuns.id, copy.runId));
copies = agentInstructionWorkingCopyService(db);
await copies.recoverStopped();
expect((await revisions.readCurrent(target(), board()))?.content).toBe(initial);
expect((await copies.list(companyId, agentId, board()))[0]).toMatchObject({ state: "pending_collection", content: null });
await appendHeartbeatRunEvent(db, { ...target(), runId: copy.runId, eventType: "native.local_process_stopped", stream: "system" });
await copies.recoverStopped();
expect((await revisions.readCurrent(target(), board()))?.content).toBe("edit before controller restart");
});
it("never treats a superseded stop receipt or unavailable environment as a save", async () => {
const copy = await run();
await fs.writeFile(path.join(copy.localRoot, entryFile), "unverified live edit");
await db.update(heartbeatRuns).set({ runtimeMode: "native", status: "failed", finishedAt: new Date() }).where(eq(heartbeatRuns.id, copy.runId));
await appendHeartbeatRunEvent(db, { ...target(), runId: copy.runId, eventType: "native.local_process_stopped", stream: "system" });
await appendHeartbeatRunEvent(db, { ...target(), runId: copy.runId, eventType: "native.process_start_requested", stream: "system" });
await copies.recoverStopped();
expect((await revisions.readCurrent(target(), board()))?.content).toBe(initial);
const lost = await copies.reportUnavailable(companyId, copy.runId);
expect(lost).toMatchObject({ state: "unavailable", candidateBase64: null, processStoppedAt: null });
expect(lost?.errorMessage).not.toContain(copy.localRoot);
});
it("keeps private run copies out of normal Git staging and workspace snapshots", async () => {
const workspace = path.join(home, `workspace-${randomUUID()}`);
await fs.mkdir(workspace);
await execFile("git", ["init", workspace]);
await execFile("git", ["-C", workspace, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--allow-empty", "-m", "fixture"]);
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
const copy = (await copies.prepare({ legacy: true, ...target(), runId, cwd: workspace }))!;
expect(copy.localRoot).toBe(path.join(workspace, ".paperclip-runtime", `instruction-edits-${runId}`, "instructions"));
await fs.writeFile(path.join(workspace, "deliverable.txt"), "public work");
await execFile("git", ["-C", workspace, "add", "."]);
const staged = await execFile("git", ["-C", workspace, "diff", "--cached", "--name-only"]);
expect(staged.stdout).toBe("deliverable.txt\n");
const snapshot = await gitWorkspaceSync.readGitWorkspaceSnapshot(workspace);
try {
const paths: unknown = snapshot?.overlayPaths;
if (Array.isArray(paths)) {
expect(paths).toEqual(["deliverable.txt"]);
} else {
// The streaming Git snapshot stores the same path set in a private
// manifest. Inspect its actual records, not only the reported count.
expect(paths).toMatchObject({ kind: "path_manifest", version: 1, category: "overlay", count: 1 });
const manifest = paths as { filePath: string; category: string };
const manifestDb = new DatabaseSync(manifest.filePath, { readOnly: true, allowExtension: false });
try {
const rows = manifestDb.prepare("SELECT path FROM records WHERE category = ? ORDER BY path").all(manifest.category);
expect(rows.map((row) => row.path)).toEqual(["deliverable.txt"]);
} finally { manifestDb.close(); }
}
} finally {
if ("disposeGitWorkspaceSnapshot" in gitWorkspaceSync && typeof gitWorkspaceSync.disposeGitWorkspaceSnapshot === "function") {
await gitWorkspaceSync.disposeGitWorkspaceSnapshot(snapshot);
}
}
const files = await captureDirectorySnapshot(workspace, { exclude: [".git", ".paperclip-runtime"] });
expect([...files.entries].map(([relative]) => relative)).toEqual(["deliverable.txt"]);
await expect(copies.prepare({ legacy: true, ...target(), runId, cwd: home })).rejects.toThrow("different run workspace");
});
});
@@ -1,5 +1,6 @@
import express from "express";
import request from "supertest";
import { Readable } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockAgentService = vi.hoisted(() => ({
@@ -24,6 +25,21 @@ const mockAgentInstructionsService = vi.hoisted(() => ({
materializeManagedBundle: vi.fn(),
}));
const mockInstructionWorkingCopies = vi.hoisted(() => ({ list: vi.fn(), resolve: vi.fn(), acknowledgeExplicitSave: vi.fn() }));
vi.mock("../services/agent-instruction-working-copies.js", () => ({ agentInstructionWorkingCopyService: () => mockInstructionWorkingCopies }));
const mockDownloadAgentFile = vi.hoisted(() => vi.fn());
vi.mock("../services/agent-file-store.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../services/agent-file-store.js")>();
return { ...actual, agentFileStore: (...args: Parameters<typeof actual.agentFileStore>) => ({ ...actual.agentFileStore(...args), download: mockDownloadAgentFile }) };
});
const mockInstructionRevisions = vi.hoisted(() => ({ readCurrent: vi.fn(), commit: vi.fn(), restore: vi.fn(), history: vi.fn(), readRevision: vi.fn(), diff: vi.fn(), materializeCurrent: vi.fn() }));
vi.mock("../services/agent-instruction-revisions.js", () => ({ agentInstructionRevisionService: () => mockInstructionRevisions }));
const mockAuthorizeInstructionRead = vi.hoisted(() => vi.fn());
vi.mock("../services/agent-instruction-authorization.js", () => ({ authorizeInstructionRead: mockAuthorizeInstructionRead }));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
@@ -202,6 +218,9 @@ describe("agent instructions bundle routes", () => {
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.clearAllMocks();
mockAuthorizeInstructionRead.mockImplementation(async (_db, actor) => actor);
mockInstructionRevisions.readCurrent.mockResolvedValue(null);
mockInstructionRevisions.commit.mockResolvedValue({ revision: { id: "33333333-3333-4333-8333-333333333333", entryFile: "AGENTS.md", byteLength: 18 }, content: "# Updated Agent\n", changed: true, materialization: "current" });
mockBuiltInAgentService.ensureCompanyDefaultAgentGrants.mockResolvedValue(0);
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
mockFindServerAdapter.mockImplementation((_type: string) => ({ type: _type }));
@@ -286,6 +305,8 @@ describe("agent instructions bundle routes", () => {
entryFile: "AGENTS.md",
});
expect(mockAgentInstructionsService.getBundle).toHaveBeenCalled();
expect(mockAuthorizeInstructionRead).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ type: "board" }),
expect.objectContaining({ companyId: "company-1", id: "11111111-1111-4111-8111-111111111111" }));
});
it("requires instance-admin access for every external instruction entry point", async () => {
@@ -331,6 +352,7 @@ describe("agent instructions bundle routes", () => {
expect(mockAgentInstructionsService.updateBundle).not.toHaveBeenCalled();
expect(mockAgentInstructionsService.writeFile).not.toHaveBeenCalled();
expect(mockAgentInstructionsService.deleteFile).not.toHaveBeenCalled();
expect(mockAuthorizeInstructionRead).not.toHaveBeenCalled();
});
it("treats a host root mislabeled as managed as external", async () => {
@@ -383,6 +405,7 @@ describe("agent instructions bundle routes", () => {
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentInstructionsService.getBundle).toHaveBeenCalled();
expect(mockAuthorizeInstructionRead).not.toHaveBeenCalled();
});
it("rejects a company admin that requests a new external instruction root", async () => {
@@ -484,7 +507,8 @@ describe("agent instructions bundle routes", () => {
expect(mockAgentService.create).not.toHaveBeenCalled();
});
it("denies non-privileged agents from reading peer instructions bundles", async () => {
it.each(["", "/file?path=AGENTS.md", "/file?path=private-support.md"])(
"denies peer instruction reads before any data dispatch: %s", async (suffix) => {
mockAgentService.getById.mockImplementation(async (id: string) => {
if (id === "agent-reader") {
return {
@@ -496,11 +520,10 @@ describe("agent instructions bundle routes", () => {
}
return makeAgent();
});
mockAccessService.decide.mockResolvedValue({
allowed: false,
reason: "deny_no_grant",
explanation: "Missing permission: agents:configure or agents:suggest-changes.",
});
// General agent visibility is allowed. It must not authorize instruction
// metadata, canonical entry bytes, or supporting files from a peer.
const { forbidden } = await vi.importActual<typeof import("../errors.js")>("../errors.js");
mockAuthorizeInstructionRead.mockRejectedValue(forbidden("Missing permission to read peer instructions"));
const res = await requestApp(
await createApp({
@@ -510,20 +533,18 @@ describe("agent instructions bundle routes", () => {
source: "agent_key",
}),
(baseUrl) => request(baseUrl)
.get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle"),
.get(`/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle${suffix}`),
);
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toContain("Missing permission");
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
action: "agent_config:read",
resource: {
type: "agent",
companyId: "company-1",
agentId: "11111111-1111-4111-8111-111111111111",
},
}));
expect(mockAuthorizeInstructionRead).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
type: "agent", agentId: "agent-reader",
}), { companyId: "company-1", id: "11111111-1111-4111-8111-111111111111" });
expect(mockAgentInstructionsService.getBundle).not.toHaveBeenCalled();
expect(mockAgentInstructionsService.readFile).not.toHaveBeenCalled();
expect(mockInstructionRevisions.readCurrent).not.toHaveBeenCalled();
expect(mockInstructionRevisions.materializeCurrent).not.toHaveBeenCalled();
});
it("allows agents to read their own instructions bundles", async () => {
@@ -540,9 +561,12 @@ describe("agent instructions bundle routes", () => {
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentInstructionsService.getBundle).toHaveBeenCalled();
expect(mockAuthorizeInstructionRead).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
type: "agent", agentId: "11111111-1111-4111-8111-111111111111",
}), { companyId: "company-1", id: "11111111-1111-4111-8111-111111111111" });
});
it("allows agents with suggest grants to read peer instructions bundles", async () => {
it.each(["AGENTS.md", "private-support.md"])("allows an authorized peer to read instruction file %s", async (filePath) => {
mockAccessService.decide.mockResolvedValue({
allowed: true,
reason: "allow_explicit_grant",
@@ -570,52 +594,100 @@ describe("agent instructions bundle routes", () => {
}),
(baseUrl) => request(baseUrl)
.get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file")
.query({ path: "AGENTS.md" }),
.query({ path: filePath }),
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
action: "agent_config:read",
resource: {
type: "agent",
companyId: "company-1",
agentId: "11111111-1111-4111-8111-111111111111",
},
}));
expect(mockAuthorizeInstructionRead).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
type: "agent", agentId: "coach-agent",
}), { companyId: "company-1", id: "11111111-1111-4111-8111-111111111111" });
expect(mockAgentInstructionsService.readFile).toHaveBeenCalledWith(
expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }),
"AGENTS.md",
filePath,
);
});
it("writes a bundle file and persists compatibility config", async () => {
it("commits entry bytes through the canonical service with the authenticated actor", async () => {
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
.put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file?companyId=company-1")
.send({
path: "AGENTS.md",
content: "# Updated Agent\n",
clearLegacyPromptTemplate: true,
}));
.send({ path: "AGENTS.md", content: "# Updated Agent\n", baseRevisionId: null }));
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentInstructionsService.writeFile).toHaveBeenCalledWith(
expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }),
"AGENTS.md",
"# Updated Agent\n",
{ clearLegacyPromptTemplate: true },
);
expect(mockAgentService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
adapterConfig: expect.objectContaining({
instructionsBundleMode: "managed",
instructionsRootPath: "/tmp/agent-1",
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: "/tmp/agent-1/AGENTS.md",
}),
}),
expect.any(Object),
);
expect(mockInstructionRevisions.commit).toHaveBeenCalledWith(expect.objectContaining({
agentId: "11111111-1111-4111-8111-111111111111", entryFile: "AGENTS.md", content: "# Updated Agent\n", baseRevisionId: null, source: "board",
}), expect.objectContaining({ type: "board" }));
expect(mockAgentInstructionsService.writeFile).not.toHaveBeenCalled();
expect(res.body.receipt.revision.id).toBe("33333333-3333-4333-8333-333333333333");
});
it("returns committed entry content independently of its disk copy", async () => {
mockInstructionRevisions.readCurrent.mockResolvedValue({ revision: { id: "33333333-3333-4333-8333-333333333333", entryFile: "AGENTS.md", byteLength: 9 }, content: "committed" });
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
.get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file?path=AGENTS.md"));
expect(res.status).toBe(200);
expect(res.body.content).toBe("committed");
expect(res.body.revision.id).toBe("33333333-3333-4333-8333-333333333333");
expect(mockAgentInstructionsService.readFile).not.toHaveBeenCalled();
});
it.each([Buffer.from([0, 255, 128, 17]), Buffer.alloc(0)])("streams an authorized binary download without text conversion (%j)", async bytes => {
mockAgentService.getById.mockResolvedValue({ ...makeAgent(), adapterConfig: { instructionsBundleMode: "managed" } });
mockDownloadAgentFile.mockResolvedValue({ size: bytes.length, stream: Readable.from(bytes.length ? [bytes] : []) });
const res = await requestApp(await createApp(), url => request(url)
.get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file")
.query({ path: "notes/data.bin", download: "true" }));
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toEqual(bytes);
expect(res.headers["content-length"]).toBe(String(bytes.length));
expect(res.headers["content-disposition"]).toContain('filename="data.bin"');
expect(mockDownloadAgentFile).toHaveBeenCalledWith("company-1", "11111111-1111-4111-8111-111111111111", "notes/data.bin", expect.objectContaining({ type: "board" }));
expect(mockAgentInstructionsService.readFile).not.toHaveBeenCalled();
});
it("lists and resolves preserved edits with server scope, actor, and explicit revision", async () => {
const app = await createApp();
const runId = "55555555-5555-4555-8555-555555555555";
const baseRevisionId = "44444444-4444-4444-8444-444444444444";
const prefix = "/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/candidates";
mockInstructionWorkingCopies.list.mockResolvedValue([{ runId, entryFile: "AGENTS.md", state: "conflict", content: "preserved" }]);
mockInstructionWorkingCopies.resolve.mockResolvedValue({ revision: { id: baseRevisionId, entryFile: "AGENTS.md", byteLength: 8 }, content: "resolved", changed: true, materialization: "current" });
const listed = await requestApp(app, (url) => request(url).get(prefix));
expect(listed.status).toBe(200);
expect(listed.body).toEqual([{ runId, entryFile: "AGENTS.md", state: "conflict", content: "preserved" }]);
expect(mockInstructionWorkingCopies.list).toHaveBeenCalledWith("company-1", "11111111-1111-4111-8111-111111111111", expect.objectContaining({ type: "board", userId: "local-board" }));
const resolved = await requestApp(app, (url) => request(url).post(`${prefix}/${runId}/resolve`).send({ baseRevisionId, content: "resolved" }));
expect(resolved.status).toBe(200);
expect(resolved.body).toMatchObject({ content: "resolved", receipt: { changed: true } });
expect(mockInstructionWorkingCopies.resolve).toHaveBeenCalledWith({ companyId: "company-1", agentId: "11111111-1111-4111-8111-111111111111", runId, baseRevisionId, content: "resolved" }, expect.objectContaining({ type: "board", userId: "local-board" }));
for (const body of [{ content: "missing base" }, { baseRevisionId, content: "forged", responsibleUserId: "other" }, { baseRevisionId, content: "forged", entryFile: "other.md" }]) {
expect((await requestApp(app, (url) => request(url).post(`${prefix}/${runId}/resolve`).send(body))).status).toBe(400);
}
expect(mockInstructionWorkingCopies.resolve).toHaveBeenCalledOnce();
});
it("exposes scoped history, diff and restore with the server actor", async () => {
const app = await createApp();
const revisionId = "33333333-3333-4333-8333-333333333333";
const baseRevisionId = "44444444-4444-4444-8444-444444444444";
const prefix = "/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle";
mockInstructionRevisions.history.mockResolvedValue({ revisions: [], nextCursor: null });
mockInstructionRevisions.diff.mockResolvedValue({ removed: "old", added: "new" });
mockInstructionRevisions.restore.mockResolvedValue({ revision: { id: revisionId, entryFile: "AGENTS.md", byteLength: 3 }, content: "old", changed: true, materialization: "current" });
expect((await requestApp(app, (url) => request(url).get(`${prefix}/history?path=AGENTS.md`))).status).toBe(200);
expect((await requestApp(app, (url) => request(url).get(`${prefix}/diff?path=AGENTS.md&from=${revisionId}&to=${baseRevisionId}`))).status).toBe(200);
const restored = await requestApp(app, (url) => request(url).post(`${prefix}/restore`).send({ path: "AGENTS.md", revisionId, baseRevisionId }));
expect(restored.status).toBe(200);
expect(restored.body.receipt.changed).toBe(true);
expect(mockInstructionRevisions.restore).toHaveBeenCalledWith({ companyId: "company-1", agentId: "11111111-1111-4111-8111-111111111111", entryFile: "AGENTS.md", revisionId, baseRevisionId }, expect.objectContaining({ type: "board", userId: "local-board" }));
});
it("requires a base revision and rejects client-supplied responsible identity", async () => {
const app = await createApp();
for (const body of [ { path: "AGENTS.md", content: "stale" }, { path: "AGENTS.md", content: "forged", baseRevisionId: null, responsibleUserId: "forged" } ]) {
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file").send(body));
expect([400, 422]).toContain(res.status);
}
expect(mockInstructionRevisions.commit).not.toHaveBeenCalled();
});
it("preserves managed instructions config when switching adapters", async () => {
@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { agentInstructionsService } from "../services/agent-instructions.js";
type TestAgent = {
@@ -12,7 +12,7 @@ type TestAgent = {
};
async function makeTempDir(prefix: string) {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
return fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), prefix)));
}
function makeAgent(adapterConfig: Record<string, unknown>): TestAgent {
@@ -29,6 +29,13 @@ describe("agent instructions service", () => {
const originalPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
const cleanupDirs = new Set<string>();
beforeEach(async () => {
const home = await makeTempDir("agent-instructions-test-home-");
cleanupDirs.add(home);
process.env.PAPERCLIP_HOME = home;
process.env.PAPERCLIP_INSTANCE_ID = "instructions-service-test";
});
afterEach(async () => {
if (originalPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = originalPaperclipHome;
@@ -41,6 +48,37 @@ describe("agent instructions service", () => {
}));
});
it.each(["./AGENTS.md", "notes/../AGENTS.md", "notes\\..\\AGENTS.md", "../invalid"])("reads legacy entry configuration %s without breaking the bundle", async (entry) => {
const root = await makeTempDir("legacy-entry-"); cleanupDirs.add(root);
await fs.writeFile(path.join(root, "AGENTS.md"), "legacy instructions");
const agent = makeAgent({ instructionsBundleMode: "external", instructionsRootPath: root, instructionsEntryFile: entry });
const svc = agentInstructionsService();
const bundle = await svc.getBundle(agent);
expect(bundle.entryFile).toBe("AGENTS.md");
expect((await svc.readFile(agent, bundle.entryFile)).content).toBe("legacy instructions");
if (entry === "../invalid") expect(bundle.warnings.length).toBeGreaterThan(0);
});
it("lists external bundles containing large binary assets", async () => {
const root = await makeTempDir("external-large-assets-"); cleanupDirs.add(root);
await fs.writeFile(path.join(root, "AGENTS.md"), "external instructions");
const asset = await fs.open(path.join(root, "large.bin"), "w");
try { await asset.truncate(17 * 1024 * 1024); } finally { await asset.close(); }
const agent = makeAgent({ instructionsBundleMode: "external", instructionsRootPath: root });
const svc = agentInstructionsService();
const bundle = await svc.getBundle(agent);
expect(bundle.files).toContainEqual(expect.objectContaining({ path: "large.bin", binary: true, editable: false }));
expect((await svc.readFile(agent, "AGENTS.md")).content).toBe("external instructions");
});
it("rejects reserved paths while initializing an unconfigured managed bundle", async () => {
const root = await makeTempDir("unconfigured-reserved-path-"); cleanupDirs.add(root);
process.env.PAPERCLIP_HOME = root;
const svc = agentInstructionsService();
await expect(svc.writeFile(makeAgent({}), ".paperclip-runtime/state", "invalid")).rejects.toMatchObject({ status: 422 });
expect(await fs.readdir(root)).toEqual([]);
});
it("copies the existing bundle into the managed root when switching to managed mode", async () => {
const paperclipHome = await makeTempDir("paperclip-agent-instructions-home-");
const externalRoot = await makeTempDir("paperclip-agent-instructions-external-");
@@ -53,7 +91,7 @@ describe("agent instructions service", () => {
await fs.mkdir(path.join(externalRoot, "docs"), { recursive: true });
await fs.writeFile(path.join(externalRoot, "docs", "TOOLS.md"), "## Tools\n", "utf8");
const svc = agentInstructionsService();
const svc = agentInstructionsService({ select: () => ({ from: () => ({ where: async () => [] }) }) } as never);
const agent = makeAgent({
instructionsBundleMode: "external",
instructionsRootPath: externalRoot,
@@ -250,11 +288,10 @@ describe("agent instructions service", () => {
expect(bundle.mode).toBe("managed");
expect(bundle.rootPath).toBe(managedRoot);
expect(bundle.entryFile).toBe("AGENTS.md");
expect(bundle.entryFile).toBe("docs/MISSING.md");
expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
expect(bundle.warnings).toEqual([
`Recovered managed instructions from disk at ${managedRoot}; ignoring stale configured root ${staleRoot}.`,
"Recovered managed instructions entry file from disk as AGENTS.md; previous entry docs/MISSING.md was missing.",
]);
expect(exported.files).toEqual({ "AGENTS.md": "# Managed Agent\n" });
});
@@ -293,8 +330,8 @@ describe("agent instructions service", () => {
expect(result.adapterConfig).toMatchObject({
instructionsBundleMode: "managed",
instructionsRootPath: managedRoot,
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: path.join(managedRoot, "AGENTS.md"),
instructionsEntryFile: "docs/MISSING.md",
instructionsFilePath: path.join(managedRoot, "docs/MISSING.md"),
});
await expect(fs.readFile(path.join(managedRoot, "docs", "TOOLS.md"), "utf8")).resolves.toBe("## Tools\n");
});
@@ -334,8 +371,8 @@ describe("agent instructions service", () => {
expect(result.adapterConfig).toMatchObject({
instructionsBundleMode: "managed",
instructionsRootPath: managedRoot,
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: path.join(managedRoot, "AGENTS.md"),
instructionsEntryFile: "docs/MISSING.md",
instructionsFilePath: path.join(managedRoot, "docs/MISSING.md"),
});
await expect(fs.stat(path.join(managedRoot, "docs", "TOOLS.md"))).rejects.toThrow();
expect(result.bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
@@ -373,11 +410,10 @@ describe("agent instructions service", () => {
expect(bundle.mode).toBe("managed");
expect(bundle.rootPath).toBe(managedRoot);
expect(bundle.entryFile).toBe("AGENTS.md");
expect(bundle.entryFile).toBe("docs/MISSING.md");
expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
expect(bundle.warnings).toEqual([
`Recovered managed instructions from disk at ${managedRoot}; ignoring stale configured root ${staleRoot}.`,
"Recovered managed instructions entry file from disk as AGENTS.md; previous entry docs/MISSING.md was missing.",
]);
expect(exported.files).toEqual({ "AGENTS.md": "# Managed Agent\n" });
});
@@ -412,7 +412,7 @@ describe("built-in agent routes", () => {
.send({});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockBuiltInAgentService.reset).toHaveBeenCalledWith(companyId, "briefs", {});
expect(mockBuiltInAgentService.reset).toHaveBeenCalledWith(companyId, "briefs", {}, expect.objectContaining({ type: "agent", agentId: "manager-agent" }));
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
companyId,
actorType: "agent",
+127 -9
View File
@@ -1,3 +1,6 @@
import fs from "node:fs/promises";
import { fileHash } from "../services/agent-file-store.js";
import { stockHash } from "../services/managed-resource-drift.js";
import { randomUUID } from "node:crypto";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -27,6 +30,7 @@ import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
import { agentInstructionsService } from "../services/agent-instructions.ts";
import { agentService } from "../services/agents.ts";
import { approvalService } from "../services/approvals.ts";
@@ -116,6 +120,16 @@ describe("built-in agent asset loading", () => {
describeEmbeddedPostgres("built-in agents", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const instructionOperator = { type: "board", userId: "local-board", source: "local_implicit" } as const;
async function writeInstructionEntry(agent: { id: string; companyId: string }, entryFile: string, content: string) {
const service = agentInstructionRevisionService(db);
const target = { companyId: agent.companyId, agentId: agent.id };
const baseline = await service.readCurrent(target, instructionOperator);
await service.commit({ ...target, entryFile, content, baseRevisionId: baseline?.revision.id ?? null, source: "board" }, instructionOperator);
const refreshed = await agentService(db).getById(agent.id);
return { adapterConfig: refreshed!.adapterConfig };
}
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-built-in-agents-");
@@ -553,7 +567,7 @@ describeEmbeddedPostgres("built-in agents", () => {
capabilities: "Custom purpose",
});
const reset = await builtIns.reset(companyId, "briefs");
const reset = await builtIns.reset(companyId, "briefs", {}, instructionOperator);
expect(reset).toMatchObject({
status: "ready",
@@ -795,13 +809,111 @@ describeEmbeddedPostgres("built-in agents", () => {
expect(approvalRows).toHaveLength(1);
});
it("automatically upgrades untouched stock instructions while preserving personal files", async () => {
const companyId = await seedCompany({ requireApproval: false });
const svc = builtInAgentService(db);
const created = await svc.ensure(companyId, "reflection-coach");
const agent = created.agent!;
const oldFiles = { "AGENTS.md": "# Previous stock instructions\n", "obsolete.txt": "old stock support" };
await writeInstructionEntry(agent, "AGENTS.md", oldFiles["AGENTS.md"]);
await agentInstructionsService(db).writeFile(agent, "obsolete.txt", oldFiles["obsolete.txt"]);
await agentInstructionsService(db).writeFile(agent, "personal.txt", "keep my notes");
await db.update(builtInManagedResources).set({ stockHash: stockHash(oldFiles), stockVersion: "previous",
defaultsJson: { entryFile: "AGENTS.md", files: Object.keys(oldFiles) },
}).where(and(eq(builtInManagedResources.companyId, companyId), eq(builtInManagedResources.resourceKind, "instructions")));
const updated = await svc.ensure(companyId, "reflection-coach");
expect(updated.resources.find(resource => resource.resourceKind === "instructions")).toMatchObject({ stockStatus: "stock_current" });
expect((await agentInstructionsService().readFile(updated.agent!, "AGENTS.md")).content).toContain("You are Reflection Coach");
expect((await agentInstructionsService().readFile(updated.agent!, "personal.txt")).content).toBe("keep my notes");
await expect(agentInstructionsService().readFile(updated.agent!, "obsolete.txt")).rejects.toMatchObject({ status: 404 });
const [event] = await db.select().from(activityLog).where(and(eq(activityLog.companyId, companyId), eq(activityLog.actorId, "built-in-reconcile")));
expect(event).toMatchObject({ actorType: "system", action: "agent.files_updated" });
});
it.each(["file", "database"])("retries a stock update after a %s failure without overwriting intervening edits", async (failure) => {
const companyId = await seedCompany({ requireApproval: false });
const svc = builtInAgentService(db);
const created = await svc.ensure(companyId, "reflection-coach");
const agent = created.agent!;
const oldFiles = { "AGENTS.md": "# Old stock\n", "obsolete.txt": "old supporting file" };
await writeInstructionEntry(agent, "AGENTS.md", oldFiles["AGENTS.md"]);
await agentInstructionsService(db).writeFile(agent, "obsolete.txt", oldFiles["obsolete.txt"]);
await db.update(builtInManagedResources).set({ stockHash: stockHash(oldFiles), stockVersion: "old",
defaultsJson: { entryFile: "AGENTS.md", files: Object.keys(oldFiles) },
}).where(and(eq(builtInManagedResources.companyId, companyId), eq(builtInManagedResources.resourceKind, "instructions")));
const trigger = `stock_failure_${companyId.replaceAll("-", "")}`;
const remove = fs.rm.bind(fs);
const fault = failure === "file" ? vi.spyOn(fs, "rm").mockImplementation(async (file, options) => {
if (String(file).endsWith("/obsolete.txt")) throw new Error("injected stock file failure");
return remove(file, options);
}) : null;
if (failure === "database") {
await db.execute(sql.raw(`CREATE FUNCTION ${trigger}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
IF NEW.company_id = '${companyId}' AND NEW.resource_kind = 'instructions' AND NEW.stock_hash <> OLD.stock_hash THEN
RAISE EXCEPTION 'injected stock binding failure'; END IF; RETURN NEW; END $$`));
await db.execute(sql.raw(`CREATE TRIGGER ${trigger} BEFORE UPDATE ON built_in_managed_resources FOR EACH ROW EXECUTE FUNCTION ${trigger}()`));
}
try { await expect(svc.ensure(companyId, "reflection-coach")).rejects.toThrow(); }
finally {
fault?.mockRestore();
if (failure === "database") {
await db.execute(sql.raw(`DROP TRIGGER ${trigger} ON built_in_managed_resources`));
await db.execute(sql.raw(`DROP FUNCTION ${trigger}()`));
}
}
const [pending] = await db.select().from(builtInManagedResources).where(and(eq(builtInManagedResources.companyId, companyId), eq(builtInManagedResources.resourceKind, "instructions")));
expect(pending.defaultsJson.pendingInstructionsUpdate).toBeTruthy();
expect(pending.stockHash).toBe(stockHash(oldFiles));
const partial = (await agentInstructionsService().readFile(agent, "AGENTS.md")).content;
expect(partial).toContain("You are Reflection Coach");
// A real operator edit after the failed attempt must stop the retry.
await writeInstructionEntry(agent, "AGENTS.md", "operator edit after failure");
await svc.ensure(companyId, "reflection-coach");
expect((await agentInstructionsService().readFile(agent, "AGENTS.md")).content).toBe("operator edit after failure");
await writeInstructionEntry(agent, "AGENTS.md", partial);
const retried = await svc.ensure(companyId, "reflection-coach");
expect(retried.resources.find(resource => resource.resourceKind === "instructions")).toMatchObject({ stockStatus: "stock_current" });
await expect(agentInstructionsService().readFile(agent, "obsolete.txt")).rejects.toMatchObject({ status: 404 });
const [complete] = await db.select().from(builtInManagedResources).where(eq(builtInManagedResources.id, pending.id));
expect(complete.defaultsJson.pendingInstructionsUpdate).toBeUndefined();
});
it.each([false, true])("supersedes an interrupted stock version, including rollback=%s", async (rollback) => {
const companyId = await seedCompany({ requireApproval: false });
const svc = builtInAgentService(db);
const created = await svc.ensure(companyId, "reflection-coach");
const agent = created.agent!;
const currentStock = (await agentInstructionsService().readFile(agent, "AGENTS.md")).content;
const oldFiles = { "AGENTS.md": rollback ? currentStock : "# Original stock" };
const interruptedFiles = { "AGENTS.md": "# Intermediate stock", "intermediate-only.txt": "intermediate support" };
await writeInstructionEntry(agent, "AGENTS.md", interruptedFiles["AGENTS.md"]);
await agentInstructionsService(db).writeFile(agent, "intermediate-only.txt", interruptedFiles["intermediate-only.txt"]);
await agentInstructionsService(db).writeFile(agent, "personal.txt", "personal notes");
await db.update(builtInManagedResources).set({ stockHash: stockHash(oldFiles), stockVersion: "old",
defaultsJson: { entryFile: "AGENTS.md", files: Object.keys(oldFiles), pendingInstructionsUpdate: {
stockHash: stockHash(interruptedFiles),
baseHashes: { "AGENTS.md": fileHash(Buffer.from(oldFiles["AGENTS.md"])), "intermediate-only.txt": null },
nextHashes: Object.fromEntries(Object.entries(interruptedFiles).map(([file, text]) => [file, fileHash(Buffer.from(text))])),
} },
}).where(and(eq(builtInManagedResources.companyId, companyId), eq(builtInManagedResources.resourceKind, "instructions")));
await writeInstructionEntry(agent, "AGENTS.md", "intervening operator edit");
await svc.ensure(companyId, "reflection-coach");
expect((await agentInstructionsService().readFile(agent, "AGENTS.md")).content).toBe("intervening operator edit");
await writeInstructionEntry(agent, "AGENTS.md", interruptedFiles["AGENTS.md"]);
const updated = await svc.ensure(companyId, "reflection-coach");
expect(updated.resources.find(resource => resource.resourceKind === "instructions")).toMatchObject({ stockStatus: "stock_current" });
expect((await agentInstructionsService().readFile(agent, "AGENTS.md")).content).toBe(currentStock);
await expect(agentInstructionsService().readFile(agent, "intermediate-only.txt")).rejects.toMatchObject({ status: 404 });
expect((await agentInstructionsService().readFile(agent, "personal.txt")).content).toBe("personal notes");
});
it("preserves Reflection Coach instruction drift on reconcile and restores it on reset", async () => {
const companyId = await seedCompany();
const builtIns = builtInAgentService(db);
const created = await builtIns.ensure(companyId, "reflection-coach");
const instructions = agentInstructionsService();
await instructions.writeFile(created.agent!, "AGENTS.md", "# Custom Reflection Coach\n\nOperator edit.\n");
await writeInstructionEntry(created.agent!, "AGENTS.md", "# Custom Reflection Coach\n\nOperator edit.\n");
const reconciled = await builtIns.ensure(companyId, "reflection-coach");
const drift = reconciled.resources.find((resource) => resource.resourceKind === "instructions");
@@ -815,7 +927,7 @@ describeEmbeddedPostgres("built-in agents", () => {
content: "# Custom Reflection Coach\n\nOperator edit.\n",
});
const reset = await builtIns.reset(companyId, "reflection-coach");
const reset = await builtIns.reset(companyId, "reflection-coach", {}, instructionOperator);
expect(reset.resources.find((resource) => resource.resourceKind === "instructions")).toMatchObject({
stockStatus: "stock_current",
resetAvailable: false,
@@ -823,6 +935,12 @@ describeEmbeddedPostgres("built-in agents", () => {
const resetFile = await instructions.readFile(reset.agent!, "AGENTS.md");
expect(resetFile.content).toContain("Reflection Coach");
expect(resetFile.content).not.toContain("Operator edit.");
const revisions = agentInstructionRevisionService(db);
const target = { companyId, agentId: created.agent!.id, entryFile: "AGENTS.md" };
const history = await revisions.history(target, instructionOperator);
expect(history.revisions).toHaveLength(0);
const current = await revisions.readCurrent(target, instructionOperator);
expect(current?.content).toBe(resetFile.content);
});
it("blocks deleting a built-in agent", async () => {
@@ -1270,7 +1388,7 @@ describeEmbeddedPostgres("built-in agents", () => {
const created = await builtIns.ensure(companyId, "summarizer");
const instructions = agentInstructionsService();
await instructions.writeFile(created.agent!, "AGENTS.md", "# Custom Summarizer\n\nOperator edit.\n");
await writeInstructionEntry(created.agent!, "AGENTS.md", "# Custom Summarizer\n\nOperator edit.\n");
const reconciled = await builtIns.ensure(companyId, "summarizer");
expect(reconciled.resources.find((resource) => resource.resourceKind === "instructions")).toMatchObject({
@@ -1279,7 +1397,7 @@ describeEmbeddedPostgres("built-in agents", () => {
changedFiles: ["AGENTS.md"],
});
const reset = await builtIns.reset(companyId, "summarizer");
const reset = await builtIns.reset(companyId, "summarizer", {}, instructionOperator);
expect(reset.resources.find((resource) => resource.resourceKind === "instructions")).toMatchObject({
stockStatus: "stock_current",
resetAvailable: false,
@@ -1419,7 +1537,7 @@ describeEmbeddedPostgres("built-in agents", () => {
const coach = created.agent!;
const instructionsSvc = agentInstructionsService();
const originalInstructions = "# Target Coder\n\nWork from the assigned issue.\n";
const prepared = await instructionsSvc.writeFile(target, "AGENTS.md", originalInstructions);
const prepared = await writeInstructionEntry(target, "AGENTS.md", originalInstructions);
let persistedTarget = (await agentsSvc.update(target.id, { adapterConfig: prepared.adapterConfig }))!;
const interactionsSvc = issueThreadInteractionService(db);
@@ -1431,7 +1549,7 @@ describeEmbeddedPostgres("built-in agents", () => {
if (interaction?.kind !== "request_confirmation" || interaction.status !== "accepted") {
return false;
}
const written = await instructionsSvc.writeFile(persistedTarget, "AGENTS.md", input.nextInstructions);
const written = await writeInstructionEntry(persistedTarget, "AGENTS.md", input.nextInstructions);
persistedTarget = (await agentsSvc.update(persistedTarget.id, { adapterConfig: written.adapterConfig }))!;
return true;
};
@@ -1568,7 +1686,7 @@ describeEmbeddedPostgres("built-in agents", () => {
const agent = created.agent!;
const instructionsSvc = agentInstructionsService();
await instructionsSvc.writeFile(agent, "AGENTS.md", "# Custom Reflection Coach\n\nDo not overwrite me.\n");
await writeInstructionEntry(agent, "AGENTS.md", "# Custom Reflection Coach\n\nDo not overwrite me.\n");
await db
.update(companySkills)
.set({ markdown: "---\nname: reflection-coach\n---\n\n# Custom skill\n" })
@@ -1597,7 +1715,7 @@ describeEmbeddedPostgres("built-in agents", () => {
const reset = await builtInAgentService(db).reset(companyId, "reflection-coach", {
resources: ["instructions", "routine"],
});
}, instructionOperator);
expect(reset.resources.find((resource) => resource.resourceKind === "instructions")).toMatchObject({
stockStatus: "stock_current",
resetAvailable: false,
@@ -56157,9 +56157,13 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
"leased",
"wrong_thread",
"source_edited",
])(
"retries only the original pre-provider Telegram request after exact cleanup: %s",
async (mode) => {
"different_error",
].flatMap((mode) => [
"runner_state_identity_mismatch",
"runner_state_identity_mismatch: prior_owner_active",
].map((errorMessage) => ({ mode, errorMessage }))))(
"retries only the original pre-provider Telegram request after exact cleanup: $mode ($errorMessage)",
async ({ mode, errorMessage }) => {
const context = await committedChatResponseRecoveryFixture("telegram");
const providerAccount =
mode === "null_account"
@@ -56286,7 +56290,9 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
agentId: context.fixture.assignedAgentId,
status: "failed",
errorCode: "adapter_failed",
error: "runner_state_identity_mismatch",
error: mode === "different_error"
? errorMessage.replace("runner_state_identity_mismatch", "runner_state_identity_mismatch_other")
: errorMessage,
finishedAt: new Date(),
wakeupRequestId: action.id,
runtimeMode: "native",
@@ -69057,7 +69063,11 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
},
);
it("retries an unknown subscription mutation after restart using freshly observed options, not a recovered confirmation flag", async () => {
it("retries an unknown subscription mutation after restart using freshly observed options, not a recovered confirmation flag", async ({ onTestFailed }) => {
let phase = "create fixture";
onTestFailed(() => {
console.error(`Telegram subscription recovery failed during: ${phase}`);
});
const lane = await draftFixture();
try {
await db
@@ -69074,6 +69084,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
throw new Error("Synthetic unknown subscription response");
return undefined;
});
phase = "first subscription attempt";
await lane.processSubscriptionAttempt();
const [action] = await db
.select()
@@ -69089,17 +69100,28 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
status: "failed",
result: { retryable: true, providerConfirmed: false },
});
// Keep the retry pending until the explicit post-restart transition below.
// A busy runner can otherwise exhaust the real one-second backoff here.
await db
.update(chatActions)
.set({
result: { ...action!.result, retryAt: "2099-01-01T00:00:00.000Z" },
})
.where(eq(chatActions.id, action!.id));
phase = "ordinary publication before restart";
expect((await lane.send("unknown-subscription"))?.state).toBe(
"published",
);
expect(lane.requests).toHaveLength(1);
expect(lane.requests[0]!.method.endsWith("Draft")).toBe(false);
phase = "pending recovery before restart";
await lane.context.service.processPendingDeliveries();
expect(
lane.maintenanceRequests.filter(
({ method }) => method === "setWebhook",
),
).toHaveLength(1);
phase = "restart";
await lane.restart();
lane.setSubscriptionInfo({
allowed_updates: ["message", "chat_member"],
@@ -69115,10 +69137,12 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
},
})
.where(eq(chatActions.id, action!.id));
phase = "concurrent recovery after restart";
await Promise.all([
lane.context.service.processPendingDeliveries(),
lane.context.service.processPendingDeliveries(),
]);
phase = "verify recovered subscription";
const mutations = lane.maintenanceRequests.filter(
({ method }) => method === "setWebhook",
);
@@ -69148,9 +69172,11 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
200,
);
});
phase = "publication after subscription recovery";
expect((await lane.send("repaired-after-unknown"))?.state).toBe(
"cancelled",
);
phase = "close fixture";
} finally {
await lane.close();
}
@@ -1009,7 +1009,11 @@ describeEmbeddedPostgres(
{ id: fixture.issueId, companyId: fixture.companyId },
{
kind: "request_confirmation",
payload: { version: 1, prompt: "Proceed with the release?" },
payload: {
version: 1,
prompt: "Proceed with the release?",
supersedeOnUserComment: true,
},
},
{ agentId: fixture.agentId },
);
@@ -1053,6 +1057,7 @@ describeEmbeddedPostgres(
kind: "ask_user_questions",
payload: {
version: 1,
supersedeOnUserComment: true,
questions: [
{
id: "priority",
@@ -300,6 +300,107 @@ describe("execution workspace policy helpers", () => {
});
});
describe("partial issue workspace strategies", () => {
const projectStrategy = {
type: "git_worktree" as const,
baseRef: "origin/main",
branchTemplate: "{{issue.identifier}}-{{slug}}",
worktreeParentDir: ".paperclip/worktrees",
provisionCommand: "true",
runtimeProvisionCommand: "npm run setup:runtime",
teardownCommand: "npm run teardown",
};
function resolveStrategy(
strategy: Record<string, unknown>,
enabled = true,
) {
return buildExecutionWorkspaceAdapterConfig({
agentConfig: { workspaceStrategy: { type: "git_worktree", provisionCommand: "agent-setup" } },
projectPolicy: parseProjectExecutionWorkspacePolicy({
enabled,
defaultMode: "isolated_workspace",
workspaceStrategy: projectStrategy,
}),
issueSettings: parseIssueExecutionWorkspaceSettings({
mode: "isolated_workspace",
workspaceStrategy: strategy,
}),
mode: "isolated_workspace",
legacyUseProjectWorkspace: null,
}).workspaceStrategy;
}
it("retains project hooks when an issue changes only its base branch", () => {
expect(resolveStrategy({ type: "git_worktree", baseRef: "origin/release" })).toEqual({
...projectStrategy,
baseRef: "origin/release",
});
});
it.each(["npm run issue-setup", "", null])("honors an explicit provisioning override of %j", (provisionCommand) => {
expect(resolveStrategy({ type: "git_worktree", provisionCommand })).toEqual({
...projectStrategy,
provisionCommand,
});
});
it("preserves explicit null clears through persisted JSON parsing", () => {
const strategy = {
type: "git_worktree",
baseRef: null,
branchTemplate: null,
worktreeParentDir: null,
provisionCommand: null,
runtimeProvisionCommand: null,
teardownCommand: null,
};
expect(resolveStrategy(strategy)).toEqual(strategy);
});
it.each(["cloud_sandbox", "adapter_managed", "project_primary"])("does not carry project hooks into %s", (type) => {
expect(resolveStrategy({ type })).toEqual({ type });
});
it("does not inherit a disabled project strategy", () => {
expect(resolveStrategy({ type: "git_worktree", baseRef: "origin/release" }, false)).toEqual({
type: "git_worktree",
baseRef: "origin/release",
});
expect(resolveStrategy({}, false)).toEqual({
type: "git_worktree",
provisionCommand: "agent-setup",
});
});
it("keeps project hooks for an exact branch pin without inheriting a branch template", () => {
const resolved = resolveStrategy({ type: "git_worktree", existingBranch: "fix/existing" });
expect(resolved).toEqual({
...projectStrategy,
branchTemplate: undefined,
existingBranch: "fix/existing",
});
expect(issueExecutionWorkspaceSettingsSchema.safeParse({
mode: "isolated_workspace",
workspaceStrategy: resolved,
}).success).toBe(true);
});
it("does not mutate the project or issue strategy", () => {
const issueStrategy = { type: "git_worktree" as const, baseRef: "origin/release" };
const result = buildExecutionWorkspaceAdapterConfig({
agentConfig: {},
projectPolicy: { enabled: true, workspaceStrategy: Object.freeze({ ...projectStrategy }) },
issueSettings: { workspaceStrategy: Object.freeze(issueStrategy) },
mode: "isolated_workspace",
legacyUseProjectWorkspace: null,
});
expect(result.workspaceStrategy).not.toBe(issueStrategy);
expect(issueStrategy).toEqual({ type: "git_worktree", baseRef: "origin/release" });
expect(projectStrategy.baseRef).toBe("origin/main");
});
});
it("preserves project authorization policy for trust-preset resolution", () => {
expect(parseProjectExecutionWorkspacePolicy({
enabled: true,
@@ -264,6 +264,12 @@ describeEmbeddedPostgres("heartbeat list", () => {
summary: "completed",
stdout: oversizedStdout,
nestedHuge: { payload: oversizedNestedPayload },
instructionSave: {
state: "unavailable", contract: "agent_files", entryFile: "AGENTS.md",
errorCode: "AGENT_FILES_LIMIT_EXCEEDED",
storageWarning: "Agent storage is full. Runs can continue.".repeat(50),
privateSyncMetadata: oversizedNestedPayload,
},
workspaceRestoreFailure: "restore_unsafe_archive",
finalResponseRecorded: true,
executionBeforeRestore: { errorCode: "model_error", exitCode: 2, timedOut: false },
@@ -278,6 +284,11 @@ describeEmbeddedPostgres("heartbeat list", () => {
truncated: true,
truncationReason: "oversized_result_json",
stdoutTruncated: true,
instructionSave: {
state: "unavailable", contract: "agent_files", entryFile: "AGENTS.md",
errorCode: "AGENT_FILES_LIMIT_EXCEEDED",
storageWarning: "Agent storage is full. Runs can continue.".repeat(50).slice(0, 1024),
},
workspaceRestoreFailure: "restore_unsafe_archive",
finalResponseRecorded: true,
executionBeforeRestore: { errorCode: "model_error", exitCode: 2, timedOut: false },
@@ -285,6 +296,7 @@ describeEmbeddedPostgres("heartbeat list", () => {
expect(typeof result?.stdout).toBe("string");
expect((result?.stdout as string).length).toBeLessThan(oversizedStdout.length);
expect(result).not.toHaveProperty("nestedHuge");
expect(result?.instructionSave).not.toHaveProperty("privateSyncMetadata");
});
});
@@ -394,6 +394,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
continuationPolicy: "wake_assignee",
payload: {
version: 1,
supersedeOnUserComment: true,
questions: [{
id: "scope",
prompt: "Which scope?",
@@ -1435,7 +1436,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
});
});
it("expires ask_user_questions interactions by default when a user comments after creation", async () => {
it("expires ask_user_questions when a creator opts into comment supersede", async () => {
const { companyId, issueId } = await seedConfirmationIssue("Question supersede");
const commentId = randomUUID();
@@ -1446,6 +1447,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
kind: "ask_user_questions",
payload: {
version: 1,
supersedeOnUserComment: true,
questions: [{
id: "scope",
prompt: "Choose the scope",
@@ -1491,7 +1493,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
});
});
it("keeps ask_user_questions pending when user-comment supersede is explicitly disabled", async () => {
it("keeps ask_user_questions pending by default when the user sends a message", async () => {
const { companyId, issueId } = await seedConfirmationIssue("Question supersede opt-out");
await interactionsSvc.create({
@@ -1501,7 +1503,6 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
kind: "ask_user_questions",
payload: {
version: 1,
supersedeOnUserComment: false,
questions: [{
id: "scope",
prompt: "Choose the scope",
@@ -1513,6 +1514,9 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
userId: "local-board",
});
const [created] = await db.select().from(issueThreadInteractions);
expect(created?.payload).toMatchObject({ supersedeOnUserComment: false });
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
id: issueId,
companyId,
@@ -1600,6 +1604,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
kind: "ask_user_questions",
payload: {
version: 1,
supersedeOnUserComment: true,
questions: [{
id: "scope",
prompt: "Choose the scope",
@@ -2519,7 +2524,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
status: "pending",
continuationPolicy: "wake_assignee",
payload: {
supersedeOnUserComment: true,
supersedeOnUserComment: false,
allowDeclineReason: true,
},
});
@@ -2618,6 +2623,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
payload: {
version: 1,
prompt: "Which files should be deleted?",
supersedeOnUserComment: true,
options: [{ id: "file-a", label: "a.txt" }],
},
}, {
@@ -2648,6 +2654,27 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
});
});
it("keeps checkbox confirmations pending by default after a user comment", async () => {
const { companyId, issueId } = await seedConfirmationIssue("Checkbox card remains");
const created = await interactionsSvc.create({ id: issueId, companyId }, {
kind: "request_checkbox_confirmation",
payload: {
version: 1,
prompt: "Choose a file",
options: [{ id: "file-a", label: "a.txt" }],
},
}, { userId: "local-board" });
expect(created.payload.supersedeOnUserComment).toBe(false);
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment(
{ id: issueId, companyId },
{ id: randomUUID(), createdAt: new Date(Date.now() + 1_000), authorUserId: "local-board" },
{ userId: "local-board" },
);
expect(expired).toHaveLength(0);
expect((await db.select().from(issueThreadInteractions))[0]?.status).toBe("pending");
});
it("submits request_item_verdicts partially and completes when all items are resolved", async () => {
const { companyId, issueId } = await seedConfirmationIssue("Item verdict partial submit");
@@ -2677,7 +2704,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
verdicts: ["approve", "reject"],
requireReasonOn: ["reject"],
allowBulkApprove: true,
supersedeOnUserComment: true,
supersedeOnUserComment: false,
},
});
@@ -2835,6 +2862,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
payload: {
version: 1,
prompt: "Review generated artifacts.",
supersedeOnUserComment: true,
items: [
{ id: "api", label: "API route" },
{ id: "docs", label: "Docs" },
@@ -2885,6 +2913,27 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
});
});
it("keeps item verdict requests pending by default after a user comment", async () => {
const { companyId, issueId } = await seedConfirmationIssue("Verdict card remains");
const created = await interactionsSvc.create({ id: issueId, companyId }, {
kind: "request_item_verdicts",
payload: {
version: 1,
prompt: "Review the file",
items: [{ id: "file-a", label: "a.txt" }],
},
}, { userId: "local-board" });
expect(created.payload.supersedeOnUserComment).toBe(false);
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment(
{ id: issueId, companyId },
{ id: randomUUID(), createdAt: new Date(Date.now() + 1_000), authorUserId: "local-board" },
{ userId: "local-board" },
);
expect(expired).toHaveLength(0);
expect((await db.select().from(issueThreadInteractions))[0]?.status).toBe("pending");
});
it("returns accepted agent confirmations from review without resetting active work", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
@@ -3187,7 +3236,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
.resolves.toBe("planning");
});
it("expires request confirmations by default when a user comments after creation", async () => {
it("expires request confirmations when a creator opts into comment supersede", async () => {
const { companyId, issueId } = await seedConfirmationIssue();
const commentId = randomUUID();
@@ -3199,6 +3248,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
payload: {
version: 1,
prompt: "Proceed with the current draft?",
supersedeOnUserComment: true,
},
}, {
userId: "local-board",
@@ -3234,7 +3284,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
});
});
it("keeps request confirmations pending when user-comment supersede is explicitly disabled", async () => {
it("keeps request confirmations pending by default when the user sends a message", async () => {
const { companyId, issueId } = await seedConfirmationIssue("Comment supersede opt-out");
await interactionsSvc.create({
@@ -3245,12 +3295,14 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
payload: {
version: 1,
prompt: "Proceed with the current draft?",
supersedeOnUserComment: false,
},
}, {
userId: "local-board",
});
const [created] = await db.select().from(issueThreadInteractions);
expect(created?.payload).toMatchObject({ supersedeOnUserComment: false });
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
id: issueId,
companyId,
@@ -3475,6 +3527,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
payload: {
version: 1,
prompt: "Proceed with the current draft?",
supersedeOnUserComment: true,
},
}, {
userId: "local-board",
@@ -438,6 +438,7 @@ describeEmbeddedPostgres("issueThreadInteractionService telemetry", () => {
continuationPolicy: "wake_assignee",
payload: {
version: 1,
supersedeOnUserComment: true,
questions: [
{
id: "scope",
@@ -563,6 +564,7 @@ describeEmbeddedPostgres("issueThreadInteractionService telemetry", () => {
payload: {
version: 1,
prompt: "Approve this plan?",
supersedeOnUserComment: true,
},
}, {
userId: "local-board",
@@ -201,7 +201,8 @@ function registerModuleMocks() {
}));
}
async function createApp() {
async function createApp(transaction: (callback: (tx: Record<string, never>) => Promise<unknown>) => Promise<unknown> =
async (callback) => callback({})) {
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
@@ -220,7 +221,7 @@ async function createApp() {
next();
});
app.use("/api", issueRoutes({
transaction: async (callback: (tx: Record<string, never>) => Promise<unknown>) => callback({}),
transaction,
} as any, {} as any));
app.use(errorHandler);
return app;
@@ -330,6 +331,7 @@ describe("issue update comment wakeups", () => {
externalConversationState,
assigneeAgentId: ASSIGNEE_AGENT_ID,
assigneeUserId: null,
assigneeAdapterOverrides: { adapterConfig: { model: "gpt-6-astra", modelReasoningEffort: "ultra", fastMode: true } },
});
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.update.mockResolvedValue(updated);
@@ -347,11 +349,16 @@ describe("issue update comment wakeups", () => {
assigneeUserId: null,
comment: "write the whole thing",
commentClientRequestId: "55555555-5555-4555-8555-555555555555",
assigneeAdapterOverrides: updated.assigneeAdapterOverrides,
});
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(existing.id, expect.objectContaining({
assigneeAdapterOverrides: updated.assigneeAdapterOverrides,
}), expect.anything());
expect(mockIssueService.addComment).toHaveBeenCalledWith(existing.id, "write the whole thing", expect.anything(),
expect.objectContaining({ clientRequestId: "55555555-5555-4555-8555-555555555555" }));
expect.objectContaining({ clientRequestId: "55555555-5555-4555-8555-555555555555" }), expect.anything());
expect(mockIssueService.update.mock.calls[0]?.[2]).toBe(mockIssueService.addComment.mock.calls[0]?.[4]);
// The route dispatches the wake after it sends the response, so wait for
// the fire-and-forget dispatch to settle. This keeps the wake inside this
// test and stops it from leaking into the next test as an extra call.
@@ -378,6 +385,35 @@ describe("issue update comment wakeups", () => {
);
});
it("rolls back adapter settings if the accompanying comment fails", async () => {
const existing = makeIssue({ assigneeAgentId: ASSIGNEE_AGENT_ID, assigneeUserId: null });
let persistedModel = "gpt-6-sol";
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.update.mockImplementation(async (_id, fields) => {
persistedModel = fields.assigneeAdapterOverrides.adapterConfig.model;
return { ...existing, ...fields };
});
mockIssueService.addComment.mockRejectedValue(new Error("comment write failed"));
const transaction = vi.fn(async (callback: (tx: Record<string, never>) => Promise<unknown>) => {
const previousModel = persistedModel;
try {
return await callback({});
} catch (error) {
persistedModel = previousModel;
throw error;
}
});
const res = await request(await createApp(transaction))
.patch(`/api/issues/${existing.id}`)
.send({ comment: "use Astra", assigneeAdapterOverrides: { adapterConfig: { model: "gpt-6-astra" } } });
expect(res.status).toBe(500);
expect(transaction).toHaveBeenCalledOnce();
expect(persistedModel).toBe("gpt-6-sol");
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("interrupts the active run and wakes the newly assigned agent with handoff context", async () => {
const existing = makeIssue({
assigneeAgentId: PREVIOUS_AGENT_ID,
+152 -1
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { asc, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { sql } from "drizzle-orm";
import {
activityLog,
@@ -41,7 +41,9 @@ import {
deriveIssueCommentRunLogAttribution,
ISSUE_LIST_MAX_LIMIT,
issueService,
readIssueCommentRunLogText,
} from "../services/issues.ts";
import { getRunLogStore } from "../services/run-log-store.js";
import {
WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
@@ -149,6 +151,155 @@ describeEmbeddedPostgres("issueService run attachment artifacts", () => {
}, 20_000);
});
describe("readIssueCommentRunLogText", () => {
it("cancels timed-out storage reads so later listings can recover", async () => {
let active = 0;
const cleanups: Array<() => void> = [];
const read = vi.spyOn(getRunLogStore(), "read").mockImplementation((_handle, options) =>
new Promise((_resolve, reject) => {
active += 1;
let settled = false;
const abort = () => {
if (settled) return;
settled = true;
active -= 1;
reject(new DOMException("Read aborted", "AbortError"));
};
cleanups.push(abort);
options?.signal?.addEventListener("abort", abort, { once: true });
}),
);
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const run = { runId: "run", logStore: "local_file", logRef: "test/run.ndjson", logBytes: null };
const firstBatch = Array.from({ length: 8 }, () => readIssueCommentRunLogText(run));
try {
await vi.advanceTimersByTimeAsync(3_000);
expect(await Promise.all(firstBatch)).toEqual(Array(8).fill(""));
expect(active).toBe(0);
read.mockResolvedValueOnce({ content: "storage recovered" });
await expect(readIssueCommentRunLogText(run)).resolves.toBe("storage recovered");
expect(read).toHaveBeenCalledTimes(9);
} finally {
for (const cleanup of cleanups) cleanup();
await Promise.allSettled(firstBatch);
await vi.advanceTimersByTimeAsync(0);
vi.useRealTimers();
read.mockRestore();
}
});
it("keeps readable attribution evidence for concurrent listings", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
const read = vi.spyOn(getRunLogStore(), "read").mockImplementation(async () => {
await gate;
return { content: "comment id: legacy-comment" };
});
const run = { runId: "run", logStore: "local_file", logRef: "test/run.ndjson", logBytes: null };
const listings = Array.from({ length: 2 }, () =>
Promise.all(Array.from({ length: 8 }, () => readIssueCommentRunLogText(run))),
);
try {
release();
for (const listing of listings) {
expect(await listing).toEqual(Array(8).fill("comment id: legacy-comment"));
}
expect(read).toHaveBeenCalledTimes(16);
} finally {
release();
await Promise.allSettled(listings);
read.mockRestore();
}
});
it.each([null, 128])("keeps partial attribution evidence when storage fails with logBytes=%s", async (logBytes) => {
const read = vi.spyOn(getRunLogStore(), "read").mockRejectedValue(new Error("Storage gateway unavailable"));
const run = { runId: "run", logStore: "local_file", logRef: "test/run.ndjson", logBytes };
try {
await expect(readIssueCommentRunLogText(run)).resolves.toBe("");
read.mockResolvedValueOnce({ content: "earlier evidence", nextOffset: 16 });
await expect(readIssueCommentRunLogText(run)).resolves.toBe("earlier evidence");
} finally {
read.mockRestore();
}
});
it("bounds reads that ignore cancellation and stops late pagination after the deadline", async () => {
let release!: (value: { content: string; nextOffset: number }) => void;
const stalled = new Promise<{ content: string; nextOffset: number }>((resolve) => {
release = resolve;
});
const read = vi.spyOn(getRunLogStore(), "read")
.mockResolvedValueOnce({ content: "earlier evidence", nextOffset: 16 })
.mockReturnValueOnce(stalled);
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
let result: string | undefined;
const pending = readIssueCommentRunLogText({
runId: "run", logStore: "local_file", logRef: "test/run.ndjson", logBytes: null,
}).then((value) => { result = value; });
try {
await vi.advanceTimersByTimeAsync(3_000);
expect(result).toBe("earlier evidence");
expect(read.mock.calls[1]?.[1]?.signal?.aborted).toBe(true);
release({ content: "too late", nextOffset: 24 });
await pending;
await vi.advanceTimersByTimeAsync(0);
expect(read).toHaveBeenCalledTimes(2);
expect(result).toBe("earlier evidence");
} finally {
release({ content: "", nextOffset: 0 });
await pending.catch(() => {});
vi.useRealTimers();
read.mockRestore();
}
});
it.each([null, 128, 0])("reads existing attribution markers with logBytes=%s", async (logBytes) => {
const commentId = randomUUID();
const runId = randomUUID();
const agentId = randomUUID();
const read = vi.spyOn(getRunLogStore(), "read")
.mockResolvedValueOnce({ content: "comment id: ", nextOffset: 12 })
.mockResolvedValueOnce({ content: commentId + "\n" });
try {
const logContent = await readIssueCommentRunLogText({
runId, logStore: "local_file", logRef: "test/run.ndjson", logBytes,
});
const derived = deriveIssueCommentRunLogAttribution(
[{
id: commentId,
authorAgentId: null,
authorUserId: "local-board",
createdByRunId: null,
createdAt: new Date("2020-01-01T00:00:01Z"),
}],
[{
runId,
agentId,
createdAt: new Date("2020-01-01T00:00:00Z"),
startedAt: new Date("2020-01-01T00:00:00Z"),
finishedAt: new Date("2020-01-01T00:00:02Z"),
logContent,
}],
);
if (logBytes === 0) {
expect(read).not.toHaveBeenCalled();
expect(derived.size).toBe(0);
} else {
expect(read).toHaveBeenCalledTimes(2);
expect(read.mock.calls[1]?.[1]?.offset).toBe(12);
expect(derived.get(commentId)).toEqual({
derivedAuthorAgentId: agentId,
derivedCreatedByRunId: runId,
derivedAuthorSource: "run_log_comment_post",
});
}
} finally {
read.mockRestore();
}
});
});
describe("deriveIssueCommentRunLogAttribution", () => {
it("recovers agent attribution from run logs that printed the posted comment id", () => {
const commentId = randomUUID();
@@ -7,6 +7,8 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agentConfigRevisions,
agentInstructionRevisions,
agentInstructionHeads,
agents,
approvals,
companies,
@@ -23,6 +25,9 @@ import {
} from "./helpers/embedded-postgres.js";
import { buildHostServices } from "../services/plugin-host-services.js";
import { agentService } from "../services/agents.js";
import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
import { agentInstructionsService } from "../services/agent-instructions.js";
import { pluginRegistryService } from "../services/plugin-registry.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -312,7 +317,7 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
it("materializes declared managed agent instructions with local folder paths", async () => {
const previousHome = process.env.PAPERCLIP_HOME;
const previousInstance = process.env.PAPERCLIP_INSTANCE_ID;
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-managed-agent-home-"));
const tempHome = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-managed-agent-home-")));
const wikiRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-managed-agent-wiki-")));
process.env.PAPERCLIP_HOME = tempHome;
process.env.PAPERCLIP_INSTANCE_ID = "test";
@@ -369,6 +374,39 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
expect(content).toContain("You are the LLM Wiki Maintainer.");
expect(content).toContain(`Wiki root: \`${wikiRoot}\``);
expect(content).toContain(`Wiki schema: \`${path.join(wikiRoot, "AGENTS.md")}\``);
// A managed plugin reset must preserve the previous entry in canonical
// history instead of deleting a shared bundle or bypassing its write guard.
const edited = "# User-customized wiki instructions\n";
await fs.writeFile(instructionsFilePath as string, edited);
const reset = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" });
expect(reset.status).toBe("reset");
expect(await fs.readFile(instructionsFilePath as string, "utf8")).toBe(content);
const history = await db.select().from(agentInstructionRevisions)
.where(eq(agentInstructionRevisions.agentId, created.agentId!));
expect(history).toHaveLength(0);
expect(await db.select().from(agentInstructionHeads)
.where(eq(agentInstructionHeads.agentId, created.agentId!))).toHaveLength(0);
const repeated = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" });
expect(repeated.status).toBe("reset");
expect(await db.select().from(agentInstructionRevisions)
.where(eq(agentInstructionRevisions.agentId, created.agentId!))).toHaveLength(0);
const audit = await db.select().from(activityLog).where(eq(activityLog.action, "agent.files_updated"));
expect(audit).toHaveLength(1);
expect(audit.every((row) => row.actorType === "plugin" && row.actorId === pluginId)).toBe(true);
const bundles = agentInstructionsService(db);
await bundles.writeFile(repeated.agent!, "CUSTOM.md", "# Alternate configured entry\n");
const switched = await bundles.updateBundle(repeated.agent!, { entryFile: "CUSTOM.md" });
await agentService(db).update(created.agentId!, { adapterConfig: switched.adapterConfig });
const resetEntry = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" });
expect(resetEntry.agent?.adapterConfig.instructionsEntryFile).toBe("AGENTS.md");
expect(resetEntry.defaultDrift).toBeNull();
expect(await fs.readFile(instructionsFilePath as string, "utf8")).toBe(content);
const preserved = await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, created.agentId!));
expect(preserved).toHaveLength(0);
expect(await fs.readFile(path.join(path.dirname(instructionsFilePath as string), "CUSTOM.md"), "utf8")).toBe("# Alternate configured entry\n");
} finally {
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = previousHome;
@@ -379,6 +417,60 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
}
});
it("fences plugin instruction reset by exact ownership, current capability and canonical CAS", async () => {
const previousHome = process.env.PAPERCLIP_HOME;
const tempHome = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "plugin-reset-cas-")));
process.env.PAPERCLIP_HOME = tempHome;
try {
const pluginManifest = manifest();
pluginManifest.agents![0]!.instructions = { content: "# Default\n" };
const { companyId, pluginId, services } = await seedCompanyAndPlugin({ manifest: pluginManifest });
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
const target = { companyId, agentId: created.agentId! };
const actor = { type: "plugin" as const, pluginId, pluginKey: pluginManifest.id, agentKey: "wiki-maintainer" };
const revisions = agentInstructionRevisionService(db);
const baselineRead = await revisions.readForPluginReset(target, actor);
const baseline = baselineRead.snapshot!;
const input = { ...target, entryFile: "AGENTS.md", baseRevisionId: baseline.revision.id,
configuredEntryFile: baselineRead.configuredEntryFile, content: "# Reset\n" };
await expect(revisions.commitPluginReset(input, { ...actor, pluginId: randomUUID() })).rejects.toMatchObject({ status: 403 });
await expect(revisions.commitPluginReset({ ...input, companyId: randomUUID() }, actor)).rejects.toMatchObject({ status: 404 });
await expect(revisions.commitPluginReset(input, { ...actor, agentKey: "foreign-agent" })).rejects.toMatchObject({ status: 403 });
await db.update(plugins).set({ manifestJson: { ...pluginManifest, capabilities: [] } }).where(eq(plugins.id, pluginId));
await expect(revisions.commitPluginReset(input, actor)).rejects.toMatchObject({ status: 403 });
await db.update(plugins).set({ manifestJson: pluginManifest }).where(eq(plugins.id, pluginId));
const originalMetadata = created.agent!.metadata;
await db.update(agents).set({ metadata: {} }).where(eq(agents.id, target.agentId));
await expect(revisions.commitPluginReset(input, actor)).rejects.toMatchObject({ status: 403 });
await db.update(agents).set({ metadata: originalMetadata }).where(eq(agents.id, target.agentId));
const [binding] = await db.select().from(pluginManagedResources).where(eq(pluginManagedResources.resourceId, target.agentId));
await db.update(pluginManagedResources).set({ resourceId: randomUUID() }).where(eq(pluginManagedResources.id, binding.id));
await expect(revisions.commitPluginReset(input, actor)).rejects.toMatchObject({ status: 403 });
await db.update(pluginManagedResources).set({ resourceId: target.agentId }).where(eq(pluginManagedResources.id, binding.id));
await expect(revisions.commitPluginReset({ ...input, entryFile: "OTHER.md" }, actor)).rejects.toMatchObject({ status: 403 });
const bundles = agentInstructionsService(db);
await bundles.writeFile(created.agent!, "OTHER.md", "# Other entry\n");
const switched = await bundles.updateBundle(created.agent!, { entryFile: "OTHER.md" });
await agentService(db).update(target.agentId, { adapterConfig: switched.adapterConfig });
await expect(revisions.commitPluginReset(input, actor)).rejects.toMatchObject({ status: 409, details: { code: "INSTRUCTION_ENTRY_CHANGED" } });
await agentService(db).update(target.agentId, { adapterConfig: created.agent!.adapterConfig });
const results = await Promise.allSettled([
revisions.commitPluginReset(input, actor),
revisions.commitPluginReset({ ...input, content: "# Concurrent reset\n" }, actor),
]);
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
const loser = results.find((result) => result.status === "rejected") as PromiseRejectedResult;
expect(loser.reason).toMatchObject({ status: 409, details: { code: "INSTRUCTION_REVISION_CONFLICT" } });
const winner = results.find((result) => result.status === "fulfilled") as PromiseFulfilledResult<Awaited<ReturnType<typeof revisions.commitPluginReset>>>;
expect(await fs.readFile(created.agent!.adapterConfig.instructionsFilePath as string, "utf8")).toBe(winner.value.content);
const history = await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, target.agentId));
expect(history).toHaveLength(0);
} finally {
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
await fs.rm(tempHome, { recursive: true, force: true });
}
});
it("repairs a missing binding by relinking a same-company managed agent marker", async () => {
const { companyId, pluginId, pluginManifest, services } = await seedCompanyAndPlugin();
const agentId = randomUUID();
@@ -410,6 +502,76 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
expect(binding?.data).toMatchObject({ agentId });
});
it("preserves current-file authority when relinking after a hard uninstall and reinstall", async () => {
const previousHome = process.env.PAPERCLIP_HOME;
const tempHome = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "plugin-reinstall-reset-")));
process.env.PAPERCLIP_HOME = tempHome;
try {
const pluginManifest = manifest();
pluginManifest.agents![0]!.instructions = { content: "# Original stock\n" };
const { companyId, pluginId, services } = await seedCompanyAndPlugin({ manifest: pluginManifest });
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
const target = { companyId, agentId: created.agentId! };
const revisions = agentInstructionRevisionService(db);
const original = await revisions.readForPluginReset(target, {
type: "plugin", pluginId, pluginKey: pluginManifest.id, agentKey: "wiki-maintainer",
});
const registry = pluginRegistryService(db);
await registry.uninstall(pluginId, true);
expect(await db.select().from(pluginManagedResources)).toHaveLength(0);
expect(await db.select().from(pluginEntities)).toHaveLength(0);
const nextManifest = structuredClone(pluginManifest);
nextManifest.agents![0]!.instructions = { content: "# Reinstalled stock\n" };
const installed = await registry.install({ packageName: "@paperclipai/plugin-managed-agents-test" }, nextManifest);
expect(installed!.id).not.toBe(pluginId);
await registry.updateStatus(installed!.id, { status: "ready" });
const reinstalledServices = buildHostServices(db, installed!.id, nextManifest.id, createEventBusStub(), undefined, {
manifest: nextManifest,
});
const relinked = await reinstalledServices.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
expect(relinked.status).toBe("relinked");
expect(relinked.agentId).toBe(created.agentId);
const reset = await reinstalledServices.agents.managedReset({ companyId, agentKey: "wiki-maintainer" });
expect(reset.agentId).toBe(created.agentId);
expect(reset.agent!.metadata).toMatchObject({ paperclipManagedResource: { pluginId: installed!.id }, pluginManagedAgent: { pluginId: installed!.id } });
expect(await fs.readFile(reset.agent!.adapterConfig.instructionsFilePath as string, "utf8")).toBe("# Reinstalled stock\n");
const history = await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, created.agentId!));
expect(history).toHaveLength(0);
} finally {
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
await fs.rm(tempHome, { recursive: true, force: true });
}
});
it.each(["not-ready", "capability-removed", "declaration-removed", "other-plugin-owner"])(
"does not relink a managed agent when its authority is %s", async (reason) => {
const { companyId, pluginId, pluginManifest, services } = await seedCompanyAndPlugin();
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
await db.delete(pluginEntities);
await db.delete(pluginManagedResources);
if (reason === "not-ready") {
await db.update(plugins).set({ status: "disabled" }).where(eq(plugins.id, pluginId));
} else if (reason === "capability-removed") {
await db.update(plugins).set({ manifestJson: { ...pluginManifest, capabilities: [] } }).where(eq(plugins.id, pluginId));
} else if (reason === "declaration-removed") {
await db.update(plugins).set({ manifestJson: { ...pluginManifest, agents: [] } }).where(eq(plugins.id, pluginId));
} else {
const otherId = randomUUID();
await db.insert(plugins).values({ id: otherId, pluginKey: "paperclip.other-owner", packageName: "other",
version: "0.1.0", apiVersion: 1, categories: [], manifestJson: { ...pluginManifest, id: "paperclip.other-owner" }, status: "ready", installOrder: 2 });
await db.update(agents).set({ metadata: { ...created.agent!.metadata,
paperclipManagedResource: { pluginId: otherId, pluginKey: pluginManifest.id, resourceKind: "agent", resourceKey: "wiki-maintainer" },
} }).where(eq(agents.id, created.agentId!));
}
const [before] = await db.select().from(agents).where(eq(agents.id, created.agentId!));
await expect(services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" })).rejects.toMatchObject({ status: 403 });
expect(await db.select().from(pluginEntities)).toHaveLength(0);
expect(await db.select().from(pluginManagedResources)).toHaveLength(0);
const [after] = await db.select().from(agents).where(eq(agents.id, created.agentId!));
expect(after.metadata).toEqual(before.metadata);
},
);
it("respects board approval policy for new managed agents", async () => {
const { companyId, services } = await seedCompanyAndPlugin({ requireApproval: true });
@@ -9,7 +9,7 @@ import {
writeEnvFileAtomicallyIfChanged,
} from "@paperclipai/shared/env-file";
import { resourceStatus, stockHash } from "../services/managed-resource-drift.js";
import { agentInstructionsService } from "../services/agent-instructions.js";
import { agentInstructionsService, resolveManagedInstructionsRoot } from "../services/agent-instructions.js";
/**
* Cross-cutting setup/sync rerun survival test.
@@ -23,7 +23,7 @@ import { agentInstructionsService } from "../services/agent-instructions.js";
* - .env -> updateEnvFileContents + writeEnvFileAtomicallyIfChanged
* - managed sandbox env -> resourceStatus/stockHash drift gate (the `shouldWrite`
* classifier the boot reconciler and built-in sync share)
* - managed instructions -> agentInstructionsService materialize + the drift gate that
* - managed instructions -> agentInstructionsService export + the drift gate that
* makes built-in/plugin/portability rebuilds skip operator edits
*
* The DB-backed end-to-end variants of the sandbox-row, skills-assignment, and
@@ -31,7 +31,7 @@ import { agentInstructionsService } from "../services/agent-instructions.js";
* server/src/__tests__/company-skills-service.test.ts (skills add/remove/replace)
* server/src/services/managed-environments.test.ts (sandbox row boot reconcile)
* server/src/__tests__/environment-service.test.ts (sandbox skip + stock update)
* server/src/__tests__/agent-instructions-service.test.ts (managed instruction drift)
* server/src/__tests__/agent-instruction-revisions.test.ts (guarded bundle materialization)
*/
const cleanupDirs = new Set<string>();
@@ -147,7 +147,7 @@ describe("setup/sync rerun survival — cross-cutting", () => {
).toBe("stock_current");
});
it("keeps operator edits and additions in a managed instructions tree across a re-materialize", async () => {
it("keeps operator edits and additions when managed instructions reconciliation detects drift", async () => {
const home = await tmp("pap16587-instr-home-");
process.env.PAPERCLIP_HOME = home;
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
@@ -156,18 +156,25 @@ describe("setup/sync rerun survival — cross-cutting", () => {
const agent = { id: "agent-1", companyId: "company-1", name: "Agent 1", adapterConfig: {} };
const stockFiles = { "AGENTS.md": "# stock\n", "docs/TOOLS.md": "## stock tools\n" };
const first = await svc.materializeManagedBundle(agent, stockFiles, { entryFile: "AGENTS.md" });
const root = first.bundle.managedRootPath!;
// Initial files are fixture setup. Bundle materialization now requires a
// real database so that it cannot bypass a canonical instruction revision.
const root = resolveManagedInstructionsRoot(agent);
for (const [name, content] of Object.entries(stockFiles)) {
await fs.mkdir(path.dirname(path.join(root, name)), { recursive: true });
await fs.writeFile(path.join(root, name), content, "utf8");
}
const managedAgent = { ...agent, adapterConfig: {
instructionsBundleMode: "managed", instructionsRootPath: root,
instructionsEntryFile: "AGENTS.md", instructionsFilePath: path.join(root, "AGENTS.md"),
} };
// Operator hand-edits a managed file and adds a brand-new operator-only file.
await fs.writeFile(path.join(root, "AGENTS.md"), "# stock\n\noperator note\n", "utf8");
await fs.writeFile(path.join(root, "OPERATOR.md"), "operator-added\n", "utf8");
// The drift gate the built-in/plugin/portability rebuilds share: classify the tree.
const currentFiles = {
"AGENTS.md": await fs.readFile(path.join(root, "AGENTS.md"), "utf8"),
"docs/TOOLS.md": await fs.readFile(path.join(root, "docs", "TOOLS.md"), "utf8"),
};
const currentFiles = (await svc.exportFiles(managedAgent)).files;
expect(currentFiles["OPERATOR.md"]).toBe("operator-added\n");
const status = resourceStatus({
resourceId: agent.id,
currentHash: stockHash(currentFiles),
+44 -3
View File
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { agentWakeupRequests, agents, companies, createDb, heartbeatRuns, heartbeatRunEvents, issueComments, issueThreadInteractions, issues } from "@paperclipai/db";
import { agentWakeupRequests, agents, companies, createDb, heartbeatRuns, heartbeatRunEvents, issueComments, issueThreadInteractions, issues, secretAccessEvents } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { acceptSteeredIdentity, captureRunIdentity, initializeRunIdentity, listRunIdentityContexts, rejectSteeredIdentity, reserveSteeredIdentity } from "../services/run-identity.js";
@@ -217,11 +217,11 @@ const support = await getEmbeddedPostgresTestSupport();
}
});
it("does not deadlock identity initialization against a task mutation that also updates the run", async () => {
it.each(["update", "no key update"] as const)("serializes identity initialization behind a task's %s lock", async (lockMode) => {
const input = await seed();
let initialization!: ReturnType<typeof initializeRunIdentity>;
await db.transaction(async (tx) => {
await tx.select().from(issues).where(eq(issues.id, input.issueId)).for("update");
await tx.select().from(issues).where(eq(issues.id, input.issueId)).for(lockMode);
const [backend] = await tx.execute(sql`select pg_backend_pid() as pid`) as unknown as Array<{ pid: number }>;
initialization = initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "instruction" });
// Wait until initialization is blocked by this task mutation, rather than
@@ -241,6 +241,47 @@ const support = await getEmbeddedPostgresTestSupport();
await expect(initialization).resolves.toMatchObject({ responsibleUserId: "A" });
});
it.each(["initialize", "capture"] as const)(
"can %s identity while an audit append holds foreign-key locks",
async (operation) => {
const input = await seed();
if (operation === "capture") {
await initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "instruction" });
}
// A real append holds KEY SHARE on both parent rows until it commits.
// Bound the other connection's wait so a conflicting lock fails this
// regression instead of leaving both transactions waiting for each other.
const identityDb = createDb(`${database.connectionString}?options=-c%20lock_timeout%3D1000`, {
maxConnections: 1,
});
const [settings] = await identityDb.execute(sql`show lock_timeout`);
expect(settings?.lock_timeout).toBe("1s");
await db.transaction(async (audit) => {
await audit.insert(secretAccessEvents).values({
companyId: input.companyId,
heartbeatRunId: input.runId,
issueId: input.issueId,
provider: "local_encrypted",
actorType: "agent",
actorId: input.agentId,
consumerType: "agent",
consumerId: input.agentId,
outcome: "granted",
});
if (operation === "initialize") {
await expect(initializeRunIdentity(identityDb, {
...input, messageIds: [], responsibleUserId: "A", cause: "instruction",
})).resolves.toMatchObject({ responsibleUserId: "A" });
} else {
await expect(captureRunIdentity(identityDb, input)).resolves.toMatchObject({
run: { responsibleUserId: "A" },
context: { responsibleUserId: "A" },
});
}
});
},
);
it("does not turn a company-default fallback into personal consent on continuation", async () => {
const input = await seed();
await initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "company_default" });
@@ -24,6 +24,11 @@ import {
workspaceRuntimeServices,
} from "@paperclipai/db";
import { eq } from "drizzle-orm";
import {
buildExecutionWorkspaceAdapterConfig,
parseIssueExecutionWorkspaceSettings,
parseProjectExecutionWorkspacePolicy,
} from "../services/execution-workspace-policy.ts";
import {
buildWorkspaceRuntimeDesiredStatePatch,
cleanupExecutionWorkspaceArtifacts,
@@ -869,6 +874,52 @@ describe("realizeExecutionWorkspace", () => {
expect(second.branchName).toBe(first.branchName);
});
it("retains the project provision command when an issue overrides its base branch", async () => {
const repoRoot = await createTempRepo();
await fs.mkdir(path.join(repoRoot, "scripts"));
await fs.writeFile(
path.join(repoRoot, "scripts", "provision-worktree.sh"),
"#!/usr/bin/env bash\necho 'Unexpected repository provision fallback' >&2\nexit 1\n",
);
await runGit(repoRoot, ["add", "scripts/provision-worktree.sh"]);
await runGit(repoRoot, ["commit", "-m", "Add fallback provisioner"]);
await runGit(repoRoot, ["branch", "release"]);
const config = buildExecutionWorkspaceAdapterConfig({
agentConfig: {},
projectPolicy: parseProjectExecutionWorkspacePolicy({
enabled: true,
defaultMode: "isolated_workspace",
workspaceStrategy: { type: "git_worktree", baseRef: "main", provisionCommand: "true" },
}),
issueSettings: parseIssueExecutionWorkspaceSettings({
mode: "isolated_workspace",
workspaceStrategy: { type: "git_worktree", baseRef: "release" },
}),
mode: "isolated_workspace",
legacyUseProjectWorkspace: null,
});
try {
const workspace = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config,
issue: { id: "issue-1", identifier: "TEST-1", title: "Keep project setup" },
agent: { id: "agent-1", name: "Test agent", companyId: "company-1" },
});
expect(workspace.created).toBe(true);
expect(workspace.baseRefSha).toBe(await readGit(repoRoot, ["rev-parse", "release"]));
await expect(fs.stat(path.join(workspace.cwd, ".git"))).resolves.toBeTruthy();
} finally {
await fs.rm(repoRoot, { recursive: true, force: true });
}
});
it("defaults the repo-provided worktree provisioner for git worktree strategies", async () => {
const repoRoot = await createTempRepo();
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
+16 -4
View File
@@ -1042,13 +1042,22 @@ function getDeclaredAdapterModels(): ReturnType<typeof parseAdapterModelsEnv> {
return value;
}
function declaredModelsForAdapter(type: string): { id: string; label: string }[] | null {
const declared = getDeclaredAdapterModels()?.[type];
return declared?.length
? declared.map((model) => ({ id: model.id, label: model.label ?? model.id }))
: null;
}
export async function listAdapterModels(type: string): Promise<{ id: string; label: string }[]> {
const declaredModels = getDeclaredAdapterModels();
if (declaredModels && declaredModels[type]?.length) {
return declaredModels[type].map((m) => ({ id: m.id, label: m.label ?? m.id }));
}
const declaredModels = declaredModelsForAdapter(type);
if (declaredModels) return declaredModels;
const adapter = findActiveServerAdapter(type);
if (!adapter) return [];
// The built-in Codex adapter's OpenAI discovery includes image, audio, and
// embedding models that Codex cannot run. Use its curated list; declared
// models above and custom adapter discovery remain authoritative.
if (adapter === codexLocalAdapter) return adapter.models ?? [];
if (adapter.listModels) {
const discovered = await adapter.listModels();
if (discovered.length > 0) return discovered;
@@ -1057,6 +1066,9 @@ export async function listAdapterModels(type: string): Promise<{ id: string; lab
}
export async function refreshAdapterModels(type: string): Promise<{ id: string; label: string }[]> {
const declaredModels = declaredModelsForAdapter(type);
if (declaredModels) return declaredModels;
if (findActiveServerAdapter(type) === codexLocalAdapter) return listAdapterModels(type);
const adapter = findActiveServerAdapter(type);
if (!adapter) return [];
if (adapter.refreshModels) {
@@ -50,7 +50,7 @@ Status quick guide:
- Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. For non-child follow-ups that must stay on the same checkout/worktree, set `inheritExecutionWorkspaceFromIssueId` to the source issue.
- When you know the needed work and owner, create those subtasks directly. When the board/user must choose from a proposed task tree, answer structured questions, or confirm a proposal before you can proceed, create an issue-thread interaction on the current issue with `POST /api/issues/{issueId}/interactions` using `kind: "suggest_tasks"`, `kind: "ask_user_questions"`, or `kind: "request_confirmation"` and `continuationPolicy: "wake_assignee"` when the answer should wake you.
- For plan approval, update the `plan` document first, create `request_confirmation` targeting the latest `plan` revision, use an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, set the source issue to `in_review`, and do not create implementation subtasks until the board/user accepts it.
- `ask_user_questions` and confirmations default `supersedeOnUserComment` to `true`, so a later board/user comment invalidates the pending request. Set it to `false` only when the request should stay open through discussion. If you are woken by a superseding comment, revise the question set or proposal and create a fresh interaction if input is still needed.
- `ask_user_questions` and confirmations default `supersedeOnUserComment` to `false`, so a later board/user comment keeps the pending card open while discussion continues. Set it to `true` when a new comment should replace the pending request. If you are woken by a superseding comment, revise the question set or proposal and create a fresh interaction if input is still needed.
- Use `paperclip-create-agent` skill when hiring new agents.
- Assign work to the right agent for the job.
@@ -17,7 +17,7 @@ You are an agent at Paperclip company.
3. Only then create `request_confirmation` with `target={ type: 'issue_document', key: 'plan', revisionId: latestRevisionId }` and `idempotencyKey=confirmation:{issueId}:plan:{revisionId}`.
4. Wait for acceptance before creating implementation subtasks.
Never present a plan only in a thread comment or through `ask_user_questions`; comments are supporting context and questions are for gathering input, not plan review.
- `ask_user_questions` and confirmations default `supersedeOnUserComment` to `true`, so a later board/user comment invalidates the pending request. Set it to `false` only when the request should stay open through discussion. If you wake up from a superseding comment, revise the artifact, question set, or proposal and create a fresh interaction if input is still needed.
- `ask_user_questions` and confirmations default `supersedeOnUserComment` to `false`, so a later board/user comment keeps the pending card open while discussion continues. Set it to `true` when a new comment should replace the pending request. If you wake up from a superseding comment, revise the artifact, question set, or proposal and create a fresh interaction if input is still needed.
- For human input, save a pending question/confirmation interaction and set `in_review`; prose alone does not create a waiting path. Use `blockedByIssueIds` for issue dependencies. An agent may set an `unblockDescriptor` only for itself (`owner: { "agentId": "<your-agent-id>" }` plus `action`), not for the board/user or another agent.
- Respect budget, pause/cancel, approval gates, and company boundaries.
+154 -4
View File
@@ -1,3 +1,5 @@
import { agentFileStore, agentFileTokenFromHash } from "../services/agent-file-store.js";
import { pipeline } from "node:stream/promises";
import { resolveAgentAppearance, agentAvatarUrl } from "@paperclipai/shared";
import { listOpenRouterModels } from "../services/openrouter-models.js";
import { prepareManagedAiRuntime, assertManagedAiProjectAuth, stripAiAuthBindings } from "../services/ai-connection-runtime.js";
@@ -38,6 +40,8 @@ import {
type AgentSkillSnapshot,
type InstanceSchedulerHeartbeatAgent,
upsertAgentInstructionsFileSchema,
restoreAgentInstructionSchema,
resolveAgentInstructionCandidateSchema,
updateAgentInstructionsBundleSchema,
updateAgentPermissionsSchema,
updateAgentInstructionsPathSchema,
@@ -63,7 +67,11 @@ import {
import { trackAgentCreated } from "@paperclipai/shared/telemetry";
import { validate } from "../middleware/validate.js";
import { inheritNativeRunnerAdapterConfig } from "../services/native-runtime/native-agent-runtime-inheritance.js";
import { agentInstructionsBundleMode } from "../services/agent-instructions.js";
import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
import { agentInstructionWorkingCopyService } from "../services/agent-instruction-working-copies.js";
import { authorizeInstructionRead } from "../services/agent-instruction-authorization.js";
import { instructionPath } from "../services/agent-instruction-files.js";
import { agentInstructionsBundleMode, deriveBundleState } from "../services/agent-instructions.js";
import {
agentService,
agentInstructionsService,
@@ -724,7 +732,17 @@ export function agentRoutes(
const recovery = recoveryService(db, { enqueueWakeup: heartbeat.wakeup });
const issueApprovalsSvc = issueApprovalService(db);
const secretsSvc = secretService(db);
const instructions = agentInstructionsService();
const instructions = agentInstructionsService(db);
const agentFiles = agentFileStore(db);
const instructionRevisions = agentInstructionRevisionService(db);
const instructionWorkingCopies = agentInstructionWorkingCopyService(db);
function instructionFileDetail(snapshot: import("@paperclipai/shared").AgentInstructionSnapshot,
receipt?: import("@paperclipai/shared").AgentInstructionCommitReceipt) {
const path = snapshot.revision.entryFile;
return { path, content: snapshot.content, contentHash: snapshot.revision.contentHash, size: snapshot.revision.byteLength, revision: snapshot.revision, receipt,
language: path.toLowerCase().endsWith(".md") ? "markdown" : "text", markdown: path.toLowerCase().endsWith(".md"),
isEntryFile: true, editable: true, deprecated: false, virtual: false };
}
const companySkills = companySkillService(db);
const workspaceOperations = workspaceOperationService(db);
const instanceSettings = instanceSettingsService(db);
@@ -5067,8 +5085,24 @@ export function agentRoutes(
const id = req.params.id as string;
const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found");
if (!existing) return;
await assertCanReadAgent(req, existing);
assertExternalInstructionsAdmin(req, existing);
if (agentInstructionsBundleMode(existing) === "external") {
await assertCanReadAgent(req, existing);
} else {
await authorizeInstructionRead(db, req.actor, { companyId: existing.companyId, id: existing.id });
}
if (agentInstructionsBundleMode(existing) !== "external") {
const target = { companyId: existing.companyId, agentId: existing.id };
const current = await instructionRevisions.readCurrent(target, req.actor);
if (current) {
try { await instructionRevisions.materializeCurrent(target); }
catch (error) {
const bundle = await instructions.getBundle(existing);
bundle.warnings.push(`Saved instruction revision needs materialization: ${error instanceof Error ? error.message : String(error)}`);
res.json(bundle); return;
}
}
}
res.json(await instructions.getBundle(existing));
});
@@ -5124,8 +5158,12 @@ export function agentRoutes(
const id = req.params.id as string;
const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found");
if (!existing) return;
await assertCanReadAgent(req, existing);
assertExternalInstructionsAdmin(req, existing);
if (agentInstructionsBundleMode(existing) === "external") {
await assertCanReadAgent(req, existing);
} else {
await authorizeInstructionRead(db, req.actor, { companyId: existing.companyId, id: existing.id });
}
const relativePath = typeof req.query.path === "string" ? req.query.path : "";
if (!relativePath.trim()) {
@@ -5133,6 +5171,19 @@ export function agentRoutes(
return;
}
if (req.query.download === "true" && agentInstructionsBundleMode(existing) === "managed") {
const download = await agentFiles.download(existing.companyId, existing.id, relativePath, req.actor);
if (download === null) throw notFound("Agent file not found");
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader("X-Content-Type-Options", "nosniff");
res.attachment(relativePath.split("/").at(-1)!);
res.setHeader("Content-Length", download.size);
await pipeline(download.stream, res); return;
}
if (agentInstructionsBundleMode(existing) !== "external" && instructionPath(relativePath) === deriveBundleState(existing).entryFile) {
const snapshot = await instructionRevisions.readCurrent({ companyId: existing.companyId, agentId: existing.id }, req.actor);
if (snapshot) { res.json(instructionFileDetail(snapshot)); return; }
}
res.json(await instructions.readFile(existing, relativePath));
});
@@ -5140,9 +5191,42 @@ export function agentRoutes(
const id = req.params.id as string;
const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found");
if (!existing) return;
const entryFile = deriveBundleState(existing).entryFile;
if (instructionPath(req.body.path) === entryFile) {
assertExternalInstructionsAdmin(req, existing);
if (req.body.baseHash !== undefined) req.body.baseRevisionId = req.body.baseHash === null ? null : agentFileTokenFromHash(req.body.baseHash);
if (req.body.baseRevisionId === undefined) throw unprocessable("Read the entry and supply baseRevisionId (null for a new entry)", { code: "INSTRUCTION_BASE_REQUIRED" });
// Clearing legacy prompt configuration remains a protected config change.
if (req.body.clearLegacyPromptTemplate) await assertCanManageInstructionsPath(req, existing);
const receipt = await instructionRevisions.commit({ companyId: existing.companyId, agentId: existing.id,
entryFile, content: req.body.content, baseRevisionId: req.body.baseRevisionId,
source: req.actor.type === "board" ? "board" : "api" }, req.actor);
if (req.actor.type === "agent" && req.actor.runId) {
await instructionWorkingCopies.acknowledgeExplicitSave({ companyId: existing.companyId, agentId: existing.id,
runId: req.actor.runId, entryFile: receipt.revision.entryFile, revisionId: receipt.revision.id,
contentHash: receipt.revision.contentHash }).catch(() => undefined);
}
if (req.body.clearLegacyPromptTemplate) {
const fresh = await svc.getById(existing.id);
if (fresh) {
const adapterConfig = { ...asRecord(fresh.adapterConfig) };
delete adapterConfig.promptTemplate;
delete adapterConfig.bootstrapPromptTemplate;
await svc.update(existing.id, { adapterConfig });
}
}
res.json(instructionFileDetail(receipt, receipt));
return;
}
await assertCanManageInstructionsPath(req, existing);
assertExternalInstructionsAdmin(req, existing);
if (agentInstructionsBundleMode(existing) === "managed" && req.body.path !== "promptTemplate.legacy.md") {
if (req.body.baseHash === undefined) throw unprocessable("Read the file and supply baseHash (null for a new file)");
await agentFiles.write({ companyId: existing.companyId, agentId: existing.id, path: req.body.path,
bytes: Buffer.from(req.body.content, "utf8"), baseHash: req.body.baseHash }, req.actor);
res.json(await instructions.readFile(existing, req.body.path)); return;
}
const actor = getActorInfo(req);
const result = await instructions.writeFile(existing, req.body.path, req.body.content, {
clearLegacyPromptTemplate: req.body.clearLegacyPromptTemplate,
@@ -5184,6 +5268,66 @@ export function agentRoutes(
res.json(result.file);
});
router.get("/agents/:id/instructions-bundle/candidates", async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found");
if (!existing) return;
assertExternalInstructionsAdmin(req, existing);
res.json(await instructionWorkingCopies.list(existing.companyId, existing.id, req.actor));
});
router.post("/agents/:id/instructions-bundle/candidates/:runId/resolve", validate(resolveAgentInstructionCandidateSchema), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found");
if (!existing) return;
assertExternalInstructionsAdmin(req, existing);
const runId = req.params.runId as string;
if (!isUuidLike(runId)) throw unprocessable("Invalid instruction candidate run id");
const receipt = await instructionWorkingCopies.resolve({ companyId: existing.companyId, agentId: existing.id,
runId, baseRevisionId: req.body.baseRevisionId, content: req.body.content }, req.actor);
res.json(instructionFileDetail(receipt, receipt));
});
router.get("/agents/:id/instructions-bundle/history", async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found");
if (!existing) return;
assertExternalInstructionsAdmin(req, existing);
const entryFile = typeof req.query.path === "string" ? req.query.path : deriveBundleState(existing).entryFile;
const cursor = typeof req.query.cursor === "string" ? req.query.cursor : undefined;
if (cursor && !isUuidLike(cursor)) throw unprocessable("Invalid history cursor");
res.json(await instructionRevisions.history({ companyId: existing.companyId, agentId: existing.id, entryFile, cursor }, req.actor));
});
router.get("/agents/:id/instructions-bundle/revision/:revisionId", async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found");
if (!existing) return;
assertExternalInstructionsAdmin(req, existing);
const revisionId = req.params.revisionId as string;
if (!isUuidLike(revisionId)) throw unprocessable("Invalid instruction revision id");
const entryFile = typeof req.query.path === "string" ? req.query.path : deriveBundleState(existing).entryFile;
res.json(await instructionRevisions.readRevision({ companyId: existing.companyId, agentId: existing.id, entryFile, revisionId }, req.actor));
});
router.get("/agents/:id/instructions-bundle/diff", async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found");
if (!existing) return;
assertExternalInstructionsAdmin(req, existing);
const fromRevisionId = typeof req.query.from === "string" ? req.query.from : "";
const toRevisionId = typeof req.query.to === "string" ? req.query.to : "";
if (!isUuidLike(fromRevisionId) || !isUuidLike(toRevisionId)) throw unprocessable("Provide valid from and to revision ids");
const entryFile = typeof req.query.path === "string" ? req.query.path : deriveBundleState(existing).entryFile;
res.json(await instructionRevisions.diff({ companyId: existing.companyId, agentId: existing.id, entryFile, fromRevisionId, toRevisionId }, req.actor));
});
router.post("/agents/:id/instructions-bundle/restore", validate(restoreAgentInstructionSchema), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found");
if (!existing) return;
assertExternalInstructionsAdmin(req, existing);
const receipt = await instructionRevisions.restore({ companyId: existing.companyId, agentId: existing.id,
entryFile: req.body.path, baseRevisionId: req.body.baseRevisionId, revisionId: req.body.revisionId }, req.actor);
if (req.actor.type === "agent" && req.actor.runId) {
await instructionWorkingCopies.acknowledgeExplicitSave({ companyId: existing.companyId, agentId: existing.id,
runId: req.actor.runId, entryFile: receipt.revision.entryFile, revisionId: receipt.revision.id,
contentHash: receipt.revision.contentHash }).catch(() => undefined);
}
res.json(instructionFileDetail(receipt, receipt));
});
router.delete("/agents/:id/instructions-bundle/file", async (req, res) => {
const id = req.params.id as string;
const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found");
@@ -5197,6 +5341,12 @@ export function agentRoutes(
return;
}
if (agentInstructionsBundleMode(existing) === "managed") {
const baseHash = typeof req.query.baseHash === "string" && /^[a-f0-9]{64}$/.test(req.query.baseHash) ? req.query.baseHash : null;
if (baseHash === null) throw unprocessable("Read the file and supply baseHash before deleting it");
await agentFiles.write({ companyId: existing.companyId, agentId: existing.id, path: relativePath, bytes: null, baseHash }, req.actor);
res.json(await instructions.getBundle(existing)); return;
}
const actor = getActorInfo(req);
const result = await instructions.deleteFile(existing, relativePath);
await logActivity(db, {
+1 -1
View File
@@ -217,7 +217,7 @@ export function builtInAgentRoutes(db: Db) {
const key = req.params.key as string;
await assertBuiltInAgentsEnabled();
await assertCanProvisionBuiltInAgents(req, companyId);
const state = await svc.reset(companyId, key, req.body);
const state = await svc.reset(companyId, key, req.body, req.actor);
await logBuiltInAgentMutation(req, {
companyId,
action: "built_in_agent.reset",
Loaded 100 of 217 files, more files were not shown because too many files have changed in this diff. Show more