fix(runner): require accessible requested file deliverables

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-12 21:42:47 -05:00
parent da5237f5d6
commit c4f5d4e471
9 changed files with 174 additions and 38 deletions

View File

@ -1182,3 +1182,19 @@ The active session advertises steering only when its driver supports it. A
transport method that rejects steering does not grant that capability. The
queued-message control remains mounted until the server accepts a steer request,
so a rejected last-row action keeps its message and visible error.
### Preserve work across handoff and deliver requested files
An agent handoff carries the interrupted run's authorized task history, completed
semantic actions, and available result summary to the replacement agent. The
replacement must inspect existing files and preserve completed content before
editing. Source history is still scoped to the same company and task; prior
results are untrusted evidence, not instructions or new authorization.
A requested file is complete when the user can retrieve it. Native runners must
register requested output files before reporting Done and link the resulting
attachment in their answer. Completion feedback rejects workspace-only file
references and fabricated or cross-task delivery receipts. Text answers and
accessible repository work products do not require an attachment. Publication
failure calls for continued work or a concrete blocker, not a human confirmation
that the task is complete.

View File

@ -68,6 +68,13 @@ describe("native runtime context files", () => {
);
});
it("requires requested file deliverables before completion in ordinary native tasks", () => {
const constraints = nativeTaskConstraints(runtimeInput("/bundle", "AGENTS.md")).join("\n");
expect(constraints).toContain("register_deliverable");
expect(constraints).toContain("deliverable:");
expect(constraints).toContain("download link");
});
it("marks only authoritative answered-question envelopes as resolved in the outer task", () => {
const answeredQuestion = {
interactionId: "answered-question-1",

View File

@ -100,6 +100,7 @@ export function nativeTaskConstraints(input: NativeExecutionInput): string[] {
return [
"Use only the assigned skills and provider-native tools.",
"Use Paperclip semantic tools for coordination and finalization.",
"When the requested result is a file, use register_deliverable before paperclip_finish. Compute its exact byte size and SHA-256, register the workspace-relative file, cite deliverable:<attachmentId> from the receipt as completion evidence, and include /api/attachments/<attachmentId>/content as the download link in your answer. A bare workspace filename is not a delivered result. For repository edits, cite an accessible PR or registered work product. Preserve existing work; do not upload unrelated files. If file publication fails, fix it or report the concrete blocker instead of claiming the file is delivered.",
...(answeredQuestionConstraint ? [answeredQuestionConstraint] : []),
finalResponseConstraint,
];

View File

@ -1,3 +1,4 @@
import { validateNativeDeliverableEvidence } from "./native-deliverable-feedback.js";
import { findAutomaticCompletionReviews } from "./automatic-completion-reviews.js";
import { issueService } from "../issues.js";
import { and, eq, inArray, notInArray } from "drizzle-orm";
@ -61,6 +62,7 @@ export async function nativeCompletionFeedback(
if (issue.executionRunId && issue.executionRunId !== runId) {
return "Report accepted; a newer run owns the task. Do not claim this report changed its status.";
}
await validateNativeDeliverableEvidence(db, { companyId: run.companyId, issueId: issue.id }, result);
const retiredCandidates = await findAutomaticCompletionReviews(db, issue.id);
const retiredIds = retiredCandidates.map(({ interaction }) => interaction.id);
const [interaction, approval] = await Promise.all([

View File

@ -0,0 +1,41 @@
import { and, eq } from "drizzle-orm";
import { assets, issueAttachments, type Db } from "@paperclipai/db";
import type { PrpStructuredRunResult } from "../../vendor/paperclip-runner/index.js";
/** Files cited as completed output must be reachable outside the agent workspace. */
export async function validateNativeDeliverableEvidence(
db: Db,
binding: { companyId: string; issueId: string },
result: PrpStructuredRunResult,
): Promise<void> {
if (result.reportedWorkDisposition !== "done") return;
const refs = new Set([
...result.evidence.map(({ ref }) => ref),
...result.artifacts.map(({ ref }) => ref),
...result.completionClaim.criteria.flatMap(({ evidenceRefs }) => evidenceRefs),
]);
for (const value of refs) {
const ref = value.trim();
if (ref.startsWith("deliverable:")) {
const id = ref.slice("deliverable:".length);
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
const [attachment] = uuid.test(id)
? await db.select({ id: issueAttachments.id }).from(issueAttachments)
.innerJoin(assets, and(eq(assets.id, issueAttachments.assetId), eq(assets.companyId, binding.companyId)))
.where(and(eq(issueAttachments.id, id), eq(issueAttachments.companyId, binding.companyId), eq(issueAttachments.issueId, binding.issueId)))
.limit(1)
: [];
if (!attachment) {
throw new Error("Completion cites no registered attachment on this task. Use register_deliverable for the requested file and cite deliverable:<attachmentId> from its receipt. No human completion approval was created.");
}
continue;
}
// URLs and typed durable refs are not workspace paths. Verification commands
// belong in verification; do not scan prose or upload files named by a model.
const localFile = /^(?:file:|\.{0,2}\/|[a-z]:[\\/])/iu.test(ref)
|| (!/^[a-z][a-z0-9+.-]*:/iu.test(ref) && /^[^\s]+\.[a-z0-9]{1,16}(?::\d+(?::\d+)?)?$/iu.test(ref));
if (localFile) {
throw new Error("Completion cites a workspace-only file that the user cannot download. Before finishing, use register_deliverable for requested file outputs and cite deliverable:<attachmentId> from the receipt, with /api/attachments/<attachmentId>/content as the download link. For repository changes, cite an accessible PR or registered work product instead. No human completion approval was created.");
}
}
}

View File

@ -1,3 +1,5 @@
import type { PrpStructuredRunResult } from "../../vendor/paperclip-runner/index.js";
import { nativeCompletionFeedback } from "./native-completion-feedback.js";
import { createHash } from "node:crypto";
import { spawn } from "node:child_process";
import { once } from "node:events";
@ -14,7 +16,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
@ -116,6 +118,7 @@ describe("native runner file handoff", () => {
overrides: Partial<{
companyId: string;
executionTargetKind: "local" | "remote";
readRemoteWorkspaceFile: (input: { contentRef: string; byteSize: number; sha256: string }) => Promise<Buffer>;
}> = {},
) {
return new PaperclipRunnerToolAuthority(db, {
@ -125,6 +128,7 @@ describe("native runner file handoff", () => {
runId,
workspaceRoot,
executionTargetKind: overrides.executionTargetKind ?? "local",
readRemoteWorkspaceFile: overrides.readRemoteWorkspaceFile,
storage: createStorageService(
createLocalDiskStorageProvider(storageRoot),
),
@ -179,6 +183,28 @@ describe("native runner file handoff", () => {
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: [],
};
}
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([])))
.resolves.toContain("Completion report accepted");
await expect(nativeCompletionFeedback(db, runId, doneReport(["https://example.com/report.pdf"])))
.resolves.toContain("Completion report accepted");
});
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 });
@ -228,6 +254,19 @@ describe("native runner file handoff", () => {
entityRefs: first.entityRefs,
});
await expect(nativeCompletionFeedback(db, runId, doneReport([`deliverable:${first.entityRefs[0]}`])))
.resolves.toContain("Completion report accepted");
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)
@ -353,6 +392,22 @@ describe("native runner file handoff", () => {
).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("stages only exact wake-bound inbound bytes without exposing an API credential", async () => {
const storage = createStorageService(
createLocalDiskStorageProvider(storageRoot),

View File

@ -35,6 +35,8 @@ import type { StorageService } from "../../storage/types.js";
import { readProcessStartedAt } from "../hot-restart.js";
import { issueService } from "../issues.js";
export type RemoteWorkspaceFileReader = (input: Pick<NativeRunnerFileHandoffInput, "contentRef" | "byteSize" | "sha256">) => Promise<Buffer>;
export interface NativeRunnerFileHandoffBinding {
readonly companyId: string;
readonly issueId: string;
@ -42,6 +44,8 @@ export interface NativeRunnerFileHandoffBinding {
readonly agentId: string;
readonly workspaceRoot: string;
readonly executionTargetKind: "local" | "remote";
/** Server-bound reader. Never supplied by the model or a request body. */
readonly readRemoteWorkspaceFile?: RemoteWorkspaceFileReader;
}
export interface NativeRunnerFileHandoffInput {
@ -286,8 +290,43 @@ async function readVerifiedWorkspaceFile(
binding: NativeRunnerFileHandoffBinding,
input: NativeRunnerFileHandoffInput,
): Promise<VerifiedWorkspaceFile> {
if (binding.executionTargetKind !== "local") {
throw new Error("paperclip_runner_file_handoff_remote_unsupported");
const filename = requiredText(input.filename, "filename", 500);
if (path.basename(filename) !== filename || filename.includes("\\")) {
throw new Error("paperclip_runner_file_handoff_invalid_filename");
}
const title = requiredText(input.title, "title", 500);
if (
!Number.isSafeInteger(input.byteSize) ||
input.byteSize <= 0 ||
input.byteSize > MAX_ATTACHMENT_BYTES
) {
throw new Error("paperclip_runner_file_handoff_size_denied");
}
const expectedSha256 = input.sha256.trim().toLowerCase();
if (!/^[a-f0-9]{64}$/u.test(expectedSha256)) {
throw new Error("paperclip_runner_file_handoff_invalid_sha256");
}
const contentType = normalizeUploadAttachmentContentType({
contentType: requiredText(input.contentType, "content_type", 200),
originalFilename: filename,
isAllowedContentType,
});
if (!isAllowedContentType(contentType)) {
throw new Error("paperclip_runner_file_handoff_content_type_denied");
}
if (binding.executionTargetKind === "remote") {
if (!binding.readRemoteWorkspaceFile) throw new Error("paperclip_runner_file_handoff_remote_unsupported");
const contentRef = requiredText(input.contentRef, "content_ref", 2_000);
if (path.posix.isAbsolute(contentRef) || /^[a-z][a-z0-9+.-]*:/iu.test(contentRef)
|| contentRef.includes("\\") || path.posix.normalize(contentRef) === ".."
|| path.posix.normalize(contentRef).startsWith("../")) {
throw new Error("paperclip_runner_file_handoff_path_denied");
}
const body = await binding.readRemoteWorkspaceFile({ contentRef, byteSize: input.byteSize, sha256: expectedSha256 });
if (body.length !== input.byteSize) throw new Error("paperclip_runner_file_handoff_size_denied");
if (createHash("sha256").update(body).digest("hex") !== expectedSha256) throw new Error("paperclip_runner_file_handoff_hash_mismatch");
return { body, contentType, filename, sha256: expectedSha256, title };
}
const workspaceRoot = await realpath(
@ -315,31 +354,6 @@ async function readVerifiedWorkspaceFile(
throw new Error("paperclip_runner_file_handoff_path_denied");
}
const filename = requiredText(input.filename, "filename", 500);
if (path.basename(filename) !== filename || filename.includes("\\")) {
throw new Error("paperclip_runner_file_handoff_invalid_filename");
}
const title = requiredText(input.title, "title", 500);
if (
!Number.isSafeInteger(input.byteSize) ||
input.byteSize <= 0 ||
input.byteSize > MAX_ATTACHMENT_BYTES
) {
throw new Error("paperclip_runner_file_handoff_size_denied");
}
const expectedSha256 = input.sha256.trim().toLowerCase();
if (!/^[a-f0-9]{64}$/u.test(expectedSha256)) {
throw new Error("paperclip_runner_file_handoff_invalid_sha256");
}
const contentType = normalizeUploadAttachmentContentType({
contentType: requiredText(input.contentType, "content_type", 200),
originalFilename: filename,
isAllowedContentType,
});
if (!isAllowedContentType(contentType)) {
throw new Error("paperclip_runner_file_handoff_content_type_denied");
}
const handle = await open(
canonicalCandidate,
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),

View File

@ -14,8 +14,8 @@ export type NativeToolExecutionTargetKind = "local" | "remote";
* Persisted provider threads retain their dynamic-tool declarations. This
* fingerprint is part of checkpoint compatibility and must change whenever
* the server-authorized native tool definitions or advertisement policy
* changes. The execution target is included because register_deliverable is
* intentionally absent for remote workspaces.
* changes. The execution target remains part of compatibility because local and
* remote files use different server-bound readers.
*/
export function nativeToolContractFingerprintForTarget(
executionTargetKind: NativeToolExecutionTargetKind,
@ -23,7 +23,7 @@ export function nativeToolContractFingerprintForTarget(
return `sha256:${createHash("sha256")
.update(
JSON.stringify({
schema: "paperclip.native-tool-contract.v12",
schema: "paperclip.native-tool-contract.v13",
executionTargetKind,
advertisementPolicy: {
// Direct provider threads retain declarations from thread/start.
@ -32,17 +32,15 @@ export function nativeToolContractFingerprintForTarget(
readCurrentWakeComments: "always_advertised_binding_gated.v1",
historicalChatAttachments:
"always_advertised_conversation_binding_gated.v1",
registerDeliverable: "local_workspace_only.v1",
registerDeliverable: "verified_local_or_remote_workspace.v2",
readChatAttachment: "always_advertised_run_scope_local_staging.v1",
structuredHumanInput:
"always_advertised_run_issue_agent_binding_gated_current_task_description.v2",
semanticCompletion: "finish_response_wake_user_facing_summary.v3",
semanticCompletion: "finish_accessible_deliverable_evidence.v4",
connectorTools: "assigned_resources_and_pinned_skill_bundle.v1",
},
tools: [
...(executionTargetKind === "local"
? [{ name: "register_deliverable", version: 1 }]
: []),
{ name: "register_deliverable", version: 2 },
{
name: "read_current_wake_comments",
semanticContract: "paperclip.server-current-wake-comments.v1",

View File

@ -43,7 +43,7 @@ import { issueService } from "../issues.js";
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
import { persistActivity, publishActivity } from "../activity-log.js";
import { captureRunIdentity } from "../run-identity.js";
import { prepareNativeRunnerFileHandoff } from "./native-runner-file-handoff.js";
import { prepareNativeRunnerFileHandoff, type RemoteWorkspaceFileReader } from "./native-runner-file-handoff.js";
import { MAX_ATTACHMENT_BYTES } from "../../attachment-types.js";
import {
READ_CURRENT_WAKE_COMMENTS_TOOL_DEFINITION,
@ -93,6 +93,7 @@ type Binding = {
workMode?: "standard" | "planning" | "ask";
workspaceRoot?: string;
executionTargetKind?: "local" | "remote";
readRemoteWorkspaceFile?: RemoteWorkspaceFileReader;
currentWakeComments?: CurrentWakeCommentsBinding;
chatAttachmentReadScope?: NativeChatAttachmentReadScope;
enqueueWakeup?: (agentId: string, options: {
@ -146,7 +147,7 @@ export class PaperclipRunnerToolAuthority {
descriptor.allowedModes.includes(workMode) &&
(descriptor.operationId !== "register_deliverable" ||
(Boolean(this.binding.workspaceRoot) &&
(this.binding.executionTargetKind ?? "local") === "local")),
((this.binding.executionTargetKind ?? "local") === "local" || Boolean(this.binding.readRemoteWorkspaceFile)))),
).map((descriptor) => ({
name: descriptor.operationId,
description:
@ -835,6 +836,7 @@ export class PaperclipRunnerToolAuthority {
agentId: this.binding.agentId,
workspaceRoot,
executionTargetKind: this.binding.executionTargetKind ?? "local",
readRemoteWorkspaceFile: this.binding.readRemoteWorkspaceFile,
},
deliverable: {
filename: typeof input.filename === "string" ? input.filename : "",