fix(dot): enforce operator file grants and cache verified pages

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-07 17:00:15 -05:00
1 parent 1f27dbcb5d
commit 2626c8f9b4
12 files changed
+231 -13

No files matched your search

+9 -1
View File
@@ -360,12 +360,20 @@ of **Workspace files and commands** and is never inherited by a hired teammate.
Reads are restricted to the current run's assigned task and company, recheck
live run/binding/setting authority before returning, verify byte size and
SHA-256, and return at most 12,000 bytes per page from files up to 16 MiB.
Each run keeps a verified in-memory copy bounded to 16 MiB and 20 files;
subsequent pages reuse those bytes and recheck file metadata and live access.
Closing the run clears the cache. Agent configuration routes reject attempts
to change the operator-owned attachment, workspace, and pairing flags.
Text pages preserve UTF-8 boundaries; binary files use base64. Pass the returned
SHA-256 on subsequent pages to detect changes. Missing, oversized or corrupt
files return terminal errors. Read audit entries contain metadata, never file
contents. Revocation blocks further reads, but cannot withdraw bytes already
sent to OpenAI. Files and filenames remain untrusted input. There is no public
attachment URL or Paperclip credential exposed to Dot.
attachment URL or Paperclip credential exposed to Dot. Generic `call_api`
attachment and workspace downloads cannot bypass these settings; use the scoped
read tools. Raw asset reads and uploads are limited to API captures and output
artifacts from the current run. API uploads from workspace paths require the
workspace grant and the same confined root as workspace tools.
A plugin upgraded during a running Dot conversation can retain an old top-level
tool catalog. Refresh its tools in ChatGPT plugin settings and reattach it.
@@ -118,6 +118,11 @@ describe("buildCodexLocalConfig", () => {
});
describe("buildPaperclipRunnerConfig", () => {
it.each([undefined, false, true])("preserves Dot attachment consent in create/import forms: %s", value => {
const config = buildPaperclipRunnerConfig(makeValues({ adapterType: "paperclip_runner", adapterSchemaValues: { provider: "openai_dot", dotAttachmentAccess: value } }));
expect(config.dotAttachmentAccess).toBe(value === true);
expect(config.dotWorkspaceAccess).toBe(false);
});
it("keeps only settings implemented by the Codex runner profile", () => {
const config = buildPaperclipRunnerConfig(makeValues({
codexEngine: "acp",
@@ -98,7 +98,7 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
? providerCandidate
: "codex";
if (provider === "openai_dot") {
return { provider, lifecycleMode: "per_turn", allowUnmeteredProvider: schemaValues.allowUnmeteredProvider === true, dotWorkspaceAccess: schemaValues.dotWorkspaceAccess === true,
return { provider, lifecycleMode: "per_turn", allowUnmeteredProvider: schemaValues.allowUnmeteredProvider === true, dotWorkspaceAccess: schemaValues.dotWorkspaceAccess === true, dotAttachmentAccess: schemaValues.dotAttachmentAccess === true,
...(typeof schemaValues.dotBindingId === "string" ? { dotBindingId: schemaValues.dotBindingId } : {}) };
}
const selectedAcpxProfile = PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === schemaValues.acpxAgent);
@@ -173,7 +173,7 @@ const externalAdapter: ServerAdapterModule = {
const missingAdapterType = "missing_adapter_validation_test";
async function createApp() {
async function createApp(actorOverride: Record<string, unknown> = {}) {
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
@@ -187,6 +187,7 @@ async function createApp() {
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
...actorOverride,
};
next();
});
@@ -723,6 +724,19 @@ describe("agent routes adapter validation", () => {
);
});
it.each(["dotAttachmentAccess", "dotWorkspaceAccess", "dotBindingId"])("refuses an agent granting itself the operator-owned Dot setting %s", async key => {
const app = await createApp({ type: "agent", agentId: "11111111-1111-4111-8111-111111111111", companyId: "company-1", source: "agent_jwt" });
const response = await requestApp(app, baseUrl => request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111").send({ adapterConfig: { [key]: key === "dotBindingId" ? "another-binding" : true } }));
expect(response.status, JSON.stringify(response.body)).toBe(403);
expect(mockAgentService.update).not.toHaveBeenCalled();
});
it("allows an operator to configure Dot attachment access separately", async () => {
const app = await createApp();
const response = await requestApp(app, baseUrl => request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111").send({ adapterConfig: { dotAttachmentAccess: true, dotWorkspaceAccess: false } }));
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(mockAgentService.update).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ adapterConfig: expect.objectContaining({ dotAttachmentAccess: true, dotWorkspaceAccess: false }) }), expect.anything());
});
it.each(["create", "convert"])("saves an unpaired Dot configuration for %s while refusing task admission", async mode => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
const app = await createApp();
+2
View File
@@ -2920,6 +2920,8 @@ export function agentRoutes(
adapterConfig: Record<string, unknown>,
path = "adapterConfig",
) {
if (req.actor.type === "agent" && ["dotAttachmentAccess", "dotWorkspaceAccess", "dotBindingId"].some(key => hasOwn(adapterConfig, key)))
throw forbidden("Only an operator can configure Dot attachment access, workspace access, or pairing.");
assertNoAgentInstructionsConfigMutation(req, adapterConfig, path);
assertNoAgentHostWorkspaceCommandMutation(
req,
@@ -226,6 +226,7 @@ vi.mock("@paperclipai/adapter-codex-local/server", async (importOriginal) => ({
vi.mock("./paperclip-runner-tool-authority.js", () => ({
PaperclipRunnerToolAuthority: class {
close() {}
readonly binding: Record<string, unknown>;
constructor(_db: unknown, binding: Record<string, unknown>) {
@@ -1514,6 +1514,7 @@ class SessionToolAuthorityEpoch {
revoke(): void {
this.#revoked = true;
this.#authority.close();
}
#assertCurrent(): void {
@@ -10945,6 +10946,7 @@ async function createRunnerdBackendWithinSessionClaim(
runtimeContext: "runtimeContext" in input.execution ? input.execution.runtimeContext : undefined,
workspaceBridge: input.execution.provider.kind === "openai_dot" && !!input.dotWorkspaceRoot,
taskAttachmentRead: dotAttachmentActor?.config.dotAttachmentAccess === true,
dotRuntime: input.execution.provider.kind === "openai_dot",
assertBridgeAuthority: input.execution.provider.kind === "openai_dot" ? () => dotRunnerBroker(input.db).assertRunAuthority(input.execution as import("../../vendor/paperclip-runner/index.js").NativeExecutionInputV6) : undefined,
workspaceRoot: input.dotWorkspaceRoot ?? remoteTarget?.remoteCwd ?? input.execution.workspace.cwd ?? undefined,
executionTargetKind: target.kind,
@@ -1,5 +1,5 @@
import { TASK_ATTACHMENT_DEFINITIONS, TASK_ATTACHMENT_SCHEMAS, listTaskAttachments, readTaskAttachment } from "./runner-task-attachments.js";
import { RUNNER_BRIDGE_SCHEMAS, runnerBridgeDefinitions, readAssignedSkill, executeWorkspaceTool, settleRunnerBridgeRead } from "./runner-bridge-tools.js";
import { TaskAttachmentReadCache, TASK_ATTACHMENT_DEFINITIONS, TASK_ATTACHMENT_SCHEMAS, listTaskAttachments, readTaskAttachment } from "./runner-task-attachments.js";
import { RUNNER_BRIDGE_SCHEMAS, runnerBridgeDefinitions, readAssignedSkill, executeWorkspaceTool, readWorkspaceUploadFile, settleRunnerBridgeRead } from "./runner-bridge-tools.js";
import type { NativeRuntimeContextSnapshot } from "../../vendor/paperclip-runner/index.js";
import { readTaskQuestionContext } from "../issue-question-context.js";
import { isConversation } from "../agent-conversations.js";
@@ -62,6 +62,7 @@ import {
heartbeatRuns,
issueApprovals,
issueComments,
issueAttachments,
issueDocuments,
issues,
projects,
@@ -136,6 +137,8 @@ type Binding = {
workMode?: "standard" | "planning" | "ask";
workspaceRoot?: string;
workspaceBridge?: boolean;
/** Pinned provider identity; configuration edits cannot remove API file guards. */
dotRuntime?: boolean;
/** Operator-approved Dot attachment access, pinned when the turn opens. */
taskAttachmentRead?: boolean;
runtimeContext?: NativeRuntimeContextSnapshot;
@@ -200,6 +203,8 @@ function canonicalJson(value: unknown): string {
}
export class PaperclipRunnerToolAuthority {
#taskAttachmentCache = new TaskAttachmentReadCache();
close() { this.#taskAttachmentCache.close(); }
constructor(readonly db: Db, readonly binding: Binding) {}
definitions(): Array<Record<string, unknown>> {
@@ -309,7 +314,7 @@ export class PaperclipRunnerToolAuthority {
const parsed = schema.safeParse(call.arguments);
if (!parsed.success) return { outcome: "failed", code: "runner_attachment_invalid_arguments", message: "Arguments do not match this tool's advertised schema." };
return settleRunnerBridgeRead(async () => {
if (call.tool === "read_task_attachment") return readTaskAttachment({ db: this.db, binding: this.binding, arguments: parsed.data, storage: this.binding.storage, authorize });
if (call.tool === "read_task_attachment") return readTaskAttachment({ db: this.db, binding: this.binding, arguments: parsed.data, storage: this.binding.storage, cache: this.#taskAttachmentCache, authorize });
const result = await listTaskAttachments(this.db, this.binding, (parsed.data as { after?: string }).after);
await authorize();
return result;
@@ -688,6 +693,7 @@ export class PaperclipRunnerToolAuthority {
const bound = await this.#boundContext();
const context = { ...this.binding, issueIdentifier: bound.issue.identifier, workMode: bound.issue.workMode };
const { input, operation } = validateRunnerApiCall(value, context);
await this.#assertDotApiFileAccess(operation.path, input);
const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL;
if (!apiUrl) throw new Error("Paperclip API origin is unavailable");
const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, bound.actor.adapterType, this.binding.runId, bound.run.responsibleUserId);
@@ -697,12 +703,13 @@ export class PaperclipRunnerToolAuthority {
let storageAttempted = false;
const current = await this.#boundContext();
if (!runnerApiToolsEnabled(this.binding.companyId, this.binding.apiToolsEnabled)) throw new Error("paperclip_runner_tool_not_advertised");
return executeRunnerApi(input, { ...context, workMode: current.issue.workMode }, {
const result = await executeRunnerApi(input, { ...context, workMode: current.issue.workMode }, {
apiUrl, token,
beforeDispatch: async () => {
const fresh = await this.#boundContext();
if (!runnerApiToolsEnabled(this.binding.companyId, this.binding.apiToolsEnabled)) throw new Error("paperclip_runner_tool_not_advertised");
validateRunnerApiCall(input, { ...context, workMode: fresh.issue.workMode });
await this.#assertDotApiFileAccess(operation.path, input);
},
readFile: (file) => this.#readApiFile(file),
reserveResponseCapture: async () => {
@@ -738,6 +745,8 @@ export class PaperclipRunnerToolAuthority {
return { artifactId: asset.id, url: `/api/assets/${asset.id}/content`, contentType, byteSize: saved.byteSize, sha256: saved.sha256 };
},
});
await this.#assertDotApiFileAccess(operation.path, input);
return result;
};
if (["GET", "HEAD", "OPTIONS"].includes(operation.method)) return execute();
if (!callId || callId.length > 500) throw badRequest("A bounded runner call id is required");
@@ -843,7 +852,51 @@ export class PaperclipRunnerToolAuthority {
} };
}
async #dotOwnsApiAsset(assetId: string): Promise<boolean> {
const [capture] = await this.db.select({ id: runnerApiResponseReservations.id }).from(runnerApiResponseReservations).where(and(
eq(runnerApiResponseReservations.companyId, this.binding.companyId), eq(runnerApiResponseReservations.runId, this.binding.runId),
eq(runnerApiResponseReservations.assetId, assetId),
)).limit(1);
if (capture) return true;
const current = await this.#boundContext();
if (!this.binding.workspaceBridge || current.actor.adapterConfig?.dotWorkspaceAccess !== true) return false;
const [output] = await this.db.select({ id: issueAttachments.id }).from(issueAttachments).where(and(
eq(issueAttachments.companyId, this.binding.companyId), eq(issueAttachments.issueId, this.binding.issueId),
eq(issueAttachments.originatingRunId, this.binding.runId), eq(issueAttachments.assetId, assetId),
)).limit(1);
return !!output;
}
async #assertDotApiFileAccess(path: string, input: { pathParams?: Record<string, string>; files?: RunnerApiFile[] }) {
if (!this.binding.dotRuntime) return;
const current = await this.#boundContext();
await this.binding.assertBridgeAuthority?.();
if (/^\/api\/attachments\/\{[^}]+\}\/content$/.test(path))
throw forbidden("Dot must use read_task_attachment for verified contents of its current assigned task; direct attachment API downloads are disabled.");
if (/^\/api\/issues\/\{[^}]+\}\/file-resources\/content$/.test(path))
throw forbidden("Dot must use workspace_read with its separate workspace grant; direct workspace API downloads are disabled.");
if (/^\/api\/assets\/\{[^}]+\}\/content$/.test(path)) {
const assetId = input.pathParams?.assetId;
if (!assetId || !/^[0-9a-f-]{36}$/i.test(assetId) || !await this.#dotOwnsApiAsset(assetId))
throw forbidden("Dot may read API captures and outputs from this run only. Use read_task_attachment for assigned input files.");
}
for (const file of input.files ?? []) {
if (file.path && (!this.binding.workspaceBridge || !this.binding.workspaceRoot || current.actor.adapterConfig?.dotWorkspaceAccess !== true))
throw forbidden("Dot workspace access is required for API file uploads.");
if (file.artifactId && (!/^[0-9a-f-]{36}$/i.test(file.artifactId) || !await this.#dotOwnsApiAsset(file.artifactId)))
throw forbidden("Dot may upload API captures and outputs from this run only.");
}
}
async #readApiFile(file: RunnerApiFile): Promise<{ bytes: Buffer; filename: string; contentType: string }> {
if (this.binding.dotRuntime) {
await this.#assertDotApiFileAccess("", { files: [file] });
if (file.path) {
const bytes = await readWorkspaceUploadFile(this.binding.workspaceRoot!, file.path);
await this.#assertDotApiFileAccess("", { files: [file] });
return { bytes, filename: basename(file.path), contentType: "application/octet-stream" };
}
}
if (file.artifactId) {
const asset = await assetService(this.db).getById(file.artifactId);
if (!asset || asset.companyId !== this.binding.companyId) throw forbidden("Artifact is not available in this company");
@@ -1488,6 +1541,11 @@ export class PaperclipRunnerToolAuthority {
idempotencyKey,
input,
async (tx, context) => {
if (this.binding.dotRuntime) {
if (!this.binding.workspaceBridge || context.actor.adapterConfig?.dotWorkspaceAccess !== true)
throw forbidden("Dot workspace access is required to publish workspace files.");
await this.binding.assertBridgeAuthority?.();
}
const prepared = await prepareNativeRunnerFileHandoff({
db: tx,
binding: {
@@ -4,7 +4,7 @@ import { materializeAsset } from "./runtime-context.js";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, describe, expect, it } from "vitest";
import { executeWorkspaceTool, readAssignedSkill, runnerBridgeDefinitions, workspaceCommandSandboxAvailable, settleRunnerBridgeRead } from "./runner-bridge-tools.js";
import { executeWorkspaceTool, readAssignedSkill, readWorkspaceUploadFile, runnerBridgeDefinitions, workspaceCommandSandboxAvailable, settleRunnerBridgeRead } from "./runner-bridge-tools.js";
const roots: string[] = [];
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
const root = async () => { const directory = await mkdtemp(join(tmpdir(), "dot-bridge-")); roots.push(directory); return directory; };
@@ -21,6 +21,9 @@ describe("Runner workspace bridge", () => {
await expect(executeWorkspaceTool(directory, "workspace_read", { path: "../hello.txt" }, authorize)).rejects.toThrow();
await writeFile(join(outside, "private.txt"), "PRIVATE");
await symlink(outside, join(directory, "outside"));
await expect(readWorkspaceUploadFile(directory, "../private.txt")).rejects.toThrow();
await expect(readWorkspaceUploadFile(directory, "outside/private.txt")).rejects.toThrow("symlink_denied");
expect((await readWorkspaceUploadFile(directory, "hello.txt")).toString()).toBe("updated");
await expect(executeWorkspaceTool(directory, "workspace_read", { path: "outside/private.txt" }, authorize)).rejects.toThrow("symlink");
});
it("serializes overlapping writes so only one observed hash can commit", async () => {
@@ -44,6 +44,23 @@ async function confined(root: string, name: string, newFile = false) {
}
return target;
}
/** API uploads use the same operator-bound root and path confinement as workspace tools. */
export async function readWorkspaceUploadFile(root: string, name: string): Promise<Buffer> {
const handle = await openRunnerApiWorkspaceFile(await confined(root, name));
try {
const stat = await handle.stat();
if (!stat.isFile() || stat.size > 10 * 1024 * 1024) throw new Error("runner_bridge_file_size_limit");
const bytes = Buffer.alloc(10 * 1024 * 1024 + 1);
let length = 0;
while (length < bytes.length) {
const read = await handle.read(bytes, length, bytes.length - length, length);
if (!read.bytesRead) break;
length += read.bytesRead;
}
if (length > 10 * 1024 * 1024) throw new Error("runner_bridge_file_size_limit");
return bytes.subarray(0, length);
} finally { await handle.close(); }
}
async function readFilePage(file: string, offset: number) {
const handle = await openRunnerApiWorkspaceFile(file);
try {
@@ -1,10 +1,12 @@
import { createHash, randomUUID } from "node:crypto";
import { Readable } from "node:stream";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { activityLog, agents, assets, companies, createDb, heartbeatRuns, issueAttachments, issues } from "@paperclipai/db";
import { activityLog, agents, assets, companies, createDb, heartbeatRuns, issueAttachments, issues, runnerApiResponseReservations } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import type { StorageService } from "../../storage/types.js";
import { TaskAttachmentReadCache } from "./runner-task-attachments.js";
import { runnerApiCatalog } from "./runner-api-catalog.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
const sha = (body: Buffer) => createHash("sha256").update(body).digest("hex");
@@ -13,6 +15,7 @@ describe("Dot assigned-task attachment reading", () => {
let db: ReturnType<typeof createDb>;
beforeAll(async () => { temporary = await startEmbeddedPostgresTestDatabase("dot-task-files-"); db = createDb(temporary.connectionString); });
afterAll(async () => { await temporary?.cleanup(); });
afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); });
async function fixture(body = Buffer.from("only in the file 🌍"), enabled = true) {
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Files", issuePrefix: `F${companyId.slice(0, 6)}` });
@@ -26,7 +29,7 @@ describe("Dot assigned-task attachment reading", () => {
const getObject = vi.fn().mockImplementation(async () => ({ stream: Readable.from([body]) }));
const storage = { provider: "local_disk", getObject } as unknown as StorageService;
const assertBridgeAuthority = vi.fn().mockResolvedValue(undefined);
const binding = { companyId, agentId, issueId, runId, taskAttachmentRead: enabled, storage, assertBridgeAuthority };
const binding = { companyId, agentId, issueId, runId, dotRuntime: true, taskAttachmentRead: enabled, storage, assertBridgeAuthority };
const authority = new PaperclipRunnerToolAuthority(db, binding);
const call = (tool: string, args: unknown = {}) => authority.execute({ tool, callId: randomUUID(), arguments: args });
return { ...binding, authority, call, asset: asset!, attachment: attachment!, body, getObject };
@@ -95,9 +98,75 @@ describe("Dot assigned-task attachment reading", () => {
expect(first).toMatchObject({ returnedBytes: 11999, nextOffset: 11999, content: "x".repeat(11999) });
const second = await f.call("read_task_attachment", { attachmentId: f.attachment.id, offset: first.nextOffset, expectedSha256: first.sha256 });
expect(second).toMatchObject({ content: "🌍tail", nextOffset: null, returnedBytes: 8 });
expect(f.getObject).toHaveBeenCalledTimes(1);
expect(await f.call("read_task_attachment", { attachmentId: f.attachment.id, offset: 12000 })).toMatchObject({ outcome: "failed", code: "runner_attachment_offset_invalid" });
expect(await f.call("read_task_attachment", { attachmentId: f.attachment.id, expectedSha256: "0".repeat(64) })).toMatchObject({ outcome: "failed", code: "runner_attachment_changed" });
});
it("clears cached bytes on closure and checks deleted attachment links on cached pages", async () => {
const f = await fixture();
await f.call("read_task_attachment", { attachmentId: f.attachment.id });
await db.delete(issueAttachments).where(eq(issueAttachments.id, f.attachment.id));
expect(await f.call("read_task_attachment", { attachmentId: f.attachment.id })).toMatchObject({ code: "runner_attachment_not_found" });
expect(f.getObject).toHaveBeenCalledTimes(1);
const closed = await fixture();
await closed.call("read_task_attachment", { attachmentId: closed.attachment.id });
closed.authority.close();
expect(await closed.call("read_task_attachment", { attachmentId: closed.attachment.id })).toMatchObject({ code: "runner_attachment_scope_closed" });
expect(closed.getObject).toHaveBeenCalledTimes(1);
});
it.each([false, true])("prevents generic API attachment and workspace downloads with attachment grant %s", async enabled => {
const f = await fixture(Buffer.from("private input"), enabled);
for (const [path, pathParams] of [
["/api/attachments/{attachmentId}/content", { attachmentId: f.attachment.id }],
["/api/assets/{assetId}/content", { assetId: f.asset.id }],
["/api/issues/{issueId}/file-resources/content", { issueId: f.issueId }],
] as const) {
const operation = runnerApiCatalog().find(o => o.method === "GET" && o.path === path)!;
await expect(f.call("call_api", { operationId: operation.operationId, pathParams })).rejects.toThrow(/Dot must use|Dot may read/);
}
const upload = runnerApiCatalog().find(o => o.method === "POST" && o.path === "/api/companies/{companyId}/issues/{issueId}/attachments")!;
await expect(f.call("call_api", { operationId: upload.operationId, pathParams: { companyId: f.companyId, issueId: f.issueId }, contentType: "multipart/form-data", files: [{ path: "private.txt" }] })).rejects.toThrow("workspace access");
await expect(f.call("call_api", { operationId: upload.operationId, pathParams: { companyId: f.companyId, issueId: f.issueId }, contentType: "multipart/form-data", files: [{ artifactId: f.asset.id }] })).rejects.toThrow("this run only");
expect(f.getObject).not.toHaveBeenCalled();
});
it("retains pagination of this run's API captures while rejecting another run's capture", async () => {
const f = await fixture(Buffer.from("captured")), other = await fixture();
await db.insert(runnerApiResponseReservations).values([
{ companyId: f.companyId, runId: f.runId, assetId: f.asset.id, reservedBytes: f.body.length },
{ companyId: other.companyId, runId: other.runId, assetId: other.asset.id, reservedBytes: other.body.length },
]);
vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", "synthetic-test-key-not-a-live-credential");
vi.stubEnv("PAPERCLIP_API_URL", "http://127.0.0.1:3217");
const fetch = vi.fn().mockResolvedValue(new Response(f.body, { status: 206, headers: { "Content-Type": "text/plain", "Content-Range": `bytes 0-${f.body.length - 1}/${f.body.length}` } }));
vi.stubGlobal("fetch", fetch);
const operation = runnerApiCatalog().find(o => o.method === "GET" && o.path === "/api/assets/{assetId}/content")!;
expect(await f.call("call_api", { operationId: operation.operationId, pathParams: { assetId: f.asset.id } })).toMatchObject({ ok: true, data: "captured" });
await expect(f.call("call_api", { operationId: operation.operationId, pathParams: { assetId: other.asset.id } })).rejects.toThrow("this run only");
expect(fetch).toHaveBeenCalledTimes(1);
});
it("deduplicates concurrent verification and clears a read still pending on closure", async () => {
const cache = new TaskAttachmentReadCache();
let resolve!: (body: Buffer) => void;
const load = vi.fn(() => new Promise<Buffer>(done => { resolve = done; }));
const first = cache.read("file", "v1", load), second = cache.read("file", "v1", load);
resolve(Buffer.from("verified"));
expect((await first).toString()).toBe("verified");
expect((await second).toString()).toBe("verified");
expect(load).toHaveBeenCalledTimes(1);
const pending = cache.read("later", "v1", load);
cache.close(); resolve(Buffer.from("must not retain"));
await expect(pending).rejects.toThrow("scope_closed");
await expect(cache.read("file", "v1", load)).rejects.toThrow("scope_closed");
});
it("evicts verified copies when the per-run memory budget is reached", async () => {
const cache = new TaskAttachmentReadCache(), first = vi.fn(async () => Buffer.alloc(8 * 1024 * 1024));
await cache.read("first", "v1", first);
await cache.read("second", "v1", async () => Buffer.alloc(8 * 1024 * 1024));
await cache.read("third", "v1", async () => Buffer.from("x"));
await cache.read("first", "v1", first);
expect(first).toHaveBeenCalledTimes(2);
cache.close();
});
it("reads binary pages explicitly as base64 and supports empty files", async () => {
const f = await fixture(Buffer.from([0xff, 0x00, 0x81]));
expect(await f.call("read_task_attachment", { attachmentId: f.attachment.id })).toMatchObject({ outcome: "failed", code: "runner_attachment_use_base64" });
@@ -26,6 +26,43 @@ export const TASK_ATTACHMENT_DEFINITIONS = Object.entries(TASK_ATTACHMENT_SCHEMA
inputSchema: z.toJSONSchema(schema),
}));
/** One server-owned run lifetime, at most 16 MiB, 20 entries and two concurrent fetches. */
export class TaskAttachmentReadCache {
#entries = new Map<string, { signature: string; body: Buffer }>();
#pending = new Map<string, Promise<Buffer>>();
#bytes = 0;
#closed = false;
assertOpen() { if (this.#closed) throw new Error("runner_attachment_scope_closed"); }
close() { this.#closed = true; this.#entries.clear(); this.#pending.clear(); this.#bytes = 0; }
async read(id: string, signature: string, load: () => Promise<Buffer>): Promise<Buffer> {
this.assertOpen();
const cached = this.#entries.get(id);
if (cached?.signature === signature) {
this.#entries.delete(id); this.#entries.set(id, cached);
return cached.body;
}
const key = JSON.stringify([id, signature]);
let pending = this.#pending.get(key);
if (!pending) {
if (this.#pending.size >= 2) throw new Error("runner_attachment_read_busy");
pending = load(); this.#pending.set(key, pending);
}
let body: Buffer;
try { body = await pending; }
finally { if (this.#pending.get(key) === pending) this.#pending.delete(key); }
this.assertOpen();
const prior = this.#entries.get(id);
if (prior) { this.#bytes -= prior.body.length; this.#entries.delete(id); }
while (this.#entries.size >= 20 || this.#bytes + body.length > MAX_READ_BYTES) {
const oldest = this.#entries.keys().next().value;
if (oldest === undefined) throw new Error("runner_attachment_cache_size_limit");
this.#bytes -= this.#entries.get(oldest)!.body.length; this.#entries.delete(oldest);
}
this.#entries.set(id, { signature, body }); this.#bytes += body.length;
return body;
}
}
type Binding = { companyId: string; issueId: string; agentId: string; runId: string };
const columns = { attachmentId: issueAttachments.id, sourceCommentId: issueAttachments.issueCommentId,
filename: assets.originalFilename, contentType: assets.contentType, byteSize: assets.byteSize, sha256: assets.sha256 };
@@ -80,14 +117,15 @@ async function bytes(storage: StorageService, companyId: string, file: Awaited<R
return body;
}
export async function readTaskAttachment(input: {
db: Db; binding: Binding; arguments: unknown; storage?: StorageService; authorize: () => Promise<void>;
db: Db; binding: Binding; arguments: unknown; storage?: StorageService; cache: TaskAttachmentReadCache; authorize: () => Promise<void>;
}) {
const args = TASK_ATTACHMENT_SCHEMAS.read_task_attachment.parse(input.arguments);
await input.authorize();
const file = await source(input.db, input.binding, args.attachmentId);
if (args.expectedSha256 && args.expectedSha256 !== file.sha256.toLowerCase()) throw new Error("runner_attachment_changed");
if (args.offset > file.byteSize) throw new Error("runner_attachment_offset_invalid");
const body = await bytes(input.storage ?? getStorageService(), input.binding.companyId, file);
const signature = JSON.stringify([file.objectKey, file.sha256, file.byteSize, file.contentType]);
const body = await input.cache.read(file.attachmentId, signature, () => bytes(input.storage ?? getStorageService(), input.binding.companyId, file));
await input.authorize();
const current = await source(input.db, input.binding, args.attachmentId);
if (current.objectKey !== file.objectKey || current.sha256 !== file.sha256 || current.byteSize !== file.byteSize || current.contentType !== file.contentType)
@@ -103,6 +141,7 @@ export async function readTaskAttachment(input: {
runId: input.binding.runId, action: "dot.attachment_read", entityType: "issue", entityId: input.binding.issueId,
details: { attachmentId: file.attachmentId, offset: args.offset, returnedBytes: end - args.offset, sha256: file.sha256, encoding: args.encoding } });
await input.authorize();
input.cache.assertOpen();
const { objectKey: _privateKey, ...metadata } = file;
return { ...metadata, encoding: args.encoding, content: body.subarray(args.offset, end).toString(args.encoding === "utf8" ? "utf8" : "base64"),
offset: args.offset, returnedBytes: end - args.offset, nextOffset: end < body.length ? end : null,