mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
## Thinking Path > - Paperclip manages AI agents and their work. > - The experimental Runner owns provider processes and durable sessions. > - Pi needs working task execution and human controls. > - The five-PR stack must preserve changes already on master. > - Each layer now carries the complete integrated source for a safe sequential fallback. > - This PR belongs to native GitHub stack #15602, ending at #14956. ## Linked Issues or Issue Description Refs #14436, #14631, #14743 and #14956. Ship Pi 1.0 through the experimental Paperclip Runner. The five PRs are #14921, #14922, #14923, #14924 and #14956. The user authorized the complete merge after checks pass. Existing `pi_local` execution is unchanged. Accounting and wider provider/platform qualification remain deferred. ## What Changed - Recover missing final replies after workspace finalization changes owners, using accepted-turn evidence without rerunning work or granting external-chat publication. - Preserve the admitted Pi instruction root across warm runs, while retaining changed-root rejection. - Give Pi a bounded 15-second default shutdown grace so stop, drain acknowledgement and durable suspension can complete. Explicit deadlines and other providers retain their existing behavior. - Integrate the Pi 1.0 runtime and master contracts. - Use Pi profile 22. Preserve explicit caller-selected models and exact native thinking levels. Keep Pi's wrapper, helper, extension and question/control behavior unchanged from the qualified profile-19 runtime. - Preserve master's Dot lifecycle and consent fields, configured task environment, status guards and current Codex/Claude dependency versions. Cursor stays qualified. Copilot stays pending; profile 17 binds the changed shared protocol validation sources. - Exclude general AWS IAM credentials from Pi static/custom provider bindings and selected task projections; preserve the provider-scoped Bedrock bearer key. Profile 21 is retained as historical provenance. Rust and cloud install probes use the current declaration. - Patch bundled brace-expansion 5.0.9 to the exact official 5.0.12 payload. Pin the patch and complete runtime closures. Include the patch in normal installed setup tooling. Keep the upstream Pi shrinkwrap as provenance and permit only this exact security correction. - Include current attestation files in the Docker build context. Keep the repository lockfile unchanged from master. CI and private image builds resolve manifest changes before their frozen installation. ## Verification - Full local `pnpm -r typecheck` passes, including Runner Rust, server and UI. Focused integration checks pass: 194 Runner admission/environment tests, 63 profile/credential tests with one expected skip, 152 Dot/UI configuration tests, and Pi transcript/notice tests. - Full local `pnpm build` passes on the final source. - Fresh final-source checks pass: all 698 Rust workspace tests (32 binaries), 156 credential/profile/controller tests with one expected skip, Runner TypeScript typecheck, and 20 package/setup/sandbox tests. - The profile-21 Pi materializer passes on the native host with the official pinned Node 24.21.0 and its npm. It verifies all 150 locked packages, the patched dependency and the exact closure. Setup/package bundle tests and UI token gates pass. - The old hashes were reproduced for all three supported targets before calculating the patched graph. New closure hashes are darwin-arm64 `282022db10150c6632b3444df421342e7d534bdf5d5fb1097a2e79d0625a2bcf`, darwin-x64 `64e251e19009f755c0b04f73ce2138246faab71a961b0f13d75ebfcc34bef12e`, and linux-x64 `713b1fdff42fb56a1518bdc084f181d70bee8ebadc3e4b1d76321ed9108c8410`. Independent native platform execution is separate from graph identity reproduction. - Historical cloud qualification remains unchanged: all seven core cases pass on shipping source `10dc43c9ec65d88c2f782d62afb296d09494f215`, harness `1a4408a48cfb5a1f094a311141c257c92cd7a893`, image `sha256:5b3a775b383591bda1b0c1889e509acc70ce7f37c53f09733c81d59037f02280`, and accepted Sonnet 4.6/low fixture. All 215 canonical files and all seven cleanup checks pass independent verification. These are profile-19 results and are not relabeled as fresh profile-22 runs. - Current Pi digest: `sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9`. [The readiness plan](https://github.com/paperclipai/paperclip/blob/codex/pi-production-readiness/doc/plans/2026-10-02-pi-production-readiness.md) preserves campaign and failed-attempt provenance. - Merge only after every PR's current-head CI and fresh review pass. Linux CI covers the full suites, build and browser tests. The local embedded Postgres API-authority suite cannot start on this macOS/Node 26 host, so Linux CI must confirm that suite. ### Fresh profile-22 core qualification — 2026-10-08 All seven accepted core cases pass canonically on Pi profile 22, with `openrouter/anthropic/claude-sonnet-4.6` and native-confirmed low thinking. This model is a fixture; production accepts the caller's explicit Pi provider/model. Runtime/install source: `3241a992f2a7703e59e97ed0fd3e5d6405de4401`. Frozen accepted harness: `1a4408a48cfb5a1f094a311141c257c92cd7a893`. Immutable cloud image: `ghcr.io/paperclipai/paperclip-daytona-runner@sha256:506f22db7edd78f37c0c40bec1cc084af1850455026dbf467194bfbb8fcef141`. Pi digest: `sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9`. [Hosted Linux image and clean-install verification](https://github.com/paperclipai/paperclip/actions/runs/37868328023) passes, including all 20 source-bound archives, normal CLI/Pi setup, companion import and the production pack reader. This exact installation source includes the latest master integration and the corrected Pi warm instruction-root fence. Full local typecheck/build and current-head hosted CI verify the final stack. All 13 focused real-root regressions pass. The full local executor suite passed 662 tests; 15 database tests could not start the Mac embedded PostgreSQL service. Hosted Linux CI passes the full required verification and E2E checks. These fresh results keep their own source identity; profile-19 results remain historical. | Core path | Canonical campaign | Retained archive SHA-256 | | --- | --- | --- | | File edit, validation, download and Done | `pi-core22-replyfix-0-1791511228` | 23 files; `a473e8603a3dd4737863291f8d3d1e392391f0b16d433c3e0e0e9d8baf7a97b0` | | Pending question and controller restart | `pi-core22-replyfix-1-1791511376` | 33 files; `6b829c4eb74e1f32a89c692a4ae7130dbfc1c6d3cf13915effe2103d9e242c8e` | | Three-turn session/process/workspace continuity | `pi-core22-replyfix-2-1791511587` | 23 files; `7a87021f8f9a3fdd3c58bb4467f8d82c635e3ea4795d6e75f144d9aa14818df8` | | Four typed questions and browser reconnects | `pi-core22-replyfix-3-1791511881` | 42 files; `9e31755252be1f4f9cb0626c984c142d4d1ae5f5bee3a7af08444db8d12c280a` | | Plan approval and completion | `pi-core22-replyfix-4-1791512031` | 22 files; `a0383ce1aab38e7b5a25ce0e9dd3bebea5c037ebd96ae6b29dae19015da2ae2c` | | Same-turn steering and permission denial | `pi-core22-replyfix-5-1791512261` | 39 files; `c929b8c7070f0b66aedc17e65ca46e6beab1e363926ac9f7e2a75fb250f05949` | | Stop during pending permission | `pi-core22-replyfix-6-1791512390` | 33 files; `7f0a58ae0f4d5bfc76149435f4e322537089c5bd16e7ffe9b5ad71f10a621a07` | All 215 canonical files (28714587 bytes) are independently hash-verified. All seven cleanup grades pass, with no owned runtime process or temporary root after each case. Automatic retries are zero. The owned cloud host stopped normally after retention. The prior profile-22 warm attempt remains failed and separately retained: archive SHA-256 `1e54eba5ec72b50cee1534b23d1d1d4f21a090006b8a64501ba70db972abfde5`. Its original canonical classification is preserved. Diagnosis reproduced a product bug comparing an agent-files root against an unset checkpoint-only field. The fix stores the admitted physical root separately from the adopted per-run collection capability. The real-root regression fails before the fix and passes afterward, including rejection of a changed physical root. Fixture, grader, model and all seven accepted case IDs are unchanged; this fresh campaign tests final-reply publication after file registration first. The intermediate restart attempt also remains failed and retained: archive SHA-256 `5dcaefdf1d17cf4cd54fd4cf810f45e736667392339b8ce7caf08bb4e225277f`. Its original canonical classification is preserved. Pi resumed, wrote the verified answer and completed its task; exact runner suspension was proven, but idle stop consumed about 5.2s and left under 3s for the drain acknowledgement. The Pi-only default shutdown grace is now 15s, preserving a full 5s drain round trip and a finite suspension reserve. Explicit caller deadlines, other provider defaults, literal drain receipts and exact suspension identity checks remain unchanged. The timing regression fails before this correction and passes afterward; all 18 focused settlement tests and Runner typecheck pass. The final-source file attempt is also preserved as failed (`candidate_failure`), archive SHA-256 `db6767b6773ea618997927ac77bdb005a5ac81492c7b9c0ffbc900449f829bc9`. Native edit, validation, exact downloadable artifact and Done/succeeded all passed, and the exact final reply was durably recorded. A workspace recovery owner completed before the live heartbeat reached presentation, leaving that reply absent from task chat. Recovery now materializes only a completed final reply from the accepted turn of an ordinary internal Done task, preserving issue/run/contract binding, suppression, external-chat authorization and same-run deduplication. The database regression covers the generated file-preparation receipt, suppression, unapproved external continuation and replay. Server typecheck and all 49 response-selection tests pass; hosted Linux verifies the database regression because embedded PostgreSQL cannot start on this Mac. The delayed-final-answer database regression passes on [the final root-source Linux server shard](https://github.com/paperclipai/paperclip/actions/runs/37868262553/job/113628594152), alongside 1,108 passing tests. The first root Runner shard had one unchanged durable-resume test exceed its 5-second timeout; the identical top-source shard and the isolated exact test passed. One rerun of that failed job and its required aggregate passed without source or test changes. The original failed job log and the single-rerun receipt remain retained. ### October 9 merge verification Current merge head: `5a8fe63512a7166aaef5cf50065a25008aa8b44b`. All current-head checks pass, including `ci / verify` and `ci / e2e`; exact-head Greptile review is 5/5 with no unresolved threads. Current master conflicts are resolved. The user authorized the maintainer override of the code-owner review gate after these checks. The seven retained live core cases remain bound to source `3241a992f2a7703e59e97ed0fd3e5d6405de4401` and its recorded cloud image. ## Risks - The security correction changes the dependency closure and profile identity. Old sessions must reopen on the new profile. Exact identities and credential bindings fail closed. - The runner remains experimental and requires explicit selection. Legacy Pi Local is unchanged. Caller model IDs pass through; the E2E model is a fixture. - Accounting and the broad platform/provider matrix remain deferred. This merge does not publish a release or deploy a service. ## Model Used OpenAI GPT-6 through Codex assisted with reasoning, repository inspection, editing and tool use. The exact serving ID and context window are not exposed in this session. Final live qualification uses Pi 1.0.0 with `openrouter/anthropic/claude-sonnet-4.6` and native-confirmed low thinking. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1382 lines
53 KiB
TypeScript
1382 lines
53 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";
|
|
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,
|
|
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");
|
|
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId, executionContinuation: { objective: "Do not create a file. Explain the result inline." } } }).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("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));
|
|
}
|
|
});
|
|
});
|