mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Runner support suite (`pnpm test:e2e:runner:unit`) tests the code behind paid Runner qualification: workflow security policy, evidence oracles, cost receipts and controller recovery. > - PR CI does not run this suite. Only a manual `docker-runner-check.yml` run does. > - So deliberate changes on master broke its tests, and nobody saw it. There were 12–13 failures on Nov 3 and 16 on Nov 6. > - This pull request finds the master change behind each failure and fixes the side that is wrong. No security check becomes weaker. > - The benefit is that the suite passes on master again (with #19527), so it can become a required check. ## Linked Issues or Issue Description Refs #18516, #18625, #18983, #18325, #19265, #18994, #17009, #19195. Companion PR: #19527 (the one workflow fix). **What happened?** At master `543aa4d331`, `pnpm test:e2e:runner:unit` had 16 failures in 8 files. At `98ebeefbc9` I saw 15 in 7 files. The two embedded-Postgres files passed in every run I made, and the 256MiB packaging test timed out once. **Expected behavior** The suite passes on master. Each test still checks the rule it was written for. **Steps to reproduce** Run `pnpm install`, then `pnpm test:e2e:runner:unit` on master. ## What Changed Each failure, its cause, and the fix: - **`workflow-security.test.ts`: chat catalog workflows hold provider keys and use `runner-e2e-private-paid`.** This is a deliberate policy. #18516 admitted guarded, pinned-source chat TestOps cells, with special checks. #18625 removed the pilot workflows and their checks. It did not move the checks to the full-catalog workflows from #18542 and #18647, which use the same trust model. The fix adds `chat-catalog-testops.yml` and `chat-catalog-testops-case.yml` to the provider-key allowlist. It also brings back the #18516 checks, adapted to the caller plus reusable-workflow layout: - The cell is `workflow_call` only. Its single caller is the master-only dispatcher. - The trusted guard runs before the pinned candidate checkout. `verify-selection` runs before each provider key. - The paid job never sees `ALLURE_TOKEN`. The report job runs in `runner-e2e-private-history` and has no candidate checkout. The upload job has no private environment and no secret except `ALLURE_TOKEN`. - Provider keys appear only in the two caller jobs. Those jobs need `authorize, select, build` and pass the guard's `target_sha`. - I mutation-tested this: an `ALLURE_TOKEN` in the paid job fails, and a second caller workflow fails. - **`workflow-security.test.ts`: `ref: ${{ github.sha }}` 6 vs 4, `persist-credentials: false` 12 vs 10.** #18983 added two trusted TestOps jobs (`testops_selection`, `publish_testops`). Both check out reviewed code next to the TestOps token. This is the safe direction. The counts are updated. A new check makes sure both jobs check out only `github.sha`, do not install the candidate lockfile, and use no secret except `ALLURE_TOKEN`. - **`pi-file-evidence.test.ts` (4 tests): "one observed provider validation execution".** #19265 deliberately changed the ACPX tool normalizer to keep the latest concrete title. Pi's shell call now goes from `bash` to the exact command. The ACPX sidecar uses this normalizer, so the oracle would also reject real Pi runs. The oracle now accepts one change only, from `bash` to the exact validation command, with no change back. It also counts every completed `execute` call, whatever its name. This is stricter than before: a refined title cannot hide a second shell command. New mutations cover a regressed name, a different command, and an extra call that has its own command name. The retained 44-row real stream still passes. - **`pi-controls-evidence.test.ts`: Pi profile 37.** #18325 deliberately reset Pi to profile 37. The pin and the `pi-native` suite hash are updated. I checked that the old hash comes back exactly when the version is set to 36. So the version is the only change. - **`controller-outage.test.ts`: 1 continuity cell, not 2.** #18994 deliberately limited this lane to providers with managed warm sessions. The test now expects exactly `runner-codex`. - **`operating-receipts.test.ts` (3 tests): "Operating overhead allowance needs review".** This is a time bomb from #17009. The fixture allowance was valid from Oct 1 until Nov 1, 2026. `admitAttempt` reads the real clock. The allowance window is limited to 31 days, so a longer fixture is not possible. These tests now pin only `Date` to the fixture time. Timers and the filesystem stay real. I ran the full suite with the clock moved to Dec 15, 2026, and found no other date-bound failures. - **New file `private-trust-workflow-security.test.ts`.** The private archive trust test, the chat catalog checks and the full-stack TestOps job checks moved here from `workflow-security.test.ts`. That file would have crossed 1,000 lines. It is now 970 lines. The checks did not change. - **`remote-native-bootstrap.test.ts`: cannot load `@paperclipai/plugin-sdk`.** Since #19195, server code under test imports the built plugin SDK. `run-unit.mjs` now runs `scripts/ensure-plugin-build-deps.mjs` first, as `test:run:general` already does. This step is cached. - **`private-framework-report.test.ts`: 256MiB packaging test timed out.** It takes about 10s alone and more than 30s with the whole suite running in parallel. It now has a 120s budget. - **`controller-database.test.ts`, `controller-recovery-observer.integration.test.ts`: no change.** Embedded Postgres started and both files passed in all my runs. The earlier failures look like local resource problems. ## Verification - Combined check (this branch plus #19527, on master `5d25117ffa`): `pnpm test:e2e:runner:unit` — Vitest 317 files passed (6,815 tests, 3 skipped), node:test 373 passed, 0 failed. It took 63s on a 10-core Mac. - `pnpm test:e2e:runner:typecheck` — passed. - This branch alone fails only the two pnpm-setup tests that #19527 fixes. - Focused files: `pi-file-evidence` 75 passed, `pi-controls-evidence` 72 passed, `operating-receipts` 12 passed, `workflow-security` 14 of 16 passed (the other 2 pass with #19527), `private-trust-workflow-security` 3 passed, `remote-native-bootstrap` 71 passed. ## Risks - Low risk. Only test files, one evidence oracle under `tests/runner-e2e/`, and the suite launcher change. No product code changes. - The Pi oracle change affects how paid Pi file qualification is graded. It accepts the old and new title shapes, and it is stricter about extra shell calls. A live Pi qualification run would confirm it on real Rust-normalized events. I did not run one, because it is paid. - The chat catalog policy is the same as #18516. Under that design, the pinned candidate source runs in `runner-e2e-private-paid` with `id-token: write`. The guards above limit who can start it and which source runs. This PR does not change that trust model. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), by Anthropic, in Claude Code with tool use (shell, file edits, git, GitHub CLI). ## 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 #` / `Refs #` 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
282 lines
21 KiB
TypeScript
282 lines
21 KiB
TypeScript
import { isRollingWebDeployment } from "./controller-rolling-deployment.js";
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { createHash } from "node:crypto";
|
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { collectPiFileEvidence, gradePiCopyback, gradePiFileEvidence, piFileContract, piFilePrompt, seedPiFile } from "./pi-file-evidence.js";
|
|
import { canonicalProviderEventsFromAcpxRuntimeEvent, createAcpxToolEventNormalizer } from "../../packages/paperclip-runner/src/provider-events.js";
|
|
import { safeAcpxLocations } from "../../packages/paperclip-runner/src/drivers/acpx/safe-locations.js";
|
|
import { classifyFailure } from "./failure-classifier.js";
|
|
import { runnerMatrix } from "./catalog.js";
|
|
|
|
function fixture() {
|
|
const c = piFileContract("fixture"), attachmentId = "12345678-1234-1234-1234-123456789abc";
|
|
const normalize = createAcpxToolEventNormalizer();
|
|
const tool = (seq: number, executionId: string, name: string, operation: string,
|
|
phase: "start" | "progress" | "end", target: string | null, text = "", rawOutput: unknown = undefined) => {
|
|
// Sanitized shape of the retained Pi stream: missing opening location,
|
|
// later resolved edit arguments, and a bash opening name refined to the command.
|
|
// This exercises TS normalization/projection, not the Rust implementation.
|
|
const event = normalize({ type: "tool_call", toolCallId: executionId,
|
|
tag: phase === "start" ? "tool_call" : "tool_call_update", title: name, kind: operation,
|
|
status: phase === "end" ? "completed" : phase === "start" ? "pending" : "in_progress",
|
|
locations: safeAcpxLocations(target ? [{ path: `/workspace/${target}` }] : [], "/workspace", operation, name),
|
|
text, rawOutput });
|
|
const canonical = canonicalProviderEventsFromAcpxRuntimeEvent(event, executionId, "turn")[0]!;
|
|
return { companyId: "company", runId: "run", seq, protocolSchemaVersion: 1, eventType: canonical.eventType,
|
|
sourceInstanceId: "runner", sourceSeq: seq, sourceEventId: `runner:run:${seq}`,
|
|
payload: { prpEvent: { schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", sourceInstanceId: "runner",
|
|
sourceSeq: seq, sourceEventId: `runner:run:${seq}`, runId: "run", turnId: "turn", normalizedSessionId: "session", ...canonical } } };
|
|
};
|
|
return {
|
|
environment: "local" as const, environmentId: "environment", nonce: "fixture", companyId: "company", issueId: "issue", agentId: "agent", attachmentId,
|
|
seed: { schema: "paperclip.e2e.pi-file-seed.v1", filename: c.filename,
|
|
beforeSha256: createHash("sha256").update("ready-fixture\n").digest("hex"), byteSize: Buffer.byteLength(c.before) },
|
|
workspaceBytes: Buffer.from(c.after), downloadedBytes: Buffer.from(c.after),
|
|
run: { id: "run", companyId: "company", agentId: "agent", runtimeMode: "native", nativeSessionId: "session", runnerInstanceId: "runner", status: "succeeded", resultJson: { semanticToolReceipts: {
|
|
publish: { operationId: "register_deliverable", input: { filename: c.filename, contentRef: c.filename, contentType: "text/plain", byteSize: c.byteSize, sha256: c.sha256 },
|
|
result: { disposition: "applied", commandId: `deliverable-prepared:${attachmentId}`, entityRefs: [attachmentId, "product", "comment"] } },
|
|
} } },
|
|
events: [tool(1, "edit", "edit", "edit", "start", null),
|
|
tool(2, "edit", "edit", "edit", "progress", c.filename),
|
|
tool(3, "edit", "edit", "edit", "end", c.filename),
|
|
tool(4, "validate", "bash", "execute", "start", null, "bash (pending): [terminal] validate"),
|
|
tool(5, "validate", c.validationCommand, "execute", "progress", null, `${c.validationCommand} (in_progress): ${c.validationCommand}`),
|
|
tool(6, "validate", c.validationCommand, "execute", "end", null, "", {
|
|
content: [{ type: "text", text: `${c.validationMarker}\n` }],
|
|
structuredContent: { output: `${c.validationMarker}\n`, truncated: false, exit_code: 0, wall_time_seconds: 0 },
|
|
})],
|
|
attachments: [{ id: attachmentId, companyId: "company", issueId: "issue", originatingRunId: "run", createdByAgentId: "agent",
|
|
originalFilename: c.filename, contentType: "text/plain", byteSize: c.byteSize, sha256: c.sha256 }],
|
|
activity: [{ action: "issue.attachment_added", companyId: "company", runId: "run", actorId: "agent", entityId: "issue",
|
|
details: { attachmentId, source: "paperclip_runner_protocol" } }],
|
|
};
|
|
}
|
|
function changeOutput(f: ReturnType<typeof fixture>, change: (output: any) => void) {
|
|
const p = f.events[5]!.payload.prpEvent.payload, output = JSON.parse(p.output as string);
|
|
change(output); p.output = JSON.stringify(output);
|
|
}
|
|
describe("Pi edit, validation and public artifact oracle", () => {
|
|
it("calibrates against the sanitized 44-row actual native stream without regrading the paid attempt", async () => {
|
|
const retained = JSON.parse(await readFile(new URL("./fixtures/pi-file-evidence-actual-stream.json", import.meta.url), "utf8"));
|
|
const f = fixture();
|
|
expect(retained.events).toHaveLength(44);
|
|
f.events = retained.events.map((x: any) => ({ companyId: "company", runId: "run", seq: x.seq,
|
|
protocolSchemaVersion: 1, eventType: x.eventType, sourceInstanceId: "runner", sourceSeq: x.sourceSeq,
|
|
sourceEventId: `runner:run:${x.sourceSeq}`, payload: { prpEvent: {
|
|
schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", sourceInstanceId: "runner",
|
|
sourceSeq: x.sourceSeq, sourceEventId: `runner:run:${x.sourceSeq}`, runId: "run", turnId: "turn",
|
|
normalizedSessionId: "session", eventType: x.eventType, payload: x.payload,
|
|
} } }));
|
|
expect(gradePiFileEvidence(f).verification).toMatchObject({ nativeStructuredExitCode: 0, typedExitCode: null });
|
|
});
|
|
it("accepts actual correlated lifecycles, independent bytes and a run-bound registered download", () => {
|
|
const result = gradePiFileEvidence(fixture());
|
|
expect(result.passed).toBe(true);
|
|
expect(result.diff.text).toContain("-ready-fixture\n+verified-fixture\n");
|
|
expect(result.limits.join(" ")).toContain("not attested");
|
|
expect(result.verification.providerExecutionStatus).toBe("completed");
|
|
expect(result.verification.command).toBe(piFileContract("fixture").validationCommand);
|
|
expect(result.verification).toMatchObject({ commandEvidence: "correlated_native_progress",
|
|
exitEvidence: "native_structured_output_receipt", nativeStructuredExitCode: 0, typedExitCode: null });
|
|
expect(fixture().events.map(e => e.payload.prpEvent.payload.target)).toEqual([null, "extended-fixture.txt", "extended-fixture.txt", null, null, null]);
|
|
// The runner keeps the latest concrete ACP title (#19265): bash, then the exact command.
|
|
expect(fixture().events.slice(3).map(e => e.payload.prpEvent.payload.name)).toEqual(["bash",
|
|
piFileContract("fixture").validationCommand, piFileContract("fixture").validationCommand]);
|
|
expect(result.verification.nativeFileAttribution).toBe("workspace_relative_display_target");
|
|
});
|
|
it.each(["before", "after"])("rejects an extra metadata bash call %s validation despite correct file and download bytes", placement => {
|
|
const f = fixture();
|
|
const metadata = structuredClone(f.events.slice(3));
|
|
for (const row of metadata) row.payload.prpEvent.payload.executionId = "metadata";
|
|
metadata[1]!.payload.prpEvent.payload.progress = "wc -c extended-fixture.txt (in_progress): wc -c extended-fixture.txt";
|
|
// A refined title names the extra call by its own command; it still counts.
|
|
metadata[1]!.payload.prpEvent.payload.name = metadata[2]!.payload.prpEvent.payload.name = "wc -c extended-fixture.txt";
|
|
metadata[2]!.payload.prpEvent.payload.output = JSON.stringify({
|
|
content: [{ type: "text", text: "17 extended-fixture.txt\n" }],
|
|
structuredContent: { output: "17 extended-fixture.txt\n", truncated: false, exit_code: 0 },
|
|
});
|
|
if (placement === "before") f.events.splice(3, 0, ...metadata);
|
|
else f.events.push(...metadata);
|
|
for (const [index, row] of f.events.entries()) {
|
|
row.seq = row.sourceSeq = row.payload.prpEvent.sourceSeq = index + 1;
|
|
row.sourceEventId = row.payload.prpEvent.sourceEventId = `runner:run:${index + 1}`;
|
|
}
|
|
expect(() => gradePiFileEvidence(f)).toThrow("one observed provider validation execution");
|
|
});
|
|
const mutations: Array<[string, (f: ReturnType<typeof fixture>) => void]> = [
|
|
["absent seed", f => { f.seed = {} as any; }],
|
|
["wrong final bytes", f => { f.workspaceBytes = Buffer.from("wrong\n"); }],
|
|
["final-only write", f => { f.events[0]!.payload.prpEvent.payload.name = "write"; f.events[2]!.payload.prpEvent.payload.name = "write"; }],
|
|
["missing edit", f => { f.events = f.events.slice(3); }],
|
|
["missing validation", f => { f.events = f.events.slice(0, 3); }],
|
|
["echo-only validation", f => { f.events[3]!.payload.prpEvent.payload.name = `echo ${piFileContract("fixture").validationMarker}`; f.events[5]!.payload.prpEvent.payload.name = f.events[3]!.payload.prpEvent.payload.name; }],
|
|
["validation name regresses to bash", f => { f.events[5]!.payload.prpEvent.payload.name = "bash"; }],
|
|
["validation renamed to another command", f => { f.events[4]!.payload.prpEvent.payload.name = f.events[5]!.payload.prpEvent.payload.name = "node -e 'console.log(1)'"; }],
|
|
["failed validation", f => { f.events[5]!.payload.prpEvent.payload.status = "failed"; }],
|
|
["unfinished validation", f => { f.events.pop(); }],
|
|
["unrelated validation turn", f => { f.events[3]!.payload.prpEvent.turnId = "other"; f.events[5]!.payload.prpEvent.turnId = "other"; }],
|
|
["foreign native session", f => { f.run.nativeSessionId = "other"; }],
|
|
["foreign native producer", f => { f.run.runnerInstanceId = "other"; }],
|
|
["foreign event run", f => { f.events[2]!.payload.prpEvent.runId = "other"; }],
|
|
["duplicate event", f => { f.events.push(structuredClone(f.events[5]!)); }],
|
|
["missing tool start", f => { f.events.splice(3, 1); }],
|
|
["truncated validation output", f => { f.events[5]!.payload.prpEvent.payload.outputTruncated = true; }],
|
|
["missing native target", f => { f.events[0]!.payload.prpEvent.payload.target = null; f.events[2]!.payload.prpEvent.payload.target = null; }],
|
|
["wrong target", f => { f.events[2]!.payload.prpEvent.payload.target = "other.txt"; }],
|
|
["missing registration", f => { f.run.resultJson.semanticToolReceipts = {} as any; }],
|
|
["rejected registration", f => { f.run.resultJson.semanticToolReceipts.publish.result.disposition = "denied"; }],
|
|
["wrong registered hash", f => { f.run.resultJson.semanticToolReceipts.publish.input.sha256 = "wrong"; }],
|
|
["wrong registered entity", f => { f.run.resultJson.semanticToolReceipts.publish.result.entityRefs[0] = "other"; }],
|
|
["missing attachment", f => { f.attachments = []; }],
|
|
["foreign attachment company", f => { f.attachments[0]!.companyId = "other"; }],
|
|
["stale attachment run", f => { f.attachments[0]!.originatingRunId = "older"; }],
|
|
["wrong download bytes", f => { f.downloadedBytes = Buffer.from("wrong\n"); }],
|
|
["missing publication activity", f => { f.activity = []; }],
|
|
["foreign publication run", f => { f.activity[0]!.runId = "other"; }],
|
|
["conflicting resolved target", f => { f.events[1]!.payload.prpEvent.payload.target = "other.txt"; }],
|
|
["target regresses after resolution", f => { f.events[0]!.payload.prpEvent.payload.target = piFileContract("fixture").filename; f.events[1]!.payload.prpEvent.payload.target = null; }],
|
|
["missing terminal target", f => { f.events[2]!.payload.prpEvent.payload.target = null; }],
|
|
["absent command proof", f => { f.events[4]!.payload.prpEvent.payload.progress = null; }],
|
|
["echo-only command proof", f => { f.events[4]!.payload.prpEvent.payload.progress = "echo PI-VALIDATED-fixture (in_progress): echo PI-VALIDATED-fixture"; }],
|
|
["conflicting command before exact proof", f => { f.events[3]!.payload.prpEvent.payload.progress = "node other.js (pending): node other.js"; }],
|
|
["late command proof", f => { f.events[5]!.payload.prpEvent.payload.progress = f.events[4]!.payload.prpEvent.payload.progress; f.events[4]!.payload.prpEvent.payload.progress = null; }],
|
|
["plain echoed marker output", f => { f.events[5]!.payload.prpEvent.payload.output = piFileContract("fixture").validationMarker; }],
|
|
["nonzero native exit", f => { changeOutput(f, o => { o.structuredContent.exit_code = 1; }); }],
|
|
["missing native exit", f => { changeOutput(f, o => { delete o.structuredContent.exit_code; }); }],
|
|
["truncated native output", f => { changeOutput(f, o => { o.structuredContent.truncated = true; }); }],
|
|
["false marker substring", f => { changeOutput(f, o => { o.structuredContent.output = "failed before PI-VALIDATED-fixture\n"; }); }],
|
|
["contradictory content", f => { changeOutput(f, o => { o.content[0].text = "failed"; }); }],
|
|
["contradictory typed exit", f => { f.events[5]!.payload.prpEvent.payload.exitCode = 1; }],
|
|
["foreign command proof execution", f => { f.events[4]!.payload.prpEvent.payload.executionId = "foreign"; }],
|
|
["foreign command proof turn", f => { f.events[4]!.payload.prpEvent.turnId = "foreign"; }],
|
|
["foreign command proof session", f => { f.events[4]!.payload.prpEvent.normalizedSessionId = "foreign"; }],
|
|
["foreign command proof producer", f => {
|
|
const row = f.events[4]!, e = row.payload.prpEvent;
|
|
e.sourceInstanceId = row.sourceInstanceId = "foreign"; e.sourceEventId = row.sourceEventId = `foreign:run:${e.sourceSeq}`;
|
|
}],
|
|
["durable source differs from envelope", f => { f.events[4]!.sourceInstanceId = "foreign"; }],
|
|
["durable event identity differs from envelope", f => { f.events[4]!.sourceEventId = "runner:run:999"; }],
|
|
["durable source sequence differs from envelope", f => { f.events[4]!.sourceSeq = 999; }],
|
|
["command proof after terminal", f => {
|
|
const e = f.events[4]!; f.events.splice(4, 1); e.seq = 7; e.payload.prpEvent.sourceSeq = 7;
|
|
e.sourceSeq = 7; e.sourceEventId = e.payload.prpEvent.sourceEventId = "runner:run:7"; f.events.push(e);
|
|
}],
|
|
];
|
|
it.each(mutations)("rejects %s even when the model claims completion", (_name, change) => {
|
|
const f = fixture(); change(f); expect(() => gradePiFileEvidence(f)).toThrow("Pi file evidence:");
|
|
try { gradePiFileEvidence(f); } catch (error) { expect(classifyFailure(error)).toBe("candidate_failure"); }
|
|
});
|
|
it("keeps real nonce commands within the Rust 240-character name contract", () => {
|
|
const c = piFileContract("0123456789ab-1");
|
|
expect([...c.validationCommand].length).toBeLessThanOrEqual(240);
|
|
expect(safeAcpxLocations([{ path: `/workspace/${c.filename}` }], "/workspace", "edit", "edit")[0]).toMatchObject({
|
|
path: c.filename, pathBoundary: "paperclip.workspace_relative_display.v2",
|
|
});
|
|
expect(() => piFileContract("a".repeat(100))).toThrow("native Rust tool-name bound");
|
|
});
|
|
it("runs the exact fixture validation command and rejects plausible wrong bytes", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "pi-validation-command-"));
|
|
const c = piFileContract("0123456789ab-1");
|
|
const validate = () => promisify(execFile)("/bin/sh", ["-c", c.validationCommand], {
|
|
cwd: root, timeout: 3_000, maxBuffer: 4096, env: { PATH: dirname(process.execPath) },
|
|
});
|
|
try {
|
|
await writeFile(join(root, c.filename), c.after);
|
|
expect((await validate()).stdout).toBe(`${c.validationMarker}\n`);
|
|
await writeFile(join(root, c.filename), c.after.trimEnd());
|
|
await expect(validate()).rejects.toMatchObject({ code: 1 });
|
|
await rm(join(root, c.filename));
|
|
await expect(validate()).rejects.toMatchObject({ code: 1 });
|
|
} finally { await rm(root, { recursive: true, force: true }); }
|
|
});
|
|
it("pastes the literal validation command as Markdown code instead of escaped paragraph text", () => {
|
|
const command = piFileContract("0123456789ab-1").validationCommand;
|
|
const fenced = piFilePrompt("0123456789ab-1").match(/\n```bash\n([^\n]+)\n```\n/u);
|
|
expect(fenced?.[1]).toBe(command);
|
|
expect(fenced?.[1]).toContain("!==");
|
|
expect(fenced?.[1]).not.toContain("!\\=\\=");
|
|
});
|
|
it("changes only Pi file prompts and keeps all four question methods", () => {
|
|
const cells = runnerMatrix.filter(c => c.suite.id === "extended-harnesses" && c.task.id === "file-edit-validate");
|
|
expect(cells).toHaveLength(6);
|
|
for (const cell of cells) {
|
|
const prompt = cell.task.buildPrompt("fixture");
|
|
expect(prompt.includes("register_deliverable")).toBe(cell.profile.qualificationCandidate === "pi");
|
|
if (cell.profile.qualificationCandidate === "pi") {
|
|
const artifact = cell.task.buildMatchers("fixture", cell).find(matcher => matcher.kind === "artifact_exact");
|
|
if (!artifact || artifact.kind !== "artifact_exact") throw new Error("Pi file task must require a registered artifact");
|
|
expect(artifact.name).toBe(piFileContract("fixture").filename);
|
|
expect(prompt).toContain(`title ${artifact.name},`);
|
|
}
|
|
}
|
|
expect(runnerMatrix.filter(c => !isRollingWebDeployment(c) && c.profile.qualificationCandidate === "pi")).toHaveLength(26);
|
|
});
|
|
it.each(["local", "daytona"] as const)("seeds once and collects public %s evidence", async environment => {
|
|
const root = await mkdtemp(join(tmpdir(), "pi-file-evidence-"));
|
|
try {
|
|
const seed = await seedPiFile(root, "fixture"), f = environment === "daytona" ? copyback() : fixture(), c = piFileContract("fixture");
|
|
expect(await readFile(join(root, c.filename), "utf8")).toBe(c.before);
|
|
await expect(seedPiFile(root, "fixture")).rejects.toThrow();
|
|
await writeFile(join(root, c.filename), c.after);
|
|
const get = vi.fn(async (path: string) => {
|
|
if (path === "/api/heartbeat-runs/run") return f.run;
|
|
if (path === "/api/environment-leases/lease" && "lease" in f) return f.lease;
|
|
if (path.endsWith("/attachments")) return f.attachments;
|
|
if (path.endsWith("/activity")) return f.activity;
|
|
throw new Error("Unexpected route");
|
|
});
|
|
const download = vi.fn(async () => ({ ok: () => true, body: async () => f.downloadedBytes }));
|
|
const visible = vi.fn(async () => {}), evidence = vi.fn(async () => {});
|
|
const proof = await collectPiFileEvidence({ ...f, seed, workspace: root, evidence,
|
|
api: { get, request: { get: download } } as any,
|
|
page: { locator: () => ({ first: () => ({ waitFor: visible }) }) } as any });
|
|
expect(proof.passed).toBe(true);
|
|
if (environment === "daytona") {
|
|
expect(get).toHaveBeenCalledWith("/api/heartbeat-runs/run");
|
|
expect(get).toHaveBeenCalledWith("/api/environment-leases/lease");
|
|
expect(proof.workspaceProvenance.kind).toBe("product_copyback");
|
|
}
|
|
expect(visible).toHaveBeenCalledOnce();
|
|
expect(evidence).toHaveBeenCalledWith(expect.objectContaining({ workspaceBytes: f.workspaceBytes.toString("base64"), downloadedBytes: f.downloadedBytes.toString("base64") }));
|
|
expect(download).toHaveBeenCalledWith(`/api/attachments/${f.attachmentId}/content`);
|
|
} finally { await rm(root, { recursive: true, force: true }); }
|
|
});
|
|
});
|
|
|
|
function copyback() {
|
|
const f = fixture();
|
|
const reference = { schema: "paperclip.native-workspace-sync/v2", state: "finalized", baselineSha256: "a".repeat(64),
|
|
descriptorSha256: "b".repeat(64), finalHostSha256: "c".repeat(64), workspaceId: "workspace", leaseId: "lease",
|
|
providerLeaseId: "sandbox", remoteCwd: "/workspace" };
|
|
const run = { ...f.run, contextSnapshot: { executionWorkspaceId: "workspace", paperclipEnvironment: { id: "environment", driver: "sandbox", leaseId: "lease" } },
|
|
runnerProfileJson: { nativeWorkspaceSync: reference, nativeExecutionInput: { binding: { executionWorkspaceId: "workspace" } } } };
|
|
const lease = { id: "lease", companyId: "company", environmentId: "environment", heartbeatRunId: "run", provider: "daytona", providerLeaseId: "sandbox", metadata: { remoteCwd: "/workspace" } };
|
|
return { ...f, run, lease, environment: "daytona" as const };
|
|
}
|
|
it("accepts public finalized same-run copy-back attribution without claiming guest observation", () => {
|
|
const f = copyback();
|
|
expect(gradePiFileEvidence(f).workspaceProvenance).toMatchObject({ kind: "product_copyback", guestObservation: false, hostSnapshotRecomputed: false });
|
|
});
|
|
it.each([
|
|
["missing reference", f => { f.run.runnerProfileJson.nativeWorkspaceSync = {} as any; }],
|
|
["prepared only", f => { f.run.runnerProfileJson.nativeWorkspaceSync.state = "prepared"; }],
|
|
["missing baseline", f => { f.run.runnerProfileJson.nativeWorkspaceSync.baselineSha256 = ""; }],
|
|
["invalid final hash", f => { f.run.runnerProfileJson.nativeWorkspaceSync.finalHostSha256 = "wrong"; }],
|
|
["foreign workspace", f => { f.run.contextSnapshot.executionWorkspaceId = "other"; }],
|
|
["contradictory native binding", f => { f.run.runnerProfileJson.nativeExecutionInput.binding.executionWorkspaceId = "other"; }],
|
|
["foreign lease", f => { f.lease.id = "other"; }],
|
|
["foreign run", f => { f.lease.heartbeatRunId = "other"; }],
|
|
["foreign company", f => { f.lease.companyId = "other"; }],
|
|
["foreign environment", f => { f.lease.environmentId = "other"; }],
|
|
["foreign provider lease", f => { f.lease.providerLeaseId = "other"; }],
|
|
["wrong remote root", f => { f.lease.metadata.remoteCwd = "/other"; }],
|
|
["wrong provider", f => { f.lease.provider = "other"; }],
|
|
] satisfies Array<[string, (f: ReturnType<typeof copyback>) => void]>)("rejects %s copy-back provenance", (_label, change) => {
|
|
const f = copyback(); change(f);
|
|
expect(() => gradePiCopyback(f.run, f.lease, f.companyId, f.environmentId)).toThrow("Pi file evidence:");
|
|
});
|