mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +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>
143 lines
9.2 KiB
TypeScript
143 lines
9.2 KiB
TypeScript
import { mkdtemp, mkdir, writeFile, readFile, rm, lstat } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { configuredControllerOutage, controllerOutageForExecution, parseControllerOutage, replaceController, retireControllerCache, validControllerOutageReceipt } from "./controller-outage.js";
|
|
import { parseRunnerSelectors, selectRunnerExecutions } from "./selectors.js";
|
|
|
|
function fixture() {
|
|
const order: string[] = [];
|
|
const receipts: unknown[] = [];
|
|
return { order, receipts, input: {
|
|
requestId: "calibration", previousPid: 100, outage: { signal: "SIGKILL" as const, holdMs: 20 },
|
|
stop: async (signal: string) => { order.push(signal); },
|
|
waitForUnhealthy: async () => { order.push("http-down"); },
|
|
discardCache: async () => { order.push("empty-cache"); },
|
|
start: vi.fn(async () => { order.push("start"); return { pid: 200 }; }),
|
|
waitForReady: async () => { order.push("ready"); }, cancelled: () => false,
|
|
record: async (receipt: unknown) => { receipts.push(receipt); },
|
|
} };
|
|
}
|
|
|
|
describe("controller outage qualification", () => {
|
|
it("keeps original-execution fault evidence enabled for every dedicated continuity cell", async () => {
|
|
const executions = selectRunnerExecutions(parseRunnerSelectors(["--suite", "daytona-restart-continuity"]));
|
|
// Only providers with production managed warm sessions belong in this lane (#18994).
|
|
expect(executions.map(execution => execution.profile.id)).toEqual(["runner-codex"]);
|
|
for (const execution of executions) {
|
|
const outage = controllerOutageForExecution(execution, {});
|
|
expect(outage).toEqual({ signal: "SIGKILL", holdMs: 300_000 });
|
|
expect(controllerOutageForExecution(execution, { PAPERCLIP_RUNNER_E2E_CONTROLLER_OUTAGE_MS: "300000" }))
|
|
.toEqual({ signal: "SIGKILL", holdMs: 300_000 });
|
|
expect(() => controllerOutageForExecution(execution, { PAPERCLIP_RUNNER_E2E_CONTROLLER_OUTAGE_MS: "1000" }))
|
|
.toThrow("shorter than the dedicated continuity contract");
|
|
}
|
|
});
|
|
|
|
it("uses the everyday suite's declared default while retaining explicit short profiles", () => {
|
|
const [execution] = selectRunnerExecutions(parseRunnerSelectors([
|
|
"--id", "everyday-workflows.runner-codex.daytona.recover-controller",
|
|
]));
|
|
expect(controllerOutageForExecution(execution!, {})).toEqual({ signal: "SIGKILL", holdMs: 125_000 });
|
|
expect(controllerOutageForExecution(execution!, { PAPERCLIP_RUNNER_E2E_CONTROLLER_OUTAGE_MS: "1000" }))
|
|
.toEqual({ signal: "SIGKILL", holdMs: 1_000 });
|
|
});
|
|
|
|
it.each(["runner-codex", "runner-acpx-claude"])("supplies replacement fault evidence to the %s Daytona restart cell", async profile => {
|
|
const [execution] = selectRunnerExecutions(parseRunnerSelectors([
|
|
"--id", `everyday-workflows.${profile}.daytona.recover-controller`,
|
|
]));
|
|
expect(execution).toBeDefined();
|
|
const f = fixture();
|
|
const outage = controllerOutageForExecution(execution!, { PAPERCLIP_RUNNER_E2E_CONTROLLER_OUTAGE_MS: "20" });
|
|
const receipt = outage ? await replaceController({ ...f.input, outage }) : undefined;
|
|
expect(validControllerOutageReceipt(receipt, { signal: "SIGKILL", holdMs: 20 })).toBe(true);
|
|
expect(receipt).toMatchObject({ previousPid: 100, replacementPid: 200, cacheDiscarded: true });
|
|
expect(f.order).toEqual(["SIGKILL", "http-down", "empty-cache", "start", "ready"]);
|
|
for (const other of [
|
|
{ ...execution!, environment: { ...execution!.environment, id: "local" as const } },
|
|
{ ...execution!, profile: { ...execution!.profile, generation: "legacy" as const } },
|
|
{ ...execution!, task: { ...execution!.task, id: "stop-redirect" } },
|
|
]) expect(controllerOutageForExecution(other, {})).toBeUndefined();
|
|
});
|
|
it("records an actual bounded outage before starting the successor", async () => {
|
|
const f = fixture();
|
|
const receipt = await replaceController(f.input);
|
|
expect(f.order).toEqual(["SIGKILL", "http-down", "empty-cache", "start", "ready"]);
|
|
expect(receipt.replacementStartedAfterMs! - receipt.stoppedAfterMs!).toBeGreaterThanOrEqual(20);
|
|
expect(validControllerOutageReceipt(receipt, f.input.outage)).toBe(true);
|
|
expect(validControllerOutageReceipt(receipt, { signal: "SIGTERM", holdMs: 20 })).toBe(false);
|
|
expect(validControllerOutageReceipt(receipt, { signal: "SIGKILL", holdMs: 300_000 })).toBe(false);
|
|
expect(validControllerOutageReceipt(receipt, f.input.outage, "another-attempt")).toBe(false);
|
|
expect(f.receipts[0]).not.toHaveProperty("readyAfterMs");
|
|
expect(f.receipts.at(-1)).toEqual(receipt);
|
|
});
|
|
|
|
it.each(["stop", "waitForUnhealthy", "discardCache", "start", "waitForReady"] as const)("retains incomplete evidence when %s fails", async stage => {
|
|
const f = fixture();
|
|
f.input[stage] = vi.fn(async () => { throw new Error(stage); });
|
|
await expect(replaceController(f.input)).rejects.toThrow(stage);
|
|
expect(f.receipts.length).toBeGreaterThan(0);
|
|
expect(f.receipts.every(receipt => !validControllerOutageReceipt(receipt))).toBe(true);
|
|
});
|
|
|
|
it("cancels during the offline interval without launching another server", async () => {
|
|
const f = fixture();
|
|
f.input.cancelled = () => f.order.includes("empty-cache");
|
|
await expect(replaceController(f.input)).rejects.toThrow("shutdown interrupted");
|
|
expect(f.input.start).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([-1, 300001, 0.5, NaN, Infinity, "300000", null])("rejects invalid duration %s before signaling", holdMs => {
|
|
expect(() => parseControllerOutage({ signal: "SIGKILL", holdMs })).toThrow();
|
|
});
|
|
it.each(["", "-1", "1e3", "NaN", " 1000", "300001"])("rejects invalid environment duration %s", value => {
|
|
expect(() => configuredControllerOutage({ PAPERCLIP_RUNNER_E2E_CONTROLLER_OUTAGE_MS: value })).toThrow();
|
|
});
|
|
it("accepts the five-minute contract without extending the runner grace", () => {
|
|
expect(configuredControllerOutage({ PAPERCLIP_RUNNER_E2E_CONTROLLER_OUTAGE_MS: "300000" }))
|
|
.toEqual({ signal: "SIGKILL", holdMs: 300000 });
|
|
expect(validControllerOutageReceipt({ schema: "paperclip.e2e.controller-outage/v1", requestId: "late",
|
|
signal: "SIGKILL", holdMs: 300000, previousPid: 1, replacementPid: 2, startedAt: new Date().toISOString(),
|
|
stoppedAfterMs: 0, cacheDiscarded: true, replacementStartedAfterMs: 300000, readyAfterMs: 420000 })).toBe(false);
|
|
});
|
|
});
|
|
|
|
it.each(["absent", "present"])("retires a %s old controller cache", async state => {
|
|
const root = await mkdtemp(join(tmpdir(), "controller-cache-"));
|
|
const cache = join(root, "cache"), retired = join(root, "retired");
|
|
try {
|
|
if (state === "present") { await mkdir(cache); await writeFile(join(cache, "evidence"), "saved"); }
|
|
await retireControllerCache(cache, retired);
|
|
await expect(lstat(cache)).rejects.toMatchObject({ code: "ENOENT" });
|
|
if (state === "present") expect(await readFile(join(retired, "evidence"), "utf8")).toBe("saved");
|
|
} finally { await rm(root, { recursive: true, force: true }); }
|
|
});
|
|
it("does not mistake a missing retirement parent for an already absent cache", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "controller-cache-")), cache = join(root, "cache");
|
|
try {
|
|
await mkdir(cache); await writeFile(join(cache, "evidence"), "saved");
|
|
await expect(retireControllerCache(cache, join(root, "missing", "retired"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(await readFile(join(cache, "evidence"), "utf8")).toBe("saved");
|
|
} finally { await rm(root, { recursive: true, force: true }); }
|
|
});
|
|
it.each(["missing", "previous-revision", "previous-tree", "replacement-revision", "replacement-tree", "reversed"])(
|
|
"requires the requested upgrade artifact pair when evidence is %s", problem => {
|
|
const A = { revision: "a".repeat(40), treeSha256: "a".repeat(64), version: "1", evidence: "development-build" as const };
|
|
const B = { ...A, revision: "b".repeat(40), treeSha256: "b".repeat(64) };
|
|
const receipt = { schema: "paperclip.e2e.controller-outage/v1", requestId: "upgrade", previousPid: 1, replacementPid: 2,
|
|
startedAt: new Date().toISOString(), signal: "SIGKILL", holdMs: 0, stoppedAfterMs: 0,
|
|
replacementStartedAfterMs: 1, readyAfterMs: 2, cacheDiscarded: true,
|
|
previousArtifact: { ...A }, replacementArtifact: { ...B } };
|
|
expect(validControllerOutageReceipt(receipt, undefined, "upgrade", { previous: A, replacement: B })).toBe(true);
|
|
expect(validControllerOutageReceipt({ ...receipt, previousArtifact: B, replacementArtifact: A }, undefined,
|
|
"upgrade", { previous: B, replacement: A })).toBe(true);
|
|
if (problem === "missing") Object.assign(receipt, { previousArtifact: undefined, replacementArtifact: undefined });
|
|
if (problem === "previous-revision") receipt.previousArtifact.revision = "c".repeat(40);
|
|
if (problem === "previous-tree") receipt.previousArtifact.treeSha256 = "c".repeat(64);
|
|
if (problem === "replacement-revision") receipt.replacementArtifact.revision = "c".repeat(40);
|
|
if (problem === "replacement-tree") receipt.replacementArtifact.treeSha256 = "c".repeat(64);
|
|
if (problem === "reversed") Object.assign(receipt, { previousArtifact: B, replacementArtifact: A });
|
|
expect(validControllerOutageReceipt(receipt, undefined, "upgrade", { previous: A, replacement: B })).toBe(false);
|
|
});
|