diff --git a/DESIGN.md b/DESIGN.md index d80f6a70754..75a2972dbce 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 3c82a93a2b7..3a348cbe52a 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -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 diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 417715fc925..c830de4bcbd 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -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": "" } }`. 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. diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 04aaa912d5e..12e000fea51 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -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 diff --git a/doc/SPEC.md b/doc/SPEC.md index 3e5fa410fe6..e69a6f1b8b0 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -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 diff --git a/doc/agent-files.md b/doc/agent-files.md new file mode 100644 index 00000000000..956db02c029 --- /dev/null +++ b/doc/agent-files.md @@ -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: + +``` +/companies//agents// + instructions/ current agent files (editor) + AGENTS.md + notes/ + any-supported-file + file-sync/ controller-only operational state + adopted.json + runs//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///` 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. diff --git a/doc/agent-instruction-revisions.md b/doc/agent-instruction-revisions.md new file mode 100644 index 00000000000..d4512d68678 --- /dev/null +++ b/doc/agent-instruction-revisions.md @@ -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. diff --git a/doc/architecture/runner-rich-acp-capabilities.md b/doc/architecture/runner-rich-acp-capabilities.md index 13aa59e6aba..54467eaa033 100644 --- a/doc/architecture/runner-rich-acp-capabilities.md +++ b/doc/architecture/runner-rich-acp-capabilities.md @@ -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. | diff --git a/doc/composer-stop.md b/doc/composer-stop.md index 7c77b2d1134..5579d55f10e 100644 --- a/doc/composer-stop.md +++ b/doc/composer-stop.md @@ -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 diff --git a/doc/evals.md b/doc/evals.md index e1e78cfda4d..061918c6084 100644 --- a/doc/evals.md +++ b/doc/evals.md @@ -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). diff --git a/doc/observability.md b/doc/observability.md index 7b5bef23c88..491e8e35c85 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -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 diff --git a/package.json b/package.json index f5566737bfe..fc3c8b0bae9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 3293014bc7c..a26f980fc9a 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -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 }).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"); diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 9640ee414f8..7aaef803b9d 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -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 { - 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; env: Record; 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, diff --git a/packages/adapter-utils/src/command-managed-runtime.ts b/packages/adapter-utils/src/command-managed-runtime.ts index befdccf674d..9abfd933c9f 100644 --- a/packages/adapter-utils/src/command-managed-runtime.ts +++ b/packages/adapter-utils/src/command-managed-runtime.ts @@ -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, diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts index 36695e85c1b..bf600fdd74c 100644 --- a/packages/adapter-utils/src/execution-target-sandbox.test.ts +++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts @@ -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); diff --git a/packages/adapter-utils/src/execution-target-stdin-race.test.ts b/packages/adapter-utils/src/execution-target-stdin-race.test.ts index 9341358c38a..a87deb0e89a 100644 --- a/packages/adapter-utils/src/execution-target-stdin-race.test.ts +++ b/packages/adapter-utils/src/execution-target-stdin-race.test.ts @@ -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 { + async function waitFor(check: () => boolean | Promise, timeoutMs = 4_000): Promise { 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) | undefined; + const local = createLocalSandboxRunner(); + const runner = { + execute: async (input: Parameters[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((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((resolve) => peer!.on("close", () => resolve())); + await new Promise((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((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 | 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((resolve) => peer!.once("close", resolve)); + await new Promise((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((_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 () => { diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 54b055bac6d..6b118011d43 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -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 = 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, diff --git a/packages/adapter-utils/src/remote-managed-runtime.test.ts b/packages/adapter-utils/src/remote-managed-runtime.test.ts index 89659bb643e..f5cb64b9d06 100644 --- a/packages/adapter-utils/src/remote-managed-runtime.test.ts +++ b/packages/adapter-utils/src/remote-managed-runtime.test.ts @@ -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 } }]; + expect(args[0].baselineSnapshot.entries.has("node_modules/personal.bin")).toBe(true); + }); + + afterEach(async () => { vi.clearAllMocks(); while (cleanupDirs.length > 0) { diff --git a/packages/adapter-utils/src/remote-managed-runtime.ts b/packages/adapter-utils/src/remote-managed-runtime.ts index ff394dc7fb3..cb47f3caede 100644 --- a/packages/adapter-utils/src/remote-managed-runtime.ts +++ b/packages/adapter-utils/src/remote-managed-runtime.ts @@ -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; diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.ts b/packages/adapter-utils/src/sandbox-callback-bridge.ts index d9145c9c256..40776a3da59 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.ts @@ -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); diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index 3f83e61639a..10eeab606e4 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -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, diff --git a/packages/adapter-utils/src/ssh-fixture.test.ts b/packages/adapter-utils/src/ssh-fixture.test.ts index c9c7657f564..83c4ad7e6d5 100644 --- a/packages/adapter-utils/src/ssh-fixture.test.ts +++ b/packages/adapter-utils/src/ssh-fixture.test.ts @@ -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"); diff --git a/packages/adapter-utils/src/ssh.ts b/packages/adapter-utils/src/ssh.ts index 084bb0803d1..89fd456c5d4 100644 --- a/packages/adapter-utils/src/ssh.ts +++ b/packages/adapter-utils/src/ssh.ts @@ -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", }); diff --git a/packages/adapter-utils/src/workspace-restore-merge.test.ts b/packages/adapter-utils/src/workspace-restore-merge.test.ts index 7833997dd44..914d99ac751 100644 --- a/packages/adapter-utils/src/workspace-restore-merge.test.ts +++ b/packages/adapter-utils/src/workspace-restore-merge.test.ts @@ -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 }); } +}); diff --git a/packages/adapter-utils/src/workspace-restore-merge.ts b/packages/adapter-utils/src/workspace-restore-merge.ts index 88e89e8f920..60e3ce539f4 100644 --- a/packages/adapter-utils/src/workspace-restore-merge.ts +++ b/packages/adapter-utils/src/workspace-restore-merge.ts @@ -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(); + 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; afterApply?: () => Promise; }): Promise { @@ -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); } diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index 3cb69dc4ce9..5d3a95c888f 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -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" }, diff --git a/packages/adapters/codex-local/src/server/acp.test.ts b/packages/adapters/codex-local/src/server/acp.test.ts index 5935b238b31..b31dafcb9ff 100644 --- a/packages/adapters/codex-local/src/server/acp.test.ts +++ b/packages/adapters/codex-local/src/server/acp.test.ts @@ -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"); diff --git a/packages/adapters/codex-local/src/server/acp.ts b/packages/adapters/codex-local/src/server/acp.ts index e9b19def480..c7715dc75ff 100644 --- a/packages/adapters/codex-local/src/server/acp.ts +++ b/packages/adapters/codex-local/src/server/acp.ts @@ -166,17 +166,16 @@ export function buildCodexAcpConfig(config: Record): Record + 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 diff --git a/packages/db/src/agent-instruction-migration.test.ts b/packages/db/src/agent-instruction-migration.test.ts new file mode 100644 index 00000000000..6def7b7e884 --- /dev/null +++ b/packages/db/src/agent-instruction-migration.test.ts @@ -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); +}); diff --git a/packages/db/src/migrations/0287_serious_tinkerer.sql b/packages/db/src/migrations/0287_serious_tinkerer.sql new file mode 100644 index 00000000000..2810534d4c1 --- /dev/null +++ b/packages/db/src/migrations/0287_serious_tinkerer.sql @@ -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"); diff --git a/packages/db/src/migrations/meta/0282_snapshot.json b/packages/db/src/migrations/meta/0287_snapshot.json similarity index 97% rename from packages/db/src/migrations/meta/0282_snapshot.json rename to packages/db/src/migrations/meta/0287_snapshot.json index 5c16a75ee35..258c8e4a6a2 100644 --- a/packages/db/src/migrations/meta/0282_snapshot.json +++ b/packages/db/src/migrations/meta/0287_snapshot.json @@ -1,6 +1,6 @@ { - "id": "23f8fed6-33cf-43ff-b711-8adb151a4bfc", - "prevId": "8d202a5a-5d6b-44d9-9336-3bb39a8b99bc", + "id": "26beb23c-50c3-42f9-8a1c-814d0c8acfbe", + "prevId": "33120bf3-9cac-4909-ae94-d514ddc1e9e2", "version": "7", "dialect": "postgresql", "tables": { @@ -829,6 +829,479 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.agent_instruction_heads": { + "name": "agent_instruction_heads", + "schema": "", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entry_file": { + "name": "entry_file", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision_id": { + "name": "revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agent_instruction_heads_company_id_agent_id_entry_file_revision_id_agent_instruction_revisions_company_id_agent_id_entry_file_id_fk": { + "name": "agent_instruction_heads_company_id_agent_id_entry_file_revision_id_agent_instruction_revisions_company_id_agent_id_entry_file_id_fk", + "tableFrom": "agent_instruction_heads", + "tableTo": "agent_instruction_revisions", + "columnsFrom": [ + "company_id", + "agent_id", + "entry_file", + "revision_id" + ], + "columnsTo": [ + "company_id", + "agent_id", + "entry_file", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agent_instruction_heads_identity_uq": { + "name": "agent_instruction_heads_identity_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "agent_id", + "entry_file" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_instruction_revisions": { + "name": "agent_instruction_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entry_file": { + "name": "entry_file", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_base64": { + "name": "content_base64", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_length": { + "name": "byte_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_revision_id": { + "name": "parent_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "base_revision_id": { + "name": "base_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "restored_from_revision_id": { + "name": "restored_from_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_agent_id": { + "name": "actor_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "responsible_user_id": { + "name": "responsible_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_run_id": { + "name": "source_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_instruction_revisions_history_idx": { + "name": "agent_instruction_revisions_history_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entry_file", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_instruction_revisions_company_id_agent_id_agents_company_id_id_fk": { + "name": "agent_instruction_revisions_company_id_agent_id_agents_company_id_id_fk", + "tableFrom": "agent_instruction_revisions", + "tableTo": "agents", + "columnsFrom": [ + "company_id", + "agent_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agent_instruction_revisions_identity_uq": { + "name": "agent_instruction_revisions_identity_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "agent_id", + "entry_file", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_instruction_working_copies": { + "name": "agent_instruction_working_copies", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "responsible_user_id": { + "name": "responsible_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entry_file": { + "name": "entry_file", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_revision_id": { + "name": "base_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "base_hash": { + "name": "base_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_root": { + "name": "local_root", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_root": { + "name": "execution_root", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'prepared'" + }, + "candidate_base64": { + "name": "candidate_base64", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "candidate_hash": { + "name": "candidate_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "receipt": { + "name": "receipt", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "process_stopped_at": { + "name": "process_stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_instruction_copies_pending_idx": { + "name": "agent_instruction_copies_pending_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_instruction_copies_agent_idx": { + "name": "agent_instruction_copies_agent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_instruction_working_copies_run_id_heartbeat_runs_id_fk": { + "name": "agent_instruction_working_copies_run_id_heartbeat_runs_id_fk", + "tableFrom": "agent_instruction_working_copies", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_instruction_working_copies_company_id_agent_id_agents_company_id_id_fk": { + "name": "agent_instruction_working_copies_company_id_agent_id_agents_company_id_id_fk", + "tableFrom": "agent_instruction_working_copies", + "tableTo": "agents", + "columnsFrom": [ + "company_id", + "agent_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.agent_memberships": { "name": "agent_memberships", "schema": "", @@ -2917,7 +3390,7 @@ }, "byte_size": { "name": "byte_size", - "type": "integer", + "type": "bigint", "primaryKey": false, "notNull": true }, @@ -3302,6 +3775,13 @@ "primaryKey": false, "notNull": false }, + "keyboard_shortcuts": { + "name": "keyboard_shortcuts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, "created_at": { "name": "created_at", "type": "timestamp with time zone", @@ -8109,6 +8589,519 @@ }, "isRLSEnabled": false }, + "public.chat_github_configurations": { + "name": "chat_github_configurations", + "schema": "", + "columns": { + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_github_configurations_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { + "name": "chat_github_configurations_company_id_endpoint_id_chat_endpoints_company_id_id_fk", + "tableFrom": "chat_github_configurations", + "tableTo": "chat_endpoints", + "columnsFrom": [ + "company_id", + "endpoint_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chat_github_config_revision_check": { + "name": "chat_github_config_revision_check", + "value": "\"chat_github_configurations\".\"revision\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.chat_github_registrations": { + "name": "chat_github_registrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trusted_origin": { + "name": "trusted_origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_github_registration_state_uq": { + "name": "chat_github_registration_state_uq", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_github_registration_endpoint_idx": { + "name": "chat_github_registration_endpoint_idx", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_github_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { + "name": "chat_github_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk", + "tableFrom": "chat_github_registrations", + "tableTo": "chat_endpoints", + "columnsFrom": [ + "company_id", + "endpoint_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chat_github_registration_status_check": { + "name": "chat_github_registration_status_check", + "value": "\"chat_github_registrations\".\"status\" in ('pending', 'exchanging', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.chat_github_reviews": { + "name": "chat_github_reviews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pull_number": { + "name": "pull_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "configuration_revision": { + "name": "configuration_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "policy_snapshot": { + "name": "policy_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "assessment": { + "name": "assessment", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "conclusion": { + "name": "conclusion", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "check_id": { + "name": "check_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "check_url": { + "name": "check_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_id": { + "name": "summary_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_url": { + "name": "summary_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "publication_receipts": { + "name": "publication_receipts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_github_reviews_delivery_uq": { + "name": "chat_github_reviews_delivery_uq", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivery_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_github_reviews_pull_idx": { + "name": "chat_github_reviews_pull_idx", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pull_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_github_reviews_task_idx": { + "name": "chat_github_reviews_task_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_github_reviews_run_id_heartbeat_runs_id_fk": { + "name": "chat_github_reviews_run_id_heartbeat_runs_id_fk", + "tableFrom": "chat_github_reviews", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "chat_github_reviews_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { + "name": "chat_github_reviews_company_id_endpoint_id_chat_endpoints_company_id_id_fk", + "tableFrom": "chat_github_reviews", + "tableTo": "chat_endpoints", + "columnsFrom": [ + "company_id", + "endpoint_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_github_reviews_company_id_issue_id_issues_company_id_id_fk": { + "name": "chat_github_reviews_company_id_issue_id_issues_company_id_id_fk", + "tableFrom": "chat_github_reviews", + "tableTo": "issues", + "columnsFrom": [ + "company_id", + "issue_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_github_reviews_company_id_uq": { + "name": "chat_github_reviews_company_id_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "chat_github_review_pull_number_check": { + "name": "chat_github_review_pull_number_check", + "value": "\"chat_github_reviews\".\"pull_number\" > 0" + }, + "chat_github_review_state_check": { + "name": "chat_github_review_state_check", + "value": "\"chat_github_reviews\".\"state\" in ('queued', 'running', 'completed', 'incomplete', 'error', 'superseded', 'manual_required')" + } + }, + "isRLSEnabled": false + }, "public.chat_teams_file_transfers": { "name": "chat_teams_file_transfers", "schema": "", @@ -38419,6 +39412,113 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.runner_api_response_reservations": { + "name": "runner_api_response_reservations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reserved_bytes": { + "name": "reserved_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "runner_api_response_reservations_company_idx": { + "name": "runner_api_response_reservations_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "runner_api_response_reservations_company_id_companies_id_fk": { + "name": "runner_api_response_reservations_company_id_companies_id_fk", + "tableFrom": "runner_api_response_reservations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "runner_api_response_reservations_run_id_heartbeat_runs_id_fk": { + "name": "runner_api_response_reservations_run_id_heartbeat_runs_id_fk", + "tableFrom": "runner_api_response_reservations", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "runner_api_response_reservations_asset_id_assets_id_fk": { + "name": "runner_api_response_reservations_asset_id_assets_id_fk", + "tableFrom": "runner_api_response_reservations", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.secret_access_events": { "name": "secret_access_events", "schema": "", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 264cefe4465..f1937652643 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1996,6 +1996,13 @@ "when": 1790607525758, "tag": "0286_complete_alice", "breakpoints": true + }, + { + "idx": 287, + "version": "7", + "when": 1790611116286, + "tag": "0287_serious_tinkerer", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/agent_instruction_revisions.ts b/packages/db/src/schema/agent_instruction_revisions.ts new file mode 100644 index 00000000000..39db9af7237 --- /dev/null +++ b/packages/db/src/schema/agent_instruction_revisions.ts @@ -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>(), + 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), + }), +); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index db17f2aa695..bd007401dbb 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -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"; diff --git a/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json b/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json index 9ca2c4ba8d1..a42ac9b66bb 100644 --- a/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json +++ b/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json @@ -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","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create or update an active-task document with optimistic revision safety.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision id, or null when creating.","maxLength":20000,"type":["string","null"]},"body":{"description":"Markdown document body.","maxLength":200000,"minLength":1,"type":"string"},"changeSummary":{"description":"Optional revision summary.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"title":{"description":"Document title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","key","title","body","baseRevisionId"],"type":"object"},"name":"write_document","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":"request_human_input","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a durable human question or approval card on the current Paperclip task bound to this run; Paperclip renders it and authenticates the response. Use questions with continuationPolicy='wake_assignee' when an answer is needed, including otherwise tool-free chat turns. Supply a stable idempotencyKey and reuse it on retries. For one question at a time, ask only the next unanswered question and wait for its real answer. Never infer answers, answer your own card, or treat clarification as approval. Preserve existing review gates. Call this tool before claiming a question was asked; if creation fails, report the failure. Do not fabricate answer links or Markdown buttons, post duplicate cards, or substitute call_api. Use payload.questions for choices and payload.questionSet for text fields; see the payload schema for formats.","inputSchema":{"additionalProperties":false,"properties":{"continuationPolicy":{"enum":["none","wake_assignee","wake_assignee_on_accept"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"interactionKind":{"enum":["confirmation","checkbox","questions","suggest_tasks","item_verdicts"]},"payload":{"additionalProperties":true,"description":"Kind-specific interaction data. For interactionKind='questions', use version:1 and questions:[{id,prompt,selectionMode:'single'|'multi',required?,options:[{id,label,description?,freeText?}]}]. Choice questions need at least two distinct meaningful options. For an open-ended text answer, ALSO include questionSet:{schema:'paperclip.question_set.v1',questions:[{id,prompt,answerMode:'text',required?}]} with no options or customAnswer in its text questions. Keep matching IDs/prompts in both arrays; the required compatibility questions entry uses selectionMode:'single' and options:[{id:'describe',label:'Your answer',freeText:true}]. Without questionSet this incorrectly renders as a one-option choice. Never use a lone Other or describe option as the presentation. Option keys are id/label, not value. For confirmation, payload may be {}. Keep IDs stable across retries.","type":"object"},"prompt":{"description":"Question or decision prompt.","maxLength":10000,"minLength":1,"type":"string"},"targetRevisionId":{"description":"Optional bound document revision.","maxLength":20000,"type":["string","null"]},"title":{"description":"Interaction card title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","interactionKind","title","prompt","continuationPolicy"],"type":"object"},"name":"request_human_input","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":"register_deliverable","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Register attachment metadata and its artifact work product without credentials or bytes in the tool result.","inputSchema":{"additionalProperties":false,"properties":{"byteSize":{"maximum":100000000,"minimum":0,"type":"integer"},"contentRef":{"description":"Opaque package-local content reference.","maxLength":2000,"minLength":1,"type":"string"},"contentType":{"description":"Media type.","maxLength":200,"minLength":1,"type":"string"},"filename":{"description":"Display filename.","maxLength":500,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"sha256":{"pattern":"^[a-fA-F0-9]{64}$","type":"string"},"title":{"description":"Work-product title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","filename","contentType","byteSize","sha256","contentRef","title"],"type":"object"},"name":"register_deliverable","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":"finish_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Finish the active task with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Completion summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"finish_task","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":"block_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Block the active task with a durable reason and optional first-class dependencies.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Internal task ids that block this task.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Block reason.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","reason"],"type":"object"},"name":"block_task","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":"request_review","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Move the active task to review with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Review handoff summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"request_review","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":"optional","operationId":"list_agents","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List redacted actor profiles.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_agents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_agent","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one redacted actor profile.","inputSchema":{"additionalProperties":false,"properties":{"actorId":{"description":"Actor id.","maxLength":200,"minLength":1,"type":"string"}},"required":["actorId"],"type":"object"},"name":"get_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"hire_agent","requiredClaims":["delegation:agents:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a persistent native Runner teammate with an identity and persona. The teammate reports to the caller and inherits the caller's native runtime; provider, adapter, environment, and credential settings are selected by Paperclip and are never caller-supplied here. Reuse a suitable teammate from list_agents when possible.","inputSchema":{"additionalProperties":false,"properties":{"capabilities":{"maxLength":2000,"type":["string","null"]},"instructions":{"maxLength":20000,"type":["string","null"]},"name":{"maxLength":200,"minLength":1,"type":"string"},"role":{"enum":["ceo","cto","cmo","cfo","security","engineer","designer","pm","qa","devops","researcher","general"],"type":"string"},"title":{"maxLength":300,"type":["string","null"]}},"required":["name"],"type":"object"},"name":"hire_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_tasks","requiredClaims":["discovery:tasks:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Search tasks by text and status within the run company.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"},"query":{"maxLength":500,"type":"string"},"statuses":{"items":{"enum":["backlog","todo","in_progress","in_review","done","blocked","cancelled"]},"maxItems":7,"type":"array","uniqueItems":true}},"required":[],"type":"object"},"name":"search_tasks","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_approvals","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List approvals in the run company.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_approvals","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval without protected data.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval_context","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval, its comments, and linked tasks.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace_runtime","requiredClaims":["workspace:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read active-task workspace services.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace_runtime","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"control_workspace_service","requiredClaims":["workspace:control"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Start, stop, or fault one active-task workspace service.","inputSchema":{"additionalProperties":false,"properties":{"action":{"enum":["start","stop","fail"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"serviceId":{"description":"Workspace service id.","maxLength":200,"minLength":1,"type":"string"},"url":{"description":"Optional service URL.","maxLength":20000,"type":["string","null"]}},"required":["idempotencyKey","serviceId","action"],"type":"object"},"name":"control_workspace_service","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":"optional","operationId":"set_dependencies","requiredClaims":["dependencies:write"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace the active task's first-class blocker set.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Replacement blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","blockedByTaskIds"],"type":"object"},"name":"set_dependencies","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":"optional","operationId":"create_task","requiredClaims":["delegation:tasks:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution. Set status to backlog when the user wants to save or plan work without starting it; backlog tasks never wake an agent. Omitted status means todo, subject to blockers.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"Optional agent assignee. Omit to assign the current agent.","maxLength":20000,"type":["string","null"]},"blockedByTaskIds":{"description":"Initial blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"description":{"description":"Child task description.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"initialPlan":{"description":"Relevant markdown plan saved on the new task before execution starts.","maxLength":200000,"type":["string","null"]},"priority":{"enum":["critical","high","medium","low"]},"projectId":{"description":"Project ID for the task.","type":["string","null"]},"status":{"description":"Initial status. Use backlog to save work without executing it. Defaults to todo (blocked when dependencies are unresolved).","enum":["backlog","todo"]},"title":{"description":"Child task title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","title"],"type":"object"},"name":"create_task","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"},"task":{"additionalProperties":false,"properties":{"assigneeActorId":{"type":["string","null"]},"id":{"minLength":1,"type":"string"},"identifier":{"type":["string","null"]},"parentId":{"minLength":1,"type":["string","null"]},"projectId":{"minLength":1,"type":["string","null"]},"status":{"minLength":1,"type":"string"}},"required":["id","identifier","parentId","status","assigneeActorId"],"type":"object"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds","task"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"request_approval","requiredClaims":["governance:approvals:request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a governed approval and waiting posture.","inputSchema":{"additionalProperties":false,"properties":{"approvalType":{"description":"Stable approval type.","maxLength":200,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","approvalType","payload"],"type":"object"},"name":"request_approval","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":"optional","operationId":"decide_approval","requiredClaims":["governance:approvals:decide"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Decide an approval as an explicitly authorized approver.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"decision":{"enum":["approved","rejected","cancelled"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"note":{"description":"Decision note.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","decision","note"],"type":"object"},"name":"decide_approval","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":"optional","operationId":"comment_on_approval","requiredClaims":["governance:approvals:comment"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Add a durable comment to an approval.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"body":{"description":"Approval comment.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","body"],"type":"object"},"name":"comment_on_approval","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":"optional","operationId":"schedule_wake","requiredClaims":["control_plane:wakes"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule a deterministic continuation wake.","inputSchema":{"additionalProperties":false,"properties":{"delayTicks":{"maximum":10000,"minimum":1,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"reason":{"enum":["manual","issue_commented","interaction_resolved","approval_resolved","blockers_resolved","scheduled_retry","resume"]}},"required":["idempotencyKey","reason","delayTicks"],"type":"object"},"name":"schedule_wake","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":"optional","operationId":"generic_api_request","requiredClaims":["test:generic_api_request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.","inputSchema":{"additionalProperties":false,"properties":{"body":{"additionalProperties":true,"type":"object"},"method":{"enum":["GET","POST","PATCH"]},"path":{"maxLength":500,"pattern":"^/mock/","type":"string"}},"required":["method","path"],"type":"object"},"name":"generic_api_request","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_api","requiredClaims":["api:discover"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them. For a persistent hire, first list_agents to reuse a suitable teammate. Search agent-hires for the hiring schema and agent-configurations for compatible adapter/runtime settings, then use call_api with the returned operationId. Supply role-specific instructionsBundle.files as a filename-to-content record. Use the returned agent.id for delegation and obey any pending approval. Provider helper threads are temporary workers, not Paperclip hires. If a hire response is uncertain, reconcile with list_agents before retrying.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":200,"type":"string"},"limit":{"default":5,"maximum":10,"minimum":1,"type":"integer"},"query":{"maxLength":500,"minLength":1,"type":"string"}},"required":["query"],"type":"object"},"name":"search_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"call_api","requiredClaims":["api:call"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.","inputSchema":{"additionalProperties":false,"properties":{"body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"items":{},"type":"array"},{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"}],"description":"Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."},"contentType":{"maxLength":120,"type":"string"},"files":{"items":{"additionalProperties":false,"oneOf":[{"properties":{"artifactId":{}},"required":["artifactId"]},{"properties":{"path":{}},"required":["path"]}],"properties":{"artifactId":{"type":"string"},"field":{"type":"string"},"path":{"description":"File relative to the active issue workspace. Remote files must first be uploaded as an artifact.","type":"string"}},"type":"object"},"maxItems":10,"type":"array"},"operationId":{"description":"Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.","maxLength":500,"minLength":1,"type":"string"},"pathParams":{"additionalProperties":{"type":"string"},"type":"object"},"query":{"additionalProperties":true,"type":"object"},"responseText":{"additionalProperties":false,"description":"GET only: return a bounded UTF-8 text window inline, including JSON as text, without saving another artifact. Offsets and limits are bytes. Use the returned nextOffsetBytes to continue; null means complete. Prefer reading a saved artifact for a stable snapshot. New live responses have a 1 GiB capture limit and a 4 GiB per-run capture budget. Existing larger assets remain readable in bounded pages. If a live response returns an artifact, continue on its content operation for a stable snapshot.","properties":{"limitBytes":{"default":24576,"maximum":24576,"minimum":4,"type":"integer"},"offsetBytes":{"default":0,"maximum":9007199254740991,"minimum":0,"type":"integer"}},"type":"object"}},"required":["operationId"],"type":"object"},"name":"call_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_project","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.","inputSchema":{"additionalProperties":false,"properties":{"archivedAt":{"description":"Archive timestamp.","maxLength":20000,"type":["string","null"]},"color":{"description":"Project color.","maxLength":20000,"type":["string","null"]},"description":{"description":"Project outcome and context.","maxLength":20000,"type":["string","null"]},"env":{"additionalProperties":true,"type":"object"},"executionWorkspacePolicy":{"additionalProperties":true,"type":"object"},"goalId":{"description":"Goal ID.","maxLength":20000,"type":["string","null"]},"goalIds":{"description":"Goal IDs.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"icon":{"description":"Project icon.","enum":["folder","rocket","code","terminal","database","globe","package","boxes","box","layers","briefcase","compass","target","flame","zap","star","bug","wrench","hammer","lightbulb","sparkles","shield","lock","search","cog","brain","cpu","git-branch","file-code","puzzle","gem","atom","heart","mail","message-square","crown","radar","telescope","hexagon",null],"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"leadAgentId":{"description":"Lead agent ID.","maxLength":20000,"type":["string","null"]},"name":{"description":"Project name.","maxLength":500,"minLength":1,"type":"string"},"repositoryIds":{"description":"Authorized repository IDs from list_project_repositories; may contain multiple repositories.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"repositoryUrls":{"description":"Existing HTTPS GitHub repository URLs, including repos absent from the catalog.","items":{"maxLength":2000,"pattern":"^https://github\\.com/(?!\\.{1,2}/)[A-Za-z0-9_.-]+/(?!\\.{1,2}/?$)[A-Za-z0-9_.-]+/?$","type":"string"},"maxItems":100,"type":"array"},"status":{"enum":["backlog","planned","in_progress","completed","cancelled"]},"targetDate":{"description":"Target date.","maxLength":20000,"type":["string","null"]},"workspace":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","name"],"type":"object"},"name":"create_project","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_project_repositories","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_project_repositories","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_projects","requiredClaims":["discovery:projects:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Inspect available company projects before selecting a project for new work.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_projects","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a reusable single-file skill in the company library. Supply a complete SKILL.md whose name and description match the inputs. This saves the skill and shows a card; it does not assign the skill to any agent. Reuse idempotencyKey on retries.","inputSchema":{"additionalProperties":false,"properties":{"description":{"maxLength":2000,"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"description":"Complete SKILL.md including name and description frontmatter and substantive instructions.","maxLength":200000,"minLength":1,"type":"string"},"name":{"description":"Lowercase skill name, matching SKILL.md frontmatter.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"},"slug":{"description":"Optional; must equal name.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"}},"required":["idempotencyKey","name","description","markdown"],"type":"object"},"name":"create_skill","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"reassign_task","requiredClaims":["delegation:tasks:assign"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Reassign an existing task to another company agent. Read search_tasks first and supply its current assignee and statusVersion to prevent overwriting a concurrent change. Preserve the task, documents, dependencies, and blocked/backlog status. Active work is stopped before handoff. Use a stable idempotency key for retries. Cannot reassign this run’s own task, conversations, completed tasks, or pending reviews; use create_task for delegation from the current task.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"New company agent ID from list_agents.","minLength":1,"type":"string"},"expectedAssigneeActorId":{"description":"Current assigneeAgentId from search_tasks; null means unassigned.","type":["string","null"]},"expectedStatusVersion":{"description":"Current statusVersion from search_tasks.","minimum":0,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Why the task should move and context for the new owner.","maxLength":20000,"minLength":1,"type":"string"},"taskId":{"description":"Existing task ID from search_tasks.","minLength":1,"type":"string"}},"required":["idempotencyKey","taskId","assigneeActorId","expectedAssigneeActorId","expectedStatusVersion","reason"],"type":"object"},"name":"reassign_task","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":"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","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create or update an active-task document with optimistic revision safety.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision id, or null when creating.","maxLength":20000,"type":["string","null"]},"body":{"description":"Markdown document body.","maxLength":200000,"minLength":1,"type":"string"},"changeSummary":{"description":"Optional revision summary.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"title":{"description":"Document title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","key","title","body","baseRevisionId"],"type":"object"},"name":"write_document","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":"request_human_input","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a durable human question or approval card on the current Paperclip task bound to this run; Paperclip renders it and authenticates the response. Use questions with continuationPolicy='wake_assignee' when an answer is needed, including otherwise tool-free chat turns. Supply a stable idempotencyKey and reuse it on retries. For one question at a time, ask only the next unanswered question and wait for its real answer. Never infer answers, answer your own card, or treat clarification as approval. Preserve existing review gates. Call this tool before claiming a question was asked; if creation fails, report the failure. Do not fabricate answer links or Markdown buttons, post duplicate cards, or substitute call_api. Use payload.questions for choices and payload.questionSet for text fields; see the payload schema for formats.","inputSchema":{"additionalProperties":false,"properties":{"continuationPolicy":{"enum":["none","wake_assignee","wake_assignee_on_accept"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"interactionKind":{"enum":["confirmation","checkbox","questions","suggest_tasks","item_verdicts"]},"payload":{"additionalProperties":true,"description":"Kind-specific interaction data. For interactionKind='questions', use version:1 and questions:[{id,prompt,selectionMode:'single'|'multi',required?,options:[{id,label,description?,freeText?}]}]. Choice questions need at least two distinct meaningful options. For an open-ended text answer, ALSO include questionSet:{schema:'paperclip.question_set.v1',questions:[{id,prompt,answerMode:'text',required?}]} with no options or customAnswer in its text questions. Keep matching IDs/prompts in both arrays; the required compatibility questions entry uses selectionMode:'single' and options:[{id:'describe',label:'Your answer',freeText:true}]. Without questionSet this incorrectly renders as a one-option choice. Never use a lone Other or describe option as the presentation. Option keys are id/label, not value. For confirmation, payload may be {}. Keep IDs stable across retries.","type":"object"},"prompt":{"description":"Question or decision prompt.","maxLength":10000,"minLength":1,"type":"string"},"targetRevisionId":{"description":"Optional bound document revision.","maxLength":20000,"type":["string","null"]},"title":{"description":"Interaction card title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","interactionKind","title","prompt","continuationPolicy"],"type":"object"},"name":"request_human_input","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":"register_deliverable","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Register attachment metadata and its artifact work product without credentials or bytes in the tool result.","inputSchema":{"additionalProperties":false,"properties":{"byteSize":{"maximum":100000000,"minimum":0,"type":"integer"},"contentRef":{"description":"Opaque package-local content reference.","maxLength":2000,"minLength":1,"type":"string"},"contentType":{"description":"Media type.","maxLength":200,"minLength":1,"type":"string"},"filename":{"description":"Display filename.","maxLength":500,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"sha256":{"pattern":"^[a-fA-F0-9]{64}$","type":"string"},"title":{"description":"Work-product title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","filename","contentType","byteSize","sha256","contentRef","title"],"type":"object"},"name":"register_deliverable","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":"finish_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Finish the active task with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Completion summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"finish_task","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":"block_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Block the active task with a durable reason and optional first-class dependencies.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Internal task ids that block this task.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Block reason.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","reason"],"type":"object"},"name":"block_task","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":"request_review","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Move the active task to review with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Review handoff summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"request_review","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":"optional","operationId":"list_agents","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List redacted actor profiles.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_agents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_agent","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one redacted actor profile.","inputSchema":{"additionalProperties":false,"properties":{"actorId":{"description":"Actor id.","maxLength":200,"minLength":1,"type":"string"}},"required":["actorId"],"type":"object"},"name":"get_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"hire_agent","requiredClaims":["delegation:agents:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a persistent native Runner teammate with an identity and persona. The teammate reports to the caller and inherits the caller's native runtime; provider, adapter, environment, and credential settings are selected by Paperclip and are never caller-supplied here. Reuse a suitable teammate from list_agents when possible.","inputSchema":{"additionalProperties":false,"properties":{"capabilities":{"maxLength":2000,"type":["string","null"]},"instructions":{"maxLength":20000,"type":["string","null"]},"name":{"maxLength":200,"minLength":1,"type":"string"},"role":{"enum":["ceo","cto","cmo","cfo","security","engineer","designer","pm","qa","devops","researcher","general"],"type":"string"},"title":{"maxLength":300,"type":["string","null"]}},"required":["name"],"type":"object"},"name":"hire_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_tasks","requiredClaims":["discovery:tasks:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Search tasks by text and status within the run company.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"},"query":{"maxLength":500,"type":"string"},"statuses":{"items":{"enum":["backlog","todo","in_progress","in_review","done","blocked","cancelled"]},"maxItems":7,"type":"array","uniqueItems":true}},"required":[],"type":"object"},"name":"search_tasks","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_approvals","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List approvals in the run company.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_approvals","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval without protected data.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval_context","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval, its comments, and linked tasks.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace_runtime","requiredClaims":["workspace:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read active-task workspace services.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace_runtime","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"control_workspace_service","requiredClaims":["workspace:control"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Start, stop, or fault one active-task workspace service.","inputSchema":{"additionalProperties":false,"properties":{"action":{"enum":["start","stop","fail"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"serviceId":{"description":"Workspace service id.","maxLength":200,"minLength":1,"type":"string"},"url":{"description":"Optional service URL.","maxLength":20000,"type":["string","null"]}},"required":["idempotencyKey","serviceId","action"],"type":"object"},"name":"control_workspace_service","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":"optional","operationId":"set_dependencies","requiredClaims":["dependencies:write"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace the active task's first-class blocker set.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Replacement blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","blockedByTaskIds"],"type":"object"},"name":"set_dependencies","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":"optional","operationId":"create_task","requiredClaims":["delegation:tasks:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution. Set status to backlog when the user wants to save or plan work without starting it; backlog tasks never wake an agent. Omitted status means todo, subject to blockers.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"Optional agent assignee. Omit to assign the current agent.","maxLength":20000,"type":["string","null"]},"blockedByTaskIds":{"description":"Initial blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"description":{"description":"Child task description.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"initialPlan":{"description":"Relevant markdown plan saved on the new task before execution starts.","maxLength":200000,"type":["string","null"]},"priority":{"enum":["critical","high","medium","low"]},"projectId":{"description":"Project ID for the task.","type":["string","null"]},"status":{"description":"Initial status. Use backlog to save work without executing it. Defaults to todo (blocked when dependencies are unresolved).","enum":["backlog","todo"]},"title":{"description":"Child task title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","title"],"type":"object"},"name":"create_task","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"},"task":{"additionalProperties":false,"properties":{"assigneeActorId":{"type":["string","null"]},"id":{"minLength":1,"type":"string"},"identifier":{"type":["string","null"]},"parentId":{"minLength":1,"type":["string","null"]},"projectId":{"minLength":1,"type":["string","null"]},"status":{"minLength":1,"type":"string"}},"required":["id","identifier","parentId","status","assigneeActorId"],"type":"object"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds","task"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"request_approval","requiredClaims":["governance:approvals:request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a governed approval and waiting posture.","inputSchema":{"additionalProperties":false,"properties":{"approvalType":{"description":"Stable approval type.","maxLength":200,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","approvalType","payload"],"type":"object"},"name":"request_approval","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":"optional","operationId":"decide_approval","requiredClaims":["governance:approvals:decide"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Decide an approval as an explicitly authorized approver.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"decision":{"enum":["approved","rejected","cancelled"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"note":{"description":"Decision note.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","decision","note"],"type":"object"},"name":"decide_approval","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":"optional","operationId":"comment_on_approval","requiredClaims":["governance:approvals:comment"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Add a durable comment to an approval.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"body":{"description":"Approval comment.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","body"],"type":"object"},"name":"comment_on_approval","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":"optional","operationId":"schedule_wake","requiredClaims":["control_plane:wakes"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule a deterministic continuation wake.","inputSchema":{"additionalProperties":false,"properties":{"delayTicks":{"maximum":10000,"minimum":1,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"reason":{"enum":["manual","issue_commented","interaction_resolved","approval_resolved","blockers_resolved","scheduled_retry","resume"]}},"required":["idempotencyKey","reason","delayTicks"],"type":"object"},"name":"schedule_wake","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":"optional","operationId":"generic_api_request","requiredClaims":["test:generic_api_request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.","inputSchema":{"additionalProperties":false,"properties":{"body":{"additionalProperties":true,"type":"object"},"method":{"enum":["GET","POST","PATCH"]},"path":{"maxLength":500,"pattern":"^/mock/","type":"string"}},"required":["method","path"],"type":"object"},"name":"generic_api_request","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"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.","inputSchema":{"additionalProperties":false,"properties":{"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"revisionId":{"description":"Historical revision to inspect; also provide its entryFile.","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}$","type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","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}$","type":"string"}},"required":[],"type":"object"},"name":"read_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_api","requiredClaims":["api:discover"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them. For a persistent hire, first list_agents to reuse a suitable teammate. Search agent-hires for the hiring schema and agent-configurations for compatible adapter/runtime settings, then use call_api with the returned operationId. Supply role-specific instructionsBundle.files as a filename-to-content record. Use the returned agent.id for delegation and obey any pending approval. Provider helper threads are temporary workers, not Paperclip hires. If a hire response is uncertain, reconcile with list_agents before retrying.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":200,"type":"string"},"limit":{"default":5,"maximum":10,"minimum":1,"type":"integer"},"query":{"maxLength":500,"minLength":1,"type":"string"}},"required":["query"],"type":"object"},"name":"search_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"update_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Commit instruction content with the exact entryFile and baseRevisionId from a prior read. Requires the responsible user’s current target edit permission. On conflict, preserve your candidate and explicitly resolve it; never overwrite the newer head.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"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}$","type":["string","null"]},"content":{"description":"Complete UTF-8 instruction content, at most 1 MiB; empty content is valid.","maxLength":1048576,"type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","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}$","type":"string"}},"required":["entryFile","content","baseRevisionId"],"type":"object"},"name":"update_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"call_api","requiredClaims":["api:call"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.","inputSchema":{"additionalProperties":false,"properties":{"body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"items":{},"type":"array"},{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"}],"description":"Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."},"contentType":{"maxLength":120,"type":"string"},"files":{"items":{"additionalProperties":false,"oneOf":[{"properties":{"artifactId":{}},"required":["artifactId"]},{"properties":{"path":{}},"required":["path"]}],"properties":{"artifactId":{"type":"string"},"field":{"type":"string"},"path":{"description":"File relative to the active issue workspace. Remote files must first be uploaded as an artifact.","type":"string"}},"type":"object"},"maxItems":10,"type":"array"},"operationId":{"description":"Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.","maxLength":500,"minLength":1,"type":"string"},"pathParams":{"additionalProperties":{"type":"string"},"type":"object"},"query":{"additionalProperties":true,"type":"object"},"responseText":{"additionalProperties":false,"description":"GET only: return a bounded UTF-8 text window inline, including JSON as text, without saving another artifact. Offsets and limits are bytes. Use the returned nextOffsetBytes to continue; null means complete. Prefer reading a saved artifact for a stable snapshot. New live responses have a 1 GiB capture limit and a 4 GiB per-run capture budget. Existing larger assets remain readable in bounded pages. If a live response returns an artifact, continue on its content operation for a stable snapshot.","properties":{"limitBytes":{"default":24576,"maximum":24576,"minimum":4,"type":"integer"},"offsetBytes":{"default":0,"maximum":9007199254740991,"minimum":0,"type":"integer"}},"type":"object"}},"required":["operationId"],"type":"object"},"name":"call_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_agent_instruction_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"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.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":2048,"minLength":1,"type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"limit":{"maximum":100,"minimum":1,"type":"integer"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","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}$","type":"string"}},"required":["entryFile"],"type":"object"},"name":"get_agent_instruction_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"restore_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a historical instruction revision as the current content using the current baseRevisionId. Requires the responsible user’s current target edit permission. History remains intact; conflicts require an explicit resolution.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"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}$","type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"revisionId":{"description":"Historical revision whose exact content should be restored.","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}$","type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","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}$","type":"string"}},"required":["entryFile","revisionId","baseRevisionId"],"type":"object"},"name":"restore_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_project","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.","inputSchema":{"additionalProperties":false,"properties":{"archivedAt":{"description":"Archive timestamp.","maxLength":20000,"type":["string","null"]},"color":{"description":"Project color.","maxLength":20000,"type":["string","null"]},"description":{"description":"Project outcome and context.","maxLength":20000,"type":["string","null"]},"env":{"additionalProperties":true,"type":"object"},"executionWorkspacePolicy":{"additionalProperties":true,"type":"object"},"goalId":{"description":"Goal ID.","maxLength":20000,"type":["string","null"]},"goalIds":{"description":"Goal IDs.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"icon":{"description":"Project icon.","enum":["folder","rocket","code","terminal","database","globe","package","boxes","box","layers","briefcase","compass","target","flame","zap","star","bug","wrench","hammer","lightbulb","sparkles","shield","lock","search","cog","brain","cpu","git-branch","file-code","puzzle","gem","atom","heart","mail","message-square","crown","radar","telescope","hexagon",null],"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"leadAgentId":{"description":"Lead agent ID.","maxLength":20000,"type":["string","null"]},"name":{"description":"Project name.","maxLength":500,"minLength":1,"type":"string"},"repositoryIds":{"description":"Authorized repository IDs from list_project_repositories; may contain multiple repositories.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"repositoryUrls":{"description":"Existing HTTPS GitHub repository URLs, including repos absent from the catalog.","items":{"maxLength":2000,"pattern":"^https://github\\.com/(?!\\.{1,2}/)[A-Za-z0-9_.-]+/(?!\\.{1,2}/?$)[A-Za-z0-9_.-]+/?$","type":"string"},"maxItems":100,"type":"array"},"status":{"enum":["backlog","planned","in_progress","completed","cancelled"]},"targetDate":{"description":"Target date.","maxLength":20000,"type":["string","null"]},"workspace":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","name"],"type":"object"},"name":"create_project","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_project_repositories","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_project_repositories","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_projects","requiredClaims":["discovery:projects:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Inspect available company projects before selecting a project for new work.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_projects","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a reusable single-file skill in the company library. Supply a complete SKILL.md whose name and description match the inputs. This saves the skill and shows a card; it does not assign the skill to any agent. Reuse idempotencyKey on retries.","inputSchema":{"additionalProperties":false,"properties":{"description":{"maxLength":2000,"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"description":"Complete SKILL.md including name and description frontmatter and substantive instructions.","maxLength":200000,"minLength":1,"type":"string"},"name":{"description":"Lowercase skill name, matching SKILL.md frontmatter.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"},"slug":{"description":"Optional; must equal name.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"}},"required":["idempotencyKey","name","description","markdown"],"type":"object"},"name":"create_skill","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"reassign_task","requiredClaims":["delegation:tasks:assign"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Reassign an existing task to another company agent. Read search_tasks first and supply its current assignee and statusVersion to prevent overwriting a concurrent change. Preserve the task, documents, dependencies, and blocked/backlog status. Active work is stopped before handoff. Use a stable idempotency key for retries. Cannot reassign this run’s own task, conversations, completed tasks, or pending reviews; use create_task for delegation from the current task.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"New company agent ID from list_agents.","minLength":1,"type":"string"},"expectedAssigneeActorId":{"description":"Current assigneeAgentId from search_tasks; null means unassigned.","type":["string","null"]},"expectedStatusVersion":{"description":"Current statusVersion from search_tasks.","minimum":0,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Why the task should move and context for the new owner.","maxLength":20000,"minLength":1,"type":"string"},"taskId":{"description":"Existing task ID from search_tasks.","minLength":1,"type":"string"}},"required":["idempotencyKey","taskId","assigneeActorId","expectedAssigneeActorId","expectedStatusVersion","reason"],"type":"object"},"name":"reassign_task","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"}}] diff --git a/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json b/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json index 74e4b4be817..e535bd9f8f6 100644 --- a/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json +++ b/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json @@ -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" diff --git a/packages/paperclip-runner/protocol/manifest.json b/packages/paperclip-runner/protocol/manifest.json index c9d17d453b5..919a0d38f8d 100644 --- a/packages/paperclip-runner/protocol/manifest.json +++ b/packages/paperclip-runner/protocol/manifest.json @@ -160,7 +160,7 @@ }, { "path": "fixtures/evals/native-execution-seeded.json", - "sha256": "c6367c7c7e3dd01fed341b7a83b0008c9d5d1efdfa85bc70e1013bc304a008c7", + "sha256": "c9c56cc11b420302be2d0044aeee30302e43da74c076e13a31addfbcaacd0458", "expectation": "accept", "compatibilityCase": "canonical" }, diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index 3c84813dd42..c2408ec731a 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -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, restore_checked: bool, restore_error: Option, + persistence_error: Option, + #[cfg(test)] + fail_after_state_rename: bool, launch_profile: Option, } @@ -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, 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::(&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"); diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs index 8f4eab51cab..0a1d9bdf5cc 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs @@ -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 { + 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, DurableRunnerError> { + self.retained_reads += 1; + Err(DurableRunnerError::invalid( + "ACPX persistence latch blocks retained settlement", + )) + } + + fn poll_events(&mut self) -> Result, 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()); diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs index 034727b788c..9699a80278c 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs @@ -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"); diff --git a/packages/paperclip-runner/spec/capability/protocol-coverage.json b/packages/paperclip-runner/spec/capability/protocol-coverage.json index 8c72856beb7..2371a89f6df 100644 --- a/packages/paperclip-runner/spec/capability/protocol-coverage.json +++ b/packages/paperclip-runner/spec/capability/protocol-coverage.json @@ -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", diff --git a/packages/paperclip-runner/spec/operation-groups/source.json b/packages/paperclip-runner/spec/operation-groups/source.json index 2d1cecf0f87..6b24551b0e4 100644 --- a/packages/paperclip-runner/spec/operation-groups/source.json +++ b/packages/paperclip-runner/spec/operation-groups/source.json @@ -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", diff --git a/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md b/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md index b6bfa691674..afadc264d7d 100644 --- a/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md +++ b/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md @@ -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`
`skill_test` | `task_write` | `required` | no | `semantic_command:finish_task` | `scenario` + `live`
`live_codex` | `unbound`
semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events
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`
`test_only` | `unbound`
test-only; excluded from product PRP evidence
catalog PRP status: `audit_pending` | | `get_agent` | `optional_agent_tool` | `discovery:agents:read` | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | inline/no mapping | `live`
`live_codex` | `unbound`
read projection surfaced via a tool-result item event; no control-plane state diff
catalog PRP status: `audit_pending` | +| `get_agent_instruction_history` | `always_agent_tool` | none | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | inline/no mapping | `live`
`live_codex` | `agentInstructionRevisionService`
tool-result item event plus canonical revision receipt for writes; no scenario mock coverage
catalog PRP status: `bound` | | `get_approval` | `optional_agent_tool` | `governance:approvals:read` | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | inline/no mapping | `live`
`live_codex` | `unbound`
read projection surfaced via a tool-result item event; no control-plane state diff
catalog PRP status: `audit_pending` | | `get_approval_context` | `optional_agent_tool` | `governance:approvals:read` | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | inline/no mapping | `live`
`live_codex` | `unbound`
read projection surfaced via a tool-result item event; no control-plane state diff
catalog PRP status: `audit_pending` | | `get_task_context` | `always_agent_tool` | none | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | `context_read:active_task` | `scenario` + `live`
`live_codex` | `PaperclipRunnerToolAuthority active issue/run + accepted plan revision`
bound company/assignment query plus exact accepted document revision projection
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`
`skill_test` | `read` | `none` | no | `mock_extension:routines.list` | `scenario`
`scenario_mock` | `unbound`
read projection surfaced via a tool-result item event; no control-plane state diff
catalog PRP status: `audit_pending` | | `list_secret_metadata` | `optional_agent_tool` | `secrets:metadata:read` | `standard`
`skill_test` | `read` | `none` | no | `mock_extension:secrets.metadata` | `scenario`
`scenario_mock` | `unbound`
read projection surfaced via a tool-result item event; no control-plane state diff
catalog PRP status: `audit_pending` | | `manage_routine` | `optional_agent_tool` | `routines:write` | `standard`
`skill_test` | `admin` | `required` | no | `mock_extension:routines.manage` | `scenario`
`scenario_mock` | `unbound`
company admin/portability item event plus audit record
catalog PRP status: `audit_pending` | +| `read_agent_instructions` | `always_agent_tool` | none | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | inline/no mapping | `live`
`live_codex` | `agentInstructionRevisionService`
tool-result item event plus canonical revision receipt for writes; no scenario mock coverage
catalog PRP status: `bound` | | `read_document` | `always_agent_tool` | none | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | `snapshot_read:active_task_document` | `scenario` + `live`
`live_codex` | `unbound`
read projection surfaced via a tool-result item event; no control-plane state diff
catalog PRP status: `audit_pending` | | `read_secret_value` | `optional_agent_tool` | `secrets:values:read` | `standard`
`skill_test` | `secret_read` | `none` | yes | `mock_extension:secrets.value` | `scenario`
`scenario_mock` | `unbound`
redacted tool-result item event; secret value never reaches the wire
catalog PRP status: `audit_pending` | | `reassign_task` | `optional_agent_tool` | `delegation:tasks:assign` | `standard` | `company_write` | `required` | no | `semantic_command:reassign_task` | `scenario` + `live`
`live_codex` | `PaperclipRunnerToolAuthority.reassign_task`
semantic-operation item event plus company-entity state diff and audit record
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`
`skill_test` | `governance` | `required` | no | `semantic_command:request_approval` | `scenario` + `live`
`live_codex` | `unbound`
approval lifecycle plus governed-wait continuation and audit events
catalog PRP status: `audit_pending` | | `request_human_input` | `always_agent_tool` | none | `standard`
`planning`
`ask`
`skill_test` | `task_write` | `required` | no | `semantic_command:request_human_input` | `scenario` + `live`
`live_codex` | `unbound`
semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events
catalog PRP status: `audit_pending` | | `request_review` | `always_agent_tool` | none | `standard`
`skill_test` | `task_write` | `required` | no | `semantic_command:request_review` | `scenario` + `live`
`live_codex` | `unbound`
semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events
catalog PRP status: `audit_pending` | +| `restore_agent_instructions` | `always_agent_tool` | none | `standard`
`planning` | `company_write` | `recommended` | no | inline/no mapping | `live`
`live_codex` | `agentInstructionRevisionService`
tool-result item event plus canonical revision receipt for writes; no scenario mock coverage
catalog PRP status: `bound` | | `schedule_wake` | `optional_agent_tool` | `control_plane:wakes` | `standard`
`skill_test` | `task_write` | `required` | no | inline/no mapping | `live`
`live_codex` | `unbound`
semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events
catalog PRP status: `audit_pending` | | `search_api` | `optional_agent_tool` | `api:discover` | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | inline/no mapping | `live`
`live_codex` | `PaperclipRunnerToolAuthority`
Authenticated PRP tool input/result and existing HTTP route authorization/activity records.
catalog PRP status: `bound` | | `search_tasks` | `optional_agent_tool` | `discovery:tasks:read` | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | `snapshot_read:company_tasks` | `scenario` + `live`
`live_codex` | `unbound`
read projection surfaced via a tool-result item event; no control-plane state diff
catalog PRP status: `audit_pending` | | `set_dependencies` | `optional_agent_tool` | `dependencies:write` | `standard`
`skill_test` | `company_write` | `required` | no | `semantic_command:set_dependencies` | `scenario` + `live`
`live_codex` | `issues.update.blockedByIssueIds`
semantic-operation item event plus company-entity state diff and audit record
catalog PRP status: `bound` | | `sync_company_skills` | `optional_agent_tool` | `company_skills:write` | `standard`
`skill_test` | `admin` | `required` | no | `mock_extension:company_skills.sync` | `scenario`
`scenario_mock` | `unbound`
company admin/portability item event plus audit record
catalog PRP status: `audit_pending` | +| `update_agent_instructions` | `always_agent_tool` | none | `standard`
`planning` | `company_write` | `recommended` | no | inline/no mapping | `live`
`live_codex` | `agentInstructionRevisionService`
tool-result item event plus canonical revision receipt for writes; no scenario mock coverage
catalog PRP status: `bound` | | `upsert_case` | `optional_agent_tool` | `cases:write` | `standard`
`skill_test` | `company_write` | `required` | no | `mock_extension:cases.upsert` | `scenario`
`scenario_mock` | `unbound`
semantic-operation item event plus company-entity state diff and audit record
catalog PRP status: `audit_pending` | | `write_document` | `always_agent_tool` | none | `standard`
`planning`
`skill_test` | `task_write` | `required` | no | `semantic_command:write_document` | `scenario` + `live`
`live_codex` | `unbound`
semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events
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`
`get_approval`
`get_approval_context`
`request_approval`
`decide_approval`
`comment_on_approval` | `route_wake`
`append_audit_record` | company approval, decision, issue-link, comment, and governed-action services | `company`
`task`
`approvals`
`actor`
`wake`
`audit`
`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`
`artifacts`
`workProducts`
`workspace`
`audit`
`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`
`control_workspace_service`
`schedule_wake`
`inspect_operation_result` | `release_task`
`enforce_budget`
`persist_run`
`replay_run`
`reconcile_run` | workspace runtime, monitor/recovery, budget, run persistence/replay, release, and terminal services | `workspace`
`budget`
`run`
`wake`
`audit`
`idempotency`
`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`
`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` | `append_audit_record` | project, case, routine, company-skill, secret, portability, and administration services | `company`
`cases`
`routines`
`skills`
`secrets`
`audit`
`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`
`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` | `append_audit_record` | agent instruction revisions, project, case, routine, company-skill, secret, portability, and administration services | `company`
`cases`
`routines`
`skills`
`secrets`
`audit`
`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`
`set_dependencies`
`request_human_input`
`request_approval`
`register_deliverable` | `route_wake`
`reconcile_run` | delegation, dependency, interaction, approval, artifact, and terminal orchestration services | `task`
`blockers`
`interactions`
`approvals`
`artifacts`
`wake`
`run`
`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`
`read_secret_value`
`generic_api_request` | `enforce_budget` | task-mode, secret-broker, test-scope, pause, and budget policy checks | `actor`
`task`
`budget`
`secrets`
`audit`
`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`
`get_task_history`
`schedule_wake` | `select_work`
`route_wake` | wakeup requests, heartbeat context, comment/interaction/approval/blocker wake routing, and scheduled wake services | `wake`
`task`
`comments`
`interactions`
`approvals`
`blockers`
`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. diff --git a/packages/paperclip-runner/src/backends/codex-native-backend.ts b/packages/paperclip-runner/src/backends/codex-native-backend.ts index 6513af82eee..6f8efb8e15a 100644 --- a/packages/paperclip-runner/src/backends/codex-native-backend.ts +++ b/packages/paperclip-runner/src/backends/codex-native-backend.ts @@ -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( diff --git a/packages/paperclip-runner/src/backends/runtime-context.ts b/packages/paperclip-runner/src/backends/runtime-context.ts index 203e42552a4..05f7d177c3c 100644 --- a/packages/paperclip-runner/src/backends/runtime-context.ts +++ b/packages/paperclip-runner/src/backends/runtime-context.ts @@ -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: from the receipt as completion evidence, and include /api/attachments//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.", diff --git a/packages/paperclip-runner/src/catalog/reconciliation.test.ts b/packages/paperclip-runner/src/catalog/reconciliation.test.ts index 9ab2cdf7c8c..0f69be379c1 100644 --- a/packages/paperclip-runner/src/catalog/reconciliation.test.ts +++ b/packages/paperclip-runner/src/catalog/reconciliation.test.ts @@ -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, }); diff --git a/packages/paperclip-runner/src/contracts/runtime-context.ts b/packages/paperclip-runner/src/contracts/runtime-context.ts index 948cdbc62fa..bbf7e89ab5e 100644 --- a/packages/paperclip-runner/src/contracts/runtime-context.ts +++ b/packages/paperclip-runner/src/contracts/runtime-context.ts @@ -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"); } diff --git a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts index 6e8ed1c8424..0b06817a53c 100644 --- a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts +++ b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts @@ -197,6 +197,7 @@ class BoundedLineDecoder { } const SAFE_ENVIRONMENT_KEYS = [ + "AGENT_HOME", "ALL_PROXY", "CODEX_HOME", "HOME", diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts index 40ef0df0268..0fba00ebad7 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts @@ -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, diff --git a/packages/paperclip-runner/src/drivers/codex/codex-driver-types.ts b/packages/paperclip-runner/src/drivers/codex/codex-driver-types.ts index 0414b2ccf7c..12e6265b6ee 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-driver-types.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-driver-types.ts @@ -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"; diff --git a/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts index f22d328ffb0..acecaf2c838 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts @@ -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`); diff --git a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts index b041ff43634..f6480c12c03 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts @@ -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"}`, diff --git a/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts b/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts index 0898771d666..6f59edee752 100644 --- a/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts +++ b/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts @@ -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({ diff --git a/packages/paperclip-runner/src/eval/workflow-evals.test.ts b/packages/paperclip-runner/src/eval/workflow-evals.test.ts index 8deb3154b18..14cbe7199c0 100644 --- a/packages/paperclip-runner/src/eval/workflow-evals.test.ts +++ b/packages/paperclip-runner/src/eval/workflow-evals.test.ts @@ -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"', diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index bad027bc29b..825e8dd5a11 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -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 \n#include \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"); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index b1862b71e7b..2176cbd4d1a 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -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 diff --git a/packages/paperclip-runner/src/native-session-runtime.test.ts b/packages/paperclip-runner/src/native-session-runtime.test.ts index ab82510cf91..1c02ca05c5b 100644 --- a/packages/paperclip-runner/src/native-session-runtime.test.ts +++ b/packages/paperclip-runner/src/native-session-runtime.test.ts @@ -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((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(); } diff --git a/packages/paperclip-runner/src/native-session-runtime.ts b/packages/paperclip-runner/src/native-session-runtime.ts index 408f3adc9e6..27b5c204c3a 100644 --- a/packages/paperclip-runner/src/native-session-runtime.ts +++ b/packages/paperclip-runner/src/native-session-runtime.ts @@ -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; 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. diff --git a/packages/paperclip-runner/src/protocol-actions/core.ts b/packages/paperclip-runner/src/protocol-actions/core.ts index ee195815279..8e0a5695dac 100644 --- a/packages/paperclip-runner/src/protocol-actions/core.ts +++ b/packages/paperclip-runner/src/protocol-actions/core.ts @@ -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, diff --git a/packages/paperclip-runner/src/protocol-actions/get-agent-instruction-history.ts b/packages/paperclip-runner/src/protocol-actions/get-agent-instruction-history.ts new file mode 100644 index 00000000000..11046457e0b --- /dev/null +++ b/packages/paperclip-runner/src/protocol-actions/get-agent-instruction-history.ts @@ -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; diff --git a/packages/paperclip-runner/src/protocol-actions/index.ts b/packages/paperclip-runner/src/protocol-actions/index.ts index 8f07df6695d..4dcfcc66b0e 100644 --- a/packages/paperclip-runner/src/protocol-actions/index.ts +++ b/packages/paperclip-runner/src/protocol-actions/index.ts @@ -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, diff --git a/packages/paperclip-runner/src/protocol-actions/read-agent-instructions.ts b/packages/paperclip-runner/src/protocol-actions/read-agent-instructions.ts new file mode 100644 index 00000000000..9a947d1a2cb --- /dev/null +++ b/packages/paperclip-runner/src/protocol-actions/read-agent-instructions.ts @@ -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; diff --git a/packages/paperclip-runner/src/protocol-actions/restore-agent-instructions.ts b/packages/paperclip-runner/src/protocol-actions/restore-agent-instructions.ts new file mode 100644 index 00000000000..464a556f410 --- /dev/null +++ b/packages/paperclip-runner/src/protocol-actions/restore-agent-instructions.ts @@ -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; diff --git a/packages/paperclip-runner/src/protocol-actions/update-agent-instructions.ts b/packages/paperclip-runner/src/protocol-actions/update-agent-instructions.ts new file mode 100644 index 00000000000..a42356b6ca3 --- /dev/null +++ b/packages/paperclip-runner/src/protocol-actions/update-agent-instructions.ts @@ -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; diff --git a/packages/paperclip-runner/src/semantic-tools/discovery.ts b/packages/paperclip-runner/src/semantic-tools/discovery.ts index d1bf2bd4335..7d6c28a0d2f 100644 --- a/packages/paperclip-runner/src/semantic-tools/discovery.ts +++ b/packages/paperclip-runner/src/semantic-tools/discovery.ts @@ -27,7 +27,12 @@ const NAMESPACE: Readonly> = 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) })) diff --git a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts index af1344a2984..1e6ebc1bb50 100644 --- a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts +++ b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts @@ -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"); diff --git a/packages/paperclip-runner/src/semantic-tools/types.ts b/packages/paperclip-runner/src/semantic-tools/types.ts index ea9339edf8d..31178d2690a 100644 --- a/packages/paperclip-runner/src/semantic-tools/types.ts +++ b/packages/paperclip-runner/src/semantic-tools/types.ts @@ -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" diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 04b12167862..9591a8ce372 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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"; diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index e775c5b9941..443e61f217e 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -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; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 179832a3de4..87ace9cd47b 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -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"; diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index e40a7cf6ce7..9c2ec1d0611 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -34,10 +34,18 @@ export const updateAgentInstructionsBundleSchema = z.object({ export type UpdateAgentInstructionsBundle = z.infer; 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; @@ -289,3 +297,9 @@ export const updateAgentPermissionsSchema = z.object({ }); export type UpdateAgentPermissions = z.infer; + +export const resolveAgentInstructionCandidateSchema = z.object({ + baseRevisionId: z.string().uuid().nullable(), + content: z.string().max(1024 * 1024), +}).strict(); +export type ResolveAgentInstructionCandidate = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index a39388a080c..8489488d1c7 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -985,3 +985,5 @@ export * from "./chat-channels.js"; export * from "./chat-github.js"; export * from "./email.js"; + +export { restoreAgentInstructionSchema } from "./agent.js"; diff --git a/scripts/storybook-agent-avatar-assets.mjs b/scripts/storybook-agent-avatar-assets.mjs index 736bcb28643..3487904493c 100644 --- a/scripts/storybook-agent-avatar-assets.mjs +++ b/scripts/storybook-agent-avatar-assets.mjs @@ -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")); diff --git a/server/src/__tests__/adapter-models.test.ts b/server/src/__tests__/adapter-models.test.ts index e35ba1b4471..c04f07fb000 100644 --- a/server/src/__tests__/adapter-models.test.ts +++ b/server/src/__tests__/adapter-models.test.ts @@ -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" }], diff --git a/server/src/__tests__/agent-directory-working-copies.test.ts b/server/src/__tests__/agent-directory-working-copies.test.ts new file mode 100644 index 00000000000..17d7acdb882 --- /dev/null +++ b/server/src/__tests__/agent-directory-working-copies.test.ts @@ -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>; + let db: ReturnType; + let copies: ReturnType; + let revisions: ReturnType; + 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) => { + 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"); + }); + +}); diff --git a/server/src/__tests__/agent-instruction-git-exclusion.test.ts b/server/src/__tests__/agent-instruction-git-exclusion.test.ts new file mode 100644 index 00000000000..73f81d4c92f --- /dev/null +++ b/server/src/__tests__/agent-instruction-git-exclusion.test.ts @@ -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"); +}); diff --git a/server/src/__tests__/agent-instruction-revisions.test.ts b/server/src/__tests__/agent-instruction-revisions.test.ts new file mode 100644 index 00000000000..6d2a516cb2c --- /dev/null +++ b/server/src/__tests__/agent-instruction-revisions.test.ts @@ -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>; + let db: ReturnType; + let service: ReturnType; + 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); + }); +}); diff --git a/server/src/__tests__/agent-instruction-working-copies.test.ts b/server/src/__tests__/agent-instruction-working-copies.test.ts new file mode 100644 index 00000000000..1b6c9b2eb3a --- /dev/null +++ b/server/src/__tests__/agent-instruction-working-copies.test.ts @@ -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>; + let db: ReturnType; + let copies: ReturnType; + let revisions: ReturnType; + 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) => { + 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 => { + 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"); + }); + +}); diff --git a/server/src/__tests__/agent-instructions-routes.test.ts b/server/src/__tests__/agent-instructions-routes.test.ts index 0a110b78025..494f89ffb0c 100644 --- a/server/src/__tests__/agent-instructions-routes.test.ts +++ b/server/src/__tests__/agent-instructions-routes.test.ts @@ -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(); + return { ...actual, agentFileStore: (...args: Parameters) => ({ ...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("../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 () => { diff --git a/server/src/__tests__/agent-instructions-service.test.ts b/server/src/__tests__/agent-instructions-service.test.ts index 2dc4719d1a8..7641dcc6778 100644 --- a/server/src/__tests__/agent-instructions-service.test.ts +++ b/server/src/__tests__/agent-instructions-service.test.ts @@ -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): TestAgent { @@ -29,6 +29,13 @@ describe("agent instructions service", () => { const originalPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID; const cleanupDirs = new Set(); + 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" }); }); diff --git a/server/src/__tests__/built-in-agent-routes.test.ts b/server/src/__tests__/built-in-agent-routes.test.ts index acae6704dc3..e51166f2ba4 100644 --- a/server/src/__tests__/built-in-agent-routes.test.ts +++ b/server/src/__tests__/built-in-agent-routes.test.ts @@ -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", diff --git a/server/src/__tests__/built-in-agents.test.ts b/server/src/__tests__/built-in-agents.test.ts index 8f3abf3972d..69de71c730c 100644 --- a/server/src/__tests__/built-in-agents.test.ts +++ b/server/src/__tests__/built-in-agents.test.ts @@ -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; let tempDb: Awaited> | 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, diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 85d968cbc1c..39d9e150fe8 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -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(); } diff --git a/server/src/__tests__/chat-interaction-publications.test.ts b/server/src/__tests__/chat-interaction-publications.test.ts index c9cba1b72f4..6c4c5d5f8ee 100644 --- a/server/src/__tests__/chat-interaction-publications.test.ts +++ b/server/src/__tests__/chat-interaction-publications.test.ts @@ -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", diff --git a/server/src/__tests__/execution-workspace-policy.test.ts b/server/src/__tests__/execution-workspace-policy.test.ts index 07f61797313..e5cf70cf011 100644 --- a/server/src/__tests__/execution-workspace-policy.test.ts +++ b/server/src/__tests__/execution-workspace-policy.test.ts @@ -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, + 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, diff --git a/server/src/__tests__/heartbeat-list.test.ts b/server/src/__tests__/heartbeat-list.test.ts index 465514840ba..b07c8e4368d 100644 --- a/server/src/__tests__/heartbeat-list.test.ts +++ b/server/src/__tests__/heartbeat-list.test.ts @@ -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"); }); }); diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index d73a3fbd2d0..d8282401e92 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -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", diff --git a/server/src/__tests__/issue-thread-interactions-telemetry.test.ts b/server/src/__tests__/issue-thread-interactions-telemetry.test.ts index d86813db1d1..d3db5df11d3 100644 --- a/server/src/__tests__/issue-thread-interactions-telemetry.test.ts +++ b/server/src/__tests__/issue-thread-interactions-telemetry.test.ts @@ -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", diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts index bf7712de39c..841896e0338 100644 --- a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -201,7 +201,8 @@ function registerModuleMocks() { })); } -async function createApp() { +async function createApp(transaction: (callback: (tx: Record) => Promise) => Promise = + async (callback) => callback({})) { const [{ errorHandler }, { issueRoutes }] = await Promise.all([ vi.importActual("../middleware/index.js"), vi.importActual("../routes/issues.js"), @@ -220,7 +221,7 @@ async function createApp() { next(); }); app.use("/api", issueRoutes({ - transaction: async (callback: (tx: Record) => Promise) => 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) => Promise) => { + 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, diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 9309731a6dd..a3fa946571d 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -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((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(); diff --git a/server/src/__tests__/plugin-managed-agents.test.ts b/server/src/__tests__/plugin-managed-agents.test.ts index 34ba238c554..0b06a551266 100644 --- a/server/src/__tests__/plugin-managed-agents.test.ts +++ b/server/src/__tests__/plugin-managed-agents.test.ts @@ -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>>; + 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 }); diff --git a/server/src/__tests__/rerun-survival.integration.test.ts b/server/src/__tests__/rerun-survival.integration.test.ts index 1708febeadf..aa3d5cb48c4 100644 --- a/server/src/__tests__/rerun-survival.integration.test.ts +++ b/server/src/__tests__/rerun-survival.integration.test.ts @@ -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(); @@ -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), diff --git a/server/src/__tests__/run-identity.test.ts b/server/src/__tests__/run-identity.test.ts index 9155a9b9fa7..d6dbbbfc3ff 100644 --- a/server/src/__tests__/run-identity.test.ts +++ b/server/src/__tests__/run-identity.test.ts @@ -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; 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" }); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index ececaebf9e4..fd0caefa8e1 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -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 }); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index d4abb5249ed..62cef753d7b 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -1042,13 +1042,22 @@ function getDeclaredAdapterModels(): ReturnType { 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) { diff --git a/server/src/onboarding-assets/ceo/HEARTBEAT.md b/server/src/onboarding-assets/ceo/HEARTBEAT.md index dcdc6f85df7..f80d325505a 100644 --- a/server/src/onboarding-assets/ceo/HEARTBEAT.md +++ b/server/src/onboarding-assets/ceo/HEARTBEAT.md @@ -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. diff --git a/server/src/onboarding-assets/default/AGENTS.md b/server/src/onboarding-assets/default/AGENTS.md index cf9d10a55f1..14724839144 100644 --- a/server/src/onboarding-assets/default/AGENTS.md +++ b/server/src/onboarding-assets/default/AGENTS.md @@ -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": "" }` plus `action`), not for the board/user or another agent. - Respect budget, pause/cancel, approval gates, and company boundaries. diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 2efcc764571..756d0c7c377 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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, { diff --git a/server/src/routes/built-in-agents.ts b/server/src/routes/built-in-agents.ts index 9c16ba6f53b..c881ae9c93f 100644 --- a/server/src/routes/built-in-agents.ts +++ b/server/src/routes/built-in-agents.ts @@ -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", diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 653c5e7965f..10ac1f566c6 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -13729,13 +13729,17 @@ export function issueRoutes( }); const decision = transition.decision && decisionId ? transition.decision : null; - let attachmentComment: Awaited> | null = + let transactionalComment: Awaited> | null = null; - const attachmentCommentSourceTrust = commentAttachmentIds?.length + const commentWithAdapterOverrides = Boolean( + commentBody && updateFields.assigneeAdapterOverrides !== undefined, + ); + const transactionalCommentSourceTrust = commentAttachmentIds?.length || commentWithAdapterOverrides ? await sourceTrustForActorWrite(existing, actor) : undefined; const shouldUseTransactionalIssueUpdate = Boolean(commentAttachmentIds?.length) || + commentWithAdapterOverrides || Boolean(decision) || shouldRelayStop || persistReviewActivityTransactionally || @@ -13750,10 +13754,10 @@ export function issueRoutes( return null; const updated = await updateIssue(tx); if (!updated) return null; - if (commentAttachmentIds?.length) { - // Reassignment, comment creation and upload binding commit together. - // An invalid or already-bound receipt rolls back the issue update. - attachmentComment = await svc.addComment( + if (commentAttachmentIds?.length || commentWithAdapterOverrides) { + // Adapter settings, reassignment, comment and upload binding commit together. + // A failed comment or invalid receipt rolls back the issue update. + transactionalComment = await svc.addComment( id, commentBody, { @@ -13768,7 +13772,7 @@ export function issueRoutes( clientRequestId: actor.actorType === "user" ? commentClientRequestId : undefined, mirrorToSlack: actor.actorType === "user", authorizationReason: issueMutationAuthorizationReason, - sourceTrust: attachmentCommentSourceTrust, + sourceTrust: transactionalCommentSourceTrust, }, tx, ); @@ -14353,7 +14357,7 @@ export function issueRoutes( } let comment: Awaited> | null = - attachmentComment; + transactionalComment; let goalCommentSteered = false; let lostReviewPathRef: string | null = null; if (commentBody) { diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 9cd65e6f6d0..38b876197d6 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -27,6 +27,8 @@ import { updateAgentInstructionsPathSchema, updateAgentInstructionsBundleSchema, upsertAgentInstructionsFileSchema, + restoreAgentInstructionSchema, + resolveAgentInstructionCandidateSchema, createAgentKeySchema, builtInAgentEmptyMutationSchema, builtInAgentProvisionSchema, @@ -3455,8 +3457,8 @@ registry.registerPath({ method: "get", path: "/api/agents/{id}/instructions-bundle/file", tags: ["agents"], - summary: "Get agent instructions file", - request: { params: z.object({ id: z.string() }) }, + summary: "Get agent file content or download its original bytes", + request: { params: z.object({ id: z.string() }), query: z.object({ path: z.string(), download: z.enum(["true", "false"]).optional() }) }, responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, }); @@ -3472,12 +3474,46 @@ registry.registerPath({ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, }); +for (const operation of [ + { suffix: "history", summary: "List immutable instruction revisions", query: z.object({ path: z.string().optional(), cursor: z.string().uuid().optional() }) }, + { suffix: "revision/{revisionId}", summary: "Read exact instruction content at a revision", query: z.object({ path: z.string().optional() }) }, + { suffix: "diff", summary: "Compare instruction revisions (exact common prefix, removed, added, suffix)", query: z.object({ path: z.string().optional(), from: z.string().uuid(), to: z.string().uuid() }) }, +]) { + registry.registerPath({ method: "get", path: `/api/agents/{id}/instructions-bundle/${operation.suffix}`, tags: ["agents"], summary: operation.summary, + request: { params: operation.suffix.includes("revisionId") ? z.object({ id: z.string(), revisionId: z.string().uuid() }) : z.object({ id: z.string() }), query: operation.query }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound } }); +} +registry.registerPath({ method: "post", path: "/api/agents/{id}/instructions-bundle/restore", tags: ["agents"], summary: "Restore a pre-upgrade instruction snapshot into current files with compare-and-swap", + request: { params: z.object({ id: z.string() }), body: jsonBody(restoreAgentInstructionSchema) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict } }); + +registry.registerPath({ + method: "get", + path: "/api/agents/{id}/instructions-bundle/candidates", + tags: ["agents"], + summary: "List preserved instruction edits and collection diagnostics", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/instructions-bundle/candidates/{runId}/resolve", + tags: ["agents"], + summary: "Explicitly save a preserved instruction edit with compare-and-swap", + request: { + params: z.object({ id: z.string(), runId: z.string().uuid() }), + body: jsonBody(resolveAgentInstructionCandidateSchema), + }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict, 422: r.unprocessable }, +}); + registry.registerPath({ method: "delete", path: "/api/agents/{id}/instructions-bundle/file", tags: ["agents"], - summary: "Delete agent instructions file", - request: { params: z.object({ id: z.string() }) }, + summary: "Delete agent file with compare-and-swap for managed storage", + request: { params: z.object({ id: z.string() }), query: z.object({ path: z.string(), baseHash: z.string().regex(/^[a-f0-9]{64}$/).optional() }) }, responses: { 200: r.ok(), 401: r.unauthorized }, }); diff --git a/server/src/services/agent-directory-working-copies.ts b/server/src/services/agent-directory-working-copies.ts new file mode 100644 index 00000000000..ab3dde2e112 --- /dev/null +++ b/server/src/services/agent-directory-working-copies.ts @@ -0,0 +1,258 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { and, eq } from "drizzle-orm"; +import { agents, environmentLeases, environments, agentInstructionWorkingCopies as copies, type Db } from "@paperclipai/db"; +import { syncDirectoryToSsh, restoreWorkspaceFromSshExecution } from "@paperclipai/adapter-utils/ssh"; +import { prepareAdapterExecutionTargetRuntime, runAdapterExecutionTargetShellCommand, type AdapterExecutionTarget, type PreparedAdapterExecutionTargetRuntime } from "@paperclipai/adapter-utils/execution-target"; +import { withDirectoryMergeLock, directorySnapshotSha256, parseDirectorySnapshot, serializeDirectorySnapshot } from "@paperclipai/adapter-utils/workspace-restore-merge"; +import { AGENT_FILES_CONTRACT, AgentFileLimitError, agentFileStore, agentStorageWarning, inspectAgentDirectory } from "./agent-file-store.js"; +import { agentInstructionsBundleMode, deriveBundleState, resolveManagedInstructionsRoot } from "./agent-instructions.js"; +import { instructionGitExcludeProgram } from "./agent-instruction-files.js"; +import { resolveInstructionActor } from "./agent-instruction-authorization.js"; +import { HttpError, conflict, notFound } from "../errors.js"; +import type { AuthorizationActor } from "./authorization.js"; +import type { EnvironmentRuntimeService } from "./environment-runtime.js"; +import type { Environment, EnvironmentLease } from "@paperclipai/shared"; +import { hasRemoteTerminationReceipt } from "./remote-execution-termination.js"; + +type Copy = typeof copies.$inferSelect; +const completed = new Set(["saved", "unchanged", "resolved", "unavailable"]); +const transports = new Map(); +const key = (row: Pick) => `${row.companyId}:${row.runId}`; +export function isAgentDirectoryCopy(row: Pick | null): boolean { + return row?.receipt?.schema === AGENT_FILES_CONTRACT; +} +function baseline(row: Copy) { + const snapshot = parseDirectorySnapshot(row.receipt?.baseline); + if (!snapshot || directorySnapshotSha256(snapshot) !== row.baseHash) throw new Error("Agent directory baseline is invalid"); + return snapshot; +} +function actor(row: Copy): AuthorizationActor { + return { type: "agent", companyId: row.companyId, agentId: row.agentId, runId: row.runId, onBehalfOfUserId: row.responsibleUserId }; +} +export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string, runId: string) => Promise, patch: (row: Copy, values: Partial) => Promise, environmentRuntime?: EnvironmentRuntimeService) { + const store = agentFileStore(db); + async function transport(row: Copy, target: AdapterExecutionTarget, recovering: boolean) { + if (target.kind !== "remote" || row.location !== `remote:${target.environmentId ?? ""}`) throw new Error("Agent directory environment changed"); + if (recovering && target.transport === "ssh") throw new Error("The original SSH collector is unavailable"); + if (target.transport === "ssh") { + // Reuse SSH's plain directory transfer without its task-workspace suffix + // or Git-history discovery. The registered root is the exact writable root. + await syncDirectoryToSsh({ spec: target.spec, localDir: row.localRoot, remoteDir: row.executionRoot, exclude: [".paperclip-runtime"] }); + return { target, workspaceRemoteDir: row.executionRoot, runtimeRootDir: null, + assetDirs: {}, additionalSourceDirs: {}, additionalSourceFailures: [], workspaceSyncSnapshot: null, + restoreWorkspace: () => restoreWorkspaceFromSshExecution({ spec: target.spec, localDir: row.localRoot, + remoteDir: row.executionRoot, baselineSnapshot: { ...baseline(row), exclude: [".paperclip-runtime"] }, restoreGitHistory: false }) }; + } + return prepareAdapterExecutionTargetRuntime({ target, runId: row.runId, adapterKey: "agent-files", + workspaceLocalDir: row.localRoot, workspaceRemoteDir: row.executionRoot, + syncWorkspace: true, workspaceInboundMode: recovering ? "adopt_remote" : undefined, + workspaceBaseline: baseline(row), workspaceGitSnapshot: null, workspaceFileMode: "all", + workspaceExclude: [".paperclip-runtime", ".paperclip-runtime/**"] }); + } + async function prepare(input: { companyId: string; agentId: string; runId: string; target?: AdapterExecutionTarget | null; cwd: string }) { + const [agent] = await db.select().from(agents).where(and(eq(agents.id, input.agentId), eq(agents.companyId, input.companyId))); + if (!agent) throw notFound("Agent not found"); + if (agentInstructionsBundleMode(agent) !== "managed") return null; + const bound = await resolveInstructionActor(db, { type: "agent", companyId: input.companyId, agentId: input.agentId, runId: input.runId }); + const root = resolveManagedInstructionsRoot(agent); + const localRoot = path.join(path.dirname(root), "file-sync", "runs", input.runId, "live"); + // Local copies live outside the task cwd. Remote copies use the reserved, + // excluded runtime area inside the provider's confined workspace. They are + // synchronized independently; provider HOME is never reinterpreted. + const executionRoot = input.target?.kind === "remote" + ? path.posix.join(input.target.remoteCwd, ".paperclip-runtime", "agent-files", input.agentId, input.runId) + : localRoot; + const location = input.target?.kind === "remote" ? `remote:${input.target.environmentId ?? ""}` : "local"; + let row = await get(input.companyId, input.runId); + if (row && (row.localRoot !== localRoot || row.executionRoot !== executionRoot || row.agentId !== input.agentId || row.location !== location)) throw conflict("Agent directory belongs to a different execution environment"); + if (row && !completed.has(row.state) && row.state !== "preparing") { + if (input.target?.kind === "remote" && !transports.has(key(row))) transports.set(key(row), await transport(row, input.target, true)); + return row; + } + // Register ownership before creating any bytes, so a crash during the copy + // leaves a recoverable preparing receipt instead of an orphan directory. + const preparing = { entryFile: deriveBundleState(agent).entryFile, baseRevisionId: null, baseHash: "preparing", + localRoot, executionRoot, location, state: "preparing", candidateBase64: null, candidateHash: null, + receipt: { schema: AGENT_FILES_CONTRACT, ...(input.target?.kind === "remote" + ? { cleanup: { leaseId: input.target.leaseId ?? null, remoteCwd: input.target.remoteCwd } } : {}) }, processStoppedAt: null, attempts: 0, + nextAttemptAt: null, errorCode: null, errorMessage: null }; + if (row) row = await patch(row, preparing); + else { + await db.insert(copies).values({ runId: input.runId, companyId: input.companyId, agentId: input.agentId, responsibleUserId: bound.onBehalfOfUserId!, ...preparing }); + row = (await get(input.companyId, input.runId))!; + } + const { snapshot, storageWarning } = await store.locked(input.companyId, input.agentId, bound, false, async (_tx, _agent, canonical) => { + // Storage quotas govern saves, never admission to a future run. Restore + // existing bytes so the agent can work normally and remove excess files. + // Path/link validation remains mandatory even for an over-quota folder. + const inspected = await inspectAgentDirectory(canonical, false); + // A stopped lifecycle has already accounted for all its bytes. No live + // or pending candidate ever enters this replacement path. + await fs.rm(localRoot, { recursive: true, force: true }); + await fs.mkdir(path.dirname(localRoot), { recursive: true, mode: 0o700 }); + try { + await fs.cp(canonical, localRoot, { recursive: true, preserveTimestamps: true }); + return inspected; + } catch (error) { + await fs.rm(path.dirname(localRoot), { recursive: true, force: true }); + throw error; + } + }).catch(async error => { + await fs.rm(path.dirname(localRoot), { recursive: true, force: true }); + throw error; + }); + const values = { entryFile: deriveBundleState(agent).entryFile, baseRevisionId: null, baseHash: directorySnapshotSha256(snapshot), + localRoot, executionRoot, location, state: "preparing", candidateBase64: null, candidateHash: null, + receipt: { ...preparing.receipt, baseline: serializeDirectorySnapshot(snapshot), storageWarning }, + processStoppedAt: null, attempts: 0, nextAttemptAt: null, errorCode: null, errorMessage: null }; + try { + row = await patch(row, values); + } catch (error) { + await fs.rm(path.dirname(localRoot), { recursive: true, force: true }); + throw error; + } + try { + if (input.target?.kind === "remote") { + const quote = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`; + const excluded = await runAdapterExecutionTargetShellCommand(input.runId, input.target, + `node -e ${quote(instructionGitExcludeProgram)} ${quote(input.target.remoteCwd)}`, + { cwd: input.target.remoteCwd, env: {}, timeoutSec: 15 }); + if (excluded.exitCode !== 0 || excluded.timedOut) throw new Error("Could not exclude agent files from task Git staging"); + transports.set(key(row), await transport(row, input.target, false)); + } + return await patch(row, { state: "prepared" }); + } catch (error) { + // Preparation failed before a provider could start using this copy. + row = await patch(row, { state: "unavailable", processStoppedAt: new Date(), errorCode: "AGENT_FILES_PREPARE_FAILED", + errorMessage: "Agent files could not be staged. The temporary copy was discarded.", nextAttemptAt: null }); + await release(row, input.target); + throw error; + } + } + + async function retrieve(row: Copy, target?: AdapterExecutionTarget | null) { + if (row.location !== "local") { + let runtime = transports.get(key(row)); + if (!runtime) { + if (!target) throw new Error("Agent directory transport is unavailable"); + runtime = await transport(row, target, true); + transports.set(key(row), runtime); + } + await runtime.restoreWorkspace(); + } + return inspectAgentDirectory(row.localRoot); + } + async function hasChanges(_row: Copy, _target?: AdapterExecutionTarget | null) { + // A whole-directory collector needs a quiescent provider, including its + // child processes. Checkpoint and close at the turn boundary before reading + // either local or remote files. The provider conversation remains resumable; + // ordinary file edits do not change the session configuration digest. + return true; + } + async function collectStopped(row: Copy, target?: AdapterExecutionTarget | null) { + if (completed.has(row.state)) { await release(row); return (await get(row.companyId, row.runId))!; } + row = await patch(row, { processStoppedAt: row.processStoppedAt ?? new Date() }); + // The stopped working copy is the only temporary tree. Retry transient I/O + // at this boundary, then clean it up; there are no preserved run snapshots. + let inspected: Awaited> | undefined; + for (;;) { + row = await patch(row, { attempts: row.attempts + 1 }); + try { + inspected ??= await retrieve(row, target); + const { snapshot } = inspected; + const candidateHash = directorySnapshotSha256(snapshot); + let storageWarning = inspected.storageWarning; + if (candidateHash === row.baseHash) { + row = await patch(row, { state: "unchanged", errorCode: null, errorMessage: null, nextAttemptAt: null }); + } else { + ({ storageWarning } = await store.apply({ companyId: row.companyId, agentId: row.agentId, sourceDir: row.localRoot, baseline: baseline(row) }, actor(row))); + row = await patch(row, { state: "saved", candidateHash, errorCode: null, errorMessage: null, nextAttemptAt: null }); + } + row = await patch(row, { receipt: { ...row.receipt, storageWarning } }); + break; + } catch (error) { + const retryable = !(error instanceof HttpError) || error.status >= 500; + if (retryable && row.attempts < 3) continue; + row = await patch(row, { state: "unavailable", nextAttemptAt: null, + receipt: { ...row.receipt, storageWarning: error instanceof AgentFileLimitError ? agentStorageWarning(error.message) : row.receipt?.storageWarning ?? null }, + errorCode: error instanceof AgentFileLimitError ? "AGENT_FILES_LIMIT_EXCEEDED" : "AGENT_FILES_SAVE_FAILED", + errorMessage: error instanceof HttpError && error.status === 422 + ? `${error.message}. This run's agent-folder changes were not saved; the temporary copy is discarded.` + : "Agent-file synchronization failed. No successful save is claimed; the temporary copy is discarded.", + }); + break; + } + } + await release(row); + return (await get(row.companyId, row.runId))!; + } + async function release(row: Copy, target?: AdapterExecutionTarget | null) { + // Collection and environment teardown can both release the same copy. + // A compact successful cleanup receipt is final, even after restart. + if (completed.has(row.state) && row.processStoppedAt && row.receipt?.cleanupPending === false) return; + const runtime = transports.get(key(row)); + transports.delete(key(row)); + let cleanupPending = false; + await runtime?.cleanupWorkspaceSnapshot?.().catch(() => { cleanupPending = true; }); + const cleanupTarget = runtime?.target ?? target; + if (row.processStoppedAt && completed.has(row.state) && cleanupTarget?.kind === "remote") { + const expected = path.posix.join(cleanupTarget.remoteCwd, ".paperclip-runtime", "agent-files", row.agentId, row.runId); + if (row.executionRoot !== expected) throw new Error("Agent directory cleanup path changed"); + const quoted = `'${expected.replaceAll("'", `'"'"'`)}'`; + const remoteCleanupFailed = await runAdapterExecutionTargetShellCommand(row.runId, cleanupTarget, `rm -rf -- ${quoted}`, + { cwd: cleanupTarget.remoteCwd, env: {}, timeoutSec: 15 }).then(result => result.exitCode !== 0 || result.timedOut, () => true); + cleanupPending ||= remoteCleanupFailed; + } else if (row.processStoppedAt && completed.has(row.state) && row.location !== "local") { + // Rebind only the original host-owned lease. Never acquire a replacement + // sandbox or persist transport credentials in a working-copy receipt. + cleanupPending ||= !await cleanupRemoteAfterRestart(row).catch(() => false); + } + if (completed.has(row.state) && row.processStoppedAt) { + try { await fs.rm(path.dirname(row.localRoot), { recursive: true, force: true }); } + catch { + // Leave the baseline marker so restart recovery retries failed cleanup. + await patch(row, { receipt: { ...row.receipt, cleanupPending: true } }); + return; + } + await patch(row, { receipt: { schema: AGENT_FILES_CONTRACT, state: row.state, appliedCandidateHash: row.candidateHash, storageWarning: row.receipt?.storageWarning ?? null, cleanupPending, + ...(cleanupPending ? { cleanup: row.receipt?.cleanup } : {}) }, + nextAttemptAt: cleanupPending ? new Date(Date.now() + 30_000) : null }); + } + } + async function cleanupRemoteAfterRestart(row: Copy): Promise { + const cleanup = row.receipt?.cleanup as { leaseId?: string; remoteCwd?: string } | undefined; + // Older preview receipts did not record the lease ID. Accept only a unique + // lease still bound to this run and environment in that compatibility case. + const leases = await db.select().from(environmentLeases).where(and( + eq(environmentLeases.companyId, row.companyId), + cleanup?.leaseId ? eq(environmentLeases.id, cleanup.leaseId) : and( + eq(environmentLeases.environmentId, row.location.slice("remote:".length)), eq(environmentLeases.heartbeatRunId, row.runId)), + )); + if (leases.length !== 1) return false; + const lease = leases[0]!; + const termination = lease.metadata?.remoteExecutionTermination as { state?: string } | undefined; + if (hasRemoteTerminationReceipt(lease) && termination?.state === "destroyed") return true; + if (!environmentRuntime || !lease.environmentId || row.location !== `remote:${lease.environmentId}`) return false; + const [environment] = await db.select().from(environments).where(eq(environments.id, lease.environmentId)); + if (!environment) return false; + const remoteCwd = cleanup?.remoteCwd ?? lease.metadata?.remoteCwd; + if (typeof remoteCwd !== "string" || row.executionRoot !== path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", row.agentId, row.runId)) return false; + const result = await environmentRuntime.execute({ environment: environment as Environment, lease: lease as EnvironmentLease, command: "rm", args: ["-rf", "--", row.executionRoot], + cwd: remoteCwd, env: {}, timeoutMs: 15_000, bypassSession: true }); + return result.exitCode === 0 && !result.timedOut; + } + async function serial(row: Copy, fn: (current: Copy) => Promise): Promise { + // Duplicate stop callbacks and restart recovery must not race while moving + // the stopped working copy. This lock is outside the writable tree. + return withDirectoryMergeLock(path.resolve(row.localRoot, "../../.."), async () => { + const current = await get(row.companyId, row.runId); + if (!current) throw notFound("Agent directory copy not found"); + return fn(current); + }); + } + return { prepare, hasChanges, + collectStopped: (row: Copy, target?: AdapterExecutionTarget | null) => serial(row, current => collectStopped(current, target)), + release: (row: Copy) => serial(row, release), + }; +} diff --git a/server/src/services/agent-file-store.ts b/server/src/services/agent-file-store.ts new file mode 100644 index 00000000000..868a3560553 --- /dev/null +++ b/server/src/services/agent-file-store.ts @@ -0,0 +1,282 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import { constants } from "node:fs"; +import { Readable } from "node:stream"; +import path from "node:path"; +import { and, eq } from "drizzle-orm"; +import { agents, activityLog, agentInstructionHeads, agentInstructionRevisions, type Db } from "@paperclipai/db"; +import { captureDirectorySnapshot, mergeDirectoryWithBaseline, type DirectorySnapshot } from "@paperclipai/adapter-utils/workspace-restore-merge"; +import { HttpError, conflict, notFound, unprocessable } from "../errors.js"; +import { authorizeInstructionCommit, authorizeInstructionRead } from "./agent-instruction-authorization.js"; +import { assertInstructionPathSafe, instructionPath, instructionBytes, materializeInstructionBytes, readInstructionBytes, MAX_INSTRUCTION_BYTES } from "./agent-instruction-files.js"; +import { agentInstructionsBundleMode, deriveBundleState, resolveManagedInstructionsRoot } from "./agent-instructions.js"; +import type { AuthorizationActor } from "./authorization.js"; + +type Tx = Parameters[0]>[0]; +type Agent = typeof agents.$inferSelect; +export const AGENT_FILES_CONTRACT = "paperclip.agent-files.v1"; +export const MAX_AGENT_FILE_BYTES = 256 * 1024 * 1024; +export const MAX_AGENT_DIRECTORY_BYTES = 2 * 1024 * 1024 * 1024; +export const MAX_AGENT_DIRECTORY_ENTRIES = 100_000; +export const fileHash = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex"); + +export class AgentFileLimitError extends HttpError { + constructor(message: string) { + super(422, message, { code: "AGENT_FILES_LIMIT_EXCEEDED" }); + } +} +function assertFileSize(size: number, relative: string) { + if (size > MAX_AGENT_FILE_BYTES) throw new AgentFileLimitError(`Agent file "${relative}" exceeds the 256 MiB per-file limit`); +} +function assertDirectorySize(size: number, count: number) { + if (size > MAX_AGENT_DIRECTORY_BYTES) throw new AgentFileLimitError("Agent directory exceeds the 2 GiB total storage limit"); + if (count > MAX_AGENT_DIRECTORY_ENTRIES) throw new AgentFileLimitError("Agent directory exceeds the 100,000-entry limit (files and folders)"); +} +export function agentStorageWarning(detail: string) { + return `Agent storage is full. ${detail}. Runs can continue; remove or shrink files in AGENT_HOME to free space. Changes exceeding the storage limits will not be saved.`; +} +function storageWarning(size: number, count: number, fullFile?: string) { + if (size >= MAX_AGENT_DIRECTORY_BYTES) return agentStorageWarning("The agent folder has reached its 2 GiB limit"); + if (count >= MAX_AGENT_DIRECTORY_ENTRIES) return agentStorageWarning("The agent folder has reached its 100,000-entry limit"); + if (fullFile) return agentStorageWarning(`Agent file "${fullFile}" has reached its 256 MiB limit`); + return null; +} + +export function agentFilePath(value: string): string { + const relative = instructionPath(value); + if (relative.split("/").includes(".paperclip-runtime") || relative === "promptTemplate.legacy.md") { + throw unprocessable(`${relative} is reserved and cannot be used for agent files`); + } + return relative; +} + +/** Compatibility ETag for clients whose old schema requires a UUID. This is a + * content token, not a revision ID: no snapshot or history row is created. */ +export function agentFileToken(bytes: Uint8Array): string { + return agentFileTokenFromHash(fileHash(bytes)); +} +export function agentFileTokenFromHash(h: string): string { + return `${h.slice(0, 8)}-${h.slice(8, 12)}-8${h.slice(13, 16)}-a${h.slice(17, 20)}-${h.slice(20, 32)}`; +} + +/** Call under the agent row lock. Import deployed revision heads once, then the + * directory is authoritative. The adoption marker lives OUTSIDE agent files. */ +export async function adoptAgentFiles(tx: Tx, agent: Agent): Promise { + if (agentInstructionsBundleMode(agent) === "external") throw unprocessable("External instructions must be migrated to managed storage first"); + const root = resolveManagedInstructionsRoot(agent); + const markerRoot = path.join(path.dirname(root), "file-sync"); + const marker = "adopted.json"; + await assertInstructionPathSafe(root, deriveBundleState(agent).entryFile); + await fs.mkdir(root, { recursive: true, mode: 0o700 }); + const filename = await assertInstructionPathSafe(markerRoot, marker); + const adopted = await fs.readFile(filename, "utf8").catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (adopted !== null) { + if (JSON.parse(adopted).schema !== AGENT_FILES_CONTRACT) throw new Error("Unsupported agent file contract"); + return root; + } + const legacy = await tx.select({ entryFile: agentInstructionHeads.entryFile, contentBase64: agentInstructionRevisions.contentBase64 }) + .from(agentInstructionHeads).innerJoin(agentInstructionRevisions, eq(agentInstructionHeads.revisionId, agentInstructionRevisions.id)) + .where(and(eq(agentInstructionHeads.companyId, agent.companyId), eq(agentInstructionHeads.agentId, agent.id))); + for (const entry of legacy) await materializeInstructionBytes(root, entry.entryFile, Buffer.from(entry.contentBase64, "base64")); + await materializeInstructionBytes(markerRoot, marker, Buffer.from(JSON.stringify({ schema: AGENT_FILES_CONTRACT }))); + return root; +} + +async function openAgentFile(root: string, relative: string) { + const filename = await assertInstructionPathSafe(root, relative); + const handle = await fs.open(filename, constants.O_RDONLY | constants.O_NOFOLLOW).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (!handle) return null; + try { + const stat = await handle.stat(); + if (!stat.isFile() || stat.nlink !== 1) throw unprocessable("Agent files must be regular files without links"); + assertFileSize(stat.size, relative); + return { handle, size: stat.size }; + } catch (error) { await handle.close(); throw error; } +} + +/** Hash large files incrementally. Only editor-sized content is retained in RAM. */ +export async function inspectAgentFile(root: string, relative: string, bufferLimit = MAX_INSTRUCTION_BYTES) { + const opened = await openAgentFile(root, relative); + if (!opened) return null; + const hash = createHash("sha256"); + let chunks: Buffer[] | null = opened.size <= bufferLimit ? [] : null; + let size = 0; + try { + for await (const chunk of opened.handle.createReadStream()) { + size += chunk.length; + assertFileSize(size, relative); + hash.update(chunk); + if (size > bufferLimit) chunks = null; + chunks?.push(chunk); + } + return { size, hash: hash.digest("hex"), bytes: chunks ? Buffer.concat(chunks) : null }; + } finally { await opened.handle.close(); } +} + +export async function readAgentFile(root: string, relative: string): Promise { + return (await inspectAgentFile(root, relative, MAX_AGENT_FILE_BYTES))?.bytes ?? null; +} + +/** Validate before staging and again after provider stop; never follow links or + * silently skip an unsupported file. Bounds apply to bytes, including binaries. */ +async function scanAgentFiles(root: string, enforceLimits = true) { + await assertInstructionPathSafe(root, ".path-check"); + let size = 0, count = 0; + const entries = new Set(); + let fullFile: string | undefined; + async function walk(dir: string) { + for (const item of await fs.readdir(path.join(root, dir), { withFileTypes: true })) { + const relative = agentFilePath(dir ? `${dir}/${item.name}` : item.name); + const stat = await fs.lstat(path.join(root, relative)); + count++; + if (enforceLimits) assertDirectorySize(size, count); + entries.add(relative); + if (stat.isDirectory()) await walk(relative); + else if (stat.isFile() && stat.nlink === 1) { + size += stat.size; + if (stat.size >= MAX_AGENT_FILE_BYTES) fullFile ??= relative; + if (enforceLimits) { + assertFileSize(stat.size, relative); + assertDirectorySize(size, count); + } + } else throw unprocessable("Agent directories support regular files and directories, without links or special files"); + } + } + await walk(""); + return { size, entries, storageWarning: storageWarning(size, count, fullFile) }; +} + +export async function inspectAgentDirectory(root: string, enforceLimits = true) { + const usage = await scanAgentFiles(root, enforceLimits); + return { snapshot: await captureDirectorySnapshot(root), storageWarning: usage.storageWarning }; +} +export async function snapshotAgentFiles(root: string, enforceLimits = true): Promise { + return (await inspectAgentDirectory(root, enforceLimits)).snapshot; +} + +export function agentFileStore(db: Db) { + async function locked(companyId: string, agentId: string, actor: AuthorizationActor, write: boolean, + fn: (tx: Tx, agent: Agent, root: string, bound: AuthorizationActor) => Promise) { + return db.transaction(async tx => { + const [agent] = await tx.select().from(agents).where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))).for("update"); + if (!agent) throw notFound("Agent not found"); + const bound = write ? await authorizeInstructionCommit(tx, actor, agent) : await authorizeInstructionRead(tx, actor, agent); + const root = await adoptAgentFiles(tx, agent); + return fn(tx, agent, root, bound); + }); + } + async function audit(tx: Tx, agent: Agent, actor: AuthorizationActor, details: Record) { + await tx.insert(activityLog).values({ companyId: agent.companyId, actorType: actor.type === "board" ? "user" : "agent", + actorId: (actor.type === "board" ? actor.userId : actor.agentId)!, agentId: actor.type === "agent" ? actor.agentId : null, + runId: actor.runId, responsibleUserId: actor.type === "board" ? actor.userId : actor.onBehalfOfUserId, + action: "agent.files_updated", entityType: "agent", entityId: agent.id, details }); + } + return { + locked, + download: async (companyId: string, agentId: string, relative: string, actor: AuthorizationActor) => { + const opened: { file: Awaited> } = { file: null }; + try { + const file = await locked(companyId, agentId, actor, false, async (_tx, _agent, root) => { + opened.file = await openAgentFile(root, instructionPath(relative)); + return opened.file; + }); + // Transfer outside the database lock. The open descriptor pins the file + // across concurrent atomic replacements; end bounds concurrent growth. + if (!file) return null; + if (file.size === 0) { await file.handle.close(); return { size: 0, stream: Readable.from([]) }; } + return { size: file.size, stream: file.handle.createReadStream({ end: file.size - 1 }) }; + } catch (error) { await opened.file?.handle.close(); throw error; } + }, + read: (companyId: string, agentId: string, relative: string, actor: AuthorizationActor) => + locked(companyId, agentId, actor, false, (_tx, _agent, root) => readAgentFile(root, instructionPath(relative))), + write: (input: { companyId: string; agentId: string; path: string; bytes: Buffer | null; baseHash: string | null }, actor: AuthorizationActor) => + locked(input.companyId, input.agentId, actor, true, async (tx, agent, root, bound) => { + const relative = agentFilePath(input.path); + if (input.bytes) assertFileSize(input.bytes.length, relative); + const previous = await inspectAgentFile(root, relative); + const currentHash = previous?.hash ?? null; + const incomingHash = input.bytes === null ? null : fileHash(input.bytes); + if (currentHash === incomingHash) return { contentHash: currentHash, changed: false }; + if (currentHash !== input.baseHash) throw conflict("This file changed since it was read. Reload before saving.", { code: "AGENT_FILE_CONFLICT", path: relative, currentHash }); + if (input.bytes === null && relative === deriveBundleState(agent).entryFile) throw unprocessable("The configured instruction entry cannot be deleted"); + if (input.bytes !== null) { + if (relative === deriveBundleState(agent).entryFile) instructionBytes(input.bytes); + // A quota check needs metadata only. Do not hash unrelated large + // files while holding the editor's row lock for a one-file save. + const snapshot = await scanAgentFiles(root); + const total = snapshot.size + input.bytes.length - (previous?.size ?? 0); + const newEntries = relative.split("/").map((_part, i, parts) => parts.slice(0, i + 1).join("/")).filter(name => !snapshot.entries.has(name)).length; + assertDirectorySize(total, snapshot.entries.size + newEntries); + } + if (input.bytes === null) await fs.unlink(await assertInstructionPathSafe(root, relative)); + else await materializeInstructionBytes(root, relative, input.bytes); + await audit(tx, agent, bound, { path: relative, contentHash: incomingHash }); + return { contentHash: incomingHash, changed: true }; + }), + apply: (input: { companyId: string; agentId: string; sourceDir: string; baseline: DirectorySnapshot }, actor: AuthorizationActor) => + locked(input.companyId, input.agentId, actor, true, async (tx, agent, root, bound) => { + const incoming = await snapshotAgentFiles(input.sourceDir); + const entry = await readInstructionBytes(input.sourceDir, deriveBundleState(agent).entryFile); + if (entry === null) throw unprocessable("The configured instruction entry cannot be deleted"); + instructionBytes(entry); + // Previously saved/imported bytes must remain readable, including when + // over quota. Enforce limits on the incoming and resulting tree so a + // run can delete files to recover instead of being locked out forever. + const current = await snapshotAgentFiles(root, false); + // Rebase only the run's changed paths onto the current tree. This makes + // same-file edits/deletions last-sync-wins while untouched files retain + // changes from other runs. Ancestors may need recreating after a writer + // replaced a directory with a file. + const entries = new Map(input.baseline.entries); + const changed = [...new Set([...input.baseline.entries, ...incoming.entries].map(([name]) => name))] + .filter(name => JSON.stringify(input.baseline.entries.get(name)) !== JSON.stringify(incoming.entries.get(name))); + for (const name of changed) { + const present = current.entries.get(name); + if (present) entries.set(name, present); else entries.delete(name); + if (incoming.entries.has(name)) { + for (let parent = path.posix.dirname(name); parent !== "."; parent = path.posix.dirname(parent)) { + if (current.entries.get(parent)?.kind === "dir") continue; + const before = current.entries.get(parent); + if (before) entries.set(parent, before); else entries.delete(parent); + } + } + } + const applyBaseline = { ...input.baseline, entries }; + const finalEntries = new Map([...current.entries].map(([name, value]) => [name, { value, root }])); + for (const name of changed) if (!incoming.entries.has(name)) finalEntries.delete(name); + for (const [name, value] of incoming.entries) { + if (JSON.stringify(input.baseline.entries.get(name)) === JSON.stringify(value)) continue; + if (value.kind !== "dir") for (const child of finalEntries.keys()) if (child.startsWith(`${name}/`)) finalEntries.delete(child); + finalEntries.set(name, { value, root: input.sourceDir }); + for (let parent = path.posix.dirname(name); parent !== "."; parent = path.posix.dirname(parent)) { + if (finalEntries.get(parent)?.value.kind !== "dir") finalEntries.set(parent, { value: { kind: "dir" }, root: input.sourceDir }); + } + } + // An unchanged file created by another run can keep a removed folder + // alive. Count those surviving parent directories in quota preflight. + for (const name of finalEntries.keys()) { + for (let parent = path.posix.dirname(name); parent !== "."; parent = path.posix.dirname(parent)) { + if (!finalEntries.has(parent)) finalEntries.set(parent, { value: { kind: "dir" }, root }); + } + } + let total = 0; + let fullFile: string | undefined; + for (const [name, item] of finalEntries) if (item.value.kind === "file") { + const size = (await fs.stat(path.join(item.root, name))).size; + assertFileSize(size, name); + if (size >= MAX_AGENT_FILE_BYTES) fullFile ??= name; + total += size; + } + assertDirectorySize(total, finalEntries.size); + await mergeDirectoryWithBaseline({ ...input, baseline: applyBaseline, targetDir: root }); + await audit(tx, agent, bound, { sourceRunId: actor.runId, contract: AGENT_FILES_CONTRACT }); + return { storageWarning: storageWarning(total, finalEntries.size, fullFile) }; + }), + }; +} diff --git a/server/src/services/agent-instruction-authorization.ts b/server/src/services/agent-instruction-authorization.ts new file mode 100644 index 00000000000..fe98d26995e --- /dev/null +++ b/server/src/services/agent-instruction-authorization.ts @@ -0,0 +1,195 @@ +import { and, eq, isNull } from "drizzle-orm"; +import { + agentApiKeys, + authUsers, + companyMemberships, + heartbeatRuns, + runIdentityContexts, + type Db, +} from "@paperclipai/db"; +import { forbidden } from "../errors.js"; +import { + authorizationService, + authorizationDeniedDetails, + type AuthorizationActor, +} from "./authorization.js"; +import { + agentInstructionsChangeTargetKey, + changeConsentGateService, +} from "./change-consent-gate.js"; + +type Connection = Db | Parameters[0]>[0]; + +/** Accept ONLY a server-authenticated actor, never request/tool JSON. Rebind run/key provenance on every call. */ +export async function resolveInstructionActor( + db: Connection, + actor: AuthorizationActor, +): Promise { + if (actor.type === "board") { + if (!actor.userId) + throw forbidden("Instruction edits require an authenticated user"); + // Discard caller membership caches so revoked access is observed. + return { + type: "board", + userId: actor.userId, + source: actor.source, + ignoreInstanceAdmin: actor.ignoreInstanceAdmin, + }; + } + if (actor.type !== "agent" || !actor.agentId || !actor.companyId) + throw forbidden("Instruction identity is missing"); + let responsibleUserId: string | null = null; + let keyScope = actor.keyScope; + if (actor.keyId) { + const [key] = await db + .select() + .from(agentApiKeys) + .where( + and( + eq(agentApiKeys.id, actor.keyId), + eq(agentApiKeys.companyId, actor.companyId), + eq(agentApiKeys.agentId, actor.agentId), + isNull(agentApiKeys.revokedAt), + ), + ); + if (!key) + throw forbidden("Instruction API key is missing or revoked", { + code: "INSTRUCTION_IDENTITY_INVALID", + }); + responsibleUserId = key.responsibleUserId; + keyScope = key.scopeConfig ?? undefined; + } + if (actor.runId) { + const [run] = await db + .select() + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.id, actor.runId), + eq(heartbeatRuns.companyId, actor.companyId), + eq(heartbeatRuns.agentId, actor.agentId), + ), + ); + if (!run) + throw forbidden("Instruction run identity does not match", { + code: "INSTRUCTION_IDENTITY_INVALID", + }); + if (actor.keyId && responsibleUserId !== run.responsibleUserId) + throw forbidden("Key and run responsible identity do not match", { + code: "INSTRUCTION_IDENTITY_INVALID", + }); + responsibleUserId = run.responsibleUserId; + if (run.activeIdentityContextId) { + const [context] = await db + .select() + .from(runIdentityContexts) + .where( + and( + eq(runIdentityContexts.id, run.activeIdentityContextId), + eq(runIdentityContexts.runId, run.id), + eq(runIdentityContexts.companyId, run.companyId), + eq(runIdentityContexts.status, "accepted"), + ), + ); + if (!context) + throw forbidden("Instruction run identity is unavailable", { + code: "INSTRUCTION_IDENTITY_INVALID", + }); + responsibleUserId = context.responsibleUserId; + } + } + if ( + !responsibleUserId || + (actor.onBehalfOfUserId && actor.onBehalfOfUserId !== responsibleUserId) + ) { + throw forbidden("A current server-bound responsible user is required", { + code: "INSTRUCTION_IDENTITY_INVALID", + }); + } + const [membership] = await db + .select({ userId: authUsers.id }) + .from(companyMemberships) + .innerJoin(authUsers, eq(authUsers.id, companyMemberships.principalId)) + .where( + and( + eq(companyMemberships.companyId, actor.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, responsibleUserId), + eq(companyMemberships.status, "active"), + ), + ); + if (!membership) + throw forbidden("The responsible user is unavailable in this company", { + code: "RESPONSIBLE_USER_UNAVAILABLE", + }); + return { + type: "agent", + agentId: actor.agentId, + companyId: actor.companyId, + runId: actor.runId, + keyId: actor.keyId, + keyScope, + source: actor.source, + onBehalfOfUserId: responsibleUserId, + }; +} + +/** Instruction content is narrower than general same-company agent visibility. */ +export async function authorizeInstructionRead( + db: Connection, + actor: AuthorizationActor, + target: { companyId: string; id: string }, +) { + const bound = await resolveInstructionActor(db, actor); + const access = authorizationService(db); + const resource = { type: "agent" as const, companyId: target.companyId, agentId: target.id }; + const peer = bound.type === "agent" && bound.agentId !== target.id; + const decision = await access.decide({ + actor: bound, + action: peer ? "agent_config:read" : "agent:read", + resource, + scope: { targetAgentId: target.id }, + }); + if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); + return bound; +} + +/** Current target edit access + agent containment + protected-change consent, shared by all writers. */ +export async function authorizeInstructionCommit( + db: Connection, + actor: AuthorizationActor, + target: { companyId: string; id: string }, +) { + const bound = await resolveInstructionActor(db, actor); + const access = authorizationService(db); + const input = { + actor: bound, + action: "agent_instructions:update" as const, + resource: { + type: "agent" as const, + companyId: target.companyId, + agentId: target.id, + }, + scope: { targetAgentId: target.id, requiresChangeGrant: true }, + }; + let decision = await access.decide(input); + if ( + !decision.allowed && + decision.reason === "deny_missing_consent" && + bound.type === "agent" + ) { + await changeConsentGateService(db).assertConsented({ + companyId: target.companyId, + actorAgentId: bound.agentId, + actorRunId: bound.runId, + targetKeys: [agentInstructionsChangeTargetKey(target.id)], + }); + decision = await access.decide({ + ...input, + scope: { ...input.scope, consentedChange: true }, + }); + } + if (!decision.allowed) + throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); + return bound; +} diff --git a/server/src/services/agent-instruction-files.ts b/server/src/services/agent-instruction-files.ts new file mode 100644 index 00000000000..6f3db874aca --- /dev/null +++ b/server/src/services/agent-instruction-files.ts @@ -0,0 +1,178 @@ +import fs from "node:fs/promises"; +import { constants } from "node:fs"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { unprocessable } from "../errors.js"; + +export const MAX_INSTRUCTION_BYTES = 1024 * 1024; +export function instructionPath(value: string): string { + if ( + !value || + value.length > 512 || + value.includes("\0") || + value.includes("\\") || + path.posix.isAbsolute(value) || + /^[a-z]:/i.test(value) || + value.split("/").some((part) => !part || part === "." || part === "..") + ) { + throw unprocessable("Use a relative instructions path without traversal", { + code: "INSTRUCTION_PATH_INVALID", + }); + } + return value; +} + +export function instructionBytes(content: string | Uint8Array): Buffer { + const byteLength = + typeof content === "string" + ? Buffer.byteLength(content, "utf8") + : content.byteLength; + if (byteLength > MAX_INSTRUCTION_BYTES) { + throw unprocessable("Instructions exceed the 1 MiB limit", { + code: "INSTRUCTION_CONTENT_INVALID", + }); + } + const bytes = + typeof content === "string" + ? Buffer.from(content, "utf8") + : Buffer.from(content); + try { + // Preserve BOM; reject invalid UTF-8 and unpaired JS surrogates instead of replacing bytes. + const decoded = new TextDecoder("utf-8", { + fatal: true, + ignoreBOM: true, + }).decode(bytes); + if (typeof content === "string" && decoded !== content) + throw new Error("invalid Unicode"); + } catch { + throw unprocessable("Instructions must be valid UTF-8", { + code: "INSTRUCTION_CONTENT_INVALID", + }); + } + return bytes; +} + +export async function assertInstructionPathSafe( + root: string, + entryFile: string, +) { + instructionPath(entryFile); + let absolute = path.resolve(root, entryFile); + // macOS exposes its system temporary directories through root-owned aliases. + // Resolve only those fixed OS aliases, never a link in operator/agent storage. + if (process.platform === "darwin") { + const alias = absolute.split(path.sep)[1]; + if (alias === "var" || alias === "tmp" || alias === "etc") { + const systemPath = `/${alias}`; + const stat = await fs.lstat(systemPath); + if (stat.isSymbolicLink() && stat.uid === 0 && await fs.realpath(systemPath) === `/private/${alias}`) { + absolute = `/private${absolute}`; + } + } + } + let current = path.parse(absolute).root; + const parts = absolute.slice(current.length).split(path.sep); + for (const [i, part] of parts.entries()) { + current = path.join(current, part); + const stat = await fs + .lstat(current) + .catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (!stat) continue; + if ( + stat.isSymbolicLink() || + (i < parts.length - 1 ? !stat.isDirectory() : !stat.isFile()) + ) { + throw unprocessable( + "Instructions require regular files and directories without symlinks", + { code: "INSTRUCTION_PATH_INVALID" }, + ); + } + } + return absolute; +} + +export async function readInstructionBytes( + root: string, + entryFile: string, +): Promise { + const absolute = await assertInstructionPathSafe(root, entryFile); + const file = await fs + .open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW) + .catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (!file) return null; + try { + const stat = await file.stat(); + if (!stat.isFile() || stat.size > MAX_INSTRUCTION_BYTES) { + throw unprocessable( + "Instructions must be a regular UTF-8 file of at most 1 MiB", + { code: "INSTRUCTION_CONTENT_INVALID" }, + ); + } + const buffer = Buffer.alloc(MAX_INSTRUCTION_BYTES + 1); + let length = 0; + while (length < buffer.length) { + const result = await file.read( + buffer, + length, + buffer.length - length, + length, + ); + if (result.bytesRead === 0) break; + length += result.bytesRead; + } + return instructionBytes(buffer.subarray(0, length)); + } finally { + await file.close(); + } +} + +/** Call while holding the canonical agent lock. Never writes through a symlink. */ +export async function materializeInstructionBytes( + root: string, + entryFile: string, + bytes: Buffer, +) { + const absolute = await assertInstructionPathSafe(root, entryFile); + await fs.mkdir(path.dirname(absolute), { recursive: true }); + await assertInstructionPathSafe(root, entryFile); + const temporary = path.join( + path.dirname(absolute), + `.instruction-${randomUUID()}.tmp`, + ); + try { + const file = await fs.open(temporary, "wx", 0o600); + try { + await file.writeFile(bytes); + await file.sync(); + } finally { + await file.close(); + } + await assertInstructionPathSafe(root, entryFile); + await fs.rename(temporary, absolute); + } finally { + await fs.rm(temporary, { force: true }); + } +} + +// Keep the reserved runtime directory out of Git using a self-ignoring file +// inside that directory. Never write to a gitdir or exclude path derived from +// repository-controlled metadata; legitimate worktrees can keep external gitdirs. +export const instructionGitExcludeProgram = String.raw` +const fs=require('node:fs'),path=require('node:path'),crypto=require('node:crypto'); +const cwd=fs.realpathSync(process.argv[1]); +const root=path.join(cwd,'.paperclip-runtime'),filename=path.join(root,'.gitignore'); +const stat=(name)=>{try{return fs.lstatSync(name)}catch(e){if(e.code==='ENOENT')return null;throw e}}; +const directory=stat(root); +if(directory && (directory.isSymbolicLink() || !directory.isDirectory()))throw Error('Unsafe runtime exclusion directory'); +if(!directory)fs.mkdirSync(root,{mode:0o700}); +const existing=stat(filename); +if(existing && (existing.isSymbolicLink() || !existing.isFile()))throw Error('Unsafe runtime exclusion file'); +const temporary=path.join(root,'.ignore-'+crypto.randomUUID()+'.tmp'); +try{fs.writeFileSync(temporary,'*\n',{flag:'wx',mode:0o600});fs.renameSync(temporary,filename)}finally{fs.rmSync(temporary,{force:true})} +`; diff --git a/server/src/services/agent-instruction-revisions.ts b/server/src/services/agent-instruction-revisions.ts new file mode 100644 index 00000000000..6c07b0d08f8 --- /dev/null +++ b/server/src/services/agent-instruction-revisions.ts @@ -0,0 +1,429 @@ +import { createHash } from "node:crypto"; +import { adoptAgentFiles, agentFileToken } from "./agent-file-store.js"; +import { and, desc, eq, getTableColumns, lt, or, sql } from "drizzle-orm"; +import { + activityLog, + agents, + plugins, + pluginManagedResources, + agentInstructionHeads as heads, + agentInstructionRevisions as revisions, + type Db, +} from "@paperclipai/db"; +import type { + AgentInstructionCommitReceipt, + AgentInstructionDiff, + AgentInstructionHistory, + AgentInstructionSnapshot, + AgentInstructionSource, +} from "@paperclipai/shared"; +import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; +import { + agentInstructionsBundleMode, + deriveBundleState, + resolveManagedInstructionsRoot, +} from "./agent-instructions.js"; +import { + instructionBytes, + instructionPath, + assertInstructionPathSafe, + readInstructionBytes, + materializeInstructionBytes, +} from "./agent-instruction-files.js"; +import { + authorizeInstructionCommit, + authorizeInstructionRead, +} from "./agent-instruction-authorization.js"; +import type { AuthorizationActor } from "./authorization.js"; + +type Tx = Parameters[0]>[0]; +type Revision = typeof revisions.$inferSelect; +type PluginResetActor = { type: "plugin"; pluginId: string; pluginKey: string; agentKey: string }; +type RevisionActor = AuthorizationActor | PluginResetActor; +const { contentBase64: _contentColumn, ...revisionMetadataColumns } = + getTableColumns(revisions); +export type InstructionTarget = { companyId: string; agentId: string }; +export type InstructionCommitInput = InstructionTarget & { + entryFile: string; + content: string | Uint8Array; + baseRevisionId: string | null; + source: Exclude; +}; +function metadata(row: Omit) { + const { createdAt, source, ...fields } = row; + return { + ...fields, + source: source as AgentInstructionSource, + createdAt: createdAt.toISOString(), + }; +} +function snapshot(row: Revision): AgentInstructionSnapshot { + const { contentBase64, ...fields } = row; + return { + revision: metadata(fields), + content: Buffer.from(contentBase64, "base64").toString("utf8"), + }; +} +function owner(target: InstructionTarget, entryFile: string) { + return and( + eq(revisions.companyId, target.companyId), + eq(revisions.agentId, target.agentId), + eq(revisions.entryFile, entryFile), + ); +} +function headOwner(target: InstructionTarget, entryFile: string) { + return and( + eq(heads.companyId, target.companyId), + eq(heads.agentId, target.agentId), + eq(heads.entryFile, entryFile), + ); +} + +export function agentInstructionRevisionService(db: Db) { + async function lockTarget( + tx: Tx, + target: InstructionTarget, + actor?: AuthorizationActor, + ) { + if (actor?.type === "agent" && actor.companyId !== target.companyId) + throw notFound("Agent not found"); + const [agent] = await tx + .select() + .from(agents) + .where( + and( + eq(agents.id, target.agentId), + eq(agents.companyId, target.companyId), + ), + ) + .for("update"); + if (!agent) throw notFound("Agent not found"); + if (agentInstructionsBundleMode(agent) === "external") { + throw unprocessable( + "External host instructions do not support revision write-back. Ask an instance administrator to migrate this bundle to managed storage.", + { code: "INSTRUCTION_MANAGED_BUNDLE_REQUIRED" }, + ); + } + const state = deriveBundleState(agent); + const entryFile = instructionPath(state.entryFile); + return { agent, entryFile, root: resolveManagedInstructionsRoot(agent) }; + } + async function authorizeRead( + tx: Tx, + actor: AuthorizationActor, + target: InstructionTarget, + ) { + return authorizeInstructionRead(tx, actor, { companyId: target.companyId, id: target.agentId }); + } + // Trusted host-only reset authority. No HTTP or agent tool accepts this actor. + async function authorizePluginReset(tx: Tx, actor: PluginResetActor, target: InstructionTarget, agent: typeof agents.$inferSelect) { + const [plugin] = await tx.select().from(plugins).where(and(eq(plugins.id, actor.pluginId), eq(plugins.pluginKey, actor.pluginKey))); + const marker = agent.metadata?.paperclipManagedResource as Record | undefined; + const [binding] = await tx.select().from(pluginManagedResources).where(and( + eq(pluginManagedResources.pluginId, actor.pluginId), eq(pluginManagedResources.pluginKey, actor.pluginKey), + eq(pluginManagedResources.companyId, target.companyId), eq(pluginManagedResources.resourceKind, "agent"), + eq(pluginManagedResources.resourceKey, actor.agentKey), eq(pluginManagedResources.resourceId, target.agentId), + )); + if (!plugin || plugin.status !== "ready" || !plugin.manifestJson.capabilities.includes("agents.managed") + || !plugin.manifestJson.agents?.some((declaration) => declaration.agentKey === actor.agentKey) + || !binding || marker?.pluginId !== actor.pluginId || marker.pluginKey !== actor.pluginKey + || marker.resourceKind !== "agent" || marker.resourceKey !== actor.agentKey) { + throw forbidden("Plugin reset is limited to its declared, bound managed agent"); + } + const declared = plugin.manifestJson.agents!.find((declaration) => declaration.agentKey === actor.agentKey)!; + return { ...actor, declaredEntryFile: instructionPath(declared.instructions?.entryFile ?? "AGENTS.md") }; + } + async function readForPluginReset(target: InstructionTarget, actor: PluginResetActor) { + return db.transaction(async (tx) => { + const state = await lockTarget(tx, target); + const bound = await authorizePluginReset(tx, actor, target, state.agent); + // Preserve the formerly configured entry before an explicit stock reset. + await currentFile(tx, target, state, bound); + const row = await currentFile(tx, target, { ...state, entryFile: bound.declaredEntryFile }, bound); + return { snapshot: row ? snapshot(row) : null, configuredEntryFile: state.entryFile }; + }); + } + async function head(tx: Tx, target: InstructionTarget, entryFile: string) { + const [result] = await tx + .select({ revision: revisions }) + .from(heads) + .innerJoin(revisions, eq(revisions.id, heads.revisionId)) + .where(headOwner(target, entryFile)); + return result?.revision ?? null; + } + // The deployed revision tables are read-only upgrade input. Current bytes + // live in the agent directory; old UUID clients receive a content ETag. + async function currentFile(tx: Tx, target: InstructionTarget, state: Awaited>, actor?: RevisionActor) { + await adoptAgentFiles(tx, state.agent); + const bytes = await readInstructionBytes(state.root, state.entryFile); + if (bytes === null) return null; + return currentRow(target, state.entryFile, bytes, actor); + } + function currentRow(target: InstructionTarget, entryFile: string, bytes: Buffer, actor?: RevisionActor): Revision { + return { ...target, id: agentFileToken(bytes), entryFile, contentBase64: bytes.toString("base64"), + contentHash: createHash("sha256").update(bytes).digest("hex"), byteLength: bytes.length, + parentRevisionId: null, baseRevisionId: null, restoredFromRevisionId: null, source: "api", + actorAgentId: actor?.type === "agent" ? actor.agentId ?? null : null, + actorUserId: actor?.type === "board" ? actor.userId ?? null : null, + responsibleUserId: actor?.type === "board" ? actor.userId ?? null : actor?.type === "agent" ? actor.onBehalfOfUserId ?? null : null, + sourceRunId: actor && actor.type !== "plugin" ? actor.runId ?? null : null, createdAt: new Date(0) }; + } + async function getRevision( + tx: Tx, + target: InstructionTarget, + entryFile: string, + id: string, + ) { + const [row] = await tx + .select() + .from(revisions) + .where(and(owner(target, entryFile), eq(revisions.id, id))); + if (!row) throw notFound("Instruction revision not found"); + return row; + } + async function readCurrent( + target: InstructionTarget, + actor: AuthorizationActor, + ): Promise { + return db.transaction(async (tx) => { + const state = await lockTarget(tx, target, actor); + const bound = await authorizeRead(tx, actor, target); + const row = await currentFile(tx, target, state, bound); + return row ? snapshot(row) : null; + }); + } + /** Trusted orchestration read for the already authorized run target. This + * never seeds or writes, and is not exposed through HTTP or model tools. */ + async function readCommittedForRuntime(target: InstructionTarget): Promise { + return db.transaction(async (tx) => { + const state = await lockTarget(tx, target); + const current = await currentFile(tx, target, state); + return current ? snapshot(current) : null; + }); + } + /** Rebuild disk from the current committed head under the same lock as commits. Safe after restart. */ + async function materializeCurrent(target: InstructionTarget): Promise { + await db.transaction(async (tx) => { + const state = await lockTarget(tx, target); + await adoptAgentFiles(tx, state.agent); + }); + } + async function commitInternal( + input: + | InstructionCommitInput + | (Omit & { + restoreRevisionId: string; + }), + actor: RevisionActor, + configuredEntryFile?: string, + ): Promise { + instructionPath(input.entryFile); + const bytes = "content" in input ? instructionBytes(input.content) : null; + const result = await db.transaction(async (tx) => { + const state = await lockTarget(tx, input, actor.type === "plugin" ? undefined : actor); + const bound = actor.type === "plugin" + ? await authorizePluginReset(tx, actor, input, state.agent) + : await authorizeInstructionCommit(tx, actor, state.agent); + if (bound.type === "plugin") { + if (input.entryFile !== bound.declaredEntryFile) throw forbidden("Plugin reset must restore its declared entry file"); + if (configuredEntryFile !== state.entryFile) throw conflict("Configured instruction entry changed; read the current entry and retry", { code: "INSTRUCTION_ENTRY_CHANGED", entryFile: state.entryFile }); + state.entryFile = input.entryFile; + } + if (state.entryFile !== input.entryFile) + throw conflict( + "Configured instruction entry changed; read the current entry and retry", + { code: "INSTRUCTION_ENTRY_CHANGED", entryFile: state.entryFile }, + ); + await assertInstructionPathSafe(state.root, state.entryFile); + const current = await currentFile(tx, input, state, bound); + const restored = + "restoreRevisionId" in input + ? await getRevision( + tx, + input, + input.entryFile, + input.restoreRevisionId, + ) + : null; + const candidate = bytes ?? Buffer.from(restored!.contentBase64, "base64"); + const bindEntry = async () => { + if ( + !state.agent.adapterConfig.instructionsRootPath || + !state.agent.adapterConfig.instructionsBundleMode || + (bound.type === "plugin" && configuredEntryFile !== input.entryFile) + ) { + await tx + .update(agents) + .set({ + adapterConfig: { + ...state.agent.adapterConfig, + instructionsBundleMode: "managed", + instructionsRootPath: state.root, + instructionsEntryFile: state.entryFile, + instructionsFilePath: `${state.root}/${state.entryFile}`, + }, + updatedAt: new Date(), + }) + .where(eq(agents.id, state.agent.id)); + } + }; + // An exact replay can safely return the durable receipt even after its base + // advanced. Restore also deduplicates identical content, preserving history. + if (current && current.contentBase64 === candidate.toString("base64")) { + await bindEntry(); + return { row: current, changed: false }; + } + // A pre-upgrade run can still pin a historical UUID. Compare its bytes, + // never let the old head project over a newer directory. + const legacyBase = input.baseRevisionId && current?.id !== input.baseRevisionId + ? await tx.select().from(revisions).where(and(owner(input, input.entryFile), eq(revisions.id, input.baseRevisionId))).then(rows => rows[0]) + : null; + if ((current?.id ?? null) !== input.baseRevisionId && !(legacyBase && legacyBase.contentHash === current?.contentHash)) { + throw conflict( + "Instructions changed since the base revision. Read the current entry before saving.", + { + code: "INSTRUCTION_REVISION_CONFLICT", + baseRevisionId: input.baseRevisionId, + currentRevisionId: current?.id ?? null, + }, + ); + } + await materializeInstructionBytes(state.root, state.entryFile, candidate); + const row = currentRow(input, input.entryFile, candidate, bound); + await tx.insert(activityLog).values({ companyId: input.companyId, + actorType: bound.type === "plugin" ? "plugin" : bound.type === "board" ? "user" : "agent", + actorId: (bound.type === "plugin" ? bound.pluginId : bound.type === "board" ? bound.userId : bound.agentId)!, + agentId: bound.type === "agent" ? bound.agentId : null, runId: row.sourceRunId, + responsibleUserId: row.responsibleUserId, action: "agent.files_updated", entityType: "agent", entityId: input.agentId, + details: { path: input.entryFile, contentHash: row.contentHash, source: restored ? "legacy_restore" : (input as InstructionCommitInput).source } }); + await bindEntry(); + return { row, changed: true }; + }); + return { ...snapshot(result.row), changed: result.changed, materialization: "current" }; + } + + async function history( + target: InstructionTarget & { + entryFile: string; + cursor?: string; + limit?: number; + }, + actor: AuthorizationActor, + ): Promise { + const limit = Math.max(1, Math.min(100, target.limit ?? 50)); + return db.transaction(async (tx) => { + const state = await lockTarget(tx, target, actor); + const bound = await authorizeRead(tx, actor, target); + await currentFile(tx, target, state, bound); + instructionPath(target.entryFile); + const cursor = target.cursor + ? await getRevision(tx, target, target.entryFile, target.cursor) + : null; + // Keep PostgreSQL timestamp precision; JS Date truncates microseconds and + // would skip revisions in the cursor's fractional millisecond. + const cursorTime = cursor + ? sql`(select created_at from agent_instruction_revisions where id = ${cursor.id})` + : null; + const rows = await tx + .select(revisionMetadataColumns) + .from(revisions) + .where( + and( + owner(target, target.entryFile), + cursor && cursorTime + ? or( + lt(revisions.createdAt, cursorTime), + and( + eq(revisions.createdAt, cursorTime), + lt(revisions.id, cursor.id), + ), + ) + : undefined, + ), + ) + .orderBy(desc(revisions.createdAt), desc(revisions.id)) + .limit(limit + 1); + return { + revisions: rows.slice(0, limit).map(metadata), + nextCursor: rows.length > limit ? rows[limit - 1]!.id : null, + }; + }); + } + async function readRevision( + target: InstructionTarget & { entryFile: string; revisionId: string }, + actor: AuthorizationActor, + ) { + return db.transaction(async (tx) => { + await lockTarget(tx, target, actor); + await authorizeRead(tx, actor, target); + return snapshot( + await getRevision( + tx, + target, + instructionPath(target.entryFile), + target.revisionId, + ), + ); + }); + } + async function diff( + target: InstructionTarget & { + entryFile: string; + fromRevisionId: string; + toRevisionId: string; + }, + actor: AuthorizationActor, + ): Promise { + const from = await readRevision( + { ...target, revisionId: target.fromRevisionId }, + actor, + ); + const to = await readRevision( + { ...target, revisionId: target.toRevisionId }, + actor, + ); + // Linear, bounded exact replacement diff. Code points keep surrogate pairs intact. + const a = Array.from(from.content), + b = Array.from(to.content); + let start = 0, + end = 0; + while (start < a.length && start < b.length && a[start] === b[start]) + start++; + while ( + end < a.length - start && + end < b.length - start && + a[a.length - end - 1] === b[b.length - end - 1] + ) + end++; + return { + from, + to, + prefix: a.slice(0, start).join(""), + removed: a.slice(start, a.length - end).join(""), + added: b.slice(start, b.length - end).join(""), + suffix: end ? a.slice(-end).join("") : "", + }; + } + return { + readCommittedForRuntime, + readCurrent, + readForPluginReset, + commitPluginReset: (input: Omit & { configuredEntryFile: string }, actor: PluginResetActor) => + commitInternal({ ...input, source: "api" }, actor, input.configuredEntryFile), + readRevision, + history, + diff, + materializeCurrent, + commit: (input: InstructionCommitInput, actor: AuthorizationActor) => + commitInternal(input, actor), + restore: ( + input: InstructionTarget & { + entryFile: string; + baseRevisionId: string; + revisionId: string; + }, + actor: AuthorizationActor, + ) => + commitInternal({ ...input, restoreRevisionId: input.revisionId }, actor), + }; +} diff --git a/server/src/services/agent-instruction-working-copies.ts b/server/src/services/agent-instruction-working-copies.ts new file mode 100644 index 00000000000..7611dcb614f --- /dev/null +++ b/server/src/services/agent-instruction-working-copies.ts @@ -0,0 +1,356 @@ +import { agentDirectoryWorkingCopyService, isAgentDirectoryCopy } from "./agent-directory-working-copies.js"; +import { createHash } from "node:crypto"; +import { execFile as execFileCallback } from "node:child_process"; +import { promisify } from "node:util"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { and, asc, desc, eq, inArray, lte, or, isNull, isNotNull, sql } from "drizzle-orm"; +import { agents, heartbeatRuns, agentInstructionWorkingCopies as copies, type Db } from "@paperclipai/db"; +import { + prepareAdapterExecutionTargetRuntime, + runAdapterExecutionTargetShellCommand, + type AdapterExecutionTarget, +} from "@paperclipai/adapter-utils/execution-target"; +import { conflict, notFound } from "../errors.js"; +import { agentInstructionsService, agentInstructionsBundleMode } from "./agent-instructions.js"; +import { agentInstructionRevisionService } from "./agent-instruction-revisions.js"; +import { resolveInstructionActor } from "./agent-instruction-authorization.js"; +import { assertInstructionPathSafe, instructionBytes, instructionPath, materializeInstructionBytes, MAX_INSTRUCTION_BYTES, readInstructionBytes, instructionGitExcludeProgram } from "./agent-instruction-files.js"; +import { hasNativeLocalProcessStop } from "./native-local-process-stop.js"; +import { remoteExecutionHasStopped } from "./remote-execution-termination.js"; +import type { AuthorizationActor } from "./authorization.js"; +import type { EnvironmentRuntimeService } from "./environment-runtime.js"; + +const execFile = promisify(execFileCallback); +type Copy = typeof copies.$inferSelect; +const hash = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex"); +const quote = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`; +const completed = new Set(["saved", "unchanged", "resolved"]); +const MAX_COLLECTION_ATTEMPTS = 3; +// Only orchestration can register a live target; tools and HTTP callers cannot +// supply one. These handles are optional: durable captured bytes survive restart. +const liveTargets = new Map(); +const targetKey = (companyId: string, runId: string) => `${companyId}:${runId}`; + +export function instructionWorkingCopyGuidance(copy: Pick) { + if (isAgentDirectoryCopy(copy)) return `Your persistent agent directory is ${copy.executionRoot} (AGENT_HOME). Your instruction entry is ${copy.executionRoot}/${copy.entryFile}. Read and write your own files and subfolders there. This directory belongs to this agent across tasks and sessions; task files belong in the task working directory. Paperclip restores this directory before execution and saves changes after the provider stops. Regular files, including binary files, persist; symlinks and special files are unsupported. Check the agent-files save receipt before claiming persistence; Only files you change or delete are synchronized. If another run changes the same file, the last completed synchronization wins. Temporary copies are removed after synchronization; there is no per-run file history. Storage allows 256 MiB per file, 2 GiB total, and 100,000 entries; the instruction entry must remain UTF-8 and at most 1 MiB. Reaching a storage limit never prevents this or future tasks from running. Remove or shrink files to free space; changes that exceed the limits will not be saved.${typeof copy.receipt?.storageWarning === "string" ? `\n\n${copy.receipt.storageWarning}` : ""}`; + return `Your editable agent instruction file is ${copy.executionRoot}/${copy.entryFile}. 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. Use read_agent_instructions, update_agent_instructions, get_agent_instruction_history, and restore_agent_instructions for immediate saves and history. Read first and pin the returned revision. Preserve conflicts; never silently retry against a newer head. Repository instructions, skills, and the loaded prompt are separate and are not collected.`; +} + +/** Remote reads use the registered entry only; never scan for files called AGENTS.md. */ +export function instructionCollectionScript(root: string, entry: string) { + instructionPath(entry); + return `node -e ${quote([ + "const fs=require('node:fs'),path=require('node:path')", + "const root=process.argv[1],entry=process.argv[2],max=Number(process.argv[3])", + "const absolute=path.resolve(root,entry),parts=absolute.split('/').filter(Boolean)", + "let current='/'", + "for(let i=0;imax)throw Error('INSTRUCTION_CONTENT_INVALID');const bytes=Buffer.alloc(max+1);let n=0;while(nmax)throw Error('INSTRUCTION_CONTENT_INVALID');process.stdout.write(JSON.stringify({contentBase64:bytes.subarray(0,n).toString('base64')}))}finally{fs.closeSync(fd)}", + ].join(";"))} ${quote(root)} ${quote(entry)} ${MAX_INSTRUCTION_BYTES}`; +} + + +export function agentInstructionWorkingCopyService(db: Db, options: { environmentRuntime?: EnvironmentRuntimeService } = {}) { + const revisions = agentInstructionRevisionService(db); + const scope = (companyId: string, runId: string) => and(eq(copies.companyId, companyId), eq(copies.runId, runId)); + async function get(companyId: string, runId: string) { + const [row] = await db.select().from(copies).where(scope(companyId, runId)); + return row ?? null; + } + async function patch(row: Copy, values: Partial) { + const [updated] = await db.update(copies).set({ ...values, updatedAt: new Date() }).where(and( + scope(row.companyId, row.runId), eq(copies.state, row.state), eq(copies.attempts, row.attempts), eq(copies.baseHash, row.baseHash), + )).returning(); + // A late cleanup must not overwrite an explicit resolution or a newer + // baseline. Canonical content has its own independent head CAS. + return updated ?? (await get(row.companyId, row.runId))!; + } + function actorFor(row: Copy): AuthorizationActor { + return { type: "agent", companyId: row.companyId, agentId: row.agentId, runId: row.runId, onBehalfOfUserId: row.responsibleUserId }; + } + const directories = agentDirectoryWorkingCopyService(db, get, patch, options.environmentRuntime); + async function prepare(input: { companyId: string; agentId: string; runId: string; target?: AdapterExecutionTarget | null; cwd: string; legacy?: boolean }) { + const existing = await get(input.companyId, input.runId); + if (isAgentDirectoryCopy(existing) || (!existing && !input.legacy)) return directories.prepare(input); + let refreshStoppedCopy = false; + const workspace = await fs.realpath(input.cwd); + const expectedLocalRoot = path.join(workspace, ".paperclip-runtime", `instruction-edits-${input.runId}`, "instructions"); + if (existing && existing.localRoot !== expectedLocalRoot) throw conflict("The registered instruction copy belongs to a different run workspace"); + const location = input.target?.kind === "remote" ? `remote:${input.target.environmentId ?? ""}` : "local"; + if (existing && (existing.agentId !== input.agentId || existing.location !== location)) { + throw conflict("The registered instruction copy belongs to a different run environment"); + } + // Preserve in-flight bytes and their CAS baseline. A completed, stopped copy + // can start a fresh lifecycle only after its recorded bytes are accounted for. + if (existing && existing.state !== "preparing") { + if (["conflict", "pending_commit", "unavailable"].includes(existing.state)) { + throw conflict("Resolve this run's preserved instruction candidate before editing its working copy again"); + } + if (input.target) liveTargets.set(targetKey(input.companyId, input.runId), input.target); + if (completed.has(existing.state) && existing.processStoppedAt) { + const privateBytes = await readCandidate(existing, input.target); + // Refresh only bytes whose previous lifecycle is complete. A changed + // private file must keep its old CAS fence; never silently rebase edits. + refreshStoppedCopy = privateBytes === null || hash(privateBytes) === (existing.candidateHash ?? existing.baseHash); + if (!refreshStoppedCopy && privateBytes !== null) { + // A failed staging attempt can already have replaced the file before + // the completed row advances. Exact current canonical bytes are also + // accounted for; any other private edit still keeps its old fence. + const current = await revisions.readCommittedForRuntime({ companyId: input.companyId, agentId: input.agentId }); + refreshStoppedCopy = current?.revision.entryFile === existing.entryFile + && current.revision.contentHash === hash(privateBytes); + } + } + if (!refreshStoppedCopy) { + if (completed.has(existing.state) || existing.state === "unchanged_turn") { + // unchanged_turn is a live warm-owner observation, not stop proof. + return patch(existing, { state: "prepared", candidateBase64: null, candidateHash: null, + receipt: null, processStoppedAt: null, attempts: 0, nextAttemptAt: null }); + } + return existing; + } + } + const [agent] = await db.select().from(agents).where(and(eq(agents.id, input.agentId), eq(agents.companyId, input.companyId))); + if (!agent) throw notFound("Agent not found"); + if (agentInstructionsBundleMode(agent) !== "managed") return null; + const bound = await resolveInstructionActor(db, { type: "agent", companyId: input.companyId, agentId: input.agentId, runId: input.runId }); + const baseline = existing?.baseRevisionId && !refreshStoppedCopy + ? await revisions.readRevision({ companyId: input.companyId, agentId: input.agentId, entryFile: existing.entryFile, revisionId: existing.baseRevisionId }, bound).catch(async (error) => { + if ((error as { status?: number }).status !== 404) throw error; + const current = await revisions.readCurrent({ companyId: input.companyId, agentId: input.agentId }, bound); + if (current?.revision.id !== existing.baseRevisionId) throw conflict("The instruction baseline changed during preparation"); + return current; + }) + : await revisions.readCurrent({ companyId: input.companyId, agentId: input.agentId }, bound); + if (!baseline) return null; + const exported = await agentInstructionsService().exportFiles(agent, { rejectSymlinks: true }); + const entryFile = instructionPath(baseline?.revision.entryFile ?? exported.entryFile); + const content = instructionBytes(baseline?.content ?? exported.files[entryFile] ?? ""); + const localRoot = existing?.localRoot ?? expectedLocalRoot; + const target = input.target?.kind === "remote" ? input.target : null; + const executionRoot = target + ? path.posix.join(target.remoteCwd, ".paperclip-runtime", `instruction-edits-${input.runId}`, "instructions") + : localRoot; + if (!existing) { + await db.insert(copies).values({ + runId: input.runId, companyId: input.companyId, agentId: input.agentId, + responsibleUserId: bound.onBehalfOfUserId!, entryFile, + baseRevisionId: baseline?.revision.id ?? null, baseHash: hash(content), + localRoot, executionRoot, location, state: "preparing", + }); + } + const row = (await get(input.companyId, input.runId))!; + await assertInstructionPathSafe(localRoot, entryFile); + await execFile(process.execPath, ["-e", instructionGitExcludeProgram, workspace], { timeout: 15_000 }); + await fs.mkdir(localRoot, { recursive: true, mode: 0o700 }); + for (const [name, text] of Object.entries({ ...exported.files, [entryFile]: content.toString("utf8") })) { + const relative = instructionPath(name); + const filename = await assertInstructionPathSafe(localRoot, relative); + await fs.mkdir(path.dirname(filename), { recursive: true, mode: 0o700 }); + // Atomic replacement also allows interrupted preparation to restage an + // already-read-only sibling without writing through its existing inode. + await materializeInstructionBytes(localRoot, relative, instructionBytes(text)); + await fs.chmod(filename, name === entryFile ? 0o600 : 0o400); + } + if (target) { + const staged = await prepareAdapterExecutionTargetRuntime({ + target, runId: input.runId, adapterKey: `instruction-edits-${input.runId}`, + workspaceLocalDir: input.cwd, syncWorkspace: false, + assets: [{ key: "instructions", localDir: localRoot, followSymlinks: false }], + }); + if (staged.assetDirs.instructions !== executionRoot) throw new Error("Instruction copy staging path changed"); + const excluded = await runAdapterExecutionTargetShellCommand(input.runId, target, + `node -e ${quote(instructionGitExcludeProgram)} ${quote(target.remoteCwd)}`, { cwd: target.remoteCwd, env: {}, timeoutSec: 15 }); + if (excluded.exitCode !== 0 || excluded.timedOut) throw new Error("Could not exclude private instructions from Git staging"); + } + // A refresh does not supersede the completed turn until every local and + // remote staging step succeeds. Keep its receipt and stop evidence intact + // if preparation throws or the controller stops midway through staging. + const prepared = await patch(row, { state: "prepared", ...(refreshStoppedCopy ? { + entryFile, baseRevisionId: baseline.revision.id, baseHash: hash(content), + candidateBase64: null, candidateHash: null, receipt: null, processStoppedAt: null, + attempts: 0, nextAttemptAt: null, errorCode: null, errorMessage: null, + } : {}) }); + if (input.target) liveTargets.set(targetKey(input.companyId, input.runId), input.target); + return prepared; + } + + async function readCandidate(row: Copy, target?: AdapterExecutionTarget | null) { + if (row.location === "local") return readInstructionBytes(row.localRoot, row.entryFile); + if (target?.kind !== "remote") throw new Error("The original execution environment is needed to retrieve this instruction copy"); + if (row.location !== `remote:${target.environmentId ?? ""}`) throw new Error("Instruction copy execution environment changed"); + const expected = path.posix.join(target.remoteCwd, ".paperclip-runtime", `instruction-edits-${row.runId}`, "instructions"); + if (expected !== row.executionRoot) throw new Error("Instruction copy execution environment changed"); + const result = await runAdapterExecutionTargetShellCommand(row.runId, target, instructionCollectionScript(row.executionRoot, row.entryFile), { cwd: target.remoteCwd, env: {}, timeoutSec: 30 }); + if (result.exitCode !== 0 || result.timedOut) throw new Error("Could not safely retrieve the stopped run's instruction file"); + const payload = JSON.parse(result.stdout) as { missing?: boolean; contentBase64?: string }; + if (payload.missing) return null; + if (typeof payload.contentBase64 !== "string" || payload.contentBase64.length > Math.ceil(MAX_INSTRUCTION_BYTES / 3) * 4) throw new Error("Instruction response exceeds the collection bound"); + return instructionBytes(Buffer.from(payload.contentBase64, "base64")); + } + + /** A terminal-turn probe is not a save: changed/invalid copies require a + * stopped provider before capture. Unchanged warm providers remain reusable. */ + async function hasChanges(input: { companyId: string; runId: string; target?: AdapterExecutionTarget | null }) { + const row = await get(input.companyId, input.runId); + if (!row || completed.has(row.state)) return false; + if (isAgentDirectoryCopy(row)) return directories.hasChanges(row, input.target); + try { + const content = await readCandidate(row, input.target); + if (content !== null && hash(content) === row.baseHash) { + // This is an unchanged turn receipt, not stopped-process collection. + // No candidate bytes or new revision are persisted from a live owner. + await patch(row, { state: "unchanged_turn", nextAttemptAt: null }); + return false; + } + return true; + } catch { return true; } + } + + async function acknowledgeExplicitSave(input: { companyId: string; agentId: string; runId: string; entryFile: string; revisionId: string; contentHash: string }) { + const row = await get(input.companyId, input.runId); + if (!row || isAgentDirectoryCopy(row) || row.agentId !== input.agentId || row.entryFile !== input.entryFile || row.state !== "prepared") return; + const target = liveTargets.get(targetKey(row.companyId, row.runId)); + const content = await readCandidate(row, target).catch(() => null); + // Never advance a stale local edit past an unrelated explicit update. + if (content === null || hash(content) !== input.contentHash) return; + await db.update(copies).set({ baseRevisionId: input.revisionId, baseHash: input.contentHash, updatedAt: new Date() }) + .where(and(scope(row.companyId, row.runId), eq(copies.state, "prepared"), eq(copies.baseHash, row.baseHash))); + } + + async function commitCandidate(row: Copy) { + if (isAgentDirectoryCopy(row)) return directories.collectStopped(row); + if (row.candidateBase64 === null || completed.has(row.state) || row.state === "conflict") return row; + try { + const receipt = await revisions.commit({ + companyId: row.companyId, agentId: row.agentId, entryFile: row.entryFile, + baseRevisionId: row.baseRevisionId, content: Buffer.from(row.candidateBase64, "base64"), source: "cleanup", + }, actorFor(row)); + return patch(row, { state: "saved", receipt: { ...receipt }, errorCode: null, errorMessage: null, nextAttemptAt: null }); + } catch (error) { + const detail = error as { status?: number; details?: { code?: string }; message?: string }; + const code = detail.details?.code ?? "INSTRUCTION_SAVE_FAILED"; + const retryable = !detail.status || detail.status >= 500; + const message = detail.status === 403 + ? "Current permissions do not allow this instruction save. The candidate was preserved." + : detail.status === 409 + ? "Instructions changed after this run started. Review the preserved candidate against the current revision." + : "Instruction edits were preserved but not saved. Review the candidate before retrying."; + return patch(row, { state: retryable ? "pending_commit" : "conflict", errorCode: code, errorMessage: message, nextAttemptAt: retryable && row.attempts < MAX_COLLECTION_ATTEMPTS ? new Date(Date.now() + 30_000) : null }); + } + } + + /** Caller must have joined the owned provider process before granting this proof. */ + async function collectStopped(input: { companyId: string; runId: string; target?: AdapterExecutionTarget | null }) { + let row = await get(input.companyId, input.runId); + if (!row) return row; + if (isAgentDirectoryCopy(row)) return directories.collectStopped(row, input.target); + if (completed.has(row.state) || row.state === "conflict") return row; + row = await patch(row, { processStoppedAt: row.processStoppedAt ?? new Date(), attempts: row.attempts + 1 }); + if (completed.has(row.state) || row.state === "conflict") return row; + if (row.candidateBase64 !== null) return commitCandidate(row); + try { + const candidate = await readCandidate(row, input.target); + if (candidate === null) { + return patch(row, { state: "unavailable", errorCode: "INSTRUCTION_FILE_MISSING", errorMessage: "The registered instruction file was removed. Canonical instructions were kept; no edits were saved.", nextAttemptAt: null }); + } + if (hash(candidate) === row.baseHash) return patch(row, { state: "unchanged", nextAttemptAt: null }); + // Save the candidate durably before attempting authorization/CAS or releasing the environment. + row = await patch(row, { state: "pending_commit", candidateBase64: candidate.toString("base64"), candidateHash: hash(candidate), nextAttemptAt: new Date() }); + return commitCandidate(row); + } catch (error) { + return patch(row, { state: row.attempts < MAX_COLLECTION_ATTEMPTS ? "pending_collection" : "unavailable", errorCode: "INSTRUCTION_COLLECTION_FAILED", errorMessage: "The registered instruction copy could not be read safely. Canonical instructions were kept; no save receipt exists.", nextAttemptAt: row.attempts < MAX_COLLECTION_ATTEMPTS ? new Date(Date.now() + 30_000) : null }); + } + } + + /** Restarts can retry persisted bytes without touching a live filesystem or launching a model. */ + async function recoverCaptured() { + const pending = await db.select().from(copies).where(and( + eq(copies.state, "pending_commit"), + or(isNull(copies.nextAttemptAt), lte(copies.nextAttemptAt, new Date())), + lte(copies.attempts, MAX_COLLECTION_ATTEMPTS - 1), + )).limit(20); + for (const row of pending) { + const result = await commitCandidate(await patch(row, { attempts: row.attempts + 1 })); + if (isAgentDirectoryCopy(result) && completed.has(result.state)) await directories.release(result); + } + // A crash between a durable save and release must not retain run snapshots + // forever. Only discard copies with recorded stop proof and a terminal receipt. + const cleanup = await db.select().from(copies).where(and( + inArray(copies.state, [...completed, "unavailable"]), isNotNull(copies.processStoppedAt), + sql`${copies.receipt}->>'schema' = 'paperclip.agent-files.v1'`, + or(sql`${copies.receipt} ? 'baseline'`, sql`${copies.receipt}->>'cleanupPending' = 'true'`), + or(isNull(copies.nextAttemptAt), lte(copies.nextAttemptAt, new Date())), + )).orderBy(asc(copies.updatedAt)).limit(20); + for (const row of cleanup) await directories.release(row); + return pending.length; + } + + /** Recover only the registered copy after independently recorded process-stop + * proof. Terminal run status is never a substitute for that proof. Remote + * receipts mean the environment is stopped; do not restart it just to read. */ + async function recoverStopped() { + const pending = await db.select({ copy: copies, runtimeMode: heartbeatRuns.runtimeMode }).from(copies) + .innerJoin(heartbeatRuns, and(eq(heartbeatRuns.companyId, copies.companyId), eq(heartbeatRuns.id, copies.runId))) + .where(and(or(inArray(copies.state, ["prepared", "pending_collection"]), + and(eq(copies.state, "preparing"), sql`${copies.receipt}->>'schema' = 'paperclip.agent-files.v1'`)), + inArray(heartbeatRuns.status, ["succeeded", "failed", "cancelled", "timed_out", "interrupted"]), + lte(copies.attempts, MAX_COLLECTION_ATTEMPTS - 1))).limit(20); + for (const { copy: row, runtimeMode } of pending) { + if (row.state === "preparing" && isAgentDirectoryCopy(row)) { + // A provider cannot launch until preparation records "prepared". With + // the owning run terminal, this is an interrupted staging copy only. + await directories.release(await patch(row, { state: "unavailable", processStoppedAt: new Date(), + errorCode: "AGENT_FILES_PREPARE_FAILED", errorMessage: "Agent-file preparation was interrupted. The temporary copy was discarded.", nextAttemptAt: null })); + } else if (row.location === "local" && (row.processStoppedAt || runtimeMode === "native" && + await hasNativeLocalProcessStop(db, row.companyId, row.runId))) { + const result = await collectStopped({ companyId: row.companyId, runId: row.runId }); + if (result && isAgentDirectoryCopy(result) && completed.has(result.state)) await directories.release(result); + } else if (row.location !== "local" && await remoteExecutionHasStopped(db, row.companyId, row.runId)) { + const unavailable = await reportUnavailable(row.companyId, row.runId); + if (unavailable && isAgentDirectoryCopy(unavailable)) { + await directories.release(await patch(unavailable, { processStoppedAt: unavailable.processStoppedAt ?? new Date() })); + } + } else if (row.state === "prepared") { + await patch(row, { state: "pending_collection", errorCode: "INSTRUCTION_STOP_UNCONFIRMED", + errorMessage: "The provider's stop has not been confirmed. Instruction collection is pending; no save is claimed.", nextAttemptAt: null }); + } + } + return pending.length; + } + + async function list(companyId: string, agentId: string, actor: AuthorizationActor) { + await revisions.readCurrent({ companyId, agentId }, actor); + const rows = await db.select().from(copies).where(and(eq(copies.companyId, companyId), eq(copies.agentId, agentId), inArray(copies.state, ["conflict", "pending_collection", "pending_commit", "unavailable"]))).orderBy(desc(copies.createdAt)).limit(50); + return rows.map(row => ({ runId: row.runId, entryFile: row.entryFile, baseRevisionId: row.baseRevisionId, + baseHash: row.baseHash, state: row.state as "conflict" | "pending_collection" | "pending_commit" | "unavailable", + candidateHash: row.candidateHash, contract: isAgentDirectoryCopy(row) ? "agent_files" : "legacy", + content: row.candidateBase64 === null ? null : Buffer.from(row.candidateBase64, "base64").toString("utf8"), + errorCode: row.errorCode, errorMessage: row.errorMessage, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString() })); + } + + async function resolve(input: { companyId: string; agentId: string; runId: string; baseRevisionId: string | null; content: string }, actor: AuthorizationActor) { + const row = await get(input.companyId, input.runId); + if (!row || row.agentId !== input.agentId) throw notFound("Instruction candidate not found"); + if (completed.has(row.state)) throw conflict("This instruction candidate has already been resolved"); + if (isAgentDirectoryCopy(row)) throw conflict("Agent-file synchronization failures have no preserved candidate"); + const receipt = await revisions.commit({ companyId: input.companyId, agentId: input.agentId, entryFile: row.entryFile, content: input.content, baseRevisionId: input.baseRevisionId, source: "api" }, actor); + await patch(row, { state: "resolved", receipt: { ...receipt }, nextAttemptAt: null }); + return receipt; + } + + /** A controller may report loss before disposal, never fabricate a save or + * read a possibly live provider. Already captured candidates remain intact. */ + async function reportUnavailable(companyId: string, runId: string) { + const row = await get(companyId, runId); + if (!row || completed.has(row.state) || row.state === "unchanged_turn" || row.candidateBase64 !== null || (isAgentDirectoryCopy(row) && row.candidateHash !== null) || row.state === "conflict") return row; + if (isAgentDirectoryCopy(row) && row.state === "unavailable") return row; + return patch(row, { state: "unavailable", errorCode: "INSTRUCTION_COLLECTION_UNAVAILABLE", + errorMessage: "The registered instruction copy could not be retrieved safely before environment release. No instruction save is claimed.", nextAttemptAt: null }); + } + + async function release(companyId: string, runId: string) { liveTargets.delete(targetKey(companyId, runId)); const row = await get(companyId, runId); if (row && isAgentDirectoryCopy(row)) await directories.release(row); } + return { prepare, get, hasChanges, acknowledgeExplicitSave, collectStopped, recoverCaptured, recoverStopped, list, resolve, reportUnavailable, release }; +} diff --git a/server/src/services/agent-instructions.ts b/server/src/services/agent-instructions.ts index 5045143eb70..4f66a27545a 100644 --- a/server/src/services/agent-instructions.ts +++ b/server/src/services/agent-instructions.ts @@ -1,5 +1,9 @@ +import { inspectAgentFile, fileHash, agentFilePath, MAX_AGENT_FILE_BYTES } from "./agent-file-store.js"; import fs from "node:fs/promises"; import path from "node:path"; +import { and, eq } from "drizzle-orm"; +import { agentInstructionHeads, agents, type Db } from "@paperclipai/db"; +import { instructionPath, assertInstructionPathSafe, instructionBytes, readInstructionBytes } from "./agent-instruction-files.js"; import { notFound, unprocessable } from "../errors.js"; import { resolveHomeAwarePath, resolvePaperclipInstanceRoot } from "../home-paths.js"; @@ -36,6 +40,8 @@ type AgentLike = { type AgentInstructionsFileSummary = { path: string; + contentHash?: string; + binary?: boolean; size: number; language: string; markdown: boolean; @@ -53,6 +59,7 @@ type AgentInstructionsFileDetail = AgentInstructionsFileSummary & { type AgentInstructionsBundle = { agentId: string; companyId: string; + persistence?: "agent_files"; mode: BundleMode | null; rootPath: string | null; managedRootPath: string; @@ -112,11 +119,7 @@ function isMarkdown(relativePath: string) { } function normalizeRelativeFilePath(candidatePath: string): string { - const normalized = path.posix.normalize(candidatePath.replaceAll("\\", "/")).replace(/^\/+/, ""); - if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) { - throw unprocessable("Instructions file path must stay within the bundle root"); - } - return normalized; + return instructionPath(candidatePath); } function resolvePathWithinRoot(rootPath: string, relativePath: string): string { @@ -130,7 +133,7 @@ function resolvePathWithinRoot(rootPath: string, relativePath: string): string { return absolutePath; } -function resolveManagedInstructionsRoot(agent: AgentLike): string { +export function resolveManagedInstructionsRoot(agent: AgentLike): string { return path.resolve( resolvePaperclipInstanceRoot(), "companies", @@ -153,7 +156,7 @@ function resolveLegacyInstructionsPath(candidatePath: string, config: Record null); + return fs.lstat(targetPath).catch(() => null); } function shouldIgnoreInstructionsEntry(entry: { name: string; isDirectory(): boolean; isFile(): boolean }) { @@ -172,14 +175,14 @@ function shouldIgnoreInstructionsEntry(entry: { name: string; isDirectory(): boo async function listFilesRecursive( rootPath: string, - options?: { rejectSymlinks?: boolean }, + options?: { rejectSymlinks?: boolean; legacyExcludes?: boolean }, ): Promise { const output: string[] = []; async function walk(currentPath: string, relativeDir: string) { const entries = await fs.readdir(currentPath, { withFileTypes: true }).catch(() => []); for (const entry of entries) { - if (shouldIgnoreInstructionsEntry(entry)) continue; + if (options?.legacyExcludes && shouldIgnoreInstructionsEntry(entry)) continue; const absolutePath = path.join(currentPath, entry.name); const relativePath = normalizeRelativeFilePath( relativeDir ? path.posix.join(relativeDir, entry.name) : entry.name, @@ -206,13 +209,21 @@ async function listFilesRecursive( async function readFileSummary(rootPath: string, relativePath: string, entryFile: string): Promise { const absolutePath = resolvePathWithinRoot(rootPath, relativePath); const stat = await fs.stat(absolutePath); + // External bundles may contain large assets; listing must not read them. + const file = stat.size > MAX_AGENT_FILE_BYTES ? null : await inspectAgentFile(rootPath, relativePath); + return summarizeFile(relativePath, entryFile, file?.size ?? stat.size, file?.bytes ?? null, file?.hash); +} + +function summarizeFile(relativePath: string, entryFile: string, size: number, bytes: Buffer | null, hash?: string): AgentInstructionsFileSummary { + let binary = bytes === null; + try { if (bytes?.includes(0)) binary = true; new TextDecoder("utf-8", { fatal: true }).decode(bytes ?? undefined); } catch { binary = true; } return { path: relativePath, - size: stat.size, + size, language: inferLanguage(relativePath), markdown: isMarkdown(relativePath), isEntryFile: relativePath === entryFile, - editable: true, + editable: !binary, binary, contentHash: hash, deprecated: false, virtual: false, }; @@ -231,7 +242,7 @@ async function readLegacyInstructions(agent: AgentLike, config: Record[0]>[0] | undefined = db) { + if (!connection) throw unprocessable("Bundle initialization requires the database-backed instructions service"); + const [head] = await connection.select().from(agentInstructionHeads).where(and(eq(agentInstructionHeads.companyId, agent.companyId), + eq(agentInstructionHeads.agentId, agent.id), eq(agentInstructionHeads.entryFile, entryFile))); + if (head) throw unprocessable("This entry has revision history. Use the instruction content API with its baseRevisionId to update it.", { code: "INSTRUCTION_REVISION_REQUIRED" }); + } async function getBundle(agent: AgentLike): Promise { const state = await recoverManagedBundleState(agent, deriveBundleState(agent)); if (!state.rootPath) return toBundle(agent, state, []); + await assertInstructionPathSafe(state.rootPath, state.entryFile); const stat = await statIfExists(state.rootPath); if (!stat?.isDirectory()) { return toBundle(agent, { @@ -484,8 +506,12 @@ export function agentInstructionsService() { warnings: [...state.warnings, `Instructions root does not exist: ${state.rootPath}`], }, []); } - const files = await listFilesRecursive(state.rootPath); - const summaries = await Promise.all(files.map((relativePath) => readFileSummary(state.rootPath!, relativePath, state.entryFile))); + const files = await listFilesRecursive(state.rootPath, { legacyExcludes: state.mode === "external" }); + const summaries: AgentInstructionsFileSummary[] = []; + // Bound open descriptors and text buffers even for a large personal folder. + for (let index = 0; index < files.length; index += 8) { + summaries.push(...await Promise.all(files.slice(index, index + 8).map(relativePath => readFileSummary(state.rootPath!, relativePath, state.entryFile)))); + } return toBundle(agent, state, summaries); } @@ -507,24 +533,11 @@ export function agentInstructionsService() { }; } if (!state.rootPath) throw notFound("Agent instructions bundle is not configured"); - const absolutePath = resolvePathWithinRoot(state.rootPath, relativePath); - const [content, stat] = await Promise.all([ - fs.readFile(absolutePath, "utf8").catch(() => null), - fs.stat(absolutePath).catch(() => null), - ]); - if (content === null || !stat?.isFile()) throw notFound("Instructions file not found"); - const normalizedPath = normalizeRelativeFilePath(relativePath); - return { - path: normalizedPath, - size: stat.size, - language: inferLanguage(normalizedPath), - markdown: isMarkdown(normalizedPath), - isEntryFile: normalizedPath === state.entryFile, - editable: true, - deprecated: false, - virtual: false, - content, - }; + await assertInstructionPathSafe(state.rootPath, relativePath); + const file = await inspectAgentFile(state.rootPath, relativePath); + if (file === null) throw notFound("Instructions file not found"); + const summary = summarizeFile(relativePath, state.entryFile, file.size, file.bytes, file.hash); + return { ...summary, content: summary.binary ? "" : file.bytes!.toString("utf8") }; } async function ensureWritableBundle( @@ -551,11 +564,12 @@ export function agentInstructionsService() { }); await fs.mkdir(managedRoot, { recursive: true }); - const entryPath = resolvePathWithinRoot(managedRoot, entryFile); + const entryPath = await assertInstructionPathSafe(managedRoot, entryFile); const entryStat = await statIfExists(entryPath); if (!entryStat?.isFile()) { const legacyInstructions = await readLegacyInstructions(agent, current.config); if (legacyInstructions.trim().length > 0) { + await assertUnversionedEntry(agent, entryFile); await fs.mkdir(path.dirname(entryPath), { recursive: true }); await fs.writeFile(entryPath, legacyInstructions, "utf8"); } @@ -595,9 +609,13 @@ export function agentInstructionsService() { nextRootPath = resolvedRoot; } + await assertInstructionPathSafe(nextRootPath, nextEntryFile); await fs.mkdir(nextRootPath, { recursive: true }); const existingFiles = await listFilesRecursive(nextRootPath); + if (nextMode === "managed" && !existingFiles.includes(nextEntryFile)) { + await assertUnversionedEntry(agent, nextEntryFile); + } const exported = await exportFiles(agent); if (existingFiles.length === 0) { await writeBundleFiles(nextRootPath, exported.files); @@ -618,7 +636,7 @@ export function agentInstructionsService() { return { bundle: nextBundle, adapterConfig: nextConfig }; } - async function writeFile( + async function writeFileUnversioned( agent: AgentLike, relativePath: string, content: string, @@ -642,7 +660,14 @@ export function agentInstructionsService() { return { bundle, file, adapterConfig }; } + const configured = await recoverManagedBundleState(agent, current); + if (normalizeRelativeFilePath(relativePath) === configured.entryFile) { + throw unprocessable("Entry edits require the canonical instruction commit service and baseRevisionId", { code: "INSTRUCTION_REVISION_REQUIRED" }); + } + if (configured.mode !== "external") agentFilePath(relativePath); const prepared = await ensureWritableBundle(agent, options); + instructionBytes(content); + await assertInstructionPathSafe(prepared.state.rootPath!, relativePath); const absolutePath = resolvePathWithinRoot(prepared.state.rootPath!, relativePath); await fs.mkdir(path.dirname(absolutePath), { recursive: true }); await fs.writeFile(absolutePath, content, "utf8"); @@ -654,7 +679,7 @@ export function agentInstructionsService() { return { bundle, file, adapterConfig: prepared.adapterConfig }; } - async function deleteFile(agent: AgentLike, relativePath: string): Promise<{ + async function deleteFileUnversioned(agent: AgentLike, relativePath: string): Promise<{ bundle: AgentInstructionsBundle; adapterConfig: Record; }> { @@ -668,13 +693,35 @@ export function agentInstructionsService() { if (normalizedPath === state.entryFile) { throw unprocessable("Cannot delete the bundle entry file"); } - const absolutePath = resolvePathWithinRoot(state.rootPath, normalizedPath); + const absolutePath = await assertInstructionPathSafe(state.rootPath, normalizedPath); await fs.rm(absolutePath, { force: true }); const adapterConfig = buildPersistedBundleConfig(derived, state); const bundle = await getBundle({ ...agent, adapterConfig }); return { bundle, adapterConfig }; } + // Reload configuration under the commit lock: a previously supporting file + // may have become the configured entry since the route loaded its agent. + async function withCurrentAgent(agent: AgentLike, relativePath: string, operation: (current: AgentLike) => Promise) { + if (!db) return operation(agent); + return db.transaction(async (tx) => { + const [current] = await tx.select().from(agents) + .where(and(eq(agents.id, agent.id), eq(agents.companyId, agent.companyId))).for("update"); + if (!current) throw notFound("Agent not found"); + if (relativePath !== LEGACY_PROMPT_TEMPLATE_PATH) { + // Historical entries also remain projections if the configured entry changes. + await assertUnversionedEntry(current, normalizeRelativeFilePath(relativePath), tx); + } + return operation(current); + }); + } + async function writeFile(...args: Parameters) { + return withCurrentAgent(args[0], args[1], (current) => writeFileUnversioned(current, args[1], args[2], args[3])); + } + async function deleteFile(...args: Parameters) { + return withCurrentAgent(args[0], args[1], (current) => deleteFileUnversioned(current, args[1])); + } + async function exportFiles(agent: AgentLike, options?: { rejectSymlinks?: boolean }): Promise<{ files: Record; entryFile: string; @@ -684,7 +731,7 @@ export function agentInstructionsService() { if (state.rootPath) { const stat = await statIfExists(state.rootPath); if (stat?.isDirectory()) { - const relativePaths = await listFilesRecursive(state.rootPath, options); + const relativePaths = await listFilesRecursive(state.rootPath, { ...options, legacyExcludes: true }); const files = Object.fromEntries(await Promise.all(relativePaths.map(async (relativePath) => { const absolutePath = resolvePathWithinRoot(state.rootPath!, relativePath); const content = await fs.readFile(absolutePath, "utf8"); @@ -704,7 +751,7 @@ export function agentInstructionsService() { }; } - async function materializeManagedBundle( + async function materializeManagedBundleUnversioned( agent: AgentLike, files: Record, options?: { @@ -716,6 +763,14 @@ export function agentInstructionsService() { const rootPath = resolveManagedInstructionsRoot(agent); const entryFile = options?.entryFile ? normalizeRelativeFilePath(options.entryFile) : ENTRY_FILE_DEFAULT; + for (const [relativePath, content] of Object.entries(files)) { + instructionBytes(content); + await assertInstructionPathSafe(rootPath, agentFilePath(relativePath)); + } + const previous = await readInstructionBytes(rootPath, entryFile); + if (previous && !previous.equals(instructionBytes(files[entryFile] ?? ""))) { + throw unprocessable("Existing entry content must be saved through the canonical revision API before replacing a bundle", { code: "INSTRUCTION_REVISION_REQUIRED" }); + } if (options?.replaceExisting) { await fs.rm(rootPath, { recursive: true, force: true }); } @@ -744,6 +799,24 @@ export function agentInstructionsService() { return { bundle, adapterConfig }; } + async function materializeManagedBundle( + ...args: Parameters + ) { + if (!db) throw unprocessable("Bundle initialization requires the database-backed instructions service"); + return db.transaction(async (tx) => { + const agent = args[0]; + const [owner] = await tx.select({ id: agents.id }).from(agents).where(and(eq(agents.id, agent.id), eq(agents.companyId, agent.companyId))).for("update"); + if (!owner) throw notFound("Agent not found"); + await assertUnversionedEntry(agent, args[2]?.entryFile ? normalizeRelativeFilePath(args[2].entryFile) : ENTRY_FILE_DEFAULT, tx); + if (args[2]?.replaceExisting) { + const [existingHead] = await tx.select({ id: agentInstructionHeads.revisionId }).from(agentInstructionHeads) + .where(and(eq(agentInstructionHeads.companyId, agent.companyId), eq(agentInstructionHeads.agentId, agent.id))).limit(1); + if (existingHead) throw unprocessable("Replacing this bundle would remove a versioned entry. Use canonical content commits.", { code: "INSTRUCTION_REVISION_REQUIRED" }); + } + return materializeManagedBundleUnversioned(...args); + }); + } + return { getBundle, readFile, diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index 70b1d2032cf..ed1719e0280 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -62,6 +62,7 @@ export type AuthorizationAction = | PermissionKey | "agent_config:read" | "agent_config:update" + | "agent_instructions:update" | "skill_config:update" | "agent:read" | "agent:wake" @@ -148,7 +149,7 @@ function companyIdForResource(resource: AuthorizationResource) { } function permissionForAction(action: AuthorizationAction): PermissionKey | null { - if (action === "agent_config:read" || action === "agent_config:update" || action === "skill_config:update") { + if (action === "agent_config:read" || action === "agent_config:update" || action === "agent_instructions:update" || action === "skill_config:update") { return null; } if ( @@ -998,6 +999,7 @@ export function authorizationService(db: Db | DbTransaction) { input.action === "decision_triage:manage" || input.action === "agent_config:read" || input.action === "agent_config:update" || + input.action === "agent_instructions:update" || input.action === "skill_config:update" || input.action === "inbox:manage" || input.action === "runtime:manage" || @@ -1734,7 +1736,7 @@ export function authorizationService(db: Db | DbTransaction) { if (input.action === "agent_config:read") { return decideWithAgentConfigReadGrant("user", input.actor.userId); } - if (input.action === "agent_config:update") { + if (input.action === "agent_config:update" || input.action === "agent_instructions:update") { return decideWithProtectedChangeGrants("user", input.actor.userId, { direct: "agents:configure", suggest: "agents:suggest-changes", @@ -2227,6 +2229,19 @@ export function authorizationService(db: Db | DbTransaction) { return decideWithAgentConfigReadGrant("agent", actorAgentId); } + if (input.action === "agent_instructions:update") { + if (!isSimpleAssignableAgentStatus(actorAgent.status) || !input.actor.onBehalfOfUserId) { + return deny({ action: input.action, reason: "deny_missing_membership", explanation: "Instruction edits require an active agent and a responsible user." }); + } + // Explicit configure/suggest restrictions still govern content changes. + // Only self edits may fall back to the responsible user's target access. + const restricted = await decideWithProtectedChangeGrants("agent", actorAgentId, { + direct: "agents:configure", suggest: "agents:suggest-changes", + }); + if (restricted.reason !== "deny_no_grant" || input.resource.type !== "agent" || input.resource.agentId !== actorAgentId) return restricted; + return allow({ action: input.action, reason: "allow_self", explanation: "Own instruction content edit, subject to the responsible user's target edit access." }); + } + if (input.action === "agent_config:update") { return decideWithProtectedChangeGrants("agent", actorAgentId, { direct: "agents:configure", @@ -2391,7 +2406,7 @@ export function authorizationService(db: Db | DbTransaction) { responsibleUserId, }, "responsible-user authorization intersection denied"); - return responsibleUserAuthzShadowMode() ? agentDecision : denied; + return input.action !== "agent_instructions:update" && responsibleUserAuthzShadowMode() ? agentDecision : denied; } async function decide(input: { diff --git a/server/src/services/built-in-agents.ts b/server/src/services/built-in-agents.ts index 718263668eb..1b5f70308d0 100644 --- a/server/src/services/built-in-agents.ts +++ b/server/src/services/built-in-agents.ts @@ -1,3 +1,4 @@ +import fs from "node:fs/promises"; import { readFileSync } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; @@ -5,12 +6,16 @@ import { fileURLToPath } from "node:url"; import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils"; import { and, desc, eq, ne } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { agents, builtInManagedResources, companies, issueThreadInteractions, issues, routines, routineTriggers } from "@paperclipai/db"; +import { activityLog, agents, builtInManagedResources, companies, issueThreadInteractions, issues, routines, routineTriggers } from "@paperclipai/db"; import { syncRoutineVariablesWithTemplate } from "@paperclipai/shared"; import type { Agent, Approval, CompanySkill, PermissionKey, Routine, RoutineTrigger, RoutineVariable } from "@paperclipai/shared"; import { conflict, HttpError, notFound, unprocessable } from "../errors.js"; import { logActivity } from "./activity-log.js"; -import { agentInstructionsService } from "./agent-instructions.js"; +import { adoptAgentFiles, agentFilePath, fileHash, readAgentFile, snapshotAgentFiles } from "./agent-file-store.js"; +import { assertInstructionPathSafe, instructionBytes, materializeInstructionBytes } from "./agent-instruction-files.js"; +import { agentInstructionRevisionService } from "./agent-instruction-revisions.js"; +import type { AuthorizationActor } from "./authorization.js"; +import { agentInstructionsBundleMode, agentInstructionsService } from "./agent-instructions.js"; import { agentService } from "./agents.js"; import { approvalService } from "./approvals.js"; import { @@ -802,7 +807,7 @@ export function builtInAgentService(db: Db) { const agentSvc = agentService(db); const accessSvc = accessService(db); const approvalSvc = approvalService(db); - const instructionsSvc = agentInstructionsService(); + const instructionsSvc = agentInstructionsService(db); const skillSvc = companySkillService(db); const routineSvc = routineService(db); @@ -916,9 +921,9 @@ export function builtInAgentService(db: Db) { stockVersion: string; stockHash: string; defaultsJson: Record; - }) { + }, connection: Db | Parameters[0]>[0] = db) { const now = new Date(); - return db + return connection .insert(builtInManagedResources) .values(input) .onConflictDoUpdate({ @@ -952,10 +957,15 @@ export function builtInAgentService(db: Db) { return currentFiles; } - async function materializeInstructions(agent: Agent, definition: BuiltInAgentDefinition, mode: "reconcile" | "reset") { + async function materializeInstructions(agent: Agent, definition: BuiltInAgentDefinition, mode: "reconcile" | "reset", actor?: AuthorizationActor) { const bundle = definition.bundle!; const stock = stockHash(bundle.instructions.files); const binding = await getManagedResourceBinding(agent.companyId, definition.key, "instructions", "AGENTS.md"); + // Stock operations also read the committed projection after interrupted writes. + if (agentInstructionsBundleMode(agent) === "managed") { + try { await agentInstructionRevisionService(db).materializeCurrent({ companyId: agent.companyId, agentId: agent.id }); } + catch (error) { if (!(error instanceof HttpError && error.status === 404)) throw error; } + } const currentFiles = await currentInstructionFiles(agent, bundle); const currentHash = Object.values(currentFiles).some((value) => value === null) ? null : stockHash(currentFiles); const currentState = stockState({ @@ -969,6 +979,100 @@ export function builtInAgentService(db: Db) { changedFiles: changedFileList(currentFiles, bundle.instructions.files), }); + if (!actor && mode === "reconcile" && agentInstructionsBundleMode(agent) === "managed" + && binding && (binding.stockHash !== stock || binding.defaultsJson.pendingInstructionsUpdate)) { + const bindingWhere = and(eq(builtInManagedResources.companyId, agent.companyId), + eq(builtInManagedResources.bundleKey, definition.key), eq(builtInManagedResources.resourceKind, "instructions"), + eq(builtInManagedResources.resourceKey, "AGENTS.md")); + const incoming = Object.fromEntries(Object.entries(bundle.instructions.files) + .map(([file, content]) => [agentFilePath(file), instructionBytes(content)])); + // Persist only the pending operation's baseline hashes before touching + // files. If a write or DB commit fails, the next reconciliation can accept + // already-applied bytes and retry. This is not instruction revision history. + const prepared = await db.transaction(async (tx) => { + const [current] = await tx.select().from(agents).where(and(eq(agents.companyId, agent.companyId), eq(agents.id, agent.id))).for("update"); + if (!current) throw notFound("Built-in agent not found"); + if (agentInstructionsBundleMode(current) !== "managed") return false; + const root = await adoptAgentFiles(tx, current); + const [latest] = await tx.select().from(builtInManagedResources).where(bindingWhere); + if (!latest) return false; + const pending = latest.defaultsJson.pendingInstructionsUpdate as { + stockHash?: string; baseHashes?: Record; nextHashes?: Record; + } | undefined; + if (pending?.stockHash === stock) return true; + if (!pending && latest.stockHash === stock) return false; + const previousPaths = latest.defaultsJson.files; + if (!Array.isArray(previousPaths) || !previousPaths.every((file): file is string => typeof file === "string")) return false; + const ownedPaths = new Set(previousPaths); + if (pending) { + // A newer release (or rollback) can supersede an interrupted update. + // Recognize only the previous operation's baseline or intended bytes; + // never mistake an intervening operator edit for partially applied stock. + if (!pending.baseHashes || !pending.nextHashes) return false; + for (const [file, baseHash] of Object.entries(pending.baseHashes)) { + if (!(file in pending.nextHashes)) return false; + const bytes = await readAgentFile(root, agentFilePath(file)); + const hash = bytes === null ? null : fileHash(bytes); + if (hash !== baseHash && hash !== pending.nextHashes[file]) return false; + ownedPaths.add(file); + } + } else { + const previousFiles: Record = {}; + for (const file of previousPaths) { + try { previousFiles[file] = (await instructionsSvc.readFile(current, file)).content; } + catch { previousFiles[file] = null; } + } + if (stockHash(previousFiles) !== latest.stockHash) return false; + } + await snapshotAgentFiles(root); + const baseHashes: Record = {}; + const nextHashes: Record = {}; + for (const file of new Set([...ownedPaths, ...Object.keys(incoming)])) { + const bytes = await readAgentFile(root, agentFilePath(file)); + // A newly declared stock path must not overwrite a personal file. + if (!ownedPaths.has(file) && bytes !== null && !bytes.equals(incoming[file]!)) return false; + baseHashes[file] = bytes === null ? null : fileHash(bytes); + nextHashes[file] = incoming[file] ? fileHash(incoming[file]) : null; + } + await tx.update(builtInManagedResources).set({ defaultsJson: { ...latest.defaultsJson, + pendingInstructionsUpdate: { stockHash: stock, baseHashes, nextHashes } }, updatedAt: new Date() }).where(bindingWhere); + return true; + }); + if (!prepared) return currentState; + return db.transaction(async (tx) => { + const [current] = await tx.select().from(agents).where(and(eq(agents.companyId, agent.companyId), eq(agents.id, agent.id))).for("update"); + if (!current) throw notFound("Built-in agent not found"); + if (agentInstructionsBundleMode(current) !== "managed") return currentState; + const root = await adoptAgentFiles(tx, current); + const [latest] = await tx.select().from(builtInManagedResources).where(bindingWhere); + const pending = latest?.defaultsJson.pendingInstructionsUpdate as { stockHash?: string; baseHashes?: Record } | undefined; + if (pending?.stockHash !== stock || !pending.baseHashes) return currentState; + await snapshotAgentFiles(root); + // Preflight every changed or removed path before mutation; preserve any + // operator edit made between preparation, failure, and this retry. + for (const [file, baseHash] of Object.entries(pending.baseHashes)) { + const bytes = await readAgentFile(root, agentFilePath(file)); + const hash = bytes === null ? null : fileHash(bytes); + const nextHash = incoming[file] ? fileHash(incoming[file]) : null; + if (hash !== baseHash && hash !== nextHash) return currentState; + } + for (const [file, bytes] of Object.entries(incoming)) await materializeInstructionBytes(root, file, bytes); + for (const file of Object.keys(pending.baseHashes)) { + if (!(file in incoming)) await fs.rm(await assertInstructionPathSafe(root, file), { force: true }); + } + await upsertManagedResourceBinding({ companyId: agent.companyId, bundleKey: definition.key, + resourceKind: "instructions", resourceKey: "AGENTS.md", resourceId: agent.id, + stockVersion: bundle.stockVersion, stockHash: stock, + defaultsJson: { entryFile: bundle.instructions.entryFile, files: Object.keys(bundle.instructions.files) }, + }, tx); + await tx.insert(activityLog).values({ companyId: agent.companyId, actorType: "system", actorId: "built-in-reconcile", + action: "agent.files_updated", entityType: "agent", entityId: agent.id, + details: { source: "built-in-stock-update", bundleKey: definition.key, stockHash: stock } }); + return stockState({ resourceKind: "instructions", resourceKey: "AGENTS.md", resourceId: agent.id, + stockVersion: bundle.stockVersion, latestStockHash: stock, currentHash: stock, bindingStockHash: stock }); + }); + } + const shouldWrite = mode === "reset" || currentState.stockStatus === "missing" @@ -992,13 +1096,36 @@ export function builtInAgentService(db: Db) { return currentState; } - const materialized = await instructionsSvc.materializeManagedBundle(agent, bundle.instructions.files, { - entryFile: bundle.instructions.entryFile, - replaceExisting: true, - clearLegacyPromptTemplate: true, - }); + let adapterConfig: Record; + if (currentFiles[bundle.instructions.entryFile] !== null && currentFiles[bundle.instructions.entryFile] !== undefined) { + if (!actor && mode === "reconcile") return currentState; + if (!actor) throw unprocessable("Resetting existing instructions requires an authenticated operator", { code: "INSTRUCTION_IDENTITY_INVALID" }); + const revisions = agentInstructionRevisionService(db); + const target = { companyId: agent.companyId, agentId: agent.id }; + const baseline = await revisions.readCurrent(target, actor); + const receipt = await revisions.commit({ ...target, entryFile: bundle.instructions.entryFile, + baseRevisionId: baseline?.revision.id ?? null, content: bundle.instructions.files[bundle.instructions.entryFile] ?? "", + source: actor.type === "board" ? "board" : "api" }, actor); + if (receipt.materialization === "pending") throw conflict("Instruction revision saved; retry reset to repair its disk copy", { revisionId: receipt.revision.id }); + const refreshed = await agentSvc.getById(agent.id); + if (!refreshed) throw notFound("Built-in agent not found"); + for (const [file, content] of Object.entries(bundle.instructions.files)) { + if (file !== bundle.instructions.entryFile) await instructionsSvc.writeFile(refreshed, file, content); + } + // Preserve personal files outside this built-in bundle's declared paths. + adapterConfig = { ...refreshed.adapterConfig }; + delete adapterConfig.promptTemplate; + delete adapterConfig.bootstrapPromptTemplate; + } else { + const materialized = await instructionsSvc.materializeManagedBundle(agent, bundle.instructions.files, { + entryFile: bundle.instructions.entryFile, + replaceExisting: false, + clearLegacyPromptTemplate: true, + }); + adapterConfig = materialized.adapterConfig; + } const updated = await agentSvc.update(agent.id, { - adapterConfig: materialized.adapterConfig, + adapterConfig, }, { allowBuiltInAgentMetadata: true, recordRevision: { source: `built-in-bundle:${mode}:instructions` }, @@ -1480,13 +1607,14 @@ export function builtInAgentService(db: Db) { definition: BuiltInAgentDefinition, mode: "reconcile" | "reset", resources?: Array<"instructions" | "skill" | "routine">, + actor?: AuthorizationActor, ) { if (!definition.bundle) return []; const selected = new Set(resources ?? ["instructions", "skill", "routine"]); const existingStates = await bundleResourceStates(agent.companyId, definition, agent); const byKind = new Map(existingStates.map((state) => [state.resourceKind, state])); const instruction = selected.has("instructions") - ? await materializeInstructions(agent, definition, mode) + ? await materializeInstructions(agent, definition, mode, actor) : byKind.get("instructions")!; const refreshedAgent = await agentSvc.getById(agent.id) as Agent | null; if (!refreshedAgent) throw notFound("Built-in agent not found"); @@ -1847,7 +1975,7 @@ export function builtInAgentService(db: Db) { return state(definition, updated as Agent); } - async function reset(companyId: string, key: string, input: { resources?: Array<"agent" | "instructions" | "skill" | "routine"> } = {}) { + async function reset(companyId: string, key: string, input: { resources?: Array<"agent" | "instructions" | "skill" | "routine"> } = {}, actor?: AuthorizationActor) { const definition = requireBuiltInAgentDefinition(key); const resetAgentDefaults = !input.resources || input.resources.includes("agent"); const current = resetAgentDefaults @@ -1862,6 +1990,7 @@ export function builtInAgentService(db: Db) { definition, "reset", input.resources ? selectedBundleResources ?? [] : undefined, + actor, ); return state(definition, await agentSvc.getById(current.agent.id) as Agent, resources); } diff --git a/server/src/services/change-consent-gate.ts b/server/src/services/change-consent-gate.ts index 8523f04bcad..4569a0f8b38 100644 --- a/server/src/services/change-consent-gate.ts +++ b/server/src/services/change-consent-gate.ts @@ -112,7 +112,7 @@ function expandTargetKeysForLegacyCompatibility(targetKeys: string[]) { return [...expanded]; } -export function changeConsentGateService(db: Db) { +export function changeConsentGateService(db: Db | Parameters[0]>[0]) { return { assertConsented: async (input: { companyId: string; diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index dd3694dc6ab..a4c67d20b37 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -12006,7 +12006,11 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { run.nativeIssueId === issueId && run.status === "failed" && run.errorCode === "adapter_failed" && - run.error === "runner_state_identity_mismatch" && + // A diagnostic reason does not change this failure category. The exact + // checkpoint, cleanup receipt, and no-provider-work proofs below still + // decide whether the original request can be retried. + (run.error === "runner_state_identity_mismatch" || + run.error?.startsWith("runner_state_identity_mismatch: ")) && run.nativePhase === "observed" && coordinator.phase === "observed" && coordinator.attempt === 0 && diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 7c7501f6316..701894a7610 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -3549,7 +3549,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const companies = companyService(db); const agents = agentService(db); const assetRecords = assetService(db); - const instructions = agentInstructionsService(); + const instructions = agentInstructionsService(db); const access = accessService(db); const projects = projectService(db); const issues = issueService(db); diff --git a/server/src/services/execution-workspace-policy.ts b/server/src/services/execution-workspace-policy.ts index b430de18cc2..dd36825a2a6 100644 --- a/server/src/services/execution-workspace-policy.ts +++ b/server/src/services/execution-workspace-policy.ts @@ -38,17 +38,17 @@ function parseExecutionWorkspaceStrategy(raw: unknown): ExecutionWorkspaceStrate } return { type, - ...(typeof parsed.baseRef === "string" ? { baseRef: parsed.baseRef } : {}), - ...(typeof parsed.branchTemplate === "string" ? { branchTemplate: parsed.branchTemplate } : {}), + ...(typeof parsed.baseRef === "string" || parsed.baseRef === null ? { baseRef: parsed.baseRef } : {}), + ...(typeof parsed.branchTemplate === "string" || parsed.branchTemplate === null ? { branchTemplate: parsed.branchTemplate } : {}), ...(typeof parsed.existingBranch === "string" && parsed.existingBranch.trim().length > 0 ? { existingBranch: parsed.existingBranch.trim() } : {}), - ...(typeof parsed.worktreeParentDir === "string" ? { worktreeParentDir: parsed.worktreeParentDir } : {}), - ...(typeof parsed.provisionCommand === "string" ? { provisionCommand: parsed.provisionCommand } : {}), - ...(typeof parsed.runtimeProvisionCommand === "string" + ...(typeof parsed.worktreeParentDir === "string" || parsed.worktreeParentDir === null ? { worktreeParentDir: parsed.worktreeParentDir } : {}), + ...(typeof parsed.provisionCommand === "string" || parsed.provisionCommand === null ? { provisionCommand: parsed.provisionCommand } : {}), + ...(typeof parsed.runtimeProvisionCommand === "string" || parsed.runtimeProvisionCommand === null ? { runtimeProvisionCommand: parsed.runtimeProvisionCommand } : {}), - ...(typeof parsed.teardownCommand === "string" ? { teardownCommand: parsed.teardownCommand } : {}), + ...(typeof parsed.teardownCommand === "string" || parsed.teardownCommand === null ? { teardownCommand: parsed.teardownCommand } : {}), }; } @@ -418,11 +418,18 @@ export function buildExecutionWorkspaceAdapterConfig(input: { if (hasWorkspaceControl) { if (input.mode === "isolated_workspace") { - const strategy = - input.issueSettings?.workspaceStrategy ?? - input.projectPolicy?.workspaceStrategy ?? + const projectStrategy = projectHasPolicy ? input.projectPolicy?.workspaceStrategy : undefined; + const issueStrategy = input.issueSettings?.workspaceStrategy; + // An issue that changes its branch still needs the project's setup hooks. + // Do not carry those defaults into a different execution strategy. + const strategy = issueStrategy && projectStrategy?.type === issueStrategy.type + ? { ...projectStrategy, ...issueStrategy } + : issueStrategy ?? projectStrategy ?? parseExecutionWorkspaceStrategy(nextConfig.workspaceStrategy) ?? ({ type: "git_worktree" } satisfies ExecutionWorkspaceStrategy); + if (issueStrategy?.existingBranch && issueStrategy.branchTemplate === undefined && strategy !== issueStrategy) { + delete strategy.branchTemplate; + } nextConfig.workspaceStrategy = strategy as unknown as Record; } else { delete nextConfig.workspaceStrategy; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index a62c2691153..981ac9713ab 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1,3 +1,5 @@ +import { isAgentDirectoryCopy } from "./agent-directory-working-copies.js"; + import type { PaperclipTurnContext } from "@paperclipai/adapter-utils/server-utils"; import { restoreNativeWorkspaceBestEffort } from "./native-runtime/native-workspace-best-effort.js"; import { @@ -67,6 +69,7 @@ import { startAdapterExecutionTargetPaperclipBridge, } from "@paperclipai/adapter-utils/execution-target"; import { agentService } from "./agents.js"; +import { agentInstructionWorkingCopyService, instructionWorkingCopyGuidance } from "./agent-instruction-working-copies.js"; import { normalizeLegacyRunnerProvider } from "@paperclipai/adapter-utils"; import fs from "node:fs/promises"; import path from "node:path"; @@ -3442,6 +3445,15 @@ const heartbeatRunSafeResultJsonColumn = sql | null>` 'error', left(${heartbeatRuns.resultJson} ->> 'error', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}), 'stdout', left(${heartbeatRuns.resultJson} ->> 'stdout', ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS}), 'stderr', left(${heartbeatRuns.resultJson} ->> 'stderr', ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS}), + 'instructionSave', case when jsonb_typeof(${heartbeatRuns.resultJson} -> 'instructionSave') = 'object' + then jsonb_strip_nulls(jsonb_build_object( + 'state', left(${heartbeatRuns.resultJson} #>> '{instructionSave,state}', 32), + 'contract', left(${heartbeatRuns.resultJson} #>> '{instructionSave,contract}', 32), + 'entryFile', left(${heartbeatRuns.resultJson} #>> '{instructionSave,entryFile}', 512), + 'errorCode', left(${heartbeatRuns.resultJson} #>> '{instructionSave,errorCode}', 128), + 'errorMessage', left(${heartbeatRuns.resultJson} #>> '{instructionSave,errorMessage}', 1024), + 'storageWarning', left(${heartbeatRuns.resultJson} #>> '{instructionSave,storageWarning}', 1024) + )) end, 'workspaceRestoreFailure', case when ${heartbeatRuns.resultJson} ->> 'workspaceRestoreFailure' in ('restore_permission_denied', 'restore_lock_timeout', 'restore_unsafe_archive', 'restore_failed') then ${heartbeatRuns.resultJson} -> 'workspaceRestoreFailure' end, @@ -9502,6 +9514,7 @@ export function heartbeatService( environmentRuntimeService(db, { pluginWorkerManager: options.pluginWorkerManager, }); + const instructionCopies = agentInstructionWorkingCopyService(db, { environmentRuntime }); const envOrchestrator = environmentRunOrchestrator(db, { pluginWorkerManager: options.pluginWorkerManager, environmentRuntime, @@ -18868,6 +18881,14 @@ export function heartbeatService( async function reapOrphanedRuns(opts?: { staleThresholdMs?: number }) { const staleThresholdMs = opts?.staleThresholdMs ?? 0; const now = new Date(); + // Recovery never launches a provider or infers stopped ownership from + // terminal status. Uncaptured local copies require durable stop evidence. + await instructionCopies.recoverStopped().catch(error => { + logger.warn({ err: error }, "failed to recover stopped instruction copies"); + }); + await instructionCopies.recoverCaptured().catch(error => { + logger.warn({ err: error }, "failed to retry captured instruction revisions"); + }); // Complete persisted native results before generic orphan recovery. The // reconciler reads the durable workspace barrier and persisted runtime @@ -22350,6 +22371,29 @@ export function heartbeatService( await bindIssueToPersistedExecutionWorkspace(persistedExecutionWorkspace); const workspaceRealization = realizationResult.workspaceRealization; const executionTarget = realizationResult.executionTarget; + let instructionCopy: Awaited> = null; + let instructionSave: Record | null = null; + const collectStoppedInstructions = async () => { + if (!instructionCopy) return; + let saved = await instructionCopies.collectStopped({ companyId: agent.companyId, runId: run.id, target: executionTarget }); + // Capture before disposal. Exhausted bounded collection leaves a durable + // explicit loss report, never a claim that missing bytes were saved. + while (saved?.state === "pending_collection" && saved.attempts < 3) { + saved = await instructionCopies.collectStopped({ companyId: agent.companyId, runId: run.id, target: executionTarget }); + } + if (!saved) return; + const receipt = parseObject(saved.receipt); + const storageWarning = readNonEmptyString(receipt.storageWarning); + instructionSave = { state: saved.state, entryFile: saved.entryFile, + ...(isAgentDirectoryCopy(saved) ? { contract: "agent_files", appliedCandidateHash: saved.candidateHash } + : { revisionId: parseObject(receipt.revision).id ?? null }), storageWarning, errorCode: saved.errorCode, errorMessage: saved.errorMessage }; + await appendRunEvent(run, { eventType: "instruction_save", stream: "system", + level: !storageWarning && ["saved", "unchanged", "resolved"].includes(saved.state) ? "info" : "warn", + message: storageWarning ?? (saved.state === "saved" ? "Agent files saved." + : saved.state === "unchanged" ? "Instruction working copy is unchanged." + : saved.errorMessage ?? "Instruction edits were not saved. Review the preserved candidate in the agent instruction editor."), + payload: instructionSave }); + }; if (managedAiRuntime && aiBinding) { try { await assertManagedAiProjectAuth({ ...resolvedConfig, cwd: executionWorkspace.cwd }, aiBinding.provider, executionTarget); } catch { throw new ConfigurationIncompleteFailure("Project authentication conflicts with this agent’s managed AI connection", { configurationIncomplete: { reason: "ai_connection_incompatible", actionUrl: `/agents/${agent.id}/runtime` } }); } @@ -23159,6 +23203,62 @@ export function heartbeatService( target: executionTarget, workspaceId: persistedExecutionWorkspace?.id ?? null, }); + const hasInstructionFilesystem = nativeRuntimeResolution.kind !== "native" + ? adapter.supportsInstructionsBundle === true + : !["claude_managed_agents_api", "aws_agentcore_harness_api"].includes(nativeRuntimeResolution.profile.backend); + if (hasInstructionFilesystem) { + try { + // Missing contract fields on a restored session mean the deployed + // legacy format. New sessions opt into whole-directory persistence. + const priorFileRun = taskSessionForRun?.lastRunId + ? await db.select({ profile: heartbeatRuns.runnerProfileJson }).from(heartbeatRuns).where(and( + eq(heartbeatRuns.id, taskSessionForRun.lastRunId), eq(heartbeatRuns.companyId, agent.companyId), eq(heartbeatRuns.agentId, agent.id))).then(rows => rows[0]) + : null; + const savedFileInput = parseObject(parseObject(run.runnerProfileJson).nativeExecutionInput); + const priorFileInput = Object.keys(savedFileInput).length ? savedFileInput : parseObject(parseObject(priorFileRun?.profile).nativeExecutionInput); + const priorWorkingCopy = parseObject(parseObject(parseObject(priorFileInput.runtimeContext).instructions).workingCopy); + instructionCopy = await instructionCopies.prepare({ + companyId: agent.companyId, agentId: agent.id, runId: run.id, + target: executionTarget, cwd: executionWorkspace.cwd, + legacy: Object.keys(priorFileInput).length > 0 && priorWorkingCopy.kind !== "agent_files", + }); + } catch (error) { + if ((error as { status?: number }).status !== 403) throw error; + // Missing write identity must not break a background run's read-only + // prompt. It must also never imply that ordinary file edits will save. + await appendRunEvent(run, { eventType: "instruction_save", stream: "system", level: "warn", + message: "Persistent instruction editing is unavailable. Use an authenticated user with instruction edit access and a managed instruction bundle.", + payload: { state: "unavailable", code: "INSTRUCTION_COPY_UNAVAILABLE" } }); + const guidance = "No editable agent instruction working copy is registered for this turn. Use authenticated agent file tools for persistent edits; do not edit a private copy named in an earlier turn or claim its changes will persist."; + for (const key of ["paperclipTaskMarkdown", "paperclipTaskMarkdownCompact"]) { + context[key] = [readNonEmptyString(context[key]), guidance].filter(Boolean).join("\n\n"); + } + } + if (instructionCopy) { + const storageWarning = readNonEmptyString(instructionCopy.receipt?.storageWarning); + if (storageWarning) { + instructionSave = { state: "prepared", contract: "agent_files", storageWarning }; + // This is an advisory on the run, never an agent pause, execution + // failure, or scheduling gate. Keep it visible while work runs. + await db.update(heartbeatRuns).set({ resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || ${JSON.stringify({ instructionSave })}::jsonb` }).where(eq(heartbeatRuns.id, run.id)); + await appendRunEvent(run, { eventType: "instruction_save", stream: "system", level: "warn", + message: storageWarning, payload: instructionSave }); + } + runtimeConfig = { ...runtimeConfig, instructionsFilePath: path.join(instructionCopy.localRoot, instructionCopy.entryFile) }; + if (isAgentDirectoryCopy(instructionCopy)) { + const workspace = parseObject(context.paperclipWorkspace); + context.paperclipWorkspace = { ...workspace, agentHome: instructionCopy.executionRoot, + // Keep the pre-existing permission root stable for ACP session + // identity. The per-run copy is already under the company root. + agentHomeForPermissions: workspace.agentHome, + }; + } + const guidance = instructionWorkingCopyGuidance(instructionCopy); + for (const key of ["paperclipTaskMarkdown", "paperclipTaskMarkdownCompact"]) { + context[key] = [readNonEmptyString(context[key]), guidance].filter(Boolean).join("\n\n"); + } + } + } let nativeExecution: NativeExecutionInput | null = null; let nativeRunnerInstanceId: string | null = null; if (nativeRuntimeResolution.kind === "native") { @@ -23588,6 +23688,7 @@ export function heartbeatService( runId: run.id, runtimeConfig, runtimeSkillEntries, + instructionWorkingCopy: instructionCopy ? { rootPath: instructionCopy.executionRoot, entryPath: instructionCopy.entryFile, ...(isAgentDirectoryCopy(instructionCopy) ? { kind: "agent_files" as const } : {}) } : undefined, }); const nativeExecutionWithCheckpoint = buildNativeExecutionWithCheckpoint({ @@ -24295,6 +24396,10 @@ export function heartbeatService( }, onLog, onEvent: onAdapterEvent, + instructionWorkingCopy: instructionCopy ? { + hasChanges: () => instructionCopies.hasChanges({ companyId: agent.companyId, runId: run.id, target: executionTarget }), + collectStopped: collectStoppedInstructions, + } : undefined, preparationSpans: nativeRunnerPreparationSpans, // Bootstrap with executable/home discovery while keeping // configured provider values and the server-selected @@ -24308,6 +24413,7 @@ export function heartbeatService( process.env, executionWorkspace.cwd, ), + ...(instructionCopy && isAgentDirectoryCopy(instructionCopy) ? { AGENT_HOME: instructionCopy.executionRoot } : {}), ...(nativeMcpServer ? { PAPERCLIP_NATIVE_MCP_NAME: nativeMcpServer.name, @@ -24488,6 +24594,7 @@ export function heartbeatService( issueId, ); }, + onProviderStopped: collectStoppedInstructions, onDispatch: markDispatchStarted, signal: executionControl.controller.signal, ...(executionTarget?.kind === "remote" && executionTarget.transport === "sandbox" ? { @@ -24536,6 +24643,7 @@ export function heartbeatService( if (!guardedDispatch.dispatched) return; adapterResult = await guardedDispatch.resultPromise; } + if (instructionSave) adapterResult.resultJson = { ...adapterResult.resultJson, instructionSave }; adapterResult = applyWorkspaceRestoreFailure(adapterResult); // A returned result can include a failed restore. Keep the workspace // barrier closed until required files have been restored. @@ -24771,6 +24879,7 @@ export function heartbeatService( "failed to revoke heartbeat-run MCP gateway tokens", ); } + await instructionCopies.release(agent.companyId, run.id); } // Reconcile the referenced-project set against the real remote staging outcome. A referenced // project can pass authorization and clone locally at run prep, then fail to stage into the @@ -24933,8 +25042,8 @@ export function heartbeatService( : null; let logSummary: { - bytes: number; - sha256?: string; + bytes: number | null; + sha256?: string | null; compressed: boolean; } | null = null; if (handle) { @@ -25652,8 +25761,8 @@ export function heartbeatService( logger.error({ err, runId }, "heartbeat execution failed"); let logSummary: { - bytes: number; - sha256?: string; + bytes: number | null; + sha256?: string | null; compressed: boolean; } | null = null; if (handle) { @@ -26162,6 +26271,15 @@ export function heartbeatService( }, ); } + // A retained or unverified process stays above this release boundary. + // If no stopped-copy capture occurred, preserve an explicit loss report. + const uncapturedInstructions = await instructionCopies.reportUnavailable(run.companyId, run.id); + if (uncapturedInstructions?.state === "unavailable") { + await appendRunEvent(run, { eventType: "instruction_save", stream: "system", level: "warn", + message: "Instruction edits could not be recovered before environment release. No instruction save is claimed.", + payload: { state: "unavailable", code: uncapturedInstructions.errorCode } }); + } + await instructionCopies.release(run.companyId, run.id); await releaseEnvironmentLeasesForRun({ runId: run.id, companyId: run.companyId, diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 86cd7077e25..deef5871d5c 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -851,7 +851,7 @@ function normalizeCreateInteractionInput( ...input, payload: { ...input.payload, - supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true, + supersedeOnUserComment: input.payload.supersedeOnUserComment ?? false, }, }; case "request_confirmation": @@ -859,7 +859,7 @@ function normalizeCreateInteractionInput( ...input, payload: { ...input.payload, - supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true, + supersedeOnUserComment: input.payload.supersedeOnUserComment ?? false, }, }; case "request_checkbox_confirmation": @@ -867,7 +867,7 @@ function normalizeCreateInteractionInput( ...input, payload: { ...input.payload, - supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true, + supersedeOnUserComment: input.payload.supersedeOnUserComment ?? false, }, }; case "request_item_verdicts": @@ -875,7 +875,7 @@ function normalizeCreateInteractionInput( ...input, payload: { ...input.payload, - supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true, + supersedeOnUserComment: input.payload.supersedeOnUserComment ?? false, }, }; default: diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 9feb50394ab..ceb13379f74 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -231,6 +231,7 @@ const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES = 2_000_000; const ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES = 256_000; const ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS = 60_000; const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS = 8; +const ISSUE_COMMENT_RUN_LOG_DERIVATION_TIMEOUT_MS = 3_000; export const ISSUE_CREATE_IDEMPOTENCY_KEY_RETENTION_DAYS = 7; const ISSUE_CREATE_IDEMPOTENCY_KEY_RETENTION_MS = ISSUE_CREATE_IDEMPOTENCY_KEY_RETENTION_DAYS * 24 * 60 * 60 * 1000; @@ -6542,6 +6543,76 @@ async function countBlockedInboxIssues( }, 0); } +export async function readIssueCommentRunLogText(run: { + runId?: string | null; + logStore: string | null; + logRef: string | null; + logBytes: number | null; +}) { + if (run.logStore !== "local_file" || !run.logRef) return ""; + // A timed-out finalization leaves size unknown even when earlier entries + // exist. Read those logs within the same byte budget as a known-size log. + if (run.logBytes !== null && (!Number.isFinite(run.logBytes) || run.logBytes <= 0)) return ""; + + const logRef = run.logRef; + const store = getRunLogStore(); + let offset = 0; + let content = ""; + let nextOffset: number | undefined = 0; + const controller = new AbortController(); + let readTimer: NodeJS.Timeout | undefined; + + const readChunks = async () => { + while (nextOffset !== undefined) { + controller.signal.throwIfAborted(); + const remainingBytes = + ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES - + Buffer.byteLength(content, "utf8"); + if (remainingBytes <= 0) break; + const chunk = await store.read( + { store: "local_file", logRef }, + { + offset, + limitBytes: Math.min(ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES, remainingBytes), + signal: controller.signal, + }, + ); + controller.signal.throwIfAborted(); + content += chunk.content; + nextOffset = chunk.nextOffset; + offset = chunk.nextOffset ?? 0; + } + }; + + try { + await Promise.race([ + readChunks(), + new Promise((_resolve, reject) => { + readTimer = setTimeout(() => { + const reason = new DOMException("Attribution log read timed out", "TimeoutError"); + // Cancellation closes storage work where supported, but filesystem + // I/O can delay stream destruction. Keep the response deadline too. + reject(reason); + controller.abort(reason); + }, ISSUE_COMMENT_RUN_LOG_DERIVATION_TIMEOUT_MS); + readTimer.unref?.(); + }), + ]); + } catch (err) { + // Attribution enriches already-authorized comments. Missing, failed, or + // stalled storage must not prevent listing them; keep any evidence read. + // Do not log raw provider errors, which can contain credentialed URLs. + logger.warn( + { runId: run.runId ?? undefined, logRef, status: err instanceof HttpError ? err.status : undefined }, + "could not read heartbeat run log while deriving optional issue comment metadata", + ); + } finally { + clearTimeout(readTimer); + } + + return content; +} + export function issueService(db: Db) { const instanceSettings = instanceSettingsService(db); const treeControlSvc = issueTreeControlService(db); @@ -6760,54 +6831,6 @@ export function issueService(db: Db) { }; } - async function readRunLogText(run: { - runId?: string | null; - logStore: string | null; - logRef: string | null; - logBytes: number | null; - }) { - if (run.logStore !== "local_file" || !run.logRef) return ""; - const logBytes = Number(run.logBytes ?? 0); - if (!Number.isFinite(logBytes) || logBytes <= 0) return ""; - - const store = getRunLogStore(); - let offset = 0; - let content = ""; - let nextOffset: number | undefined = 0; - - try { - while (nextOffset !== undefined) { - const remainingBytes = - ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES - - Buffer.byteLength(content, "utf8"); - if (remainingBytes <= 0) break; - const chunk = await store.read( - { store: "local_file", logRef: run.logRef }, - { - offset, - limitBytes: Math.min( - ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES, - remainingBytes, - ), - }, - ); - content += chunk.content; - nextOffset = chunk.nextOffset; - offset = chunk.nextOffset ?? 0; - } - } catch (err) { - if (err instanceof HttpError && err.status === 404) { - logger.warn( - { err, runId: run.runId ?? undefined, logRef: run.logRef }, - "missing heartbeat run log while deriving issue comment metadata", - ); - return content; - } - throw err; - } - - return content; - } // Persist a resolved attribution so subsequent reads stop re-scanning run // logs (and old "Board" threads stay fixed durably). Best-effort: a write @@ -7027,7 +7050,7 @@ export function issueService(db: Db) { ); await Promise.all( batch.map(async (run) => { - logByRunId.set(run.runId, await readRunLogText(run)); + logByRunId.set(run.runId, await readIssueCommentRunLogText(run)); }), ); } diff --git a/server/src/services/native-runtime/agent-instruction-tools.integration.test.ts b/server/src/services/native-runtime/agent-instruction-tools.integration.test.ts new file mode 100644 index 00000000000..fe79d00aa8e --- /dev/null +++ b/server/src/services/native-runtime/agent-instruction-tools.integration.test.ts @@ -0,0 +1,89 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { agents, authUsers, companies, companyMemberships, principalPermissionGrants, heartbeatRuns, issues, agentInstructionRevisions, createDb } from "@paperclipai/db"; +import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; +import { resolveManagedInstructionsRoot } from "../agent-instructions.js"; +import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js"; + +describe("canonical instruction tools through native authority", () => { + let database: Awaited>; + let db: ReturnType; + let home: string; + const previousHome = process.env.PAPERCLIP_HOME; + let companyId: string, agentId: string, targetAgentId: string, userId: string, runId: string, issueId: string, root: string; + let authority: PaperclipRunnerToolAuthority; + const entryFile = "policy/ENTRY.md"; + const original = "\uFEFF# Original\r\n\0☃\n"; + beforeAll(async () => { + home = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "instruction-tool-home-"))); + process.env.PAPERCLIP_HOME = home; + database = await startEmbeddedPostgresTestDatabase("instruction-tool-db-"); + db = createDb(database.connectionString); + }, 90_000); + afterAll(async () => { + 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, agentId, targetAgentId, userId, runId, issueId] = Array.from({ length: 6 }, () => randomUUID()); + await db.insert(companies).values({ id: companyId, name: "Tool scope", 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: targetAgentId, companyId, name: "Target", adapterConfig: {} }); + await db.insert(agents).values([ + { id: agentId, companyId, name: "Caller", status: "active" }, + { id: targetAgentId, 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: [targetAgentId] } }, + { companyId, principalType: "agent", principalId: agentId, permissionKey: "agents:configure", scope: { agentIds: [targetAgentId] } }, + ]); + await db.insert(issues).values({ id: issueId, companyId, title: "Maintain instructions", status: "in_progress", workMode: "standard", assigneeAgentId: agentId }); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", runtimeMode: "native", nativeIssueId: issueId, invocationSource: "on_demand", responsibleUserId: userId }); + await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId)); + authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId }); + await fs.mkdir(path.dirname(path.join(root, entryFile)), { recursive: true }); + await fs.writeFile(path.join(root, entryFile), original); + }); + const call = (tool: string, args: Record = {}) => authority.execute({ tool, callId: randomUUID(), arguments: { targetAgentId, ...args } }) as Promise; + it("bridges old native instruction tools to current files without writing history", async () => { + const first = await call("read_agent_instructions"); + expect(first).toMatchObject({ entryFile, content: original }); + const changed = await call("update_agent_instructions", { entryFile, content: "changed\r\n", baseRevisionId: first.revision.id }); + expect(changed.revision).toMatchObject({ responsibleUserId: userId, actorAgentId: agentId, sourceRunId: runId }); + expect((await call("get_agent_instruction_history", { entryFile })).revisions).toHaveLength(0); + expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe("changed\r\n"); + await expect(call("update_agent_instructions", { entryFile, content: "stale", baseRevisionId: first.revision.id })).rejects.toMatchObject({ status: 409 }); + expect((await call("read_agent_instructions")).content).toBe("changed\r\n"); + }); + it("rejects supplied identity and missing CAS bases before changing content", async () => { + const first = await call("read_agent_instructions"); + for (const identity of ["companyId", "agentId", "runId", "responsibleUserId", "onBehalfOfUserId", "actor"]) { + await expect(call("update_agent_instructions", { entryFile, content: "forged", baseRevisionId: first.revision.id, [identity]: randomUUID() })).rejects.toMatchObject({ status: 400 }); + } + await expect(call("update_agent_instructions", { entryFile, content: "missing base" })).rejects.toMatchObject({ status: 400 }); + expect((await call("read_agent_instructions")).content).toBe(original); + expect(await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, targetAgentId))).toHaveLength(0); + }); + it("rechecks responsible-user access and rejects a cross-company target", async () => { + const first = await call("read_agent_instructions"); + await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, userId)); + await expect(call("update_agent_instructions", { entryFile, content: "revoked", baseRevisionId: first.revision.id })).rejects.toMatchObject({ status: 403 }); + const foreignCompanyId = randomUUID(), foreignAgentId = randomUUID(); + await db.insert(companies).values({ id: foreignCompanyId, name: "Foreign", issuePrefix: randomUUID().slice(0, 8) }); + await db.insert(agents).values({ id: foreignAgentId, companyId: foreignCompanyId, name: "Foreign" }); + await expect(call("read_agent_instructions", { targetAgentId: foreignAgentId })).rejects.toMatchObject({ status: 404 }); + await expect(call("update_agent_instructions", { targetAgentId: foreignAgentId, entryFile, content: "foreign", baseRevisionId: null })).rejects.toMatchObject({ status: 404 }); + await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, runId)); + await expect(call("read_agent_instructions")).rejects.toThrow("paperclip_runner_tool_binding_not_authorized"); + expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(original); + }); +}); diff --git a/server/src/services/native-runtime/agent-instruction-tools.test.ts b/server/src/services/native-runtime/agent-instruction-tools.test.ts new file mode 100644 index 00000000000..619d05a9f6b --- /dev/null +++ b/server/src/services/native-runtime/agent-instruction-tools.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import type { Db } from "@paperclipai/db"; +import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js"; + +const binding = { companyId: "00000000-0000-4000-8000-000000000001", agentId: "00000000-0000-4000-8000-000000000002", issueId: "00000000-0000-4000-8000-000000000003", runId: "00000000-0000-4000-8000-000000000004" }; + +describe("dedicated canonical instruction tools", () => { + it("advertises read, CAS update, history, and restore on ordinary native runs", () => { + const authority = new PaperclipRunnerToolAuthority({} as Db, binding); + const tools = authority.definitions(); + expect(tools.map((tool) => tool.name)).toEqual(expect.arrayContaining([ + "read_agent_instructions", "update_agent_instructions", "get_agent_instruction_history", "restore_agent_instructions", + ])); + for (const name of ["update_agent_instructions", "restore_agent_instructions"]) { + const tool = tools.find((candidate) => candidate.name === name)!; + expect(tool.inputSchema).toMatchObject({ required: expect.arrayContaining(["baseRevisionId"]), additionalProperties: false }); + expect(tool.inputSchema).not.toHaveProperty("properties.responsibleUserId"); + } + }); +}); diff --git a/server/src/services/native-runtime/agent-instruction-tools.ts b/server/src/services/native-runtime/agent-instruction-tools.ts new file mode 100644 index 00000000000..032e66699fa --- /dev/null +++ b/server/src/services/native-runtime/agent-instruction-tools.ts @@ -0,0 +1,76 @@ +import type { AgentInstructionCommitReceipt } from "@paperclipai/shared"; +import { z } from "zod"; +import type { Db } from "@paperclipai/db"; +import { badRequest, notFound } from "../../errors.js"; +import { agentInstructionRevisionService } from "../agent-instruction-revisions.js"; +import { agentInstructionWorkingCopyService } from "../agent-instruction-working-copies.js"; +import { deriveBundleState } from "../agent-instructions.js"; +import { agentService } from "../agents.js"; +import type { AuthorizationActor } from "../authorization.js"; + +const target = { targetAgentId: z.string().uuid().optional() }; +const entryFile = z.string().min(1).max(4096); +const baseRevisionId = z.string().uuid().nullable(); +const schemas = { + read_agent_instructions: z.object({ ...target, entryFile: entryFile.optional(), revisionId: z.string().uuid().optional() }).strict() + .refine((input) => Boolean(input.entryFile) === Boolean(input.revisionId), "Historical reads require both entryFile and revisionId"), + update_agent_instructions: z.object({ ...target, entryFile, content: z.string().max(1024 * 1024), baseRevisionId }).strict(), + get_agent_instruction_history: z.object({ ...target, entryFile, cursor: z.string().min(1).max(2048).optional(), limit: z.number().int().min(1).max(100).optional() }).strict(), + restore_agent_instructions: z.object({ ...target, entryFile, revisionId: z.string().uuid(), baseRevisionId: z.string().uuid() }).strict(), +}; + +export type AgentInstructionToolName = keyof typeof schemas; + +/** Called only after the native authority validates its server-bound active run. */ +export async function executeAgentInstructionTool(input: { + db: Db; + binding: { companyId: string; agentId: string; runId: string }; + tool: AgentInstructionToolName; + arguments: unknown; +}) { + // Never accept an actor, responsible user, company, or source run from tool JSON. + const actor: AuthorizationActor = { type: "agent", source: "agent_jwt", companyId: input.binding.companyId, agentId: input.binding.agentId, runId: input.binding.runId }; + const service = agentInstructionRevisionService(input.db); + const parse = (schema: z.ZodType): T => { + const result = schema.safeParse(input.arguments); + if (!result.success) throw badRequest("Invalid instruction tool arguments", result.error.flatten()); + return result.data; + }; + const scope = (targetAgentId?: string) => ({ companyId: input.binding.companyId, agentId: targetAgentId ?? input.binding.agentId }); + const acknowledge = async (receipt: AgentInstructionCommitReceipt, targetAgentId?: string) => { + // The commit is already durable. A failed baseline refresh keeps the old CAS + // fence, so later cleanup preserves a conflict instead of overwriting it. + await agentInstructionWorkingCopyService(input.db).acknowledgeExplicitSave({ + ...scope(targetAgentId), runId: input.binding.runId, entryFile: receipt.revision.entryFile, + revisionId: receipt.revision.id, contentHash: receipt.revision.contentHash, + }).catch(() => undefined); + return receipt; + }; + switch (input.tool) { + case "read_agent_instructions": { + const args = parse(schemas.read_agent_instructions); + const target = scope(args.targetAgentId); + if (args.entryFile && args.revisionId) return service.readRevision({ ...target, entryFile: args.entryFile, revisionId: args.revisionId }, actor); + const snapshot = await service.readCurrent(target, actor); + if (snapshot) return { ...snapshot, entryFile: snapshot.revision.entryFile }; + // An unseeded empty entry still needs its configured filename for the first CAS. + const agent = await agentService(input.db).getById(target.agentId); + if (!agent || agent.companyId !== target.companyId) throw notFound("Agent not found"); + return { entryFile: deriveBundleState(agent).entryFile, revision: null, content: null }; + } + case "update_agent_instructions": { + const args = parse(schemas.update_agent_instructions); + const receipt = await service.commit({ ...scope(args.targetAgentId), entryFile: args.entryFile, content: args.content, baseRevisionId: args.baseRevisionId, source: "tool" }, actor); + return acknowledge(receipt, args.targetAgentId); + } + case "get_agent_instruction_history": { + const args = parse(schemas.get_agent_instruction_history); + return service.history({ ...scope(args.targetAgentId), entryFile: args.entryFile, cursor: args.cursor, limit: args.limit }, actor); + } + case "restore_agent_instructions": { + const args = parse(schemas.restore_agent_instructions); + const receipt = await service.restore({ ...scope(args.targetAgentId), entryFile: args.entryFile, revisionId: args.revisionId, baseRevisionId: args.baseRevisionId }, actor); + return acknowledge(receipt, args.targetAgentId); + } + } +} diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 39243c01488..ca537cb0fb6 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; import { access, cp, @@ -57,6 +58,11 @@ import { buildNativeHeartbeatPreparationSpans } from "./native-run-trace.js"; import { NativeRunnerOwnershipUnverifiedError } from "./native-runner-ownership.js"; import type { AdapterRuntimeEvent } from "../../adapters/index.js"; +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, readFileSync: vi.fn(actual.readFileSync) }; +}); + const githubAccess = vi.hoisted(() => ({ activate: vi.fn((_binding: { runId: string }) => vi.fn()), stop: vi.fn(async () => undefined), @@ -1201,6 +1207,54 @@ describe("remote provider pack manifest", () => { }); }); +describe("provider pack read diagnostics", () => { + it.each([ + ["EACCES", "permission_denied"], + ["EPERM", "permission_denied"], + ["EIO", "io_error"], + ])("reports %s without exposing the underlying filesystem message", (code, reason) => { + const root = join(tmpdir(), "private-provider-pack"); + const manifestPath = join(root, "provider-pack.json"); + const cause = Object.assign(new Error(`${code}: cannot read ${manifestPath}`), { + code, + path: manifestPath, + }); + vi.mocked(readFileSync).mockImplementationOnce(() => { throw cause; }); + let failure: Error | undefined; + try { readRemoteProviderPackManifest(root); } catch (error) { failure = error as Error; } + expect(readFileSync).toHaveBeenLastCalledWith(manifestPath, "utf8"); + expect(failure?.message).toBe(`runner_remote_provider_artifact_incompatible: provider-pack.json is unreadable (${reason})`); + expect(failure?.message).not.toContain(root); + expect(failure?.message).not.toContain(code); + expect(failure?.cause).toBe(cause); + }); + + it("classifies a JSON null manifest as incompatible instead of a TypeError", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-null-pack-")); + try { + await writeFile(join(root, "provider-pack.json"), "null"); + expect(() => readRemoteProviderPackManifest(root)).toThrow( + "runner_remote_provider_artifact_incompatible: provider pack pins or source revision do not match", + ); + } finally { await rm(root, { recursive: true, force: true }); } + }); + + it.each(["missing", "invalid_json", "invalid_path_type"])("reports %s without putting the path in the terminal message", async (reason) => { + const root = await mkdtemp(join(tmpdir(), "paperclip-private-pack-")); + try { + const manifestPath = join(root, "provider-pack.json"); + if (reason === "invalid_json") await writeFile(manifestPath, "{ private-invalid-json"); + if (reason === "invalid_path_type") await mkdir(manifestPath); + let failure: Error | undefined; + try { readRemoteProviderPackManifest(root); } catch (error) { failure = error as Error; } + expect(failure?.message).toBe(`runner_remote_provider_artifact_incompatible: provider-pack.json is unreadable (${reason})`); + expect(failure?.message).not.toContain(root); + expect(failure?.message).not.toContain("private-invalid-json"); + expect(failure?.cause).toBeDefined(); + } finally { await rm(root, { recursive: true, force: true }); } + }); +}); + describe("native harness persistence profiles", () => { const profile = (provider: Record, driverKind: string) => resolveNativeHarnessPersistenceProfile({ @@ -1507,7 +1561,7 @@ describe("verified native harness backups", () => { }, sourceProviderLeaseId: "sandbox-1", }), - ).toThrow("runner_harness_state_mismatch"); + ).toThrow("runner_harness_state_mismatch: backup_provider_identity_missing"); } finally { await rm(root, { recursive: true, force: true }); } @@ -5863,6 +5917,57 @@ describe("native session same-turn steering", () => { }); describe("native warm session supervision", () => { + it.each([ + { changed: false, closeFails: false }, + { changed: true, closeFails: false }, + { changed: true, closeFails: true }, + ])("collects changed instructions only after the owned warm provider stops (changed=$changed, close fails=$closeFails)", async ({ changed, closeFails }) => { + const identity = `instruction-close-${changed}-${closeFails}`; + let releaseClose!: () => void; + const closed = new Promise((resolve) => { releaseClose = resolve; }); + const close = vi.fn(async () => { await closed; if (closeFails) throw new Error("instruction provider close failed"); }); + const collectStopped = vi.fn(async () => {}); + const hasChanges = vi.fn(async () => changed); + const warmExecution = { ...execution, + binding: { ...execution.binding, runId: identity, executionWorkspaceId: identity }, + session: { ...execution.session, normalizedSessionId: identity, lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 60_000 } }, + } as NativeExecutionInputV1; + state.execute.mockReset().mockImplementationOnce(async (options) => { + expect(options.requireSessionCloseBeforeReturn).toBe(true); + expect(options.onSessionClosed).toBe(collectStopped); + await options.onSession?.({ close }); + return { result: { summary: "completed" }, terminal: { runTerminalState: "succeeded" }, + turnId: identity, normalizedSessionId: identity, providerSessionId: identity, + driverKind: "test", driverVersion: "1", nativeEventCount: 1, highestContiguousSourceSeq: 1, usage: null }; + }); + const running = executePaperclipNativeSession({ db: leaseDb(warmExecution), execution: warmExecution, + runnerInstanceId: identity, runnerExecutionTarget: { kind: "remote", transport: "sandbox", environmentId: identity, remoteCwd: `/tmp/${identity}` }, + instructionWorkingCopy: { hasChanges, collectStopped } }); + const observed = running.then(() => null, error => error); + try { + if (changed) { + await vi.waitFor(() => expect(close).toHaveBeenCalledOnce()); + expect(collectStopped).not.toHaveBeenCalled(); + releaseClose(); + const error = await observed; + if (closeFails) { + expect(error?.message).toBe("instruction provider close failed"); + expect(collectStopped).not.toHaveBeenCalled(); + } else { + expect(error).toBeNull(); + expect(collectStopped).toHaveBeenCalledOnce(); + } + } else { + expect(await observed).toBeNull(); + expect(close).not.toHaveBeenCalled(); + expect(collectStopped).not.toHaveBeenCalled(); + } + } finally { + releaseClose(); + await closeWarmNativeSessionsForEnvironment({ environmentId: identity, reason: "test cleanup" }); + } + }); + describe("warm session identity transitions", () => { let previousHome: string | undefined; let isolatedHome: string; @@ -9166,7 +9271,7 @@ describe("runnerd provider runtime wiring", () => { execution: currentExecution, runnerInstanceId: "runner-after-running-prior-scope", }), - ).rejects.toThrow("runner_state_identity_mismatch"); + ).rejects.toThrow("runner_state_identity_mismatch: prior_owner_active"); await expect(access(scopedRoot)).resolves.toBeUndefined(); await expect(access(join(stateBase, "quarantine"))).rejects.toThrow(); expect(state.createBackend).not.toHaveBeenCalled(); @@ -10738,7 +10843,7 @@ describe("runnerd provider runtime wiring", () => { } // Ambiguous ordinary recovery must still fail closed. Only the runtime's // explicitly admitted replacement may retire these prior-session backups. - await expect(prepareReplacement()).rejects.toThrow("runner_harness_state_mismatch"); + await expect(prepareReplacement()).rejects.toThrow("runner_harness_state_mismatch: backup_without_reusable_lease"); await expect(backend.openReplacementSession!({ identity: { runId: execution.binding.runId }, workingDirectory: execution.workspace.cwd, } as never, {} as never)).resolves.toBe(replacement); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 9e714a7cea2..36884c09809 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -1705,7 +1705,7 @@ function migrateLegacyRunnerdStateRoot(input: { // A legacy path does not encode the full session scope. A mismatch may be // valid live state owned by another agent/workspace, so refusing the claim // is safe but moving that ambiguous directory is not. - throw new Error("runner_state_identity_mismatch"); + throw new Error("runner_state_identity_mismatch: legacy_owner_unverified"); } if ( exactRun && @@ -1718,7 +1718,7 @@ function migrateLegacyRunnerdStateRoot(input: { ) ) { quarantineRunnerdStateRoot(input.legacy, "identity_indeterminate"); - throw new Error("runner_state_identity_mismatch"); + throw new Error("runner_state_identity_mismatch: legacy_authority_indeterminate"); } try { renameSync(input.legacy, input.scoped); @@ -1742,7 +1742,7 @@ function migrateLegacyRunnerdStateRoot(input: { durableIdentityMatchesSession(scopedIdentity, input.execution), ); if (!exactScopedRun && !sameVerifiedPriorRun) { - throw new Error("runner_state_identity_mismatch"); + throw new Error("runner_state_identity_mismatch: migration_destination_owner_changed"); } } return input.scoped; @@ -4587,7 +4587,7 @@ async function migrateRunnerdStateRootForExecution(input: { await recoverQuiescentRunnerdState({ ...input, scoped }); } if (input.restartRecovery?.kind === "reattach_remote_runner" && !existsSync(scoped)) { - throw new Error("runner_state_identity_mismatch"); + throw new Error("runner_state_identity_mismatch: remote_reattach_root_missing"); } if (existsSync(scoped)) { if (!isSafeNativeStateDirectory(scoped)) { @@ -4607,14 +4607,14 @@ async function migrateRunnerdStateRootForExecution(input: { input.restartRecovery?.kind !== "reattach_remote_runner") { quarantineRunnerdStateRoot(scoped, "identity_indeterminate"); } - throw new Error("runner_state_identity_mismatch"); + throw new Error("runner_state_identity_mismatch: durable_identity_unreadable"); } if (!durableIdentityMatchesSession(identity, input.execution)) { if (input.restartRecovery?.kind !== "reattach_existing_runner" && input.restartRecovery?.kind !== "reattach_remote_runner") { quarantineRunnerdStateRoot(scoped, "identity_mismatch"); } - throw new Error("runner_state_identity_mismatch"); + throw new Error("runner_state_identity_mismatch: session_scope_mismatch"); } if (input.restartRecovery?.kind === "bootstrap_incomplete") { if (runnerdStateProvesIncompleteBootstrap(scoped)) { @@ -4624,7 +4624,7 @@ async function migrateRunnerdStateRootForExecution(input: { // Database evidence alone cannot distinguish a never-connected runner // from a partially-persisted provider bootstrap. Only the durable PRP // root can authorize a fresh bootstrap; anything else stays fail-closed. - throw new Error("runner_state_identity_mismatch"); + throw new Error("runner_state_identity_mismatch: bootstrap_not_proven_incomplete"); } if (input.restartRecovery?.kind === "reattach_remote_runner") { await verifyRemoteRunnerReattachment({ @@ -4645,7 +4645,7 @@ async function migrateRunnerdStateRootForExecution(input: { if (input.restartRecovery?.kind !== "reattach_existing_runner") { quarantineRunnerdStateRoot(scoped, "identity_indeterminate"); } - throw new Error("runner_state_identity_mismatch"); + throw new Error("runner_state_identity_mismatch: authority_indeterminate"); } } else { const verification = await verifyPriorRunnerdStateForSessionScope({ @@ -4668,7 +4668,7 @@ async function migrateRunnerdStateRootForExecution(input: { : "identity_indeterminate", ); } - throw new Error("runner_state_identity_mismatch"); + throw new Error(`runner_state_identity_mismatch: prior_owner_${verification}`); } } return; @@ -4686,7 +4686,7 @@ async function migrateRunnerdStateRootForExecution(input: { // Unlike the full-scope target above, this legacy name can legitimately // belong to another scope. Leave it in place for its owner and fail the // attempted migration visibly. - throw new Error("runner_state_identity_mismatch"); + throw new Error("runner_state_identity_mismatch: legacy_session_scope_mismatch"); } let verifiedPriorRunId: string | undefined; if (!durableIdentityMatchesExecution(identity, input.execution)) { @@ -4709,7 +4709,7 @@ async function migrateRunnerdStateRootForExecution(input: { // untouched because the legacy name may still belong to them. quarantineRunnerdStateRoot(legacy, "identity_indeterminate"); } - throw new Error("runner_state_identity_mismatch"); + throw new Error(`runner_state_identity_mismatch: legacy_prior_owner_${verification}`); } verifiedPriorRunId = identity.runId; } @@ -4759,7 +4759,7 @@ async function recoverQuiescentRunnerdState(input: { ) { // Do not roll back to an older valid checkpoint when a newer quarantined // root contains unconfirmed work, even if the newer root is unreadable. - throw new Error("runner_state_identity_mismatch"); + throw new Error("runner_state_identity_mismatch: quarantine_candidates_ambiguous"); } const verified: Array<{ root: string; @@ -4946,7 +4946,7 @@ async function recoverQuiescentRunnerdState(input: { ) { // Known provider history is not permission to start a replacement when // recovery cannot prove a unique, settled owner. - throw new Error("runner_state_identity_mismatch"); + throw new Error("runner_state_identity_mismatch: quarantine_owner_unverified"); } return; } @@ -4965,14 +4965,14 @@ async function recoverQuiescentRunnerdState(input: { "recovery_state_too_large", ).toString("utf8") !== expected ) { - throw new Error("runner_state_identity_mismatch"); + throw new Error("runner_state_identity_mismatch: recovery_evidence_changed"); } } if ( !localProcessDefinitelyGone(candidate.processPid) || !localProcessDefinitelyGone(candidate.processGroupId, true) ) { - throw new Error("runner_state_identity_mismatch"); + throw new Error("runner_state_identity_mismatch: recovery_process_not_gone"); } if (candidate.root !== input.scoped) { if (existsSync(input.scoped)) { @@ -5687,12 +5687,12 @@ export function buildNativeHarnessBackupManifest(input: { completedAt?: string; }): NativeHarnessBackupManifest { if (!providerSessionIdentityIsPresent(input.providerSessionIdentity)) { - throw new Error("runner_harness_state_mismatch"); + throw new Error("runner_harness_state_mismatch: backup_provider_identity_missing"); } const profile = resolveNativeHarnessPersistenceProfile(input.execution); const directories = profile.directories.map((directory) => { const path = resolve(input.backupRoot, directory.name); - if (!existsSync(path)) throw new Error("runner_harness_state_mismatch"); + if (!existsSync(path)) throw new Error("runner_harness_state_mismatch: backup_directory_missing"); return { name: directory.name, ...digestBackupDirectory(path) }; }); return { @@ -7181,6 +7181,12 @@ export async function executePaperclipNativeSession(input: { chatAttachmentReadScope?: NativeChatAttachmentReadScope; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; onEvent?: (event: AdapterRuntimeEvent) => Promise; + /** Only this run's registered private instruction entry. + * Probe at terminal; persist only after owned shutdown. */ + instructionWorkingCopy?: { + hasChanges: () => Promise; + collectStopped: () => Promise; + }; /** Persist task-level continuity before a durable goal can outlive this run. */ onGoalCheckpoint?: (snapshot: PersistedNativeSession) => Promise; sessionGoalControl?: NativeSessionGoalControl | null; @@ -8320,7 +8326,8 @@ async function executePaperclipNativeSessionWithinScope( resumeSessionGoalHeartbeat: input.resumeSessionGoalHeartbeat, // Every durable runner must finish its bounded suspension before // the next run verifies and rotates the saved authority. - requireSessionCloseBeforeReturn: runnerdBackend !== null, + requireSessionCloseBeforeReturn: runnerdBackend !== null || input.instructionWorkingCopy !== undefined, + onSessionClosed: input.instructionWorkingCopy?.collectStopped, onCheckpoint: async (snapshot) => { if (warmSessionId !== null && warmConfigDigest !== null) { await persistWarmNativeCheckpoint( @@ -9080,12 +9087,22 @@ async function executePaperclipNativeSessionWithinScope( // A following run cannot attach until the prior run's durable finalization // is committed. Provider completion alone is not an authority boundary. if (warmSessionId !== null && lifecyclePolicy.mode === "warm") { + const instructionCopy = input.instructionWorkingCopy; + const ownedSession = warmNativeSessions.get(warmSessionId); + const collectInstructions = Boolean(instructionCopy && ownedSession?.ownerToken === warmSessionOwnerToken && + await instructionCopy.hasChanges()); + if (collectInstructions && ownedSession) { + // Keep the unchanged warm path intact. A changed private instruction copy + // requires the existing checkpoint-and-close boundary before collection. + ownedSession.closeOnReleaseReason = "registered instruction edits require stopped-provider collection"; + } await releaseWarmNativeSession( warmSessionId, warmSessionOwnerToken, lifecyclePolicy.idleTimeoutMs, false, ); + if (collectInstructions) await instructionCopy!.collectStopped(); } const adapterResult: AdapterExecutionResult = { exitCode: native.terminal.runTerminalState === "succeeded" ? 0 : 1, @@ -9399,14 +9416,22 @@ export function readRemoteProviderPackManifest( readFileSync(resolve(packRoot, "provider-pack.json"), "utf8"), ) as RemoteProviderPackManifest; } catch (error) { + // The terminal run report keeps the outer message, not the cause chain. + // Keep a bounded reason there; raw filesystem errors include private paths. + const code = (error as NodeJS.ErrnoException | null)?.code; + const reason = error instanceof SyntaxError ? "invalid_json" + : code === "ENOENT" ? "missing" + : code === "EACCES" || code === "EPERM" ? "permission_denied" + : code === "EISDIR" || code === "ENOTDIR" ? "invalid_path_type" + : "io_error"; throw new Error( - "runner_remote_provider_artifact_incompatible: provider-pack.json is unreadable", + `runner_remote_provider_artifact_incompatible: provider-pack.json is unreadable (${reason})`, { cause: error }, ); } const payload = manifest?.payload; if ( - manifest.schema !== REMOTE_PROVIDER_PACK_SCHEMA || + manifest?.schema !== REMOTE_PROVIDER_PACK_SCHEMA || !payload || canonicalJson(payload.pins) !== canonicalJson(REMOTE_PROVIDER_PACK_PINS) || canonicalJson(payload.acpxProfileDigests) !== @@ -11601,7 +11626,7 @@ async function createRunnerdBackendWithinSessionClaim( async () => { for (const directory of persistenceProfile.directories) { const targetPath = remotePersistencePath(directory); - if (!targetPath) throw new Error("runner_harness_state_mismatch"); + if (!targetPath) throw new Error("runner_harness_state_mismatch: restore_target_unavailable"); await stageRemoteRunnerDirectory({ target: remoteTarget, runner: remoteCommandRunner, @@ -11629,7 +11654,7 @@ async function createRunnerdBackendWithinSessionClaim( canonicalJson(restored.providerSessionIdentity) !== canonicalJson(backup.manifest.providerSessionIdentity) ) { - throw new Error("runner_harness_state_mismatch"); + throw new Error("runner_harness_state_mismatch: restored_provider_identity_changed"); } // A deliberately non-reusable environment receives a fresh provider lease // for every turn. Stamp that new lease as soon as the verified host backup @@ -11644,7 +11669,7 @@ async function createRunnerdBackendWithinSessionClaim( for (const directory of persistenceProfile.directories) { if (directory.location !== "filesystem") continue; const targetPath = remotePersistencePath(directory); - if (!targetPath) throw new Error("runner_harness_state_mismatch"); + if (!targetPath) throw new Error("runner_harness_state_mismatch: bootstrap_target_unavailable"); const escapedTarget = targetPath.replaceAll("'", "'\\''"); const created = await remoteCommandRunner.execute({ command: "sh", @@ -11824,7 +11849,7 @@ async function createRunnerdBackendWithinSessionClaim( // A continuation that has a durable backup but no recorded reusable // lease was not provider-confirmed lost. Never silently create a new // provider session from that ambiguous state. - throw new Error("runner_harness_state_mismatch"); + throw new Error("runner_harness_state_mismatch: backup_without_reusable_lease"); } } } else if (remoteTarget && remoteCommandRunner) { @@ -12008,7 +12033,7 @@ async function createRunnerdBackendWithinSessionClaim( !verified.runnerState || !verified.providerSessionIdentity ) { - throw new Error("runner_harness_state_mismatch"); + throw new Error("runner_harness_state_mismatch: checkpoint_identity_incomplete"); } const providerSessionIdentity = verified.providerSessionIdentity; @@ -12023,7 +12048,7 @@ async function createRunnerdBackendWithinSessionClaim( for (const directory of persistenceProfile.directories) { const sourcePath = remotePersistencePath(directory); if (!sourcePath) - throw new Error("runner_harness_state_mismatch"); + throw new Error("runner_harness_state_mismatch: checkpoint_source_unavailable"); const targetPath = resolve(pendingRoot, directory.name); await syncRemoteRunnerDirectoryOut({ runner: remoteCommandRunner, @@ -12033,7 +12058,7 @@ async function createRunnerdBackendWithinSessionClaim( excludeEntries: directory.excludeEntries, }); if (!existsSync(targetPath)) { - throw new Error("runner_harness_state_mismatch"); + throw new Error("runner_harness_state_mismatch: checkpoint_directory_missing"); } } const manifest = buildNativeHarnessBackupManifest({ diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts index 5b1c58b5ad6..6b5e6f0d33f 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts @@ -99,7 +99,7 @@ describe("PaperclipRunnerToolAuthority", () => { issueId, runId, }); - expect(authority.definitions()).toHaveLength(30); + expect(authority.definitions()).toHaveLength(34); const questions = authority.definitions().find(tool => tool.name === "request_human_input")!; expect(questions.description).toContain("ask only the next unanswered question"); expect(questions.description).toContain("Never infer answers"); diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index dab0e16d0fe..27fd2f45e65 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -1,3 +1,4 @@ +import { executeAgentInstructionTool } from "./agent-instruction-tools.js"; import { createReadStream } from "node:fs"; import { publicChatTaskUrl } from "../chat-task-url.js"; import type { createAssignedMcpTools } from "./assigned-mcp-tools.js"; @@ -83,6 +84,7 @@ import { } from "./chat-attachment-read.js"; const IMPLEMENTED_OPERATIONS = new Set([ + "read_agent_instructions", "update_agent_instructions", "get_agent_instruction_history", "restore_agent_instructions", "search_api", "call_api", "hire_agent", "get_task_context", "get_task_history", "search_tasks", "report_progress", "request_human_input", @@ -368,6 +370,13 @@ export class PaperclipRunnerToolAuthority { throw new Error("paperclip_runner_tool_mode_denied"); } switch (call.tool) { + case "read_agent_instructions": + case "update_agent_instructions": + case "get_agent_instruction_history": + case "restore_agent_instructions": + return executeAgentInstructionTool({ db: this.db, binding: { + companyId: this.binding.companyId, agentId: this.binding.agentId, runId: this.binding.runId, + }, tool: call.tool, arguments: call.arguments }); case "create_skill": { const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL; const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, context.actor.adapterType, this.binding.runId, context.run.responsibleUserId); @@ -1700,7 +1709,7 @@ export class PaperclipRunnerToolAuthority { acceptLabel: normalizedPayload.acceptLabel ?? "Confirm", rejectLabel: normalizedPayload.rejectLabel ?? "Request changes", rejectRequiresReason: normalizedPayload.rejectRequiresReason ?? false, - supersedeOnUserComment: normalizedPayload.supersedeOnUserComment ?? true, + supersedeOnUserComment: normalizedPayload.supersedeOnUserComment ?? false, } : {}), }, } as never, { agentId: this.binding.agentId, userId: null, identityContextId }); diff --git a/server/src/services/native-runtime/runner-api-catalog.ts b/server/src/services/native-runtime/runner-api-catalog.ts index d8f8fd7d79f..85da534726f 100644 --- a/server/src/services/native-runtime/runner-api-catalog.ts +++ b/server/src/services/native-runtime/runner-api-catalog.ts @@ -42,6 +42,10 @@ function words(text: string): string[] { } function dedicatedTools(method: string, path: string): string[] { + if (/\/agents\/\{[^}]+\}\/instructions-bundle\/file$/.test(path)) return method === "GET" ? ["read_agent_instructions"] : method === "PUT" ? ["update_agent_instructions"] : []; + if (/\/agents\/\{[^}]+\}\/instructions-bundle\/history$/.test(path) && method === "GET") return ["get_agent_instruction_history"]; + if (/\/agents\/\{[^}]+\}\/instructions-bundle\/revision\/\{[^}]+\}$/.test(path) && method === "GET") return ["read_agent_instructions"]; + if (/\/agents\/\{[^}]+\}\/instructions-bundle\/restore$/.test(path) && method === "POST") return ["restore_agent_instructions"]; if (/\/companies\/\{[^}]+\}\/skills$/.test(path) && method === "POST") return ["create_skill"]; if (/\/companies\/\{[^}]+\}\/agent-hires$/.test(path) && method === "POST") return ["hire_agent"]; if (/\/projects$/.test(path)) return method === "GET" ? ["list_projects"] : method === "POST" ? ["create_project"] : []; diff --git a/server/src/services/native-runtime/runner-api.test.ts b/server/src/services/native-runtime/runner-api.test.ts index 5cc31752644..0746456f259 100644 --- a/server/src/services/native-runtime/runner-api.test.ts +++ b/server/src/services/native-runtime/runner-api.test.ts @@ -72,6 +72,10 @@ describe("runner API catalog", () => { expect(runnerApiOperation(createProject).dedicatedTools).toEqual(["create_project"]); expect(runnerApiOperation(projects).dedicatedTools).toEqual(["list_projects"]); expect(runnerApiOperation("GET /api/companies/{companyId}/project-repositories").dedicatedTools).toEqual(["list_project_repositories"]); + expect(runnerApiOperation("GET /api/agents/{id}/instructions-bundle/file").dedicatedTools).toEqual(["read_agent_instructions"]); + expect(runnerApiOperation("PUT /api/agents/{id}/instructions-bundle/file").dedicatedTools).toEqual(["update_agent_instructions"]); + expect(runnerApiOperation("GET /api/agents/{id}/instructions-bundle/history").dedicatedTools).toEqual(["get_agent_instruction_history"]); + expect(runnerApiOperation("POST /api/agents/{id}/instructions-bundle/restore").dedicatedTools).toEqual(["restore_agent_instructions"]); expect(runnerApiOperation("POST /api/companies/{companyId}/agent-hires").dedicatedTools).toEqual(["hire_agent"]); expect(runnerApiOperation("POST /api/companies/{companyId}/agent-hires").dedicatedToolGuidance).toContain("inherits the caller's native runtime"); }); diff --git a/server/src/services/native-runtime/runtime-context.test.ts b/server/src/services/native-runtime/runtime-context.test.ts index a2903c177ac..f8996f1f7f6 100644 --- a/server/src/services/native-runtime/runtime-context.test.ts +++ b/server/src/services/native-runtime/runtime-context.test.ts @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const serviceMocks = vi.hoisted(() => ({ exportFiles: vi.fn(), + readCommittedForRuntime: vi.fn(), getEffectiveProfilesForAgent: vi.fn(), githubBotConnectionIdsForRun: vi.fn(), })); @@ -20,6 +21,11 @@ vi.mock("../chat-github-tools.js", () => ({ vi.mock("../agent-instructions.js", () => ({ agentInstructionsService: () => ({ exportFiles: serviceMocks.exportFiles }), + agentInstructionsBundleMode: (agent: { adapterConfig?: { instructionsBundleMode?: string } }) => agent.adapterConfig?.instructionsBundleMode ?? null, +})); + +vi.mock("../agent-instruction-revisions.js", () => ({ + agentInstructionRevisionService: () => ({ readCommittedForRuntime: serviceMocks.readCommittedForRuntime }), })); vi.mock("../tool-access.js", () => ({ @@ -50,6 +56,7 @@ async function makeTreeWritable(target: string): Promise { beforeEach(async () => { vi.clearAllMocks(); + serviceMocks.readCommittedForRuntime.mockResolvedValue(null); serviceMocks.githubBotConnectionIdsForRun.mockResolvedValue(new Set()); previousPaperclipHome = process.env.PAPERCLIP_HOME; previousInstanceId = process.env.PAPERCLIP_INSTANCE_ID; @@ -90,6 +97,60 @@ afterEach(async () => { }); describe("buildNativeRuntimeContext", () => { + it("uses committed instructions when the disk projection is stale", async () => { + serviceMocks.exportFiles.mockResolvedValue({ + entryFile: "AGENTS.md", + files: { "AGENTS.md": "Old disk projection\n", "reference.md": "Sibling reference\n" }, + }); + serviceMocks.readCommittedForRuntime.mockResolvedValue({ + revision: { entryFile: "AGENTS.md" }, content: "Saved canonical instructions\n", + }); + const context = await buildNativeRuntimeContext({ + db: {} as Db, + agent: { id: "agent-1", companyId: "company-1", name: "Reviewer", adapterType: "paperclip_runner", adapterConfig: { instructionsBundleMode: "managed" } }, + runId: "run-1", runtimeConfig: {}, runtimeSkillEntries: [], + }); + expect(await readFile(path.join(context.instructions.bundle.rootPath, context.instructions.entryPath), "utf8")) + .toBe("Saved canonical instructions\n"); + expect(await readFile(path.join(context.instructions.bundle.rootPath, "reference.md"), "utf8")) + .toBe("Sibling reference\n"); + expect(serviceMocks.readCommittedForRuntime).toHaveBeenCalledWith({ companyId: "company-1", agentId: "agent-1" }); + }); + + it("does not substitute canonical managed instructions into an external bundle", async () => { + serviceMocks.exportFiles.mockResolvedValue({ entryFile: "AGENTS.md", files: { "AGENTS.md": "External instructions\n" } }); + const context = await buildNativeRuntimeContext({ + db: {} as Db, + agent: { id: "agent-1", companyId: "company-1", name: "Reviewer", adapterConfig: { instructionsBundleMode: "external" } }, + runId: "run-1", runtimeConfig: {}, runtimeSkillEntries: [], + }); + expect(await readFile(path.join(context.instructions.bundle.rootPath, context.instructions.entryPath), "utf8")) + .toBe("External instructions\n"); + expect(serviceMocks.readCommittedForRuntime).not.toHaveBeenCalled(); + }); + + it("keeps the pinned prompt immutable while exposing the registered editable instruction copy", async () => { + serviceMocks.exportFiles.mockResolvedValue({ + entryFile: "instructions/CHARter.md", + files: { "instructions/CHARter.md": "Original instructions.\n" }, + }); + const workingRoot = path.join(temporaryRoots.at(-1)!, "private-run-copy"); + await mkdir(path.join(workingRoot, "instructions"), { recursive: true }); + await writeFile(path.join(workingRoot, "instructions/CHARter.md"), "Original instructions.\n"); + const context = await buildNativeRuntimeContext({ + db: {} as Db, + agent: { id: "agent-1", companyId: "company-1", name: "Reviewer", adapterType: "paperclip_runner", adapterConfig: {} }, + runId: "run-1", + runtimeConfig: {}, + runtimeSkillEntries: [], + instructionWorkingCopy: { rootPath: workingRoot, entryPath: "instructions/CHARter.md" }, + }); + expect(context.instructions.workingCopy).toEqual({ rootPath: workingRoot, entryPath: "instructions/CHARter.md" }); + await writeFile(path.join(workingRoot, "instructions/CHARter.md"), "Persist this next time.\n"); + expect(await readFile(path.join(context.instructions.bundle.rootPath, context.instructions.entryPath), "utf8")) + .toBe("Original instructions.\n"); + }); + it.each(["disabled", "degraded"] as const)( "omits an unavailable native MCP connection when it is %s without aborting runtime context creation", async (unavailableState) => { diff --git a/server/src/services/native-runtime/runtime-context.ts b/server/src/services/native-runtime/runtime-context.ts index 32bb32426ea..5498f279988 100644 --- a/server/src/services/native-runtime/runtime-context.ts +++ b/server/src/services/native-runtime/runtime-context.ts @@ -21,7 +21,8 @@ import { type NativeRuntimeContextSnapshot, } from "../../vendor/paperclip-runner/index.js"; import { resolvePaperclipInstanceRoot } from "../../home-paths.js"; -import { agentInstructionsService } from "../agent-instructions.js"; +import { agentInstructionsService, agentInstructionsBundleMode } from "../agent-instructions.js"; +import { agentInstructionRevisionService } from "../agent-instruction-revisions.js"; import { toolAccessService } from "../tool-access.js"; import { filterResolvedGitHubConnectionsForRun } from "../git-credentials.js"; @@ -145,8 +146,24 @@ export async function materializeAsset(files: AssetFile[]): Promise ({ path: safeRelativePath(relativePath, "instruction path"), content: Buffer.from(content, "utf8"), mode: 0o444 })); @@ -222,12 +239,17 @@ export async function resolveNativeRuntimeMcpSnapshot(input: { db: Db; agent: Pi return { assignmentSetId: `sha256:${assignmentDigest}`, digest: assignmentDigest, bindingId: assignment.connections.length ? `native-mcp:${input.runId}` : null }; } -export async function buildNativeRuntimeContext(input: { db: Db; agent: RuntimeAgent; runId: string; runtimeConfig: Record; runtimeSkillEntries: PaperclipSkillEntry[] }): Promise { +export async function buildNativeRuntimeContext(input: { db: Db; agent: RuntimeAgent; runId: string; runtimeConfig: Record; runtimeSkillEntries: PaperclipSkillEntry[]; instructionWorkingCopy?: { rootPath: string; entryPath: string; kind?: "agent_files" } }): Promise { const [instructions, skills, mcp] = await Promise.all([ - materializeInstructionBundle(input.agent), + materializeInstructionBundle(input.db, input.agent, input.instructionWorkingCopy?.kind === "agent_files"), materializeSelectedSkills(input.runtimeConfig, input.runtimeSkillEntries, input.agent.adapterType === "paperclip_runner"), resolveNativeRuntimeMcpSnapshot({ db: input.db, agent: input.agent, runId: input.runId }), ]); - const snapshot = { prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() }, instructions, skills, mcp }; + const snapshot = { + prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() }, + instructions: { ...instructions, ...(input.instructionWorkingCopy ? { workingCopy: input.instructionWorkingCopy } : {}) }, + skills, + mcp, + }; return parseNativeRuntimeContext({ ...snapshot, aggregateDigest: canonicalNativeRuntimeContextDigest(snapshot) }); } diff --git a/server/src/services/plugin-managed-agents.ts b/server/src/services/plugin-managed-agents.ts index 13473ec8c0a..f86f7a439db 100644 --- a/server/src/services/plugin-managed-agents.ts +++ b/server/src/services/plugin-managed-agents.ts @@ -5,6 +5,8 @@ import { companies, pluginEntities, pluginManagedResources, + plugins, + activityLog, } from "@paperclipai/db"; import type { Agent, @@ -12,11 +14,13 @@ import type { PluginManagedAgentDeclaration, PluginManagedAgentResolution, } from "@paperclipai/shared"; -import { notFound } from "../errors.js"; +import { isUuidLike } from "@paperclipai/shared"; +import { conflict, forbidden, notFound } from "../errors.js"; import { agentService } from "./agents.js"; import { approvalService } from "./approvals.js"; import { logActivity } from "./activity-log.js"; import { agentInstructionsBundleMode, agentInstructionsService } from "./agent-instructions.js"; +import { agentInstructionRevisionService } from "./agent-instruction-revisions.js"; const MANAGED_AGENT_ENTITY_TYPE = "managed_agent"; const DEFAULT_MANAGED_AGENT_ADAPTER_TYPE = "process"; @@ -180,7 +184,7 @@ export function pluginManagedAgentService( ) { const agentSvc = agentService(db); const approvalSvc = approvalService(db); - const instructions = agentInstructionsService(); + const instructions = agentInstructionsService(db); function declarationFor(agentKey: string) { const declaration = options.manifest?.agents?.find((agent) => agent.agentKey === agentKey); @@ -190,8 +194,8 @@ export function pluginManagedAgentService( return declaration; } - async function getBinding(companyId: string, agentKey: string) { - return db + async function getBinding(companyId: string, agentKey: string, client: Pick = db) { + return client .select() .from(pluginEntities) .where( @@ -210,6 +214,7 @@ export function pluginManagedAgentService( agentId: string, extraData: Record = {}, effectiveAdapterType?: string, + client: Pick = db, ) { const adapterType = effectiveAdapterType ?? (await resolveManagedAdapterType(companyId, declaration)); const defaultsJson = { @@ -227,7 +232,7 @@ export function pluginManagedAgentService( budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0, instructions: declaration.instructions ?? null, }; - const managedResource = await db + const managedResource = await client .select({ id: pluginManagedResources.id }) .from(pluginManagedResources) .where(and( @@ -238,12 +243,12 @@ export function pluginManagedAgentService( )) .then((rows) => rows[0] ?? null); if (managedResource) { - await db + await client .update(pluginManagedResources) .set({ resourceId: agentId, defaultsJson, updatedAt: new Date() }) .where(eq(pluginManagedResources.id, managedResource.id)); } else { - await db.insert(pluginManagedResources).values({ + await client.insert(pluginManagedResources).values({ companyId, pluginId: options.pluginId, pluginKey: options.pluginKey, @@ -265,9 +270,9 @@ export function pluginManagedAgentService( lastReconciledAt: new Date().toISOString(), ...extraData, }; - const existing = await getBinding(companyId, declaration.agentKey); + const existing = await getBinding(companyId, declaration.agentKey, client); if (existing) { - return db + return client .update(pluginEntities) .set({ scopeKind: "company", @@ -281,7 +286,7 @@ export function pluginManagedAgentService( .returning() .then((rows) => rows[0]); } - return db + return client .insert(pluginEntities) .values({ pluginId: options.pluginId, @@ -305,6 +310,46 @@ export function pluginManagedAgentService( return rows.find((row) => rowIsManagedAgent(row, options.pluginKey, declaration.agentKey)) ?? null; } + async function relinkManagedAgent(companyId: string, declaration: PluginManagedAgentDeclaration, agentId: string) { + const adapterType = await resolveManagedAdapterType(companyId, declaration); + return db.transaction(async (tx) => { + const [agent] = await tx.select().from(agents).where(and( + eq(agents.id, agentId), eq(agents.companyId, companyId), ne(agents.status, "terminated"), + )).for("update"); + if (!agent || !rowIsManagedAgent(agent, options.pluginKey, declaration.agentKey)) { + throw conflict("Managed agent ownership changed before relink"); + } + const [plugin] = await tx.select().from(plugins).where(and( + eq(plugins.id, options.pluginId), eq(plugins.pluginKey, options.pluginKey), + )).for("share"); + if (!plugin || plugin.status !== "ready" || !plugin.manifestJson.capabilities.includes("agents.managed") + || !plugin.manifestJson.agents?.some((entry) => entry.agentKey === declaration.agentKey)) { + throw forbidden("Plugin relink is limited to its registered managed agent declaration"); + } + const marker = agent.metadata!.paperclipManagedResource as Record; + const previousPluginId = marker.pluginId; + if (typeof previousPluginId !== "string" || !isUuidLike(previousPluginId)) throw forbidden("Managed agent has no plugin owner"); + if (previousPluginId !== options.pluginId) { + const [previousPlugin] = await tx.select({ id: plugins.id }).from(plugins).where(eq(plugins.id, previousPluginId)); + if (previousPlugin) throw forbidden("Managed agent still belongs to another installed plugin"); + } + // Hard uninstall cascades the bindings, but deliberately leaves the agent + // and its canonical history. Transfer only its proven stable-key marker to + // the replacement installation, atomically with the new scoped bindings. + await upsertBinding(companyId, declaration, agentId, {}, adapterType, tx); + const [relinked] = await tx.update(agents).set({ + metadata: managedMetadata(options.pluginId, options.pluginKey, declaration, agent.metadata), + updatedAt: new Date(), + }).where(and(eq(agents.id, agentId), eq(agents.companyId, companyId))).returning(); + await tx.insert(activityLog).values({ + companyId, actorType: "plugin", actorId: options.pluginId, + action: "plugin.managed_agent.relinked", entityType: "agent", entityId: agentId, + details: { sourcePluginKey: options.pluginKey, managedResourceKey: declaration.agentKey, previousPluginId }, + }); + return relinked as Agent; + }); + } + async function companyAdapterUsage(companyId: string) { const rows = await db .select({ adapterType: agents.adapterType }) @@ -336,23 +381,39 @@ export function pluginManagedAgentService( const declared = declaredInstructionFiles(declaration, variables); if (!declared) return agent; - const materialized = await instructions.materializeManagedBundle( - agent, - declared.files, - { - entryFile: declared.entryFile, - replaceExisting: materializeOptions.replaceExisting, - clearLegacyPromptTemplate: true, - }, - ); + let adapterConfig: Record; + if (materializeOptions.replaceExisting) { + const revisions = agentInstructionRevisionService(db); + const target = { companyId, agentId: agent.id }; + const actor = { type: "plugin" as const, pluginId: options.pluginId, pluginKey: options.pluginKey, agentKey: declaration.agentKey }; + const baseline = await revisions.readForPluginReset(target, actor); + const receipt = await revisions.commitPluginReset({ ...target, entryFile: declared.entryFile, + baseRevisionId: baseline.snapshot?.revision.id ?? null, configuredEntryFile: baseline.configuredEntryFile, content: declared.files[declared.entryFile] ?? "" }, actor); + if (receipt.materialization === "pending") throw conflict("Instruction revision saved; retry reset to repair its disk copy", { revisionId: receipt.revision.id }); + const refreshed = await agentSvc.getById(agent.id); + if (!refreshed) throw notFound("Managed agent not found"); + for (const [file, content] of Object.entries(declared.files)) { + if (file !== declared.entryFile) await instructions.writeFile(refreshed, file, content); + } + // A stock reset owns declared instruction paths, not the agent's other + // persistent files (including a formerly configured entry). + adapterConfig = { ...refreshed.adapterConfig }; + delete adapterConfig.promptTemplate; + delete adapterConfig.bootstrapPromptTemplate; + } else { + const materialized = await instructions.materializeManagedBundle(agent, declared.files, { + entryFile: declared.entryFile, replaceExisting: false, clearLegacyPromptTemplate: true, + }); + adapterConfig = materialized.adapterConfig; + } const updated = await agentSvc.update(agent.id, { - adapterConfig: materialized.adapterConfig, + adapterConfig, }, { recordRevision: { source: `plugin:${optionsForRevisionSource()}:managed-agent-instructions`, }, }); - return (updated as Agent | null) ?? { ...agent, adapterConfig: materialized.adapterConfig }; + return (updated as Agent | null) ?? { ...agent, adapterConfig }; } async function managedInstructionDefaultDrift( @@ -375,8 +436,7 @@ export function pluginManagedAgentService( return { entryFile: declared.entryFile, changedFiles: [declared.entryFile] }; } - const paths = new Set([...Object.keys(declared.files), ...Object.keys(exported.files)]); - const changedFiles = [...paths] + const changedFiles = Object.keys(declared.files) .filter((filePath) => (exported.files[filePath] ?? null) !== (declared.files[filePath] ?? null)) .sort((left, right) => left.localeCompare(right)); if (exported.entryFile !== declared.entryFile && !changedFiles.includes(declared.entryFile)) { @@ -433,7 +493,7 @@ export function pluginManagedAgentService( spentMonthlyCents: 0, lastHeartbeatAt: null, }) as Agent; - created = await materializeDeclaredInstructions(companyId, created, declaration, { replaceExisting: true }); + created = await materializeDeclaredInstructions(companyId, created, declaration, { replaceExisting: false }); let approvalId: string | null = null; if (requiresApproval) { @@ -574,9 +634,7 @@ export function pluginManagedAgentService( const relinkCandidate = await findRelinkCandidate(companyId, declaration); if (relinkCandidate) { - await upsertBinding(companyId, declaration, relinkCandidate.id); - const relinkedAgent = await agentSvc.getById(relinkCandidate.id) as Agent | null; - if (!relinkedAgent) throw notFound("Managed agent not found"); + const relinkedAgent = await relinkManagedAgent(companyId, declaration, relinkCandidate.id); const agent = await backfillManagedPauseReason( companyId, declaration, @@ -596,8 +654,19 @@ export function pluginManagedAgentService( ? reconciled.agent.metadata : {}; const adapterType = await resolveManagedAdapterType(companyId, declaration); + // Reset content through the canonical CAS path before changing defaults. + // A conflict must leave the existing adapter configuration untouched. + const withInstructions = await materializeDeclaredInstructions(companyId, reconciled.agent, declaration, { replaceExisting: true }); + const defaults = declarationPatch(declaration, { adapterType }); + const adapterConfig = { ...defaults.adapterConfig } as Record; + if (declaration.instructions) { + for (const key of ["instructionsBundleMode", "instructionsRootPath", "instructionsEntryFile", "instructionsFilePath"]) { + if (withInstructions.adapterConfig[key] !== undefined) adapterConfig[key] = withInstructions.adapterConfig[key]; + } + } const updated = await agentSvc.update(reconciled.agent.id, { - ...declarationPatch(declaration, { adapterType }), + ...defaults, + adapterConfig, metadata: managedMetadata(options.pluginId, options.pluginKey, declaration, currentMetadata), }, { recordRevision: { @@ -605,7 +674,7 @@ export function pluginManagedAgentService( }, }); if (!updated) throw notFound("Managed agent not found"); - const updatedAgent = await materializeDeclaredInstructions(companyId, updated as Agent, declaration, { replaceExisting: true }); + const updatedAgent = updated as Agent; await upsertBinding(companyId, declaration, updatedAgent.id, {}, adapterType); await logActivity(db, { companyId, diff --git a/server/src/services/run-identity.ts b/server/src/services/run-identity.ts index dfc5f5bb896..496b58719d2 100644 --- a/server/src/services/run-identity.ts +++ b/server/src/services/run-identity.ts @@ -57,7 +57,13 @@ export async function explicitOperatorRunIdentity( export type RunIdentityContext = typeof runIdentityContexts.$inferSelect; type Executor = Pick; -/** Match task mutation ordering: lock the task before the run, never the reverse. */ +/** + * Lock the task before the run, matching task mutation ordering. Identity + * operations do not change parent keys: NO KEY UPDATE still serializes writers + * and steering, while allowing audit inserts to check their foreign keys. + * FOR UPDATE can deadlock with an append that holds KEY SHARE on the run and + * then checks the task while identity capture holds the task and waits on the run. + */ async function lockIdentityTask( executor: Pick, companyId: string, @@ -85,7 +91,7 @@ async function lockIdentityTask( .select({ id: issues.id }) .from(issues) .where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))) - .for("update"); + .for("no key update"); } async function append( @@ -181,7 +187,7 @@ export async function initializeRunIdentity( eq(heartbeatRuns.companyId, input.companyId), ), ) - .for("update"); + .for("no key update"); if (!run) throw forbidden("Run identity does not belong to this company"); if (run.activeIdentityContextId) { const [current] = await tx @@ -345,7 +351,7 @@ export async function reserveSteeredIdentity( eq(heartbeatRuns.companyId, input.companyId), ), ) - .for("update"); + .for("no key update"); // Processes started before the broker rollout keep their original environment. if (!run?.activeIdentityContextId) return null; const [pending] = await tx @@ -439,7 +445,7 @@ export async function captureRunIdentity( eq(heartbeatRuns.agentId, input.agentId), ), ) - .for("update"); + .for("no key update"); if (!run || run.status !== "running") throw forbidden( "Credential acquisition requires this agent's active run", @@ -512,7 +518,7 @@ export async function reconcileSteeredIdentity( eq(heartbeatRuns.companyId, context.companyId), ), ) - .for("update"); + .for("no key update"); if (!run) return; await acceptSteeredIdentity(tx, context); }); diff --git a/server/src/services/run-log-store-cancellation.test.ts b/server/src/services/run-log-store-cancellation.test.ts new file mode 100644 index 00000000000..36d405d132b --- /dev/null +++ b/server/src/services/run-log-store-cancellation.test.ts @@ -0,0 +1,72 @@ +import { createServer } from "node:http"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createDurableRunLogStore } from "./run-log-store.js"; +import { createS3StorageProvider } from "../storage/s3-provider.js"; + +afterEach(() => vi.unstubAllEnvs()); + +describe("run-log read cancellation", () => { + it.each(["head", "get", "body"])("closes a stalled S3 %s connection and permits a subsequent read", async (stage) => { + // Exercise the real SDK against an on-host server. No provider credentials + // or external network are used by this cancellation regression. + vi.stubEnv("AWS_ACCESS_KEY_ID", "test-access-key"); + vi.stubEnv("AWS_SECRET_ACCESS_KEY", "test-secret-key"); + vi.stubEnv("AWS_SESSION_TOKEN", ""); + const basePath = await fs.mkdtemp(path.join(os.tmpdir(), "run-log-abort-")); + let recover = false; + let closed = false; + let started!: () => void; + const stalled = new Promise((resolve) => { started = resolve; }); + const server = createServer((request, response) => { + const shouldStall = !recover && (stage === "head" ? request.method === "HEAD" : request.method === "GET"); + if (shouldStall) { + response.on("close", () => { closed = true; }); + if (stage === "body") { + response.writeHead(200, { "Content-Length": "4" }); + response.write("d"); + } + started(); + return; + } + response.writeHead(200, { "Content-Length": "4" }); + response.end(request.method === "HEAD" ? undefined : "data"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Test server did not bind"); + const provider = createS3StorageProvider({ + bucket: "test-bucket", region: "us-east-1", forcePathStyle: true, + endpoint: `http://127.0.0.1:${address.port}`, + }); + const store = createDurableRunLogStore({ basePath, s3: { provider } }); + const handle = { store: "local_file" as const, logRef: "missing.ndjson" }; + const controller = new AbortController(); + const read = store.read(handle, { signal: controller.signal }); + void read.catch(() => {}); + try { + await stalled; + controller.abort(); + await expect(read).rejects.toMatchObject({ name: "AbortError" }); + await vi.waitFor(() => expect(closed).toBe(true)); + recover = true; + expect(await store.read(handle)).toEqual({ content: "data", nextOffset: undefined }); + } finally { + controller.abort(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + await fs.rm(basePath, { recursive: true, force: true }); + } + }, 10_000); + + it("rejects an already-cancelled local read before opening a file", async () => { + const store = createDurableRunLogStore({ basePath: os.tmpdir() }); + const controller = new AbortController(); + controller.abort(); + await expect(store.read({ store: "local_file", logRef: "unused.ndjson" }, { + signal: controller.signal, + })).rejects.toMatchObject({ name: "AbortError" }); + }); +}); diff --git a/server/src/services/run-log-store.test.ts b/server/src/services/run-log-store.test.ts index ab12ae17b34..ccadb78c30f 100644 --- a/server/src/services/run-log-store.test.ts +++ b/server/src/services/run-log-store.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { promises as fs } from "node:fs"; import path from "node:path"; import os from "node:os"; +import { createHash } from "node:crypto"; import { Readable } from "node:stream"; import { createDurableRunLogStore } from "./run-log-store.js"; import type { StorageProvider } from "../storage/types.js"; @@ -100,6 +101,102 @@ describe("createDurableRunLogStore", () => { expect(objects.get(key)!.toString("utf8")).toContain("line-B"); }); + it.each(["finishes", "fails"])("waits for an accepted file append that %s before finalizing", async (outcome) => { + const { provider, objects } = createMemoryProvider(); + const store = createDurableRunLogStore({ basePath: baseDir, s3: { provider } }); + const handle = await store.begin(begin); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const appendFile = fs.appendFile.bind(fs); + const spy = vi.spyOn(fs, "appendFile").mockImplementationOnce(async (...args) => { + await gate; + if (outcome === "fails") throw new Error("disk unavailable"); + await appendFile(...args); + }); + const append = store.append(handle, { stream: "stderr", chunk: "late diagnostic", ts: "t1" }); + // Observe the deliberate rejection independently of finalization. + void append.catch(() => {}); + let finalized = false; + const finalize = store.finalize(handle).then((summary) => { + finalized = true; + return summary; + }); + try { + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(finalized).toBe(false); + release(); + if (outcome === "fails") await expect(append).rejects.toThrow("disk unavailable"); + else await append; + const summary = await finalize; + const local = await fs.readFile(path.join(baseDir, handle.logRef)); + expect(summary.bytes).toBe(local.length); + expect(summary.sha256).toBe(createHash("sha256").update(local).digest("hex")); + expect(objects.get(handle.logRef)).toEqual(local); + expect(local.toString()).toBe(outcome === "fails" ? "" : JSON.stringify({ + ts: "t1", stream: "stderr", chunk: "late diagnostic", + }) + "\n"); + } finally { + release(); + await append.catch(() => {}); + await finalize; + spy.mockRestore(); + } + }); + + it("ignores appends once finalization starts so the durable snapshot stays immutable", async () => { + const { provider, objects } = createMemoryProvider(); + const store = createDurableRunLogStore({ basePath: baseDir, s3: { provider } }); + const handle = await store.begin(begin); + await store.append(handle, { stream: "stdout", chunk: "accepted", ts: "t1" }); + const finalize = store.finalize(handle); + expect(await store.append(handle, { stream: "stderr", chunk: "too late", ts: "t2" })).toBe(0); + const summary = await finalize; + expect(await store.append(handle, { stream: "stderr", chunk: "also too late", ts: "t3" })).toBe(0); + const local = await fs.readFile(path.join(baseDir, handle.logRef)); + expect(local.toString()).not.toContain("too late"); + expect(summary.bytes).toBe(local.length); + expect(summary.sha256).toBe(createHash("sha256").update(local).digest("hex")); + expect(objects.get(handle.logRef)).toEqual(local); + }); + + it("leaves final metadata unknown when an append stalls and never mirrors its late completion", async () => { + const { provider, calls } = createMemoryProvider(); + const store = createDurableRunLogStore({ basePath: baseDir, s3: { provider, inflightMirrorMs: 10_000 } }); + const handle = await store.begin(begin); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const appendFile = fs.appendFile.bind(fs); + const spy = vi.spyOn(fs, "appendFile").mockImplementationOnce(async (...args) => { + await gate; + await appendFile(...args); + }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const append = store.append(handle, { stream: "stderr", chunk: "stalled diagnostic", ts: "t1" }); + let summary: Awaited> | undefined; + const finalize = store.finalize(handle).then((result) => { summary = result; }); + try { + await vi.advanceTimersByTimeAsync(3_000); + expect(summary).toEqual({ bytes: null, sha256: null, compressed: false }); + expect(warn).toHaveBeenCalled(); + expect(calls.put).toBe(0); + release(); + await append; + await vi.advanceTimersByTimeAsync(20_000); + await store.flushInflightMirrors!(); + expect(calls.put).toBe(0); + expect(await store.finalize(handle)).toEqual(summary); + expect(await store.append(handle, { stream: "stderr", chunk: "too late", ts: "t2" })).toBe(0); + } finally { + release(); + await append; + await finalize; + vi.useRealTimers(); + spy.mockRestore(); + warn.mockRestore(); + } + }); + it("falls back to S3 when the local file is gone (the pod-roll case that caused 'Run log not found')", async () => { const { provider } = createMemoryProvider(); const store = createDurableRunLogStore({ basePath: baseDir, s3: { provider, keyPrefix: "run-logs" } }); @@ -163,25 +260,15 @@ describe("createDurableRunLogStore", () => { expect(caughtUp.nextOffset).toBeUndefined(); }); - it("falls back to S3 when the local file vanishes between stat() and open (TOCTOU race)", async () => { - const { provider } = createMemoryProvider(); - const store = createDurableRunLogStore({ basePath: baseDir, s3: { provider, keyPrefix: "run-logs" } }); + it("reads local pages without waiting for a separate metadata request", async () => { + const store = createDurableRunLogStore({ basePath: baseDir }); const handle = await store.begin(begin); - await store.append(handle, { stream: "stdout", chunk: "raced-line", ts: "t1" }); - await store.finalize(handle); - // Delete the local file DURING stat(), i.e. after it reports the file - // present but before createReadStream opens it -> the open hits ENOENT. - const realStat = fs.stat.bind(fs); - const statSpy = vi.spyOn(fs, "stat").mockImplementation(async (target, ...rest) => { - const result = await realStat(target as Parameters[0], ...(rest as [])); - if (String(target).endsWith(".ndjson")) { - await fs.rm(target as string, { force: true }); - } - return result; - }); + await fs.writeFile(path.join(baseDir, handle.logRef), "0123456789"); + const statSpy = vi.spyOn(fs, "stat").mockRejectedValue(new Error("Metadata unavailable")); try { - const res = await store.read(handle); - expect(res.content).toContain("raced-line"); + expect(await store.read(handle, { offset: 2, limitBytes: 4 })).toEqual({ content: "2345", nextOffset: 6 }); + expect(await store.read(handle, { offset: 6, limitBytes: 4 })).toEqual({ content: "6789", nextOffset: undefined }); + expect(await store.read(handle, { offset: 20, limitBytes: 4 })).toEqual({ content: "", nextOffset: undefined }); } finally { statSpy.mockRestore(); } diff --git a/server/src/services/run-log-store.ts b/server/src/services/run-log-store.ts index f07ede9abdf..7e7b4597eae 100644 --- a/server/src/services/run-log-store.ts +++ b/server/src/services/run-log-store.ts @@ -1,6 +1,7 @@ import { createReadStream, promises as fs } from "node:fs"; import path from "node:path"; import { createHash } from "node:crypto"; +import { addAbortSignal } from "node:stream"; import { notFound } from "../errors.js"; import { resolvePaperclipInstanceRoot } from "../home-paths.js"; import { createS3StorageProvider } from "../storage/s3-provider.js"; @@ -16,6 +17,7 @@ export interface RunLogHandle { export interface RunLogReadOptions { offset?: number; limitBytes?: number; + signal?: AbortSignal; } export interface RunLogReadResult { @@ -24,8 +26,10 @@ export interface RunLogReadResult { } export interface RunLogFinalizeSummary { - bytes: number; - sha256?: string; + // Null means a stalled write prevented a verified final snapshot. Callers + // can still settle the run without recording a false byte count or hash. + bytes: number | null; + sha256?: string | null; compressed: boolean; } @@ -100,6 +104,13 @@ export function createDurableRunLogStore(options: DurableRunLogStoreOptions): Ru const s3 = options.s3; const s3Prefix = normalizeKeyPrefix(s3?.keyPrefix); const inflightMirrorMs = s3?.inflightMirrorMs && s3.inflightMirrorMs > 0 ? s3.inflightMirrorMs : 0; + // A run owns the write handle returned by begin(). Diagnostics can be + // dispatched without awaiting the rest of onLog (DB progress/live events), + // but finalize must include every file append already accepted on that handle. + // Weak collections let completed handles disappear with their owning runs. + const pendingAppends = new WeakMap>>(); + const closingHandles = new WeakSet(); + const abandonedHandles = new WeakSet(); function s3Key(logRef: string): string { return s3Prefix ? `${s3Prefix}/${logRef}` : logRef; @@ -159,6 +170,7 @@ export function createDurableRunLogStore(options: DurableRunLogStoreOptions): Ru } function scheduleInflightMirror(logRef: string, entry: InflightMirrorEntry): void { + if (inflightMirrors.get(logRef) !== entry) return; if (entry.timer || entry.upload) return; const delay = Math.max(0, inflightMirrorMs - (Date.now() - entry.lastMirrorAt)); entry.timer = setTimeout(() => { @@ -205,33 +217,28 @@ export function createDurableRunLogStore(options: DurableRunLogStoreOptions): Ru filePath: string, offset: number, limitBytes: number, + signal?: AbortSignal, ): Promise { - const stat = await fs.stat(filePath).catch(() => null); - if (!stat) return null; - const start = Math.max(0, Math.min(offset, stat.size)); - // No lower clamp to `start`: when the reader is fully caught up - // (offset === size) that clamp made end === start and produced a - // 1-byte-past-EOF range instead of an empty read. - const end = Math.min(start + limitBytes - 1, stat.size - 1); - if (start > end) return { content: "", nextOffset: start < stat.size ? start : undefined }; - + signal?.throwIfAborted(); + const start = Math.max(0, offset); + // Read one extra byte to discover whether another page exists. A single + // abortable stream avoids an uncancellable stat before opening the file. const chunks: Buffer[] = []; try { - await new Promise((resolve, reject) => { - const stream = createReadStream(filePath, { start, end }); - stream.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); - stream.on("error", reject); - stream.on("end", () => resolve()); - }); + const stream = createReadStream(filePath, { start, end: start + limitBytes, signal }); + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } } catch (err) { - // File deleted between stat() and open (pod-roll cleanup racing a read): - // treat as missing so the caller falls through to the S3 mirror instead - // of surfacing the very "Run log not found" this store exists to prevent. + signal?.throwIfAborted(); + // A missing file, including deletion before open, falls back to S3. if ((err as NodeJS.ErrnoException | null)?.code === "ENOENT") return null; throw err; } - const content = Buffer.concat(chunks).toString("utf8"); - const nextOffset = end + 1 < stat.size ? end + 1 : undefined; + signal?.throwIfAborted(); + const bytes = Buffer.concat(chunks); + const content = bytes.subarray(0, limitBytes).toString("utf8"); + const nextOffset = bytes.length > limitBytes ? start + limitBytes : undefined; return { content, nextOffset }; } @@ -239,10 +246,13 @@ export function createDurableRunLogStore(options: DurableRunLogStoreOptions): Ru logRef: string, offset: number, limitBytes: number, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); if (!s3) throw notFound("Run log not found"); const key = s3Key(logRef); - const head = await s3.provider.headObject({ objectKey: key }); + const head = await s3.provider.headObject({ objectKey: key, signal }); + signal?.throwIfAborted(); if (!head.exists) throw notFound("Run log not found"); const total = head.contentLength ?? 0; const start = Math.max(0, Math.min(offset, total)); @@ -253,13 +263,15 @@ export function createDurableRunLogStore(options: DurableRunLogStoreOptions): Ru const end = Math.min(start + limitBytes - 1, total - 1); if (total === 0 || start > end) return { content: "", nextOffset: start < total ? start : undefined }; - const result = await s3.provider.getObject({ objectKey: key, range: { start, end } }); + const result = await s3.provider.getObject({ objectKey: key, range: { start, end }, signal }); + // Destroy a body that stalls after headers arrive, including a body + // returned just after the caller cancelled the request. + if (signal) addAbortSignal(signal, result.stream); const chunks: Buffer[] = []; - await new Promise((resolve, reject) => { - result.stream.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); - result.stream.on("error", reject); - result.stream.on("end", () => resolve()); - }); + for await (const chunk of result.stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + signal?.throwIfAborted(); const content = Buffer.concat(chunks).toString("utf8"); const nextOffset = end + 1 < total ? end + 1 : undefined; return { content, nextOffset }; @@ -289,7 +301,7 @@ export function createDurableRunLogStore(options: DurableRunLogStoreOptions): Ru }, async append(handle, event) { - if (handle.store !== "local_file") return 0; + if (handle.store !== "local_file" || closingHandles.has(handle)) return 0; const absPath = resolveWithin(basePath, handle.logRef); const line = JSON.stringify({ ts: event.ts, @@ -301,13 +313,47 @@ export function createDurableRunLogStore(options: DurableRunLogStoreOptions): Ru ...(typeof event.seq === "number" && Number.isFinite(event.seq) ? { seq: event.seq } : {}), }); const persisted = `${line}\n`; - await fs.appendFile(absPath, persisted, "utf8"); - noteInflightAppend(handle.logRef); + let pending = pendingAppends.get(handle); + if (!pending) { + pending = new Set(); + pendingAppends.set(handle, pending); + } + const write = fs.appendFile(absPath, persisted, "utf8").then(() => { + if (!closingHandles.has(handle)) noteInflightAppend(handle.logRef); + }); + pending.add(write); + try { + await write; + } finally { + pending.delete(write); + } return Buffer.byteLength(persisted, "utf8"); }, async finalize(handle) { if (handle.store !== "local_file") return { bytes: 0, compressed: false }; + if (abandonedHandles.has(handle)) return { bytes: null, sha256: null, compressed: false }; + // Close admission before the first await. Drain file writes only, not + // heartbeat's later DB/live-event persistence, then freeze one consistent + // byte count, hash, and durable copy. Late diagnostics cannot mutate it. + closingHandles.add(handle); + let drainTimer: NodeJS.Timeout | undefined; + const drained = await Promise.race([ + Promise.allSettled(pendingAppends.get(handle) ?? []).then(() => true), + new Promise((resolve) => { + drainTimer = setTimeout(() => resolve(false), 3_000); + drainTimer.unref?.(); + }), + ]).finally(() => clearTimeout(drainTimer)); + if (!drained) { + // An in-flight fs append cannot be cancelled safely. Do not hash or + // mirror a file it may still change, and never re-arm mirroring when + // that write eventually finishes. Terminal run status can still settle. + abandonedHandles.add(handle); + void retireInflightMirror(handle.logRef).catch(() => undefined); + console.warn("[run-log-store] Pending log writes did not settle within 3000ms; final log size and hash are unknown."); + return { bytes: null, sha256: null, compressed: false }; + } await retireInflightMirror(handle.logRef); const absPath = resolveWithin(basePath, handle.logRef); const stat = await fs.stat(absPath).catch(() => null); @@ -344,14 +390,15 @@ export function createDurableRunLogStore(options: DurableRunLogStoreOptions): Ru }, async read(handle, opts) { + opts?.signal?.throwIfAborted(); if (handle.store !== "local_file") throw notFound("Run log not found"); const absPath = resolveWithin(basePath, handle.logRef); const offset = opts?.offset ?? 0; const limitBytes = opts?.limitBytes ?? 256_000; - const local = await readLocalRange(absPath, offset, limitBytes); + const local = await readLocalRange(absPath, offset, limitBytes, opts?.signal); if (local) return local; // Local file gone (pod rolled) -> serve from the S3 mirror if configured. - return readS3Range(handle.logRef, offset, limitBytes); + return readS3Range(handle.logRef, offset, limitBytes, opts?.signal); }, async flushInflightMirrors() { diff --git a/server/src/storage/s3-provider.ts b/server/src/storage/s3-provider.ts index 517ccf23d98..4939bb9b25f 100644 --- a/server/src/storage/s3-provider.ts +++ b/server/src/storage/s3-provider.ts @@ -6,7 +6,7 @@ import { PutObjectCommand, } from "@aws-sdk/client-s3"; import { putS3Multipart } from "./s3-multipart.js"; -import { Readable } from "node:stream"; +import { addAbortSignal, Readable } from "node:stream"; import type { StorageProvider, GetObjectResult, HeadObjectResult } from "./types.js"; import { notFound, unprocessable } from "../errors.js"; @@ -106,10 +106,13 @@ export function createS3StorageProvider(config: S3ProviderConfig): StorageProvid Key: key, Range: input.range ? `bytes=${input.range.start}-${input.range.end}` : undefined, }), + { abortSignal: input.signal }, ); + const stream = await toReadableStream(output.Body); + if (input.signal) addAbortSignal(input.signal, stream); return { - stream: await toReadableStream(output.Body), + stream, contentType: output.ContentType, contentLength: output.ContentLength, etag: output.ETag, @@ -130,6 +133,7 @@ export function createS3StorageProvider(config: S3ProviderConfig): StorageProvid Bucket: bucket, Key: key, }), + { abortSignal: input.signal }, ); return { diff --git a/server/src/storage/types.ts b/server/src/storage/types.ts index 15289f77a5d..30f0845b5b7 100644 --- a/server/src/storage/types.ts +++ b/server/src/storage/types.ts @@ -12,6 +12,8 @@ export interface PutObjectInput { export interface GetObjectInput { objectKey: string; + // S3 reads cancel pending requests and their response streams. + signal?: AbortSignal; range?: { start: number; end: number; diff --git a/tests/e2e/agent-chat-projects.spec.ts b/tests/e2e/agent-chat-projects.spec.ts index e9c89067a3e..88751f70d3f 100644 --- a/tests/e2e/agent-chat-projects.spec.ts +++ b/tests/e2e/agent-chat-projects.spec.ts @@ -246,11 +246,8 @@ for (const mode of ["Ask", "Plan"]) const f = await setup(request); try { await page.goto(f.route); - await page.getByTestId("task-chat-composer-mode").click(); - await page - .getByTestId("task-chat-composer-mode-menu") - .getByText(`${mode} mode`, { exact: true }) - .click(); + await page.getByTestId("task-chat-composer-add").click(); + await page.getByTestId(mode === "Plan" ? "composer-add-plan" : "composer-add-ask").click(); await send(page, { action: "project", name: "Forbidden mutation" }); await idle(request, f.chatPath); expect( @@ -387,11 +384,8 @@ test("plan approval hands the preserved revision to an assigned project task", a const f = await setup(request); try { await page.goto(f.route); - await page.getByTestId("task-chat-composer-mode").click(); - await page - .getByTestId("task-chat-composer-mode-menu") - .getByText("Plan mode", { exact: true }) - .click(); + await page.getByTestId("task-chat-composer-add").click(); + await page.getByTestId("composer-add-plan").click(); await send(page, { action: "plan", text: "# Approved welcome\nWrite two friendly sentences.", diff --git a/tests/e2e/artifact-tab-arrival.spec.ts b/tests/e2e/artifact-tab-arrival.spec.ts index a04a54f9b3f..e4f29c6adbd 100644 --- a/tests/e2e/artifact-tab-arrival.spec.ts +++ b/tests/e2e/artifact-tab-arrival.spec.ts @@ -50,7 +50,7 @@ for (const mobile of [false, true]) { await expect(artifacts).toBeVisible(); await expect(artifacts).toHaveAttribute("aria-selected", "false"); await artifacts.click(); - await expect(panel.getByText("Arriving report", { exact: true })).toBeVisible(); + await expect(panel.getByRole("heading", { name: "Arriving report", level: 2, exact: true })).toBeVisible(); await page.screenshot({ path: testInfo.outputPath("artifact-opened-by-user.png"), fullPage: true }); }); } diff --git a/tests/e2e/board-attachment-receipts.spec.ts b/tests/e2e/board-attachment-receipts.spec.ts index 3d0deb442d4..de7fb5cbde7 100644 --- a/tests/e2e/board-attachment-receipts.spec.ts +++ b/tests/e2e/board-attachment-receipts.spec.ts @@ -3,6 +3,7 @@ import { expect, test, type APIRequestContext, + type Locator, type Page, } from "@playwright/test"; @@ -95,15 +96,19 @@ const files = [ }, ]; +async function openAttachmentChooser(page: Page, composer: Locator) { + await composer.getByRole("button", { name: "Add to composer" }).click(); + const chooser = page.waitForEvent("filechooser"); + await page.getByRole("menuitem", { name: "Files and images", exact: true }).click(); + return chooser; +} + async function upload( page: Page, fixture: Awaited>, file: (typeof files)[number], ) { - const chooser = page.waitForEvent("filechooser"); - await fixture.composer - .getByRole("button", { name: "Attach file", exact: true }) - .click(); + const chooser = await openAttachmentChooser(page, fixture.composer); const response = page.waitForResponse( (res) => res.request().method() === "POST" && @@ -111,7 +116,7 @@ async function upload( `/issues/${fixture.issue.id}/attachments`, ), ); - await (await chooser).setFiles(file); + await chooser.setFiles(file); const receipt = await body(await response); await expect .poll(async () => @@ -376,11 +381,8 @@ for (const classic of [false, true]) await route.fulfill({ response }); }, ); - const chooser = page.waitForEvent("filechooser"); - await fixture.composer - .getByRole("button", { name: "Attach file", exact: true }) - .click(); - await (await chooser).setFiles(files[1]!); + const chooser = await openAttachmentChooser(page, fixture.composer); + await chooser.setFiles(files[1]!); await expect.poll(() => arrived).toBe(true); await expect(fixture.send).toBeDisabled(); try { @@ -430,11 +432,8 @@ test("legacy failed upload can be removed before sending the retained text", asy body: JSON.stringify({ error: "Fixture upload rejected" }), }), ); - const chooser = page.waitForEvent("filechooser"); - await fixture.composer - .getByRole("button", { name: "Attach file", exact: true }) - .click(); - await (await chooser).setFiles(files[0]!); + const chooser = await openAttachmentChooser(page, fixture.composer); + await chooser.setFiles(files[0]!); await expect( fixture.composer.getByText("Fixture upload rejected", { exact: true }), ).toBeVisible(); diff --git a/tests/e2e/chat-adapters-ui-messaging.spec.ts b/tests/e2e/chat-adapters-ui-messaging.spec.ts index 97ce3997b97..de11726d126 100644 --- a/tests/e2e/chat-adapters-ui-messaging.spec.ts +++ b/tests/e2e/chat-adapters-ui-messaging.spec.ts @@ -275,13 +275,14 @@ test.describe("Board send delivery refresh", () => { "base64", ), }; + if (!classic) { + await page.getByRole("button", { name: "Add to composer" }).click(); + } const chooserPromise = page.waitForEvent("filechooser"); - await page - .getByRole("button", { - name: classic ? "Upload attachment" : "Attach file", - exact: true, - }) - .click(); + await (classic + ? page.getByRole("button", { name: "Upload attachment", exact: true }) + : page.getByRole("menuitem", { name: "Files and images", exact: true }) + ).click(); const responsePromise = page.waitForResponse( (response) => response.request().method() === "POST" && diff --git a/tests/e2e/company-context-refresh.spec.ts b/tests/e2e/company-context-refresh.spec.ts new file mode 100644 index 00000000000..309bd841b88 --- /dev/null +++ b/tests/e2e/company-context-refresh.spec.ts @@ -0,0 +1,77 @@ +import path from "node:path"; +import { expect, test } from "@playwright/test"; +import { createServer, type ViteDevServer } from "../../ui/node_modules/vite/dist/node/index.js"; + +let server: ViteDevServer; +let origin: string; +const uiRoot = path.resolve(import.meta.dirname, "../../ui"); +const probePath = path.join(uiRoot, "src/__company_context_probe.tsx"); + +// Use real Vite module instances and React contexts. Re-importing a refreshed +// consumer must still see a provider retained from the previous module version. +test.beforeAll(async () => { + server = await createServer({ + root: uiRoot, + configFile: false, + resolve: { alias: { "@": path.join(uiRoot, "src") } }, + esbuild: { jsx: "automatic" }, + server: { host: "127.0.0.1", port: 0 }, + plugins: [{ + name: "company-context-regression", + resolveId(id) { if (id === "/src/__company_context_probe.tsx") return probePath; }, + load(id) { + if (id !== probePath) return; + return ` + import React from "react"; + import { createRoot } from "react-dom/client"; + import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + import { CompanyProvider, useCompany } from "/src/context/CompanyContext.tsx"; + const root = createRoot(document.getElementById("root")); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + function render(hook, generation) { + function Consumer() { return
{generation + ":" + (hook().selectedCompanyId ?? "loading")}
; } + root.render(); + } + render(useCompany, "initial"); + window.refreshConsumer = async () => { + const refreshed = await import(/* @vite-ignore */ "/src/context/CompanyContext.tsx?t=" + Date.now()); + render(refreshed.useCompany, "refreshed"); + }; + `; + }, + configureServer(vite) { + vite.middlewares.use((req, res, next) => { + if (req.url !== "/") return next(); + res.setHeader("Content-Type", "text/html"); + res.end('
'); + }); + }, + }], + }); + await server.listen(); + const address = server.httpServer!.address(); + if (!address || typeof address === "string") throw new Error("Missing test server address"); + origin = `http://127.0.0.1:${address.port}`; +}); + +test.afterAll(async () => { await server?.close(); }); + +test("a refreshed company consumer reads the retained provider", async ({ page }) => { + await page.route("**/api/**", async (route) => { + const pathname = new URL(route.request().url()).pathname; + if (!pathname.startsWith("/api/")) return route.continue(); + const body = pathname === "/api/auth/get-session" + ? { user: { id: "test-user" }, session: { userId: "test-user" } } + : pathname === "/api/companies" + ? [{ id: "test-company", name: "Test company", status: "active" }] + : []; + await route.fulfill({ json: body }); + }); + const errors: string[] = []; + page.on("pageerror", (error) => { errors.push(error.message); }); + await page.goto(origin); + await expect(page.getByRole("main")).toHaveText("initial:test-company"); + await page.evaluate(() => (window as unknown as { refreshConsumer: () => Promise }).refreshConsumer()); + await expect(page.getByRole("main")).toHaveText("refreshed:test-company"); + expect(errors).toEqual([]); +}); diff --git a/tests/e2e/planning-mode-visual-verification.spec.ts b/tests/e2e/planning-mode-visual-verification.spec.ts index ea53f5fc74f..564ae156d93 100644 --- a/tests/e2e/planning-mode-visual-verification.spec.ts +++ b/tests/e2e/planning-mode-visual-verification.spec.ts @@ -12,12 +12,12 @@ const TASK_TITLE = "Paperclip onboarding"; /** * The first task opens with the chief of staff's opening card sitting where * the composer is. Cancel hands the plain composer back (the card stays - * pending), and the composer is where the mode toggle lives. + * pending), and the composer is where the mode chip lives. * * The card arrives with the interactions fetch, after the composer's first * paint, so a bare `count()` right after navigation sees no card and skips * the click; the card then lands on top of the composer and hides the mode - * toggle. Wait for the card (or, if it is already dismissed, the pending + * chip. Wait for the card (or, if it is already dismissed, the pending * strip it leaves behind) before deciding, and only return once the plain * composer is back. */ @@ -176,8 +176,8 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => { await page.goto(issuePath); await dismissOpeningCard(page); await page.getByTestId("task-chat-composer-mode").click(); - await page.getByRole("menuitem", { name: /Auto mode/ }).click(); - await expect(page.getByTestId("task-chat-composer-mode")).toHaveAttribute("data-pending-work-mode", "standard"); + await expect(page.getByTestId("task-chat-composer-mode")).toHaveCount(0); + await expect(page.getByTestId("task-chat-composer-add")).toBeVisible(); await page.screenshot({ path: `${screenshotDir}/desktop-standard-toggle-${timestamp}.png`, fullPage: true, diff --git a/tests/e2e/playwright-company-context.config.ts b/tests/e2e/playwright-company-context.config.ts new file mode 100644 index 00000000000..15c5f3a8300 --- /dev/null +++ b/tests/e2e/playwright-company-context.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: ".", + testMatch: "company-context-refresh.spec.ts", + workers: 1, + timeout: 30_000, + use: { + headless: true, + ...(process.env.PAPERCLIP_PLAYWRIGHT_CHANNEL + ? { channel: process.env.PAPERCLIP_PLAYWRIGHT_CHANNEL } + : {}), + }, + outputDir: "./test-results", + reporter: "list", +}); diff --git a/tests/runner-e2e/FIXTURES.md b/tests/runner-e2e/FIXTURES.md index 0506028c44e..bd0696b76b1 100644 --- a/tests/runner-e2e/FIXTURES.md +++ b/tests/runner-e2e/FIXTURES.md @@ -241,3 +241,19 @@ default. Its file case anchors the task to a public project workspace, validates the model's claimed result by reading the actual final bytes, and also exercises remote copy-back. Keep candidate admission scoped to the selected model and the isolated operator environment; ordinary agent configuration must not enable it. + +## Persistent agent files + +The `instruction_persistence` flow uses production managed storage and public file +APIs. The browser creates a supporting file, then a real agent edits its registered +AGENT_HOME with ordinary filesystem tools. Independent oracles verify instructions, +nested text, binary download bytes, and a stopped-run save receipt without new +revision history. The harness restarts the server and creates a fresh browser task +without disclosing the saved nonces. Its readback oracle downloads and verifies an +attachment's bytes and SHA-256, rather than accepting a filename or model claim. +A third task uploads a ready attachment and waits in an ordinary bounded shell +command while the board changes the current file through the public API. Stopped +cleanup must preserve the original candidate as a conflict. The browser reviews +current and incoming files and applies the run edits against the reviewed current +directory hash. All three tasks' runs count toward billing and teardown. The suite +is explicit-only. No private control-plane hooks or direct database writes are used. diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index 2667793c469..7b99f319b20 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -193,6 +193,30 @@ duplicating the final response. The second workflow restarts the isolated Paperclip server while the interaction is waiting, reloads that state, and then resumes it. The suite has no Daytona cells. +`instruction-persistence` is an explicit-only three-cell workflow: legacy and +native Codex locally, plus native Codex on Daytona. Each creates six browser tasks +for the same agent. The editor first creates a nested supporting file. The first +run edits its registered AGENT_HOME using ordinary filesystem tools: instructions, +nested text, editor-created content, and exact binary bytes. The oracle checks the +current files, a stopped-run save receipt, and absence of newly appended history. +The first task also publishes a small verification receipt for the normal +completion contract; the personal files stay in the agent directory. +After a Paperclip restart, a fresh task must upload a downloaded proof attachment +containing independent saved nonces absent from its prompt. A third task edits its +private copy while the browser edits the same current file. The later run sync +must win for that changed file, preserve an unrelated board-created file, and +produce no conflict candidate or manual review step. Exact bytes, downloads, +and receipts are independently checked; model claims alone cannot pass. +Three further tasks fill a sparse personal file to its 256 MiB limit, exceed +that limit, and clean it up. Every run must still succeed; the run UI must show +a warning while full and clear it after cleanup. Rejected bytes must not replace +the saved file. This adds at most one 256 MiB saved fixture per isolated agent. +The deadline is twenty minutes per cell, with six expected provider runs; +normal instance/Daytona cleanup, screenshots, evidence, and billing apply. Run with +`pnpm test:e2e:runner -- --suite instruction-persistence`. Managed agent directories +checkpoint and close the provider before collection while retaining conversation +state. The separate `daytona-warm-continuity` suite covers warm runtime behavior. + `daytona-warm-continuity` (**Daytona Warm Continuity**) is exactly two paid cells: legacy Codex and Runner Codex against one reusable warm Daytona configuration. Each cell creates a real project with a primary local-path @@ -426,6 +450,14 @@ content tags are never rebuilt or overwritten by the workflow. ### Match the local controller package to the Daytona image +When the controller runs on macOS or another platform different from the sandbox, +set `PAPERCLIP_RUNNER_REMOTE_BINARY_PATH` to a verified Linux amd64 +`paperclip-runnerd`, such as the binary copied from `/usr/local/bin/paperclip-runnerd` +in the pinned image. The controller must have these exact bytes for its artifact +identity check. A local macOS runner cannot substitute for the Linux binary, +even when the sandbox image contains a compatible runner. This also applies to +native Codex cells, which do not otherwise need the remote provider pack below. + Native ACPX (including Claude) and OpenCode Daytona cells also require `PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH` on the controller. The package and the image must come from the same verified build. Equal provider version numbers diff --git a/tests/runner-e2e/catalog.test.ts b/tests/runner-e2e/catalog.test.ts index 22de844e138..575c9f4322f 100644 --- a/tests/runner-e2e/catalog.test.ts +++ b/tests/runner-e2e/catalog.test.ts @@ -91,10 +91,10 @@ describe("runner E2E catalog", () => { expect(localIntegrityTasks).toHaveLength(2); expect(openRouterBreadthTasks).toHaveLength(3); expect(runnerSuites.map((suite) => suite.expectedMatrixSize)).toEqual([ - 30, 16, 16, 2, 8, 46, 23, 47, 20, 52, 28, 18, 6, 6, 4, 48, 16, 10, 2, + 30, 3, 16, 16, 2, 8, 46, 23, 47, 20, 52, 28, 18, 6, 6, 4, 48, 16, 10, 2, ]); - expect(validateRunnerCatalog()).toHaveLength(398); - expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(398); + expect(validateRunnerCatalog()).toHaveLength(401); + expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(401); expect( runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"), ).toHaveLength(48); diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index f557c8e2bf1..6837d2399b9 100644 --- a/tests/runner-e2e/catalog.ts +++ b/tests/runner-e2e/catalog.ts @@ -1,3 +1,4 @@ +import { instructionPersistenceTask } from "./instruction-persistence.js"; import { apiResponseReadingTask } from "./api-response-reading.js"; import { accountingTasks } from "./accounting-cases.js"; import { continuationTasks } from "./continuation-cases.js"; @@ -1035,6 +1036,15 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [ expectedMatrixSize: 30, definitionMetadata: { version: 1, qualification: "pending", scheduling: "explicit-only", admission: "host-exact-candidate-and-model", authenticatedDiscoveryDate: "2026-09-28" }, }, + { + id: "instruction-persistence", label: "Instruction Persistence", + description: "Agent-owned text and binary files round trip through the editor, survive a server restart and fresh task, and synchronize concurrent edits per file with last-sync-wins.", + groups: [], profiles: codexContinuityProfiles, + environments: [localEnvironment, runnerEnvironments.find(environment => environment.id === "daytona")!], tasks: [instructionPersistenceTask], + excludedExecutionIds: ["instruction-persistence.legacy-codex.daytona.private-copy-persists"], + expectedMatrixSize: 3, manualOnly: true, + definitionMetadata: { version: 6, oracle: "current-directory-independent-nonce-binary-last-sync-wins-and-nonblocking-storage-quota", providerTurns: 6, restart: "between-tasks", instructions: "production" }, + }, { id: "grok-subscription-qualification", label: "Grok Build Subscription Qualification", manualOnly: true, description: "Explicit company subscription login across Grok browser workflows in local and Daytona environments.", @@ -1364,6 +1374,7 @@ export function validateRunnerCatalog(): MatrixExecution[] { ...localIntegrityTasks, ...openRouterBreadthTasks, daytonaWarmContinuityTask, + instructionPersistenceTask, ]; for (const [label, values] of [ ["suite", runnerSuites], diff --git a/tests/runner-e2e/chat-flow.ts b/tests/runner-e2e/chat-flow.ts index 08a5f9c304e..f8ae68be14e 100644 --- a/tests/runner-e2e/chat-flow.ts +++ b/tests/runner-e2e/chat-flow.ts @@ -693,11 +693,8 @@ export async function runChatFlow(input: ChatFlowInput) { await idle(3); } else await turn(clarification, 3); } else if (caseId === "plan-handoff") { - await page.getByTestId("task-chat-composer-mode").click(); - await page - .getByTestId("task-chat-composer-mode-menu") - .getByText("Plan mode", { exact: true }) - .click(); + await page.getByTestId("task-chat-composer-add").click(); + await page.getByTestId("composer-add-plan").click(); await turn( `Let's plan a two-sentence garden club welcome note. The finished welcome note itself must contain the exact phrase ${draftMarker}. Write a plan in the plan panel that includes this requirement, and present it for approval. When I approve the final revision, create a suitable repository-free project and an assigned task for yourself, copy the plan into that task, and have it save the note as a Paperclip document attached to that execution task and finish. Do not create the project or task before approval.`, 1, diff --git a/tests/runner-e2e/instruction-persistence.test.ts b/tests/runner-e2e/instruction-persistence.test.ts new file mode 100644 index 00000000000..a0d547d983d --- /dev/null +++ b/tests/runner-e2e/instruction-persistence.test.ts @@ -0,0 +1,36 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { gradeInstructionPersistence, instructionPersistenceTask } from "./instruction-persistence.js"; +import { runnerMatrix } from "./catalog.js"; +const expectedContent = "original\nInstruction persistence nonce: fixture\n"; +const valid = () => ({ before: { revision: { id: "before" } }, + saveEvent: { runId: "first-run", state: "saved" }, fileProof: true, + after: { content: expectedContent, contentHash: createHash("sha256").update(expectedContent).digest("hex"), revision: { id: "after", source: "cleanup", sourceRunId: "first-run", contentHash: createHash("sha256").update(expectedContent).digest("hex") } }, + firstRunId: "first-run", expectedContent, + proof: { body: "Instruction persistence nonce: fixture\n", contentVerified: true }, + expectedProof: "Instruction persistence nonce: fixture\n" }); +describe("instruction persistence independent oracle", () => { + it("does not disclose the persisted nonce in the fresh-task marker", () => { + const nonce = "only-the-instruction-entry-reveals-this"; + expect(instructionPersistenceTask.buildVisibleMarker(nonce)).not.toContain(nonce); + }); + it("accepts exact cleanup content and a verified downloaded fresh-task proof", () => { + expect(gradeInstructionPersistence(valid()).every(row => row.passed)).toBe(true); + }); + it.each(["missing-save", "wrong-run", "missing-files", "wrong-bytes", "missing-proof", "unverified-proof", "wrong-proof"])("rejects %s evidence", kind => { + const value = valid(); + if (kind === "missing-save") value.saveEvent.state = "unavailable"; + if (kind === "wrong-run") value.saveEvent.runId = "another-run"; + if (kind === "missing-files") value.fileProof = false; + if (kind === "wrong-bytes") value.after.content = "claimed success"; + if (kind === "missing-proof") return expect(gradeInstructionPersistence({ ...value, proof: undefined }).some(row => !row.passed)).toBe(true); + if (kind === "unverified-proof") value.proof.contentVerified = false; + if (kind === "wrong-proof") value.proof.body = "claimed success"; + expect(gradeInstructionPersistence(value).some(row => !row.passed)).toBe(true); + }); + it("registers exactly three explicit cells without changing scheduled campaigns", () => { + const cells = runnerMatrix.filter(row => row.suite.id === "instruction-persistence"); + expect(cells.map(row => `${row.profile.id}.${row.environment.id}`)).toEqual(["legacy-codex.local", "runner-codex.local", "runner-codex.daytona"]); + expect(cells.every(row => row.suite.manualOnly && row.task.expectedRunCount === 6)).toBe(true); + }); +}); diff --git a/tests/runner-e2e/instruction-persistence.ts b/tests/runner-e2e/instruction-persistence.ts new file mode 100644 index 00000000000..6e60b304dd5 --- /dev/null +++ b/tests/runner-e2e/instruction-persistence.ts @@ -0,0 +1,232 @@ +import { createHash, randomBytes } from "node:crypto"; +import { expect, type Page } from "@playwright/test"; +import { pollUntil, type RunnerApi } from "./api.js"; +import { captureFirstTaskAttachments } from "./first-task-attachments.js"; +import { collectRunEvents } from "./run-observations.js"; +import { createTaskThroughUi } from "./user-actions.js"; +import type { LiveFixtureValues } from "./live-fixtures.js"; +import type { MatrixExecution, RunnerTaskFixture } from "./types.js"; + +type Row = Record; +export const instructionNonceLine = (nonce: string) => `Instruction persistence nonce: ${nonce}\n`; +export const instructionPersistenceTask: RunnerTaskFixture = { + id: "private-copy-persists", label: "Agent directory survives a fresh task", + groups: [], workMode: "standard", flow: "instruction_persistence", + expectedRunCount: 6, attemptTimeoutMs: { local: 20 * 60_000, daytona: 20 * 60_000 }, + expectedTerminalState: { issue: "done", run: "succeeded" }, + buildTitle: nonce => `Persist private instructions ${nonce}`, + buildVisibleMarker: () => "INSTRUCTIONS-VERIFIED", + buildPrompt: nonce => [ + "Edit your own registered writable agent instruction entry with ordinary filesystem tools. The runtime guidance gives its exact private path.", + "Use Node.js built-in fs for these byte-preserving edits. Apply each append exactly once: inspect the existing suffix before retrying any command, because a warning does not imply that its writes failed.", + `Preserve its existing bytes and append exactly this UTF-8 suffix, represented as a JSON string: ${JSON.stringify(`\n${instructionNonceLine(nonce)}`)}`, + "Decode the JSON string once and append those bytes. Do not trim or normalize the existing file and do not add another blank line or separator.", + `In AGENT_HOME, create notes/retained.txt containing exactly ${JSON.stringify(`Personal file nonce: ${nonce}\n`)}. Create notes/bytes.bin with exactly the bytes [0,255,17,128,9]. Read notes/from-editor.txt and append exactly a newline followed by Edited by agent. and a final newline.`, + "Do not use update_agent_instructions, restore_agent_instructions, or an instructions API to save it. Do not edit repository AGENTS.md or the read-only loaded bundle.", + "After verifying the edits, upload a small text/plain attachment named agent-file-check.txt containing only 'Private file edits verified'. Use this attachment as your task completion evidence; the personal files themselves stay in AGENT_HOME.", + "Reply only Instruction copy edited without printing filesystem paths, then complete this task after the file edit. Paperclip will collect it after the provider stops; do not claim it has already persisted. Do not create further tasks.", + ].join("\n"), + buildMatchers: () => [], // Independent current file and attachment oracle below. +}; + +export function gradeInstructionPersistence(input: { before: Row; after: Row; firstRunId: string; expectedContent: string; proof: Row | undefined; expectedProof: string; saveEvent?: Row; fileProof?: boolean }) { + return [ + { id: "directory-save-receipt", passed: input.saveEvent?.runId === input.firstRunId && input.saveEvent?.state === "saved" }, + { id: "nested-and-binary-files", passed: input.fileProof === true }, + { id: "exact-canonical-bytes", passed: input.after.content === input.expectedContent && input.after.contentHash === createHash("sha256").update(input.expectedContent).digest("hex") }, + { id: "fresh-task-downloaded-proof", passed: input.proof?.contentVerified === true && input.proof.body === input.expectedProof }, + ].map(check => ({ ...check, detail: check.passed ? `${check.id} verified independently` : `${check.id} missing or incorrect` })); +} + +export async function runInstructionPersistenceFlow(input: { + page: Page; api: RunnerApi; fixtures: LiveFixtureValues; execution: MatrixExecution; nonce: string; + secrets: readonly string[]; deadlineAt: number; + restart(): Promise; + observe(issue: Row, runs: Row[]): void; + capture(id: string, label: string, file: string): Promise; + evidence(name: string, data: unknown): Promise; +}) { + const { page, api, fixtures, execution, nonce } = input; + // Fixture names contain the campaign nonce. Use an unrelated value that the + // fresh task can obtain only from the saved entry (or forbidden task history). + const persistedNonce = randomBytes(16).toString("hex"); + const filePath = `/api/agents/${fixtures.agent.id}/instructions-bundle/file?path=AGENTS.md`; + const before = await api.get(filePath); + if (typeof before.content !== "string" || !before.contentHash) throw new Error("Managed instructions must expose a current file hash"); + const expectedContent = `${before.content}\n${instructionNonceLine(persistedNonce)}`; + const instructionsUrl = `/${fixtures.company.issuePrefix}/agents/${fixtures.agent.id}/instructions`; + const editorText = `Editor nonce: ${randomBytes(16).toString("hex")}`; + await page.goto(instructionsUrl); + await page.getByRole("button", { name: "Add agent file", exact: true }).click(); + await page.getByPlaceholder("TOOLS.md").fill("notes/from-editor.txt"); + await page.getByRole("button", { name: "Create", exact: true }).click(); + await page.getByRole("group", { name: "Instruction file view" }).getByRole("button", { name: "edit", exact: true }).click(); + await page.getByRole("textbox", { name: "Instruction file editor" }).fill(editorText); + await page.getByRole("button", { name: "Save changes", exact: true }).click(); + await expect(page.getByRole("button", { name: "Save changes", exact: true })).toBeDisabled(); + await expect(page.getByRole("button", { name: "History", exact: true })).toHaveCount(0); + let issue: Row = {}; + let runs: Row[] = []; + async function create(title: string, prompt: string) { + await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title, prompt, workMode: "standard", projectName: fixtures.project?.name }); + const found = await pollUntil({ label: `instruction task ${title}`, deadlineAt: input.deadlineAt, + load: async () => (await api.get(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(row => row.title === title), accept: row => Boolean(row) }); + if (!found) throw new Error("Browser-created instruction task missing"); + issue = found; + input.observe(issue, runs); + await page.goto(`/${fixtures.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`); + } + async function settle(count: number) { + await pollUntil({ label: `instruction run ${count} completed`, deadlineAt: input.deadlineAt, + load: async () => { + issue = await api.get(`/api/issues/${issue.id}`); + const listed = await api.get(`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`); + runs = await Promise.all(listed.map(row => api.get(`/api/heartbeat-runs/${row.id}`))); + runs.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt))); + input.observe(issue, runs); + return { issue, runs }; + }, + accept: state => state.issue.status === "done" && state.runs.length === count && state.runs.every(row => row.status === "succeeded"), + reject: state => state.runs.some(row => ["failed", "cancelled", "timed_out"].includes(row.status)) ? "Instruction task provider run failed" : state.runs.length > count ? "Instruction task dispatched an extra run" : state.issue.status === "blocked" && state.runs.length === count && state.runs.every(row => row.status === "succeeded") ? `Instruction task reported a terminal blocker: ${JSON.stringify(state.issue.unblockDescriptor ?? {})}` : undefined, + }); + expect(runs.every(row => row.runtimeMode === execution.profile.expectedRuntimeMode)).toBe(true); + await page.reload(); + await expect(page.getByTestId("issue-detail-header").getByRole("button", { name: "Change status (current: Done)", exact: true })).toBeVisible(); + } + await create(execution.task.buildTitle(nonce), execution.task.buildPrompt(persistedNonce)); + await settle(1); + const firstRunId = runs[0]!.id; + const after = await pollUntil({ label: "stopped agent directory save", deadlineAt: Math.min(input.deadlineAt, Date.now() + 30_000), + load: () => api.get(filePath), accept: row => row.content === expectedContent }); + const events = await collectRunEvents((afterSeq, limit) => api.get(`/api/heartbeat-runs/${firstRunId}/events?afterSeq=${afterSeq}&limit=${limit}`)); + const saveEvent = events.find(row => row.eventType === "instruction_save" && row.payload?.state === "saved"); + expect(saveEvent).toBeTruthy(); + const readPersonal = (name: string) => api.get(`/api/agents/${fixtures.agent.id}/instructions-bundle/file?path=${encodeURIComponent(name)}`); + const note = await readPersonal("notes/retained.txt"); + const fromEditor = await readPersonal("notes/from-editor.txt"); + const binaryResponse = await api.request.get(`/api/agents/${fixtures.agent.id}/instructions-bundle/file?path=notes%2Fbytes.bin&download=true`); + expect(binaryResponse.ok()).toBe(true); + const binary = await binaryResponse.body(); + const fileProof = note.content === `Personal file nonce: ${persistedNonce}\n` && fromEditor.content === `${editorText}\nEdited by agent.\n` && binary.equals(Buffer.from([0,255,17,128,9])); + expect(fileProof).toBe(true); + const history = await api.get(`/api/agents/${fixtures.agent.id}/instructions-bundle/history?path=AGENTS.md`); + expect(history.revisions).toHaveLength(0); + await input.evidence("instruction-first-save.json", { before, after, run: runs[0], events }); + await input.capture("instruction-edited", "Private instructions saved after provider stop", "instruction-edited.png"); + // A new server and a new issue cannot pass by retaining model conversation. + await input.restart(); + expect((await api.get(filePath)).content).toBe(expectedContent); + await create("Read persisted instructions", [ + "Read your own loaded agent instruction entry (or its current registered private copy) using ordinary filesystem tools.", + "Find the line beginning 'Instruction persistence nonce: '. Copy that entire line plus one final newline into instruction-proof.txt. Then append the exact bytes of notes/retained.txt from AGENT_HOME. Verify notes/bytes.bin contains the bytes [0,255,17,128,9]. Do not infer the value from this task title or other task history. Do not change your instructions.", + "Upload instruction-proof.txt as a text/plain task attachment named instruction-proof.txt using the normal artifact workflow. A local file alone is insufficient.", + `Reply with exactly ${execution.task.buildVisibleMarker(nonce)} and complete the task.`, + ].join("\n")); + await settle(2); + const attachments = await captureFirstTaskAttachments(api, [{ ...issue, id: String(issue.id) }], input.secrets); + const proof = attachments.find(row => row.originalFilename === "instruction-proof.txt" || row.name === "instruction-proof.txt"); + const final = await api.get(filePath); + expect(final.revision.id).toBe(after.revision.id); + const checks = gradeInstructionPersistence({ before, after, firstRunId, expectedContent, proof, expectedProof: `${instructionNonceLine(persistedNonce)}Personal file nonce: ${persistedNonce}\n`, saveEvent: { runId: firstRunId, state: saveEvent?.payload?.state }, fileProof }); + await expect(page.getByTestId("task-chat-agent-bubble").filter({ hasText: execution.task.buildVisibleMarker(nonce) }).last()).toBeVisible(); + await input.capture("final-state", "Fresh task downloaded the persisted instruction nonce", "final-state.png"); + expect(checks.filter(check => !check.passed), "Independent instruction persistence checks").toEqual([]); + + checks.push({ id: "editor-round-trip-no-history", passed: true, detail: "A browser-created supporting file was edited by the agent; all current bytes persisted without revision rows" }); + const restored = final; + + // The provider publishes an ordinary attachment before a bounded command wait. + // A board edit during that run is superseded only for the same changed file. + const conflictSuffix = `\nLast completed synchronization: ${nonce}\n`; + const expectedCandidate = `${restored.content}${conflictSuffix}`; + await create("Sync a concurrent instruction edit", [ + "Use Node.js built-in fs. Apply the append exactly once, checking existing bytes before any retry.", + `Append exactly this UTF-8 suffix to your current registered writable instruction entry, represented as a JSON string: ${JSON.stringify(conflictSuffix)}`, + "Decode the JSON string once. Preserve all existing bytes. Do not use an instruction revision tool or instructions API.", + "After the file edit, upload a text/plain task attachment named instruction-candidate-ready.txt with the text ready. Use the ordinary artifact workflow.", + "Then execute the ordinary shell command sleep 45 and wait for it to finish. This gives the board time to edit the canonical instructions concurrently. Do not complete the task before that command finishes.", + "After the wait completes, reply Candidate edit ready and complete the task. Do not change the instructions again or claim that they saved.", + ].join("\n")); + await pollUntil({ label: "provider staged concurrent instruction edit", deadlineAt: input.deadlineAt, + load: () => api.get(`/api/issues/${issue.id}/attachments`), + accept: rows => rows.some(row => row.originalFilename === "instruction-candidate-ready.txt" || row.name === "instruction-candidate-ready.txt") }); + const active = await api.get(`/api/issues/${issue.id}/runs`); + expect(active.some(row => row.status === "running")).toBe(true); + const boardMarker = "Concurrent board instruction edit."; + await page.goto(instructionsUrl); + await page.getByText("AGENTS.md", { exact: true }).first().click(); + await page.getByRole("group", { name: "Instruction file view" }).getByRole("button", { name: "edit", exact: true }).click(); + const entryEditor = page.getByRole("textbox", { name: "editable markdown" }); + await entryEditor.click(); + await entryEditor.press("ControlOrMeta+End"); + await entryEditor.press("Enter"); + await entryEditor.pressSequentially(boardMarker); + await page.getByRole("button", { name: "Save changes", exact: true }).click(); + await expect(page.getByRole("button", { name: "Save changes", exact: true })).toBeDisabled(); + const board = await api.get(filePath); + expect(board.content).toContain(boardMarker); + expect(board.contentHash).not.toBe(restored.contentHash); + const unrelatedContent = `Concurrent independent file: ${nonce}`; + const unrelated = await api.request.put(`/api/agents/${fixtures.agent.id}/instructions-bundle/file`, { + data: { path: "notes/concurrent-editor.txt", content: unrelatedContent, baseHash: null }, + }); + expect(unrelated.ok()).toBe(true); + await page.goto(`/${fixtures.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`); + await settle(3); + const syncRunId = runs[2]!.id; + const resolved = await pollUntil({ label: "last completed sync wins", deadlineAt: Math.min(input.deadlineAt, Date.now() + 30_000), + load: () => api.get(filePath), accept: row => row.content === expectedCandidate }); + const candidates = await api.get(`/api/agents/${fixtures.agent.id}/instructions-bundle/candidates`); + expect(candidates.some(row => row.runId === syncRunId)).toBe(false); + expect((await readPersonal("notes/concurrent-editor.txt")).content).toBe(unrelatedContent); + const syncEvents = await collectRunEvents((afterSeq, limit) => api.get(`/api/heartbeat-runs/${syncRunId}/events?afterSeq=${afterSeq}&limit=${limit}`)); + expect(syncEvents.some(row => row.eventType === "instruction_save" && row.payload?.state === "saved")).toBe(true); + await page.goto(instructionsUrl); + await expect(page.getByRole("button", { name: "Review preserved files", exact: true })).toHaveCount(0); + checks.push({ id: "per-file-last-sync-wins", passed: true, detail: "The later agent sync replaced the concurrent browser edit to its changed entry, preserved an unrelated new file, and created no conflict candidate" }); + await page.goto(`/${fixtures.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`); + await input.capture("last-sync-wins", "Concurrent changes synchronized per file without a conflict-review step", "last-sync-wins.png"); + + const quotaTask = (action: string, receipt: string) => [ + "This is a controlled persistent-storage quota check. Use ordinary Node.js filesystem tools in your registered AGENT_HOME. Do not edit AGENTS.md.", + action, + `Upload a small text/plain task attachment named ${receipt}.txt containing the observed file size or cleanup result. This attachment is the primary task deliverable.`, + "Complete this task normally after uploading the receipt. A persistent-file storage warning is expected and must not prevent completion. Do not perform additional cleanup or change other personal files.", + ].join("\n"); + await create("Reach the agent file storage limit", quotaTask( + "Create quota-cache.bin using fs.openSync with flag w, fs.ftruncateSync(fd, 268435456), and fs.closeSync. This is a sparse fixture file, not a download. Verify its size using fs.statSync without reading the large contents.", "quota-full")); + await settle(4); + const fullRun = runs[3]!; + expect(fullRun.resultJson?.instructionSave).toMatchObject({ state: "saved", storageWarning: expect.stringContaining("Agent storage is full") }); + await page.goto(`/${fixtures.company.issuePrefix}/agents/${fixtures.agent.id}/runs/${fullRun.id}`); + await expect(page.getByRole("note").filter({ hasText: "Agent storage warning" })).toContainText("Runs can continue"); + // Public campaign screenshots are limited to sanitized task routes. Verify + // the warning in the real run UI, then capture its completed task outcome. + await page.goto(`/${fixtures.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`); + await input.capture("storage-warning", "Task succeeded while agent storage reached its limit", "storage-warning.png"); + + await create("Keep running while agent storage is full", quotaTask( + "Verify quota-cache.bin already exists and its size is exactly 268435456. Grow only this file to 268435457 bytes with fs.truncateSync, then verify the new size. Leave it above the limit for this run's sync check.", "quota-exceeded")); + await settle(5); + const exceededRun = runs[4]!; + expect(exceededRun.resultJson?.instructionSave).toMatchObject({ state: "unavailable", errorCode: "AGENT_FILES_LIMIT_EXCEEDED", storageWarning: expect.stringContaining("Runs can continue") }); + const fullEvents = await collectRunEvents((afterSeq, limit) => api.get(`/api/heartbeat-runs/${exceededRun.id}/events?afterSeq=${afterSeq}&limit=${limit}`)); + expect(fullEvents.some(row => row.eventType === "instruction_save" && row.level === "warn" && row.payload?.state === "prepared" && row.payload?.storageWarning)).toBe(true); + + await create("Clean up agent storage during a normal task", quotaTask( + "Verify restored quota-cache.bin has size 268435456: the rejected oversized edit must not have replaced its saved bytes. Delete quota-cache.bin with fs.unlinkSync, then write notes/after-quota.txt containing exactly 'Runs still work after quota cleanup'.", "quota-cleaned")); + await settle(6); + const cleanedRun = runs[5]!; + expect(cleanedRun.resultJson?.instructionSave).toMatchObject({ state: "saved", storageWarning: null }); + expect((await readPersonal("notes/after-quota.txt")).content).toBe("Runs still work after quota cleanup"); + const bundle = await api.get(`/api/agents/${fixtures.agent.id}/instructions-bundle`); + expect(bundle.files.some((file: Row) => file.path === "quota-cache.bin")).toBe(false); + await page.goto(`/${fixtures.company.issuePrefix}/agents/${fixtures.agent.id}/runs/${cleanedRun.id}`); + await expect(page.getByRole("note").filter({ hasText: "Agent storage warning" })).toHaveCount(0); + checks.push({ id: "storage-full-does-not-block-runs", passed: true, detail: "A run saved a file at quota, a subsequent run succeeded despite an oversized save rejection, and the next run removed the full file and cleared its warning; all three tasks completed" }); + await input.evidence("api-state.json", { issue, runs, checks, canonicalInstructions: resolved, attachments }); + await input.evidence("instruction-persistence.json", { checks, before, after, final, restored, board, candidates, resolved, syncEvents, runs, attachments, fullEvents }); + await page.goto(`/${fixtures.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`); + await input.capture("storage-recovered", "Agent completed a normal task and cleared storage warning after cleanup", "storage-recovered.png"); + return { issue, runs, checks }; +} diff --git a/tests/runner-e2e/runner.spec.ts b/tests/runner-e2e/runner.spec.ts index 6f372e4b09f..4802d10f3bf 100644 --- a/tests/runner-e2e/runner.spec.ts +++ b/tests/runner-e2e/runner.spec.ts @@ -1,3 +1,4 @@ +import { runInstructionPersistenceFlow } from "./instruction-persistence.js"; import { gradeApiResponsePaging, readResponseProof, responseEvidenceDescription } from "./api-response-reading.js"; import { observeBrowserBootstrap } from "./browser-bootstrap-diagnostics.js"; import { runAccountingFlow } from "./accounting-flow.js"; @@ -546,7 +547,7 @@ for (const execution of executions) { const credentials = credentialValues(); const secrets = normalizedSecrets(Object.values(credentials)); const api = new RunnerApi(request); - const companyRunFlow = ["continuation_accounting", "continuation", "context_integrity", "agent_chat", "everyday_workflow", "first_task"].includes(execution.task.flow); + const companyRunFlow = ["continuation_accounting", "continuation", "context_integrity", "agent_chat", "everyday_workflow", "first_task", "instruction_persistence"].includes(execution.task.flow); const consoleDiagnostics: Array> = []; const networkDiagnostics: Array> = []; const pageLifecycleDiagnostics: Array> = []; @@ -859,6 +860,16 @@ for (const execution of executions) { evidence: (name, data) => writeSanitizedJson(snapshotsDir, name, data, secrets), }); issue = accounting.issue as IssueRecord; selectedRuns = accounting.runs as RunRecord[]; + } else if (execution.task.flow === "instruction_persistence") { + const story = await runInstructionPersistenceFlow({ + page, api, fixtures, execution, nonce, secrets, deadlineAt: startedAtMs + deadlineMs, + restart: () => restartIsolatedPaperclipServer({ api, requestId: `instructions-${nonce}`, deadlineAt: startedAtMs + deadlineMs }), + observe: (currentIssue, currentRuns) => { issue = currentIssue as IssueRecord; selectedRuns = currentRuns as RunRecord[]; }, + capture: captureScreenshot, + evidence: (name, data) => writeSanitizedJson(snapshotsDir, name, data, secrets), + }); + issue = story.issue as IssueRecord; selectedRuns = story.runs as RunRecord[]; + matcherResults = story.checks.map(check => ({ matcher: { kind: "json_path" as const, path: `instructions.${check.id}`, expected: true }, passed: check.passed, detail: check.detail })); } else if (execution.task.flow === "continuation") { const continuation = await runContinuationFlow({ page, api, fixtures, execution, nonce, secrets, workspacePath, deadlineAt: startedAtMs + deadlineMs - 60_000, diff --git a/tests/runner-e2e/types.ts b/tests/runner-e2e/types.ts index ecfedd41070..58bc93bda4a 100644 --- a/tests/runner-e2e/types.ts +++ b/tests/runner-e2e/types.ts @@ -27,7 +27,8 @@ export type RunnerTaskFlow = | "plan_revision_acceptance" | "question_resume_completion" | "plan_approval_completion" - | "warm_three_turn"; + | "warm_three_turn" + | "instruction_persistence"; export interface SecretReference { type: "secret_ref"; diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 5bde9d5e01d..62de19bcfcf 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -5,6 +5,11 @@ import type { AgentPermissions, AgentDetail, AgentInstructionsBundle, + AgentInstructionHistory, + AgentInstructionCandidate, + ResolveAgentInstructionCandidate, + AgentInstructionDiff, + AgentInstructionSnapshot, AgentInstructionsFileDetail, AgentSkillSnapshot, AdapterEnvironmentTestResult, @@ -167,12 +172,26 @@ export const agentsApi = { ), saveInstructionsFile: ( id: string, - data: { path: string; content: string; clearLegacyPromptTemplate?: boolean }, + data: { path: string; content: string; baseRevisionId?: string | null; baseHash?: string | null; clearLegacyPromptTemplate?: boolean }, companyId?: string, ) => api.put(agentPath(id, companyId, "/instructions-bundle/file"), data), - deleteInstructionsFile: (id: string, relativePath: string, companyId?: string) => + instructionCandidates: (id: string, companyId?: string) => + api.get(agentPath(id, companyId, "/instructions-bundle/candidates")), + resolveInstructionCandidate: (id: string, runId: string, data: ResolveAgentInstructionCandidate, companyId?: string) => + api.post(agentPath(id, companyId, `/instructions-bundle/candidates/${runId}/resolve`), data), + instructionHistory: (id: string, path: string, companyId?: string, cursor?: string) => + api.get(agentPath(id, companyId, `/instructions-bundle/history?path=${encodeURIComponent(path)}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`)), + instructionRevision: (id: string, path: string, revisionId: string, companyId?: string) => + api.get(agentPath(id, companyId, `/instructions-bundle/revision/${revisionId}?path=${encodeURIComponent(path)}`)), + instructionDiff: (id: string, path: string, from: string, to: string, companyId?: string) => + api.get(agentPath(id, companyId, `/instructions-bundle/diff?path=${encodeURIComponent(path)}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`)), + restoreInstructions: (id: string, data: { path: string; revisionId: string; baseRevisionId: string }, companyId?: string) => + api.post(agentPath(id, companyId, "/instructions-bundle/restore"), data), + downloadInstructionsFile: (id: string, relativePath: string, companyId?: string) => + `/api${agentPath(id, companyId, `/instructions-bundle/file?path=${encodeURIComponent(relativePath)}&download=true`)}`, + deleteInstructionsFile: (id: string, relativePath: string, companyId?: string, baseHash?: string) => api.delete( - agentPath(id, companyId, `/instructions-bundle/file?path=${encodeURIComponent(relativePath)}`), + agentPath(id, companyId, `/instructions-bundle/file?path=${encodeURIComponent(relativePath)}${baseHash ? `&baseHash=${baseHash}` : ""}`), ), pause: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/pause"), {}), resume: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/resume"), {}), diff --git a/ui/src/components/InstructionHistory.tsx b/ui/src/components/InstructionHistory.tsx new file mode 100644 index 00000000000..2d8dbecfdfe --- /dev/null +++ b/ui/src/components/InstructionHistory.tsx @@ -0,0 +1,145 @@ +import { useState } from "react"; +import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query"; +import type { AgentInstructionsFileDetail } from "@paperclipai/shared"; +import { agentsApi } from "../api/agents"; +import { Button } from "./ui/button"; + +export function InstructionHistory({ + agentId, + companyId, + path, + currentRevisionId, + disabled, + onRestored, +}: { + agentId: string; + companyId?: string; + path: string; + currentRevisionId: string; + disabled: boolean; + onRestored: (file: AgentInstructionsFileDetail) => void; +}) { + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState(null); + const history = useInfiniteQuery({ + queryKey: ["instruction-history", agentId, path, currentRevisionId], + queryFn: ({ pageParam }) => + agentsApi.instructionHistory(agentId, path, companyId, pageParam), + initialPageParam: undefined as string | undefined, + getNextPageParam: (page) => page.nextCursor ?? undefined, + enabled: open, + }); + const diff = useQuery({ + queryKey: ["instruction-diff", agentId, path, selected, currentRevisionId], + queryFn: () => + agentsApi.instructionDiff( + agentId, + path, + selected!, + currentRevisionId, + companyId, + ), + enabled: open && Boolean(selected), + }); + const restore = useMutation({ + mutationFn: () => + agentsApi.restoreInstructions( + agentId, + { path, revisionId: selected!, baseRevisionId: currentRevisionId }, + companyId, + ), + onSuccess: (file) => { + setSelected(null); + onRestored(file); + }, + }); + const error = history.error ?? diff.error ?? restore.error; + return ( +
+ + {open && ( +
+ {history.isLoading && ( +

Loading revisions…

+ )} + {error && ( +

+ {error.message} +

+ )} + {history.data?.pages + .flatMap((page) => page.revisions) + .map((revision) => ( +
+ + + {revision.source} ·{" "} + {new Date(revision.createdAt).toLocaleString()} + {revision.id === currentRevisionId ? " · Current" : ""} + +
+ ))} + {history.hasNextPage && ( + + )} + {diff.data && ( + <> +

Selected revision

+
+                {diff.data.from.content}
+              
+

+ Changes from selected revision to current +

+
+                {diff.data.removed && `Removed:\n${diff.data.removed}\n`}
+                {diff.data.added && `Added:\n${diff.data.added}`}
+                {!diff.data.removed &&
+                  !diff.data.added &&
+                  "No content changes."}
+              
+ + {disabled && ( +

+ Save or cancel your edits before restoring. +

+ )} + + )} +
+ )} +
+ ); +} diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 62e1e686d11..384fa952a3d 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -732,20 +732,19 @@ describe("IssueChatThread", () => { expect(composer?.getAttribute("data-pending-work-mode")).toBe("planning"); expect(composer?.className).toContain("amber"); - const toggle = container.querySelector( - '[data-testid="issue-chat-composer-work-mode-toggle"]', + const chip = container.querySelector( + '[data-testid="issue-chat-composer-work-mode-chip"]', ); - expect(toggle).not.toBeNull(); - expect(toggle?.getAttribute("data-pending-work-mode")).toBe("planning"); - expect(toggle?.getAttribute("aria-pressed")).toBe("true"); - expect(toggle?.textContent).toContain("Plan mode"); + expect(chip).not.toBeNull(); + expect(chip?.getAttribute("data-pending-work-mode")).toBe("planning"); + expect(chip?.textContent).toContain("Plan mode"); act(() => { root.unmount(); }); }); - it("shows a persistent neutral mode chip on a standard issue and selects planning through its menu", () => { + it("selects planning from the add menu and removes its chip", () => { const root = createRoot(container); const onWorkModeChange = vi.fn(); @@ -766,13 +765,9 @@ describe("IssueChatThread", () => { ); }); - // The mode chip is always present (mockup rev 5) — neutral "Auto mode" here. - const chip = container.querySelector( - '[data-testid="issue-chat-composer-work-mode-toggle"]', - ) as HTMLButtonElement | null; - expect(chip).not.toBeNull(); - expect(chip?.getAttribute("data-pending-work-mode")).toBe("standard"); - expect(chip?.textContent).toContain("Auto mode"); + expect(container.querySelector('[data-testid="issue-chat-composer-work-mode-chip"]')).toBeNull(); + const add = container.querySelector('[data-testid="issue-chat-composer-add"]') as HTMLButtonElement; + expect(add).not.toBeNull(); const composer = container.querySelector( '[data-testid="issue-chat-composer"]', @@ -781,11 +776,11 @@ describe("IssueChatThread", () => { expect(composer?.className).not.toContain("amber"); act(() => { - chip?.click(); + add.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 })); }); const menuItem = document.querySelector( - '[data-testid="issue-chat-composer-work-mode-menu-planning"]', + '[data-testid="composer-add-plan"]', ) as HTMLButtonElement | null; expect(menuItem).not.toBeNull(); expect(menuItem?.textContent).toContain("Plan mode"); @@ -798,7 +793,11 @@ describe("IssueChatThread", () => { expect(onWorkModeChange).not.toHaveBeenCalled(); expect(composer?.getAttribute("data-pending-work-mode")).toBe("planning"); expect(composer?.className).toContain("amber"); + const chip = container.querySelector('[data-testid="issue-chat-composer-work-mode-chip"]') as HTMLButtonElement; expect(chip?.textContent).toContain("Plan mode"); + act(() => chip.click()); + expect(composer?.getAttribute("data-pending-work-mode")).toBe("standard"); + expect(container.querySelector('[data-testid="issue-chat-composer-work-mode-chip"]')).toBeNull(); act(() => { root.unmount(); @@ -826,21 +825,19 @@ describe("IssueChatThread", () => { ); }); - const chip = container.querySelector( - '[data-testid="issue-chat-composer-work-mode-toggle"]', - ) as HTMLButtonElement | null; + const add = container.querySelector('[data-testid="issue-chat-composer-add"]') as HTMLButtonElement; const composer = container.querySelector( '[data-testid="issue-chat-composer"]', ) as HTMLDivElement | null; - expect(chip).not.toBeNull(); + expect(add).not.toBeNull(); expect(composer).not.toBeNull(); act(() => { - chip?.click(); + add.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 })); }); const askMenuItem = document.querySelector( - '[data-testid="issue-chat-composer-work-mode-menu-ask"]', + '[data-testid="composer-add-ask"]', ) as HTMLButtonElement | null; expect(askMenuItem).not.toBeNull(); expect(askMenuItem?.textContent).toContain("Ask mode"); @@ -852,7 +849,7 @@ describe("IssueChatThread", () => { expect(onWorkModeChange).not.toHaveBeenCalled(); expect(composer?.getAttribute("data-pending-work-mode")).toBe("ask"); expect(composer?.className).toContain("sky"); - expect(chip?.textContent).toContain("Ask mode"); + expect(container.querySelector('[data-testid="issue-chat-composer-work-mode-chip"]')?.textContent).toContain("Ask mode"); act(() => { composer?.dispatchEvent( @@ -866,7 +863,7 @@ describe("IssueChatThread", () => { }); expect(composer?.getAttribute("data-pending-work-mode")).toBe("standard"); - expect(chip?.textContent).toContain("Auto mode"); + expect(container.querySelector('[data-testid="issue-chat-composer-work-mode-chip"]')).toBeNull(); act(() => { root.unmount(); @@ -3681,26 +3678,33 @@ describe("IssueChatThread", () => { }); }); - it("shows non-image attachment upload state in the composer after a drop", async () => { + it("keeps mode controls available while a dropped file uploads", async () => { const root = createRoot(container); - const onAttachImage = vi.fn(async (file: File) => ({ - id: "attachment-1", - companyId: "company-1", - issueId: "issue-1", - issueCommentId: null, - assetId: "asset-1", - provider: "local_disk", - objectKey: "issues/issue-1/report.pdf", - contentPath: "/api/attachments/attachment-1/content", - originalFilename: file.name, - contentType: file.type, - byteSize: file.size, - sha256: "abc123", - createdByAgentId: null, - createdByUserId: "user-1", - createdAt: new Date("2026-04-24T12:00:00.000Z"), - updatedAt: new Date("2026-04-24T12:00:00.000Z"), - })); + let finishUpload: () => void = () => {}; + const uploadGate = new Promise((resolve) => { + finishUpload = resolve; + }); + const onAttachImage = vi.fn(async (file: File) => { + await uploadGate; + return { + id: "attachment-1", + companyId: "company-1", + issueId: "issue-1", + issueCommentId: null, + assetId: "asset-1", + provider: "local_disk", + objectKey: "issues/issue-1/report.pdf", + contentPath: "/api/attachments/attachment-1/content", + originalFilename: file.name, + contentType: file.type, + byteSize: file.size, + sha256: "abc123", + createdByAgentId: null, + createdByUserId: "user-1", + createdAt: new Date("2026-04-24T12:00:00.000Z"), + updatedAt: new Date("2026-04-24T12:00:00.000Z"), + }; + }); await act(async () => { root.render( @@ -3712,6 +3716,8 @@ describe("IssueChatThread", () => { liveRuns={[]} onAdd={async () => {}} onAttachImage={onAttachImage} + issueWorkMode="standard" + onWorkModeChange={() => {}} enableLiveTranscriptPolling={false} /> , @@ -3725,11 +3731,28 @@ describe("IssueChatThread", () => { type: "application/pdf", }); - await act(async () => { + act(() => { composer?.dispatchEvent(createFileDragEvent("drop", [file])); }); expect(onAttachImage).toHaveBeenCalledWith(file); + const add = container.querySelector('[data-testid="issue-chat-composer-add"]') as HTMLButtonElement; + expect(add.disabled).toBe(false); + act(() => { + add.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 })); + }); + expect(document.querySelector('[data-testid="composer-add-file"]')?.getAttribute("data-disabled")).not.toBeNull(); + const plan = document.querySelector('[data-testid="composer-add-plan"]') as HTMLButtonElement; + act(() => plan.click()); + const chip = container.querySelector('[data-testid="issue-chat-composer-work-mode-chip"]') as HTMLButtonElement; + expect(chip?.textContent).toContain("Plan mode"); + expect(chip.disabled).toBe(false); + act(() => chip.click()); + expect(container.querySelector('[data-testid="issue-chat-composer-work-mode-chip"]')).toBeNull(); + + await act(async () => { + finishUpload(); + }); const attachmentList = container.querySelector( '[data-testid="issue-chat-composer-attachments"]', ); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 1b7f8418e72..328a476df8e 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -1,5 +1,8 @@ import { DispositionRecoveryNotice, useDispositionRecoverySnapshot } from "./DispositionRecoveryNotice"; import { AgentAvatar } from "@/components/AgentAvatar"; +import type { ComposerRunSettings } from "./task-chat/composer-run-settings"; +import { ComposerRunSettingsPicker } from "./task-chat/ComposerRunSettingsPicker"; +import { ComposerAddMenu, ComposerModeChip } from "./task-chat/ComposerAddMenu"; import { TaskChatPausedTakeover, type TaskComposerPause } from "./task-chat/TaskChatPausedTakeover"; import { useEmailComment } from "./EmailMessageCard"; import { AssistantRuntimeProvider } from "@assistant-ui/react"; @@ -46,6 +49,7 @@ import type { SuccessfulRunHandoffState, IssueWorkMode, IssueWorkProduct, + IssueAssigneeAdapterOverrides, } from "@paperclipai/shared"; import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; import { findUIAdapter } from "../adapters/registry"; @@ -213,9 +217,7 @@ import { cn, formatDateTime, formatShortDate } from "../lib/utils"; import { liveBlueBadge } from "../lib/status-colors"; import { nextWorkMode, - titleForPendingWorkMode, workModeMetaFor, - workModeMetaList, } from "../lib/work-mode-meta"; import { Tooltip, @@ -518,6 +520,8 @@ interface IssueChatComposerProps { enableReassign?: boolean; reassignOptions?: InlineEntityOption[]; currentAssigneeValue?: string; + companyId?: string | null; + assigneeAdapterOverrides?: IssueAssigneeAdapterOverrides | null; suggestedAssigneeValue?: string; mentions?: MentionOption[]; agentMap?: Map; @@ -609,6 +613,7 @@ interface IssueChatThreadProps { reassignment?: CommentReassignment, attachmentIds?: string[], clientRequestId?: string, + runSettings?: ComposerRunSettings, ) => Promise; onReviewConversation?: () => Promise; onCancelRun?: () => Promise; @@ -625,6 +630,7 @@ interface IssueChatThreadProps { enableReassign?: boolean; reassignOptions?: InlineEntityOption[]; currentAssigneeValue?: string; + assigneeAdapterOverrides?: IssueAssigneeAdapterOverrides | null; suggestedAssigneeValue?: string; mentions?: MentionOption[]; composerPause?: TaskComposerPause | null; @@ -4659,6 +4665,8 @@ const IssueChatComposer = forwardRef< enableReassign = false, reassignOptions = [], currentAssigneeValue = "", + companyId, + assigneeAdapterOverrides, suggestedAssigneeValue, mentions = [], agentMap, @@ -4771,6 +4779,8 @@ const IssueChatComposer = forwardRef< const [reassignTarget, setReassignTarget] = useState( effectiveSuggestedAssigneeValue, ); + const [runSettings, setRunSettings] = useState(null); + useEffect(() => setRunSettings(null), [draftKey, currentAssigneeValue]); const [noAssigneeDialogOpen, setNoAssigneeDialogOpen] = useState(false); const [dismissedCoachToken, setDismissedCoachToken] = useState( null, @@ -4779,7 +4789,6 @@ const IssueChatComposer = forwardRef< const [pendingWorkMode, setPendingWorkMode] = useState( resolvedIssueWorkMode, ); - const [workModeMenuOpen, setWorkModeMenuOpen] = useState(false); const canToggleWorkMode = typeof onWorkModeChange === "function"; const attachInputRef = useRef(null); const reassignTriggerRef = useRef(null); @@ -5054,10 +5063,11 @@ const IssueChatComposer = forwardRef< } // assistant-ui thread.append is fire-and-forget. Await the actual Board // mutation; it already owns optimistic echo and durable error handling. - const sendPromise = onSend( - submittedBody, reopen, reassignment, - attachmentIds.length ? attachmentIds : undefined, attemptId, - ); + const sendPromise = runSettings + ? onSend(submittedBody, reopen, reassignment, + attachmentIds.length ? attachmentIds : undefined, attemptId, runSettings) + : onSend(submittedBody, reopen, reassignment, + attachmentIds.length ? attachmentIds : undefined, attemptId); queueViewportRestore(viewportSnapshot); await sendPromise; // Settle the captured task even if the user navigated away. The exact @@ -5069,6 +5079,7 @@ const IssueChatComposer = forwardRef< current.filter((item) => !submittedAttachmentKeys.has(item.id)), ); setReassignTarget(effectiveSuggestedAssigneeValue); + setRunSettings(null); } catch (error) { if (mountedTaskKey.current !== draftKey) return; const nextDraft = bodyRef.current; @@ -5344,9 +5355,7 @@ const IssueChatComposer = forwardRef< ); } - const workModeOptions = workModeMetaList(); const pendingWorkModeMeta = workModeMetaFor(pendingWorkMode); - const PendingWorkModeIcon = pendingWorkModeMeta.icon; function handleComposerKeyDown(evt: ReactKeyboardEvent) { // Match the period via both `code` and `key`: iOS Safari with a hardware @@ -5586,89 +5595,38 @@ const IssueChatComposer = forwardRef<
{canAcceptFiles ? ( - <> - - - - ) : null} - {canToggleWorkMode ? ( - - - {/* Single persistent mode chip (PAP-95b mockup rev 5): yellow in - planning, neutral in standard, caret opens the switch menu. */} - - - - {workModeOptions.map((option) => { - const Icon = option.icon; - const active = option.value === pendingWorkMode; - return ( - - ); - })} -
- Cmd/Ctrl+. cycles modes -
-
-
+ ) : null} + attachInputRef.current?.click() : undefined} + attachDisabled={attaching} + disabled={!!uncertainSubmission} + triggerTestId="issue-chat-composer-add" menuTestId="issue-chat-composer-add-menu" /> + setPendingWorkMode("standard") : undefined} + disabled={!!uncertainSubmission} + testId="issue-chat-composer-work-mode-chip" />
- {enableReassign && reassignOptions.length > 0 ? ( + {enableReassign && reassignOptions.length > 0 && companyId && agentMap ? ( + { + const selected = value.startsWith("agent:") ? agentMap.get(value.slice(6)) : null; + return selected ? : null; + }} + /> + ) : enableReassign && reassignOptions.length > 0 ? ( ( - (body, reopen, reassignment, attachmentIds, clientRequestId) => { + (body, reopen, reassignment, attachmentIds, clientRequestId, runSettings) => { pendingSubmitScrollRef.current = true; - return onAdd(body, reopen, reassignment, attachmentIds, clientRequestId); + return runSettings + ? onAdd(body, reopen, reassignment, attachmentIds, clientRequestId, runSettings) + : onAdd(body, reopen, reassignment, attachmentIds, clientRequestId); }, [onAdd], ); @@ -6805,6 +6766,8 @@ export function IssueChatThread({ enableReassign={enableReassign} reassignOptions={reassignOptions} currentAssigneeValue={currentAssigneeValue} + companyId={companyId} + assigneeAdapterOverrides={assigneeAdapterOverrides} suggestedAssigneeValue={suggestedAssigneeValue} mentions={mentions} agentMap={agentMap} diff --git a/ui/src/components/Layout.production.tsx b/ui/src/components/Layout.production.tsx index 6f88bbeeb1a..c809a8cb286 100644 --- a/ui/src/components/Layout.production.tsx +++ b/ui/src/components/Layout.production.tsx @@ -63,6 +63,7 @@ import { queryKeys } from "../lib/queryKeys"; import { scheduleMainContentFocus } from "../lib/main-content-focus"; import { pinDocumentScrollToZero } from "../lib/pin-document-scroll"; import { cn } from "../lib/utils"; +import { classifyShellRoute } from "../lib/shell-navigation"; import { NotFoundPage } from "../pages/NotFound"; import { PluginSlotMount, @@ -145,6 +146,7 @@ export function Layout() { const navigate = useNavigate(); const location = useLocation(); const navigationType = useNavigationType(); + const isTaskDetailRoute = classifyShellRoute(location.pathname, companyPrefix).isTaskDetail; const isCompanySettingsRoute = [ "/company/settings", "/company/export", @@ -738,8 +740,8 @@ export function Layout() { style={ isMobile ? ({ - "--tc-composer-bottom": mobileNavVisible - ? "var(--sz-calc-14)" + "--tc-composer-bottom": mobileNavVisible + ? "var(--tc-composer-visible-nav-offset)" : "var(--sz-calc-8)", } as CSSProperties) : undefined @@ -750,7 +752,9 @@ export function Layout() { // changes (e.g. switching skill-detail tabs) don't widen/shift // when the vertical scrollbar appears or disappears (PAP-10907). isMobile - ? "overflow-visible pb-(--sz-calc-14)" + ? isTaskDetailRoute && mobileNavVisible + ? "overflow-visible pb-(--tc-composer-visible-nav-offset)" + : "overflow-visible pb-(--sz-calc-14)" : "overflow-auto [scrollbar-gutter:stable]", )} > diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 79437ea31f7..ccf674d8532 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -740,7 +740,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) { isMobile ? ({ "--tc-composer-bottom": mobileNavVisible - ? "var(--sz-calc-14)" + ? "var(--tc-composer-visible-nav-offset)" : "var(--tc-composer-hidden-nav-offset)", } as CSSProperties) : undefined @@ -755,8 +755,10 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) { // changes (e.g. switching skill-detail tabs) don't widen/shift // when the vertical scrollbar appears or disappears (PAP-10907). isMobile - ? isTaskDetailRoute && !mobileNavVisible - ? "overflow-visible pb-(--tc-composer-hidden-nav-offset)" + ? isTaskDetailRoute + ? mobileNavVisible + ? "overflow-visible pb-(--tc-composer-visible-nav-offset)" + : "overflow-visible pb-(--tc-composer-hidden-nav-offset)" : "overflow-visible pb-(--sz-calc-14)" : "overflow-auto [scrollbar-gutter:stable]", )} diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 6ec28f29e8f..34e75aee25a 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -87,6 +87,7 @@ import { taskChatContentKey, } from "@/components/task-chat/TaskChatThreadView"; import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer"; +import { TaskChatComposerDock } from "@/components/task-chat/TaskChatComposerDock"; import { RunnerGoalWidget, useRunnerGoalControl, @@ -510,6 +511,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { conversationMode, reassignOptions, currentAssigneeValue, + assigneeAdapterOverrides, issueStatus, issueAssigneeAgentId = null, onAcceptInteraction, @@ -2985,28 +2987,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
) : null} {showComposer ? ( -
+ {composerAccessory} {tailTurnStatus ? ( @@ -3079,8 +3060,11 @@ export function TaskChatThread(props: TaskChatThreadProps) { conversationMode={conversationMode} reassignOptions={reassignOptions} agentMap={agentMap} + modelAgents={agentMap} userProfileMap={userProfileMap} currentAssigneeValue={currentAssigneeValue} + companyId={companyId} + assigneeAdapterOverrides={assigneeAdapterOverrides} onPendingAssigneeChange={setPendingComposerAssignee} issueStatus={issueStatus} mobile={isMobile} @@ -3106,7 +3090,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
{footer} - + ) : null} diff --git a/ui/src/components/artifacts/IssueArtifactCard.test.tsx b/ui/src/components/artifacts/IssueArtifactCard.test.tsx new file mode 100644 index 00000000000..75402929e38 --- /dev/null +++ b/ui/src/components/artifacts/IssueArtifactCard.test.tsx @@ -0,0 +1,275 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import type { IssueWorkProduct } from "@paperclipai/shared"; +import { + IssueArtifactFile, + IssueWorkProductArtifactCard, +} from "./IssueArtifactCard"; + +import { DocumentCard } from "./RichArtifactCards"; + +function product(overrides: Partial = {}): IssueWorkProduct { + return { + id: "wp-1", + companyId: "company-1", + projectId: null, + issueId: "issue-1", + executionWorkspaceId: null, + runtimeServiceId: null, + type: "pull_request", + provider: "github", + externalId: null, + title: "Actual artifact title", + url: "https://github.com/org/repo/pull/42", + status: "active", + reviewState: "none", + isPrimary: false, + healthStatus: "unknown", + summary: "Actual artifact summary", + metadata: null, + createdByRunId: null, + createdAt: new Date("2026-09-28"), + updatedAt: new Date("2026-09-28"), + ...overrides, + }; +} +function render(wp: IssueWorkProduct) { + return renderToStaticMarkup( + + + , + ); +} + +describe("production artifact cards", () => { + it("renders supplied PR metadata without synthesizing missing counts, checks or branch names", () => { + const sparse = render(product()); + expect(sparse).toContain("Actual artifact title"); + expect(sparse).toContain("Actual artifact summary"); + expect(sparse).toContain("Actual agent"); + expect(sparse).toContain("Checks not available"); + expect(sparse).not.toContain("Checks passed"); + expect(sparse).not.toContain("+0"); + expect(sparse).not.toContain("#0"); + expect(sparse).not.toContain("master"); + const full = render( + product({ + metadata: { + number: 42, + repo: "org/repo", + state: "draft", + additions: 0, + deletions: 8, + changedFiles: 2, + headRef: "feature", + baseRef: "main", + }, + }), + ); + for (const value of [ + "#42", + "org/repo", + "Draft", + "+0", + "−8", + "feature", + "main", + ]) + expect(full).toContain(value); + }); + it("preserves review and unhealthy states", () => { + expect( + render(product({ reviewState: "changes_requested", status: "merged" })), + ).toContain("Changes requested"); + expect( + render(product({ type: "preview_url", healthStatus: "unhealthy" })), + ).toContain("Unhealthy"); + expect( + render(product({ type: "preview_url", healthStatus: "unknown" })), + ).not.toContain("Healthy"); + }); + it.each([ + ["ready_for_review", "Review"], + ["changes_requested", "Changes requested"], + ])( + "preserves PR status %s without a separate reviewState", + (status, label) => { + expect(render(product({ status, reviewState: "none" }))).toContain(label); + }, + ); + + it("preserves browser-open actions for attachments and signed external file URLs", () => { + const signedUrl = + "https://files.example/report.pdf?signature=abc&expires=123"; + const external = render( + product({ + type: "artifact", + url: signedUrl, + metadata: { contentType: "application/pdf" }, + }), + ); + expect(external).toContain( + 'href="https://files.example/report.pdf?signature=abc&expires=123"', + ); + expect(external).not.toContain("?download=1"); + expect(external).toContain("Open file"); + expect(external).toContain("Download file"); + + const local = renderToStaticMarkup( + + + , + ); + expect(local).toContain( + 'href="/api/attachments/pdf-1/content" target="_blank"', + ); + expect(local).toContain( + 'href="/api/attachments/pdf-1/content?download=1" download="report.pdf"', + ); + }); + + it("selects commit, link, image, video and file renderers from real records", () => { + expect( + render( + product({ + type: "commit", + metadata: { sha: "123456789abcdef", repo: "org/repo" }, + }), + ), + ).toContain("12345678"); + expect(render(product({ type: "preview_url" }))).toContain("Open link"); + const attachment = { + contentPath: "/api/attachments/file-1/content", + byteSize: 25, + }; + expect( + render( + product({ + type: "artifact", + metadata: { + ...attachment, + contentType: "image/png", + originalFilename: "actual.png", + }, + }), + ), + ).toContain("View image"); + const video = render( + product({ + type: "artifact", + metadata: { + ...attachment, + contentType: "video/mp4", + originalFilename: "actual.mp4", + }, + }), + ); + expect(video).toContain(" { + const client = new QueryClient(); + client.setQueryData( + ["artifact-csv", "csv-1", "/api/attachments/csv-1/content", "Today"], + { columns: ["Region"], rows: [["Actual region"]], truncated: false }, + ); + const html = renderToStaticMarkup( + + + , + ); + expect(html).toContain("Actual region"); + expect(html).toContain("View data"); + }); + it("does not fetch remote Markdown images just to render a document preview", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).not.toContain('src="https://tracker.example'); + expect(html).toContain('href="https://tracker.example/image.png"'); + expect(html).toContain('src="/api/attachments/image-1/content"'); + }); + it("does not automatically load remote thumbnail or poster metadata", () => { + const link = render( + product({ + type: "preview_url", + metadata: { imageUrl: "https://tracker.example/pixel.png" }, + }), + ); + expect(link).not.toContain(" { + expect(render(product({ type: "branch" }))).toContain("Open on GitHub"); + expect( + render(product({ type: "runtime_service", healthStatus: "unhealthy" })), + ).toContain("Unhealthy"); + expect( + render(product({ type: "preview_url", url: "javascript:alert(1)" })), + ).not.toContain("javascript:"); + }); +}); diff --git a/ui/src/components/artifacts/IssueArtifactCard.tsx b/ui/src/components/artifacts/IssueArtifactCard.tsx new file mode 100644 index 00000000000..90118a87d70 --- /dev/null +++ b/ui/src/components/artifacts/IssueArtifactCard.tsx @@ -0,0 +1,277 @@ +import { useContext, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + getAttachmentArtifactWorkProductMetadata, + type IssueWorkProduct, +} from "@paperclipai/shared"; +import { IssueGalleryContext } from "@/context/IssueGalleryContext"; +import { ImageGalleryModal } from "@/components/ImageGalleryModal"; +import { + RichWorkProductCard, + stateChipFor, +} from "@/components/task-chat/RichWorkProductCard"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { isImageLikeOutput, isVideoLikeOutput } from "@/lib/issue-output"; +import { attachmentDownloadPath } from "@/lib/issue-attachments"; +import { workProductHref } from "@/lib/issue-artifacts"; +import { formatDateTime } from "@/lib/utils"; +import { + artifactText as text, + artifactNumber as number, + artifactUrl, + artifactPreviewUrl, + artifactFileSize, + CSV_PREVIEW_MAX_BYTES, + loadArtifactCsv, +} from "@/lib/artifact-card-data"; +import { + CommitCard, + DataCard, + FileCard, + ImageCard, + LinkPreviewCard, + PullRequestCard, + VideoCard, + type ArtifactIdentity, +} from "./RichArtifactCards"; + +export interface IssueArtifactFileProps extends ArtifactIdentity { + id: string; + filename: string; + contentType: string; + contentPath: string; + downloadPath: string; + openPath?: string; + byteSize: number | null; + metadata?: Record | null; +} + +/** Shared by uploads and promoted uploads; both use the issue's existing gallery. */ +export function IssueArtifactFile(props: IssueArtifactFileProps) { + const { metadata = null } = props; + const openGallery = useContext(IssueGalleryContext); + const [galleryOpen, setGalleryOpen] = useState(false); + const [csvRequested, setCsvRequested] = useState(false); + const image = isImageLikeOutput(props.contentType, props.filename); + const video = isVideoLikeOutput(props.contentType, props.filename); + const csv = + props.contentType.split(";")[0] === "text/csv" || + /\.csv$/i.test(props.filename); + const tooLarge = + props.byteSize !== null && props.byteSize > CSV_PREVIEW_MAX_BYTES; + const localCsv = + csv && + /^\/api\/attachments\/[a-zA-Z0-9-]+\/content$/.test(props.contentPath); + const data = useQuery({ + queryKey: ["artifact-csv", props.id, props.contentPath, props.updatedAt], + queryFn: ({ signal }) => loadArtifactCsv(props.contentPath, signal), + enabled: localCsv && !tooLarge && csvRequested, + retry: false, + staleTime: Infinity, + }); + const contentPath = artifactUrl(props.contentPath); + const downloadPath = artifactUrl(props.downloadPath); + const onOpen = () => { + if (!openGallery?.(contentPath)) setGalleryOpen(true); + }; + if ((image || video) && contentPath) { + return ( + <> + {video ? ( + + ) : ( + + )} + {galleryOpen && ( + + )} + + ); + } + if (localCsv && !tooLarge && data.data) + return ; + return ( +
+ setCsvRequested(true)} + > + {data.isFetching ? "Loading preview…" : "Preview data"} + + ) : undefined + } + /> + {csv && (tooLarge || data.isError || !localCsv) && ( +
+ {tooLarge + ? "CSV is too large to preview. Download the file to view it." + : data.isError + ? data.error.message + : "CSV preview is unavailable. Download the file to view it."} + {data.isError && ( + + )} +
+ )} +
+ ); +} + +export function IssueWorkProductArtifactCard({ + workProduct: wp, + author, +}: { + workProduct: IssueWorkProduct; + author: string; +}) { + const m = wp.metadata; + const href = artifactUrl(workProductHref(wp)); + const chip = stateChipFor(wp.type, wp.status, wp.reviewState); + const identity: ArtifactIdentity = { + title: wp.title, + summary: wp.summary ?? "", + author, + updatedAt: formatDateTime(wp.updatedAt), + statusBadge: + wp.healthStatus === "unhealthy" ? ( + Unhealthy + ) : chip ? ( + {chip.label} + ) : undefined, + }; + const diff = { + additions: number(m, "additions"), + deletions: number(m, "deletions"), + filesChanged: number(m, "changedFiles", "files"), + }; + if (wp.type === "pull_request") { + const state = + text(m, "state") || (wp.status === "active" ? "open" : wp.status); + const checks = text(m, "checks"); + return ( + + ); + } + if (wp.type === "commit") + return ( + + ); + if (wp.type === "preview_url") + return ( + + ); + if (wp.type === "artifact") { + const attachment = getAttachmentArtifactWorkProductMetadata(wp); + const contentPath = artifactUrl( + attachment?.contentPath || text(m, "contentPath") || href, + ); + return ( + + ); + } + // Branches, external documents, and runtime services keep their existing actions and health semantics. + return ; +} diff --git a/ui/src/components/artifacts/IssueArtifactFile.test.tsx b/ui/src/components/artifacts/IssueArtifactFile.test.tsx new file mode 100644 index 00000000000..a4f7b845f8b --- /dev/null +++ b/ui/src/components/artifacts/IssueArtifactFile.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi } from "vitest"; +import { IssueArtifactFile } from "./IssueArtifactCard"; +import { loadArtifactCsv } from "@/lib/artifact-card-data"; + +vi.mock("@/lib/artifact-card-data", async (importOriginal) => ({ + ...(await importOriginal()), + loadArtifactCsv: vi.fn(), +})); + +describe("CSV preview consent", () => { + it("does not fetch any CSV until its preview is requested", async () => { + const load = vi + .mocked(loadArtifactCsv) + .mockResolvedValue({ + columns: ["Name"], + rows: [["Actual row"]], + truncated: false, + }); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + try { + await act(async () => + root.render( + + {Array.from({ length: 20 }, (_, i) => ( + + ))} + , + ), + ); + expect(load).not.toHaveBeenCalled(); + const preview = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Preview data", + ); + expect(preview).toBeDefined(); + await act(async () => preview!.click()); + await vi.waitFor(() => + expect(container.textContent).toContain("Actual row"), + ); + expect(load).toHaveBeenCalledTimes(1); + expect(load).toHaveBeenCalledWith( + "/api/attachments/csv-0/content", + expect.any(AbortSignal), + ); + expect(container.textContent).toContain("View data"); + } finally { + await act(async () => root.unmount()); + client.clear(); + container.remove(); + vi.clearAllMocks(); + } + }); +}); diff --git a/ui/src/components/artifacts/RichArtifactCards.tsx b/ui/src/components/artifacts/RichArtifactCards.tsx new file mode 100644 index 00000000000..2c09febcb56 --- /dev/null +++ b/ui/src/components/artifacts/RichArtifactCards.tsx @@ -0,0 +1,733 @@ +import { useState, type ReactNode } from "react"; +import ReactMarkdown from "react-markdown"; +import { + ArrowRight, + CircleCheck, + CircleHelp, + Clock, + ExternalLink, + File, + FileText, + Film, + GitBranch, + GitCommitHorizontal, + GitMerge, + GitPullRequest, + Globe, + Image as ImageIcon, + Table2, + TriangleAlert, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { artifactPreviewUrl, artifactUrl } from "@/lib/artifact-card-data"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +// Renderers contain UI labels only. All artifact-specific content is passed in. +// Example values live exclusively in the individual stories' args. +export interface ArtifactIdentity { + title: string; + summary: string; + author: string; + updatedAt: string; + statusBadge?: ReactNode; +} + +function Identity({ + title, + summary, +}: Pick) { + return ( +
+

+ {title} +

+ {summary && ( +

+ {summary} +

+ )} +
+ ); +} + +function Footer({ + author, + updatedAt, + action, + statusBadge, +}: Pick & { + action: ReactNode; +}) { + return ( +
+ + {[author, updatedAt].filter(Boolean).join(" · ")} + + {statusBadge} + {action} +
+ ); +} + +function Card({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function SourceLink({ url, children }: { url: string; children: ReactNode }) { + return url ? ( + + ) : ( + + ); +} + +function Viewer({ + title, + description, + action, + children, +}: { + title: string; + description: string; + action: string; + children: ReactNode; +}) { + return ( + + + + + + + {title} + {description} + + {children} + + + ); +} + +interface DiffProps { + additions?: number | null; + deletions?: number | null; + filesChanged?: number | null; +} +function Diff({ additions, deletions, filesChanged }: DiffProps) { + return ( +
+ {additions != null && ( + + +{additions.toLocaleString("en-US")} + + )} + {deletions != null && ( + + −{deletions.toLocaleString("en-US")} + + )} + {filesChanged != null && ( + + {filesChanged} {filesChanged === 1 ? "file" : "files"} + + )} +
+ ); +} + +export interface PullRequestCardProps extends ArtifactIdentity, DiffProps { + number?: number | null; + repository: string; + sourceBranch: string; + targetBranch: string; + state: "open" | "draft" | "merged" | "closed" | "unknown"; + checks: "passed" | "pending" | "failed" | "unknown"; + evidenceSource: string; + reviewSummary: string; + url: string; +} + +const checksLabel = { + passed: "Checks passed", + pending: "Checks pending", + failed: "Checks failed", + unknown: "Checks not available", +}; +const stateLabel = { + open: "Open", + draft: "Draft", + merged: "Merged", + closed: "Closed", + unknown: "State unknown", +}; + +export function PullRequestCard(props: PullRequestCardProps) { + const { + number, + repository, + sourceBranch, + targetBranch, + state, + checks, + evidenceSource, + reviewSummary, + url, + } = props; + const CheckIcon = { + passed: CircleCheck, + pending: Clock, + failed: TriangleAlert, + unknown: CircleHelp, + }[checks]; + const checkTone = { + passed: "text-(--status-task-icon-done)", + pending: "text-muted-foreground", + failed: "text-(--status-task-icon-blocked)", + unknown: "text-muted-foreground", + }[checks]; + return ( + +
+
+ + Pull request{" "} + {number != null && #{number}} + + + {state === "merged" && } + {stateLabel[state]} + +
+ +
+ {repository} + {(sourceBranch || targetBranch) && ( +
+ + + {sourceBranch} + + {sourceBranch && targetBranch && ( + + )} + + {targetBranch} + +
+ )} +
+ +
+
+ + + {checksLabel[checks]} + + {reviewSummary && ( +

{reviewSummary}

+ )} + {evidenceSource && ( +

+ Source: {evidenceSource} +

+ )} +
+