From fc3b15537b00f7cd50329926e24ea0c20487fad5 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:34:15 -0500 Subject: [PATCH] fix(runner): match call_api to existing agent REST permissions (#16040) Route native call_api through existing agent REST permissions. Preserve admitted credentials across restarts, protect sensitive replies and private snapshots, and compare saved completion with provider history through the same redaction projection. Add permission, privacy and recovery regressions and update the access map and run-log guide. Co-Authored-By: Paperclip --- doc/run-log-events.md | 27 +- doc/runner-api-tools.md | 72 ++-- maps/README.md | 7 +- maps/agent-access-map/README.md | 40 ++ maps/agent-access-map/access.md | 8 +- maps/agent-access-map/agents.md | 12 +- maps/agent-access-map/chat-channels.md | 8 +- maps/agent-access-map/companies.md | 12 +- maps/agent-access-map/gateways-profiles.md | 17 +- maps/agent-access-map/pipelines.md | 9 +- maps/agent-access-map/recovery.md | 23 +- maps/agent-access-map/routines.md | 35 +- maps/agent-access-map/runs-adapters.md | 17 +- maps/agent-access-map/secrets.md | 29 +- maps/agent-access-map/skills.md | 13 +- maps/agent-access-map/steering.md | 8 +- maps/agent-access-map/task-coordination.md | 11 +- maps/agent-access-map/tasks.md | 23 +- maps/agent-access-map/workspaces.md | 13 +- .../capability/semantic-tool-contracts.json | 2 +- .../generated/semantic-action-catalog.json | 2 +- .../evals/native-execution-seeded.json | 2 +- .../paperclip-runner/protocol/manifest.json | 2 +- .../src/backends/harness-driver-backend.ts | 1 + .../src/contracts/control-plane-port.ts | 1 + .../src/contracts/harness-driver.ts | 1 + .../src/contracts/native-session-backend.ts | 1 + .../codex/codex-app-server-driver-impl.ts | 2 + .../src/drivers/codex/codex-session-state.ts | 4 + .../drivers/codex/codex-session-terminal.ts | 21 +- .../src/native-session-runtime.test.ts | 53 +++ .../src/native-session-runtime.ts | 4 +- .../src/protocol-actions/call-api.ts | 4 +- .../src/__tests__/company-portability.test.ts | 56 ++- .../__tests__/heartbeat-task-context.test.ts | 2 + .../src/__tests__/runner-api-recovery.test.ts | 42 ++ server/src/routes/assets.ts | 41 +- server/src/services/asset-content-access.ts | 59 +++ server/src/services/companies.ts | 6 +- server/src/services/company-portability.ts | 9 +- server/src/services/heartbeat.ts | 2 +- server/src/services/heartbeat/task-context.ts | 11 + ...nfirmation-comment-api.integration.test.ts | 12 +- .../paperclip-control-plane-port.test.ts | 47 +++ .../paperclip-control-plane-port.ts | 10 +- .../paperclip-runner-tool-authority.ts | 53 ++- .../native-runtime/runner-api-catalog.ts | 14 +- .../native-runtime/runner-api-client.ts | 41 +- .../runner-api-log-redaction.test.ts | 35 ++ .../runner-api-log-redaction.ts | 40 ++ .../native-runtime/runner-api-policy.ts | 66 ---- .../runner-api-sensitive-response.ts | 8 + .../runner-api.integration.test.ts | 359 ++++++++++++++++-- .../native-runtime/runner-api.test.ts | 223 ++++------- 54 files changed, 1118 insertions(+), 502 deletions(-) create mode 100644 server/src/__tests__/runner-api-recovery.test.ts create mode 100644 server/src/services/asset-content-access.ts create mode 100644 server/src/services/native-runtime/runner-api-log-redaction.test.ts create mode 100644 server/src/services/native-runtime/runner-api-log-redaction.ts delete mode 100644 server/src/services/native-runtime/runner-api-policy.ts create mode 100644 server/src/services/native-runtime/runner-api-sensitive-response.ts diff --git a/doc/run-log-events.md b/doc/run-log-events.md index 7c9080d96c6..28472e42ccf 100644 --- a/doc/run-log-events.md +++ b/doc/run-log-events.md @@ -16,15 +16,38 @@ a system warning in the task conversation. It remains local run-log data. The hidden native coordinator writes each validated PRP event to the bound run's existing event stream before it acknowledges the runner. The row keeps the PRP `eventType`, source instance, source event ID, source sequence, protocol -schema version, and a SHA-256 digest of the canonical source envelope. Its +schema version, and a SHA-256 digest of the canonical stored envelope. Its payload is `{ "prpEvent": }`. +Native API tool events omit HTTP request and response bodies from durable history. +This includes serialized provider `contentItems`, legacy HTTP receipts and +`call_api` arguments. Saved receipts retain the operation and call IDs, success +flag, HTTP status and content type when present. The body becomes +`***REDACTED***`, with `apiPayloadRedacted: true` and `apiPayloadSha256`, a SHA-256 +digest of the original payload. These digests distinguish duplicate events from +changed payloads; they cannot recover omitted bodies for debugging or replay. +The model receives the original authorized response. Credential-bearing mutation +receipts use encrypted storage separately from this run log. + +Checkpoints and completion records apply the same redaction. Completion retries +compare stored and expected payloads through the control-plane port's idempotent +`normalizeReplayPayload` projection, so a saved redacted result can be confirmed +without restoring API bodies. Ports without a projection compare their original +payloads. Repeated redaction preserves the original digest and detects changed +private payloads. + +The native runtime passes this projection through backend recovery to the Codex +driver. Recovery compares saved semantic results and turn fingerprints with +provider history through the same representation. A private body change retains +its distinct digest and fails recovery; an unchanged body does not require +restoring its plaintext into the checkpoint. + PostgreSQL JSONB cannot represent NUL (U+0000), which can occur in command output such as Vite virtual-module paths. The run-event payload column uses a lossless storage codec for these events: the JSONB projection renders NUL as the literal `\u0000`, and the reserved `$paperclipRunEventJsonV1` field contains the original serialized JSON as a doubly escaped string. Ordinary payloads -retain their existing representation. Drizzle reads restore the exact original +retain their existing representation. Drizzle reads restore the exact stored payload before replay, hash validation, redaction, or API presentation. SQL queries can still inspect ordinary routing fields in the projection; raw SQL readers of the whole payload must apply `decodeRunEventPayload`. The column diff --git a/doc/runner-api-tools.md b/doc/runner-api-tools.md index a8dd849d49e..9ad9cd7acac 100644 --- a/doc/runner-api-tools.md +++ b/doc/runner-api-tools.md @@ -3,7 +3,7 @@ `search_api` and `call_api` extend the native runner when an available dedicated operation cannot express the requested work. Use `set_task_monitor` to schedule or clear a one-shot task check; after confirming the receipt, finish with `yielded` and `continuation.kind: "monitor"` to await that -task’s timer. `call_api` still rejects monitor/execution-policy lifecycle writes. +task’s timer. The monitor workflow remains preferred; REST calls use the same route authorization as legacy agents. 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 @@ -28,7 +28,7 @@ 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. -All company, run, work-mode, credential, and lifecycle checks below still apply. +All company, run, work-mode, credential, and route authorization checks below still apply. ## Discovery and requests @@ -64,7 +64,10 @@ inspect its state rather than retrying it to obtain a smaller response. Connection setup and stalled response reads time out after 30 seconds. An active capture has a 10-minute total download deadline. Responses above 24 KiB stream -into a private temporary file, then into a company-owned asset. Memory stays +into a private temporary file, then into a company-owned asset scoped to the +issuing agent and responsible user (plus authorized board administrators). +Ordinary task or run visibility does not grant access to another actor’s API +snapshot. Explicit deliverables use the existing attachment/work-product flow. Memory stays bounded by the inline prefix and stream buffers. Binary responses also become assets; text previews are limited to 2,000 bytes. @@ -163,23 +166,25 @@ failed to return a valid response. 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 +audit behavior. Skill-test runs retain the same task-scoped `skill_test` JWT +as legacy REST runs, including after later edits to the task mode. 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. +`call_api` is an authenticated REST transport, with the same endpoint and payload +permissions as a legacy agent using REST. It does not maintain a second denylist +for routines, runtime services, skill tests, recovery, invitations, secrets, +exports, lifecycle fields or approval routes. Existing HTTP routes enforce their +company/resource scope, ownership, grants, redaction, run requirements and board +or approval gates. A board-only route still denies an agent call. Dedicated tools +remain preferred for common workflows, including native completion and monitors. + +Ask and pre-acceptance Plan retain their existing read restrictions and specific +mutation exceptions (task titles and conversational confirmation recording). +Protocol endpoints still require their existing clients. Active-run validation, +server-owned credentials, file containment, transfer bounds and replay handling +also remain in place. REST parity does not grant board identity or new permissions. 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 @@ -190,13 +195,34 @@ 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 +Artifacts must belong to the bound company and pass the same actor access checks +as downloads, before and after reading their bytes. Private API response snapshots +cannot be used as public company logos or bundled into company exports. An owner +can explicitly publish a copy through the existing attachment upload path. +Request bodies and uploads go +through the route's validation and authorization; the fallback does not reject +fields that an agent can supply through REST. Route-authorized responses normally use the bounded inline/artifact capture and +replay mechanism. Credential-bearing responses (secret values, issued keys, +invites, webhook secrets (including restored triggers) and gateway credentials) stay inline, with a 10 MiB +response bound. They never spill into plaintext files or saved response assets. +Their durable mutation receipts encrypt the response with the existing local +secret provider. Replays decrypt it only inside the authorized run; no HTTP +request repeats. A working secret store is checked before credential dispatch. +Other API responses retain the existing 24 KiB inline and 1 GiB capture limits. + +API request and response bodies are omitted from persisted native tool events, +checkpoints and completion results. Saved events retain operation identity, HTTP +status and a payload digest for replay conflict detection. The model still receives +the original authorized HTTP response; this redaction affects durable history. +Completion retries compare expected and persisted events through the same +idempotent redaction projection. Repeated projection preserves the original +payload digest. Provider recovery uses the same projection to compare saved +completion results with provider history. Changed private bodies still conflict. +Restart preparation preserves the run's admitted REST credential scope, even +when the task mode changes. See [the run-log guide](run-log-events.md) for stored fields. + +The server-issued REST credential for the bound run is never returned to the model. +Streaming, WebSocket, MCP and authentication handshakes are documented as protocol operations requiring their existing clients. ## Catalog maintenance diff --git a/maps/README.md b/maps/README.md index 5946460d2d4..d747280d101 100644 --- a/maps/README.md +++ b/maps/README.md @@ -90,14 +90,15 @@ Review: `operation-reviewed`. ### `create` API: `present` — POST /api/companies/{companyId}/routines. -Agent access: `partial` — agent REST and CLI routine create; default native -mutation binding missing, and call_api blocks routine mutations. +Agent access: `available` — agent REST, CLI routine create and native call_api +under the enabled rollout and active-run/mode gates. Policy: same company; assigneeAgentId must be the calling agent; cite the routine authorization and native API policy sources. Evidence: `source-only` — reviewed commit and source links; no live proof. Verification: `not-run` — create an owned routine; reject assignment to another agent; inspect saved configuration, attribution and retry behavior. -Gaps: default native create path; verify using the real permitted agent context. +Gaps: verify ownership and responsible-user resolution with a permitted agent +context; source presence alone does not qualify this operation. ``` ## Keeping the maps synchronized diff --git a/maps/agent-access-map/README.md b/maps/agent-access-map/README.md index 9b8a94ab47e..b8d6986494d 100644 --- a/maps/agent-access-map/README.md +++ b/maps/agent-access-map/README.md @@ -67,3 +67,43 @@ Use `verify-paperclip` and existing API/runner/MCP fixtures to collect the permitted-agent and denial evidence; report blocked/not-run coverage honestly. Run `node scripts/check-maps.mjs` after editing. The structural check does not certify permissions, API catalog coverage, tool admission or live behavior. + +## Native REST transport + +The `call_api` parity change reconciles affected baselines against +`58e16dbddfc9c8d0cf60867f9d9847f0feda055b` and the changes in this PR. Ordinary +REST operations use the same route authorization as legacy agent REST. Dedicated +tools remain optional for those operations; protocol endpoints still need their +existing clients. Board-only routes remain restricted for agent identity. + +Native `search_api` / `call_api` requires operator rollout admission, a running +authorized native run, current task assignment and execution-run binding, company +and budget authority, task privacy, and a permitted work mode. Ask and Plan retain +mutation restrictions, and runs retain their admitted JWT scope during restart +recovery even when task mode changes. A waiting +agent does not gain independent native control just because a REST route exists. + +Credential-bearing replies, including company exports and their legacy alias, +stay inline under a 10 MiB limit and use encrypted mutation receipts. Other saved +API snapshots are limited to the issuing actor and responsible user, plus authorized +administrators. Artifact uploads enforce the same access checks as downloads; +private snapshots are excluded from company exports and public company logos. +Explicit deliverables use the existing sharing path. Native event history, +checkpoints and completion records omit API bodies while preserving receipt +metadata and replay digests. Restored webhook secrets use the same encrypted +receipt and inline response policy as newly created webhook secrets. +Task image access requires the company boundary and the task permission; +skill-test and low-trust agents do not need company-wide browsing authority. +Completion replay applies the same idempotent projection to stored and expected +payloads; [the run-log guide](../../doc/run-log-events.md) describes its fields. + +Sources: [API client](../../server/src/services/native-runtime/runner-api-client.ts), +[catalog](../../server/src/services/native-runtime/runner-api-catalog.ts), +[native authority](../../server/src/services/native-runtime/paperclip-runner-tool-authority.ts), +[credential storage classification](../../server/src/services/native-runtime/runner-api-sensitive-response.ts), +[snapshot access](../../server/src/services/asset-content-access.ts), +and [history redaction](../../server/src/services/native-runtime/runner-api-log-redaction.ts). The affected entries remain +`baseline` and `source-only`; this reconciliation does not certify every operation. +Targeted real-route proof lives in the +[API integration suite](../../server/src/services/native-runtime/runner-api.integration.test.ts), +including owned routines, skill tests, scoped secrets, proposals and private export replay. diff --git a/maps/agent-access-map/access.md b/maps/agent-access-map/access.md index 4382be3416c..c88ce491678 100644 --- a/maps/agent-access-map/access.md +++ b/maps/agent-access-map/access.md @@ -2,7 +2,9 @@ Product map: [Login, invitations, and access](../feature-map/access.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -43,8 +45,8 @@ API: `present` — representative operation IDs: - `POST /api/companies/{companyId}/invites`. - `POST /api/invites/{token}/accept`. -Agent access: `partial` — REST with invite permission; CLI access; native call_api -blocks invite credentials. +Agent access: `partial` — REST / native `call_api` invite creation with the invite +permission; CLI access. Human acceptance and agent onboarding have distinct policies. Policy: Company access plus action-specific grants; instance administration requires a board administrator. diff --git a/maps/agent-access-map/agents.md b/maps/agent-access-map/agents.md index 46c3ee462a2..decd1fbe6d0 100644 --- a/maps/agent-access-map/agents.md +++ b/maps/agent-access-map/agents.md @@ -2,7 +2,9 @@ Product map: [Hiring, agent configuration, and organization](../feature-map/agents.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -97,8 +99,8 @@ API: `present` — representative operation IDs: - `POST /api/agents/{id}/resume`. - `POST /api/agents/{id}/terminate`. -Agent access: `partial` — REST resume under change grant; pause/terminate require board; -native call_api blocks all three. +Agent access: `partial` — REST / native `call_api` resume under agent_config:update +change-grant authorization; pause and termination remain board-only. Policy: Agent creation/configuration uses action grants, resource policies and change consent; some controls require a board actor. @@ -111,8 +113,8 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Resume is agent-eligible but lacks a default native binding. Pause and termination -are actor-boundary decisions. +Gaps: Resume has a generic native REST path. Pause and termination remain +enforced actor restrictions. ## `built-in` diff --git a/maps/agent-access-map/chat-channels.md b/maps/agent-access-map/chat-channels.md index 70ad018cb28..58fe31ce533 100644 --- a/maps/agent-access-map/chat-channels.md +++ b/maps/agent-access-map/chat-channels.md @@ -2,7 +2,9 @@ Product map: [External chat and email conversations](../feature-map/chat-channels.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -53,8 +55,8 @@ Verification: `not-run` — assert the documented agent denial with an owned tes verify any listed agent request/result path separately. A delegated operation needs its own policy and access review. -Gaps: No impersonation path; native call_api additionally blocks identity-link -operations. +Gaps: Identity linking requires a signed-in board user, rollout admission and +provider checks at the HTTP route. Native `call_api` preserves these permissions. ## `conversation` diff --git a/maps/agent-access-map/companies.md b/maps/agent-access-map/companies.md index 0366a4cbf4e..85c41cefb79 100644 --- a/maps/agent-access-map/companies.md +++ b/maps/agent-access-map/companies.md @@ -2,7 +2,9 @@ Product map: [Companies and portability](../feature-map/companies.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -97,8 +99,8 @@ API: `present` — representative operation IDs: - `POST /api/companies/{companyId}/imports/preview`. - `POST /api/companies/{companyId}/imports/apply`. -Agent access: `partial` — CEO REST / CLI company; native export restricted, eligible -same-company import uses fallback. +Agent access: `partial` — Same-company CEO REST / native `call_api` / CLI exports +and eligible same-company imports. Policy: Same-company CEO or board for identity/import operations; list/create/archive convenience endpoint is board-gated. @@ -111,5 +113,5 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Secure exports lack a default native binding; new-company/ZIP import flows have -different board and transport constraints. +Gaps: Exports use inline credential delivery and encrypted mutation receipts. +New-company/ZIP import flows retain their board and transport constraints. diff --git a/maps/agent-access-map/gateways-profiles.md b/maps/agent-access-map/gateways-profiles.md index de326b65acf..7ab04395a01 100644 --- a/maps/agent-access-map/gateways-profiles.md +++ b/maps/agent-access-map/gateways-profiles.md @@ -2,7 +2,9 @@ Product map: [Tool gateways and access profiles](../feature-map/gateways-profiles.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -65,8 +67,8 @@ API: `present` — representative operation IDs: - `GET /api/tool-gateway/gateways/{gatewayId}/mcp`. - `POST /api/tool-gateway/sessions`. -Agent access: `partial` — Existing scoped MCP client/session flow; assigned native app -tools. +Agent access: `partial` — Agent REST / native `call_api` for scoped session creation +and revocation; existing MCP client protocol and assigned native app tools. Policy: Named gateway/profile administration and credentials are board tools:admin; agent sessions/tool use use separate scoped authority. @@ -79,8 +81,8 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Agent tool consumption exists. Copying/managing the endpoint and its client -credentials remain management operations. +Gaps: Scoped sessions remain agent/run-bound. MCP requires its existing protocol +client; named gateways and gateway-token management retain board permissions. ## `tokens` @@ -103,8 +105,9 @@ Verification: `not-run` — assert the documented agent denial with an owned tes verify any listed agent request/result path separately. A delegated operation needs its own policy and access review. -Gaps: Native call_api blocks credential management; a grant to use an app does not -create gateway-token authority. +Gaps: Gateway-token management requires board tools:admin at the HTTP route. +A grant to use an app does not create this authority; native `call_api` preserves +the denial. ## `activity` diff --git a/maps/agent-access-map/pipelines.md b/maps/agent-access-map/pipelines.md index b33ca8ce805..a9fea855343 100644 --- a/maps/agent-access-map/pipelines.md +++ b/maps/agent-access-map/pipelines.md @@ -2,7 +2,9 @@ Product map: [Pipelines, review queues, and learnings](../feature-map/pipelines.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -31,8 +33,9 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Configuration paths exist. Generic fallback blocks case automation execution -paths; do not conflate configuration and dispatch. +Gaps: Configuration and case-automation retry/rerun have native `call_api` REST +paths under company, active-agent-run, case and target-pipeline write checks. The +route and service retain retry/rerun eligibility and dispatch policy. ## `items` diff --git a/maps/agent-access-map/recovery.md b/maps/agent-access-map/recovery.md index db98fb8ef76..1b90fa083d4 100644 --- a/maps/agent-access-map/recovery.md +++ b/maps/agent-access-map/recovery.md @@ -2,7 +2,9 @@ Product map: [Recovery](../feature-map/recovery.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -42,7 +44,8 @@ API: `present` — representative operation IDs: - `POST /api/issues/{id}/recovery-actions/resolve`. -Agent access: `partial` — Eligible agent REST / CLI; native generic mutation restricted. +Agent access: `partial` — Eligible agent REST / native `call_api` / CLI under +source-bound recovery authority. Policy: Task/run visibility, run ownership and source/action policy; workspace export retry and some operator controls require board. @@ -55,8 +58,9 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Underlying route has agent checks; no corresponding default native recovery -mutation tool is advertised. +Gaps: Eligible resolutions use native `call_api`. The route retains owner and +management authority, active-run/source-trust checks, board-only outcomes and +reconciliation controls. ## `source-action` @@ -67,7 +71,8 @@ API: `present` — representative operation IDs: - `GET /api/issues/{id}/recovery-actions`. - `POST /api/issues/{id}/recovery-actions/resolve`. -Agent access: `partial` — Eligible agent REST / CLI; source-bound recovery state. +Agent access: `partial` — Eligible agent REST / native `call_api` / CLI; source-bound +recovery state. Policy: Task/run visibility, run ownership and source/action policy; workspace export retry and some operator controls require board. Completion or cancellation of a @@ -89,8 +94,8 @@ physical copyback, and cleanup after the receipt is cleared. Exercise this sequence in a disposable instance; automatic cleanup must use the system actor and must not create reader participation. No live recovery proof is claimed. -Gaps: Need operation-level eligibility and a real native binding for permitted recovery -actions; do not route around lifecycle authority. +Gaps: Permitted resolutions use native `call_api` under existing eligibility +and ownership checks. Source/action policy determines whether continuation is allowed. ## `exhaustion` @@ -139,8 +144,8 @@ Verification: `not-run` — assert the documented agent denial with an owned tes verify any listed agent request/result path separately. A delegated operation needs its own policy and access review. -Gaps: Export retry/reconciliation APIs exist but require board. Native generic -execution-control mutations are also blocked. +Gaps: Export retry and branch reconciliation require board runtime:manage at +their HTTP routes. Native `call_api` preserves these denials. ## `unknown-outcome` diff --git a/maps/agent-access-map/routines.md b/maps/agent-access-map/routines.md index bb3ec03599a..8d0bb4ea889 100644 --- a/maps/agent-access-map/routines.md +++ b/maps/agent-access-map/routines.md @@ -2,7 +2,9 @@ Product map: [Routines, schedules, and triggers](../feature-map/routines.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -18,8 +20,8 @@ API: `present` — representative operation IDs: - `POST /api/companies/{companyId}/routines`. - `PATCH /api/routines/{id}`. -Agent access: `partial` — Agent REST / CLI routine create/update; optional installed MCP -generic request. +Agent access: `available` — Agent REST / native `call_api` / CLI routine create/update; +optional installed MCP generic request. Policy: Agents may read company routines but only create/manage routines assigned to themselves; trigger secrets need secure handling. @@ -32,8 +34,9 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Native call_api rejects routine mutation; manage_routine is scenario-only/unbound. -Agent REST is implemented. +Gaps: No dedicated production manage_routine binding; generic `call_api` provides +the permitted REST path. Creation needs a responsible user resolved from the parent +issue or company default. ## `triggers` @@ -44,7 +47,8 @@ API: `present` — representative operation IDs: - `POST /api/routines/{id}/triggers`. - `PATCH /api/routine-triggers/{id}`. -Agent access: `partial` — Owned-routine agent REST / CLI routine trigger commands. +Agent access: `available` — Owned-routine agent REST / native `call_api` / CLI trigger +commands. Policy: Agents may read company routines but only create/manage routines assigned to themselves; trigger secrets need secure handling. @@ -57,8 +61,9 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: No production default native trigger-management binding. Webhook creation/rotation -also returns credential material. +Gaps: No dedicated production trigger-management binding; generic `call_api` +provides the permitted REST path. Credential replies use inline delivery and encrypted +mutation receipts rather than shared response assets. ## `run-now` @@ -68,7 +73,8 @@ API: `present` — representative operation IDs: - `POST /api/routines/{id}/run`. -Agent access: `partial` — Owned-routine agent REST / CLI routine run. +Agent access: `partial` — Owned-routine agent REST / CLI run; native `call_api` from +an authorized active run. Policy: Agents may read company routines but only create/manage routines assigned to themselves; trigger secrets need secure handling. @@ -81,8 +87,8 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: A scheduled/waiting native agent cannot use call_api for this eligible operation; -a real control binding is missing. +Gaps: Scheduled/waiting native agents cannot independently call this operation: +native authority requires a running bound run. A separate control binding remains absent. ## `history` @@ -117,8 +123,7 @@ API: `present` — representative operation IDs: - `PATCH /api/routines/{id}`. -Agent access: `partial` — Owned-routine agent REST / CLI; normal native fallback can -manage separate folder metadata where permitted. +Agent access: `available` — Owned-routine agent REST / native `call_api` / CLI. Policy: Agents may read company routines but only create/manage routines assigned to themselves; trigger secrets need secure handling. @@ -131,5 +136,5 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Pause/archive routine mutation lacks a production native binding. Plugin-managed -routine rules remain separate. +Gaps: Pause/archive mutations have a generic native REST path under routine +ownership rules. Plugin-managed routine rules remain separate. diff --git a/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md index 93c0adc6bd7..9316af9c19c 100644 --- a/maps/agent-access-map/runs-adapters.md +++ b/maps/agent-access-map/runs-adapters.md @@ -2,7 +2,9 @@ Product map: [Runs, harnesses, and model accounts](../feature-map/runs-adapters.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -23,8 +25,8 @@ API: `present` — representative operation IDs: - `GET /api/adapters`. - `GET /api/companies/{companyId}/adapters/{type}/models`. -Agent access: `partial` — Agent company-model API and bound runtime context; board -adapter/account inventories. +Agent access: `partial` — Agent company-model REST / native `call_api` discovery and +bound runtime context; adapter/account inventories remain board-only. Policy: Run/cost visibility and agent configuration policy; adapter installation/account login/session control require board/admin. @@ -48,8 +50,8 @@ API: `present` — representative operation IDs: - `POST /api/companies/{companyId}/adapters/{type}/test-environment`. -Agent access: `partial` — Permitted agent-creation validation path / REST / CLI; -operator account login. +Agent access: `partial` — Permitted agent-creation validation through REST / native +`call_api` / CLI; account login requires its operator. Policy: Run/cost visibility and agent configuration policy; adapter installation/account login/session control require board/admin. @@ -112,8 +114,9 @@ Verification: `not-run` — assert the documented agent denial with an owned tes verify any listed agent request/result path separately. A delegated operation needs its own policy and access review. -Gaps: Execution context reset is not a permitted generic native API mutation or an -ordinary agent management path. +Gaps: Task-session listing and execution-context reset require board agent +management at their HTTP routes. Native `call_api` preserves these denials; native +continuation remains server-owned. Scoped Evidence: `source-only` — the [native transport](../../packages/paperclip-runner/src/live/runnerd-codex-transport.ts) diff --git a/maps/agent-access-map/secrets.md b/maps/agent-access-map/secrets.md index 083c56da74c..de4a5426219 100644 --- a/maps/agent-access-map/secrets.md +++ b/maps/agent-access-map/secrets.md @@ -2,7 +2,9 @@ Product map: [Secrets, personal credentials, and proposals](../feature-map/secrets.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -18,8 +20,8 @@ API: `present` — representative operation IDs: - `GET /api/companies/{companyId}/secrets/catalog`. - `POST /api/companies/{companyId}/secrets`. -Agent access: `partial` — Agent metadata catalog; board secret administration; agent -secret proposal API. +Agent access: `partial` — Agent REST / native `call_api` metadata catalog and secret +proposals; company secret administration remains board-only. Policy: Board secret administration, active-run/self/report proposal policy, and scoped secret broker bindings; personal secret values belong to their user. @@ -32,8 +34,8 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Metadata and requesting a change exist; generic native API blocks secret -management/value transport. +Gaps: Native transport preserves board-only administration. Bound value retrieval +is a separate scoped operation described under runtime-delivery. ## `user-secrets` @@ -69,8 +71,8 @@ API: `present` — representative operation IDs: - `POST /api/agents/me/secret-proposals`. - `GET /api/agents/me/secret-proposals`. -Agent access: `partial` — Active-run agent REST with redaction; board -approval/rejection. +Agent access: `partial` — Active-run agent REST / native `call_api` with redaction; +approval/rejection remains board-only. Policy: Board secret administration, active-run/self/report proposal policy, and scoped secret broker bindings; personal secret values belong to their user. @@ -83,8 +85,8 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Native call_api blocks secret-proposals and no dedicated production proposal tool -is in its default authority set. +Gaps: No dedicated production proposal tool is required for the REST path. +Native calls retain active-run, scope and skill-test JWT restrictions. ## `vault-import` @@ -120,8 +122,8 @@ API: `present` — representative operation IDs: - `GET /api/agents/me/secrets`. - `POST /api/agents/me/secrets/{key}/value`. -Agent access: `partial` — Authorized environment/broker delivery; eligible legacy agent -REST access. +Agent access: `available` — Authorized environment/broker delivery and scoped agent +REST / native `call_api` value retrieval. Policy: Board secret administration, active-run/self/report proposal policy, and scoped secret broker bindings; personal secret values belong to their user. @@ -134,5 +136,6 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Native metadata reads exist; value call is blocked and scenario read_secret_value -is unbound. Do not add raw secret-returning fallback tools. +Gaps: Values remain restricted to the bound agent and active run. Credential +responses stay inline and use encrypted mutation receipts; they are not saved as +plaintext response assets. Dedicated scenario read_secret_value remains unbound. diff --git a/maps/agent-access-map/skills.md b/maps/agent-access-map/skills.md index b4ce67184c8..2231414834f 100644 --- a/maps/agent-access-map/skills.md +++ b/maps/agent-access-map/skills.md @@ -2,7 +2,9 @@ Product map: [Skill discovery, authoring, and policy](../feature-map/skills.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -122,8 +124,8 @@ API: `present` — representative operation IDs: - `POST /api/companies/{companyId}/skills/{skillId}/test-runs`. -Agent access: `partial` — Permitted agent REST; native read fallback for history; no -dedicated CLI test command found. +Agent access: `available` — Permitted agent REST / native `call_api` creation, control +and history; no dedicated CLI test command found. Policy: Company skill policy by action/resource plus platform invariants; test execution additionally requires task-assignment authority. @@ -137,5 +139,6 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Native call_api rejects test-run creation/control while the route can authorize -agents. No default native orchestration binding. +Gaps: No dedicated native orchestration binding; generic REST calls retain +skills.test policy, task-assignment authorization, responsible-user intersection and +admitted skill-test JWT scope. diff --git a/maps/agent-access-map/steering.md b/maps/agent-access-map/steering.md index 0b872acd931..58035be531e 100644 --- a/maps/agent-access-map/steering.md +++ b/maps/agent-access-map/steering.md @@ -2,7 +2,9 @@ Product map: [Steering and queued messages](../feature-map/steering.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -80,8 +82,8 @@ Verification: `not-run` — assert the documented agent denial with an owned tes verify any listed agent request/result path separately. A delegated operation needs its own policy and access review. -Gaps: Generic native API blocks this execution control; no authority to impersonate the -steering user. +Gaps: Steering requires a board user at the HTTP route. Native `call_api` +preserves this denial and cannot impersonate the steering user. ## `interrupt` diff --git a/maps/agent-access-map/task-coordination.md b/maps/agent-access-map/task-coordination.md index c32d5849d2c..20b33cb299b 100644 --- a/maps/agent-access-map/task-coordination.md +++ b/maps/agent-access-map/task-coordination.md @@ -3,7 +3,9 @@ Product map: [Delegation, dependencies, and signoff](../feature-map/task-coordination.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -43,8 +45,8 @@ API: `present` — representative operation IDs: - `PATCH /api/issues/{id}`. -Agent access: `available` — Native set_dependencies for current task; agent REST under -mutation/checkout policy. +Agent access: `available` — Native set_dependencies for the current task; agent REST +/ native `call_api` under mutation and checkout policy. Policy: Task visibility/mutation, task-assignment and ownership policy; review audience independent; tree controls board-only. @@ -58,7 +60,8 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Generic native PATCH rejects blockedByIssueIds so use its real dedicated tool. +Gaps: Dedicated set_dependencies is current-task scoped. Generic `call_api` +forwards blockedByIssueIds to the REST route and retains its ownership checks. ## `signoff` diff --git a/maps/agent-access-map/tasks.md b/maps/agent-access-map/tasks.md index b3272d30a74..cf040965c7f 100644 --- a/maps/agent-access-map/tasks.md +++ b/maps/agent-access-map/tasks.md @@ -2,7 +2,9 @@ Product map: [Task creation and lifecycle](../feature-map/tasks.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -67,8 +69,8 @@ API: `present` — representative operation IDs: - `PATCH /api/issues/{id}`. - `PUT /api/issues/{id}/title`. -Agent access: `partial` — Native set_task_title/reassign_task/set_dependencies; fallback -for permitted non-lifecycle fields; REST. +Agent access: `available` — Native set_task_title/reassign_task/set_dependencies and +`call_api` for REST-authorized fields; agent REST. Policy: Company/task visibility, assignment/change policy, active-run checkout ownership and lifecycle rules. A legacy run can assign its own task to a human without @@ -89,8 +91,8 @@ regressions in [ownership tests](../../server/src/__tests__/issue-agent-mutation cover a human handoff and stop enforcement for other actors, runtimes and targets; they use mocked services and do not claim live execution proof. -Gaps: Native fallback blocks status/assignment/dependency policy fields. Dedicated tools -differ in scope and supported fields. +Gaps: Dedicated tools differ in scope and fields. Generic `call_api` forwards +status, assignment and dependency fields to existing route authorization. ## `discussion` @@ -115,8 +117,9 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Other-task comments and active-task progress have separate scopes; explicit -reopen/interrupt is not a generic comment capability. +Gaps: Other-task comments and active-task progress have separate scopes. Generic +`call_api` forwards explicit reopen/resume/interrupt fields to the existing route +checks; it cannot impersonate a user. ## `completion` @@ -127,7 +130,7 @@ API: `present` — representative operation IDs: - `PATCH /api/issues/{id}`. Agent access: `available` — Native paperclip_finish/paperclip_block through runner -protocol; legacy agent REST disposition. +protocol; agent REST / native `call_api` disposition under route permissions. Policy: Company/task visibility, assignment/change policy, active-run checkout ownership and lifecycle rules. @@ -140,5 +143,5 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Server governs whether a reported result becomes done/review/blocked. A generic -lifecycle PATCH is intentionally rejected for native runs. +Gaps: The server governs whether a reported result becomes done/review/blocked. +Generic lifecycle PATCH uses existing REST authorization, checkout and approval rules. diff --git a/maps/agent-access-map/workspaces.md b/maps/agent-access-map/workspaces.md index 8cbd031ab22..bf3cafe1ed2 100644 --- a/maps/agent-access-map/workspaces.md +++ b/maps/agent-access-map/workspaces.md @@ -2,7 +2,9 @@ Product map: [Execution workspaces, services, and files](../feature-map/workspaces.md). -Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. +Review date: 2026-10-12. Source commit: `58e16dbddfc9c8d0cf60867f9d9847f0feda055b`. +Native REST transport reconciled in this change; see the +[shared transport contract](README.md#native-rest-transport). This is a source-reviewed baseline of representative operations and known gaps. Each capability needs operation-level review before its entire scope can be @@ -119,8 +121,8 @@ API: `present` — representative operation IDs: - `POST /api/execution-workspaces/{id}/runtime-services/{action}`. -Agent access: `partial` — Authorized agent REST / CLI workspace runtime-service / -existing stdio MCP controls. +Agent access: `available` — Authorized agent REST / native `call_api` / CLI runtime +service controls and existing stdio MCP controls. Policy: Company/project/workspace visibility and runtime authority; active run, trust preset and task ownership where required. @@ -134,8 +136,9 @@ credentials/run context; inspect its result and applicable persistence/attributi Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. -Gaps: Native call_api blocks runtime-service mutation; control_workspace_service is not -in the production authority set. Optional MCP availability must be recorded. +Gaps: No dedicated production control_workspace_service binding. Generic native +calls retain runtime trust and workspace/task scope checks. Optional MCP availability +must be recorded. ## `files-git` diff --git a/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json b/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json index f9dc67e83f9..4231409b10d 100644 --- a/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json +++ b/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json @@ -1 +1 @@ -[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create or update an active-task document with optimistic revision safety.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision id, or null when creating.","maxLength":20000,"type":["string","null"]},"body":{"description":"Markdown document body.","maxLength":200000,"minLength":1,"type":"string"},"changeSummary":{"description":"Optional revision summary.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"title":{"description":"Document title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","key","title","body","baseRevisionId"],"type":"object"},"name":"write_document","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_human_input","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a durable human question or approval card on the current Paperclip task bound to this run; Paperclip renders it and authenticates the response. Use questions with continuationPolicy='wake_assignee' when an answer is needed, including otherwise tool-free chat turns. Supply a stable idempotencyKey and reuse it on retries. For one question at a time, ask only the next unanswered question and wait for its real answer. Never fabricate answers or treat ambiguous clarification as approval. For an ordinary confirmation or checkbox card, record a clear user chat answer with call_api POST /api/issues/{id}/interactions/{interactionId}/resolve-from-comment, using the source commentId and decision (accept/reject), plus explicit selectedOptionIds for checkbox acceptance. Existing resolver permissions still apply; governed tool, secret, and connection approvals are excluded. Question forms retain their dedicated answer workflow. Preserve existing review gates. Call this tool before claiming a question was asked; if creation fails, report the failure. Do not fabricate answer links or Markdown buttons, post duplicate cards, or use call_api to create the card. Use one complete payload.questionSet for text and choice questions. Paperclip generates compatibility questions; see the payload schema for formats.","inputSchema":{"additionalProperties":false,"allOf":[{"if":{"properties":{"interactionKind":{"const":"questions"}},"required":["interactionKind"]},"then":{"properties":{"payload":{"anyOf":[{"required":["questionSet"]},{"required":["questions"]}],"required":["version"]}},"required":["payload"]}}],"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 questions, send version:1 and one complete questionSet containing every text and choice question. Paperclip generates compatibility questions. Each canonical question needs id, prompt, required, and answerMode: text, single_select, or multi_select. Text questions have no options or customAnswer. Choice questions need at least two meaningful options with id/label. Use customAnswer:{enabled:true} for an optional written answer to a choice question. Legacy questions remain supported; if both representations are supplied, they must describe the same complete form. Keep IDs stable across retries. For confirmation, payload may be {}.","properties":{"questionSet":{"additionalProperties":false,"properties":{"description":{"maxLength":100000,"type":"string"},"questions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"answerMode":{"const":"text"}},"required":["answerMode"]},"then":{"not":{"required":["customAnswer"]},"properties":{"options":{"maxItems":0,"type":"array"}}}},{"if":{"properties":{"answerMode":{"enum":["single_select","multi_select"]}},"required":["answerMode"]},"then":{"not":{"required":["initialText"]},"properties":{"options":{"minItems":1,"type":"array"}},"required":["options"]}}],"properties":{"answerMode":{"enum":["single_select","multi_select","text"]},"customAnswer":{"additionalProperties":false,"properties":{"enabled":{"const":true},"label":{"maxLength":1000,"type":"string"},"placeholder":{"maxLength":1000,"type":"string"}},"required":["enabled"],"type":"object"},"header":{"maxLength":1000,"type":"string"},"helpText":{"maxLength":4000,"type":"string"},"id":{"maxLength":160,"minLength":1,"type":"string"},"initialText":{"description":"Editable draft of at most 100000 Unicode code points. Never an implicit answer; submitted text still uses the existing response bounds.","maxLength":100000,"type":"string"},"options":{"items":{"additionalProperties":false,"properties":{"description":{"maxLength":4000,"type":"string"},"id":{"maxLength":160,"minLength":1,"type":"string"},"label":{"maxLength":1000,"minLength":1,"type":"string"},"recommended":{"type":"boolean"}},"required":["id","label"],"type":"object"},"maxItems":128,"type":"array"},"prompt":{"maxLength":4000,"minLength":1,"type":"string"},"required":{"type":"boolean"},"textValidation":{"additionalProperties":false,"properties":{"inputType":{"enum":["text","number","integer"]},"maxLength":{"maximum":100000,"minimum":0,"type":"integer"},"maximum":{"type":"number"},"minLength":{"maximum":100000,"minimum":0,"type":"integer"},"minimum":{"type":"number"},"pattern":{"maxLength":1000,"type":"string"}},"type":"object"}},"required":["id","prompt","required","answerMode"],"type":"object"},"maxItems":64,"minItems":1,"type":"array"},"schema":{"const":"paperclip.question_set.v1"},"submitLabel":{"maxLength":200,"type":"string"},"title":{"maxLength":1000,"type":"string"}},"required":["schema","questions"],"type":"object"},"questions":{"items":{"additionalProperties":true,"properties":{"id":{"maxLength":160,"minLength":1,"type":"string"},"options":{"items":{"additionalProperties":true,"properties":{"freeText":{"type":"boolean"},"id":{"maxLength":160,"minLength":1,"type":"string"},"label":{"maxLength":1000,"minLength":1,"type":"string"}},"required":["id","label"],"type":"object"},"maxItems":129,"minItems":1,"type":"array"},"prompt":{"maxLength":4000,"minLength":1,"type":"string"},"required":{"type":"boolean"},"selectionMode":{"enum":["single","multi"]}},"required":["id","prompt","selectionMode","options"],"type":"object"},"maxItems":64,"minItems":1,"type":"array"},"version":{"const":1}},"type":"object"},"prompt":{"description":"Question or decision prompt.","maxLength":10000,"minLength":1,"type":"string"},"targetRevisionId":{"description":"Optional bound document revision.","maxLength":20000,"type":["string","null"]},"title":{"description":"Interaction card title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","interactionKind","title","prompt","continuationPolicy"],"type":"object"},"name":"request_human_input","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"register_deliverable","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Register attachment metadata and its artifact work product without credentials or bytes in the tool result.","inputSchema":{"additionalProperties":false,"properties":{"byteSize":{"maximum":100000000,"minimum":0,"type":"integer"},"contentRef":{"description":"Opaque package-local content reference.","maxLength":2000,"minLength":1,"type":"string"},"contentType":{"description":"Media type.","maxLength":200,"minLength":1,"type":"string"},"filename":{"description":"Display filename.","maxLength":500,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"sha256":{"pattern":"^[a-fA-F0-9]{64}$","type":"string"},"title":{"description":"Work-product title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","filename","contentType","byteSize","sha256","contentRef","title"],"type":"object"},"name":"register_deliverable","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"finish_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Finish the active task with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Completion summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"finish_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"block_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Block the active task with a durable reason and optional first-class dependencies.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Internal task ids that block this task.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Block reason.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","reason"],"type":"object"},"name":"block_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_review","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Move the active task to review with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Review handoff summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"request_review","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_agents","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List redacted actor profiles.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_agents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_agent","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one redacted actor profile.","inputSchema":{"additionalProperties":false,"properties":{"actorId":{"description":"Actor id.","maxLength":200,"minLength":1,"type":"string"}},"required":["actorId"],"type":"object"},"name":"get_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"hire_agent","requiredClaims":["delegation:agents:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a persistent native Runner teammate with an identity and persona. The teammate reports to the caller and inherits the caller's native runtime; provider, adapter, environment, and credential settings are selected by Paperclip and are never caller-supplied here. Reuse a suitable teammate from list_agents when possible.","inputSchema":{"additionalProperties":false,"properties":{"capabilities":{"maxLength":2000,"type":["string","null"]},"instructions":{"maxLength":20000,"type":["string","null"]},"name":{"maxLength":200,"minLength":1,"type":"string"},"role":{"enum":["ceo","cto","cmo","cfo","security","engineer","designer","pm","qa","devops","researcher","general"],"type":"string"},"title":{"maxLength":300,"type":["string","null"]}},"required":["name"],"type":"object"},"name":"hire_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_tasks","requiredClaims":["discovery:tasks:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Search tasks by text and status within the run company.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"},"query":{"maxLength":500,"type":"string"},"statuses":{"items":{"enum":["backlog","todo","in_progress","in_review","done","blocked","cancelled"]},"maxItems":7,"type":"array","uniqueItems":true}},"required":[],"type":"object"},"name":"search_tasks","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_approvals","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List approvals in the run company.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_approvals","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval without protected data.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval_context","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval, its comments, and linked tasks.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace_runtime","requiredClaims":["workspace:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read active-task workspace services.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace_runtime","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"control_workspace_service","requiredClaims":["workspace:control"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Start, stop, or fault one active-task workspace service.","inputSchema":{"additionalProperties":false,"properties":{"action":{"enum":["start","stop","fail"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"serviceId":{"description":"Workspace service id.","maxLength":200,"minLength":1,"type":"string"},"url":{"description":"Optional service URL.","maxLength":20000,"type":["string","null"]}},"required":["idempotencyKey","serviceId","action"],"type":"object"},"name":"control_workspace_service","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_dependencies","requiredClaims":["dependencies:write"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace the active task's first-class blocker set.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Replacement blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","blockedByTaskIds"],"type":"object"},"name":"set_dependencies","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_task","requiredClaims":["delegation:tasks:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution. Set status to backlog when the user wants to save or plan work without starting it; backlog tasks never wake an agent. Omitted status means todo, subject to blockers.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"Optional agent assignee. Omit to assign the current agent.","maxLength":20000,"type":["string","null"]},"assigneeUserId":{"description":"Company person ID from list_people. Mutually exclusive with assigneeActorId.","type":["string","null"]},"blockedByTaskIds":{"description":"Initial blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"description":{"description":"Child task description.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"initialPlan":{"description":"Relevant markdown plan saved on the new task before execution starts.","maxLength":200000,"type":["string","null"]},"priority":{"enum":["critical","high","medium","low"]},"projectId":{"description":"Project ID for the task.","type":["string","null"]},"status":{"description":"Initial status. Use backlog to save work without executing it. Defaults to todo (blocked when dependencies are unresolved).","enum":["backlog","todo"]},"title":{"description":"Child task title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","title"],"type":"object"},"name":"create_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"},"task":{"additionalProperties":false,"properties":{"assigneeActorId":{"type":["string","null"]},"assigneeUserId":{"description":"Company person ID from list_people. Mutually exclusive with assigneeActorId.","type":["string","null"]},"id":{"minLength":1,"type":"string"},"identifier":{"type":["string","null"]},"parentId":{"minLength":1,"type":["string","null"]},"projectId":{"minLength":1,"type":["string","null"]},"status":{"minLength":1,"type":"string"}},"required":["id","identifier","parentId","status","assigneeActorId"],"type":"object"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds","task"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"request_approval","requiredClaims":["governance:approvals:request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a governed approval and waiting posture.","inputSchema":{"additionalProperties":false,"properties":{"approvalType":{"description":"Stable approval type.","maxLength":200,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","approvalType","payload"],"type":"object"},"name":"request_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"decide_approval","requiredClaims":["governance:approvals:decide"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Decide an approval as an explicitly authorized approver.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"decision":{"enum":["approved","rejected","cancelled"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"note":{"description":"Decision note.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","decision","note"],"type":"object"},"name":"decide_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"comment_on_approval","requiredClaims":["governance:approvals:comment"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Add a durable comment to an approval.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"body":{"description":"Approval comment.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","body"],"type":"object"},"name":"comment_on_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"schedule_wake","requiredClaims":["control_plane:wakes"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule a deterministic continuation wake.","inputSchema":{"additionalProperties":false,"properties":{"delayTicks":{"maximum":10000,"minimum":1,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"reason":{"enum":["manual","issue_commented","interaction_resolved","approval_resolved","blockers_resolved","scheduled_retry","resume"]}},"required":["idempotencyKey","reason","delayTicks"],"type":"object"},"name":"schedule_wake","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"generic_api_request","requiredClaims":["test:generic_api_request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.","inputSchema":{"additionalProperties":false,"properties":{"body":{"additionalProperties":true,"type":"object"},"method":{"enum":["GET","POST","PATCH"]},"path":{"maxLength":500,"pattern":"^/mock/","type":"string"}},"required":["method","path"],"type":"object"},"name":"generic_api_request","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current canonical instruction entry and its revision, or inspect a historical revision by supplying both entryFile and revisionId. Read before updating; do not edit shared instruction caches.","inputSchema":{"additionalProperties":false,"properties":{"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"revisionId":{"description":"Historical revision to inspect; also provide its entryFile.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":[],"type":"object"},"name":"read_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_api","requiredClaims":["api:discover"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them. For a persistent hire, first list_agents to reuse a suitable teammate. Search agent-hires for the hiring schema and agent-configurations for compatible adapter/runtime settings, then use call_api with the returned operationId. Supply role-specific instructionsBundle.files as a filename-to-content record. Use the returned agent.id for delegation and obey any pending approval. Provider helper threads are temporary workers, not Paperclip hires. If a hire response is uncertain, reconcile with list_agents before retrying.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":200,"type":"string"},"limit":{"default":5,"maximum":10,"minimum":1,"type":"integer"},"query":{"maxLength":500,"minLength":1,"type":"string"}},"required":["query"],"type":"object"},"name":"search_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"update_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Commit instruction content with the exact entryFile and baseRevisionId from a prior read. Requires the responsible user’s current target edit permission. On conflict, preserve your candidate and explicitly resolve it; never overwrite the newer head.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Revision read before editing; null only when no canonical entry exists. Never replace a stale base silently.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":["string","null"]},"content":{"description":"Complete UTF-8 instruction content, at most 1 MiB; empty content is valid.","maxLength":1048576,"type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile","content","baseRevisionId"],"type":"object"},"name":"update_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"call_api","requiredClaims":["api:call"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.","inputSchema":{"additionalProperties":false,"properties":{"body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"items":{},"type":"array"},{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"}],"description":"Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."},"contentType":{"maxLength":120,"type":"string"},"files":{"items":{"additionalProperties":false,"oneOf":[{"properties":{"artifactId":{}},"required":["artifactId"]},{"properties":{"path":{}},"required":["path"]}],"properties":{"artifactId":{"type":"string"},"field":{"type":"string"},"path":{"description":"File relative to the active issue workspace. Remote files must first be uploaded as an artifact.","type":"string"}},"type":"object"},"maxItems":10,"type":"array"},"operationId":{"description":"Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.","maxLength":500,"minLength":1,"type":"string"},"pathParams":{"additionalProperties":{"type":"string"},"type":"object"},"query":{"additionalProperties":true,"type":"object"},"responseText":{"additionalProperties":false,"description":"GET only: return a bounded UTF-8 text window inline, including JSON as text, without saving another artifact. Offsets and limits are bytes. Use the returned nextOffsetBytes to continue; null means complete. Prefer reading a saved artifact for a stable snapshot. New live responses have a 1 GiB capture limit and a 4 GiB per-run capture budget. Existing larger assets remain readable in bounded pages. If a live response returns an artifact, continue on its content operation for a stable snapshot.","properties":{"limitBytes":{"default":24576,"maximum":24576,"minimum":4,"type":"integer"},"offsetBytes":{"default":0,"maximum":9007199254740991,"minimum":0,"type":"integer"}},"type":"object"}},"required":["operationId"],"type":"object"},"name":"call_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_agent_instruction_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List bounded canonical instruction revision metadata, including actor, source run, and restore origin. Use read_agent_instructions with entryFile and revisionId to inspect exact historical content.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":2048,"minLength":1,"type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"limit":{"maximum":100,"minimum":1,"type":"integer"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile"],"type":"object"},"name":"get_agent_instruction_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"restore_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a historical instruction revision as the current content using the current baseRevisionId. Requires the responsible user’s current target edit permission. History remains intact; conflicts require an explicit resolution.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision read before restoring. Never replace a stale base silently.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"revisionId":{"description":"Historical revision whose exact content should be restored.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile","revisionId","baseRevisionId"],"type":"object"},"name":"restore_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_project","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.","inputSchema":{"additionalProperties":false,"properties":{"archivedAt":{"description":"Archive timestamp.","maxLength":20000,"type":["string","null"]},"color":{"description":"Project color.","maxLength":20000,"type":["string","null"]},"description":{"description":"Project outcome and context.","maxLength":20000,"type":["string","null"]},"env":{"additionalProperties":true,"type":"object"},"executionWorkspacePolicy":{"additionalProperties":true,"type":"object"},"goalId":{"description":"Goal ID.","maxLength":20000,"type":["string","null"]},"goalIds":{"description":"Goal IDs.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"icon":{"description":"Project icon.","enum":["folder","rocket","code","terminal","database","globe","package","boxes","box","layers","briefcase","compass","target","flame","zap","star","bug","wrench","hammer","lightbulb","sparkles","shield","lock","search","cog","brain","cpu","git-branch","file-code","puzzle","gem","atom","heart","mail","message-square","crown","radar","telescope","hexagon",null],"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"leadAgentId":{"description":"Lead agent ID.","maxLength":20000,"type":["string","null"]},"name":{"description":"Project name.","maxLength":500,"minLength":1,"type":"string"},"repositoryIds":{"description":"Authorized repository IDs from list_project_repositories; may contain multiple repositories.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"repositoryUrls":{"description":"Existing HTTPS GitHub repository URLs, including repos absent from the catalog.","items":{"maxLength":2000,"pattern":"^https://github\\.com/(?!\\.{1,2}/)[A-Za-z0-9_.-]+/(?!\\.{1,2}/?$)[A-Za-z0-9_.-]+/?$","type":"string"},"maxItems":100,"type":"array"},"status":{"enum":["backlog","planned","in_progress","completed","cancelled"]},"targetDate":{"description":"Target date.","maxLength":20000,"type":["string","null"]},"workspace":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","name"],"type":"object"},"name":"create_project","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_project_repositories","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_project_repositories","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_projects","requiredClaims":["discovery:projects:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List company project summaries (IDs, names, status, and up to 1,000 characters of description). Returns up to 50 projects and nextCursor; continue with cursor until it is null. Read a project's API resource for full details.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"description":"The nextCursor returned by the previous page.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"limit":{"description":"Page size (default 50).","maximum":50,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"list_projects","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a reusable single-file skill in the company library. Supply a complete SKILL.md whose name and description match the inputs. This saves the skill and shows a card; it does not assign the skill to any agent. Reuse idempotencyKey on retries.","inputSchema":{"additionalProperties":false,"properties":{"description":{"maxLength":2000,"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"description":"Complete SKILL.md including name and description frontmatter and substantive instructions.","maxLength":200000,"minLength":1,"type":"string"},"name":{"description":"Lowercase skill name, matching SKILL.md frontmatter.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"},"slug":{"description":"Optional; must equal name.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"}},"required":["idempotencyKey","name","description","markdown"],"type":"object"},"name":"create_skill","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"reassign_task","requiredClaims":["delegation:tasks:assign"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Reassign an existing task to another company agent. Read search_tasks first and supply its current assignee and statusVersion to prevent overwriting a concurrent change. Preserve the task, documents, dependencies, and blocked/backlog status. Active work is stopped before handoff. Use a stable idempotency key for retries. Cannot reassign this run’s own task, conversations, completed tasks, or pending reviews; use create_task for delegation from the current task.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"New company agent ID from list_agents; null when assigning a person.","type":["string","null"]},"assigneeUserId":{"description":"Company person ID from list_people. Mutually exclusive with assigneeActorId.","type":["string","null"]},"expectedAssigneeActorId":{"description":"Current assigneeAgentId from search_tasks; null means unassigned.","type":["string","null"]},"expectedAssigneeUserId":{"description":"Current assigneeUserId from search_tasks; omit only when there is no person assigned.","type":["string","null"]},"expectedStatusVersion":{"description":"Current statusVersion from search_tasks.","minimum":0,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Why the task should move and context for the new owner.","maxLength":20000,"minLength":1,"type":"string"},"taskId":{"description":"Existing task ID from search_tasks.","minLength":1,"type":"string"}},"required":["idempotencyKey","taskId","assigneeActorId","expectedAssigneeActorId","expectedStatusVersion","reason"],"type":"object"},"name":"reassign_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"update_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace an existing company skill's complete SKILL.md using the current version as a guard. Reuse idempotencyKey on lost-response retries; read again after a version conflict.","inputSchema":{"additionalProperties":false,"properties":{"expectedVersionId":{"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"maxLength":200000,"minLength":1,"type":"string"},"skillId":{"minLength":1,"type":"string"}},"required":["skillId","markdown","expectedVersionId","idempotencyKey"],"type":"object"},"name":"update_skill","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_task_title","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Set a concise, descriptive title for the active task. When its titleNeedsGeneration is true, call this early with onlyIfProvisional: true to replace the initial prompt slice without overwriting a user's title. Use false only for an intentional rename. This changes no task status, ownership, or description.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Reuse this key on retries.","maxLength":240,"minLength":1,"type":"string"},"onlyIfProvisional":{"description":"True for automatic initial naming; preserves any title already chosen by a user or agent.","type":"boolean"},"title":{"description":"Short title describing the requested outcome.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","title","onlyIfProvisional"],"type":"object"},"name":"set_task_title","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_task_monitor","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule, replace, or clear a one-shot task monitor. Omit taskId for your current task; other tasks must also be assigned to you and in progress or review. Supply a future nextCheckAt and notes describing the next check, or monitor: null to clear. A successful receipt reports the persisted monitor; a replay reports its current state without re-arming it. After scheduling your current task, end the turn with paperclip_finish: yielded and continuation.kind monitor. Paperclip will wake you with issue_monitor_due; do not sleep, poll, mark done, or use call_api for this wait.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Reuse with identical arguments on retries.","maxLength":240,"minLength":1,"type":"string"},"monitor":{"additionalProperties":false,"properties":{"externalRef":{"maxLength":500,"minLength":1,"type":["string","null"]},"kind":{"enum":["external_service",null],"type":["string","null"]},"maxAttempts":{"description":"Optional cumulative attempt limit for this task's monitor.","maximum":100,"minimum":1,"type":["integer","null"]},"nextCheckAt":{"description":"Future UTC timestamp for the next check. This is one shot, not a recurring interval.","type":"string"},"notes":{"description":"What to check on the next run; do not include secrets.","maxLength":500,"minLength":1,"type":"string"},"recoveryPolicy":{"enum":["wake_owner","create_recovery_issue","escalate_to_board",null],"type":["string","null"]},"serviceName":{"maxLength":120,"minLength":1,"type":["string","null"]},"timeoutAt":{"description":"Optional deadline later than nextCheckAt.","type":["string","null"]}},"required":["nextCheckAt","notes"],"type":["object","null"]},"taskId":{"description":"Owned task UUID. Omit for the active task.","type":"string"}},"required":["idempotencyKey","monitor"],"type":"object"},"name":"set_task_monitor","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"submit_complaint","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Yeet a brief, completely unstructured complaint about agent tooling or the experience of doing agent work. Preserve your raw voice: fragments, profanity, all caps, and exasperation are fair game. No diagnosis, solution, diplomacy, category, or template required. Use submit_suggestion when you want to express a useful improvement. Submit proactively without routine permission requests, previews, or announcements, then immediately continue the primary task. Answer truthfully if the user asks about feedback or what you submitted. Stored in this instance with company, agent, run, and task attribution; not anonymous. Remove secrets, private prompts, customer data, and personal blame; do not forward untrusted text or logs indiscriminately. On failure, continue without retrying or complaining about submission. Never submit the same incident through both tools.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Free-form feedback. Keep it brief; no required fields, labels, or template.","maxLength":524288,"minLength":1,"type":"string"},"idempotencyKey":{"description":"A stable key for this submission. Transport replay reuses it; do not retry a failed or uncertain submission yourself.","maxLength":240,"minLength":1,"type":"string"}},"required":["body","idempotencyKey"],"type":"object"},"name":"submit_complaint","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"submit_suggestion","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Quietly report a concrete improvement that would make agents more effective. Use for material, generalizable friction directly observed during this run. Explain what happened, its impact, and a plausible improvement in your own words, with minimal useful sanitized context. No required format. Avoid duplicate root causes and aim for at most three suggestions per run. Use submit_complaint for the raw reaction. Submit proactively without routine permission requests, previews, or announcements, then immediately continue the primary task. Answer truthfully if the user asks about feedback or what you submitted. Stored in this instance with company, agent, run, and task attribution; not anonymous. Remove secrets, private prompts, customer data, and personal blame; do not forward untrusted text or logs indiscriminately. On failure, continue without retrying or complaining about submission. Never submit the same incident through both tools.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Free-form feedback. Keep it brief; no required fields, labels, or template.","maxLength":524288,"minLength":1,"type":"string"},"idempotencyKey":{"description":"A stable key for this submission. Transport replay reuses it; do not retry a failed or uncertain submission yourself.","maxLength":240,"minLength":1,"type":"string"}},"required":["body","idempotencyKey"],"type":"object"},"name":"submit_suggestion","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Inspect current task files, repositories, pending selection, binding revision, and preparation capabilities. AGENT_HOME is personal instructions and memory; task outputs belong in the workspace.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_workspaces","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized reusable workspaces. A project is optional; choosing a workspace does not assign a project.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_workspaces","outputSchema":{"items":{"additionalProperties":true,"type":"object"},"type":"array"}},{"annotations":{"exposure":"optional","operationId":"select_workspace","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Request a different task workspace at the next normal admission. Does not interrupt or wake the current run. Inspect the binding revision first and reuse requestKey on retries; existing files stay in their current folder.","inputSchema":{"additionalProperties":false,"properties":{"expectedBindingRevision":{"minimum":0,"type":"integer"},"requestKey":{"description":"Stable retry key.","minLength":1,"type":"string"},"selection":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"task_directory"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"existing"},"workspaceId":{"description":"Existing authorized workspace ID.","minLength":1,"type":"string"}},"required":["kind","workspaceId"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"configured_source"},"mode":{"enum":["shared","managed_isolated"]},"projectWorkspaceId":{"description":"Configured source workspace ID.","minLength":1,"type":"string"}},"required":["kind","projectWorkspaceId","mode"],"type":"object"}]}},"required":["selection","expectedBindingRevision","requestKey"],"type":"object"},"name":"select_workspace","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"prepare_repository","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Request an authorized repository inside task files at the next normal admission, without creating a project. Discover repository IDs first. Does not interrupt or wake the current run. Reuse requestKey; a conflicting ref never resets existing work.","inputSchema":{"additionalProperties":false,"properties":{"ref":{"description":"Optional Git branch, tag, or commit. Default HEAD is pinned once.","minLength":1,"type":"string"},"repository":{"oneOf":[{"additionalProperties":false,"properties":{"id":{"description":"Authorized ID from list_project_repositories.","minLength":1,"type":"string"},"kind":{"const":"catalog"}},"required":["kind","id"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"url"},"url":{"description":"Existing HTTPS GitHub repository URL; uncataloged URLs use public anonymous access only.","minLength":1,"type":"string"}},"required":["kind","url"],"type":"object"}]},"requestKey":{"description":"Stable retry key.","minLength":1,"type":"string"}},"required":["repository","requestKey"],"type":"object"},"name":"prepare_repository","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 task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create or update an active-task document with optimistic revision safety.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision id, or null when creating.","maxLength":20000,"type":["string","null"]},"body":{"description":"Markdown document body.","maxLength":200000,"minLength":1,"type":"string"},"changeSummary":{"description":"Optional revision summary.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"title":{"description":"Document title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","key","title","body","baseRevisionId"],"type":"object"},"name":"write_document","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_human_input","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a durable human question or approval card on the current Paperclip task bound to this run; Paperclip renders it and authenticates the response. Use questions with continuationPolicy='wake_assignee' when an answer is needed, including otherwise tool-free chat turns. Supply a stable idempotencyKey and reuse it on retries. For one question at a time, ask only the next unanswered question and wait for its real answer. Never fabricate answers or treat ambiguous clarification as approval. For an ordinary confirmation or checkbox card, record a clear user chat answer with call_api POST /api/issues/{id}/interactions/{interactionId}/resolve-from-comment, using the source commentId and decision (accept/reject), plus explicit selectedOptionIds for checkbox acceptance. Existing resolver permissions still apply; governed tool, secret, and connection approvals are excluded. Question forms retain their dedicated answer workflow. Preserve existing review gates. Call this tool before claiming a question was asked; if creation fails, report the failure. Do not fabricate answer links or Markdown buttons, post duplicate cards, or use call_api to create the card. Use one complete payload.questionSet for text and choice questions. Paperclip generates compatibility questions; see the payload schema for formats.","inputSchema":{"additionalProperties":false,"allOf":[{"if":{"properties":{"interactionKind":{"const":"questions"}},"required":["interactionKind"]},"then":{"properties":{"payload":{"anyOf":[{"required":["questionSet"]},{"required":["questions"]}],"required":["version"]}},"required":["payload"]}}],"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 questions, send version:1 and one complete questionSet containing every text and choice question. Paperclip generates compatibility questions. Each canonical question needs id, prompt, required, and answerMode: text, single_select, or multi_select. Text questions have no options or customAnswer. Choice questions need at least two meaningful options with id/label. Use customAnswer:{enabled:true} for an optional written answer to a choice question. Legacy questions remain supported; if both representations are supplied, they must describe the same complete form. Keep IDs stable across retries. For confirmation, payload may be {}.","properties":{"questionSet":{"additionalProperties":false,"properties":{"description":{"maxLength":100000,"type":"string"},"questions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"answerMode":{"const":"text"}},"required":["answerMode"]},"then":{"not":{"required":["customAnswer"]},"properties":{"options":{"maxItems":0,"type":"array"}}}},{"if":{"properties":{"answerMode":{"enum":["single_select","multi_select"]}},"required":["answerMode"]},"then":{"not":{"required":["initialText"]},"properties":{"options":{"minItems":1,"type":"array"}},"required":["options"]}}],"properties":{"answerMode":{"enum":["single_select","multi_select","text"]},"customAnswer":{"additionalProperties":false,"properties":{"enabled":{"const":true},"label":{"maxLength":1000,"type":"string"},"placeholder":{"maxLength":1000,"type":"string"}},"required":["enabled"],"type":"object"},"header":{"maxLength":1000,"type":"string"},"helpText":{"maxLength":4000,"type":"string"},"id":{"maxLength":160,"minLength":1,"type":"string"},"initialText":{"description":"Editable draft of at most 100000 Unicode code points. Never an implicit answer; submitted text still uses the existing response bounds.","maxLength":100000,"type":"string"},"options":{"items":{"additionalProperties":false,"properties":{"description":{"maxLength":4000,"type":"string"},"id":{"maxLength":160,"minLength":1,"type":"string"},"label":{"maxLength":1000,"minLength":1,"type":"string"},"recommended":{"type":"boolean"}},"required":["id","label"],"type":"object"},"maxItems":128,"type":"array"},"prompt":{"maxLength":4000,"minLength":1,"type":"string"},"required":{"type":"boolean"},"textValidation":{"additionalProperties":false,"properties":{"inputType":{"enum":["text","number","integer"]},"maxLength":{"maximum":100000,"minimum":0,"type":"integer"},"maximum":{"type":"number"},"minLength":{"maximum":100000,"minimum":0,"type":"integer"},"minimum":{"type":"number"},"pattern":{"maxLength":1000,"type":"string"}},"type":"object"}},"required":["id","prompt","required","answerMode"],"type":"object"},"maxItems":64,"minItems":1,"type":"array"},"schema":{"const":"paperclip.question_set.v1"},"submitLabel":{"maxLength":200,"type":"string"},"title":{"maxLength":1000,"type":"string"}},"required":["schema","questions"],"type":"object"},"questions":{"items":{"additionalProperties":true,"properties":{"id":{"maxLength":160,"minLength":1,"type":"string"},"options":{"items":{"additionalProperties":true,"properties":{"freeText":{"type":"boolean"},"id":{"maxLength":160,"minLength":1,"type":"string"},"label":{"maxLength":1000,"minLength":1,"type":"string"}},"required":["id","label"],"type":"object"},"maxItems":129,"minItems":1,"type":"array"},"prompt":{"maxLength":4000,"minLength":1,"type":"string"},"required":{"type":"boolean"},"selectionMode":{"enum":["single","multi"]}},"required":["id","prompt","selectionMode","options"],"type":"object"},"maxItems":64,"minItems":1,"type":"array"},"version":{"const":1}},"type":"object"},"prompt":{"description":"Question or decision prompt.","maxLength":10000,"minLength":1,"type":"string"},"targetRevisionId":{"description":"Optional bound document revision.","maxLength":20000,"type":["string","null"]},"title":{"description":"Interaction card title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","interactionKind","title","prompt","continuationPolicy"],"type":"object"},"name":"request_human_input","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"register_deliverable","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Register attachment metadata and its artifact work product without credentials or bytes in the tool result.","inputSchema":{"additionalProperties":false,"properties":{"byteSize":{"maximum":100000000,"minimum":0,"type":"integer"},"contentRef":{"description":"Opaque package-local content reference.","maxLength":2000,"minLength":1,"type":"string"},"contentType":{"description":"Media type.","maxLength":200,"minLength":1,"type":"string"},"filename":{"description":"Display filename.","maxLength":500,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"sha256":{"pattern":"^[a-fA-F0-9]{64}$","type":"string"},"title":{"description":"Work-product title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","filename","contentType","byteSize","sha256","contentRef","title"],"type":"object"},"name":"register_deliverable","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"finish_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Finish the active task with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Completion summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"finish_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"block_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Block the active task with a durable reason and optional first-class dependencies.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Internal task ids that block this task.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Block reason.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","reason"],"type":"object"},"name":"block_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_review","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Move the active task to review with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Review handoff summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"request_review","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_agents","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List redacted actor profiles.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_agents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_agent","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one redacted actor profile.","inputSchema":{"additionalProperties":false,"properties":{"actorId":{"description":"Actor id.","maxLength":200,"minLength":1,"type":"string"}},"required":["actorId"],"type":"object"},"name":"get_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"hire_agent","requiredClaims":["delegation:agents:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a persistent native Runner teammate with an identity and persona. The teammate reports to the caller and inherits the caller's native runtime; provider, adapter, environment, and credential settings are selected by Paperclip and are never caller-supplied here. Reuse a suitable teammate from list_agents when possible.","inputSchema":{"additionalProperties":false,"properties":{"capabilities":{"maxLength":2000,"type":["string","null"]},"instructions":{"maxLength":20000,"type":["string","null"]},"name":{"maxLength":200,"minLength":1,"type":"string"},"role":{"enum":["ceo","cto","cmo","cfo","security","engineer","designer","pm","qa","devops","researcher","general"],"type":"string"},"title":{"maxLength":300,"type":["string","null"]}},"required":["name"],"type":"object"},"name":"hire_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_tasks","requiredClaims":["discovery:tasks:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Search tasks by text and status within the run company.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"},"query":{"maxLength":500,"type":"string"},"statuses":{"items":{"enum":["backlog","todo","in_progress","in_review","done","blocked","cancelled"]},"maxItems":7,"type":"array","uniqueItems":true}},"required":[],"type":"object"},"name":"search_tasks","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_approvals","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List approvals in the run company.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_approvals","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval without protected data.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval_context","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval, its comments, and linked tasks.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace_runtime","requiredClaims":["workspace:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read active-task workspace services.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace_runtime","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"control_workspace_service","requiredClaims":["workspace:control"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Start, stop, or fault one active-task workspace service.","inputSchema":{"additionalProperties":false,"properties":{"action":{"enum":["start","stop","fail"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"serviceId":{"description":"Workspace service id.","maxLength":200,"minLength":1,"type":"string"},"url":{"description":"Optional service URL.","maxLength":20000,"type":["string","null"]}},"required":["idempotencyKey","serviceId","action"],"type":"object"},"name":"control_workspace_service","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_dependencies","requiredClaims":["dependencies:write"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace the active task's first-class blocker set.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Replacement blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","blockedByTaskIds"],"type":"object"},"name":"set_dependencies","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_task","requiredClaims":["delegation:tasks:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution. Set status to backlog when the user wants to save or plan work without starting it; backlog tasks never wake an agent. Omitted status means todo, subject to blockers.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"Optional agent assignee. Omit to assign the current agent.","maxLength":20000,"type":["string","null"]},"assigneeUserId":{"description":"Company person ID from list_people. Mutually exclusive with assigneeActorId.","type":["string","null"]},"blockedByTaskIds":{"description":"Initial blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"description":{"description":"Child task description.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"initialPlan":{"description":"Relevant markdown plan saved on the new task before execution starts.","maxLength":200000,"type":["string","null"]},"priority":{"enum":["critical","high","medium","low"]},"projectId":{"description":"Project ID for the task.","type":["string","null"]},"status":{"description":"Initial status. Use backlog to save work without executing it. Defaults to todo (blocked when dependencies are unresolved).","enum":["backlog","todo"]},"title":{"description":"Child task title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","title"],"type":"object"},"name":"create_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"},"task":{"additionalProperties":false,"properties":{"assigneeActorId":{"type":["string","null"]},"assigneeUserId":{"description":"Company person ID from list_people. Mutually exclusive with assigneeActorId.","type":["string","null"]},"id":{"minLength":1,"type":"string"},"identifier":{"type":["string","null"]},"parentId":{"minLength":1,"type":["string","null"]},"projectId":{"minLength":1,"type":["string","null"]},"status":{"minLength":1,"type":"string"}},"required":["id","identifier","parentId","status","assigneeActorId"],"type":"object"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds","task"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"request_approval","requiredClaims":["governance:approvals:request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a governed approval and waiting posture.","inputSchema":{"additionalProperties":false,"properties":{"approvalType":{"description":"Stable approval type.","maxLength":200,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","approvalType","payload"],"type":"object"},"name":"request_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"decide_approval","requiredClaims":["governance:approvals:decide"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Decide an approval as an explicitly authorized approver.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"decision":{"enum":["approved","rejected","cancelled"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"note":{"description":"Decision note.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","decision","note"],"type":"object"},"name":"decide_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"comment_on_approval","requiredClaims":["governance:approvals:comment"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Add a durable comment to an approval.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"body":{"description":"Approval comment.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","body"],"type":"object"},"name":"comment_on_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"schedule_wake","requiredClaims":["control_plane:wakes"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule a deterministic continuation wake.","inputSchema":{"additionalProperties":false,"properties":{"delayTicks":{"maximum":10000,"minimum":1,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"reason":{"enum":["manual","issue_commented","interaction_resolved","approval_resolved","blockers_resolved","scheduled_retry","resume"]}},"required":["idempotencyKey","reason","delayTicks"],"type":"object"},"name":"schedule_wake","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"generic_api_request","requiredClaims":["test:generic_api_request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.","inputSchema":{"additionalProperties":false,"properties":{"body":{"additionalProperties":true,"type":"object"},"method":{"enum":["GET","POST","PATCH"]},"path":{"maxLength":500,"pattern":"^/mock/","type":"string"}},"required":["method","path"],"type":"object"},"name":"generic_api_request","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current canonical instruction entry and its revision, or inspect a historical revision by supplying both entryFile and revisionId. Read before updating; do not edit shared instruction caches.","inputSchema":{"additionalProperties":false,"properties":{"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"revisionId":{"description":"Historical revision to inspect; also provide its entryFile.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":[],"type":"object"},"name":"read_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_api","requiredClaims":["api:discover"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them. For a persistent hire, first list_agents to reuse a suitable teammate. Search agent-hires for the hiring schema and agent-configurations for compatible adapter/runtime settings, then use call_api with the returned operationId. Supply role-specific instructionsBundle.files as a filename-to-content record. Use the returned agent.id for delegation and obey any pending approval. Provider helper threads are temporary workers, not Paperclip hires. If a hire response is uncertain, reconcile with list_agents before retrying.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":200,"type":"string"},"limit":{"default":5,"maximum":10,"minimum":1,"type":"integer"},"query":{"maxLength":500,"minLength":1,"type":"string"}},"required":["query"],"type":"object"},"name":"search_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"update_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Commit instruction content with the exact entryFile and baseRevisionId from a prior read. Requires the responsible user’s current target edit permission. On conflict, preserve your candidate and explicitly resolve it; never overwrite the newer head.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Revision read before editing; null only when no canonical entry exists. Never replace a stale base silently.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":["string","null"]},"content":{"description":"Complete UTF-8 instruction content, at most 1 MiB; empty content is valid.","maxLength":1048576,"type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile","content","baseRevisionId"],"type":"object"},"name":"update_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"call_api","requiredClaims":["api:call"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses the same route permissions as agent REST access. Prefer dedicated tools for common workflows; route denials still apply. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.","inputSchema":{"additionalProperties":false,"properties":{"body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"items":{},"type":"array"},{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"}],"description":"Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."},"contentType":{"maxLength":120,"type":"string"},"files":{"items":{"additionalProperties":false,"oneOf":[{"properties":{"artifactId":{}},"required":["artifactId"]},{"properties":{"path":{}},"required":["path"]}],"properties":{"artifactId":{"type":"string"},"field":{"type":"string"},"path":{"description":"File relative to the active issue workspace. Remote files must first be uploaded as an artifact.","type":"string"}},"type":"object"},"maxItems":10,"type":"array"},"operationId":{"description":"Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.","maxLength":500,"minLength":1,"type":"string"},"pathParams":{"additionalProperties":{"type":"string"},"type":"object"},"query":{"additionalProperties":true,"type":"object"},"responseText":{"additionalProperties":false,"description":"GET only: return a bounded UTF-8 text window inline, including JSON as text, without saving another artifact. Offsets and limits are bytes. Use the returned nextOffsetBytes to continue; null means complete. Prefer reading a saved artifact for a stable snapshot. New live responses have a 1 GiB capture limit and a 4 GiB per-run capture budget. Existing larger assets remain readable in bounded pages. If a live response returns an artifact, continue on its content operation for a stable snapshot.","properties":{"limitBytes":{"default":24576,"maximum":24576,"minimum":4,"type":"integer"},"offsetBytes":{"default":0,"maximum":9007199254740991,"minimum":0,"type":"integer"}},"type":"object"}},"required":["operationId"],"type":"object"},"name":"call_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_agent_instruction_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List bounded canonical instruction revision metadata, including actor, source run, and restore origin. Use read_agent_instructions with entryFile and revisionId to inspect exact historical content.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":2048,"minLength":1,"type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"limit":{"maximum":100,"minimum":1,"type":"integer"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile"],"type":"object"},"name":"get_agent_instruction_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"restore_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a historical instruction revision as the current content using the current baseRevisionId. Requires the responsible user’s current target edit permission. History remains intact; conflicts require an explicit resolution.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision read before restoring. Never replace a stale base silently.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"revisionId":{"description":"Historical revision whose exact content should be restored.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile","revisionId","baseRevisionId"],"type":"object"},"name":"restore_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_project","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.","inputSchema":{"additionalProperties":false,"properties":{"archivedAt":{"description":"Archive timestamp.","maxLength":20000,"type":["string","null"]},"color":{"description":"Project color.","maxLength":20000,"type":["string","null"]},"description":{"description":"Project outcome and context.","maxLength":20000,"type":["string","null"]},"env":{"additionalProperties":true,"type":"object"},"executionWorkspacePolicy":{"additionalProperties":true,"type":"object"},"goalId":{"description":"Goal ID.","maxLength":20000,"type":["string","null"]},"goalIds":{"description":"Goal IDs.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"icon":{"description":"Project icon.","enum":["folder","rocket","code","terminal","database","globe","package","boxes","box","layers","briefcase","compass","target","flame","zap","star","bug","wrench","hammer","lightbulb","sparkles","shield","lock","search","cog","brain","cpu","git-branch","file-code","puzzle","gem","atom","heart","mail","message-square","crown","radar","telescope","hexagon",null],"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"leadAgentId":{"description":"Lead agent ID.","maxLength":20000,"type":["string","null"]},"name":{"description":"Project name.","maxLength":500,"minLength":1,"type":"string"},"repositoryIds":{"description":"Authorized repository IDs from list_project_repositories; may contain multiple repositories.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"repositoryUrls":{"description":"Existing HTTPS GitHub repository URLs, including repos absent from the catalog.","items":{"maxLength":2000,"pattern":"^https://github\\.com/(?!\\.{1,2}/)[A-Za-z0-9_.-]+/(?!\\.{1,2}/?$)[A-Za-z0-9_.-]+/?$","type":"string"},"maxItems":100,"type":"array"},"status":{"enum":["backlog","planned","in_progress","completed","cancelled"]},"targetDate":{"description":"Target date.","maxLength":20000,"type":["string","null"]},"workspace":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","name"],"type":"object"},"name":"create_project","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_project_repositories","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_project_repositories","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_projects","requiredClaims":["discovery:projects:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List company project summaries (IDs, names, status, and up to 1,000 characters of description). Returns up to 50 projects and nextCursor; continue with cursor until it is null. Read a project's API resource for full details.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"description":"The nextCursor returned by the previous page.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"limit":{"description":"Page size (default 50).","maximum":50,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"list_projects","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a reusable single-file skill in the company library. Supply a complete SKILL.md whose name and description match the inputs. This saves the skill and shows a card; it does not assign the skill to any agent. Reuse idempotencyKey on retries.","inputSchema":{"additionalProperties":false,"properties":{"description":{"maxLength":2000,"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"description":"Complete SKILL.md including name and description frontmatter and substantive instructions.","maxLength":200000,"minLength":1,"type":"string"},"name":{"description":"Lowercase skill name, matching SKILL.md frontmatter.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"},"slug":{"description":"Optional; must equal name.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"}},"required":["idempotencyKey","name","description","markdown"],"type":"object"},"name":"create_skill","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"reassign_task","requiredClaims":["delegation:tasks:assign"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Reassign an existing task to another company agent. Read search_tasks first and supply its current assignee and statusVersion to prevent overwriting a concurrent change. Preserve the task, documents, dependencies, and blocked/backlog status. Active work is stopped before handoff. Use a stable idempotency key for retries. Cannot reassign this run’s own task, conversations, completed tasks, or pending reviews; use create_task for delegation from the current task.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"New company agent ID from list_agents; null when assigning a person.","type":["string","null"]},"assigneeUserId":{"description":"Company person ID from list_people. Mutually exclusive with assigneeActorId.","type":["string","null"]},"expectedAssigneeActorId":{"description":"Current assigneeAgentId from search_tasks; null means unassigned.","type":["string","null"]},"expectedAssigneeUserId":{"description":"Current assigneeUserId from search_tasks; omit only when there is no person assigned.","type":["string","null"]},"expectedStatusVersion":{"description":"Current statusVersion from search_tasks.","minimum":0,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Why the task should move and context for the new owner.","maxLength":20000,"minLength":1,"type":"string"},"taskId":{"description":"Existing task ID from search_tasks.","minLength":1,"type":"string"}},"required":["idempotencyKey","taskId","assigneeActorId","expectedAssigneeActorId","expectedStatusVersion","reason"],"type":"object"},"name":"reassign_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"update_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace an existing company skill's complete SKILL.md using the current version as a guard. Reuse idempotencyKey on lost-response retries; read again after a version conflict.","inputSchema":{"additionalProperties":false,"properties":{"expectedVersionId":{"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"maxLength":200000,"minLength":1,"type":"string"},"skillId":{"minLength":1,"type":"string"}},"required":["skillId","markdown","expectedVersionId","idempotencyKey"],"type":"object"},"name":"update_skill","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_task_title","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Set a concise, descriptive title for the active task. When its titleNeedsGeneration is true, call this early with onlyIfProvisional: true to replace the initial prompt slice without overwriting a user's title. Use false only for an intentional rename. This changes no task status, ownership, or description.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Reuse this key on retries.","maxLength":240,"minLength":1,"type":"string"},"onlyIfProvisional":{"description":"True for automatic initial naming; preserves any title already chosen by a user or agent.","type":"boolean"},"title":{"description":"Short title describing the requested outcome.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","title","onlyIfProvisional"],"type":"object"},"name":"set_task_title","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_task_monitor","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule, replace, or clear a one-shot task monitor. Omit taskId for your current task; other tasks must also be assigned to you and in progress or review. Supply a future nextCheckAt and notes describing the next check, or monitor: null to clear. A successful receipt reports the persisted monitor; a replay reports its current state without re-arming it. After scheduling your current task, end the turn with paperclip_finish: yielded and continuation.kind monitor. Paperclip will wake you with issue_monitor_due; do not sleep, poll, mark done, or use call_api for this wait.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Reuse with identical arguments on retries.","maxLength":240,"minLength":1,"type":"string"},"monitor":{"additionalProperties":false,"properties":{"externalRef":{"maxLength":500,"minLength":1,"type":["string","null"]},"kind":{"enum":["external_service",null],"type":["string","null"]},"maxAttempts":{"description":"Optional cumulative attempt limit for this task's monitor.","maximum":100,"minimum":1,"type":["integer","null"]},"nextCheckAt":{"description":"Future UTC timestamp for the next check. This is one shot, not a recurring interval.","type":"string"},"notes":{"description":"What to check on the next run; do not include secrets.","maxLength":500,"minLength":1,"type":"string"},"recoveryPolicy":{"enum":["wake_owner","create_recovery_issue","escalate_to_board",null],"type":["string","null"]},"serviceName":{"maxLength":120,"minLength":1,"type":["string","null"]},"timeoutAt":{"description":"Optional deadline later than nextCheckAt.","type":["string","null"]}},"required":["nextCheckAt","notes"],"type":["object","null"]},"taskId":{"description":"Owned task UUID. Omit for the active task.","type":"string"}},"required":["idempotencyKey","monitor"],"type":"object"},"name":"set_task_monitor","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"submit_complaint","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Yeet a brief, completely unstructured complaint about agent tooling or the experience of doing agent work. Preserve your raw voice: fragments, profanity, all caps, and exasperation are fair game. No diagnosis, solution, diplomacy, category, or template required. Use submit_suggestion when you want to express a useful improvement. Submit proactively without routine permission requests, previews, or announcements, then immediately continue the primary task. Answer truthfully if the user asks about feedback or what you submitted. Stored in this instance with company, agent, run, and task attribution; not anonymous. Remove secrets, private prompts, customer data, and personal blame; do not forward untrusted text or logs indiscriminately. On failure, continue without retrying or complaining about submission. Never submit the same incident through both tools.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Free-form feedback. Keep it brief; no required fields, labels, or template.","maxLength":524288,"minLength":1,"type":"string"},"idempotencyKey":{"description":"A stable key for this submission. Transport replay reuses it; do not retry a failed or uncertain submission yourself.","maxLength":240,"minLength":1,"type":"string"}},"required":["body","idempotencyKey"],"type":"object"},"name":"submit_complaint","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"submit_suggestion","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Quietly report a concrete improvement that would make agents more effective. Use for material, generalizable friction directly observed during this run. Explain what happened, its impact, and a plausible improvement in your own words, with minimal useful sanitized context. No required format. Avoid duplicate root causes and aim for at most three suggestions per run. Use submit_complaint for the raw reaction. Submit proactively without routine permission requests, previews, or announcements, then immediately continue the primary task. Answer truthfully if the user asks about feedback or what you submitted. Stored in this instance with company, agent, run, and task attribution; not anonymous. Remove secrets, private prompts, customer data, and personal blame; do not forward untrusted text or logs indiscriminately. On failure, continue without retrying or complaining about submission. Never submit the same incident through both tools.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Free-form feedback. Keep it brief; no required fields, labels, or template.","maxLength":524288,"minLength":1,"type":"string"},"idempotencyKey":{"description":"A stable key for this submission. Transport replay reuses it; do not retry a failed or uncertain submission yourself.","maxLength":240,"minLength":1,"type":"string"}},"required":["body","idempotencyKey"],"type":"object"},"name":"submit_suggestion","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Inspect current task files, repositories, pending selection, binding revision, and preparation capabilities. AGENT_HOME is personal instructions and memory; task outputs belong in the workspace.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_workspaces","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized reusable workspaces. A project is optional; choosing a workspace does not assign a project.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_workspaces","outputSchema":{"items":{"additionalProperties":true,"type":"object"},"type":"array"}},{"annotations":{"exposure":"optional","operationId":"select_workspace","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Request a different task workspace at the next normal admission. Does not interrupt or wake the current run. Inspect the binding revision first and reuse requestKey on retries; existing files stay in their current folder.","inputSchema":{"additionalProperties":false,"properties":{"expectedBindingRevision":{"minimum":0,"type":"integer"},"requestKey":{"description":"Stable retry key.","minLength":1,"type":"string"},"selection":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"task_directory"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"existing"},"workspaceId":{"description":"Existing authorized workspace ID.","minLength":1,"type":"string"}},"required":["kind","workspaceId"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"configured_source"},"mode":{"enum":["shared","managed_isolated"]},"projectWorkspaceId":{"description":"Configured source workspace ID.","minLength":1,"type":"string"}},"required":["kind","projectWorkspaceId","mode"],"type":"object"}]}},"required":["selection","expectedBindingRevision","requestKey"],"type":"object"},"name":"select_workspace","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"prepare_repository","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Request an authorized repository inside task files at the next normal admission, without creating a project. Discover repository IDs first. Does not interrupt or wake the current run. Reuse requestKey; a conflicting ref never resets existing work.","inputSchema":{"additionalProperties":false,"properties":{"ref":{"description":"Optional Git branch, tag, or commit. Default HEAD is pinned once.","minLength":1,"type":"string"},"repository":{"oneOf":[{"additionalProperties":false,"properties":{"id":{"description":"Authorized ID from list_project_repositories.","minLength":1,"type":"string"},"kind":{"const":"catalog"}},"required":["kind","id"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"url"},"url":{"description":"Existing HTTPS GitHub repository URL; uncataloged URLs use public anonymous access only.","minLength":1,"type":"string"}},"required":["kind","url"],"type":"object"}]},"requestKey":{"description":"Stable retry key.","minLength":1,"type":"string"}},"required":["repository","requestKey"],"type":"object"},"name":"prepare_repository","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 e45555acc0e..5e979e4447c 100644 --- a/packages/paperclip-runner/generated/semantic-action-catalog.json +++ b/packages/paperclip-runner/generated/semantic-action-catalog.json @@ -112,7 +112,7 @@ "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. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.", + "description": "Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses the same route permissions as agent REST access. Prefer dedicated tools for common workflows; route denials still apply. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.", "effect": "write", "inputSchema": { "additionalProperties": false, 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 7a12f8c3b22..7063601981d 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:73f19996d9e1b46e957ba732354eb24daf4e96776ae121ea059acfaf01f56ef6", + "catalogSha256": "sha256:2de31fff50aed0a7b28e222084695cae5d7ba04612737881114edff0dbc2781c", "driverContractVersion": 1, "driverKind": "paperclip-deterministic", "driverVersion": "1.0.0" diff --git a/packages/paperclip-runner/protocol/manifest.json b/packages/paperclip-runner/protocol/manifest.json index 234c6e49139..1660a1367e1 100644 --- a/packages/paperclip-runner/protocol/manifest.json +++ b/packages/paperclip-runner/protocol/manifest.json @@ -175,7 +175,7 @@ }, { "path": "fixtures/evals/native-execution-seeded.json", - "sha256": "872f556fd39a4b7e50a902d09c8b42f8fc729030d03642983fa16872f623e1ef", + "sha256": "a564abe2d4dd502913250c93a0bbdd6dc06f2556efff6d93d577fcb9011e4a70", "expectation": "accept", "compatibilityCase": "canonical" }, diff --git a/packages/paperclip-runner/src/backends/harness-driver-backend.ts b/packages/paperclip-runner/src/backends/harness-driver-backend.ts index fcdb43e16b7..abda538f49b 100644 --- a/packages/paperclip-runner/src/backends/harness-driver-backend.ts +++ b/packages/paperclip-runner/src/backends/harness-driver-backend.ts @@ -154,6 +154,7 @@ export class HarnessDriverBackend implements NativeSessionBackend { }; const recoveryOptions: HarnessSessionRecoveryOptions = { signal: options.signal, + normalizeReplayPayload: options.normalizeReplayPayload, }; const recovered = await this.#driver.recoverSession( persisted, diff --git a/packages/paperclip-runner/src/contracts/control-plane-port.ts b/packages/paperclip-runner/src/contracts/control-plane-port.ts index d8773168683..b219c207b3a 100644 --- a/packages/paperclip-runner/src/contracts/control-plane-port.ts +++ b/packages/paperclip-runner/src/contracts/control-plane-port.ts @@ -73,6 +73,7 @@ export interface CompleteControlPlaneRunInput { * runner package never imports the server, UI, or database that sits behind it. */ export interface ControlPlanePort { + normalizeReplayPayload?(payload: PrpEvent["payload"]): PrpEvent["payload"]; openRun(input: OpenControlPlaneRunInput): Promise; loadSessionCheckpoint?(): Promise; checkpointSession?( diff --git a/packages/paperclip-runner/src/contracts/harness-driver.ts b/packages/paperclip-runner/src/contracts/harness-driver.ts index a520a146431..88340f94b80 100644 --- a/packages/paperclip-runner/src/contracts/harness-driver.ts +++ b/packages/paperclip-runner/src/contracts/harness-driver.ts @@ -57,6 +57,7 @@ export interface OpenHarnessSessionInput { } export interface HarnessSessionRecoveryOptions { + normalizeReplayPayload?(payload: PrpEvent["payload"]): PrpEvent["payload"]; /** Abort provider recovery and release any not-yet-returned provider state. */ signal: AbortSignal; } diff --git a/packages/paperclip-runner/src/contracts/native-session-backend.ts b/packages/paperclip-runner/src/contracts/native-session-backend.ts index 251d10748e7..94921f3e247 100644 --- a/packages/paperclip-runner/src/contracts/native-session-backend.ts +++ b/packages/paperclip-runner/src/contracts/native-session-backend.ts @@ -40,6 +40,7 @@ export interface OpenNativeSessionInput { } export interface NativeSessionRecoveryOptions { + normalizeReplayPayload?(payload: PrpEvent["payload"]): PrpEvent["payload"]; /** Abort provider recovery and release any not-yet-returned provider state. */ signal: AbortSignal; } diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts index 6f3b5ad8a76..a4f2ae1aa6e 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts @@ -623,6 +623,7 @@ export class CodexAppServerDriver implements HarnessDriver { resumed: true, activeTurnId: recoveredActiveTurnId, semanticResult: snapshot.semanticResult ?? null, + normalizeReplayPayload: options.normalizeReplayPayload, terminalTurns: snapshot.terminalTurns ?? [], codexUsageBaseline: snapshot.codexUsageBaseline, dispositionOnlyRecoveryConsumed, @@ -923,6 +924,7 @@ export class CodexAppServerDriver implements HarnessDriver { resumed: boolean; activeTurnId?: string | null; semanticResult?: PersistedHarnessSemanticResult | null; + normalizeReplayPayload?: HarnessSessionRecoveryOptions["normalizeReplayPayload"]; terminalTurns?: PersistedHarnessTurnTerminal[]; codexUsageBaseline?: PersistedHarnessSession["codexUsageBaseline"]; dispositionOnlyRecoveryConsumed?: boolean; diff --git a/packages/paperclip-runner/src/drivers/codex/codex-session-state.ts b/packages/paperclip-runner/src/drivers/codex/codex-session-state.ts index 318ad4cc7b5..116516d9450 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-session-state.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-session-state.ts @@ -1,6 +1,7 @@ import { type CodexUsageBaseline, codexRunUsage } from "./codex-usage-baseline.js"; import type { HarnessRuntimeRequest, + HarnessSessionRecoveryOptions, HarnessThreadGoal, HarnessThreadLineageEntry, PersistedHarnessSemanticResult, @@ -112,6 +113,7 @@ export class CodexSessionState { codexUsageBaseline: CodexUsageBaseline | null = null; result: PrpStructuredRunResult | null = null; resultFingerprint: string | null = null; + readonly normalizeReplayPayload?: HarnessSessionRecoveryOptions["normalizeReplayPayload"]; resultCallId: string | null = null; resultTurnId: string | null = null; turnStartPending = false; @@ -158,6 +160,7 @@ export class CodexSessionState { resumed: boolean; activeTurnId?: string | null; semanticResult?: PersistedHarnessSemanticResult | null; + normalizeReplayPayload?: HarnessSessionRecoveryOptions["normalizeReplayPayload"]; terminalTurns?: PersistedHarnessTurnTerminal[]; codexUsageBaseline?: CodexUsageBaseline; dispositionOnlyRecoveryConsumed?: boolean; @@ -214,6 +217,7 @@ export class CodexSessionState { structuredClone(input.opened.lineage), ); } + this.normalizeReplayPayload = input.normalizeReplayPayload; if (input.semanticResult) { const validation = validatePrpStructuredRunResult( input.semanticResult.result, diff --git a/packages/paperclip-runner/src/drivers/codex/codex-session-terminal.ts b/packages/paperclip-runner/src/drivers/codex/codex-session-terminal.ts index 47612b9267b..582730399aa 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-session-terminal.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-session-terminal.ts @@ -55,7 +55,10 @@ export function admitResult( ): SemanticResultAdmission { const fingerprint = canonicalJson(result); if (state.resultFingerprint !== null) { - return state.resultFingerprint === fingerprint ? "identical" : "conflict"; + const same = state.normalizeReplayPayload + ? replayFingerprint(state, JSON.parse(state.resultFingerprint)) === replayFingerprint(state, result) + : state.resultFingerprint === fingerprint; + return same ? "identical" : "conflict"; } state.result = structuredClone(result); state.resultFingerprint = fingerprint; @@ -178,6 +181,11 @@ export function mapTerminalTurn( } } +function replayFingerprint(state: CodexSessionState, value: unknown): string { + const payload = { value }; + return canonicalJson(state.normalizeReplayPayload?.(payload) ?? payload); +} + function terminalFingerprint( state: CodexSessionState, turn: Record, @@ -201,7 +209,9 @@ export function terminalReplayConflict( candidate !== null && state.resultFingerprint !== null && state.resultTurnId === turnId && - canonicalJson(candidate) !== state.resultFingerprint + (state.normalizeReplayPayload + ? replayFingerprint(state, candidate) !== replayFingerprint(state, JSON.parse(state.resultFingerprint)) + : canonicalJson(candidate) !== state.resultFingerprint) ) { return { code: "conflicting_semantic_result", @@ -211,9 +221,10 @@ export function terminalReplayConflict( const semanticResult = state.resultTurnId === turnId ? state.result : candidate; - if ( - terminalFingerprint(state, turn, semanticResult) !== expectedFingerprint - ) { + const actualFingerprint = terminalFingerprint(state, turn, semanticResult); + if (state.normalizeReplayPayload + ? replayFingerprint(state, JSON.parse(actualFingerprint)) !== replayFingerprint(state, JSON.parse(expectedFingerprint)) + : actualFingerprint !== expectedFingerprint) { return { code: "conflicting_turn_terminal", message: "changed from its committed terminal fingerprint", diff --git a/packages/paperclip-runner/src/native-session-runtime.test.ts b/packages/paperclip-runner/src/native-session-runtime.test.ts index 610813ef9c6..ed0459c1231 100644 --- a/packages/paperclip-runner/src/native-session-runtime.test.ts +++ b/packages/paperclip-runner/src/native-session-runtime.test.ts @@ -7355,6 +7355,59 @@ describe("executeNativeSession recovery", () => { expect(completeRun).toHaveBeenCalledOnce(); }); + it.each([false, true])("confirms a redacted completion after a lost acknowledgement (conflict: %s)", async conflict => { + vi.useFakeTimers(); + try { + const completion = { ...result, summary: JSON.stringify({ operationId: "call_api", data: "private-fixture-value" }) }; + const history: PrpEvent[] = []; + const normalize = (payload: PrpEvent["payload"]): PrpEvent["payload"] => + JSON.parse(JSON.stringify(payload).replaceAll("private-fixture-value", "***REDACTED***")); + let committed!: () => void; + const saved = new Promise(resolve => { committed = resolve; }); + let lostAcknowledgement = false; + const completeRun = vi.fn(async () => undefined); + const session: NativeSession = { + identity: () => identity, + async capabilities() { return { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true }; }, + async startTurn() { return { turnId: "turn-recovery" }; }, + async *events() { yield runnerEvent(1, "turn.completed"); }, + async result() { return { result: completion, terminal, turnId: "turn-recovery" }; }, + async snapshot() { return { backendKind: "mock", sessionId: identity.sessionId, identity, activeTurnId: null }; }, + async close() {}, + }; + const port: ControlPlanePort = { + async openRun() {}, async checkpointSession() {}, normalizeReplayPayload: normalize, completeRun, + async appendEvent(event) { + const row = event as PrpEvent; + history.push({ ...row, payload: normalize(row.payload) }); + if (row.eventType === "run.result.accepted" && !lostAcknowledgement) { + lostAcknowledgement = true; + if (conflict) history.at(-1)!.payload = { ...history.at(-1)!.payload, changed: true }; + committed(); + return await new Promise(() => undefined); + } + return { cursor: history.length, highestContiguousSourceSeq: row.sourceSeq, disposition: "committed" }; + }, + async replayEvents(request) { + const events = history.filter(row => row.sourceInstanceId === request.sourceInstanceId && row.sourceSeq > request.afterSourceSeq); + return { events, highestContiguousSourceSeq: events.at(-1)?.sourceSeq ?? request.afterSourceSeq }; + }, + }; + const execution = executeNativeSession({ input, backend: { + async descriptor() { return { kind: "mock", name: "redacted-completion", version: "1", capabilities: await session.capabilities() }; }, + async openSession() { return session; }, + }, controlPlane: port, runnerInstanceId: "runner-recovery", controlPlaneInstanceId: "control-recovery", timeoutMs: 20 }); + const outcome = conflict ? expect(execution).rejects.toThrow("native_control_event_replay_conflict:1") + : expect(execution).resolves.toMatchObject({ result: completion }); + await saved; + await vi.advanceTimersByTimeAsync(25); + await outcome; + expect(completeRun).toHaveBeenCalledTimes(conflict ? 0 : 1); + expect(JSON.stringify(history)).not.toContain("private-fixture-value"); + expect(history.filter(event => event.eventType === "run.result.accepted")).toHaveLength(1); + } finally { vi.useRealTimers(); } + }); + it("keeps a reconstructed semantic result on its matched terminal turn", async () => { const semanticFingerprint = canonicalTestJson(result); const checkpoint: PersistedNativeSession = { diff --git a/packages/paperclip-runner/src/native-session-runtime.ts b/packages/paperclip-runner/src/native-session-runtime.ts index e01dd745e23..c2b223e3dfc 100644 --- a/packages/paperclip-runner/src/native-session-runtime.ts +++ b/packages/paperclip-runner/src/native-session-runtime.ts @@ -2062,6 +2062,7 @@ export async function executeNativeSession( operation: (signal) => options.backend.recoverSession!(providerRecoveryCheckpoint, { signal, + normalizeReplayPayload: options.controlPlane.normalizeReplayPayload?.bind(options.controlPlane), }), onLateResolution: async (lateRecovery) => { if (lateRecovery.session) { @@ -2761,7 +2762,8 @@ export async function executeNativeSession( if ( expected === undefined || existing.eventType !== expected.eventType || - canonicalJson(existing.payload) !== canonicalJson(expected.payload) + canonicalJson(options.controlPlane.normalizeReplayPayload?.(existing.payload) ?? existing.payload) + !== canonicalJson(options.controlPlane.normalizeReplayPayload?.(expected.payload) ?? expected.payload) ) { throw new Error( `native_control_event_replay_conflict:${existing.sourceSeq}`, diff --git a/packages/paperclip-runner/src/protocol-actions/call-api.ts b/packages/paperclip-runner/src/protocol-actions/call-api.ts index 6ce1236ddb7..083d9131c10 100644 --- a/packages/paperclip-runner/src/protocol-actions/call-api.ts +++ b/packages/paperclip-runner/src/protocol-actions/call-api.ts @@ -28,7 +28,7 @@ export const callApiAction = { }, "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. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.", + "description": "Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses the same route permissions as agent REST access. Prefer dedicated tools for common workflows; route denials still apply. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.", "note": "Production HTTP fallback; does not grant privileges or replace dedicated tools." }, "examples": { @@ -51,7 +51,7 @@ export const callApiAction = { "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. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.", + "description": "Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses the same route permissions as agent REST access. Prefer dedicated tools for common workflows; route denials still apply. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.", "exposure": "optional", "requiredClaims": [ "api:call" diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 8d7f1ca34bf..bfd3cc30543 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { Readable } from "node:stream"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CompanyPortabilityFileEntry } from "@paperclipai/shared"; +import { runnerApiResponseReservations } from "@paperclipai/db"; const companySvc = { getById: vi.fn(), @@ -195,6 +196,13 @@ vi.mock("../routes/org-chart-svg.js", () => ({ const { companyPortabilityService, dedupeImportedCompanyName, parseGitHubSourceUrl, renderYamlBlock, renderFrontmatter } = await import("../services/company-portability.js"); +function responseAssetDb(reservations: Array<{ id: string }> = []) { + return { select: () => ({ from: (table: unknown) => ({ where: () => { + const rows = table === runnerApiResponseReservations ? reservations : []; + return Object.assign(Promise.resolve(rows), { limit: async () => rows }); + } }) }) }; +} + function asTextFile(entry: CompanyPortabilityFileEntry | undefined) { expect(typeof entry).toBe("string"); return typeof entry === "string" ? entry : ""; @@ -1043,6 +1051,20 @@ describe("company portability", () => { expect(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"]).toBeDefined(); }); + it.each(["namespace", "reservation"])("excludes a private API snapshot used as a company logo (%s)", async kind => { + const storage = { getObject: vi.fn() }; + companySvc.getById.mockResolvedValue({ id: "company-1", name: "Paperclip", issuePrefix: "PAP", logoAssetId: "private-logo" }); + assetSvc.getById.mockResolvedValue({ id: "private-logo", companyId: "company-1", + objectKey: kind === "namespace" ? "company-1/runner-api/run/private.json" : "assets/companies/private-logo", contentType: "image/png" }); + const db = responseAssetDb(kind === "reservation" ? [{ id: "capture-reservation" }] : []); + const exported = await companyPortabilityService(db as any, storage as any).exportBundle("company-1", { + include: { company: true, agents: false, projects: false, issues: false }, + }); + expect(storage.getObject).not.toHaveBeenCalled(); + expect(exported.files["images/company-logo.png"]).toBeUndefined(); + expect(exported.warnings).toContain("Skipped private API response snapshot used as a company logo."); + }); + it("exports the company logo into images/ and references it from .paperclip.yaml", async () => { const storage = { getObject: vi.fn().mockResolvedValue({ @@ -1066,7 +1088,7 @@ describe("company portability", () => { originalFilename: "logo.png", }); - const portability = companyPortabilityService({} as any, storage as any); + const portability = companyPortabilityService(responseAssetDb() as any, storage as any); const exported = await portability.exportBundle("company-1", { include: { @@ -3405,7 +3427,7 @@ describe("company portability", () => { name: "ClaudeCoder", }); - const portability = companyPortabilityService({} as any, storage as any); + const portability = companyPortabilityService(responseAssetDb() as any, storage as any); const exported = await exportCompanyAndAgents(portability); exported.files["images/company-logo.png"] = { @@ -4622,7 +4644,7 @@ describe("company portability", () => { it("carries issue attachments as content-addressed blobs through export and import", async () => { const storage = fakeAttachmentStorage(); - const portability = companyPortabilityService({} as any, storage as any); + const portability = companyPortabilityService(responseAssetDb() as any, storage as any); mockAttachmentExportSources(); const sha = sha256Of("png-bytes"); @@ -4730,7 +4752,7 @@ describe("company portability", () => { it("skips all attachment imports with one warning when the target has no storage", async () => { const storage = fakeAttachmentStorage(); - const exporting = companyPortabilityService({} as any, storage as any); + const exporting = companyPortabilityService(responseAssetDb() as any, storage as any); mockAttachmentExportSources(); const exported = await exporting.exportBundle("company-1", { include: { company: true, agents: false, projects: false, issues: true }, @@ -4756,7 +4778,7 @@ describe("company portability", () => { it("fails closed when a bundle blob does not match its declared sha256", async () => { const storage = fakeAttachmentStorage(); - const portability = companyPortabilityService({} as any, storage as any); + const portability = companyPortabilityService(responseAssetDb() as any, storage as any); mockAttachmentExportSources(); const sha = sha256Of("png-bytes"); @@ -4913,9 +4935,23 @@ describe("company portability", () => { )); } + it.each(["namespace", "reservation"])("excludes private API snapshots from embedded images (%s)", async kind => { + const storage = fakeAttachmentStorage(); + mockEmbeddedAssetExportSources(); + const asset = await assetSvc.getById(EMBEDDED_ASSET_ID); + assetSvc.getById.mockResolvedValue({ ...asset, objectKey: kind === "namespace" ? "company-1/runner-api/run/private.json" : asset.objectKey }); + const db = responseAssetDb(kind === "reservation" ? [{ id: "capture-reservation" }] : []); + const exported = await companyPortabilityService(db as any, storage as any).exportBundle("company-1", { + include: { company: true, agents: false, projects: false, issues: true }, + }); + expect(Object.keys(exported.files).filter(filePath => filePath.startsWith("blobs/"))).toEqual([]); + expect(storage.getObject).not.toHaveBeenCalled(); + expect(exported.warnings).toContainEqual(expect.stringContaining("private API response snapshot")); + }); + it("carries embedded asset images through export and import with rewritten references", async () => { const storage = fakeAttachmentStorage(); - const portability = companyPortabilityService({} as any, storage as any); + const portability = companyPortabilityService(responseAssetDb() as any, storage as any); mockEmbeddedAssetExportSources(); const sha = sha256Of("embedded-image-bytes"); @@ -4999,7 +5035,7 @@ describe("company portability", () => { it("skips embedded image references that are foreign or dangling with one aggregate warning", async () => { const storage = fakeAttachmentStorage(); - const portability = companyPortabilityService({} as any, storage as any); + const portability = companyPortabilityService(responseAssetDb() as any, storage as any); const foreignAssetId = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; const missingAssetId = "12345678-1234-4123-8123-123456789abc"; mockEmbeddedAssetExportSources( @@ -5033,13 +5069,13 @@ describe("company portability", () => { expect(asTextFile(exported.files[".paperclip.yaml"])).not.toContain("embeddedAssets:"); expect(exported.manifest.embeddedAssets).toEqual([]); expect(exported.warnings).toContain( - "2 embedded image references point at assets that do not belong to this company or no longer exist; their images were not exported.", + "2 embedded image references point at assets that are unavailable for export (missing, outside this company, or private API response snapshots); their images were not exported.", ); }); it("leaves embedded image references untouched when their blob is missing at import", async () => { const storage = fakeAttachmentStorage(); - const portability = companyPortabilityService({} as any, storage as any); + const portability = companyPortabilityService(responseAssetDb() as any, storage as any); mockEmbeddedAssetExportSources(); const sha = sha256Of("embedded-image-bytes"); @@ -5073,7 +5109,7 @@ describe("company portability", () => { it("prunes embedded asset entries and blobs when the referencing files are excluded from the export selection", async () => { const storage = fakeAttachmentStorage(); - const portability = companyPortabilityService({} as any, storage as any); + const portability = companyPortabilityService(responseAssetDb() as any, storage as any); mockEmbeddedAssetExportSources(); const sha = sha256Of("embedded-image-bytes"); diff --git a/server/src/__tests__/heartbeat-task-context.test.ts b/server/src/__tests__/heartbeat-task-context.test.ts index bca00d2a403..e348b79ea71 100644 --- a/server/src/__tests__/heartbeat-task-context.test.ts +++ b/server/src/__tests__/heartbeat-task-context.test.ts @@ -89,6 +89,7 @@ describePostgres("heartbeat task context delivered to an adapter", () => { wakeCommentId: commentId, wakeCommentIds: [commentId], paperclipTaskCommunicationGuidance: "FORGED caller guidance", paperclipSkillTest: { directive: "FORGED skill" }, paperclipContinuationSummary: { body: "FORGED summary" }, + paperclipAgentJwtScope: { kind: "skill_test", issueId: f.issueId }, }); expect(context.paperclipTaskMarkdown).toContain("Assignment description canary"); expect(context.paperclipTaskMarkdown).toContain("Wake comment canary"); @@ -103,6 +104,7 @@ describePostgres("heartbeat task context delivered to an adapter", () => { } expect(context.paperclipTaskCommunicationGuidance).toBe(FAST_RESPONSE_AGENT_GUIDANCE); expect(context.paperclipSkillTest).toBeUndefined(); + expect(context.paperclipAgentJwtScope).toEqual({ kind: "standard" }); expect(context.paperclipContinuationSummary).toBeUndefined(); const digest = (value: string) => createHash("sha256").update(value).digest("hex"); expect(context.paperclipTurnContext).toMatchObject({ diff --git a/server/src/__tests__/runner-api-recovery.test.ts b/server/src/__tests__/runner-api-recovery.test.ts new file mode 100644 index 00000000000..fe45c3a6814 --- /dev/null +++ b/server/src/__tests__/runner-api-recovery.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { HarnessDriverBackend } from "../../../packages/paperclip-runner/src/backends/harness-driver-backend.js"; +import { FakeCodexTransport, makeDriver, result, WORKSPACE } from "../../../packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test-support.js"; +import { redactRunnerApiPayloads } from "../services/native-runtime/runner-api-log-redaction.js"; + +describe("private API output in provider recovery", () => { + it.each([false, true])("compares active completion through the saved projection (changed=%s)", async changed => { + const first = new FakeCodexTransport(); + const second = new FakeCodexTransport(); + const backend = new HarnessDriverBackend(makeDriver([first, second])); + const identity = { runId: "api-recovery-run", sessionId: "api-recovery-session", companyId: "company", issueId: "issue", agentId: "agent" }; + const original = await backend.openSession({ identity, workingDirectory: WORKSPACE }); + await original.startTurn({ message: { role: "user", text: "Finish the task." } }); + const receipt = { operationId: "call_api", apiOperationId: "POST /api/companies/{companyId}/exports", callId: "export", ok: true, status: 200, data: { credential: "private-recovery-value" } }; + const completion = { ...result, summary: JSON.stringify(receipt) }; + expect(await first.invoke({ id: "finish", method: "item/tool/call", params: { + threadId: "thread-1", turnId: "turn-1", callId: "finish", tool: "paperclip_finish", arguments: completion, + } })).toMatchObject({ success: true }); + const raw = await original.snapshot(); + expect(raw.activeTurnId).toBe("turn-1"); + expect(raw.semanticResult).toEqual(completion); + const checkpoint = redactRunnerApiPayloads(raw); + expect(JSON.stringify(checkpoint)).not.toContain("private-recovery-value"); + expect(JSON.stringify(checkpoint)).toContain("apiPayloadSha256"); + await original.close({ reason: "simulate restart before the terminal checkpoint" }); + const providerResult = changed ? { ...completion, summary: JSON.stringify({ ...receipt, data: { credential: "changed-value" } }) } : completion; + second.readResponse = { thread: { id: "thread-1", sessionId: "provider-session-1", cwd: WORKSPACE, + turns: [{ id: "turn-1", status: "completed", items: [{ id: "finish", type: "agentMessage", text: JSON.stringify(providerResult) }] }], + } }; + const recovered = await backend.recoverSession(checkpoint, { + signal: new AbortController().signal, normalizeReplayPayload: redactRunnerApiPayloads, + }); + if (changed) { + expect(recovered).toMatchObject({ recovered: false, reason: expect.stringContaining("conflicting") }); + } else { + expect(recovered.recovered).toBe(true); + expect((await recovered.session!.snapshot()).semanticResult).toEqual(checkpoint.semanticResult); + await recovered.session!.close({ reason: "verified recovery" }); + } + expect(second.calls.some(call => call.method === "turn/start")).toBe(false); + }); +}); diff --git a/server/src/routes/assets.ts b/server/src/routes/assets.ts index 11fe0eb8e68..5f48886baa3 100644 --- a/server/src/routes/assets.ts +++ b/server/src/routes/assets.ts @@ -1,13 +1,10 @@ -import { and, eq } from "drizzle-orm"; -import { runnerApiResponseReservations, heartbeatRuns, issues, issueAttachments } from "@paperclipai/db"; -import { authorizationService } from "../services/authorization.js"; -import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js"; +import { canActorReadAssetContent } from "../services/asset-content-access.js"; import { Router, type Request, type Response } from "express"; import multer from "multer"; import createDOMPurify from "dompurify"; import { JSDOM } from "jsdom"; import type { Db } from "@paperclipai/db"; -import { ASSET_NAMESPACE_RULE, isUuidLike, createAssetImageMetadataSchema } from "@paperclipai/shared"; +import { ASSET_NAMESPACE_RULE, createAssetImageMetadataSchema } from "@paperclipai/shared"; import type { StorageService } from "../storage/types.js"; import { assetService, logActivity } from "../services/index.js"; import { @@ -331,41 +328,9 @@ export function assetRoutes(db: Db, storage: StorageService) { const assetId = req.params.assetId as string; const asset = await getAccessibleResource(req, res, svc.getById(assetId), "Asset not found"); if (!asset) return; - const access = authorizationService(db); - // Persisted storage namespaces retain provenance after attachment rows are deleted. - const objectParts = asset.objectKey.split("/"); - const isDraftImage = objectParts[1] === "assets" && objectParts[2] === "issues" && objectParts[3] === "drafts"; - const namespace = objectParts[1] === "assets" ? objectParts[2] : objectParts[1]; - const sourceIssueId = objectParts[1] === "assets" ? objectParts[3] : objectParts[2]; - const sourceExists = namespace !== "issues" || isDraftImage || (sourceIssueId && isUuidLike(sourceIssueId) && await db.select({ id: issues.id }).from(issues) - .where(and(eq(issues.id, sourceIssueId), eq(issues.companyId, asset.companyId))).limit(1).then(rows => rows.length > 0)); - if (namespace === "issues" && !isDraftImage && (!sourceExists || !(await access.decide({ actor: req.actor, action: "issue:read", - resource: { type: "issue", companyId: asset.companyId, issueId: sourceIssueId } })).allowed)) { + if (!(await canActorReadAssetContent(db, req.actor, asset))) { res.status(404).json({ error: "Asset not found" }); return; } - const captures = await db.select({ run: heartbeatRuns }).from(runnerApiResponseReservations) - .leftJoin(heartbeatRuns, eq(heartbeatRuns.id, runnerApiResponseReservations.runId)) - .where(and(eq(runnerApiResponseReservations.assetId, asset.id), eq(runnerApiResponseReservations.companyId, asset.companyId))); - if (namespace === "runner-api" && !captures.length) { res.status(404).json({ error: "Asset not found" }); return; } - for (const { run } of captures) { - if (!run || !(await canActorReadHeartbeatRun(db, access, req.actor, run))) { - res.status(404).json({ error: "Asset not found" }); return; - } - } - const attachments = await db.select({ issueId: issueAttachments.issueId }).from(issueAttachments) - .where(and(eq(issueAttachments.assetId, asset.id), eq(issueAttachments.companyId, asset.companyId))); - if (isDraftImage && !attachments.length && !(req.actor.type === "board" - ? req.actor.source === "local_implicit" || req.actor.isInstanceAdmin || req.actor.userId === asset.createdByUserId - : req.actor.type === "agent" && req.actor.agentId === asset.createdByAgentId - && (!asset.createdByUserId || req.actor.onBehalfOfUserId === asset.createdByUserId))) { - res.status(404).json({ error: "Asset not found" }); return; - } - for (const attachment of attachments) { - if (!(await access.decide({ actor: req.actor, action: "issue:read", resource: { - type: "issue", companyId: asset.companyId, issueId: attachment.issueId, - } })).allowed) { res.status(404).json({ error: "Asset not found" }); return; } - } - // Use the persisted size only after resource authorization. Single ranges // keep saved API text pages bounded all the way to disk or object storage. diff --git a/server/src/services/asset-content-access.ts b/server/src/services/asset-content-access.ts new file mode 100644 index 00000000000..0fa83f066ed --- /dev/null +++ b/server/src/services/asset-content-access.ts @@ -0,0 +1,59 @@ +import { and, eq } from "drizzle-orm"; +import { assets, heartbeatRuns, issues, issueAttachments, runnerApiResponseReservations, type Db } from "@paperclipai/db"; +import { isUuidLike } from "@paperclipai/shared"; +import { authorizationService, type AuthorizationActor } from "./authorization.js"; +import { canActorReadHeartbeatRun } from "./heartbeat-run-privacy.js"; + +type Asset = Pick; + +function namespace(asset: Asset) { + const parts = asset.objectKey.split("/"); + return parts[1] === "assets" ? parts[2] : parts[1]; +} + +export async function isRunnerApiResponseAsset(db: Pick, asset: Asset): Promise { + if (namespace(asset) === "runner-api") return true; + return !!(await db.select({ id: runnerApiResponseReservations.id }).from(runnerApiResponseReservations) + .where(and(eq(runnerApiResponseReservations.assetId, asset.id), eq(runnerApiResponseReservations.companyId, asset.companyId))).limit(1))[0]; +} + +export async function canActorReadAssetContent(db: Db, actor: AuthorizationActor, asset: Asset): Promise { + const access = authorizationService(db); + if (actor.type === "agent") { + if (actor.companyId !== asset.companyId) return false; + } else if (actor.type !== "board" || !(await access.decide({ actor, action: "company_scope:read", resource: { type: "company", companyId: asset.companyId } })).allowed) return false; + const parts = asset.objectKey.split("/"); + const isDraft = parts[1] === "assets" && parts[2] === "issues" && parts[3] === "drafts"; + const sourceIssueId = parts[1] === "assets" ? parts[3] : parts[2]; + if (namespace(asset) === "issues" && !isDraft) { + if (!sourceIssueId || !isUuidLike(sourceIssueId)) return false; + const [issue] = await db.select({ id: issues.id }).from(issues) + .where(and(eq(issues.id, sourceIssueId), eq(issues.companyId, asset.companyId))).limit(1); + if (!issue || !(await access.decide({ actor, action: "issue:read", resource: { + type: "issue", companyId: asset.companyId, issueId: sourceIssueId, + } })).allowed) return false; + } + const captures = await db.select({ run: heartbeatRuns }).from(runnerApiResponseReservations) + .leftJoin(heartbeatRuns, eq(heartbeatRuns.id, runnerApiResponseReservations.runId)) + .where(and(eq(runnerApiResponseReservations.assetId, asset.id), eq(runnerApiResponseReservations.companyId, asset.companyId))); + if (namespace(asset) === "runner-api" && !captures.length) return false; + for (const { run } of captures) { + const owns = run && (actor.type === "agent" + ? actor.agentId === run.agentId && (!run.responsibleUserId || actor.onBehalfOfUserId === run.responsibleUserId) + : actor.type === "board" && (actor.source === "local_implicit" || actor.isInstanceAdmin + || (Boolean(actor.userId) && actor.userId === run.responsibleUserId))); + if (!owns || !run || !(await canActorReadHeartbeatRun(db, access, actor, run))) return false; + } + const attachments = await db.select({ issueId: issueAttachments.issueId }).from(issueAttachments) + .where(and(eq(issueAttachments.assetId, asset.id), eq(issueAttachments.companyId, asset.companyId))); + if (isDraft && !attachments.length && !(actor.type === "board" + ? actor.source === "local_implicit" || actor.isInstanceAdmin || actor.userId === asset.createdByUserId + : actor.type === "agent" && actor.agentId === asset.createdByAgentId + && (!asset.createdByUserId || actor.onBehalfOfUserId === asset.createdByUserId))) return false; + for (const attachment of attachments) { + if (!(await access.decide({ actor, action: "issue:read", resource: { + type: "issue", companyId: asset.companyId, issueId: attachment.issueId, + } })).allowed) return false; + } + return true; +} diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index 149b30b4fe9..06df82c6ee0 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -1,3 +1,4 @@ +import { isRunnerApiResponseAsset } from "./asset-content-access.js"; import { deleteCompany } from "./company-deletion.js"; import { createAgentLifecycle } from "./agent-lifecycle.js"; import { publishAccountingActivities } from "./accounting-transaction.js"; @@ -316,11 +317,14 @@ export function companyService(db: Db, budgetHooks: BudgetServiceHooks = {}) { if (logoAssetId !== undefined && logoAssetId !== null) { const nextLogoAsset = await tx - .select({ id: assets.id, companyId: assets.companyId }) + .select() .from(assets) .where(eq(assets.id, logoAssetId)) .then((rows) => rows[0] ?? null); if (!nextLogoAsset) throw notFound("Logo asset not found"); + if (await isRunnerApiResponseAsset(tx, nextLogoAsset)) { + throw unprocessable("API response snapshots cannot be company logos; upload a logo asset"); + } if (nextLogoAsset.companyId !== existing.id) { throw unprocessable("Logo asset must belong to the same company"); } diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 1d7b983f347..8dca564153b 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -80,6 +80,7 @@ import { accessService } from "./access.js"; import { agentService } from "./agents.js"; import { agentInstructionsBundleMode, agentInstructionsService } from "./agent-instructions.js"; import { assetService } from "./assets.js"; +import { isRunnerApiResponseAsset } from "./asset-content-access.js"; import { generateReadme } from "./company-export-readme.js"; import { renderOrgChartPng, type OrgNode } from "../routes/org-chart-svg.js"; import { companySkillService } from "./company-skills.js"; @@ -4116,6 +4117,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const logoAsset = await assetRecords.getById(company.logoAssetId); if (!logoAsset) { warnings.push(`Skipped company logo ${company.logoAssetId} because the asset record was not found.`); + } else if (await isRunnerApiResponseAsset(db, logoAsset)) { + warnings.push("Skipped private API response snapshot used as a company logo."); } else { try { const object = await storage.getObject(company.id, logoAsset.objectKey); @@ -4659,7 +4662,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { // Embedded references only pull bytes for assets owned by the // exporting company: a crafted URL naming another company's asset id // must not leak that asset's bytes into the bundle. - if (!asset || asset.companyId !== companyId) { + if (!asset || asset.companyId !== companyId || await isRunnerApiResponseAsset(db, asset)) { unownedEmbeddedAssetRefCount += 1; continue; } @@ -4690,8 +4693,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } if (unownedEmbeddedAssetRefCount > 0) { warnings.push(unownedEmbeddedAssetRefCount === 1 - ? "1 embedded image reference points at an asset that does not belong to this company or no longer exists; its image was not exported." - : `${unownedEmbeddedAssetRefCount} embedded image references point at assets that do not belong to this company or no longer exist; their images were not exported.`); + ? "1 embedded image reference points at an asset that is unavailable for export (missing, outside this company, or a private API response snapshot); its image was not exported." + : `${unownedEmbeddedAssetRefCount} embedded image references point at assets that are unavailable for export (missing, outside this company, or private API response snapshots); their images were not exported.`); } const paperclipExtensionPath = ".paperclip.yaml"; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index c8df58d270a..4a6d6a32611 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2576,7 +2576,7 @@ export function heartbeatService( } = await prepareHeartbeatTaskContext(db, { run, agent, task: { issueRef, issueContext, wakeCommentContext, context, taskSession }, - policy: { trustPreset, experimentalInstanceSettings }, + policy: { trustPreset, experimentalInstanceSettings, recovering: Boolean(runOptions.nativeRestartRecovery || runOptions.nativeLeaseOwner) }, services: { issuesSvc, getPinnedSkillTestContext }, }); const sourceWorkspaceConfig = buildExecutionWorkspaceAdapterConfig({ diff --git a/server/src/services/heartbeat/task-context.ts b/server/src/services/heartbeat/task-context.ts index 56210bd00e1..d346d0eb17f 100644 --- a/server/src/services/heartbeat/task-context.ts +++ b/server/src/services/heartbeat/task-context.ts @@ -34,6 +34,7 @@ export interface HeartbeatTaskContextInput { taskSession: Pick | null; }; policy: { + recovering?: boolean; trustPreset: Pick; experimentalInstanceSettings: { enableSimplifiedEnglishInteractions?: boolean }; }; @@ -79,6 +80,16 @@ export async function prepareHeartbeatTaskContext(db: Db, input: HeartbeatTaskCo } else { delete context.paperclipContinuationSummary; } + const admittedScope = parseObject(context.paperclipAgentJwtScope); + if (!input.policy.recovering || (admittedScope.kind !== "standard" && admittedScope.kind !== "skill_test")) { + const savedIssue = parseObject(context.paperclipIssue); + const skillTest = input.policy.recovering + ? savedIssue.workMode === "skill_test" || Boolean(context.paperclipSkillTest) + : issueRef?.workMode === "skill_test"; + context.paperclipAgentJwtScope = skillTest && issueRef + ? { kind: "skill_test", issueId: issueRef.id } + : { kind: "standard" }; + } const pinnedSkillTestContext = issueRef?.workMode === "skill_test" ? await getPinnedSkillTestContext(agent.companyId, issueRef.id) diff --git a/server/src/services/native-runtime/confirmation-comment-api.integration.test.ts b/server/src/services/native-runtime/confirmation-comment-api.integration.test.ts index fd497938cce..a85d6dd5277 100644 --- a/server/src/services/native-runtime/confirmation-comment-api.integration.test.ts +++ b/server/src/services/native-runtime/confirmation-comment-api.integration.test.ts @@ -6,7 +6,6 @@ import { issueComments, issueThreadInteractions, issues } from "@paperclipai/db" import { createLocalAgentJwt } from "../../agent-auth-jwt.js"; import { startRunnerApiTestServer } from "../../__tests__/helpers/runner-api-server.js"; import { issueThreadInteractionService } from "../issue-thread-interactions.js"; -import { runnerApiMutationRestriction } from "./runner-api-policy.js"; describe("confirmation reply through the native API tool", () => { let server: Awaited>; @@ -52,10 +51,11 @@ describe("confirmation reply through the native API tool", () => { const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.userId); expect((await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ ...call.arguments.body, actorUserId: f.userId }) })).status).toBe(400); }); - it("does not open the ordinary interaction or governance mutation routes", () => { - for (const operation of ["accept", "reject", "respond", "verdicts", "withdraw"]) { - expect(runnerApiMutationRestriction(`/api/issues/{id}/interactions/{interactionId}/${operation}`)).not.toBeNull(); - } - expect(runnerApiMutationRestriction("/api/issues/{id}/interactions/{interactionId}/resolve-from-comment")).toBeNull(); + it("preserves formal approval authority in the HTTP route", async () => { + const { f } = await seed(); + expect(await f.authority.execute({ tool: "call_api", callId: randomUUID(), arguments: { + operationId: "POST /api/approvals/{id}/approve", pathParams: { id: f.approvalId }, body: {}, + } })).toMatchObject({ ok: false, status: 403 }); + expect((await f.snapshot()).approvals.find(row => row.id === f.approvalId)?.status).toBe("pending"); }); }); diff --git a/server/src/services/native-runtime/paperclip-control-plane-port.test.ts b/server/src/services/native-runtime/paperclip-control-plane-port.test.ts index 9acb7e6a091..ff6807c6bfa 100644 --- a/server/src/services/native-runtime/paperclip-control-plane-port.test.ts +++ b/server/src/services/native-runtime/paperclip-control-plane-port.test.ts @@ -361,6 +361,53 @@ describe("PaperclipControlPlanePort conformance", () => { terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL })).resolves.toBeUndefined(); }); + it("omits API credentials from persisted tool events and checkpoints without changing wire results", async () => { + const identity = { ...CONTROL_PLANE_CONFORMANCE_OPEN.identity, runId: randomUUID(), sessionId: randomUUID() }; + const runnerId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: identity.runId, companyId: identity.companyId, agentId: identity.agentId, + status: "running", runtimeMode: "native", nativeIssueId: identity.issueId, + nativeSessionId: identity.sessionId, runnerInstanceId: runnerId, + completionContractId: contractId, completionContractSha256: contractSha, + contextSnapshot: { issueId: identity.issueId }, + }); + const observed: PrpEvent[] = []; + const port = new PaperclipControlPlanePort(db, { + ...identity, completionContractId: contractId, completionContractSha256: contractSha, + sourceInstanceId: runnerId, controlPlaneSourceInstanceId: `api-${identity.runId}`, + }, { onCommittedEvent: async event => { observed.push(event); } }); + await port.openRun({ identity, backendKind: "mock", sourceInstanceId: runnerId }); + const result = { operationId: "call_api", apiOperationId: "POST /api/companies/{companyId}/exports", callId: "private-export", + status: 200, ok: true, data: { credentials: "private-history-fixture-value" } }; + const wireResult = { success: true, contentItems: [{ type: "inputText", text: JSON.stringify(result) }] }; + const event: PrpEvent = { + schema: "paperclip.prp.event.v1", sourceEventId: `${runnerId}:1`, sourceSeq: 1, + sourceInstanceId: runnerId, sourceKind: "runner", runId: identity.runId, + normalizedSessionId: identity.sessionId, eventType: "item.completed", schemaVersion: 1, + priority: 0, emittedAt: new Date().toISOString(), + payload: { kind: "dynamicToolCall", item: { type: "tool_result", id: "private-export", tool_use_id: "private-export", result: wireResult } }, + }; + await port.appendEvent(event); + expect((await port.appendEvent(event)).disposition).toBe("duplicate"); + expect(port.normalizeReplayPayload(event.payload)).toEqual(observed[0]?.payload); + expect(port.normalizeReplayPayload(observed[0]!.payload)).toEqual(observed[0]?.payload); + await expect(port.appendEvent({ ...event, payload: { ...event.payload, item: { + ...(event.payload.item as object), result: { success: true, contentItems: [{ type: "inputText", + text: JSON.stringify({ ...result, data: { credentials: "different-history-fixture-value" } }), + }] }, + } } })).rejects.toThrow(); + await port.checkpointSession({ backendKind: "mock", sessionId: identity.sessionId, identity, + semanticResult: { ...CONTROL_PLANE_CONFORMANCE_RESULT, summary: JSON.stringify(result) }, + }); + const persisted = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, identity.runId)); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, identity.runId)); + for (const saved of [persisted, observed, run.runnerProfileJson]) { + expect(JSON.stringify(saved)).not.toContain("private-history-fixture-value"); + expect(JSON.stringify(saved)).toContain("apiPayloadRedacted"); + } + expect(result.data.credentials).toBe("private-history-fixture-value"); + }); + it("redacts identity private material before persisting native events and checkpoints", async () => { const identity = { ...CONTROL_PLANE_CONFORMANCE_OPEN.identity, runId: randomUUID(), sessionId: randomUUID() }; const runnerId = randomUUID(); diff --git a/server/src/services/native-runtime/paperclip-control-plane-port.ts b/server/src/services/native-runtime/paperclip-control-plane-port.ts index 1e7937edd6e..ecdc639e74c 100644 --- a/server/src/services/native-runtime/paperclip-control-plane-port.ts +++ b/server/src/services/native-runtime/paperclip-control-plane-port.ts @@ -27,6 +27,7 @@ import { import { appendHeartbeatRunEvent } from "../heartbeat-run-events.js"; import { publishChatPublicationCommitSignal } from "../chat-publication-reconciliation.js"; import { nativeSha256 } from "./canonical.js"; +import { redactRunnerApiPayloads } from "./runner-api-log-redaction.js"; export interface PaperclipControlPlaneBinding { companyId: string; @@ -172,9 +173,13 @@ export class PaperclipControlPlanePort implements ControlPlanePort { return structuredClone(snapshot as PersistedNativeSession); } + normalizeReplayPayload(payload: PrpEvent["payload"]): PrpEvent["payload"] { + return redactRunnerApiPayloads(this.#identityRedactor.redact(payload)); + } + async checkpointSession(snapshot: PersistedNativeSession, options?: { signal: AbortSignal }): Promise { this.#assertActive(options); - snapshot = this.#identityRedactor.redact(snapshot); + snapshot = redactRunnerApiPayloads(this.#identityRedactor.redact(snapshot)); const identity = snapshot.identity; if ( identity.companyId !== this.#binding.companyId @@ -251,6 +256,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort { } } } + event = redactRunnerApiPayloads(event); const persisted = await this.#db.transaction(async tx => { this.#assertActive(options); const receipt = await appendHeartbeatRunEvent(tx as unknown as Db, { @@ -331,7 +337,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort { async completeRun(value: NativeRunResult | CompleteControlPlaneRunInput, options?: { signal: AbortSignal }): Promise { this.#assertActive(options); - value = this.#identityRedactor.redact(value); + value = redactRunnerApiPayloads(this.#identityRedactor.redact(value)); if (!isCompleteInput(value)) throw new Error("native_structured_result_required"); // Capture compatibility diagnostics before canonical validation removes // legacy/non-actionable attention payloads. They are operator evidence, 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 058500e8d8a..1e4c7cab6b8 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -42,6 +42,9 @@ 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 { canActorReadAssetContent } from "../asset-content-access.js"; +import { getSecretProvider } from "../../secrets/provider-registry.js"; +import { runnerApiSensitiveResponse } from "./runner-api-sensitive-response.js"; import { workspaceFileResourceService } from "../workspace-file-resources.js"; import { badRequest, forbidden, notFound, HttpError } from "../../errors.js"; import { runnerApiCatalog, searchRunnerApi } from "./runner-api-catalog.js"; @@ -498,13 +501,13 @@ export class PaperclipRunnerToolAuthority { } case "create_skill": { const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL; - const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, context.actor.adapterType, this.binding.runId, context.run.responsibleUserId); + const token = this.#apiToken(context); if (!apiUrl || !token) throw new Error("Skill tool authentication is unavailable"); return callCreateSkillTool({ arguments: input, apiUrl, token, companyId: this.binding.companyId }); } case "update_skill": { const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL; - const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, context.actor.adapterType, this.binding.runId, context.run.responsibleUserId); + const token = this.#apiToken(context); if (!apiUrl || !token) throw new Error("Skill tool authentication is unavailable"); return callUpdateSkillTool({ arguments: input, apiUrl, token, companyId: this.binding.companyId }); } @@ -521,7 +524,7 @@ export class PaperclipRunnerToolAuthority { case "list_project_repositories": case "list_projects": { const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL; - const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, context.actor.adapterType, this.binding.runId, context.run.responsibleUserId); + const token = this.#apiToken(context); if (!apiUrl || !token) throw new Error("Project tool authentication is unavailable"); return callProjectTool({ name: call.tool, arguments: input, apiUrl, token, companyId: this.binding.companyId, issueId: this.binding.issueId, agentId: this.binding.agentId, @@ -703,14 +706,35 @@ export class PaperclipRunnerToolAuthority { return result; } + #apiScope(context: { + issue: { workMode: string }; + run: { contextSnapshot: unknown }; + }) { + const snapshot = record(context.run.contextSnapshot); + const admittedKind = record(snapshot.paperclipAgentJwtScope).kind; + const skillTest = admittedKind === "skill_test" || (admittedKind !== "standard" + && (Boolean(snapshot.paperclipSkillTest) || context.issue.workMode === "skill_test")); + return skillTest ? { kind: "skill_test" as const, issueId: this.binding.issueId } : { kind: "standard" as const }; + } + + #apiToken(context: { + actor: { adapterType: string }; + issue: { workMode: string }; + run: { responsibleUserId: string | null; contextSnapshot: unknown }; + }) { + return createLocalAgentJwt(this.binding.agentId, this.binding.companyId, context.actor.adapterType, + this.binding.runId, context.run.responsibleUserId, this.#apiScope(context)); + } + 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 sensitiveResponse = runnerApiSensitiveResponse(operation.method, operation.path); await this.#assertDotApiFileAccess(operation.path, input); 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); + const token = this.#apiToken(bound); if (!token) throw new Error("Paperclip run authentication is unavailable"); const execute = async () => { let reservationId: string | undefined; @@ -764,6 +788,8 @@ export class PaperclipRunnerToolAuthority { }; if (["GET", "HEAD", "OPTIONS"].includes(operation.method)) return execute(); if (!callId || callId.length > 500) throw badRequest("A bounded runner call id is required"); + const provider = sensitiveResponse ? getSecretProvider("local_encrypted") : null; + if (provider) await provider.createSecret({ value: "runner-api-receipt-key-check" }); // 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. @@ -776,6 +802,10 @@ export class PaperclipRunnerToolAuthority { const existing = record(receipts[key]); if (receipts[key]) { if (existing.digest !== digest) throw badRequest("API call id was reused with different arguments"); + if (existing.encryptedResult) { + const text = await getSecretProvider("local_encrypted").resolveVersion({ material: record(existing.encryptedResult), externalRef: null }); + return { result: JSON.parse(text) as unknown }; + } 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"); @@ -785,6 +815,9 @@ export class PaperclipRunnerToolAuthority { }); if (prior) return prior.result; const result = await execute(); + const storedResult = provider + ? { encryptedResult: (await provider.createSecret({ value: JSON.stringify(result) })).material } + : { result }; // 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, { @@ -798,7 +831,7 @@ export class PaperclipRunnerToolAuthority { 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, operationId: operation.operationId, state: "completed", result }; + receipts[key] = { digest, operationId: operation.operationId, state: "completed", ...storedResult }; await tx.update(heartbeatRuns).set({ resultJson: { ...resultJson, apiToolReceipts: receipts } }).where(eq(heartbeatRuns.id, this.binding.runId)); }); return result; @@ -914,6 +947,12 @@ export class PaperclipRunnerToolAuthority { 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"); + const assertArtifactAccess = async () => { + const current = await this.#boundContext(); + if (!(await canActorReadAssetContent(this.db, { ...this.#privacyActor(current.run), keyScope: this.#apiScope(current) }, asset))) + throw forbidden("Artifact is not available to this actor"); + }; + await assertArtifactAccess(); 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[] = []; @@ -926,6 +965,7 @@ export class PaperclipRunnerToolAuthority { chunks.push(bytes); } } finally { object.stream.destroy(); } + await assertArtifactAccess(); 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" }); @@ -1027,8 +1067,7 @@ export class PaperclipRunnerToolAuthority { return { interactionId: review.interaction.id, status: expectedStatus, deduplicated: true }; } const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL; - const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, - context.actor.adapterType, this.binding.runId, context.run.responsibleUserId); + const token = this.#apiToken(context); if (!apiUrl || !token) throw new Error("Review tool authentication is unavailable"); // Use the existing resolution route so authorization, activity, dependency // wakes and request-changes continuation have one implementation. diff --git a/server/src/services/native-runtime/runner-api-catalog.ts b/server/src/services/native-runtime/runner-api-catalog.ts index 4dddb822a8d..fb3bf8767a7 100644 --- a/server/src/services/native-runtime/runner-api-catalog.ts +++ b/server/src/services/native-runtime/runner-api-catalog.ts @@ -3,7 +3,6 @@ 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; @@ -24,7 +23,7 @@ export interface RunnerApiOperation { dedicatedToolGuidance: string; dedicatedToolCapabilities?: { name: string; description: string; supportedParameters: string[] }[]; runnerRestrictions?: string[]; - callPolicy: "rest" | "restricted" | "protocol"; + callPolicy: "rest" | "protocol"; } const METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]); @@ -82,7 +81,6 @@ export function buildRunnerApiCatalog(document: Json = buildOpenApiDocument()): for (const [verb, operation] of Object.entries(item)) { if (!METHODS.has(verb)) continue; const method = verb.toUpperCase(); - const restriction = runnerApiRestriction(method, path); const conversationalConfirmation = method === "POST" && /^\/api\/issues\/\{[^}]+\}\/interactions\/\{[^}]+\}\/resolve-from-comment$/.test(path); const taskTitleUpdate = method === "PUT" && /^\/api\/issues\/\{[^}]+\}\/title$/.test(path); @@ -99,19 +97,19 @@ export function buildRunnerApiCatalog(document: Json = buildOpenApiDocument()): responses: dereference(operation.responses ?? {}), authorization: operation["x-paperclip-authorization"] ?? { actor: "board_or_agent" }, transport: protocol ? "protocol" : "rest", - callPolicy: protocol ? "protocol" : restriction ? "restricted" : "rest", + callPolicy: protocol ? "protocol" : "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.", + runnerRestrictions: ["Active run and assignment must remain authorized.", conversationalConfirmation ? "This records an ordinary conversational confirmation under existing resolver permissions. It cannot decide governed approvals or impersonate the user." - : "Cannot replace checkout, completion, task status/ownership changes, governed approval decisions, or runner execution control. Dedicated tools retain their existing permissions."], + : "The HTTP route enforces the same endpoint, payload, company, resource and approval permissions as agent REST access."], dedicatedToolGuidance: dedicatedTools(method, path).includes("hire_agent") - ? "Use hire_agent for native teammate identity and persona fields. It fixes the reportsTo and source task context and inherits the caller's native runtime; never use call_api to supply adapter, environment, or credential configuration." - : "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.", + ? "Prefer hire_agent for native teammate identity and persona fields. It fixes the reportsTo and source task context and inherits the caller's native runtime. Additional API fields use the route's existing agent permissions." + : "Prefer an available dedicated tool for its supported fields. call_api supports the route's other operations and fields under the same agent permissions.", allowedModes: method === "GET" || method === "HEAD" || taskTitleUpdate ? ["standard", "ask", "planning", "skill_test"] : conversationalConfirmation ? ["standard", "planning", "skill_test"] : ["standard", "skill_test"], ...(skillReference ? { skillReference } : {}), diff --git a/server/src/services/native-runtime/runner-api-client.ts b/server/src/services/native-runtime/runner-api-client.ts index 0b8110c0de8..457d3141999 100644 --- a/server/src/services/native-runtime/runner-api-client.ts +++ b/server/src/services/native-runtime/runner-api-client.ts @@ -1,8 +1,8 @@ import { z } from "zod"; import { badRequest, forbidden, unprocessable } from "../../errors.js"; +import { runnerApiSensitiveResponse } from "./runner-api-sensitive-response.js"; import { runnerApiOperation, type RunnerApiOperation } from "./runner-api-catalog.js"; import { captureRunnerApiResponse, type RunnerApiResponseBody } from "./runner-api-response.js"; -import { runnerApiRestriction } from "./runner-api-policy.js"; import { RunnerApiResponseLimitError, RunnerApiResponseCleanupError, RUNNER_API_RESPONSE_MAX_BYTES } from "./runner-api-response-limits.js"; /** Request/upload bound only. Response capture has a separate streamed limit. */ @@ -54,37 +54,8 @@ export function validateRunnerApiCall(value: unknown, context: RunnerApiContext) const operation = runnerApiOperation(input.operationId); if (operation.transport !== "rest") throw unprocessable("This endpoint requires its existing protocol client; call_api supports REST only"); if (input.responseText && operation.method !== "GET") throw badRequest("responseText requires a GET operation; read the saved artifact instead of repeating a mutation"); - 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; @@ -151,6 +122,7 @@ export async function readBoundedResponse(response: Response, maxBytes = RUNNER_ export async function executeRunnerApi(input: RunnerApiCall, context: RunnerApiContext, io: RunnerApiIo) { const { operation, input: validated } = validateRunnerApiCall(input, context); input = validated; + const sensitiveResponse = runnerApiSensitiveResponse(operation.method, operation.path); 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 }); @@ -215,7 +187,11 @@ export async function executeRunnerApi(input: RunnerApiCall, context: RunnerApiC try { response = await (io.fetch ?? fetch)(url, { method: operation.method, headers, body, redirect: "manual", signal: controller.signal }); clearTimeout(timeout); - if (assetTextRange && response.ok) { + if (sensitiveResponse) { + timeout = setTimeout(() => controller.abort(), RUNNER_API_TIMEOUT_MS); + bytes = await readBoundedResponse(response); + await io.beforeDispatch?.(); + } else if (assetTextRange && response.ok) { timeout = setTimeout(() => controller.abort(), RUNNER_API_TIMEOUT_MS); bytes = await readBoundedResponse(response, textLimit + 2); } else { @@ -294,10 +270,11 @@ export async function executeRunnerApi(input: RunnerApiCall, context: RunnerApiC guidance: "Read this asset with responseText: { offsetBytes: 0 }. Continue with responseText.nextOffsetBytes until null." }; } if (!totalBytes) return { ...base, data: null }; - if (totalBytes > RUNNER_API_INLINE_BYTES || !/json|^text\//i.test(type)) { + if (!sensitiveResponse && (totalBytes > RUNNER_API_INLINE_BYTES || !/json|^text\//i.test(type))) { return { ...base, artifact: await saveResponse(captured?.body ?? bytes, type), byteSize: totalBytes, preview: /json|^text\//i.test(type) ? bytes.subarray(0, 2000).toString("utf8") : null, ...(/json|^text\//i.test(type) ? { guidance: "Read the saved artifact with GET /api/assets/{assetId}/content and responseText: { offsetBytes: 0 }. Continue with responseText.nextOffsetBytes until null. Do not repeat a mutation to read its response." } : {}) }; } + if (sensitiveResponse && !/json|^text\//i.test(type)) return { ...base, ok: false, error: "credential_response_requires_text_content" }; const text = bytes.toString("utf8"); if (/json/i.test(type)) { try { return { ...base, data: JSON.parse(text) as unknown }; } diff --git a/server/src/services/native-runtime/runner-api-log-redaction.test.ts b/server/src/services/native-runtime/runner-api-log-redaction.test.ts new file mode 100644 index 00000000000..eae4316583e --- /dev/null +++ b/server/src/services/native-runtime/runner-api-log-redaction.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { redactRunnerApiPayloads } from "./runner-api-log-redaction.js"; + +describe("API payload persistence redaction", () => { + it.each(["POST /api/agents/me/secrets/{key}/value", "POST /api/companies/{companyId}/exports", "GET /api/projects/{id}"])("preserves private HTTP results on the wire, but omits %s from history", operationId => { + const result = { operationId: "call_api", apiOperationId: operationId, status: 200, ok: true, data: { value: "private-fixture-value" } }; + const event = { item: { type: "tool_result", result } }; + const saved = redactRunnerApiPayloads(event); + expect(saved.item.result).toMatchObject({ operationId: "call_api", apiOperationId: operationId, status: 200, apiPayloadRedacted: true }); + expect(JSON.stringify(saved)).not.toContain("private-fixture-value"); + expect(event.item.result.data.value).toBe("private-fixture-value"); + expect(redactRunnerApiPayloads(event)).toEqual(saved); + expect(redactRunnerApiPayloads(saved)).toEqual(saved); + expect((redactRunnerApiPayloads({ ...result, data: { value: "another-fixture-value" } }) as any).apiPayloadSha256) + .not.toBe((saved.item.result as any).apiPayloadSha256); + }); + it("redacts serialized legacy receipts and API request arguments", () => { + const value = { text: JSON.stringify({ result: { operationId: "POST /api/agents/me/secrets/{key}/value", data: { value: "private-fixture-value" } } }), + input: { operationId: "POST /api/agents/me/secret-proposals", body: { value: "proposal-fixture-value" } } }; + const saved = redactRunnerApiPayloads(value); + expect(JSON.stringify(saved)).not.toContain("private-fixture-value"); + expect(JSON.stringify(saved)).not.toContain("proposal-fixture-value"); + expect(JSON.parse(saved.text).result.apiPayloadRedacted).toBe(true); + }); + it("preserves unrelated tool output and JSON text formatting", () => { + const value = { result: { operationId: "get_task_context", data: { title: "Work" } }, text: '{ "ordinary": "output" }' }; + expect(redactRunnerApiPayloads(value)).toEqual(value); + }); + it("omits malformed API arguments and semantic result envelopes", () => { + const value = { item: { type: "tool_use", name: "call_api", input: { operationId: "invalid", body: { value: "private-fixture-value" } } }, + result: { operationId: "call_api", result: { data: "private-fixture-value" } } }; + expect(JSON.stringify(redactRunnerApiPayloads(value))).not.toContain("private-fixture-value"); + expect(redactRunnerApiPayloads(redactRunnerApiPayloads(value))).toEqual(redactRunnerApiPayloads(value)); + }); +}); diff --git a/server/src/services/native-runtime/runner-api-log-redaction.ts b/server/src/services/native-runtime/runner-api-log-redaction.ts new file mode 100644 index 00000000000..f06ad10266a --- /dev/null +++ b/server/src/services/native-runtime/runner-api-log-redaction.ts @@ -0,0 +1,40 @@ +import { REDACTED_EVENT_VALUE } from "../../redaction.js"; +import { nativeSha256 } from "./canonical.js"; + +const RECEIPT_FIELDS = new Set(["operationId", "apiOperationId", "callId", "ok", "status", "contentType"]); +const REDACTED_FIELDS = new Set([...RECEIPT_FIELDS, "data", "apiPayloadRedacted", "apiPayloadSha256"]); + +function redactedPayload(value: unknown) { + const entry = value && typeof value === "object" ? value as Record : {}; + const wasRedacted = entry.data === REDACTED_EVENT_VALUE && entry.apiPayloadRedacted === true + && typeof entry.apiPayloadSha256 === "string" && /^[a-f0-9]{64}$/.test(entry.apiPayloadSha256) + && Object.keys(entry).every(key => REDACTED_FIELDS.has(key)); + return { + ...Object.fromEntries(Object.entries(entry).filter(([key, item]) => RECEIPT_FIELDS.has(key) + && ["string", "number", "boolean"].includes(typeof item))), + data: REDACTED_EVENT_VALUE, + apiPayloadRedacted: true, + apiPayloadSha256: wasRedacted ? entry.apiPayloadSha256 : nativeSha256(value), + }; +} + +export function redactRunnerApiPayloads(value: T): T { + if (Array.isArray(value)) return value.map(redactRunnerApiPayloads) as T; + if (typeof value === "string" && /^[\s]*[\[{]/.test(value)) { + try { + const parsed = JSON.parse(value); + const redacted = redactRunnerApiPayloads(parsed); + return (JSON.stringify(parsed) === JSON.stringify(redacted) ? value : JSON.stringify(redacted)) as T; + } catch { return value; } + } + if (!value || typeof value !== "object") return value; + const entry = value as Record; + const operation = entry.apiOperationId ?? entry.operationId; + if (operation === "call_api" || (typeof operation === "string" && /^(GET|HEAD|OPTIONS|POST|PUT|PATCH|DELETE) \/api\//.test(operation))) { + return redactedPayload(value) as T; + } + if (entry.type === "tool_use" && entry.name === "call_api") { + return { ...entry, input: redactedPayload(entry.input) } as T; + } + return Object.fromEntries(Object.entries(entry).map(([key, item]) => [key, redactRunnerApiPayloads(item)])) as T; +} diff --git a/server/src/services/native-runtime/runner-api-policy.ts b/server/src/services/native-runtime/runner-api-policy.ts deleted file mode 100644 index 2663d17f174..00000000000 --- a/server/src/services/native-runtime/runner-api-policy.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** 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") || - /^\/api\/chat-identity-links(\/|$)/.test(path) || - /\/chat-endpoints\/\{[^}]+\}\/principals\/\{[^}]+\}\/link(?:-intent)?$/.test( - path, - ) || - (!metadataRead && - (/\/(secrets|setup-secret|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 { - // This endpoint only records an eligible responding agent's interpretation - // of a same-task user message. It checks the live run, resolver policy and - // session under lock, and cannot execute governed approvals or enqueue work. - if (/^\/api\/issues\/\{[^}]+\}\/interactions\/\{[^}]+\}\/resolve-from-comment$/.test(path)) return 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-sensitive-response.ts b/server/src/services/native-runtime/runner-api-sensitive-response.ts new file mode 100644 index 00000000000..1920d27e239 --- /dev/null +++ b/server/src/services/native-runtime/runner-api-sensitive-response.ts @@ -0,0 +1,8 @@ +export function runnerApiSensitiveResponse(method: string, path: string): boolean { + if (/\/secrets\/\{[^}]+\}\/value$/.test(path)) return true; + return /^\/api\/tool-gateway\/sessions(?:\/|$)/.test(path) + || /^\/api\/companies\/\{[^}]+\}\/exports?(?:\/|$)/.test(path) + || (method === "POST" && /^\/api\/routines\/\{[^}]+\}\/triggers$/.test(path)) + || (method === "POST" && /^\/api\/routines\/\{[^}]+\}\/revisions\/\{[^}]+\}\/restore$/.test(path)) + || /\/(keys|board-api-keys|credentials|invites|join-requests|gateway-tokens|tokens|token|rotate-secret|terminal-session-token|claim-api-key)(\/|$)/.test(path); +} diff --git a/server/src/services/native-runtime/runner-api.integration.test.ts b/server/src/services/native-runtime/runner-api.integration.test.ts index ceb6e4784a6..764cbcc27ad 100644 --- a/server/src/services/native-runtime/runner-api.integration.test.ts +++ b/server/src/services/native-runtime/runner-api.integration.test.ts @@ -3,8 +3,10 @@ import { randomUUID } from "node:crypto"; import { chmod, writeFile, symlink, mkdir, open } from "node:fs/promises"; import { join, dirname } from "node:path"; import { eq } from "drizzle-orm"; -import { assets, documents, heartbeatRuns, issues, projects, routineDocuments, routines, runnerApiResponseReservations } from "@paperclipai/db"; +import { agents, assets, documents, heartbeatRuns, issues, projects, routineDocuments, routines, runnerApiResponseReservations, companySkills, companyMemberships, principalPermissionGrants } from "@paperclipai/db"; import { beforeAll, afterAll, beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { secretService } from "../secrets.js"; +import { authorizationService } from "../authorization.js"; 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"; @@ -12,10 +14,12 @@ import { registerRunnerPrpAuthority } from "../../realtime/runner-prp-ws.js"; import { createLocalAgentJwt } from "../../agent-auth-jwt.js"; import { RUNNER_API_RESPONSE_MAX_BYTES, RUNNER_API_RESPONSE_RUN_MAX_BYTES } from "./runner-api-response-limits.js"; import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js"; +import { prepareHeartbeatTaskContext } from "../heartbeat/task-context.js"; describe("runner API against real HTTP routes", () => { let server: Awaited>; const oldSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET; + const oldMasterKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; beforeEach(() => { vi.stubEnv("PAPERCLIP_RUNNER_API_TOOLS_ENABLED", undefined); vi.stubEnv("PAPERCLIP_RUNNER_API_TOOLS_COMPANY_IDS", undefined); @@ -24,9 +28,12 @@ describe("runner API against real HTTP routes", () => { beforeAll(async () => { process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID(); server = await startRunnerApiTestServer(); + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = join(server.root, "master.key"); }, 60_000); afterAll(async () => { await server?.close(); + if (oldMasterKeyFile === undefined) delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + else process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = oldMasterKeyFile; if (oldSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET; else process.env.PAPERCLIP_AGENT_JWT_SECRET = oldSecret; }); @@ -108,24 +115,302 @@ else if(m.id!==undefined) send({id:m.id,result:{}}); } finally { delete process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED; } }); - 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("uses route permissions for lifecycle payloads instead of fallback-only denials", async () => { + const fixture = await server.fixture({ disableWakeOnDemand: true }); + const result = await fixture.authority.execute({ tool: "call_api", callId: "block-via-rest", arguments: { + operationId: "PATCH /api/issues/{id}", pathParams: { id: fixture.blockerId }, + body: { status: "blocked", billingCode: "REST-PARITY", blockedByIssueIds: [fixture.issueId] }, + } }); + expect(result).toMatchObject({ ok: true, status: 200, data: { status: "blocked", billingCode: "REST-PARITY" } }); + expect((await fixture.snapshot()).issues.find(row => row.id === fixture.blockerId)) + .toMatchObject({ status: "blocked", billingCode: "REST-PARITY" }); }); - it("rejects issue lifecycle intents before dispatch or receipt creation", async () => { + it("manages owned routines and triggers with durable replay, attribution and owner denials", async () => { + const fixture = await server.fixture({ disableWakeOnDemand: true, conversation: true }); + await server.db.update(issues).set({ responsibleUserId: fixture.responsibleUserId }).where(eq(issues.id, fixture.issueId)); + const args = { operationId: "POST /api/companies/{companyId}/routines", body: { + title: "REST parity routine", assigneeAgentId: fixture.agentId, projectId: fixture.projectId, parentIssueId: fixture.issueId, status: "paused", + } }; + const call = (callId: string, arguments_: unknown) => fixture.authority.execute({ tool: "call_api", callId, arguments: arguments_ }) as Promise; + const created = await call("routine-create", args); + expect(created).toMatchObject({ ok: true, status: 201, data: { title: "REST parity routine", assigneeAgentId: fixture.agentId } }); + expect(await call("routine-create", args)).toEqual(created); + const routineId = created.data.id; + const trigger = await call("routine-trigger", { operationId: "POST /api/routines/{id}/triggers", pathParams: { id: routineId }, body: { kind: "schedule", cronExpression: "0 9 * * *", timezone: "UTC", enabled: false } }); + expect(trigger).toMatchObject({ ok: true, status: 201, data: { trigger: { routineId, enabled: false } } }); + expect(await call("routine-update", { operationId: "PATCH /api/routines/{id}", pathParams: { id: routineId }, body: { title: "Updated through fallback" } })) + .toMatchObject({ ok: true, status: 200, data: { title: "Updated through fallback" } }); + const [other] = await server.db.insert(agents).values({ companyId: fixture.companyId, name: "Other routine owner", status: "active" }).returning(); + expect(await call("routine-other-owner", { ...args, body: { ...args.body, assigneeAgentId: other.id } })) + .toMatchObject({ ok: false, status: 403 }); + const snapshot = await fixture.snapshot(); + expect(snapshot.activity).toEqual(expect.arrayContaining([ + expect.objectContaining({ action: "routine.created", agentId: fixture.agentId, runId: fixture.runId }), + expect.objectContaining({ action: "runner.api_called", agentId: fixture.agentId, runId: fixture.runId, details: expect.objectContaining({ operationId: args.operationId, status: 201 }) }), + ])); + expect(await server.db.select().from(routines).where(eq(routines.companyId, fixture.companyId))).toHaveLength(1); + }); + + it("starts a permitted skill test through the fallback using the existing assignment grants", async () => { + const fixture = await server.fixture({ conversation: true, disableWakeOnDemand: true }); + await server.db.insert(companyMemberships).values({ companyId: fixture.companyId, principalType: "agent", principalId: fixture.agentId, status: "active", membershipRole: "member" }); + await server.db.insert(principalPermissionGrants).values([ + { companyId: fixture.companyId, principalType: "agent", principalId: fixture.agentId, permissionKey: "tasks:assign" }, + { companyId: fixture.companyId, principalType: "user", principalId: fixture.responsibleUserId!, permissionKey: "tasks:assign" }, + ]); + const skillDir = join(server.root, `parity-skill-${randomUUID()}`); + await mkdir(skillDir); + await writeFile(join(skillDir, "SKILL.md"), "# Parity skill\nReturn the supplied fixture input.\n"); + const [skill] = await server.db.insert(companySkills).values({ + companyId: fixture.companyId, key: `company/${fixture.companyId}/parity`, slug: "parity", name: "Parity skill", + markdown: "# Parity skill\n", sourceType: "local_path", sourceLocator: skillDir, + trustLevel: "markdown_only", compatibility: "compatible", fileInventory: [{ path: "SKILL.md", kind: "skill" }], metadata: { sourceKind: "managed_local" }, + }).returning(); + const call = { tool: "call_api", callId: "skill-test-create", arguments: { + operationId: "POST /api/companies/{companyId}/skills/{skillId}/test-runs", pathParams: { skillId: skill.id }, + body: { agentId: fixture.agentId, content: "Fixture input" }, + } }; + const result = await fixture.authority.execute(call) as any; + expect(result).toMatchObject({ ok: true, status: 201, data: { skillId: skill.id, agentId: fixture.agentId } }); + expect(await fixture.authority.execute(call)).toEqual(result); + expect((await fixture.snapshot()).issues.some(issue => issue.id === result.data.issueId)).toBe(true); + }); + + it("keeps saved API responses within the issuing actor's scope", 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 body = "Private API response\n".repeat(2000); + const saved = await fixture.authority.execute({ tool: "call_api", callId: "private-snapshot", arguments: { + operationId: "PUT /api/issues/{id}/documents/{key}", pathParams: { id: fixture.issueId, key: "private-api-result" }, + body: { title: "Private API result", body, format: "markdown", baseRevisionId: null }, + } }) as any; + expect(saved).toMatchObject({ ok: true, status: 201, artifact: { artifactId: expect.any(String) } }); + const url = `${fixture.apiUrl}/api/assets/${saved.artifact.artifactId}/content`; + const owner = createLocalAgentJwt(fixture.agentId, fixture.companyId, "paperclip_runner", fixture.runId); + expect((await fetch(url, { headers: { Authorization: `Bearer ${owner}` } })).status).toBe(200); + const [peer] = await server.db.insert(agents).values({ companyId: fixture.companyId, name: "Peer snapshot reader", status: "active", role: "ceo", adapterType: "paperclip_runner" }).returning(); + const [peerRun] = await server.db.insert(heartbeatRuns).values({ companyId: fixture.companyId, agentId: peer.id, status: "running", runtimeMode: "native", nativeIssueId: fixture.blockerId, contextSnapshot: { issueId: fixture.blockerId } }).returning(); + await server.db.update(issues).set({ assigneeAgentId: peer.id, status: "in_progress", executionRunId: peerRun.id, + description: `![private](/api/assets/${saved.artifact.artifactId}/content)`, + }).where(eq(issues.id, fixture.blockerId)); + const token = createLocalAgentJwt(peer.id, fixture.companyId, "codex_local", peerRun.id); + expect((await fetch(url, { headers: { Authorization: `Bearer ${token}` } })).status).toBe(404); + const peerAuthority = new PaperclipRunnerToolAuthority(server.db, { + ...fixture.authority.binding, agentId: peer.id, runId: peerRun.id, issueId: fixture.blockerId, + }); + await expect(peerAuthority.execute({ tool: "call_api", callId: "peer-snapshot-upload", arguments: { + operationId: "POST /api/companies/{companyId}/issues/{issueId}/attachments", pathParams: { issueId: fixture.blockerId }, + files: [{ artifactId: saved.artifact.artifactId }], + } })).rejects.toThrow("Artifact is not available to this actor"); + const exported = await peerAuthority.execute({ tool: "call_api", callId: "peer-snapshot-export", arguments: { + operationId: "POST /api/companies/{companyId}/exports", body: { include: { issues: true }, issues: [fixture.blockerId] }, + } }) as any; + expect(exported).toMatchObject({ ok: true, status: 200 }); + for (const [path, file] of Object.entries(exported.data.files)) { + if (!path.startsWith("blobs/")) continue; + expect(Buffer.from((file as { data: string }).data, "base64").toString("utf8")).not.toContain("Private API response"); } + expect(await peerAuthority.execute({ tool: "call_api", callId: "peer-snapshot-logo", arguments: { + operationId: "PATCH /api/companies/{companyId}/branding", body: { logoAssetId: saved.artifact.artifactId }, + } })).toMatchObject({ ok: false, status: 422 }); + const ownUpload = await fixture.authority.execute({ tool: "call_api", callId: "own-snapshot-upload", arguments: { + operationId: "POST /api/companies/{companyId}/issues/{issueId}/attachments", pathParams: { issueId: fixture.issueId }, + files: [{ artifactId: saved.artifact.artifactId }], + } }); + expect(ownUpload).toMatchObject({ ok: true, status: 201 }); + }); + + it.each(["skill_test", "low_trust_review"])("allows task image reads without company browsing (%s)", async scope => { + const fixture = await server.fixture(); + if (scope === "skill_test") { + await server.db.update(heartbeatRuns).set({ contextSnapshot: { issueId: fixture.issueId, + paperclipAgentJwtScope: { kind: "skill_test", issueId: fixture.issueId }, + } }).where(eq(heartbeatRuns.id, fixture.runId)); + } else { + await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: { + trustBoundary: { mode: "low_trust_review", companyId: fixture.companyId, issueIds: [fixture.issueId], allowedAgentIds: [fixture.agentId] }, + } } }).where(eq(agents.id, fixture.agentId)); + } + const keyScope = scope === "skill_test" ? { kind: "skill_test" as const, issueId: fixture.issueId } : { kind: "standard" as const }; + const actor = { type: "agent" as const, agentId: fixture.agentId, companyId: fixture.companyId, runId: fixture.runId, keyScope }; + expect((await authorizationService(server.db).decide({ actor, action: "company_scope:read", + resource: { type: "company", companyId: fixture.companyId }, + })).allowed).toBe(false); + const imageIds: string[] = []; + for (const issueId of [fixture.issueId, fixture.blockerId]) { + const stored = await server.storage.putFile({ companyId: fixture.companyId, namespace: `issues/${issueId}`, + originalFilename: "image.png", contentType: "image/png", body: Buffer.from("task-image-fixture"), + }); + const [image] = await server.db.insert(assets).values({ companyId: fixture.companyId, ...stored, createdByAgentId: fixture.agentId }).returning(); + imageIds.push(image.id); + } + const token = createLocalAgentJwt(fixture.agentId, fixture.companyId, "paperclip_runner", fixture.runId, null, keyScope); + expect((await fetch(`${fixture.apiUrl}/api/assets/${imageIds[0]}/content`, { headers: { Authorization: `Bearer ${token}` } })).status).toBe(200); + expect((await fetch(`${fixture.apiUrl}/api/assets/${imageIds[1]}/content`, { headers: { Authorization: `Bearer ${token}` } })).status).toBe(404); + expect(await fixture.authority.execute({ tool: "call_api", callId: "scoped-image", arguments: { + operationId: "GET /api/assets/{assetId}/content", pathParams: { assetId: imageIds[0] }, + } })).toMatchObject({ ok: true, status: 206 }); + await expect(fixture.authority.execute({ tool: "call_api", callId: "scoped-image-upload", arguments: { + operationId: "POST /api/companies/{companyId}/issues/{issueId}/attachments", pathParams: { issueId: fixture.issueId }, + files: [{ artifactId: imageIds[1] }], + } })).rejects.toThrow("Artifact is not available to this actor"); + }); + + it("keeps restored webhook secrets inline and encrypts their replay receipts", async () => { + const fixture = await server.fixture({ disableWakeOnDemand: true, conversation: true }); + vi.stubEnv("PAPERCLIP_API_URL", fixture.apiUrl); + await server.db.update(issues).set({ responsibleUserId: fixture.responsibleUserId }).where(eq(issues.id, fixture.issueId)); + const call = (callId: string, args: unknown) => fixture.authority.execute({ tool: "call_api", callId, arguments: args }) as Promise; + const created = await call("webhook-routine", { operationId: "POST /api/companies/{companyId}/routines", body: { + title: "Restored webhook", assigneeAgentId: fixture.agentId, projectId: fixture.projectId, parentIssueId: fixture.issueId, status: "paused", + } }); + expect(created).toMatchObject({ status: 201 }); + const routineId = created.data.id; + await server.db.update(routines).set({ description: "Restored routine description\n".repeat(2000) }).where(eq(routines.id, routineId)); + const trigger = await call("webhook-trigger", { operationId: "POST /api/routines/{id}/triggers", pathParams: { id: routineId }, + body: { kind: "webhook", signingMode: "bearer", replayWindowSec: 300 }, + }); + expect(trigger).toMatchObject({ status: 201 }); + expect(await call("delete-webhook", { operationId: "DELETE /api/routine-triggers/{id}", pathParams: { id: trigger.data.trigger.id } })) + .toMatchObject({ status: 204 }); + const args = { operationId: "POST /api/routines/{id}/revisions/{revisionId}/restore", + pathParams: { id: routineId, revisionId: trigger.data.revision.id }, + }; + const restored = await call("restore-webhook", args); + expect(restored).toMatchObject({ ok: true, status: 200, data: { secretMaterials: [expect.objectContaining({ webhookSecret: expect.any(String) })] } }); + expect(restored).not.toHaveProperty("artifact"); + expect(await call("restore-webhook", args)).toEqual(restored); + const value = restored.data.secretMaterials[0].webhookSecret; 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"); + expect(JSON.stringify(run.resultJson)).not.toContain(value); + expect(Object.values((run.resultJson as any).apiToolReceipts).find((receipt: any) => receipt.operationId === args.operationId)) + .toHaveProperty("encryptedResult"); + expect((await fixture.snapshot()).assets.filter(asset => asset.objectKey.includes("/runner-api/"))).toEqual([]); + }); + + it("returns and replays scoped secret values without plaintext receipts or response assets", async () => { + const fixture = await server.fixture(); + const value = "disposable-private-credential-".repeat(1500); + const secrets = secretService(server.db); + const secret = await secrets.create(fixture.companyId, { name: "parity/private-value", key: "PARITY_TOKEN", provider: "local_encrypted", value }); + await secrets.createBinding({ companyId: fixture.companyId, secretId: secret.id, targetType: "agent", targetId: fixture.agentId, configPath: "env.PARITY_TOKEN" }); + const call = { tool: "call_api", callId: "secret-value", arguments: { operationId: "POST /api/agents/me/secrets/{key}/value", pathParams: { key: secret.key } } }; + const result = await fixture.authority.execute(call); + expect(result).toMatchObject({ ok: true, status: 200, data: { key: secret.key, value } }); + expect(await fixture.authority.execute(call)).toEqual(result); + const snapshot = await fixture.snapshot(); + expect(snapshot.assets.filter(asset => asset.objectKey.includes("/runner-api/"))).toEqual([]); + const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId)); + expect(JSON.stringify(run.resultJson)).not.toContain(value); + expect(Object.values((run.resultJson as any).apiToolReceipts)[0]).toHaveProperty("encryptedResult"); + expect(snapshot.activity.filter(row => row.action === "runner.api_called")).toHaveLength(1); + const otherAgentId = randomUUID(); + await server.db.insert(agents).values({ id: otherAgentId, companyId: fixture.companyId, name: "Unbound peer", status: "active" }); + const [peerRun] = await server.db.insert(heartbeatRuns).values({ companyId: fixture.companyId, agentId: otherAgentId, status: "running", runtimeMode: "native", nativeIssueId: fixture.blockerId, contextSnapshot: { issueId: fixture.blockerId } }).returning(); + await server.db.update(issues).set({ assigneeAgentId: otherAgentId, status: "in_progress", executionRunId: peerRun.id }).where(eq(issues.id, fixture.blockerId)); + const token = createLocalAgentJwt(otherAgentId, fixture.companyId, "paperclip_runner", peerRun.id); + expect((await fetch(`${fixture.apiUrl}/api/agents/me/secrets/${secret.key}/value`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "X-Paperclip-Run-Id": peerRun.id } })).status).toBe(403); + }); + + it.each([ + ["POST /api/companies/{companyId}/exports", 1], + ["POST /api/companies/{companyId}/export", 2500], + ])("keeps authorized company export credentials private and replayable for %s", async (operationId, repeats) => { + const fixture = await server.fixture(); + await server.db.update(agents).set({ role: "ceo" }).where(eq(agents.id, fixture.agentId)); + const credential = "disposable-export-credential-".repeat(repeats); + const [httpAgent] = await server.db.insert(agents).values({ + companyId: fixture.companyId, name: "Exported HTTP fixture", adapterType: "http", + adapterConfig: { url: "https://fixture.invalid", headers: { Authorization: credential } }, + }).returning(); + const call = { tool: "call_api", callId: "private-export", arguments: { + operationId, body: { include: { agents: true }, agents: [httpAgent.id] }, + } }; + const result = await fixture.authority.execute(call) as any; + expect(result).toMatchObject({ ok: true, status: 200 }); + expect(JSON.stringify(result.data)).toContain(credential); + expect(result).not.toHaveProperty("artifact"); + expect(await fixture.authority.execute(call)).toEqual(result); + const snapshot = await fixture.snapshot(); + expect(snapshot.assets.filter(asset => asset.objectKey.includes("/runner-api/"))).toEqual([]); + expect(snapshot.activity.filter(row => row.action === "runner.api_called")).toHaveLength(1); + const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId)); + expect(JSON.stringify(run.resultJson)).not.toContain(credential); + expect(Object.values((run.resultJson as any).apiToolReceipts)[0]).toHaveProperty("encryptedResult"); + }); + + async function reprepareRun(fixture: { runId: string; issueId: string; agentId: string }) { + const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId)); + const [issue] = await server.db.select().from(issues).where(eq(issues.id, fixture.issueId)); + const [agent] = await server.db.select().from(agents).where(eq(agents.id, fixture.agentId)); + const context = structuredClone(run.contextSnapshot ?? {}); + await prepareHeartbeatTaskContext(server.db, { run, agent, + task: { issueRef: issue, issueContext: null, wakeCommentContext: null, context, taskSession: null }, + policy: { recovering: true, trustPreset: { kind: "standard" }, experimentalInstanceSettings: {} }, + services: { issuesSvc: { getAncestors: async () => [] }, getPinnedSkillTestContext: async () => null }, + }); + await server.db.update(heartbeatRuns).set({ contextSnapshot: context }).where(eq(heartbeatRuns.id, fixture.runId)); + } + + it("preserves the REST skill-test credential scope after a task mode edit and restart preparation", async () => { + const fixture = await server.fixture(); + await server.db.update(heartbeatRuns).set({ contextSnapshot: { issueId: fixture.issueId, + paperclipAgentJwtScope: { kind: "skill_test", issueId: fixture.issueId }, + } }).where(eq(heartbeatRuns.id, fixture.runId)); + await server.db.update(issues).set({ workMode: "skill_test" }).where(eq(issues.id, fixture.issueId)); + await server.db.update(issues).set({ workMode: "standard" }).where(eq(issues.id, fixture.issueId)); + await reprepareRun(fixture); + const body = { kind: "secret", name: "parity/skill-test", value: "disposable-skill-test-value", justification: "Must remain denied" }; + const token = createLocalAgentJwt(fixture.agentId, fixture.companyId, "paperclip_runner", fixture.runId, + fixture.responsibleUserId, { kind: "skill_test", issueId: fixture.issueId }); + const direct = await fetch(`${fixture.apiUrl}/api/agents/me/secret-proposals`, { + method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "X-Paperclip-Run-Id": fixture.runId }, body: JSON.stringify(body), + }); + expect(direct.status).toBe(403); + const result = await fixture.authority.execute({ tool: "call_api", callId: "skill-test-secret-denied", arguments: { + operationId: "POST /api/agents/me/secret-proposals", body, + } }); + expect(result).toMatchObject({ ok: false, status: direct.status, data: await direct.json() }); + }); + + it("retains standard REST credential scope after a task becomes a skill test and restart preparation", async () => { + const fixture = await server.fixture(); + await server.db.update(heartbeatRuns).set({ contextSnapshot: { issueId: fixture.issueId, + paperclipAgentJwtScope: { kind: "standard" }, + } }).where(eq(heartbeatRuns.id, fixture.runId)); + await server.db.update(issues).set({ workMode: "skill_test" }).where(eq(issues.id, fixture.issueId)); + await reprepareRun(fixture); + const token = createLocalAgentJwt(fixture.agentId, fixture.companyId, "paperclip_runner", fixture.runId, null, { kind: "standard" }); + const direct = await fetch(`${fixture.apiUrl}/api/companies/${fixture.companyId}/projects`, { headers: { Authorization: `Bearer ${token}` } }); + expect(direct.status).toBe(200); + expect(await fixture.authority.execute({ tool: "call_api", callId: "standard-scope", arguments: { + operationId: "GET /api/companies/{companyId}/projects", + } })).toMatchObject({ ok: true, status: 200, data: await direct.json() }); + }); + + it("creates and reads secret proposals while preserving redaction and board review", async () => { + const fixture = await server.fixture(); + const keyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = join(server.root, "proposal-master.key"); + try { + const args = { operationId: "POST /api/agents/me/secret-proposals", body: { + kind: "secret", name: "parity/example", value: "disposable-parity-secret", justification: "Test existing agent REST permission", + } }; + const call = (callId: string, arguments_: unknown) => fixture.authority.execute({ tool: "call_api", callId, arguments: arguments_ }) as Promise; + const created = await call("proposal-create", args); + expect(created).toMatchObject({ ok: true, status: 201, data: { status: "pending", kind: "secret" } }); + expect(await call("proposal-create", args)).toEqual(created); + const listed = await call("proposal-list", { operationId: "GET /api/agents/me/secret-proposals" }); + expect(listed).toMatchObject({ ok: true, status: 200, data: { proposals: [expect.objectContaining({ id: created.data.id })] } }); + expect(JSON.stringify([created, listed])).not.toContain(args.body.value); + expect(await call("proposal-approve-denied", { operationId: "POST /api/companies/{companyId}/secret-proposals/{id}/approve", pathParams: { id: created.data.id } })) + .toMatchObject({ ok: false, status: 403 }); + const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId)); + expect(JSON.stringify(run.resultJson)).not.toContain(args.body.value); + } finally { + if (keyFile === undefined) delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + else process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = keyFile; + } }); it("supports routine annotation collaboration without changing scheduling", async () => { @@ -147,15 +432,31 @@ else if(m.id!==undefined) send({id:m.id,result:{}}); 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/); + it("matches direct agent REST authorization for formerly restricted routes", async () => { + const fixture = await server.fixture({ disableWakeOnDemand: true }); + const token = createLocalAgentJwt(fixture.agentId, fixture.companyId, "paperclip_runner", fixture.runId); + for (const [operationId, path, body] of [ + ["POST /api/agents/{id}/pause", `/api/agents/${fixture.agentId}/pause`, {}], + ["POST /api/agents/{id}/resume", `/api/agents/${fixture.agentId}/resume`, {}], + ["POST /api/companies/{companyId}/exports", `/api/companies/${fixture.companyId}/exports`, {}], + ["POST /api/companies/{companyId}/invites", `/api/companies/${fixture.companyId}/invites`, {}], + ["POST /api/approvals/{id}/approve", `/api/approvals/${fixture.approvalId}/approve`, {}], + ["POST /api/issues/{id}/recovery-actions/resolve", `/api/issues/${fixture.blockerId}/recovery-actions/resolve`, {}], + ["POST /api/execution-workspaces/{id}/runtime-services/{action}", `/api/execution-workspaces/${randomUUID()}/runtime-services/stop`, {}], + ["POST /api/companies/{companyId}/skills/{skillId}/test-runs", `/api/companies/${fixture.companyId}/skills/${randomUUID()}/test-runs`, { agentId: fixture.agentId, content: "Test input" }], + ] as const) { + const direct = await fetch(`${fixture.apiUrl}${path}`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "X-Paperclip-Run-Id": fixture.runId, "Content-Type": "application/json" }, body: JSON.stringify(body) }); + const names = [...operationId.matchAll(/\{([^}]+)\}/g)].map(([, name]) => name); + const template = operationId.slice(5).split("/"); + const actual = path.split("/"); + const values = template.flatMap((segment, index) => segment.startsWith("{") ? [actual[index]] : []); + const result = await fixture.authority.execute({ tool: "call_api", callId: randomUUID(), arguments: { + operationId, pathParams: Object.fromEntries(names.map((name, index) => [name, values[index]])), body, + } }) as any; + expect(result.status, operationId).toBe(direct.status); + expect(result.ok, operationId).toBe(direct.ok); + if (!direct.ok) expect(result.data, operationId).toEqual(await direct.json()); } - 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 () => { @@ -187,20 +488,6 @@ else if(m.id!==undefined) send({id:m.id,result:{}}); } }); - 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); diff --git a/server/src/services/native-runtime/runner-api.test.ts b/server/src/services/native-runtime/runner-api.test.ts index cb75b0cfb3d..cab4698c71f 100644 --- a/server/src/services/native-runtime/runner-api.test.ts +++ b/server/src/services/native-runtime/runner-api.test.ts @@ -75,7 +75,7 @@ describe("runner API catalog", () => { expect(operation.allowedModes).toContain("planning"); expect(operation.allowedModes).not.toContain("ask"); expect(operation.runnerRestrictions?.join(" ")).toContain("conversational confirmation"); - expect(runnerApiOperation("POST /api/issues/{id}/interactions/{interactionId}/accept").callPolicy).toBe("restricted"); + expect(runnerApiOperation("POST /api/issues/{id}/interactions/{interactionId}/accept").callPolicy).toBe("rest"); expect(runnerApiOperation(createProject).allowedModes).not.toContain("planning"); }); it("accounts for unique operations with resolved request contracts", () => { @@ -138,21 +138,25 @@ describe("runner API catalog", () => { }); 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(runnerApiCatalog().filter(operation => operation.transport === "rest"))( + "dispatches $operationId with agent identity and preserves route authorization", + async operation => { + const request = vi.fn(async () => + Response.json({ error: "Route permission required" }, { status: 403 }), + ); + const pathParams = Object.fromEntries( + [...operation.path.matchAll(/\{([^}]+)\}/g)].map(([, name]) => + [name, name === "companyId" ? context.companyId : "fixture"]), + ); + const result = await executeRunnerApi( + { operationId: operation.operationId, pathParams }, context, io(request), + ); + expect(operation.callPolicy).toBe("rest"); + expect(result).toMatchObject({ ok: false, status: 403, data: { error: "Route permission required" } }); + expect(request).toHaveBeenCalledOnce(); + const headers = new Headers(request.mock.calls[0][1]?.headers); + expect(headers.get("Authorization")).toBe("Bearer private-agent-token"); + expect(headers.get("X-Paperclip-Run-Id")).toBe(context.runId); }, ); it.each([ @@ -171,28 +175,6 @@ describe("runner API request boundary", () => { 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) => @@ -226,51 +208,16 @@ describe("runner API request boundary", () => { ).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}", - "POST /api/chat-endpoints/{endpointId}/setup-secret", - "POST /api/chat-endpoints/{endpointId}/principals/{principalId}/link-intent", - "DELETE /api/chat-endpoints/{endpointId}/principals/{principalId}/link", - "POST /api/chat-identity-links/confirm", - "GET /api/chat-identity-links/preview", - ])( - "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", - }); - }, - ); + ["PATCH /api/issues/{id}", { status: "blocked", assigneeAgentId: "teammate", blockedByIssueIds: ["dependency"], reopen: true }], + ["PATCH /api/agents/{id}", { status: "idle" }], + ["POST /api/issues/{id}/comments", { body: "Continue", resume: true }], + ])("passes REST payload fields to route authorization for %s", async (operationId, body) => { + const request = vi.fn(async () => Response.json({ accepted: true })); + expect(await executeRunnerApi({ operationId: operationId as string, pathParams: { id: "fixture" }, body }, context, io(request))) + .toMatchObject({ ok: true, status: 200 }); + expect(JSON.parse(request.mock.calls[0][1]?.body as string)).toEqual(body); + }); it("retains safe secret metadata discovery", () => { for (const operationId of [ "GET /api/agents/me/secrets", @@ -341,39 +288,34 @@ describe("runner API request boundary", () => { ).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([ + ["POST /api/agents/me/secrets/{key}/value", { key: "FIXTURE_TOKEN" }], + ["POST /api/companies/{companyId}/exports", {}], + ["POST /api/companies/{companyId}/export", {}], + ])("returns credential responses for %s above the inline limit without plaintext artifacts", async (operationId, pathParams) => { + const value = "fixture-credential-".repeat(3000); + const transport = io(async () => Response.json({ value })); + const save = vi.fn(transport.saveResponse); + const reserve = vi.fn(async () => vi.fn(async () => {})); + const result = await executeRunnerApi({ operationId, pathParams }, context, { ...transport, saveResponse: save, reserveResponseCapture: reserve }); + expect(result).toMatchObject({ ok: true, status: 200, data: { value } }); + expect(result).not.toHaveProperty("artifact"); + expect(save).not.toHaveBeenCalled(); + expect(reserve).not.toHaveBeenCalled(); + }); + it("withholds a credential response when the run is revoked during HTTP", async () => { + let authorized = true; + const request = vi.fn(async () => { + authorized = false; + return Response.json({ value: "private-fixture-value" }); + }); + const result = await executeRunnerApi({ operationId: "POST /api/agents/me/secrets/{key}/value", pathParams: { key: "fixture-key" } }, context, { + ...io(request), beforeDispatch: async () => { if (!authorized) throw new Error("Run revoked"); }, + }); + expect(result).toMatchObject({ ok: false, error: "api_transport_failure", outcome: "unknown" }); + expect(JSON.stringify(result)).not.toContain("private-fixture-value"); + expect(request).toHaveBeenCalledOnce(); }); - 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 }), @@ -559,29 +501,11 @@ describe("runner API request boundary", () => { ), ).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 protocol clients separate from REST dispatch", () => { + 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"); } }); it("keeps skill-test mode consistent with the advertised tool contract", () => { @@ -592,24 +516,13 @@ describe("runner API request boundary", () => { ).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("passes uploaded JSON bodies through the same route validation as REST", async () => { + const request = vi.fn(async () => Response.json({ error: "Board access required" }, { status: 403 })); + const transport = io(request); + transport.readFile = async () => ({ bytes: Buffer.from('{"status":"idle"}'), filename: "update.json", contentType: "application/json" }); + expect(await executeRunnerApi({ operationId: "PATCH /api/agents/{id}", pathParams: { id: "fixture" }, files: [{ path: "update.json" }], contentType: "application/json" }, context, transport)) + .toMatchObject({ ok: false, status: 403 }); + expect(Buffer.from(request.mock.calls[0][1]?.body as Uint8Array).toString()).toBe('{"status":"idle"}'); }); it("reads text windows from a live response and supplies stable snapshots", async () => { const text = "prefix\n" + "🧭é\n".repeat(6000) + "END-OF-EVIDENCE";