fix(runner): require accessible requested file deliverables
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
da5237f5d6
commit
c4f5d4e471
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 : "",
|
||||
|
|
|
|||
Loading…
Reference in New Issue