mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
feat(runner): export authorized read responses as artifacts (#20606)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native agents use authorized API reads to get task evidence. > - Small responses return inline JSON, so an agent must copy each document into a local helper. > - Copying changes bytes and uses model output for data transport. > - This pull request adds an explicit artifact export for non-sensitive GET reads. > - Helpers can download the exact response bytes and check their identity. ## Linked Issues or Issue Description **What existing behavior does this improve?** The native `call_api` response capture path. **Current behavior** Small JSON responses have no artifact reference. Local helpers need the model to copy the JSON data. **Proposed behavior** `saveResponse: true` saves a complete authorized GET response below or above the inline threshold. Zero-byte success returns an explicit empty receipt without storage. The receipt returns an artifact ID, byte size and SHA-256 for `download_artifact`. Other callers keep the current behavior. Related work: #17393 covers saved app-tool result size. #18105 covers mutation response paging. This change adds an explicit read export. ## What Changed - Add GET-only `saveResponse` validation and capture through the current artifact store. - Refuse sensitive operations, denied reads, partial responses and combined text paging. - Keep route authorization, actor visibility, storage budgets and download integrity checks. - Update the native tool contract, generated catalogs and API guide. - Add boundary and real-route byte comparison tests. - Preserve saved run state. This is an optional input field; old calls and receipts keep their current shape. No schema migration or identity reset is needed. The seeded catalog fixture is generated. ## Verification - Final published head `d47b52e1bf412418e24768f865cc139097a6d068`: [typecheck, build, all tests, Linux Rust and aggregate verify passed](https://github.com/paperclipai/paperclip/actions/runs/42918883477); [Windows Rust passed](https://github.com/paperclipai/paperclip/actions/runs/42918883348); [quality gate passed](https://github.com/paperclipai/paperclip/actions/runs/42918883016). Greptile completed successfully at this exact head, 5/5; all five review threads resolved. Automated Paperclip review remains queued due to a failed reviewer run; no final verdict is claimed for that service. - Current head: `d47b52e1bf412418e24768f865cc139097a6d068`. Merged current master to refresh GitHub's stale base snapshot; the PR now contains exactly nine intended artifact transport files, with no lockfile or UI changes. Fresh final-head CI/review is required. - Previous head `986ad87490fff93cfec313ba6fd56d3dc0bb665b` passed typecheck, build, Rust verification, artifact/native suites and completed test shards; remaining shards were still running when the base snapshot was refreshed. Greptile was 5/5, with one unrelated master UI finding resolved as outside the corrected PR diff. - Review fixes verified at `986ad87490fff93cfec313ba6fd56d3dc0bb665b`: 2,135 checks passed across the five focused suites; Runner TypeScript build and regenerated contract/catalog checks passed. This head replays the original implementation on current master and retains the original PR commit in its history. - Review red/green at replayed baseline `da3a1d4de9`: empty HTTP 200/204 tests failed with real storage's `File is empty`, then passed with zero new assets or quota reservations. Error-body tests failed without size/truncation fields, then passed. - Empty successes now return `empty: true`, `byteSize: 0`, `data: null` without storage. Nonempty JSON results such as `[]` export normally. Error receipts report byte size and truncation. Docs and tool guidance state the existing 10 MiB download limit and paged text alternative. - Previous-head CI typecheck/build/tests passed except the Rust process-lineage cleanup test (`unattributed_orphan`); final-head CI and fresh review remain required. - Red at `7368a0b15c`: the small-document export test failed with `Invalid call_api arguments` before production edits. The same check passed after the change. - Final post-rebase run at `6e7a35ed6a59bb74f4218312d9f10557099433c3`: all 2,124 checks passed across the API, capture, artifact boundary, real-route export and artifact download suites. - Real-route export test passed: HTTP bytes, stored bytes and downloaded bytes were equal. Foreign-company access and forged download metadata were rejected. - Existing API integration and artifact suites passed. Nearby API integration checks also passed (59 passed, 2 skipped). - Disposable native helper scan passed: 12 existing documents, 15 GET requests, zero mutations, 73,919 bytes, 17.985 seconds. All files passed size/hash checks. The bridge passed file references only. Production token savings were not measured. - Runner TypeScript build and generated contract checks passed. - `pnpm verify` returned exit 2 and required broader CI coverage. Local preflight stopped at missing Cargo after dependency and SDK setup. The local server typecheck stopped during the cycle layer without a diagnostic; the container recorded an OOM kill. This check has no pass verdict. Required CI must cover typecheck, build and test at the final head. ## Risks - Each explicit export uses the current capture budget and storage admission. Frequent small exports can reach that budget. - An artifact holds one HTTP response. Callers must still follow list pagination. - No database or saved-state migration is required. The option does not change default response behavior. ## Model Used OpenAI `gpt-6.1-sol`, through Codex in Paperclip Runner. Used reasoning, shell execution and native tools. The active agent configuration supplied this exact model ID. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
bc453342ef
commit
84fc67317e
9 files changed
+228
-8
No files matched your search
+23
-1
@@ -54,6 +54,28 @@ exactly one authorized `artifactId` or task-workspace `path`, and an optional
|
|||||||
multipart `field`. No arbitrary URL, headers, authentication, or remote file URL
|
multipart `field`. No arbitrary URL, headers, authentication, or remote file URL
|
||||||
can be supplied. Routes still validate payloads and enforce permissions.
|
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
|
||||||
|
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.
|
||||||
|
Artifact receipts contain `artifactId`, `byteSize` and `sha256`. For artifacts up
|
||||||
|
to 10 MiB, pass those exact values to `download_artifact` with a workspace-relative
|
||||||
|
destination. Larger text snapshots (up to the 1 GiB capture limit) remain readable
|
||||||
|
through `GET /api/assets/{assetId}/content` with `responseText`; follow
|
||||||
|
`nextOffsetBytes` until null. They cannot be installed with `download_artifact`;
|
||||||
|
narrow or paginate the source query for local helper input. Pass downloaded files
|
||||||
|
to the helper instead of recreating JSON in model output. Pagination metadata remains in the receipt; each
|
||||||
|
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
|
||||||
|
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
|
||||||
|
request runs once. Default inline behavior is unchanged when the option is absent.
|
||||||
|
|
||||||
Requests have a 16 KiB URL limit and 10 MiB request/upload limit. **New response
|
Requests have a 16 KiB URL limit and 10 MiB request/upload limit. **New response
|
||||||
captures are limited to 1 GiB of decoded bytes.** This is separate from the 24 KiB
|
captures are limited to 1 GiB of decoded bytes.** This is separate from the 24 KiB
|
||||||
inline/page limit. The receiver rejects an oversized Content-Length before
|
inline/page limit. The receiver rejects an oversized Content-Length before
|
||||||
@@ -74,7 +96,7 @@ assets; text previews are limited to 2,000 bytes.
|
|||||||
Large captures reserve 1 GiB against a **durable 4 GiB per-run capture budget**
|
Large captures reserve 1 GiB against a **durable 4 GiB per-run capture budget**
|
||||||
before creating a file. A completed capture settles to its actual byte count;
|
before creating a file. A completed capture settles to its actual byte count;
|
||||||
failed/interrupted captures retain the full reservation to bound retry loops.
|
failed/interrupted captures retain the full reservation to bound retry loops.
|
||||||
A process restart does not reset that budget. Small inline responses and reads
|
A process restart does not reset that budget. Small inline responses without explicit export and reads
|
||||||
of existing assets need no reservation. Concurrent large captures are limited
|
of existing assets need no reservation. Concurrent large captures are limited
|
||||||
to **two per company and four per server process**, including the storage upload
|
to **two per company and four per server process**, including the storage upload
|
||||||
and temporary-file cleanup. Budget exhaustion returns `api_response_capture_limit`;
|
and temporary-file cleanup. Budget exhaustion returns `api_response_capture_limit`;
|
||||||
|
|||||||
@@ -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",
|
"planning",
|
||||||
"skill_test"
|
"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 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 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.",
|
||||||
"effect": "write",
|
"effect": "write",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -218,6 +218,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object"
|
"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": [
|
"required": [
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"prpVersion": 1,
|
"prpVersion": 1,
|
||||||
"nativeExecutionVersion": 1,
|
"nativeExecutionVersion": 1,
|
||||||
"catalogVersion": 1,
|
"catalogVersion": 1,
|
||||||
"catalogSha256": "sha256:bcbe20460b6f3cf3fc814b088d24c81fc2cfb337c42f2907b416c29b4410e76d",
|
"catalogSha256": "sha256:c5b8e95e8125d68467b8f29d86426fde5280f59e111bc4e48f8684daa174aaca",
|
||||||
"driverContractVersion": 1,
|
"driverContractVersion": 1,
|
||||||
"driverKind": "paperclip-deterministic",
|
"driverKind": "paperclip-deterministic",
|
||||||
"driverVersion": "1.0.0"
|
"driverVersion": "1.0.0"
|
||||||
|
|||||||
@@ -185,7 +185,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "fixtures/evals/native-execution-seeded.json",
|
"path": "fixtures/evals/native-execution-seeded.json",
|
||||||
"sha256": "b4fd01c42444d812bd3d6335f65a9871a7615a1183313d27a17f9467c0656524",
|
"sha256": "2cd8450f79dc7ccd618072ba6da43fcb02c56f59cb83164760e066583f1357ba",
|
||||||
"expectation": "accept",
|
"expectation": "accept",
|
||||||
"compatibilityCase": "canonical"
|
"compatibilityCase": "canonical"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const callApiAction = {
|
|||||||
},
|
},
|
||||||
"documentation": {
|
"documentation": {
|
||||||
"title": "Call the Paperclip API",
|
"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 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 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.",
|
||||||
"note": "Production HTTP fallback; does not grant privileges or replace dedicated tools."
|
"note": "Production HTTP fallback; does not grant privileges or replace dedicated tools."
|
||||||
},
|
},
|
||||||
"examples": {
|
"examples": {
|
||||||
@@ -51,7 +51,7 @@ export const callApiAction = {
|
|||||||
"operationId": "call_api",
|
"operationId": "call_api",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"title": "Call the Paperclip API",
|
"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 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 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.",
|
||||||
"exposure": "optional",
|
"exposure": "optional",
|
||||||
"requiredClaims": [
|
"requiredClaims": [
|
||||||
"api:call"
|
"api:call"
|
||||||
@@ -96,6 +96,10 @@ export const callApiAction = {
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 120
|
"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": {
|
"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.",
|
"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",
|
"type": "object",
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { assets, runnerApiResponseReservations } from "@paperclipai/db";
|
||||||
|
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { startRunnerApiTestServer } from "../../__tests__/helpers/runner-api-server.js";
|
||||||
|
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
|
||||||
|
|
||||||
|
describe("authorized read artifacts through real routes and storage", () => {
|
||||||
|
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
|
||||||
|
const oldSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||||
|
beforeAll(async () => {
|
||||||
|
process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID();
|
||||||
|
server = await startRunnerApiTestServer();
|
||||||
|
}, 60000);
|
||||||
|
afterAll(async () => {
|
||||||
|
await server?.close();
|
||||||
|
if (oldSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||||
|
else process.env.PAPERCLIP_AGENT_JWT_SECRET = oldSecret;
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([200, 204])("returns an explicit empty HTTP %s receipt without storage or quota reservations", async status => {
|
||||||
|
const fixture = await server.fixture();
|
||||||
|
const authority = new PaperclipRunnerToolAuthority(server.db, { ...fixture, storage: server.storage, workspaceRoot: fixture.workspace });
|
||||||
|
const before = await server.db.select().from(assets).where(eq(assets.companyId, fixture.companyId));
|
||||||
|
// Inject only the upstream empty response; storage and reservation writes
|
||||||
|
// remain real so production's empty-file rejection cannot be hidden.
|
||||||
|
const spy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status }));
|
||||||
|
try {
|
||||||
|
const receipt = await authority.execute({ tool: "call_api", callId: randomUUID(), arguments: {
|
||||||
|
operationId: "GET /api/issues/{id}", pathParams: { id: fixture.issueId }, saveResponse: true,
|
||||||
|
} });
|
||||||
|
expect(receipt).toMatchObject({ ok: true, status, empty: true, byteSize: 0, data: null });
|
||||||
|
expect(receipt).not.toHaveProperty("artifact");
|
||||||
|
} finally { spy.mockRestore(); }
|
||||||
|
expect(await server.db.select().from(assets).where(eq(assets.companyId, fixture.companyId))).toEqual(before);
|
||||||
|
expect(await server.db.select().from(runnerApiResponseReservations).where(eq(runnerApiResponseReservations.companyId, fixture.companyId))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves exact HTTP bytes in the immutable asset and downloaded helper input", async () => {
|
||||||
|
const fixture = await server.fixture();
|
||||||
|
const authority = new PaperclipRunnerToolAuthority(server.db, { ...fixture, workspaceRoot: fixture.workspace });
|
||||||
|
const execute = (tool: string, args: object) => authority.execute({ tool, callId: randomUUID(), arguments: args }) as Promise<any>;
|
||||||
|
const body = '# Evidence\n\né🧭 "quoted" \\escaped\n```json\n{"empty":[]}\n```';
|
||||||
|
await execute("call_api", { operationId: "PUT /api/issues/{id}/documents/{key}", pathParams: { id: fixture.issueId, key: "evidence" }, body: { title: "Evidence", format: "markdown", body, baseRevisionId: null } });
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let source: Buffer | undefined;
|
||||||
|
let requests = 0;
|
||||||
|
// Observe real authorized HTTP bytes without replacing the server or storage.
|
||||||
|
const spy = vi.spyOn(globalThis, "fetch").mockImplementation(async (...args) => {
|
||||||
|
const response = await originalFetch(...args);
|
||||||
|
requests++;
|
||||||
|
source = Buffer.from(await response.clone().arrayBuffer());
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
let receipt: any;
|
||||||
|
try {
|
||||||
|
receipt = await execute("call_api", { operationId: "GET /api/issues/{id}/documents/{key}", pathParams: { id: fixture.issueId, key: "evidence" }, saveResponse: true });
|
||||||
|
} finally { spy.mockRestore(); }
|
||||||
|
expect(requests).toBe(1);
|
||||||
|
expect(receipt).toMatchObject({ ok: true, artifact: { byteSize: source!.length, sha256: createHash("sha256").update(source!).digest("hex") } });
|
||||||
|
expect(receipt).not.toHaveProperty("data");
|
||||||
|
expect(receipt).not.toHaveProperty("preview");
|
||||||
|
const [asset] = await server.db.select().from(assets).where(eq(assets.id, receipt.artifact.artifactId));
|
||||||
|
const stored = await server.storage.getObject(fixture.companyId, asset.objectKey);
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of stored.stream) chunks.push(Buffer.from(chunk));
|
||||||
|
expect(Buffer.concat(chunks)).toEqual(source);
|
||||||
|
await execute("download_artifact", { artifactId: receipt.artifact.artifactId, byteSize: receipt.artifact.byteSize, sha256: receipt.artifact.sha256, destination: "helper-response.json" });
|
||||||
|
const downloaded = await readFile(join(fixture.workspace, "helper-response.json"));
|
||||||
|
expect(downloaded).toEqual(source);
|
||||||
|
expect(JSON.parse(downloaded.toString()).body).toBe(body);
|
||||||
|
|
||||||
|
for (const args of [
|
||||||
|
{ ...receipt.artifact, sha256: "0".repeat(64), destination: "forged.json" },
|
||||||
|
{ ...receipt.artifact, destination: "../escape.json" },
|
||||||
|
]) {
|
||||||
|
const { url, contentType, ...input } = args;
|
||||||
|
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 });
|
||||||
|
expect(denied.ok).toBe(false);
|
||||||
|
expect(denied).not.toHaveProperty("artifact");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { executeRunnerApi, RUNNER_API_INLINE_BYTES, type RunnerApiIo } from "./runner-api-client.js";
|
||||||
|
const context = { companyId: "company-a", issueId: "issue-a", runId: "run-a", workMode: "standard" };
|
||||||
|
const projects = "GET /api/companies/{companyId}/projects";
|
||||||
|
const io = (fetcher: typeof fetch): RunnerApiIo => ({
|
||||||
|
apiUrl: "http://127.0.0.1:3100", token: "private-agent-token", fetch: fetcher,
|
||||||
|
readFile: async () => { throw new Error("not an upload"); },
|
||||||
|
saveResponse: async body => {
|
||||||
|
const bytes = Buffer.isBuffer(body) ? body : await readFile(body.path);
|
||||||
|
return { artifactId: "saved", byteSize: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
describe("explicit read response artifacts", () => {
|
||||||
|
it("exports a small authorized document without JSON transcription or a second request", async () => {
|
||||||
|
const source = Buffer.from(`{ "body": ${JSON.stringify('café 🌍\n# Evidence\n"quoted"')}, "empty": [] }\n`);
|
||||||
|
const fetcher = vi.fn(async () => new Response(source, { headers: { "content-type": "application/json" } }));
|
||||||
|
let saved: Buffer | undefined;
|
||||||
|
const result = await executeRunnerApi({ operationId: projects, saveResponse: true }, context, {
|
||||||
|
...io(fetcher),
|
||||||
|
saveResponse: async (body, contentType) => {
|
||||||
|
saved = Buffer.isBuffer(body) ? body : await readFile(body.path);
|
||||||
|
return { artifactId: "document", byteSize: saved.length, contentType };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({ ok: true, artifact: { artifactId: "document", byteSize: source.length } });
|
||||||
|
expect(result).not.toHaveProperty("data");
|
||||||
|
expect(saved).toEqual(source);
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("complete authorized read export", () => {
|
||||||
|
it.each(["[]", "null", "{\n \"body\": \"é🧭\\n\\\"quoted\\\"\"\n}", ...[RUNNER_API_INLINE_BYTES - 1, RUNNER_API_INLINE_BYTES, RUNNER_API_INLINE_BYTES + 1, 100000].map(n => "x".repeat(n))])("preserves source bytes and integrity for response %#", async source => {
|
||||||
|
const bytes = Buffer.from(source);
|
||||||
|
const fetcher = vi.fn(async () => new Response(bytes, { headers: { "content-type": "text/plain", "x-paperclip-has-more": "true", "x-paperclip-next-offset": "2" } }));
|
||||||
|
const reserve = vi.fn(async () => vi.fn(async () => {}));
|
||||||
|
const result = await executeRunnerApi({ operationId: projects, saveResponse: true }, context, { ...io(fetcher), reserveResponseCapture: reserve });
|
||||||
|
expect(result).toMatchObject({ ok: true, artifact: { byteSize: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") }, pagination: { hasMore: true, nextOffset: 2 } });
|
||||||
|
expect(result).not.toHaveProperty("data");
|
||||||
|
expect(result).not.toHaveProperty("preview");
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||||
|
expect(reserve).toHaveBeenCalledTimes(1);
|
||||||
|
await expect(reserve.mock.results[0].value).resolves.toHaveBeenCalledWith(bytes.length, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([403, 500])("marks a truncated HTTP %s error body without exporting it", async status => {
|
||||||
|
const source = "é".repeat(RUNNER_API_INLINE_BYTES);
|
||||||
|
const saveResponse = vi.fn(io(fetch).saveResponse);
|
||||||
|
const result = await executeRunnerApi({ operationId: projects, saveResponse: true }, context, {
|
||||||
|
...io(async () => new Response(source, { status })), saveResponse,
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({ ok: false, status, byteSize: Buffer.byteLength(source), truncated: true });
|
||||||
|
expect(result).not.toHaveProperty("artifact");
|
||||||
|
expect(saveResponse).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([403, 404, 500, 206])("does not export an HTTP %s response", async status => {
|
||||||
|
const saveResponse = vi.fn(io(fetch).saveResponse);
|
||||||
|
const result = await executeRunnerApi({ operationId: projects, saveResponse: true }, context, {
|
||||||
|
...io(async () => new Response("private", { status })), saveResponse,
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({ ok: false, status });
|
||||||
|
expect(result).not.toHaveProperty("artifact");
|
||||||
|
expect(saveResponse).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ operationId: "POST /api/companies/{companyId}/projects" },
|
||||||
|
{ operationId: "GET /api/board-api-keys" },
|
||||||
|
{ operationId: projects, responseText: {} },
|
||||||
|
{ operationId: projects, pathParams: { companyId: "foreign" } },
|
||||||
|
])("rejects invalid export before dispatch: %j", async args => {
|
||||||
|
const fetcher = vi.fn(fetch);
|
||||||
|
await expect(executeRunnerApi({ ...args, saveResponse: true }, context, io(fetcher))).rejects.toThrow();
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the default small JSON response inline", async () => {
|
||||||
|
const saveResponse = vi.fn(io(fetch).saveResponse);
|
||||||
|
expect(await executeRunnerApi({ operationId: projects }, context, { ...io(async () => Response.json([])), saveResponse })).toMatchObject({ data: [] });
|
||||||
|
expect(saveResponse).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ export const runnerApiCallSchema = z.object({
|
|||||||
body: z.unknown().optional(),
|
body: z.unknown().optional(),
|
||||||
contentType: z.string().max(120).optional(),
|
contentType: z.string().max(120).optional(),
|
||||||
files: z.array(fileSchema).max(10).optional(),
|
files: z.array(fileSchema).max(10).optional(),
|
||||||
|
saveResponse: z.boolean().optional(),
|
||||||
responseText: z.object({
|
responseText: z.object({
|
||||||
offsetBytes: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).optional(),
|
offsetBytes: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).optional(),
|
||||||
limitBytes: z.number().int().min(4).max(RUNNER_API_INLINE_BYTES).optional(),
|
limitBytes: z.number().int().min(4).max(RUNNER_API_INLINE_BYTES).optional(),
|
||||||
@@ -55,6 +56,10 @@ export function validateRunnerApiCall(value: unknown, context: RunnerApiContext)
|
|||||||
let input = parsed.data;
|
let input = parsed.data;
|
||||||
const operation = runnerApiOperation(input.operationId);
|
const operation = runnerApiOperation(input.operationId);
|
||||||
if (operation.transport !== "rest") throw unprocessable(`This endpoint requires its existing protocol client; call_api supports REST only and no other API route substitutes for it.${operation.description.trim() ? " Read its catalog description for details." : ""}`);
|
if (operation.transport !== "rest") throw unprocessable(`This endpoint requires its existing protocol client; call_api supports REST only and no other API route substitutes for it.${operation.description.trim() ? " Read its catalog description for details." : ""}`);
|
||||||
|
if (input.saveResponse && (operation.method !== "GET" || runnerApiSensitiveResponse(operation.method, operation.path))) {
|
||||||
|
throw badRequest("This request was not sent: saveResponse supports non-sensitive GET reads only.");
|
||||||
|
}
|
||||||
|
if (input.saveResponse && input.responseText) throw badRequest("Use either saveResponse or responseText, not both.");
|
||||||
if (input.responseText && operation.method !== "GET") {
|
if (input.responseText && operation.method !== "GET") {
|
||||||
// A later page of a mutation response would need the mutation repeated.
|
// A later page of a mutation response would need the mutation repeated.
|
||||||
// A first-window request changes only presentation, so the mutation runs
|
// A first-window request changes only presentation, so the mutation runs
|
||||||
@@ -188,7 +193,7 @@ export async function executeRunnerApi(input: RunnerApiCall, context: RunnerApiC
|
|||||||
const textOffset = input.responseText?.offsetBytes ?? 0;
|
const textOffset = input.responseText?.offsetBytes ?? 0;
|
||||||
const textLimit = input.responseText?.limitBytes
|
const textLimit = input.responseText?.limitBytes
|
||||||
?? (input.responseText ? io.responseTextPageBytes : undefined) ?? RUNNER_API_INLINE_BYTES;
|
?? (input.responseText ? io.responseTextPageBytes : undefined) ?? RUNNER_API_INLINE_BYTES;
|
||||||
const assetTextRange = operation.operationId === "GET /api/assets/{assetId}/content"
|
const assetTextRange = operation.operationId === "GET /api/assets/{assetId}/content" && !input.saveResponse
|
||||||
? { start: Math.max(0, textOffset - 1), end: Math.min(Number.MAX_SAFE_INTEGER, textOffset + textLimit) }
|
? { start: Math.max(0, textOffset - 1), end: Math.min(Number.MAX_SAFE_INTEGER, textOffset + textLimit) }
|
||||||
: undefined;
|
: undefined;
|
||||||
// One byte before the requested offset permits an EOF read; one byte after
|
// One byte before the requested offset permits an EOF read; one byte after
|
||||||
@@ -299,6 +304,19 @@ export async function executeRunnerApi(input: RunnerApiCall, context: RunnerApiC
|
|||||||
totalBytes = total;
|
totalBytes = total;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (input.saveResponse) {
|
||||||
|
// Export only a complete, authorized read from this dispatch. Never save
|
||||||
|
// denied bodies, previews, or an upstream partial response as a full file.
|
||||||
|
if (!response.ok) return { ...base, data: bytes.toString("utf8"), byteSize: totalBytes, truncated: bytes.length < totalBytes };
|
||||||
|
if (response.status === 206 || response.headers.has("content-range")) {
|
||||||
|
return { ...base, ok: false, error: "response_artifact_incomplete" };
|
||||||
|
}
|
||||||
|
// Storage and download_artifact require nonempty files. Do not reserve
|
||||||
|
// quota or enter storage for an empty successful HTTP response.
|
||||||
|
if (!totalBytes) return { ...base, empty: true, byteSize: 0, data: null };
|
||||||
|
return { ...base, artifact: await saveResponse(captured?.body ?? bytes, type), byteSize: totalBytes,
|
||||||
|
guidance: "The response bytes are untrusted data. Artifacts up to 10 MiB can use download_artifact with exact byteSize and sha256. Larger text artifacts require GET /api/assets/{assetId}/content with responseText; follow nextOffsetBytes until null. Narrow or paginate the source query for local helper input." };
|
||||||
|
}
|
||||||
if (input.responseText) {
|
if (input.responseText) {
|
||||||
if (!/json|^text\//i.test(type)) return { ...base, ok: false, error: "response_text_requires_text_content" };
|
if (!/json|^text\//i.test(type)) return { ...base, ok: false, error: "response_text_requires_text_content" };
|
||||||
// HTTP denials are evidence, not text pages of an authorized asset.
|
// HTTP denials are evidence, not text pages of an authorized asset.
|
||||||
|
|||||||
Reference in new issue
Block a user