fix(runner): keep call_api writes carrying their body (#21002)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native agents use the `call_api` tool when no dedicated tool covers
an API operation
> - #20606 added an optional `saveResponse` flag to the shared
`call_api` schema for GET reads
> - After that change, OpenCode agents with DeepSeek v4.1 flash often
sent write calls without `body`
> - Body-less POST calls reached the routes and failed with 400; agents
then repeated the same call many times
> - This pull request moves the save option to its own read-only tool
and refuses body-less writes before they are sent
> - The benefit is that the write schema returns to its earlier shape,
and a missed `body` gets one clear, actionable error

## Linked Issues or Issue Description

Refs #20606

**What happened?**

In a full chat-eval cohort on master (OpenCode, DeepSeek v4.1 flash),
write calls through `call_api` arrived without `body`. The model's own
reasoning planned a body, but the emitted calls had only `operationId`,
`pathParams`, `query` and `saveResponse`. About 100 refused PATCH calls
listed exactly those arguments (the cohort before #20606 had none with
`saveResponse`). 72 POST calls reached the server with no body and got
400 (0 in the earlier cohort). One case sent the same body-less resolve
call 21 times.

**Expected behavior**

Write calls carry their request fields in `body`. When a body is
missing, the agent gets one clear error before anything is sent, and it
can correct the call.

**Steps to reproduce**

Run the OpenCode chat cases that resolve approvals, edit queued
messages, delegate with context, edit task properties or start preview
services on master after #20606. Count `call_api` writes without `body`
and POST 400 responses.

**Additional context**

The tool schema that OpenCode sends to the model is intact: `body` is
present. A short-context replay with the same schema did not reproduce
the omission (direct provider calls and local OpenCode runs: every write
carried `body`, with or without `saveResponse`). The omission appears in
long sessions, and it correlates with the added read-only flag. This
change removes that flag from the write schema and adds a safety net for
the remaining cases.

## What Changed

- Remove `saveResponse` from the advertised `call_api` schema and
description. The description now says that request fields go only in
`body`, and the `body` description says when it is required.
- Add a separate read-only `save_api_response` tool (`operationId`,
`pathParams`, `query`). It accepts only GET operation IDs, has no body
field, and uses the existing `saveResponse` export path with the same
access, sensitivity, budget and integrity rules.
- Refuse a write before dispatch when its body is missing: PATCH with a
required body (as before), and POST or PUT whose JSON request schema
names required fields. The error names the operation, the allowed
fields, the received arguments, the shape to resend, and that `query`,
`responseText` and `saveResponse` never carry request fields.
- Keep server-side acceptance of `saveResponse` on `call_api`, so saved
calls and receipts from earlier runs stay valid for recovery.
- Regenerate the runner contract, catalog, seeded fixture and manifest.
Update `doc/runner-api-tools.md`.

## Verification

- Red at master sources: the 3 replayed recorded writes (POST
resolve-from-comment, PATCH issue, POST comment, each with `query: {}`
and `saveResponse: false` and no body) were sent to the route instead of
being refused, and the schema test found no `save_api_response` tool. 4
tests failed.
- Green with this change:
`server/src/services/native-runtime/runner-api.test.ts` 2105/2105
passed; `runner-api.integration.test.ts` 61/61 passed;
`runner-bridge-tools.test.ts`, `task-document-key-contract.test.ts` and
`runner-api-artifacts.test.ts` passed; runner
`protocol-action-contracts.test.ts` 70/70 passed; server `tsc --noEmit`
passed.
- `runner-api-artifacts.integration.test.ts` now saves through
`save_api_response` and checks that a write operation ID or a body is
refused. Its download step needs Linux; it fails on macOS at master too,
so CI covers it.
- Paid E2E, OpenCode + DeepSeek v4.1 flash, [run
43138806990](https://github.com/paperclipai/paperclip/actions/runs/43138806990)
on this fix: 5/5 affected cases passed
(questions-and-approvals.reject-and-revise,
steering.edit-queued-message, task-coordination.delegate-with-context,
tasks.edit-properties, workspaces.preview-service). Body-less write
refusals went from 30 to 0, and body-less POST 400s went from 48 to 0,
compared with the earlier cohort on the same cases. The approval resolve
call was sent once (it was sent 24 times before). Every logged write
carried a body; the remaining non-2xx writes are the cases' intended
stale-edit and failing-fixture responses.
- Run-history redaction also covers `save_api_response` arguments; a
regression test fails on the earlier source and passes now.

## Risks

- Agents that learned `call_api` + `saveResponse: true` in a running
session will see a schema refusal and must use `save_api_response`.
Saved calls keep working for recovery.
- The new POST/PUT refusal applies only when the JSON request schema
names required fields, so writes whose body is optional are still sent.
- One more small read tool is advertised when API tools are enabled.

## Model Used

Anthropic Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool
use, shell execution and code editing.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-11 20:46:14 -06:00
1 parent 2a1620f143
commit 50b79d711a
16 files changed
+119 -39

No files matched your search

+14 -4
View File
@@ -54,8 +54,16 @@ exactly one authorized `artifactId` or task-workspace `path`, and an optional
multipart `field`. No arbitrary URL, headers, authentication, or remote file URL
can be supplied. Routes still validate payloads and enforce permissions.
For lossless input to a local helper, set `saveResponse: true` on an authorized
non-sensitive GET. This saves the complete response bytes as an immutable artifact
Writes put every request field in `body`. A POST or PUT whose request schema
names required fields, or a PATCH with a required body, is refused before it is
sent when `body` (and `files`) are missing; the error names the received
arguments and the shape to resend. `query` and the read-only options never carry
request fields.
For lossless input to a local helper, call the separate `save_api_response`
tool with an authorized non-sensitive GET `operationId`, `pathParams` and
`query`. It has no request body and cannot run a write, so `call_api`'s write
path carries no read-only save option. This saves the complete response bytes as an immutable artifact
even below the inline threshold. A zero-byte successful response instead returns
`empty: true`, `byteSize: 0` and `data: null`, without an asset or storage
reservation. JSON empty results such as `[]` are nonempty bytes and export normally.
@@ -69,8 +77,10 @@ to the helper instead of recreating JSON in model output. Pagination metadata re
artifact contains one complete HTTP response, not all pages of a list. Continue
list pagination explicitly. The file remains untrusted data.
`saveResponse` cannot be combined with `responseText`, and is rejected before
dispatch for mutations and sensitive endpoints. Denied or partial HTTP responses
Sensitive endpoints are rejected before dispatch. Older saved `call_api` calls
that carry `saveResponse: true` remain valid for recovery and follow the same
rules; `saveResponse` cannot be combined with `responseText`, and is rejected
before dispatch for mutations. Denied or partial HTTP responses
are not exported. Failed response receipts include the full `byteSize` and a
`truncated` flag when their inline diagnostic body is incomplete. It uses the same capture limits, storage admission, actor access
and integrity validation as automatic large-response artifacts. The original
@@ -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 only. Calling get_task does not change the active task. This is not a status or assignment audit. For progress chronology and handoff actors, use search_api to discover the company timeline or issue activity, then call_api.","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 document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.","maxLength":120,"minLength":1,"pattern":"^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$","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 document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.","maxLength":120,"minLength":1,"pattern":"^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$","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.Line 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 only. Calling get_task does not change the active task. This is not a status or assignment audit. For progress chronology and handoff actors, use search_api to discover the company timeline or issue activity, then call_api.","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 document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.","maxLength":120,"minLength":1,"pattern":"^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$","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 document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.","maxLength":120,"minLength":1,"pattern":"^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$","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.Line 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 the same route permissions as agent REST access. Prefer dedicated tools for common workflows; route denials still apply. For lossless helper input, use saveResponse:true on a non-sensitive GET to save nonempty responses as artifacts. Empty responses return empty:true without an artifact. Use download_artifact for artifacts up to 10 MiB; larger text artifacts require paged content reads. 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. Request fields go only in body. To save a non-sensitive GET response as an artifact for a local helper, use save_api_response. 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,
@@ -140,7 +140,7 @@
"type": "null"
}
],
"description": "Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."
"description": "Request value matching the discovered schema. Required when the operation has a required request body, as most POST, PUT and PATCH operations do; all request fields go here. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."
},
"contentType": {
"maxLength": 120,
@@ -218,10 +218,6 @@
}
},
"type": "object"
},
"saveResponse": {
"description": "GET only: save complete nonempty authorized, non-sensitive response bytes as an immutable artifact even below the inline threshold. Returns artifactId, byteSize and sha256 without inline body transcription. Zero-byte success returns empty:true and no artifact. download_artifact accepts at most 10 MiB; use paged content reads for larger text artifacts. Uses existing capture limits and budgets. Cannot combine with responseText. Denied, sensitive and partial responses are not exported. Never repeats the source request.",
"type": "boolean"
}
},
"required": [
@@ -24,7 +24,7 @@
"prpVersion": 1,
"nativeExecutionVersion": 1,
"catalogVersion": 1,
"catalogSha256": "sha256:c5b8e95e8125d68467b8f29d86426fde5280f59e111bc4e48f8684daa174aaca",
"catalogSha256": "sha256:e6d6d7701ab56baf5bb00fb39ed04e9eabddd9baed127c65c42fe71b23cffa00",
"driverContractVersion": 1,
"driverKind": "paperclip-deterministic",
"driverVersion": "1.0.0"
@@ -185,7 +185,7 @@
},
{
"path": "fixtures/evals/native-execution-seeded.json",
"sha256": "2cd8450f79dc7ccd618072ba6da43fcb02c56f59cb83164760e066583f1357ba",
"sha256": "9a89a18e01b2eef9066a4d84dcad385df173742ce8db1baf19e6d6e1d25ab72b",
"expectation": "accept",
"compatibilityCase": "canonical"
},
@@ -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 the same route permissions as agent REST access. Prefer dedicated tools for common workflows; route denials still apply. For lossless helper input, use saveResponse:true on a non-sensitive GET to save nonempty responses as artifacts. Empty responses return empty:true without an artifact. Use download_artifact for artifacts up to 10 MiB; larger text artifacts require paged content reads. 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. Request fields go only in body. To save a non-sensitive GET response as an artifact for a local helper, use save_api_response. 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 the same route permissions as agent REST access. Prefer dedicated tools for common workflows; route denials still apply. For lossless helper input, use saveResponse:true on a non-sensitive GET to save nonempty responses as artifacts. Empty responses return empty:true without an artifact. Use download_artifact for artifacts up to 10 MiB; larger text artifacts require paged content reads. 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. Request fields go only in body. To save a non-sensitive GET response as an artifact for a local helper, use save_api_response. 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"
@@ -82,7 +82,7 @@ export const callApiAction = {
"additionalProperties": true
},
"body": {
"description": "Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string.",
"description": "Request value matching the discovered schema. Required when the operation has a required request body, as most POST, PUT and PATCH operations do; all request fields go here. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string.",
"anyOf": [
{ "type": "object", "additionalProperties": true },
{ "type": "array", "items": {} },
@@ -96,10 +96,6 @@ export const callApiAction = {
"type": "string",
"maxLength": 120
},
"saveResponse": {
"description": "GET only: save complete nonempty authorized, non-sensitive response bytes as an immutable artifact even below the inline threshold. Returns artifactId, byteSize and sha256 without inline body transcription. Zero-byte success returns empty:true and no artifact. download_artifact accepts at most 10 MiB; use paged content reads for larger text artifacts. Uses existing capture limits and budgets. Cannot combine with responseText. Denied, sensitive and partial responses are not exported. Never repeats the source request.",
"type": "boolean"
},
"responseText": {
"description": "GET only: return a bounded UTF-8 text window inline, including JSON as text, without saving another artifact. Offsets and limits are bytes. Use the returned nextOffsetBytes to continue; null means complete. Prefer reading a saved artifact for a stable snapshot. New live responses have a 1 GiB capture limit and a 4 GiB per-run capture budget. Existing larger assets remain readable in bounded pages. If a live response returns an artifact, continue on its content operation for a stable snapshot.",
"type": "object",
@@ -6,7 +6,7 @@ const taskTools = new Set([
"list_documents", "read_document", "list_document_revisions", "write_document",
"list_task_documents", "read_task_document", "write_task_document", "comment_on_task",
"report_progress", "set_task_title", "request_human_input", "register_deliverable",
"list_assigned_skills", "read_assigned_skill", "search_api", "call_api",
"list_assigned_skills", "read_assigned_skill", "search_api", "call_api", "save_api_response",
"read_current_wake_comments", "list_chat_attachments", "read_chat_attachment",
"reuse_chat_attachment", "list_task_attachments", "read_task_attachment",
]);
@@ -194,11 +194,16 @@ describe("PaperclipRunnerToolAuthority", () => {
issueId,
runId,
});
expect(authority.definitions()).toHaveLength(53);
expect(authority.definitions()).toHaveLength(54);
expect(authority.definitions().map(tool => tool.name)).toEqual(expect.arrayContaining([
"get_workspace", "list_workspaces", "select_workspace", "prepare_repository",
"search_skills", "load_skill",
]));
// The read-only response export is its own tool, so call_api offers no save flag on writes.
const save = authority.definitions().find(tool => tool.name === "save_api_response")!;
expect(Object.keys((save.inputSchema as { properties: object }).properties).sort()).toEqual(["operationId", "pathParams", "query"]);
const callApi = authority.definitions().find(tool => tool.name === "call_api")!;
expect((callApi.inputSchema as { properties: object }).properties).not.toHaveProperty("saveResponse");
const questions = authority.definitions().find(tool => tool.name === "request_human_input")!;
expect(questions.description).toContain("ask only the next unanswered question");
expect(questions.description).toContain("Never fabricate answers");
@@ -718,6 +718,11 @@ export class PaperclipRunnerToolAuthority {
if (call.tool === "list_assigned_skills") return runtimeContext.skills.map(skill => ({ key: skill.key, name: skill.runtimeName, versionId: skill.versionId, digest: skill.bundle.digest }));
return settleRunnerBridgeRead(() => readAssignedSkill(runtimeContext, input));
}
if (call.tool === "save_api_response") {
// Saving is a separate read tool so call_api's write path carries no
// read-only options; the shared client still enforces GET-only export.
return apiToolResult(await this.#callApi(call.callId, { ...input, saveResponse: true } as RunnerApiCall));
}
return apiToolResult(await this.#callApi(call.callId, taskBridgeApiRequest(call.tool, input)));
}
if (this.binding.firstPartyOnly && (call.tool === READ_TOOL_RESULT || this.binding.assignedMcpTools?.has(call.tool) || isConnectorTool(call.tool)
@@ -56,7 +56,7 @@ describe("authorized read artifacts through real routes and storage", () => {
});
let receipt: any;
try {
receipt = await execute("call_api", { operationId: "GET /api/issues/{id}/documents/{key}", pathParams: { id: fixture.issueId, key: "evidence" }, saveResponse: true });
receipt = await execute("save_api_response", { operationId: "GET /api/issues/{id}/documents/{key}", pathParams: { id: fixture.issueId, key: "evidence" } });
} finally { spy.mockRestore(); }
expect(requests).toBe(1);
expect(receipt).toMatchObject({ ok: true, artifact: { byteSize: source!.length, sha256: createHash("sha256").update(source!).digest("hex") } });
@@ -80,8 +80,13 @@ describe("authorized read artifacts through real routes and storage", () => {
await expect(execute("download_artifact", input)).rejects.toThrow();
}
const foreign = await server.fixture();
const denied = await execute("call_api", { operationId: "GET /api/issues/{id}", pathParams: { id: foreign.issueId }, saveResponse: true });
const denied = await execute("save_api_response", { operationId: "GET /api/issues/{id}", pathParams: { id: foreign.issueId } });
expect(denied.ok).toBe(false);
expect(denied).not.toHaveProperty("artifact");
// The save tool is read-only: a write operation or a body never reaches the API.
for (const args of [
{ operationId: "PUT /api/issues/{id}/documents/{key}", pathParams: { id: fixture.issueId, key: "evidence" } },
{ operationId: "GET /api/issues/{id}", pathParams: { id: fixture.issueId }, body: { title: "x" } },
]) expect(await execute("save_api_response", args)).toMatchObject({ outcome: "failed", code: "runner_bridge_invalid_arguments" });
});
});
@@ -89,14 +89,21 @@ export function validateRunnerApiCall(value: unknown, context: RunnerApiContext)
* Callers that reserve a mutation receipt must run this before the reservation.
*/
export function assertRunnerApiDispatchable(operation: RunnerApiOperation, input: RunnerApiCall, receivedKeys: readonly string[]): void {
if (operation.method === "PATCH" && input.body === undefined && !input.files?.length && operation.requestBody?.required) {
// A PATCH without a body changes nothing but still returns success, which
// reads as a completed update. Name the missing argument instead.
throw badRequest(`This request was not sent: this PATCH requires body, a JSON object with the fields to change${patchFieldHint(operation)}. Received arguments: ${[...receivedKeys].sort().join(", ")}.`);
}
if (input.body !== undefined || input.files?.length || !operation.requestBody?.required) return;
// A PATCH without a body succeeds as a no-op that reads like a completed
// update. A POST or PUT without a body whose schema names required fields
// can only fail at the route. Name the missing argument before sending.
const schema = operation.requestBody.content?.["application/json"]?.schema as { required?: unknown } | undefined;
const requiresFields = Array.isArray(schema?.required) && schema.required.length > 0;
if (!(operation.method === "PATCH" || ((operation.method === "POST" || operation.method === "PUT") && requiresFields))) return;
const received = [...receivedKeys].sort();
const misplaced = received.filter(key => ["query", "responseText", "saveResponse"].includes(key));
throw badRequest(`This request was not sent: ${operation.operationId} requires body, a JSON object with the request fields${requestFieldHint(operation)}. `
+ `Received arguments: ${received.join(", ")}. Resend with a top-level body argument: {"operationId": ${JSON.stringify(operation.operationId)}, "pathParams": {...}, "body": {...}}.`
+ (misplaced.length ? ` ${misplaced.join(", ")} never carr${misplaced.length === 1 ? "ies" : "y"} request fields.` : ""));
}
function patchFieldHint(operation: RunnerApiOperation): string {
function requestFieldHint(operation: RunnerApiOperation): string {
const schema = operation.requestBody?.content?.["application/json"]?.schema as { properties?: Record<string, unknown> } | undefined;
const fields = Object.keys(schema?.properties ?? {});
if (!fields.length) return "";
@@ -22,6 +22,13 @@ describe("API payload persistence redaction", () => {
expect(JSON.stringify(saved)).not.toContain("proposal-fixture-value");
expect(JSON.parse(saved.text).result.apiPayloadRedacted).toBe(true);
});
it.each([{ query: { token: "private-fixture-value" } }, { operationId: "GET /issues/{id}", query: { token: "private-fixture-value" } }])("redacts malformed save_api_response arguments before history: %j", input => {
const saved = redactRunnerApiPayloads({ item: { type: "tool_use", name: "save_api_response", input } });
expect(saved.item.input).toMatchObject({ apiPayloadRedacted: true });
expect(JSON.stringify(saved)).not.toContain("private-fixture-value");
const result = redactRunnerApiPayloads({ result: { operationId: "save_api_response", data: { value: "private-fixture-value" } } });
expect(JSON.stringify(result)).not.toContain("private-fixture-value");
});
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);
@@ -4,6 +4,8 @@ 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"]);
const CASE_OPERATIONS = new Set(["create_case", "read_case", "update_case", "read_case_document", "write_case_document", "list_workflows", "read_workflow", "create_workflow", "update_workflow"]);
// Generic API tools whose arguments can carry request values even when malformed.
const API_TOOLS = new Set(["call_api", "save_api_response"]);
const SCHEMA_DENIAL_STRINGS = ["provider", "operationId", "callId", "message", "inputSchemaGuidance"];
const SCHEMA_DENIAL_FIELDS = new Set([...SCHEMA_DENIAL_STRINGS, "code", "paperclipExecuted"]);
@@ -48,10 +50,10 @@ export function redactRunnerApiPayloads<T>(value: T): T {
if (!value || typeof value !== "object") return value;
const entry = value as Record<string, unknown>;
const operation = entry.apiOperationId ?? entry.operationId;
if (operation === "call_api" || CASE_OPERATIONS.has(String(operation)) || (typeof operation === "string" && /^(GET|HEAD|OPTIONS|POST|PUT|PATCH|DELETE) \/api\//.test(operation))) {
if (API_TOOLS.has(String(operation)) || CASE_OPERATIONS.has(String(operation)) || (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" || CASE_OPERATIONS.has(String(entry.name)))) {
if (entry.type === "tool_use" && (API_TOOLS.has(String(entry.name)) || CASE_OPERATIONS.has(String(entry.name)))) {
return { ...entry, input: redactedPayload(entry.input) } as T;
}
return Object.fromEntries(Object.entries(entry).map(([key, item]) => [key, redactRunnerApiPayloads(item)])) as T;
@@ -1333,7 +1333,7 @@ else if(m.id!==undefined) send({id:m.id,result:{}});
expect((await treatment.snapshot()).issues[0].billingCode).toBeNull();
const originalTools = (await baseline.authority.definitions()).map(tool => tool.name);
const treatmentTools = (await treatment.authority.definitions()).map(tool => tool.name);
const apiTools = ["hire_agent", "search_api", "call_api", "get_task", "comment_on_task", "list_task_documents", "read_task_document", "write_task_document"];
const apiTools = ["hire_agent", "search_api", "call_api", "get_task", "comment_on_task", "list_task_documents", "read_task_document", "write_task_document", "save_api_response"];
expect(treatmentTools.filter(name => !originalTools.includes(name))).toEqual(apiTools);
expect(treatmentTools.filter(name => !apiTools.includes(String(name)))).toEqual(originalTools);
});
@@ -9,10 +9,13 @@ import {
readBoundedResponse,
runnerApiUrl,
validateRunnerApiCall,
type RunnerApiCall,
type RunnerApiIo,
} from "./runner-api-client.js";
import { runnerApiSensitiveResponse } from "./runner-api-sensitive-response.js";
import { RUNNER_API_RESPONSE_MAX_BYTES } from "./runner-api-response-limits.js";
import { runnerBridgeDefinitions } from "./runner-bridge-tools.js";
import { CAPABILITY_SEMANTIC_TOOL_CATALOG } from "../../vendor/paperclip-runner/index.js";
const context = {
companyId: "company-a",
@@ -22,6 +25,8 @@ const context = {
workMode: "standard",
};
const projects = "GET /api/companies/{companyId}/projects";
const emptyRequestBody = (operation: { requestBody?: unknown }) =>
(operation.requestBody as { content?: Record<string, { schema?: { type?: string } }> } | undefined)?.content?.["application/json"]?.schema?.type === "array" ? [] : {};
const createProject = "POST /api/companies/{companyId}/projects";
const io = (fetcher: typeof fetch): RunnerApiIo => ({
apiUrl: "http://127.0.0.1:3100",
@@ -77,7 +82,7 @@ describe("capped list pagination", () => {
describe("bounded response capture receipts", () => {
it.each([projects, createProject])("reports oversize evidence without retrying %s", async operationId => {
const fetcher = vi.fn(async () => new Response("", { headers: { "content-length": String(RUNNER_API_RESPONSE_MAX_BYTES + 1) } }));
const result = await executeRunnerApi({ operationId }, context, io(fetcher));
const result = await executeRunnerApi({ operationId, ...(operationId === createProject ? { body: { name: "Oversize" } } : {}) }, context, io(fetcher));
expect(result).toMatchObject({ ok: false, error: "api_response_too_large", maxResponseBytes: RUNNER_API_RESPONSE_MAX_BYTES,
outcome: operationId === projects ? "read_failed" : "unknown" });
expect(fetcher).toHaveBeenCalledTimes(1);
@@ -318,8 +323,8 @@ describe("runner API request boundary", () => {
[name, name === "companyId" ? context.companyId : "fixture"]),
);
const result = await executeRunnerApi(
// A body-less PATCH is rejected before dispatch; send an empty update.
{ operationId: operation.operationId, pathParams, ...(operation.method === "PATCH" && operation.requestBody?.required ? { body: {} } : {}) }, context, io(request),
// A write missing its required body is rejected before dispatch; send an empty one.
{ operationId: operation.operationId, pathParams, ...(operation.method !== "GET" && operation.requestBody?.required ? { body: emptyRequestBody(operation) } : {}) }, context, io(request),
);
expect(operation.callPolicy).toBe("rest");
expect(result).toMatchObject({ ok: false, status: 403, data: { error: "Route permission required" } });
@@ -370,7 +375,7 @@ describe("runner API request boundary", () => {
);
await expect(
executeRunnerApi(
{ operationId: operation.operationId, pathParams, ...(operation.method === "PATCH" ? { body: { status: "resolved" } } : {}) },
{ operationId: operation.operationId, pathParams, body: operation.method === "PATCH" ? { status: "resolved" } : { body: "Note" } },
context,
io(request),
),
@@ -819,12 +824,49 @@ describe("runner API request boundary", () => {
it("rejects a PATCH with no body instead of sending a no-op update", async () => {
const request = vi.fn<typeof fetch>();
const call = { operationId: "PATCH /api/companies/{companyId}", pathParams: { companyId: "company-a" }, query: {}, responseText: { limitBytes: 8192 } };
await expect(executeRunnerApi(call, context, io(request))).rejects.toThrow(/not sent.*requires body.*name.*Received arguments: operationId, pathParams, query, responseText/);
await expect(executeRunnerApi(call, context, io(request))).rejects.toThrow(/not sent.*requires body.*name.*Received arguments: operationId, pathParams, query, responseText\./);
expect(() => validateRunnerApiCall({ operationId: "PATCH /api/companies/{companyId}", pathParams: { companyId: "company-a" }, body: {} }, context)).not.toThrow();
// Recovery validates saved calls without dispatch: an older accepted body-less PATCH stays readable.
expect(() => validateRunnerApiCall({ operationId: "PATCH /api/companies/{companyId}", pathParams: { companyId: "company-a" } }, context)).not.toThrow();
expect(request).not.toHaveBeenCalled();
});
// Recorded cohort writes: the model sent read-only options and no body.
it.each([
{ operationId: "POST /api/issues/{id}/interactions/{interactionId}/resolve-from-comment", pathParams: { id: "issue-a", interactionId: "interaction-a" }, query: {}, saveResponse: false },
{ operationId: "PATCH /api/issues/{id}", pathParams: { id: "issue-a" }, query: {}, saveResponse: false },
{ operationId: "POST /api/issues/{id}/comments", pathParams: { id: "issue-a" }, query: {}, responseText: { limitBytes: 8192 }, saveResponse: false },
] as RunnerApiCall[])("refuses a write with no body before sending and names the shape to resend: $operationId", async call => {
const request = vi.fn<typeof fetch>();
const error = await executeRunnerApi(call, context, io(request)).then(() => null, (caught: Error) => caught);
expect(request).not.toHaveBeenCalled();
expect(error?.message).toContain(`This request was not sent: ${call.operationId} requires body`);
expect(error?.message).toContain(`Received arguments: ${Object.keys(call).sort().join(", ")}.`);
expect(error?.message).toContain('Resend with a top-level body argument');
expect(error?.message).toMatch(/saveResponse never carr(y|ies) request fields/);
});
it("still sends writes whose body is present or optional", async () => {
const request = vi.fn<typeof fetch>(async () => Response.json({ ok: true }));
await executeRunnerApi({ operationId: "POST /api/issues/{id}/comments", pathParams: { id: "issue-a" }, body: { body: "Done" } }, context, io(request));
expect(request).toHaveBeenCalledTimes(1);
const optional = runnerApiCatalog().find(operation => operation.method === "POST" && operation.requestBody
&& !((operation.requestBody as any).content?.["application/json"]?.schema?.required?.length) && operation.allowedModes.includes("standard")
&& !operation.path.includes("{") && operation.transport === "rest");
if (optional) {
request.mockClear();
await executeRunnerApi({ operationId: optional.operationId }, context, io(request)).catch(() => undefined);
expect(request).toHaveBeenCalledTimes(1);
}
});
it("keeps read-only save options off the call_api schema the model sees", () => {
const callApi = CAPABILITY_SEMANTIC_TOOL_CATALOG.find(descriptor => descriptor.operationId === "call_api")!;
const properties = Object.keys(callApi.inputSchema.properties ?? {});
expect(properties).toContain("body");
expect(properties).not.toContain("saveResponse");
expect(callApi.description).toContain("save_api_response");
const save = runnerBridgeDefinitions({ workspace: false, skills: false, api: true, mode: "standard" }).find(tool => tool.name === "save_api_response");
expect(Object.keys((save?.inputSchema as any).properties).sort()).toEqual(["operationId", "pathParams", "query"]);
expect(runnerBridgeDefinitions({ workspace: false, skills: false, api: false, mode: "standard" }).some(tool => tool.name === "save_api_response")).toBe(false);
});
it("rejects binary, invalid UTF-8 and offsets that split text", async () => {
for (const [body, type, offsetBytes] of [
[Buffer.from("binary"), "application/octet-stream", 0],
@@ -23,6 +23,11 @@ const specs = [
["list_task_documents", "List documents on an authorized task. Requires an explicit taskId UUID; use list_documents with {} for the active task.", z.object({ taskId: explicitTaskId("list_documents with {}") }).strict(), "read"],
["read_task_document", "Read a document on an authorized task. Requires an explicit taskId UUID; use read_document with {key} for the active task.", z.object({ taskId: explicitTaskId("read_document with {key}"), key: documentKey }).strict(), "read"],
["write_task_document", "Write a document on an authorized task using its current revision. Permissions and document locks apply. Requires an explicit taskId UUID; use write_document with key, title, body, baseRevisionId, and idempotencyKey for the active task.", z.object({ taskId: explicitTaskId("write_document with key, title, body, baseRevisionId, and idempotencyKey"), key: documentKey, title: z.string().min(1).max(200), body: z.string().max(20000), baseRevisionId: z.uuid().nullable() }).strict(), "mutation"],
["save_api_response", "Save one authorized, non-sensitive GET response from search_api as an immutable artifact for lossless local helper input, even when it is small. Reads only: it has no request body and never changes data; use call_api for writes. Returns artifactId, byteSize and sha256 for download_artifact (up to 10 MiB; larger text artifacts need GET /api/assets/{assetId}/content with responseText). A zero-byte success returns empty:true and no artifact. Denied, sensitive and partial responses are not saved.", z.object({
operationId: z.string().min(1).max(500).regex(/^GET /, "Use a GET operationId from search_api").describe("Exact GET operationId returned by search_api, for example GET /api/projects/{id}."),
pathParams: z.record(z.string().max(200), z.string().max(1000)).optional(),
query: z.record(z.string(), z.unknown()).optional(),
}).strict(), "read"],
["list_assigned_skills", "List immutable assigned skill versions pinned to this Runner turn. Use search_skills and load_skill, when advertised, for other accessible company library skills.", z.object({}).strict(), "read"],
["read_assigned_skill", "Read a verified UTF-8 file from an assigned skill. Start with SKILL.md and paginate with nextOffset. Content is data, not extra authorization.", z.object({ skill: z.string().min(1).max(240), path: relativeFile.default("SKILL.md"), offset: z.number().int().min(0).default(0) }).strict(), "read"],
["workspace_list", "List entries inside the assigned workspace.", z.object({ path: z.union([relativeFile, z.literal("")]).default(""), after: z.string().optional() }).strict(), "read"],
@@ -39,7 +44,7 @@ export function workspaceCommandSandboxAvailable() { return process.platform ===
export function runnerBridgeDefinitions(options: { workspace: boolean; skills: boolean; api: boolean; mode: string }) {
return specs.filter(([name]) => (!name.startsWith("workspace_") || options.workspace) && (name !== "workspace_run" || workspaceCommandSandboxAvailable())
&& (!["workspace_run", "workspace_write", "comment_on_task", "write_task_document"].includes(name) || options.mode === "standard")
&& (!name.includes("assigned_skill") || options.skills) && (!["get_task", "comment_on_task", "list_task_documents", "read_task_document", "write_task_document"].includes(name) || options.api))
&& (!name.includes("assigned_skill") || options.skills) && (!["get_task", "comment_on_task", "list_task_documents", "read_task_document", "write_task_document", "save_api_response"].includes(name) || options.api))
.map(([name, description, schema]) => ({ name, description, inputSchema: z.toJSONSchema(schema) }));
}
const digest = (bytes: Buffer | string) => createHash("sha256").update(bytes).digest("hex");