mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## 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>
88 lines
5.3 KiB
TypeScript
88 lines
5.3 KiB
TypeScript
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");
|
|
});
|
|
});
|