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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-13 07:34:15 -05:00
1 parent eb06c9fedd
commit fc3b15537b
54 files changed
+1118 -502

No files matched your search

+25 -2
View File
@@ -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": <canonical PRP event> }`.
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
+49 -23
View File
@@ -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
+4 -3
View File
@@ -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
+40
View File
@@ -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.
+5 -3
View File
@@ -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.
+7 -5
View File
@@ -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`
+5 -3
View File
@@ -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`
+7 -5
View File
@@ -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.
+10 -7
View File
@@ -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`
+6 -3
View File
@@ -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`
+14 -9
View File
@@ -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`
+20 -15
View File
@@ -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.
+10 -7
View File
@@ -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)
+16 -13
View File
@@ -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.
+8 -5
View File
@@ -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.
+5 -3
View File
@@ -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`
+7 -4
View File
@@ -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`
+13 -10
View File
@@ -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.
+8 -5
View File
@@ -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`
@@ -1 +1 @@
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
@@ -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,
@@ -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"
@@ -175,7 +175,7 @@
},
{
"path": "fixtures/evals/native-execution-seeded.json",
"sha256": "872f556fd39a4b7e50a902d09c8b42f8fc729030d03642983fa16872f623e1ef",
"sha256": "a564abe2d4dd502913250c93a0bbdd6dc06f2556efff6d93d577fcb9011e4a70",
"expectation": "accept",
"compatibilityCase": "canonical"
},
@@ -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,
@@ -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<void>;
loadSessionCheckpoint?(): Promise<PersistedNativeSession | null>;
checkpointSession?(
@@ -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;
}
@@ -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;
}
@@ -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;
@@ -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,
@@ -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<string, unknown>,
@@ -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",
@@ -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<void>(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<never>(() => 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 = {
@@ -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}`,
@@ -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"
@@ -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");
@@ -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({
@@ -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);
});
});
+3 -38
View File
@@ -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.
@@ -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<typeof assets.$inferSelect, "id" | "companyId" | "objectKey" | "createdByAgentId" | "createdByUserId">;
function namespace(asset: Asset) {
const parts = asset.objectKey.split("/");
return parts[1] === "assets" ? parts[2] : parts[1];
}
export async function isRunnerApiResponseAsset(db: Pick<Db, "select">, asset: Asset): Promise<boolean> {
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<boolean> {
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;
}
+5 -1
View File
@@ -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");
}
+6 -3
View File
@@ -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";
+1 -1
View File
@@ -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({
@@ -34,6 +34,7 @@ export interface HeartbeatTaskContextInput {
taskSession: Pick<typeof agentTaskSessions.$inferSelect, "lastRunId"> | null;
};
policy: {
recovering?: boolean;
trustPreset: Pick<TrustPresetResolution, "kind">;
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)
@@ -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<ReturnType<typeof startRunnerApiTestServer>>;
@@ -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");
});
});
@@ -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();
@@ -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<void> {
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<void> {
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,
@@ -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<unknown> {
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.
@@ -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<string, any>;
@@ -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<Json>(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 } : {}),
@@ -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<string, unknown> : {};
// 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 }; }
@@ -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));
});
});
@@ -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<string, unknown> : {};
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<T>(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<string, unknown>;
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;
}
@@ -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;
}
@@ -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);
}
@@ -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<ReturnType<typeof startRunnerApiTestServer>>;
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<string, unknown> | 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<any>;
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<any>;
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<string, unknown> | 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<any>;
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<string, unknown> | 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);
@@ -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<typeof fetch>();
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<typeof fetch>(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<typeof fetch>();
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<typeof fetch>();
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<typeof fetch>();
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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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";