merge: preserve Claude consumers and canonical install diagnostics

Retain both Claude SDK admission and bounded Intel initial-install failure evidence. All25 focused consumer controls pass; no provider, build campaign, local Rust or Docker.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-12 22:00:38 -05:00
commit 4ba521823f
240 files changed
+192172 -1485

No files matched your search

+45
View File
@@ -755,3 +755,48 @@ Use the same root client for in-process writers. New queue insertion paths must
register before writing, or they can remain unseen until the next startup or
another notification. Tests should verify both the producer and its outer
transaction boundary.
### Task workspace records (2026-10-09)
Migration [0336_big_crystal.sql](../packages/db/src/migrations/0336_big_crystal.sql) makes `execution_workspaces.project_id` nullable and changes its project reference to `ON DELETE RESTRICT`: source-project policy cannot disappear while a workspace still depends on it. `issues.execution_workspace_id` remains the sole active task binding. Versioned selection/pending-selection JSON and a binding revision record intent and protect transitions; they are not additional active bindings. Accepting a new pending choice advances the revision without changing the active folder. Its receipt retains the original request revision, so current-choice retries are idempotent and superseded retries fail the revision check.
`execution_workspace_repositories` is company-scoped inventory beneath an execution workspace. It records repository identity, contained relative path, requested ref, pinned commit, preparation state and idempotency receipts. Credentials are resolved at use and are never stored in this inventory. Connection/destination `execution_defaults` JSON contains optional typed project/workspace choices, never arbitrary host paths or commands.
Migration [0337_exotic_proemial_gods.sql](../packages/db/src/migrations/0337_exotic_proemial_gods.sql) adds internal `execution_workspace_repository_roots` records. A company and hash of its canonical physical path uniquely identify a sticky `anonymous_only` or `managed` acquisition reservation. Short transactions serialize reservations with repository requests and credential promotion, preventing private publication after low-trust admission checks. Records have no workspace foreign key: mutable workspace metadata, relocation, archival or deletion cannot release a physical folder still accessible to an admitted run. There is no automatic mode change or reservation deletion.
Migration [0338_absurd_vengeance.sql](../packages/db/src/migrations/0338_absurd_vengeance.sql) adds internal `execution_workspace_repository_access` provenance. Company/canonical-root source entries retain execution-workspace and source-project identities; reader entries retain the exact admitted run, task, agent and responsible user. New repository receipts also record their root at creation; legacy receipts receive no inferred origin. Shared project-source entries retain which physical owners require workspace write authority. Current admission, selection, requests and publication check the current task’s writer against those owners; retained past readers and file APIs still require only read authority. Genuine isolated copies retain source read access without requiring shared-folder write access. An existing linked worktree can recover a missing isolated-copy proof only after Git registration and its captured source owner are verified. Configured local sibling copies retain deny-only task/project source ownership on the bound task root before materialization; ordinary repository clones create no isolation proof. The shared root fence checks every reader against every source before admission and future publication, including tasks without repository requests. File APIs apply the same source privacy without writing provenance. These entries grant no authority and have no workspace or run deletion cascade; unavailable retained identity fails closed. Automatic provenance release is deferred, so a newly incompatible permission scope requires a separate physical root.
Git-source capture checks the actual common Git storage directory and verified
checkout owners. Separately stored metadata does not make its parent a checkout.
When a linked worktree cannot identify its primary checkout, the service verifies
company-known configured, execution, and retained roots against that metadata
identity. A primary checkout identifies itself when its Git directory equals its
common directory, including separate metadata layouts. Verified primary sources
and linked worktrees with a verified registered primary avoid the fallback scan.
Writable selection, source-copy preparation, admission, and publication check the trust policy of
current and retained physical source projects. A projectless alias cannot bypass
a source's sandbox requirement. An incompatible preset or boundary is rejected;
the check never silently changes the selected execution environment. Admitted
and retained readers use their persisted effective run policy; absence of a
retained low-trust policy counts as standard. Live policy edits cannot relabel
a standard local run as sandboxed. Read-only file access
keeps its existing authorization rules.
Source-copy checks retain deny-only ownership on failure but create a new
isolated-copy proof only after physical identity, access, and trust checks pass.
For low-trust sandbox runs, the harness also disables host worktree provisioning
on fresh realization, reuse, and rebuild. This covers configured commands and
the repository's built-in provision script; an empty configured command alone
would still allow that fallback. The capability comes from the server's selected
trust policy and environment, never repository configuration.
After source authorization and retained-reader checks pass, the existing
`source` and `project_source` kinds retain ownership on shared Git storage.
Removing the original checkout cannot release private committed files in surviving
worktrees. These records grant no authority and create no isolation proof. Denied
source captures leave no new records. Worktree realization uses a verified checkout
as its Git command directory, including when metadata sits inside another repository.
No migration scans or copies existing agent home directories. Keep admitted native descriptors and recovery references until compatible controllers finish their persistence obligations. See [the architecture rollout contract](plans/2026-10-09-task-workspaces.md).
+8
View File
@@ -2300,6 +2300,14 @@ it needs no running Paperclip instance or provider credentials. The same spec li
in the default `test:e2e` discovery tree, so the existing Chrome CI shards run it
on pull requests.
### Workspace feature checks (2026-10-09)
Task files, personal agent state (`AGENT_HOME`), and provider home are distinct. New filesystem tasks without an explicit source use a stable company/task directory. Debug workspace placement through the task workspace API rather than inferring it from organizational project or agent identity.
Focused coverage lives in `task-workspace-binding.test.ts`, `task-workspace-repositories*.test.ts`, `task-workspace-tools.test.ts`, channel integration tests, and adapter-utils checkpoint/native persistence tests. Full handoff still requires the repository typecheck, test, and build commands. Remote transport fixtures establish protocol behavior; they do not qualify production provider throughput.
Channel defaults can be configured in Apps connection settings and per destination. They affect new conversations only. A queued selection or repository request is applied on the next normal admission; configuration changes do not interrupt a live run. See [the architecture](plans/2026-10-09-task-workspaces.md).
### Slack provisioning paths
Self-hosted development continues to use independent Slack app setup. Managed
+11
View File
@@ -2160,6 +2160,17 @@ Company Settings → General can configure one shared API-key connection for opt
The internal service provides local-only availability and bounded, reauthorized execution. It records metadata-only invocation history and independent fractional service charges in the existing cost ledger, applying company and applicable agent/project budgets under the accounting lock. Unknown dispatch charges retain reservations until audited resolution. Settings/testing require connection management permission; history uses existing cost visibility. See [decision-models.md](decision-models.md) for the contract, supported models, accounting, privacy, and endpoints.
### Task workspace ownership (2026-10-09)
A task's organizational project and filesystem workspace are independent. New tasks that need a filesystem and have no selected/configured workspace get a company/task directory, not the agent home. Plain tasks need neither Git nor a project. Existing bindings and admitted roots remain authoritative; changing project does not move files. Explicit workspace selections apply at a subsequent normal admission after prior execution and persistence obligations settle.
Chat connection and destination settings may supply project/workspace defaults. Destination fields override connection fields; omitted values inherit and null clears the inherited choice. Resolve and authorize these defaults once for a new conversation, then retain the resulting task intent. Existing conversations do not change when settings change. Trust confinement still takes precedence.
Agents inspect/select workspaces and request contained repository preparation through ordinary authorized APIs. Repository preparation is idempotent and may require the next admission; it never claims a host-only clone is available in an already-running remote session. Simultaneous tasks may still explicitly share one physical local/SSH folder. Remote sharing requires actual placement and coherent persistence capability.
See [the layer architecture](plans/2026-10-09-task-workspaces.md) for resolution rules, module ownership, persistence versions, rollout, and verification.
## Company fast-response service
Companies may configure an optional shared API connection and model for short acknowledgements of accepted human turns. Fast responses use direct SDK inference alongside normal agent work, with a three-second admission deadline, bounded prompts/output, current authorization and independent `fast_response` accounting. They are server-marked platform receipts attributed to the agent, never completion, interaction or recovery evidence. Company-sponsored unlinked external senders are explicit; linked-user permission denials are never bypassed. See [fast-responses.md](fast-responses.md) for provider, delivery, history and endpoint contracts.
+7
View File
@@ -766,6 +766,13 @@ authentication, replay, document-sized limits, inspection, and deletion semantic
A company may configure a shared decision model for optional Paperclip features. The instance owns credential resolution, authorization, budget admission, and attributable service charges. Company-sponsored background use is enabled by default during configuration; explicit opt-out persists. User and agent requests keep their own access boundaries and cannot become sponsored background requests after denial. Availability is a cheap local capability check, and metadata-only request history makes service usage inspectable. The implemented V1 contract is in [decision-models.md](decision-models.md).
### Task workspace ownership (2026-10-09)
Organizational projects, task files, personal agent home, and provider home are separate concepts. App and chat tasks share one workspace policy: preserve a binding or explicit configured folder, otherwise allocate a stable task directory. A repository is optional and can be prepared without creating a project. Optional connection and destination defaults feed the same task policy; they do not grant permissions or relocate existing conversations.
Agents select authorized workspace/repository intent while the harness owns paths, preparation, persistence and safe transitions. Intentional simultaneous sharing of one physical local/SSH folder remains supported. Provider sessions keep their admitted root through completion and recovery. See [SPEC-implementation.md](SPEC-implementation.md) and [the layer architecture](plans/2026-10-09-task-workspaces.md) for the versioned contracts and rollout constraints.
### Fast responses
A company may use a shared API model to acknowledge accepted human messages across tasks, agent chat and external channels while normal work starts. Acknowledgements retain their platform provenance and cannot satisfy execution or completion requirements. The bounded inference, authorization, sponsorship and independent accounting contract is defined in [fast-responses.md](fast-responses.md).
+6
View File
@@ -453,3 +453,9 @@ See the [suite contract](../tests/runner-e2e/README.md#conversational-confirmati
Hiring notification accounting now also requires exact completed action attribution. Missing native/provider ID mapping is uncomparable evidence; it must not be reported as a model task regression or waived through name/order matching. The fixture waits for both known completion callbacks and settled bracketed observations, including the gap before pending outbox work becomes a wake. Strict action replay and original machine verdicts are retained separately.
The explicit-only [planning guidance utility comparison](../tests/runner-e2e/PLAN-TASK-GUIDANCE.md) measures task decomposition and handoffs with current, short, and disabled skills.
The explicit-only [task-workspaces Product E2E suite](../tests/runner-e2e/README.md#projectless-task-workspaces-explicit-only)
covers projectless task files, deferred repository acquisition, controller restart
and nested Git/dirty-file persistence on legacy local, native local and native
Daytona OpenCode. Its three configured cells use independent filesystem, public
API and download oracles; no live qualification is implied by fixture registration.
+14 -8
View File
@@ -164,14 +164,19 @@ Those sections follow below.
## Native Runner Trace Spans
Paperclip Runner task runs emit a single foldable OpenTelemetry trace. This is
the native-run trace schema version `2`. `task.run` is the only full-run root;
the native-run trace schema version `3`. `task.provider_session` is the native
provider lifecycle root; it includes preparation and provider execution, but
ends before outer workspace persistence. The heartbeat-owned local run-log
`task.run.measured` records terminal elapsed time including durable copyback;
every other native span carries a real OpenTelemetry parent context rather than
only a descriptive `parentName` field.
The run link in Honeycomb matches both `task.provider_session` for native runs and `task.run` for legacy adapters. It retains the exact hashed run-ID and service filters and groups by trace ID. The URL builder uses Honeycomb’s supported `in` operator for the two root names; see the [query specification](https://github.com/honeycombio/honeycomb-mcp#query-specification-for-run_query). This changes only the query link. The operator endpoint gate and span attribute allowlist remain unchanged.
The canonical lifecycle is:
```text
task.run
task.provider_session
├── heartbeat.queue
├── task.prepare
│ ├── environment.startup
@@ -253,10 +258,11 @@ and outcome. Native child attributes use the bounded
`paperclip.native.span.` prefix and a closed key allowlist; values are limited
to finite numbers, booleans, or short strings. Commands, arguments, environment
values, paths, output, credentials, and raw identifiers are discarded by the
trace helper. `task.run.measured` remains in the local run log for compatibility
but is not exported as a second full-width OTel span.
trace helper. `task.provider_session.measured` records that same interval in the
local run log, without a duplicate full-width OTel span. The separate heartbeat
`task.run.measured` event is the authoritative end-to-end duration.
Persisted `run.performance.span` events retain the v1 run-log schema and include
`traceSchemaVersion: 2` so local tooling can distinguish the hierarchy.
`traceSchemaVersion: 3` so local tooling can distinguish the hierarchy.
Like every span in this document, native-run spans are opt-in. When
`OTEL_EXPORTER_OTLP_ENDPOINT` is unset, the tracer remains a no-op; the local
@@ -1054,16 +1060,16 @@ spans. A run-time
(`sandbox.agentSession.sendInput`, `sandbox.agentSession.pollOutput`,
`sandbox.callbackBridge.relayRequest`, or `sandbox.agentProcess`). Each run-time
wrapper span parents to the live run span (`agent.turn` during the turn,
`task.run` otherwise). With no active trace context the exec span opens
`task.provider_session` otherwise). With no active trace context the exec span opens
unparented.
`sandbox.agentProcess` wraps the persistent streamed agent process. The
process-session bridge launches it during `bridge.process-session`, so it opens
under `task.run` — no turn has started yet. It therefore overlaps the sibling
under `task.provider_session` — no turn has started yet. It therefore overlaps the sibling
`agent.turn` rather than nesting under it or dangling off the short-lived bring-up
step. The span ends when the process settles or when the bridge tears down,
whichever comes first. The bridge tears down before the run root span ends, so
the span never outlives `task.run` even when the process lingers past teardown
the span never outlives `task.provider_session` even when the process lingers past teardown
(the sandbox `execute` has no cancel, so a lingering process cannot be forced to
resolve).
@@ -2,6 +2,75 @@
Updated 2026-10-12.
## Master reconciliation and installed-package checkpoint
The published prerequisite `64c074fb8da1b31f8d6042c4fef48dc4164e759b`
passes 54 current-head checks with four conditional skips and fresh Greptile 5/5.
All four review threads are resolved. The actual hosted transport suite passes
246 tests and startup trust passes 22; this closes the earlier local fake-binary
limitation at that literal source. Full workspace checks, Linux DB controls,
Rust suites, isolated native compilation and all eight browser shards pass.
Receipt: `/private/tmp/pr2-64c074-full-ci-review-0144-receipt.json`.
The [existing final package campaign](https://github.com/paperclipai/paperclip/actions/runs/38332921463)
completed on literal `64c074`. All four native producers, public Linux package
assembly, Apple Silicon and Linux ARM64 installed consumers pass. Intel Mac
fails during initial `npm install --ignore-scripts --omit=dev`, with `ETIMEDOUT`
after the existing 10-minute bound. It never reaches lifecycle or native runtime
assertions. The log establishes an installation timeout, not its network/host
cause and not a runner failure. Intel installed execution remains unqualified.
Keep the failed receipt; do not relax assertions or relabel the successful
platforms as a complete campaign. No automatic retry was dispatched.
Master then advanced to `f329b24ee12496e710ad17377ec7716e0eee22ef`, adding
separately merged durable task workspaces. It conflicts only in the test-home
helper/test and the Storybook review record. Both independent additive resolution
proposals are preserved. The cleanup keeps guarded read-only permission recovery
and its unrelated-ENOTEMPTY negative, adopts master's POSIX error assertion,
and retains master's new isolated-home identity/symlink protections byte-for-byte.
Both source-only Storybook review appendices remain; the catalog merges without
new story/fixture edits or changed review classifications. The task-workspace
migration and runtime changes come from master, not this PR's feature scope;
final image/migrator and backward-image proof must include them.
The composed worktree passes six existing home controls, with zero failures or
skips, plus runner/surfaces/server/E2E TypeScript checks, token gates, both map
checks (35 areas/171 IDs) and Storybook structure (223 files/2,210 stories).
Only scratch-local TypeScript preparation ran; root's idle test-drive and data
stay unchanged. The contribution check has no below-1,000-to-above-1,000 code
crossing. The two whitespace lines already in master's unrelated privacy test
are not modified; the PR diff against master is clean. The review remains 63
files. These composed checks have their working-tree source identity and are not
claimed as execution of the old published commit. Receipts are
`/private/tmp/pr2-f329-uncommitted-home-controls-receipt.json` and
`/private/tmp/pr2-f329-composed-source-checks-receipt.json`.
The existing Mac installer now retains bounded npm timing/progress diagnostics
in its already-uploaded receipt path before cleanup. Only allowlisted metadata
is retained; argv, URLs, environment, raw stderr and exception text are omitted.
At most two 16 KiB file tails and 32 entries per tail are read. Symlink exclusions
and the original exception identity remain. Failure is explicitly FAILED,
unqualified and runtime-not-attempted. The existing 10-minute deadline and all
package, lock, payload, lifecycle and startup oracles are unchanged. Three narrow
cases extend the existing suite; all 24 pass on canonical Node 24, and independent
matching-source review is clear. This adds diagnostics, not an installation
success or explanation of the old timeout. Receipts:
`/private/tmp/pr2-mac-initial-install-diagnostics-repair-receipt.json`,
`/private/tmp/ci-final-mac-install-diagnostics-independent-review.json` and
`/private/tmp/pr2-f329-install-diagnostics-node24.log`.
Next action: publish the ordinary master-reconciliation commit, require fresh
full CI/review and exact-source package qualification, then normal code-owner
review and merge. Prior `64c074` CI/install receipts remain literal after this
source change. The Claude owner is waiting for the exact canonical parent before
its next composed campaign; keep all its Claude fixes and separate $50 allowance.
Codex remains `1a0ca662` until actual prerequisite merge and reconciliation.
Final immutable OCI QA onboarding/task/follow-up, private Cloud composition,
warm/cold signup and backward-image or forward-recovery proof remain required.
Production Fleet Admin sign-in and code-owner approval are still pending.
No slice is merged or production-ready. Paid holds remain $248.179254104/$250;
no new paid/provider action, local native/Docker build or rollout took place.
## Production readiness: owned startup correction
The human requests production readiness; preparation and normal sequential
+153
View File
@@ -0,0 +1,153 @@
# Selected architecture — 2026-10-09
This design addresses PAP-171: a short agent turn paid minutes of workspace hydration and copyback because a projectless chat task inherited a large agent fallback directory. It covers app/API and chat tasks, including optional channel defaults, independent project and filesystem choices, explicit physical sharing, and deterministic harness preparation. Two architecture workers received the same full plan and grounding. Both candidates and an independent review used the inherited parent model; this is not cross-model consensus.
## Caller contract first
App/API/chat issue creation supplies typed workspace intent to the same workspace owner. Chat supplies optional defaults and authenticated source context, never paths or credentials. Task creation is transactional and cheap. Run preparation resolves an already-bound workspace or deterministically creates a company/task directory; provider, budget, run ownership and terminal disposition stay with heartbeat. The workspace owner does not become a second heartbeat orchestrator.
Agent tools inspect workspace, discover existing authorized resources, prepare a repository, and explicitly select a workspace. Callers choose intent, never host shell commands or arbitrary destination paths. A repository checkout is optional and never requires a project. Root changes apply only at a safe subsequent admission; existing provider roots are immutable. Ordinary project edits do not move files.
## Synthesis decision
Base A retains issues.executionWorkspaceId as the single active binding and execution-workspace service as the mutation owner. Reject B's replacement binding/realization tables: existing environment leases and native descriptors already own physical and recovery facts. Graft B's narrow provider-facing context, durable settlement by run ID (reload accepted result/source evidence), explicit retention references, atomic repository subtree publication and project-deletion safety. Keep source-project authorization distinct from organizational task project.
Parent scores: A 4/4/4/4/4; B 4/3/4/4/2 on ownership, preserved invariants, origin/tool parity, sync improvement, migration feasibility. Cross-judge independently chose A with the same scores. Neither candidate is implemented unchanged: their broad prepareRun/settleRun facade must not move unrelated execution orchestration into the workspace module.
## State and boundaries
- `issues.executionWorkspaceId`: canonical active binding. Workspace selection/revision and pending intent are typed/versioned task fields, not a second binding. Atomic issue lock/CAS and idempotent mutation receipts protect transitions.
- `execution_workspaces`: filesystem ownership/lifecycle and optional source project. A task-owned workspace has stable company/task identity, independent of agent. Existing rows/paths retain identity. Preserve historical privacy-source intersection. Project deletion cannot cascade needed workspace/recovery state; retain the source policy tombstone or reject deletion until explicit lifecycle cleanup.
- Repository inventory: one company-scoped child table for verified repository identity, relative path, pinned ref/branch, preparation operation/state. No plaintext credentials. Unique contained destination and source intent suppress concurrent duplicate preparation. Resolve symbolic/default ref once; another ref never silently switches a dirty checkout.
- Existing environment orchestration/leases: machine/provider placement and physical ownership. Workspace service asks it to realize selected intent. Reuse sharedWorkspaceKey/canonical paths; same logical ID does not imply same machine. Provider sessions and credentials remain agent/run scoped.
- Existing native sync/descriptors: admitted run baseline, exact source retention, immutable manifests/seeds and durable completion. Generic manifest/transport code stays in adapter-utils. AGENT_HOME retains its separate store and policy.
- Run lifetime and disposition remain owned by heartbeat. Workspace timings wrap actual filesystem work; provider turn is a child interval. Native admission ordering is unchanged: durable ownership/input before corresponding external effects, never reconstruct an admitted root from fresh defaults.
- Routes and runner authority translate/authenticate and call the domain owner. Share validators and generate tool descriptors from the canonical protocol action definitions. No parallel tool-only policy implementation.
## Resolution and transition rules
1. Already-admitted run: use immutable recorded input/recovery evidence.
2. New admission: eligible pending explicit selection, only after prior process and persistence obligations settle.
3. Otherwise current durable binding.
4. Unbound task: explicit selection > eligible parent inheritance > snapshotted resource/endpoint defaults > configured project policy > explicit configured adapter cwd > task directory.
Explicit invalid/unauthorized choices fail with actionable errors, never fall back to home. Existing session cwd becomes a compatibility binding only with verified provenance. No eager scan/copy/migration of legacy homes. Workspace-free providers remain workspace-free. Existing low-trust/native-chat confinement stays authoritative and cannot be relaxed by defaults.
Typed selection vocabulary: task_directory; existing execution workspace; configured project source with shared or managed-isolated policy. Operator cwd compatibility is internal and cannot be submitted by an ordinary agent. New repository paths use existing `.paperclip-repositories/<stable-key>` namespace; existing root checkouts retain their paths.
Source inspection and isolated copies require source-project read access. Writable shared roots additionally require the internal `project:write_workspace` authorization action. It reuses existing `tasks:assign` and `tasks:assign_scope` grants for the source project and actual assignee, but evaluates only the source project’s assignment policy: continuing a task does not reassign its protected task, parent, or agent. Task lineage still enforces low-trust containment, and the responsible-user intersection applies independently. The resolved strategy determines sharing: only `git_worktree` creates a separate checkout; an isolated mode label does not exempt a source-root strategy. Board requests use the task's actual assignee when checking scoped grants. Check that authority at selection, task creation, repository mutation, and each new admission; revocation blocks future use without changing already-admitted recovery inputs.
Independent repository acquisition does not inherit private-repository authority from a chat endpoint's sponsor. Effective low-trust tasks cannot request new controller-backed repository acquisition, including anonymous public URLs; provider full-auto permissions do not override this capability boundary. Pending or preparing intents fail before Git after trust is downgraded. Already-ready public checkouts may be reused only after validating their confined directory, persisted pin and Git origin without cloning, repair or lazy object fetch; managed receipts remain forbidden. Resolve current agent, task, organizational project and workspace-source policies together with the retained run policy at request and admission. Existing configured-source authorization remains the path for private sources. A trust change must fail before cloning or reusing existing private bytes. Empty-inventory admission, authorized file/discovery reads and already-admitted immutable recovery remain available. Trusted routine and local-board acquisitions retain their existing authority contract; agent requests still require the persisted run responsible identity. Trusted controller acquisition has no per-repository byte or cumulative storage quota in this feature, so trusted full-auto acquisition remains an operator resource risk; a general storage quota system is deferred.
Admission with no repository requests remains heartbeat-owned and requires no additional repository-mutation permission. Low-trust admission still rejects managed acquisition bytes in the same physical folder. Immutable recovery uses its admitted folder and skips new repository acquisition even if the task's current binding has changed. A reservation-only recovery hook fences legacy admitted host roots before input capture or provider dispatch. Local recovery uses its immutable local root; sandbox recovery obtains the host path from its verified immutable sync descriptor. SSH recovery uses its remote filesystem directly and does not require a host snapshot descriptor. Older projectless native runs may use the exact persisted run-ID workspace binding without a workspace row.
Task creation supplies an explicit internal prospective-task context to workspace selection. An admitted agent choosing an existing workspace for a child must have an authoritative saved run, a matching company and agent identity, and a verified parent lineage. The server checks current parent and prospective child policies against the saved run boundary and the physical source policy before saving the child. The creator run belongs to its existing task, not the unsaved child. Ordinary existing-task selection and writes still require their exact saved task/run identity; a parent ID alone does not enable the creation path.
Independent acquisition reserves a company-scoped canonical physical root as either `anonymous_only` or `managed` in an internal durable record. A short database fence serializes these reservations with repository requests and credential promotion. Low-trust use rejects every managed intent, including a clone still in staging, and reserves anonymous use before capture. Future managed requests cannot publish into that reserved root. The reservation survives controller restart, workspace aliases, metadata replacement, relocation and archival; changing the acquisition mode requires a separate physical root. No database lock remains held through cloning, input capture or provider execution. Anonymous admission also checks the readable candidate repository namespace against company-managed receipt identities, including on an existing reservation. A published identity collision is denied even when the old receipt path is unreadable; mutable ownership markers cannot authorize it. This conservatively requires a fresh root when anonymous and managed publications share an identity. Reservations also compare retained overlapping parent/child roots; component-boundary siblings remain independent. Initial reservations compare legacy managed intents through accessible canonical ancestors, conservatively rejecting overlapping inaccessible paths while unavailable unrelated historical folders do not block admission. Arbitrary external file writers remain subject to the existing shared-folder constraints.
Shared physical roots also retain source workspace and admitted reader identities separately from acquisition mode. Empty inventories do not bypass peer privacy: admission and recovery record the reader; requests and publication must remain readable to every retained reader. Admission registers the root before preparing configured sibling repositories. Each local sibling source is authorized before materialization, including local file URLs without a configured cwd and reuse of an existing sibling checkout. File-URL-only sources keep ordinary clone semantics; their source permissions still follow the copied files. Its captured task and project owners are retained on the bound task root before copying, so staging, published and detached files keep source privacy. A publication callback rechecks source permissions and every retained destination reader. Ordinary sibling clones add only read restrictions; they never create a worktree-isolation proof. The fence uses authoritative workspace/run/task identities and current source privacy, never writable Git ownership markers. Ordinary heartbeat-owned admission to the same task's sole source adds no repository-mutation requirement, while any peer source or task requires the full read intersection. Missing retained identity fails closed; automatic release is deferred. Current writers must also satisfy `project:write_workspace` for every shared physical source project, including configured aliases with no execution row. Sharing classification is retained separately from read provenance and survives pointer changes. Selection checks without registering a reader; admission and publication independently recheck current writer authority. Proven isolated worktree copies require source read authority; past readers never receive an added mutation requirement.
Repository identity alone does not prove that a workspace published into a particular folder. Available unrelated copies remain independent; retained sources and ambiguous unavailable legacy publishers still constrain access. Physical-source authority includes canonical ancestor and descendant overlaps, current configured/execution roots, and retained source/readers. Every admission/publication rechecks current roots even after a reader has been recorded. Configured workspace move/removal and project removal preserve source provenance before their first admission too. Valid local file URLs and absolute repository paths also identify physical configured owners when no usable cwd exists. The same source resolver protects copying, file reads and configuration detachment. File listing, previews and downloads use the same read-only physical-source resolution. Those reads create no policy or reader records. Git worktree preparation checks actual checkout and common-repository source roots before copying. Server-owned captured source identities are retained on the isolated target before Git/provisioning and rechecked before exposure. The isolated_source and isolated_project_source text kinds preserve task/project privacy before any copying. They are deny-only intent, not proof of isolation. The separate isolated_copy proof is recorded only after Git validates a registered linked worktree; the ancestor-source write exemption also requires the target to remain a real worktree. Preparation may temporarily allow a validated missing/empty target, without persisting that exemption. An intact existing linked worktree without a proof record is first verified against the captured source and Git registration. Only that verification can establish its proof and let it resume after an upgrade or interrupted preparation; occupied ordinary folders remain strict. Existing ordinary folders and exact/descendant target owners keep their write requirements. Deny-only copy provenance survives permission revocation or source deletion during preparation, including failed copies. Managed local sources used by main or referenced projects receive the same checks and committed staging ownership before Git reads bytes. Closed-worktree reopening commits source ownership before its lifecycle transaction, uses read-only checks under that lock, and retains completed-copy evidence after releasing it. Source and destination identities must remain unchanged; no company root fence is acquired while the lifecycle lock is held. Explicit child source choices, including clearing inherited linkage with null, take precedence over parent workspace inheritance; omitted choices retain inheritance and normal project defaults.
Reopening has no selected execution lease. Before rebuilding a worktree, resolve the bound task’s current task, assignee, organizational-project and filesystem-source policies inside the lifecycle transaction. Any effective low-trust policy suppresses both configured and built-in host provision scripts. Missing referenced authority or denied policy blocks reopening; trusted provisioning keeps its existing behavior. A board comment does not authorize sandbox repository scripts to execute on the host.
Admission derives controller provisioning from the authoritative selected environment, independently of task trust and filesystem permissions. Sandbox and SSH selections suppress configured and built-in controller provision scripts during creation, reuse and rebuild, including host runtime provisioning. Read-only source copying remains available for genuine isolated worktrees; source write grants and board sponsorship do not authorize controller execution. Selected isolated sources are copy-only for controller provisioning even when execution is local: explicit, channel, inherited and legacy choices do not record host-script approval. Foreign organizational/source project lineage and nondefault legacy sources also remain copy-only. Persist a deny-only marker on the realized workspace and preserve it through metadata updates, binding changes, reuse, rebuild and reopening; missing source authority must not become a default approval. Existing implicit trusted local project defaults and taskless operator runs with no source retain their behavior. One shared resolver gives task creation, bulk creation and controller classification the same configured-policy pointer, primary, creation-time and ID precedence. Creation keeps its existing source validation; classification separately requires the actual source to belong to the organizational project and company. A dangling configured pointer does not fall back. This correction does not rotate existing bindings or clear retained denial markers. Unknown task-backed or projectless source identity stays copy-only; the reserved operator-cwd provenance compatibility branch does not imply a new producer or approval API. SSH retains its explicit remote provision-command path. Sandbox workspace staging does not add a remote provision-command or runtime-provision transport in this change.
Channel defaults use optional properties (inherit), explicit null (clear), concrete value (override). Resource override beats endpoint. Defaults are captured once at task creation and reauthorized on use. Changing/deleting/revoking configuration cannot relocate existing tasks. Project choice and workspace choice are independent; choosing a source does not silently assign organizational project. Endpoints without configuration work normally.
The organizational task project retains task identity, environment, and secret scope regardless of the filesystem source. An organizational project with no usable configured folder or repository is not a filesystem source. Unbound tasks without typed selection still retain that project's explicit workspace requirements; persisted and typed source choices retain precedence. Both task-project and source-project policies constrain run trust; source policy controls workspace realization. Channel task creation separately checks assignment authority for the effective organizational project and actual assignee against locked endpoint values inside its transaction. Saving defaults does not grant future senders that authority.
New native chat tasks may realize an authorized managed-isolated Git worktree before their first binding exists. The resulting durable task-owned worktree must pass the existing chat-root guard before provider dispatch; shared roots and mismatched admitted recovery roots remain rejected.
Active root selection stores pending intent and returns next-normal-admission, without interrupting or scheduling a new model turn. If atomic safe nested acquisition/capture is supported, repository preparation may add a new contained subtree to the active root; otherwise it records preparation for next admission with explicit capability result. Never claim a host-only checkout is available in the running remote environment.
## Sharing and durability
Preserve simultaneous local/SSH tasks in an explicitly configured identical physical folder, including current local/SSH handling of legacy serialize settings. Administrative Git/checkout/restore actions retain target-scoped locks; ordinary provider work is concurrent. Closing one task cannot remove configured shared files or release another participant's resource.
Concurrent remote sharing requires proven common physical placement, separate session authority and coherent snapshot capability. Unsupported new concurrent sharing returns a typed unavailable result; do not instantiate separate clones and label them shared. Already-admitted descriptors remain recoverable unchanged. Local authoritative roots need no replica copyback; SSH retains its actual existing persistence contract.
Sparse filesystem checkpoints use disk-backed full manifests, changed bytes/deletions, safe-link and existing ignore semantics, and separate nested Git history. Capture still enumerates and hashes files with stable-read validation; a metadata hash cache is deferred. No-change sends zero file-content payload where capability is supported; manifest metadata still transfers; unsupported transport uses explicit confined full fallback. Sparse capture capacity failures before outbound data is read also use the existing confined full transfer, so a tiny output can recover when the sandbox cannot preserve the sparse manifest reserve. Returned manifest, path, digest and payload validation failures remain errors and never trigger fallback. Full checkpoint exports validate the completed staging root before merge, reject unsupported entries, and omit absolute or escaping links without following them; safe relative links remain supported. Reuse immutable durable seed generations, not only warm sandbox contents; reuse still verifies archive digests. Existing run references pin seeds/deltas until all admitted runs/recovery obligations release them. Retain the final host rescan because the run ownership fence does not exclude external writers to shared folders; do not reuse a stale post-merge manifest.
A returned sparse snapshot must describe a coherent tree before any host mutation. Every nested entry requires an immediate parent marked as a directory in that same returned snapshot, including baseline-unchanged files. Checking every directory also verifies recursive ancestry. Top-level entries use the implicit root. Invalid trees fail without overwriting retained host files and keep recovery evidence. The check uses the existing indexed manifest interface; it adds no host-path scans or unbounded in-memory inventory.
Seed recovery deliberately uses **no delta chain** in this implementation. An unchanged generation reuses its verified content-addressed immutable full seed; a changed generation creates a fresh full seed. Sparse output transfer is separate from seed recovery. This revises the sketch's 16-delta compaction proposal: composing recovery generations would add a new failure surface before the simpler cache has been qualified. Existing references retain old seeds; no automatic eviction/deletion policy is introduced. Optional cache publication is capped at 16 generations/1 GiB per workspace and 256 generations/256 workspace directories/8 GiB per company. A company-scoped filesystem lock serializes quota checks and publication across workspace roots. Archive and receipt bytes, corrupt entries and abandoned pending generations count against capacity; publication also preserves the manifest storage free-space reserve (256 MiB by default), accounting for a full-copy fallback. Saturation or cache failure skips publication and keeps the already-written per-run recovery seed. Existing over-capacity caches stop growing without deleting admitted evidence. This deliberately trades cache hit rate after saturation for bounded additional storage and unchanged recovery semantics; no global cross-company quota is added. Changed input generations still pay full snapshot creation. New cache entries are reconstructed and checked against the admitted baseline before publication, so concurrent writes cannot label an archive as the wrong generation; this adds cold verification work. A corrupted cache entry remains a miss and falls back to a fresh seed; automatic cache repair and garbage collection are deferred. The existing Git seed contract captures the selected branch/HEAD, not unrelated refs.
Ordinary settlement remains host-durable before successful completion. Preserve export-only recovery and exact-source lease retention. Preserve the specific unsafe-export omission exception and its run-log-only diagnostic. Do not change global merge conflict semantics as a side effect.
Folder-change guards track live run ownership, finalization work and pending file recovery. A historical stopped or declined run whose cleanup has settled must not permanently block the task's next folder choice. Retained export recovery and unresolved ownership still block changes until their durable release or persistence receipts exist.
## Delivery and ownership
The core implementation owns execution-workspace/task binding schema + shared selection contracts, workspace domain/resolver, issues/heartbeat integration and nullable-project read models. The persistence implementation owns adapter-utils/native sync/trace helpers, exposes integration hooks to core rather than concurrent heartbeat editing. The repository implementation owns acquisition service, inventory schema and routes/runner catalogs, coordinating domain interfaces with core. The integration layer owns channel defaults/config API/UI, documentation, migration generation and final integration/verification. All workers are in the same checkout; do not revert others.
Generate the feature migrations centrally from finalized schemas, preserving the upstream journal prefix and snapshot chain. Task intent/inventory, physical acquisition policy and retained source access use sequential additive migrations. No handwritten snapshots. Upgrade is additive; old immutable inputs and descriptors remain readable. New selections/checkpoint versions must not be admitted by old controllers: deploy schema first, drain only local test processes if needed, activate new admissions after all serving controllers support them. Rolling back application must disable new admissions, retain new state and finish/recover already-admitted work on compatible controllers; no destructive down migration.
## Acceptance
Targeted tests cover app/chat parity, task directory without Git, reassignment, organization change without relocation, explicit configured-folder concurrency/lifecycle, privacy intersection, repository permission/idempotency/ref conflicts, pending root changes without interruption, accepted-result recovery, unsafe-export exception, old descriptors, and no access to unrelated home contents. Persistence fixtures prove unchanged zero payload and one-file/deletion changes where supported, seed reuse and corrupted/stale fallback. Timings include delayed finalization. UI settings/errors/projectless links work with token-only styling. Run full typecheck/test:run/build before handoff; distinguish fixture proof from unperformed live provider qualification.
## Phase status
- Ground: complete.
- Sketch: complete, A selected with stated grafts.
- Agree: user explicitly chose architecture then implementation; no additional checkpoint.
- Implement: code complete; targeted checks pass, broad-suite qualification remains incomplete (see verification record).
- Scrap: conditional; two repeated boundary workarounds require redesign rather than silent escape hatches.
## Layer plan and public usage
| Layer | Responsibility | Contract |
| --- | --- | --- |
| Shared types and database | Durable task intent, one active binding, optional source project, repository inventory | `TaskWorkspaceSelection`; existing `issues.executionWorkspaceId` |
| Workspace domain | Authorize selection, queue safe transitions, bind stable task folder, retain source privacy | `validateSelection`, `inspectTaskWorkspace`, `selectTaskWorkspace`, `applyPendingTaskWorkspaceSelection` |
| Task creation | Resolve explicit/parent/channel intent without making filesystem side effects | `workspaceSelection` on create; source marked internally |
| Channels | Merge connection/destination defaults, check sender and agent access, snapshot once | `executionDefaults: { projectId?, workspace? }` |
| Repository preparation | Validate repository/ref, authorize credentials, publish contained checkout idempotently | Repository request queues for next normal admission |
| Runner tools | Discover authorized choices and submit intent | Get/list/select workspace; prepare repository |
| Harness | Determine cwd, prepare queued repositories before baseline, enforce trust/placement policy | Existing admission and environment ownership fences |
| Filesystem persistence | Stable manifests, sparse output, verified seed reuse, durable settlement | Backward-readable native descriptors; new descriptor version for nested repositories |
| UI | Optional defaults and projectless workspace navigation | Existing Apps settings and Workspaces surfaces |
Example task creation:
```ts
await issues.create(companyId, {
title: "Write the report",
projectId: null,
workspaceSelection: { kind: "task_directory" },
});
```
A task can later select an authorized existing workspace with its current binding revision and an idempotency key. Accepting a new pending selection atomically advances that revision, so retrying a superseded choice cannot replace a newer one. Retrying the current pending or applied request remains idempotent. The response states that the selection applies on the next normal admission; it does not restart the current process. A repository request similarly records preparation intent when the active provider root cannot safely accept a new checkout. Project assignment alone never requests this transition.
Example channel defaults:
```json
{
"executionDefaults": {
"projectId": null,
"workspace": { "kind": "task_directory" }
}
}
```
A destination may override either field independently. Omitted fields inherit the connection value; `null` clears that field's inherited choice. A cleared workspace choice allows the ordinary project/task resolution path. Changing defaults affects new conversations only. Saving a destination default does not overwrite its independently managed enabled state.
## Verification and rollout limits
Tests must distinguish local fixture proof from live provider qualification. The implementation adds database and protocol tests, compatibility tests for old descriptors, plain-directory and nested-repository persistence fixtures, and sparse/checkpoint/cache tests. A passing fixture does not prove Daytona or SSH production throughput. The original diagnostic run is never interrupted or modified as part of this work.
Deploy schema before admitting the new state. Native descriptor v3 requires compatible recovery controllers; mixed-version controllers must not claim new admissions. Rollback retains the schema and immutable seeds and routes existing v3 obligations to compatible controllers. Do not delete legacy home content, existing workspace directories, or accepted-result recovery evidence.
### Local verification record — 2026-10-09
- Full `pnpm -r typecheck` and `pnpm build` passed. A final direct server `tsc --noEmit` passed after the last server corrections. The full build preceded those final corrections; it was not repeated.
- Generated runner contracts, migration validation, module boundaries, token gates, and `git diff --check` passed.
- Focused checks passed for workspace binding/admission/reopen, concurrent local/SSH sharing, task-folder isolation, channel defaults and email admission, projectless UI, and immutable recovery behavior. Final repository lifecycle batch: 19/19; file browsing: 38/38; sparse checkpoints/cache: 7/7; Git/history: 69/69; tracing: 11/11; executor timing: 2/2.
- `pnpm test:run` was attempted and stopped with exit 130 after roughly an hour of serial server tests and repeated fixture timeouts. Its observed failures were 54 chat cases, one executor timing case, and 23 runtime-service cases. This is **not** a passing full-suite result. The run also began before the final source edits, so it does not qualify the frozen final tree.
- A fresh retry passed 53/54 previously failing chat cases; the remaining Slack admission case exceeded its short asynchronous observation window. After one database-setup timeout, the final isolated retry passed with the original assertions and a larger setup-hook budget (1 passed, 1,145 skipped; `chat-slash-final.log`). Thus all 54 cases passed across fresh retries, not in one clean full run. The executor timing cases both pass in fresh processes. Slow local listener lookups were independently measured at 3.5–6 seconds each; do not interpret the broader runtime failures as proven product regressions or proven baseline failures without further isolation.
- No live SSH/Daytona performance qualification or staging deployment was performed. Clean full-suite qualification on the frozen tree remains a release prerequisite.
Detailed local logs are retained in `/tmp/paperclip-workspace-architect/` (`build.log`, `typecheck-final.log`, `tests.log`, `chat-failures-retry.log`, `repository-lifecycle-tests.log`, and `file-resources-final.log`). Worker verification notes are in `core-progress.md`, `repository-progress.md`, and `sync-progress.md` there.
+35
View File
@@ -217,6 +217,41 @@ The payload never carries a command, an argument, a path, an environment value,
or a raw identifier. The event rides the `ctx.onEvent` run-event bridge and is
run-log-only. It needs no OTLP endpoint.
## Workspace Restore and Settled Run Measurements
The heartbeat controller writes these records with `eventType: "lifecycle"`,
`stream: "system"`, and `level: "info"`. The event name below is its `message`
and its payload's `phase`. They remain in the instance run log and add no
Paperclip Telemetry or OpenTelemetry export.
| Event | Payload fields besides `phase` | Meaning |
| --- | --- | --- |
| `workspace_checkpoint` | `mode`, `scannedEntries`, `changedFiles`, `payloadBytes` | Metrics for a validated sparse checkpoint, or a full restore fallback. |
| `workspace.restore` | `durationMs` | Wall time around native workspace restoration. |
| `task.run.measured` | `durationMs`, `outcome`, `durableSettlement` | Wall time through terminal owner cleanup, with the persisted terminal run status and `durableSettlement: true`. |
For `workspace_checkpoint`, `mode` is `"sparse"` or `"full"`. Sparse metrics
count snapshot entries in `scannedEntries`, regular files in the validated
downloaded payload in `changedFiles`, and their total bytes in `payloadBytes`.
Deleted entries and symlinks do not add to the regular-file count or byte total.
Full fallback reports all three counts as `null`: unavailable, not zero. A run
can emit multiple checkpoint records while restoring its repositories. These
payloads contain no paths, filenames, file contents, or credentials.
`workspace.restore` is emitted when the timed restoration exits, including an
exception. It has no outcome field and does not by itself prove restore success.
Its producer is the native workspace sync `onPhase` callback.
`task.run.measured` is emitted only after the controller observes a terminal run
with no remaining native runner ownership. Its duration starts at `startedAt`,
or `createdAt` if the run never started, and includes preparation, execution,
finalization, and cleanup. It is not model-only time; the creation-time fallback
also includes time spent queued. `outcome` is the actual terminal status, such as
`succeeded`, `failed`, or `cancelled`. Provider turn completion can precede this
settlement record. The record does not imply exactly-once emission across
controller recovery, and failure to write it is logged without changing the
already-settled outcome.
## ACP terminal failure diagnostics
The shared ACP adapter engine preserves typed terminal session failures in the
+12
View File
@@ -81,6 +81,13 @@ remaining operations before qualifying the whole capability.
Gaps: Normal agent replies work through server delivery. Explicit endpoint publication
APIs are board management paths.
New conversations resolve typed project/workspace defaults from their endpoint
and destination under the task-creation transaction. Defaults do not authorize
private sources: admission checks the linked user's authority and assigned agent.
An existing conversation keeps its task binding. See
[creation service](../../server/src/services/chat-channels.ts) and
[defaults contract](../../packages/shared/src/types/chat-channels.ts).
## `attachments`
Review: `baseline`.
@@ -115,6 +122,7 @@ API: `present` — representative operation IDs:
- `GET /api/chat-endpoints/{endpointId}/activity`.
- `PATCH /api/chat-endpoints/{endpointId}`.
- `PUT /api/chat-endpoints/{endpointId}/resources` configures destination defaults.
Agent access: `restricted` — Board lifecycle APIs; agent requests repair when blocked.
@@ -130,3 +138,7 @@ own policy and access review.
Gaps: Activity/repair/replay/disable use board endpoint access, not the agent's ability
to reply.
Endpoint/destination execution defaults use the same board-only management
boundary. Agents select an authorized workspace for their current task through
the [workspace APIs](workspaces.md), rather than editing channel configuration.
+58
View File
@@ -17,12 +17,50 @@ API: `present` — representative operation IDs:
- `GET /api/issues/{id}/heartbeat-context`.
- `GET /api/execution-workspaces/{id}`.
- `GET /api/issues/{id}/workspace` inspects the durable task binding.
- `PUT /api/issues/{id}/workspace` requests the next binding.
- `GET /api/companies/{companyId}/execution-workspaces?summary=true&selectableForTask=true`
lists authorized task-selectable workspaces.
Agent access: `available` — Native get_task_context / REST / CLI / MCP workspace read.
The task-bound native tools `get_workspace`, `list_workspaces`, and
`select_workspace` use the ordinary authenticated APIs. Legacy agents can call
those APIs with their admitted run token. These paths do not require a project.
Policy: Company/project/workspace visibility and runtime authority; active run, trust
preset and task ownership where required.
Task workspace writes require `issue:mutate`, an active owned task/run for agents,
and current source visibility. Shared physical roots also require source-project
workspace write authority. Accepting a new pending choice advances its binding
revision; superseded retries fail while current-choice retries remain idempotent.
Binding revisions and request keys fence retries;
selection applies at the next normal admission without interrupting a live run.
Writable aliases must also be compatible with current and retained physical
source projects' trust boundaries. Incompatible choices are rejected before
dispatch; a projectless alias cannot bypass required sandbox containment.
Source-copy checks apply the same trust boundary before provisioning. The
authoritative sandbox or SSH selection suppresses configured and built-in
controller worktree and runtime provisioning regardless of task trust, source
write grants or board sponsorship. Genuine isolated worktrees still permit
authorized read-only source copying. Source write permission does not authorize
controller execution. Explicit, channel, inherited, foreign or nondefault isolated
source choices remain copy-only for controller provisioning even on local runs.
Their deny-only workspace marker survives metadata and binding changes; a board
reopen does not turn it into approval. Implicit trusted local defaults and explicit
SSH remote provisioning retain their existing placement; no new sandbox provision transport
is claimed. The implicit default follows task creation: configured policy pointer,
then primary source, creation time and ID. A missing or foreign configured source
does not authorize controller hooks. This precedence neither changes existing
creation validation nor rotates bindings or clears retained copy-only markers.
Closed-worktree reopening checks current task, agent, organizational and source policies without assuming a future driver. Effective low-trust reopens suppress configured and built-in host scripts. Returned checkpoints must have directory parents for every nested entry before merging, including unchanged entries; invalid output leaves retained host files intact and keeps recovery evidence.
Child creation by an admitted agent checks an explicit prospective-task context before the child is saved. The creator run must have an authoritative saved policy and a verified company/agent/parent lineage. Current parent, prospective child and physical source policies must fit that boundary. This creation path does not relax exact task/run checks for existing-task writes. The [child trust regression](../../server/src/__tests__/task-workspace-child-trust.test.ts) is automated source-level evidence, not live agent qualification.
Source review for these additions: PR #15749 and
[task workspace routes](../../server/src/routes/task-workspaces.ts),
[native authority](../../server/src/services/native-runtime/paperclip-runner-tool-authority.ts),
and [semantic transport](../../server/src/services/task-workspace-tools.ts).
Evidence: `source-only` —
[implementation](../../server/src/routes/workspace-runtime-service-authz.ts#L76) at the
reviewed commit above.
@@ -43,10 +81,21 @@ API: `present` — representative operation IDs:
- `GET /api/execution-workspaces/{id}`.
- `GET /api/execution-workspaces/{id}/workspace-operations`.
- `POST /api/issues/{id}/workspace/repositories` requests repository preparation.
Agent access: `partial` — REST / native call_api reads; task/project assignment triggers
server provisioning.
Native `prepare_repository` and admitted legacy REST callers can request a
catalog repository or validated repository URL for their current task.
Preparation occurs at normal admission; the agent cannot supply a host path or
controller identity. Current source permissions, responsible-user intersection,
trust restrictions, immutable receipts, and retained physical-root ownership are
checked before acquisition and publication. Low-trust callers cannot trigger a
new controller-side Git acquisition. Evidence remains source-only here; focused
[repository tests](../../server/src/__tests__/task-workspace-repositories-trust.test.ts)
are separate from live agent qualification.
Policy: Company/project/workspace visibility and runtime authority; active run, trust
preset and task ownership where required.
@@ -115,6 +164,15 @@ remaining operations before qualifying the whole capability.
Gaps: Files panel HTTP requires board. Agents can use their admitted workspace file
tools; remote/Dot scope and Git command availability differ.
File reads also enforce retained source privacy after local copy, configuration
move/removal, reopen, and project deletion. A task assignment or repository
receipt does not grant access to private files from another physical owner.
Authorized Git-source capture also retains ownership on the actual shared Git
storage. Source checks use verified checkout identities, including company-known
owners when separate metadata has no primary-checkout registration. Removing that
checkout does not release its private committed files in surviving worktrees.
## `closure`
Review: `baseline`.
+5
View File
@@ -44,6 +44,11 @@ Automated: [channel integration](../../server/src/__tests__/chat-channels.integr
Manual: Inspect the bound agent and identity, recent activity, and delivery failure details. Repair a disposable credential, retry a safe message when offered, and verify exactly one external reply. Disable the endpoint and confirm new traffic is not silently processed.
Configure optional project and workspace defaults at the connection and destination
levels. Start a new conversation and inspect its task workspace. Verify that
inheritance, an explicit selection, and clearing a default remain distinct.
Changing defaults must not move an existing conversation or interrupt its run.
## Gotchas
- Visibility flags do not pause existing delivery. Disable the disposable
+38
View File
@@ -34,6 +34,41 @@ Automated: [workspace card](../../ui/src/components/IssueWorkspaceCard.test.tsx)
Manual: Run a task that writes a small file, follow the bound workspace, and inspect the file and revision. Reload and verify links still refer to the same workspace, not another checkout or a stale run.
For task workspace selection, use the authorized task workspace API to choose a
task directory, project source, or existing workspace. Verify that a change
made during a run stays pending until the next normal admission. Submit another
choice using the returned revision, then retry the superseded request; it must
fail without replacing the newer pending choice. A projectless
task must retain its files across resume and reassignment. Use the repository
preparation API to add an authorized repository beneath that workspace;
repeat the request and verify that it reuses the checkout and pinned revision.
See [task binding tests](../../server/src/__tests__/task-workspace-binding.test.ts)
and [repository access tests](../../server/src/__tests__/task-workspace-repositories-trust.test.ts).
An existing projectless folder alias of a sandbox-restricted project must be
rejected for a standard local task before its adapter executes. Repeat after
relocating the configured source; retained ownership must still enforce the
boundary. This is separate from ordinary authorized file preview/download.
For a registered worktree selected for a sandbox or SSH environment, configured
and built-in provision scripts must not run on the controller during fresh
creation, reuse, or rebuild, including host runtime provisioning. Repeat with
standard trust and a protected read-only source, then with source write grants;
permissions must not change execution placement. Verify isolated source copying
and selected-environment dispatch still work. Repeat an explicit local isolated
source choice: copying remains available but controller scripts must stay absent,
including after binding or metadata changes and closed-worktree reopening. An
implicit trusted local project default remains a positive provisioning control.
Repeat with a newer configured default overriding an older primary source, then
with a newer primary overriding an older nonprimary source. Both configured and
built-in hooks must run for the implicit local default. Task creation and controller
classification share configured-default, primary, creation-time and ID precedence.
Explicit choices and saved copy-only markers still suppress hooks; changing the
default must not rotate an existing binding or clear its marker.
SSH keeps its explicit remote provision-command path; these checks
do not claim a new sandbox provision transport.
Low-trust closed-worktree reopening, including a board-comment reopen of a projectless task, must also suppress both kinds of host provision script. Returned checkpoints must reject file, symlink or missing parents before host writes, even if a nested file is marked unchanged. The retained host file and recovery evidence must survive rejection. These are automated safety contracts, not new live runtime acceptance.
For a child created by an admitted agent with an explicit existing workspace, verify that a matching sandbox boundary permits creation before the child has an ID. A changed child policy, unrelated parent, or forged creator run must fail. See the [child workspace trust regression](../../server/src/__tests__/task-workspace-child-trust.test.ts). This automated contract does not add live acceptance evidence.
### `workspace-management`
Automated: [workspace detail](../../ui/src/pages/ExecutionWorkspaceDetail.test.tsx) and [runtime service](../../server/src/__tests__/workspace-runtime.test.ts) cover UI and runtime contracts separately.
@@ -49,4 +84,7 @@ Manual: Provision a disposable workspace, wait for readiness, start an available
- A link or running badge does not prove the service is reachable from the user’s browser.
- Workspace IDs, runtime leases, and repository paths are not interchangeable.
- Organizational project, source project, task files, personal `AGENT_HOME`, and
provider home have separate meanings. Private source ownership remains attached
to copied files after source configuration changes or workspace closure.
- Use [recovery](./recovery.md) for divergence/export repair and [CLI operations](./cli-operations.md) for local worktree tooling.
+26 -26
View File
@@ -98,20 +98,20 @@ Status describes product alignment; implementation describes the rendered subjec
| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note |
| --- | --- | --- | --- | --- |
| [Apps, channels, and extensions/External chat and email/Connections/AgentMail setup](../../ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 19 / 16 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Apps, channels, and extensions/External chat and email/Connections/AgentMail settings](../../ui/storybook/stories/chat-channels/agentmail-settings.stories.tsx) | `chat-channels/lifecycle` | unreviewed / production / fixtures | 8 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Apps, channels, and extensions/External chat and email/Connections/AgentMail tasks](../../ui/storybook/stories/chat-channels/agentmail.stories.tsx) | `chat-channels/conversation` | unreviewed / prototype / fixtures | 9 / 2 | Local EmailTaskExperience combines production composer/bubbles with simulated email lifecycle; compare against IssueDetail before claiming page parity. |
| [Apps, channels, and extensions/External chat and email/Connections/Chat connection actions](../../ui/storybook/stories/chat-channels/chat-connection-actions.stories.tsx) | `chat-channels/providers` | unreviewed / production / fixtures | 1 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Apps, channels, and extensions/External chat and email/Connections/Chat management](../../ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx) | `chat-channels/lifecycle` | unreviewed / production / fixtures | 15 / 6 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Apps, channels, and extensions/External chat and email/Connectors/GitHub bot management](../../ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx) | `chat-channels/providers`, `chat-channels/conversation` | unreviewed / production / fixtures | 33 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed. |
| [Apps, channels, and extensions/External chat and email/Historical/GitHub chat prototypes](../../ui/storybook/stories/chat-channels/github-chat.stories.tsx) | `chat-channels/providers`, `chat-channels/conversation` | historical / prototype / fixtures | 48 / 9 | Historical wizard retained for original setup/access/review design; current GitHub setup is ui/src/pages/apps/chat/GitHubChatSetup.tsx. |
| [Apps, channels, and extensions/External chat and email/Connections/GitHub and Code Review Bot](../../ui/storybook/stories/chat-channels/github-connections.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 7 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Apps, channels, and extensions/External chat and email/Connections/iMessage Photon](../../ui/storybook/stories/chat-channels/imessage-photon.stories.tsx) | `chat-channels/conversation` | unreviewed / production / fixtures | 13 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Apps, channels, and extensions/External chat and email/Connections/Slack/Automatic setup](../../ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 18 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Apps, channels, and extensions/External chat and email/Connections/Slack/Avatar in Settings](../../ui/storybook/stories/chat-channels/slack-avatar-settings.stories.tsx) | `chat-channels/identity` | current / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Apps, channels, and extensions/External chat and email/Connections/Slack/Communication in Settings](../../ui/storybook/stories/chat-channels/slack-communication-settings.stories.tsx) | `chat-channels/identity` | current / production / fixtures | 4 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Apps, channels, and extensions/External chat and email/Connections/Slack/Managed setup](../../ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 21 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Apps, channels, and extensions/External chat and email/Connections/Slack/Task tools](../../ui/storybook/stories/chat-channels/slack-tools.stories.tsx) | `chat-channels/conversation` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Apps, channels, and extensions/External chat and email/Connections/AgentMail setup](../../ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 19 / 16 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. |
| [Apps, channels, and extensions/External chat and email/Connections/AgentMail settings](../../ui/storybook/stories/chat-channels/agentmail-settings.stories.tsx) | `chat-channels/lifecycle` | unreviewed / production / fixtures | 8 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: EmailEndpointSettings remains the mounted inbox subsection. The surrounding ChatEndpointDetail connection-default editor and its save/inheritance behavior are not represented here. |
| [Apps, channels, and extensions/External chat and email/Connections/AgentMail tasks](../../ui/storybook/stories/chat-channels/agentmail.stories.tsx) | `chat-channels/conversation` | unreviewed / prototype / fixtures | 9 / 2 | Local EmailTaskExperience combines production composer/bubbles with simulated email lifecycle; compare against IssueDetail before claiming page parity. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. |
| [Apps, channels, and extensions/External chat and email/Connections/Chat connection actions](../../ui/storybook/stories/chat-channels/chat-connection-actions.stories.tsx) | `chat-channels/providers` | unreviewed / production / fixtures | 1 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. |
| [Apps, channels, and extensions/External chat and email/Connections/Chat management](../../ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx) | `chat-channels/lifecycle` | unreviewed / production / fixtures | 15 / 6 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: the mounted endpoint adds connection and destination editors. Local endpoint PATCH/resource PUT preserve defaults; workspace-choice requests now return only active/idle, unclosed fixture workspaces. Browser save/error/reload and new-conversation inheritance remain unverified. |
| [Apps, channels, and extensions/External chat and email/Connectors/GitHub bot management](../../ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx) | `chat-channels/providers`, `chat-channels/conversation` | unreviewed / production / fixtures | 33 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed. Task-workspace reconciliation: mounted repository and endpoint editors use simulated defaults. Endpoint PATCH now persists omitted/null/concrete values across GET and resets on remount; repository PUT already preserves them. Choice caches intentionally contain no projects/workspaces. Browser editor interactions, access denials and new-task inheritance remain unverified. |
| [Apps, channels, and extensions/External chat and email/Historical/GitHub chat prototypes](../../ui/storybook/stories/chat-channels/github-chat.stories.tsx) | `chat-channels/providers`, `chat-channels/conversation` | historical / prototype / fixtures | 48 / 9 | Historical wizard retained for original setup/access/review design; current GitHub setup is ui/src/pages/apps/chat/GitHubChatSetup.tsx. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. |
| [Apps, channels, and extensions/External chat and email/Connections/GitHub and Code Review Bot](../../ui/storybook/stories/chat-channels/github-connections.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 7 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. |
| [Apps, channels, and extensions/External chat and email/Connections/iMessage Photon](../../ui/storybook/stories/chat-channels/imessage-photon.stories.tsx) | `chat-channels/conversation` | unreviewed / production / fixtures | 13 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the endpoint editor addition; this suite selects setup/access and locally simulated conversation states. Endpoint PATCH retains fields, but settings workspace-choice requests and default selection are not covered by these scenarios. |
| [Apps, channels, and extensions/External chat and email/Connections/Slack/Automatic setup](../../ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 18 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. |
| [Apps, channels, and extensions/External chat and email/Connections/Slack/Avatar in Settings](../../ui/storybook/stories/chat-channels/slack-avatar-settings.stories.tsx) | `chat-channels/identity` | current / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. |
| [Apps, channels, and extensions/External chat and email/Connections/Slack/Communication in Settings](../../ui/storybook/stories/chat-channels/slack-communication-settings.stories.tsx) | `chat-channels/identity` | current / production / fixtures | 4 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. |
| [Apps, channels, and extensions/External chat and email/Connections/Slack/Managed setup](../../ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 21 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. |
| [Apps, channels, and extensions/External chat and email/Connections/Slack/Task tools](../../ui/storybook/stories/chat-channels/slack-tools.stories.tsx) | `chat-channels/conversation` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. |
## companies
@@ -180,9 +180,9 @@ Status describes product alignment; implementation describes the rendered subjec
| [Tasks and conversations/Documents, attachments, and work products/Product/Documents/Annotations](../../ui/storybook/stories/documents-artifacts/document-annotations.stories.tsx) | `documents-artifacts/annotations` | unreviewed / production / fixtures | 9 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Tasks and conversations/Documents, attachments, and work products/Product/Document Comments](../../ui/storybook/stories/documents-artifacts/document-comments.stories.tsx) | `documents-artifacts/annotations` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Tasks and conversations/Documents, attachments, and work products/Components/File Viewer Sheet](../../ui/storybook/stories/documents-artifacts/file-viewer.stories.tsx) | `documents-artifacts/attachments` | unreviewed / production / fixtures | 13 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/01 Preview](../../ui/storybook/stories/documents-artifacts/html-artifacts/01-preview.stories.tsx) | `documents-artifacts/attachments`, `workspaces/files-git` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/02 Workspace](../../ui/storybook/stories/documents-artifacts/html-artifacts/02-workspace.stories.tsx) | `documents-artifacts/attachments`, `workspaces/files-git` | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/03 Task journey](../../ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx) | `documents-artifacts/attachments`, `workspaces/files-git` | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/01 Preview](../../ui/storybook/stories/documents-artifacts/html-artifacts/01-preview.stories.tsx) | `documents-artifacts/attachments`, `workspaces/files-git` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. |
| [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/02 Workspace](../../ui/storybook/stories/documents-artifacts/html-artifacts/02-workspace.stories.tsx) | `documents-artifacts/attachments`, `workspaces/files-git` | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. |
| [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/03 Task journey](../../ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx) | `documents-artifacts/attachments`, `workspaces/files-git` | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. |
| [Tasks and conversations/Documents, attachments, and work products/Task Chat/Rich Work Product Cards](../../ui/storybook/stories/documents-artifacts/rich-work-product-cards.stories.tsx) | `documents-artifacts/work-products` | unreviewed / production / fixtures | 9 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Tasks and conversations/Documents, attachments, and work products/Tasks/Artifact Gallery](../../ui/storybook/stories/documents-artifacts/task-artifact-gallery.stories.tsx) | `documents-artifacts/library`, `documents-artifacts/work-products` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Tasks and conversations/Documents, attachments, and work products/Tasks/Text file tabs](../../ui/storybook/stories/documents-artifacts/task-text-files.stories.tsx) | `documents-artifacts/attachments` | unreviewed / production / fixtures | 7 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
@@ -258,9 +258,9 @@ Status describes product alignment; implementation describes the rendered subjec
| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note |
| --- | --- | --- | --- | --- |
| [Projects and execution/Projects and repositories/Proposals/Project repos/New project](../../ui/storybook/stories/projects/new-project-repos.stories.tsx) | `projects/repositories` | proposed / mixed / fixtures | 18 / 5 | RepositoryEditor is shipped, but NewProjectPrototype composes its own creation flow. Preserve proposal until route parity is checked. |
| [Projects and execution/Projects and repositories/Workspaces/Project runtime provision command](../../ui/storybook/stories/projects/project-execution-workspace-strategy.stories.tsx) | `projects/defaults`, `workspaces/provisioning` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Projects and execution/Projects and repositories/Workspaces/Project runtime provision command](../../ui/storybook/stories/projects/project-execution-workspace-strategy.stories.tsx) | `projects/defaults`, `workspaces/provisioning` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. |
| [Projects and execution/Projects and repositories/Proposals/Project repos/Configuration](../../ui/storybook/stories/projects/project-repos-configuration.stories.tsx) | `projects/repositories` | proposed / mixed / fixtures | 12 / 0 | Production properties plus local configuration page composition; repository interaction is simulated. |
| [Projects and execution/Projects and repositories/Product/Projects Goals Workspaces](../../ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx) | `projects/lifecycle`, `goals/hierarchy`, `workspaces/binding` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Projects and execution/Projects and repositories/Product/Projects Goals Workspaces](../../ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx) | `projects/lifecycle`, `goals/hierarchy`, `workspaces/binding` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed nullable project references and guarded runtime invalidations. Existing summaries remain project-bound and retain their routes; null-project execution workspace rendering and controls remain a coverage gap. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. |
## questions-and-approvals
@@ -425,13 +425,13 @@ Status describes product alignment; implementation describes the rendered subjec
| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note |
| --- | --- | --- | --- | --- |
| [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Task activity](../../ui/storybook/stories/workspaces/browser-use-activity.stories.tsx) | `workspaces/services`, `tasks/discussion` | unreviewed / production / fixtures | 7 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Footer](../../ui/storybook/stories/workspaces/browser-use-footer.stories.tsx) | `workspaces/services`, `tasks/discussion` | unreviewed / production / fixtures | 21 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Panel](../../ui/storybook/stories/workspaces/browser-use.stories.tsx) | `workspaces/services`, `tasks/discussion` | unreviewed / production / fixtures | 28 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Projects and execution/Workspaces, services, and files/Components/SearchableSelect/Workspace picker](../../ui/storybook/stories/workspaces/searchable-select.stories.tsx) | `workspaces/binding` | unreviewed / production / fixtures | 9 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Projects and execution/Workspaces, services, and files/Issue/Workspace File Browser](../../ui/storybook/stories/workspaces/workspace-file-browser.stories.tsx) | `workspaces/files-git` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Projects and execution/Workspaces, services, and files/Workspaces/Runtime provisioning status](../../ui/storybook/stories/workspaces/workspace-runtime-provisioning.stories.tsx) | `workspaces/provisioning` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Projects and execution/Workspaces, services, and files/Workspaces/Service control bar](../../ui/storybook/stories/workspaces/workspace-service-control-bar.stories.tsx) | `workspaces/services` | unreviewed / production / fixtures | 16 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. |
| [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Task activity](../../ui/storybook/stories/workspaces/browser-use-activity.stories.tsx) | `workspaces/services`, `tasks/discussion` | unreviewed / production / fixtures | 7 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. |
| [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Footer](../../ui/storybook/stories/workspaces/browser-use-footer.stories.tsx) | `workspaces/services`, `tasks/discussion` | unreviewed / production / fixtures | 21 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. |
| [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Panel](../../ui/storybook/stories/workspaces/browser-use.stories.tsx) | `workspaces/services`, `tasks/discussion` | unreviewed / production / fixtures | 28 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. |
| [Projects and execution/Workspaces, services, and files/Components/SearchableSelect/Workspace picker](../../ui/storybook/stories/workspaces/searchable-select.stories.tsx) | `workspaces/binding` | unreviewed / production / fixtures | 9 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. |
| [Projects and execution/Workspaces, services, and files/Issue/Workspace File Browser](../../ui/storybook/stories/workspaces/workspace-file-browser.stories.tsx) | `workspaces/files-git` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. |
| [Projects and execution/Workspaces, services, and files/Workspaces/Runtime provisioning status](../../ui/storybook/stories/workspaces/workspace-runtime-provisioning.stories.tsx) | `workspaces/provisioning` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed ExecutionWorkspaceDetail null-project handling. This suite mounts only the unchanged RuntimeProvisionStatusValue inside a local context row; it does not exercise the full detail page or projectless queries. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. |
| [Projects and execution/Workspaces, services, and files/Workspaces/Service control bar](../../ui/storybook/stories/workspaces/workspace-service-control-bar.stories.tsx) | `workspaces/services` | unreviewed / production / fixtures | 16 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence. |
## Capability lookup
+55
View File
@@ -233,3 +233,58 @@ baseline comparisons are not run. The retained product preview's separate mobile
inspection remains bound to its earlier compiled source, and final installed
product/provider checks remain pending. The catalog checker and nine checker
controls pass; UI noemit, maps and token gates pass on the composed source.
## Task workspace reconciliation — 2026-10-12
Scope: the 26 declared-source entries affected by task-workspace work at
`88b1d3a2`, compared with master `dabb8c5daf`, including subsequent comment-only
cleanup and the fixture corrections below. This is a bounded source review,
not a new review of all Storybook states. Reconciliation onto `b5046ec022`
preserves its new feature folders, sidebar titles and explicit stable IDs, along
with the existing exports, classification and simulation boundaries. Shared GitHub
fixtures now live under `ui/storybook/support/github-bot-management/`.
Twenty-one entries changed only through the expanded chat/workspace recipes.
Their setup, identity, browser, artifact, picker and service examples are retained;
the new recipe requirements do not become covered merely because they reference
the same capability. Five entries also depend on changed production subjects:
| Story file under `ui/storybook/stories/` | Source review and remaining scope |
| --- | --- |
| `chat-channels/chat-management/chat-management.stories.tsx` | Mounted endpoint includes connection/destination defaults. Added the missing selectable-workspace fixture response; existing endpoint PATCH and resource PUT retain defaults. No browser save, error, reload or inheritance proof in this pass. |
| `chat-channels/github-bot-management/management.stories.tsx` | Mounted endpoint/repository editors include defaults. Fixed the endpoint mock to persist PATCH values instead of returning the original endpoint. Resource PUT already distinguishes omitted, null and concrete values. Project/workspace choices are intentionally empty fixtures. |
| `chat-channels/imessage-photon.stories.tsx` | Existing scenarios mount setup/access and simulate task conversations. They do not exercise the newly added settings editor or its workspace-choice request. Kept unreviewed rather than claiming settings coverage. |
| `projects/projects-goals-workspaces.stories.tsx` | Existing project-bound summaries still match the rendered components. New nullable project references and runtime invalidation branches have no projectless story scenario here. |
| `workspaces/workspace-runtime-provisioning.stories.tsx` | The demonstrated status subcomponent is unchanged. Its local context row does not prove the full detail page's new projectless behavior. |
The three endpoint-management entries now declare `ChatExecutionDefaults` and its
API clients as review dependencies. AgentMail settings still demonstrates only
the inbox subsection, not the surrounding endpoint defaults. Historical GitHub
designs remain historical; existing unreviewed entries remain unreviewed. None of
these fixture examples establishes source authorization, retained file privacy,
pending workspace selection, sandbox admission or existing-conversation continuity.
Executed: the GitHub fixture's two Vitest cases passed, including default save,
subsequent GET, omitted-value preservation, explicit null clearing and fresh-mount
reset. After the folder rebase, both fixture tests and all nine catalog checker
tests passed again; token gates passed before the path-only reconciliation. After the final
source freeze, only the 26 reviewed fingerprints were refreshed; catalog validation
passed for all 223 files and all 2,210 source-parsed story IDs were unchanged.
All 223 upstream file paths, explicit stable IDs and sidebar titles were preserved.
Not run in this pass: Storybook build, browser rendering or `play`, screenshot
comparisons, provider calls and live production acceptance. No new visual
baselines were accepted. Fixture tests are boundary simulations, not server
persistence or permission tests.
## Workspace safety recipe follow-up — 2026-10-12
Reviewed the twelve entries that depend on the workspace feature recipe: browser-use activity/footer/page, the three HTML-artifact suites, project execution strategy and project workspace summaries, searchable selection, workspace files, runtime provisioning and service controls. Their production subjects, fixtures, feature folders, sidebar titles and stable IDs are unchanged. The recipe now requires prospective child boundary checks, safe low-trust reopening, and coherent returned checkpoint ancestry. These backend safety contracts are not exercised by those fixtures. Each entry remains unreviewed for full production parity. Only the reviewed recipe fingerprints were refreshed; no browser render, play, screenshot baseline or production acceptance was run for this follow-up.
## Remote provisioning recipe follow-up — 2026-10-13
Reviewed the same twelve workspace-recipe-dependent entries after extending the controller-provisioning boundary to standard-trust sandbox and SSH selections. Their components, fixtures and stable IDs are unchanged. Project strategy stories demonstrate configuration fields with environment selection disabled; runtime provisioning stories demonstrate status values with a no-operation log handler. Browser, artifact, file and service-control fixtures do not execute source scripts or prove admission placement. The new recipe requires controller marker denial independent of source write grants while copying and dispatch remain available, including local copy-only selections, retained deny-only metadata and closed-worktree reopening. Implicit trusted local defaults remain separate positive controls. All twelve entries retain their unreviewed production classification. Refreshed only these recipe fingerprints after this source review; no browser render, play, screenshot, Storybook build or live runtime qualification is claimed.
## Default-source recipe follow-up — 2026-10-13
Reviewed the twelve unchanged workspace recipe subjects and fixtures after correcting implicit default selection. Task creation and controller classification now share configured-policy, primary, creation-time and ID precedence. Explicit choices and retained denial markers still suppress controller hooks. Project strategy fixtures show configuration, while runtime status and file/browser/artifact/service fixtures do not execute source scripts or prove this backend ordering. Refreshed only their reviewed recipe fingerprints and retained all classifications and stable IDs. No browser render, play, screenshot, Storybook build or live runtime acceptance was performed.
+76 -64
View File
@@ -635,7 +635,7 @@
"status": "unreviewed",
"implementation": "production",
"boundary": "fixtures",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.",
"reviewedAt": "2026-10-12",
"sources": [
"maps/feature-map/chat-channels.md",
@@ -645,7 +645,7 @@
"ui/storybook/fixtures/paperclipData.ts",
"ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx"
],
"fingerprint": "a1d017e03f98f45224826a5c6a2bebeecb8a9fc42eba667a8164d0be6168f735",
"fingerprint": "e25771274378d2ff2f2447b2841ff20b91319dba4e50f697c5b2c6cc74698b4b",
"stableId": "connections-agentmail-setup",
"sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/AgentMail setup"
},
@@ -658,14 +658,14 @@
"status": "unreviewed",
"implementation": "production",
"boundary": "fixtures",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: EmailEndpointSettings remains the mounted inbox subsection. The surrounding ChatEndpointDetail connection-default editor and its save/inheritance behavior are not represented here.",
"reviewedAt": "2026-10-12",
"sources": [
"maps/feature-map/chat-channels.md",
"ui/src/pages/apps/chat/EmailEndpointSetup.tsx",
"ui/storybook/stories/chat-channels/agentmail-settings.stories.tsx"
],
"fingerprint": "1fda3e7570e13c73c9bb9908efe05508e3eb14c716a7c9462ae6a370b77b5a5c",
"fingerprint": "a57256936459ba14d7ad8e0a73ed494c0f269c294240a67c0d27af555e0c736d",
"stableId": "connections-agentmail-settings",
"sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/AgentMail settings"
},
@@ -678,7 +678,7 @@
"status": "unreviewed",
"implementation": "prototype",
"boundary": "fixtures",
"notes": "Local EmailTaskExperience combines production composer/bubbles with simulated email lifecycle; compare against IssueDetail before claiming page parity.",
"notes": "Local EmailTaskExperience combines production composer/bubbles with simulated email lifecycle; compare against IssueDetail before claiming page parity. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.",
"reviewedAt": "2026-10-12",
"sources": [
"maps/feature-map/chat-channels.md",
@@ -689,7 +689,7 @@
"ui/src/components/task-chat/presentation-mode.tsx",
"ui/storybook/stories/chat-channels/agentmail.stories.tsx"
],
"fingerprint": "9f476aac6ea2ecd4a5cb72226b460fc1c78e3bfda05926d6bfd1b8581e68fdcd",
"fingerprint": "4433454864b43dce30061f1b7e8f65e7bc5fa2f386bde115e276c30de4e6898f",
"stableId": "connections-agentmail-tasks",
"sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/AgentMail tasks"
},
@@ -1210,8 +1210,8 @@
"status": "unreviewed",
"implementation": "production",
"boundary": "fixtures",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.",
"reviewedAt": "2026-10-12",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence.",
"reviewedAt": "2026-10-13",
"sources": [
"maps/feature-map/tasks.md",
"maps/feature-map/workspaces.md",
@@ -1221,7 +1221,7 @@
"ui/storybook/fixtures/browser-use.tsx",
"ui/storybook/stories/workspaces/browser-use-activity.stories.tsx"
],
"fingerprint": "0fb038daa922a0b61864f4ebf0096a50c9caf5dc290887cbf97a0bb63dad22af",
"fingerprint": "101840c76686fbf674f97ab469410466a57ff6ed733950544f7fe18b48b98376",
"stableId": "tasks-browser-use-task-activity",
"sidebarTitle": "Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Task activity"
},
@@ -1235,8 +1235,8 @@
"status": "unreviewed",
"implementation": "production",
"boundary": "fixtures",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.",
"reviewedAt": "2026-10-12",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence.",
"reviewedAt": "2026-10-13",
"sources": [
"maps/feature-map/tasks.md",
"maps/feature-map/workspaces.md",
@@ -1245,7 +1245,7 @@
"ui/storybook/fixtures/browser-use.tsx",
"ui/storybook/stories/workspaces/browser-use-footer.stories.tsx"
],
"fingerprint": "f42487c77335ac64cbe0822c51b1706098f26d9b5823e20837089f261277fd58",
"fingerprint": "8cf7c9c98d9626150ab7ee48bf81ab5c98e1832b5b1c38ed4a47d5e15e0be9dc",
"stableId": "tasks-browser-use-footer",
"sidebarTitle": "Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Footer"
},
@@ -1281,8 +1281,8 @@
"status": "unreviewed",
"implementation": "production",
"boundary": "fixtures",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.",
"reviewedAt": "2026-10-12",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence.",
"reviewedAt": "2026-10-13",
"sources": [
"maps/feature-map/tasks.md",
"maps/feature-map/workspaces.md",
@@ -1292,7 +1292,7 @@
"ui/storybook/fixtures/paperclipData.ts",
"ui/storybook/stories/workspaces/browser-use.stories.tsx"
],
"fingerprint": "ce0948c8a9dade2bcab999dccf4f5daa1ddbc4f3ecc72a70e88a1f40861d5199",
"fingerprint": "3a9d780a83fe5a79fd8209125debc6292b2dbcffdf40cf0b0935e442fc8cb7a8",
"stableId": "tasks-browser-use-panel",
"sidebarTitle": "Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Panel"
},
@@ -1389,14 +1389,14 @@
"status": "unreviewed",
"implementation": "production",
"boundary": "fixtures",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.",
"reviewedAt": "2026-10-12",
"sources": [
"maps/feature-map/chat-channels.md",
"ui/src/pages/apps/Browse.tsx",
"ui/storybook/stories/chat-channels/chat-connection-actions.stories.tsx"
],
"fingerprint": "c80c99044431f1cfe75e7b3da1b28d776595217d5bbda14aa82c78b0b272aab0",
"fingerprint": "b5c4a820f42d7b3d5e665161b95056b80b5591f6100e6847488eea49b876d36d",
"stableId": "connections-chat-connection-actions",
"sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Chat connection actions"
},
@@ -1445,16 +1445,20 @@
"status": "unreviewed",
"implementation": "production",
"boundary": "fixtures",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: the mounted endpoint adds connection and destination editors. Local endpoint PATCH/resource PUT preserve defaults; workspace-choice requests now return only active/idle, unclosed fixture workspaces. Browser save/error/reload and new-conversation inheritance remain unverified.",
"reviewedAt": "2026-10-12",
"sources": [
"maps/feature-map/chat-channels.md",
"ui/src/api/chatEndpoints.ts",
"ui/src/api/execution-workspaces.ts",
"ui/src/api/projects.ts",
"ui/src/components/Layout.tsx",
"ui/src/pages/apps/chat/ChatEndpointDetail.tsx",
"ui/src/pages/apps/chat/ChatExecutionDefaults.tsx",
"ui/storybook/fixtures/paperclipData.ts",
"ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx"
],
"fingerprint": "5025a812c84d5700fea3907dd09f484ae3ded0d2f1cfc0f8e83f0fb5bc6d181e",
"fingerprint": "593f980a74e21b1e8bce9f48ee8ef7f1247546e4b4519a0916b64881ba5224c8",
"stableId": "connections-chat-management",
"sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Chat management"
},
@@ -2524,15 +2528,19 @@
"status": "unreviewed",
"implementation": "production",
"boundary": "fixtures",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed.",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed. Task-workspace reconciliation: mounted repository and endpoint editors use simulated defaults. Endpoint PATCH now persists omitted/null/concrete values across GET and resets on remount; repository PUT already preserves them. Choice caches intentionally contain no projects/workspaces. Browser editor interactions, access denials and new-task inheritance remain unverified.",
"reviewedAt": "2026-10-12",
"sources": [
"maps/feature-map/chat-channels.md",
"ui/src/api/chatEndpoints.ts",
"ui/src/api/execution-workspaces.ts",
"ui/src/api/projects.ts",
"ui/src/components/AgentRunnerSelect.tsx",
"ui/src/components/Layout.tsx",
"ui/src/components/OnboardingWizard.tsx",
"ui/src/pages/apps/chat/ChatConversationList.tsx",
"ui/src/pages/apps/chat/ChatEndpointDetail.tsx",
"ui/src/pages/apps/chat/ChatExecutionDefaults.tsx",
"ui/src/pages/apps/chat/GitHubAppIdentity.tsx",
"ui/src/pages/apps/chat/GitHubBotConfiguration.tsx",
"ui/src/pages/apps/chat/GitHubBotManagement.tsx",
@@ -2544,7 +2552,7 @@
"ui/storybook/support/github-bot-management/fixture-api.tsx",
"ui/storybook/support/github-bot-management/fixtures.ts"
],
"fingerprint": "cfcaa3fd3774837c872d32299f205d636f550cedc6d1ef2ed377550a906019c4",
"fingerprint": "a5738c8ecdb983379dacf07138f833f5461facc55e1405f1300c85b2968ef86e",
"stableId": "connectors-github-bot-management",
"sidebarTitle": "Apps, channels, and extensions/External chat and email/Connectors/GitHub bot management"
},
@@ -2558,7 +2566,7 @@
"status": "historical",
"implementation": "prototype",
"boundary": "fixtures",
"notes": "Historical wizard retained for original setup/access/review design; current GitHub setup is ui/src/pages/apps/chat/GitHubChatSetup.tsx.",
"notes": "Historical wizard retained for original setup/access/review design; current GitHub setup is ui/src/pages/apps/chat/GitHubChatSetup.tsx. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.",
"reviewedAt": "2026-10-12",
"sources": [
"maps/feature-map/chat-channels.md",
@@ -2571,7 +2579,7 @@
"ui/storybook/stories/chat-channels/github-chat.stories.tsx"
],
"replacement": "ui/storybook/stories/chat-channels/github-connections.stories.tsx",
"fingerprint": "acb56c908e107266128dd0d817162007a0a7ec75b24fa059714cee490e4ef642",
"fingerprint": "1b98e4a1e023630f9d27105f47ee8e04c7e49e31f1eb77430e55cf0518bca033",
"stableId": "historical-github-chat-prototypes",
"sidebarTitle": "Apps, channels, and extensions/External chat and email/Historical/GitHub chat prototypes"
},
@@ -2585,7 +2593,7 @@
"status": "unreviewed",
"implementation": "production",
"boundary": "fixtures",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.",
"reviewedAt": "2026-10-12",
"sources": [
"maps/feature-map/chat-channels.md",
@@ -2597,7 +2605,7 @@
"ui/storybook/fixtures/paperclipData.ts",
"ui/storybook/stories/chat-channels/github-connections.stories.tsx"
],
"fingerprint": "737355c3a9a9e1fe69b606edf543d0e7f0b94b7332a344a0f077c4cccfb544b9",
"fingerprint": "1db66005c3710754f307003ce6907073e8eca0319074c323d57992c0b26d8b23",
"stableId": "connections-github-and-code-review-bot",
"sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/GitHub and Code Review Bot"
},
@@ -2660,8 +2668,8 @@
"status": "unreviewed",
"implementation": "production",
"boundary": "fixtures",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.",
"reviewedAt": "2026-10-12",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence.",
"reviewedAt": "2026-10-13",
"sources": [
"maps/feature-map/documents-artifacts.md",
"maps/feature-map/workspaces.md",
@@ -2669,7 +2677,7 @@
"ui/storybook/stories/documents-artifacts/html-artifacts/01-preview.stories.tsx",
"ui/storybook/support/html-artifacts/fixtures.ts"
],
"fingerprint": "7289b022aeb669441639942cb709e07a1f03f1cf4e0ca5db3672c654a50da92a",
"fingerprint": "9f96f1e4abf214d663906e05b0a8ce231f06217fc5f022415a84df33c1d0ac96",
"stableId": "html-artifacts-01-preview",
"sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/HTML artifacts/01 Preview"
},
@@ -2683,8 +2691,8 @@
"status": "unreviewed",
"implementation": "production",
"boundary": "fixtures",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.",
"reviewedAt": "2026-10-12",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence.",
"reviewedAt": "2026-10-13",
"sources": [
"maps/feature-map/documents-artifacts.md",
"maps/feature-map/workspaces.md",
@@ -2693,7 +2701,7 @@
"ui/storybook/stories/documents-artifacts/html-artifacts/02-workspace.stories.tsx",
"ui/storybook/support/html-artifacts/fixtures.ts"
],
"fingerprint": "2463cc23aa39bca80133e8b19bb4899f9dfbab7272747796db7855da51642ec2",
"fingerprint": "47c33fce5f296031d43f31d7f099abcef40393cfe51e565db542b7e8230a00a3",
"stableId": "html-artifacts-02-workspace",
"sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/HTML artifacts/02 Workspace"
},
@@ -2707,8 +2715,8 @@
"status": "unreviewed",
"implementation": "production",
"boundary": "fixtures",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.",
"reviewedAt": "2026-10-12",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence.",
"reviewedAt": "2026-10-13",
"sources": [
"maps/feature-map/documents-artifacts.md",
"maps/feature-map/workspaces.md",
@@ -2720,7 +2728,7 @@
"ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx",
"ui/storybook/support/html-artifacts/fixtures.ts"
],
"fingerprint": "d2b3ecd2b4490ca3bec6c8dfacad4c02b794f78fd4d348c6c0fd165fb8928f33",
"fingerprint": "470396b938981d10ddc74d03cec0808aaa1fb305dcdeb8056b308c2752f7cea7",
"stableId": "html-artifacts-03-task-journey",
"sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/HTML artifacts/03 Task journey"
},
@@ -2733,19 +2741,23 @@
"status": "unreviewed",
"implementation": "production",
"boundary": "fixtures",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the endpoint editor addition; this suite selects setup/access and locally simulated conversation states. Endpoint PATCH retains fields, but settings workspace-choice requests and default selection are not covered by these scenarios.",
"reviewedAt": "2026-10-12",
"sources": [
"maps/feature-map/chat-channels.md",
"ui/src/api/chatEndpoints.ts",
"ui/src/api/execution-workspaces.ts",
"ui/src/api/projects.ts",
"ui/src/components/task-chat/TaskChatBubble.tsx",
"ui/src/components/task-chat/task-chat-model.ts",
"ui/src/pages/apps/Browse.tsx",
"ui/src/pages/apps/chat/ChatEndpointDetail.tsx",
"ui/src/pages/apps/chat/ChatEndpointSetup.tsx",
"ui/src/pages/apps/chat/ChatExecutionDefaults.tsx",
"ui/storybook/fixtures/paperclipData.ts",
"ui/storybook/stories/chat-channels/imessage-photon.stories.tsx"
],
"fingerprint": "e808e8533ef84eaa5f0c49144f7fa23554a0956217177a25fe7386ddaf4c9ffc",
"fingerprint": "90d113f72e3f6db48428bf498fba3d14d713eaab194bfb66eb1d27fe17843063",
"stableId": "connections-imessage-photon",
"sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/iMessage Photon"
},
@@ -3934,8 +3946,8 @@
"status": "unreviewed",
"implementation": "production",
"boundary": "fixtures",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.",
"reviewedAt": "2026-10-12",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence.",
"reviewedAt": "2026-10-13",
"sources": [
"maps/feature-map/projects.md",
"maps/feature-map/workspaces.md",
@@ -3943,7 +3955,7 @@
"ui/storybook/fixtures/paperclipData.ts",
"ui/storybook/stories/projects/project-execution-workspace-strategy.stories.tsx"
],
"fingerprint": "d3c1ddd40e4acf1b30ee80655773c475e71885b75fe61b94c5a7ab1010fcb7ed",
"fingerprint": "618a389f8aa005657fc633c9a0ebdc34e0fc77df6d1cc8d0474330590d4c081c",
"stableId": "workspaces-project-runtime-provision-command",
"sidebarTitle": "Projects and execution/Projects and repositories/Workspaces/Project runtime provision command"
},
@@ -3987,8 +3999,8 @@
"status": "unreviewed",
"implementation": "production",
"boundary": "fixtures",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.",
"reviewedAt": "2026-10-12",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed nullable project references and guarded runtime invalidations. Existing summaries remain project-bound and retain their routes; null-project execution workspace rendering and controls remain a coverage gap. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence.",
"reviewedAt": "2026-10-13",
"sources": [
"maps/feature-map/goals.md",
"maps/feature-map/projects.md",
@@ -4003,7 +4015,7 @@
"ui/storybook/fixtures/paperclipData.ts",
"ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx"
],
"fingerprint": "4aa89b2f36fd6db30b20528b2e95063a8846279d28623f927de35acd75ce64f5",
"fingerprint": "83a6815f2ceea9c18eb9145f2abf7f90588dafe833c20abcd93050ee1c66114a",
"stableId": "product-projects-goals-workspaces",
"sidebarTitle": "Projects and execution/Projects and repositories/Product/Projects Goals Workspaces"
},
@@ -4675,14 +4687,14 @@
"status": "unreviewed",
"implementation": "production",
"boundary": "fixtures",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.",
"reviewedAt": "2026-10-12",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence.",
"reviewedAt": "2026-10-13",
"sources": [
"maps/feature-map/workspaces.md",
"ui/src/components/SearchableSelect.tsx",
"ui/storybook/stories/workspaces/searchable-select.stories.tsx"
],
"fingerprint": "a09a2ecf9c05843dc29ea4fd173fa7e9b0968defc8a83d48b2ffc81503b2f98f",
"fingerprint": "524803af7c926b60e8c636031f703d4bb4abe45cd4832c7985f81c8f4d0cb035",
"stableId": "components-searchableselect-workspace-picker",
"sidebarTitle": "Projects and execution/Workspaces, services, and files/Components/SearchableSelect/Workspace picker"
},
@@ -4823,7 +4835,7 @@
"status": "unreviewed",
"implementation": "production",
"boundary": "fixtures",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.",
"reviewedAt": "2026-10-12",
"sources": [
"maps/feature-map/chat-channels.md",
@@ -4834,7 +4846,7 @@
"ui/storybook/fixtures/paperclipData.ts",
"ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx"
],
"fingerprint": "42fb2d53434f7ba8cc5cce556818d6469e02da559bd40fb6dbd4fd5bec9aecb5",
"fingerprint": "df0600372c76d7ff7eb15ccc14a7a5bf7f0b5f5d64a366013686b6aaf888ee00",
"stableId": "connections-slack-automatic-setup",
"sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Automatic setup"
},
@@ -4847,7 +4859,7 @@
"status": "current",
"implementation": "production",
"boundary": "fixtures",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.",
"reviewedAt": "2026-10-12",
"sources": [
"maps/feature-map/chat-channels.md",
@@ -4856,7 +4868,7 @@
"ui/storybook/prototypes/slack-avatar/SlackAvatarSettingsPreview.tsx",
"ui/storybook/stories/chat-channels/slack-avatar-settings.stories.tsx"
],
"fingerprint": "ed2053a543507e9ad110084b6974204897193fcc1d7a1c4cb23f15e1cfe5dcba",
"fingerprint": "725869cfc3280b8384899e0f692bcf17663368f389c38fe1a16ec34c27faf20d",
"stableId": "connections-slack-avatar-in-settings",
"sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Avatar in Settings"
},
@@ -4869,7 +4881,7 @@
"status": "current",
"implementation": "production",
"boundary": "fixtures",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.",
"reviewedAt": "2026-10-12",
"sources": [
"maps/feature-map/chat-channels.md",
@@ -4878,7 +4890,7 @@
"ui/storybook/prototypes/slack-avatar/SlackAvatarSettingsPreview.tsx",
"ui/storybook/stories/chat-channels/slack-communication-settings.stories.tsx"
],
"fingerprint": "c581843a009938f16173f6874a1edbe6a562c3c4382b3a674fcd3487d37a50ce",
"fingerprint": "f0d8df504be6e6e5a1ea09b868ee8a75e35d76041a792f48955dc1e834297cb6",
"stableId": "connections-slack-communication-in-settings",
"sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Communication in Settings"
},
@@ -4892,7 +4904,7 @@
"status": "unreviewed",
"implementation": "production",
"boundary": "fixtures",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.",
"reviewedAt": "2026-10-12",
"sources": [
"maps/feature-map/chat-channels.md",
@@ -4903,7 +4915,7 @@
"ui/storybook/fixtures/paperclipData.ts",
"ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx"
],
"fingerprint": "d947a4bd8dec88fd77c0b68e51ff12bab050342661e455a4f72443d82916d7c9",
"fingerprint": "24e3f986568fad11d604fddc48db890e2c75091b8b7c0ff916c6d609dd4c033d",
"stableId": "connections-slack-managed-setup",
"sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Managed setup"
},
@@ -4916,14 +4928,14 @@
"status": "unreviewed",
"implementation": "production",
"boundary": "fixtures",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.",
"reviewedAt": "2026-10-12",
"sources": [
"maps/feature-map/chat-channels.md",
"ui/src/pages/apps/chat/SlackToolSettings.tsx",
"ui/storybook/stories/chat-channels/slack-tools.stories.tsx"
],
"fingerprint": "a8464a4cbed26f72dacc14e77326caa7d649b23eec075729667d48f267bb8a03",
"fingerprint": "9eaae8672c83f1be3d6bdea09e6a77fd62f2828ab410addfe82f9c08c3c34a4a",
"stableId": "connections-slack-task-tools",
"sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Task tools"
},
@@ -5542,15 +5554,15 @@
"status": "unreviewed",
"implementation": "production",
"boundary": "fixtures",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.",
"reviewedAt": "2026-10-12",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence.",
"reviewedAt": "2026-10-13",
"sources": [
"maps/feature-map/workspaces.md",
"ui/src/components/FileViewerSheet.tsx",
"ui/src/components/IssueWorkspaceCard.tsx",
"ui/storybook/stories/workspaces/workspace-file-browser.stories.tsx"
],
"fingerprint": "e8ece699c3f156643552e788ba86cedda4f2e3a4e6e1a40fbf8b6bef1103254f",
"fingerprint": "980fcd9a6905ac1eae10efeb7979dd8b3769c2cc7d625d4e0ba4b091f4b4d614",
"stableId": "issue-workspace-file-browser",
"sidebarTitle": "Projects and execution/Workspaces, services, and files/Issue/Workspace File Browser"
},
@@ -5563,14 +5575,14 @@
"status": "unreviewed",
"implementation": "production",
"boundary": "fixtures",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.",
"reviewedAt": "2026-10-12",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed ExecutionWorkspaceDetail null-project handling. This suite mounts only the unchanged RuntimeProvisionStatusValue inside a local context row; it does not exercise the full detail page or projectless queries. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence.",
"reviewedAt": "2026-10-13",
"sources": [
"maps/feature-map/workspaces.md",
"ui/src/pages/ExecutionWorkspaceDetail.tsx",
"ui/storybook/stories/workspaces/workspace-runtime-provisioning.stories.tsx"
],
"fingerprint": "4ec295039bb17a9590ab1fb0be2864845a903a625fcaf79459541ddd5a4670f4",
"fingerprint": "f8a7316fb6637151d8da32b637136408cd6fd9d505fe2a2a47f3c0d670b0eeec",
"stableId": "workspaces-runtime-provisioning-status",
"sidebarTitle": "Projects and execution/Workspaces, services, and files/Workspaces/Runtime provisioning status"
},
@@ -5583,14 +5595,14 @@
"status": "unreviewed",
"implementation": "production",
"boundary": "fixtures",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.",
"reviewedAt": "2026-10-12",
"notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. The provisioning recipe requires controller script denial for remote selections and copy-only local source choices, including retained denial through metadata changes and reopening; these fixtures do not verify execution placement. Canonical default provisioning follows configured policy, primary, creation time and ID; these fixtures do not run source hooks or establish this backend precedence.",
"reviewedAt": "2026-10-13",
"sources": [
"maps/feature-map/workspaces.md",
"ui/src/components/WorkspaceServiceControlBar.tsx",
"ui/storybook/stories/workspaces/workspace-service-control-bar.stories.tsx"
],
"fingerprint": "cbe4b5d61324f8eec1dfff9583dc84eab5c83947ff947f5c381d00217b29b5e2",
"fingerprint": "b9cd51c58b31ab445e2997b9f2a23c7a7b3f4ecd7b6dd39fc0f35ae0779311df",
"stableId": "workspaces-service-control-bar",
"sidebarTitle": "Projects and execution/Workspaces, services, and files/Workspaces/Service control bar"
},
@@ -1,3 +1,4 @@
import type { WorkspaceCheckpointMetrics } from "./workspace-checkpoint.js";
import { promises as fs } from "node:fs";
import { randomUUID } from "node:crypto";
import os from "node:os";
@@ -545,10 +546,14 @@ export async function prepareCommandManagedRuntime(input: {
syncWorkspace?: boolean;
workspaceInboundMode?: WorkspaceInboundMode;
workspaceDurableSeed?: WorkspaceDurableSeedPaths;
workspaceCheckpoint?: boolean;
onWorkspaceCheckpoint?: (metrics: WorkspaceCheckpointMetrics) => Promise<void>;
workspaceSeedCacheDirectory?: string;
workspaceSeedCacheCompanyDirectory?: string;
workspaceBaseline?: DirectorySnapshot;
workspaceGitSnapshot?: GitWorkspaceSnapshot | null;
workspaceRepositories?: NonNullable<GitWorkspaceSnapshot["repositories"]>;
workspaceExclude?: string[];
/** Plain persistent directories include all files, independent of Git and task cache exclusions. */
workspaceFileMode?: "all";
preserveAbsentOnRestore?: string[];
assets?: CommandManagedRuntimeAsset[];
@@ -615,8 +620,13 @@ export async function prepareCommandManagedRuntime(input: {
syncWorkspace: input.syncWorkspace,
workspaceInboundMode: input.workspaceInboundMode,
workspaceDurableSeed: input.workspaceDurableSeed,
workspaceCheckpoint: input.workspaceCheckpoint,
onWorkspaceCheckpoint: input.onWorkspaceCheckpoint,
workspaceSeedCacheDirectory: input.workspaceSeedCacheDirectory,
workspaceSeedCacheCompanyDirectory: input.workspaceSeedCacheCompanyDirectory,
workspaceBaseline: input.workspaceBaseline,
workspaceGitSnapshot: input.workspaceGitSnapshot,
workspaceRepositories: input.workspaceRepositories,
workspaceExclude: mergeRuntimeExcludes(input.workspaceExclude),
workspaceFileMode: input.workspaceFileMode,
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
@@ -661,8 +671,13 @@ export async function prepareCommandManagedRuntime(input: {
syncWorkspace: input.syncWorkspace,
workspaceInboundMode: input.workspaceInboundMode,
workspaceDurableSeed: input.workspaceDurableSeed,
workspaceCheckpoint: input.workspaceCheckpoint,
onWorkspaceCheckpoint: input.onWorkspaceCheckpoint,
workspaceSeedCacheDirectory: input.workspaceSeedCacheDirectory,
workspaceSeedCacheCompanyDirectory: input.workspaceSeedCacheCompanyDirectory,
workspaceBaseline: input.workspaceBaseline,
workspaceGitSnapshot: input.workspaceGitSnapshot,
workspaceRepositories: input.workspaceRepositories,
workspaceExclude: mergeRuntimeExcludes(input.workspaceExclude),
workspaceFileMode: input.workspaceFileMode,
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
+12 -1
View File
@@ -1,3 +1,4 @@
import type { WorkspaceCheckpointMetrics } from "./workspace-checkpoint.js";
import fs from "node:fs/promises";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
@@ -267,6 +268,7 @@ export interface PreparedAdapterExecutionTargetRuntime {
workspaceSyncSnapshot: {
baseline: DirectorySnapshot;
gitSnapshot: GitWorkspaceSnapshot | null;
repositories?: NonNullable<GitWorkspaceSnapshot["repositories"]>;
} | null;
cleanupWorkspaceSnapshot?(): Promise<void>;
restoreWorkspace(onProgress?: RuntimeProgressSink): Promise<void>;
@@ -1438,10 +1440,14 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
syncWorkspace?: boolean;
workspaceInboundMode?: WorkspaceInboundMode;
workspaceDurableSeed?: WorkspaceDurableSeedPaths;
workspaceCheckpoint?: boolean;
onWorkspaceCheckpoint?: (metrics: WorkspaceCheckpointMetrics) => Promise<void>;
workspaceSeedCacheDirectory?: string;
workspaceSeedCacheCompanyDirectory?: string;
workspaceBaseline?: DirectorySnapshot;
workspaceGitSnapshot?: GitWorkspaceSnapshot | null;
workspaceRepositories?: NonNullable<GitWorkspaceSnapshot["repositories"]>;
workspaceExclude?: string[];
/** Plain persistent directories include all files, independent of Git and task cache exclusions. */
workspaceFileMode?: "all";
preserveAbsentOnRestore?: string[];
assets?: AdapterManagedRuntimeAsset[];
@@ -1528,8 +1534,13 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
syncWorkspace: input.syncWorkspace,
workspaceInboundMode: input.workspaceInboundMode,
workspaceDurableSeed: input.workspaceDurableSeed,
workspaceCheckpoint: input.workspaceCheckpoint,
onWorkspaceCheckpoint: input.onWorkspaceCheckpoint,
workspaceSeedCacheDirectory: input.workspaceSeedCacheDirectory,
workspaceSeedCacheCompanyDirectory: input.workspaceSeedCacheCompanyDirectory,
workspaceBaseline: input.workspaceBaseline,
workspaceGitSnapshot: input.workspaceGitSnapshot,
workspaceRepositories: input.workspaceRepositories,
workspaceExclude: input.workspaceExclude,
workspaceFileMode: input.workspaceFileMode,
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
@@ -15,6 +15,7 @@ export interface GitCommandResult {
export interface GitWorkspaceSnapshot {
headCommit: string;
branchName: string | null;
originUrl?: string | null;
overlayPaths: WorkspacePaths;
deletedPaths: WorkspacePaths;
ignoredPaths: WorkspacePaths;
@@ -168,6 +169,33 @@ export function workspaceSnapshotTimeoutMs(): number {
return Number.isFinite(configured) && configured >= 1000 ? Math.min(configured, 86_400_000) : 30 * 60_000;
}
export async function readManagedWorkspaceRepositories(localDir: string, options: { signal?: AbortSignal } = {}): Promise<NonNullable<GitWorkspaceSnapshot["repositories"]>> {
const repositories: NonNullable<GitWorkspaceSnapshot["repositories"]> = [];
try {
const root = path.join(localDir, PROJECT_REPOSITORIES_DIR);
const rootStat = await fs.lstat(root).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null;
throw error;
});
if (rootStat) {
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error("Invalid project repositories directory");
for await (const entry of await fs.opendir(root)) {
if (!entry.isDirectory() || !/^[a-zA-Z0-9_-]+$/.test(entry.name)) throw new Error("Invalid project repository directory");
const relative = `${PROJECT_REPOSITORIES_DIR}/${entry.name}`;
const snapshot = await readGitWorkspaceSnapshot(path.join(localDir, relative), false, options);
if (!snapshot) throw new Error(`Project repository is not a Git checkout: ${relative}`);
repositories.push({ path: relative, snapshot });
}
}
return repositories;
} catch (error) {
for (const repo of repositories) await disposeGitWorkspaceSnapshot(repo.snapshot);
throw error;
}
}
export async function readGitWorkspaceSnapshot(localDir: string, includeRepositories = true, options: { signal?: AbortSignal } = {}): Promise<GitWorkspaceSnapshot | null> {
const repositories: NonNullable<GitWorkspaceSnapshot["repositories"]> = [];
// Only repository discovery may report an ordinary directory. A failed
@@ -205,23 +233,7 @@ export async function readGitWorkspaceSnapshot(localDir: string, includeReposito
options.signal?.addEventListener("abort", abort, { once: true });
if (options.signal?.aborted) abort();
try {
if (includeRepositories) {
const root = path.join(localDir, PROJECT_REPOSITORIES_DIR);
const rootStat = await fs.lstat(root).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null;
throw error;
});
if (rootStat) {
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error("Invalid project repositories directory");
for await (const entry of await fs.opendir(root)) {
if (!entry.isDirectory() || !/^[a-zA-Z0-9_-]+$/.test(entry.name)) throw new Error("Invalid project repository directory");
const relative = `${PROJECT_REPOSITORIES_DIR}/${entry.name}`;
const snapshot = await readGitWorkspaceSnapshot(path.join(localDir, relative), false, options);
if (!snapshot) throw new Error(`Project repository is not a Git checkout: ${relative}`);
repositories.push({ path: relative, snapshot });
}
}
}
if (includeRepositories) repositories.push(...await readManagedWorkspaceRepositories(localDir, options));
const scan = async (args: string[], operation: string, category: string) => {
const parser = new WorkspaceNulParser((record) => {
@@ -267,6 +279,7 @@ export async function readGitWorkspaceSnapshot(localDir: string, includeReposito
const branch = (settled[1] as PromiseFulfilledResult<GitCommandResult>).value.stdout.trim();
const snapshot: GitWorkspaceSnapshot = {
headCommit: head, branchName: branch && branch !== "HEAD" ? branch : null,
originUrl: await readSanitizedOriginRemoteUrl(localDir),
overlayPaths: writer.paths("overlay"), deletedPaths: writer.paths("deleted"), ignoredPaths: writer.paths("ignored"),
...(repositories.length ? { repositories } : {}),
};
@@ -573,7 +586,7 @@ export async function readSanitizedOriginRemoteUrl(localDir: string): Promise<st
}
}
async function copyCloneTree(source: string, target: string): Promise<void> {
export async function copyCloneTree(source: string, target: string): Promise<void> {
await fs.mkdir(target, { recursive: true });
for await (const entry of await fs.opendir(source)) {
const from = path.join(source, entry.name);
@@ -594,7 +607,7 @@ export async function withShallowGitWorkspaceClone<T>(
const cloneDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-git-workspace-"));
const tempRef = `refs/paperclip/git-sync/import/${randomUUID()}`;
try {
const originUrl = await readSanitizedOriginRemoteUrl(input.localDir);
const originUrl = input.snapshot.originUrl !== undefined ? input.snapshot.originUrl : await readSanitizedOriginRemoteUrl(input.localDir);
await runLocalGit(input.localDir, ["update-ref", tempRef, input.snapshot.headCommit], {
timeout: 10_000,
maxBuffer: 16 * 1024,
@@ -1059,7 +1059,7 @@ describe("sandbox callback bridge", () => {
await bridge.stop();
});
await writeFile(
await publishBridgeRequest(
path.posix.join(directories.requestsDir, "existing.json"),
`${JSON.stringify({
id: "existing",
@@ -1070,7 +1070,6 @@ describe("sandbox callback bridge", () => {
body: "",
createdAt: new Date().toISOString(),
})}\n`,
"utf8",
);
const queueFullResponse = await fetch(`${bridge.baseUrl}/api/agents/me`, {
@@ -1900,7 +1899,7 @@ describe("sandbox callback bridge", () => {
const queueDir = path.posix.join(rootDir, "queue");
const directories = sandboxCallbackBridgeDirectories(queueDir);
await mkdir(directories.requestsDir, { recursive: true });
await writeFile(path.posix.join(directories.requestsDir, "req-a.json"), bridgeRequestJson("req-a"), "utf8");
await publishBridgeRequest(path.posix.join(directories.requestsDir, "req-a.json"), bridgeRequestJson("req-a"));
const { runtimeSpan, workerErrors } = createWorkerErrorCapture();
@@ -2949,7 +2948,7 @@ describe("sandbox callback bridge", () => {
const queueDir = path.posix.join(rootDir, "queue");
const directories = sandboxCallbackBridgeDirectories(queueDir);
await mkdir(directories.requestsDir, { recursive: true });
await writeFile(path.posix.join(directories.requestsDir, "req-w.json"), bridgeRequestJson("req-w"), "utf8");
await publishBridgeRequest(path.posix.join(directories.requestsDir, "req-w.json"), bridgeRequestJson("req-w"));
const base = createFileSystemSandboxCallbackBridgeQueueClient();
let listCalls = 0;
@@ -2996,7 +2995,7 @@ describe("sandbox callback bridge", () => {
const queueDir = path.posix.join(rootDir, "queue");
const directories = sandboxCallbackBridgeDirectories(queueDir);
await mkdir(directories.requestsDir, { recursive: true });
await writeFile(path.posix.join(directories.requestsDir, "req-ok.json"), bridgeRequestJson("req-ok"), "utf8");
await publishBridgeRequest(path.posix.join(directories.requestsDir, "req-ok.json"), bridgeRequestJson("req-ok"));
const { runtimeSpan, workerErrors } = createWorkerErrorCapture();
const processed: string[] = [];
@@ -0,0 +1,79 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, expect, it } from "vitest";
import { prepareSandboxManagedRuntime, type SandboxManagedRuntimeClient } from "./sandbox-managed-runtime.js";
const exec = promisify(execFile);
const roots: string[] = [];
afterEach(async () => {
for (const root of roots.splice(0)) await fs.rm(root, { recursive: true, force: true });
});
async function deletedFixture(names: string[], beforeDelete?: (remote: string) => Promise<void>) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "sandbox-deletions-"));
roots.push(root);
const source = path.join(root, "source"), remote = path.join(root, "remote");
await fs.mkdir(source);
const git = (...args: string[]) => exec("git", ["-C", source, ...args]);
await git("init");
for (const name of names) {
await fs.mkdir(path.dirname(path.join(source, name)), { recursive: true });
await fs.writeFile(path.join(source, name), "tracked content");
}
await git("add", "--", ".");
await git("-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-qm", "fixture");
for (const name of names) await fs.rm(path.join(source, name));
const client: SandboxManagedRuntimeClient = {
makeDir: async directory => { await fs.mkdir(directory, { recursive: true }); },
writeFile: async (file, bytes) => { await fs.writeFile(file, Buffer.from(bytes)); },
readFile: file => fs.readFile(file),
listFiles: directory => fs.readdir(directory),
remove: file => fs.rm(file, { recursive: true, force: true }),
run: async command => { await exec("sh", ["-c", command]); },
syncIn: async operations => {
for (const operation of operations) {
for (const mapping of operation.files) {
await fs.mkdir(path.dirname(mapping.targetPath), { recursive: true });
await fs.copyFile(mapping.sourcePath, mapping.targetPath);
}
for (const { command } of operation.postUploadCommands ?? []) {
if (command.includes("xargs -0")) await beforeDelete?.(remote);
await exec("sh", ["-c", command]);
}
}
return { operations: [] };
},
};
return { root, remote, prepare: () => prepareSandboxManagedRuntime({
spec: { transport: "sandbox", provider: "test", sandboxId: "test", remoteCwd: remote, timeoutMs: 30_000, apiKey: null },
client, adapterKey: "test", workspaceLocalDir: source,
}) };
}
it("deletes multiple bounded batches with newline, whitespace, Unicode and leading-dash filenames", async () => {
const names = [" space ", "line\nbreak", "-option", "雪-💾", "wild[?]*",
...Array.from({ length: 130 }, (_, index) => `nested/file-${index}`)];
const fixture = await deletedFixture(names);
const runtime = await fixture.prepare();
try {
for (const name of names) await expect(fs.lstat(path.join(fixture.remote, name))).rejects.toMatchObject({ code: "ENOENT" });
} finally { await runtime.cleanupWorkspaceSnapshot(); }
});
it.each(["symlink", "non_directory"] as const)("rejects a %s ancestor before deleting any entry in its batch", async kind => {
let outside: string;
const fixture = await deletedFixture(["a-valid", "z-parent/secret"], async remote => {
outside = path.join(path.dirname(remote), "outside");
await fs.mkdir(outside);
await fs.writeFile(path.join(outside, "secret"), "private content");
await fs.rm(path.join(remote, "z-parent"), { recursive: true });
if (kind === "symlink") await fs.symlink(outside, path.join(remote, "z-parent"));
else await fs.writeFile(path.join(remote, "z-parent"), "blocker");
});
await expect(fixture.prepare()).rejects.toThrow();
expect(await fs.readFile(path.join(outside!, "secret"), "utf8")).toBe("private content");
expect(await fs.readFile(path.join(fixture.remote, "a-valid"), "utf8")).toBe("tracked content");
});
@@ -631,6 +631,9 @@ describe("sandbox managed runtime", () => {
const localAssetsDir = path.join(rootDir, "local-assets");
const linkedAssetPath = path.join(rootDir, "linked-skill.md");
await mkdir(path.join(localWorkspaceDir, ".claude"), { recursive: true });
const cloneStaging = path.join(".paperclip-runtime", "repository-staging", "pending.clone-abc", "partial.txt");
await mkdir(path.dirname(path.join(localWorkspaceDir, cloneStaging)), { recursive: true });
await writeFile(path.join(localWorkspaceDir, cloneStaging), "Incomplete private clone");
await mkdir(localAssetsDir, { recursive: true });
await writeFile(path.join(localWorkspaceDir, "README.md"), "local workspace\n", "utf8");
await writeFile(path.join(localWorkspaceDir, "._README.md"), "appledouble\n", "utf8");
@@ -692,6 +695,7 @@ describe("sandbox managed runtime", () => {
});
await expect(readFile(path.join(remoteWorkspaceDir, "README.md"), "utf8")).resolves.toBe("local workspace\n");
await expect(readFile(path.join(remoteWorkspaceDir, cloneStaging), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(path.join(remoteWorkspaceDir, "._README.md"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(path.join(remoteWorkspaceDir, ".claude", "settings.json"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(path.join(prepared.assetDirs.skills, "skill.md"), "utf8")).resolves.toBe("skill body\n");
@@ -720,6 +724,7 @@ describe("sandbox managed runtime", () => {
expect.stringMatching(/^config_sync:Syncing skills to environment: 100% \(\d+\.\d\/\d+\.\d MB\)$/),
expect.stringMatching(/^restore:Restoring workspace from environment: 100% \(\d+\.\d\/\d+\.\d MB\)$/),
]));
await expect(readFile(path.join(localWorkspaceDir, cloneStaging), "utf8")).resolves.toBe("Incomplete private clone");
expect(runtimeStatuses.at(-1)).toBe("finalize:Finalizing workspace");
expect(run.mock.calls.some(([command]) => command.includes("workspace-download.tar"))).toBe(true);
});
@@ -1,3 +1,6 @@
import type { WorkspaceCheckpointMetrics } from "./workspace-checkpoint.js";
import { workspaceSeedGeneration, readWorkspaceSeedGeneration, publishWorkspaceSeedGeneration } from "./workspace-seed-cache.js";
import { writeCheckpointBaseline, workspaceCheckpointScript, readCheckpointSnapshot, validateCheckpointPayload } from "./workspace-checkpoint.js";
import { execFile as execFileCallback } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import {
@@ -6,7 +9,7 @@ import {
promises as fs,
} from "node:fs";
import os from "node:os";
import { workspacePaths, workspacePathMatcher, writeWorkspacePaths, isPathManifest, type WorkspacePaths } from "./workspace-manifest.js";
import { createWorkspaceManifest, WorkspaceManifestMap, workspacePaths, workspacePathMatcher, writeWorkspacePaths, isPathManifest, type WorkspacePaths } from "./workspace-manifest.js";
import path from "node:path";
import { promisify } from "node:util";
import {
@@ -20,6 +23,8 @@ import {
type GitWorkspaceSnapshot,
integrateImportedGitHead,
readGitWorkspaceSnapshot,
readManagedWorkspaceRepositories,
copyCloneTree,
disposeGitWorkspaceSnapshot,
ReferencedSourceIgnoreScanLimitExceededError,
readReferencedSourceGitIgnoredPaths,
@@ -29,6 +34,7 @@ import {
} from "./git-workspace-sync.js";
import {
captureDirectorySnapshot,
directorySnapshotSha256,
classifyWorkspaceRestoreFailure,
disposeDirectorySnapshot,
selectDirectorySnapshot,
@@ -362,6 +368,14 @@ function isWorkspaceGitScanSaturatedError(error: unknown): boolean {
);
}
function isCheckpointCaptureCapacityError(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const failure = error as { code?: unknown; message?: unknown; stderr?: unknown };
if (["ENOSPC", "EDQUOT", "SQLITE_FULL"].includes(String(failure.code))) return true;
const diagnostic = [failure.message, failure.stderr].filter(value => typeof value === "string").join("\n");
return /Workspace checkpoint storage is below its free-space reserve|\b(?:ENOSPC|EDQUOT|SQLITE_FULL)\b|database or disk is full/i.test(diagnostic);
}
/**
* Resolve a referenced project's Git-ignored paths ONCE, before any staging
* site runs. Called once per project (see `execute.ts`); the sandbox lane,
@@ -606,6 +620,7 @@ export interface PreparedSandboxManagedRuntime {
workspaceSyncSnapshot: {
baseline: DirectorySnapshot;
gitSnapshot: GitWorkspaceSnapshot | null;
repositories?: NonNullable<GitWorkspaceSnapshot["repositories"]>;
} | null;
cleanupWorkspaceSnapshot(): Promise<void>;
restoreWorkspace(onProgress?: RuntimeProgressSink): Promise<void>;
@@ -670,6 +685,7 @@ function buildWorkspaceTarExtractCommand(input: {
workspaceRemoteDir: string;
remoteTar: string;
wipeExceptNames: string[] | null;
exclude?: string[];
}): string {
// The wipe must also preserve any in-flight sync scratch tarball at the
// workspace root. A concurrent referenced-project upload stages a scratch
@@ -686,7 +702,7 @@ function buildWorkspaceTarExtractCommand(input: {
: "";
return (
`mkdir -p ${shellQuote(input.workspaceRemoteDir)}${wipe} && ` +
`tar -xf ${shellQuote(input.remoteTar)} -C ${shellQuote(input.workspaceRemoteDir)} && ` +
`tar${(input.exclude ?? []).map(entry => ` --exclude=${shellQuote(escapeTarExcludeLiteral(entry))}`).join("")} -xf ${shellQuote(input.remoteTar)} -C ${shellQuote(input.workspaceRemoteDir)} && ` +
`rm -f ${shellQuote(input.remoteTar)}`
);
}
@@ -696,15 +712,14 @@ function buildWorkspaceTarExtractCommand(input: {
// already-confined relative paths from the git snapshot.
function buildRemoveDeletedPathsCommand(input: { remoteDir: string; manifestPath: string }): string {
// NUL input plus bounded xargs batches preserve whitespace and never create
// one argument list for the full snapshot. Refuse symlink ancestors.
const remove = `for entry do
parent=$entry
while [ "\${parent#*/}" != "$parent" ]; do
parent=\${parent%/*}
if [ -L "$parent" ] || { [ -e "$parent" ] && [ ! -d "$parent" ]; }; then exit 42; fi
done
rm -rf -- "$entry" || exit
done`;
done
rm -rf -- "$@"`;
return `cd ${shellQuote(input.remoteDir)} && xargs -0 -r -n 64 sh -c ${shellQuote(remove)} sh < ${shellQuote(input.manifestPath)} && rm -f -- ${shellQuote(input.manifestPath)}`;
}
@@ -820,7 +835,7 @@ async function copyDurableSeedArchive(input: {
) {
throw new Error("workspace_durable_seed_digest_mismatch");
}
await fs.copyFile(input.sourcePath, input.targetPath);
await fs.copyFile(input.sourcePath, input.targetPath, fsConstants.COPYFILE_FICLONE);
}
async function persistDurableSeedArchive(input: {
@@ -838,7 +853,7 @@ async function persistDurableSeedArchive(input: {
`.${path.basename(input.targetPath)}.${randomUUID()}.tmp`,
);
try {
await fs.copyFile(input.sourcePath, temporary);
await fs.copyFile(input.sourcePath, temporary, fsConstants.COPYFILE_FICLONE);
await fs.chmod(temporary, 0o600);
await fs.rename(temporary, input.targetPath);
} finally {
@@ -885,9 +900,10 @@ export async function createTarballFromDirectory(input: {
async function extractTarballToDirectory(input: {
archivePath: string;
localDir: string;
exclude?: string[];
}): Promise<void> {
await fs.mkdir(input.localDir, { recursive: true });
await execTar(["-xf", input.archivePath, "-C", input.localDir]);
await execTar([...(input.exclude ?? []).map(entry => `--exclude=${escapeTarExcludeLiteral(entry)}`), "-xf", input.archivePath, "-C", input.localDir]);
}
async function* walkDirectory(root: string, relative = "", childrenFirst = false): AsyncGenerator<string> {
@@ -899,6 +915,28 @@ async function* walkDirectory(root: string, relative = "", childrenFirst = false
}
}
async function confineFullWorkspaceExport(root: string): Promise<void> {
const unsafe = () => Object.assign(new Error("Unsafe workspace export directory"), { code: "WORKSPACE_RESTORE_UNSAFE_ARCHIVE" });
if (!(await fs.lstat(root)).isDirectory()) throw unsafe();
const canonicalRoot = await fs.realpath(root);
const walk = async (directory: string): Promise<void> => {
if (await fs.realpath(directory) !== directory) throw unsafe();
for await (const entry of await fs.opendir(directory)) {
const entryPath = path.join(directory, entry.name);
const stats = await fs.lstat(entryPath);
if (stats.isDirectory()) await walk(entryPath);
else if (stats.isSymbolicLink()) {
const target = await fs.readlink(entryPath);
const resolved = path.resolve(directory, target);
if (path.isAbsolute(target) || (resolved !== canonicalRoot && !resolved.startsWith(`${canonicalRoot}${path.sep}`))) {
await fs.unlink(entryPath);
}
} else if (!stats.isFile()) throw unsafe();
}
};
await walk(canonicalRoot);
}
async function copyWorkspaceEntry(sourceRoot: string, targetRoot: string, relative: string): Promise<void> {
const sourcePath = path.join(sourceRoot, relative);
const targetPath = path.join(targetRoot, relative);
@@ -1136,11 +1174,15 @@ export async function prepareSandboxManagedRuntime(input: {
/** Selects authoritative host staging, exact durable-seed replay, or no-overwrite adoption. */
workspaceInboundMode?: WorkspaceInboundMode;
workspaceDurableSeed?: WorkspaceDurableSeedPaths;
workspaceCheckpoint?: boolean;
onWorkspaceCheckpoint?: (metrics: WorkspaceCheckpointMetrics) => Promise<void>;
workspaceSeedCacheDirectory?: string;
workspaceSeedCacheCompanyDirectory?: string;
/** Durable snapshots supplied when reconstructing an interrupted restore. */
workspaceBaseline?: DirectorySnapshot;
workspaceGitSnapshot?: GitWorkspaceSnapshot | null;
workspaceRepositories?: NonNullable<GitWorkspaceSnapshot["repositories"]>;
workspaceExclude?: string[];
/** Plain persistent directories include all files, independent of Git and task cache exclusions. */
workspaceFileMode?: "all";
preserveAbsentOnRestore?: string[];
assets?: SandboxManagedRuntimeAsset[];
@@ -1228,17 +1270,40 @@ 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 && input.workspaceFileMode !== "all"
const gitSnapshot = syncWorkspace && (input.workspaceGitSnapshot !== undefined || input.workspaceFileMode !== "all")
? input.workspaceGitSnapshot !== undefined
? input.workspaceGitSnapshot
: await runStepSpan("snapshot.git", () =>
readGitWorkspaceSnapshot(input.workspaceLocalDir),
)
: null;
const repositories = gitSnapshot ? (gitSnapshot.repositories ?? []) : input.workspaceRepositories ?? (syncWorkspace ? await readManagedWorkspaceRepositories(input.workspaceLocalDir) : []);
const repositoryGitPaths = (children: typeof repositories, prefix = ""): string[] => children.flatMap(repo => {
const relative = prefix ? `${prefix}/${repo.path}` : repo.path;
return [`${relative}/.git`, ...repositoryGitPaths(repo.snapshot.repositories ?? [], relative)];
});
const repositoryMetadataExcludes = repositoryGitPaths(repositories);
let managedIgnored: Awaited<ReturnType<typeof createWorkspaceManifest>> | null = null;
let ignoredPaths = input.workspaceFileMode === "all" ? undefined : gitSnapshot?.ignoredPaths;
if (!gitSnapshot && repositories.length) {
managedIgnored = await createWorkspaceManifest();
for (const repo of repositories) {
if (input.workspaceFileMode !== "all") for (const relative of workspacePaths(repo.snapshot.ignoredPaths)) managedIgnored.add("ignored", `${repo.path}/${relative}`);
for (const relative of workspacePaths(repo.snapshot.deletedPaths)) managedIgnored.add("deleted", `${repo.path}/${relative}`);
}
ignoredPaths = managedIgnored.paths("ignored");
}
let deletedPaths = gitSnapshot?.deletedPaths ?? managedIgnored?.paths("deleted") ?? [];
let filesystemDeleted: Awaited<ReturnType<typeof createWorkspaceManifest>> | null = null;
managedIgnored?.close();
let baselineSnapshot: DirectorySnapshot | null = null;
const cleanupWorkspaceSnapshot = async () => {
await disposeDirectorySnapshot(baselineSnapshot);
if (input.workspaceBaseline && input.workspaceBaseline !== baselineSnapshot) await disposeDirectorySnapshot(input.workspaceBaseline);
await disposeGitWorkspaceSnapshot(gitSnapshot);
if (!gitSnapshot) for (const repo of repositories) await disposeGitWorkspaceSnapshot(repo.snapshot);
if (managedIgnored) await fs.rm(path.dirname(managedIgnored.filePath), { recursive: true, force: true });
if (filesystemDeleted) await fs.rm(path.dirname(filesystemDeleted.filePath), { recursive: true, force: true });
};
try {
// A selected subfolder has no cloneable Git snapshot, but its parent
@@ -1252,20 +1317,22 @@ export async function prepareSandboxManagedRuntime(input: {
}
const gitIgnoredExcludes = directoryIgnore?.kind === "git" ? directoryIgnore.ignoredPaths : undefined;
const workspaceArchiveExclude = mergeExcludes(
[".paperclip-runtime"],
repositoryMetadataExcludes,
input.workspaceFileMode === "all" ? [] : SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES,
input.workspaceFileMode === "all" ? [] : [...GIT_ARCHIVE_EXCLUDES],
input.workspaceFileMode === "all" && !gitSnapshot ? [] : [...GIT_ARCHIVE_EXCLUDES],
input.workspaceExclude,
gitIgnoredExcludes,
);
const restoreExclude = mergeExcludes(
repositoryMetadataExcludes,
input.workspaceFileMode === "all" ? [] : SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES,
input.workspaceFileMode === "all" ? [] : [...GIT_ARCHIVE_EXCLUDES],
input.workspaceFileMode === "all" && !gitSnapshot ? [] : [...GIT_ARCHIVE_EXCLUDES],
[".paperclip-runtime"],
input.preserveAbsentOnRestore,
input.workspaceExclude,
gitIgnoredExcludes,
);
const repositories = gitSnapshot?.repositories ?? [];
const workspaceRestoreExclude = mergeExcludes(restoreExclude, repositories.map((repo) => repo.path));
// The baseline "before" snapshot: a recursive walk of the whole workspace that
// `lstat`s every entry and SHA-256-hashes every file's bytes. This is the
@@ -1276,11 +1343,33 @@ export async function prepareSandboxManagedRuntime(input: {
(await runStepSpan("snapshot.baseline", () =>
captureDirectorySnapshot(input.workspaceLocalDir, {
exclude: restoreExclude,
ignoredPaths: gitSnapshot?.ignoredPaths,
ignoredPaths,
diskBacked: true,
}),
)))
: null;
if (input.workspaceBaseline && repositoryMetadataExcludes.length) {
baselineSnapshot = await selectDirectorySnapshot(input.workspaceBaseline, {
omit: repositoryMetadataExcludes, exclude: mergeExcludes(input.workspaceBaseline.exclude, repositoryMetadataExcludes),
ignoredPaths: input.workspaceBaseline.ignoredPaths,
});
}
if (input.workspaceFileMode === "all" && baselineSnapshot
&& (isPathManifest(deletedPaths) ? deletedPaths.count : deletedPaths.length) > 0) {
filesystemDeleted = await createWorkspaceManifest();
try {
for (const relative of workspacePaths(deletedPaths)) {
if (!baselineSnapshot.entries.has(relative)) filesystemDeleted.add("deleted", relative);
}
deletedPaths = filesystemDeleted.paths("deleted");
} finally { filesystemDeleted.close(); }
}
const seedGeneration = input.workspaceSeedCacheDirectory && baselineSnapshot && workspaceInboundMode !== "durable_seed"
? workspaceSeedGeneration(baselineSnapshot, gitSnapshot, repositories) : null;
const cachedSeed = seedGeneration
? await readWorkspaceSeedGeneration(input.workspaceSeedCacheDirectory!, seedGeneration) : null;
const replaySeed = workspaceInboundMode === "durable_seed" ? input.workspaceDurableSeed : cachedSeed;
// Every inbound staging step delegates to the provider through `client.syncIn`:
// the orchestrator no longer inlines `writeFile`+`run` or chooses a transport,
@@ -1430,38 +1519,30 @@ export async function prepareSandboxManagedRuntime(input: {
// `runtimeRootDir` (`.paperclip-runtime/<adapterKey>`). The git extract
// wipes the target tree EXCEPT `.paperclip-runtime`, so the overlay tar,
// which sits under `.paperclip-runtime`, survives to run its own extract.
if (gitSnapshot) {
if (gitSnapshot || repositories.length) {
await emitRuntimeStatus(input.onRuntimeProgress, "git_sync", "Syncing git history to environment");
const gitTarPath = path.join(tempDir, "git-workspace.tar");
const remoteGitTar = path.posix.join(
runtimeRootDir,
"git-workspace-upload.tar",
);
if (workspaceInboundMode === "durable_seed") {
const durableGitArchive =
input.workspaceDurableSeed?.gitArchivePath;
if (replaySeed) {
const durableGitArchive = replaySeed.gitArchivePath;
if (!durableGitArchive) {
throw new Error("workspace_durable_seed_git_missing");
}
await copyDurableSeedArchive({
sourcePath: durableGitArchive,
targetPath: gitTarPath,
expectedSha256: input.workspaceDurableSeed?.gitArchiveSha256,
expectedSha256: replaySeed.gitArchiveSha256,
});
} else {
await withShallowGitWorkspaceClone(
{
localDir: input.workspaceLocalDir,
snapshot: gitSnapshot,
},
async (cloneDir) => {
await createTarballFromDirectory({
localDir: cloneDir,
archivePath: gitTarPath,
exclude: [".paperclip-runtime"],
});
},
);
const archive = async (cloneDir: string) => createTarballFromDirectory({ localDir: cloneDir, archivePath: gitTarPath, exclude: [".paperclip-runtime"] });
if (gitSnapshot) await withShallowGitWorkspaceClone({ localDir: input.workspaceLocalDir, snapshot: gitSnapshot }, archive);
else await withTempDir("paperclip-managed-repositories-", async (cloneDir) => {
for (const repo of repositories) await withShallowGitWorkspaceClone({ localDir: path.join(input.workspaceLocalDir, repo.path), snapshot: repo.snapshot }, async (child) => copyCloneTree(child, path.join(cloneDir, repo.path)));
await archive(cloneDir);
});
if (input.workspaceDurableSeed?.gitArchivePath) {
await persistDurableSeedArchive({
sourcePath: gitTarPath,
@@ -1469,6 +1550,7 @@ export async function prepareSandboxManagedRuntime(input: {
});
}
}
if (cachedSeed && input.workspaceDurableSeed?.gitArchivePath) await persistDurableSeedArchive({ sourcePath: gitTarPath, targetPath: input.workspaceDurableSeed.gitArchivePath });
workspaceFiles.push({
sourcePath: gitTarPath,
targetPath: remoteGitTar,
@@ -1491,30 +1573,30 @@ export async function prepareSandboxManagedRuntime(input: {
// the preserved names first. The extract runs AFTER the git extract.
await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing workspace to environment");
const workspaceTarPath = path.join(tempDir, "workspace.tar");
if (workspaceInboundMode === "durable_seed") {
if (replaySeed) {
await copyDurableSeedArchive({
sourcePath: input.workspaceDurableSeed!.workspaceArchivePath,
sourcePath: replaySeed.workspaceArchivePath,
targetPath: workspaceTarPath,
expectedSha256:
input.workspaceDurableSeed!.workspaceArchiveSha256,
replaySeed.workspaceArchiveSha256,
});
} else {
const workspaceArchiveDir = gitSnapshot
const workspaceArchiveDir = gitSnapshot || repositories.length
? path.join(tempDir, "workspace-overlay")
: input.workspaceLocalDir;
if (gitSnapshot) {
if (gitSnapshot || repositories.length) {
await copySelectedWorkspaceEntries({
sourceRoot: workspaceRoot!,
targetDir: workspaceArchiveDir,
relativePaths: gitSnapshot.overlayPaths,
ignoredPaths: gitSnapshot.ignoredPaths,
relativePaths: (input.workspaceFileMode === "all" ? undefined : gitSnapshot?.overlayPaths) ?? (baselineSnapshot!.entries instanceof WorkspaceManifestMap ? baselineSnapshot!.entries.manifest : [...baselineSnapshot!.entries.keys()]),
ignoredPaths,
exclude: workspaceArchiveExclude,
});
}
await createTarballFromDirectory({
localDir: workspaceArchiveDir,
archivePath: workspaceTarPath,
exclude: gitSnapshot ? undefined : workspaceArchiveExclude,
exclude: gitSnapshot || repositories.length ? undefined : workspaceArchiveExclude,
});
if (input.workspaceDurableSeed) {
await persistDurableSeedArchive({
@@ -1523,6 +1605,7 @@ export async function prepareSandboxManagedRuntime(input: {
});
}
}
if (cachedSeed && input.workspaceDurableSeed) await persistDurableSeedArchive({ sourcePath: workspaceTarPath, targetPath: input.workspaceDurableSeed.workspaceArchivePath });
const remoteWorkspaceTar = path.posix.join(
runtimeRootDir,
"workspace-upload.tar",
@@ -1538,20 +1621,47 @@ export async function prepareSandboxManagedRuntime(input: {
command: buildWorkspaceTarExtractCommand({
workspaceRemoteDir,
remoteTar: remoteWorkspaceTar,
wipeExceptNames: gitSnapshot ? null : [...preservedNames],
wipeExceptNames: gitSnapshot || repositories.length ? null : [...preservedNames],
exclude: repositoryMetadataExcludes,
}),
});
// 3. Optional remove-deleted-paths command runs LAST, after both extracts.
if (gitSnapshot && (isPathManifest(gitSnapshot.deletedPaths) ? gitSnapshot.deletedPaths.count : gitSnapshot.deletedPaths.length) > 0) {
if ((isPathManifest(deletedPaths) ? deletedPaths.count : deletedPaths.length) > 0) {
const deletedManifest = path.join(tempDir, "deleted.nul");
const remoteDeletedManifest = path.posix.join(runtimeRootDir, "deleted.nul");
await writeWorkspacePaths(gitSnapshot.deletedPaths, deletedManifest);
await writeWorkspacePaths(deletedPaths, deletedManifest);
workspaceFiles.push({ sourcePath: deletedManifest, targetPath: remoteDeletedManifest, kind: "file", access: "rw", writablePath: runtimeRootDir });
workspacePostUploadCommands.push({ command: buildRemoveDeletedPathsCommand({ remoteDir: workspaceRemoteDir, manifestPath: remoteDeletedManifest }) });
}
workspaceUploadBytes += (await fs.stat(workspaceTarPath)).size;
});
if (seedGeneration && !cachedSeed && input.workspaceDurableSeed) {
const seed = input.workspaceDurableSeed;
await publishWorkspaceSeedGeneration(input.workspaceSeedCacheDirectory!, seedGeneration,
{ ...seed, gitArchivePath: gitSnapshot || repositories.length ? seed.gitArchivePath : null }, {
companyDirectory: input.workspaceSeedCacheCompanyDirectory,
verify: () => runStepSpan("seed.verify", async () => {
const verifiedRoot = path.join(tempDir, "verify-seed");
try {
if (gitSnapshot || repositories.length) await extractTarballToDirectory({ archivePath: path.join(tempDir, "git-workspace.tar"), localDir: verifiedRoot });
await extractTarballToDirectory({ archivePath: path.join(tempDir, "workspace.tar"), localDir: verifiedRoot, exclude: repositoryMetadataExcludes });
for (const relative of workspacePaths(deletedPaths)) {
let parent = verifiedRoot;
for (const segment of relative.split("/").slice(0, -1)) {
parent = path.join(parent, segment);
const stat = await fs.lstat(parent).catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return null; throw error; });
if (stat && !stat.isDirectory()) return false;
}
await fs.rm(path.join(verifiedRoot, relative), { recursive: true, force: true });
}
const verified = await captureDirectorySnapshot(verifiedRoot, { exclude: baselineSnapshot!.exclude, ignoredPaths: baselineSnapshot!.ignoredPaths, diskBacked: true });
try { return directorySnapshotSha256(verified) === directorySnapshotSha256(baselineSnapshot!); }
finally { await disposeDirectorySnapshot(verified); }
} finally { await fs.rm(verifiedRoot, { recursive: true, force: true }); }
}),
});
}
if (!stageWorkspace) return;
// One confined `syncIn` for the whole merged workspace file set. The confine
@@ -1748,7 +1858,7 @@ export async function prepareSandboxManagedRuntime(input: {
additionalSourceFailures,
workspaceSyncSnapshot:
syncWorkspace && baselineSnapshot
? { baseline: baselineSnapshot, gitSnapshot }
? { baseline: baselineSnapshot, gitSnapshot, ...(!gitSnapshot && repositories.length ? { repositories } : {}) }
: null,
cleanupWorkspaceSnapshot,
restoreWorkspace: async (onProgress?: RuntimeProgressSink) => {
@@ -1798,12 +1908,15 @@ export async function prepareSandboxManagedRuntime(input: {
adapterKey: input.adapterKey,
workspaceLocalDir: localDir,
workspaceInboundMode: "adopt_remote",
workspaceCheckpoint: input.workspaceCheckpoint,
onWorkspaceCheckpoint: input.onWorkspaceCheckpoint,
workspaceGitSnapshot: repository.snapshot,
workspaceFileMode: input.workspaceFileMode,
workspaceExclude: nestedExclude,
workspaceBaseline: await selectDirectorySnapshot(baselineSnapshot!, {
prefix,
exclude: mergeExcludes(SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES, [...GIT_ARCHIVE_EXCLUDES], [".paperclip-runtime"], nestedExclude),
ignoredPaths: repository.snapshot.ignoredPaths,
exclude: mergeExcludes(input.workspaceFileMode === "all" ? [] : SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES, [...GIT_ARCHIVE_EXCLUDES], [".paperclip-runtime"], nestedExclude),
ignoredPaths: input.workspaceFileMode === "all" ? undefined : repository.snapshot.ignoredPaths,
}),
onRuntimeProgress: input.onRuntimeProgress,
workspaceRestoreRetryDelaysMs: input.workspaceRestoreRetryDelaysMs,
@@ -1816,6 +1929,7 @@ export async function prepareSandboxManagedRuntime(input: {
let importedRef: string | null = null;
let importedHead: string | null = null;
let remoteWorkspaceStatus = "dirty";
let checkpointSnapshot: DirectorySnapshot | undefined;
try {
if (gitSnapshot) {
await emitRuntimeStatus(input.onRuntimeProgress, "export", "Exporting git changes from environment");
@@ -1908,7 +2022,60 @@ export async function prepareSandboxManagedRuntime(input: {
await emitRuntimeStatus(input.onRuntimeProgress, "restore", "Restoring workspace from environment");
const extractedDir = path.join(tempDir, "workspace");
if (nativeSyncOut) {
let sparseSupported = false;
if (input.workspaceCheckpoint && nativeSyncOut) {
sparseSupported = await input.client.run(
`node --input-type=module -e ${shellQuote("import {DatabaseSync} from 'node:sqlite'; new DatabaseSync(':memory:').close()")}`,
{ timeoutMs: 15_000 },
).then(() => true, () => false);
}
if (sparseSupported) {
const checkpointBaseline = repositories.length === 0 ? baselineSnapshot! : await selectDirectorySnapshot(baselineSnapshot!, {
omit: repositories.map((repo) => repo.path), exclude: workspaceRestoreExclude, ignoredPaths,
});
try {
const inputManifest = path.join(tempDir, "checkpoint-baseline.sqlite");
const helper = path.join(tempDir, "checkpoint.mjs");
await writeCheckpointBaseline(checkpointBaseline, inputManifest);
await fs.writeFile(helper, workspaceCheckpointScript());
const remoteRoot = path.posix.join(runtimeRootDir, `checkpoint-${randomUUID()}`);
const remoteManifest = path.posix.join(remoteRoot, "baseline.sqlite");
const remoteHelper = path.posix.join(remoteRoot, "checkpoint.mjs");
const remoteOutput = path.posix.join(remoteRoot, "output");
const inbound: SandboxSyncOperation[] = [{ operationId: nextSyncOperationId(), files: [
{ sourcePath: inputManifest, targetPath: remoteManifest, kind: "file" },
{ sourcePath: helper, targetPath: remoteHelper, kind: "file" },
] }];
assertSyncOperationsConfined(inbound, { sourceRoots: [tempDir], targetRoots: [runtimeRootDir] });
await input.client.syncIn!(inbound);
try {
let captured = false;
try {
await input.client.run(["node", remoteHelper, workspaceRemoteDir, remoteManifest, remoteOutput, JSON.stringify(workspaceRestoreExclude)].map(shellQuote).join(" "), { timeoutMs: input.spec.timeoutMs });
captured = true;
} catch (error) {
if (!isCheckpointCaptureCapacityError(error)) throw error;
}
if (captured) {
const output = path.join(tempDir, "checkpoint");
const outbound: SandboxSyncOperation[] = [{ operationId: nextSyncOperationId(), files: [
{ sourcePath: remoteOutput, targetPath: output, kind: "directory" },
] }];
assertSyncOperationsConfined(outbound, { sourceRoots: [runtimeRootDir], targetRoots: [tempDir] });
await withWorkspaceRestoreStep("workspace_transfer", () => input.client.syncOut!(outbound));
checkpointSnapshot = readCheckpointSnapshot(path.join(output, "manifest.sqlite"), checkpointBaseline);
await fs.rename(path.join(output, "payload"), extractedDir);
const metrics = await validateCheckpointPayload({ baseline: checkpointBaseline, snapshot: checkpointSnapshot, payload: extractedDir });
await input.onWorkspaceCheckpoint?.(metrics).catch(() => undefined);
}
} catch (error) {
if (error instanceof Error && error.message.includes("Unsafe workspace archive symlink")) Object.assign(error, { code: "WORKSPACE_RESTORE_UNSAFE_ARCHIVE" });
throw error;
} finally { await input.client.remove(remoteRoot).catch(() => undefined); }
} finally { if (checkpointBaseline !== baselineSnapshot) await disposeDirectorySnapshot(checkpointBaseline); }
}
if (input.workspaceCheckpoint && !checkpointSnapshot) await input.onWorkspaceCheckpoint?.({ mode: "full", scannedEntries: null, changedFiles: null, payloadBytes: null }).catch(() => undefined);
if (!checkpointSnapshot && nativeSyncOut) {
// Native outbound: the provider materializes the sandbox workspace into
// a fresh host directory. It is a clean destroy-then-replace into a
// temp dir the orchestrator just created, so it maps exactly to a
@@ -1939,7 +2106,7 @@ export async function prepareSandboxManagedRuntime(input: {
const syncResult = await withWorkspaceRestoreStep("workspace_transfer", () => input.client.syncOut!(operations));
const transferredBytes = sumSyncResultBytes(syncResult);
await workspaceRestore.finish(transferredBytes, transferredBytes);
} else {
} else if (!checkpointSnapshot) {
const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-download.tar");
await withWorkspaceRestoreStep("workspace_transfer", () => input.client.run(
`sh -c ${shellQuote(createRemoteTarballFromDirectoryCommand({
@@ -1969,15 +2136,17 @@ export async function prepareSandboxManagedRuntime(input: {
});
});
}
if (!checkpointSnapshot) await confineFullWorkspaceExport(extractedDir);
const gitHeadToIntegrate = importedHead;
await withWorkspaceRestoreStep("directory_merge", async () => {
const mergeBaseline = repositories.length === 0 ? baselineSnapshot! : await selectDirectorySnapshot(baselineSnapshot!, {
omit: repositories.map((repo) => repo.path), exclude: workspaceRestoreExclude, ignoredPaths: gitSnapshot?.ignoredPaths,
omit: repositories.map((repo) => repo.path), exclude: workspaceRestoreExclude, ignoredPaths,
});
try {
await mergeDirectoryWithBaseline({
baseline: mergeBaseline,
sourceDir: extractedDir,
...(checkpointSnapshot ? { snapshots: { source: checkpointSnapshot } } : {}),
targetDir: input.workspaceLocalDir,
// Runs under the merge lock, before the first host write.
beforeApply: async () => {
@@ -2001,6 +2170,7 @@ export async function prepareSandboxManagedRuntime(input: {
} finally { if (mergeBaseline !== baselineSnapshot) await disposeDirectorySnapshot(mergeBaseline); }
});
} finally {
await disposeDirectorySnapshot(checkpointSnapshot ?? null);
await emitRuntimeStatus(input.onRuntimeProgress, "finalize", "Finalizing workspace");
if (importedRef) {
await withWorkspaceRestoreStep("git_ref_cleanup", () => deleteLocalGitRef({ localDir: input.workspaceLocalDir, ref: importedRef! }));
@@ -0,0 +1,113 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { afterEach, expect, it, vi } from "vitest";
import { createTarballFromDirectory, prepareSandboxManagedRuntime, type SandboxSyncOperation } from "./sandbox-managed-runtime.js";
import { captureDirectorySnapshot } from "./workspace-restore-merge.js";
const execute = promisify(execFile);
const roots: string[] = [];
afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true })));
});
it.each([undefined, "all"] as const)("keeps nested Git metadata sanitized in cold, cached and recovered seeds (file mode %s)", async (workspaceFileMode) => {
const temp = await fs.mkdtemp(path.join(os.tmpdir(), "managed-seed-sanitized-")); roots.push(temp);
vi.stubEnv("PAPERCLIP_HOME", path.join(temp, "home"));
const host = path.join(temp, "host"), remote = path.join(temp, "remote"), cache = path.join(temp, "cache");
const relative = ".paperclip-repositories/fixture";
const repository = path.join(host, relative); await fs.mkdir(repository, { recursive: true });
const git = async (root: string, ...args: string[]) => (await execute("git", ["-C", root, ...args])).stdout.trim();
await git(repository, "init", "-b", "main");
await fs.writeFile(path.join(repository, ".gitignore"), "ignored.txt\n");
await fs.writeFile(path.join(repository, "work.txt"), "first\n");
if (workspaceFileMode === "all") {
await fs.writeFile(path.join(repository, "notes.txt"), "keep after untracking\n");
await fs.writeFile(path.join(repository, "absent.txt"), "truly deleted\n");
}
await git(repository, "add", ".");
await git(repository, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "first");
await git(repository, "branch", "private-fixture");
await fs.writeFile(path.join(repository, "work.txt"), "committed\n");
await git(repository, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-am", "selected");
const head = await git(repository, "rev-parse", "HEAD");
await git(repository, "remote", "add", "origin", "https://fixture-user:fixture-token@example.test/repository.git");
await fs.writeFile(path.join(repository, ".git/hooks/post-commit"), "private-hook-fixture");
await fs.writeFile(path.join(repository, "work.txt"), "dirty\n");
await fs.writeFile(path.join(repository, "ignored.txt"), "ordinary ignored bytes");
if (workspaceFileMode === "all") {
await git(repository, "rm", "--cached", "notes.txt");
await fs.unlink(path.join(repository, "absent.txt"));
}
const run = async (command: string) => { await execute("sh", ["-c", command]); };
const sync = async (operations: SandboxSyncOperation[]) => {
for (const operation of operations) {
for (const file of operation.files) {
await fs.mkdir(path.dirname(file.targetPath), { recursive: true });
await fs.cp(file.sourcePath, file.targetPath, { recursive: true, verbatimSymlinks: true });
if (file.sourcePath.endsWith("/workspace.tar")) {
await expect(fs.stat(path.join(path.dirname(file.sourcePath), "verify-seed"))).rejects.toMatchObject({ code: "ENOENT" });
}
}
for (const command of operation.postUploadCommands ?? []) await run(command.command);
}
return { operations: operations.map(operation => ({ operationId: operation.operationId, filesTransferred: 0, bytesTransferred: 0 })) };
};
const client = { makeDir: (dir: string) => fs.mkdir(dir, { recursive: true }).then(() => {}), run,
writeFile: (file: string, data: ArrayBuffer) => fs.writeFile(file, Buffer.from(data)), readFile: (file: string) => fs.readFile(file),
listFiles: (dir: string) => fs.readdir(dir), remove: (file: string) => fs.rm(file, { force: true, recursive: true }), syncIn: sync, syncOut: sync };
const seed = { workspaceArchivePath: path.join(temp, "run-workspace.tar"), gitArchivePath: path.join(temp, "run-git.tar") };
let verifications = 0;
const prepare = (overrides: Partial<Parameters<typeof prepareSandboxManagedRuntime>[0]> = {}) => prepareSandboxManagedRuntime({
spec: { provider: "test", sandboxId: "test", remoteCwd: remote, timeoutMs: 30000, apiKey: null, transport: "sandbox" },
adapterKey: "test", client, workspaceLocalDir: host, workspaceFileMode, workspaceSeedCacheDirectory: cache,
workspaceDurableSeed: seed, runtimeSpan: async (name, work) => { if (name === "seed.verify") verifications++; return work(); }, ...overrides,
});
const assertSanitized = async () => {
const checkout = path.join(remote, relative);
expect(await git(checkout, "remote", "get-url", "origin")).toBe("https://example.test/repository.git");
expect(await fs.readFile(path.join(checkout, ".git/config"), "utf8")).not.toContain("fixture-token");
await expect(fs.stat(path.join(checkout, ".git/hooks/post-commit"))).rejects.toMatchObject({ code: "ENOENT" });
await expect(git(checkout, "rev-parse", "--verify", "refs/heads/private-fixture")).rejects.toThrow();
expect(await git(checkout, "rev-parse", "HEAD")).toBe(head);
expect(await git(checkout, "show", "HEAD:work.txt")).toBe("committed");
expect(await fs.readFile(path.join(checkout, "work.txt"), "utf8")).toBe("dirty\n");
if (workspaceFileMode === "all") {
expect(await fs.readFile(path.join(checkout, "ignored.txt"), "utf8")).toBe("ordinary ignored bytes");
expect(await fs.readFile(path.join(checkout, "notes.txt"), "utf8")).toBe("keep after untracking\n");
await expect(fs.stat(path.join(checkout, "absent.txt"))).rejects.toMatchObject({ code: "ENOENT" });
}
};
const cold = await prepare(); await assertSanitized();
expect([...cold.workspaceSyncSnapshot!.baseline.entries].some(([key]) => key.startsWith(`${relative}/.git/`))).toBe(false);
expect(await fs.readdir(cache)).toHaveLength(1); expect(verifications).toBe(1);
await cold.cleanupWorkspaceSnapshot();
await fs.writeFile(path.join(repository, ".git/hooks/post-commit"), "changed private hook");
const cached = await prepare(); await assertSanitized(); expect(verifications).toBe(1);
const oldBaseline = await captureDirectorySnapshot(host, { diskBacked: true });
await createTarballFromDirectory({ localDir: host, archivePath: seed.workspaceArchivePath });
const recovered = await prepare({ workspaceInboundMode: "durable_seed", workspaceBaseline: oldBaseline,
workspaceGitSnapshot: null, workspaceRepositories: cached.workspaceSyncSnapshot!.repositories });
await assertSanitized();
expect([...recovered.workspaceSyncSnapshot!.baseline.entries].some(([key]) => key.startsWith(`${relative}/.git/`))).toBe(false);
const remoteRepository = path.join(remote, relative);
await fs.writeFile(path.join(remoteRepository, "work.txt"), "remote commit\n");
await git(remoteRepository, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-am", "remote commit");
const remoteHead = await git(remoteRepository, "rev-parse", "HEAD");
await fs.writeFile(path.join(remoteRepository, "work.txt"), "restored dirty bytes\n");
if (workspaceFileMode === "all") await fs.writeFile(path.join(remoteRepository, "ignored.txt"), "updated ordinary ignored bytes");
await recovered.restoreWorkspace();
expect(await git(repository, "rev-parse", "HEAD")).toBe(remoteHead);
expect(await fs.readFile(path.join(repository, "work.txt"), "utf8")).toBe("restored dirty bytes\n");
expect(await fs.readFile(path.join(repository, ".git/config"), "utf8")).toContain("fixture-token");
expect(await fs.readFile(path.join(repository, ".git/hooks/post-commit"), "utf8")).toBe("changed private hook");
if (workspaceFileMode === "all") {
expect(await fs.readFile(path.join(repository, "ignored.txt"), "utf8")).toBe("updated ordinary ignored bytes");
expect(await fs.readFile(path.join(repository, "notes.txt"), "utf8")).toBe("keep after untracking\n");
await expect(fs.stat(path.join(repository, "absent.txt"))).rejects.toMatchObject({ code: "ENOENT" });
}
await recovered.cleanupWorkspaceSnapshot(); await cached.cleanupWorkspaceSnapshot();
}, 30000);
+1 -1
View File
@@ -1088,7 +1088,7 @@ async function isLocalGitRepositoryRoot(localDir: string): Promise<boolean> {
}
}
async function listLocalProjectRepositories(localDir: string): Promise<string[]> {
export async function listLocalProjectRepositories(localDir: string): Promise<string[]> {
const root = path.join(localDir, PROJECT_REPOSITORIES_DIR);
const rootStat = await fs.lstat(root).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null;
@@ -0,0 +1,455 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { afterEach, describe, expect, it, vi } from "vitest";
import { captureDirectorySnapshot, disposeDirectorySnapshot, mergeDirectoryWithBaseline } from "./workspace-restore-merge.js";
import { writeCheckpointBaseline, workspaceCheckpointScript, readCheckpointSnapshot, validateCheckpointPayload } from "./workspace-checkpoint.js";
import { publishWorkspaceSeedGeneration, readWorkspaceSeedGeneration, workspaceSeedGeneration } from "./workspace-seed-cache.js";
import { WorkspaceManifestWriter } from "./workspace-manifest.js";
const execute = promisify(execFile);
const roots: string[] = [];
afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); });
async function fixture() {
const temp = await fs.mkdtemp(path.join(os.tmpdir(), "checkpoint-test-")); roots.push(temp); vi.stubEnv("PAPERCLIP_HOME", path.join(temp, "home"));
const root = path.join(temp, "root"); await fs.mkdir(root);
await fs.writeFile(path.join(root, "unchanged"), "retained");
await fs.writeFile(path.join(root, "deleted"), "delete me");
await fs.mkdir(path.join(root, "cache")); await fs.writeFile(path.join(root, "cache", "ignored"), "private");
const baseline = await captureDirectorySnapshot(root, { diskBacked: true, exclude: ["cache"] });
const manifest = path.join(temp, "baseline.sqlite"); await writeCheckpointBaseline(baseline, manifest);
const script = path.join(temp, "checkpoint.mjs"); await fs.writeFile(script, workspaceCheckpointScript());
const capture = async () => {
const output = path.join(temp, "output");
await execute(process.execPath, [script, root, manifest, output, JSON.stringify(baseline.exclude)]);
const snapshot = readCheckpointSnapshot(path.join(output, "manifest.sqlite"), baseline);
const payload = path.join(output, "payload");
const metrics = await validateCheckpointPayload({ baseline, snapshot, payload });
return { snapshot, payload, metrics, receipt: JSON.parse(await fs.readFile(path.join(output, "receipt.json"), "utf8")) };
};
return { temp, root, baseline, capture };
}
describe("workspace sparse checkpoint", () => {
it.each((["file", "symlink", "missing"] as const).flatMap(attack =>
(["a/kept", "a/nested/kept"] as const).flatMap(child =>
(["manifest", "memory"] as const).map(storage => ({ attack, child, storage })))))(
"rejects a returned $attack ancestor of $child ($storage) before any host mutation", async ({ attack, child, storage }) => {
const f = await fixture(); await disposeDirectorySnapshot(f.baseline);
await fs.mkdir(path.dirname(path.join(f.root, child)), { recursive: true });
await fs.writeFile(path.join(f.root, child), "retained child");
const baseline = await captureDirectorySnapshot(f.root, { diskBacked: true, exclude: ["cache"] });
const payload = path.join(f.temp, "forged-payload"); await fs.mkdir(payload);
await fs.writeFile(path.join(payload, "unchanged"), "otherwise valid edit");
if (attack === "file") await fs.writeFile(path.join(payload, "a"), "replace directory");
if (attack === "symlink") await fs.symlink("unchanged", path.join(payload, "a"));
const captured = await captureDirectorySnapshot(payload);
const entries = new Map(baseline.entries);
entries.delete("deleted"); entries.delete("a");
for (const [relative, entry] of captured.entries) entries.set(relative, entry);
const manifest = path.join(f.temp, "forged.sqlite");
await writeCheckpointBaseline({ ...baseline, entries }, manifest);
const evidence = await fs.readFile(manifest);
const snapshot = storage === "manifest" ? readCheckpointSnapshot(manifest, baseline)
: { ...baseline, entries: new Map([...entries].reverse()) };
let failure: unknown;
try {
await validateCheckpointPayload({ baseline, snapshot, payload });
await mergeDirectoryWithBaseline({ baseline, sourceDir: payload, targetDir: f.root, snapshots: { source: snapshot } });
} catch (error) { failure = error; }
expect(await fs.readFile(path.join(f.root, child), "utf8")).toBe("retained child");
expect(await fs.readFile(path.join(f.root, "unchanged"), "utf8")).toBe("retained");
expect(await fs.readFile(path.join(f.root, "deleted"), "utf8")).toBe("delete me");
expect(failure).toBeInstanceOf(Error);
expect(String(failure)).toContain("Invalid workspace checkpoint parent");
expect(await fs.readFile(manifest)).toEqual(evidence);
const replay = readCheckpointSnapshot(manifest, baseline);
await expect(validateCheckpointPayload({ baseline, snapshot: replay, payload })).rejects.toThrow("Invalid workspace checkpoint parent");
await disposeDirectorySnapshot(replay); await disposeDirectorySnapshot(snapshot); await disposeDirectorySnapshot(baseline);
});
it.each(["file_to_directory", "directory_to_file"] as const)("restores a coherent %s checkpoint", async transition => {
const f = await fixture(); await disposeDirectorySnapshot(f.baseline);
if (transition === "file_to_directory") await fs.writeFile(path.join(f.root, "a"), "old file");
else {
await fs.mkdir(path.join(f.root, "a")); await fs.writeFile(path.join(f.root, "a", "old"), "old child");
}
const baseline = await captureDirectorySnapshot(f.root, { diskBacked: true, exclude: ["cache"] });
const host = path.join(f.temp, "host"); await fs.cp(f.root, host, { recursive: true });
await fs.rm(path.join(f.root, "a"), { recursive: true });
if (transition === "file_to_directory") {
await fs.mkdir(path.join(f.root, "a", "nested"), { recursive: true });
await fs.writeFile(path.join(f.root, "a", "nested", "new"), "new child");
} else await fs.writeFile(path.join(f.root, "a"), "new file");
const manifest = path.join(f.temp, "transition-baseline.sqlite"); await writeCheckpointBaseline(baseline, manifest);
const output = path.join(f.temp, "transition-output");
await execute(process.execPath, [path.join(f.temp, "checkpoint.mjs"), f.root, manifest, output, JSON.stringify(baseline.exclude)]);
const snapshot = readCheckpointSnapshot(path.join(output, "manifest.sqlite"), baseline), payload = path.join(output, "payload");
await validateCheckpointPayload({ baseline, snapshot, payload });
await mergeDirectoryWithBaseline({ baseline, sourceDir: payload, targetDir: host, snapshots: { source: snapshot } });
expect(await fs.readFile(path.join(host, transition === "file_to_directory" ? "a/nested/new" : "a"), "utf8"))
.toBe(transition === "file_to_directory" ? "new child" : "new file");
expect(await fs.readFile(path.join(host, "unchanged"), "utf8")).toBe("retained");
await disposeDirectorySnapshot(snapshot); await disposeDirectorySnapshot(baseline);
});
it("enumerates unchanged files but transfers zero workspace payload", async () => {
const f = await fixture(); const result = await f.capture();
expect(result.receipt).toMatchObject({ files: 0, bytes: 0, scanned: 2 });
expect(result.metrics).toEqual({ mode: "sparse", scannedEntries: 2, changedFiles: 0, payloadBytes: 0 });
expect(await fs.readdir(result.payload)).toEqual([]);
expect([...result.snapshot.entries]).toEqual([...f.baseline.entries]);
await disposeDirectorySnapshot(result.snapshot); await disposeDirectorySnapshot(f.baseline);
});
it("merges one changed file, deletion, unusual names and a safe link without copying unchanged files", async () => {
const f = await fixture(); const host = path.join(f.temp, "host"); await fs.cp(f.root, host, { recursive: true });
const name = "new\nfile"; await fs.writeFile(path.join(f.root, name), "changed");
await fs.unlink(path.join(f.root, "deleted")); await fs.symlink(name, path.join(f.root, "link"));
const result = await f.capture(); expect(result.receipt.files).toBe(1); expect(result.receipt.bytes).toBe(7);
await mergeDirectoryWithBaseline({ baseline: f.baseline, sourceDir: result.payload, targetDir: host, snapshots: { source: result.snapshot } });
expect(await fs.readFile(path.join(host, name), "utf8")).toBe("changed");
expect(await fs.readFile(path.join(host, "unchanged"), "utf8")).toBe("retained");
expect(await fs.readlink(path.join(host, "link"))).toBe(name);
await expect(fs.stat(path.join(host, "deleted"))).rejects.toMatchObject({ code: "ENOENT" });
expect(await fs.readFile(path.join(host, "cache", "ignored"), "utf8")).toBe("private");
await disposeDirectorySnapshot(f.baseline);
});
it.each(["absolute", "escaping"])("omits an %s link while restoring ordinary changes and safe links", async (kind) => {
const f = await fixture();
const host = path.join(f.temp, "host"); await fs.cp(f.root, host, { recursive: true });
const outside = path.join(f.temp, "secret"); await fs.writeFile(outside, "outside bytes");
await fs.symlink(kind === "absolute" ? outside : "../secret", path.join(f.root, "unsafe"));
await fs.writeFile(path.join(f.root, "unchanged"), "edited");
await fs.writeFile(path.join(f.root, "new"), "new output");
await fs.unlink(path.join(f.root, "deleted"));
await fs.symlink("new", path.join(f.root, "safe"));
const result = await f.capture();
expect(result.snapshot.entries.has("unsafe")).toBe(false);
await expect(fs.lstat(path.join(result.payload, "unsafe"))).rejects.toMatchObject({ code: "ENOENT" });
await mergeDirectoryWithBaseline({ baseline: f.baseline, sourceDir: result.payload, targetDir: host, snapshots: { source: result.snapshot } });
expect(await fs.readFile(path.join(host, "unchanged"), "utf8")).toBe("edited");
expect(await fs.readFile(path.join(host, "new"), "utf8")).toBe("new output");
expect(await fs.readlink(path.join(host, "safe"))).toBe("new");
await expect(fs.lstat(path.join(host, "unsafe"))).rejects.toMatchObject({ code: "ENOENT" });
await expect(fs.lstat(path.join(host, "deleted"))).rejects.toMatchObject({ code: "ENOENT" });
expect(await fs.readFile(outside, "utf8")).toBe("outside bytes");
await disposeDirectorySnapshot(result.snapshot); await disposeDirectorySnapshot(f.baseline);
});
it("rejects an unsafe link forged into a checkpoint manifest before merge", async () => {
const f = await fixture(); const result = await f.capture();
const entries = new Map(result.snapshot.entries);
entries.set("unsafe", { kind: "symlink", target: "../secret" });
await expect(validateCheckpointPayload({ baseline: f.baseline, snapshot: { ...result.snapshot, entries }, payload: result.payload }))
.rejects.toMatchObject({ code: "WORKSPACE_RESTORE_UNSAFE_ARCHIVE" });
await disposeDirectorySnapshot(result.snapshot); await disposeDirectorySnapshot(f.baseline);
});
it("replays a checkpoint after interruption and preserves a concurrently edited deletion", async () => {
const f = await fixture(); const host = path.join(f.temp, "host"); await fs.cp(f.root, host, { recursive: true });
await fs.writeFile(path.join(f.root, "new"), "remote"); await fs.unlink(path.join(f.root, "deleted"));
await fs.writeFile(path.join(host, "deleted"), "host edit");
const result = await f.capture();
await mergeDirectoryWithBaseline({ baseline: f.baseline, sourceDir: result.payload, targetDir: host, snapshots: { source: result.snapshot } });
const replay = readCheckpointSnapshot(path.join(f.temp, "output", "manifest.sqlite"), f.baseline);
await validateCheckpointPayload({ baseline: f.baseline, snapshot: replay, payload: result.payload });
await mergeDirectoryWithBaseline({ baseline: f.baseline, sourceDir: result.payload, targetDir: host, snapshots: { source: replay } });
expect(await fs.readFile(path.join(host, "deleted"), "utf8")).toBe("host edit");
expect(await fs.readFile(path.join(host, "new"), "utf8")).toBe("remote");
await disposeDirectorySnapshot(f.baseline);
});
it("rejects bytes changed after capture before applying the manifest", async () => {
const f = await fixture(); await fs.writeFile(path.join(f.root, "new"), "remote");
const result = await f.capture(); await fs.writeFile(path.join(result.payload, "new"), "tampered");
await expect(validateCheckpointPayload({ baseline: f.baseline, snapshot: result.snapshot, payload: result.payload })).rejects.toThrow("payload mismatch");
await disposeDirectorySnapshot(result.snapshot); await disposeDirectorySnapshot(f.baseline);
});
it.each(["array", "manifest"] as const)("rejects ignored entries and ancestor replacements before merge with %s ignore paths", async storage => {
for (const attack of ["matching_file", "matching_descendant", "payload_only", "ancestor_file", "ancestor_link"] as const) {
const f = await fixture(); await disposeDirectorySnapshot(f.baseline);
await fs.writeFile(path.join(f.root, ".env"), "host secret");
await fs.mkdir(path.join(f.root, "private")); await fs.writeFile(path.join(f.root, "private", "token"), "private token");
await fs.mkdir(path.join(f.root, "config")); await fs.writeFile(path.join(f.root, "config", ".env"), "nested secret");
const paths = [".env", "private", "config/.env"];
const writer = new WorkspaceManifestWriter(path.join(f.temp, "ignored.sqlite"));
for (const relative of paths) writer.add("ignored", relative);
const ignoredPaths = storage === "manifest" ? writer.paths("ignored") : paths;
writer.close();
const baseline = await captureDirectorySnapshot(f.root, { exclude: ["cache"], ignoredPaths, diskBacked: true });
const payload = path.join(f.temp, "forged-payload"); await fs.mkdir(payload);
await fs.writeFile(path.join(payload, "unchanged"), "otherwise valid edit");
if (attack === "matching_file" || attack === "payload_only") await fs.writeFile(path.join(payload, ".env"), "injected secret");
if (attack === "matching_descendant") {
await fs.mkdir(path.join(payload, "private")); await fs.writeFile(path.join(payload, "private", "token"), "injected token");
}
if (attack === "ancestor_file") await fs.writeFile(path.join(payload, "config"), "replace directory");
if (attack === "ancestor_link") await fs.symlink("unchanged", path.join(payload, "config"));
const captured = await captureDirectorySnapshot(payload);
const entries = new Map(baseline.entries);
entries.delete("deleted");
for (const [relative, entry] of captured.entries) if (!(attack === "payload_only" && relative === ".env")) entries.set(relative, entry);
if (attack === "matching_descendant") entries.delete("private");
const manifestPath = path.join(f.temp, "forged.sqlite");
await writeCheckpointBaseline({ ...baseline, entries }, manifestPath);
const snapshot = readCheckpointSnapshot(manifestPath, baseline);
try {
await expect((async () => {
await validateCheckpointPayload({ baseline, snapshot, payload });
await mergeDirectoryWithBaseline({ baseline, sourceDir: payload, targetDir: f.root, snapshots: { source: snapshot } });
})()).rejects.toThrow(attack.startsWith("ancestor") ? "ignored path ancestor" : "Excluded workspace checkpoint entry");
expect(await fs.readFile(path.join(f.root, "unchanged"), "utf8")).toBe("retained");
expect(await fs.readFile(path.join(f.root, "deleted"), "utf8")).toBe("delete me");
expect(await fs.readFile(path.join(f.root, ".env"), "utf8")).toBe("host secret");
expect(await fs.readFile(path.join(f.root, "private", "token"), "utf8")).toBe("private token");
expect(await fs.readFile(path.join(f.root, "config", ".env"), "utf8")).toBe("nested secret");
} finally { await disposeDirectorySnapshot(snapshot); await disposeDirectorySnapshot(baseline); }
}
});
it("allows directory tombstones while retaining ignored host children", async () => {
const f = await fixture(); await disposeDirectorySnapshot(f.baseline);
await fs.mkdir(path.join(f.root, "config"));
await fs.writeFile(path.join(f.root, "config", ".env"), "host only");
await fs.writeFile(path.join(f.root, "config", "tracked"), "delete tracked child");
const baseline = await captureDirectorySnapshot(f.root, { exclude: ["cache"], ignoredPaths: ["config/.env"], diskBacked: true });
const payload = path.join(f.temp, "payload"); await fs.mkdir(payload);
await fs.writeFile(path.join(payload, "unchanged"), "valid edit");
const captured = await captureDirectorySnapshot(payload);
const snapshot = { ...baseline, entries: new Map(captured.entries) };
await validateCheckpointPayload({ baseline, snapshot, payload });
await mergeDirectoryWithBaseline({ baseline, sourceDir: payload, targetDir: f.root, snapshots: { source: snapshot } });
expect(await fs.readFile(path.join(f.root, "config", ".env"), "utf8")).toBe("host only");
expect(await fs.readFile(path.join(f.root, "unchanged"), "utf8")).toBe("valid edit");
await expect(fs.stat(path.join(f.root, "config", "tracked"))).rejects.toMatchObject({ code: "ENOENT" });
await expect(fs.stat(path.join(f.root, "deleted"))).rejects.toMatchObject({ code: "ENOENT" });
await disposeDirectorySnapshot(baseline);
});
it("reuses durable seed generations and rejects corruption after run cleanup", async () => {
const f = await fixture(); const cache = path.join(f.temp, "cache-seeds");
const archive = path.join(f.temp, "seed.tar"); await fs.writeFile(archive, "archive");
const generation = workspaceSeedGeneration(f.baseline, null);
await publishWorkspaceSeedGeneration(cache, generation, { workspaceArchivePath: archive });
await fs.unlink(archive);
const cached = await readWorkspaceSeedGeneration(cache, generation); expect(cached).not.toBeNull();
expect(await fs.readFile(cached!.workspaceArchivePath, "utf8")).toBe("archive");
await fs.writeFile(cached!.workspaceArchivePath, "corrupted");
expect(await readWorkspaceSeedGeneration(cache, generation)).toBeNull();
await disposeDirectorySnapshot(f.baseline);
});
});
it("uses sparse native transfer end-to-end, including replay and unsupported-runtime fallback", async () => {
const { prepareSandboxManagedRuntime } = await import("./sandbox-managed-runtime.js");
const f = await fixture(); await disposeDirectorySnapshot(f.baseline);
const remote = path.join(f.temp, "remote");
const cache = path.join(f.temp, "seeds");
let supportsCheckpoint = true;
let payloadBytes = -1;
let mutateDuringPack = false;
let failVerification = false, verificationCalls = 0;
const run = async (command: string) => {
if (!supportsCheckpoint && command.startsWith("node --input-type=module")) throw new Error("node unavailable");
await execute("sh", ["-c", command]);
};
const sync = async (operations: import("./sandbox-managed-runtime.js").SandboxSyncOperation[]) => {
for (const operation of operations) {
for (const file of operation.files) {
await fs.mkdir(path.dirname(file.targetPath), { recursive: true });
await fs.cp(file.sourcePath, file.targetPath, { recursive: true, verbatimSymlinks: true,
filter: (source) => !file.exclude?.some((exclude) => path.relative(file.sourcePath, source) === exclude) });
if (file.sourcePath.endsWith("/output")) {
payloadBytes = JSON.parse(await fs.readFile(path.join(file.targetPath, "receipt.json"), "utf8")).bytes;
}
}
for (const command of operation.postUploadCommands ?? []) await run(command.command);
}
return { operations: operations.map((op) => ({ operationId: op.operationId, filesTransferred: 0, bytesTransferred: 0 })) };
};
const client = { makeDir: (dir: string) => fs.mkdir(dir, { recursive: true }).then(() => {}),
writeFile: (file: string, data: ArrayBuffer) => fs.writeFile(file, Buffer.from(data)), readFile: (file: string) => fs.readFile(file),
listFiles: (dir: string) => fs.readdir(dir), remove: (file: string) => fs.rm(file, { force: true, recursive: true }), run,
syncIn: sync, syncOut: sync };
const prepare = (overrides: Partial<Parameters<typeof prepareSandboxManagedRuntime>[0]> = {}) => prepareSandboxManagedRuntime({ spec: { provider: "test", sandboxId: "test", remoteCwd: remote, timeoutMs: 30000, apiKey: null, transport: "sandbox" },
adapterKey: "test", client, workspaceLocalDir: f.root, workspaceCheckpoint: true,
runtimeSpan: async (name, work) => {
if (mutateDuringPack && name === "pack") await fs.writeFile(path.join(f.root, "unchanged"), "concurrent change");
if (name === "seed.verify") {
verificationCalls++;
const result = await work();
if (failVerification) throw Object.assign(new Error("verification disk full"), { code: "ENOSPC" });
return result;
}
return work();
},
workspaceSeedCacheDirectory: cache, workspaceDurableSeed: { workspaceArchivePath: path.join(f.temp, "run-seed.tar") }, ...overrides });
const first = await prepare(); await first.restoreWorkspace(); expect(payloadBytes).toBe(0);
const generation = (await fs.readdir(cache))[0]!;
await fs.writeFile(path.join(cache, generation, "workspace.tar"), "corrupt cache");
const second = await prepare();
await fs.writeFile(path.join(remote, "new"), "changed"); await fs.unlink(path.join(remote, "deleted"));
const outside = path.join(f.temp, "outside"); await fs.writeFile(outside, "private");
await fs.symlink(outside, path.join(remote, "absolute-link"));
await fs.symlink("../outside", path.join(remote, "escaping-link"));
await fs.symlink("new", path.join(remote, "safe-link"));
await second.restoreWorkspace(); expect(payloadBytes).toBe(7);
expect(await fs.readFile(path.join(f.root, "new"), "utf8")).toBe("changed");
expect(await fs.readlink(path.join(f.root, "safe-link"))).toBe("new");
for (const name of ["absolute-link", "escaping-link"]) {
await expect(fs.lstat(path.join(f.root, name))).rejects.toMatchObject({ code: "ENOENT" });
}
expect(await fs.readFile(outside, "utf8")).toBe("private");
await expect(fs.stat(path.join(f.root, "deleted"))).rejects.toMatchObject({ code: "ENOENT" });
supportsCheckpoint = false;
const third = await prepare(); await fs.writeFile(path.join(remote, "new"), "fallback");
await third.restoreWorkspace(); expect(await fs.readFile(path.join(f.root, "new"), "utf8")).toBe("fallback");
const existingGenerations = await fs.readdir(cache);
mutateDuringPack = true;
const raced = await prepare();
expect(await fs.readdir(cache)).toEqual(existingGenerations);
expect((await fs.stat(path.join(f.temp, "run-seed.tar"))).size).toBeGreaterThan(0);
await raced.cleanupWorkspaceSnapshot();
mutateDuringPack = false; failVerification = true;
await fs.writeFile(path.join(f.root, "new"), "verification recovery");
const verificationFailed = await prepare();
expect(await fs.readdir(cache)).toEqual(existingGenerations);
const failedSnapshot = verificationFailed.workspaceSyncSnapshot!;
await fs.writeFile(path.join(f.root, "new"), "later host bytes");
await fs.rm(remote, { recursive: true, force: true });
const verificationRecovery = await prepare({ workspaceInboundMode: "durable_seed", workspaceBaseline: failedSnapshot.baseline,
workspaceGitSnapshot: failedSnapshot.gitSnapshot, workspaceRepositories: failedSnapshot.repositories });
expect(await fs.readFile(path.join(remote, "new"), "utf8")).toBe("verification recovery");
await verificationRecovery.cleanupWorkspaceSnapshot();
failVerification = false;
mutateDuringPack = false;
for (let count = (await fs.readdir(cache)).length; count < 16; count++) {
await fs.mkdir(path.join(cache, `.pending-retained-${count}`));
}
const saturated = await fs.readdir(cache);
await fs.writeFile(path.join(f.root, "new"), "quota recovery");
const verificationCallsBeforeSaturation = verificationCalls;
const uncached = await prepare();
expect(verificationCalls).toBe(verificationCallsBeforeSaturation);
expect(await fs.readdir(cache)).toEqual(saturated);
const snapshot = uncached.workspaceSyncSnapshot!;
await fs.writeFile(path.join(f.root, "new"), "later host edit");
await fs.rm(remote, { recursive: true, force: true });
const recovered = await prepare({ workspaceInboundMode: "durable_seed", workspaceBaseline: snapshot.baseline,
workspaceGitSnapshot: snapshot.gitSnapshot, workspaceRepositories: snapshot.repositories });
expect(await fs.readFile(path.join(remote, "new"), "utf8")).toBe("quota recovery");
expect(await fs.readFile(path.join(f.root, "new"), "utf8")).toBe("later host edit");
expect(await fs.readdir(cache)).toEqual(saturated);
await recovered.cleanupWorkspaceSnapshot();
}, 30000);
async function exportFailureFixture(failure: "low_disk" | "full_root" | "full_special" | "digest" | "path" | "symlink") {
const { prepareSandboxManagedRuntime } = await import("./sandbox-managed-runtime.js");
const { DatabaseSync } = await import("node:sqlite");
const f = await fixture();
await disposeDirectorySnapshot(f.baseline);
const remote = path.join(f.temp, "remote"), outside = path.join(f.temp, "outside");
await fs.writeFile(outside, "private");
const metrics: unknown[] = [];
const counts = { captures: 0, fullExports: 0, capacityFailures: 0 };
const run = async (command: string) => {
const capture = command.includes("checkpoint.mjs");
if (capture) counts.captures++;
try { await execute("sh", ["-c", command]); }
catch (error) {
if (capture && ["low_disk", "full_root", "full_special"].includes(failure)) {
expect(String(error)).toContain("Workspace checkpoint storage is below its free-space reserve");
counts.capacityFailures++;
await expect(fs.stat(path.join(f.root, "new"))).rejects.toMatchObject({ code: "ENOENT" });
}
throw error;
}
};
const sync = async (operations: import("./sandbox-managed-runtime.js").SandboxSyncOperation[]) => {
for (const operation of operations) {
for (const file of operation.files) {
if (file.sourcePath === remote) {
counts.fullExports++;
if (failure === "full_root") {
await fs.rm(file.targetPath, { recursive: true, force: true });
await fs.symlink(remote, file.targetPath);
continue;
}
}
await fs.mkdir(path.dirname(file.targetPath), { recursive: true });
await fs.cp(file.sourcePath, file.targetPath, { recursive: true, verbatimSymlinks: true,
filter: source => !file.exclude?.some(exclude => path.relative(file.sourcePath, source) === exclude) });
if (file.sourcePath === remote && failure === "full_special") await execute("mkfifo", [path.join(file.targetPath, "unsafe-pipe")]);
if (["low_disk", "full_root", "full_special"].includes(failure) && file.targetPath.endsWith("/checkpoint.mjs")) {
const script = await fs.readFile(file.targetPath, "utf8");
await fs.writeFile(file.targetPath, script.replace("const __name", "fs.statfs = async () => ({ bavail: 128n * 1024n, bsize: 1024n });\nconst __name"));
}
if (file.sourcePath.endsWith("/output")) {
if (failure === "digest") await fs.writeFile(path.join(file.targetPath, "payload", "new"), "forged");
else if (failure === "path" || failure === "symlink") {
const manifest = new DatabaseSync(path.join(file.targetPath, "manifest.sqlite"));
try {
manifest.prepare("INSERT INTO records VALUES('baseline',?,?)").run(
failure === "path" ? "../escape" : "escape",
JSON.stringify({ kind: "symlink", target: outside }),
);
} finally { manifest.close(); }
}
}
}
for (const command of operation.postUploadCommands ?? []) await run(command.command);
}
return { operations: operations.map(op => ({ operationId: op.operationId, filesTransferred: 0, bytesTransferred: 0 })) };
};
const client = { makeDir: (dir: string) => fs.mkdir(dir, { recursive: true }).then(() => {}),
writeFile: (file: string, data: ArrayBuffer) => fs.writeFile(file, Buffer.from(data)), readFile: (file: string) => fs.readFile(file),
listFiles: (dir: string) => fs.readdir(dir), remove: (file: string) => fs.rm(file, { force: true, recursive: true }), run,
syncIn: sync, syncOut: sync };
const prepare = (overrides: Partial<Parameters<typeof prepareSandboxManagedRuntime>[0]> = {}) => prepareSandboxManagedRuntime({
spec: { provider: "test", sandboxId: "test", remoteCwd: remote, timeoutMs: 30000, apiKey: null, transport: "sandbox" },
adapterKey: "test", client, workspaceLocalDir: f.root, workspaceCheckpoint: true,
onWorkspaceCheckpoint: async metric => { metrics.push(metric); }, ...overrides,
});
const prepared = await prepare();
await fs.writeFile(path.join(remote, "new"), "tiny export");
await fs.unlink(path.join(remote, "deleted"));
return { ...f, remote, outside, prepared, prepare, metrics, counts };
}
it.each([false, true])("falls back once to a full tiny export below the sparse disk reserve (recovery=%s)", async recovery => {
const f = await exportFailureFixture("low_disk");
const snapshot = f.prepared.workspaceSyncSnapshot!;
const runtime = recovery ? await f.prepare({ workspaceInboundMode: "adopt_remote", workspaceBaseline: snapshot.baseline,
workspaceGitSnapshot: snapshot.gitSnapshot, workspaceRepositories: snapshot.repositories }) : f.prepared;
await fs.writeFile(path.join(f.root, "host-only"), "concurrent host edit");
await fs.symlink(f.outside, path.join(f.remote, "unsafe-link"));
await fs.symlink("../outside", path.join(f.remote, "escaping-link"));
await fs.symlink("new", path.join(f.remote, "safe-link"));
await runtime.restoreWorkspace();
expect(f.counts).toEqual({ captures: 1, fullExports: 1, capacityFailures: 1 });
expect(f.metrics).toEqual([{ mode: "full", scannedEntries: null, changedFiles: null, payloadBytes: null }]);
expect(await fs.readFile(path.join(f.root, "new"), "utf8")).toBe("tiny export");
expect(await fs.readFile(path.join(f.root, "host-only"), "utf8")).toBe("concurrent host edit");
await expect(fs.stat(path.join(f.root, "deleted"))).rejects.toMatchObject({ code: "ENOENT" });
expect(await fs.readFile(f.outside, "utf8")).toBe("private");
for (const name of ["unsafe-link", "escaping-link"]) {
await expect(fs.lstat(path.join(f.root, name))).rejects.toMatchObject({ code: "ENOENT" });
}
expect(await fs.readlink(path.join(f.root, "safe-link"))).toBe("new");
await runtime.cleanupWorkspaceSnapshot();
if (recovery) await f.prepared.cleanupWorkspaceSnapshot();
}, 30000);
it.each(["digest", "path", "symlink"] as const)("never falls back after returned sparse %s validation fails", async attack => {
const f = await exportFailureFixture(attack);
await expect(f.prepared.restoreWorkspace()).rejects.toThrow();
expect(f.counts.fullExports).toBe(0);
expect(f.metrics).toEqual([]);
await expect(fs.stat(path.join(f.root, "new"))).rejects.toMatchObject({ code: "ENOENT" });
expect(await fs.readFile(path.join(f.root, "deleted"), "utf8")).toBe("delete me");
expect(await fs.readFile(f.outside, "utf8")).toBe("private");
await f.prepared.cleanupWorkspaceSnapshot();
}, 30000);
it.each(["full_root", "full_special"] as const)("rejects unsafe %s data before host writes after capacity fallback", async attack => {
const f = await exportFailureFixture(attack);
await expect(f.prepared.restoreWorkspace()).rejects.toThrow("Unsafe workspace export directory");
expect(f.counts).toEqual({ captures: 1, fullExports: 1, capacityFailures: 1 });
await expect(fs.stat(path.join(f.root, "new"))).rejects.toMatchObject({ code: "ENOENT" });
expect(await fs.readFile(path.join(f.root, "deleted"), "utf8")).toBe("delete me");
expect(await fs.readFile(f.outside, "utf8")).toBe("private");
await f.prepared.cleanupWorkspaceSnapshot();
}, 30000);
@@ -0,0 +1,163 @@
import fs from "node:fs/promises";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { WorkspaceManifestWriter, WorkspaceManifestMap, workspacePaths, workspacePathMatcher } from "./workspace-manifest.js";
import { shouldExcludePath, excludePatternMatches, isRelativePathOrDescendant } from "./exclude-patterns.js";
import { openDirectorySnapshot, type DirectorySnapshot } from "./workspace-restore-merge.js";
export async function writeCheckpointBaseline(snapshot: DirectorySnapshot, filePath: string): Promise<void> {
const writer = new WorkspaceManifestWriter(filePath);
try {
for (const [relative, entry] of snapshot.entries) writer.add("baseline", relative, JSON.stringify(entry));
if (snapshot.ignoredPaths) for (const relative of workspacePaths(snapshot.ignoredPaths)) writer.add("ignored", relative);
writer.close();
} catch (error) { writer.close(false); throw error; }
}
async function checkpointMain(
fs: typeof import("node:fs/promises"), path: typeof import("node:path"),
DatabaseSync: typeof import("node:sqlite").DatabaseSync,
createHash: typeof import("node:crypto").createHash,
createReadStream: typeof import("node:fs").createReadStream,
shouldExcludePath: (relative: string, exclude: readonly string[]) => boolean,
) {
const [rootArg, baselinePath, outputDir, exclusions] = process.argv.slice(2);
const root = await fs.realpath(rootArg!);
const exclude: string[] = JSON.parse(exclusions!);
await fs.mkdir(outputDir!, { recursive: true });
const payload = path.join(outputDir!, "payload");
await fs.mkdir(payload);
const baseline = new DatabaseSync(baselinePath!, { readOnly: true, allowExtension: false });
baseline.exec("PRAGMA trusted_schema=OFF; PRAGMA cache_size=-1024; PRAGMA temp_store=FILE; PRAGMA mmap_size=0;");
const lookup = baseline.prepare("SELECT value FROM records WHERE category='baseline' AND path=?");
const ignored = baseline.prepare("SELECT 1 FROM records WHERE category='ignored' AND path=?");
const result = new DatabaseSync(path.join(outputDir!, "manifest.sqlite"));
const space = await fs.statfs(outputDir!, { bigint: true });
const available = space.bavail * space.bsize - 256n * 1024n * 1024n;
if (available <= 0n) throw new Error("Workspace checkpoint storage is below its free-space reserve");
result.exec(`PRAGMA max_page_count=${Math.min(Number(available / 4n / 4096n), 4_294_967_294)}`);
result.exec("PRAGMA journal_mode=DELETE; PRAGMA cache_size=-1024; PRAGMA temp_store=FILE; CREATE TABLE records(category TEXT,path TEXT,value TEXT NOT NULL, PRIMARY KEY(category,path)) WITHOUT ROWID; BEGIN;");
const insert = result.prepare("INSERT INTO records VALUES('baseline',?,?)");
let files = 0, bytes = 0, scanned = 0;
const fingerprint = (s: Awaited<ReturnType<typeof fs.lstat>>) => [s.dev, s.ino, s.mode, s.size, s.mtimeMs, s.ctimeMs].join(":");
const hash = async (file: string) => {
const digest = createHash("sha256");
for await (const chunk of createReadStream(file)) digest.update(chunk);
return digest.digest("hex");
};
const walk = async (relative = "") => {
const directory = path.join(root, relative);
if (await fs.realpath(directory) !== directory) throw new Error("Workspace directory changed during checkpoint capture");
for await (const child of await fs.opendir(directory)) {
const name = relative ? `${relative}/${child.name}` : child.name;
if (Buffer.byteLength(name) > 65536) throw new Error("Invalid workspace manifest path");
let ignoredPath = false;
for (let p = name; p; p = p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "") {
if (ignored.get(p)) { ignoredPath = true; break; }
}
if (ignoredPath || shouldExcludePath(name, exclude)) continue;
const source = path.join(root, name);
const before = await fs.lstat(source);
scanned++;
let entry: { kind: string; hash?: string; mode?: number; target?: string };
if (before.isDirectory()) entry = { kind: "dir" };
else if (before.isFile()) entry = { kind: "file", mode: before.mode, hash: await hash(source) };
else if (before.isSymbolicLink()) {
const target = await fs.readlink(source);
const resolved = path.resolve(path.dirname(source), target);
if (path.isAbsolute(target) || (resolved !== root && !resolved.startsWith(`${root}/`))) continue;
entry = { kind: "symlink", target };
} else continue;
if (fingerprint(before) !== fingerprint(await fs.lstat(source))) throw new Error("Workspace changed during checkpoint capture");
const encoded = JSON.stringify(entry);
insert.run(name, encoded);
if (lookup.get(name)?.value !== encoded) {
const destination = path.join(payload, name);
await fs.mkdir(path.dirname(destination), { recursive: true });
if (entry.kind === "dir") await fs.mkdir(destination, { recursive: true });
else if (entry.kind === "symlink") await fs.symlink(entry.target!, destination);
else {
await fs.copyFile(source, destination);
await fs.chmod(destination, before.mode);
if (await hash(destination) !== entry.hash || fingerprint(before) !== fingerprint(await fs.lstat(source))) throw new Error("Workspace changed during checkpoint copy");
files++; bytes += before.size;
}
}
if (before.isDirectory()) await walk(name);
}
};
try {
await walk();
result.exec("COMMIT");
await fs.writeFile(path.join(outputDir!, "receipt.json"), JSON.stringify({ version: 1, files, bytes, scanned }));
} finally { baseline.close(); result.close(); }
}
export function workspaceCheckpointScript(): string {
return `import fs from 'node:fs/promises';\nimport path from 'node:path';\nimport { DatabaseSync } from 'node:sqlite';\nimport { createHash } from 'node:crypto';\nimport { createReadStream } from 'node:fs';\nconst __name = (fn) => fn;\n${isRelativePathOrDescendant.toString()}\nfunction pathContainsSegmentOrDescendant(relative, segment) { return relative === segment || relative.startsWith(segment + '/') || relative.endsWith('/' + segment) || relative.includes('/' + segment + '/'); }\n${excludePatternMatches.toString()}\n${shouldExcludePath.toString()}\nawait (${checkpointMain.toString()})(fs, path, DatabaseSync, createHash, createReadStream, shouldExcludePath);\n`;
}
export function readCheckpointSnapshot(filePath: string, baseline: DirectorySnapshot): DirectorySnapshot {
const db = new DatabaseSync(filePath, { readOnly: true, allowExtension: false });
let count: number;
try {
db.exec("PRAGMA trusted_schema=OFF; PRAGMA cache_size=-1024; PRAGMA temp_store=FILE; PRAGMA mmap_size=0;");
count = Number(db.prepare("SELECT count(*) AS count FROM records WHERE category='baseline'").get()!.count);
} finally { db.close(); }
const snapshot = openDirectorySnapshot({ version: 2, exclude: baseline.exclude, ignoredPaths: baseline.ignoredPaths,
entries: { kind: "path_manifest", version: 1, filePath, category: "baseline", count } });
try { for (const _entry of snapshot.entries) { } }
catch (error) { (snapshot.entries as WorkspaceManifestMap<unknown>).close(); throw error; }
return snapshot;
}
export async function validateCheckpointPayload(input: { baseline: DirectorySnapshot; snapshot: DirectorySnapshot; payload: string }): Promise<WorkspaceCheckpointMetrics> {
const { captureDirectorySnapshot, disposeDirectorySnapshot } = await import("./workspace-restore-merge.js");
const ignored = workspacePathMatcher(input.baseline.ignoredPaths);
let actual: DirectorySnapshot | null = null;
const equal = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right);
const assertIncluded = (relative: string) => {
if (shouldExcludePath(relative, input.baseline.exclude) || ignored.matches(relative)) {
throw new Error("Excluded workspace checkpoint entry");
}
};
let changedFiles = 0, payloadBytes = 0;
try {
actual = await captureDirectorySnapshot(input.payload, { diskBacked: true });
for (const [relative, entry] of input.snapshot.entries) {
assertIncluded(relative);
const separator = relative.lastIndexOf("/");
if (separator >= 0 && input.snapshot.entries.get(relative.slice(0, separator))?.kind !== "dir") {
throw new Error("Invalid workspace checkpoint parent");
}
if (entry.kind === "symlink") {
const target = path.resolve(input.payload, path.dirname(relative), entry.target);
if (path.isAbsolute(entry.target) || (target !== path.resolve(input.payload) && !target.startsWith(`${path.resolve(input.payload)}/`))) {
throw Object.assign(new Error("Unsafe workspace checkpoint symlink"), { code: "WORKSPACE_RESTORE_UNSAFE_ARCHIVE" });
}
}
if (!equal(entry, input.baseline.entries.get(relative)) && !equal(entry, actual.entries.get(relative))) throw new Error("Workspace checkpoint payload mismatch");
}
for (const [relative, entry] of actual.entries) {
assertIncluded(relative);
if (!equal(entry, input.snapshot.entries.get(relative))) throw new Error("Unexpected workspace checkpoint payload");
if (entry.kind === "file") { changedFiles++; payloadBytes += (await fs.stat(path.join(input.payload, relative))).size; }
}
if (input.baseline.ignoredPaths) for (const relative of workspacePaths(input.baseline.ignoredPaths)) {
for (let slash = relative.lastIndexOf("/"); slash >= 0; slash = relative.lastIndexOf("/", slash - 1)) {
const ancestor = relative.slice(0, slash);
for (const entry of [input.snapshot.entries.get(ancestor), actual.entries.get(ancestor)]) {
if (entry && entry.kind !== "dir") throw new Error("Workspace checkpoint replaces an ignored path ancestor");
}
}
}
return { mode: "sparse", scannedEntries: input.snapshot.entries.size, changedFiles, payloadBytes };
} finally { ignored.close(); await disposeDirectorySnapshot(actual); }
}
export interface WorkspaceCheckpointMetrics {
mode: "sparse" | "full";
scannedEntries: number | null;
changedFiles: number | null;
payloadBytes: number | null;
}
@@ -17,6 +17,7 @@ import {
parseDirectorySnapshot,
serializeDirectorySnapshot,
withDirectoryMergeLock,
withDirectoryPublicationLock,
WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE,
} from "./workspace-restore-merge.js";
@@ -31,6 +32,42 @@ describe("workspace restore merge", () => {
}
});
it("locks absent publications independently while sharing the published target's merge lock", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-publication-lock-"));
cleanupDirs.push(root);
const env = { PAPERCLIP_HOME: path.join(root, "home"), PAPERCLIP_INSTANCE_ID: "publication" };
const first = path.join(root, "first"), second = path.join(root, "second");
await withDirectoryPublicationLock(first, async canonical => {
expect(await lstat(first).catch(() => null)).toBeNull();
await mkdir(canonical);
await withDirectoryPublicationLock(second, async other => {
await mkdir(other);
expect(other).not.toBe(canonical);
}, env);
const merge = vi.fn();
await expect(withDirectoryMergeLock(first, merge, env, undefined, 30)).rejects.toMatchObject({
code: WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE,
});
expect(merge).not.toHaveBeenCalled();
}, env);
await withDirectoryMergeLock(first, async canonical => expect(canonical).toBe(await realpath(first)), env);
});
it.skipIf(process.platform === "win32")("canonicalizes publication parents and rejects unsafe existing leaves", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-publication-alias-"));
cleanupDirs.push(root);
const env = { PAPERCLIP_HOME: path.join(root, "home"), PAPERCLIP_INSTANCE_ID: "publication" };
const parent = path.join(root, "parent"), alias = path.join(root, "alias");
await mkdir(parent); await symlink(parent, alias);
await withDirectoryPublicationLock(path.join(alias, "child"), async canonical => {
expect(canonical).toBe(path.join(await realpath(parent), "child"));
}, env);
await symlink(parent, path.join(parent, "child"));
await expect(withDirectoryPublicationLock(path.join(alias, "child"), async () => undefined, env)).rejects.toThrow("not a plain directory");
await rm(path.join(parent, "child")); await writeFile(path.join(parent, "child"), "occupied");
await expect(withDirectoryPublicationLock(path.join(alias, "child"), async () => undefined, env)).rejects.toThrow("not a plain directory");
});
it("round-trips a deterministic durable snapshot and rejects traversal", async () => {
const rootDir = await mkdtemp(
path.join(os.tmpdir(), "paperclip-snapshot-"),
@@ -504,6 +504,40 @@ export async function withDirectoryMergeLock<T>(
// Canonicalize before we hash or lock: a retargeted symlink must not let the
// lock protect one directory while the caller mutates another.
const canonicalTargetDir = await fs.realpath(targetDir);
return withCanonicalDirectoryMergeLock(canonicalTargetDir, fn, env, diagnosticOperation, waitMs);
}
export async function withDirectoryPublicationLock<T>(
targetDir: string,
fn: (canonicalTargetDir: string) => Promise<T>,
env: NodeJS.ProcessEnv = process.env,
): Promise<T> {
const absoluteTarget = path.resolve(targetDir);
const parent = await fs.realpath(path.dirname(absoluteTarget));
const canonicalTargetDir = path.join(parent, path.basename(absoluteTarget));
const assertSafeLeaf = async () => {
const leaf = await fs.lstat(canonicalTargetDir).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null;
throw error;
});
if (leaf && (!leaf.isDirectory() || leaf.isSymbolicLink())) {
throw new Error(`Directory publication target at ${canonicalTargetDir} is not a plain directory.`);
}
};
await assertSafeLeaf();
return withCanonicalDirectoryMergeLock(canonicalTargetDir, async () => {
await assertSafeLeaf();
return fn(canonicalTargetDir);
}, env);
}
async function withCanonicalDirectoryMergeLock<T>(
canonicalTargetDir: string,
fn: (canonicalTargetDir: string) => Promise<T>,
env: NodeJS.ProcessEnv,
diagnosticOperation?: DirectoryMergeLockOperation,
waitMs: number = LOCK_WAIT_MS,
): Promise<T> {
const lockRoot = await resolveDirectoryMergeLockRoot(env);
const lockKey = createHash("sha256").update(canonicalTargetDir).digest("hex");
const releaseLock = await acquireDirectoryMergeLock(path.join(lockRoot, `${lockKey}.lock`), diagnosticOperation, waitMs);
@@ -662,7 +696,7 @@ export async function mergeDirectoryWithBaseline(input: {
afterApply?: () => Promise<void>;
/** Caller holds the target's writer lock and validated an immutable sparse
* source. Unchanged entries need no payload and are never copied. */
snapshots?: { source: DirectorySnapshot; current: DirectorySnapshot };
snapshots?: { source: DirectorySnapshot; current?: DirectorySnapshot };
}): Promise<void> {
const options = { exclude: input.baseline.exclude, ignoredPaths: input.baseline.ignoredPaths, diskBacked: true };
const source = input.snapshots?.source ?? await captureDirectorySnapshot(input.sourceDir, options);
@@ -0,0 +1,114 @@
import fs from "node:fs/promises";
import nodeFs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { publishWorkspaceSeedGeneration, readWorkspaceSeedGeneration } from "./workspace-seed-cache.js";
const roots: string[] = [];
afterEach(async () => {
vi.restoreAllMocks(); vi.unstubAllEnvs();
await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true })));
});
async function fixture() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "seed-cache-quota-")); roots.push(root);
vi.stubEnv("PAPERCLIP_HOME", path.join(root, "home"));
const company = path.join(root, "company"), workspace = path.join(company, "workspace");
const archive = path.join(root, "run-seed.tar"); await fs.writeFile(archive, "run recovery bytes");
const seed = { workspaceArchivePath: archive };
return { root, company, workspace, archive, seed };
}
const generation = (index: number) => index.toString(16).padStart(64, "0");
it("stops new workspace generations at capacity while retaining reusable and per-run seeds", async () => {
const f = await fixture(); const options = { companyDirectory: f.company, limits: { workspaceGenerations: 1 } };
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, options)).toBe(true);
const prior = await readWorkspaceSeedGeneration(f.workspace, generation(1));
expect(prior).not.toBeNull();
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(2), f.seed, options)).toBe(false);
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, options)).toBe(true);
expect(await fs.readdir(f.workspace)).toEqual([generation(1)]);
expect(await fs.readFile(f.archive, "utf8")).toBe("run recovery bytes");
expect(await fs.readFile(prior!.workspaceArchivePath, "utf8")).toBe("run recovery bytes");
});
it.each(["workspaceBytes", "companyBytes"] as const)("counts archive and receipt bytes against %s without creating an empty workspace", async (limit) => {
const f = await fixture();
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, {
companyDirectory: f.company, limits: { [limit]: 4096 },
})).toBe(false);
expect(await fs.readdir(f.company)).toEqual([]);
expect(await fs.readFile(f.archive, "utf8")).toBe("run recovery bytes");
});
it.each(["companyGenerations", "companyWorkspaces"] as const)("serializes distinct-workspace publication against %s", async (limit) => {
const f = await fixture(); const options = { companyDirectory: f.company, limits: { [limit]: 1 } };
const results = await Promise.all(["first", "second"].map((name, index) =>
publishWorkspaceSeedGeneration(path.join(f.company, name), generation(index), f.seed, options)));
expect(results.sort()).toEqual([false, true]);
expect(await fs.readdir(f.company)).toHaveLength(1);
expect(await fs.readFile(f.archive, "utf8")).toBe("run recovery bytes");
});
it("applies the company byte cap across workspace roots", async () => {
const f = await fixture();
const options = { companyDirectory: f.company, limits: { companyBytes: 4500 } };
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, options)).toBe(true);
const secondArchive = path.join(f.root, "second-seed.tar"); await fs.writeFile(secondArchive, "x".repeat(1000));
expect(await publishWorkspaceSeedGeneration(path.join(f.company, "second"), generation(2), { workspaceArchivePath: secondArchive }, options)).toBe(false);
expect(await fs.readdir(f.company)).toEqual(["workspace"]);
});
it("serializes concurrent archive publication against the aggregate byte cap", async () => {
const f = await fixture(); await fs.writeFile(f.archive, "x".repeat(1024));
const options = { companyDirectory: f.company, limits: { companyBytes: 5500 } };
const results = await Promise.all(["first", "second"].map((name, index) =>
publishWorkspaceSeedGeneration(path.join(f.company, name), generation(index), f.seed, options)));
expect(results.sort()).toEqual([false, true]);
expect(await fs.readdir(f.company)).toHaveLength(1);
});
it("counts abandoned pending and pre-existing over-quota directories without deleting them", async () => {
const f = await fixture(); await fs.mkdir(path.join(f.workspace, ".pending-old"), { recursive: true });
await fs.writeFile(path.join(f.workspace, ".pending-old", "workspace.tar"), "retained");
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(2), f.seed, {
companyDirectory: f.company, limits: { workspaceGenerations: 0 },
})).toBe(false);
expect(await fs.readFile(path.join(f.workspace, ".pending-old", "workspace.tar"), "utf8")).toBe("retained");
expect(await fs.readdir(f.workspace)).toEqual([".pending-old"]);
});
it("reserves full-copy disk capacity and skips a cache write without losing recovery", async () => {
const f = await fixture();
vi.spyOn(nodeFs, "statfsSync").mockReturnValue({ bavail: 256n * 1024n * 1024n, bsize: 1n } as ReturnType<typeof nodeFs.statfsSync>);
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, { companyDirectory: f.company })).toBe(false);
expect(await fs.readdir(f.company)).toEqual([]);
expect(await fs.readFile(f.archive, "utf8")).toBe("run recovery bytes");
});
it("supports cross-device archive copies independently of the per-run seed", async () => {
const f = await fixture();
vi.spyOn(fs, "link").mockRejectedValue(Object.assign(new Error("cross-device"), { code: "EXDEV" }));
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, { companyDirectory: f.company })).toBe(true);
await fs.unlink(f.archive);
const cached = await readWorkspaceSeedGeneration(f.workspace, generation(1));
expect(await fs.readFile(cached!.workspaceArchivePath, "utf8")).toBe("run recovery bytes");
});
it("skips optional verification when capacity is exhausted", async () => {
const f = await fixture(); const verify = vi.fn(async () => true);
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, {
companyDirectory: f.company, limits: { workspaceGenerations: 0 }, verify,
})).toBe(false);
expect(verify).not.toHaveBeenCalled();
expect(await fs.readFile(f.archive, "utf8")).toBe("run recovery bytes");
});
it("treats optional verification storage failure as a cache miss", async () => {
const f = await fixture();
expect(await publishWorkspaceSeedGeneration(f.workspace, generation(1), f.seed, {
companyDirectory: f.company, verify: async () => { throw Object.assign(new Error("disk full"), { code: "ENOSPC" }); },
})).toBe(false);
expect(await fs.readdir(f.company)).toEqual([]);
expect(await fs.readFile(f.archive, "utf8")).toBe("run recovery bytes");
});
@@ -0,0 +1,134 @@
import fs from "node:fs/promises";
import path from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { directorySnapshotSha256, withDirectoryMergeLock, type DirectorySnapshot } from "./workspace-restore-merge.js";
import { assertWorkspaceManifestDiskSpace } from "./workspace-manifest.js";
import type { GitWorkspaceSnapshot } from "./git-workspace-sync.js";
import type { WorkspaceDurableSeedPaths } from "./sandbox-managed-runtime.js";
export function workspaceSeedGeneration(baseline: DirectorySnapshot, git: GitWorkspaceSnapshot | null, repositories: NonNullable<GitWorkspaceSnapshot["repositories"]> = []): string {
const identity = (snapshot: GitWorkspaceSnapshot | null): unknown => snapshot && ({
head: snapshot.headCommit, branch: snapshot.branchName, origin: snapshot.originUrl,
repositories: snapshot.repositories?.map((repo) => ({ path: repo.path, git: identity(repo.snapshot) })),
});
return createHash("sha256").update("verified-seed-v1\0").update(JSON.stringify([directorySnapshotSha256(baseline), identity(git), repositories.map((repo) => ({ path: repo.path, git: identity(repo.snapshot) }))])).digest("hex");
}
async function digest(file: string): Promise<string> {
const hash = createHash("sha256");
for await (const chunk of createReadStream(file)) hash.update(chunk);
return hash.digest("hex");
}
export async function readWorkspaceSeedGeneration(root: string, generation: string): Promise<WorkspaceDurableSeedPaths | null> {
const directory = path.join(root, generation);
try {
for (const item of [root, directory]) { const stat = await fs.lstat(item); if (!stat.isDirectory() || stat.isSymbolicLink()) return null; }
const receipt = path.join(directory, "receipt.json");
const receiptStat = await fs.lstat(receipt);
if (!receiptStat.isFile() || receiptStat.isSymbolicLink() || receiptStat.size > 4096) return null;
const metadata = JSON.parse(await fs.readFile(receipt, "utf8"));
if (!/^[a-f0-9]{64}$/.test(metadata.workspaceSha256) || (metadata.gitSha256 !== null && !/^[a-f0-9]{64}$/.test(metadata.gitSha256))) return null;
if (metadata.version !== 1 || metadata.generation !== generation) return null;
const workspaceArchivePath = path.join(directory, "workspace.tar");
const gitArchivePath = metadata.gitSha256 ? path.join(directory, "git.tar") : null;
for (const [file, expected] of [[workspaceArchivePath, metadata.workspaceSha256], [gitArchivePath, metadata.gitSha256]]) {
if (!file) continue;
const stat = await fs.lstat(file);
if (!stat.isFile() || stat.isSymbolicLink() || await digest(file) !== expected) return null;
}
return { workspaceArchivePath, workspaceArchiveSha256: metadata.workspaceSha256,
gitArchivePath, gitArchiveSha256: metadata.gitSha256 };
} catch { return null; }
}
export const WORKSPACE_SEED_CACHE_LIMITS = Object.freeze({
workspaceGenerations: 16,
workspaceBytes: 1024 ** 3,
companyGenerations: 256,
companyWorkspaces: 256,
companyBytes: 8 * 1024 ** 3,
});
type SeedCacheLimits = { [Key in keyof typeof WORKSPACE_SEED_CACHE_LIMITS]: number };
async function cacheUsage(root: string, limits: SeedCacheLimits): Promise<{ generations: number; bytes: number } | null> {
let generations = 0, bytes = 0;
for await (const entry of await fs.opendir(root)) {
if (++generations > limits.companyGenerations) return null;
const directory = path.join(root, entry.name);
const stat = await fs.lstat(directory);
if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
let files = 0;
for await (const child of await fs.opendir(directory)) {
if (++files > 3) return null;
const file = await fs.lstat(path.join(directory, child.name));
if (!file.isFile() || file.isSymbolicLink()) return null;
bytes += file.size;
if (bytes > limits.companyBytes) return null;
}
}
return { generations, bytes };
}
export async function publishWorkspaceSeedGeneration(
root: string, generation: string, seed: WorkspaceDurableSeedPaths,
options: { companyDirectory?: string; limits?: Partial<SeedCacheLimits>; verify?: () => Promise<boolean> } = {},
): Promise<boolean> {
const limits = { ...WORKSPACE_SEED_CACHE_LIMITS, ...options.limits };
if (!/^[a-f0-9]{64}$/.test(generation)
|| Object.values(limits).some(value => !Number.isSafeInteger(value) || value < 0)) return false;
root = path.resolve(root);
const company = path.resolve(options.companyDirectory ?? root);
if (root !== company && path.dirname(root) !== company) return false;
try {
await fs.mkdir(company, { recursive: true, mode: 0o700 });
const companyStat = await fs.lstat(company);
if (!companyStat.isDirectory() || companyStat.isSymbolicLink()) return false;
return await withDirectoryMergeLock(company, async () => {
let totalGenerations = 0, totalBytes = 0, workspaces = 0;
let workspace = { generations: 0, bytes: 0 };
let workspaceExists = false;
const directories = root === company ? [{ name: "" }] : await fs.opendir(company);
for await (const { name } of directories) {
if (++workspaces > limits.companyWorkspaces) return false;
const directory = root === company ? company : path.join(company, name);
const stat = await fs.lstat(directory);
if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
const usage = await cacheUsage(directory, limits);
if (!usage) return false;
totalGenerations += usage.generations; totalBytes += usage.bytes;
if (totalGenerations > limits.companyGenerations || totalBytes > limits.companyBytes) return false;
if (directory === root) { workspace = usage; workspaceExists = true; }
}
if (workspaceExists && await fs.lstat(path.join(root, generation)).then(() => true, () => false)) return true;
let incomingBytes = 4096;
for (const archive of [seed.workspaceArchivePath, seed.gitArchivePath]) {
if (!archive) continue;
const stat = await fs.lstat(archive);
if (!stat.isFile() || stat.isSymbolicLink()) return false;
incomingBytes += stat.size;
}
if (workspace.generations + 1 > limits.workspaceGenerations || workspace.bytes + incomingBytes > limits.workspaceBytes
|| totalGenerations + 1 > limits.companyGenerations || totalBytes + incomingBytes > limits.companyBytes
|| workspaces + (workspaceExists ? 0 : 1) > limits.companyWorkspaces) return false;
assertWorkspaceManifestDiskSpace(company, incomingBytes);
if (options.verify && !await options.verify()) return false;
await fs.mkdir(root, { recursive: true, mode: 0o700 });
const temporary = path.join(root, `.pending-${randomUUID()}`);
await fs.mkdir(temporary, { mode: 0o700 });
try {
const copy = async (source: string, name: string) => {
const target = path.join(temporary, name);
await fs.link(source, target).catch(() => fs.copyFile(source, target));
const file = await fs.open(target, "r");
try { await file.sync(); } finally { await file.close(); }
return digest(target);
};
const workspaceSha256 = await copy(seed.workspaceArchivePath, "workspace.tar");
const gitSha256 = seed.gitArchivePath ? await copy(seed.gitArchivePath, "git.tar") : null;
await fs.writeFile(path.join(temporary, "receipt.json"), JSON.stringify({ version: 1, generation, workspaceSha256, gitSha256 }));
await fs.rename(temporary, path.join(root, generation));
return true;
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
});
} catch { return false; }
}
@@ -0,0 +1,48 @@
CREATE TABLE IF NOT EXISTS "execution_workspace_repositories" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"execution_workspace_id" uuid NOT NULL,
"requested_by_issue_id" uuid,
"repository_identity" text NOT NULL,
"catalog_repository_id" text,
"repo_url" text NOT NULL,
"relative_path" text NOT NULL,
"requested_ref" text DEFAULT 'HEAD' NOT NULL,
"pinned_commit" text,
"branch_name" text,
"request_key" text NOT NULL,
"request_keys" jsonb DEFAULT '[]'::jsonb NOT NULL,
"state" text DEFAULT 'pending' NOT NULL,
"failure_code" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "execution_workspaces" DROP CONSTRAINT IF EXISTS "execution_workspaces_project_id_projects_id_fk";
--> statement-breakpoint
ALTER TABLE "execution_workspaces" ALTER COLUMN "project_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "chat_endpoint_resources" ADD COLUMN IF NOT EXISTS "execution_defaults" jsonb;--> statement-breakpoint
ALTER TABLE "chat_endpoints" ADD COLUMN IF NOT EXISTS "execution_defaults" jsonb;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "workspace_binding_revision" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "workspace_selection" jsonb;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "workspace_pending_selection" jsonb;--> statement-breakpoint
DO $migration$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'execution_workspace_repositories'::regclass AND conname = 'execution_workspace_repositories_company_id_companies_id_fk'::name) THEN
ALTER TABLE "execution_workspace_repositories" ADD CONSTRAINT "execution_workspace_repositories_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $migration$;--> statement-breakpoint
DO $migration$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'execution_workspace_repositories'::regclass AND conname = 'execution_workspace_repositories_execution_workspace_id_execution_workspaces_id_fk'::name) THEN
ALTER TABLE "execution_workspace_repositories" ADD CONSTRAINT "execution_workspace_repositories_execution_workspace_id_execution_workspaces_id_fk" FOREIGN KEY ("execution_workspace_id") REFERENCES "public"."execution_workspaces"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $migration$;--> statement-breakpoint
DO $migration$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'execution_workspace_repositories'::regclass AND conname = 'execution_workspace_repositories_requested_by_issue_id_issues_id_fk'::name) THEN
ALTER TABLE "execution_workspace_repositories" ADD CONSTRAINT "execution_workspace_repositories_requested_by_issue_id_issues_id_fk" FOREIGN KEY ("requested_by_issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $migration$;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "execution_workspace_repositories_identity_idx" ON "execution_workspace_repositories" USING btree ("execution_workspace_id","repository_identity");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "execution_workspace_repositories_path_idx" ON "execution_workspace_repositories" USING btree ("execution_workspace_id","relative_path");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "execution_workspace_repositories_request_idx" ON "execution_workspace_repositories" USING btree ("execution_workspace_id","request_key");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "execution_workspace_repositories_company_workspace_idx" ON "execution_workspace_repositories" USING btree ("company_id","execution_workspace_id");--> statement-breakpoint
ALTER TABLE "execution_workspaces" ADD CONSTRAINT "execution_workspaces_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE restrict ON UPDATE no action;
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS "execution_workspace_repository_roots" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"canonical_cwd" text NOT NULL,
"root_key" text NOT NULL,
"mode" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $migration$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'execution_workspace_repository_roots'::regclass AND conname = 'execution_workspace_repository_roots_company_id_companies_id_fk'::name) THEN
ALTER TABLE "execution_workspace_repository_roots" ADD CONSTRAINT "execution_workspace_repository_roots_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $migration$;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "execution_workspace_repository_roots_company_root_idx" ON "execution_workspace_repository_roots" USING btree ("company_id","root_key");
@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS "execution_workspace_repository_access" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"root_key" text NOT NULL,
"canonical_cwd" text NOT NULL,
"kind" text NOT NULL,
"scope_id" uuid NOT NULL,
"issue_id" uuid,
"agent_id" uuid,
"responsible_user_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $migration$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'execution_workspace_repository_access'::regclass AND conname = 'execution_workspace_repository_access_company_id_companies_id_fk'::name) THEN
ALTER TABLE "execution_workspace_repository_access" ADD CONSTRAINT "execution_workspace_repository_access_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $migration$;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "execution_workspace_repository_access_scope_idx" ON "execution_workspace_repository_access" USING btree ("company_id","root_key","kind","scope_id");
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+21
View File
@@ -2339,6 +2339,27 @@
"when": 1791832982875,
"tag": "0335_absurd_bulldozer",
"breakpoints": true
},
{
"idx": 336,
"version": "7",
"when": 1791837392742,
"tag": "0336_big_crystal",
"breakpoints": true
},
{
"idx": 337,
"version": "7",
"when": 1791837407237,
"tag": "0337_exotic_proemial_gods",
"breakpoints": true
},
{
"idx": 338,
"version": "7",
"when": 1791837431016,
"tag": "0338_absurd_vengeance",
"breakpoints": true
}
]
}
+3
View File
@@ -14,6 +14,7 @@ import {
uuid,
} from "drizzle-orm/pg-core";
import type {
ChatExecutionDefaults,
ChatAdapterCapabilities,
ChatConcurrencyPolicy,
ChatDeliveryState,
@@ -65,6 +66,7 @@ export const chatEndpoints = pgTable(
botDisplayName: text("bot_display_name"),
botAvatarUrl: text("bot_avatar_url"),
communicationInstructions: text("communication_instructions").notNull().default(""),
executionDefaults: jsonb("execution_defaults").$type<ChatExecutionDefaults>(),
allowDirectMessages: boolean("allow_direct_messages")
.notNull()
.default(true),
@@ -220,6 +222,7 @@ export const chatEndpointResources = pgTable(
.notNull()
.default("available"),
enabled: boolean("enabled").notNull().default(false),
executionDefaults: jsonb("execution_defaults").$type<ChatExecutionDefaults>(),
metadata: jsonb("metadata")
.$type<Record<string, unknown>>()
.notNull()
@@ -0,0 +1,29 @@
import { index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { executionWorkspaces } from "./execution_workspaces.js";
import { issues } from "./issues.js";
export const executionWorkspaceRepositories = pgTable("execution_workspace_repositories", {
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
executionWorkspaceId: uuid("execution_workspace_id").notNull().references(() => executionWorkspaces.id, { onDelete: "cascade" }),
requestedByIssueId: uuid("requested_by_issue_id").references(() => issues.id, { onDelete: "set null" }),
repositoryIdentity: text("repository_identity").notNull(),
catalogRepositoryId: text("catalog_repository_id"),
repoUrl: text("repo_url").notNull(),
relativePath: text("relative_path").notNull(),
requestedRef: text("requested_ref").notNull().default("HEAD"),
pinnedCommit: text("pinned_commit"),
branchName: text("branch_name"),
requestKey: text("request_key").notNull(),
requestKeys: jsonb("request_keys").$type<string[]>().notNull().default([]),
state: text("state").notNull().default("pending"),
failureCode: text("failure_code"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
identityIdx: uniqueIndex("execution_workspace_repositories_identity_idx").on(table.executionWorkspaceId, table.repositoryIdentity),
pathIdx: uniqueIndex("execution_workspace_repositories_path_idx").on(table.executionWorkspaceId, table.relativePath),
requestIdx: uniqueIndex("execution_workspace_repositories_request_idx").on(table.executionWorkspaceId, table.requestKey),
companyWorkspaceIdx: index("execution_workspace_repositories_company_workspace_idx").on(table.companyId, table.executionWorkspaceId),
}));
@@ -0,0 +1,18 @@
import { pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
export const executionWorkspaceRepositoryAccess = pgTable("execution_workspace_repository_access", {
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
rootKey: text("root_key").notNull(),
canonicalCwd: text("canonical_cwd").notNull(),
kind: text("kind").$type<"source" | "reader" | "project_source" | "shared_project_source" | "isolated_source" | "isolated_project_source" | "isolated_copy" | "repository">().notNull(),
scopeId: uuid("scope_id").notNull(),
issueId: uuid("issue_id"),
agentId: uuid("agent_id"),
responsibleUserId: text("responsible_user_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
scopeIdx: uniqueIndex("execution_workspace_repository_access_scope_idx").on(table.companyId, table.rootKey, table.kind, table.scopeId),
}));
@@ -0,0 +1,14 @@
import { pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
export const executionWorkspaceRepositoryRoots = pgTable("execution_workspace_repository_roots", {
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
canonicalCwd: text("canonical_cwd").notNull(),
rootKey: text("root_key").notNull(),
mode: text("mode").$type<"anonymous_only" | "managed">().notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
rootIdx: uniqueIndex("execution_workspace_repository_roots_company_root_idx").on(table.companyId, table.rootKey),
}));
@@ -17,7 +17,7 @@ export const executionWorkspaces = pgTable(
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
projectId: uuid("project_id").references(() => projects.id, { onDelete: "restrict" }),
projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }),
sourceIssueId: uuid("source_issue_id").references((): AnyPgColumn => issues.id, { onDelete: "set null" }),
mode: text("mode").notNull(),
+3
View File
@@ -247,4 +247,7 @@ export { userCompanyPreferences } from "./user_company_preferences.js";
export { chatSlackManagerGrants } from "./chat_slack_manager_grants.js";
export { aiSubscriptions, aiSubscriptionPrices, aiSubscriptionConnections } from "./ai_subscriptions.js";
export { executionWorkspaceRepositories } from "./execution_workspace_repositories.js";
export { executionWorkspaceRepositoryRoots } from "./execution_workspace_repository_roots.js";
export { executionWorkspaceRepositoryAccess } from "./execution_workspace_repository_access.js";
export * from "./fast_responses.js";
+4 -1
View File
@@ -21,7 +21,7 @@ import { companies } from "./companies.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { projectWorkspaces } from "./project_workspaces.js";
import { executionWorkspaces } from "./execution_workspaces.js";
import type { IssueReviewPolicy, IssueUnblockDescriptor, SourceTrustMetadata } from "@paperclipai/shared";
import type { IssueReviewPolicy, IssueUnblockDescriptor, SourceTrustMetadata, TaskWorkspaceIntent, TaskWorkspacePendingSelection } from "@paperclipai/shared";
export const issues = pgTable(
"issues",
@@ -81,6 +81,9 @@ export const issues = pgTable(
monitorScheduledBy: text("monitor_scheduled_by"),
executionWorkspaceId: uuid("execution_workspace_id")
.references((): AnyPgColumn => executionWorkspaces.id, { onDelete: "set null" }),
workspaceBindingRevision: integer("workspace_binding_revision").notNull().default(0),
workspaceSelection: jsonb("workspace_selection").$type<TaskWorkspaceIntent>(),
workspacePendingSelection: jsonb("workspace_pending_selection").$type<TaskWorkspacePendingSelection>(),
executionWorkspacePreference: text("execution_workspace_preference"),
executionWorkspaceSettings: jsonb("execution_workspace_settings").$type<Record<string, unknown>>(),
sourceTrust: jsonb("source_trust").$type<SourceTrustMetadata | null>(),
+5
View File
@@ -338,6 +338,11 @@ do not change workspace isolation or grant credentials or connection access.
task tools; `approve-reads` allows assigned reads; `deny-all` rejects requests.
None of these restrictive modes is the default.
`prepare_repository` requires a provider permission decision in restrictive
modes, including `approve-paperclip`: repository acquisition can clone onto the
controller and is not automatically approved as a planning action. Full auto
still allows it; repository acquisition currently has no cumulative disk quota.
Restrictive profiles route supported permission decisions through durable runtime
requests and the existing task interaction controls. Requests are persisted
before presentation; answers are checked against the offered decisions and
@@ -8,9 +8,9 @@ The skill/reference inventory and eval cases are the only normative behavior sou
## Baseline Counts
- Skill/reference headings: 162
- Skill/reference headings: 164
- Eval cases: 106 across 16 groups
- Total normative rows: 268
- Total normative rows: 270
- Legacy MCP aliases folded into normative rows: 42
| Eval group | Cases |
@@ -61,19 +61,20 @@ The skill/reference inventory and eval cases are the only normative behavior sou
| skill:skills/paperclip/SKILL.md:cases:520 | optional_agent_tool | skills/paperclip/SKILL.md:520 |
| skill:skills/paperclip/SKILL.md:company-skills-workflow:525 | optional_agent_tool | skills/paperclip/SKILL.md:525 |
| skill:skills/paperclip/SKILL.md:routines:536 | optional_agent_tool | skills/paperclip/SKILL.md:536 |
| skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:547 | optional_agent_tool | skills/paperclip/SKILL.md:547 |
| skill:skills/paperclip/SKILL.md:proposing-credentials-safely:554 | optional_agent_tool | skills/paperclip/SKILL.md:554 |
| skill:skills/paperclip/SKILL.md:reading-granted-secrets:561 | optional_agent_tool | skills/paperclip/SKILL.md:561 |
| skill:skills/paperclip/SKILL.md:critical-rules:587 | optional_agent_tool | skills/paperclip/SKILL.md:587 |
| skill:skills/paperclip/SKILL.md:comment-style-required:608 | always_agent_tool | skills/paperclip/SKILL.md:608 |
| skill:skills/paperclip/SKILL.md:update:640 | optional_agent_tool | skills/paperclip/SKILL.md:640 |
| skill:skills/paperclip/SKILL.md:task-documents-and-deliverables:650 | always_agent_tool | skills/paperclip/SKILL.md:650 |
| skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:664 | optional_agent_tool | skills/paperclip/SKILL.md:664 |
| skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:695 | optional_agent_tool | skills/paperclip/SKILL.md:695 |
| skill:skills/paperclip/SKILL.md:searching-issues:724 | optional_agent_tool | skills/paperclip/SKILL.md:724 |
| skill:skills/paperclip/SKILL.md:full-reference:734 | optional_agent_tool | skills/paperclip/SKILL.md:734 |
| skill:skills/paperclip/SKILL.md:conversational-confirmation-answers:738 | always_agent_tool | skills/paperclip/SKILL.md:738 |
| skill:skills/paperclip/SKILL.md:incidental-feedback:755 | optional_agent_tool | skills/paperclip/SKILL.md:755 |
| skill:skills/paperclip/SKILL.md:task-files-and-workspace-choices:547 | optional_agent_tool | skills/paperclip/SKILL.md:547 |
| skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:553 | optional_agent_tool | skills/paperclip/SKILL.md:553 |
| skill:skills/paperclip/SKILL.md:proposing-credentials-safely:560 | optional_agent_tool | skills/paperclip/SKILL.md:560 |
| skill:skills/paperclip/SKILL.md:reading-granted-secrets:567 | optional_agent_tool | skills/paperclip/SKILL.md:567 |
| skill:skills/paperclip/SKILL.md:critical-rules:593 | optional_agent_tool | skills/paperclip/SKILL.md:593 |
| skill:skills/paperclip/SKILL.md:comment-style-required:614 | always_agent_tool | skills/paperclip/SKILL.md:614 |
| skill:skills/paperclip/SKILL.md:update:646 | optional_agent_tool | skills/paperclip/SKILL.md:646 |
| skill:skills/paperclip/SKILL.md:task-documents-and-deliverables:656 | always_agent_tool | skills/paperclip/SKILL.md:656 |
| skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:670 | optional_agent_tool | skills/paperclip/SKILL.md:670 |
| skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:701 | optional_agent_tool | skills/paperclip/SKILL.md:701 |
| skill:skills/paperclip/SKILL.md:searching-issues:730 | optional_agent_tool | skills/paperclip/SKILL.md:730 |
| skill:skills/paperclip/SKILL.md:full-reference:740 | optional_agent_tool | skills/paperclip/SKILL.md:740 |
| skill:skills/paperclip/SKILL.md:conversational-confirmation-answers:744 | always_agent_tool | skills/paperclip/SKILL.md:744 |
| skill:skills/paperclip/SKILL.md:incidental-feedback:761 | optional_agent_tool | skills/paperclip/SKILL.md:761 |
| skill:skills/paperclip/references/api-reference.md:paperclip-api-reference:1 | optional_agent_tool | skills/paperclip/references/api-reference.md:1 |
| skill:skills/paperclip/references/api-reference.md:response-schemas:9 | optional_agent_tool | skills/paperclip/references/api-reference.md:9 |
| skill:skills/paperclip/references/api-reference.md:agent-record-get-api-agents-me-or-get-api-agents-agentid:11 | optional_agent_tool | skills/paperclip/references/api-reference.md:11 |
@@ -176,12 +177,13 @@ The skill/reference inventory and eval cases are the only normative behavior sou
| skill:skills/paperclip/references/issue-documents.md:update-or-resolve-an-unclear-write:49 | optional_agent_tool | skills/paperclip/references/issue-documents.md:49 |
| skill:skills/paperclip/references/issue-workspaces.md:issue-workspace-runtime-controls:1 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:1 |
| skill:skills/paperclip/references/issue-workspaces.md:discover-the-workspace:5 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:5 |
| skill:skills/paperclip/references/issue-workspaces.md:control-services:23 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:23 |
| skill:skills/paperclip/references/issue-workspaces.md:start-all-configured-services-waits-for-configured-readiness-checks:28 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:28 |
| skill:skills/paperclip/references/issue-workspaces.md:restart-all-configured-services:36 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:36 |
| skill:skills/paperclip/references/issue-workspaces.md:stop-all-running-services:44 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:44 |
| skill:skills/paperclip/references/issue-workspaces.md:read-the-url:63 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:63 |
| skill:skills/paperclip/references/issue-workspaces.md:mcp-tools:72 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:72 |
| skill:skills/paperclip/references/issue-workspaces.md:choose-files-and-repositories:23 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:23 |
| skill:skills/paperclip/references/issue-workspaces.md:control-services:37 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:37 |
| skill:skills/paperclip/references/issue-workspaces.md:start-all-configured-services-waits-for-configured-readiness-checks:42 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:42 |
| skill:skills/paperclip/references/issue-workspaces.md:restart-all-configured-services:50 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:50 |
| skill:skills/paperclip/references/issue-workspaces.md:stop-all-running-services:58 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:58 |
| skill:skills/paperclip/references/issue-workspaces.md:read-the-url:77 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:77 |
| skill:skills/paperclip/references/issue-workspaces.md:mcp-tools:86 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:86 |
| skill:skills/paperclip/references/routines.md:paperclip-routines:1 | optional_agent_tool | skills/paperclip/references/routines.md:1 |
| skill:skills/paperclip/references/routines.md:lifecycle:16 | optional_agent_tool | skills/paperclip/references/routines.md:16 |
| skill:skills/paperclip/references/routines.md:creating-a-routine:27 | optional_agent_tool | skills/paperclip/references/routines.md:27 |
@@ -184,115 +184,124 @@
{
"id": "skill:skills/paperclip/SKILL.md:547",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L547:issue-workspace-runtime-controls",
"sourceAnchor": "skills/paperclip/SKILL.md#L547:task-files-and-workspace-choices",
"heading": "Task Files and Workspace Choices",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:553",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L553:issue-workspace-runtime-controls",
"heading": "Issue Workspace Runtime Controls",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:554",
"id": "skill:skills/paperclip/SKILL.md:560",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L554:proposing-credentials-safely",
"sourceAnchor": "skills/paperclip/SKILL.md#L560:proposing-credentials-safely",
"heading": "Proposing Credentials Safely",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:561",
"id": "skill:skills/paperclip/SKILL.md:567",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L561:reading-granted-secrets",
"sourceAnchor": "skills/paperclip/SKILL.md#L567:reading-granted-secrets",
"heading": "Reading Granted Secrets",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:587",
"id": "skill:skills/paperclip/SKILL.md:593",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L587:critical-rules",
"sourceAnchor": "skills/paperclip/SKILL.md#L593:critical-rules",
"heading": "Critical Rules",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:608",
"id": "skill:skills/paperclip/SKILL.md:614",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L608:comment-style-required",
"sourceAnchor": "skills/paperclip/SKILL.md#L614:comment-style-required",
"heading": "Comment Style (Required)",
"primaryDisposition": "always_agent_tool",
"semanticOperation": "report_progress",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:640",
"id": "skill:skills/paperclip/SKILL.md:646",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L640:update",
"sourceAnchor": "skills/paperclip/SKILL.md#L646:update",
"heading": "Update",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:650",
"id": "skill:skills/paperclip/SKILL.md:656",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L650:task-documents-and-deliverables",
"sourceAnchor": "skills/paperclip/SKILL.md#L656:task-documents-and-deliverables",
"heading": "Task Documents and Deliverables",
"primaryDisposition": "always_agent_tool",
"semanticOperation": "write_document",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:664",
"id": "skill:skills/paperclip/SKILL.md:670",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L664:planning-required-when-planning-requested",
"sourceAnchor": "skills/paperclip/SKILL.md#L670:planning-required-when-planning-requested",
"heading": "Planning (Required when planning requested)",
"primaryDisposition": "always_agent_tool",
"semanticOperation": "write_document",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:695",
"id": "skill:skills/paperclip/SKILL.md:701",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L695:key-endpoints-hot-routes",
"sourceAnchor": "skills/paperclip/SKILL.md#L701:key-endpoints-hot-routes",
"heading": "Key Endpoints (Hot Routes)",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:724",
"id": "skill:skills/paperclip/SKILL.md:730",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L724:searching-issues",
"sourceAnchor": "skills/paperclip/SKILL.md#L730:searching-issues",
"heading": "Searching Issues",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:734",
"id": "skill:skills/paperclip/SKILL.md:740",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L734:full-reference",
"sourceAnchor": "skills/paperclip/SKILL.md#L740:full-reference",
"heading": "Full Reference",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:738",
"id": "skill:skills/paperclip/SKILL.md:744",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L738:conversational-confirmation-answers",
"sourceAnchor": "skills/paperclip/SKILL.md#L744:conversational-confirmation-answers",
"heading": "Conversational confirmation answers",
"primaryDisposition": "always_agent_tool",
"semanticOperation": "call_api",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:755",
"id": "skill:skills/paperclip/SKILL.md:761",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L755:incidental-feedback",
"sourceAnchor": "skills/paperclip/SKILL.md#L761:incidental-feedback",
"heading": "Incidental feedback",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
@@ -1228,52 +1237,61 @@
{
"id": "skill:skills/paperclip/references/issue-workspaces.md:23",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L23:control-services",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L23:choose-files-and-repositories",
"heading": "Choose Files and Repositories",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/references/issue-workspaces.md:37",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L37:control-services",
"heading": "Control Services",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/references/issue-workspaces.md:28",
"id": "skill:skills/paperclip/references/issue-workspaces.md:42",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L28:start-all-configured-services-waits-for-configured-readiness-checks",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L42:start-all-configured-services-waits-for-configured-readiness-checks",
"heading": "Start all configured services; waits for configured readiness checks.",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/references/issue-workspaces.md:36",
"id": "skill:skills/paperclip/references/issue-workspaces.md:50",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L36:restart-all-configured-services",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L50:restart-all-configured-services",
"heading": "Restart all configured services.",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/references/issue-workspaces.md:44",
"id": "skill:skills/paperclip/references/issue-workspaces.md:58",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L44:stop-all-running-services",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L58:stop-all-running-services",
"heading": "Stop all running services.",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/references/issue-workspaces.md:63",
"id": "skill:skills/paperclip/references/issue-workspaces.md:77",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L63:read-the-url",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L77:read-the-url",
"heading": "Read the URL",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/references/issue-workspaces.md:72",
"id": "skill:skills/paperclip/references/issue-workspaces.md:86",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L72:mcp-tools",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L86:mcp-tools",
"heading": "MCP Tools",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
@@ -2,9 +2,9 @@
Generated by `scripts/generate-capability-contract.mjs`; do not edit generated files.
- Skill/reference headings: 163
- Skill/reference headings: 165
- Legacy MCP tools: 42
- Eval cases: 106 across 16 groups
- Deterministic content SHA-256: `70c4d4e90346747d89b722e09bf1c82672437d55e81bfe5f19211642c3749aa8`
- Deterministic content SHA-256: `83447a4f9463f9a3d73dd4bea78b580df0356545ba00d0da96763282653bdf0f`
Every row has exactly one primary disposition, a source anchor, a semantic operation, and a mock-state expectation.
@@ -1 +1 @@
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
@@ -1883,6 +1883,236 @@
"title": "Set task dependencies",
"version": 1
},
{
"allowedModes": [
"standard",
"ask",
"planning",
"skill_test"
],
"description": "Inspect current task files, repositories, pending selection, binding revision, and preparation capabilities. AGENT_HOME is personal instructions and memory; task outputs belong in the workspace.",
"effect": "read",
"inputSchema": {
"additionalProperties": false,
"properties": {},
"required": [],
"type": "object"
},
"operationId": "get_workspace",
"outputSchema": {
"additionalProperties": true,
"type": "object"
},
"placement": "optional",
"requiredClaims": [],
"schema": "paperclip.semantic-action.v1",
"title": "Inspect task workspace",
"version": 1
},
{
"allowedModes": [
"standard",
"ask",
"planning",
"skill_test"
],
"description": "List authorized reusable workspaces. A project is optional; choosing a workspace does not assign a project.",
"effect": "read",
"inputSchema": {
"additionalProperties": false,
"properties": {},
"required": [],
"type": "object"
},
"operationId": "list_workspaces",
"outputSchema": {
"items": {
"additionalProperties": true,
"type": "object"
},
"type": "array"
},
"placement": "optional",
"requiredClaims": [],
"schema": "paperclip.semantic-action.v1",
"title": "List available workspaces",
"version": 1
},
{
"allowedModes": [
"standard",
"skill_test"
],
"description": "Request a different task workspace at the next normal admission. Does not interrupt or wake the current run. Inspect the binding revision first and reuse requestKey on retries; existing files stay in their current folder.",
"effect": "write",
"inputSchema": {
"additionalProperties": false,
"properties": {
"expectedBindingRevision": {
"minimum": 0,
"type": "integer"
},
"requestKey": {
"description": "Stable retry key.",
"minLength": 1,
"type": "string"
},
"selection": {
"oneOf": [
{
"additionalProperties": false,
"properties": {
"kind": {
"const": "task_directory"
}
},
"required": [
"kind"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"kind": {
"const": "existing"
},
"workspaceId": {
"description": "Existing authorized workspace ID.",
"minLength": 1,
"type": "string"
}
},
"required": [
"kind",
"workspaceId"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"kind": {
"const": "configured_source"
},
"mode": {
"enum": [
"shared",
"managed_isolated"
]
},
"projectWorkspaceId": {
"description": "Configured source workspace ID.",
"minLength": 1,
"type": "string"
}
},
"required": [
"kind",
"projectWorkspaceId",
"mode"
],
"type": "object"
}
]
}
},
"required": [
"selection",
"expectedBindingRevision",
"requestKey"
],
"type": "object"
},
"operationId": "select_workspace",
"outputSchema": {
"additionalProperties": true,
"type": "object"
},
"placement": "optional",
"requiredClaims": [],
"schema": "paperclip.semantic-action.v1",
"title": "Select task workspace",
"version": 1
},
{
"allowedModes": [
"standard",
"skill_test"
],
"description": "Request an authorized repository inside task files at the next normal admission, without creating a project. Discover repository IDs first. Does not interrupt or wake the current run. Reuse requestKey; a conflicting ref never resets existing work.",
"effect": "write",
"inputSchema": {
"additionalProperties": false,
"properties": {
"ref": {
"description": "Optional Git branch, tag, or commit. Default HEAD is pinned once.",
"minLength": 1,
"type": "string"
},
"repository": {
"oneOf": [
{
"additionalProperties": false,
"properties": {
"id": {
"description": "Authorized ID from list_project_repositories.",
"minLength": 1,
"type": "string"
},
"kind": {
"const": "catalog"
}
},
"required": [
"kind",
"id"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"kind": {
"const": "url"
},
"url": {
"description": "Existing HTTPS GitHub repository URL; uncataloged URLs use public anonymous access only.",
"minLength": 1,
"type": "string"
}
},
"required": [
"kind",
"url"
],
"type": "object"
}
]
},
"requestKey": {
"description": "Stable retry key.",
"minLength": 1,
"type": "string"
}
},
"required": [
"repository",
"requestKey"
],
"type": "object"
},
"operationId": "prepare_repository",
"outputSchema": {
"additionalProperties": true,
"type": "object"
},
"placement": "optional",
"requiredClaims": [],
"schema": "paperclip.semantic-action.v1",
"title": "Prepare task repository",
"version": 1
},
{
"allowedModes": [
"standard",
@@ -24,7 +24,7 @@
"prpVersion": 1,
"nativeExecutionVersion": 1,
"catalogVersion": 1,
"catalogSha256": "sha256:ff3e2bdc66e564cef9500f0940b9afe9d1878e7c8ca3d5a5a0c986b23ffe79de",
"catalogSha256": "sha256:73f19996d9e1b46e957ba732354eb24daf4e96776ae121ea059acfaf01f56ef6",
"driverContractVersion": 1,
"driverKind": "paperclip-deterministic",
"driverVersion": "1.0.0"
@@ -175,7 +175,7 @@
},
{
"path": "fixtures/evals/native-execution-seeded.json",
"sha256": "248c366cd272e1cc13506886bee4ee15fd63813280583b5859011446035ebbbb",
"sha256": "872f556fd39a4b7e50a902d09c8b42f8fc729030d03642983fa16872f623e1ef",
"expectation": "accept",
"compatibilityCase": "canonical"
},
@@ -42,7 +42,7 @@ function validInventories() {
schemaVersion: 2,
inventoryRole: "normative",
generatedFrom: ["skills/paperclip/SKILL.md"],
rows: Array.from({ length: 162 }, (_, index) => row(`capability-${index}`)),
rows: Array.from({ length: 164 }, (_, index) => row(`capability-${index}`)),
},
evaluations: {
schemaVersion: 2,
@@ -247,7 +247,7 @@ export async function buildMcpInventory(repoRoot) {
export function validateInventories(inventories) {
const errors = [];
const expectedCounts = { capabilities: 162, evaluations: 106, legacyMcpAliases: 42 };
const expectedCounts = { capabilities: 164, evaluations: 106, legacyMcpAliases: 42 };
const normativeNames = ["capabilities", "evaluations"];
const normativeRows = new Map();
const globalNormativeIds = new Set();
@@ -315,11 +315,11 @@
]
},
{
"id": "skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:547",
"id": "skill:skills/paperclip/SKILL.md:task-files-and-workspace-choices:547",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:547",
"title": "Issue Workspace Runtime Controls",
"expectedSemantics": "Skill guidance headed “Issue Workspace Runtime Controls”.",
"title": "Task Files and Workspace Choices",
"expectedSemantics": "Skill guidance headed “Task Files and Workspace Choices”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
@@ -330,9 +330,24 @@
]
},
{
"id": "skill:skills/paperclip/SKILL.md:proposing-credentials-safely:554",
"id": "skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:553",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:554",
"sourceAnchor": "skills/paperclip/SKILL.md:553",
"title": "Issue Workspace Runtime Controls",
"expectedSemantics": "Skill guidance headed “Issue Workspace Runtime Controls”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:553"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:proposing-credentials-safely:560",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:560",
"title": "Proposing Credentials Safely",
"expectedSemantics": "Skill guidance headed “Proposing Credentials Safely”.",
"primaryDisposition": "optional_agent_tool",
@@ -341,13 +356,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:554"
"skill:skills/paperclip/SKILL.md:560"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:reading-granted-secrets:561",
"id": "skill:skills/paperclip/SKILL.md:reading-granted-secrets:567",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:561",
"sourceAnchor": "skills/paperclip/SKILL.md:567",
"title": "Reading Granted Secrets",
"expectedSemantics": "Skill guidance headed “Reading Granted Secrets”.",
"primaryDisposition": "optional_agent_tool",
@@ -356,13 +371,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:561"
"skill:skills/paperclip/SKILL.md:567"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:critical-rules:587",
"id": "skill:skills/paperclip/SKILL.md:critical-rules:593",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:587",
"sourceAnchor": "skills/paperclip/SKILL.md:593",
"title": "Critical Rules",
"expectedSemantics": "Skill guidance headed “Critical Rules”.",
"primaryDisposition": "optional_agent_tool",
@@ -371,13 +386,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:587"
"skill:skills/paperclip/SKILL.md:593"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:comment-style-required:608",
"id": "skill:skills/paperclip/SKILL.md:comment-style-required:614",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:608",
"sourceAnchor": "skills/paperclip/SKILL.md:614",
"title": "Comment Style (Required)",
"expectedSemantics": "Skill guidance headed “Comment Style (Required)”.",
"primaryDisposition": "always_agent_tool",
@@ -386,13 +401,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:608"
"skill:skills/paperclip/SKILL.md:614"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:update:640",
"id": "skill:skills/paperclip/SKILL.md:update:646",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:640",
"sourceAnchor": "skills/paperclip/SKILL.md:646",
"title": "Update",
"expectedSemantics": "Skill guidance headed “Update”.",
"primaryDisposition": "optional_agent_tool",
@@ -401,13 +416,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:640"
"skill:skills/paperclip/SKILL.md:646"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:task-documents-and-deliverables:650",
"id": "skill:skills/paperclip/SKILL.md:task-documents-and-deliverables:656",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:650",
"sourceAnchor": "skills/paperclip/SKILL.md:656",
"title": "Task Documents and Deliverables",
"expectedSemantics": "Skill guidance headed “Task Documents and Deliverables”.",
"primaryDisposition": "always_agent_tool",
@@ -416,13 +431,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:650"
"skill:skills/paperclip/SKILL.md:656"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:664",
"id": "skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:670",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:664",
"sourceAnchor": "skills/paperclip/SKILL.md:670",
"title": "Planning (Required when planning requested)",
"expectedSemantics": "Skill guidance headed “Planning (Required when planning requested)”.",
"primaryDisposition": "optional_agent_tool",
@@ -431,13 +446,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:664"
"skill:skills/paperclip/SKILL.md:670"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:695",
"id": "skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:701",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:695",
"sourceAnchor": "skills/paperclip/SKILL.md:701",
"title": "Key Endpoints (Hot Routes)",
"expectedSemantics": "Skill guidance headed “Key Endpoints (Hot Routes)”.",
"primaryDisposition": "optional_agent_tool",
@@ -446,13 +461,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:695"
"skill:skills/paperclip/SKILL.md:701"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:searching-issues:724",
"id": "skill:skills/paperclip/SKILL.md:searching-issues:730",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:724",
"sourceAnchor": "skills/paperclip/SKILL.md:730",
"title": "Searching Issues",
"expectedSemantics": "Skill guidance headed “Searching Issues”.",
"primaryDisposition": "optional_agent_tool",
@@ -461,13 +476,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:724"
"skill:skills/paperclip/SKILL.md:730"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:full-reference:734",
"id": "skill:skills/paperclip/SKILL.md:full-reference:740",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:734",
"sourceAnchor": "skills/paperclip/SKILL.md:740",
"title": "Full Reference",
"expectedSemantics": "Skill guidance headed “Full Reference”.",
"primaryDisposition": "optional_agent_tool",
@@ -476,13 +491,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:734"
"skill:skills/paperclip/SKILL.md:740"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:conversational-confirmation-answers:738",
"id": "skill:skills/paperclip/SKILL.md:conversational-confirmation-answers:744",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:738",
"sourceAnchor": "skills/paperclip/SKILL.md:744",
"title": "Conversational confirmation answers",
"expectedSemantics": "Skill guidance headed “Conversational confirmation answers”.",
"primaryDisposition": "always_agent_tool",
@@ -491,13 +506,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:738"
"skill:skills/paperclip/SKILL.md:744"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:incidental-feedback:755",
"id": "skill:skills/paperclip/SKILL.md:incidental-feedback:761",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:755",
"sourceAnchor": "skills/paperclip/SKILL.md:761",
"title": "Incidental feedback",
"expectedSemantics": "Skill guidance headed “Incidental feedback”.",
"primaryDisposition": "optional_agent_tool",
@@ -506,7 +521,7 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:755"
"skill:skills/paperclip/SKILL.md:761"
]
},
{
@@ -2040,11 +2055,11 @@
]
},
{
"id": "skill:skills/paperclip/references/issue-workspaces.md:control-services:23",
"id": "skill:skills/paperclip/references/issue-workspaces.md:choose-files-and-repositories:23",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md:23",
"title": "Control Services",
"expectedSemantics": "Skill guidance headed “Control Services”.",
"title": "Choose Files and Repositories",
"expectedSemantics": "Skill guidance headed “Choose Files and Repositories”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
@@ -2055,9 +2070,24 @@
]
},
{
"id": "skill:skills/paperclip/references/issue-workspaces.md:start-all-configured-services-waits-for-configured-readiness-checks:28",
"id": "skill:skills/paperclip/references/issue-workspaces.md:control-services:37",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md:28",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md:37",
"title": "Control Services",
"expectedSemantics": "Skill guidance headed “Control Services”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/issue-workspaces.md:37"
]
},
{
"id": "skill:skills/paperclip/references/issue-workspaces.md:start-all-configured-services-waits-for-configured-readiness-checks:42",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md:42",
"title": "Start all configured services; waits for configured readiness checks.",
"expectedSemantics": "Skill guidance headed “Start all configured services; waits for configured readiness checks.”.",
"primaryDisposition": "optional_agent_tool",
@@ -2066,13 +2096,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/issue-workspaces.md:28"
"skill:skills/paperclip/references/issue-workspaces.md:42"
]
},
{
"id": "skill:skills/paperclip/references/issue-workspaces.md:restart-all-configured-services:36",
"id": "skill:skills/paperclip/references/issue-workspaces.md:restart-all-configured-services:50",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md:36",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md:50",
"title": "Restart all configured services.",
"expectedSemantics": "Skill guidance headed “Restart all configured services.”.",
"primaryDisposition": "optional_agent_tool",
@@ -2081,13 +2111,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/issue-workspaces.md:36"
"skill:skills/paperclip/references/issue-workspaces.md:50"
]
},
{
"id": "skill:skills/paperclip/references/issue-workspaces.md:stop-all-running-services:44",
"id": "skill:skills/paperclip/references/issue-workspaces.md:stop-all-running-services:58",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md:44",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md:58",
"title": "Stop all running services.",
"expectedSemantics": "Skill guidance headed “Stop all running services.”.",
"primaryDisposition": "optional_agent_tool",
@@ -2096,13 +2126,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/issue-workspaces.md:44"
"skill:skills/paperclip/references/issue-workspaces.md:58"
]
},
{
"id": "skill:skills/paperclip/references/issue-workspaces.md:read-the-url:63",
"id": "skill:skills/paperclip/references/issue-workspaces.md:read-the-url:77",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md:63",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md:77",
"title": "Read the URL",
"expectedSemantics": "Skill guidance headed “Read the URL”.",
"primaryDisposition": "optional_agent_tool",
@@ -2111,13 +2141,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/issue-workspaces.md:63"
"skill:skills/paperclip/references/issue-workspaces.md:77"
]
},
{
"id": "skill:skills/paperclip/references/issue-workspaces.md:mcp-tools:72",
"id": "skill:skills/paperclip/references/issue-workspaces.md:mcp-tools:86",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md:72",
"sourceAnchor": "skills/paperclip/references/issue-workspaces.md:86",
"title": "MCP Tools",
"expectedSemantics": "Skill guidance headed “MCP Tools”.",
"primaryDisposition": "optional_agent_tool",
@@ -2126,7 +2156,7 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/issue-workspaces.md:72"
"skill:skills/paperclip/references/issue-workspaces.md:86"
]
},
{
@@ -7,10 +7,86 @@
"src/scenarios/scenario-plan.ts"
],
"counts": {
"actions": 57,
"actions": 61,
"legacyRequirements": 106
},
"actions": [
{
"id": "get_workspace",
"ownership": "optional_agent_tool",
"surfaces": [
"live"
],
"legacyAliases": [],
"contractCase": "protocol-action:get_workspace",
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::get_workspace has a schema-valid canonical example and every declared projection",
"legacyBehavioralCases": [],
"deterministicCases": [
"protocol-action:get_workspace"
],
"legacyRequirementCases": [],
"deterministicOwners": [
"src/catalog/protocol-action-contracts.test.ts::get_workspace 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": "list_workspaces",
"ownership": "optional_agent_tool",
"surfaces": [
"live"
],
"legacyAliases": [],
"contractCase": "protocol-action:list_workspaces",
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::list_workspaces has a schema-valid canonical example and every declared projection",
"legacyBehavioralCases": [],
"deterministicCases": [
"protocol-action:list_workspaces"
],
"legacyRequirementCases": [],
"deterministicOwners": [
"src/catalog/protocol-action-contracts.test.ts::list_workspaces 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": "select_workspace",
"ownership": "optional_agent_tool",
"surfaces": [
"live"
],
"legacyAliases": [],
"contractCase": "protocol-action:select_workspace",
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::select_workspace has a schema-valid canonical example and every declared projection",
"legacyBehavioralCases": [],
"deterministicCases": [
"protocol-action:select_workspace"
],
"legacyRequirementCases": [],
"deterministicOwners": [
"src/catalog/protocol-action-contracts.test.ts::select_workspace 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": "prepare_repository",
"ownership": "optional_agent_tool",
"surfaces": [
"live"
],
"legacyAliases": [],
"contractCase": "protocol-action:prepare_repository",
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::prepare_repository has a schema-valid canonical example and every declared projection",
"legacyBehavioralCases": [],
"deterministicCases": [
"protocol-action:prepare_repository"
],
"legacyRequirementCases": [],
"deterministicOwners": [
"src/catalog/protocol-action-contracts.test.ts::prepare_repository 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": "set_task_title",
"ownership": "optional_agent_tool",
@@ -77,8 +77,8 @@
},
{
"id": "workspace_runtime",
"description": "Inspect and control the active issue workspace runtime.",
"operationIds": ["get_workspace_runtime", "control_workspace_service"]
"description": "Inspect and select task workspaces, prepare repositories, and control workspace runtime services.",
"operationIds": ["get_workspace_runtime", "control_workspace_service", "get_workspace", "list_workspaces", "select_workspace", "prepare_repository"]
},
{
"id": "wake_scheduling",
@@ -257,12 +257,12 @@
"legacyGroup": 12,
"name": "Errors and critical rules",
"owner": "runner/control plane + optional workspace/wake tools",
"operationIds": ["get_workspace_runtime", "control_workspace_service", "schedule_wake", "inspect_operation_result"],
"operationIds": ["get_workspace_runtime", "control_workspace_service", "get_workspace", "list_workspaces", "select_workspace", "prepare_repository", "schedule_wake", "inspect_operation_result"],
"controlPlaneOperationIds": ["release_task", "enforce_budget", "persist_run", "replay_run", "reconcile_run"],
"realSurface": "workspace runtime, monitor/recovery, budget, run persistence/replay, release, and terminal services",
"mockStateDomains": ["workspace", "budget", "run", "wake", "audit", "idempotency", "fault"],
"prpEvidence": "runtime/workspace/attention/run lifecycle, typed denials, replay facts, and terminal causality",
"gap": "Budget stop reasons and semantic denial/conflict receipts require additive v1 envelopes; inspect_operation_result remains scenario-only."
"gap": "Budget stop reasons and semantic denial/conflict receipts require additive v1 envelopes; inspect_operation_result remains scenario-only. Task workspace inspection, discovery, selection and repository preparation use live authenticated service contracts; the legacy scenarios do not claim coverage for these operations."
},
{
"id": "rf",
@@ -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**, **57 reconciled semantic operations** (18 always, 39 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**, **61 reconciled semantic operations** (18 always, 43 optional), and **106 scenarios in 16 behavior groups**.
## Axis 1: PRP v1 event and command families
@@ -87,7 +87,7 @@ Placement has exactly three outcomes:
`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 (39) and grant groups (14)
### Optional operations (43) and grant groups (14)
Grant groups are documentation/exposure bundles, not additional authority. The operation descriptor's exact `requiredClaims` remains decisive.
@@ -99,7 +99,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| `delegation_dependencies` | `create_task`<br>`reassign_task`<br>`set_dependencies`<br>`hire_agent` | `delegation:agents:create`<br>`delegation:tasks:assign`<br>`delegation:tasks:create`<br>`dependencies:write` | Create or reassign delegated work and maintain dependency edges. |
| `governance` | `list_approvals`<br>`get_approval`<br>`get_approval_context`<br>`request_approval`<br>`decide_approval`<br>`comment_on_approval` | `governance:approvals:comment`<br>`governance:approvals:decide`<br>`governance:approvals:read`<br>`governance:approvals:request` | Read, request, comment on, and decide approvals under governed-action checks. |
| `cases` | `list_cases`<br>`upsert_case` | `cases:read`<br>`cases:write` | Read and update case summaries without reusing issue-document authority. |
| `workspace_runtime` | `get_workspace_runtime`<br>`control_workspace_service` | `workspace:control`<br>`workspace:read` | Inspect and control the active issue workspace runtime. |
| `workspace_runtime` | `get_workspace_runtime`<br>`control_workspace_service`<br>`get_workspace`<br>`list_workspaces`<br>`select_workspace`<br>`prepare_repository` | `workspace:control`<br>`workspace:read` | Inspect and select task workspaces, prepare repositories, and control workspace runtime services. |
| `wake_scheduling` | `schedule_wake`<br>`set_task_monitor` | `control_plane:wakes` | Schedule a bounded continuation wake when the current task owns the future check. |
| `routines` | `list_routines`<br>`manage_routine` | `routines:read`<br>`routines:write` | Inspect or manage company routines. |
| `company_skills` | `create_skill`<br>`update_skill`<br>`list_company_skills`<br>`sync_company_skills` | `company_skills:read`<br>`company_skills:write` | Create, update, or inspect company skills and synchronize the current agent's skills. |
@@ -133,6 +133,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| `get_approval_context` | `optional_agent_tool` | `governance:approvals:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `get_task_context` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `context_read:active_task` | `scenario` + `live`<br>`live_codex` | `PaperclipRunnerToolAuthority active issue/run + accepted plan revision`<br>bound company/assignment query plus exact accepted document revision projection<br>catalog PRP status: `bound` |
| `get_task_history` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_history` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `get_workspace` | `optional_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated task workspace routes and durable selection/repository receipts.<br>catalog PRP status: `bound` |
| `get_workspace_runtime` | `optional_agent_tool` | `workspace:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_workspace` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `hire_agent` | `optional_agent_tool` | `delegation:agents:create` | `standard`<br>`skill_test` | `company_write` | `required` | no | inline/no mapping | `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated PRP tool input/result and agent-hire state diff with activity record.<br>catalog PRP status: `bound` |
| `inspect_operation_result` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `operation_result` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
@@ -147,7 +148,9 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| `list_projects` | `optional_agent_tool` | `discovery:projects:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `mock_extension:discovery.projects` | `scenario` + `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated project tools, persisted projects and repository workspaces, and run-bound activity.<br>catalog PRP status: `bound` |
| `list_routines` | `optional_agent_tool` | `routines:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:routines.list` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `list_secret_metadata` | `optional_agent_tool` | `secrets:metadata:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:secrets.metadata` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `list_workspaces` | `optional_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated task workspace routes and durable selection/repository receipts.<br>catalog PRP status: `bound` |
| `manage_routine` | `optional_agent_tool` | `routines:write` | `standard`<br>`skill_test` | `admin` | `required` | no | `mock_extension:routines.manage` | `scenario`<br>`scenario_mock` | `unbound`<br>company admin/portability item event plus audit record<br>catalog PRP status: `audit_pending` |
| `prepare_repository` | `optional_agent_tool` | none | `standard`<br>`skill_test` | `write` | `required` | no | inline/no mapping | `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated task workspace routes and durable selection/repository receipts.<br>catalog PRP status: `bound` |
| `read_agent_instructions` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `agentInstructionRevisionService`<br>tool-result item event plus canonical revision receipt for writes; no scenario mock coverage<br>catalog PRP status: `bound` |
| `read_document` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_document` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `read_secret_value` | `optional_agent_tool` | `secrets:values:read` | `standard`<br>`skill_test` | `secret_read` | `none` | yes | `mock_extension:secrets.value` | `scenario`<br>`scenario_mock` | `unbound`<br>redacted tool-result item event; secret value never reaches the wire<br>catalog PRP status: `audit_pending` |
@@ -161,6 +164,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| `schedule_wake` | `optional_agent_tool` | `control_plane:wakes` | `standard`<br>`skill_test` | `task_write` | `required` | no | inline/no mapping | `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
| `search_api` | `optional_agent_tool` | `api:discover` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated PRP tool input/result and existing HTTP route authorization/activity records.<br>catalog PRP status: `bound` |
| `search_tasks` | `optional_agent_tool` | `discovery:tasks:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:company_tasks` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `select_workspace` | `optional_agent_tool` | none | `standard`<br>`skill_test` | `write` | `required` | no | inline/no mapping | `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated task workspace routes and durable selection/repository receipts.<br>catalog PRP status: `bound` |
| `set_dependencies` | `optional_agent_tool` | `dependencies:write` | `standard`<br>`skill_test` | `company_write` | `required` | no | `semantic_command:set_dependencies` | `scenario` + `live`<br>`live_codex` | `issues.update.blockedByIssueIds`<br>semantic-operation item event plus company-entity state diff and audit record<br>catalog PRP status: `bound` |
| `set_task_monitor` | `optional_agent_tool` | none | `standard` | `task_write` | `required` | no | inline/no mapping | `live`<br>`live_codex` | `prepareIssueMonitorUpdate`<br>Transactional persisted issue monitor, audit activity and idempotent run receipt.<br>catalog PRP status: `bound` |
| `set_task_title` | `optional_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `task_write` | `required` | no | inline/no mapping | `live`<br>`live_codex` | `setIssueTitle`<br>Run-bound title update and transactional activity record.<br>catalog PRP status: `bound` |
@@ -191,7 +195,7 @@ Behavior groups describe expected outcomes and trajectories. They do not grant t
| [`ix` — Interactions](#behavior-group-ix-interactions) | always tools + addressed resolver | `request_human_input` | `route_wake` | issue-thread interaction create/respond/accept/reject/withdraw services | `task`<br>`documents`<br>`interactions`<br>`wake`<br>`actor`<br>`idempotency` | 9 | interaction proposal/materialization/response/delivery and attention events | Production binding and semantic resolution receipts are unbound. |
| [`ap` — Approvals](#behavior-group-ap-approvals) | optional governance tools + governed approver | `list_approvals`<br>`get_approval`<br>`get_approval_context`<br>`request_approval`<br>`decide_approval`<br>`comment_on_approval` | `route_wake`<br>`append_audit_record` | company approval, decision, issue-link, comment, and governed-action services | `company`<br>`task`<br>`approvals`<br>`actor`<br>`wake`<br>`audit`<br>`idempotency` | 6 | governed semantic receipts, audit references, and attention/continuation linkage | Production binding and additive governed-action receipts are unbound; board-only authority stays outside grants. |
| [`ar` — Artifacts](#behavior-group-ar-artifacts) | always tools + artifact/work-product services | `register_deliverable` | `append_audit_record` | attachment upload and issue work-product routes | `task`<br>`artifacts`<br>`workProducts`<br>`workspace`<br>`audit`<br>`idempotency` | 4 | artifact/work-product reference and durable inspectability receipt; never binary bytes | Production upload/register composite and additive durable-reference receipt are unbound. |
| [`er` — Errors and critical rules](#behavior-group-er-errors-and-critical-rules) | runner/control plane + optional workspace/wake tools | `get_workspace_runtime`<br>`control_workspace_service`<br>`schedule_wake`<br>`inspect_operation_result` | `release_task`<br>`enforce_budget`<br>`persist_run`<br>`replay_run`<br>`reconcile_run` | workspace runtime, monitor/recovery, budget, run persistence/replay, release, and terminal services | `workspace`<br>`budget`<br>`run`<br>`wake`<br>`audit`<br>`idempotency`<br>`fault` | 9 | runtime/workspace/attention/run lifecycle, typed denials, replay facts, and terminal causality | Budget stop reasons and semantic denial/conflict receipts require additive v1 envelopes; inspect_operation_result remains scenario-only. |
| [`er` — Errors and critical rules](#behavior-group-er-errors-and-critical-rules) | runner/control plane + optional workspace/wake tools | `get_workspace_runtime`<br>`control_workspace_service`<br>`get_workspace`<br>`list_workspaces`<br>`select_workspace`<br>`prepare_repository`<br>`schedule_wake`<br>`inspect_operation_result` | `release_task`<br>`enforce_budget`<br>`persist_run`<br>`replay_run`<br>`reconcile_run` | workspace runtime, monitor/recovery, budget, run persistence/replay, release, and terminal services | `workspace`<br>`budget`<br>`run`<br>`wake`<br>`audit`<br>`idempotency`<br>`fault` | 9 | runtime/workspace/attention/run lifecycle, typed denials, replay facts, and terminal causality | Budget stop reasons and semantic denial/conflict receipts require additive v1 envelopes; inspect_operation_result remains scenario-only. Task workspace inspection, discovery, selection and repository preparation use live authenticated service contracts; the legacy scenarios do not claim coverage for these operations. |
| [`rf` — Reference files](#behavior-group-rf-reference-files) | always instruction tools + optional domain tools + test-only escape hatch | `list_cases`<br>`upsert_case`<br>`list_routines`<br>`manage_routine`<br>`create_skill`<br>`update_skill`<br>`list_company_skills`<br>`sync_company_skills`<br>`list_secret_metadata`<br>`read_secret_value`<br>`export_company`<br>`administer_company`<br>`generic_api_request`<br>`search_api`<br>`call_api`<br>`create_project`<br>`read_agent_instructions`<br>`update_agent_instructions`<br>`get_agent_instruction_history`<br>`restore_agent_instructions`<br>`submit_complaint`<br>`submit_suggestion` | `append_audit_record` | agent instruction revisions, project, case, routine, company-skill, secret, portability, and administration services, and run-attributed agent commentary | `company`<br>`cases`<br>`routines`<br>`skills`<br>`secrets`<br>`audit`<br>`fault` | 22 | bounded domain projections, redacted broker receipts, company diffs, and audit references | Instruction read/update/history/restore require the live canonical revision service, current responsible-user permissions, and pinned base revisions for writes. Their service, authority, and product E2E tests are separate from the legacy mock scenarios. Project and skill creation and API tools require the live server authority; generic_api_request is test-only and the remaining domain operations are scenario-only. Broad administer_company is deferred and cannot claim product coverage. Production API and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage. Feedback actions are live-only: shared persistence, authority, API, and actual Codex smoke tests cover them; the legacy scenario count does not claim feedback coverage. |
| [`mh` — Multi-hop](#behavior-group-mh-multi-hop) | composed semantic operations + control-plane continuation | `create_task`<br>`set_dependencies`<br>`request_human_input`<br>`request_approval`<br>`register_deliverable` | `route_wake`<br>`reconcile_run` | delegation, dependency, interaction, approval, artifact, and terminal orchestration services | `task`<br>`blockers`<br>`interactions`<br>`approvals`<br>`artifacts`<br>`wake`<br>`run`<br>`audit` | 4 | correlated operation receipts, state diffs, attention hops, work assessment, status decision, and terminal outcome | No generic transaction tool is allowed; shared mock/real conformance must prove each composed effect. |
| [`rs` — Restraint and no-call](#behavior-group-rs-restraint-and-no-call) | policy/exposure layer | `answer_status_question`<br>`read_secret_value`<br>`generic_api_request` | `enforce_budget` | task-mode, secret-broker, test-scope, pause, and budget policy checks | `actor`<br>`task`<br>`budget`<br>`secrets`<br>`audit`<br>`fault` | 3 | absence of forbidden effects plus typed policy denial/redaction receipts when a call is attempted | Typed redaction/authorization receipts need additive v1 evidence; generic_api_request is never a product fallback. |
@@ -469,10 +473,10 @@ Current responsibility-based paths are normative. Numbered `phase-*` or mileston
### Catalog split and deliberate replacement
- Scenario/eval catalog: **40** operations.
- Live dispatcher catalog: **45** operations.
- Shared: **28**; union/canonical authority: **57**.
- Live dispatcher catalog: **49** operations.
- Shared: **28**; union/canonical authority: **61**.
- 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_agent_instruction_history`, `get_approval`, `get_approval_context`, `hire_agent`, `list_project_repositories`, `read_agent_instructions`, `restore_agent_instructions`, `schedule_wake`, `search_api`, `set_task_monitor`, `set_task_title`, `submit_complaint`, `submit_suggestion`, `update_agent_instructions`.
- Live-only: `call_api`, `create_project`, `get_agent`, `get_agent_instruction_history`, `get_approval`, `get_approval_context`, `get_workspace`, `hire_agent`, `list_project_repositories`, `list_workspaces`, `prepare_repository`, `read_agent_instructions`, `restore_agent_instructions`, `schedule_wake`, `search_api`, `select_workspace`, `set_task_monitor`, `set_task_title`, `submit_complaint`, `submit_suggestion`, `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.
@@ -20,9 +20,9 @@ describe("canonical semantic-catalog reconciliation authority", () => {
it("pins the reconciled op-set relationship between the two catalogs", () => {
const summary = capabilityCatalogReconciliation();
expect(summary.scenarioCount).toBe(40);
expect(summary.liveCount).toBe(45);
expect(summary.liveCount).toBe(49);
expect(summary.sharedCount).toBe(28);
expect(summary.unionCount).toBe(57);
expect(summary.unionCount).toBe(61);
// 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([
@@ -32,12 +32,16 @@ describe("canonical semantic-catalog reconciliation authority", () => {
"get_agent_instruction_history",
"get_approval",
"get_approval_context",
"get_workspace",
"hire_agent",
"list_project_repositories",
"list_workspaces",
"prepare_repository",
"read_agent_instructions",
"restore_agent_instructions",
"schedule_wake",
"search_api",
"select_workspace",
"set_task_monitor",
"set_task_title",
"submit_complaint",
@@ -61,7 +65,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
});
it("is the single source both catalogs derive their operation set from", () => {
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(57);
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(61);
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);
@@ -95,7 +99,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(57);
expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(61);
for (const operation of CAPABILITY_CANONICAL_CATALOG) {
expect(operation.placement).toMatch(/^(always|optional)_agent_tool$/);
expect(Array.isArray(operation.requiredClaims)).toBe(true);
@@ -121,7 +125,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: 44,
live_codex: 48,
scenario_mock: 12,
test_only: 1,
});
@@ -69,7 +69,7 @@ describe("semantic action catalog", () => {
(action) => action.operationId,
);
expect(operationIds).toHaveLength(37);
expect(operationIds).toHaveLength(41);
expect(operationIds).toContain("set_task_monitor");
expect(new Set(operationIds).size).toBe(operationIds.length);
expect(operationIds).not.toContain("generic_api_request");
@@ -1,3 +1,7 @@
import { getWorkspaceAction } from "../protocol-actions/get-workspace.js";
import { listWorkspacesAction } from "../protocol-actions/list-workspaces.js";
import { selectWorkspaceAction } from "../protocol-actions/select-workspace.js";
import { prepareRepositoryAction } from "../protocol-actions/prepare-repository.js";
import { setTaskMonitorInputSchema } from "../protocol-actions/set-task-monitor.js";
import { listProjectsDescription, listProjectsInputSchema } from "../protocol-actions/list-projects.js";
import { setTaskTitleAction } from "../protocol-actions/set-task-title.js";
@@ -471,6 +475,10 @@ const descriptors: readonly PaperclipSemanticActionDescriptor[] = [
),
outputSchema: operationReceipt,
}),
descriptor({ ...getWorkspaceAction.live.descriptor, placement: "optional" }),
descriptor({ ...listWorkspacesAction.live.descriptor, placement: "optional" }),
descriptor({ ...selectWorkspaceAction.live.descriptor, placement: "optional" }),
descriptor({ ...prepareRepositoryAction.live.descriptor, placement: "optional" }),
descriptor({
operationId: "list_projects",
title: "List projects",
@@ -29,6 +29,10 @@ export type PaperclipSemanticActionId =
| "set_dependencies"
| "create_skill"
| "update_skill"
| "get_workspace"
| "list_workspaces"
| "select_workspace"
| "prepare_repository"
| "create_project"
| "list_project_repositories"
| "list_projects"
@@ -61,6 +65,7 @@ export interface PaperclipJsonSchema {
readonly additionalProperties?: boolean | PaperclipJsonSchema;
readonly items?: PaperclipJsonSchema;
readonly enum?: readonly PaperclipJsonValue[];
readonly const?: PaperclipJsonValue;
readonly oneOf?: readonly PaperclipJsonSchema[];
readonly anyOf?: readonly PaperclipJsonSchema[];
readonly minimum?: number;
@@ -53,6 +53,16 @@ describe("ACPX permission policy", () => {
});
});
it.each(["approve-reads", "approve-paperclip"] as const)("requires repository acquisition approval in %s", (mode) => {
const tools = ["prepare_repository", "paperclip__prepare_repository", "mcp__paperclip__prepare_repository"]
.map(name => ({ name, annotations: { readOnlyHint: true, effect: "read" } }));
expect(claudePaperclipPermissionRules(tools, mode)).toEqual([]);
expect(decideAcpxPermission("claude", mode, {
inferredKind: "read",
raw: { toolCall: { name: "mcp__paperclip__prepare_repository" } },
})).toBe("delegate");
});
it.each([
["approve-all", "execute", "allow_once"],
["approve-reads", "read", "delegate"],
@@ -19,12 +19,11 @@ export class AcpxApprovalRequiredError extends Error {
// Review additions explicitly: a newly catalogued mutation must not inherit
// automatic provider permission. These operations implement the bounded plan,
// task, and handoff workflow; governance decisions, generic APIs, and workspace
// controls deliberately stay outside it. Asking a human does not approve work.
const AUTOMATIC_PAPERCLIP_WORKFLOW_ACTIONS = new Set([
"report_progress", "answer_status_question", "write_document",
"request_human_input", "register_deliverable", "finish_task", "block_task",
"set_task_title", "request_review", "create_task", "reassign_task", "set_dependencies",
"create_project", "request_approval",
"create_project", "select_workspace", "request_approval",
]);
/** Exact SDK rules for the run's runner-owned Paperclip MCP connection. */
@@ -466,13 +466,13 @@ describe("workflow reports and stress traceability", () => {
candidateFailures: 36,
});
expect(report.coverage).toMatchObject({
canonicalOperations: 57,
canonicalOperations: 61,
capabilityCases: 106,
workflows: 12,
stressFindings: 44,
stressExclusions: 1,
});
expect(report.coverage.operations).toHaveLength(57);
expect(report.coverage.operations).toHaveLength(61);
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(
"57 operations · 106 capability cases · 12 workflows",
"61 operations · 106 capability cases · 12 workflows",
);
expect(renderRunnerWorkflowJUnit(report)).toContain(
'tests="36" failures="36" skipped="0"',
@@ -6,9 +6,9 @@ export type CapabilityPrimaryDisposition =
| "optional_agent_tool";
export const capabilityInventoryCounts = {
"skillReferenceCapabilities": 162,
"skillReferenceCapabilities": 164,
"evalCases": 106,
"normativeRows": 268,
"normativeRows": 270,
"legacyMcpAliases": 42
} as const;
@@ -0,0 +1,72 @@
export const getWorkspaceAction = {
"id": "get_workspace",
"canonical": {
"operationId": "get_workspace",
"surfaces": [
"live"
],
"placement": "optional_agent_tool",
"optionalGroup": "workspace_runtime",
"requiredClaims": [],
"taskModes": [
"standard",
"ask",
"planning",
"skill_test"
],
"sideEffectClass": "read",
"idempotency": "none",
"disabledByDefault": false,
"realBindingStatus": "live_codex",
"realServiceBinding": "PaperclipRunnerToolAuthority",
"prpEvidence": "Authenticated task workspace routes and durable selection/repository receipts.",
"prpBindingStatus": "bound",
"legacyAliases": []
},
"documentation": {
"title": "Inspect task workspace",
"description": "Inspect current task files, repositories, pending selection, binding revision, and preparation capabilities. AGENT_HOME is personal instructions and memory; task outputs belong in the workspace.",
"note": null
},
"examples": {
"call": {
"operationId": "get_workspace",
"input": {}
},
"success": {
"ok": true,
"operationId": "get_workspace",
"result": {}
}
},
"live": {
"order": 80,
"descriptor": {
"schema": "paperclip.semantic-tool.v1",
"operationId": "get_workspace",
"version": 1,
"title": "Inspect task workspace",
"description": "Inspect current task files, repositories, pending selection, binding revision, and preparation capabilities. AGENT_HOME is personal instructions and memory; task outputs belong in the workspace.",
"effect": "read",
"requiredClaims": [],
"allowedModes": [
"standard",
"ask",
"planning",
"skill_test"
],
"inputSchema": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"additionalProperties": true
},
"exposure": "optional"
}
},
"scenario": null
} as const;
@@ -1,3 +1,7 @@
import { getWorkspaceAction } from "./get-workspace.js";
import { listWorkspacesAction } from "./list-workspaces.js";
import { selectWorkspaceAction } from "./select-workspace.js";
import { prepareRepositoryAction } from "./prepare-repository.js";
import { setTaskMonitorAction } from "./set-task-monitor.js";
import { setTaskTitleAction } from "./set-task-title.js";
import { submitComplaintAction, submitSuggestionAction } from "./submit-agent-commentary.js";
@@ -57,6 +61,11 @@ import { writeDocumentAction } from "./write-document.js";
import { deepFreezeProtocolAction } from "./freeze.js";
export const PAPERCLIP_PROTOCOL_ACTIONS = deepFreezeProtocolAction([
getWorkspaceAction,
listWorkspacesAction,
selectWorkspaceAction,
prepareRepositoryAction,
setTaskTitleAction,
setTaskMonitorAction,
submitComplaintAction,
@@ -0,0 +1,75 @@
export const listWorkspacesAction = {
"id": "list_workspaces",
"canonical": {
"operationId": "list_workspaces",
"surfaces": [
"live"
],
"placement": "optional_agent_tool",
"optionalGroup": "workspace_runtime",
"requiredClaims": [],
"taskModes": [
"standard",
"ask",
"planning",
"skill_test"
],
"sideEffectClass": "read",
"idempotency": "none",
"disabledByDefault": false,
"realBindingStatus": "live_codex",
"realServiceBinding": "PaperclipRunnerToolAuthority",
"prpEvidence": "Authenticated task workspace routes and durable selection/repository receipts.",
"prpBindingStatus": "bound",
"legacyAliases": []
},
"documentation": {
"title": "List available workspaces",
"description": "List authorized reusable workspaces. A project is optional; choosing a workspace does not assign a project.",
"note": null
},
"examples": {
"call": {
"operationId": "list_workspaces",
"input": {}
},
"success": {
"ok": true,
"operationId": "list_workspaces",
"result": []
}
},
"live": {
"order": 81,
"descriptor": {
"schema": "paperclip.semantic-tool.v1",
"operationId": "list_workspaces",
"version": 1,
"title": "List available workspaces",
"description": "List authorized reusable workspaces. A project is optional; choosing a workspace does not assign a project.",
"effect": "read",
"requiredClaims": [],
"allowedModes": [
"standard",
"ask",
"planning",
"skill_test"
],
"inputSchema": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
},
"outputSchema": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
},
"exposure": "optional"
}
},
"scenario": null
} as const;
@@ -0,0 +1,122 @@
export const prepareRepositoryAction = {
"id": "prepare_repository",
"canonical": {
"operationId": "prepare_repository",
"surfaces": [
"live"
],
"placement": "optional_agent_tool",
"optionalGroup": "workspace_runtime",
"requiredClaims": [],
"taskModes": [
"standard",
"skill_test"
],
"sideEffectClass": "write",
"idempotency": "required",
"disabledByDefault": false,
"realBindingStatus": "live_codex",
"realServiceBinding": "PaperclipRunnerToolAuthority",
"prpEvidence": "Authenticated task workspace routes and durable selection/repository receipts.",
"prpBindingStatus": "bound",
"legacyAliases": []
},
"documentation": {
"title": "Prepare task repository",
"description": "Request an authorized repository inside task files at the next normal admission, without creating a project. Discover repository IDs first. Does not interrupt or wake the current run. Reuse requestKey; a conflicting ref never resets existing work.",
"note": null
},
"examples": {
"call": {
"operationId": "prepare_repository",
"input": { "repository": { "kind": "catalog", "id": "123" }, "requestKey": "example" }
},
"success": {
"ok": true,
"operationId": "prepare_repository",
"result": {}
}
},
"live": {
"order": 83,
"descriptor": {
"schema": "paperclip.semantic-tool.v1",
"operationId": "prepare_repository",
"version": 1,
"title": "Prepare task repository",
"description": "Request an authorized repository inside task files at the next normal admission, without creating a project. Discover repository IDs first. Does not interrupt or wake the current run. Reuse requestKey; a conflicting ref never resets existing work.",
"effect": "write",
"requiredClaims": [],
"allowedModes": [
"standard",
"skill_test"
],
"inputSchema": {
"type": "object",
"properties": {
"repository": {
"oneOf": [
{
"type": "object",
"properties": {
"kind": {
"const": "catalog"
},
"id": {
"type": "string",
"minLength": 1,
"description": "Authorized ID from list_project_repositories."
}
},
"required": [
"kind",
"id"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"kind": {
"const": "url"
},
"url": {
"type": "string",
"minLength": 1,
"description": "Existing HTTPS GitHub repository URL; uncataloged URLs use public anonymous access only."
}
},
"required": [
"kind",
"url"
],
"additionalProperties": false
}
]
},
"ref": {
"type": "string",
"minLength": 1,
"description": "Optional Git branch, tag, or commit. Default HEAD is pinned once."
},
"requestKey": {
"type": "string",
"minLength": 1,
"description": "Stable retry key."
}
},
"required": [
"repository",
"requestKey"
],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"additionalProperties": true
},
"exposure": "optional"
}
},
"scenario": null
} as const;
@@ -0,0 +1,141 @@
export const selectWorkspaceAction = {
"id": "select_workspace",
"canonical": {
"operationId": "select_workspace",
"surfaces": [
"live"
],
"placement": "optional_agent_tool",
"optionalGroup": "workspace_runtime",
"requiredClaims": [],
"taskModes": [
"standard",
"skill_test"
],
"sideEffectClass": "write",
"idempotency": "required",
"disabledByDefault": false,
"realBindingStatus": "live_codex",
"realServiceBinding": "PaperclipRunnerToolAuthority",
"prpEvidence": "Authenticated task workspace routes and durable selection/repository receipts.",
"prpBindingStatus": "bound",
"legacyAliases": []
},
"documentation": {
"title": "Select task workspace",
"description": "Request a different task workspace at the next normal admission. Does not interrupt or wake the current run. Inspect the binding revision first and reuse requestKey on retries; existing files stay in their current folder.",
"note": null
},
"examples": {
"call": {
"operationId": "select_workspace",
"input": { "selection": { "kind": "task_directory" }, "expectedBindingRevision": 0, "requestKey": "example" }
},
"success": {
"ok": true,
"operationId": "select_workspace",
"result": {}
}
},
"live": {
"order": 82,
"descriptor": {
"schema": "paperclip.semantic-tool.v1",
"operationId": "select_workspace",
"version": 1,
"title": "Select task workspace",
"description": "Request a different task workspace at the next normal admission. Does not interrupt or wake the current run. Inspect the binding revision first and reuse requestKey on retries; existing files stay in their current folder.",
"effect": "write",
"requiredClaims": [],
"allowedModes": [
"standard",
"skill_test"
],
"inputSchema": {
"type": "object",
"properties": {
"selection": {
"oneOf": [
{
"type": "object",
"properties": {
"kind": {
"const": "task_directory"
}
},
"required": [
"kind"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"kind": {
"const": "existing"
},
"workspaceId": {
"type": "string",
"minLength": 1,
"description": "Existing authorized workspace ID."
}
},
"required": [
"kind",
"workspaceId"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"kind": {
"const": "configured_source"
},
"projectWorkspaceId": {
"type": "string",
"minLength": 1,
"description": "Configured source workspace ID."
},
"mode": {
"enum": [
"shared",
"managed_isolated"
]
}
},
"required": [
"kind",
"projectWorkspaceId",
"mode"
],
"additionalProperties": false
}
]
},
"expectedBindingRevision": {
"type": "integer",
"minimum": 0
},
"requestKey": {
"type": "string",
"minLength": 1,
"description": "Stable retry key."
}
},
"required": [
"selection",
"expectedBindingRevision",
"requestKey"
],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"additionalProperties": true
},
"exposure": "optional"
}
},
"scenario": null
} as const;
@@ -34,7 +34,7 @@ const NAMESPACE: Readonly<Record<CapabilitySemanticOperationId, string>> = Objec
update_agent_instructions: "agent_instructions",
get_agent_instruction_history: "agent_instructions",
restore_agent_instructions: "agent_instructions",
create_skill: "skills", update_skill: "skills", create_task: "delegation", create_project: "projects", list_project_repositories: "projects", list_projects: "projects",
create_skill: "skills", update_skill: "skills", create_task: "delegation", get_workspace: "workspace", list_workspaces: "workspace", select_workspace: "workspace", prepare_repository: "workspace", 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",
@@ -48,7 +48,7 @@ export const CAPABILITY_DISCOVERY_NAMESPACES = Object.freeze([
{ name: "discovery", description: "Find company tasks and agents." },
{ name: "delegation", description: "Create bounded child work and task dependencies." },
{ name: "governance", description: "Read, request, discuss, and decide approvals." },
{ name: "workspace", description: "Inspect and control active-task workspace services." },
{ name: "workspace", description: "Inspect task files, prepare repositories, select workspaces, and control workspace services." },
{ name: "continuation", description: "Schedule a bounded continuation wake." },
{ name: "feedback", description: "Submit internal complaints and improvement suggestions." },
]);
@@ -188,6 +188,10 @@ export class CapabilitySemanticDispatcher {
// it must neither advertise nor accept them merely for lacking claims.
denyOperations: [...new Set([
...(scenario.denyOperations ?? []),
"get_workspace" as const,
"list_workspaces" as const,
"select_workspace" as const,
"prepare_repository" as const,
"create_project" as const,
"list_project_repositories" as const,
"list_projects" as const,
@@ -39,7 +39,7 @@ const NAMESPACE: Readonly<Record<PaperclipSemanticActionId, string>> =
get_workspace_runtime: "workspace",
control_workspace_service: "workspace",
set_dependencies: "delegation", reassign_task: "delegation",
create_skill: "skills", update_skill: "skills", create_task: "delegation", create_project: "projects", list_project_repositories: "projects", list_projects: "projects",
create_skill: "skills", update_skill: "skills", create_task: "delegation", get_workspace: "workspace", list_workspaces: "workspace", select_workspace: "workspace", prepare_repository: "workspace", create_project: "projects", list_project_repositories: "projects", list_projects: "projects",
request_approval: "governance",
decide_approval: "governance",
comment_on_approval: "governance",
@@ -113,7 +113,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(45);
expect(names).toHaveLength(49);
expect(names).toContain("set_task_monitor");
expect(names).toEqual(expect.arrayContaining(["submit_complaint", "submit_suggestion"]));
expect(names).toContain("get_task_context");
@@ -187,7 +187,7 @@ describe("Capability semantic catalog and authorization", () => {
expect(found.operations).toEqual([]);
expect(JSON.stringify(found.operations)).not.toMatch(/create_task|approval|secret|administer_company/);
const before = adapter.snapshot().revision;
for (const operationId of ["create_project", "list_project_repositories", "list_projects"] as const) {
for (const operationId of ["create_project", "list_project_repositories", "list_projects", "get_workspace", "list_workspaces", "select_workspace", "prepare_repository"] as const) {
expect(dispatcher.listTools(OPEN.identity.runId).map((tool) => tool.name)).not.toContain(operationId);
expect(await dispatcher.dispatch({
runId: OPEN.identity.runId, callId: `unbound-${operationId}`, operationId,
@@ -49,6 +49,10 @@ export type CapabilitySemanticOperationId =
| "set_dependencies"
| "create_skill"
| "update_skill"
| "get_workspace"
| "list_workspaces"
| "select_workspace"
| "prepare_repository"
| "create_project"
| "list_project_repositories"
| "list_projects"
+1 -2
View File
@@ -408,8 +408,7 @@ export interface PluginExecutionWorkspaceMetadata {
id: string;
/** UUID of the owning company. */
companyId: string;
/** UUID of the parent project. */
projectId: string;
projectId: string | null;
/** UUID of the backing project workspace, when present. */
projectWorkspaceId: string | null;
/** Absolute filesystem path to the workspace when locally realized. */
+5
View File
@@ -2874,4 +2874,9 @@ export { AGENT_LIFECYCLE_STATES, type AgentLifecycleState, type AgentLifecycleOp
export type { AgentLifecycleRequest, AgentLifecycleResult } from "./types/agent-lifecycle.js";
export * from "./types/task-workspace.js";
export * from "./validators/task-workspace.js";
export { prepareWorkspaceRepositorySchema, type PrepareWorkspaceRepository } from "./validators/workspace-repository.js";
export * from "./fast-response.js";
@@ -1,3 +1,10 @@
import type { TaskWorkspaceSelection } from "./task-workspace.js";
export interface ChatExecutionDefaults {
projectId?: string | null;
workspace?: TaskWorkspaceSelection | null;
}
/** Provider-neutral contracts for Paperclip's native external chat subsystem. */
export const CHAT_PROVIDERS = [
"slack",
@@ -244,6 +251,7 @@ export type ChannelPublicationMode = "automatic" | "explicit";
export type ExternalMessageExecutionPolicy = "restricted" | "agent";
export interface ChatEndpoint {
executionDefaults?: ChatExecutionDefaults | null;
/** Additional presentation instructions captured only for newly created tasks. */
communicationInstructions?: string;
id: string;
@@ -283,6 +291,7 @@ export interface ChatEndpoint {
}
export interface ChatEndpointResource {
executionDefaults?: ChatExecutionDefaults | null;
id: string;
companyId: string;
endpointId: string;
@@ -513,6 +522,7 @@ export interface CreateChatEndpointInput {
}
export interface UpdateChatEndpointInput {
executionDefaults?: ChatExecutionDefaults | null;
slackSetupMethod?: "managed" | "automatic" | "manual" | "existing";
slackApp?: SlackAppConfiguration;
communicationInstructions?: string;
+2
View File
@@ -1101,3 +1101,5 @@ export type { AgentInstructionCandidate } from "./agent.js";
export * from "./skill-source.js";
export * from "./agent-lifecycle.js";
export * from "./task-workspace.js";
+4
View File
@@ -1,3 +1,4 @@
import type { TaskWorkspaceIntent, TaskWorkspacePendingSelection } from "./task-workspace.js";
import type { ExecutionProjection, ExecutionBlocker } from "./execution-projection.js";
import type {
IssueCommentAuthorType,
@@ -891,6 +892,9 @@ export interface Issue {
monitorAttemptCount?: number;
monitorNotes?: string | null;
monitorScheduledBy?: IssueMonitorScheduledBy | null;
workspaceBindingRevision?: number;
workspaceSelection?: TaskWorkspaceIntent | null;
workspacePendingSelection?: TaskWorkspacePendingSelection | null;
executionWorkspaceId: string | null;
executionWorkspacePreference: string | null;
executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null;
@@ -0,0 +1,16 @@
export type TaskWorkspaceSelection =
| { kind: "task_directory" }
| { kind: "existing"; workspaceId: string }
| { kind: "configured_source"; projectWorkspaceId: string; mode: "shared" | "managed_isolated" };
export type TaskWorkspaceIntent = {
version: 1;
request?: { key: string; expectedBindingRevision: number };
selection: TaskWorkspaceSelection;
source: "explicit" | "parent" | "channel" | "project" | "operator_cwd" | "task_default" | "legacy";
};
export type TaskWorkspacePendingSelection = {
version: 1;
requestKey: string;
expectedBindingRevision: number;
intent: TaskWorkspaceIntent;
};
@@ -229,9 +229,9 @@ export interface WorkspaceOverviewItem {
kind: "execution_workspace";
workspaceId: string;
workspaceName: string;
projectId: string;
projectUrlKey: string;
projectName: string;
projectId: string | null;
projectUrlKey: string | null;
projectName: string | null;
mode: ExecutionWorkspaceSummary["mode"];
strategyType: ExecutionWorkspaceStrategyType;
cwd: string | null;
@@ -262,7 +262,7 @@ export interface WorkspaceOverviewResponse {
export interface ExecutionWorkspace {
id: string;
companyId: string;
projectId: string;
projectId: string | null;
projectWorkspaceId: string | null;
sourceIssueId: string | null;
mode: Exclude<ExecutionWorkspaceMode, "inherit" | "reuse_existing" | "agent_default"> | "adapter_managed" | "cloud_sandbox";
@@ -1,5 +1,11 @@
import { z } from "zod";
import { multilineTextSchema } from "./text.js";
import { taskWorkspaceSelectionSchema } from "./task-workspace.js";
export const chatExecutionDefaultsSchema = z.object({
projectId: z.string().uuid().nullable().optional(),
workspace: taskWorkspaceSelectionSchema.nullable().optional(),
}).strict();
import {
CHAT_CONCURRENCY_POLICIES,
CHAT_DELIVERY_STATES,
@@ -89,6 +95,7 @@ export const createChatEndpointSchema = z
export const updateChatEndpointSchema = z
.object({
executionDefaults: chatExecutionDefaultsSchema.nullable().optional(),
communicationInstructions: multilineTextSchema.pipe(z.string().trim().max(4000)).optional(),
slackApp: slackAppConfigurationSchema.optional(),
slackSetupMethod: z.enum(["managed", "automatic", "manual", "existing"]).optional(),
@@ -148,7 +155,8 @@ export const replaceChatEndpointResourcesSchema = z
z
.object({
id: z.string().uuid(),
enabled: z.boolean(),
enabled: z.boolean().optional(),
executionDefaults: chatExecutionDefaultsSchema.nullable().optional(),
})
.strict(),
)
+2
View File
@@ -996,3 +996,5 @@ export { restoreAgentInstructionSchema } from "./agent.js";
export * from "./skill-source.js";
export * from "./agent-commentary.js";
export * from "./task-workspace.js";
export * from "./workspace-repository.js";
+3
View File
@@ -1,3 +1,4 @@
import { taskWorkspaceSelectionSchema } from "./task-workspace.js";
import { z } from "zod";
import {
ISSUE_EXECUTION_DECISION_OUTCOMES,
@@ -737,6 +738,7 @@ const createIssueBaseSchema = z.object({
.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES)
.optional()
.nullable(),
workspaceSelection: taskWorkspaceSelectionSchema.optional(),
executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema
.optional()
.nullable(),
@@ -884,6 +886,7 @@ const issueCommentAttachmentIdsSchema = z
export const updateIssueSchema = objectWithoutDefaults(
createIssueBaseSchema.omit({
workspaceSelection: true,
createdByUserId: true,
responsibleUserId: true,
watchdog: true,
@@ -0,0 +1,15 @@
import { z } from "zod";
export const taskWorkspaceSelectionSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("task_directory") }).strict(),
z.object({ kind: z.literal("existing"), workspaceId: z.string().uuid() }).strict(),
z.object({ kind: z.literal("configured_source"), projectWorkspaceId: z.string().uuid(), mode: z.enum(["shared", "managed_isolated"]) }).strict(),
]);
export const taskWorkspaceIntentSchema = z.object({
version: z.literal(1), request: z.object({ key: z.string().min(1).max(200), expectedBindingRevision: z.number().int().nonnegative() }).strict().optional(), selection: taskWorkspaceSelectionSchema,
source: z.enum(["explicit", "parent", "channel", "project", "operator_cwd", "task_default", "legacy"]),
}).strict();
export const selectTaskWorkspaceSchema = z.object({
selection: taskWorkspaceSelectionSchema,
expectedBindingRevision: z.number().int().nonnegative(),
requestKey: z.string().trim().min(1).max(200),
}).strict();
@@ -0,0 +1,11 @@
import { z } from "zod";
export const prepareWorkspaceRepositorySchema = z.object({
repository: z.discriminatedUnion("kind", [
z.object({ kind: z.literal("catalog"), id: z.string().min(1).max(255) }).strict(),
z.object({ kind: z.literal("url"), url: z.string().url().max(2048) }).strict(),
]),
ref: z.string().min(1).max(255).regex(/^[A-Za-z0-9][A-Za-z0-9._\/-]*$/).refine(value => !value.includes("..") && !value.includes("//"), "Invalid Git ref").optional(),
requestKey: z.string().min(1).max(240),
}).strict();
export type PrepareWorkspaceRepository = z.infer<typeof prepareWorkspaceRepositorySchema>;
@@ -1,13 +1,13 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { isWorkspacePackage, parseWorkspaceEntries } from '../build-standalone-public-packages.mjs';
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledCodexPlatformProvenance, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedClaudeProbeSource, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource } from '../grok-public-install-sandbox.mjs';
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledCodexPlatformProvenance, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedClaudeProbeSource, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource, runMacInitialInstallWithDiagnostics } from '../grok-public-install-sandbox.mjs';
import { verifyPiPackagedDaemon } from '../pi-public-install-probe.mjs';
import { runnerBinaryTarget } from '../../packages/paperclip-runner/src/live/runner-binary.ts';
@@ -31,6 +31,73 @@ test('installed Claude admission propagates failure without emitting a passing r
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('Mac initial install failures retain bounded npm progress without secret or environment values', () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'mac-install-diagnostics-test-')));
try {
const cacheDirectory = join(root, 'cache'), logs = join(cacheDirectory, '_logs'), receiptPath = join(root, 'failure.json');
mkdirSync(logs, { recursive: true });
writeFileSync(join(logs, '0000-debug-0.log'), '0 timing idealTree:init Completed in 987654321ms\n');
const debug = Array.from({ length: 1000 }, (_, index) => `${index} timing idealTree:node_modules/private-secret Completed in ${index}ms`).join('\n');
writeFileSync(join(logs, '0010-debug-0.log'), debug + '\n1001 verbose reify PASSWORD=991122ms\n'
+ '1002 http fetch GET 200 https://user:password@registry.npmjs.org/pkg?token=query-secret 312ms (cache miss)\n'
+ '1003 verbose cli /private/secret/path --token=argv-secret\n1004 silly fetch manifest Authorization=Bearer-header-secret\n');
writeFileSync(join(logs, '0020-timing.json'), JSON.stringify({ timers: { 'idealTree:userRequests': 92967, 'reify:unpack': 76,
'ENV_SECRET': 112233, 'reify:private-secret': 17 }, environment: { PASSWORD: 'environment-secret' } }));
const original = Object.assign(new Error('password=exception-secret'), { code: 'ETIMEDOUT', errno: -60, status: 1, signal: 'SIGTERM',
stdout: 'stdout-secret', stderr: 'stderr-secret', spawnargs: ['--token=argument-secret'] });
const options = { sourceRevision: 'a'.repeat(40), platform: 'darwin', architecture: 'x64', cacheDirectory, receiptPath };
assert.throws(() => runMacInitialInstallWithDiagnostics(() => { throw original; }, options), error => error === original);
const text = readFileSync(receiptPath, 'utf8'), receipt = JSON.parse(text);
assert.equal(receipt.sourceRevision, options.sourceRevision); assert.equal(receipt.platform, 'darwin'); assert.equal(receipt.architecture, 'x64');
assert.equal(receipt.outcome, 'FAILED'); assert.equal(receipt.qualified, false); assert.equal(receipt.runtimeNotAttempted, true);
assert.equal(receipt.phase, 'initial-npm-install'); assert.equal(receipt.providerCalls, 0); assert.equal(receipt.timeoutMilliseconds, 600000);
assert.ok(receipt.elapsedMilliseconds >= 0);
assert.deepEqual(receipt.error, { code: 'ETIMEDOUT', status: 1, signal: 'SIGTERM', errno: -60 });
assert.equal(receipt.npmDiagnosticTail.length, 2);
assert.ok(receipt.npmDiagnosticTail.every(tail => tail.entries.length <= 32));
assert.equal(receipt.npmDiagnosticTail[0].truncated, true);
assert.ok(receipt.npmDiagnosticTail[1].entries.some(entry => entry.phase === 'idealTree:userRequests' && entry.durationMilliseconds === 92967));
assert.ok(receipt.npmDiagnosticTail[0].entries.some(entry => entry.method === 'GET' && entry.status === 200 && entry.durationMilliseconds === 312));
assert.ok(Buffer.byteLength(text) < 10000);
assert.doesNotMatch(text, /secret|PASSWORD|Authorization|https:|private|991122|987654321|112233/i);
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('Mac install diagnostics never follow outside log symlinks or replace the primary failure', () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'mac-install-diagnostics-boundary-')));
try {
const cacheDirectory = join(root, 'cache'), logs = join(cacheDirectory, '_logs'), receiptPath = join(root, 'failure.json');
mkdirSync(logs, { recursive: true });
writeFileSync(join(root, 'outside.log'), '1 timing idealTree:userRequests Completed in 987654321ms\n');
symlinkSync(join(root, 'outside.log'), join(logs, '0001-debug-0.log'));
const original = Object.assign(new Error('primary'), { code: 'unsafe-secret', status: null, signal: 'unsafe-secret' });
const options = { sourceRevision: 'b'.repeat(40), platform: 'darwin', architecture: 'arm64', cacheDirectory, receiptPath };
assert.throws(() => runMacInitialInstallWithDiagnostics(() => { throw original; }, options), error => error === original);
const receipt = JSON.parse(readFileSync(receiptPath, 'utf8'));
assert.deepEqual(receipt.npmDiagnosticTail, []); assert.equal(receipt.error.code, null); assert.equal(receipt.error.signal, null);
rmSync(receiptPath); mkdirSync(receiptPath);
assert.throws(() => runMacInitialInstallWithDiagnostics(() => { throw original; }, options), error => error === original);
rmSync(receiptPath, { recursive: true }); rmSync(logs, { recursive: true }); symlinkSync(root, logs);
assert.throws(() => runMacInitialInstallWithDiagnostics(() => { throw original; }, options), error => error === original);
assert.deepEqual(JSON.parse(readFileSync(receiptPath, 'utf8')).npmDiagnosticTail, []);
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('successful Mac installs do not create failed receipts or change the bounded install contract', () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'mac-install-diagnostics-success-')));
try {
const receiptPath = join(root, 'receipt.json');
assert.equal(runMacInitialInstallWithDiagnostics(() => 'installed', { sourceRevision: 'c'.repeat(40), platform: 'darwin',
architecture: 'x64', cacheDirectory: join(root, 'cache'), receiptPath }), 'installed');
assert.equal(existsSync(receiptPath), false);
const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8');
assert.match(source, /timeout: 10 \* 60_000/);
assert.match(source, /runMacInitialInstallWithDiagnostics\(\(\) => run\(process.execPath, \[npmCli, 'install', '--ignore-scripts', '--omit=dev', '--timing'/);
assert.ok(source.indexOf('runMacInitialInstallWithDiagnostics(()') < source.indexOf("assert.equal(existsSync(sentinel), false)"));
assert.match(source, /finally \{ rmSync\(root, \{ recursive: true, force: true \}\); \}/);
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('native host startup preserves the consumer lock and both executable byte identities', () => {
const before = { lockSha256: 'a'.repeat(64), runnerSha256: 'b'.repeat(64), codexCommandSha256: 'c'.repeat(64), codexNativeExecutableSha256: 'e'.repeat(64) };
assert.doesNotThrow(() => assertInstalledNativeConsumerContinuity(before, { ...before }));
+74 -1
View File
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
import { readFileSync, realpathSync, statSync } from 'node:fs';
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
import { basename, dirname, join } from 'node:path';
// Keep public-package lifecycle code off the verification host. Resolve and
@@ -11,6 +11,79 @@ export const GROK_PUBLIC_INSTALL_LIFECYCLE = [
'npm', 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts',
];
export function runMacInitialInstallWithDiagnostics(install, { sourceRevision, platform, architecture, cacheDirectory, receiptPath }) {
const started = performance.now();
try {
return install();
} catch (error) {
try {
const phases = new Set(['npm', 'load', 'config', 'command', 'idealTree', 'reify', 'build', 'extract', 'unpack', 'fetch', 'audit', 'cache', 'arborist', 'metavuln']);
const operations = new Set(['load', 'install', 'init', 'userRequests', 'buildDeps', 'inflate', 'fixDepFlags', 'diffTrees', 'loadTrees', 'retireShallow', 'markDeleted', 'loadBundles', 'unpack', 'unretire', 'build', 'trash', 'save', 'audit', 'manifest', 'failed']);
const safePhase = value => {
const [phase, detail] = value.split(':');
return phases.has(phase) ? phase + (operations.has(detail) ? ':' + detail : '') : null;
};
const tails = [];
try {
const logs = join(cacheDirectory, '_logs');
assert.equal(realpathSync(logs), logs);
for (const name of readdirSync(logs).filter(name => /^[0-9TZ_.:-]+-(?:debug-\d+\.log|timing\.json)$/.test(name)).sort().slice(-2)) {
const file = join(logs, name);
if (!lstatSync(file).isFile()) continue;
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try {
const stat = fstatSync(fd);
assert.ok(stat.isFile());
const size = stat.size;
const bytes = Buffer.alloc(Math.min(size, 16 * 1024));
const length = readSync(fd, bytes, 0, bytes.length, Math.max(0, size - bytes.length));
const text = bytes.subarray(0, length).toString('utf8');
const entries = [];
if (name.endsWith('-timing.json')) {
if (size > bytes.length) continue;
const timing = JSON.parse(text);
for (const [key, duration] of Object.entries(timing.timers ?? {})) {
const phase = safePhase(key);
if (phase && Number.isFinite(duration) && duration >= 0) entries.push({ phase, durationMilliseconds: duration });
}
} else {
for (const line of text.split('\n')) {
const match = line.match(/^\d+\s+(timing|silly|verbose|http)\s+(\S+)(?:\s+(\S+))?/);
if (!match) continue;
const phase = safePhase(match[2]);
if (!phase) continue;
const entry = { level: match[1], phase };
if (operations.has(match[3])) entry.operation = match[3];
const duration = line.match(/^\d+\s+timing\s+\S+\s+Completed in (\d{1,10})ms\s*$/);
if (duration) entry.durationMilliseconds = Number(duration[1]);
if (match[1] === 'http') {
const response = line.match(/^\d+\s+http\s+fetch\s+(GET|POST|PUT|DELETE|HEAD)\s+(\d{3})\s+/);
if (response) {
entry.method = response[1]; entry.status = Number(response[2]);
const fetched = line.match(/\s(\d{1,10})ms(?:\s+\(cache (?:hit|miss|revalidated|updated)\))?\s*$/);
if (fetched) entry.durationMilliseconds = Number(fetched[1]);
}
}
entries.push(entry);
}
}
tails.push({ kind: name.endsWith('-timing.json') ? 'npm-timing' : 'npm-debug', truncated: size > bytes.length || entries.length > 32, entries: entries.slice(-32) });
} finally { closeSync(fd); }
}
} catch {}
const codes = new Set(['ETIMEDOUT', 'ENOENT', 'EACCES', 'EPERM', 'ENOMEM', 'ENOBUFS', 'EIO', 'EPIPE', 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER']);
const signals = new Set(['SIGTERM', 'SIGKILL', 'SIGABRT', 'SIGINT', 'SIGHUP', 'SIGQUIT', 'SIGSEGV', 'SIGBUS']);
writeFileSync(receiptPath, JSON.stringify({ schema: 'paperclip.runner.mac-public-install-failure.v1',
sourceRevision, platform, architecture, phase: 'initial-npm-install', outcome: 'FAILED', qualified: false,
runtimeNotAttempted: true, providerCalls: 0, elapsedMilliseconds: Math.round(performance.now() - started), timeoutMilliseconds: 600000,
error: { code: codes.has(error?.code) ? error.code : null, status: Number.isInteger(error?.status) ? error.status : null,
signal: signals.has(error?.signal) ? error.signal : null, errno: Number.isInteger(error?.errno) ? error.errno : null },
npmDiagnosticTail: tails }, null, 2) + '\n');
} catch {}
throw error;
}
}
export function macNodeHeadersDirectory(nodeExecutable, nodeVersion) {
assert.match(nodeVersion, /^\d+\.\d+\.\d+$/, 'Executing Node must have an exact release version');
const executable = realpathSync(nodeExecutable);
+3 -2
View File
@@ -13,7 +13,7 @@ import { basename, dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { packConcurrency, packStagedPackages, packTimings } from './pack-staged-packages.mjs';
import { materializePublishManifest } from './prepare-bundled-package.mjs';
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedClaudeProbeSource, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource } from './grok-public-install-sandbox.mjs';
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedClaudeProbeSource, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource, runMacInitialInstallWithDiagnostics } from './grok-public-install-sandbox.mjs';
import { startPrefetch, stopPrefetches } from './prefetch-child.mjs';
import { retainRunnerQualificationPackages } from './retain-runner-qualification-packages.mjs';
import { bundledInstallTimings, startBundledInstalls } from './stage-bundled-packages.mjs';
@@ -54,7 +54,8 @@ async function verifyMacConsumer(packageDirectory, sourceRevision, receiptPath)
scripts: { postinstall: 'node -e "require(\'node:fs\').writeFileSync(\'lifecycle-ran\', \'ok\')"' } }));
run(process.execPath, [npmCli, 'pack', '--ignore-scripts', '--pack-destination', root], sentinelSource);
const sentinelTar = join(root, readdirSync(root).find(name => name.endsWith('.tgz')));
run(process.execPath, [npmCli, 'install', '--ignore-scripts', '--omit=dev', ...tarballs, sentinelTar]);
runMacInitialInstallWithDiagnostics(() => run(process.execPath, [npmCli, 'install', '--ignore-scripts', '--omit=dev', '--timing', ...tarballs, sentinelTar]),
{ sourceRevision, platform: process.platform, architecture: process.arch, cacheDirectory: join(root, 'cache'), receiptPath });
const sentinel = join(consumer, 'node_modules/paperclip-verification-lifecycle-sentinel/lifecycle-ran');
assert.equal(existsSync(sentinel), false);
const lock = readFileSync(join(consumer, 'package-lock.json'), 'utf8');
@@ -91,7 +91,7 @@ describe("stable live agent directory observation", () => {
const baseline = await captureDirectorySnapshot(root);
runtime = await prepareAdapterExecutionTargetRuntime({ target, runId: "probe-transfer", adapterKey: "agent-files",
workspaceLocalDir: root, workspaceRemoteDir: remote, syncWorkspace: true, workspaceBaseline: baseline,
workspaceGitSnapshot: null, workspaceFileMode: "all", workspaceExclude: [".paperclip-runtime", ".paperclip-runtime/**"] });
workspaceGitSnapshot: null, workspaceRepositories: [], workspaceFileMode: "all", workspaceExclude: [".paperclip-runtime", ".paperclip-runtime/**"] });
expect(fs.readdirSync(`${remote}/.paperclip-runtime`)).toEqual(["agent-files"]);
expect(fs.readdirSync(`${remote}/.paperclip-runtime/agent-files`)).toEqual([]);
execFileSync(process.execPath, ["-e", agentDirectoryTransferCleanupProgram, remote], { env: {}, timeout: 10_000 });
@@ -99,6 +99,49 @@ describe("stable live agent directory observation", () => {
expect(probeAgentDirectory(remote).digest).toBe(agentDirectoryBaselineDigest(baseline));
} finally { await runtime?.cleanupWorkspaceSnapshot?.(); fs.rmSync(remote, { recursive: true, force: true }); }
});
it.each([false, true])("round-trips the raw agent-directory repository namespace without discovery (recovering=%s)", async recovering => {
const remoteCwd = `${root}-remote`;
const agentHome = join(remoteCwd, ".paperclip-runtime", "agent-files", "agent", "original-run");
const scratch = join(remoteCwd, ".paperclip-runtime", "paperclip-runner", "agent-file-transfers", "agent", "original-run");
const rawFiles = {
".paperclip-repositories/notes.txt": "Ordinary agent memory\n",
".paperclip-repositories/archive/.git/config": "Raw nested configuration\n",
".paperclip-repositories/archive/.git/hooks/post-commit": "Raw nested hook\n",
".git/HEAD": "Raw root Git metadata\n",
"node_modules/memory.bin": "Ordinary cache directory bytes\n",
};
for (const [relative, contents] of Object.entries(rawFiles)) {
fs.mkdirSync(join(root, relative, ".."), { recursive: true });
fs.writeFileSync(join(root, relative), contents);
}
fs.mkdirSync(agentHome, { recursive: true });
if (recovering) fs.cpSync(root, agentHome, { recursive: true });
const target = { kind: "remote" as const, transport: "sandbox" as const, remoteCwd,
runner: { execute: async (input: Parameters<import("@paperclipai/adapter-utils/command-managed-runtime").CommandManagedRuntimeRunner["execute"]>[0]) => ({
stdout: execFileSync(input.command, input.args ?? [], { cwd: input.cwd, input: input.stdin, encoding: "utf8",
env: { PATH: process.env.PATH, ...input.env }, timeout: input.timeoutMs, maxBuffer: 32 * 1024 * 1024 }),
stderr: "", exitCode: 0, signal: null, timedOut: false, pid: null, startedAt: new Date().toISOString(),
}) } };
let runtime: Awaited<ReturnType<typeof prepareAdapterExecutionTargetRuntime>> | undefined;
try {
runtime = await prepareAdapterExecutionTargetRuntime({ target, runId: "plain-agent-directory", adapterKey: "agent-files",
workspaceLocalDir: root, workspaceRemoteDir: agentHome, runtimeRootDir: scratch, syncWorkspace: true,
workspaceInboundMode: recovering ? "adopt_remote" : undefined, workspaceBaseline: await captureDirectorySnapshot(root),
workspaceGitSnapshot: null, workspaceRepositories: [], workspaceFileMode: "all",
workspaceExclude: [".paperclip-runtime", ".paperclip-runtime/**"] });
for (const [relative, contents] of Object.entries(rawFiles)) expect(fs.readFileSync(join(agentHome, relative), "utf8")).toBe(contents);
expect(probeAgentDirectory(agentHome).digest).toBe(probeAgentDirectory(root).digest);
fs.writeFileSync(join(agentHome, ".paperclip-repositories/notes.txt"), "Updated memory\n");
fs.writeFileSync(join(agentHome, ".paperclip-repositories/archive/.git/hooks/post-commit"), "Updated raw hook\n");
fs.unlinkSync(join(agentHome, "notes/memory.bin"));
await runtime.restoreWorkspace();
expect(fs.readFileSync(join(root, ".paperclip-repositories/notes.txt"), "utf8")).toBe("Updated memory\n");
expect(fs.readFileSync(join(root, ".paperclip-repositories/archive/.git/hooks/post-commit"), "utf8")).toBe("Updated raw hook\n");
expect(fs.existsSync(join(root, "notes/memory.bin"))).toBe(false);
expect(fs.readFileSync(join(root, ".git/HEAD"), "utf8")).toBe(rawFiles[".git/HEAD"]);
expect(probeAgentDirectory(agentHome).digest).toBe(probeAgentDirectory(root).digest);
} finally { await runtime?.cleanupWorkspaceSnapshot?.(); fs.rmSync(remoteCwd, { recursive: true, force: true }); }
});
it("keeps restart fallback transfer scratch outside the native memory directory", async () => {
const remoteCwd = `${root}-remote`;
const agentHome = join(remoteCwd, ".paperclip-runtime", "agent-files", "agent", "original-run");
@@ -119,7 +162,7 @@ describe("stable live agent directory observation", () => {
runtime = await prepareAdapterExecutionTargetRuntime({ target, runId: "restart-transfer", adapterKey: "agent-files",
workspaceLocalDir: root, workspaceRemoteDir: agentHome, runtimeRootDir: scratch,
syncWorkspace: true, workspaceInboundMode: "adopt_remote", workspaceBaseline: baseline,
workspaceGitSnapshot: null, workspaceFileMode: "all", workspaceExclude: [".paperclip-runtime", ".paperclip-runtime/**"] });
workspaceGitSnapshot: null, workspaceRepositories: [], workspaceFileMode: "all", workspaceExclude: [".paperclip-runtime", ".paperclip-runtime/**"] });
const memory = "0123456789abcdef0123456789abcdef\n";
fs.writeFileSync(join(agentHome, "notes", "memory.txt"), memory);
await runtime.restoreWorkspace();
@@ -103,6 +103,8 @@ import {
nativeRunFinalizations,
environmentLeases,
principalPermissionGrants,
projects,
projectWorkspaces,
toolConnections,
} from "@paperclipai/db";
import { defaultGitHubReviewPolicy, type ChatProvider } from "@paperclipai/shared";
@@ -4627,7 +4629,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
changes: { body: { from: "No mention" } },
};
await f.service.handleWebhook(f.endpoint.publicId, "github", signedGitHubWebhookRequest({ event, delivery: randomUUID(), payload, webhookSecret: f.webhookSecret }));
await expect.poll(async () => (await readChatConversationsByEndpointId(f.endpoint.id)).length).toBe(1);
await expect.poll(async () => (await readChatConversationsByEndpointId(f.endpoint.id)).length, { timeout: 10_000 }).toBe(1);
const deliveries = await readChatDeliveriesByEndpointId(f.endpoint.id);
expect(deliveries).toEqual(expect.arrayContaining([
expect.objectContaining({ eventKind: "message_updated" }),
@@ -8113,6 +8115,137 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
expect((await readChatDeliveriesById(delivery.id))[0].state).toBe("filtered");
});
it("snapshots channel workspace defaults on new tasks and preserves them on replies", async () => {
const fixture = await seedCompany();
const { callbacks, endpoint, service } = await configuredSlackEndpoint(fixture);
await service.update(endpoint.id, { executionDefaults: { workspace: { kind: "task_directory" } } }, "owner-user");
const thread = makeThread({ channelId: "C-WORKSPACE", id: "slack:C-WORKSPACE:8100.1", name: "workspace" });
await deliverMessage({ callbacks, endpointId: endpoint.id, thread: thread.thread,
message: makeMessage({ id: "8100.1", text: "@maya make a report", mentioned: true }), trigger: "mention" });
const conversations = () => db.select().from(chatConversations).where(eq(chatConversations.endpointId, endpoint.id)).orderBy(asc(chatConversations.createdAt));
const [first] = await conversations();
const [task] = await db.select().from(issues).where(eq(issues.id, first.issueId));
expect(task.projectId).toBeNull();
expect(task.workspaceSelection).toEqual({ version: 1, selection: { kind: "task_directory" }, source: "channel" });
const [resource] = (await service.listResources(endpoint.id)).filter(row => row.providerResourceId === "C-WORKSPACE");
expect(resource).toBeDefined();
await service.replaceResources(endpoint.id, [{ id: resource.id, executionDefaults: { workspace: null } }], "owner-user");
expect((await service.listResources(endpoint.id)).find(row => row.id === resource.id)?.enabled).toBe(resource.enabled);
await service.update(endpoint.id, { executionDefaults: null }, "owner-user");
await deliverMessage({ callbacks, endpointId: endpoint.id, thread: thread.thread,
message: makeMessage({ id: "8100.2", text: "Continue" }), trigger: "subscribed_message" });
const [unchanged] = await db.select().from(issues).where(eq(issues.id, first.issueId));
expect(unchanged.workspaceSelection).toEqual(task.workspaceSelection);
await service.update(endpoint.id, { executionDefaults: { workspace: { kind: "task_directory" } } }, "owner-user");
const next = makeThread({ channelId: "C-WORKSPACE", id: "slack:C-WORKSPACE:8200.1", name: "workspace" });
await deliverMessage({ callbacks, endpointId: endpoint.id, thread: next.thread,
message: makeMessage({ id: "8200.1", text: "@maya another report", mentioned: true }), trigger: "mention" });
const rows = await conversations();
expect(rows).toHaveLength(2);
const [second] = await db.select().from(issues).where(eq(issues.id, rows[1].issueId));
expect(second.workspaceSelection).toBeNull();
await expect(service.update(endpoint.id, { executionDefaults: { projectId: randomUUID() } }, "owner-user")).rejects.toMatchObject({ status: 404 });
expect((await service.get(endpoint.id)).executionDefaults).toEqual({ workspace: { kind: "task_directory" } });
});
it("checks both linked sender and agent assignment grants for protected organizational defaults", async () => {
const fixture = await seedCompany();
const { callbacks, endpoint, service, wakeup } = await configuredSlackEndpoint(fixture, { linkedBoardUser: true });
const [project] = await db.insert(projects).values({ companyId: fixture.companyId, name: "Protected task project",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
const defaults = { projectId: project.id, workspace: { kind: "task_directory" as const } };
await service.update(endpoint.id, { executionDefaults: defaults }, "owner-user");
await db.insert(companyMemberships).values({ companyId: fixture.companyId, principalType: "agent",
principalId: fixture.assignedAgentId, status: "active", membershipRole: "member" });
const send = async (id: string) => {
const thread = makeThread({ channelId: "C-ASSIGNMENT", id: `slack:C-ASSIGNMENT:${id}`, name: "assignment" });
await deliverMessage({ callbacks, endpointId: endpoint.id, thread: thread.thread,
message: makeMessage({ id, text: "@maya create a report", mentioned: true, userId: "UBOARD" }), trigger: "mention" });
};
const tasks = () => db.select().from(issues).where(eq(issues.companyId, fixture.companyId));
await expect(send("8310.1")).rejects.toMatchObject({ status: 403, message: expect.stringContaining("protected") });
expect(await tasks()).toHaveLength(0);
expect(wakeup).not.toHaveBeenCalled();
const [senderGrant] = await db.insert(principalPermissionGrants).values({ companyId: fixture.companyId,
principalType: "user", principalId: "owner-user", permissionKey: "tasks:assign_scope",
scope: { projectId: project.id, assigneeAgentId: fixture.assignedAgentId } }).returning();
await expect(send("8310.2")).rejects.toMatchObject({ status: 403, message: expect.stringContaining("protected") });
expect(await tasks()).toHaveLength(0);
expect(wakeup).not.toHaveBeenCalled();
await db.insert(principalPermissionGrants).values({ companyId: fixture.companyId,
principalType: "agent", principalId: fixture.assignedAgentId, permissionKey: "tasks:assign_scope",
scope: { projectId: project.id, assigneeAgentId: fixture.assignedAgentId } });
await send("8310.3");
expect(await tasks()).toEqual([expect.objectContaining({ projectId: project.id,
assigneeAgentId: fixture.assignedAgentId,
workspaceSelection: { version: 1, selection: { kind: "task_directory" }, source: "channel" } })]);
expect(wakeup).toHaveBeenCalledTimes(1);
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, senderGrant.id));
await expect(send("8310.4")).rejects.toMatchObject({ status: 403, message: expect.stringContaining("protected") });
expect(await tasks()).toHaveLength(1);
expect(wakeup).toHaveBeenCalledTimes(1);
expect(await db.select().from(chatConversations).where(eq(chatConversations.endpointId, endpoint.id))).toHaveLength(1);
});
it("checks protected organizational defaults against the assignee locked at creation", async () => {
const fixture = await seedCompany();
let endpointId = "";
const { callbacks, endpoint, service, wakeup } = await configuredSlackEndpoint(fixture, {
linkedBoardUser: true,
reachAuthorizationBarrier: async () => {
await db.update(chatEndpoints).set({ assignedAgentId: fixture.replacementAgentId }).where(eq(chatEndpoints.id, endpointId));
},
});
endpointId = endpoint.id;
const [project] = await db.insert(projects).values({ companyId: fixture.companyId, name: "Protected reassignment",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
await service.update(endpoint.id, { executionDefaults: { projectId: project.id, workspace: { kind: "task_directory" } } }, "owner-user");
await db.insert(companyMemberships).values({ companyId: fixture.companyId, principalType: "agent",
principalId: fixture.assignedAgentId, status: "active", membershipRole: "member" });
await db.insert(principalPermissionGrants).values([
{ companyId: fixture.companyId, principalType: "user", principalId: "owner-user", permissionKey: "tasks:assign_scope",
scope: { projectId: project.id, assigneeAgentId: fixture.assignedAgentId } },
{ companyId: fixture.companyId, principalType: "agent", principalId: fixture.assignedAgentId, permissionKey: "tasks:assign_scope",
scope: { projectId: project.id, assigneeAgentId: fixture.assignedAgentId } },
]);
const thread = makeThread({ channelId: "C-REASSIGNED", id: "slack:C-REASSIGNED:8320.1", name: "reassigned" });
await expect(deliverMessage({ callbacks, endpointId: endpoint.id, thread: thread.thread,
message: makeMessage({ id: "8320.1", text: "@maya create a report", mentioned: true, userId: "UBOARD" }), trigger: "mention" }))
.rejects.toMatchObject({ status: 403, message: expect.stringContaining("protected") });
expect(await db.select().from(issues).where(eq(issues.companyId, fixture.companyId))).toHaveLength(0);
expect(wakeup).not.toHaveBeenCalled();
});
it("authorizes protected channel workspace defaults for the endpoint's assigned agent", async () => {
const fixture = await seedCompany();
const { callbacks, endpoint, service } = await configuredSlackEndpoint(fixture, { linkedBoardUser: true });
const [project] = await db.insert(projects).values({ companyId: fixture.companyId, name: "Protected chat source",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
const [source] = await db.insert(projectWorkspaces).values({ companyId: fixture.companyId, projectId: project.id,
name: "Source", cwd: `/tmp/chat-source-${fixture.companyId}` }).returning();
const defaults = { workspace: { kind: "configured_source" as const, projectWorkspaceId: source.id, mode: "shared" as const } };
await expect(service.update(endpoint.id, { executionDefaults: defaults }, "owner-user")).rejects.toMatchObject({ status: 403 });
await db.insert(companyMemberships).values({ companyId: fixture.companyId, principalType: "agent",
principalId: fixture.assignedAgentId, status: "active", membershipRole: "member" });
await db.insert(principalPermissionGrants).values([
{ companyId: fixture.companyId, principalType: "user", principalId: "owner-user", permissionKey: "tasks:assign_scope",
scope: { projectId: project.id, assigneeAgentId: fixture.assignedAgentId } },
{ companyId: fixture.companyId, principalType: "agent", principalId: fixture.assignedAgentId, permissionKey: "tasks:assign_scope",
scope: { projectId: project.id, assigneeAgentId: fixture.assignedAgentId } },
]);
await service.update(endpoint.id, { executionDefaults: defaults }, "owner-user");
const thread = makeThread({ channelId: "C-PROTECTED", id: "slack:C-PROTECTED:8300.1", name: "protected" });
await deliverMessage({ callbacks, endpointId: endpoint.id, thread: thread.thread,
message: makeMessage({ id: "8300.1", text: "@maya update these files", mentioned: true, userId: "UBOARD" }), trigger: "mention" });
const [conversation] = await db.select().from(chatConversations).where(eq(chatConversations.endpointId, endpoint.id));
expect(conversation).toBeDefined();
const [task] = await db.select().from(issues).where(eq(issues.id, conversation.issueId));
expect(task).toMatchObject({ assigneeAgentId: fixture.assignedAgentId, projectId: null, projectWorkspaceId: source.id });
const [resource] = (await service.listResources(endpoint.id)).filter(row => row.providerResourceId === "C-PROTECTED");
await service.replaceResources(endpoint.id, [{ id: resource.id, executionDefaults: defaults }], "owner-user");
expect((await service.listResources(endpoint.id)).find(row => row.id === resource.id)?.executionDefaults).toEqual(defaults);
});
it("captures initial Slack communication guidance once per task, ignoring forged message configuration", async () => {
const fixture = await seedCompany();
const { callbacks, endpoint, service } = await configuredSlackEndpoint(fixture);
@@ -64924,7 +65057,10 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
}
});
it("migrates Telegram basic-group reach and topic tasks to the replacement supergroup", async () => {
it.each([
...["unset", "inherit", "clear", "configured"].flatMap((defaultsMode) =>
[false, true].map((existingDestination) => ({ defaultsMode, existingDestination }))),
])("migrates Telegram basic-group reach, topic tasks and $defaultsMode defaults (existing destination: $existingDestination)", async ({ defaultsMode, existingDestination }) => {
const fixture = await seedCompany();
const { callbacks, endpoint, service, wakeup } =
await configuredTelegramEndpoint(fixture);
@@ -64932,6 +65068,17 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
.update(chatEndpoints)
.set({ status: "active" })
.where(eq(chatEndpoints.id, endpoint.id));
const [defaultProject, selectedProject] = await db.insert(projects).values([
{ companyId: fixture.companyId, name: "Endpoint default project" },
{ companyId: fixture.companyId, name: "Migrated resource project" },
]).returning();
await service.update(endpoint.id, { executionDefaults: {
projectId: defaultProject.id, workspace: { kind: "task_directory" },
} }, "owner-user");
const executionDefaults = defaultsMode === "unset" ? null
: defaultsMode === "inherit" ? {}
: defaultsMode === "clear" ? { projectId: null, workspace: null }
: { projectId: selectedProject.id, workspace: { kind: "task_directory" as const } };
const previousChatId = "-5546433913";
const migratedChatId = "-1004415501660";
const topicId = 77;
@@ -64945,8 +65092,14 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
label: "Telegram migration group",
availability: "available",
enabled: true,
executionDefaults,
})
.returning();
if (existingDestination) await db.insert(chatEndpointResources).values({
companyId: fixture.companyId, endpointId: endpoint.id, type: "chat",
providerResourceId: migratedChatId, label: "Existing destination", availability: "available",
executionDefaults: { projectId: selectedProject.id, workspace: null },
});
const previousTopic = makeThread({
channelId: `telegram:${previousChatId}`,
id: `telegram:${previousChatId}:${topicId}`,
@@ -65005,6 +65158,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
}),
expect.objectContaining({
providerResourceId: migratedChatId,
executionDefaults,
label: "Telegram migration group",
availability: "available",
enabled: true,
@@ -65059,6 +65213,20 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
{ body: "Continue by replying after the group migration" },
]);
expect(wakeup).toHaveBeenCalledTimes(2);
const newTopic = makeThread({ channelId: `telegram:${migratedChatId}`,
id: `telegram:${migratedChatId}:${topicId + 1}`, name: "New migrated topic" });
await deliverMessage({ callbacks, endpointId: endpoint.id, provider: "telegram", thread: newTopic.thread,
message: makeMessage({ id: `${migratedChatId}:43`, raw: { message_id: 43, message_thread_id: topicId + 1 },
text: "Start a new topic after migration", mentioned: true, userId: "telegram-migration-user" }), trigger: "mention" });
const newConversation = (await service.listConversations(endpoint.id)).find(row => row.id !== beforeMigration.id);
expect(newConversation).toBeDefined();
const [newTask] = await readIssuesById(newConversation!.issueId);
expect(newTask.projectId).toBe(defaultsMode === "clear" ? null
: defaultsMode === "configured" ? selectedProject.id : defaultProject.id);
expect(newTask.workspaceSelection).toEqual(defaultsMode === "clear" ? null
: { version: 1, selection: { kind: "task_directory" }, source: "channel" });
expect(wakeup).toHaveBeenCalledTimes(3);
});
it("enforces Telegram group reach and privacy-mode addressing before retaining message content", async () => {
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { chatExecutionDefaultsSchema, updateChatEndpointSchema, replaceChatEndpointResourcesSchema } from "@paperclipai/shared";
import { resolveChatExecutionDefaults } from "../services/chat-execution-defaults.js";
const projectId = "11f01381-578d-4bb7-9de4-57e6d9678313";
const workspaceId = "13f01381-578d-4bb7-9de4-57e6d9678313";
describe("channel execution defaults", () => {
it("leaves unconfigured channels on common task defaults", () => {
expect(resolveChatExecutionDefaults(null, null)).toEqual({});
});
it("inherits omitted properties and clears explicit null independently", () => {
expect(resolveChatExecutionDefaults({ projectId, workspace: { kind: "existing", workspaceId } }, { projectId: null }))
.toEqual({ projectId: null, workspace: { kind: "existing", workspaceId } });
expect(resolveChatExecutionDefaults({ projectId, workspace: { kind: "existing", workspaceId } }, { workspace: null }))
.toEqual({ projectId, workspace: null });
});
it("resource task directories override endpoint shared choices", () => {
expect(resolveChatExecutionDefaults({ workspace: { kind: "existing", workspaceId } }, { workspace: { kind: "task_directory" } }))
.toEqual({ workspace: { kind: "task_directory" } });
});
it("rejects unknown fields, paths and commands rather than granting filesystem authority", () => {
for (const input of [{ cwd: "/tmp" }, { workspace: { kind: "task_directory", command: "echo hello" } }, { projectId: "not-a-uuid" }]) {
expect(chatExecutionDefaultsSchema.safeParse(input).success).toBe(false);
}
});
it("accepts defaults through both endpoint and resource management contracts", () => {
expect(updateChatEndpointSchema.parse({ executionDefaults: { projectId } })).toEqual({ executionDefaults: { projectId } });
expect(replaceChatEndpointResourcesSchema.parse({ resources: [{ id: workspaceId, enabled: true, executionDefaults: { workspace: null } }] }).resources[0].executionDefaults)
.toEqual({ workspace: null });
});
});
@@ -246,7 +246,7 @@ const support = await getEmbeddedPostgresTestSupport();
await expect(call(f, "create_task", { title: "Exact scope is not a subtree", idempotencyKey: "exact-child" })).rejects.toThrow(/outside.*boundary/);
});
it("authorizes workspace-derived projects before creating children of a projectless root", async () => {
it("authorizes workspace sources without assigning projects to children of a projectless root", async () => {
const f = await server.fixture({ disableWakeOnDemand: true });
const settings = instanceSettingsService(server.db);
const previous = await settings.getExperimental();
@@ -254,7 +254,7 @@ const support = await getEmbeddedPostgresTestSupport();
try {
await server.db.update(issues).set({ projectId: null, projectWorkspaceId: null }).where(eq(issues.id, f.issueId));
const [executionWorkspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId,
projectId: f.projectId, projectWorkspaceId: f.projectWorkspaceId, mode: "shared", strategyType: "project_primary", name: "Outside root" }).returning();
projectId: f.projectId, projectWorkspaceId: f.projectWorkspaceId, mode: "shared_workspace", strategyType: "project_primary", name: "Outside root" }).returning();
const boundary = { mode: "low_trust_review", companyId: f.companyId, rootIssueId: f.issueId };
await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: boundary } } }).where(eq(agents.id, f.agentId));
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
@@ -279,6 +279,8 @@ const support = await getEmbeddedPostgresTestSupport();
}
expect(await server.db.select({ id: issues.id }).from(issues).where(eq(issues.companyId, f.companyId))).toEqual(before);
await server.db.update(issues).set({ executionWorkspaceId: executionWorkspace.id }).where(eq(issues.id, f.issueId));
await expect(call(f, "create_task", { title: "Inherited source cannot escape", idempotencyKey: "inherited-source-denied", status: "backlog" })).rejects.toThrow(/outside.*boundary/i);
await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: {
trustBoundary: { ...boundary, projectIds: [f.projectId] },
} } }).where(eq(agents.id, f.agentId));
@@ -287,7 +289,11 @@ const support = await getEmbeddedPostgresTestSupport();
if (path.endsWith("/children") && selection.inheritExecutionWorkspaceFromIssueId) continue;
const response = await create(path, selection);
expect(response.status, JSON.stringify(response.body)).toBe(201);
expect(response.body.projectId).toBe(f.projectId);
expect(response.body.projectId).toBe(selection.inheritExecutionWorkspaceFromIssueId ? f.projectId : null);
if (selection.projectWorkspaceId) {
expect(response.body.projectWorkspaceId).toBe(selection.projectWorkspaceId);
expect(response.body.executionWorkspaceId).toBeNull();
}
expect(response.body.executionPolicy.authorizationPolicy.trustBoundary.projectIds).toEqual([f.projectId]);
}
}
@@ -1,3 +1,4 @@
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import {
@@ -12,6 +13,8 @@ import {
import {
agents,
companies,
authUsers,
companyMemberships,
createDb,
heartbeatRuns,
agentWakeupRequests,
@@ -87,7 +90,7 @@ describe("durable inbound chat scheduler receipts", () => {
testedAt: new Date(0).toISOString(),
}),
});
}, 30_000);
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterEach(() => {
execute.mockClear();
for (const unregister of unregisterAuthorities.splice(0)) unregister();
@@ -111,6 +114,8 @@ describe("durable inbound chat scheduler receipts", () => {
defaultResponsibleUserId: "board-user",
requireBoardApprovalForNewAgents: false,
});
await db.insert(authUsers).values({ id: "board-user", name: "Fixture user", email: "board-user@example.test", createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing();
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "board-user", status: "active", membershipRole: "member" });
await db.insert(agents).values({
id: agentId,
companyId,
@@ -512,6 +512,64 @@ describe("AgentMail durable email pipeline", () => {
await db.select().from(issues).where(eq(issues.companyId, f.companyId)),
).toHaveLength(1);
});
it("snapshots email workspace defaults only when a conversation creates a task", async () => {
const f = await fixture();
await db.update(chatEndpoints).set({ executionDefaults: { workspace: { kind: "task_directory" } } }).where(eq(chatEndpoints.id, f.endpointId));
const first = f.message();
await f.receive(first);
const tasks = () => db.select().from(issues).where(eq(issues.companyId, f.companyId));
const [task] = await tasks();
expect(task.workspaceSelection).toEqual({ version: 1, selection: { kind: "task_directory" }, source: "channel" });
expect(task.projectId).toBeNull();
await db.update(chatEndpoints).set({ executionDefaults: null }).where(eq(chatEndpoints.id, f.endpointId));
await f.receive(f.message(randomUUID(), first.thread_id));
expect((await tasks())[0].workspaceSelection).toEqual(task.workspaceSelection);
await f.receive(f.message());
expect(await tasks()).toEqual(expect.arrayContaining([expect.objectContaining({ workspaceSelection: null })]));
});
it("checks sponsor and agent assignment grants for protected email organizational defaults", async () => {
const f = await fixture();
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected email tasks",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
const fetcher = vi.mocked(f.fetcher);
const fetch = fetcher.getMockImplementation()!;
let changedDefaults = false;
fetcher.mockImplementation(async (...args) => {
if (!changedDefaults && String(args[0]).includes("/threads/")) {
changedDefaults = true;
await db.update(chatEndpoints).set({ executionDefaults: { projectId: project.id, workspace: { kind: "task_directory" } } })
.where(eq(chatEndpoints.id, f.endpointId));
}
return fetch(...args);
});
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "agent",
principalId: f.agentId, status: "active", membershipRole: "member" });
const tasks = () => db.select().from(issues).where(eq(issues.companyId, f.companyId));
await f.receive(f.message());
expect(await tasks()).toHaveLength(0);
expect(f.wakeup).not.toHaveBeenCalled();
const [sponsorGrant] = await db.insert(principalPermissionGrants).values({ companyId: f.companyId,
principalType: "user", principalId: "email-board", permissionKey: "tasks:assign_scope",
scope: { projectId: project.id, assigneeAgentId: f.agentId } }).returning();
await f.receive(f.message());
expect(await tasks()).toHaveLength(0);
expect(f.wakeup).not.toHaveBeenCalled();
await db.insert(principalPermissionGrants).values({ companyId: f.companyId,
principalType: "agent", principalId: f.agentId, permissionKey: "tasks:assign_scope",
scope: { projectId: project.id, assigneeAgentId: f.agentId } });
await f.receive(f.message());
expect(await tasks()).toEqual([expect.objectContaining({ projectId: project.id,
assigneeAgentId: f.agentId,
workspaceSelection: { version: 1, selection: { kind: "task_directory" }, source: "channel" } })]);
expect(f.wakeup).toHaveBeenCalledTimes(1);
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, sponsorGrant.id));
await f.receive(f.message());
expect(await tasks()).toHaveLength(1);
expect(f.wakeup).toHaveBeenCalledTimes(1);
expect(await db.select().from(chatConversations).where(eq(chatConversations.endpointId, f.endpointId))).toHaveLength(1);
});
it("deduplicates events/messages, keeps identical subjects separate, and never grants sender board identity", async () => {
const f = await fixture();
const m = f.message();
@@ -1,13 +1,20 @@
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { mkdtemp, rm, stat } from "node:fs/promises";
import { mkdtemp, rm, stat, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { pathToFileURL } from "node:url";
import { assertExecutionWorkspaceRootReadAccess } from "../services/execution-workspace-repositories.js";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agents,
authUsers,
companyMemberships,
issueAccessGrants,
companies,
createDb,
executionWorkspaces,
@@ -48,7 +55,7 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => {
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-reopen-");
db = createDb(tempDb.connectionString);
}, 20_000);
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterEach(async () => {
await db.delete(activityLog);
@@ -56,7 +63,10 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => {
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(companyMemberships);
await db.delete(agents);
await db.delete(companies);
await db.delete(authUsers);
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
@@ -250,6 +260,84 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => {
.then((rows) => rows[0] ?? null);
}
describe.each(["configured", "builtin"] as const)("reopen %s host provision containment", kind => {
it.each(["task", "agent", "organization", "source", "same-source", "trusted", "explicit", "projectless", "nondefault", "foreign-source-task", "retained-missing", "retained-false", "retained-null"] as const)("honors persisted %s policy before running repository scripts", async restriction => {
const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject();
const directory = await makeExistingDir();
const base = join(directory, "source"), target = join(directory, "target"), marker = join(directory, "host-provision-ran");
initManagedGitRepo(base, "reopen-feature");
const command = `printf 'host execution\n' > '${marker}'`;
mkdirSync(join(base, "scripts"));
writeFileSync(join(base, "scripts", "provision-worktree.sh"), command + "\n");
const git = (...args: string[]) => execFileSync("git", args, { cwd: base, stdio: "ignore",
env: { ...process.env, GIT_AUTHOR_NAME: "Test", GIT_AUTHOR_EMAIL: "test@example.com", GIT_COMMITTER_NAME: "Test", GIT_COMMITTER_EMAIL: "test@example.com" } });
git("add", "scripts"); git("commit", "-m", "Host provision fixture"); git("branch", "-f", "reopen-feature", "HEAD");
await db.update(projectWorkspaces).set({ cwd: base }).where(eq(projectWorkspaces.id, projectWorkspaceId));
const workspaceId = await seedClosedGitWorktreeWorkspace({ companyId, projectId, projectWorkspaceId,
cwd: target, repoUrl: pathToFileURL(base).href, branchName: "reopen-feature" });
if (kind === "configured") await db.update(executionWorkspaces).set({ metadata: {
[EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY]: 1, config: { provisionCommand: command },
} }).where(eq(executionWorkspaces.id, workspaceId));
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4199 });
let organizationId: string | null = restriction === "projectless" ? null : projectId;
if (restriction === "organization") {
organizationId = randomUUID();
await db.insert(projects).values({ id: organizationId, companyId, name: "Organizational policy" });
}
const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustPreset: "low_trust_review",
trustBoundary: { mode: "low_trust_review", companyId, rootIssueId: issueId,
projectIds: [...new Set([projectId, ...(organizationId ? [organizationId] : [])])] } } };
const agentId = randomUUID();
await db.insert(agents).values({ id: agentId, companyId, name: "Reopen assignee",
permissions: restriction === "agent" ? policy : {} });
await db.update(issues).set({ projectId: organizationId, assigneeAgentId: agentId,
executionPolicy: restriction === "task" ? policy : null }).where(eq(issues.id, issueId));
if (["source", "same-source", "organization"].includes(restriction)) {
await db.update(projects).set({ executionWorkspacePolicy: policy })
.where(eq(projects.id, restriction === "organization" ? organizationId! : projectId));
}
if (restriction === "explicit" || restriction.startsWith("retained-")) {
await db.update(issues).set({ workspaceSelection: { version: 1, source: "explicit",
selection: { kind: "configured_source", projectWorkspaceId, mode: "managed_isolated" } } })
.where(eq(issues.id, issueId));
}
if (restriction === "nondefault") {
await db.update(projectWorkspaces).set({ isPrimary: false }).where(eq(projectWorkspaces.id, projectWorkspaceId));
await db.insert(projectWorkspaces).values({ companyId, projectId, name: "Earlier default",
sourceType: "local_path", cwd: base, isPrimary: true, createdAt: new Date(0) });
}
if (restriction === "foreign-source-task") {
const sourceIssueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4198 });
await db.update(executionWorkspaces).set({ sourceIssueId }).where(eq(executionWorkspaces.id, workspaceId));
}
if (restriction.startsWith("retained-")) {
const workspace = await readWorkspace(workspaceId);
await db.update(executionWorkspaces).set({ metadata: { ...workspace?.metadata,
_controllerProvisionCopyOnly: true } }).where(eq(executionWorkspaces.id, workspaceId));
await db.update(issues).set({ workspaceSelection: null }).where(eq(issues.id, issueId));
const service = executionWorkspaceService(db);
await service.update(workspaceId, { metadata: restriction === "retained-null" ? null
: restriction === "retained-false" ? { _controllerProvisionCopyOnly: false } : {} });
if (kind === "configured") await service.update(workspaceId, { metadata: { config: { provisionCommand: command } } });
expect((await readWorkspace(workspaceId))?.metadata).toMatchObject({ _controllerProvisionCopyOnly: true });
}
const result = await executionWorkspaceService(db).reopenClosedIsolatedExecutionWorkspaceForIssue({
workspaceId, issue: { id: issueId, companyId, projectId: organizationId },
actor: { agentId: null, actorType: "user" },
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
});
expect(result).toMatchObject({ ok: true, reopened: true });
await expect(readFile(join(target, "README.md"), "utf8")).resolves.toBe("seed\n");
if (restriction === "trusted") await expect(readFile(marker, "utf8")).resolves.toBe("host execution\n");
else await expect(readFile(marker, "utf8")).rejects.toMatchObject({ code: "ENOENT" });
expect(await readWorkspace(workspaceId)).toMatchObject({ status: "active", projectId, projectWorkspaceId });
if (["explicit", "projectless", "nondefault", "foreign-source-task"].includes(restriction) || restriction.startsWith("retained-")) expect((await readWorkspace(workspaceId))?.metadata)
.toMatchObject({ _controllerProvisionCopyOnly: true });
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0])
.toMatchObject({ projectId: organizationId, executionWorkspaceId: workspaceId, assigneeAgentId: agentId });
});
});
it("reopens the archived row in place, keeps the issue link, and raises the generation", async () => {
const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject();
const cwd = await makeExistingDir();
@@ -261,6 +349,7 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => {
workspaceId,
issue: { id: issueId, companyId, projectId },
actor: { agentId: null, actorType: "user" },
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
});
expect(result.ok).toBe(true);
@@ -282,6 +371,22 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => {
expect(issueRow?.executionWorkspaceId).toBe(workspaceId);
});
it("reopens the persisted source after the task leaves its organizational project", async () => {
const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject();
const cwd = await makeExistingDir();
const workspaceId = await seedClosedWorkspace({ companyId, projectId, projectWorkspaceId, cwd });
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4105 });
await db.update(issues).set({ projectId: null }).where(eq(issues.id, issueId));
const result = await executionWorkspaceService(db).reopenClosedIsolatedExecutionWorkspaceForIssue({
workspaceId,
issue: { id: issueId, companyId, projectId: null },
actor: { agentId: null, actorType: "user" },
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
});
expect(result.ok).toBe(true);
expect((await readWorkspace(workspaceId))?.projectId).toBe(projectId);
});
it("keeps access for every issue that shares the reopened row", async () => {
const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject();
const cwd = await makeExistingDir();
@@ -294,6 +399,7 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => {
workspaceId,
issue: { id: firstIssueId, companyId, projectId },
actor: { agentId: null, actorType: "user" },
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
});
expect(result.ok).toBe(true);
@@ -324,6 +430,7 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => {
workspaceId,
issue: { id: issueId, companyId, projectId },
actor: { agentId: null, actorType: "user" },
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
});
expect(result.ok).toBe(false);
@@ -369,6 +476,7 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => {
workspaceId,
issue: { id: issueId, companyId, projectId },
actor: { agentId: null, actorType: "user" },
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
});
expect(result.ok).toBe(true);
@@ -389,6 +497,80 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => {
}
});
it.each([false, true, "revoked_during_provision"] as const)("guards a private local managed reopen before Git and retains privacy after provisioning failure=%s", async scenario => {
const provisionFails = scenario === true;
const revokeDuringProvision = scenario === "revoked_during_provision";
const previousHome = process.env.PAPERCLIP_HOME;
const previousPrivacy = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
const home = await makeExistingDir();
process.env.PAPERCLIP_HOME = home;
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
try {
const { companyId, projectId, projectWorkspaceId } = await seedManagedCheckoutProject();
const readerId = randomUUID();
await db.insert(authUsers).values({ id: readerId, name: "Reader", email: `${readerId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: readerId, status: "active", membershipRole: "member" });
const authorizationActor = { type: "board" as const, source: "session" as const, userId: readerId, companyIds: [companyId], isInstanceAdmin: false };
const source = join(home, "private-source"), target = join(home, "worktree-copy");
initManagedGitRepo(source, "reopen-feature");
writeFileSync(join(source, "private.txt"), "PRIVATE_REOPEN_BYTES");
execFileSync("git", ["add", "private.txt"], { cwd: source });
execFileSync("git", ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "private"], { cwd: source });
execFileSync("git", ["branch", "-f", "reopen-feature", "HEAD"], { cwd: source });
const repoUrl = pathToFileURL(source).href;
await db.update(projectWorkspaces).set({ repoUrl }).where(eq(projectWorkspaces.id, projectWorkspaceId));
const privateIssueId = randomUUID();
await db.insert(issues).values({ id: privateIssueId, companyId, title: "Private source", visibility: "private", status: "todo", responsibleUserId: "another-owner" });
await db.insert(executionWorkspaces).values({ companyId, projectId: null, sourceIssueId: privateIssueId, name: "Private owner", mode: "shared_workspace", strategyType: "project_primary", cwd: source, providerType: "local_fs", status: "active" });
const workspaceId = await seedClosedGitWorktreeWorkspace({ companyId, projectId, projectWorkspaceId, cwd: target, repoUrl, branchName: "reopen-feature" });
const waitingProvision = `node -e 'const fs = require("node:fs"); fs.writeFileSync("dirty.txt", "DIRTY_REOPEN_BYTES"); fs.writeFileSync("provision-started", "ready"); const deadline = Date.now() + 15000; const timer = setInterval(() => { if (fs.existsSync("provision-continue")) { clearInterval(timer); process.exit(0); } if (Date.now() > deadline) process.exit(2); }, 20);'`;
if (provisionFails || revokeDuringProvision) await db.update(executionWorkspaces).set({ metadata: { [EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY]: 1, config: { provisionCommand: provisionFails ? "exit 42" : waitingProvision } } }).where(eq(executionWorkspaces.id, workspaceId));
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4901 });
if (revokeDuringProvision) await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId));
const reopen = () => executionWorkspaceService(db).reopenClosedIsolatedExecutionWorkspaceForIssue({ workspaceId, issue: { id: issueId, companyId, projectId }, actor: { actorType: "user", agentId: null }, authorizationActor });
expect(await reopen()).toMatchObject({ ok: false, code: "rebuild_failed" });
await expect(stat(target)).rejects.toMatchObject({ code: "ENOENT" });
const managed = resolveManagedProjectWorkspaceDir({ companyId, projectId, repoName: "private-source" });
await expect(stat(managed)).rejects.toMatchObject({ code: "ENOENT" });
await db.insert(issueAccessGrants).values({ issueId: privateIssueId, subjectType: "user", subjectId: readerId, source: "explicit" });
const attempt = revokeDuringProvision ? reopen() : null;
if (attempt) {
try {
await vi.waitFor(async () => expect(await readFile(join(target, "provision-started"), "utf8")).toBe("ready"), { timeout: 10_000 });
await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, privateIssueId));
} finally {
await writeFile(join(target, "provision-continue"), "resume");
}
}
const results = attempt ? [await attempt] : provisionFails ? [await reopen()] : await Promise.all([reopen(), reopen()]);
expect(results.every(result => result.ok)).toBe(!provisionFails && !revokeDuringProvision);
if (!provisionFails && !revokeDuringProvision) expect(results.map(result => result.ok && result.reopened).sort()).toEqual([false, true]);
expect(await readFile(join(target, "private.txt"), "utf8")).toBe("PRIVATE_REOPEN_BYTES");
if (revokeDuringProvision) {
expect(results[0]).toMatchObject({ ok: false, code: "rebuild_failed" });
const reopened = await readWorkspace(workspaceId);
expect(reopened?.status).toBe("active");
expect(metadataHasReopenPendingConsumption(reopened?.metadata)).toBe(false);
expect(await readFile(join(target, "dirty.txt"), "utf8")).toBe("DIRTY_REOPEN_BYTES");
const activity = await db.select().from(activityLog).where(eq(activityLog.entityId, workspaceId));
expect(activity.map(row => row.action)).toContain("execution_workspace.reopen_unconsumed");
const archive = await executionWorkspaceService(db).archiveWorkspaceUnderLifecycleLock({ id: workspaceId, patch: {}, closedAt: new Date() });
expect(archive?.outcome).toBe("archived");
expect(await readFile(join(target, "dirty.txt"), "utf8")).toBe("DIRTY_REOPEN_BYTES");
} else {
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId, cwd: target, actor: authorizationActor })).resolves.toBeUndefined();
}
await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, projectWorkspaceId));
await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, privateIssueId));
await rm(source, { recursive: true, force: true });
for (const cwd of [managed, target]) await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId, cwd, actor: authorizationActor })).rejects.toMatchObject({ status: 403 });
expect(await readFile(join(target, "private.txt"), "utf8")).toBe("PRIVATE_REOPEN_BYTES");
} finally {
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
if (previousPrivacy === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousPrivacy;
}
}, 30_000);
it("refuses to reopen a workspace in another company", async () => {
const first = await seedCompanyProject();
const second = await seedCompanyProject();
@@ -406,6 +588,7 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => {
// The issue belongs to a different company than the workspace.
issue: { id: randomUUID(), companyId: second.companyId, projectId: second.projectId },
actor: { agentId: null, actorType: "user" },
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
});
expect(result.ok).toBe(false);
@@ -434,6 +617,7 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => {
workspaceId,
issue: { id: issueId, companyId, projectId },
actor: { agentId: null, actorType: "user" },
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
});
expect(result.ok).toBe(true);
@@ -497,6 +681,7 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => {
workspaceId,
issue: { id: issueId, companyId, projectId },
actor: { agentId: null, actorType: "user" },
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
});
expect(reopenResult.ok).toBe(true);
if (!reopenResult.ok) throw new Error("reopen failed");
@@ -558,6 +743,7 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => {
workspaceId,
issue: { id: issueId, companyId, projectId },
actor: { agentId: null, actorType: "user" },
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
});
expect(reopenResult.ok).toBe(true);
if (!reopenResult.ok) throw new Error("reopen failed");
@@ -146,6 +146,7 @@ describe("execution workspace routes", () => {
issueId: undefined,
status: undefined,
reuseEligible: true,
selectableForTask: false,
});
expect(mockExecutionWorkspaceService.list).not.toHaveBeenCalled();
});
Loaded 100 of 240 files, more files were not shown because too many files have changed in this diff. Show more