mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
1432 lines
56 KiB
TypeScript
1432 lines
56 KiB
TypeScript
import type { PrpStructuredRunResult } from "../../vendor/paperclip-runner/index.js";
|
|
import { nativeCompletionFeedback } from "./native-completion-feedback.js";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { spawn } from "node:child_process";
|
|
import { once } from "node:events";
|
|
import {
|
|
mkdtemp,
|
|
link,
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
rm,
|
|
symlink,
|
|
unlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
|
|
import { eq, sql } from "drizzle-orm";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
activityLog,
|
|
agents,
|
|
assets,
|
|
companies,
|
|
createDb,
|
|
heartbeatRuns,
|
|
issueAttachments,
|
|
issueComments,
|
|
issues,
|
|
issueWorkProducts,
|
|
} from "@paperclipai/db";
|
|
|
|
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
|
import { createLocalDiskStorageProvider } from "../../storage/local-disk-provider.js";
|
|
import { createStorageService } from "../../storage/service.js";
|
|
import type { StorageService } from "../../storage/types.js";
|
|
import { issueService } from "../issues.js";
|
|
import { findHeartbeatRunCompletionComment, resolveHeartbeatRunResponse } from "../heartbeat-run-summary.js";
|
|
import {
|
|
renderNativeRunnerStagedAttachmentPrompt,
|
|
stageNativeRunnerAttachmentBytes,
|
|
stageNativeRunnerWakeAttachments,
|
|
} from "./native-runner-file-handoff.js";
|
|
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
|
|
|
|
describe("native runner file handoff", () => {
|
|
let temporary: Awaited<
|
|
ReturnType<typeof startEmbeddedPostgresTestDatabase>
|
|
> | null = null;
|
|
let db: ReturnType<typeof createDb>;
|
|
let temporaryRoot: string;
|
|
let workspaceRoot: string;
|
|
let storageRoot: string;
|
|
|
|
const companyId = "00000000-0000-4000-8000-000000009101";
|
|
const agentId = "00000000-0000-4000-8000-000000009102";
|
|
const issueId = "00000000-0000-4000-8000-000000009103";
|
|
const runId = "00000000-0000-4000-8000-000000009104";
|
|
|
|
beforeAll(async () => {
|
|
temporary = await startEmbeddedPostgresTestDatabase(
|
|
"native-runner-file-handoff-",
|
|
);
|
|
db = createDb(temporary.connectionString);
|
|
temporaryRoot = await mkdtemp(
|
|
path.join(tmpdir(), "paperclip-native-file-handoff-"),
|
|
);
|
|
workspaceRoot = path.join(temporaryRoot, "workspace");
|
|
storageRoot = path.join(temporaryRoot, "storage");
|
|
await mkdir(workspaceRoot, { recursive: true });
|
|
await mkdir(storageRoot, { recursive: true });
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Native file handoff",
|
|
issuePrefix: "NFH",
|
|
issueCounter: 1,
|
|
});
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Native file agent",
|
|
adapterType: "paperclip_runner",
|
|
adapterConfig: { provider: "codex" },
|
|
runtimeConfig: {},
|
|
status: "active",
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
issueNumber: 1,
|
|
identifier: "NFH-1",
|
|
title: "Prepare a requested file",
|
|
status: "in_progress",
|
|
workMode: "standard",
|
|
assigneeAgentId: agentId,
|
|
});
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
runtimeMode: "native",
|
|
nativeIssueId: issueId,
|
|
invocationSource: "assignment",
|
|
triggerDetail: "system",
|
|
contextSnapshot: { issueId },
|
|
});
|
|
await db
|
|
.update(issues)
|
|
.set({ executionRunId: runId })
|
|
.where(eq(issues.id, issueId));
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await temporary?.cleanup();
|
|
});
|
|
|
|
function authority(
|
|
overrides: Partial<{
|
|
companyId: string;
|
|
executionTargetKind: "local" | "remote";
|
|
readRemoteTaskFile: (input: { contentRef: string; byteSize: number; sha256: string }) => Promise<Buffer>;
|
|
readRemoteWorkspaceFile: (input: { contentRef: string; byteSize: number; sha256: string }) => Promise<Buffer>;
|
|
}> = {},
|
|
) {
|
|
return new PaperclipRunnerToolAuthority(db, {
|
|
companyId: overrides.companyId ?? companyId,
|
|
agentId,
|
|
issueId,
|
|
runId,
|
|
workspaceRoot,
|
|
executionTargetKind: overrides.executionTargetKind ?? "local",
|
|
readRemoteWorkspaceFile: overrides.readRemoteWorkspaceFile,
|
|
readRemoteTaskFile: overrides.readRemoteTaskFile,
|
|
storage: createStorageService(
|
|
createLocalDiskStorageProvider(storageRoot),
|
|
),
|
|
});
|
|
}
|
|
|
|
function callFor(relativePath: string, body: Buffer, idempotencyKey: string) {
|
|
return {
|
|
tool: "register_deliverable",
|
|
callId: `call-${idempotencyKey}`,
|
|
arguments: {
|
|
idempotencyKey,
|
|
filename: "answer.txt",
|
|
contentType: "text/plain",
|
|
byteSize: body.length,
|
|
sha256: createHash("sha256").update(body).digest("hex"),
|
|
contentRef: relativePath,
|
|
title: "Requested answer",
|
|
},
|
|
};
|
|
}
|
|
|
|
async function createInboundAttachmentFixture(input: {
|
|
storage: StorageService;
|
|
filename: string;
|
|
body: Buffer;
|
|
commentBody: string;
|
|
}) {
|
|
const stored = await input.storage.putFile({
|
|
companyId,
|
|
namespace: `issues/${issueId}`,
|
|
originalFilename: input.filename,
|
|
contentType: "text/plain",
|
|
body: input.body,
|
|
});
|
|
const comment = await issueService(db).addComment(
|
|
issueId,
|
|
input.commentBody,
|
|
{ userId: "inbound-user" },
|
|
);
|
|
const attachment = await issueService(db).createAttachment({
|
|
issueId,
|
|
issueCommentId: comment.id,
|
|
provider: stored.provider,
|
|
objectKey: stored.objectKey,
|
|
contentType: stored.contentType,
|
|
byteSize: stored.byteSize,
|
|
sha256: stored.sha256,
|
|
originalFilename: stored.originalFilename,
|
|
createdByUserId: "inbound-user",
|
|
});
|
|
return { attachment, comment, stored };
|
|
}
|
|
|
|
function doneReport(refs: string[]): PrpStructuredRunResult {
|
|
return {
|
|
schema: "paperclip.run_result.v1",
|
|
reportedWorkDisposition: "done",
|
|
summary: "Created the requested checklist.",
|
|
completionClaim: { contractRevision: "test", objectiveSatisfied: true, criteria: [], remainingWork: [] },
|
|
evidence: refs.map((ref) => ({ ref })),
|
|
verification: [], attentionRequests: [], artifacts: [],
|
|
};
|
|
}
|
|
|
|
async function stageEmptyWake(root: string, boundCompanyId = companyId) {
|
|
await db.update(heartbeatRuns)
|
|
.set({ contextSnapshot: { issueId } })
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
return stageNativeRunnerWakeAttachments({
|
|
db,
|
|
binding: {
|
|
companyId: boundCompanyId, issueId, runId, agentId,
|
|
workspaceRoot: root, executionTargetKind: "local",
|
|
},
|
|
});
|
|
}
|
|
|
|
it("leaves an empty workspace unchanged for an empty wake and permits later explicit staging", async () => {
|
|
const root = await mkdtemp(path.join(temporaryRoot, "empty-wake-"));
|
|
try {
|
|
const wake = await stageEmptyWake(root);
|
|
expect(wake.attachments).toEqual([]);
|
|
await wake.cleanup();
|
|
await expect(readdir(root)).resolves.toEqual([]);
|
|
|
|
const explicit = await stageNativeRunnerAttachmentBytes({ workspaceRoot: root, body: Buffer.from("later authorized bytes") });
|
|
await expect(readFile(path.join(root, explicit.workspaceRelativePath), "utf8"))
|
|
.resolves.toBe("later authorized bytes");
|
|
await explicit.cleanup();
|
|
await expect(readFile(path.join(root, explicit.workspaceRelativePath)))
|
|
.resolves.toEqual(Buffer.alloc(0));
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("scrubs inactive staging bytes on an empty wake without creating another directory", async () => {
|
|
const root = await mkdtemp(path.join(temporaryRoot, "empty-wake-residue-"));
|
|
try {
|
|
const previous = await stageNativeRunnerAttachmentBytes({ workspaceRoot: root, body: Buffer.from("previous attachment") });
|
|
await previous.cleanup();
|
|
const slot = path.join(root, previous.workspaceRelativePath);
|
|
await writeFile(slot, "stale bytes after an interrupted owner");
|
|
const directories = await readdir(path.join(root, ".paperclip-inbound"));
|
|
|
|
const wake = await stageEmptyWake(root);
|
|
expect(wake.attachments).toEqual([]);
|
|
await wake.cleanup();
|
|
await expect(readFile(slot)).resolves.toEqual(Buffer.alloc(0));
|
|
await expect(readdir(path.join(root, ".paperclip-inbound"))).resolves.toEqual(directories);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.each(["root-file", "root-symlink", "entry-symlink"] as const)(
|
|
"rejects unsafe %s staging on an empty wake without changing outside bytes",
|
|
async (kind) => {
|
|
const root = await mkdtemp(path.join(temporaryRoot, "empty-wake-unsafe-"));
|
|
const outside = await mkdtemp(path.join(temporaryRoot, "empty-wake-outside-"));
|
|
try {
|
|
await writeFile(path.join(outside, "keep.txt"), "outside bytes");
|
|
const inbound = path.join(root, ".paperclip-inbound");
|
|
if (kind === "root-file") await writeFile(inbound, "not a directory");
|
|
else if (kind === "root-symlink") await symlink(outside, inbound);
|
|
else {
|
|
await mkdir(inbound);
|
|
await symlink(outside, path.join(inbound, "untrusted-owner"));
|
|
}
|
|
await expect(stageEmptyWake(root)).rejects.toThrow(/staging_(?:path|residue)_denied/);
|
|
await expect(readFile(path.join(outside, "keep.txt"), "utf8")).resolves.toBe("outside bytes");
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
await rm(outside, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it("still authorizes an empty wake before touching its workspace", async () => {
|
|
const root = await mkdtemp(path.join(temporaryRoot, "empty-wake-unauthorized-"));
|
|
try {
|
|
await expect(stageEmptyWake(root, "00000000-0000-4000-8000-000000009199"))
|
|
.rejects.toThrow("paperclip_runner_attachment_staging_not_authorized");
|
|
await expect(readdir(root)).resolves.toEqual([]);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects a workspace-only file completion and invented delivery receipts without asking the user to approve completion", async () => {
|
|
for (const ref of ["launch-checklist.md", "./out/report.pdf", "/workspace/answer.txt", "file:out/report.csv", "deliverable:00000000-0000-4000-8000-000000000001"]) {
|
|
await expect(nativeCompletionFeedback(db, runId, doneReport([ref])))
|
|
.rejects.toThrow(/register_deliverable|registered attachment/);
|
|
}
|
|
await expect(nativeCompletionFeedback(db, runId, doneReport([])))
|
|
.rejects.toThrow(/requested file.*accessible|register_deliverable/);
|
|
await expect(nativeCompletionFeedback(db, runId, doneReport(["verification:passed"])))
|
|
.rejects.toThrow(/requested file.*accessible|register_deliverable/);
|
|
await expect(nativeCompletionFeedback(db, runId, doneReport(["https://example.com/report.pdf"])))
|
|
.rejects.toThrow(/requested file.*accessible|register_deliverable/);
|
|
await db.update(issues).set({ title: "Explain how a newsletter works" }).where(eq(issues.id, issueId));
|
|
try {
|
|
await expect(nativeCompletionFeedback(db, runId, doneReport([])))
|
|
.resolves.toContain("Completion report accepted");
|
|
await expect(nativeCompletionFeedback(db, runId, doneReport(["README.md"])))
|
|
.resolves.toContain("Completion report accepted");
|
|
await expect(nativeCompletionFeedback(db, runId, { ...doneReport([]), artifacts: [{ kind: "file", ref: "unpublished.pdf" }] }))
|
|
.rejects.toThrow("workspace-only file");
|
|
} finally {
|
|
await db.update(issues).set({ title: "Prepare a requested file" }).where(eq(issues.id, issueId));
|
|
}
|
|
});
|
|
|
|
it("accepts a cited registered work product and respects the current request", async () => {
|
|
const [product] = await db.insert(issueWorkProducts).values({ companyId, issueId, type: "artifact", provider: "external",
|
|
title: "Requested report", status: "ready_for_review", url: "https://example.com/report.pdf", createdByRunId: runId }).returning();
|
|
try {
|
|
for (const ref of [product.url!, `work_product:${product.id}`]) {
|
|
await expect(nativeCompletionFeedback(db, runId, doneReport([ref])))
|
|
.resolves.toContain("Completion report accepted");
|
|
}
|
|
// Metadata can name a nonexistent or cleaned-up workspace file; it is not a download.
|
|
await db.update(issueWorkProducts).set({ url: null, metadata: { resourceRef: { kind: "workspace_file", path: "missing-report.pdf" } } }).where(eq(issueWorkProducts.id, product.id));
|
|
await expect(nativeCompletionFeedback(db, runId, doneReport([`work_product:${product.id}`])))
|
|
.rejects.toThrow("requested file has no accessible delivery evidence");
|
|
await db.update(issueWorkProducts).set({ url: product.url }).where(eq(issueWorkProducts.id, product.id));
|
|
await db.update(issueWorkProducts).set({ status: "failed" }).where(eq(issueWorkProducts.id, product.id));
|
|
await expect(nativeCompletionFeedback(db, runId, doneReport([`work_product:${product.id}`])))
|
|
.rejects.toThrow("requested file has no accessible delivery evidence");
|
|
for (const objective of [
|
|
"Do not create a file. Explain the result inline.",
|
|
"Run hostname, id, and pwd. Report the output in a completion comment. Do not modify repository files or create attachments.",
|
|
]) {
|
|
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId, executionContinuation: { objective } } }).where(eq(heartbeatRuns.id, runId));
|
|
await expect(nativeCompletionFeedback(db, runId, doneReport([])))
|
|
.resolves.toContain("Completion report accepted");
|
|
}
|
|
} finally {
|
|
await db.delete(issueWorkProducts).where(eq(issueWorkProducts.id, product.id));
|
|
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId } }).where(eq(heartbeatRuns.id, runId));
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
"Use native write/read file tools. Write memory/pi-native.txt inside AGENT_HOME. This is personal memory, not a task deliverable.",
|
|
"Use native write to copy bytes into pi-agent-memory-proof.txt. This is an internal assertion file, not a deliverable.",
|
|
"Attempt native write once to /outside/pi-unassigned.txt. This intentionally unassigned root must be denied.",
|
|
"Write internal-proof.txt; then check it exists. This is an internal verification file, not a deliverable.",
|
|
"Attempt native write to /outside/probe.txt and create no files. This negative test must be denied.",
|
|
"Write internal-proof.txt and return it in chat. This is an internal verification file, not a deliverable.",
|
|
"Write internal-proof.txt and send it as a code block in your response. This is an internal verification file, not a deliverable.",
|
|
])("accepts an internal file outcome without treating it as published output: %s", async objective => {
|
|
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId, executionContinuation: { objective } } })
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
try {
|
|
await expect(nativeCompletionFeedback(db, runId, doneReport([])))
|
|
.resolves.toContain("Completion report accepted");
|
|
} finally {
|
|
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId } }).where(eq(heartbeatRuns.id, runId));
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
"Write report.pdf; write internal-proof.txt. This is an internal verification file, not a deliverable.",
|
|
"Write report.pdf and attempt native write to /outside/probe.txt; this negative test must be denied.",
|
|
"Write report.txt and attach it. This is an internal verification file, not a deliverable.",
|
|
"Write report.txt and send it to me. This is an internal verification file, not a deliverable.",
|
|
"Write report.txt. This is an internal verification file, not a deliverable. Send it as a download link in your response.",
|
|
])("still requires publication when the task also asks for internal or denied writes: %s", async objective => {
|
|
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId, executionContinuation: { objective } } })
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
try {
|
|
await expect(nativeCompletionFeedback(db, runId, doneReport([])))
|
|
.rejects.toThrow("requested file has no accessible delivery evidence");
|
|
} finally {
|
|
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId } }).where(eq(heartbeatRuns.id, runId));
|
|
}
|
|
});
|
|
|
|
it("prepares one verified same-run attachment and replays without duplicates", async () => {
|
|
const body = Buffer.from("native runner file handoff\n", "utf8");
|
|
await mkdir(path.join(workspaceRoot, "out"), { recursive: true });
|
|
await writeFile(path.join(workspaceRoot, "out", "answer.txt"), body);
|
|
const runner = authority();
|
|
const definition = runner
|
|
.definitions()
|
|
.find((entry) => entry.name === "register_deliverable");
|
|
expect(definition).toMatchObject({
|
|
description: expect.stringContaining(
|
|
"does not confirm provider delivery",
|
|
),
|
|
inputSchema: {
|
|
properties: {
|
|
filename: {
|
|
description: expect.stringContaining("Directory components"),
|
|
},
|
|
byteSize: { minimum: 1, maximum: 10 * 1024 * 1024 },
|
|
contentRef: {
|
|
description: expect.stringContaining("Workspace-relative"),
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const call = callFor("out/answer.txt", body, "requested-answer-v1");
|
|
const first = (await runner.execute(call)) as {
|
|
entityRefs: string[];
|
|
} & Record<string, unknown>;
|
|
const replay = await runner.execute({ ...call, callId: "replay-same-key" });
|
|
const replayWithNewKey = await runner.execute({
|
|
...call,
|
|
callId: "replay-new-key",
|
|
arguments: { ...call.arguments, idempotencyKey: "requested-answer-v2" },
|
|
});
|
|
|
|
expect(first).toMatchObject({
|
|
commandId: expect.stringMatching(/^deliverable-prepared:/u),
|
|
disposition: "applied",
|
|
stateRevision: 0,
|
|
entityRefs: [expect.any(String), expect.any(String), expect.any(String)],
|
|
scheduledWakeIds: [],
|
|
attachmentId: first.entityRefs[0],
|
|
contentPath: `/api/attachments/${first.entityRefs[0]}/content`,
|
|
downloadPath: `/api/attachments/${first.entityRefs[0]}/content?download=1`,
|
|
});
|
|
expect(replay).toEqual(first);
|
|
expect(replayWithNewKey).toMatchObject({
|
|
disposition: "duplicate",
|
|
entityRefs: first.entityRefs,
|
|
attachmentId: first.attachmentId,
|
|
contentPath: first.contentPath,
|
|
downloadPath: first.downloadPath,
|
|
});
|
|
|
|
// Exercise real persisted comments with the actual applied/duplicate receipts.
|
|
// Preparing the attachment must not consume the agent's final reply slot.
|
|
const preparedComments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
|
|
for (const receipt of [first, replayWithNewKey]) {
|
|
const resultJson = { semanticToolReceipts: { file: { operationId: "register_deliverable", result: receipt } } };
|
|
const existingComment = findHeartbeatRunCompletionComment(preparedComments, resultJson);
|
|
expect(existingComment).toBeNull();
|
|
expect(resolveHeartbeatRunResponse({
|
|
resultJson,
|
|
existingComment,
|
|
finalAgentMessage: { text: "Here is the requested answer.", channel: "final", sourceEventId: "final-1" },
|
|
})).toMatchObject({ text: "Here is the requested answer.", decision: { commentAction: "create" } });
|
|
}
|
|
|
|
await expect(nativeCompletionFeedback(db, runId, doneReport([`deliverable:${first.entityRefs[0]}`])))
|
|
.resolves.toContain("Completion report accepted");
|
|
await db.update(issues).set({ title: "Create a Markdown document on this task." }).where(eq(issues.id, issueId));
|
|
try {
|
|
await expect(nativeCompletionFeedback(db, runId, doneReport([`deliverable:${first.entityRefs[0]}`])))
|
|
.resolves.toContain("Completion report accepted");
|
|
await expect(nativeCompletionFeedback(db, runId, doneReport(["out/answer.txt"])))
|
|
.rejects.toThrow("write_document");
|
|
} finally {
|
|
await db.update(issues).set({ title: "Prepare a requested file" }).where(eq(issues.id, issueId));
|
|
}
|
|
const otherIssueId = "00000000-0000-4000-8000-000000009111";
|
|
await db.insert(issues).values({ id: otherIssueId, companyId, title: "Unrelated file", status: "in_progress" });
|
|
await db.update(issueAttachments).set({ issueId: otherIssueId }).where(eq(issueAttachments.id, first.entityRefs[0]));
|
|
try {
|
|
await expect(nativeCompletionFeedback(db, runId, doneReport([`deliverable:${first.entityRefs[0]}`])))
|
|
.rejects.toThrow("registered attachment on this task");
|
|
} finally {
|
|
await db.update(issueAttachments).set({ issueId }).where(eq(issueAttachments.id, first.entityRefs[0]));
|
|
await db.delete(issues).where(eq(issues.id, otherIssueId));
|
|
}
|
|
|
|
const attachmentRows = await db
|
|
.select()
|
|
.from(issueAttachments)
|
|
.where(eq(issueAttachments.issueId, issueId));
|
|
expect(attachmentRows).toHaveLength(1);
|
|
expect(attachmentRows[0]).toMatchObject({
|
|
originatingRunId: runId,
|
|
issueCommentId: expect.any(String),
|
|
});
|
|
await expect(
|
|
db.select().from(assets).where(eq(assets.companyId, companyId)),
|
|
).resolves.toEqual([
|
|
expect.objectContaining({
|
|
createdByAgentId: agentId,
|
|
originalFilename: "answer.txt",
|
|
contentType: "text/plain",
|
|
byteSize: body.length,
|
|
sha256: call.arguments.sha256,
|
|
}),
|
|
]);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(issueWorkProducts)
|
|
.where(eq(issueWorkProducts.issueId, issueId)),
|
|
).resolves.toEqual([
|
|
expect.objectContaining({
|
|
type: "artifact",
|
|
provider: "paperclip",
|
|
title: "Requested answer",
|
|
createdByRunId: runId,
|
|
}),
|
|
]);
|
|
await expect(
|
|
db.select().from(issueComments).where(eq(issueComments.issueId, issueId)),
|
|
).resolves.toEqual([
|
|
expect.objectContaining({
|
|
authorAgentId: agentId,
|
|
createdByRunId: runId,
|
|
body: "Prepared Requested answer for this response.",
|
|
metadata: expect.objectContaining({
|
|
authorizationReason: "paperclip_runner_protocol",
|
|
}),
|
|
}),
|
|
]);
|
|
await expect(
|
|
db.select().from(activityLog).where(eq(activityLog.entityId, issueId)),
|
|
).resolves.toEqual([
|
|
expect.objectContaining({
|
|
action: "issue.attachment_added",
|
|
agentId,
|
|
runId,
|
|
details: expect.objectContaining({
|
|
attachmentId: first.entityRefs[0],
|
|
workProductId: first.entityRefs[1],
|
|
commentId: first.entityRefs[2],
|
|
source: "paperclip_runner_protocol",
|
|
}),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("fails closed for remote targets, symlinks, traversal, hash drift, and foreign bindings", async () => {
|
|
const body = Buffer.from("untrusted path checks\n", "utf8");
|
|
await writeFile(path.join(workspaceRoot, "checked.txt"), body);
|
|
await symlink(
|
|
path.join(workspaceRoot, "checked.txt"),
|
|
path.join(workspaceRoot, "linked.txt"),
|
|
);
|
|
await mkdir(path.join(workspaceRoot, "real-directory"), {
|
|
recursive: true,
|
|
});
|
|
await writeFile(
|
|
path.join(workspaceRoot, "real-directory", "nested.txt"),
|
|
body,
|
|
);
|
|
await symlink(
|
|
path.join(workspaceRoot, "real-directory"),
|
|
path.join(workspaceRoot, "linked-directory"),
|
|
);
|
|
await writeFile(path.join(workspaceRoot, "hardlink-origin.txt"), body);
|
|
await link(
|
|
path.join(workspaceRoot, "hardlink-origin.txt"),
|
|
path.join(workspaceRoot, "hardlinked.txt"),
|
|
);
|
|
|
|
await expect(
|
|
authority({ executionTargetKind: "remote" }).execute(
|
|
callFor("checked.txt", body, "remote-denied"),
|
|
),
|
|
).rejects.toThrow("paperclip_runner_file_handoff_remote_unsupported");
|
|
await expect(
|
|
authority().execute(callFor("linked.txt", body, "symlink-denied")),
|
|
).rejects.toThrow("paperclip_runner_file_handoff_symlink_denied");
|
|
await expect(
|
|
authority().execute(
|
|
callFor(
|
|
"linked-directory/nested.txt",
|
|
body,
|
|
"intermediate-symlink-denied",
|
|
),
|
|
),
|
|
).rejects.toThrow("paperclip_runner_file_handoff_symlink_denied");
|
|
await expect(
|
|
authority().execute(callFor("hardlinked.txt", body, "hardlink-denied")),
|
|
).rejects.toThrow("paperclip_runner_file_handoff_file_changed");
|
|
await expect(
|
|
authority().execute(callFor("../checked.txt", body, "traversal-denied")),
|
|
).rejects.toThrow("paperclip_runner_file_handoff_path_denied");
|
|
await expect(
|
|
authority().execute({
|
|
...callFor("checked.txt", body, "hash-denied"),
|
|
arguments: {
|
|
...callFor("checked.txt", body, "hash-denied").arguments,
|
|
sha256: "0".repeat(64),
|
|
},
|
|
}),
|
|
).rejects.toThrow("paperclip_runner_file_handoff_hash_mismatch");
|
|
await expect(
|
|
authority({ companyId: "00000000-0000-4000-8000-000000009999" }).execute(
|
|
callFor("checked.txt", body, "foreign-denied"),
|
|
),
|
|
).rejects.toThrow("paperclip_runner_tool_binding_not_authorized");
|
|
});
|
|
|
|
it("registers a verified remote output without reading a controller path", async () => {
|
|
const body = Buffer.from("Remote requested file\n");
|
|
const reader = vi.fn(async () => body);
|
|
const remote = authority({ executionTargetKind: "remote", readRemoteWorkspaceFile: reader });
|
|
expect(remote.definitions()).toContainEqual(expect.objectContaining({ name: "register_deliverable" }));
|
|
const call = callFor("remote-only/result.txt", body, "remote-output");
|
|
const result = await remote.execute(call) as { entityRefs: string[] };
|
|
expect(reader).toHaveBeenCalledWith({ contentRef: call.arguments.contentRef, byteSize: body.length, sha256: call.arguments.sha256 });
|
|
await expect(nativeCompletionFeedback(db, runId, doneReport([`deliverable:${result.entityRefs[0]}`])))
|
|
.resolves.toContain("Completion report accepted");
|
|
const badReader = vi.fn(async () => Buffer.from("wrong bytes"));
|
|
await expect(authority({ executionTargetKind: "remote", readRemoteWorkspaceFile: badReader })
|
|
.execute(callFor("remote-only/drift.txt", body, "remote-drift")))
|
|
.rejects.toThrow(/size|hash/);
|
|
});
|
|
|
|
it("reads provider files before mutation locks and replays without reading again", async () => {
|
|
const body = Buffer.from("Provider callback output\n");
|
|
const reader = vi.fn(async () => {
|
|
await db.transaction(async tx => {
|
|
await tx.execute(sql`set local lock_timeout = '500ms'`);
|
|
await tx.select({ id: agents.id }).from(agents).where(eq(agents.id, agentId)).for("share");
|
|
});
|
|
return body;
|
|
});
|
|
const remote = authority({ executionTargetKind: "remote", readRemoteTaskFile: reader });
|
|
const call = callFor("proof.txt", body, "provider-lock-callback");
|
|
const result = await remote.execute(call);
|
|
reader.mockRejectedValueOnce(new Error("The original file is no longer available"));
|
|
await expect(remote.execute(call)).resolves.toEqual(result);
|
|
expect(reader).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("rejects publication if agent authority changes during a provider read", async () => {
|
|
const body = Buffer.from("Revoked output\n");
|
|
const before = await db.select({ id: issueAttachments.id }).from(issueAttachments);
|
|
const reader = vi.fn(async () => {
|
|
await db.update(agents).set({ status: "paused" }).where(eq(agents.id, agentId));
|
|
return body;
|
|
});
|
|
try {
|
|
await expect(authority({ executionTargetKind: "remote", readRemoteTaskFile: reader })
|
|
.execute(callFor("proof.txt", body, "revoked-during-read")))
|
|
.rejects.toThrow("paperclip_runner_tool_binding_not_authorized");
|
|
expect(await db.select({ id: issueAttachments.id }).from(issueAttachments)).toEqual(before);
|
|
} finally {
|
|
await db.update(agents).set({ status: "active" }).where(eq(agents.id, agentId));
|
|
}
|
|
});
|
|
|
|
it("registers an absolute file only through a task-location-authorizing reader", async () => {
|
|
const body=Buffer.from("Home output\n");const reader=vi.fn(async()=>body);
|
|
const call=callFor("/home/agent/proof.txt",body,"home-output");
|
|
await expect(authority({executionTargetKind:"remote",readRemoteWorkspaceFile:reader}).execute(call)).rejects.toThrow("path_denied");
|
|
expect(reader).not.toHaveBeenCalled();
|
|
await expect(authority({executionTargetKind:"remote",readRemoteTaskFile:reader}).execute(call)).resolves.toBeDefined();
|
|
expect(reader).toHaveBeenCalledWith(expect.objectContaining({contentRef:"/home/agent/proof.txt"}));
|
|
});
|
|
|
|
it("stages only exact wake-bound inbound bytes without exposing an API credential", async () => {
|
|
const storage = createStorageService(
|
|
createLocalDiskStorageProvider(storageRoot),
|
|
);
|
|
const body = Buffer.from(
|
|
"inspect these authenticated inbound bytes\n",
|
|
"utf8",
|
|
);
|
|
const stored = await storage.putFile({
|
|
companyId,
|
|
namespace: `issues/${issueId}`,
|
|
originalFilename: "inbound.txt",
|
|
contentType: "text/plain",
|
|
body,
|
|
});
|
|
const comment = await issueService(db).addComment(
|
|
issueId,
|
|
"Please inspect the attached file.",
|
|
{ userId: "inbound-user" },
|
|
);
|
|
const attachment = await issueService(db).createAttachment({
|
|
issueId,
|
|
issueCommentId: comment.id,
|
|
provider: stored.provider,
|
|
objectKey: stored.objectKey,
|
|
contentType: stored.contentType,
|
|
byteSize: stored.byteSize,
|
|
sha256: stored.sha256,
|
|
originalFilename: stored.originalFilename,
|
|
createdByUserId: "inbound-user",
|
|
});
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({
|
|
contextSnapshot: {
|
|
issueId,
|
|
paperclipWake: {
|
|
comments: [
|
|
{
|
|
id: comment.id,
|
|
body: "Please inspect the attached file.",
|
|
attachments: [
|
|
{
|
|
id: attachment.id,
|
|
filename: "inbound.txt",
|
|
contentType: "text/plain",
|
|
byteSize: body.length,
|
|
contentPath: `/api/attachments/${attachment.id}/content`,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
})
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
|
|
const stage = await stageNativeRunnerWakeAttachments({
|
|
db,
|
|
binding: {
|
|
companyId,
|
|
issueId,
|
|
runId,
|
|
agentId,
|
|
workspaceRoot,
|
|
executionTargetKind: "local",
|
|
},
|
|
storage,
|
|
});
|
|
expect(stage.attachments).toEqual([
|
|
{
|
|
id: attachment.id,
|
|
filename: "inbound.txt",
|
|
contentType: "text/plain",
|
|
byteSize: body.length,
|
|
workspaceRelativePath: expect.stringMatching(
|
|
/^\.paperclip-inbound\/.+\/[0-9a-f-]{36}$/u,
|
|
),
|
|
unavailableReason: null,
|
|
},
|
|
]);
|
|
const relativePath = stage.attachments[0]?.workspaceRelativePath;
|
|
expect(relativePath).toBeTruthy();
|
|
await expect(
|
|
readFile(path.join(workspaceRoot, relativePath!)),
|
|
).resolves.toEqual(body);
|
|
const concurrentStage = await stageNativeRunnerWakeAttachments({
|
|
db,
|
|
binding: {
|
|
companyId,
|
|
issueId,
|
|
runId,
|
|
agentId,
|
|
workspaceRoot,
|
|
executionTargetKind: "local",
|
|
},
|
|
storage,
|
|
});
|
|
expect(concurrentStage.attachments[0]?.workspaceRelativePath).not.toBe(
|
|
relativePath,
|
|
);
|
|
await expect(
|
|
readFile(path.join(workspaceRoot, relativePath!)),
|
|
).resolves.toEqual(body);
|
|
await concurrentStage.cleanup();
|
|
|
|
const liveOwner = spawn(
|
|
process.execPath,
|
|
["-e", "setInterval(() => undefined, 1_000)"],
|
|
{ stdio: "ignore" },
|
|
);
|
|
await once(liveOwner, "spawn");
|
|
if (!liveOwner.pid) throw new Error("live staging owner did not start");
|
|
try {
|
|
const liveForeignDirectory = path.join(
|
|
workspaceRoot,
|
|
".paperclip-inbound",
|
|
`process-${liveOwner.pid}-unknown-00000000-0000-4000-8000-000000009298`,
|
|
);
|
|
const liveForeignFile = path.join(liveForeignDirectory, "active-slot");
|
|
await mkdir(liveForeignDirectory, { recursive: true });
|
|
await writeFile(liveForeignFile, "bytes owned by another live process");
|
|
const crossProcessStage = await stageNativeRunnerWakeAttachments({
|
|
db,
|
|
binding: {
|
|
companyId,
|
|
issueId,
|
|
runId,
|
|
agentId,
|
|
workspaceRoot,
|
|
executionTargetKind: "local",
|
|
},
|
|
storage,
|
|
});
|
|
await expect(readFile(liveForeignFile, "utf8")).resolves.toBe(
|
|
"bytes owned by another live process",
|
|
);
|
|
await crossProcessStage.cleanup();
|
|
|
|
const recycledOwnerDirectory = path.join(
|
|
workspaceRoot,
|
|
".paperclip-inbound",
|
|
`process-${liveOwner.pid}-0-00000000-0000-4000-8000-000000009297`,
|
|
);
|
|
const recycledOwnerFile = path.join(recycledOwnerDirectory, "stale-slot");
|
|
await mkdir(recycledOwnerDirectory, { recursive: true });
|
|
await writeFile(recycledOwnerFile, "bytes from a recycled pid owner");
|
|
const recycledOwnerStage = await stageNativeRunnerWakeAttachments({
|
|
db,
|
|
binding: {
|
|
companyId,
|
|
issueId,
|
|
runId,
|
|
agentId,
|
|
workspaceRoot,
|
|
executionTargetKind: "local",
|
|
},
|
|
storage,
|
|
});
|
|
await expect(readFile(recycledOwnerFile)).resolves.toEqual(
|
|
Buffer.alloc(0),
|
|
);
|
|
await recycledOwnerStage.cleanup();
|
|
} finally {
|
|
liveOwner.kill("SIGTERM");
|
|
await once(liveOwner, "exit");
|
|
}
|
|
|
|
const prompt = renderNativeRunnerStagedAttachmentPrompt(stage.attachments);
|
|
expect(prompt).toContain(
|
|
"Never substitute an older generated workspace file",
|
|
);
|
|
expect(prompt).toContain(relativePath!);
|
|
expect(prompt).not.toContain("/api/attachments/");
|
|
expect(prompt).not.toContain("PAPERCLIP_API_KEY");
|
|
await stage.cleanup();
|
|
await expect(
|
|
readFile(path.join(workspaceRoot, relativePath!)),
|
|
).resolves.toEqual(Buffer.alloc(0));
|
|
|
|
const crashDirectory = path.join(
|
|
workspaceRoot,
|
|
".paperclip-inbound",
|
|
"process-2147483647-0-00000000-0000-4000-8000-000000009299",
|
|
);
|
|
await mkdir(crashDirectory, { recursive: true });
|
|
const outsideResidue = path.join(temporaryRoot, "outside-residue.txt");
|
|
const linkedResidue = path.join(crashDirectory, "linked-residue");
|
|
await writeFile(outsideResidue, "outside bytes must remain intact");
|
|
await symlink(outsideResidue, linkedResidue);
|
|
await expect(
|
|
stageNativeRunnerWakeAttachments({
|
|
db,
|
|
binding: {
|
|
companyId,
|
|
issueId,
|
|
runId,
|
|
agentId,
|
|
workspaceRoot,
|
|
executionTargetKind: "local",
|
|
},
|
|
storage,
|
|
}),
|
|
).rejects.toThrow("paperclip_runner_attachment_staging_residue_denied");
|
|
await expect(readFile(outsideResidue, "utf8")).resolves.toBe(
|
|
"outside bytes must remain intact",
|
|
);
|
|
await unlink(linkedResidue);
|
|
|
|
const crashedResidue = path.join(crashDirectory, "opaque-residue");
|
|
await writeFile(crashedResidue, "bytes retained by an abrupt prior crash");
|
|
const recoveredStage = await stageNativeRunnerWakeAttachments({
|
|
db,
|
|
binding: {
|
|
companyId,
|
|
issueId,
|
|
runId,
|
|
agentId,
|
|
workspaceRoot,
|
|
executionTargetKind: "local",
|
|
},
|
|
storage,
|
|
});
|
|
await expect(readFile(crashedResidue)).resolves.toEqual(Buffer.alloc(0));
|
|
await recoveredStage.cleanup();
|
|
|
|
await expect(
|
|
stageNativeRunnerWakeAttachments({
|
|
db,
|
|
binding: {
|
|
companyId: "00000000-0000-4000-8000-000000009999",
|
|
issueId,
|
|
runId,
|
|
agentId,
|
|
workspaceRoot,
|
|
executionTargetKind: "local",
|
|
},
|
|
storage,
|
|
}),
|
|
).rejects.toThrow("paperclip_runner_attachment_staging_not_authorized");
|
|
|
|
const remoteStage = await stageNativeRunnerWakeAttachments({
|
|
db,
|
|
binding: {
|
|
companyId,
|
|
issueId,
|
|
runId,
|
|
agentId,
|
|
workspaceRoot: "/remote/workspace",
|
|
executionTargetKind: "remote",
|
|
},
|
|
storage,
|
|
});
|
|
expect(remoteStage.attachments).toEqual([
|
|
expect.objectContaining({
|
|
id: attachment.id,
|
|
workspaceRelativePath: null,
|
|
unavailableReason: "remote_workspace_staging_unsupported",
|
|
}),
|
|
]);
|
|
await remoteStage.cleanup();
|
|
});
|
|
|
|
it("excludes an older same-issue attachment when the current wake selects a newer file", async () => {
|
|
const storage = createStorageService(
|
|
createLocalDiskStorageProvider(storageRoot),
|
|
);
|
|
const historicalBody = Buffer.from(
|
|
"historical decoy marker: amber-larch-17\n",
|
|
"utf8",
|
|
);
|
|
const currentBody = Buffer.from(
|
|
"current wake marker: cobalt-sparrow-42\n",
|
|
"utf8",
|
|
);
|
|
const historical = await createInboundAttachmentFixture({
|
|
storage,
|
|
filename: "historical.txt",
|
|
body: historicalBody,
|
|
commentBody: "An older attachment from this task.",
|
|
});
|
|
const current = await createInboundAttachmentFixture({
|
|
storage,
|
|
filename: "current.txt",
|
|
body: currentBody,
|
|
commentBody: "Inspect only the file attached to this turn.",
|
|
});
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({
|
|
contextSnapshot: {
|
|
issueId,
|
|
paperclipWake: {
|
|
comments: [
|
|
{
|
|
id: current.comment.id,
|
|
body: "Inspect only the file attached to this turn.",
|
|
attachments: [
|
|
{
|
|
id: current.attachment.id,
|
|
filename: "current.txt",
|
|
contentType: "text/plain",
|
|
byteSize: currentBody.length,
|
|
contentPath: `/api/attachments/${current.attachment.id}/content`,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
})
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
|
|
const objectReads: string[] = [];
|
|
const observingStorage: StorageService = {
|
|
...storage,
|
|
getObject: async (readCompanyId, objectKey, options) => {
|
|
objectReads.push(objectKey);
|
|
return storage.getObject(readCompanyId, objectKey, options);
|
|
},
|
|
};
|
|
const stage = await stageNativeRunnerWakeAttachments({
|
|
db,
|
|
binding: {
|
|
companyId,
|
|
issueId,
|
|
runId,
|
|
agentId,
|
|
workspaceRoot,
|
|
executionTargetKind: "local",
|
|
},
|
|
storage: observingStorage,
|
|
});
|
|
|
|
expect(stage.attachments).toEqual([
|
|
expect.objectContaining({
|
|
id: current.attachment.id,
|
|
filename: "current.txt",
|
|
unavailableReason: null,
|
|
}),
|
|
]);
|
|
expect(objectReads).toEqual([current.stored.objectKey]);
|
|
expect(objectReads).not.toContain(historical.stored.objectKey);
|
|
const relativePath = stage.attachments[0]?.workspaceRelativePath;
|
|
expect(relativePath).toBeTruthy();
|
|
await expect(
|
|
readFile(path.join(workspaceRoot, relativePath!)),
|
|
).resolves.toEqual(currentBody);
|
|
await stage.cleanup();
|
|
});
|
|
|
|
it("stages no historical attachment when the current wake contains only an omission", async () => {
|
|
const storage = createStorageService(
|
|
createLocalDiskStorageProvider(storageRoot),
|
|
);
|
|
const historical = await createInboundAttachmentFixture({
|
|
storage,
|
|
filename: "omission-decoy.txt",
|
|
body: Buffer.from(
|
|
"never substitute this historical attachment\n",
|
|
"utf8",
|
|
),
|
|
commentBody: "Historical file that is not part of the current wake.",
|
|
});
|
|
const currentComment = await issueService(db).addComment(
|
|
issueId,
|
|
"Inspect the current attachment if Paperclip imported it.",
|
|
{ userId: "inbound-user" },
|
|
);
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({
|
|
contextSnapshot: {
|
|
issueId,
|
|
paperclipWake: {
|
|
comments: [
|
|
{
|
|
id: currentComment.id,
|
|
body: "Inspect the current attachment if Paperclip imported it.",
|
|
attachments: [],
|
|
},
|
|
],
|
|
attachmentOmissions: [
|
|
{
|
|
commentId: currentComment.id,
|
|
reasons: { unsupported_type: 1 },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
})
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
|
|
const objectReads: string[] = [];
|
|
const observingStorage: StorageService = {
|
|
...storage,
|
|
getObject: async (readCompanyId, objectKey, options) => {
|
|
objectReads.push(objectKey);
|
|
return storage.getObject(readCompanyId, objectKey, options);
|
|
},
|
|
};
|
|
const stage = await stageNativeRunnerWakeAttachments({
|
|
db,
|
|
binding: {
|
|
companyId,
|
|
issueId,
|
|
runId,
|
|
agentId,
|
|
workspaceRoot,
|
|
executionTargetKind: "local",
|
|
},
|
|
storage: observingStorage,
|
|
});
|
|
|
|
expect(stage.attachments).toEqual([]);
|
|
expect(objectReads).toEqual([]);
|
|
expect(objectReads).not.toContain(historical.stored.objectKey);
|
|
expect(renderNativeRunnerStagedAttachmentPrompt(stage.attachments)).toBe(
|
|
"",
|
|
);
|
|
await stage.cleanup();
|
|
});
|
|
|
|
it("fails closed on a reminted work product and removes definite pre-commit storage failures", async () => {
|
|
const [existingAttachment] = await db
|
|
.select({ id: issueAttachments.id })
|
|
.from(issueAttachments)
|
|
.where(eq(issueAttachments.originatingRunId, runId))
|
|
.limit(1);
|
|
expect(existingAttachment).toBeDefined();
|
|
await db
|
|
.delete(issueWorkProducts)
|
|
.where(eq(issueWorkProducts.issueId, issueId));
|
|
const foreignRunId = "00000000-0000-4000-8000-000000009105";
|
|
await db.insert(heartbeatRuns).values({
|
|
id: foreignRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "succeeded",
|
|
runtimeMode: "native",
|
|
nativeIssueId: issueId,
|
|
invocationSource: "automation",
|
|
triggerDetail: "system",
|
|
contextSnapshot: { issueId },
|
|
});
|
|
await db.insert(issueWorkProducts).values({
|
|
companyId,
|
|
issueId,
|
|
type: "artifact",
|
|
provider: "paperclip",
|
|
externalId: existingAttachment!.id,
|
|
title: "Reminted by another run",
|
|
status: "active",
|
|
reviewState: "none",
|
|
isPrimary: false,
|
|
healthStatus: "unknown",
|
|
createdByRunId: foreignRunId,
|
|
});
|
|
const originalBody = Buffer.from("native runner file handoff\n", "utf8");
|
|
await expect(
|
|
authority().execute(
|
|
callFor("out/answer.txt", originalBody, "reminted-work-product"),
|
|
),
|
|
).rejects.toThrow("paperclip_runner_file_handoff_work_product_missing");
|
|
|
|
const failureBody = Buffer.from("storage mismatch cleanup\n", "utf8");
|
|
await writeFile(path.join(workspaceRoot, "mismatch.txt"), failureBody);
|
|
const realStorage = createStorageService(
|
|
createLocalDiskStorageProvider(storageRoot),
|
|
);
|
|
let deletedObjectKey: string | null = null;
|
|
const mismatchingStorage: StorageService = {
|
|
...realStorage,
|
|
putFile: async (input) => ({
|
|
...(await realStorage.putFile(input)),
|
|
sha256: "0".repeat(64),
|
|
}),
|
|
deleteObject: async (deleteCompanyId, objectKey) => {
|
|
deletedObjectKey = objectKey;
|
|
await realStorage.deleteObject(deleteCompanyId, objectKey);
|
|
},
|
|
};
|
|
const mismatchingAuthority = new PaperclipRunnerToolAuthority(db, {
|
|
companyId,
|
|
agentId,
|
|
issueId,
|
|
runId,
|
|
workspaceRoot,
|
|
executionTargetKind: "local",
|
|
storage: mismatchingStorage,
|
|
});
|
|
await expect(
|
|
mismatchingAuthority.execute(
|
|
callFor("mismatch.txt", failureBody, "storage-mismatch"),
|
|
),
|
|
).rejects.toThrow("paperclip_runner_file_handoff_storage_mismatch");
|
|
expect(deletedObjectKey).toEqual(expect.any(String));
|
|
await expect(
|
|
realStorage.headObject(companyId, deletedObjectKey!),
|
|
).resolves.toMatchObject({ exists: false });
|
|
|
|
const receiptFailureBody = Buffer.from(
|
|
"receipt persistence cleanup\n",
|
|
"utf8",
|
|
);
|
|
await writeFile(
|
|
path.join(workspaceRoot, "receipt-failure.txt"),
|
|
receiptFailureBody,
|
|
);
|
|
let receiptFailureObjectKey: string | null = null;
|
|
const receiptFailureStorage: StorageService = {
|
|
...realStorage,
|
|
putFile: async (input) => {
|
|
const stored = await realStorage.putFile(input);
|
|
receiptFailureObjectKey = stored.objectKey;
|
|
return stored;
|
|
},
|
|
deleteObject: async (deleteCompanyId, objectKey) => {
|
|
await realStorage.deleteObject(deleteCompanyId, objectKey);
|
|
},
|
|
};
|
|
await db.execute(sql`
|
|
CREATE OR REPLACE FUNCTION paperclip_test_fail_native_receipt()
|
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
|
BEGIN
|
|
IF NEW.result_json IS DISTINCT FROM OLD.result_json THEN
|
|
RAISE EXCEPTION 'forced_native_receipt_failure';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$
|
|
`);
|
|
await db.execute(sql`
|
|
CREATE TRIGGER paperclip_test_fail_native_receipt
|
|
BEFORE UPDATE ON heartbeat_runs
|
|
FOR EACH ROW EXECUTE FUNCTION paperclip_test_fail_native_receipt()
|
|
`);
|
|
try {
|
|
const receiptFailureAuthority = new PaperclipRunnerToolAuthority(db, {
|
|
companyId,
|
|
agentId,
|
|
issueId,
|
|
runId,
|
|
workspaceRoot,
|
|
executionTargetKind: "local",
|
|
storage: receiptFailureStorage,
|
|
});
|
|
let receiptFailure: unknown;
|
|
try {
|
|
await receiptFailureAuthority.execute(
|
|
callFor(
|
|
"receipt-failure.txt",
|
|
receiptFailureBody,
|
|
"receipt-persistence-failure",
|
|
),
|
|
);
|
|
} catch (error) {
|
|
receiptFailure = error;
|
|
}
|
|
expect(receiptFailure).toMatchObject({
|
|
message: expect.stringContaining(
|
|
'Failed query: update "heartbeat_runs"',
|
|
),
|
|
cause: {
|
|
message: expect.stringContaining("forced_native_receipt_failure"),
|
|
},
|
|
});
|
|
} finally {
|
|
await db.execute(
|
|
sql`DROP TRIGGER IF EXISTS paperclip_test_fail_native_receipt ON heartbeat_runs`,
|
|
);
|
|
await db.execute(
|
|
sql`DROP FUNCTION IF EXISTS paperclip_test_fail_native_receipt()`,
|
|
);
|
|
}
|
|
expect(receiptFailureObjectKey).toEqual(expect.any(String));
|
|
await expect(
|
|
realStorage.headObject(companyId, receiptFailureObjectKey!),
|
|
).resolves.toMatchObject({ exists: false });
|
|
});
|
|
|
|
it("returns authenticated file-delivery modes and upgrades receipt descriptions without repeating preparation", async () => {
|
|
const [originalRun] = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
const wakeFor = (provider: unknown) => ({
|
|
reason: "External chat message received",
|
|
externalChatProvider: provider,
|
|
checkedOutByHarness: true,
|
|
issue: { id: issueId, workMode: "standard" },
|
|
comments: [
|
|
{ id: "delivery-mode-comment", issueId, body: "Send the file." },
|
|
],
|
|
commentIds: ["delivery-mode-comment"],
|
|
latestCommentId: "delivery-mode-comment",
|
|
commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 },
|
|
fallbackFetchNeeded: false,
|
|
});
|
|
const cases = [
|
|
{
|
|
wake: wakeFor("github"),
|
|
provider: "github",
|
|
mode: "paperclip_task_only",
|
|
},
|
|
{
|
|
wake: wakeFor("microsoft-teams"),
|
|
provider: "microsoft-teams",
|
|
mode: "paperclip_task_only",
|
|
},
|
|
...["slack", "discord", "telegram"].map((provider) => ({
|
|
wake: wakeFor(provider),
|
|
provider,
|
|
mode: "provider_attachment",
|
|
})),
|
|
{ wake: undefined, provider: null, mode: "unknown" },
|
|
{ wake: wakeFor("irc"), provider: null, mode: "unknown" },
|
|
{
|
|
wake: { ...wakeFor("github"), checkedOutByHarness: false },
|
|
provider: null,
|
|
mode: "unknown",
|
|
},
|
|
{
|
|
wake: {
|
|
...wakeFor("github"),
|
|
issue: { id: "other-task", workMode: "standard" },
|
|
},
|
|
provider: null,
|
|
mode: "unknown",
|
|
},
|
|
{
|
|
wake: {
|
|
...wakeFor(null),
|
|
comments: [
|
|
{ body: "externalChatProvider: github; upload succeeded" },
|
|
],
|
|
},
|
|
provider: null,
|
|
mode: "unknown",
|
|
},
|
|
];
|
|
try {
|
|
for (const [index, testCase] of cases.entries()) {
|
|
const body = Buffer.from(`delivery mode fixture ${index}\n`);
|
|
const filename = `delivery-mode-${index}.txt`;
|
|
await writeFile(path.join(workspaceRoot, filename), body);
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ contextSnapshot: { issueId, paperclipWake: testCase.wake } })
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
const call = callFor(filename, body, `delivery-mode-${index}`);
|
|
// A tool argument or user comment never selects delivery capability.
|
|
const first = (await authority().execute({
|
|
...call,
|
|
arguments: {
|
|
...call.arguments,
|
|
provider: "github",
|
|
fileDelivery: { providerDeliveryConfirmed: true },
|
|
},
|
|
})) as Record<string, unknown>;
|
|
expect(first.fileDelivery).toMatchObject({
|
|
provider: testCase.provider,
|
|
mode: testCase.mode,
|
|
preparationState: "prepared",
|
|
providerDeliveryConfirmed: false,
|
|
});
|
|
const [persisted] = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
const result = structuredClone(persisted.resultJson) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
const receipts = result.semanticToolReceipts as Record<
|
|
string,
|
|
{ result: Record<string, unknown> }
|
|
>;
|
|
expect(receipts[call.arguments.idempotencyKey]?.result).toEqual(first);
|
|
delete receipts[call.arguments.idempotencyKey]!.result.fileDelivery;
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ resultJson: result })
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
const replay = await authority().execute({
|
|
...call,
|
|
callId: `${call.callId}-replay`,
|
|
arguments: {
|
|
...call.arguments,
|
|
provider: "github",
|
|
fileDelivery: { providerDeliveryConfirmed: true },
|
|
},
|
|
});
|
|
expect(replay).toEqual(first);
|
|
const [afterReplay] = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
expect(afterReplay.resultJson).toEqual(result);
|
|
}
|
|
} finally {
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ contextSnapshot: originalRun.contextSnapshot })
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
}
|
|
});
|
|
it.each(["attachment", "work product"])("rejects a previous run's %s for new output and revalidates preserved bytes internally", async (kind) => {
|
|
const key = `prior-output-${kind}`;
|
|
const body = Buffer.from("Preserved work from the previous run.\n");
|
|
await writeFile(path.join(workspaceRoot, `${key}.txt`), body);
|
|
const prior = await authority().execute(callFor(`${key}.txt`, body, key)) as { entityRefs: string[] };
|
|
const [product] = await db.insert(issueWorkProducts).values({ companyId, issueId, type: "artifact", provider: "external",
|
|
title: "Previous report", status: "ready_for_review", url: "https://example.com/previous.pdf", createdByRunId: runId }).returning();
|
|
const nextRunId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: nextRunId, companyId, agentId, status: "running", runtimeMode: "native",
|
|
nativeIssueId: issueId, invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId } });
|
|
await db.update(issues).set({ executionRunId: nextRunId }).where(eq(issues.id, issueId));
|
|
const ref = kind === "attachment" ? `deliverable:${prior.entityRefs[0]}` : `work_product:${product.id}`;
|
|
try {
|
|
await expect(nativeCompletionFeedback(db, nextRunId, doneReport([ref])))
|
|
.rejects.toThrow(/current run|this run|requested file.*accessible/);
|
|
|
|
// A reference in a text-only follow-up is still useful evidence, not a new output claim.
|
|
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId, executionContinuation: { objective: "Do not create a file. Explain the result inline." } } }).where(eq(heartbeatRuns.id, nextRunId));
|
|
await expect(nativeCompletionFeedback(db, nextRunId, doneReport([ref])))
|
|
.resolves.toContain("Completion report accepted");
|
|
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId } }).where(eq(heartbeatRuns.id, nextRunId));
|
|
|
|
// The replacement can verify and publish the existing bytes itself. No user action is needed.
|
|
const replacement = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId: nextRunId,
|
|
workspaceRoot, executionTargetKind: "local", storage: createStorageService(createLocalDiskStorageProvider(storageRoot)) });
|
|
const current = await replacement.execute(callFor(`${key}.txt`, body, `${key}-verified`)) as { entityRefs: string[] };
|
|
expect(current.entityRefs[0]).not.toBe(prior.entityRefs[0]);
|
|
expect(await readFile(path.join(workspaceRoot, `${key}.txt`))).toEqual(body);
|
|
// Feedback reloads the durable receipt, so a controller restart of the same run keeps this proof.
|
|
await expect(nativeCompletionFeedback(db, nextRunId, doneReport([`deliverable:${current.entityRefs[0]}`])))
|
|
.resolves.toContain("Completion report accepted");
|
|
await expect(nativeCompletionFeedback(db, nextRunId, doneReport([ref, `deliverable:${current.entityRefs[0]}`])))
|
|
.resolves.toContain("Completion report accepted");
|
|
const [currentProduct] = await db.insert(issueWorkProducts).values({ companyId, issueId, type: "artifact", provider: "external",
|
|
title: "Current report", status: "ready_for_review", url: "https://example.com/current.pdf", createdByRunId: nextRunId }).returning();
|
|
await expect(nativeCompletionFeedback(db, nextRunId, doneReport([ref, `work_product:${currentProduct.id}`])))
|
|
.resolves.toContain("Completion report accepted");
|
|
} finally {
|
|
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
|
|
await db.delete(issueWorkProducts).where(eq(issueWorkProducts.id, product.id));
|
|
}
|
|
});
|
|
|
|
it.each(["filename", "size", "hash", "origin", "missing receipt", "wrong operation"])("requires matching current publication proof after %s changes", async (mutation) => {
|
|
const key = `receipt-match-${mutation}`;
|
|
const body = Buffer.from(`verified publication ${mutation}\n`);
|
|
await writeFile(path.join(workspaceRoot, `${key}.txt`), body);
|
|
const result = await authority().execute(callFor(`${key}.txt`, body, key)) as { entityRefs: string[] };
|
|
const [attachment] = await db.select().from(issueAttachments).where(eq(issueAttachments.id, result.entityRefs[0]));
|
|
const [asset] = await db.select().from(assets).where(eq(assets.id, attachment.assetId));
|
|
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
|
if (mutation === "filename") await db.update(assets).set({ originalFilename: "different.txt" }).where(eq(assets.id, asset.id));
|
|
if (mutation === "size") await db.update(assets).set({ byteSize: asset.byteSize + 1 }).where(eq(assets.id, asset.id));
|
|
if (mutation === "hash") await db.update(assets).set({ sha256: "0".repeat(64) }).where(eq(assets.id, asset.id));
|
|
if (mutation === "origin") await db.update(issueAttachments).set({ originatingRunId: null }).where(eq(issueAttachments.id, attachment.id));
|
|
if (mutation === "missing receipt" || mutation === "wrong operation") {
|
|
const changed = structuredClone(run.resultJson!);
|
|
const receipts = changed.semanticToolReceipts as Record<string, { operationId: string }>;
|
|
if (mutation === "missing receipt") delete receipts[key];
|
|
else receipts[key]!.operationId = "report_progress";
|
|
await db.update(heartbeatRuns).set({ resultJson: changed }).where(eq(heartbeatRuns.id, runId));
|
|
}
|
|
try {
|
|
await expect(nativeCompletionFeedback(db, runId, doneReport([`deliverable:${attachment.id}`])))
|
|
.rejects.toThrow(/current run|this run/);
|
|
} finally {
|
|
await db.update(assets).set({ originalFilename: asset.originalFilename, byteSize: asset.byteSize, sha256: asset.sha256 }).where(eq(assets.id, asset.id));
|
|
await db.update(issueAttachments).set({ originatingRunId: attachment.originatingRunId }).where(eq(issueAttachments.id, attachment.id));
|
|
await db.update(heartbeatRuns).set({ resultJson: run.resultJson }).where(eq(heartbeatRuns.id, runId));
|
|
}
|
|
});
|
|
});
|