From c4f5d4e4716d9b2bbcf45ca0f41ce760b93e2b8f Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 12 Sep 2026 21:42:47 -0500 Subject: [PATCH] fix(runner): require accessible requested file deliverables Co-Authored-By: Paperclip --- doc/execution-semantics.md | 16 +++++ .../src/backends/runtime-context.test.ts | 7 ++ .../src/backends/runtime-context.ts | 1 + .../native-completion-feedback.ts | 2 + .../native-deliverable-feedback.ts | 41 +++++++++++ .../native-runner-file-handoff.test.ts | 57 +++++++++++++++- .../native-runner-file-handoff.ts | 68 +++++++++++-------- .../native-runtime/native-session-resume.ts | 14 ++-- .../paperclip-runner-tool-authority.ts | 6 +- 9 files changed, 174 insertions(+), 38 deletions(-) create mode 100644 server/src/services/native-runtime/native-deliverable-feedback.ts diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 180bd820b6..f012209580 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -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. diff --git a/packages/paperclip-runner/src/backends/runtime-context.test.ts b/packages/paperclip-runner/src/backends/runtime-context.test.ts index fb41bbc799..ca0292b355 100644 --- a/packages/paperclip-runner/src/backends/runtime-context.test.ts +++ b/packages/paperclip-runner/src/backends/runtime-context.test.ts @@ -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", diff --git a/packages/paperclip-runner/src/backends/runtime-context.ts b/packages/paperclip-runner/src/backends/runtime-context.ts index 5a4940b479..1735b9e560 100644 --- a/packages/paperclip-runner/src/backends/runtime-context.ts +++ b/packages/paperclip-runner/src/backends/runtime-context.ts @@ -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: from the receipt as completion evidence, and include /api/attachments//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, ]; diff --git a/server/src/services/native-runtime/native-completion-feedback.ts b/server/src/services/native-runtime/native-completion-feedback.ts index ecb91ed744..2b562d9d2b 100644 --- a/server/src/services/native-runtime/native-completion-feedback.ts +++ b/server/src/services/native-runtime/native-completion-feedback.ts @@ -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([ diff --git a/server/src/services/native-runtime/native-deliverable-feedback.ts b/server/src/services/native-runtime/native-deliverable-feedback.ts new file mode 100644 index 0000000000..cc9364088b --- /dev/null +++ b/server/src/services/native-runtime/native-deliverable-feedback.ts @@ -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 { + 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: 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: from the receipt, with /api/attachments//content as the download link. For repository changes, cite an accessible PR or registered work product instead. No human completion approval was created."); + } + } +} diff --git a/server/src/services/native-runtime/native-runner-file-handoff.test.ts b/server/src/services/native-runtime/native-runner-file-handoff.test.ts index 577452e4a1..3197134d11 100644 --- a/server/src/services/native-runtime/native-runner-file-handoff.test.ts +++ b/server/src/services/native-runtime/native-runner-file-handoff.test.ts @@ -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; }> = {}, ) { 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), diff --git a/server/src/services/native-runtime/native-runner-file-handoff.ts b/server/src/services/native-runtime/native-runner-file-handoff.ts index 259c20de0b..bb91506db0 100644 --- a/server/src/services/native-runtime/native-runner-file-handoff.ts +++ b/server/src/services/native-runtime/native-runner-file-handoff.ts @@ -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) => Promise; + 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 { - 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), diff --git a/server/src/services/native-runtime/native-session-resume.ts b/server/src/services/native-runtime/native-session-resume.ts index 3861496116..44d9e3c6c7 100644 --- a/server/src/services/native-runtime/native-session-resume.ts +++ b/server/src/services/native-runtime/native-session-resume.ts @@ -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", diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index a32a8e2560..f0262f3a43 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -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 : "",