mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
fix(dot): enforce operator file grants and cache verified pages
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
1f27dbcb5d
commit
2626c8f9b4
12 files changed
+231
-13
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user