diff --git a/doc/plans/2026-09-07-runner-api-production-readiness.md b/doc/plans/2026-09-07-runner-api-production-readiness.md
new file mode 100644
index 0000000000..8004374ffd
--- /dev/null
+++ b/doc/plans/2026-09-07-runner-api-production-readiness.md
@@ -0,0 +1,86 @@
+# Runner API production readiness — 2026-09-07
+
+The implementation is ready for final review and an opt-in rollout after the
+remaining release checks pass. General release is not yet approved. The tools
+are disabled by default and can be enabled for selected companies.
+
+## Implemented safeguards
+
+- The catalog covers mounted REST routes and identifies non-REST protocols.
+- Calls use run-bound agent credentials and the real HTTP authorization path.
+- Active-run and work-mode checks run again after file preparation.
+- File opens reject symlinks at every path component.
+- Runner lifecycle changes and active-task deletion aliases are blocked.
+- Mutation receipts prevent automatic replay after an uncertain outcome.
+- Credential-value and management operations cannot enter generic tool results
+ or replay receipts. Safe secret metadata remains discoverable.
+- HTTP errors that may follow a committed write retain an unknown outcome.
+- Dedicated child creation now records its agent and run in the activity log.
+- Eval journals and bounded provider traces survive disposable server cleanup.
+- The shared ledger blocks new paid work if accounting is incomplete.
+
+## Qualification evidence
+
+Sonnet 5 through OpenCode 1.18.17 and OpenRouter passed the read, mutation and
+cross-company denial smoke cases after fixes. It also passed eight additional
+cases covering files, Ask/Plan modes, API-only options and a mixed workflow.
+The initial malformed-JSON failure remains in the report. The corrected tool
+schema tells models to pass structured JSON directly, and invalid string-encoded
+objects receive an actionable error before HTTP dispatch.
+
+Sonnet passed all 60 common-task regression runs: ten workflows, three repetitions
+per arm. It used no unnecessary API fallback. Per-workflow average cost changes
+ranged from -1.4% to +5.5%. No cost or latency increase crossed the 20% investigation
+threshold. These are small samples, not a guarantee for all workloads.
+
+Gemini 3.8 Flash passed read, mutation and denial smoke cases. DeepSeek V4 Flash
+0731 completed the API read but did not finish within 120 seconds. It remains
+unqualified under this limit. Both interrupted attempts have retained billing
+reconciliation evidence. No missing charge was discarded or treated as zero.
+
+Luna passed all 60 paired common-task runs with no unnecessary fallback. Two
+workflows exceeded the 20% cost threshold: document reading (+38.8%) and task
+search (+35.1%). Separate one-pair repeats changed cost by -29.1% and +7.8%.
+Task-search time increased 54.5% in its single repeat. Cache use and model turns
+varied. These samples do not establish the cause; retain the flags in rollout
+monitoring. Corrected child creation includes the required audit event.
+
+Final rebased read tests passed on both Luna and Sonnet. Sonnet used the new
+repository-pinned OpenCode 1.18.29; the larger cohort used 1.18.17. Paid testing
+stopped at $9.875960 and 88.16 active minutes. No missing accounting remains.
+The remaining campaign time cannot fit another 120-second reservation.
+
+The rebased branch passed the full Linux build and typecheck. Repository tests
+were run by project and serialized shard. All 143 serialized server suites passed.
+The runner TypeScript suite passed 1,599 tests with two platform skips. Rust
+release tests, conformance and replay checks passed. The required API authority
+check passed 814 tests, including the real runnerd/PRP/HTTP integration.
+
+Retained verification logs record the initial environment failures and targeted
+reruns: missing `jq`, an overlay-filesystem identity test that passed on tmpfs,
+and parallel Rust linking that passed with one build worker. The macOS full
+runner suite has platform-specific failures; Linux is the qualified full-check
+platform. Latest-head CI remains the final release gate.
+
+Review fixes also block issue reopen/resume/interrupt intents, require explicit
+controller credential injection, and add recoverable ledger stop/reconciliation
+transitions. These changes have provider-free evidence. They do not have new
+paid-model results after the campaign time limit.
+
+## Release gates
+
+1. Preserve the completed paired comparison and its investigated threshold flags.
+2. Record final costs, latency, source revisions, failures and unrun operations.
+3. Require green current-head CI and completed security/code review before merging.
+4. Keep API tools disabled until an operator selects the first rollout companies.
+5. Inspect task correctness, fallback frequency, cost, latency, denials and unknown
+ mutation outcomes before expanding access.
+
+The catalog-wide operation cases are authored, but most have not had paid model
+execution. Generated cases that need additional fixtures do not establish working
+coverage. The coverage matrix must continue to show those gaps. The original $300
+budget and 90-minute active paid-campaign limit apply to all stages and retries.
+
+Implementation review: https://github.com/paperclipai/paperclip/pull/13003
+
+Eval suite and evidence: https://github.com/paperclipai/paperclip-evals/pull/20
diff --git a/doc/runner-api-tools.md b/doc/runner-api-tools.md
new file mode 100644
index 0000000000..121b03fbbe
--- /dev/null
+++ b/doc/runner-api-tools.md
@@ -0,0 +1,151 @@
+# Runner API escape hatch
+
+`search_api` and `call_api` extend the native runner when an available dedicated
+operation cannot express the requested work. Existing tools remain preferred;
+agents do not have to search before using them. Only two tool definitions are
+advertised. The API catalog is returned on demand, never injected into the
+initial prompt.
+
+## Controlled rollout
+
+The escape hatch is disabled by default. Set
+`PAPERCLIP_RUNNER_API_TOOLS_ENABLED=true` on the server to enable it. For an
+initial company rollout, also set `PAPERCLIP_RUNNER_API_TOOLS_COMPANY_IDS` to a
+comma-separated list of company UUIDs. An unset list allows every company;
+an explicitly empty list allows none. IDs must match exactly.
+
+The server always requires the explicit `true` flag, including for server-owned
+bindings. A binding can disable these tools for a baseline eval but cannot enable
+them without operator opt-in. Setting the flag to `false` disables them. The server checks this switch when advertising tools,
+when accepting a call, and immediately before HTTP dispatch after preparing any
+files. Existing dedicated tools remain available. Operators must update the
+environment of each server process and restart it for deployment-level changes;
+this environment switch is not a live settings API.
+
+Evaluate selected companies first. Compare success, unnecessary fallback calls,
+cost, and latency against the dedicated-tool baseline before widening. Keep the
+switch disabled if authorization, replay, or cost accounting fails.
+
+## Discovery and requests
+
+```json
+{"query":"create project","limit":5}
+```
+
+Search is deterministic lexical ranking over OpenAPI paths, summaries and the
+old skill reference. It supports task/issue and other terminology, exact
+`METHOD /api/path/{parameter}` lookup, and opaque query/catalog-bound pagination.
+Results include resolved request schemas, response descriptions, authorization
+metadata, work modes, examples where available, and relevant dedicated tools
+with their supported parameters. `limit` defaults to five and is capped at ten.
+
+```json
+{"operationId":"PATCH /api/projects/{id}","pathParams":{"id":"PROJECT_UUID"},"body":{"description":"Updated project description"}}
+```
+
+The catalog determines method and path. `companyId` is filled from the active
+binding. Scalars and arrays are accepted in `query`. `body` defaults to JSON;
+`contentType` supports text and raw uploads. `files` accepts entries containing
+exactly one authorized `artifactId` or task-workspace `path`, and an optional
+multipart `field`. No arbitrary URL, headers, authentication, or remote file URL
+can be supplied. Routes still validate payloads and enforce permissions.
+
+Requests have a 30-second HTTP timeout, 16 KiB URL limit and 10 MiB payload/response
+transfer limit. Responses above 24 KiB and binary responses become company-owned
+assets with retrievable references; text previews are limited to 2,000 bytes.
+All redirects are refused. Oversized or interrupted mutation responses have an
+unknown outcome, requiring inspection before another mutation.
+Mutation responses with HTTP 5xx, HTTP 408, redirects, or malformed JSON also
+retain an unknown outcome. A server may have committed the write before it
+failed to return a valid response.
+
+## Authority and replay
+
+The server revalidates the active native run, assigned task and actor, then
+creates a server-held agent JWT bound to that company and run. Requests go
+through the actual HTTP router with its authorization, validation and domain
+audit behavior. An additional `runner.api_called` receipt attributes mutations
+to the run even where older route audit events omit that field.
+The run and work mode are checked again after asynchronous file preparation, so
+a stopped run cannot dispatch an upload prepared under its earlier binding.
+
+Ask and pre-acceptance Plan permit reads through the escape hatch. Existing
+dedicated-tool exceptions are unchanged. Runner-owned checkout, completion,
+status/assignment transitions, approval decisions and execution-control actions
+cannot be bypassed through generic calls. Routine creation, schedule/trigger
+changes and manual/public routine execution require the existing scheduling
+clients. Direct workspace runtime commands, runtime-slot stop/restart, case
+automation retries and skill test-run controls also require their existing
+execution clients. Gateway session credentials cannot enter generic results.
+Routine metadata remains readable; annotation threads, comments and thread
+resolution remain available through the fallback. API-only ordinary fields, such as a
+task's `billingCode`, remain accessible even when a dedicated tool covers other
+fields on that endpoint.
+
+Mutation call IDs reserve a durable receipt in the run's existing `resultJson`
+before dispatch. Replays return the recorded result. Reusing an ID with different
+arguments is rejected. A crash after reservation leaves an unknown outcome and
+never automatically resends the mutation. The limit is 512 mutation receipts per
+run. No database migration is needed.
+
+Workspace uploads use the existing workspace resource containment checks,
+no-symlink file opens covering every path component, and bounded descriptor reads.
+Local uploads require Linux or macOS; authorized artifacts work on other hosts.
+Lifecycle-sensitive endpoints require an inline JSON object, so a raw uploaded
+JSON file cannot hide protected fields from policy checks. Artifacts must belong
+to the bound company. Secret-value access, credential management, secret proposals
+and company exports require their existing secure clients. Search describes these
+operations as restricted. `call_api` rejects them before creating a replay receipt
+or making an HTTP request. Safe secret metadata listing remains available.
+Agent credentials are never returned to the model. Streaming, WebSocket, MCP and authentication
+handshakes are documented as protocol operations requiring their existing clients.
+
+## Catalog maintenance
+
+`runner-api-catalog.ts` builds from the server OpenAPI registry. Experimental
+pipeline, Cases and smoke-lab routes now share their validators with discovery.
+Seven Cases/pipeline route shapes are multiplexed by resource identity: the Cases
+router intentionally forwards unknown resources to the pipeline router. Their
+separate catalog entries explain which resource identifier is required. Registry
+authorization descriptions are documentation; actual route checks are authoritative.
+
+Regenerate old-skill enrichment after editing its API reference:
+
+```sh
+node scripts/generate-runner-api-reference.mjs
+node scripts/generate-runner-api-reference.mjs --check
+node scripts/generate-runner-experimental-api-metadata.mjs
+node scripts/generate-runner-experimental-api-metadata.mjs --check
+```
+
+Mounted-route coverage tests include experimental routes. Three WebSocket mounts
+are explicitly classified in the catalog. Shared protocol-action catalogs,
+provider projections and generated compatibility checks include both tools.
+
+## Verification and paid evals
+
+The companion `paperclip-evals` worktree contains `evals/runner-api-tools`.
+Its README documents explicit case/model selectors, the cumulative budget ledger,
+fixture reset, progressive batches, and Evalbook generation. No command defaults
+to running the entire paid suite. Capability, forced operation contracts and
+paired common-operation regressions are reported separately.
+
+Provider-free integration tests exercise real runnerd → PRP → authority → HTTP,
+route validation and audit, stale bindings, Ask/Plan restrictions, identity
+spoofing, file containment, uncertain mutation receipts and fixture isolation.
+
+The Evalbook viewer uses the existing shared viewer and stylesheet on master.
+The report retains actual persisted-state summaries for private local inspection;
+public replay continues to withhold company-state details.
+
+The ACPX sidecar includes the upstream terminal-usage accounting correction from
+`origin/codex/evalbook-default-chat-sept6`. Its qualified Claude executable requires
+Linux x64. The first macOS stage records a zero-cost ACPX admission failure. A later user-authorized
+OpenCode/OpenRouter Sonnet profile reached a real HTTP read, but the attempt failed
+on a missing harness completion contract and incomplete terminal accounting. The
+harness contract is corrected. The missing fourth request was subsequently
+recovered from the matching OpenRouter session and generation billing record;
+the original failed attempt remains immutable. New attempts retain an append-only,
+flushed event journal and bounded provider trace outside disposable runtime files.
+Provider-free startup succeeds for OpenRouter Sonnet and DeepSeek. See
+`doc/plans/2026-09-07-runner-api-production-readiness.md` for remaining release gates.
diff --git a/packages/paperclip-runner/devtools/issue-thread/src/App.tsx b/packages/paperclip-runner/devtools/issue-thread/src/App.tsx
index c511c631fc..ef14d92c9b 100644
--- a/packages/paperclip-runner/devtools/issue-thread/src/App.tsx
+++ b/packages/paperclip-runner/devtools/issue-thread/src/App.tsx
@@ -106,6 +106,7 @@ interface EmbeddedEvalReport {
durationMs: number | null;
initialRevision: number;
finalRevision: number;
+ finalStateSummary?: string;
usage: {
agentTurns: number;
providerRequests: number | null;
@@ -1408,7 +1409,7 @@ export function App() {
{embeddedEval !== null ? (
Post-run state
- {embeddedEval.publication ? "Company-state details withheld from public replay" : `Final mock control-plane revision ${embeddedEval.run.finalRevision}`}
+ {embeddedEval.publication ? "Company-state details withheld from public replay" : embeddedEval.run.finalStateSummary ?? `Final mock control-plane revision ${embeddedEval.run.finalRevision}`}
check.anchor.kind === "run")} />
) : null}
diff --git a/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json b/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json
index e496c4cd64..c0d2b550b6 100644
--- a/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json
+++ b/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json
@@ -1 +1 @@
-[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active mock 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 mock 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 mock 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 mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock 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 mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create or update an active-task document with optimistic revision safety.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision id, or null when creating.","maxLength":20000,"type":["string","null"]},"body":{"description":"Markdown document body.","maxLength":200000,"minLength":1,"type":"string"},"changeSummary":{"description":"Optional revision summary.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"title":{"description":"Document title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","key","title","body","baseRevisionId"],"type":"object"},"name":"write_document","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_human_input","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a typed, durable interaction on the active mock task.","inputSchema":{"additionalProperties":false,"properties":{"continuationPolicy":{"enum":["none","wake_assignee","wake_assignee_on_accept"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"interactionKind":{"enum":["confirmation","checkbox","questions","suggest_tasks","item_verdicts"]},"payload":{"additionalProperties":true,"description":"Kind-specific interaction data. For interactionKind='questions', use exactly {version:1, questions:[{id,prompt,selectionMode:'single'|'multi',required?,options:[{id,label,description?,freeText?}]}]}; option keys are id/label, not value, and question choice cardinality is selectionMode, not type. For confirmation, payload may be {}. Keep all ids stable across retries.","type":"object"},"prompt":{"description":"Question or decision prompt.","maxLength":10000,"minLength":1,"type":"string"},"targetRevisionId":{"description":"Optional bound document revision.","maxLength":20000,"type":["string","null"]},"title":{"description":"Interaction card title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","interactionKind","title","prompt","continuationPolicy"],"type":"object"},"name":"request_human_input","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"register_deliverable","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Register mock attachment metadata and its artifact work product without credentials or bytes in the tool result.","inputSchema":{"additionalProperties":false,"properties":{"byteSize":{"maximum":100000000,"minimum":0,"type":"integer"},"contentRef":{"description":"Opaque package-local content reference.","maxLength":2000,"minLength":1,"type":"string"},"contentType":{"description":"Media type.","maxLength":200,"minLength":1,"type":"string"},"filename":{"description":"Display filename.","maxLength":500,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"sha256":{"pattern":"^[a-fA-F0-9]{64}$","type":"string"},"title":{"description":"Work-product title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","filename","contentType","byteSize","sha256","contentRef","title"],"type":"object"},"name":"register_deliverable","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"finish_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Finish the active mock task with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Completion summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"finish_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"block_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Block the active mock task with a durable reason and optional first-class dependencies.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Internal mock task ids that block this task.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Block reason.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","reason"],"type":"object"},"name":"block_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_review","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Move the active mock task to review with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Review handoff summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"request_review","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_agents","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List redacted mock actor profiles.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_agents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_agent","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one redacted mock actor profile.","inputSchema":{"additionalProperties":false,"properties":{"actorId":{"description":"Mock actor id.","maxLength":200,"minLength":1,"type":"string"}},"required":["actorId"],"type":"object"},"name":"get_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_tasks","requiredClaims":["discovery:tasks:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Search mock tasks by text and status within the run company.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"},"query":{"maxLength":500,"type":"string"},"statuses":{"items":{"enum":["backlog","todo","in_progress","in_review","done","blocked","cancelled"]},"maxItems":7,"type":"array","uniqueItems":true}},"required":[],"type":"object"},"name":"search_tasks","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_approvals","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List mock approvals in the run company.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_approvals","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one mock approval without protected data.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Mock approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval_context","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval, its comments, and linked mock tasks.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Mock approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace_runtime","requiredClaims":["workspace:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read active-task mock workspace services.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace_runtime","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"control_workspace_service","requiredClaims":["workspace:control"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Start, stop, or fault one active-task mock workspace service.","inputSchema":{"additionalProperties":false,"properties":{"action":{"enum":["start","stop","fail"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"serviceId":{"description":"Mock workspace service id.","maxLength":200,"minLength":1,"type":"string"},"url":{"description":"Optional mock service URL.","maxLength":20000,"type":["string","null"]}},"required":["idempotencyKey","serviceId","action"],"type":"object"},"name":"control_workspace_service","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_dependencies","requiredClaims":["dependencies:write"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace the active task's first-class blocker set.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Replacement blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","blockedByTaskIds"],"type":"object"},"name":"set_dependencies","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_task","requiredClaims":["delegation:tasks:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create one durable standard child under the active task. Use only when a real ownership, parallelism, dependency, review, or lifecycle boundary justifies delegation.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"Optional agent assignee. Omit to assign the current agent.","maxLength":20000,"type":["string","null"]},"blockedByTaskIds":{"description":"Initial blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"description":{"description":"Child task description.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"priority":{"enum":["critical","high","medium","low"]},"title":{"description":"Child task title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","title"],"type":"object"},"name":"create_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"},"task":{"additionalProperties":false,"properties":{"assigneeActorId":{"type":["string","null"]},"id":{"minLength":1,"type":"string"},"identifier":{"type":["string","null"]},"parentId":{"minLength":1,"type":"string"},"status":{"minLength":1,"type":"string"}},"required":["id","identifier","parentId","status","assigneeActorId"],"type":"object"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds","task"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"request_approval","requiredClaims":["governance:approvals:request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a governed mock approval and waiting posture.","inputSchema":{"additionalProperties":false,"properties":{"approvalType":{"description":"Stable approval type.","maxLength":200,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","approvalType","payload"],"type":"object"},"name":"request_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"decide_approval","requiredClaims":["governance:approvals:decide"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Decide a mock approval as an explicitly authorized approver.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Mock approval id.","maxLength":200,"minLength":1,"type":"string"},"decision":{"enum":["approved","rejected","cancelled"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"note":{"description":"Decision note.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","decision","note"],"type":"object"},"name":"decide_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"comment_on_approval","requiredClaims":["governance:approvals:comment"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Add a durable comment to a mock approval.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Mock approval id.","maxLength":200,"minLength":1,"type":"string"},"body":{"description":"Approval comment.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","body"],"type":"object"},"name":"comment_on_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"schedule_wake","requiredClaims":["control_plane:wakes"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule a deterministic mock continuation wake.","inputSchema":{"additionalProperties":false,"properties":{"delayTicks":{"maximum":10000,"minimum":1,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"reason":{"enum":["manual","issue_commented","interaction_resolved","approval_resolved","blockers_resolved","scheduled_retry","resume"]}},"required":["idempotencyKey","reason","delayTicks"],"type":"object"},"name":"schedule_wake","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"generic_api_request","requiredClaims":["test:generic_api_request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.","inputSchema":{"additionalProperties":false,"properties":{"body":{"additionalProperties":true,"type":"object"},"method":{"enum":["GET","POST","PATCH"]},"path":{"maxLength":500,"pattern":"^/mock/","type":"string"}},"required":["method","path"],"type":"object"},"name":"generic_api_request","outputSchema":{"additionalProperties":true,"type":"object"}}]
+[{"annotations":{"exposure":"always","operationId":"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 mock 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 mock 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 mock 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 mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock 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 mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create or update an active-task document with optimistic revision safety.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision id, or null when creating.","maxLength":20000,"type":["string","null"]},"body":{"description":"Markdown document body.","maxLength":200000,"minLength":1,"type":"string"},"changeSummary":{"description":"Optional revision summary.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"title":{"description":"Document title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","key","title","body","baseRevisionId"],"type":"object"},"name":"write_document","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_human_input","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a typed, durable interaction on the active mock task.","inputSchema":{"additionalProperties":false,"properties":{"continuationPolicy":{"enum":["none","wake_assignee","wake_assignee_on_accept"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"interactionKind":{"enum":["confirmation","checkbox","questions","suggest_tasks","item_verdicts"]},"payload":{"additionalProperties":true,"description":"Kind-specific interaction data. For interactionKind='questions', use exactly {version:1, questions:[{id,prompt,selectionMode:'single'|'multi',required?,options:[{id,label,description?,freeText?}]}]}; option keys are id/label, not value, and question choice cardinality is selectionMode, not type. For confirmation, payload may be {}. Keep all ids stable across retries.","type":"object"},"prompt":{"description":"Question or decision prompt.","maxLength":10000,"minLength":1,"type":"string"},"targetRevisionId":{"description":"Optional bound document revision.","maxLength":20000,"type":["string","null"]},"title":{"description":"Interaction card title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","interactionKind","title","prompt","continuationPolicy"],"type":"object"},"name":"request_human_input","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"register_deliverable","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Register mock attachment metadata and its artifact work product without credentials or bytes in the tool result.","inputSchema":{"additionalProperties":false,"properties":{"byteSize":{"maximum":100000000,"minimum":0,"type":"integer"},"contentRef":{"description":"Opaque package-local content reference.","maxLength":2000,"minLength":1,"type":"string"},"contentType":{"description":"Media type.","maxLength":200,"minLength":1,"type":"string"},"filename":{"description":"Display filename.","maxLength":500,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"sha256":{"pattern":"^[a-fA-F0-9]{64}$","type":"string"},"title":{"description":"Work-product title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","filename","contentType","byteSize","sha256","contentRef","title"],"type":"object"},"name":"register_deliverable","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"finish_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Finish the active mock task with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Completion summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"finish_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"block_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Block the active mock task with a durable reason and optional first-class dependencies.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Internal mock task ids that block this task.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Block reason.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","reason"],"type":"object"},"name":"block_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_review","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Move the active mock task to review with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Review handoff summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"request_review","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_agents","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List redacted mock actor profiles.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_agents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_agent","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one redacted mock actor profile.","inputSchema":{"additionalProperties":false,"properties":{"actorId":{"description":"Mock actor id.","maxLength":200,"minLength":1,"type":"string"}},"required":["actorId"],"type":"object"},"name":"get_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_tasks","requiredClaims":["discovery:tasks:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Search mock tasks by text and status within the run company.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"},"query":{"maxLength":500,"type":"string"},"statuses":{"items":{"enum":["backlog","todo","in_progress","in_review","done","blocked","cancelled"]},"maxItems":7,"type":"array","uniqueItems":true}},"required":[],"type":"object"},"name":"search_tasks","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_approvals","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List mock approvals in the run company.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_approvals","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one mock approval without protected data.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Mock approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval_context","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval, its comments, and linked mock tasks.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Mock approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace_runtime","requiredClaims":["workspace:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read active-task mock workspace services.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace_runtime","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"control_workspace_service","requiredClaims":["workspace:control"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Start, stop, or fault one active-task mock workspace service.","inputSchema":{"additionalProperties":false,"properties":{"action":{"enum":["start","stop","fail"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"serviceId":{"description":"Mock workspace service id.","maxLength":200,"minLength":1,"type":"string"},"url":{"description":"Optional mock service URL.","maxLength":20000,"type":["string","null"]}},"required":["idempotencyKey","serviceId","action"],"type":"object"},"name":"control_workspace_service","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_dependencies","requiredClaims":["dependencies:write"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace the active task's first-class blocker set.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Replacement blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","blockedByTaskIds"],"type":"object"},"name":"set_dependencies","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_task","requiredClaims":["delegation:tasks:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create one durable standard child under the active task. Use only when a real ownership, parallelism, dependency, review, or lifecycle boundary justifies delegation.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"Optional agent assignee. Omit to assign the current agent.","maxLength":20000,"type":["string","null"]},"blockedByTaskIds":{"description":"Initial blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"description":{"description":"Child task description.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"priority":{"enum":["critical","high","medium","low"]},"title":{"description":"Child task title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","title"],"type":"object"},"name":"create_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"},"task":{"additionalProperties":false,"properties":{"assigneeActorId":{"type":["string","null"]},"id":{"minLength":1,"type":"string"},"identifier":{"type":["string","null"]},"parentId":{"minLength":1,"type":"string"},"status":{"minLength":1,"type":"string"}},"required":["id","identifier","parentId","status","assigneeActorId"],"type":"object"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds","task"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"request_approval","requiredClaims":["governance:approvals:request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a governed mock approval and waiting posture.","inputSchema":{"additionalProperties":false,"properties":{"approvalType":{"description":"Stable approval type.","maxLength":200,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","approvalType","payload"],"type":"object"},"name":"request_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"decide_approval","requiredClaims":["governance:approvals:decide"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Decide a mock approval as an explicitly authorized approver.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Mock approval id.","maxLength":200,"minLength":1,"type":"string"},"decision":{"enum":["approved","rejected","cancelled"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"note":{"description":"Decision note.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","decision","note"],"type":"object"},"name":"decide_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"comment_on_approval","requiredClaims":["governance:approvals:comment"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Add a durable comment to a mock approval.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Mock approval id.","maxLength":200,"minLength":1,"type":"string"},"body":{"description":"Approval comment.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","body"],"type":"object"},"name":"comment_on_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"schedule_wake","requiredClaims":["control_plane:wakes"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule a deterministic mock continuation wake.","inputSchema":{"additionalProperties":false,"properties":{"delayTicks":{"maximum":10000,"minimum":1,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"reason":{"enum":["manual","issue_commented","interaction_resolved","approval_resolved","blockers_resolved","scheduled_retry","resume"]}},"required":["idempotencyKey","reason","delayTicks"],"type":"object"},"name":"schedule_wake","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"generic_api_request","requiredClaims":["test:generic_api_request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.","inputSchema":{"additionalProperties":false,"properties":{"body":{"additionalProperties":true,"type":"object"},"method":{"enum":["GET","POST","PATCH"]},"path":{"maxLength":500,"pattern":"^/mock/","type":"string"}},"required":["method","path"],"type":"object"},"name":"generic_api_request","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_api","requiredClaims":["api:discover"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":200,"type":"string"},"limit":{"default":5,"maximum":10,"minimum":1,"type":"integer"},"query":{"maxLength":500,"minLength":1,"type":"string"}},"required":["query"],"type":"object"},"name":"search_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"call_api","requiredClaims":["api:call"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool.","inputSchema":{"additionalProperties":false,"properties":{"body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"items":{},"type":"array"},{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"}],"description":"Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."},"contentType":{"maxLength":120,"type":"string"},"files":{"items":{"additionalProperties":false,"oneOf":[{"properties":{"artifactId":{}},"required":["artifactId"]},{"properties":{"path":{}},"required":["path"]}],"properties":{"artifactId":{"type":"string"},"field":{"type":"string"},"path":{"description":"File relative to the active issue workspace. Remote files must first be uploaded as an artifact.","type":"string"}},"type":"object"},"maxItems":10,"type":"array"},"operationId":{"description":"Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.","maxLength":500,"minLength":1,"type":"string"},"pathParams":{"additionalProperties":{"type":"string"},"type":"object"},"query":{"additionalProperties":true,"type":"object"}},"required":["operationId"],"type":"object"},"name":"call_api","outputSchema":{"additionalProperties":true,"type":"object"}}]
diff --git a/packages/paperclip-runner/generated/semantic-action-catalog.json b/packages/paperclip-runner/generated/semantic-action-catalog.json
index c01e62e819..2d9126bcad 100644
--- a/packages/paperclip-runner/generated/semantic-action-catalog.json
+++ b/packages/paperclip-runner/generated/semantic-action-catalog.json
@@ -1,4 +1,164 @@
[
+ {
+ "allowedModes": [
+ "standard",
+ "ask",
+ "planning",
+ "skill_test"
+ ],
+ "description": "Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them.",
+ "effect": "read",
+ "inputSchema": {
+ "additionalProperties": false,
+ "properties": {
+ "cursor": {
+ "maxLength": 200,
+ "type": "string"
+ },
+ "limit": {
+ "default": 5,
+ "maximum": 10,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "query": {
+ "maxLength": 500,
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "required": [
+ "query"
+ ],
+ "type": "object"
+ },
+ "operationId": "search_api",
+ "outputSchema": {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ "placement": "optional",
+ "requiredClaims": [
+ "api:discover"
+ ],
+ "schema": "paperclip.semantic-action.v1",
+ "title": "Search the Paperclip API",
+ "version": 1
+ },
+ {
+ "allowedModes": [
+ "standard",
+ "ask",
+ "planning",
+ "skill_test"
+ ],
+ "description": "Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool.",
+ "effect": "write",
+ "inputSchema": {
+ "additionalProperties": false,
+ "properties": {
+ "body": {
+ "anyOf": [
+ {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ {
+ "items": {},
+ "type": "array"
+ },
+ {
+ "type": "string"
+ },
+ {
+ "type": "number"
+ },
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."
+ },
+ "contentType": {
+ "maxLength": 120,
+ "type": "string"
+ },
+ "files": {
+ "items": {
+ "additionalProperties": false,
+ "oneOf": [
+ {
+ "properties": {
+ "artifactId": {}
+ },
+ "required": [
+ "artifactId"
+ ]
+ },
+ {
+ "properties": {
+ "path": {}
+ },
+ "required": [
+ "path"
+ ]
+ }
+ ],
+ "properties": {
+ "artifactId": {
+ "type": "string"
+ },
+ "field": {
+ "type": "string"
+ },
+ "path": {
+ "description": "File relative to the active issue workspace. Remote files must first be uploaded as an artifact.",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "maxItems": 10,
+ "type": "array"
+ },
+ "operationId": {
+ "description": "Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.",
+ "maxLength": 500,
+ "minLength": 1,
+ "type": "string"
+ },
+ "pathParams": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "type": "object"
+ },
+ "query": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ },
+ "required": [
+ "operationId"
+ ],
+ "type": "object"
+ },
+ "operationId": "call_api",
+ "outputSchema": {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ "placement": "optional",
+ "requiredClaims": [
+ "api:call"
+ ],
+ "schema": "paperclip.semantic-action.v1",
+ "title": "Call the Paperclip API",
+ "version": 1
+ },
{
"allowedModes": [
"standard",
diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json
index 16941733a5..90084119d5 100644
--- a/packages/paperclip-runner/package.json
+++ b/packages/paperclip-runner/package.json
@@ -96,7 +96,8 @@
"check:semantic-action-catalog": "pnpm run build:typescript && node scripts/generate-semantic-action-catalog.mjs --check",
"check:protocol": "pnpm run typecheck:typescript && pnpm run check:protocol-manifest && pnpm run test:typescript && pnpm run check:replay-goldens",
"check:runner": "pnpm run typecheck:rust && pnpm run test:rust && pnpm run check:conformance-parity && pnpm run check:replay-parity",
- "check:all": "pnpm run check:eval-kernel && pnpm run check:protocol && pnpm run check:runner",
+ "check:all": "pnpm run check:eval-kernel && pnpm run check:protocol && pnpm run check:runner && pnpm run check:api-authority",
+ "check:api-authority": "node scripts/check-api-authority.mjs",
"generate:replay-goldens": "pnpm run build:typescript && node scripts/generate-replay-goldens.mjs",
"check:replay-goldens": "pnpm run build:typescript && node scripts/generate-replay-goldens.mjs --check",
"check:browser-tokens": "node scripts/check-browser-tokens.mjs",
diff --git a/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json b/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json
index c005afd78c..aef9364cae 100644
--- a/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json
+++ b/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json
@@ -24,7 +24,7 @@
"prpVersion": 1,
"nativeExecutionVersion": 1,
"catalogVersion": 1,
- "catalogSha256": "sha256:e5a90079e1641e50f2f07a320856bbef622c04810384ffdd1895576cc16c4c5c",
+ "catalogSha256": "sha256:842a1515a5b549fcc5df7675f3a96471b2f1ca33f4699cc5dd2ecf6c4235f2ec",
"driverContractVersion": 1,
"driverKind": "paperclip-deterministic",
"driverVersion": "1.0.0"
@@ -136,7 +136,11 @@
}
]
},
- "evidence": [{ "receiptId": "receipt_evals_seeded" }],
+ "evidence": [
+ {
+ "receiptId": "receipt_evals_seeded"
+ }
+ ],
"verification": [
{
"commandOrCheck": "rejected tool effect stays denied",
diff --git a/packages/paperclip-runner/protocol/manifest.json b/packages/paperclip-runner/protocol/manifest.json
index 35cfb0f5b9..ca180ef384 100644
--- a/packages/paperclip-runner/protocol/manifest.json
+++ b/packages/paperclip-runner/protocol/manifest.json
@@ -140,7 +140,7 @@
},
{
"path": "fixtures/evals/native-execution-seeded.json",
- "sha256": "3a10ca2da2dfd1e945eae8af4518f1cd4ae5c1100adc269ca808144a78cdbcfa",
+ "sha256": "89641b73df452a5d03502bc151a81a68387ece129c8826e0572800c3b1c5265c",
"expectation": "accept",
"compatibilityCase": "canonical"
},
diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs
index d14fd2307d..aff3a58115 100644
--- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs
+++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs
@@ -116,7 +116,13 @@ fn matches_task_context_result(result: &Value, expected_canonical: Option<&Value
};
if result.get("ok") != Some(&json!(true))
|| result.get("operationId").and_then(Value::as_str) != Some("get_task_context")
- || result.get("callId").and_then(Value::as_str) != Some("semantic-call-1")
+ || result.get("callId").and_then(Value::as_str)
+ != Some(
+ expected
+ .get("callId")
+ .and_then(Value::as_str)
+ .unwrap_or("semantic-call-1"),
+ )
{
return false;
}
@@ -533,6 +539,9 @@ fn run() -> Result<(), Box> {
.iter()
.any(|value| value == "--require-codex-home-auth");
let durable_turn_ids = args.iter().any(|value| value == "--durable-turn-ids");
+ let durable_tool_ids = args.iter().any(|value| value == "--durable-tool-ids");
+ let expected_canonical_task_context_file =
+ argument(&args, "--expected-canonical-task-context-file");
let emit_tool_call = args.iter().any(|value| value == "--emit-tool-call");
let replay_completed_tool_call = args
.iter()
@@ -782,7 +791,19 @@ fn run() -> Result<(), Box> {
.and_then(Value::as_str)
.ok_or("semantic tool response omitted content text")?;
let result: Value = serde_json::from_str(text)?;
- if !matches_task_context_result(&result, expected_canonical_task_context.as_ref()) {
+ let expected_from_file = if let Some(path) = &expected_canonical_task_context_file {
+ Some(serde_json::from_str::(&std::fs::read_to_string(
+ path,
+ )?)?)
+ } else {
+ None
+ };
+ if !matches_task_context_result(
+ &result,
+ expected_from_file
+ .as_ref()
+ .or(expected_canonical_task_context.as_ref()),
+ ) {
return Err("semantic tool response changed the operation result".into());
}
log_call(call_log.as_deref(), &format!("tool-response:{text}"))?;
@@ -1175,7 +1196,7 @@ fn run() -> Result<(), Box> {
"params": {
"threadId": state.thread_id,
"turnId": provider_turn_id,
- "callId": "semantic-call-1",
+ "callId": if durable_tool_ids { format!("semantic-call-{}", state.next_turn) } else { "semantic-call-1".to_owned() },
"tool": "get_task_context",
"arguments": {}
}
diff --git a/packages/paperclip-runner/scripts/check-api-authority.mjs b/packages/paperclip-runner/scripts/check-api-authority.mjs
new file mode 100644
index 0000000000..0b3a8c8e9a
--- /dev/null
+++ b/packages/paperclip-runner/scripts/check-api-authority.mjs
@@ -0,0 +1,22 @@
+// The general server test job has no Rust binary. This check builds and stages
+// its own binary, then requires the complete runnerd → PRP → authority → HTTP test.
+import { spawnSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+
+const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
+function run(args, cwd, env = process.env) {
+ const result = spawnSync(pnpm, args, { cwd, env, stdio: "inherit" });
+ if (result.error) throw result.error;
+ if (result.status !== 0) process.exit(result.status ?? 1);
+}
+
+run(["--filter", "@paperclipai/plugin-sdk", "ensure-build-deps"], fileURLToPath(new URL("../../../", import.meta.url)));
+run(["run", "build:binary"], fileURLToPath(new URL("../", import.meta.url)));
+run([
+ "exec", "vitest", "run",
+ "server/src/services/native-runtime/runner-api.test.ts",
+ "server/src/services/native-runtime/runner-api-rollout.test.ts",
+ "server/src/services/native-runtime/runner-api.integration.test.ts",
+], fileURLToPath(new URL("../../../", import.meta.url)), {
+ ...process.env, PAPERCLIP_REQUIRE_RUNNER_API_INTEGRATION: "1",
+});
diff --git a/packages/paperclip-runner/spec/capability/protocol-coverage.json b/packages/paperclip-runner/spec/capability/protocol-coverage.json
index 8b67f5aac6..b71d9de2dc 100644
--- a/packages/paperclip-runner/spec/capability/protocol-coverage.json
+++ b/packages/paperclip-runner/spec/capability/protocol-coverage.json
@@ -7,10 +7,48 @@
"src/scenarios/scenario-plan.ts"
],
"counts": {
- "actions": 41,
+ "actions": 43,
"legacyRequirements": 106
},
"actions": [
+ {
+ "id": "search_api",
+ "ownership": "optional_agent_tool",
+ "surfaces": [
+ "live"
+ ],
+ "legacyAliases": [],
+ "contractCase": "protocol-action:search_api",
+ "contractOwner": "src/catalog/protocol-action-contracts.test.ts::search_api has a schema-valid canonical example and every declared projection",
+ "legacyBehavioralCases": [],
+ "deterministicCases": [
+ "protocol-action:search_api"
+ ],
+ "legacyRequirementCases": [],
+ "deterministicOwners": [
+ "src/catalog/protocol-action-contracts.test.ts::search_api 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": "call_api",
+ "ownership": "optional_agent_tool",
+ "surfaces": [
+ "live"
+ ],
+ "legacyAliases": [],
+ "contractCase": "protocol-action:call_api",
+ "contractOwner": "src/catalog/protocol-action-contracts.test.ts::call_api has a schema-valid canonical example and every declared projection",
+ "legacyBehavioralCases": [],
+ "deterministicCases": [
+ "protocol-action:call_api"
+ ],
+ "legacyRequirementCases": [],
+ "deterministicOwners": [
+ "src/catalog/protocol-action-contracts.test.ts::call_api 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": "administer_company",
"ownership": "optional_agent_tool",
diff --git a/packages/paperclip-runner/spec/operation-groups/source.json b/packages/paperclip-runner/spec/operation-groups/source.json
index ce4aba8553..19133ada3f 100644
--- a/packages/paperclip-runner/spec/operation-groups/source.json
+++ b/packages/paperclip-runner/spec/operation-groups/source.json
@@ -99,6 +99,14 @@
"id": "test_escape_hatch",
"description": "Controlled skill-test transport only; never product coverage.",
"operationIds": ["generic_api_request"]
+ },
+ {
+ "id": "api_fallback",
+ "description": "Discover and invoke HTTP API operations unsupported by available dedicated tools. Production authority and route authorization remain required.",
+ "operationIds": [
+ "search_api",
+ "call_api"
+ ]
}
],
"behaviorGroups": [
@@ -251,12 +259,12 @@
"legacyGroup": 13,
"name": "Reference files",
"owner": "optional domain tools + test-only escape hatch",
- "operationIds": ["list_cases", "upsert_case", "list_routines", "manage_routine", "list_company_skills", "sync_company_skills", "list_secret_metadata", "read_secret_value", "export_company", "administer_company", "generic_api_request"],
+ "operationIds": ["list_cases", "upsert_case", "list_routines", "manage_routine", "list_company_skills", "sync_company_skills", "list_secret_metadata", "read_secret_value", "export_company", "administer_company", "generic_api_request", "search_api", "call_api"],
"controlPlaneOperationIds": ["append_audit_record"],
"realSurface": "case, routine, company-skill, secret, portability, and administration services",
"mockStateDomains": ["company", "cases", "routines", "skills", "secrets", "audit", "fault"],
"prpEvidence": "bounded domain projections, redacted broker receipts, company diffs, and audit references",
- "gap": "These operations are scenario-only except generic_api_request, which is test-only; broad administer_company is deferred and cannot claim product coverage."
+ "gap": "These operations are scenario-only except generic_api_request, which is test-only; broad administer_company is deferred and cannot claim product coverage. Production escape-hatch and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage."
},
{
"id": "mh",
diff --git a/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md b/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md
index a1c30cd00a..fb387a4759 100644
--- a/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md
+++ b/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md
@@ -6,7 +6,7 @@ Status: canonical explanatory contract for the Paperclip runner V1 surface.
This document keeps three independent meanings of **group** separate. PRP families describe wire evidence and controller commands; capability placement decides who owns an operation; behavioral eval groups organize the 106 scenario corpus. None of the three axes can be used as a substitute for another.
-The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **41 reconciled semantic operations** (14 always, 27 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**, **43 reconciled semantic operations** (14 always, 29 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_task_context`, `get_task_history`, `inspect_operation_result`, `list_document_revisions`, `list_documents`, `read_document`, `register_deliverable`, `report_progress`, `request_human_input`, `request_review`, `write_document`.
-### Optional operations (27) and grant groups (11)
+### Optional operations (29) and grant groups (12)
Grant groups are documentation/exposure bundles, not additional authority. The operation descriptor's exact `requiredClaims` remains decisive.
@@ -104,6 +104,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| `secrets` | `list_secret_metadata`
`read_secret_value` | `secrets:metadata:read`
`secrets:values:read` | Inspect secret metadata or use a brokered secret value without exposing plaintext evidence. |
| `portability_admin` | `export_company`
`administer_company` | `company:admin`
`portability:export` | Export portable company state; broad administration remains deferred until split into governed operations. |
| `test_escape_hatch` | `generic_api_request` | `test:generic_api_request` | Controlled skill-test transport only; never product coverage. |
+| `api_fallback` | `search_api`
`call_api` | `api:call`
`api:discover` | Discover and invoke HTTP API operations unsupported by available dedicated tools. Production authority and route authorization remain required. |
### Reconciled semantic-operation ledger
@@ -114,6 +115,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| `administer_company` | `optional_agent_tool` | `company:admin` | `standard`
`skill_test`
roles: `board`
`ceo`
`admin` | `admin` | `required` | no | `mock_extension:company.admin` | `scenario`
`scenario_mock` | `unbound`
company admin/portability item event plus audit record
catalog PRP status: `audit_pending` |
| `answer_status_question` | `always_agent_tool` | none | `standard`
`ask`
`planning`
`skill_test` | `task_write` | `required` | no | `semantic_command:report_progress` | `scenario` + `live`
`live_codex` | `unbound`
semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events
catalog PRP status: `audit_pending` |
| `block_task` | `always_agent_tool` | none | `standard`
`skill_test` | `task_write` | `required` | no | `semantic_command:block_task` | `scenario` + `live`
`live_codex` | `unbound`
semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events
catalog PRP status: `audit_pending` |
+| `call_api` | `optional_agent_tool` | `api:call` | `standard`
`ask`
`planning`
`skill_test` | `company_write` | `none` | no | inline/no mapping | `live`
`live_codex` | `PaperclipRunnerToolAuthority`
Authenticated PRP tool input/result and existing HTTP route authorization/activity records.
catalog PRP status: `bound` |
| `comment_on_approval` | `optional_agent_tool` | `governance:approvals:comment` | `standard`
`ask`
`planning`
`skill_test` | `governance` | `required` | no | `semantic_command:comment_on_approval` | `scenario` + `live`
`live_codex` | `unbound`
approval lifecycle plus governed-wait continuation and audit events
catalog PRP status: `audit_pending` |
| `control_workspace_service` | `optional_agent_tool` | `workspace:control` | `standard`
`skill_test` | `workspace_control` | `required` | no | `semantic_command:control_workspace_service` | `scenario` + `live`
`live_codex` | `unbound`
workspace service lifecycle event
catalog PRP status: `audit_pending` |
| `create_task` | `optional_agent_tool` | `delegation:tasks:create` | `standard`
`skill_test` | `company_write` | `required` | no | `semantic_command:create_task` | `scenario` + `live`
`live_codex` | `issues.createChild`
semantic-operation item event plus company-entity state diff and audit record
catalog PRP status: `bound` |
@@ -147,6 +149,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| `request_human_input` | `always_agent_tool` | none | `standard`
`planning`
`ask`
`skill_test` | `task_write` | `required` | no | `semantic_command:request_human_input` | `scenario` + `live`
`live_codex` | `unbound`
semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events
catalog PRP status: `audit_pending` |
| `request_review` | `always_agent_tool` | none | `standard`
`skill_test` | `task_write` | `required` | no | `semantic_command:request_review` | `scenario` + `live`
`live_codex` | `unbound`
semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events
catalog PRP status: `audit_pending` |
| `schedule_wake` | `optional_agent_tool` | `control_plane:wakes` | `standard`
`skill_test` | `task_write` | `required` | no | inline/no mapping | `live`
`live_codex` | `unbound`
semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events
catalog PRP status: `audit_pending` |
+| `search_api` | `optional_agent_tool` | `api:discover` | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | inline/no mapping | `live`
`live_codex` | `PaperclipRunnerToolAuthority`
Authenticated PRP tool input/result and existing HTTP route authorization/activity records.
catalog PRP status: `bound` |
| `search_tasks` | `optional_agent_tool` | `discovery:tasks:read` | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | `snapshot_read:company_tasks` | `scenario` + `live`
`live_codex` | `unbound`
read projection surfaced via a tool-result item event; no control-plane state diff
catalog PRP status: `audit_pending` |
| `set_dependencies` | `optional_agent_tool` | `dependencies:write` | `standard`
`skill_test` | `company_write` | `required` | no | `semantic_command:set_dependencies` | `scenario` + `live`
`live_codex` | `issues.update.blockedByIssueIds`
semantic-operation item event plus company-entity state diff and audit record
catalog PRP status: `bound` |
| `sync_company_skills` | `optional_agent_tool` | `company_skills:write` | `standard`
`skill_test` | `admin` | `required` | no | `mock_extension:company_skills.sync` | `scenario`
`scenario_mock` | `unbound`
company admin/portability item event plus audit record
catalog PRP status: `audit_pending` |
@@ -173,7 +176,7 @@ Behavior groups describe expected outcomes and trajectories. They do not grant t
| [`ap` — Approvals](#behavior-group-ap-approvals) | optional governance tools + governed approver | `list_approvals`
`get_approval`
`get_approval_context`
`request_approval`
`decide_approval`
`comment_on_approval` | `route_wake`
`append_audit_record` | company approval, decision, issue-link, comment, and governed-action services | `company`
`task`
`approvals`
`actor`
`wake`
`audit`
`idempotency` | 6 | governed semantic receipts, audit references, and attention/continuation linkage | Production binding and additive governed-action receipts are unbound; board-only authority stays outside grants. |
| [`ar` — Artifacts](#behavior-group-ar-artifacts) | always tools + artifact/work-product services | `register_deliverable` | `append_audit_record` | attachment upload and issue work-product routes | `task`
`artifacts`
`workProducts`
`workspace`
`audit`
`idempotency` | 4 | artifact/work-product reference and durable inspectability receipt; never binary bytes | Production upload/register composite and additive durable-reference receipt are unbound. |
| [`er` — Errors and critical rules](#behavior-group-er-errors-and-critical-rules) | runner/control plane + optional workspace/wake tools | `get_workspace_runtime`
`control_workspace_service`
`schedule_wake`
`inspect_operation_result` | `release_task`
`enforce_budget`
`persist_run`
`replay_run`
`reconcile_run` | workspace runtime, monitor/recovery, budget, run persistence/replay, release, and terminal services | `workspace`
`budget`
`run`
`wake`
`audit`
`idempotency`
`fault` | 9 | runtime/workspace/attention/run lifecycle, typed denials, replay facts, and terminal causality | Budget stop reasons and semantic denial/conflict receipts require additive v1 envelopes; inspect_operation_result remains scenario-only. |
-| [`rf` — Reference files](#behavior-group-rf-reference-files) | optional domain tools + test-only escape hatch | `list_cases`
`upsert_case`
`list_routines`
`manage_routine`
`list_company_skills`
`sync_company_skills`
`list_secret_metadata`
`read_secret_value`
`export_company`
`administer_company`
`generic_api_request` | `append_audit_record` | case, routine, company-skill, secret, portability, and administration services | `company`
`cases`
`routines`
`skills`
`secrets`
`audit`
`fault` | 22 | bounded domain projections, redacted broker receipts, company diffs, and audit references | These operations are scenario-only except generic_api_request, which is test-only; broad administer_company is deferred and cannot claim product coverage. |
+| [`rf` — Reference files](#behavior-group-rf-reference-files) | optional domain tools + test-only escape hatch | `list_cases`
`upsert_case`
`list_routines`
`manage_routine`
`list_company_skills`
`sync_company_skills`
`list_secret_metadata`
`read_secret_value`
`export_company`
`administer_company`
`generic_api_request`
`search_api`
`call_api` | `append_audit_record` | case, routine, company-skill, secret, portability, and administration services | `company`
`cases`
`routines`
`skills`
`secrets`
`audit`
`fault` | 22 | bounded domain projections, redacted broker receipts, company diffs, and audit references | These operations are scenario-only except generic_api_request, which is test-only; broad administer_company is deferred and cannot claim product coverage. Production escape-hatch and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage. |
| [`mh` — Multi-hop](#behavior-group-mh-multi-hop) | composed semantic operations + control-plane continuation | `create_task`
`set_dependencies`
`request_human_input`
`request_approval`
`register_deliverable` | `route_wake`
`reconcile_run` | delegation, dependency, interaction, approval, artifact, and terminal orchestration services | `task`
`blockers`
`interactions`
`approvals`
`artifacts`
`wake`
`run`
`audit` | 4 | correlated operation receipts, state diffs, attention hops, work assessment, status decision, and terminal outcome | No generic transaction tool is allowed; shared mock/real conformance must prove each composed effect. |
| [`rs` — Restraint and no-call](#behavior-group-rs-restraint-and-no-call) | policy/exposure layer | `answer_status_question`
`read_secret_value`
`generic_api_request` | `enforce_budget` | task-mode, secret-broker, test-scope, pause, and budget policy checks | `actor`
`task`
`budget`
`secrets`
`audit`
`fault` | 3 | absence of forbidden effects plus typed policy denial/redaction receipts when a call is attempted | Typed redaction/authorization receipts need additive v1 evidence; generic_api_request is never a product fallback. |
| [`wk` — Wake situations](#behavior-group-wk-wake-situations) | control plane + always context/history tools | `get_task_context`
`get_task_history`
`schedule_wake` | `select_work`
`route_wake` | wakeup requests, heartbeat context, comment/interaction/approval/blocker wake routing, and scheduled wake services | `wake`
`task`
`comments`
`interactions`
`approvals`
`blockers`
`run` | 8 | attention request routing/resolution plus resumed session/run causality | Production scheduling binding is unbound; control-plane routing remains non-callable. |
@@ -450,10 +453,10 @@ Current responsibility-based paths are normative. Numbered `phase-*` or mileston
### Catalog split and deliberate replacement
- Scenario/eval catalog: **37** operations.
-- Live dispatcher catalog: **28** operations.
-- Shared: **24**; union/canonical authority: **41**.
+- Live dispatcher catalog: **30** operations.
+- Shared: **24**; union/canonical authority: **43**.
- Scenario-only: `administer_company`, `export_company`, `inspect_operation_result`, `list_cases`, `list_company_skills`, `list_goals`, `list_projects`, `list_routines`, `list_secret_metadata`, `manage_routine`, `read_secret_value`, `sync_company_skills`, `upsert_case`.
-- Live-only: `get_agent`, `get_approval`, `get_approval_context`, `schedule_wake`.
+- Live-only: `call_api`, `get_agent`, `get_approval`, `get_approval_context`, `schedule_wake`, `search_api`.
- The generated provider contract contains exactly the live catalog; the canonical union remains the migration authority until all scenario-only operations are either implemented, deferred, or removed by an explicit reconciliation decision.
- `generic_api_request` stays exported only for controlled tests and cannot be cited as real-surface, mock-parity, or PRP product coverage.
diff --git a/packages/paperclip-runner/src/catalog/canonical-operations.ts b/packages/paperclip-runner/src/catalog/canonical-operations.ts
index eac0c21c23..2d37b260f4 100644
--- a/packages/paperclip-runner/src/catalog/canonical-operations.ts
+++ b/packages/paperclip-runner/src/catalog/canonical-operations.ts
@@ -21,7 +21,7 @@ export const CAPABILITY_CANONICAL_OPERATIONS: readonly CapabilityCanonicalOperat
.sort((left, right) => left.operationId.localeCompare(right.operationId)),
);
const byId = new Map(CAPABILITY_CANONICAL_OPERATIONS.map((operation) => [operation.operationId, operation]));
-if (byId.size !== 41) throw new Error(`expected 41 canonical semantic operations, found ${byId.size}`);
+if (byId.size !== 43) throw new Error(`expected 43 canonical semantic operations, found ${byId.size}`);
export function capabilityCanonicalOperation(operationId: string): CapabilityCanonicalOperation | undefined { return byId.get(operationId); }
export function capabilityCanonicalOperationsForSurface(surface: CapabilityCatalogSurface): readonly CapabilityCanonicalOperation[] { return CAPABILITY_CANONICAL_OPERATIONS.filter((operation) => operation.surfaces.includes(surface)); }
export function capabilityCanonicalOperationIds(): readonly string[] { return CAPABILITY_CANONICAL_OPERATIONS.map((operation) => operation.operationId); }
diff --git a/packages/paperclip-runner/src/catalog/reconciliation.test.ts b/packages/paperclip-runner/src/catalog/reconciliation.test.ts
index 32c7c75fbd..5445dbcc69 100644
--- a/packages/paperclip-runner/src/catalog/reconciliation.test.ts
+++ b/packages/paperclip-runner/src/catalog/reconciliation.test.ts
@@ -20,16 +20,18 @@ describe("canonical semantic-catalog reconciliation authority", () => {
it("pins the reconciled op-set relationship between the two catalogs", () => {
const summary = capabilityCatalogReconciliation();
expect(summary.scenarioCount).toBe(37);
- expect(summary.liveCount).toBe(28);
+ expect(summary.liveCount).toBe(30);
expect(summary.sharedCount).toBe(24);
- expect(summary.unionCount).toBe(41);
+ expect(summary.unionCount).toBe(43);
// Any operation added to or removed from either catalog without a
// reconciliation decision changes these exact sets and fails the gate.
expect(summary.liveOnly).toEqual([
+ "call_api",
"get_agent",
"get_approval",
"get_approval_context",
"schedule_wake",
+ "search_api",
]);
expect(summary.scenarioOnly).toEqual([
"administer_company",
@@ -49,7 +51,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
});
it("is the single source both catalogs derive their operation set from", () => {
- expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(41);
+ expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(43);
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);
@@ -83,7 +85,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(41);
+ expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(43);
for (const operation of CAPABILITY_CANONICAL_CATALOG) {
expect(operation.placement).toMatch(/^(always|optional)_agent_tool$/);
expect(Array.isArray(operation.requiredClaims)).toBe(true);
@@ -109,7 +111,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: 27,
+ live_codex: 29,
scenario_mock: 13,
test_only: 1,
});
diff --git a/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts b/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts
index ca51f0e280..6c62a21573 100644
--- a/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts
+++ b/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts
@@ -23,7 +23,7 @@ describe("semantic action catalog", () => {
(action) => action.operationId,
);
- expect(operationIds).toHaveLength(27);
+ expect(operationIds).toHaveLength(29);
expect(new Set(operationIds).size).toBe(operationIds.length);
expect(operationIds).not.toContain("generic_api_request");
expect(Object.isFrozen(PAPERCLIP_SEMANTIC_ACTION_CATALOG)).toBe(true);
diff --git a/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts b/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts
index d04b6084f2..3d4306b352 100644
--- a/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts
+++ b/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts
@@ -4,6 +4,8 @@ import type {
PaperclipSemanticActionId,
PaperclipSemanticActionMode,
} from "./semantic-action-types.js";
+import { searchApiAction } from "../protocol-actions/search-api.js";
+import { callApiAction } from "../protocol-actions/call-api.js";
const ALL_MODES = ["standard", "ask", "planning", "skill_test"] as const;
const WORK_MODES = ["standard", "planning", "skill_test"] as const;
@@ -105,6 +107,17 @@ function descriptor(input: DescriptorInput): PaperclipSemanticActionDescriptor {
}
const descriptors: readonly PaperclipSemanticActionDescriptor[] = [
+ ...[searchApiAction, callApiAction].map(action => descriptor({
+ operationId: action.id,
+ title: action.live.descriptor.title,
+ description: action.live.descriptor.description,
+ placement: "optional",
+ effect: action.id === "search_api" ? "read" : "write",
+ requiredClaims: action.live.descriptor.requiredClaims,
+ allowedModes: action.live.descriptor.allowedModes,
+ inputSchema: action.live.descriptor.inputSchema,
+ outputSchema: action.live.descriptor.outputSchema,
+ })),
descriptor({
operationId: "get_task_context",
title: "Get active task context",
diff --git a/packages/paperclip-runner/src/catalog/semantic-action-types.ts b/packages/paperclip-runner/src/catalog/semantic-action-types.ts
index 0eb1130bb5..0aa055bf54 100644
--- a/packages/paperclip-runner/src/catalog/semantic-action-types.ts
+++ b/packages/paperclip-runner/src/catalog/semantic-action-types.ts
@@ -1,4 +1,6 @@
export type PaperclipSemanticActionId =
+ | "search_api"
+ | "call_api"
| "get_task_context"
| "get_task_history"
| "list_documents"
diff --git a/packages/paperclip-runner/src/eval/workflow-evals.test.ts b/packages/paperclip-runner/src/eval/workflow-evals.test.ts
index b4e280dccb..6ad8501468 100644
--- a/packages/paperclip-runner/src/eval/workflow-evals.test.ts
+++ b/packages/paperclip-runner/src/eval/workflow-evals.test.ts
@@ -466,13 +466,13 @@ describe("workflow reports and stress traceability", () => {
candidateFailures: 36,
});
expect(report.coverage).toMatchObject({
- canonicalOperations: 41,
+ canonicalOperations: 43,
capabilityCases: 106,
workflows: 12,
stressFindings: 44,
stressExclusions: 1,
});
- expect(report.coverage.operations).toHaveLength(41);
+ expect(report.coverage.operations).toHaveLength(43);
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(
- "41 operations · 106 capability cases · 12 workflows",
+ "43 operations · 106 capability cases · 12 workflows",
);
expect(renderRunnerWorkflowJUnit(report)).toContain(
'tests="36" failures="36" skipped="0"',
diff --git a/packages/paperclip-runner/src/evals/attempt-journal.test.ts b/packages/paperclip-runner/src/evals/attempt-journal.test.ts
new file mode 100644
index 0000000000..c553230ed5
--- /dev/null
+++ b/packages/paperclip-runner/src/evals/attempt-journal.test.ts
@@ -0,0 +1,34 @@
+import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { describe, expect, it } from "vitest";
+import { AttemptJournal } from "./attempt-journal.js";
+
+describe("attempt journal", () => {
+ it("retains acknowledged usage before completion and rejects replacement", () => {
+ const root = mkdtempSync(join(tmpdir(), "eval-journal-"));
+ const path = join(root, "journal.jsonl");
+ const journal = new AttemptJournal(path);
+ try {
+ journal.append({ kind: "dispatch", id: "attempt" });
+ journal.append({ kind: "usage", requests: 1, costUsd: 0.02 });
+ expect(readFileSync(path, "utf8").trim().split("\n").map(line => JSON.parse(line))).toEqual([
+ { kind: "dispatch", id: "attempt" }, { kind: "usage", requests: 1, costUsd: 0.02 },
+ ]);
+ expect(() => new AttemptJournal(path)).toThrow();
+ expect(statSync(path).mode & 0o777).toBe(0o600);
+ journal.close();
+ expect(() => journal.append({})).toThrow("closed");
+ } finally { journal.close(); rmSync(root, { recursive: true, force: true }); }
+ });
+ it("fails before writing an over-limit record without discarding earlier evidence", () => {
+ const root = mkdtempSync(join(tmpdir(), "eval-journal-"));
+ const path = join(root, "journal.jsonl");
+ const journal = new AttemptJournal(path, 16);
+ try {
+ journal.append({ a: 1 });
+ expect(() => journal.append({ text: "too large" })).toThrow("limit");
+ expect(readFileSync(path, "utf8")).toBe('{"a":1}\n');
+ } finally { journal.close(); rmSync(root, { recursive: true, force: true }); }
+ });
+});
diff --git a/packages/paperclip-runner/src/evals/attempt-journal.ts b/packages/paperclip-runner/src/evals/attempt-journal.ts
new file mode 100644
index 0000000000..9c904223db
--- /dev/null
+++ b/packages/paperclip-runner/src/evals/attempt-journal.ts
@@ -0,0 +1,23 @@
+import { closeSync, constants, fsyncSync, openSync, writeSync } from "node:fs";
+
+/** Controller-owned evidence outside disposable server storage. No credentials. */
+export class AttemptJournal {
+ #fd: number | null;
+ #bytes = 0;
+ constructor(path: string, readonly maxBytes = 64 * 1024 * 1024) {
+ this.#fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
+ }
+ append(value: unknown): void {
+ if (this.#fd === null) throw new Error("Attempt journal is closed");
+ const bytes = Buffer.from(JSON.stringify(value) + "\n");
+ if (this.#bytes + bytes.length > this.maxBytes) throw new Error("Attempt journal limit reached; stop provider dispatch");
+ let offset = 0;
+ while (offset < bytes.length) offset += writeSync(this.#fd, bytes, offset, bytes.length - offset);
+ fsyncSync(this.#fd);
+ this.#bytes += bytes.length;
+ }
+ close(): void {
+ if (this.#fd !== null) closeSync(this.#fd);
+ this.#fd = null;
+ }
+}
diff --git a/packages/paperclip-runner/src/evals/model-pricing.test.ts b/packages/paperclip-runner/src/evals/model-pricing.test.ts
index b97495eb2e..50bf887f32 100644
--- a/packages/paperclip-runner/src/evals/model-pricing.test.ts
+++ b/packages/paperclip-runner/src/evals/model-pricing.test.ts
@@ -31,13 +31,15 @@ describe("model pricing", () => {
});
it.each([
+ ["gpt-5.6-luna", 0.2, 0.02, 1.2],
+ ["claude-sonnet-5", 2, 0.2, 10],
["openrouter/anthropic/claude-sonnet-5", 2, 0.2, 10],
["openrouter/qwen/qwen3.8-max-0902", 2, 0.25, 6],
["openrouter/google/gemini-3.8-flash", 0.75, 0.075, 3.75],
["openrouter/z-ai/glm-5.3", 1.4, 0.14, 4.4],
- ["openrouter/deepseek/deepseek-v4-flash-0731", 0.065, 0.016, 0.18],
+ ["openrouter/deepseek/deepseek-v4-flash-0731", 0.14, 0.028, 0.28],
["openrouter/openai/gpt-6-astra", 10, 1, 50],
- ])("pins the OpenRouter breadth price for %s", (model, input, cachedInput, output) => {
+ ])("pins the provider list price for %s", (model, input, cachedInput, output) => {
expect(
estimateModelCostNanodollars(model, {
inputTokens: 1_000,
diff --git a/packages/paperclip-runner/src/evals/model-pricing.ts b/packages/paperclip-runner/src/evals/model-pricing.ts
index 8c374002a6..7db93d368c 100644
--- a/packages/paperclip-runner/src/evals/model-pricing.ts
+++ b/packages/paperclip-runner/src/evals/model-pricing.ts
@@ -1,4 +1,4 @@
-export const MODEL_PRICING_VERSION = "provider-list-prices-2026-09-05" as const;
+export const MODEL_PRICING_VERSION = "provider-list-prices-2026-09-07-openrouter" as const;
interface TokenRatesUsdPerMillion {
input: number;
@@ -15,15 +15,16 @@ const RATES: Readonly> = Object.freeze({
"gpt-5.6-terra": { input: 2, cachedInput: 0.2, output: 12 },
"gpt-5.6-luna": { input: 0.2, cachedInput: 0.02, output: 1.2 },
// Qualified Anthropic direct profile. Actual invoice discounts are intentionally excluded.
- "claude-sonnet-5": { input: 3, cachedInput: 0.3, output: 15 },
+ "claude-sonnet-5": { input: 2, cachedInput: 0.2, output: 10 },
// Amazon Bedrock global cross-region list price for Claude Sonnet 4.6.
"global.anthropic.claude-sonnet-4-6": { input: 3, cachedInput: 0.3, output: 15 },
- // OpenRouter list-price snapshot used by the qualified OpenCode breadth lane.
+ // OpenRouter catalog snapshot: https://openrouter.ai/api/v1/models (2026-09-07).
+ // Routing can change the invoice rate; callers retain provider-reported cost too.
"openrouter/anthropic/claude-sonnet-5": { input: 2, cachedInput: 0.2, output: 10 },
"openrouter/qwen/qwen3.8-max-0902": { input: 2, cachedInput: 0.25, output: 6 },
"openrouter/google/gemini-3.8-flash": { input: 0.75, cachedInput: 0.075, output: 3.75 },
"openrouter/z-ai/glm-5.3": { input: 1.4, cachedInput: 0.14, output: 4.4 },
- "openrouter/deepseek/deepseek-v4-flash-0731": { input: 0.065, cachedInput: 0.016, output: 0.18 },
+ "openrouter/deepseek/deepseek-v4-flash-0731": { input: 0.14, cachedInput: 0.028, output: 0.28 },
"openrouter/openai/gpt-6-astra": { input: 10, cachedInput: 1, output: 50 },
});
diff --git a/packages/paperclip-runner/src/protocol-actions/call-api.ts b/packages/paperclip-runner/src/protocol-actions/call-api.ts
new file mode 100644
index 0000000000..ffbed93c8c
--- /dev/null
+++ b/packages/paperclip-runner/src/protocol-actions/call-api.ts
@@ -0,0 +1,146 @@
+/** Canonical production API fallback contract. */
+export const callApiAction = {
+ "id": "call_api",
+ "canonical": {
+ "operationId": "call_api",
+ "surfaces": [
+ "live"
+ ],
+ "placement": "optional_agent_tool",
+ "optionalGroup": "discovery",
+ "requiredClaims": [
+ "api:call"
+ ],
+ "taskModes": [
+ "standard",
+ "ask",
+ "planning",
+ "skill_test"
+ ],
+ "sideEffectClass": "company_write",
+ "idempotency": "none",
+ "disabledByDefault": false,
+ "realBindingStatus": "live_codex",
+ "realServiceBinding": "PaperclipRunnerToolAuthority",
+ "prpEvidence": "Authenticated PRP tool input/result and existing HTTP route authorization/activity records.",
+ "prpBindingStatus": "bound",
+ "legacyAliases": []
+ },
+ "documentation": {
+ "title": "Call the Paperclip API",
+ "description": "Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool.",
+ "note": "Production HTTP fallback; does not grant privileges or replace dedicated tools."
+ },
+ "examples": {
+ "call": {
+ "operationId": "call_api",
+ "input": {
+ "operationId": "GET /api/companies/{companyId}/projects"
+ }
+ },
+ "success": {
+ "ok": true,
+ "operationId": "call_api",
+ "result": {}
+ }
+ },
+ "live": {
+ "order": 41,
+ "descriptor": {
+ "schema": "paperclip.semantic-tool.v1",
+ "operationId": "call_api",
+ "version": 1,
+ "title": "Call the Paperclip API",
+ "description": "Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool.",
+ "exposure": "optional",
+ "requiredClaims": [
+ "api:call"
+ ],
+ "allowedModes": [
+ "standard",
+ "ask",
+ "planning",
+ "skill_test"
+ ],
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "operationId": {
+ "description": "Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.",
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 500
+ },
+ "pathParams": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "query": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "body": {
+ "description": "Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string.",
+ "anyOf": [
+ { "type": "object", "additionalProperties": true },
+ { "type": "array", "items": {} },
+ { "type": "string" },
+ { "type": "number" },
+ { "type": "boolean" },
+ { "type": "null" }
+ ]
+ },
+ "contentType": {
+ "type": "string",
+ "maxLength": 120
+ },
+ "files": {
+ "type": "array",
+ "maxItems": 10,
+ "items": {
+ "type": "object",
+ "properties": {
+ "field": {
+ "type": "string"
+ },
+ "artifactId": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string",
+ "description": "File relative to the active issue workspace. Remote files must first be uploaded as an artifact."
+ }
+ },
+ "additionalProperties": false,
+ "oneOf": [
+ {
+ "properties": { "artifactId": {} },
+ "required": [
+ "artifactId"
+ ]
+ },
+ {
+ "properties": { "path": {} },
+ "required": [
+ "path"
+ ]
+ }
+ ]
+ }
+ }
+ },
+ "required": [
+ "operationId"
+ ],
+ "additionalProperties": false
+ },
+ "outputSchema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ },
+ "scenario": null
+} as const;
diff --git a/packages/paperclip-runner/src/protocol-actions/index.ts b/packages/paperclip-runner/src/protocol-actions/index.ts
index 4a0b05bf69..7350882445 100644
--- a/packages/paperclip-runner/src/protocol-actions/index.ts
+++ b/packages/paperclip-runner/src/protocol-actions/index.ts
@@ -1,3 +1,5 @@
+import { searchApiAction } from "./search-api.js";
+import { callApiAction } from "./call-api.js";
import { administerCompanyAction } from "./administer-company.js";
import { answerStatusQuestionAction } from "./answer-status-question.js";
import { blockTaskAction } from "./block-task.js";
@@ -42,6 +44,8 @@ import { writeDocumentAction } from "./write-document.js";
import { deepFreezeProtocolAction } from "./freeze.js";
export const PAPERCLIP_PROTOCOL_ACTIONS = deepFreezeProtocolAction([
+ searchApiAction,
+ callApiAction,
administerCompanyAction,
answerStatusQuestionAction,
blockTaskAction,
diff --git a/packages/paperclip-runner/src/protocol-actions/search-api.ts b/packages/paperclip-runner/src/protocol-actions/search-api.ts
new file mode 100644
index 0000000000..ca0dc6d2b2
--- /dev/null
+++ b/packages/paperclip-runner/src/protocol-actions/search-api.ts
@@ -0,0 +1,96 @@
+/** Canonical production API fallback contract. */
+export const searchApiAction = {
+ "id": "search_api",
+ "canonical": {
+ "operationId": "search_api",
+ "surfaces": [
+ "live"
+ ],
+ "placement": "optional_agent_tool",
+ "optionalGroup": "discovery",
+ "requiredClaims": [
+ "api:discover"
+ ],
+ "taskModes": [
+ "standard",
+ "ask",
+ "planning",
+ "skill_test"
+ ],
+ "sideEffectClass": "read",
+ "idempotency": "none",
+ "disabledByDefault": false,
+ "realBindingStatus": "live_codex",
+ "realServiceBinding": "PaperclipRunnerToolAuthority",
+ "prpEvidence": "Authenticated PRP tool input/result and existing HTTP route authorization/activity records.",
+ "prpBindingStatus": "bound",
+ "legacyAliases": []
+ },
+ "documentation": {
+ "title": "Search the Paperclip API",
+ "description": "Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them.",
+ "note": "Production HTTP fallback; does not grant privileges or replace dedicated tools."
+ },
+ "examples": {
+ "call": {
+ "operationId": "search_api",
+ "input": {
+ "query": "create project"
+ }
+ },
+ "success": {
+ "ok": true,
+ "operationId": "search_api",
+ "result": {}
+ }
+ },
+ "live": {
+ "order": 40,
+ "descriptor": {
+ "schema": "paperclip.semantic-tool.v1",
+ "operationId": "search_api",
+ "version": 1,
+ "title": "Search the Paperclip API",
+ "description": "Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them.",
+ "exposure": "optional",
+ "requiredClaims": [
+ "api:discover"
+ ],
+ "allowedModes": [
+ "standard",
+ "ask",
+ "planning",
+ "skill_test"
+ ],
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 500
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 10,
+ "default": 5
+ },
+ "cursor": {
+ "type": "string",
+ "maxLength": 200
+ }
+ },
+ "required": [
+ "query"
+ ],
+ "additionalProperties": false
+ },
+ "outputSchema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ },
+ "scenario": null
+} as const;
diff --git a/packages/paperclip-runner/src/semantic-tools/discovery.ts b/packages/paperclip-runner/src/semantic-tools/discovery.ts
index c6adf41dcd..3b498df8e8 100644
--- a/packages/paperclip-runner/src/semantic-tools/discovery.ts
+++ b/packages/paperclip-runner/src/semantic-tools/discovery.ts
@@ -20,6 +20,8 @@ export interface CapabilityDiscoveryResult {
const MAX_DISCOVERY_RESULTS = 10;
const NAMESPACE: Readonly> = Object.freeze({
+ search_api: "api_fallback",
+ call_api: "api_fallback",
get_task_context: "active_task", get_task_history: "active_task",
list_documents: "documents", read_document: "documents", list_document_revisions: "documents",
report_progress: "active_task", answer_status_question: "active_task", write_document: "documents",
@@ -34,6 +36,7 @@ const NAMESPACE: Readonly> = Objec
});
export const CAPABILITY_DISCOVERY_NAMESPACES = Object.freeze([
+ { name: "api_fallback", description: "API escape hatch for work unsupported by available dedicated tools." },
{ 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." },
diff --git a/packages/paperclip-runner/src/semantic-tools/paperclip-discovery.ts b/packages/paperclip-runner/src/semantic-tools/paperclip-discovery.ts
index cedc57fc57..65aff89cf9 100644
--- a/packages/paperclip-runner/src/semantic-tools/paperclip-discovery.ts
+++ b/packages/paperclip-runner/src/semantic-tools/paperclip-discovery.ts
@@ -12,6 +12,8 @@ import type {
const NAMESPACE: Readonly> =
Object.freeze({
+ search_api: "api_fallback",
+ call_api: "api_fallback",
get_task_context: "active_task",
get_task_history: "active_task",
list_documents: "documents",
diff --git a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts
index 5d7ca42dc1..eb9d40e7ae 100644
--- a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts
+++ b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts
@@ -53,7 +53,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(28);
+ expect(names).toHaveLength(30);
expect(names).toContain("get_task_context");
expect(names).toContain("finish_task");
expect(names).not.toContain("checkout_task");
diff --git a/packages/paperclip-runner/src/semantic-tools/types.ts b/packages/paperclip-runner/src/semantic-tools/types.ts
index 7944dc1cab..fe5f8e76d8 100644
--- a/packages/paperclip-runner/src/semantic-tools/types.ts
+++ b/packages/paperclip-runner/src/semantic-tools/types.ts
@@ -16,6 +16,8 @@ import type { PrpSemanticToolEnvelope } from "../protocol/replay-contract.js";
export type CapabilitySemanticToolExposure = "always" | "optional";
export type CapabilitySemanticOperationId =
+ | "search_api"
+ | "call_api"
| "get_task_context"
| "get_task_history"
| "list_documents"
diff --git a/scripts/generate-runner-api-reference.mjs b/scripts/generate-runner-api-reference.mjs
new file mode 100644
index 0000000000..21ef65b6c2
--- /dev/null
+++ b/scripts/generate-runner-api-reference.mjs
@@ -0,0 +1,26 @@
+import { readFileSync, writeFileSync } from "node:fs";
+import { resolve } from "node:path";
+const root = resolve(import.meta.dirname, "..");
+const source = readFileSync(resolve(root, "skills/paperclip/references/api-reference.md"), "utf8");
+const key = (method, path) => `${method} ${path.replace(/:[A-Za-z][A-Za-z0-9_]*|\{[^}]+\}/g, "{}")}`;
+const entries = {};
+let section = "Paperclip API Reference";
+for (const line of source.split("\n")) {
+ if (/^#{2,5} /.test(line)) section = line.replace(/^#+ /, "");
+ const table = /^\|\s*(GET|POST|PATCH|PUT|DELETE)\s*\|\s*`([^`]+)`\s*\|\s*(.*?)\s*\|/.exec(line);
+ if (table) entries[key(table[1], table[2])] = { section, description: table[3] };
+}
+for (const match of source.matchAll(/^(GET|POST|PATCH|PUT|DELETE) (\/api\/[^\s]+)\n(\{[\s\S]*?\n\})/gm)) {
+ try {
+ const body = JSON.parse(match[3]);
+ const id = key(match[1], match[2]);
+ entries[id] ??= { section: "Worked example" };
+ (entries[id].examples ??= []).push({ body });
+ entries[id].examples = entries[id].examples.slice(0, 2);
+ } catch { /* Narrative/pseudocode blocks are not executable examples. */ }
+}
+const destination = resolve(root, "server/src/services/native-runtime/runner-api-reference.ts");
+const output = `// Generated by scripts/generate-runner-api-reference.mjs from the legacy skill reference.\nexport const runnerApiReference: Record = ${JSON.stringify(entries, null, 2)};\n`;
+if (process.argv.includes("--check")) {
+ if (readFileSync(destination, "utf8") !== output) throw new Error("Runner API skill enrichment is stale");
+} else writeFileSync(destination, output);
diff --git a/scripts/generate-runner-experimental-api-metadata.mjs b/scripts/generate-runner-experimental-api-metadata.mjs
new file mode 100644
index 0000000000..b1264f6361
--- /dev/null
+++ b/scripts/generate-runner-experimental-api-metadata.mjs
@@ -0,0 +1,21 @@
+import { readFile, writeFile } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
+
+const root = new URL("../", import.meta.url);
+const metadata = {};
+for (const name of ["pipelines", "cases", "smoke-lab"]) {
+ const source = await readFile(new URL(`server/src/routes/${name}.ts`, root), "utf8");
+ const routes = [...source.matchAll(/router\.(get|post|put|patch|delete)\(\s*"([^"]+)"/g)];
+ for (const [index, route] of routes.entries()) {
+ const handler = source.slice(route.index, routes[index + 1]?.index ?? source.length);
+ const statuses = new Set([...handler.matchAll(/res\.status\((2\d\d)\)/g)].map(match => Number(match[1])));
+ if (/res\.json\(/.test(handler) || statuses.size === 0) statuses.add(200);
+ const path = "/api" + route[2].replace(/:([A-Za-z0-9_]+)/g, "{$1}");
+ metadata[`${route[1].toUpperCase()} ${path}`] = { successStatuses: [...statuses].sort(), boardOnly: /assertBoard\(req\)/.test(handler), source: `server/src/routes/${name}.ts` };
+ }
+}
+const output = "// Generated by scripts/generate-runner-experimental-api-metadata.mjs.\nexport const experimentalApiMetadata: Record = " + JSON.stringify(metadata, null, 2) + ";\n";
+const target = new URL("server/src/routes/experimental-api-metadata.ts", root);
+if (process.argv.includes("--check")) {
+ if (await readFile(target, "utf8") !== output) throw new Error(`Stale experimental route metadata: ${fileURLToPath(target)}`);
+} else await writeFile(target, output);
diff --git a/scripts/run-vitest-stable.mjs b/scripts/run-vitest-stable.mjs
index 8caf881dea..ec10a590a9 100644
--- a/scripts/run-vitest-stable.mjs
+++ b/scripts/run-vitest-stable.mjs
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
-import { mkdirSync, mkdtempSync, readdirSync, statSync } from "node:fs";
+import { mkdirSync, mkdtempSync, readdirSync, realpathSync, statSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -275,7 +275,8 @@ function runVitest(args, label) {
console.log(`\n[test:run] ${label}`);
invocationIndex += 1;
const tempRootParent = process.platform === "win32" ? os.tmpdir() : "/tmp";
- const testRoot = mkdtempSync(path.join(tempRootParent, `pcvt-${process.pid}-${invocationIndex}-`));
+ // Canonical roots keep security fixtures valid on macOS, where /tmp is a symlink.
+ const testRoot = realpathSync(mkdtempSync(path.join(tempRootParent, `pcvt-${process.pid}-${invocationIndex}-`)));
// Keep per-run paths compact so Unix socket fixtures stay under macOS path limits.
const env = {
...process.env,
diff --git a/scripts/runner-api-eval-worker.ts b/scripts/runner-api-eval-worker.ts
new file mode 100644
index 0000000000..1af3ad1949
--- /dev/null
+++ b/scripts/runner-api-eval-worker.ts
@@ -0,0 +1,217 @@
+/** JSONL worker for the companion paperclip-evals API suite. Never selects cases or retries. */
+import { createHash, randomUUID } from "node:crypto";
+import { createReadStream, realpathSync } from "node:fs";
+import { execFileSync } from "node:child_process";
+import { mkdir, writeFile, readFile } from "node:fs/promises";
+import { join, resolve } from "node:path";
+import { createInterface } from "node:readline";
+import { homedir } from "node:os";
+import { runnerApiCatalog } from "../server/src/services/native-runtime/runner-api-catalog.js";
+import { startRunnerApiTestServer } from "../server/src/__tests__/helpers/runner-api-server.js";
+import { registerRunnerPrpAuthority } from "../server/src/realtime/runner-prp-ws.js";
+import { createRunnerdCodexTransport, defaultCapabilityRunnerdBinary } from "../packages/paperclip-runner/src/live/runnerd-codex-transport.js";
+import { createSkilllessCodexThreadConfig } from "../packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.js";
+import { AttemptJournal } from "../packages/paperclip-runner/src/evals/attempt-journal.js";
+import { estimateModelCostNanodollars } from "../packages/paperclip-runner/src/evals/model-pricing.js";
+
+if (process.argv.includes("--catalog")) {
+ process.stdout.write(JSON.stringify(runnerApiCatalog()) + "\n");
+ process.exit(0);
+}
+if (!process.argv.includes("--jsonl")) throw new Error("Use --catalog or --jsonl; there is no default campaign");
+process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID() + randomUUID();
+const output = (value: unknown) => process.stdout.write("RUNNER_API_EVAL " + JSON.stringify(value) + "\n");
+const OPENROUTER_MODELS = new Set(["openrouter/anthropic/claude-sonnet-5", "openrouter/deepseek/deepseek-v4-flash-0731", "openrouter/google/gemini-3.8-flash"]);
+// The controller selects and injects one provider credential. The worker never
+// reads ambient home credential files or desktop keychains.
+function openRouterEnvironment() {
+ const key = process.env.OPENROUTER_API_KEY;
+ if (!key || /\s/.test(key)) throw new Error("Controller must inject OPENROUTER_API_KEY");
+ return { PATH: process.env.PATH, OPENROUTER_API_KEY: key };
+}
+const server = await startRunnerApiTestServer();
+const fileDigests = new Map>();
+function fileDigest(path: string): Promise {
+ let digest = fileDigests.get(path);
+ if (!digest) {
+ digest = (async () => {
+ const hash = createHash("sha256");
+ for await (const chunk of createReadStream(path)) hash.update(chunk);
+ return hash.digest("hex");
+ })().catch(() => null);
+ fileDigests.set(path, digest);
+ }
+ return digest;
+}
+const runtimeBuild = {
+ runnerBinarySha256: await fileDigest(defaultCapabilityRunnerdBinary()),
+ workerSourceSha256: await fileDigest(resolve("scripts/runner-api-eval-worker.ts")),
+ lockfileSha256: await fileDigest(resolve("pnpm-lock.yaml")),
+};
+output({ ready: true });
+const record = (value: unknown): Record => value && typeof value === "object" ? value as Record : {};
+
+try {
+ for await (const line of createInterface({ input: process.stdin })) {
+ const request = JSON.parse(line);
+ if (request.shutdown) break;
+ const directory = resolve(request.outputDirectory);
+ await mkdir(directory, { recursive: true, mode: 0o700 });
+ const journal = new AttemptJournal(join(directory, "journal.jsonl"));
+ const startedAt = new Date().toISOString();
+ journal.append({ kind: "attempt_started", attemptId: request.attemptId, model: request.model, startedAt });
+ const started = performance.now();
+ const isOpenRouter = OPENROUTER_MODELS.has(request.model);
+ const provider = isOpenRouter ? "opencode" : request.model === "claude-sonnet-5" ? "acpx" : "codex";
+ let providerVersion: string | null = null;
+ const fixture = await server.fixture({ mode: request.mode, apiToolsEnabled: request.arm !== "baseline", reset: true });
+ const substitutions = Object.fromEntries(Object.entries(fixture).filter(([, value]) => typeof value === "string"));
+ const expand = (value: any): any => typeof value === "string" ? value.replace(/\{\{(\w+)\}\}/g, (_, key) => String(substitutions[key] ?? (() => { throw new Error(`Unknown fixture variable ${key}`); })())) : Array.isArray(value) ? value.map(expand) : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, expand(entry)])) : value;
+ const calls: Record[] = [], notifications: unknown[] = [];
+ const observedToolCallIds = new Set();
+ const definitions = await fixture.authority.definitions();
+ const prompt = expand(request.prompt ?? "");
+ let error: string | null = null, evidence: unknown = null, thread: Record = {}, usage: Record | null = null;
+ let usageUpdates = 0, lastUsageFingerprint = "", terminalSeen = false, providerTurnStarted = false;
+ let diagnosticTail: string | null = null;
+ let bundle: ReturnType | undefined;
+ let timer: ReturnType | undefined;
+ const invoke = async (tool: string, args: unknown, callId: string) => {
+ observedToolCallIds.add(callId);
+ if (observedToolCallIds.size > 12) throw new Error("Attempt tool-call ceiling reached");
+ const call: Record = { tool, arguments: args, callId, startedMs: performance.now() - started };
+ calls.push(call);
+ journal.append({ kind: "tool_dispatch", ...call });
+ try { call.result = await fixture.authority.execute({ tool, arguments: args, callId }); }
+ catch (caught) { call.error = caught instanceof Error ? caught.message : String(caught); call.status = record(caught).status; }
+ call.durationMs = performance.now() - started - call.startedMs;
+ journal.append({ kind: "tool_result", ...call });
+ return call;
+ };
+ try {
+ if (request.calls) {
+ for (const [i, call] of request.calls.entries()) await invoke(call.tool, expand(call.arguments), call.callId ?? `direct-${i}`);
+ } else {
+ if (request.model === "claude-sonnet-5" && (process.platform !== "linux" || process.arch !== "x64")) throw new Error("Qualified ACPX Claude requires Linux x64; no provider turn was dispatched");
+ if (!request.reservationId || request.maxCostUsd !== 0.5 || !["gpt-5.6-luna", "claude-sonnet-5", ...OPENROUTER_MODELS].includes(request.model)) throw new Error("Paid attempt requires ledger reservation and qualified model");
+ if (isOpenRouter) {
+ providerVersion = execFileSync(resolve("packages/paperclip-runner/node_modules/opencode-ai/bin/opencode.exe"), ["--version"], { encoding: "utf8" }).trim();
+ if (providerVersion !== "1.18.29") throw new Error("OpenCode profile requires version 1.18.29");
+ }
+ const providerEnvironment = isOpenRouter ? openRouterEnvironment() : request.model === "claude-sonnet-5" ? (() => {
+ const token = process.env.CLAUDE_CODE_OAUTH_TOKEN;
+ if (!token) throw new Error("Controller must inject CLAUDE_CODE_OAUTH_TOKEN");
+ return { PATH: process.env.PATH, CLAUDE_CODE_OAUTH_TOKEN: token };
+ })() : undefined;
+ bundle = createRunnerdCodexTransport({
+ provider, acpxAgent: "claude", acpxPermissionMode: "approve-reads",
+ environment: { ...providerEnvironment, PAPERCLIP_PROVIDER_TRACE_PATH: join(directory, "provider-trace.jsonl"), PAPERCLIP_PROVIDER_TRACE_MAX_BYTES: String(32 * 1024 * 1024) },
+ codexCommand: request.model === "gpt-5.6-luna" ? realpathSync(execFileSync("which", ["codex"], { encoding: "utf8" }).trim()) : undefined,
+ sourceCodexHome: process.env.CODEX_HOME ?? join(homedir(), ".codex"),
+ runnerBinary: defaultCapabilityRunnerdBinary(), stateDirectory: join(server.root, `runner-${request.attemptId}`),
+ lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
+ prpIdentity: { runnerInstanceId: `runner-${fixture.runId}`, environmentLeaseId: `lease-${fixture.runId}`, runId: fixture.runId, normalizedSessionId: `session-${fixture.runId}`, turnId: `turn-${fixture.runId}`, itemId: `item-${fixture.runId}` },
+ controlPlaneRegistration: (prp) => registerRunnerPrpAuthority({ companyId: fixture.companyId, runId: fixture.runId, authority: prp }),
+ });
+ const active = bundle;
+ timer = setTimeout(() => { error = "Attempt exceeded 120 seconds"; void active.transport.close(error); }, 120_000);
+ bundle.transport.setServerRequestHandler(async (request) => {
+ const params = record(request.params);
+ const result = await invoke(String(params.tool), params.arguments, String(params.callId));
+ return { success: !result.error, contentItems: [{ type: "inputText", text: JSON.stringify(result.error ? { ok: false, error: result.error, status: result.status } : { ok: true, result: result.result }) }] };
+ });
+ await bundle.transport.request("initialize", {});
+ thread = await bundle.transport.request("thread/start", {
+ cwd: fixture.workspace, model: request.model,
+ completionContract: { revision: "runner-api-eval-v1", criterionIds: ["objective"] },
+ config: { ...createSkilllessCodexThreadConfig(fixture.workspace), model_reasoning_effort: "low" },
+ permissions: "paperclip-runner-workspace-only", runtimeWorkspaceRoots: [fixture.workspace], approvalPolicy: "never",
+ baseInstructions: "You are operating a disposable real Paperclip company. Use the provided tools to do the user's task. Do not use shell, network, skills, or credentials. Stop when the requested work is verified. " + (request.arm === "baseline" ? "" : "Prefer available dedicated tools. Only use search_api and call_api when no dedicated tool supports the required operation or parameters. Do not search before ordinary dedicated tool use."),
+ dynamicTools: definitions, experimentalRawEvents: true, persistExtendedHistory: true,
+ });
+ if (request.preflight) {
+ terminalSeen = true;
+ usage = { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, estimatedCostNanodollars: 0, providerRequests: 0, preflight: true };
+ } else {
+ journal.append({ kind: "provider_turn_dispatch", thread, at: new Date().toISOString() });
+ providerTurnStarted = true;
+ await bundle.transport.request("turn/start", { input: [{ type: "text", text: prompt }] });
+ for await (const notification of bundle.transport.notifications()) {
+ const retained = { ...notification, observedAt: new Date().toISOString() };
+ journal.append({ kind: "notification", ...retained });
+ notifications.push(retained);
+ const item = record(record(notification.params).item);
+ if (["dynamicToolCall", "tool_call", "tool_result"].includes(item.type) && typeof item.id === "string") {
+ observedToolCallIds.add(item.id);
+ if (observedToolCallIds.size > 12) { error = "Attempt tool-call ceiling reached, including provider-rejected calls"; break; }
+ }
+ if (notification.method === "thread/tokenUsage/updated") {
+ const params = record(notification.params);
+ const tokenUsage = record(params.tokenUsage);
+ const cumulative = record(tokenUsage.total ?? tokenUsage.totalTokenUsage ?? tokenUsage.total_token_usage ?? tokenUsage);
+ if (request.model === "claude-sonnet-5" && params.runDeltaAvailable !== true) continue;
+ const total = request.model === "claude-sonnet-5" ? record(tokenUsage.runDelta) : cumulative;
+ const cacheWriteTokens = total.cacheWriteTokens ?? 0;
+ const inputTokens = (total.inputTokens ?? total.input_tokens) + (request.model === "claude-sonnet-5" ? (total.cacheReadTokens ?? 0) : isOpenRouter ? (total.cacheReadTokens ?? total.cachedInputTokens ?? 0) : 0);
+ const outputTokens = total.outputTokens ?? total.output_tokens;
+ const cachedInputTokens = total.cacheReadTokens ?? total.cachedInputTokens ?? total.cache_read_input_tokens ?? 0;
+ if ([inputTokens, outputTokens, cachedInputTokens, cacheWriteTokens].every(value => Number.isSafeInteger(value) && value >= 0)) {
+ const fingerprint = `${inputTokens}:${outputTokens}:${cachedInputTokens}`;
+ if (fingerprint !== lastUsageFingerprint) { usageUpdates++; lastUsageFingerprint = fingerprint; }
+ usage = { inputTokens, outputTokens, cachedInputTokens, cacheWriteTokens, providerRequests: (total.requests ?? total.providerRequests ?? 0) > 0 ? total.requests ?? total.providerRequests : null, providerUsageUpdateCount: usageUpdates, providerRequestCountSource: (total.requests ?? total.providerRequests ?? 0) > 0 ? "provider" : "not reported; providerUsageUpdateCount is a proxy", providerReportedCostNanodollars: cumulative.providerCostUsd > 0 ? Math.round(cumulative.providerCostUsd * 1e9) : null, ...estimateModelCostNanodollars(request.model, { inputTokens, outputTokens, cachedInputTokens }) };
+ // A conservative one-hour cache-write ceiling (2× standard input)
+ // covers either cache duration when the runtime omits its TTL.
+ if (request.model === "claude-sonnet-5" || request.model === "openrouter/anthropic/claude-sonnet-5") {
+ usage.estimatedCostNanodollars += cacheWriteTokens * 4_000;
+ usage.cacheWriteUsdPerMillionTokens = 4;
+ usage.cacheWritePricing = "Conservative one-hour cache-write rate; TTL not exposed";
+ }
+ if (Math.max(usage.estimatedCostNanodollars, usage.providerReportedCostNanodollars ?? 0) >= 500_000_000) { error = "Attempt cost ceiling reached"; break; }
+ }
+ }
+ if (notification.method === "turn/completed") {
+ terminalSeen = true;
+ const params = record(notification.params);
+ const terminal = record(params.turn);
+ if ((params.status ?? terminal.status) === "failed") error = String(record(params.error ?? terminal.error).message ?? "Provider turn failed");
+ break;
+ }
+ }
+ }
+ evidence = bundle.evidence();
+ if (!request.preflight && (!usage || usage.inputTokens + usage.outputTokens === 0)) {
+ usage = null;
+ error ??= "Missing provider accounting; block further paid attempts";
+ }
+ }
+ } catch (caught) { error ??= caught instanceof Error ? caught.message : String(caught); }
+ finally {
+ if (timer) clearTimeout(timer);
+ if (bundle) {
+ evidence = bundle.evidence();
+ diagnosticTail = await readFile(join(server.root, `runner-${request.attemptId}`, "diagnostics", "runnerd.stderr.log"), "utf8").then(text => text.slice(-16000).replace(/sk-(?:ant-|or-v1-)[A-Za-z0-9_-]+/g, "[redacted]").replace(/Bearer\s+[^\s"']+/gi, "Bearer [redacted]").replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "[redacted JWT]")).catch(() => null);
+ }
+ await bundle?.transport.close();
+ }
+ if (!providerTurnStarted && !request.calls) {
+ usage = { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, estimatedCostNanodollars: 0, providerRequests: 0, accountingProvenance: "No provider turn/start was dispatched" };
+ }
+ const artifact = {
+ schema: "paperclip-runner/eval-session-artifact/v1", attemptId: request.attemptId,
+ requestedModel: request.model ?? "provider-free", provider, driver: "real-server-api-tools",
+ providerSessionId: record(thread.thread).id ?? null, effectiveModel: record(thread.thread).model ?? null,
+ providerVersion: request.model === "gpt-5.6-luna" ? execFileSync("codex", ["--version"], { encoding: "utf8" }).trim() : providerVersion ?? record(evidence).providerVersion ?? null,
+ runtimeVersions: { node: process.versions.node, acpx: record(evidence).providerVersion, agentServer: record(evidence).agentServerVersion, agentRuntime: record(evidence).agentRuntimeVersion },
+ runtimeBuild,
+ timing: { startedAt, finishedAt: new Date().toISOString(), durationMs: performance.now() - started },
+ usage, accountingComplete: Boolean(request.calls) || !providerTurnStarted || terminalSeen, providerTurnStarted, observedProviderToolCalls: observedToolCallIds.size, diagnosticTail, error, calls, notifications, evidence, thread, prompt, arm: request.arm ?? "treatment",
+ fixture: substitutions, state: await fixture.snapshot(),
+ toolSchemaBytes: Buffer.byteLength(JSON.stringify(definitions)), definitions,
+ };
+ // The controller supplies a unique retained attempt directory; never overwrite evidence.
+ journal.append({ kind: "attempt_finished", error, usage, terminalSeen, at: new Date().toISOString() });
+ journal.close();
+ await writeFile(join(directory, "artifact.json"), JSON.stringify(artifact, null, 2), { flag: "wx", mode: 0o600 });
+ output({ attemptId: request.attemptId, artifactPath: join(directory, "artifact.json"), error, usage });
+ }
+} finally { await server.close(); }
diff --git a/server/src/__tests__/helpers/runner-api-server.ts b/server/src/__tests__/helpers/runner-api-server.ts
new file mode 100644
index 0000000000..58d1df23bb
--- /dev/null
+++ b/server/src/__tests__/helpers/runner-api-server.ts
@@ -0,0 +1,105 @@
+import { createHash, randomUUID } from "node:crypto";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { createServer } from "node:http";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { eq, sql } from "drizzle-orm";
+import { agents, companies, createDb, heartbeatRuns, issues, projects, projectWorkspaces, activityLog, issueComments, assets, goals, approvals, documents, issueRelations } from "@paperclipai/db";
+import { documentService } from "../../services/documents.js";
+import { startEmbeddedPostgresTestDatabase } from "./embedded-postgres.js";
+import { createApp } from "../../app.js";
+import { createLocalDiskStorageProvider } from "../../storage/local-disk-provider.js";
+import { createStorageService } from "../../storage/service.js";
+import { setupRunnerPrpWebSocketServer, runnerPrpWebSocketInternals } from "../../realtime/runner-prp-ws.js";
+import { PaperclipRunnerToolAuthority } from "../../services/native-runtime/paperclip-runner-tool-authority.js";
+
+/** Disposable real routes, database and storage. A fresh company isolates each attempt. */
+export async function startRunnerApiTestServer() {
+ const root = await mkdtemp(join(tmpdir(), "paperclip-api-eval-"));
+ const temporary = await startEmbeddedPostgresTestDatabase("paperclip-api-eval-db-");
+ const db = createDb(temporary.connectionString);
+ const storage = createStorageService(createLocalDiskStorageProvider(join(root, "storage")));
+ const app = await createApp(db, {
+ uiMode: "none", serverPort: 0, storageService: storage,
+ deploymentMode: "authenticated", deploymentExposure: "private",
+ allowedHostnames: ["127.0.0.1"], bindHost: "127.0.0.1", authReady: true,
+ companyDeletionEnabled: false, instanceId: `eval-${randomUUID()}`,
+ localPluginDir: join(root, "plugins"), managedPluginAutoInstall: [],
+ decisionServiceOptions: { wakeOriginAgent: async () => undefined },
+ });
+ const http = createServer(app);
+ const sockets = new Set();
+ http.on("connection", socket => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); });
+ await new Promise((resolve) => http.listen(0, "127.0.0.1", resolve));
+ const address = http.address();
+ if (!address || typeof address === "string") throw new Error("Missing eval listener");
+ const apiUrl = `http://127.0.0.1:${address.port}`;
+ setupRunnerPrpWebSocketServer(http, { apiUrl });
+ return {
+ db, root, apiUrl, storage,
+ async fixture(options: { mode?: "standard" | "ask" | "planning"; apiToolsEnabled?: boolean; reset?: boolean } = {}) {
+ // This DB is created inside this helper, never supplied by a caller. Paid
+ // paired runs reset it between attempts so modeled IDs and data match.
+ if (options.reset) await db.execute(sql`TRUNCATE companies CASCADE`);
+ const id = (key: string) => {
+ if (!options.reset) return randomUUID();
+ const hex = createHash("sha256").update(`runner-api-fixture:${key}`).digest("hex");
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
+ };
+ const companyId = id("company"), agentId = id("agent"), issueId = id("issue"), runId = id("run"), projectId = id("project");
+ const foreignCompanyId = id("foreign-company"), foreignProjectId = id("foreign-project");
+ const projectWorkspaceId = id("workspace"), artifactId = id("artifact"), binaryArtifactId = id("binary-artifact"), goalId = id("goal");
+ const blockerId = id("blocker"), approvalId = id("approval");
+ const workspace = await mkdtemp(join(root, "workspace-"));
+ await writeFile(join(workspace, "sample.txt"), "API escape hatch fixture\n");
+ await db.insert(companies).values([
+ { id: companyId, name: "API eval", issueCounter: 2, issuePrefix: "E" + companyId.replaceAll("-", "").slice(0, 8) },
+ { id: foreignCompanyId, name: "Isolated foreign company", issuePrefix: "O" + foreignCompanyId.replaceAll("-", "").slice(0, 8) },
+ ]);
+ await db.insert(agents).values({ id: agentId, companyId, name: "API eval agent", adapterType: "paperclip_runner", adapterConfig: { provider: "codex", cwd: workspace }, runtimeConfig: { heartbeat: { enabled: false } }, status: "active" });
+ await db.insert(projects).values([
+ { id: projectId, companyId, name: "Aurora", description: "The project verification code is violet-otter.", status: "in_progress" },
+ { id: foreignProjectId, companyId: foreignCompanyId, name: "Private project", description: "foreign-data-must-not-leak" },
+ ]);
+ await db.insert(projectWorkspaces).values({ id: projectWorkspaceId, companyId, projectId, name: "Fixture workspace", cwd: workspace, isPrimary: true });
+ await db.insert(goals).values({ id: goalId, companyId, title: "Ship Aurora", level: "company", status: "active" });
+ for (const [id, body, contentType, filename] of [[artifactId, Buffer.from("API escape hatch fixture\n"), "text/plain", "sample.txt"], [binaryArtifactId, Buffer.alloc(32_000, 65), "application/octet-stream", "large.bin"]] as const) {
+ const saved = await storage.putFile({ companyId, namespace: "eval", originalFilename: filename, contentType, body });
+ await db.insert(assets).values({ id, companyId, ...saved, createdByAgentId: agentId });
+ }
+ await db.insert(issues).values({ id: issueId, companyId, projectId, projectWorkspaceId, issueNumber: 1, identifier: "E" + companyId.replaceAll("-", "").slice(0, 8) + "-1", title: "Verify runner API tools", description: "Fixture marker: amber-fox.", status: "in_progress", workMode: options.mode ?? "standard", assigneeAgentId: agentId });
+ await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", runtimeMode: "native", nativeIssueId: issueId, invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId } });
+ await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
+ await db.insert(issues).values({ id: blockerId, companyId, projectId, issueNumber: 2, identifier: "E" + companyId.replaceAll("-", "").slice(0, 8) + "-2", title: "Dependency gate", description: "Complete before shipping.", status: "todo", assigneeAgentId: agentId });
+ await db.insert(approvals).values({ id: approvalId, companyId, type: "runner_review", status: "pending", requestedByAgentId: agentId, payload: { title: "Launch review" } });
+ await documentService(db).upsertIssueDocument({ issueId, key: "notes", title: "Fixture notes", format: "markdown", body: "Document verification code: silver-wren.", baseRevisionId: null, changeSummary: null, createdByAgentId: agentId, createdByRunId: runId });
+ const binding = { companyId, agentId, issueId, runId, apiUrl, storage, apiToolsEnabled: options.apiToolsEnabled ?? true };
+ return {
+ ...binding, projectId, projectWorkspaceId, artifactId, binaryArtifactId, goalId, blockerId, approvalId, foreignCompanyId, foreignProjectId, workspace,
+ authority: new PaperclipRunnerToolAuthority(db, binding),
+ async snapshot() {
+ return {
+ issues: await db.select().from(issues).where(eq(issues.companyId, companyId)),
+ projects: await db.select().from(projects).where(eq(projects.companyId, companyId)),
+ activity: await db.select().from(activityLog).where(eq(activityLog.companyId, companyId)),
+ comments: await db.select().from(issueComments).where(eq(issueComments.companyId, companyId)),
+ assets: await db.select().from(assets).where(eq(assets.companyId, companyId)),
+ goals: await db.select().from(goals).where(eq(goals.companyId, companyId)),
+ approvals: await db.select().from(approvals).where(eq(approvals.companyId, companyId)),
+ documents: await db.select().from(documents).where(eq(documents.companyId, companyId)),
+ issueRelations: await db.select().from(issueRelations).where(eq(issueRelations.companyId, companyId)),
+ };
+ },
+ };
+ },
+ async close() {
+ runnerPrpWebSocketInternals.resetForTests();
+ await app.locals.paperclipShutdown();
+ for (const socket of sockets) socket.destroy();
+ http.closeAllConnections();
+ await new Promise((resolve) => http.close(() => resolve()));
+ await temporary.cleanup();
+ await rm(root, { recursive: true, force: true });
+ },
+ };
+}
diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts
index 238816b7ed..c7bf70fe73 100644
--- a/server/src/__tests__/openapi-routes.test.ts
+++ b/server/src/__tests__/openapi-routes.test.ts
@@ -12,6 +12,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROUTES_DIR = path.resolve(__dirname, "../routes");
const apiPrefixes: Record = {
+ "pipelines.ts": "/api",
+ "cases.ts": "/api",
+ "smoke-lab.ts": "/api",
"access.ts": "/api",
"activity.ts": "/api",
"adapters.ts": "/api",
@@ -68,14 +71,7 @@ const apiPrefixes: Record = {
const ROUTE_LITERAL_PATTERN = /router\.(get|post|put|patch|delete)\(\s*["'`]([^"'`]+)["'`]/g;
const ROUTER_METHOD_PATTERN = /router\.(get|post|put|patch|delete)\(/;
const HTTP_METHODS = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"]);
-const explicitOpenApiCoverageExclusions = new Set([
- // Pipeline routes are experimental and not yet represented in the public OpenAPI document.
- "pipelines.ts",
- // Case routes are experimental (enableCases flag) and not yet in the public OpenAPI document.
- "cases.ts",
- // Smoke lab routes are experimental and not yet represented in the public OpenAPI document.
- "smoke-lab.ts",
-]);
+const explicitOpenApiCoverageExclusions = new Set();
// The set of contract-first routes whose OpenAPI document leads the mounted
// request handler. The company-and-environment Claude setup-token login routes
diff --git a/server/src/app.ts b/server/src/app.ts
index 669823a648..3ca53ecc80 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -979,6 +979,8 @@ export async function createApp(
const shutdownAppServices = (): Promise => {
if (appServicesShutdown) return appServicesShutdown;
appServicesShutdown = (async () => {
+ scheduler.stop();
+ jobCoordinator.stop();
disableFeedbackExportFlushes();
if (importTransferSweepTimer) {
clearInterval(importTransferSweepTimer);
diff --git a/server/src/routes/cases-schemas.ts b/server/src/routes/cases-schemas.ts
new file mode 100644
index 0000000000..df80c9c3f1
--- /dev/null
+++ b/server/src/routes/cases-schemas.ts
@@ -0,0 +1,71 @@
+import { z } from "zod";
+
+export const CASE_STATUSES = ["draft", "in_progress", "in_review", "approved", "done", "cancelled"] as const;
+export const CASE_LINK_ROLES = ["origin", "work", "reference"] as const;
+export const DEFAULT_EVENTS_LIMIT = 100;
+export const MAX_EVENTS_LIMIT = 500;
+
+export const jsonObjectSchema = z.record(z.string(), z.unknown());
+export const caseStatusSchema = z.enum(CASE_STATUSES);
+export const caseTypeSchema = z.string().trim().min(1).max(120);
+export const caseKeySchema = z.string().trim().min(1).max(512);
+export const documentKeySchema = z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9_.:-]+$/);
+
+export const createCaseSchema = z.object({
+ projectId: z.string().guid().nullable().optional(),
+ caseType: caseTypeSchema,
+ key: caseKeySchema.nullable().optional(),
+ title: z.string().trim().min(1).max(500),
+ summary: z.string().max(8_000).nullable().optional(),
+ status: caseStatusSchema.optional(),
+ fields: jsonObjectSchema.optional(),
+ parentCaseId: z.string().guid().nullable().optional(),
+}).strict();
+
+export const patchCaseSchema = z.object({
+ projectId: z.string().guid().nullable().optional(),
+ title: z.string().trim().min(1).max(500).optional(),
+ summary: z.string().max(8_000).nullable().optional(),
+ status: caseStatusSchema.optional(),
+ fields: jsonObjectSchema.optional(),
+ parentCaseId: z.string().guid().nullable().optional(),
+ labels: z.array(z.string().guid()).max(100).optional(),
+ labelIds: z.array(z.string().guid()).max(100).optional(),
+}).strict();
+
+export const createIssueLinkSchema = z.object({
+ issueId: z.string().guid(),
+ role: z.enum(CASE_LINK_ROLES),
+}).strict();
+
+export const upsertCaseDocumentSchema = z.object({
+ title: z.string().trim().min(1).max(200).optional(),
+ format: z.string().trim().min(1).max(80).optional().default("markdown"),
+ body: z.string().max(200_000),
+ changeSummary: z.string().trim().max(1_000).nullable().optional(),
+ baseRevisionId: z.string().guid().nullable().optional(),
+}).strict();
+
+export const queryListParamSchema = z.union([z.string(), z.array(z.string())]).optional();
+
+export const listCasesQuerySchema = z.object({
+ type: z.string().trim().min(1).max(120).optional(),
+ types: queryListParamSchema,
+ status: z.string().trim().min(1).max(120).optional(),
+ statuses: queryListParamSchema,
+ project: z.string().guid().optional(),
+ projectId: z.string().guid().optional(),
+ projectIds: queryListParamSchema,
+ includeNoProject: z.enum(["true", "false", "1", "0"]).optional(),
+ label: z.string().guid().optional(),
+ labelId: z.string().guid().optional(),
+ parent: z.string().guid().optional(),
+ q: z.string().trim().min(1).max(200).optional(),
+ includeAncestors: z.enum(["true", "false", "1", "0"]).optional(),
+ limit: z.coerce.number().int().min(1).max(200).optional().default(100),
+}).strict();
+
+export const listEventsQuerySchema = z.object({
+ limit: z.coerce.number().int().min(1).max(MAX_EVENTS_LIMIT).optional().default(DEFAULT_EVENTS_LIMIT),
+}).strict();
+
diff --git a/server/src/routes/cases.ts b/server/src/routes/cases.ts
index fb88de44cd..ccc9b53ff5 100644
--- a/server/src/routes/cases.ts
+++ b/server/src/routes/cases.ts
@@ -1,3 +1,21 @@
+import {
+ CASE_STATUSES,
+ CASE_LINK_ROLES,
+ DEFAULT_EVENTS_LIMIT,
+ MAX_EVENTS_LIMIT,
+ jsonObjectSchema,
+ caseStatusSchema,
+ caseTypeSchema,
+ caseKeySchema,
+ documentKeySchema,
+ createCaseSchema,
+ patchCaseSchema,
+ createIssueLinkSchema,
+ upsertCaseDocumentSchema,
+ queryListParamSchema,
+ listCasesQuerySchema,
+ listEventsQuerySchema,
+} from "./cases-schemas.js";
import { Router, type Request, type Response } from "express";
import multer from "multer";
import { z } from "zod";
@@ -36,75 +54,6 @@ import { assertCompanyAccess, getActorInfo, hasCompanyAccess } from "./authz.js"
type CaseRouteDb = Db | Parameters[0]>[0];
type CaseActor = ReturnType;
-const CASE_STATUSES = ["draft", "in_progress", "in_review", "approved", "done", "cancelled"] as const;
-const CASE_LINK_ROLES = ["origin", "work", "reference"] as const;
-const DEFAULT_EVENTS_LIMIT = 100;
-const MAX_EVENTS_LIMIT = 500;
-
-const jsonObjectSchema = z.record(z.string(), z.unknown());
-const caseStatusSchema = z.enum(CASE_STATUSES);
-const caseTypeSchema = z.string().trim().min(1).max(120);
-const caseKeySchema = z.string().trim().min(1).max(512);
-const documentKeySchema = z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9_.:-]+$/);
-
-const createCaseSchema = z.object({
- projectId: z.string().guid().nullable().optional(),
- caseType: caseTypeSchema,
- key: caseKeySchema.nullable().optional(),
- title: z.string().trim().min(1).max(500),
- summary: z.string().max(8_000).nullable().optional(),
- status: caseStatusSchema.optional(),
- fields: jsonObjectSchema.optional(),
- parentCaseId: z.string().guid().nullable().optional(),
-}).strict();
-
-const patchCaseSchema = z.object({
- projectId: z.string().guid().nullable().optional(),
- title: z.string().trim().min(1).max(500).optional(),
- summary: z.string().max(8_000).nullable().optional(),
- status: caseStatusSchema.optional(),
- fields: jsonObjectSchema.optional(),
- parentCaseId: z.string().guid().nullable().optional(),
- labels: z.array(z.string().guid()).max(100).optional(),
- labelIds: z.array(z.string().guid()).max(100).optional(),
-}).strict();
-
-const createIssueLinkSchema = z.object({
- issueId: z.string().guid(),
- role: z.enum(CASE_LINK_ROLES),
-}).strict();
-
-const upsertCaseDocumentSchema = z.object({
- title: z.string().trim().min(1).max(200).optional(),
- format: z.string().trim().min(1).max(80).optional().default("markdown"),
- body: z.string().max(200_000),
- changeSummary: z.string().trim().max(1_000).nullable().optional(),
- baseRevisionId: z.string().guid().nullable().optional(),
-}).strict();
-
-const queryListParamSchema = z.union([z.string(), z.array(z.string())]).optional();
-
-const listCasesQuerySchema = z.object({
- type: z.string().trim().min(1).max(120).optional(),
- types: queryListParamSchema,
- status: z.string().trim().min(1).max(120).optional(),
- statuses: queryListParamSchema,
- project: z.string().guid().optional(),
- projectId: z.string().guid().optional(),
- projectIds: queryListParamSchema,
- includeNoProject: z.enum(["true", "false", "1", "0"]).optional(),
- label: z.string().guid().optional(),
- labelId: z.string().guid().optional(),
- parent: z.string().guid().optional(),
- q: z.string().trim().min(1).max(200).optional(),
- includeAncestors: z.enum(["true", "false", "1", "0"]).optional(),
- limit: z.coerce.number().int().min(1).max(200).optional().default(100),
-}).strict();
-
-const listEventsQuerySchema = z.object({
- limit: z.coerce.number().int().min(1).max(MAX_EVENTS_LIMIT).optional().default(DEFAULT_EVENTS_LIMIT),
-}).strict();
-
function eventActorValues(actor: CaseActor) {
return {
actorType: actor.actorType,
diff --git a/server/src/routes/experimental-api-metadata.ts b/server/src/routes/experimental-api-metadata.ts
new file mode 100644
index 0000000000..62cd15760b
--- /dev/null
+++ b/server/src/routes/experimental-api-metadata.ts
@@ -0,0 +1,605 @@
+// Generated by scripts/generate-runner-experimental-api-metadata.mjs.
+export const experimentalApiMetadata: Record = {
+ "GET /api/companies/{companyId}/pipelines": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "GET /api/companies/{companyId}/pipelines-attention": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "GET /api/companies/{companyId}/case-events": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "POST /api/companies/{companyId}/pipelines": {
+ "successStatuses": [
+ 201
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "GET /api/companies/{companyId}/review-cases": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "POST /api/companies/{companyId}/review-cases/bulk": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "GET /api/pipelines/{pipelineId}": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "GET /api/pipelines/{pipelineId}/health": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "GET /api/pipelines/{pipelineId}/intake-form": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "PATCH /api/pipelines/{pipelineId}": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "POST /api/pipelines/{pipelineId}/stages": {
+ "successStatuses": [
+ 201
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "PATCH /api/pipelines/{pipelineId}/stages/{stageId}": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "PATCH /api/pipelines/{pipelineId}/stages/{stageId}/automation-env": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "DELETE /api/pipelines/{pipelineId}/stages/{stageId}": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "PUT /api/pipelines/{pipelineId}/transitions": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "GET /api/pipelines/{pipelineId}/documents/{key}": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "PUT /api/pipelines/{pipelineId}/documents/{key}": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "GET /api/pipelines/{pipelineId}/documents/{key}/revisions": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "POST /api/pipelines/{pipelineId}/documents/{key}/revisions/{revisionId}/restore": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "POST /api/pipelines/{pipelineId}/cases": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "POST /api/pipelines/{pipelineId}/cases/batch": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "POST /api/cases/{caseId}/breakdown": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "GET /api/pipelines/{pipelineId}/cases": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "GET /api/cases/{caseId}": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "GET /api/cases/{caseId}/documents/{key}": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "PUT /api/cases/{caseId}/documents/{key}": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "GET /api/cases/{caseId}/documents/{key}/revisions": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "POST /api/cases/{caseId}/documents/{key}/revisions/{revisionId}/restore": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "GET /api/cases/{caseId}/children": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "PATCH /api/cases/{caseId}": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "POST /api/cases/{caseId}/claim": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "POST /api/cases/{caseId}/release": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "POST /api/cases/{caseId}/transition": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "POST /api/cases/{caseId}/suggest-transition": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "POST /api/cases/{caseId}/resolve-suggestion": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "POST /api/cases/{caseId}/acknowledge-drift": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "POST /api/cases/{caseId}/review": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "PUT /api/cases/{caseId}/blockers": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "POST /api/cases/{caseId}/open-conversation": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "GET /api/cases/{caseId}/issue-links": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "GET /api/cases/{caseId}/outputs": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "POST /api/cases/{caseId}/issue-links": {
+ "successStatuses": [
+ 201
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "DELETE /api/cases/{caseId}/issue-links/{linkId}": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "GET /api/cases/{caseId}/events": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "GET /api/cases/{caseId}/children/tree": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "GET /api/cases/{caseId}/rollup": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "GET /api/cases/{caseId}/context-pack": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "GET /api/cases/{caseId}/automation/retry-plan": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "POST /api/cases/{caseId}/automation/retry": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "POST /api/cases/{caseId}/automations/{automationId}/retry": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "POST /api/cases/{caseId}/automation/current-stage/rerun": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/pipelines.ts"
+ },
+ "POST /api/companies/{companyId}/cases": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/cases.ts"
+ },
+ "GET /api/companies/{companyId}/cases": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/cases.ts"
+ },
+ "GET /api/cases/{id}/documents/{key}": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/cases.ts"
+ },
+ "GET /api/cases/{id}/documents/{key}/annotations": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/cases.ts"
+ },
+ "GET /api/cases/{id}/documents/{key}/annotations/{threadId}": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/cases.ts"
+ },
+ "POST /api/cases/{id}/documents/{key}/annotations": {
+ "successStatuses": [
+ 201
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/cases.ts"
+ },
+ "POST /api/cases/{id}/documents/{key}/annotations/{threadId}/comments": {
+ "successStatuses": [
+ 201
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/cases.ts"
+ },
+ "PATCH /api/cases/{id}/documents/{key}/annotations/{threadId}": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/cases.ts"
+ },
+ "PUT /api/cases/{id}/documents/{key}": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/cases.ts"
+ },
+ "POST /api/cases/{id}/documents/{key}/lock": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/cases.ts"
+ },
+ "POST /api/cases/{id}/documents/{key}/unlock": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/cases.ts"
+ },
+ "POST /api/cases/{id}/documents/{key}/revisions/{revisionId}/restore": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/cases.ts"
+ },
+ "DELETE /api/cases/{id}/documents/{key}": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/cases.ts"
+ },
+ "POST /api/cases/{id}/links": {
+ "successStatuses": [
+ 201
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/cases.ts"
+ },
+ "POST /api/cases/{id}/attachments": {
+ "successStatuses": [
+ 201
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/cases.ts"
+ },
+ "GET /api/cases/{id}/events": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/cases.ts"
+ },
+ "GET /api/cases/{id}/documents/{key}/revisions": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/cases.ts"
+ },
+ "GET /api/issues/{issueId}/cases": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/cases.ts"
+ },
+ "GET /api/cases/{id}": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/cases.ts"
+ },
+ "PATCH /api/cases/{id}": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/cases.ts"
+ },
+ "GET /api/companies/{companyId}/smoke-lab/oauth/authorize": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/smoke-lab.ts"
+ },
+ "POST /api/companies/{companyId}/smoke-lab/oauth/authorize": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/smoke-lab.ts"
+ },
+ "POST /api/companies/{companyId}/smoke-lab/oauth/token": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/smoke-lab.ts"
+ },
+ "GET /api/companies/{companyId}/smoke-lab/oauth/userinfo": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/smoke-lab.ts"
+ },
+ "POST /api/companies/{companyId}/smoke-lab/oauth/revoke": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/smoke-lab.ts"
+ },
+ "GET /api/companies/{companyId}/smoke-lab/services": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": true,
+ "source": "server/src/routes/smoke-lab.ts"
+ },
+ "POST /api/companies/{companyId}/smoke-lab/services/start": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": true,
+ "source": "server/src/routes/smoke-lab.ts"
+ },
+ "POST /api/companies/{companyId}/smoke-lab/services/stop": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": true,
+ "source": "server/src/routes/smoke-lab.ts"
+ },
+ "POST /api/companies/{companyId}/smoke-lab/install-fixtures": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": true,
+ "source": "server/src/routes/smoke-lab.ts"
+ },
+ "GET /api/companies/{companyId}/smoke-lab/runs": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/smoke-lab.ts"
+ },
+ "POST /api/companies/{companyId}/smoke-lab/runs": {
+ "successStatuses": [
+ 201
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/smoke-lab.ts"
+ },
+ "GET /api/companies/{companyId}/smoke-lab/runs/{runId}": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/smoke-lab.ts"
+ },
+ "PATCH /api/companies/{companyId}/smoke-lab/runs/{runId}": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/smoke-lab.ts"
+ },
+ "POST /api/companies/{companyId}/smoke-lab/runs/{runId}/steps": {
+ "successStatuses": [
+ 201
+ ],
+ "boardOnly": false,
+ "source": "server/src/routes/smoke-lab.ts"
+ },
+ "POST /api/companies/{companyId}/smoke-lab/reset": {
+ "successStatuses": [
+ 200
+ ],
+ "boardOnly": true,
+ "source": "server/src/routes/smoke-lab.ts"
+ }
+};
diff --git a/server/src/routes/experimental-api-paths.ts b/server/src/routes/experimental-api-paths.ts
new file mode 100644
index 0000000000..3a1fd5f6ac
--- /dev/null
+++ b/server/src/routes/experimental-api-paths.ts
@@ -0,0 +1,106 @@
+// Experimental routes share their runtime validators with OpenAPI discovery.
+import { z } from "zod";
+import * as pipelineSchemas from "./pipelines-schemas.js";
+import * as caseSchemas from "./cases-schemas.js";
+import * as sharedSchemas from "@paperclipai/shared";
+
+export const experimentalApiPaths: readonly [string, string, z.ZodTypeAny | undefined][] = [
+ ["get", "/api/companies/{companyId}/pipelines", undefined],
+ ["get", "/api/companies/{companyId}/pipelines-attention", undefined],
+ ["get", "/api/companies/{companyId}/case-events", undefined],
+ ["post", "/api/companies/{companyId}/pipelines", pipelineSchemas.createPipelineSchema],
+ ["get", "/api/companies/{companyId}/review-cases", undefined],
+ ["post", "/api/companies/{companyId}/review-cases/bulk", pipelineSchemas.bulkReviewSchema],
+ ["get", "/api/pipelines/{pipelineId}", undefined],
+ ["get", "/api/pipelines/{pipelineId}/health", undefined],
+ ["get", "/api/pipelines/{pipelineId}/intake-form", undefined],
+ ["patch", "/api/pipelines/{pipelineId}", pipelineSchemas.updatePipelineSchema],
+ ["post", "/api/pipelines/{pipelineId}/stages", pipelineSchemas.createStageSchema],
+ ["patch", "/api/pipelines/{pipelineId}/stages/{stageId}", pipelineSchemas.updateStageSchema],
+ ["patch", "/api/pipelines/{pipelineId}/stages/{stageId}/automation-env", pipelineSchemas.updateStageAutomationEnvSchema],
+ ["delete", "/api/pipelines/{pipelineId}/stages/{stageId}", undefined],
+ ["put", "/api/pipelines/{pipelineId}/transitions", pipelineSchemas.replaceTransitionsSchema],
+ ["get", "/api/pipelines/{pipelineId}/documents/{key}", undefined],
+ ["put", "/api/pipelines/{pipelineId}/documents/{key}", pipelineSchemas.upsertPipelineDocumentSchema],
+ ["get", "/api/pipelines/{pipelineId}/documents/{key}/revisions", undefined],
+ ["post", "/api/pipelines/{pipelineId}/documents/{key}/revisions/{revisionId}/restore", undefined],
+ ["post", "/api/pipelines/{pipelineId}/cases", pipelineSchemas.ingestCaseSchema],
+ ["post", "/api/pipelines/{pipelineId}/cases/batch", pipelineSchemas.batchIngestSchema],
+ ["post", "/api/cases/{caseId}/breakdown", pipelineSchemas.breakdownCaseSchema],
+ ["get", "/api/pipelines/{pipelineId}/cases", undefined],
+ ["get", "/api/cases/{caseId}", undefined],
+ ["get", "/api/cases/{caseId}/documents/{key}", undefined],
+ ["put", "/api/cases/{caseId}/documents/{key}", pipelineSchemas.upsertPipelineCaseDocumentSchema],
+ ["get", "/api/cases/{caseId}/documents/{key}/revisions", undefined],
+ ["post", "/api/cases/{caseId}/documents/{key}/revisions/{revisionId}/restore", undefined],
+ ["get", "/api/cases/{caseId}/children", undefined],
+ ["patch", "/api/cases/{caseId}", pipelineSchemas.casePatchSchema],
+ ["post", "/api/cases/{caseId}/claim", pipelineSchemas.claimCaseSchema],
+ ["post", "/api/cases/{caseId}/release", pipelineSchemas.releaseCaseSchema],
+ ["post", "/api/cases/{caseId}/transition", pipelineSchemas.transitionCaseSchema],
+ ["post", "/api/cases/{caseId}/suggest-transition", pipelineSchemas.suggestTransitionSchema],
+ ["post", "/api/cases/{caseId}/resolve-suggestion", pipelineSchemas.resolveSuggestionSchema],
+ ["post", "/api/cases/{caseId}/acknowledge-drift", pipelineSchemas.acknowledgeDriftSchema],
+ ["post", "/api/cases/{caseId}/review", pipelineSchemas.reviewCaseSchema],
+ ["put", "/api/cases/{caseId}/blockers", pipelineSchemas.blockersSchema],
+ ["post", "/api/cases/{caseId}/open-conversation", undefined],
+ ["get", "/api/cases/{caseId}/issue-links", undefined],
+ ["get", "/api/cases/{caseId}/outputs", undefined],
+ ["post", "/api/cases/{caseId}/issue-links", pipelineSchemas.createIssueLinkSchema],
+ ["delete", "/api/cases/{caseId}/issue-links/{linkId}", undefined],
+ ["get", "/api/cases/{caseId}/events", undefined],
+ ["get", "/api/cases/{caseId}/children/tree", undefined],
+ ["get", "/api/cases/{caseId}/rollup", undefined],
+ ["get", "/api/cases/{caseId}/context-pack", undefined],
+ ["get", "/api/cases/{caseId}/automation/retry-plan", undefined],
+ ["post", "/api/cases/{caseId}/automation/retry", sharedSchemas.pipelineAutomationRetryRequestSchema],
+ ["post", "/api/cases/{caseId}/automations/{automationId}/retry", undefined],
+ ["post", "/api/cases/{caseId}/automation/current-stage/rerun", undefined],
+ ["post", "/api/companies/{companyId}/cases", caseSchemas.createCaseSchema],
+ ["get", "/api/companies/{companyId}/cases", undefined],
+ ["get", "/api/cases/{id}/documents/{key}", undefined],
+ ["get", "/api/cases/{id}/documents/{key}/annotations", undefined],
+ ["get", "/api/cases/{id}/documents/{key}/annotations/{threadId}", undefined],
+ ["post", "/api/cases/{id}/documents/{key}/annotations", sharedSchemas.createDocumentAnnotationThreadSchema],
+ ["post", "/api/cases/{id}/documents/{key}/annotations/{threadId}/comments", sharedSchemas.createDocumentAnnotationCommentSchema],
+ ["patch", "/api/cases/{id}/documents/{key}/annotations/{threadId}", sharedSchemas.updateDocumentAnnotationThreadSchema],
+ ["put", "/api/cases/{id}/documents/{key}", caseSchemas.upsertCaseDocumentSchema],
+ ["post", "/api/cases/{id}/documents/{key}/lock", undefined],
+ ["post", "/api/cases/{id}/documents/{key}/unlock", undefined],
+ ["post", "/api/cases/{id}/documents/{key}/revisions/{revisionId}/restore", undefined],
+ ["delete", "/api/cases/{id}/documents/{key}", undefined],
+ ["post", "/api/cases/{id}/links", caseSchemas.createIssueLinkSchema],
+ ["post", "/api/cases/{id}/attachments", undefined],
+ ["get", "/api/cases/{id}/events", undefined],
+ ["get", "/api/cases/{id}/documents/{key}/revisions", undefined],
+ ["get", "/api/issues/{issueId}/cases", undefined],
+ ["get", "/api/cases/{id}", undefined],
+ ["patch", "/api/cases/{id}", caseSchemas.patchCaseSchema],
+ ["get", "/api/companies/{companyId}/smoke-lab/oauth/authorize", undefined],
+ ["post", "/api/companies/{companyId}/smoke-lab/oauth/authorize", undefined],
+ ["post", "/api/companies/{companyId}/smoke-lab/oauth/token", undefined],
+ ["get", "/api/companies/{companyId}/smoke-lab/oauth/userinfo", undefined],
+ ["post", "/api/companies/{companyId}/smoke-lab/oauth/revoke", undefined],
+ ["get", "/api/companies/{companyId}/smoke-lab/services", undefined],
+ ["post", "/api/companies/{companyId}/smoke-lab/services/start", undefined],
+ ["post", "/api/companies/{companyId}/smoke-lab/services/stop", undefined],
+ ["post", "/api/companies/{companyId}/smoke-lab/install-fixtures", undefined],
+ ["get", "/api/companies/{companyId}/smoke-lab/runs", undefined],
+ ["post", "/api/companies/{companyId}/smoke-lab/runs", sharedSchemas.createSmokeRunSchema],
+ ["get", "/api/companies/{companyId}/smoke-lab/runs/{runId}", undefined],
+ ["patch", "/api/companies/{companyId}/smoke-lab/runs/{runId}", sharedSchemas.updateSmokeRunSchema],
+ ["post", "/api/companies/{companyId}/smoke-lab/runs/{runId}/steps", sharedSchemas.recordSmokeRunStepSchema],
+ ["post", "/api/companies/{companyId}/smoke-lab/reset", undefined],
+];
+
+// Query contracts shared with the runtime parser where one exists.
+export const experimentalApiQueries: Record> = {
+ "GET /api/companies/{companyId}/cases": caseSchemas.listCasesQuerySchema,
+ "GET /api/cases/{id}/events": caseSchemas.listEventsQuerySchema,
+ "GET /api/cases/{caseId}/automation/retry-plan": pipelineSchemas.retryAutomationQuerySchema,
+ "GET /api/companies/{companyId}/pipelines-attention": z.object({ limit: z.coerce.number().int().positive().optional() }),
+ "GET /api/companies/{companyId}/case-events": z.object({ types: z.string().optional(), limit: z.coerce.number().int().positive().optional(), offset: z.coerce.number().int().nonnegative().optional() }),
+ "GET /api/companies/{companyId}/review-cases": z.object({ pipelineId: z.string().optional(), parentCaseId: z.string().optional() }),
+ "GET /api/pipelines/{pipelineId}/cases": z.object({ stageKey: z.string().optional(), q: z.string().optional(), terminal: z.enum(["true", "false"]).optional(), includeRetired: z.enum(["true", "false"]).optional(), parentCaseId: z.string().optional() }),
+ "DELETE /api/pipelines/{pipelineId}/stages/{stageId}": z.object({ moveCasesToStageId: z.string().optional() }),
+};
diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts
index 56da28aca7..b21e9fc113 100644
--- a/server/src/routes/openapi.ts
+++ b/server/src/routes/openapi.ts
@@ -1,3 +1,5 @@
+import { experimentalApiMetadata } from "./experimental-api-metadata.js";
+import { experimentalApiPaths, experimentalApiQueries } from "./experimental-api-paths.js";
import { Router } from "express";
import { z } from "zod";
import {
@@ -855,6 +857,8 @@ const BOARD_ONLY_PREFIXES = [
];
const BOARD_ONLY_OPERATIONS = new Set([
+ "DELETE /api/issues/{id}/documents/{key}",
+ "GET /api/companies/{companyId}/decisions",
"GET /api/cloud/stacks",
"GET /api/companies",
"POST /api/companies",
@@ -1103,7 +1107,7 @@ function resolveOperationAuthLevel(method: string, path: string): OpenApiAuthLev
if (PUBLIC_OPERATIONS.has(key)) return "public";
if (RUNTIME_TOOLS_OPERATIONS.has(key)) return "runtime_tools";
if (INSTANCE_ADMIN_OPERATIONS.has(key)) return "instance_admin";
- if (isBoardOnlyOperation(method, path)) return "board";
+ if (isBoardOnlyOperation(method, path) || experimentalApiMetadata[`${method.toUpperCase()} ${path}`]?.boardOnly) return "board";
return "authenticated";
}
@@ -8348,6 +8352,24 @@ registerCurrentRoute({
}),
});
+// Every experimental REST route remains discoverable while its runtime feature
+// flag and actor checks stay authoritative. Shared validators prevent drift.
+for (const [method, path, body] of experimentalApiPaths) {
+ const query = experimentalApiQueries[`${method.toUpperCase()} ${path}`];
+ const metadata = experimentalApiMetadata[`${method.toUpperCase()} ${path}`];
+ registry.registerPath({
+ method, path, tags: ["Experimental"],
+ summary: `${method.toUpperCase()} ${path.replace(/\{[^}]+\}/g, "").replace(/\/api\//, "").replaceAll("/", " ")}`,
+ description: "Experimental API; the corresponding instance feature must be enabled. Existing route authorization applies." + (path.startsWith("/api/cases/{caseId}") ? " Pipeline case resource. On overlapping /cases routes the server selects the handler by resource identity; use a pipeline case ID." : path.startsWith("/api/cases/{id}") ? " Cases resource (not a pipeline case). Overlapping /cases routes select their handler by resource identity." : ""),
+ request: {
+ params: z.object(Object.fromEntries([...path.matchAll(/\{([^}]+)\}/g)].map((match) => [match[1], z.string()]))),
+ ...(query ? { query } : {}),
+ ...(path === "/api/cases/{id}/attachments" ? { body: { required: true, content: { "multipart/form-data": { schema: { type: "object", required: ["file"], properties: { file: { type: "string", format: "binary" } } } } } } } : body ? { body: { required: true, content: { "application/json": { schema: body } } } } : {}),
+ },
+ responses: { ...Object.fromEntries((metadata?.successStatuses ?? [200]).map(status => [status, responses.ok()])), 400: responses.badRequest, 403: responses.forbidden, 404: responses.notFound },
+ });
+}
+
// ─── Spec builder ─────────────────────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/server/src/routes/pipelines-schemas.ts b/server/src/routes/pipelines-schemas.ts
new file mode 100644
index 0000000000..99bdbaff14
--- /dev/null
+++ b/server/src/routes/pipelines-schemas.ts
@@ -0,0 +1,148 @@
+import { z } from "zod";
+import { envConfigSchema, pipelineAutomationRetryScopeSchema } from "@paperclipai/shared";
+
+export const stageKindSchema = z.enum(["open", "working", "review", "done", "cancelled"]);
+export const jsonObjectSchema = z.record(z.string(), z.unknown());
+export const stageConfigSchema = z.record(z.string(), z.unknown()).default({});
+export const casePatchSchema = z.object({
+ title: z.string().trim().min(1).max(500).optional(),
+ summary: z.string().max(8_000).nullable().optional(),
+ fields: jsonObjectSchema.optional(),
+ workspaceRef: jsonObjectSchema.nullable().optional(),
+ parentCaseId: z.string().guid().nullable().optional(),
+ expectedVersion: z.number().int().positive().optional(),
+ leaseToken: z.string().guid().nullable().optional(),
+});
+export const ingestCaseSchema = z.object({
+ caseKey: z.string().max(1_024).nullable().optional(),
+ title: z.string().trim().min(1).max(500),
+ summary: z.string().max(8_000).nullable().optional(),
+ fields: jsonObjectSchema.optional(),
+ stageKey: z.string().trim().min(1).max(120).optional(),
+ parentCaseId: z.string().guid().nullable().optional(),
+ requestKey: z.string().trim().min(1).max(512).optional(),
+ workspaceRef: jsonObjectSchema.nullable().optional(),
+ blockedByCaseIds: z.array(z.string().guid()).max(100).optional(),
+ blockedByCaseKeys: z.array(z.string().max(1_024)).max(100).optional(),
+});
+export const createPipelineSchema = z.object({
+ key: z.string().trim().min(1).max(120),
+ name: z.string().trim().min(1).max(200),
+ description: z.string().max(8_000).nullable().optional(),
+ projectId: z.string().guid().nullable().optional(),
+ enforceTransitions: z.boolean().optional(),
+ stages: z.array(z.object({
+ key: z.string().trim().min(1).max(120),
+ name: z.string().trim().min(1).max(200),
+ kind: stageKindSchema,
+ position: z.number().int().optional(),
+ config: stageConfigSchema.optional(),
+ })).optional(),
+});
+export const updatePipelineSchema = z.object({
+ name: z.string().trim().min(1).max(200).optional(),
+ description: z.string().max(8_000).nullable().optional(),
+ enforceTransitions: z.boolean().optional(),
+ archived: z.boolean().optional(),
+});
+export const createStageSchema = z.object({
+ key: z.string().trim().min(1).max(120),
+ name: z.string().trim().min(1).max(200),
+ kind: stageKindSchema,
+ position: z.number().int(),
+ config: stageConfigSchema.optional(),
+});
+export const updateStageSchema = z.object({
+ key: z.string().trim().min(1).max(120).optional(),
+ name: z.string().trim().min(1).max(200).optional(),
+ kind: stageKindSchema.optional(),
+ position: z.number().int().optional(),
+ config: stageConfigSchema.optional(),
+});
+export const updateStageAutomationEnvSchema = z.object({
+ env: envConfigSchema.nullable(),
+ baseRoutineRevisionId: z.string().guid().nullable().optional(),
+});
+export const replaceTransitionsSchema = z.object({
+ transitions: z.array(z.object({
+ fromStageKey: z.string().trim().min(1).max(120),
+ toStageKey: z.string().trim().min(1).max(120),
+ label: z.string().max(200).nullable().optional(),
+ })).max(500),
+ enforceTransitions: z.boolean().optional(),
+});
+export const batchIngestSchema = z.object({ items: z.array(ingestCaseSchema).max(200) });
+export const breakdownCaseSchema = z.object({
+ items: z.array(z.object({
+ key: z.string().trim().min(1).max(200),
+ title: z.string().trim().min(1).max(500),
+ summary: z.string().max(8_000).nullable().optional(),
+ fields: jsonObjectSchema.optional(),
+ })).max(200),
+});
+export const claimCaseSchema = z.object({ leaseSeconds: z.number().int().positive().max(86_400).optional() });
+export const releaseCaseSchema = z.object({
+ leaseToken: z.string().guid().nullable().optional(),
+ force: z.boolean().optional(),
+});
+export const transitionCaseSchema = z.object({
+ toStageKey: z.string().trim().min(1).max(120),
+ expectedVersion: z.number().int().positive(),
+ leaseToken: z.string().guid().nullable().optional(),
+ reason: z.string().max(4_000).nullable().optional(),
+ force: z.boolean().optional(),
+ acceptSuggestionId: z.string().guid().optional(),
+});
+export const suggestTransitionSchema = z.object({
+ toStageKey: z.string().trim().min(1).max(120),
+ rationale: z.string().trim().min(1).max(8_000),
+ confidence: z.number().min(0).max(1).optional(),
+});
+export const resolveSuggestionSchema = z.object({
+ suggestionId: z.string().guid(),
+ resolution: z.enum(["accept", "dismiss"]),
+ expectedVersion: z.number().int().positive().optional(),
+ reason: z.string().max(4_000).nullable().optional(),
+ leaseToken: z.string().guid().nullable().optional(),
+});
+export const acknowledgeDriftSchema = z.object({
+ expectedVersion: z.number().int().positive().optional(),
+});
+export const retryAutomationQuerySchema = z.object({
+ scope: pipelineAutomationRetryScopeSchema.default("previous_stage"),
+ targetStageId: z.string().guid().optional(),
+});
+export const reviewEditsSchema = z.object({
+ title: z.string().trim().min(1).max(500).optional(),
+ summary: z.string().max(8_000).nullable().optional(),
+ fields: jsonObjectSchema.optional(),
+ parentCaseId: z.string().guid().nullable().optional(),
+});
+export const reviewCaseSchema = z.object({
+ decision: z.enum(["approve", "reject", "request_changes"]),
+ reason: z.string().max(4_000).nullable().optional(),
+ edits: reviewEditsSchema.optional(),
+ expectedVersion: z.number().int().positive(),
+ leaseToken: z.string().guid().nullable().optional(),
+});
+export const blockersSchema = z.object({ blockedByCaseIds: z.array(z.string().guid()).max(100) });
+export const issueLinkRoleSchema = z.enum(["origin", "conversation", "work", "automation"]);
+export const createIssueLinkSchema = z.object({
+ issueId: z.string().guid(),
+ role: issueLinkRoleSchema,
+});
+export const bulkReviewSchema = z.object({
+ items: z.array(reviewCaseSchema.extend({ caseId: z.string().guid() })).max(100),
+});
+export const upsertPipelineDocumentSchema = z.object({
+ title: z.string().trim().min(1).max(200).optional(),
+ body: z.string().max(200_000),
+ baseRevisionId: z.string().guid().nullable().optional(),
+});
+export const upsertPipelineCaseDocumentSchema = z.object({
+ title: z.string().trim().min(1).max(200).optional(),
+ format: z.string().trim().min(1).max(80).optional().default("markdown"),
+ body: z.string().max(200_000),
+ changeSummary: z.string().trim().max(1_000).nullable().optional(),
+ baseRevisionId: z.string().guid().nullable().optional(),
+});
diff --git a/server/src/routes/pipelines.ts b/server/src/routes/pipelines.ts
index e9e6094086..7530753cf3 100644
--- a/server/src/routes/pipelines.ts
+++ b/server/src/routes/pipelines.ts
@@ -1,3 +1,33 @@
+import {
+ stageKindSchema,
+ jsonObjectSchema,
+ stageConfigSchema,
+ casePatchSchema,
+ ingestCaseSchema,
+ createPipelineSchema,
+ updatePipelineSchema,
+ createStageSchema,
+ updateStageSchema,
+ updateStageAutomationEnvSchema,
+ replaceTransitionsSchema,
+ batchIngestSchema,
+ breakdownCaseSchema,
+ claimCaseSchema,
+ releaseCaseSchema,
+ transitionCaseSchema,
+ suggestTransitionSchema,
+ resolveSuggestionSchema,
+ acknowledgeDriftSchema,
+ retryAutomationQuerySchema,
+ reviewEditsSchema,
+ reviewCaseSchema,
+ blockersSchema,
+ issueLinkRoleSchema,
+ createIssueLinkSchema,
+ bulkReviewSchema,
+ upsertPipelineDocumentSchema,
+ upsertPipelineCaseDocumentSchema,
+} from "./pipelines-schemas.js";
import { Router, type Request } from "express";
import { z } from "zod";
import { and, asc, desc, eq, ilike, inArray, isNotNull, isNull, ne, or, sql } from "drizzle-orm";
@@ -86,151 +116,6 @@ import {
const STAGE_INSTRUCTIONS_PREFIX = "stage-instructions:";
type PipelineRouteDb = Db | Parameters[0]>[0];
-const stageKindSchema = z.enum(["open", "working", "review", "done", "cancelled"]);
-const jsonObjectSchema = z.record(z.string(), z.unknown());
-const stageConfigSchema = z.record(z.string(), z.unknown()).default({});
-const casePatchSchema = z.object({
- title: z.string().trim().min(1).max(500).optional(),
- summary: z.string().max(8_000).nullable().optional(),
- fields: jsonObjectSchema.optional(),
- workspaceRef: jsonObjectSchema.nullable().optional(),
- parentCaseId: z.string().guid().nullable().optional(),
- expectedVersion: z.number().int().positive().optional(),
- leaseToken: z.string().guid().nullable().optional(),
-});
-const ingestCaseSchema = z.object({
- caseKey: z.string().max(1_024).nullable().optional(),
- title: z.string().trim().min(1).max(500),
- summary: z.string().max(8_000).nullable().optional(),
- fields: jsonObjectSchema.optional(),
- stageKey: z.string().trim().min(1).max(120).optional(),
- parentCaseId: z.string().guid().nullable().optional(),
- requestKey: z.string().trim().min(1).max(512).optional(),
- workspaceRef: jsonObjectSchema.nullable().optional(),
- blockedByCaseIds: z.array(z.string().guid()).max(100).optional(),
- blockedByCaseKeys: z.array(z.string().max(1_024)).max(100).optional(),
-});
-const createPipelineSchema = z.object({
- key: z.string().trim().min(1).max(120),
- name: z.string().trim().min(1).max(200),
- description: z.string().max(8_000).nullable().optional(),
- projectId: z.string().guid().nullable().optional(),
- enforceTransitions: z.boolean().optional(),
- stages: z.array(z.object({
- key: z.string().trim().min(1).max(120),
- name: z.string().trim().min(1).max(200),
- kind: stageKindSchema,
- position: z.number().int().optional(),
- config: stageConfigSchema.optional(),
- })).optional(),
-});
-const updatePipelineSchema = z.object({
- name: z.string().trim().min(1).max(200).optional(),
- description: z.string().max(8_000).nullable().optional(),
- enforceTransitions: z.boolean().optional(),
- archived: z.boolean().optional(),
-});
-const createStageSchema = z.object({
- key: z.string().trim().min(1).max(120),
- name: z.string().trim().min(1).max(200),
- kind: stageKindSchema,
- position: z.number().int(),
- config: stageConfigSchema.optional(),
-});
-const updateStageSchema = z.object({
- key: z.string().trim().min(1).max(120).optional(),
- name: z.string().trim().min(1).max(200).optional(),
- kind: stageKindSchema.optional(),
- position: z.number().int().optional(),
- config: stageConfigSchema.optional(),
-});
-const updateStageAutomationEnvSchema = z.object({
- env: envConfigSchema.nullable(),
- baseRoutineRevisionId: z.string().guid().nullable().optional(),
-});
-const replaceTransitionsSchema = z.object({
- transitions: z.array(z.object({
- fromStageKey: z.string().trim().min(1).max(120),
- toStageKey: z.string().trim().min(1).max(120),
- label: z.string().max(200).nullable().optional(),
- })).max(500),
- enforceTransitions: z.boolean().optional(),
-});
-const batchIngestSchema = z.object({ items: z.array(ingestCaseSchema).max(200) });
-const breakdownCaseSchema = z.object({
- items: z.array(z.object({
- key: z.string().trim().min(1).max(200),
- title: z.string().trim().min(1).max(500),
- summary: z.string().max(8_000).nullable().optional(),
- fields: jsonObjectSchema.optional(),
- })).max(200),
-});
-const claimCaseSchema = z.object({ leaseSeconds: z.number().int().positive().max(86_400).optional() });
-const releaseCaseSchema = z.object({
- leaseToken: z.string().guid().nullable().optional(),
- force: z.boolean().optional(),
-});
-const transitionCaseSchema = z.object({
- toStageKey: z.string().trim().min(1).max(120),
- expectedVersion: z.number().int().positive(),
- leaseToken: z.string().guid().nullable().optional(),
- reason: z.string().max(4_000).nullable().optional(),
- force: z.boolean().optional(),
- acceptSuggestionId: z.string().guid().optional(),
-});
-const suggestTransitionSchema = z.object({
- toStageKey: z.string().trim().min(1).max(120),
- rationale: z.string().trim().min(1).max(8_000),
- confidence: z.number().min(0).max(1).optional(),
-});
-const resolveSuggestionSchema = z.object({
- suggestionId: z.string().guid(),
- resolution: z.enum(["accept", "dismiss"]),
- expectedVersion: z.number().int().positive().optional(),
- reason: z.string().max(4_000).nullable().optional(),
- leaseToken: z.string().guid().nullable().optional(),
-});
-const acknowledgeDriftSchema = z.object({
- expectedVersion: z.number().int().positive().optional(),
-});
-const retryAutomationQuerySchema = z.object({
- scope: pipelineAutomationRetryScopeSchema.default("previous_stage"),
- targetStageId: z.string().guid().optional(),
-});
-const reviewEditsSchema = z.object({
- title: z.string().trim().min(1).max(500).optional(),
- summary: z.string().max(8_000).nullable().optional(),
- fields: jsonObjectSchema.optional(),
- parentCaseId: z.string().guid().nullable().optional(),
-});
-const reviewCaseSchema = z.object({
- decision: z.enum(["approve", "reject", "request_changes"]),
- reason: z.string().max(4_000).nullable().optional(),
- edits: reviewEditsSchema.optional(),
- expectedVersion: z.number().int().positive(),
- leaseToken: z.string().guid().nullable().optional(),
-});
-const blockersSchema = z.object({ blockedByCaseIds: z.array(z.string().guid()).max(100) });
-const issueLinkRoleSchema = z.enum(["origin", "conversation", "work", "automation"]);
-const createIssueLinkSchema = z.object({
- issueId: z.string().guid(),
- role: issueLinkRoleSchema,
-});
-const bulkReviewSchema = z.object({
- items: z.array(reviewCaseSchema.extend({ caseId: z.string().guid() })).max(100),
-});
-const upsertPipelineDocumentSchema = z.object({
- title: z.string().trim().min(1).max(200).optional(),
- body: z.string().max(200_000),
- baseRevisionId: z.string().guid().nullable().optional(),
-});
-const upsertPipelineCaseDocumentSchema = z.object({
- title: z.string().trim().min(1).max(200).optional(),
- format: z.string().trim().min(1).max(80).optional().default("markdown"),
- body: z.string().max(200_000),
- changeSummary: z.string().trim().max(1_000).nullable().optional(),
- baseRevisionId: z.string().guid().nullable().optional(),
-});
const intakeFieldTypes = new Set(["select", "text", "multiline"]);
function stageAutomationRoutineId(config: unknown) {
diff --git a/server/src/services/native-runtime/paperclip-runner-real-server.integration.test.ts b/server/src/services/native-runtime/paperclip-runner-real-server.integration.test.ts
index 0e09455398..622ed0da3e 100644
--- a/server/src/services/native-runtime/paperclip-runner-real-server.integration.test.ts
+++ b/server/src/services/native-runtime/paperclip-runner-real-server.integration.test.ts
@@ -1,5 +1,5 @@
import { existsSync } from "node:fs";
-import { mkdtemp, rm } from "node:fs/promises";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
@@ -88,10 +88,13 @@ describe("paperclip-runner real server vertical slice", () => {
apiUrl: `http://127.0.0.1:${address.port}`,
});
const stateDirectory = await mkdtemp(resolve(tmpdir(), "paperclip-runner-real-resume-"));
+ const expectedContextFile = resolve(stateDirectory, "expected-context.json");
+ await writeFile(expectedContextFile, JSON.stringify({ companyId, actorId: agentId, taskId: issueId, runId, callId: "semantic-call-1" }));
+ const fakeArgs = ["--state-file", resolve(stateDirectory, "fake-provider-state.json"), "--emit-tool-call", "--durable-turn-ids", "--durable-tool-ids", "--expected-canonical-task-context-file", expectedContextFile];
const bundle = createRunnerdCodexTransport({
runnerBinary: defaultCapabilityRunnerdBinary(),
codexCommand: fakeCodexAppServer,
- codexArgs: [],
+ codexArgs: fakeArgs,
stateDirectory,
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
prpIdentity: {
@@ -115,7 +118,7 @@ describe("paperclip-runner real server vertical slice", () => {
observedResults.push(result);
return {
success: true,
- contentItems: [{ type: "inputText", text: JSON.stringify({ ok: true, result }) }],
+ contentItems: [{ type: "inputText", text: JSON.stringify({ ok: true, operationId: params.tool, callId: params.callId, value: result }) }],
};
});
@@ -158,10 +161,11 @@ describe("paperclip-runner real server vertical slice", () => {
issueId,
runId: resumedRunId,
});
+ await writeFile(expectedContextFile, JSON.stringify({ companyId, actorId: agentId, taskId: issueId, runId: resumedRunId, callId: "semantic-call-2" }));
const restored = createRunnerdCodexTransport({
runnerBinary: defaultCapabilityRunnerdBinary(),
codexCommand: fakeCodexAppServer,
- codexArgs: [],
+ codexArgs: fakeArgs,
stateDirectory,
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
resumeDynamicTools: await resumedAuthority.definitions(),
@@ -189,7 +193,7 @@ describe("paperclip-runner real server vertical slice", () => {
observedResults.push(result);
return {
success: true,
- contentItems: [{ type: "inputText", text: JSON.stringify({ ok: true, result }) }],
+ contentItems: [{ type: "inputText", text: JSON.stringify({ ok: true, operationId: params.tool, callId: params.callId, value: result }) }],
};
});
try {
@@ -206,7 +210,7 @@ describe("paperclip-runner real server vertical slice", () => {
run: { id: resumedRunId },
});
expect(restored.evidence().diagnostics).toContain(
- "runnerd restored its durable PRP session and provider thread",
+ "runnerd attached the durable provider session to a fresh PRP run authority",
);
} finally {
await restored.transport.close();
diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts
index 459db62012..77574cf988 100644
--- a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts
+++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts
@@ -386,6 +386,8 @@ describe("PaperclipRunnerToolAuthority", () => {
},
});
const prerequisiteId = (prerequisite as { task: { id: string } }).task.id;
+ expect(await db.select().from(activityLog).where(eq(activityLog.entityId, prerequisiteId)))
+ .toEqual(expect.arrayContaining([expect.objectContaining({ action: "issue.created", agentId, runId, companyId })]));
const dependent = await authority.execute({
tool: "create_task",
callId: "create-dependent",
@@ -480,6 +482,10 @@ describe("PaperclipRunnerToolAuthority", () => {
},
})).rejects.toThrow("paperclip_runner_tool_idempotency_conflict");
+ const creationEvents = (await db.select().from(activityLog).where(eq(activityLog.entityId, prerequisiteId)))
+ .filter(event => event.action === "issue.created");
+ expect(creationEvents).toHaveLength(1);
+
const foreignCompanyId = "00000000-0000-4000-8000-000000000201";
const foreignAgentId = "00000000-0000-4000-8000-000000000202";
const foreignIssueId = "00000000-0000-4000-8000-000000000203";
diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts
index 1f43499bba..16894e824e 100644
--- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts
+++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts
@@ -1,4 +1,15 @@
import { createHash } from "node:crypto";
+import { runnerApiToolsEnabled } from "./runner-api-rollout.js";
+import { openRunnerApiWorkspaceFile } from "./runner-api-files.js";
+import { basename } from "node:path";
+import { createLocalAgentJwt } from "../../agent-auth-jwt.js";
+import { getStorageService } from "../../storage/index.js";
+import type { StorageService } from "../../storage/types.js";
+import { assetService } from "../assets.js";
+import { workspaceFileResourceService } from "../workspace-file-resources.js";
+import { badRequest, forbidden } from "../../errors.js";
+import { searchRunnerApi } from "./runner-api-catalog.js";
+import { executeRunnerApi, validateRunnerApiCall, RUNNER_API_MAX_BYTES, type RunnerApiFile } from "./runner-api-client.js";
import { and, desc, eq, isNull } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
@@ -20,6 +31,7 @@ import { issueThreadInteractionService } from "../issue-thread-interactions.js";
import { persistActivity, publishActivity } from "../activity-log.js";
const IMPLEMENTED_OPERATIONS = new Set([
+ "search_api", "call_api",
"get_task_context", "get_task_history", "search_tasks", "report_progress",
"request_human_input",
"create_task", "set_dependencies",
@@ -33,6 +45,11 @@ type Binding = {
runId: string;
agentId: string;
normalizedSessionId?: string;
+ /** Server-owned API origin and storage; never obtained from tool input. */
+ apiUrl?: string;
+ storage?: StorageService;
+ /** Server-owned suppression for baseline evals; true never overrides operator opt-in. */
+ apiToolsEnabled?: boolean;
workMode?: "standard" | "planning" | "ask";
enqueueWakeup?: (agentId: string, options: {
source: "assignment";
@@ -75,6 +92,7 @@ export class PaperclipRunnerToolAuthority {
return CAPABILITY_SEMANTIC_TOOL_CATALOG
.filter((descriptor) =>
IMPLEMENTED_OPERATIONS.has(descriptor.operationId)
+ && (runnerApiToolsEnabled(this.binding.companyId, this.binding.apiToolsEnabled) || !["search_api", "call_api"].includes(descriptor.operationId))
&& descriptor.allowedModes.includes(workMode)
)
.map((descriptor) => ({
@@ -86,6 +104,7 @@ export class PaperclipRunnerToolAuthority {
async execute(call: { tool: string; callId: string; arguments: unknown }): Promise {
if (!IMPLEMENTED_OPERATIONS.has(call.tool)) throw new Error("paperclip_runner_tool_not_advertised");
+ if (!runnerApiToolsEnabled(this.binding.companyId, this.binding.apiToolsEnabled) && ["search_api", "call_api"].includes(call.tool)) throw new Error("paperclip_runner_tool_not_advertised");
const context = await this.#boundContext();
const descriptor = CAPABILITY_SEMANTIC_TOOL_CATALOG.find((candidate) => candidate.operationId === call.tool);
if (!descriptor || !descriptor.allowedModes.includes(
@@ -95,6 +114,8 @@ export class PaperclipRunnerToolAuthority {
}
const input = record(call.arguments);
switch (call.tool) {
+ case "search_api": return searchRunnerApi(call.arguments);
+ case "call_api": return this.#callApi(call.callId, call.arguments);
case "get_task_context": return {
company: { id: this.binding.companyId },
actor: redactedActor(context.actor),
@@ -175,6 +196,112 @@ export class PaperclipRunnerToolAuthority {
}
}
+ async #callApi(callId: string, value: unknown): Promise {
+ const bound = await this.#boundContext();
+ const context = { ...this.binding, issueIdentifier: bound.issue.identifier, workMode: bound.issue.workMode };
+ const { input, operation } = validateRunnerApiCall(value, context);
+ const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL;
+ if (!apiUrl) throw new Error("Paperclip API origin is unavailable");
+ const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, bound.actor.adapterType, this.binding.runId, bound.run.responsibleUserId);
+ if (!token) throw new Error("Paperclip run authentication is unavailable");
+ const execute = async () => {
+ const current = await this.#boundContext();
+ if (!runnerApiToolsEnabled(this.binding.companyId, this.binding.apiToolsEnabled)) throw new Error("paperclip_runner_tool_not_advertised");
+ return executeRunnerApi(input, { ...context, workMode: current.issue.workMode }, {
+ apiUrl, token,
+ beforeDispatch: async () => {
+ const fresh = await this.#boundContext();
+ if (!runnerApiToolsEnabled(this.binding.companyId, this.binding.apiToolsEnabled)) throw new Error("paperclip_runner_tool_not_advertised");
+ validateRunnerApiCall(input, { ...context, workMode: fresh.issue.workMode });
+ },
+ readFile: (file) => this.#readApiFile(file),
+ saveResponse: async (bytes, contentType) => {
+ const storage = this.binding.storage ?? getStorageService();
+ const saved = await storage.putFile({ companyId: this.binding.companyId, namespace: "runner-api", originalFilename: contentType.includes("json") ? "response.json" : "response.bin", contentType, body: bytes });
+ const asset = await assetService(this.db).create(this.binding.companyId, { ...saved, createdByAgentId: this.binding.agentId });
+ const activity = await persistActivity(this.db, { companyId: this.binding.companyId, actorType: "agent", actorId: this.binding.agentId, agentId: this.binding.agentId, runId: this.binding.runId, issueId: this.binding.issueId, action: "asset.created", entityType: "asset", entityId: asset.id, details: { source: "runner.call_api", byteSize: saved.byteSize } });
+ publishActivity(activity.publication);
+ return { artifactId: asset.id, url: `/api/assets/${asset.id}/content`, contentType, byteSize: saved.byteSize, sha256: saved.sha256 };
+ },
+ });
+ };
+ if (["GET", "HEAD", "OPTIONS"].includes(operation.method)) return execute();
+ if (!callId || callId.length > 500) throw badRequest("A bounded runner call id is required");
+ // Reserve durably before HTTP dispatch, without holding a DB lock over an
+ // HTTP route that itself needs DB locks. A crash leaves an explicit unknown
+ // outcome rather than replaying a possibly committed external mutation.
+ const key = createHash("sha256").update(callId).digest("hex");
+ const digest = createHash("sha256").update(canonicalJson(input)).digest("hex");
+ const prior = await this.db.transaction(async (tx) => {
+ const locked = await this.#lockAuthorizedMutationContext(tx as unknown as Db);
+ const resultJson = record(locked.run.resultJson);
+ const receipts = record(resultJson.apiToolReceipts);
+ const existing = record(receipts[key]);
+ if (receipts[key]) {
+ if (existing.digest !== digest) throw badRequest("API call id was reused with different arguments");
+ return { result: existing.result ?? { ok: false, status: null, error: "api_outcome_unknown", outcome: "unknown", guidance: "Inspect state before issuing another mutation." } };
+ }
+ if (Object.keys(receipts).length >= 512) throw badRequest("Run API mutation limit reached");
+ receipts[key] = { digest, state: "pending" };
+ await tx.update(heartbeatRuns).set({ resultJson: { ...resultJson, apiToolReceipts: receipts } }).where(eq(heartbeatRuns.id, this.binding.runId));
+ return null;
+ });
+ if (prior) return prior.result;
+ const result = await execute();
+ // Some older routes attribute the agent but omit runId. Retain their domain
+ // event and add the run-bound HTTP receipt, without logging request bodies.
+ const apiActivity = await persistActivity(this.db, {
+ companyId: this.binding.companyId, actorType: "agent", actorId: this.binding.agentId,
+ agentId: this.binding.agentId, runId: this.binding.runId, issueId: this.binding.issueId,
+ action: "runner.api_called", entityType: "issue", entityId: this.binding.issueId,
+ details: { operationId: operation.operationId, requestFingerprint: digest, pathParams: input.pathParams ?? {}, status: result.status, outcome: record(result).outcome ?? (result.ok ? "succeeded" : "failed") },
+ });
+ publishActivity(apiActivity.publication);
+ await this.db.transaction(async (tx) => {
+ const [run] = await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, this.binding.runId)).for("update");
+ const resultJson = record(run?.resultJson);
+ const receipts = record(resultJson.apiToolReceipts);
+ receipts[key] = { digest, state: "completed", result };
+ await tx.update(heartbeatRuns).set({ resultJson: { ...resultJson, apiToolReceipts: receipts } }).where(eq(heartbeatRuns.id, this.binding.runId));
+ });
+ return result;
+ }
+
+ async #readApiFile(file: RunnerApiFile): Promise<{ bytes: Buffer; filename: string; contentType: string }> {
+ if (file.artifactId) {
+ const asset = await assetService(this.db).getById(file.artifactId);
+ if (!asset || asset.companyId !== this.binding.companyId) throw forbidden("Artifact is not available in this company");
+ if (asset.byteSize > RUNNER_API_MAX_BYTES) throw badRequest("Artifact exceeds API transfer limit");
+ const object = await (this.binding.storage ?? getStorageService()).getObject(this.binding.companyId, asset.objectKey);
+ const chunks: Buffer[] = [];
+ let length = 0;
+ try {
+ for await (const chunk of object.stream) {
+ const bytes = Buffer.from(chunk);
+ length += bytes.length;
+ if (length > RUNNER_API_MAX_BYTES) throw badRequest("Artifact exceeds API transfer limit");
+ chunks.push(bytes);
+ }
+ } finally { object.stream.destroy(); }
+ return { bytes: Buffer.concat(chunks), filename: asset.originalFilename ?? "file", contentType: asset.contentType };
+ }
+ const resolved = await workspaceFileResourceService(this.db).prepareDownload(this.binding.issueId, { path: file.path!, workspace: "auto" });
+ const handle = await openRunnerApiWorkspaceFile(resolved.realPath);
+ try {
+ const stat = await handle.stat();
+ if (!stat.isFile() || stat.size > RUNNER_API_MAX_BYTES) throw badRequest("Workspace file exceeds API transfer limit");
+ const bytes = Buffer.alloc(RUNNER_API_MAX_BYTES + 1);
+ let length = 0;
+ while (length < bytes.length) {
+ const { bytesRead } = await handle.read(bytes, length, bytes.length - length, length);
+ if (!bytesRead) break;
+ length += bytesRead;
+ }
+ if (length > RUNNER_API_MAX_BYTES) throw badRequest("Workspace file exceeds API transfer limit");
+ return { bytes: bytes.subarray(0, length), filename: basename(resolved.realPath), contentType: "application/octet-stream" };
+ } finally { await handle.close(); }
+ }
+
async #approval(id: string) {
const approval = await approvalService(this.db).getById(id);
if (!approval || approval.companyId !== this.binding.companyId) throw new Error("paperclip_runner_approval_not_found");
@@ -321,6 +448,7 @@ export class PaperclipRunnerToolAuthority {
const inputFingerprint = createHash("sha256")
.update(canonicalJson(input))
.digest("hex");
+ let publication: Awaited>["publication"] | null = null;
const result = await this.#withMutationReceipt("create_task", idempotencyKey, input, async (tx) => {
const existingChild = await tx.select().from(issues).where(and(
eq(issues.companyId, this.binding.companyId),
@@ -386,6 +514,16 @@ export class PaperclipRunnerToolAuthority {
}
}
}
+ if (!deduplicated) {
+ const activity = await persistActivity(tx, {
+ companyId: this.binding.companyId, actorType: "agent", actorId: this.binding.agentId,
+ agentId: this.binding.agentId, runId: this.binding.runId, issueId: child.id,
+ action: "issue.created", entityType: "issue", entityId: child.id,
+ details: { identifier: child.identifier, title: child.title, parentId: this.binding.issueId,
+ assigneeAgentId: child.assigneeAgentId, status: childStatus, source: "paperclip_runner_protocol" },
+ });
+ publication = activity.publication;
+ }
const wakeId = `created-child:${child.id}`;
const shouldWake = !deduplicated && childStatus === "todo" && Boolean(child.assigneeAgentId);
return {
@@ -404,6 +542,7 @@ export class PaperclipRunnerToolAuthority {
};
}) as Record;
+ if (publication) publishActivity(publication);
const task = record(result.task);
const childId = requiredString(task.id);
const scheduledWakeIds = Array.isArray(result.scheduledWakeIds)
diff --git a/server/src/services/native-runtime/runner-api-catalog.ts b/server/src/services/native-runtime/runner-api-catalog.ts
new file mode 100644
index 0000000000..fa3195b194
--- /dev/null
+++ b/server/src/services/native-runtime/runner-api-catalog.ts
@@ -0,0 +1,156 @@
+import { createHash } from "node:crypto";
+import { z } from "zod";
+import { buildOpenApiDocument } from "../../routes/openapi.js";
+import { badRequest, notFound } from "../../errors.js";
+import { runnerApiReference } from "./runner-api-reference.js";
+import { runnerApiRestriction } from "./runner-api-policy.js";
+import { CAPABILITY_SEMANTIC_TOOL_CATALOG } from "../../vendor/paperclip-runner/index.js";
+
+type Json = Record;
+export interface RunnerApiOperation {
+ operationId: string;
+ method: string;
+ path: string;
+ summary: string;
+ description: string;
+ parameters: Json[];
+ requestBody?: Json;
+ responses: Json;
+ authorization: Json;
+ transport: "rest" | "protocol";
+ dedicatedTools: string[];
+ allowedModes: string[];
+ skillReference?: { section: string; description?: string; examples?: { body: unknown }[] };
+ dedicatedToolGuidance: string;
+ dedicatedToolCapabilities?: { name: string; description: string; supportedParameters: string[] }[];
+ runnerRestrictions?: string[];
+ callPolicy: "rest" | "restricted" | "protocol";
+}
+
+const METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
+const SYNONYMS: Record = {
+ task: "issue", tasks: "issues", employee: "agent", employees: "agents",
+ hire: "agent", hiring: "agents", expense: "cost", expenses: "costs",
+ cron: "routines", schedule: "routines", recurring: "routines",
+ blocker: "dependencies", blockers: "dependencies", files: "attachments",
+};
+
+function words(text: string): string[] {
+ return text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)
+ .flatMap((word) => [word, ...(SYNONYMS[word] ? [SYNONYMS[word]] : [])])
+ .map((word) => word.length > 3 && word.endsWith("s") ? word.slice(0, -1) : word);
+}
+
+function dedicatedTools(method: string, path: string): string[] {
+ if (/\/issues\/\{[^}]+\}\/comments$/.test(path)) return method === "GET" ? ["get_task_history"] : ["report_progress"];
+ if (/\/issues\/\{[^}]+\}\/documents/.test(path)) return method === "DELETE" ? [] : method === "GET" ? ["list_documents", "read_document", "list_document_revisions"] : ["write_document"];
+ if (/\/issues$/.test(path)) return method === "GET" ? ["search_tasks"] : ["create_task"];
+ if (/\/issues\/\{[^}]+\}$/.test(path)) return method === "GET" ? ["get_task_context"] : ["set_dependencies", "finish_task", "block_task", "request_review"];
+ if (/\/agents$/.test(path) && method === "GET") return ["list_agents"];
+ if (/\/agents\/(me|\{[^}]+\})$/.test(path) && method === "GET") return ["get_agent"];
+ if (/\/approvals$/.test(path) && method === "GET") return ["list_approvals"];
+ if (/\/approvals\/\{[^}]+\}/.test(path) && method === "GET") return ["get_approval", "get_approval_context"];
+ return [];
+}
+
+// Descriptions and schemas are documentation, never authorization. Routes remain
+// authoritative, including conditional role, company and resource checks.
+export function buildRunnerApiCatalog(document: Json = buildOpenApiDocument()): RunnerApiOperation[] {
+ function dereference(value: any, seen = new Set()): any {
+ if (Array.isArray(value)) return value.map((entry) => dereference(entry, seen));
+ if (!value || typeof value !== "object") return value;
+ if (typeof value.$ref === "string" && value.$ref.startsWith("#/")) {
+ if (seen.has(value.$ref)) return { description: `Recursive schema: ${value.$ref}` };
+ const target = value.$ref.slice(2).split("/").reduce((node: any, key: string) => node?.[key.replace(/~1/g, "/").replace(/~0/g, "~")], document);
+ if (!target) throw new Error(`Unresolved API schema: ${value.$ref}`);
+ return dereference(target, new Set([...seen, value.$ref]));
+ }
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, dereference(entry, seen)]));
+ }
+ const result: RunnerApiOperation[] = [];
+ for (const [path, item] of Object.entries(document.paths)) {
+ for (const [verb, operation] of Object.entries(item)) {
+ if (!METHODS.has(verb)) continue;
+ const method = verb.toUpperCase();
+ const restriction = runnerApiRestriction(method, path);
+ const skillReference = runnerApiReference[`${method} ${path.replace(/\{[^}]+\}/g, "{}")}`];
+ const protocol = !path.startsWith("/api/") || /\/(oauth|auth|runtime-tools|mcp|ws)(\/|$)/.test(path)
+ || /\/(claude-login|login-sessions|start-authorization|finalize-oauth-access)(\/|$)/.test(path)
+ || /event-stream|websocket/i.test(JSON.stringify(operation.responses));
+ result.push({
+ operationId: `${method} ${path}`, method, path,
+ summary: operation.summary ?? `${method} ${path}`,
+ description: operation.description ?? "",
+ parameters: dereference([...(item.parameters ?? []), ...(operation.parameters ?? [])]),
+ ...(operation.requestBody ? { requestBody: dereference(operation.requestBody) } : {}),
+ responses: dereference(operation.responses ?? {}),
+ authorization: operation["x-paperclip-authorization"] ?? { actor: "board_or_agent" },
+ transport: protocol ? "protocol" : "rest",
+ callPolicy: protocol ? "protocol" : restriction ? "restricted" : "rest",
+ dedicatedTools: dedicatedTools(method, path),
+ dedicatedToolCapabilities: dedicatedTools(method, path).flatMap(name => {
+ const descriptor = CAPABILITY_SEMANTIC_TOOL_CATALOG.find(tool => tool.operationId === name);
+ return descriptor ? [{ name, description: descriptor.description, supportedParameters: Object.keys((descriptor.inputSchema as Json).properties ?? {}) }] : [];
+ }),
+ runnerRestrictions: [...(restriction ? [restriction] : []), "Active run and assignment must remain authorized.", "Cannot replace checkout, completion, task status/ownership changes, approval decisions, or runner execution control. Dedicated tools retain their existing permissions."],
+ dedicatedToolGuidance: "Use an available dedicated tool for its supported fields. Inspect that tool's advertised schema; call_api may be used for additional API fields, subject to lifecycle restrictions.",
+ allowedModes: method === "GET" || method === "HEAD" ? ["standard", "ask", "planning", "skill_test"] : ["standard", "skill_test"],
+ ...(skillReference ? { skillReference } : {}),
+ });
+ }
+ }
+ for (const [path, summary, actor] of [
+ ["/api/companies/{companyId}/events/ws", "Live company events WebSocket; use the existing event client", "board_or_agent"],
+ ["/api/runner/v1/connect/{runId}", "Authenticated runner PRP WebSocket; runner-owned transport", "runner"],
+ ["/api/environment-custom-image-setup-sessions/{sessionId}/terminal/ws", "Image setup terminal WebSocket; use the existing terminal client", "board"],
+ ]) {
+ if (result.some(operation => operation.path === path)) continue;
+ result.push({ operationId: `GET ${path}`, method: "GET", path, summary,
+ description: "WebSocket upgrade. Not callable through call_api.",
+ parameters: [...path.matchAll(/\{([^}]+)\}/g)].map(match => ({ in: "path", name: match[1], required: true, schema: { type: "string" } })),
+ responses: { "101": { description: "Switching Protocols" } }, authorization: { actor },
+ transport: "protocol", callPolicy: "protocol", dedicatedTools: [], dedicatedToolGuidance: "Use the existing protocol client.", allowedModes: [],
+ });
+ }
+ return result.sort((a, b) => a.operationId.localeCompare(b.operationId));
+}
+
+let cached: RunnerApiOperation[] | undefined;
+let cachedDigest: string | undefined;
+export function runnerApiCatalog(): RunnerApiOperation[] { return cached ??= buildRunnerApiCatalog(); }
+export function runnerApiOperation(id: string): RunnerApiOperation {
+ const operation = runnerApiCatalog().find((entry) => entry.operationId === id);
+ if (!operation) throw notFound("Unknown API operation; use search_api to discover its exact operationId");
+ return operation;
+}
+
+export const runnerApiSearchSchema = z.object({ query: z.string().trim().min(1).max(500), limit: z.number().int().min(1).max(10).default(5), cursor: z.string().max(200).optional() }).strict();
+export function searchRunnerApi(value: unknown) {
+ const parsed = runnerApiSearchSchema.safeParse(value);
+ if (!parsed.success) throw badRequest("Invalid API search query or limit");
+ const input = parsed.data;
+ const { query, limit } = input;
+ const catalog = runnerApiCatalog();
+ cachedDigest ??= createHash("sha256").update(JSON.stringify(catalog)).digest("hex");
+ const fingerprint = createHash("sha256").update(cachedDigest).update(query).digest("hex").slice(0, 16);
+ let offset = 0;
+ if (input.cursor) {
+ const match = /^([a-f0-9]{16}):(\d+)$/.exec(input.cursor);
+ if (!match || match[1] !== fingerprint) throw badRequest("Search cursor belongs to a different query or catalog");
+ offset = Number(match[2]);
+ if (!Number.isSafeInteger(offset)) throw badRequest("Invalid search cursor");
+ }
+ const exact = catalog.find((entry) => entry.operationId.toLowerCase() === query.trim().toLowerCase());
+ const terms = [...new Set(words(query))];
+ const matches = exact ? [exact] : catalog.map((entry) => {
+ const title = new Set(words(`${entry.method} ${entry.path} ${entry.summary}`));
+ const description = new Set(words(`${entry.description} ${entry.skillReference?.description ?? ""} ${entry.skillReference?.section ?? ""}`));
+ return { entry, score: terms.reduce((sum, word) => sum + (title.has(word) ? 5 : description.has(word) ? 1 : 0), 0) };
+ }).filter(({ score }) => score > 0).sort((a, b) => b.score - a.score || a.entry.operationId.localeCompare(b.entry.operationId)).map(({ entry }) => entry);
+ const results = matches.slice(offset, offset + limit);
+ return {
+ results, total: matches.length,
+ nextCursor: offset + limit < matches.length ? `${fingerprint}:${offset + limit}` : null,
+ guidance: "Prefer an available dedicated tool when it supports the required operation and parameters. API permissions still apply. Protocol endpoints require their existing clients. Ask and Plan permit only reads through call_api.",
+ };
+}
diff --git a/server/src/services/native-runtime/runner-api-client.ts b/server/src/services/native-runtime/runner-api-client.ts
new file mode 100644
index 0000000000..dec7582012
--- /dev/null
+++ b/server/src/services/native-runtime/runner-api-client.ts
@@ -0,0 +1,205 @@
+import { z } from "zod";
+import { badRequest, forbidden, unprocessable } from "../../errors.js";
+import { runnerApiOperation, type RunnerApiOperation } from "./runner-api-catalog.js";
+import { runnerApiRestriction } from "./runner-api-policy.js";
+
+export const RUNNER_API_MAX_BYTES = 10 * 1024 * 1024;
+export const RUNNER_API_INLINE_BYTES = 24 * 1024;
+export const RUNNER_API_TIMEOUT_MS = 30_000;
+const fileSchema = z.object({
+ field: z.string().min(1).max(200).optional(),
+ artifactId: z.string().min(1).optional(),
+ path: z.string().min(1).max(1000).optional(),
+}).strict().refine((file) => Boolean(file.artifactId) !== Boolean(file.path), "Specify exactly one artifactId or workspace path");
+export const runnerApiCallSchema = z.object({
+ operationId: z.string().min(1).max(500),
+ pathParams: z.record(z.string().max(200), z.string().max(1000)).optional(),
+ query: z.record(z.string(), z.unknown()).optional(),
+ body: z.unknown().optional(),
+ contentType: z.string().max(120).optional(),
+ files: z.array(fileSchema).max(10).optional(),
+}).strict();
+export type RunnerApiCall = z.infer;
+export type RunnerApiFile = z.infer;
+export interface RunnerApiContext {
+ companyId: string;
+ issueId: string;
+ issueIdentifier?: string | null;
+ runId: string;
+ workMode: string;
+}
+export interface RunnerApiIo {
+ apiUrl: string;
+ token: string;
+ fetch?: typeof fetch;
+ /** Revalidate server-owned authority after asynchronous file preparation. */
+ beforeDispatch?(): Promise;
+ readFile(file: RunnerApiFile): Promise<{ bytes: Buffer; filename: string; contentType: string }>;
+ saveResponse(bytes: Buffer, contentType: string): Promise>;
+}
+
+export function validateRunnerApiCall(value: unknown, context: RunnerApiContext): { input: RunnerApiCall; operation: RunnerApiOperation } {
+ const parsed = runnerApiCallSchema.safeParse(value);
+ if (!parsed.success) throw badRequest("Invalid call_api arguments", { issues: parsed.error.issues });
+ const input = parsed.data;
+ const operation = runnerApiOperation(input.operationId);
+ if (operation.transport !== "rest") throw unprocessable("This endpoint requires its existing protocol client; call_api supports REST only");
+ const restriction = runnerApiRestriction(operation.method, operation.path);
+ if (restriction) throw forbidden(restriction);
+ if (!operation.allowedModes.includes(context.workMode)) throw forbidden("call_api permits only reads in Ask and Plan modes; use the permitted dedicated tools");
+ if (input.pathParams?.companyId && input.pathParams.companyId !== context.companyId) throw forbidden("API call belongs to another company");
+ const mutation = !["GET", "HEAD", "OPTIONS"].includes(operation.method);
+ if (mutation) {
+ const issueRoute = /^\/api\/issues\/\{[^}]+\}/.test(operation.path);
+ const body = input.body && typeof input.body === "object" && !Array.isArray(input.body) ? input.body as Record : {};
+ // Raw uploads must not hide lifecycle fields from the structured policy
+ // check and subsequently become JSON inside Express's body parser.
+ const guardedBody = /^\/api\/(issues|agents)\/\{[^}]+\}$/.test(operation.path)
+ || (issueRoute && /\/comments$/.test(operation.path));
+ if (guardedBody && (input.files?.length || (input.body !== undefined && (!input.body || typeof input.body !== "object" || Array.isArray(input.body))))) {
+ throw forbidden("Lifecycle-sensitive API updates require an inline JSON object");
+ }
+ if (/^\/api\/agents\/\{[^}]+\}$/.test(operation.path) && ["status", "runtimeState"].some(key => Object.hasOwn(body, key))) {
+ throw forbidden("API agent updates cannot bypass runner execution-control authority");
+ }
+ if (issueRoute && /\/comments$/.test(operation.path) && ["reopen", "resume", "interrupt"].some(key => body[key] === true)) {
+ throw forbidden("API comments cannot change runner execution state");
+ }
+ if (issueRoute && /\/issues\/\{[^}]+\}$/.test(operation.path)) {
+ const lifecycle = ["status", "workMode", "executionRunId", "checkoutRunId", "executionState", "executionPolicy", "executionAgentNameKey", "executionLockedAt", "completedAt", "cancelledAt", "startedAt", "assigneeAgentId", "assigneeUserId", "blockedByIssueIds", "reopen", "resume", "interrupt"];
+ if (lifecycle.some((key) => Object.hasOwn(body, key))) throw forbidden("Use dedicated completion, review, delegation or dependency tools for task lifecycle changes");
+ // The real issue router normalizes identifiers and PostgreSQL accepts
+ // uppercase UUIDs. Apply the same identity comparison before dispatch.
+ const target = Object.values(input.pathParams ?? {}).map(id => id.trim().toLowerCase());
+ const activeIds = [context.issueId, context.issueIdentifier].filter((id): id is string => Boolean(id)).map(id => id.toLowerCase());
+ if (operation.method === "DELETE" && target.some(id => activeIds.includes(id))) throw forbidden("The active runner task cannot delete itself");
+ }
+ }
+ const contentType = (input.contentType ?? "application/json").split(";", 1)[0].trim().toLowerCase();
+ if (input.body !== undefined && !input.files?.length) {
+ const requestSchema = operation.requestBody?.content?.[contentType]?.schema as { type?: string } | undefined;
+ if (contentType.includes("json") && requestSchema?.type === "object" && (!input.body || typeof input.body !== "object" || Array.isArray(input.body))) {
+ throw badRequest("This operation requires body to be a JSON object. Pass the object directly, not a JSON-encoded string.");
+ }
+ if (contentType.includes("json") && requestSchema?.type === "array" && !Array.isArray(input.body)) {
+ throw badRequest("This operation requires body to be a JSON array. Pass the array directly, not a JSON-encoded string.");
+ }
+ }
+ return { input, operation };
+}
+
+export function runnerApiUrl(operation: RunnerApiOperation, input: RunnerApiCall, context: RunnerApiContext, apiUrl: string): URL {
+ const origin = new URL(apiUrl);
+ if (!["http:", "https:"].includes(origin.protocol) || origin.username || origin.password || origin.search || origin.hash) throw new Error("Invalid configured Paperclip API origin");
+ const params = { ...input.pathParams };
+ if (operation.path.includes("{companyId}")) params.companyId ??= context.companyId;
+ const names = [...operation.path.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]);
+ for (const name of Object.keys(params)) if (!names.includes(name)) throw badRequest(`Unknown path parameter: ${name}`);
+ const path = operation.path.replace(/\{([^}]+)\}/g, (_, name: string) => {
+ const value = params[name];
+ if (!value || value === "." || value === ".." || /[\\/\x00-\x1f]/.test(value) || /%[0-9a-f]{2}/i.test(value)) throw badRequest(`Invalid or missing path parameter: ${name}`);
+ return encodeURIComponent(value);
+ });
+ const url = new URL(path, origin.origin);
+ if (url.origin !== origin.origin || !url.pathname.startsWith("/api/")) throw badRequest("Invalid API path");
+ for (const [key, value] of Object.entries(input.query ?? {})) {
+ if (value === undefined || value === null) continue;
+ const parameter = operation.parameters.find((entry) => entry.in === "query" && entry.name === key);
+ const values = Array.isArray(value) ? value : [value];
+ if (values.some((entry) => !["string", "number", "boolean"].includes(typeof entry))) throw badRequest(`Query parameter ${key} must contain scalar values`);
+ if (Array.isArray(value) && parameter?.explode === false) url.searchParams.set(key, values.join(","));
+ else for (const entry of values) url.searchParams.append(key, String(entry));
+ }
+ if (url.href.length > 16_384) throw badRequest("API URL exceeds the request limit");
+ return url;
+}
+
+export async function readBoundedResponse(response: Response, maxBytes = RUNNER_API_MAX_BYTES): Promise {
+ if (Number(response.headers.get("content-length")) > maxBytes) {
+ await response.body?.cancel();
+ throw unprocessable("API response exceeds the transfer limit; narrow the request");
+ }
+ const chunks: Uint8Array[] = [];
+ let bytes = 0;
+ const reader = response.body?.getReader();
+ if (!reader) return Buffer.alloc(0);
+ try {
+ while (true) {
+ const next = await reader.read();
+ if (next.done) break;
+ bytes += next.value.byteLength;
+ if (bytes > maxBytes) throw unprocessable("API response exceeds the transfer limit; narrow the request");
+ chunks.push(next.value);
+ }
+ } finally {
+ await reader.cancel().catch(() => {});
+ reader.releaseLock();
+ }
+ return Buffer.concat(chunks);
+}
+
+export async function executeRunnerApi(input: RunnerApiCall, context: RunnerApiContext, io: RunnerApiIo) {
+ const { operation } = validateRunnerApiCall(input, context);
+ const url = runnerApiUrl(operation, input, context, io.apiUrl);
+ if (!io.token) throw new Error("Paperclip run authentication is unavailable");
+ const headers = new Headers({ Authorization: `Bearer ${io.token}`, "X-Paperclip-Run-Id": context.runId });
+ let body: BodyInit | undefined;
+ const contentType = input.contentType ?? (input.files?.length ? "multipart/form-data" : "application/json");
+ if (/\r|\n/.test(contentType)) throw badRequest("Invalid content type");
+ let totalBytes = 0;
+ if (input.files?.length) {
+ if (["GET", "HEAD"].includes(operation.method)) throw badRequest("Read requests cannot upload files");
+ const form = new FormData();
+ if (input.body !== undefined && (!input.body || typeof input.body !== "object" || Array.isArray(input.body))) throw badRequest("Multipart body must be an object of form fields");
+ for (const [key, value] of Object.entries((input.body ?? {}) as Record)) {
+ const text = typeof value === "string" ? value : JSON.stringify(value);
+ totalBytes += Buffer.byteLength(text);
+ form.append(key, text);
+ }
+ for (const file of input.files) {
+ const resolved = await io.readFile(file);
+ totalBytes += resolved.bytes.length;
+ if (totalBytes > RUNNER_API_MAX_BYTES) throw badRequest("API upload exceeds the transfer limit");
+ if (contentType === "multipart/form-data") form.append(file.field ?? "file", new Blob([new Uint8Array(resolved.bytes)], { type: resolved.contentType }), resolved.filename);
+ else {
+ if (input.files.length !== 1 || input.body !== undefined) throw badRequest("Raw uploads require exactly one file and no body");
+ body = new Uint8Array(resolved.bytes);
+ }
+ }
+ if (contentType === "multipart/form-data") body = form;
+ else headers.set("Content-Type", contentType);
+ } else if (input.body !== undefined) {
+ if (["GET", "HEAD"].includes(operation.method)) throw badRequest("Read requests cannot have a body");
+ body = contentType.includes("json") ? JSON.stringify(input.body) : typeof input.body === "string" ? input.body : undefined;
+ if (body === undefined) throw badRequest("Non-JSON request bodies must be strings");
+ if (Buffer.byteLength(body) > RUNNER_API_MAX_BYTES) throw badRequest("API request exceeds the transfer limit");
+ headers.set("Content-Type", contentType);
+ }
+ let response: Response;
+ let bytes: Buffer;
+ await io.beforeDispatch?.();
+ try {
+ response = await (io.fetch ?? fetch)(url, { method: operation.method, headers, body, redirect: "manual", signal: AbortSignal.timeout(RUNNER_API_TIMEOUT_MS) });
+ bytes = await readBoundedResponse(response);
+ } catch {
+ return { ok: false, status: null, operationId: operation.operationId, error: "api_transport_failure", outcome: ["GET", "HEAD", "OPTIONS"].includes(operation.method) ? "read_failed" : "unknown", guidance: "Inspect current state before retrying a mutation; it may already have succeeded." };
+ }
+ const type = response.headers.get("content-type") ?? "application/octet-stream";
+ const mutation = !["GET", "HEAD", "OPTIONS"].includes(operation.method);
+ // A server can commit a write before an error response or redirect. Preserve
+ // the HTTP evidence, but never describe that write as definitely not applied.
+ const uncertain = mutation && (response.status >= 500 || response.status === 408 || (response.status >= 300 && response.status < 400));
+ const uncertainty = { outcome: "unknown", guidance: "Inspect current state before retrying this mutation; it may already have succeeded." };
+ const base = { ok: response.ok, status: response.status, operationId: operation.operationId, contentType: type, retryAfter: response.headers.get("retry-after"), ...(uncertain ? uncertainty : {}) };
+ if (response.status >= 300 && response.status < 400) return { ...base, ok: false, error: "api_redirect_not_followed" };
+ if (!bytes.length) return { ...base, data: null };
+ if (bytes.length > RUNNER_API_INLINE_BYTES || !/json|^text\//i.test(type)) {
+ return { ...base, artifact: await io.saveResponse(bytes, type), byteSize: bytes.length, preview: /json|^text\//i.test(type) ? bytes.subarray(0, 2000).toString("utf8") : null };
+ }
+ const text = bytes.toString("utf8");
+ if (/json/i.test(type)) {
+ try { return { ...base, data: JSON.parse(text) as unknown }; }
+ catch { return { ...base, ok: false, error: "invalid_json_response", data: text, ...(mutation ? uncertainty : {}) }; }
+ }
+ return { ...base, data: text };
+}
diff --git a/server/src/services/native-runtime/runner-api-files.test.ts b/server/src/services/native-runtime/runner-api-files.test.ts
new file mode 100644
index 0000000000..021aa70173
--- /dev/null
+++ b/server/src/services/native-runtime/runner-api-files.test.ts
@@ -0,0 +1,25 @@
+import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { openRunnerApiWorkspaceFile } from "./runner-api-files.js";
+
+describe.skipIf(!["linux", "darwin"].includes(process.platform))("runner API confined workspace uploads", () => {
+ it("reads regular files but rejects a substituted file or ancestor symlink", async () => {
+ const root = await realpath(await mkdtemp(join(tmpdir(), "api-file-open-")));
+ try {
+ await mkdir(join(root, "allowed"));
+ await mkdir(join(root, "outside"));
+ await writeFile(join(root, "allowed", "file.txt"), "allowed");
+ await writeFile(join(root, "outside", "file.txt"), "outside");
+ const handle = await openRunnerApiWorkspaceFile(join(root, "allowed", "file.txt"));
+ try { expect(await handle.readFile("utf8")).toBe("allowed"); } finally { await handle.close(); }
+ await rm(join(root, "allowed", "file.txt"));
+ await symlink(join(root, "outside", "file.txt"), join(root, "allowed", "file.txt"));
+ await expect(openRunnerApiWorkspaceFile(join(root, "allowed", "file.txt"))).rejects.toThrow();
+ await rm(join(root, "allowed"), { recursive: true });
+ await symlink(join(root, "outside"), join(root, "allowed"));
+ await expect(openRunnerApiWorkspaceFile(join(root, "allowed", "file.txt"))).rejects.toThrow();
+ } finally { await rm(root, { recursive: true, force: true }); }
+ });
+});
diff --git a/server/src/services/native-runtime/runner-api-files.ts b/server/src/services/native-runtime/runner-api-files.ts
new file mode 100644
index 0000000000..886d1aff6d
--- /dev/null
+++ b/server/src/services/native-runtime/runner-api-files.ts
@@ -0,0 +1,27 @@
+import { constants } from "node:fs";
+import { open, type FileHandle } from "node:fs/promises";
+import { isAbsolute } from "node:path";
+
+/** Open a previously authorized canonical path without following raced symlinks. */
+export async function openRunnerApiWorkspaceFile(path: string): Promise {
+ if (!isAbsolute(path)) throw new Error("Workspace file must have a canonical absolute path");
+ if (process.platform === "darwin") {
+ // Darwin sys/fcntl.h: O_NOFOLLOW_ANY rejects symlinks at every component.
+ // Node does not expose this flag in fs.constants. Unsupported kernels fail
+ // closed instead of falling back to a pathname check followed by open.
+ return open(path, constants.O_RDONLY | constants.O_NONBLOCK | 0x20000000);
+ }
+ if (process.platform !== "linux") throw new Error("Workspace uploads require a platform with confined file opens; use an authorized artifact reference");
+ const parts = path.split("/").filter(Boolean);
+ if (!parts.length || parts.some(part => part === "." || part === "..")) throw new Error("Invalid canonical workspace path");
+ let directory = await open("/", constants.O_RDONLY | constants.O_DIRECTORY);
+ try {
+ for (const part of parts.slice(0, -1)) {
+ // Linux magic descriptor links provide openat-style directory confinement.
+ const next = await open(`/proc/self/fd/${directory.fd}/${part}`, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
+ await directory.close();
+ directory = next;
+ }
+ return await open(`/proc/self/fd/${directory.fd}/${parts.at(-1)}`, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
+ } finally { await directory.close(); }
+}
diff --git a/server/src/services/native-runtime/runner-api-policy.ts b/server/src/services/native-runtime/runner-api-policy.ts
new file mode 100644
index 0000000000..cf0acccee0
--- /dev/null
+++ b/server/src/services/native-runtime/runner-api-policy.ts
@@ -0,0 +1,32 @@
+/** Ordinary API results and replay receipts are not a secret-value channel. */
+export function runnerApiRestriction(method: string, path: string): string | null {
+ const metadataRead = method === "GET" && [
+ "/api/agents/me/secrets",
+ "/api/companies/{companyId}/secrets/catalog",
+ ].includes(path);
+ if ((/^\/api\/tool-gateway\/sessions(\/|$)/.test(path) && method !== "GET")
+ || (!metadataRead && (/\/(secrets|secret-proposals|secret-provider-configs|user-secrets|user-secret-definitions|keys|board-api-keys|credentials|setup-token-login-sessions|board-claim|invites|join-requests|gateway-tokens|tokens|token|rotate-secret|terminal-session-token|claim-api-key)(\/|$)/.test(path)
+ || /^\/api\/companies\/\{companyId\}\/exports?(\/|$)/.test(path)))) {
+ return "Use the existing credential broker or secure management client: call_api cannot return secret values, manage credentials, or export company credential configuration.";
+ }
+ return ["GET", "HEAD", "OPTIONS"].includes(method) ? null : runnerApiMutationRestriction(path);
+}
+
+/** Runner-owned transitions cannot be reached by the generic HTTP escape hatch. */
+export function runnerApiMutationRestriction(path: string): string | null {
+ const issueRoute = /^\/api\/issues\/\{[^}]+\}/.test(path);
+ const routineAnnotation = /^\/api\/routines\/\{[^}]+\}\/description\/annotations(?:\/\{[^}]+\}(?:\/comments)?)?$/.test(path);
+ if ((!routineAnnotation && /\/(routines|routine-triggers)(\/|$)/.test(path))
+ || /\/(runtime-commands|runtime-services)\/\{action\}$/.test(path)
+ || /\/(?:tool-gateway|tools)\/runtime-slots\/\{[^}]+\}\/(restart|stop)$/.test(path)
+ || /^\/api\/cases\/\{[^}]+\}\/(automation|automations)\//.test(path)
+ || /\/skills\/\{[^}]+\}\/test-runs(\/|$)/.test(path)
+ || /^\/api\/heartbeat-runs\//.test(path)
+ || /^\/api\/agents\/\{[^}]+\}\/(heartbeat|wakeup|pause|resume|terminate|approve|clear-error|runtime-state)(\/|$)/.test(path)
+ || /^\/api\/(approvals|decisions)\/\{[^}]+\}\/(approve|reject|decide|cancel|dismiss|request-revision|resubmit)$/.test(path)
+ || (issueRoute && /\/queued-comments\/\{[^}]+\}\/steer$/.test(path))
+ || (issueRoute && /\/(interactions|accepted-plan-decompositions|stalled-review-decision|tree-holds|watchdog|recovery-actions|scheduled-retry|monitor|admin|checkout|release|cancel|resume|wake|run|retry|recover|tree-control)(\/|$)/.test(path))) {
+ return "Use the dedicated tools and existing clients: call_api cannot bypass runner lifecycle, execution-control or approval authority";
+ }
+ return null;
+}
diff --git a/server/src/services/native-runtime/runner-api-reference.ts b/server/src/services/native-runtime/runner-api-reference.ts
new file mode 100644
index 0000000000..259e384eea
--- /dev/null
+++ b/server/src/services/native-runtime/runner-api-reference.ts
@@ -0,0 +1,689 @@
+// Generated by scripts/generate-runner-api-reference.mjs from the legacy skill reference.
+export const runnerApiReference: Record = {
+ "GET /api/agents/me": {
+ "section": "Agents",
+ "description": "Your agent record + chain of command"
+ },
+ "GET /api/agents/me/inbox/mine?userId={}": {
+ "section": "Agents",
+ "description": "Mine-tab issue list for a specific board user"
+ },
+ "GET /api/agents/{}": {
+ "section": "Agents",
+ "description": "Agent details + chain of command"
+ },
+ "GET /api/companies/{}/agents": {
+ "section": "Agents",
+ "description": "List all agents in company"
+ },
+ "POST /api/companies/{}/agents": {
+ "section": "Agents",
+ "description": "Create agent directly (no approval)"
+ },
+ "PATCH /api/agents/{}": {
+ "section": "Agents",
+ "description": "Update agent config or budget"
+ },
+ "POST /api/agents/{}/pause": {
+ "section": "Agents",
+ "description": "Temporarily stop heartbeats"
+ },
+ "POST /api/agents/{}/resume": {
+ "section": "Agents",
+ "description": "Resume a paused agent"
+ },
+ "POST /api/agents/{}/terminate": {
+ "section": "Agents",
+ "description": "Permanently deactivate agent (irreversible)"
+ },
+ "POST /api/agents/{}/keys": {
+ "section": "Agents",
+ "description": "Create long-lived API key (full value shown once)"
+ },
+ "POST /api/agents/{}/heartbeat/invoke": {
+ "section": "Agents",
+ "description": "Manually trigger a heartbeat"
+ },
+ "GET /api/companies/{}/org": {
+ "section": "Agents",
+ "description": "Org chart tree"
+ },
+ "GET /api/companies/{}/adapters/{}/models": {
+ "section": "Agents",
+ "description": "List selectable models for an adapter type"
+ },
+ "PATCH /api/agents/{}/instructions-path": {
+ "section": "Agents",
+ "description": "Set/clear instructions path (`AGENTS.md`)",
+ "examples": [
+ {
+ "body": {
+ "path": "agents/cmo/AGENTS.md"
+ }
+ },
+ {
+ "body": {
+ "path": "/absolute/path/to/AGENTS.md",
+ "adapterConfigKey": "adapterSpecificPathField"
+ }
+ }
+ ]
+ },
+ "GET /api/agents/{}/config-revisions": {
+ "section": "Agents",
+ "description": "List config revisions"
+ },
+ "POST /api/agents/{}/config-revisions/{}/rollback": {
+ "section": "Agents",
+ "description": "Roll back config"
+ },
+ "GET /api/companies/{}/issues": {
+ "section": "Issues (Tasks)",
+ "description": "List issues, sorted by priority. Filters: `?status=`, `?assigneeAgentId=`, `?assigneeUserId=`, `?projectId=`, `?labelId=`, `?q=` (full-text search across title, identifier, description, comments)"
+ },
+ "GET /api/issues/{}": {
+ "section": "Issues (Tasks)",
+ "description": "Issue details + ancestors"
+ },
+ "GET /api/issues/{}/heartbeat-context": {
+ "section": "Issues (Tasks)",
+ "description": "Compact issue context including `currentExecutionWorkspace` when one is linked"
+ },
+ "GET /api/issues/{}/diagnostics/blockers": {
+ "section": "Issues (Tasks)",
+ "description": "Read-only blocker diagnostic with `diagnosis`, readiness, and bounded anomaly flags"
+ },
+ "GET /api/issues/{}/diagnostics/wakes": {
+ "section": "Issues (Tasks)",
+ "description": "Read-only wake-history diagnostic with `diagnosis`, bounded events, and Case-B inference"
+ },
+ "GET /api/issues/{}/diagnostics/subtree": {
+ "section": "Issues (Tasks)",
+ "description": "Read-only subtree diagnostic combining visible child, blocker, and wake edges with `diagnosis`"
+ },
+ "POST /api/companies/{}/issues": {
+ "section": "Issues (Tasks)",
+ "description": "Create issue (supports `blockedByIssueIds: string[]` for dependencies)"
+ },
+ "PATCH /api/issues/{}": {
+ "section": "Issues (Tasks)",
+ "description": "Update issue; response is authoritative and includes `changes` + `comment` (`Prefer: return=minimal` supported); `blockedByIssueIds` replaces blocker set",
+ "examples": [
+ {
+ "body": {
+ "executionPolicy": {
+ "stages": [
+ {
+ "type": "review",
+ "participants": [
+ {
+ "type": "agent",
+ "agentId": ""
+ }
+ ]
+ }
+ ]
+ }
+ }
+ }
+ ]
+ },
+ "POST /api/issues/{}/checkout": {
+ "section": "Issues (Tasks)",
+ "description": "Atomic checkout (claim + start). Idempotent if you already own it."
+ },
+ "POST /api/issues/{}/release": {
+ "section": "Issues (Tasks)",
+ "description": "Release task ownership"
+ },
+ "GET /api/issues/{}/comments": {
+ "section": "Issues (Tasks)",
+ "description": "List comments"
+ },
+ "GET /api/issues/{}/comments/{}": {
+ "section": "Issues (Tasks)",
+ "description": "Get a specific comment by ID"
+ },
+ "POST /api/issues/{}/comments": {
+ "section": "Issues (Tasks)",
+ "description": "Add comment (@-mentions trigger wakeups)"
+ },
+ "POST /api/issues/{}/inbox-archive": {
+ "section": "Issues (Tasks)",
+ "description": "Archive issue from responsible user's inbox; optional `userId` requires saved target-user opt-in or cross-user grant"
+ },
+ "DELETE /api/issues/{}/inbox-archive": {
+ "section": "Issues (Tasks)",
+ "description": "Reverse inbox archive; same target and policy rules"
+ },
+ "GET /api/issues/{}/interactions": {
+ "section": "Issues (Tasks)",
+ "description": "List issue-thread interactions"
+ },
+ "POST /api/issues/{}/interactions": {
+ "section": "Issues (Tasks)",
+ "description": "Create issue-thread interaction (`suggest_tasks`, `ask_user_questions`, `request_confirmation`, `request_checkbox_confirmation`, `request_item_verdicts`)",
+ "examples": [
+ {
+ "body": {
+ "kind": "request_checkbox_confirmation",
+ "idempotencyKey": "checkbox:{issueId}:cleanup-files:{planRevisionId}",
+ "title": "Confirm files to delete",
+ "summary": "Pick the files you want removed before I run the cleanup.",
+ "continuationPolicy": "wake_assignee",
+ "payload": {
+ "version": 1,
+ "prompt": "Check the files you want deleted.",
+ "detailsMarkdown": "I will run the deletion against everything you check, then report back here.",
+ "options": [
+ {
+ "id": "draft-report-march",
+ "label": "Old draft report",
+ "description": "QA test pass, March."
+ },
+ {
+ "id": "tmp-export-2025",
+ "label": "tmp/export-2025.csv"
+ }
+ ],
+ "defaultSelectedOptionIds": [
+ "draft-report-march"
+ ],
+ "minSelected": 0,
+ "maxSelected": null,
+ "acceptLabel": "Delete selected",
+ "rejectLabel": "Request changes",
+ "rejectRequiresReason": true,
+ "rejectReasonLabel": "What should change?",
+ "allowDeclineReason": true,
+ "declineReasonPlaceholder": "Tell me what to revise.",
+ "supersedeOnUserComment": true,
+ "target": {
+ "type": "issue_document",
+ "issueId": "{issueId}",
+ "key": "plan",
+ "revisionId": "{latestPlanRevisionId}"
+ }
+ }
+ }
+ },
+ {
+ "body": {
+ "kind": "request_item_verdicts",
+ "idempotencyKey": "verdicts:{issueId}:generated-artifacts:{planRevisionId}",
+ "title": "Review generated artifacts",
+ "continuationPolicy": "wake_assignee",
+ "payload": {
+ "version": 1,
+ "prompt": "Review each generated artifact.",
+ "detailsMarkdown": "Approve artifacts that are ready. Reject items that need another pass.",
+ "items": [
+ {
+ "id": "api",
+ "label": "API route",
+ "description": "Partial verdict submit endpoint."
+ },
+ {
+ "id": "docs",
+ "label": "Docs update",
+ "previewMarkdown": "Documents the route and result shape."
+ }
+ ],
+ "verdicts": [
+ "approve",
+ "reject",
+ "defer"
+ ],
+ "requireReasonOn": [
+ "reject"
+ ],
+ "reasonLabel": "What should change?",
+ "allowBulkApprove": true,
+ "supersedeOnUserComment": true,
+ "target": {
+ "type": "issue_document",
+ "issueId": "{issueId}",
+ "key": "plan",
+ "revisionId": "{latestPlanRevisionId}"
+ }
+ }
+ }
+ }
+ ]
+ },
+ "POST /api/issues/{}/interactions/{}/accept": {
+ "section": "Issues (Tasks)",
+ "description": "Accept suggested tasks or confirmation (body: `selectedClientKeys` for `suggest_tasks`; `selectedOptionIds` for `request_checkbox_confirmation`)"
+ },
+ "POST /api/issues/{}/interactions/{}/reject": {
+ "section": "Issues (Tasks)",
+ "description": "Reject suggested tasks or confirmation"
+ },
+ "POST /api/issues/{}/interactions/{}/respond": {
+ "section": "Issues (Tasks)",
+ "description": "Respond to structured questions"
+ },
+ "POST /api/issues/{}/interactions/{}/verdicts": {
+ "section": "Issues (Tasks)",
+ "description": "Submit partial item verdicts for `request_item_verdicts`",
+ "examples": [
+ {
+ "body": {
+ "verdicts": [
+ {
+ "id": "api",
+ "verdict": "approve"
+ },
+ {
+ "id": "docs",
+ "verdict": "reject",
+ "reason": "Needs install instructions."
+ }
+ ]
+ }
+ }
+ ]
+ },
+ "POST /api/issues/{}/interactions/{}/withdraw": {
+ "section": "Issues (Tasks)",
+ "description": "Withdraw any pending interaction; optional `{ \"reason\": string }`; creator agent, current assignee agent, or board user"
+ },
+ "GET /api/issues/{}/documents": {
+ "section": "Issues (Tasks)",
+ "description": "List issue documents"
+ },
+ "GET /api/issues/{}/documents/{}": {
+ "section": "Issues (Tasks)",
+ "description": "Get issue document by key"
+ },
+ "PUT /api/issues/{}/documents/{}": {
+ "section": "Issues (Tasks)",
+ "description": "Create or update issue document (send `baseRevisionId` when updating)"
+ },
+ "GET /api/issues/{}/documents/{}/revisions": {
+ "section": "Issues (Tasks)",
+ "description": "Document revision history"
+ },
+ "DELETE /api/issues/{}/documents/{}": {
+ "section": "Issues (Tasks)",
+ "description": "Delete document (board-only)"
+ },
+ "GET /api/issues/{}/approvals": {
+ "section": "Issues (Tasks)",
+ "description": "List approvals linked to issue"
+ },
+ "POST /api/issues/{}/approvals": {
+ "section": "Issues (Tasks)",
+ "description": "Link approval to issue"
+ },
+ "DELETE /api/issues/{}/approvals/{}": {
+ "section": "Issues (Tasks)",
+ "description": "Unlink approval from issue"
+ },
+ "GET /api/execution-workspaces/{}": {
+ "section": "Issues (Tasks)",
+ "description": "Execution workspace detail including runtime services and service URLs"
+ },
+ "POST /api/execution-workspaces/{}/runtime-services/start": {
+ "section": "Issues (Tasks)",
+ "description": "Start configured workspace services"
+ },
+ "POST /api/execution-workspaces/{}/runtime-services/restart": {
+ "section": "Issues (Tasks)",
+ "description": "Restart configured workspace services"
+ },
+ "POST /api/execution-workspaces/{}/runtime-services/stop": {
+ "section": "Issues (Tasks)",
+ "description": "Stop workspace runtime services"
+ },
+ "GET /api/companies": {
+ "section": "Companies, Projects, Goals",
+ "description": "List all companies"
+ },
+ "POST /api/companies": {
+ "section": "Companies, Projects, Goals",
+ "description": "Create company"
+ },
+ "GET /api/companies/{}": {
+ "section": "Companies, Projects, Goals",
+ "description": "Company details"
+ },
+ "PATCH /api/companies/{}": {
+ "section": "Companies, Projects, Goals",
+ "description": "Update company fields"
+ },
+ "POST /api/companies/{}/logo": {
+ "section": "Companies, Projects, Goals",
+ "description": "Upload company logo (multipart)"
+ },
+ "POST /api/companies/{}/archive": {
+ "section": "Companies, Projects, Goals",
+ "description": "Archive company"
+ },
+ "GET /api/companies/{}/projects": {
+ "section": "Companies, Projects, Goals",
+ "description": "List projects"
+ },
+ "GET /api/projects/{}": {
+ "section": "Companies, Projects, Goals",
+ "description": "Project details"
+ },
+ "POST /api/companies/{}/projects": {
+ "section": "Companies, Projects, Goals",
+ "description": "Create project (optional inline `workspace`)",
+ "examples": [
+ {
+ "body": {
+ "name": "Paperclip Mobile App",
+ "description": "Ship iOS + Android client",
+ "status": "planned",
+ "goalIds": [
+ "{goalId}"
+ ],
+ "workspace": {
+ "name": "paperclip-mobile",
+ "cwd": "/Users/me/paperclip-mobile",
+ "repoUrl": "https://github.com/acme/paperclip-mobile",
+ "repoRef": "main",
+ "isPrimary": true
+ }
+ }
+ },
+ {
+ "body": {
+ "name": "Paperclip Mobile App",
+ "description": "Ship iOS + Android client",
+ "status": "planned"
+ }
+ }
+ ]
+ },
+ "PATCH /api/projects/{}": {
+ "section": "Companies, Projects, Goals",
+ "description": "Update project"
+ },
+ "GET /api/projects/{}/workspaces": {
+ "section": "Companies, Projects, Goals",
+ "description": "List project workspaces"
+ },
+ "POST /api/projects/{}/workspaces": {
+ "section": "Companies, Projects, Goals",
+ "description": "Create project workspace",
+ "examples": [
+ {
+ "body": {
+ "cwd": "/Users/me/paperclip-mobile",
+ "repoUrl": "https://github.com/acme/paperclip-mobile",
+ "repoRef": "main",
+ "isPrimary": true
+ }
+ }
+ ]
+ },
+ "PATCH /api/projects/{}/workspaces/{}": {
+ "section": "Companies, Projects, Goals",
+ "description": "Update project workspace"
+ },
+ "DELETE /api/projects/{}/workspaces/{}": {
+ "section": "Companies, Projects, Goals",
+ "description": "Delete project workspace"
+ },
+ "GET /api/companies/{}/goals": {
+ "section": "Companies, Projects, Goals",
+ "description": "List goals"
+ },
+ "GET /api/goals/{}": {
+ "section": "Companies, Projects, Goals",
+ "description": "Goal details"
+ },
+ "POST /api/companies/{}/goals": {
+ "section": "Companies, Projects, Goals",
+ "description": "Create goal"
+ },
+ "PATCH /api/goals/{}": {
+ "section": "Companies, Projects, Goals",
+ "description": "Update goal"
+ },
+ "POST /api/companies/{}/openclaw/invite-prompt": {
+ "section": "Companies, Projects, Goals",
+ "description": "Generate OpenClaw invite prompt (CEO/board only)"
+ },
+ "GET /api/companies/{}/routines": {
+ "section": "Routines",
+ "description": "List all routines in company"
+ },
+ "GET /api/routines/{}": {
+ "section": "Routines",
+ "description": "Routine details including triggers"
+ },
+ "POST /api/companies/{}/routines": {
+ "section": "Routines",
+ "description": "Create routine (`assigneeAgentId` + `projectId` required; agents: own only)"
+ },
+ "PATCH /api/routines/{}": {
+ "section": "Routines",
+ "description": "Update routine (agents: own only, cannot reassign)"
+ },
+ "POST /api/routines/{}/triggers": {
+ "section": "Routines",
+ "description": "Add trigger (`schedule`, `webhook`, or `api` kind)"
+ },
+ "PATCH /api/routine-triggers/{}": {
+ "section": "Routines",
+ "description": "Update trigger (e.g. disable, change cron)"
+ },
+ "DELETE /api/routine-triggers/{}": {
+ "section": "Routines",
+ "description": "Delete trigger"
+ },
+ "POST /api/routine-triggers/{}/rotate-secret": {
+ "section": "Routines",
+ "description": "Rotate webhook signing secret (previous secret immediately invalidated)"
+ },
+ "POST /api/routines/{}/run": {
+ "section": "Routines",
+ "description": "Manual run (bypasses schedule; concurrency policy still applies)"
+ },
+ "POST /api/routine-triggers/public/{}/fire": {
+ "section": "Routines",
+ "description": "Fire webhook trigger from external system"
+ },
+ "GET /api/routines/{}/runs": {
+ "section": "Routines",
+ "description": "Run history (default 50)"
+ },
+ "GET /api/companies/{}/approvals": {
+ "section": "Approvals, Costs, Activity, Dashboard",
+ "description": "List approvals (`?status=pending`)"
+ },
+ "POST /api/companies/{}/approvals": {
+ "section": "Approvals, Costs, Activity, Dashboard",
+ "description": "Create approval request"
+ },
+ "POST /api/companies/{}/agent-hires": {
+ "section": "Approvals, Costs, Activity, Dashboard",
+ "description": "Create hire request/agent draft",
+ "examples": [
+ {
+ "body": {
+ "name": "Marketing Analyst",
+ "role": "researcher",
+ "reportsTo": "{manager-agent-id}",
+ "capabilities": "Market research, competitor analysis",
+ "budgetMonthlyCents": 5000
+ }
+ }
+ ]
+ },
+ "GET /api/approvals/{}": {
+ "section": "Approvals, Costs, Activity, Dashboard",
+ "description": "Approval details"
+ },
+ "GET /api/approvals/{}/issues": {
+ "section": "Approvals, Costs, Activity, Dashboard",
+ "description": "Issues linked to approval"
+ },
+ "GET /api/approvals/{}/comments": {
+ "section": "Approvals, Costs, Activity, Dashboard",
+ "description": "Approval comments"
+ },
+ "POST /api/approvals/{}/comments": {
+ "section": "Approvals, Costs, Activity, Dashboard",
+ "description": "Add approval comment"
+ },
+ "POST /api/approvals/{}/approve": {
+ "section": "Approvals, Costs, Activity, Dashboard",
+ "description": "Approve approval request"
+ },
+ "POST /api/approvals/{}/reject": {
+ "section": "Approvals, Costs, Activity, Dashboard",
+ "description": "Reject approval request"
+ },
+ "POST /api/approvals/{}/request-revision": {
+ "section": "Approvals, Costs, Activity, Dashboard",
+ "description": "Board asks for revision"
+ },
+ "POST /api/approvals/{}/resubmit": {
+ "section": "Approvals, Costs, Activity, Dashboard",
+ "description": "Resubmit revised approval"
+ },
+ "POST /api/companies/{}/cost-events": {
+ "section": "Approvals, Costs, Activity, Dashboard",
+ "description": "Report cost event"
+ },
+ "GET /api/companies/{}/costs/summary": {
+ "section": "Approvals, Costs, Activity, Dashboard",
+ "description": "Company cost summary"
+ },
+ "GET /api/companies/{}/costs/by-agent": {
+ "section": "Approvals, Costs, Activity, Dashboard",
+ "description": "Costs by agent"
+ },
+ "GET /api/companies/{}/costs/by-project": {
+ "section": "Approvals, Costs, Activity, Dashboard",
+ "description": "Costs by project"
+ },
+ "GET /api/companies/{}/activity": {
+ "section": "Approvals, Costs, Activity, Dashboard",
+ "description": "Activity log"
+ },
+ "GET /api/companies/{}/dashboard": {
+ "section": "Approvals, Costs, Activity, Dashboard",
+ "description": "Company health summary"
+ },
+ "GET /api/companies/{}/secrets": {
+ "section": "Secrets",
+ "description": "List secrets (metadata only)"
+ },
+ "POST /api/companies/{}/secrets": {
+ "section": "Secrets",
+ "description": "Create secret"
+ },
+ "PATCH /api/secrets/{}": {
+ "section": "Secrets",
+ "description": "Update secret value (creates new version)"
+ },
+ "POST /api/agents/me/secret-proposals": {
+ "section": "Secrets",
+ "description": "Propose a secret or agent binding for board approval"
+ },
+ "GET /api/agents/me/secret-proposals": {
+ "section": "Secrets",
+ "description": "List proposals created by the agent and incoming bindings targeting it"
+ },
+ "DELETE /api/agents/me/secret-proposals/{}": {
+ "section": "Secrets",
+ "description": "Withdraw one pending proposal created by the agent"
+ },
+ "GET /api/agents/me/secrets": {
+ "section": "Secrets",
+ "description": "List secrets accessible to the current run (metadata only)"
+ },
+ "POST /api/agents/me/secrets/{}/value": {
+ "section": "Secrets",
+ "description": "Fetch one granted secret value; request body is empty"
+ },
+ "POST /api/companies/company-1/imports/preview": {
+ "section": "Worked example",
+ "examples": [
+ {
+ "body": {
+ "source": {
+ "type": "github",
+ "url": "https://github.com/acme/agent-company"
+ },
+ "include": {
+ "company": true,
+ "agents": true,
+ "projects": true,
+ "issues": true
+ },
+ "target": {
+ "mode": "existing_company",
+ "companyId": "company-1"
+ },
+ "collisionStrategy": "rename"
+ }
+ }
+ ]
+ },
+ "POST /api/companies/company-1/imports/apply": {
+ "section": "Worked example",
+ "examples": [
+ {
+ "body": {
+ "source": {
+ "type": "github",
+ "url": "https://github.com/acme/agent-company"
+ },
+ "include": {
+ "company": true,
+ "agents": true,
+ "projects": true,
+ "issues": false
+ },
+ "target": {
+ "mode": "new_company",
+ "newCompanyName": "Imported Acme"
+ },
+ "collisionStrategy": "rename"
+ }
+ }
+ ]
+ },
+ "POST /api/companies/company-1/exports/preview": {
+ "section": "Worked example",
+ "examples": [
+ {
+ "body": {
+ "include": {
+ "company": true,
+ "agents": true,
+ "projects": true
+ }
+ }
+ }
+ ]
+ },
+ "POST /api/companies/company-1/exports": {
+ "section": "Worked example",
+ "examples": [
+ {
+ "body": {
+ "include": {
+ "company": true,
+ "agents": true,
+ "projects": true,
+ "issues": true
+ },
+ "selectedFiles": [
+ "COMPANY.md",
+ "agents/ceo/AGENTS.md",
+ "skills/paperclip/SKILL.md",
+ "tasks/pap-42/TASK.md"
+ ]
+ }
+ }
+ ]
+ }
+};
diff --git a/server/src/services/native-runtime/runner-api-rollout.test.ts b/server/src/services/native-runtime/runner-api-rollout.test.ts
new file mode 100644
index 0000000000..8372cd9fa8
--- /dev/null
+++ b/server/src/services/native-runtime/runner-api-rollout.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from "vitest";
+import { runnerApiToolsEnabled } from "./runner-api-rollout.js";
+
+describe("runner API rollout", () => {
+ it("requires operator opt-in and fails closed for invalid settings", () => {
+ for (const value of [undefined, "", "TRUE", "1", "invalid"]) {
+ for (const binding of [undefined, false, true]) expect(runnerApiToolsEnabled("company", binding, { PAPERCLIP_RUNNER_API_TOOLS_ENABLED: value })).toBe(false);
+ }
+ expect(runnerApiToolsEnabled("company", undefined, { PAPERCLIP_RUNNER_API_TOOLS_ENABLED: "true" })).toBe(true);
+ });
+ it("restricts an enabled rollout to exact company IDs", () => {
+ const env = { PAPERCLIP_RUNNER_API_TOOLS_ENABLED: "true", PAPERCLIP_RUNNER_API_TOOLS_COMPANY_IDS: " alpha, beta " };
+ expect(runnerApiToolsEnabled("alpha", undefined, env)).toBe(true);
+ expect(runnerApiToolsEnabled("alph", undefined, env)).toBe(false);
+ expect(runnerApiToolsEnabled("foreign", true, env)).toBe(false);
+ expect(runnerApiToolsEnabled("alpha", undefined, { ...env, PAPERCLIP_RUNNER_API_TOOLS_COMPANY_IDS: "" })).toBe(false);
+ });
+ it("keeps baseline tools disabled and honors an operator stop over explicit bindings", () => {
+ expect(runnerApiToolsEnabled("company", false, { PAPERCLIP_RUNNER_API_TOOLS_ENABLED: "true" })).toBe(false);
+ expect(runnerApiToolsEnabled("company", true, {})).toBe(false);
+ expect(runnerApiToolsEnabled("company", true, { PAPERCLIP_RUNNER_API_TOOLS_ENABLED: "false" })).toBe(false);
+ });
+});
diff --git a/server/src/services/native-runtime/runner-api-rollout.ts b/server/src/services/native-runtime/runner-api-rollout.ts
new file mode 100644
index 0000000000..8d3fd94d76
--- /dev/null
+++ b/server/src/services/native-runtime/runner-api-rollout.ts
@@ -0,0 +1,13 @@
+/** Server-owned rollout policy. Never read from agent tool arguments. */
+export function runnerApiToolsEnabled(
+ companyId: string,
+ bindingOverride?: boolean,
+ environment: NodeJS.ProcessEnv = process.env,
+): boolean {
+ const enabled = environment.PAPERCLIP_RUNNER_API_TOOLS_ENABLED;
+ // A binding can only narrow operator permission, never opt into this surface.
+ if (enabled !== "true" || bindingOverride === false) return false;
+ const companies = environment.PAPERCLIP_RUNNER_API_TOOLS_COMPANY_IDS;
+ if (companies === undefined) return true;
+ return companies.split(",").map(value => value.trim()).filter(Boolean).includes(companyId);
+}
diff --git a/server/src/services/native-runtime/runner-api.integration.test.ts b/server/src/services/native-runtime/runner-api.integration.test.ts
new file mode 100644
index 0000000000..9dcecc474e
--- /dev/null
+++ b/server/src/services/native-runtime/runner-api.integration.test.ts
@@ -0,0 +1,269 @@
+import { existsSync } from "node:fs";
+import { randomUUID } from "node:crypto";
+import { chmod, writeFile, symlink } from "node:fs/promises";
+import { join } from "node:path";
+import { eq } from "drizzle-orm";
+import { documents, heartbeatRuns, issues, routineDocuments, routines } from "@paperclipai/db";
+import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
+import { startRunnerApiTestServer } from "../../__tests__/helpers/runner-api-server.js";
+import { createRunnerdCodexTransport, defaultCapabilityRunnerdBinary } from "../../vendor/paperclip-runner/index.js";
+import { runnerApiCatalog } from "./runner-api-catalog.js";
+import { registerRunnerPrpAuthority } from "../../realtime/runner-prp-ws.js";
+
+describe("runner API against real HTTP routes", () => {
+ let server: Awaited>;
+ const oldSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
+ const oldEnabled = process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED;
+ beforeAll(async () => {
+ process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID();
+ process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED = "true";
+ server = await startRunnerApiTestServer();
+ }, 60_000);
+ afterAll(async () => {
+ await server?.close();
+ if (oldSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
+ else process.env.PAPERCLIP_AGENT_JWT_SECRET = oldSecret;
+ if (oldEnabled === undefined) delete process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED;
+ else process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED = oldEnabled;
+ });
+
+ it.skipIf(!process.env.PAPERCLIP_REQUIRE_RUNNER_API_INTEGRATION && !existsSync(defaultCapabilityRunnerdBinary()))("runs runnerd → PRP → authority → actual authenticated HTTP", async () => {
+ const fixture = await server.fixture();
+ const provider = join(server.root, "scripted-api-provider.mjs");
+ await writeFile(provider, `#!${process.execPath}
+import { createInterface } from 'node:readline';
+const send = value => process.stdout.write(JSON.stringify(value)+'\\n');
+let step = 0;
+const calls = [{tool:'search_api',arguments:{query:'list projects'}},{tool:'call_api',arguments:{operationId:'GET /api/companies/{companyId}/projects'}}];
+const next = () => { const c=calls[step++]; if(c) send({id:'call-'+step,method:'item/tool/call',params:{threadId:'api-thread',turnId:'api-turn',itemId:'api-item-'+step,callId:'api-call-'+step,...c}}); else send({method:'turn/completed',params:{turn:{id:'api-turn',status:'completed'}}}); };
+for await (const line of createInterface({input:process.stdin})) {
+const m=JSON.parse(line);
+if(!m.method) {if(String(m.id).startsWith('call-')) next(); continue;}
+if(m.method==='initialize') send({id:m.id,result:{userAgent:'scripted-api-provider'}});
+else if(m.method==='thread/start') send({id:m.id,result:{thread:{id:'api-thread',sessionId:'api-session'}}});
+else if(m.method==='turn/start') {send({id:m.id,result:{turn:{id:'api-turn',status:'inProgress'}}});send({method:'turn/started',params:{turn:{id:'api-turn'}}});next();}
+else if(m.id!==undefined) send({id:m.id,result:{}});
+}
+`);
+ await chmod(provider, 0o700);
+ const bundle = createRunnerdCodexTransport({
+ runnerBinary: defaultCapabilityRunnerdBinary(), codexCommand: provider, codexArgs: [],
+ stateDirectory: join(server.root, "scripted-runner"), lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
+ prpIdentity: { runnerInstanceId: "api-test", environmentLeaseId: "api-test-lease", runId: fixture.runId, normalizedSessionId: "api-test-session", turnId: "api-test-turn", itemId: "api-test-item" },
+ controlPlaneRegistration: prp => registerRunnerPrpAuthority({ companyId: fixture.companyId, runId: fixture.runId, authority: prp }),
+ });
+ const results: any[] = [];
+ bundle.transport.setServerRequestHandler(async request => {
+ const params = request.params as any;
+ const result = await fixture.authority.execute({ tool: params.tool, arguments: params.arguments, callId: params.callId });
+ results.push(result);
+ return { success: true, contentItems: [{ type: "inputText", text: JSON.stringify({ ok: true, result }) }] };
+ });
+ try {
+ await bundle.transport.request("initialize", {});
+ await bundle.transport.request("thread/start", { cwd: fixture.workspace, dynamicTools: await fixture.authority.definitions() });
+ await bundle.transport.request("turn/start", { input: [{ type: "text", text: "Find the project" }] });
+ for await (const notification of bundle.transport.notifications()) if (notification.method === "turn/completed") break;
+ expect(results).toHaveLength(2);
+ expect(results[1]).toMatchObject({ status: 200, data: [{ id: fixture.projectId, name: "Aurora" }] });
+ expect(bundle.evidence().diagnostics).toContain("runnerd authenticated to the durable PRP control plane");
+ } finally { await bundle.transport.close(); }
+ }, 30_000);
+
+ it("cannot opt into API tools through a binding when the operator flag is absent", async () => {
+ const fixture = await server.fixture({ apiToolsEnabled: true });
+ delete process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED;
+ try {
+ const names = (await fixture.authority.definitions()).map(tool => tool.name);
+ expect(names).toContain("get_task_context");
+ expect(names).not.toContain("search_api");
+ expect(names).not.toContain("call_api");
+ await expect(fixture.authority.execute({ tool: "call_api", callId: "disabled", arguments: { operationId: "GET /api/companies/{companyId}/projects" } })).rejects.toThrow("not_advertised");
+ } finally { process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED = "true"; }
+ });
+
+ it("rejects credential calls before any durable receipt or secret result exists", async () => {
+ const fixture = await server.fixture();
+ for (const operationId of ["POST /api/agents/me/secrets/{key}/value", "POST /api/agents/{id}/keys"]) {
+ await expect(fixture.authority.execute({ tool: "call_api", callId: operationId, arguments: { operationId, pathParams: operationId.includes("{key}") ? { key: "EXAMPLE_SECRET" } : { id: fixture.agentId } } })).rejects.toThrow("credential broker");
+ }
+ const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId));
+ expect((run.resultJson as Record | null)?.apiToolReceipts).toBeUndefined();
+ expect((await fixture.snapshot()).activity.filter(row => row.action === "runner.api_called")).toEqual([]);
+ });
+
+ it("rejects issue lifecycle intents before dispatch or receipt creation", async () => {
+ const fixture = await server.fixture();
+ for (const field of ["reopen", "resume", "interrupt"]) {
+ await expect(fixture.authority.execute({ tool: "call_api", callId: field, arguments: { operationId: "PATCH /api/issues/{id}", pathParams: { id: fixture.blockerId }, body: { [field]: true } } })).rejects.toThrow("lifecycle changes");
+ }
+ const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId));
+ expect((run.resultJson as Record | null)?.apiToolReceipts).toBeUndefined();
+ expect((await fixture.snapshot()).issues.find(row => row.id === fixture.blockerId)?.status).toBe("todo");
+ });
+
+ it("supports routine annotation collaboration without changing scheduling", async () => {
+ const fixture = await server.fixture();
+ const [document] = await server.db.select().from(documents).where(eq(documents.companyId, fixture.companyId));
+ const [routine] = await server.db.insert(routines).values({ companyId: fixture.companyId, projectId: fixture.projectId, title: "Review schedule", description: document.latestBody, assigneeAgentId: fixture.agentId, status: "paused" }).returning();
+ await server.db.insert(routineDocuments).values({ companyId: fixture.companyId, routineId: routine.id, documentId: document.id, key: "description" });
+ const exact = "silver-wren", start = document.latestBody.indexOf(exact);
+ const base = "/api/routines/{id}/description/annotations";
+ const call = (callId: string, operationId: string, body: unknown, threadId?: string) => fixture.authority.execute({ tool: "call_api", callId, arguments: { operationId, pathParams: { id: routine.id, ...(threadId ? { threadId } : {}) }, body } }) as Promise;
+ const created = await call("annotation-create", `POST ${base}`, { baseRevisionId: document.latestRevisionId, baseRevisionNumber: document.latestRevisionNumber, selector: { quote: { exact, prefix: document.latestBody.slice(0, start), suffix: document.latestBody.slice(start + exact.length) }, position: { normalizedStart: start, normalizedEnd: start + exact.length, markdownStart: start, markdownEnd: start + exact.length } }, body: "Please clarify this note" });
+ expect(created).toMatchObject({ status: 201, data: { routineId: routine.id, status: "open" } });
+ const threadId = created.data.id;
+ expect(await call("annotation-comment", `POST ${base}/{threadId}/comments`, { body: "Clarified note" }, threadId)).toMatchObject({ status: 201, data: { body: "Clarified note" } });
+ expect(await call("annotation-resolve", `PATCH ${base}/{threadId}`, { status: "resolved" }, threadId)).toMatchObject({ status: 200, data: { status: "resolved" } });
+ expect(await call("annotation-reopen", `PATCH ${base}/{threadId}`, { status: "open" }, threadId)).toMatchObject({ status: 200, data: { status: "open" } });
+ const [unchanged] = await server.db.select().from(routines).where(eq(routines.id, routine.id));
+ expect(unchanged).toMatchObject({ status: "paused", lastTriggeredAt: null, lastEnqueuedAt: null });
+ expect((await fixture.snapshot()).activity).toEqual(expect.arrayContaining([expect.objectContaining({ action: "routine.document_annotation_thread_created", agentId: fixture.agentId, runId: fixture.runId })]));
+ });
+
+ it("rejects every restricted REST mutation before dispatch or durable receipt", async () => {
+ const fixture = await server.fixture();
+ const operations = runnerApiCatalog().filter(operation => operation.transport === "rest" && !["GET", "HEAD", "OPTIONS"].includes(operation.method) && operation.callPolicy === "restricted");
+ expect(operations.length).toBeGreaterThan(5);
+ for (const operation of operations) {
+ await expect(fixture.authority.execute({ tool: "call_api", callId: operation.operationId, arguments: { operationId: operation.operationId } })).rejects.toThrow(/cannot bypass|credential broker/);
+ }
+ const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId));
+ expect((run.resultJson as Record | null)?.apiToolReceipts).toBeUndefined();
+ });
+
+ it("preserves route validation, authorization and audit; replays mutations once", async () => {
+ const fixture = await server.fixture();
+ const call = (callId: string, args: unknown) => fixture.authority.execute({ tool: "call_api", callId, arguments: args });
+ await expect(call("foreign", { operationId: "GET /api/projects/{id}", pathParams: { id: fixture.foreignProjectId } })).resolves.toMatchObject({ ok: false, status: 404 });
+ await expect(call("invalid", { operationId: "POST /api/companies/{companyId}/projects", body: {} })).resolves.toMatchObject({ ok: false, status: 400 });
+ const args = { operationId: "POST /api/companies/{companyId}/projects", body: { name: "Created through HTTP" } };
+ const result = await call("create", args);
+ expect(result).toMatchObject({ status: 201, data: { name: "Created through HTTP" } });
+ expect(await call("create", args)).toEqual(result);
+ await expect(call("create", { ...args, body: { name: "Different" } })).rejects.toThrow("reused");
+ const snapshot = await fixture.snapshot();
+ expect(snapshot.projects.filter(p => p.name === "Created through HTTP")).toHaveLength(1);
+ expect(snapshot.activity).toEqual(expect.arrayContaining([
+ expect.objectContaining({ action: "project.created", agentId: fixture.agentId }),
+ expect.objectContaining({ action: "runner.api_called", agentId: fixture.agentId, runId: fixture.runId, details: expect.objectContaining({ operationId: args.operationId, status: 201 }) }),
+ ]));
+ });
+
+ it("blocks stale bindings and Ask/Plan mutations before HTTP", async () => {
+ for (const mode of ["ask", "planning"] as const) {
+ const fixture = await server.fixture({ mode });
+ await expect(fixture.authority.execute({ tool: "call_api", callId: "read", arguments: { operationId: "GET /api/companies/{companyId}/projects" } })).resolves.toMatchObject({ status: 200 });
+ await expect(fixture.authority.execute({ tool: "call_api", callId: "write", arguments: { operationId: "POST /api/companies/{companyId}/projects", body: { name: "Denied" } } })).rejects.toThrow("only reads");
+ await server.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, fixture.runId));
+ await expect(fixture.authority.execute({ tool: "search_api", callId: "stale", arguments: { query: "projects" } })).rejects.toThrow("binding_not_authorized");
+ expect((await fixture.snapshot()).projects.some(p => p.name === "Denied")).toBe(false);
+ }
+ });
+
+ it("allows API-only options while protecting task completion", async () => {
+ const fixture = await server.fixture();
+ const call = (body: unknown) => fixture.authority.execute({ tool: "call_api", callId: randomUUID(), arguments: { operationId: "PATCH /api/issues/{id}", pathParams: { id: fixture.issueId }, body } });
+ await expect(call({ billingCode: "API-EXTRA" })).resolves.toMatchObject({ status: 200 });
+ await expect(call({ status: "done" })).rejects.toThrow("lifecycle");
+ const [issue] = await server.db.select().from(issues).where(eq(issues.id, fixture.issueId));
+ expect(issue.billingCode).toBe("API-EXTRA");
+ expect(issue.status).toBe("in_progress");
+ for (const id of [fixture.issueId.toUpperCase(), issue.identifier!.toLowerCase(), ` ${issue.identifier!.toLowerCase()} `]) {
+ await expect(fixture.authority.execute({ tool: "call_api", callId: randomUUID(), arguments: { operationId: "DELETE /api/issues/{id}", pathParams: { id } } })).rejects.toThrow("cannot delete itself");
+ }
+ expect((await fixture.snapshot()).issues.some(row => row.id === fixture.issueId)).toBe(true);
+ });
+
+ it("revokes advertised API tools without disabling dedicated operations", async () => {
+ const fixture = await server.fixture();
+ expect(fixture.authority.definitions().some(tool => tool.name === "call_api")).toBe(true);
+ vi.stubEnv("PAPERCLIP_RUNNER_API_TOOLS_ENABLED", "false");
+ try {
+ expect(fixture.authority.definitions().some(tool => tool.name === "search_api")).toBe(false);
+ await expect(fixture.authority.execute({ tool: "call_api", callId: "revoked", arguments: {
+ operationId: "POST /api/companies/{companyId}/projects", body: { name: "Must not exist" },
+ } })).rejects.toThrow("not_advertised");
+ await expect(fixture.authority.execute({ tool: "get_task_context", callId: "dedicated-after-stop", arguments: {} }))
+ .resolves.toMatchObject({ activeTask: { id: fixture.issueId } });
+ expect((await fixture.snapshot()).projects.some(project => project.name === "Must not exist")).toBe(false);
+ } finally { vi.unstubAllEnvs(); }
+ });
+
+ it("retains an uncertain mutation receipt without dispatching it again", async () => {
+ const fixture = await server.fixture();
+ const args = { operationId: "POST /api/companies/{companyId}/projects", body: { name: "Uncertain" } };
+ let rejectNetwork!: (error: Error) => void;
+ const fetcher = vi.spyOn(globalThis, "fetch").mockImplementation(() => new Promise((_resolve, reject) => { rejectNetwork = reject; }));
+ try {
+ const call = () => fixture.authority.execute({ tool: "call_api", callId: "uncertain", arguments: args });
+ const pending = call();
+ await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
+ expect(await call()).toMatchObject({ status: null, outcome: "unknown", error: "api_outcome_unknown" });
+ rejectNetwork(new Error("lost response"));
+ const result = await pending;
+ expect(result).toMatchObject({ status: null, outcome: "unknown" });
+ expect(await call()).toEqual(result);
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ } finally { fetcher.mockRestore(); }
+ expect((await fixture.snapshot()).projects.some(p => p.name === "Uncertain")).toBe(false);
+ });
+
+ it("revalidates the active run after reading an upload", async () => {
+ const fixture = await server.fixture();
+ const original = server.storage.getObject.bind(server.storage);
+ const getObject = vi.spyOn(server.storage, "getObject").mockImplementation(async (...args) => {
+ const object = await original(...args);
+ await server.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, fixture.runId));
+ return object;
+ });
+ try {
+ await expect(fixture.authority.execute({ tool: "call_api", callId: "expired-during-upload", arguments: {
+ operationId: "POST /api/companies/{companyId}/issues/{issueId}/attachments", pathParams: { issueId: fixture.issueId }, files: [{ artifactId: fixture.artifactId }],
+ } })).rejects.toThrow("binding_not_authorized");
+ expect((await fixture.snapshot()).assets).toHaveLength(2);
+ } finally { getObject.mockRestore(); }
+ });
+
+ it("contains workspace files, checks artifact ownership, and persists downloads", async () => {
+ const fixture = await server.fixture();
+ const foreign = await server.fixture();
+ const upload = (files: unknown) => fixture.authority.execute({ tool: "call_api", callId: randomUUID(), arguments: { operationId: "POST /api/companies/{companyId}/issues/{issueId}/attachments", pathParams: { issueId: fixture.issueId }, files } });
+ await expect(upload([{ artifactId: foreign.artifactId }])).rejects.toThrow("not available");
+ await expect(upload([{ path: "../outside.txt" }])).rejects.toThrow();
+ const outside = join(server.root, "outside.txt");
+ await writeFile(outside, "must-not-upload");
+ await symlink(outside, join(fixture.workspace, "escape.txt"));
+ await expect(upload([{ path: "escape.txt" }])).rejects.toThrow();
+ await expect(upload([{ path: "sample.txt" }])).resolves.toMatchObject({ status: 201 });
+ const download = await fixture.authority.execute({ tool: "call_api", callId: "download", arguments: { operationId: "GET /api/assets/{assetId}/content", pathParams: { assetId: fixture.binaryArtifactId } } }) as any;
+ expect(download).toMatchObject({ status: 200, byteSize: 32000, artifact: { byteSize: 32000 } });
+ expect((await fixture.snapshot()).assets).toEqual(expect.arrayContaining([expect.objectContaining({ id: download.artifact.artifactId, companyId: fixture.companyId, createdByAgentId: fixture.agentId })]));
+ });
+
+ it("does not accept caller-supplied identity in an API comment", async () => {
+ const fixture = await server.fixture();
+ const result = await fixture.authority.execute({ tool: "call_api", callId: "comment", arguments: { operationId: "POST /api/issues/{id}/comments", pathParams: { id: fixture.issueId }, body: { body: "Identity proof", authorAgentId: randomUUID(), authorUserId: "spoofed", runId: randomUUID() } } });
+ expect(result).toMatchObject({ status: 201 });
+ expect((await fixture.snapshot()).comments).toEqual(expect.arrayContaining([expect.objectContaining({ body: "Identity proof", authorAgentId: fixture.agentId, authorUserId: null })]));
+ });
+
+ it("creates child tasks after seeding and preserves company numbering", async () => {
+ const fixture = await server.fixture();
+ await expect(fixture.authority.execute({ tool: "create_task", callId: "child", arguments: { title: "Verify release notes", description: "Check before shipping.", assigneeActorId: null, idempotencyKey: "child" } })).resolves.toBeDefined();
+ const children = (await fixture.snapshot()).issues.filter(issue => issue.parentId === fixture.issueId);
+ expect(children).toHaveLength(1);
+ expect(children[0].issueNumber).toBe(3);
+ });
+
+ it("resets fixture data and identities for paired comparisons", async () => {
+ const baseline = await server.fixture({ reset: true, apiToolsEnabled: false });
+ await server.db.update(issues).set({ billingCode: "previous-attempt" }).where(eq(issues.id, baseline.issueId));
+ const treatment = await server.fixture({ reset: true });
+ expect(treatment.companyId).toBe(baseline.companyId);
+ expect(treatment.issueId).toBe(baseline.issueId);
+ expect((await treatment.snapshot()).issues[0].billingCode).toBeNull();
+ const originalTools = (await baseline.authority.definitions()).map(tool => tool.name);
+ expect((await treatment.authority.definitions()).filter(tool => !["call_api", "search_api"].includes(String(tool.name))).map(tool => tool.name)).toEqual(originalTools);
+ });
+});
diff --git a/server/src/services/native-runtime/runner-api.test.ts b/server/src/services/native-runtime/runner-api.test.ts
new file mode 100644
index 0000000000..6cd113753f
--- /dev/null
+++ b/server/src/services/native-runtime/runner-api.test.ts
@@ -0,0 +1,206 @@
+import { describe, expect, it, vi } from "vitest";
+import { runnerApiCatalog, runnerApiOperation, searchRunnerApi } from "./runner-api-catalog.js";
+import { executeRunnerApi, readBoundedResponse, runnerApiUrl, validateRunnerApiCall, type RunnerApiIo } from "./runner-api-client.js";
+
+const context = { companyId: "company-a", issueId: "issue-a", issueIdentifier: "API-1", runId: "run-a", workMode: "standard" };
+const projects = "GET /api/companies/{companyId}/projects";
+const createProject = "POST /api/companies/{companyId}/projects";
+const io = (fetcher: typeof fetch): RunnerApiIo => ({ apiUrl: "http://127.0.0.1:3100", token: "private-agent-token", fetch: fetcher, readFile: async () => ({ bytes: Buffer.from("test"), filename: "proof.txt", contentType: "text/plain" }), saveResponse: async (bytes, contentType) => ({ artifactId: "artifact-a", byteSize: bytes.length, contentType }) });
+
+describe("runner API catalog", () => {
+ it("accounts for unique operations with resolved request contracts", () => {
+ const catalog = runnerApiCatalog();
+ expect(catalog.length).toBeGreaterThan(400);
+ expect(new Set(catalog.map((entry) => entry.operationId)).size).toBe(catalog.length);
+ expect(JSON.stringify(catalog)).not.toContain('"$ref"');
+ expect(runnerApiOperation("GET /api/companies/{companyId}/decisions").authorization.actor).toBe("board");
+ expect(runnerApiOperation("DELETE /api/issues/{id}/documents/{key}").authorization.actor).toBe("board");
+ expect(runnerApiOperation("DELETE /api/issues/{id}/documents/{key}").dedicatedTools).toEqual([]);
+ expect(runnerApiOperation(createProject).requestBody?.content["application/json"].schema.required).toContain("name");
+ });
+ it.each(runnerApiCatalog().filter(operation => operation.transport === "rest"))("resolves the catalog route $operationId inside the bound origin", operation => {
+ const pathParams = Object.fromEntries(operation.parameters.filter(parameter => parameter.in === "path").map(parameter => [parameter.name, parameter.name === "companyId" ? context.companyId : "fixture-id"]));
+ const url = runnerApiUrl(operation, { operationId: operation.operationId, pathParams }, context, "https://paperclip.test");
+ expect(url.origin).toBe("https://paperclip.test");
+ expect(url.pathname).not.toContain("{");
+ expect(operation.responses).toBeDefined();
+ expect(operation.authorization.actor).toBeTruthy();
+ });
+ it("ranks natural language, explains dedicated alternatives, and supports exact lookup", () => {
+ expect(searchRunnerApi({ query: "create project" }).results.map((entry) => entry.operationId)).toContain(createProject);
+ expect(searchRunnerApi({ query: "GET /api/companies/{companyId}/issues" }).results[0].dedicatedTools).toContain("search_tasks");
+ expect(searchRunnerApi({ query: "nothing-zzzzzzzzzz" }).total).toBe(0);
+ });
+ it("paginates without duplicates and rejects stale or mismatched cursors", () => {
+ const first = searchRunnerApi({ query: "project", limit: 1 });
+ const second = searchRunnerApi({ query: "project", limit: 1, cursor: first.nextCursor! });
+ expect(second.results[0].operationId).not.toBe(first.results[0].operationId);
+ expect(() => searchRunnerApi({ query: "agent", cursor: first.nextCursor! })).toThrow("different query");
+ expect(() => searchRunnerApi({ query: "project", limit: 50 })).toThrow();
+ });
+});
+
+describe("runner API request boundary", () => {
+ it.each([
+ "POST /api/execution-workspaces/{id}/runtime-commands/{action}",
+ "POST /api/projects/{id}/workspaces/{workspaceId}/runtime-services/{action}",
+ "POST /api/tool-gateway/runtime-slots/{slotId}/restart",
+ "POST /api/cases/{caseId}/automation/current-stage/rerun",
+ "POST /api/companies/{companyId}/skills/{skillId}/test-runs",
+ "POST /api/tool-gateway/sessions",
+ ])("keeps execution and gateway control %s out of generic dispatch", async operationId => {
+ const request = vi.fn();
+ await expect(executeRunnerApi({ operationId }, context, io(request))).rejects.toThrow(/cannot bypass|credential broker/);
+ expect(request).not.toHaveBeenCalled();
+ });
+ it.each([
+ "POST /api/agents/{id}/claude-login",
+ "POST /api/companies/{companyId}/adapters/{type}/login-sessions",
+ "POST /api/agents/me/connections/{connectionId}/start-authorization",
+ ])("directs authentication handshake %s to its existing client", async operationId => {
+ const request = vi.fn();
+ expect(runnerApiOperation(operationId).transport).toBe("protocol");
+ await expect(executeRunnerApi({ operationId }, context, io(request))).rejects.toThrow("existing protocol client");
+ expect(request).not.toHaveBeenCalled();
+ });
+ it.each(runnerApiCatalog().filter(operation => !["GET", "HEAD", "OPTIONS"].includes(operation.method) && /\/(routines|routine-triggers)(\/|$)/.test(operation.path) && !operation.path.includes("/description/annotations")))("keeps scheduled execution $operationId behind its existing client", async operation => {
+ const request = vi.fn();
+ await expect(executeRunnerApi({ operationId: operation.operationId }, context, io(request))).rejects.toThrow(/cannot bypass|credential broker/);
+ expect(request).not.toHaveBeenCalled();
+ expect(operation.callPolicy).toBe("restricted");
+ });
+ it.each(runnerApiCatalog().filter(operation => !["GET", "HEAD", "OPTIONS"].includes(operation.method) && operation.path.includes("/routines/{id}/description/annotations")))("preserves routine collaboration $operationId", async operation => {
+ const request = vi.fn(async () => Response.json({ id: "thread", status: "open" }));
+ const pathParams = Object.fromEntries(operation.parameters.filter(parameter => parameter.in === "path").map(parameter => [parameter.name, "fixture"]));
+ await expect(executeRunnerApi({ operationId: operation.operationId, pathParams }, context, io(request))).resolves.toMatchObject({ status: 200 });
+ expect(request).toHaveBeenCalledOnce();
+ expect(operation.callPolicy).toBe("rest");
+ });
+ it("keeps routine metadata readable", () => {
+ expect(validateRunnerApiCall({ operationId: "GET /api/companies/{companyId}/routines" }, context).operation.callPolicy).toBe("rest");
+ });
+ it.each(["reopen", "resume", "interrupt"])("cannot hide lifecycle intent %s in an ordinary issue patch", async field => {
+ const request = vi.fn();
+ await expect(executeRunnerApi({ operationId: "PATCH /api/issues/{id}", pathParams: { id: "other-issue" }, body: { [field]: true, billingCode: "safe-extra-field" } }, context, io(request))).rejects.toThrow("lifecycle changes");
+ expect(request).not.toHaveBeenCalled();
+ });
+ it.each([
+ "POST /api/agents/me/secrets/{key}/value",
+ "POST /api/agents/{id}/keys",
+ "DELETE /api/agents/{id}/keys/{keyId}",
+ "POST /api/companies/{companyId}/secret-proposals/{id}/approve",
+ "POST /api/agents/me/secret-proposals",
+ "PATCH /api/secrets/{id}",
+ "POST /api/companies/{companyId}/exports",
+ "GET /api/secret-provider-configs/{id}",
+ ])("keeps sensitive operation %s out of model results and receipts", async operationId => {
+ const request = vi.fn();
+ await expect(executeRunnerApi({ operationId }, context, io(request))).rejects.toThrow("credential broker");
+ expect(request).not.toHaveBeenCalled();
+ expect(searchRunnerApi({ query: operationId }).results[0]).toMatchObject({ callPolicy: "restricted" });
+ });
+ it("retains safe secret metadata discovery", () => {
+ for (const operationId of ["GET /api/agents/me/secrets", "GET /api/companies/{companyId}/secrets/catalog"]) {
+ expect(validateRunnerApiCall({ operationId }, context).operation.callPolicy).toBe("rest");
+ }
+ });
+ it("binds the company and encodes query scalars", () => {
+ const input = { operationId: projects, query: { q: "hello & goodbye", limit: 2, active: false } };
+ const url = runnerApiUrl(runnerApiOperation(projects), input, context, "https://paperclip.test/api");
+ expect(url.origin).toBe("https://paperclip.test");
+ expect(url.pathname).toBe("/api/companies/company-a/projects");
+ expect(url.searchParams.get("q")).toBe("hello & goodbye");
+ });
+ it.each(["../secrets", ".", "..", "%2e%2e", "abc/def", "abc\\def"])("rejects path injection %s", (id) => {
+ const input = { operationId: "GET /api/projects/{id}", pathParams: { id } };
+ expect(() => runnerApiUrl(runnerApiOperation(input.operationId), input, context, "https://paperclip.test")).toThrow();
+ });
+ it("rejects unknown inputs, foreign companies, and mode bypasses", () => {
+ expect(() => validateRunnerApiCall({ operationId: projects, headers: { Authorization: "board" } }, context)).toThrow();
+ expect(() => validateRunnerApiCall({ operationId: projects, pathParams: { companyId: "foreign" } }, context)).toThrow("another company");
+ for (const workMode of ["planning", "ask"]) {
+ expect(() => validateRunnerApiCall({ operationId: createProject, body: { name: "bad" } }, { ...context, workMode })).toThrow("only reads");
+ expect(validateRunnerApiCall({ operationId: projects }, { ...context, workMode }).operation.method).toBe("GET");
+ }
+ });
+ it("retains API-only issue options while guarding lifecycle fields", () => {
+ const input = { operationId: "PATCH /api/issues/{id}", pathParams: { id: context.issueId }, body: { billingCode: "cost-center" } };
+ expect(validateRunnerApiCall(input, context).operation.method).toBe("PATCH");
+ expect(() => validateRunnerApiCall({ ...input, body: { status: "done" } }, context)).toThrow("dedicated");
+ expect(() => validateRunnerApiCall({ operationId: "POST /api/issues/{id}/checkout", pathParams: { id: context.issueId } }, context)).toThrow("lifecycle");
+ });
+ it.each(["issue-a", "ISSUE-A", "API-1", "api-1", " api-1 "])("cannot delete its active task using the route identity alias %s", id => {
+ expect(() => validateRunnerApiCall({ operationId: "DELETE /api/issues/{id}", pathParams: { id } }, context)).toThrow("cannot delete itself");
+ });
+ it("forwards only server-owned authentication and preserves API denials", async () => {
+ const request = vi.fn(async () => Response.json({ error: "Board access required" }, { status: 403 }));
+ const result = await executeRunnerApi({ operationId: projects }, context, io(request));
+ expect(result).toMatchObject({ ok: false, status: 403, data: { error: "Board access required" } });
+ const options = request.mock.calls[0][1]!;
+ expect(new Headers(options.headers).get("Authorization")).toBe("Bearer private-agent-token");
+ expect(new Headers(options.headers).get("X-Paperclip-Run-Id")).toBe(context.runId);
+ expect(options.redirect).toBe("manual");
+ expect(JSON.stringify(result)).not.toContain("private-agent-token");
+ });
+ it("never retries a mutation after a transport failure", async () => {
+ const request = vi.fn(async () => { throw new Error("socket reset"); });
+ expect(await executeRunnerApi({ operationId: createProject, body: { name: "created?" } }, context, io(request))).toMatchObject({ outcome: "unknown", status: null });
+ expect(request).toHaveBeenCalledTimes(1);
+ });
+ it.each([500, 502, 503, 408, 302])("does not claim a mutation was unapplied after HTTP %s", async status => {
+ const request = vi.fn(async () => Response.json({ error: "Request interrupted after possible commit" }, { status }));
+ expect(await executeRunnerApi({ operationId: createProject, body: { name: "Maybe created" } }, context, io(request))).toMatchObject({ status, outcome: "unknown", ok: false });
+ expect(request).toHaveBeenCalledTimes(1);
+ });
+ it("retains uncertainty when a successful mutation returns malformed JSON", async () => {
+ const request = vi.fn(async () => new Response("truncated{", { status: 201, headers: { "content-type": "application/json" } }));
+ expect(await executeRunnerApi({ operationId: createProject, body: { name: "Maybe created" } }, context, io(request))).toMatchObject({ status: 201, outcome: "unknown", error: "invalid_json_response" });
+ });
+ it("rejects a string-encoded object before HTTP and allows a corrected request", async () => {
+ const request = vi.fn(async () => Response.json({ name: "Borealis" }, { status: 201 }));
+ await expect(executeRunnerApi({ operationId: createProject, body: '{"name":"Borealis"}' }, context, io(request))).rejects.toThrow("not a JSON-encoded string");
+ expect(request).not.toHaveBeenCalled();
+ expect(await executeRunnerApi({ operationId: createProject, body: { name: "Borealis" } }, context, io(request))).toMatchObject({ status: 201 });
+ expect(request).toHaveBeenCalledTimes(1);
+ });
+ it("does not follow redirects or pretend empty responses failed", async () => {
+ expect(await executeRunnerApi({ operationId: projects }, context, io(async () => new Response(null, { status: 302, headers: { Location: "https://foreign.test" } })))).toMatchObject({ ok: false, error: "api_redirect_not_followed" });
+ expect(await executeRunnerApi({ operationId: projects }, context, io(async () => new Response(null, { status: 204 })))).toMatchObject({ ok: true, status: 204, data: null });
+ });
+ it("uploads multipart artifacts and returns download references", async () => {
+ const request = vi.fn(async () => new Response("download", { headers: { "content-type": "application/octet-stream" } }));
+ const result = await executeRunnerApi({ operationId: createProject, files: [{ artifactId: "a", field: "package" }], body: { meta: { name: "example" } } }, context, io(request));
+ const body = request.mock.calls[0][1]!.body as FormData;
+ expect(body.get("meta")).toBe('{"name":"example"}');
+ expect(await (body.get("package") as File).text()).toBe("test");
+ expect(new Headers(request.mock.calls[0][1]!.headers).has("Content-Type")).toBe(false);
+ expect(result).toMatchObject({ artifact: { artifactId: "artifact-a", byteSize: 8 }, preview: null });
+ });
+ it("encodes text and raw file bodies without pretending they are JSON", async () => {
+ const request = vi.fn(async () => new Response(null, { status: 204 }));
+ await executeRunnerApi({ operationId: createProject, body: "plain text", contentType: "text/plain" }, context, io(request));
+ expect(request.mock.calls[0][1]?.body).toBe("plain text");
+ await executeRunnerApi({ operationId: createProject, files: [{ path: "sample.bin" }], contentType: "application/octet-stream" }, context, io(request));
+ expect(Buffer.from(request.mock.calls[1][1]?.body as Uint8Array).toString()).toBe("test");
+ await expect(executeRunnerApi({ operationId: createProject, body: "unexpected", files: [{ artifactId: "a" }], contentType: "application/octet-stream" }, context, io(request))).rejects.toThrow();
+ });
+ it("classifies protocols and prevents execution control through alternate routes", () => {
+ const protocols = runnerApiCatalog().filter(operation => operation.transport === "protocol");
+ expect(protocols.some(operation => operation.path.endsWith("/events/ws"))).toBe(true);
+ for (const operation of protocols) expect(() => validateRunnerApiCall({ operationId: operation.operationId }, context)).toThrow("protocol client");
+ for (const operationId of ["POST /api/issues/{id}/tree-holds", "POST /api/issues/{id}/stalled-review-decision", "POST /api/agents/{id}/runtime-state/reset-session", "POST /api/approvals/{id}/resubmit"]) {
+ expect(() => validateRunnerApiCall({ operationId, pathParams: { id: "fixture" } }, context)).toThrow("cannot bypass");
+ }
+ });
+ it("keeps skill-test mode consistent with the advertised tool contract", () => {
+ expect(validateRunnerApiCall({ operationId: createProject, body: { name: "Skill fixture" } }, { ...context, workMode: "skill_test" }).operation.method).toBe("POST");
+ });
+ it("cannot hide lifecycle mutations in a raw uploaded JSON body", () => {
+ for (const operationId of ["PATCH /api/issues/{id}", "PATCH /api/agents/{id}", "POST /api/issues/{id}/comments"]) {
+ expect(() => validateRunnerApiCall({ operationId, pathParams: { id: "fixture" }, files: [{ path: "hidden-status.json" }], contentType: "application/json" }, context)).toThrow("inline JSON object");
+ }
+ });
+ it("bounds streamed responses even without content-length", async () => {
+ await expect(readBoundedResponse(new Response("too large"), 3)).rejects.toThrow("transfer limit");
+ });
+});