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>
221 lines
14 KiB
TypeScript
221 lines
14 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
import * as fs from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
import { openEvalCostLedger, readEvalCostLedger } from "./cost-ledger.js";
|
|
import { EVAL_OVERHEAD_COMPONENTS, type EvalOperatingOverhead } from "./operating-overhead.js";
|
|
import { importOperatingReceipt, operatingReceiptObservation, type EvalOperatingReceipt } from "./operating-receipts.js";
|
|
|
|
vi.mock("node:fs/promises", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof fs>();
|
|
return { ...actual, open: vi.fn(actual.open) };
|
|
});
|
|
|
|
const evidence = Buffer.from("fixture-only infrastructure receipt");
|
|
const overhead: EvalOperatingOverhead = { version: 1, runner: "github-hosted", reference: "fixture-allowance",
|
|
verifiedAt: "2026-10-01T00:00:00Z", expiresAt: "2026-11-01T00:00:00Z",
|
|
components: EVAL_OVERHEAD_COMPONENTS.map(component => ({ component, maxUsd: "1", reference: `fixture-${component}` })) };
|
|
const receipt = (fields: Partial<EvalOperatingReceipt> = {}): EvalOperatingReceipt => ({ version: 2, costBasis: "component_cumulative", campaignId: "fixture",
|
|
allowanceReference: overhead.reference, component: "artifact_storage", evidenceSha256: createHash("sha256").update(evidence).digest("hex"),
|
|
windowStartedAt: "2026-10-01T00:00:00Z", windowEndedAt: "2026-10-02T00:00:00Z", coverage: "partial",
|
|
source: "infrastructure", provider: "fixture", classification: "estimated", costUsd: "0.2", pricingVersion: "fixture-pricing", ...fields });
|
|
const now = Date.parse("2026-10-03T00:00:00Z");
|
|
// Paid admission reads the clock against the 31-day fixture allowance. Pin
|
|
// only Date, so filesystem and timer behavior stay real.
|
|
beforeEach(() => { vi.useFakeTimers({ toFake: ["Date"], now }); });
|
|
afterEach(() => { vi.useRealTimers(); });
|
|
|
|
it("binds operating observations to the campaign, allowance, evidence and closed observation window", () => {
|
|
expect(operatingReceiptObservation(receipt(), "fixture", overhead, evidence, now)).toMatchObject({ requestId: "overhead:artifact_storage", final: false });
|
|
for (const changed of [receipt({ campaignId: "other" }), receipt({ allowanceReference: "other" }), receipt({ evidenceSha256: "0".repeat(64) }),
|
|
receipt({ windowEndedAt: "2026-10-04T00:00:00Z" }), receipt({ windowStartedAt: "2026-10-03T00:00:00Z" }),
|
|
receipt({ component: "model" as never }), receipt({ version: 1 as never }), receipt({ costBasis: undefined }),
|
|
receipt({ costBasis: "window_charge" as never }), { ...receipt(), apiKey: "disallowed" }]) {
|
|
expect(() => operatingReceiptObservation(changed, "fixture", overhead, evidence, now)).toThrow();
|
|
}
|
|
expect(() => operatingReceiptObservation(receipt(), "fixture", overhead, Buffer.from("different"), now)).toThrow();
|
|
});
|
|
|
|
it("does not upgrade missing or partial evidence to an invoice or completed receipt", () => {
|
|
for (const changed of [receipt({ classification: "reconciled" }), receipt({ classification: "unknown" }),
|
|
receipt({ classification: "unknown", costUsd: undefined, coverage: "complete" }), receipt({ pricingVersion: undefined }), receipt({ costUsd: "-1" })]) {
|
|
expect(() => operatingReceiptObservation(changed, "fixture", overhead, evidence, now)).toThrow();
|
|
}
|
|
expect(operatingReceiptObservation(receipt({ classification: "unknown", costUsd: undefined }), "fixture", overhead, evidence, now))
|
|
.toMatchObject({ classification: "unknown", final: false });
|
|
expect(operatingReceiptObservation(receipt({ component: "shared_subscription", classification: "allocated", coverage: "complete" }), "fixture", overhead, evidence, now))
|
|
.toMatchObject({ classification: "allocated", route: "subscription", final: true });
|
|
});
|
|
|
|
async function fixture(maxUsd = "20") {
|
|
const directory = await mkdtemp(path.join(tmpdir(), "operating-receipts-"));
|
|
const ledgerPath = path.join(directory, "ledger.jsonl");
|
|
const ledger = await openEvalCostLedger(ledgerPath, { campaignId: "fixture", maxUsd, deadlineAt: "2099-01-01T00:00:00Z",
|
|
maxRequests: 10, maxConcurrent: 1, maxAttemptsPerExecution: 1, overhead });
|
|
await ledger.close();
|
|
return { directory, ledgerPath };
|
|
}
|
|
|
|
it("retains private source evidence, imports idempotently and keeps partial holds until invoice reconciliation", async () => {
|
|
const { directory, ledgerPath } = await fixture();
|
|
try {
|
|
expect(await importOperatingReceipt(ledgerPath, receipt(), evidence)).toMatchObject({ knownUsd: "0.200000000", heldUsd: "3.800000000", unknownRequests: 4 });
|
|
const before = await readFile(ledgerPath, "utf8");
|
|
await importOperatingReceipt(ledgerPath, Object.fromEntries(Object.entries(receipt()).reverse()) as unknown as EvalOperatingReceipt, evidence);
|
|
expect(await readFile(ledgerPath, "utf8")).toBe(before);
|
|
const retained = path.join(`${ledgerPath}.overhead`, `${receipt().evidenceSha256}.evidence`);
|
|
expect(await readFile(retained)).toEqual(evidence);
|
|
expect((await stat(retained)).mode & 0o777).toBe(0o600);
|
|
expect(await readdir(`${ledgerPath}.overhead`)).toHaveLength(2);
|
|
expect(await importOperatingReceipt(ledgerPath, receipt({ source: "invoice", classification: "reconciled", costUsd: "0.3", coverage: "complete" }), evidence))
|
|
.toMatchObject({ knownUsd: "0.300000000", heldUsd: "3.000000000", unknownRequests: 3 });
|
|
expect(await importOperatingReceipt(ledgerPath, receipt({ source: "invoice", classification: "reconciled", costUsd: "0.4", coverage: "complete" }), evidence))
|
|
.toMatchObject({ knownUsd: "0.400000000", heldUsd: "3.600000000", conflictingRequests: 1 });
|
|
} finally { await rm(directory, { recursive: true, force: true }); }
|
|
});
|
|
|
|
it("fails before ledger mutation when retained evidence is corrupt or another writer owns the journal", async () => {
|
|
const { directory, ledgerPath } = await fixture();
|
|
try {
|
|
await importOperatingReceipt(ledgerPath, receipt(), evidence);
|
|
const before = await readFile(ledgerPath, "utf8");
|
|
await writeFile(path.join(`${ledgerPath}.overhead`, `${receipt().evidenceSha256}.evidence`), "corrupted");
|
|
await expect(importOperatingReceipt(ledgerPath, receipt({ costUsd: "0.5" }), evidence)).rejects.toThrow("conflicts");
|
|
expect(await readFile(ledgerPath, "utf8")).toBe(before);
|
|
const loaded = await readEvalCostLedger(ledgerPath), writer = await openEvalCostLedger(ledgerPath, loaded.state.limits);
|
|
try { await expect(importOperatingReceipt(ledgerPath, receipt(), evidence)).rejects.toThrow("writer lock"); }
|
|
finally { await writer.close(); }
|
|
expect(await readFile(ledgerPath, "utf8")).toBe(before);
|
|
} finally { await rm(directory, { recursive: true, force: true }); }
|
|
});
|
|
|
|
it("rejects separate partial windows and accounts for their cumulative total before admitting more work", async () => {
|
|
const { directory, ledgerPath } = await fixture("4.5");
|
|
try {
|
|
await importOperatingReceipt(ledgerPath, receipt({ source: "invoice", classification: "reconciled", costUsd: "0.8" }), evidence);
|
|
const before = await readFile(ledgerPath, "utf8");
|
|
await expect(importOperatingReceipt(ledgerPath, receipt({ source: "invoice", classification: "reconciled", costUsd: "0.8",
|
|
windowStartedAt: "2026-10-02T00:00:00Z", windowEndedAt: "2026-10-03T00:00:00Z" }), evidence)).rejects.toThrow("cumulative component window");
|
|
expect(await readFile(ledgerPath, "utf8")).toBe(before);
|
|
expect(await importOperatingReceipt(ledgerPath, receipt({ source: "invoice", classification: "reconciled", costUsd: "1.6",
|
|
windowEndedAt: "2026-10-03T00:00:00Z" }), evidence)).toMatchObject({ knownUsd: "1.600000000", heldUsd: "3.000000000", committedUsd: "4.600000000", overBudget: true });
|
|
const loaded = await readEvalCostLedger(ledgerPath), writer = await openEvalCostLedger(ledgerPath, loaded.state.limits);
|
|
try {
|
|
await writer.admitAttempt("fixture-cell", "next");
|
|
await expect(writer.reserve("next", { executionId: "fixture-cell", attemptId: "next", phase: "agent" }, "0.01"))
|
|
.rejects.toThrow("spending cap reached");
|
|
} finally { await writer.close(); }
|
|
await expect(importOperatingReceipt(ledgerPath, receipt({ costUsd: "0.9" }), evidence)).rejects.toThrow("cumulative component window");
|
|
} finally { await rm(directory, { recursive: true, force: true }); }
|
|
});
|
|
|
|
it.each(["partial", "complete"] as const)("rejects a larger cumulative estimate hidden by an earlier %s invoice", async coverage => {
|
|
const { directory, ledgerPath } = await fixture("4.5");
|
|
try {
|
|
await importOperatingReceipt(ledgerPath, receipt({ source: "invoice", classification: "reconciled", costUsd: "0.8", coverage }), evidence);
|
|
const before = await readFile(ledgerPath, "utf8"), retained = await readdir(`${ledgerPath}.overhead`);
|
|
await expect(importOperatingReceipt(ledgerPath, receipt({ costUsd: "1.6", coverage,
|
|
windowEndedAt: "2026-10-03T00:00:00Z" }), evidence)).rejects.toThrow("would understate the cumulative total");
|
|
expect(await readFile(ledgerPath, "utf8")).toBe(before);
|
|
expect(await readdir(`${ledgerPath}.overhead`)).toEqual(retained);
|
|
const updated = await importOperatingReceipt(ledgerPath, receipt({ source: "invoice", classification: "reconciled", costUsd: "1.6", coverage,
|
|
windowEndedAt: "2026-10-03T00:00:00Z" }), evidence);
|
|
expect(updated).toMatchObject({ knownUsd: "1.600000000", committedUsd: "4.600000000", overBudget: true });
|
|
const loaded = await readEvalCostLedger(ledgerPath), writer = await openEvalCostLedger(ledgerPath, loaded.state.limits);
|
|
try {
|
|
await writer.admitAttempt("fixture-cell", "next");
|
|
await expect(writer.reserve("next", { executionId: "fixture-cell", attemptId: "next", phase: "agent" }, "0.01"))
|
|
.rejects.toThrow(coverage === "complete" ? "receipts conflict" : "spending cap reached");
|
|
} finally { await writer.close(); }
|
|
} finally { await rm(directory, { recursive: true, force: true }); }
|
|
});
|
|
|
|
it.each(["evidence", "receipt.json"])("retries must persist the existing %s file before recording its charge", async suffix => {
|
|
const { directory, ledgerPath } = await fixture();
|
|
const before = await readFile(ledgerPath, "utf8"), originalOpen = (await vi.importActual<typeof fs>("node:fs/promises")).open;
|
|
const value = receipt(), canonical = Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
|
|
const observation = operatingReceiptObservation(value, "fixture", overhead, evidence, now);
|
|
await mkdir(`${ledgerPath}.overhead`, { mode: 0o700 });
|
|
await writeFile(path.join(`${ledgerPath}.overhead`, suffix === "evidence" ? `${value.evidenceSha256}.evidence`
|
|
: `${observation.observationId.split(":")[1]}.receipt.json`), suffix === "evidence" ? evidence : `${JSON.stringify(canonical, null, 2)}\n`, { mode: 0o600 });
|
|
let syncs = 0;
|
|
const opened = vi.mocked(fs.open).mockImplementation(async (...args) => {
|
|
const handle = await originalOpen(...args);
|
|
if (String(args[0]).endsWith(`.${suffix}`)) {
|
|
const originalSync = handle.sync.bind(handle);
|
|
vi.spyOn(handle, "sync").mockImplementation(async () => {
|
|
if (++syncs <= 2) throw new Error("fixture file sync failure");
|
|
await originalSync();
|
|
});
|
|
}
|
|
return handle;
|
|
});
|
|
try {
|
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
await expect(importOperatingReceipt(ledgerPath, receipt(), evidence)).rejects.toThrow("file sync failure");
|
|
expect(await readFile(ledgerPath, "utf8")).toBe(before);
|
|
}
|
|
expect(await importOperatingReceipt(ledgerPath, receipt(), evidence)).toMatchObject({ knownUsd: "0.200000000", heldUsd: "3.800000000" });
|
|
expect(syncs).toBe(3);
|
|
} finally {
|
|
opened.mockImplementation(originalOpen);
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.each(["evidence", "receipt.json"])("recovers an incomplete %s write without publishing corrupt evidence or requiring manual deletion", async suffix => {
|
|
const { directory, ledgerPath } = await fixture();
|
|
const before = await readFile(ledgerPath, "utf8"), originalOpen = (await vi.importActual<typeof fs>("node:fs/promises")).open;
|
|
let injected = false;
|
|
const opened = vi.mocked(fs.open).mockImplementation(async (...args) => {
|
|
const handle = await originalOpen(...args);
|
|
if (!injected && String(args[0]).includes(`.${suffix}.`) && String(args[0]).endsWith(".tmp")) {
|
|
const originalWrite = handle.writeFile.bind(handle);
|
|
vi.spyOn(handle, "writeFile").mockImplementation(async () => {
|
|
injected = true;
|
|
await originalWrite("partial");
|
|
throw new Error("fixture partial write");
|
|
});
|
|
}
|
|
return handle;
|
|
});
|
|
try {
|
|
await expect(importOperatingReceipt(ledgerPath, receipt(), evidence)).rejects.toThrow("partial write");
|
|
expect(injected).toBe(true);
|
|
expect(await readFile(ledgerPath, "utf8")).toBe(before);
|
|
expect((await readdir(`${ledgerPath}.overhead`)).some(name => name.endsWith(`.${suffix}`) || name.endsWith(".tmp"))).toBe(false);
|
|
expect(await importOperatingReceipt(ledgerPath, receipt(), evidence)).toMatchObject({ knownUsd: "0.200000000", heldUsd: "3.800000000" });
|
|
expect(await readdir(`${ledgerPath}.overhead`)).toHaveLength(2);
|
|
} finally {
|
|
opened.mockImplementation(originalOpen);
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("retains the hold when the first evidence directory cannot be persisted in its parent", async () => {
|
|
const { directory, ledgerPath } = await fixture();
|
|
const before = await readFile(ledgerPath, "utf8"), originalOpen = (await vi.importActual<typeof fs>("node:fs/promises")).open;
|
|
let parentSyncs = 0;
|
|
const opened = vi.mocked(fs.open).mockImplementation(async (...args) => {
|
|
const handle = await originalOpen(...args);
|
|
if (args[0] === directory && args[1] === "r") {
|
|
vi.spyOn(handle, "sync").mockImplementation(async () => {
|
|
parentSyncs++;
|
|
throw new Error("fixture parent directory sync failure");
|
|
});
|
|
}
|
|
return handle;
|
|
});
|
|
try {
|
|
await expect(importOperatingReceipt(ledgerPath, receipt(), evidence)).rejects.toThrow("parent directory sync failure");
|
|
expect(parentSyncs).toBe(1);
|
|
expect(await readFile(ledgerPath, "utf8")).toBe(before);
|
|
expect((await readEvalCostLedger(ledgerPath)).state.snapshot()).toMatchObject({ knownUsd: "0.000000000", heldUsd: "4.000000000" });
|
|
} finally {
|
|
opened.mockImplementation(originalOpen);
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|