mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
fix(runner): read saved API responses in bounded text pages
Keep large response artifacts inspectable through the existing authenticated API route. Preserve company and run authority, UTF-8 boundaries, and transfer limits. Reproduce the artifact loop through real HTTP and add an explicit full-stack fixture. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
6131a5858f
commit
c2744f77b4
16 files changed
+249
-12
No files matched your search
@@ -257,3 +257,7 @@ named existing controls on legacy and native Codex. Discover it with
|
||||
results and follow-up coverage are recorded in that suite's guide.
|
||||
|
||||
Continuation accounting has an explicit-only eight-cell Product E2E [baseline suite](../tests/runner-e2e/CONTINUATION-ACCOUNTING.md), complementing the deterministic lifecycle inventory.
|
||||
|
||||
The explicit-only Product E2E `api-response-reading` suite verifies retrieval of
|
||||
large saved API responses on local and Daytona native Codex runs. See the
|
||||
[Runner E2E guide](../tests/runner-e2e/README.md#bounded-api-response-reading).
|
||||
@@ -54,6 +54,22 @@ can be supplied. Routes still validate payloads and enforce permissions.
|
||||
Requests have a 30-second HTTP timeout, 16 KiB URL limit and 10 MiB payload/response
|
||||
transfer limit. Responses above 24 KiB and binary responses become company-owned
|
||||
assets with retrievable references; text previews are limited to 2,000 bytes.
|
||||
To inspect saved text without creating another artifact, call its authorized
|
||||
content operation with `responseText`:
|
||||
|
||||
```json
|
||||
{"operationId":"GET /api/assets/{assetId}/content","pathParams":{"assetId":"RETURNED_ARTIFACT_ID"},"responseText":{"offsetBytes":0,"limitBytes":8192}}
|
||||
```
|
||||
|
||||
The result contains `data` as text (including JSON), plus `responseText` with
|
||||
`offsetBytes`, `nextOffsetBytes`, and `totalBytes`. Continue at `nextOffsetBytes`
|
||||
until it is null. Windows end at UTF-8 boundaries. The limit defaults to 24 KiB
|
||||
and accepts 4–24,576 bytes. The server rejects binary content, invalid UTF-8,
|
||||
and offsets inside a code point or beyond the response. This option only works
|
||||
with GET; never repeat a mutation to retrieve another part of its response.
|
||||
Read the saved artifact for a stable snapshot instead of paging a changing live
|
||||
response. The existing 10 MiB transfer limit and all route authorization remain.
|
||||
|
||||
Tool responses identify the HTTP route with `apiOperationId`. The native protocol
|
||||
reserves `operationId` and `callId` for semantic tool-call identity; API metadata
|
||||
must not masquerade as that envelope. Saved mutation receipts are normalized at
|
||||
|
||||
@@ -1 +1 @@
|
||||
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
|
||||
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
|
||||
@@ -112,7 +112,7 @@
|
||||
"planning",
|
||||
"skill_test"
|
||||
],
|
||||
"description": "Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool.",
|
||||
"description": "Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.",
|
||||
"effect": "write",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
@@ -199,6 +199,25 @@
|
||||
"query": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"responseText": {
|
||||
"additionalProperties": false,
|
||||
"description": "GET only: return a bounded UTF-8 text window inline, including JSON as text, without saving another artifact. Offsets and limits are bytes. Use the returned nextOffsetBytes to continue; null means complete. Prefer reading a saved artifact for a stable snapshot. The 10 MiB transfer limit still applies.",
|
||||
"properties": {
|
||||
"limitBytes": {
|
||||
"default": 24576,
|
||||
"maximum": 24576,
|
||||
"minimum": 4,
|
||||
"type": "integer"
|
||||
},
|
||||
"offsetBytes": {
|
||||
"default": 0,
|
||||
"maximum": 10485760,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"prpVersion": 1,
|
||||
"nativeExecutionVersion": 1,
|
||||
"catalogVersion": 1,
|
||||
"catalogSha256": "sha256:1fa8ecf5d3c28e62a32e818de95633ff3efe5d4af1a100310516ff9b5ab2358d",
|
||||
"catalogSha256": "sha256:c5e8a60c932b37ae017b1e27d2dbc92e1ffcca628b9797a87613cb4b5bb411b5",
|
||||
"driverContractVersion": 1,
|
||||
"driverKind": "paperclip-deterministic",
|
||||
"driverVersion": "1.0.0"
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
},
|
||||
{
|
||||
"path": "fixtures/evals/native-execution-seeded.json",
|
||||
"sha256": "5f3144ea856ad27778f47de73eb542703b1d9df2abad2c427838fbaf69c3d59f",
|
||||
"sha256": "2301e907b4a76d4e9f875259133a8aa20516368bb8b8380c831d20cd1aa10df3",
|
||||
"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 your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool.",
|
||||
"description": "Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.",
|
||||
"note": "Production HTTP fallback; does not grant privileges or replace dedicated tools."
|
||||
},
|
||||
"examples": {
|
||||
@@ -51,7 +51,7 @@ export const callApiAction = {
|
||||
"operationId": "call_api",
|
||||
"version": 1,
|
||||
"title": "Call the Paperclip API",
|
||||
"description": "Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool.",
|
||||
"description": "Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.",
|
||||
"exposure": "optional",
|
||||
"requiredClaims": [
|
||||
"api:call"
|
||||
@@ -96,6 +96,15 @@ export const callApiAction = {
|
||||
"type": "string",
|
||||
"maxLength": 120
|
||||
},
|
||||
"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. The 10 MiB transfer limit still applies.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"offsetBytes": { "type": "integer", "minimum": 0, "maximum": 10485760, "default": 0 },
|
||||
"limitBytes": { "type": "integer", "minimum": 4, "maximum": 24576, "default": 24576 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"files": {
|
||||
"type": "array",
|
||||
"maxItems": 10,
|
||||
|
||||
@@ -18,6 +18,10 @@ export const runnerApiCallSchema = z.object({
|
||||
body: z.unknown().optional(),
|
||||
contentType: z.string().max(120).optional(),
|
||||
files: z.array(fileSchema).max(10).optional(),
|
||||
responseText: z.object({
|
||||
offsetBytes: z.number().int().min(0).max(RUNNER_API_MAX_BYTES).optional(),
|
||||
limitBytes: z.number().int().min(4).max(RUNNER_API_INLINE_BYTES).optional(),
|
||||
}).strict().optional(),
|
||||
}).strict();
|
||||
export type RunnerApiCall = z.infer<typeof runnerApiCallSchema>;
|
||||
export type RunnerApiFile = z.infer<typeof fileSchema>;
|
||||
@@ -44,6 +48,7 @@ export function validateRunnerApiCall(value: unknown, context: RunnerApiContext)
|
||||
const input = parsed.data;
|
||||
const operation = runnerApiOperation(input.operationId);
|
||||
if (operation.transport !== "rest") throw unprocessable("This endpoint requires its existing protocol client; call_api supports REST only");
|
||||
if (input.responseText && operation.method !== "GET") throw badRequest("responseText requires a GET operation; read the saved artifact instead of repeating a mutation");
|
||||
const restriction = runnerApiRestriction(operation.method, operation.path);
|
||||
if (restriction) throw forbidden(restriction);
|
||||
if (!operation.allowedModes.includes(context.workMode)) throw forbidden("call_api permits only reads in Ask and Plan modes; use the permitted dedicated tools");
|
||||
@@ -139,7 +144,8 @@ export async function readBoundedResponse(response: Response, maxBytes = RUNNER_
|
||||
}
|
||||
|
||||
export async function executeRunnerApi(input: RunnerApiCall, context: RunnerApiContext, io: RunnerApiIo) {
|
||||
const { operation } = validateRunnerApiCall(input, context);
|
||||
const { operation, input: validated } = validateRunnerApiCall(input, context);
|
||||
input = validated;
|
||||
const url = runnerApiUrl(operation, input, context, io.apiUrl);
|
||||
if (!io.token) throw new Error("Paperclip run authentication is unavailable");
|
||||
const headers = new Headers({ Authorization: `Bearer ${io.token}`, "X-Paperclip-Run-Id": context.runId });
|
||||
@@ -192,9 +198,27 @@ export async function executeRunnerApi(input: RunnerApiCall, context: RunnerApiC
|
||||
const uncertainty = { outcome: "unknown", guidance: "Inspect current state before retrying this mutation; it may already have succeeded." };
|
||||
const base = { ok: response.ok, status: response.status, operationId: operation.operationId, contentType: type, retryAfter: response.headers.get("retry-after"), ...(uncertain ? uncertainty : {}) };
|
||||
if (response.status >= 300 && response.status < 400) return { ...base, ok: false, error: "api_redirect_not_followed" };
|
||||
if (input.responseText) {
|
||||
if (!/json|^text\//i.test(type)) return { ...base, ok: false, error: "response_text_requires_text_content" };
|
||||
const offsetBytes = input.responseText.offsetBytes ?? 0;
|
||||
if (offsetBytes > bytes.length || (offsetBytes < bytes.length && (bytes[offsetBytes] & 0xc0) === 0x80)) {
|
||||
return { ...base, ok: false, error: "response_text_invalid_offset", byteSize: bytes.length };
|
||||
}
|
||||
let end = Math.min(bytes.length, offsetBytes + (input.responseText.limitBytes ?? RUNNER_API_INLINE_BYTES));
|
||||
// Do not cut a UTF-8 code point. The next offset always starts a whole one.
|
||||
while (end < bytes.length && end > offsetBytes && (bytes[end] & 0xc0) === 0x80) end--;
|
||||
if (end === offsetBytes && end < bytes.length) return { ...base, ok: false, error: "response_text_invalid_utf8" };
|
||||
try {
|
||||
const data = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes.subarray(offsetBytes, end));
|
||||
return { ...base, data, responseText: { offsetBytes, nextOffsetBytes: end < bytes.length ? end : null, totalBytes: bytes.length } };
|
||||
} catch {
|
||||
return { ...base, ok: false, error: "response_text_invalid_utf8" };
|
||||
}
|
||||
}
|
||||
if (!bytes.length) return { ...base, data: null };
|
||||
if (bytes.length > RUNNER_API_INLINE_BYTES || !/json|^text\//i.test(type)) {
|
||||
return { ...base, artifact: await io.saveResponse(bytes, type), byteSize: bytes.length, preview: /json|^text\//i.test(type) ? bytes.subarray(0, 2000).toString("utf8") : null };
|
||||
return { ...base, artifact: await io.saveResponse(bytes, type), byteSize: bytes.length, preview: /json|^text\//i.test(type) ? bytes.subarray(0, 2000).toString("utf8") : null,
|
||||
...(/json|^text\//i.test(type) ? { guidance: "Read the saved artifact with GET /api/assets/{assetId}/content and responseText: { offsetBytes: 0 }. Continue with responseText.nextOffsetBytes until null. Do not repeat a mutation to read its response." } : {}) };
|
||||
}
|
||||
const text = bytes.toString("utf8");
|
||||
if (/json/i.test(type)) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { chmod, writeFile, symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { documents, heartbeatRuns, issues, routineDocuments, routines } from "@paperclipai/db";
|
||||
import { assets, documents, heartbeatRuns, issues, routineDocuments, routines } from "@paperclipai/db";
|
||||
import { beforeAll, afterAll, beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { startRunnerApiTestServer } from "../../__tests__/helpers/runner-api-server.js";
|
||||
import { createRunnerdCodexTransport, defaultCapabilityRunnerdBinary } from "../../vendor/paperclip-runner/index.js";
|
||||
@@ -248,6 +248,29 @@ else if(m.id!==undefined) send({id:m.id,result:{}});
|
||||
} finally { getObject.mockRestore(); }
|
||||
});
|
||||
|
||||
it("retrieves every byte of a saved large response without creating an artifact loop", async () => {
|
||||
const fixture = await server.fixture();
|
||||
const foreign = await server.fixture();
|
||||
const text = JSON.stringify({ padding: "🧭é".repeat(6000), evidence: "last-field-is-readable" });
|
||||
const stored = await server.storage.putFile({ companyId: fixture.companyId, namespace: "eval", originalFilename: "evidence.json", contentType: "application/json", body: Buffer.from(text) });
|
||||
const [asset] = await server.db.insert(assets).values({ ...stored, companyId: fixture.companyId, createdByAgentId: fixture.agentId }).returning();
|
||||
const call = (authority: typeof fixture.authority, assetId: string, responseText?: object) => authority.execute({ tool: "call_api", callId: randomUUID(), arguments: { operationId: "GET /api/assets/{assetId}/content", pathParams: { assetId }, ...(responseText ? { responseText } : {}) } }) as Promise<any>;
|
||||
const initial = await call(fixture.authority, asset.id);
|
||||
expect(initial).toMatchObject({ status: 200, artifact: { byteSize: Buffer.byteLength(text) } });
|
||||
const before = (await fixture.snapshot()).assets.length;
|
||||
let offsetBytes = 0;
|
||||
let result = "";
|
||||
do {
|
||||
const page = await call(fixture.authority, initial.artifact.artifactId, { offsetBytes, limitBytes: 4096 });
|
||||
expect(page).toMatchObject({ status: 200, responseText: { offsetBytes, totalBytes: Buffer.byteLength(text) } });
|
||||
result += page.data;
|
||||
offsetBytes = page.responseText.nextOffsetBytes;
|
||||
} while (offsetBytes !== null);
|
||||
expect(JSON.parse(result)).toEqual(JSON.parse(text));
|
||||
expect((await fixture.snapshot()).assets).toHaveLength(before);
|
||||
expect(await call(foreign.authority, initial.artifact.artifactId, { offsetBytes: 0 })).toMatchObject({ ok: false, status: 404 });
|
||||
});
|
||||
|
||||
it("contains workspace files, checks artifact ownership, and persists downloads", async () => {
|
||||
const fixture = await server.fixture();
|
||||
const foreign = await server.fixture();
|
||||
|
||||
@@ -591,6 +591,51 @@ describe("runner API request boundary", () => {
|
||||
).toThrow("inline JSON object");
|
||||
}
|
||||
});
|
||||
it("reads complete large text without recursively saving response artifacts", async () => {
|
||||
const text = "prefix\n" + "🧭é\n".repeat(6000) + "END-OF-EVIDENCE";
|
||||
const saveResponse = vi.fn(io(fetch).saveResponse);
|
||||
const request = vi.fn<typeof fetch>(async () => new Response(text, { headers: { "content-type": "application/json" } }));
|
||||
let offsetBytes = 0;
|
||||
let received = "";
|
||||
do {
|
||||
const result = await executeRunnerApi({ operationId: projects, responseText: { offsetBytes, limitBytes: 4096 } }, context, { ...io(request), saveResponse }) as any;
|
||||
expect(result).toMatchObject({ ok: true, responseText: { offsetBytes, totalBytes: Buffer.byteLength(text) } });
|
||||
expect(Buffer.byteLength(result.data)).toBeLessThanOrEqual(4096);
|
||||
received += result.data;
|
||||
offsetBytes = result.responseText.nextOffsetBytes;
|
||||
} while (offsetBytes !== null);
|
||||
expect(received).toBe(text);
|
||||
expect(saveResponse).not.toHaveBeenCalled();
|
||||
expect(request.mock.calls.every(([, options]) => new Headers(options?.headers).get("Authorization") === "Bearer private-agent-token")).toBe(true);
|
||||
});
|
||||
it.each([{ offsetBytes: -1 }, { limitBytes: 0 }, { limitBytes: 3 }, { limitBytes: 24577 }, { offsetBytes: 0.5 }])("rejects invalid text windows before dispatch: %j", async responseText => {
|
||||
const request = vi.fn<typeof fetch>();
|
||||
await expect(executeRunnerApi({ operationId: projects, responseText }, context, io(request))).rejects.toThrow("Invalid call_api arguments");
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not page mutation responses by repeating the mutation", async () => {
|
||||
const request = vi.fn<typeof fetch>();
|
||||
await expect(executeRunnerApi({ operationId: createProject, body: { name: "Once" }, responseText: {} }, context, io(request))).rejects.toThrow("GET");
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
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],
|
||||
[Buffer.from([0xff]), "text/plain", 0],
|
||||
[Buffer.concat([Buffer.from([0xc2]), Buffer.alloc(24576, 0x80)]), "text/plain", 0],
|
||||
[Buffer.from("é"), "text/plain", 1],
|
||||
[Buffer.from("short"), "text/plain", 6],
|
||||
] as const) {
|
||||
const saveResponse = vi.fn(io(fetch).saveResponse);
|
||||
const result = await executeRunnerApi({ operationId: projects, responseText: { offsetBytes } }, context, { ...io(async () => new Response(body, { headers: { "content-type": type } })), saveResponse });
|
||||
expect(result).toMatchObject({ ok: false, error: expect.stringContaining("response_text") });
|
||||
expect(saveResponse).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
it("preserves denials and the transfer limit for bounded text reads", async () => {
|
||||
expect(await executeRunnerApi({ operationId: projects, responseText: {} }, context, io(async () => Response.json({ error: "denied" }, { status: 403 })))).toMatchObject({ ok: false, status: 403, data: '{"error":"denied"}' });
|
||||
expect(await executeRunnerApi({ operationId: projects, responseText: {} }, context, io(async () => new Response("small", { headers: { "content-type": "text/plain", "content-length": String(10 * 1024 * 1024 + 1) } })))).toMatchObject({ ok: false, status: null, outcome: "read_failed" });
|
||||
});
|
||||
it("bounds streamed responses even without content-length", async () => {
|
||||
await expect(
|
||||
readBoundedResponse(new Response("too large"), 3),
|
||||
|
||||
@@ -1042,3 +1042,15 @@ keys with low request limits. It can only lower the configured campaign limit.
|
||||
Keep subscription qualification separate from API-key results.
|
||||
|
||||
The explicit-only eight-cell [continuation accounting baseline](CONTINUATION-ACCOUNTING.md) tests productive work, bounded repair, restart and late gates with real providers.
|
||||
|
||||
### Bounded API response reading
|
||||
|
||||
`api-response-reading` is an explicit-only, two-cell native Codex suite (local
|
||||
and Daytona). Each cell creates synthetic diagnostic evidence over 24 KiB via
|
||||
the public API, outside the agent assignment. The browser starts one ordinary
|
||||
task which must read the saved response in bounded text pages and persist the
|
||||
exact hidden evidence code. The oracle also requires completed API tool events;
|
||||
missing events or a narrative completion cannot pass. Existing run, copyback,
|
||||
screenshot, billing and environment cleanup checks apply. Use
|
||||
`--id api-response-reading.runner-codex.daytona.saved-text-pages` with an
|
||||
immutable Daytona image; no private hooks or fixture database writes are used.
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { apiResponseReadingTask, responseEvidenceCode, responseEvidenceDescription, successfulApiReadCount } from "./api-response-reading.js";
|
||||
|
||||
describe("large API response evidence fixture", () => {
|
||||
it("keeps evidence beyond both preview and inline limits and out of the assignment", () => {
|
||||
const source = responseEvidenceDescription("fixture");
|
||||
const code = responseEvidenceCode("fixture");
|
||||
expect(Buffer.byteLength(source)).toBeGreaterThan(24 * 1024);
|
||||
expect(source.indexOf(code)).toBeGreaterThan(24 * 1024);
|
||||
expect(apiResponseReadingTask.buildPrompt("fixture")).not.toContain(code);
|
||||
expect(apiResponseReadingTask.buildPrompt("fixture")).toContain("responseText.nextOffsetBytes");
|
||||
});
|
||||
it("counts only real completed API tool events, never narrative claims or missing evidence", () => {
|
||||
const event = { eventType: "tool.execution.completed", payload: { prpEvent: { payload: { name: "call_api", status: "completed" } } } };
|
||||
expect(successfulApiReadCount([event])).toBe(1);
|
||||
expect(successfulApiReadCount([])).toBe(0);
|
||||
expect(successfulApiReadCount([{ ...event, eventType: "item.delta" }, { ...event, payload: null }, { ...event, payload: { prpEvent: { payload: { name: "call_api", status: "failed" } } } }])).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { RunnerTaskFixture } from "./types.js";
|
||||
|
||||
export function responseEvidenceCode(nonce: string) {
|
||||
return createHash("sha256").update(`bounded-response-evidence:${nonce}`).digest("hex");
|
||||
}
|
||||
export function responseEvidenceDescription(nonce: string) {
|
||||
return `${"Synthetic diagnostic padding.\n".repeat(1400)}\nEvidence code: ${responseEvidenceCode(nonce)}\n`;
|
||||
}
|
||||
|
||||
export function successfulApiReadCount(events: readonly { eventType?: string; payload?: unknown }[]) {
|
||||
return events.filter(event => {
|
||||
const outer = event.payload as { prpEvent?: { payload?: { name?: string; status?: string } } } | null;
|
||||
const payload = outer?.prpEvent?.payload;
|
||||
return event.eventType === "tool.execution.completed" && payload?.name === "call_api" && payload.status === "completed";
|
||||
}).length;
|
||||
}
|
||||
|
||||
export const apiResponseReadingTask: RunnerTaskFixture = {
|
||||
id: "saved-text-pages", label: "Read saved large API evidence", groups: [],
|
||||
workMode: "standard", flow: "single_turn", expectedRunCount: 1,
|
||||
attemptTimeoutMs: { local: 10 * 60_000, daytona: 10 * 60_000 },
|
||||
expectedTerminalState: { issue: "done", run: "succeeded" },
|
||||
buildTitle: nonce => `Read large API evidence ${nonce}`,
|
||||
buildVisibleMarker: nonce => `API_RESPONSE_READ_${nonce}`,
|
||||
buildPrompt: nonce => [
|
||||
"Inspect diagnostic evidence task {{API_RESPONSE_SOURCE_ID}} using the Paperclip API tools.",
|
||||
"Discover GET /api/issues/{id}, call it for that task, and retain the returned response artifact.",
|
||||
"Read the saved artifact through GET /api/assets/{assetId}/content with responseText: {offsetBytes:0,limitBytes:8192}.",
|
||||
"Continue using responseText.nextOffsetBytes until null. Extract the Evidence code at the end of its description.",
|
||||
"The large response must be read with bounded responseText pages. Do not use other tools or API routes to obtain the evidence; if bounded reading fails, report the failure instead of substituting a different reader.",
|
||||
"Write only that code followed by a newline to api-response-proof.txt in the current workspace. Do not edit the evidence task or create child tasks.",
|
||||
`Verify the proof file and finish this task with paperclip_finish, reportedWorkDisposition done, and summary API_RESPONSE_READ_${nonce}.`,
|
||||
].join("\n"),
|
||||
buildMatchers: (nonce, execution) => [
|
||||
{ kind: "file_exact", path: "api-response-proof.txt", expected: `${responseEvidenceCode(nonce)}\n` },
|
||||
{ kind: "message_contains", expected: `API_RESPONSE_READ_${nonce}` },
|
||||
{ kind: "issue_status", expected: "done" },
|
||||
{ kind: "run_status", expected: "succeeded" },
|
||||
{ kind: "runtime_mode", expected: execution.profile.expectedRuntimeMode },
|
||||
{ kind: "environment", expected: execution.environment.id },
|
||||
],
|
||||
};
|
||||
@@ -71,10 +71,10 @@ describe("runner E2E catalog", () => {
|
||||
expect(localIntegrityTasks).toHaveLength(2);
|
||||
expect(openRouterBreadthTasks).toHaveLength(3);
|
||||
expect(runnerSuites.map((suite) => suite.expectedMatrixSize)).toEqual([
|
||||
8, 46, 23, 47, 52, 28, 18, 6, 6, 42, 14, 10, 2,
|
||||
2, 8, 46, 23, 47, 52, 28, 18, 6, 6, 42, 14, 10, 2,
|
||||
]);
|
||||
expect(validateRunnerCatalog()).toHaveLength(302);
|
||||
expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(302);
|
||||
expect(validateRunnerCatalog()).toHaveLength(304);
|
||||
expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(304);
|
||||
expect(
|
||||
runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"),
|
||||
).toHaveLength(42);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiResponseReadingTask } from "./api-response-reading.js";
|
||||
import { accountingTasks } from "./accounting-cases.js";
|
||||
import { continuationTasks } from "./continuation-cases.js";
|
||||
import { lifecycleLiveTasks, lifecycleLiveDefinitionDigest } from "./lifecycle-live-cases.js";
|
||||
@@ -911,6 +912,14 @@ const everydayProfiles = [
|
||||
].map(productionStoryProfile);
|
||||
|
||||
export const runnerSuites: readonly RunnerSuiteFixture[] = [
|
||||
{
|
||||
id: "api-response-reading", label: "Bounded API response reading", manualOnly: true,
|
||||
description: "Retrieve evidence beyond a saved API preview through authorized bounded text windows.",
|
||||
groups: [], environments: runnerEnvironments,
|
||||
profiles: runnerProfiles.filter(profile => profile.id === "runner-codex"),
|
||||
tasks: [apiResponseReadingTask], expectedMatrixSize: 2,
|
||||
definitionMetadata: { version: 1, grading: "hidden-evidence-exact-copy-and-api-tool-events", scheduling: "explicit-only" },
|
||||
},
|
||||
{
|
||||
id: "continuation-accounting", label: "Continuation accounting baseline", manualOnly: true,
|
||||
description: "Structured productive steps, bounded repair, restart and late gates; comments cannot buy more attempts.",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { responseEvidenceDescription, successfulApiReadCount } from "./api-response-reading.js";
|
||||
import { observeBrowserBootstrap } from "./browser-bootstrap-diagnostics.js";
|
||||
import { runAccountingFlow } from "./accounting-flow.js";
|
||||
import type { Issue } from "../../packages/shared/src/types/issue.js";
|
||||
@@ -794,6 +795,14 @@ for (const execution of executions) {
|
||||
daytonaImage: process.env.PAPERCLIP_E2E_DAYTONA_IMAGE,
|
||||
});
|
||||
|
||||
if (execution.suite.id === "api-response-reading") {
|
||||
const source = await api.post<{ id: string }>(`/api/companies/${fixtures.company.id}/issues`, {
|
||||
title: `Synthetic diagnostic evidence ${nonce}`,
|
||||
description: responseEvidenceDescription(nonce), status: "backlog",
|
||||
});
|
||||
prompt = prompt.replaceAll("{{API_RESPONSE_SOURCE_ID}}", source.id);
|
||||
}
|
||||
|
||||
if (execution.suite.id === "lifecycle-baseline" && lifecycleLiveCase(execution.task.id)?.family === "blocker") {
|
||||
const prerequisite = await api.post<{ id: string }>(`/api/companies/${fixtures.company.id}/issues`, {
|
||||
title: `Supply dataset ${nonce}`,
|
||||
@@ -1885,6 +1894,11 @@ for (const execution of executions) {
|
||||
}
|
||||
}
|
||||
|
||||
if (execution.suite.id === "api-response-reading") {
|
||||
const completedReads = successfulApiReadCount(runEventsByRun.flatMap(captured => captured.events));
|
||||
if (completedReads < 3) invariantFailures.push(`Expected source read and multiple bounded artifact reads; observed ${completedReads} completed API calls`);
|
||||
}
|
||||
|
||||
const context = record(run.contextSnapshot);
|
||||
const environmentContext = record(context.paperclipEnvironment);
|
||||
const workspaceContext = record(context.paperclipWorkspace);
|
||||
|
||||
Reference in new issue
Block a user