test(evals): repair the Runner support suite after master drift (#19529)

## 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>
This commit is contained in:
Nicky LeachandClaude Opus 5.5 authored and GitHub committed 2026-11-06 11:26:44 -08:00
1 parent 86e9c9354d
commit 4c1f629c31
9 files changed
+151 -49

No files matched your search

+3 -2
View File
@@ -20,9 +20,10 @@ function fixture() {
}
describe("controller outage qualification", () => {
it("keeps original-execution fault evidence enabled for both dedicated continuity cells", async () => {
it("keeps original-execution fault evidence enabled for every dedicated continuity cell", async () => {
const executions = selectRunnerExecutions(parseRunnerSelectors(["--suite", "daytona-restart-continuity"]));
expect(executions).toHaveLength(2);
// 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 });
+5 -1
View File
@@ -3,7 +3,7 @@ import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/
import * as fs from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, it, vi } from "vitest";
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";
@@ -22,6 +22,10 @@ const receipt = (fields: Partial<EvalOperatingReceipt> = {}): EvalOperatingRecei
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 });
@@ -185,11 +185,12 @@ describe("Pi controls catalog admission", () => {
});
it("pins the Pi 1 profile and versioned coverage while retaining active Stop identity", () => {
// Current profile identity and native/control coverage retain their own fingerprints.
// Pi profile 37 is the deliberate one-time reset when release attestation landed (#18325).
// Extended v3 states the strict file oracle's task-wide Bash limit.
const pi = runnerMatrix.find(c => c.profile.qualificationCandidate === "pi")!.profile;
expect(pi.modelQualification?.qualificationId).toBe("pi:0.0.33:1.0.0:openrouter");
expect(runnerSuites.find(s => s.id === "pi-native")!.definitionMetadata).toMatchObject({ version: 23, remoteProcExit: "separately-confirmed-absence-after-read-failure", taskCreation: "explicit-title-and-creation-response-id", agentMemoryParent: "public-managed-file-seed-before-admission", incompleteTerminalCleanup: "retirement-retained-with-failed-watch", profileVersion: 36, agentMemoryContent: "utf8-nonce-plus-final-lf", agentMemoryPrompt: "single-json-write-and-content-bound-native-read-both-runs", agentMemoryReadAuthority: "local-withheld-or-exact-remote-agent-run-file", taskPromptTransport: "fenced-markdown-paste-and-multiline-literal-escapes", nativeFinish: "current-contract-objective-evidence-refs", providerFaultExecutable: "stable-preinstalled-runner-link-and-snapshot-node-inode-with-held-bootstrap-fd-3-or-7" });
expect(runnerSuites.find(s => s.id === "pi-controls")!.definitionMetadata).toMatchObject({ version: 10, remoteProcExit: "separately-confirmed-absence-after-read-failure", taskCreation: "explicit-title-and-creation-response-id", profileVersion: 36,
expect(runnerSuites.find(s => s.id === "pi-native")!.definitionMetadata).toMatchObject({ version: 23, remoteProcExit: "separately-confirmed-absence-after-read-failure", taskCreation: "explicit-title-and-creation-response-id", agentMemoryParent: "public-managed-file-seed-before-admission", incompleteTerminalCleanup: "retirement-retained-with-failed-watch", profileVersion: 37, agentMemoryContent: "utf8-nonce-plus-final-lf", agentMemoryPrompt: "single-json-write-and-content-bound-native-read-both-runs", agentMemoryReadAuthority: "local-withheld-or-exact-remote-agent-run-file", taskPromptTransport: "fenced-markdown-paste-and-multiline-literal-escapes", nativeFinish: "current-contract-objective-evidence-refs", providerFaultExecutable: "stable-preinstalled-runner-link-and-snapshot-node-inode-with-held-bootstrap-fd-3-or-7" });
expect(runnerSuites.find(s => s.id === "pi-controls")!.definitionMetadata).toMatchObject({ version: 10, remoteProcExit: "separately-confirmed-absence-after-read-failure", taskCreation: "explicit-title-and-creation-response-id", profileVersion: 37,
remoteProcessIdentity: "observer-pid-startTicks-bootId",
controlPlaneSettlement: "required-scoped-result-and-terminal-after-runner" });
expect(runnerSuites.find(s => s.id === "extended-harnesses")!.definitionMetadata).toMatchObject({ version: 6, piFileArtifactTitle: "exact-filename", piFileCommandTransport: "fenced-bash-markdown-paste" });
@@ -199,7 +200,7 @@ describe("Pi controls catalog admission", () => {
}
const hashes = Object.fromEntries(runnerSuites.filter(s => ["pi-native", "native-active-stop", "extended-harnesses", "rich-acp-warm-continuity"].includes(s.id)).map(s => [s.id, suiteDefinitionHash(s)]));
expect(hashes).toEqual({
"pi-native": "b8774bd124dd39a694f3b6f26c8c9c5be9278c213b34ffba08d9fe62c21e82b7",
"pi-native": "f204250f5ed9a8b255f1c9619186b957a9353aff97f592ad87e64256a2747959",
"native-active-stop": "4654a2169bff96cf8377fdd62441f5cabea5c613f447e518830f6b1efd82c051",
"rich-acp-warm-continuity": "7e8a0012f2f67fba03cd94403b096a07cfc95cdb7aa568b4c3fd18b852d735f3",
"extended-harnesses": "d5bcf5de0ade3a54e4e1560e76feb5d8a787039d56d2379cd501626d85ed7418",
+8 -2
View File
@@ -18,7 +18,7 @@ function fixture() {
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 stable bash name with command progress.
// 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,
@@ -83,7 +83,9 @@ describe("Pi edit, validation and public artifact oracle", () => {
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]);
expect(fixture().events.slice(3).every(e => e.payload.prpEvent.payload.name === "bash")).toBe(true);
// 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 => {
@@ -91,6 +93,8 @@ describe("Pi edit, validation and public artifact oracle", () => {
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 },
@@ -110,6 +114,8 @@ describe("Pi edit, validation and public artifact oracle", () => {
["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"; }],
+17 -7
View File
@@ -95,7 +95,9 @@ export function gradePiFileEvidence(input: {
requireEvidence(edits.length === 1, "one native edit required; final-only writes cannot qualify");
const edit = edits[0]!;
requireEvidence(edit.p.name === "edit" && edit.p.target === c.filename, "exact native edit and relative target required");
function lifecycle(end: typeof edit) {
// `names[0]` is the opening name. Later names may only refine it once, to
// the final name (the runner keeps the latest concrete ACP title, #19265).
function lifecycle(end: typeof edit, names: readonly unknown[] = [end.p.name]) {
const matches = tools.filter(x => x.p.executionId === end.p.executionId);
const starts = matches.filter(x => x.e.eventType === "tool.execution.started");
requireEvidence(typeof end.p.executionId === "string" && end.p.executionId.length > 0
@@ -105,7 +107,7 @@ export function gradePiFileEvidence(input: {
&& typeof end.e.normalizedSessionId === "string" && end.e.normalizedSessionId.length > 0
&& starts.length === 1 && starts[0]!.row.seq < end.row.seq
&& matches.filter(x => x.e.eventType === "tool.execution.completed").length === 1
&& matches.every(x => x.p.schema === end.p.schema && x.p.name === end.p.name && x.p.operation === end.p.operation
&& matches.every(x => x.p.schema === end.p.schema && names.includes(x.p.name) && x.p.operation === end.p.operation
&& x.row.seq >= starts[0]!.row.seq && x.row.seq <= end.row.seq
&& x.e.sourceKind === "runner" && x.e.turnId === end.e.turnId
&& x.e.normalizedSessionId === end.e.normalizedSessionId && x.e.sourceInstanceId === end.e.sourceInstanceId
@@ -118,19 +120,27 @@ export function gradePiFileEvidence(input: {
requireEvidence(x.p.target === end.p.target || (!resolved && x.p.target === null), "conflicting native tool target");
if (x.p.target !== null) resolved = true;
}
let refined = false;
for (const x of matches) {
requireEvidence(!refined || x.p.name === end.p.name && x.p.name !== names[0], "conflicting or regressed native tool name");
if (x.p.name !== names[0]) refined = true;
}
return { start: starts[0]!, matches };
}
lifecycle(edit);
const validations = tools.filter(x => x.e.eventType === "tool.execution.completed" && x.p.operation === "execute"
&& x.p.name === "bash");
// Count every completed shell execution, whatever its name, so a refined
// title cannot hide a second command from the one-bash-call rule.
const validations = tools.filter(x => x.e.eventType === "tool.execution.completed" && x.p.operation === "execute");
requireEvidence(validations.length === 1, "one observed provider validation execution");
const validation = validations[0]!, { start, matches } = lifecycle(validation);
const validationNames = ["bash", c.validationCommand] as const;
requireEvidence(validationNames.includes(validations[0]!.p.name as never), "exact native validation command required");
const validation = validations[0]!, { start, matches } = lifecycle(validation, validationNames);
requireEvidence(start.row.seq > edit.row.seq && validation.e.turnId === edit.e.turnId
&& validation.e.normalizedSessionId === edit.e.normalizedSessionId
&& validation.e.sourceInstanceId === edit.e.sourceInstanceId && validation.p.outputTruncated === false,
"validation must follow the edit in the same native turn");
// The common lifecycle preserves the opening name (bash). ACPX's resolved
// command is retained as progress, rather than replacing that stable name.
// The lifecycle opens as bash. The resolved command is retained as
// progress, and may also become the name once the runner sees it.
const commandProgress = `${c.validationCommand} (in_progress): ${c.validationCommand}`;
const allowedProgress = new Set([`bash (pending): [terminal] ${validation.p.executionId}`,
`${c.validationCommand} (pending): ${c.validationCommand}`, commandProgress]);
@@ -65,6 +65,8 @@ it("uses the real official Playwright JSON producer with failed errors, user ste
expect(result.status).toBe("failed"); expect(result.errors.length).toBeGreaterThan(0); expect(result.steps[0].title).toBe("owned failed step");
expect(retained.attachments.some(a => a.path.endsWith("/trace.zip"))).toBe(true);
});
// This writes and hashes a 256MiB attachment. Alone it takes about 10s; with the
// whole suite running in parallel it can pass 30s, so it has its own budget.
it("keeps the unchanged 256MiB packaging/archive caps with one canonical large attachment", async () => {
const { packageEvidence } = await import("./evidence.js");
const { inspectCellReportProducer, privateCellEvidenceOwner } = await import("./manual-private-report-assets.js");
@@ -138,7 +140,7 @@ it("keeps the unchanged 256MiB packaging/archive caps with one canonical large a
const allure = await exportPilotAllure(projection, path.join(f.root, "allure"), binding, { schema: "paperclip.chat-completion-testops-selection/v1", ...binding, projectId: 1, planId: 10, jobId: 11, launchId: 12, allureJobRunId: "77", cases: [{ ...descriptor, caseId: 100 }] });
expect(allure[0]!.status).toBe("failed");
expect(JSON.parse(await readFile(path.join(projection, "normalized-results.json"), "utf8")).results[0].status).toBe("failed");
}, 30000);
}, 120_000);
it.each(["literal", "escaped", "base64-body", "escaped-json-body"])("deep-scans registered immutable raw JSON text: %s", async kind => {
const f = await fixture(), canary = "synthetic-private-credential";
@@ -0,0 +1,97 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
// Private archive and TestOps trust boundaries for paid Runner workflows.
// Split from workflow-security.test.ts, which owns the rest of the policy.
const repositoryRoot = path.resolve(import.meta.dirname, "../..");
const workflows = path.join(repositoryRoot, ".github/workflows");
const environments = ["runner-e2e-private-paid", "runner-e2e-private-history"];
describe("private archive and TestOps workflow trust", () => {
it("isolates private archive trust from workflows that execute target-branch code", async () => {
const source = await readFile(path.join(workflows, "runner-full-stack-e2e.yml"), "utf8");
const trust = await readFile(path.join(repositoryRoot, ".github/infra/runner-e2e-private-history.yml"), "utf8");
const subjects = [...trust.matchAll(/!Sub 'repo:\$\{GitHubRepository\}:environment:([^']+)'/gu)].map(match => match[1]);
expect(subjects).toEqual(environments);
const recurring = source.split("\n test_recurring:")[1]!.split("\n report:")[0]!;
const report = source.split("\n report:")[1]!.split("\n publish_history:")[0]!;
for (const [index, job] of [recurring, report].entries()) {
expect(job).toContain(`name: ${environments[index]}`);
expect(job).toContain("github.ref == format('refs/heads/{0}', github.event.repository.default_branch)");
}
expect(recurring).toContain("needs.authorize.outputs.target_sha == github.sha");
for (const filename of await readdir(workflows)) {
if (!/\.ya?ml$/u.test(filename) || ["runner-full-stack-e2e.yml", "runner-protocol-live-evals.yml"].includes(filename)) continue;
const other = await readFile(path.join(workflows, filename), "utf8");
// Checked against the reviewed #18516 rules below.
if (filename === "chat-catalog-testops-case.yml") continue;
for (const environment of environments) expect(other, filename).not.toContain(environment);
}
const protocol = await readFile(path.join(workflows, "runner-protocol-live-evals.yml"), "utf8");
expect(protocol).toContain("'runner-e2e-private-paid' || 'runner-e2e-paid'");
const admission = protocol.split(" - name: Validate accounting mode and retry envelope")[1]!.split(" - name:")[0]!;
expect(admission).toContain('test "$TARGET_SHA" = "$GITHUB_SHA"');
const worker = protocol.split(" steps: &direct_eval_steps")[1]!.split(" - uses:")[0]!;
expect(worker).toContain("test '${{ needs.authorize.outputs.target_sha }}' = \"$GITHUB_SHA\"");
expect(worker).toContain('test "$GITHUB_RUN_ATTEMPT" = 1');
});
it("admits the chat catalog cell to private trust and provider keys only through the guarded dispatcher", async () => {
const other = await readFile(path.join(workflows, "chat-catalog-testops-case.yml"), "utf8");
// The reviewed #18516 pilot rules, carried to the full catalog's
// reusable cell. Trusted master code admits the dispatch and selection
// before any key; the pinned candidate never sees the TestOps token.
expect(other).toContain(" workflow_call:");
expect(other).not.toMatch(/^\s+(?:workflow_dispatch|push|pull_request(?:_target)?|schedule):/mu);
expect(other).toContain("\n node tests/runner-e2e/chat-completion-testops-cli.mjs guard\n");
expect(other).toContain("ref: ${{ github.sha }}");
expect(other).toContain("TESTOPS_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }}");
const chat = other.split("\n chat:")[1]!.split("\n report:")[0]!;
expect(chat).toContain("environment: runner-e2e-private-paid");
expect(chat).toContain(" - &guard");
expect(chat).toContain("verify-selection");
expect(chat).not.toContain("ALLURE_TOKEN");
expect(chat.indexOf("chat-completion-testops-cli.mjs guard")).toBeLessThan(chat.indexOf("ref: ${{ inputs.target_sha }}"));
expect(chat.indexOf("verify-selection")).toBeLessThan(chat.indexOf("secrets.OPENROUTER_API_KEY"));
expect(chat.indexOf("verify-selection")).toBeLessThan(chat.indexOf("secrets.ANTHROPIC_API_KEY"));
const report = other.split("\n report:")[1]!.split("\n upload:")[0]!;
expect(report).toContain("environment: runner-e2e-private-history");
expect(report).toContain("manual-private-transfer.ts report-session");
expect(report).not.toContain("ALLURE_TOKEN");
expect(report).not.toContain("ref: ${{ inputs.target_sha }}");
const upload = other.split("\n upload:")[1]!;
for (const environment of environments) expect(upload).not.toContain(environment);
expect(upload).not.toMatch(/secrets\.(?!ALLURE_TOKEN\b)/u);
// Only the master-only catalog dispatcher calls this cell, with its own
// guard's pinned source and admitted jobs.
const callers = (await Promise.all((await readdir(workflows)).filter(name => /\.ya?ml$/u.test(name))
.map(async name => [name, await readFile(path.join(workflows, name), "utf8")] as const)))
.filter(([, contents]) => contents.includes("uses: ./.github/workflows/chat-catalog-testops-case.yml"));
expect(callers.map(([name]) => name)).toEqual(["chat-catalog-testops.yml"]);
const dispatcher = callers[0]![1];
expect(dispatcher).toMatch(/^on:\n {2}workflow_dispatch:/mu);
expect(dispatcher).toContain("github.workflow_ref == 'paperclipai/paperclip/.github/workflows/chat-catalog-testops.yml@refs/heads/master'");
expect(dispatcher.split("\n authorize:")[1]!.split("\n select:")[0]).toContain("run: node tests/runner-e2e/chat-completion-testops-cli.mjs guard");
const cellJobs = dispatcher.split(/\n(?= {2}[a-z_]+:\n)/u).filter(job => job.includes("uses: ./.github/workflows/chat-catalog-testops-case.yml"));
expect(cellJobs).toHaveLength(2);
for (const job of cellJobs) {
expect(job).toContain("needs: [authorize, select, build]");
expect(job).toContain("target_sha: ${{ needs.authorize.outputs.target_sha }}");
}
const keyed = dispatcher.split(/\n(?= {2}[a-z_]+:\n)/u).filter(job => /secrets\.(?:OPENROUTER|ANTHROPIC)_API_KEY/u.test(job));
expect(keyed).toEqual(cellJobs);
});
it("keeps the full-stack TestOps selection and publication jobs on reviewed code", async () => {
const fullStack = await readFile(path.join(workflows, "runner-full-stack-e2e.yml"), "utf8");
for (const [job, next] of [["testops_selection", "catalog"], ["publish_testops", "publish_history"]]) {
const trusted = fullStack.split(`\n ${job}:\n`)[1]!.split(`\n ${next}:\n`)[0]!;
expect(trusted, job).toContain("ref: ${{ github.sha }}");
expect(trusted, job).not.toContain("ref: ${{ needs.authorize.outputs.target_sha }}");
expect(trusted, job).not.toContain("Download resolved target lockfile");
expect(trusted, job).not.toMatch(/pnpm install|secrets\.(?!ALLURE_TOKEN\b)/u);
}
});
});
+3 -1
View File
@@ -19,7 +19,9 @@ delete env.NODE_PATH;
delete env.NODE_OPTIONS;
for (const [name, command] of [
["Vitest", [vitest, "run", "--config", "tests/runner-e2e/vitest.config.ts", ...args]],
// Server modules under test import the built plugin SDK (and shared) package.
["Plugin build dependencies", ["scripts/ensure-plugin-build-deps.mjs"]],
["Vitest",[vitest, "run", "--config", "tests/runner-e2e/vitest.config.ts", ...args]],
["Node tests", ["--test", "tests/runner-e2e/product-regression-testops.test.mjs", "tests/runner-e2e/artifact-budget.test.mjs", "tests/runner-e2e/native-completion-checks.test.mjs",
"tests/runner-e2e/native-completion-git-source.test.mjs", "tests/runner-e2e/native-completion-source-contract.test.mjs",
"tests/runner-e2e/chat-feature-testops-api.test.mjs", "tests/runner-e2e/chat-feature-testops-cli.test.mjs", "tests/runner-e2e/chat-feature-testops-results.test.mjs", "tests/runner-e2e/testops-chat-reporter.test.mjs", "tests/runner-e2e/chat-feature-testops-retained-verification.node-test.mjs",
+11 -32
View File
@@ -83,9 +83,10 @@ describe("public repository paid workflow security", () => {
});
// PR #18348 added the ninth pr-trusted bootstrap, in the Vitest selection job.
// #19533 added the tenth, for the sandbox provider tests.
const trustedPnpmBootstraps = {
"runner-full-stack-e2e.yml": 8,
"pr-trusted.yml": 9,
"pr-trusted.yml": 10,
} as const;
it("keeps pnpm bootstrap registry telemetry out of trusted workflow setup", async () => {
@@ -544,8 +545,10 @@ describe("public repository paid workflow security", () => {
/ref: \$\{\{ needs\.authorize\.outputs\.target_sha \}\}/g,
),
).toHaveLength(6);
expect(fullStack.match(/ref: \$\{\{ github\.sha \}\}/g)).toHaveLength(4);
expect(fullStack.match(/persist-credentials: false/g)).toHaveLength(10);
// PR #18983 added two trusted TestOps jobs. private-trust-workflow-security.test.ts
// checks that they check out only reviewed code.
expect(fullStack.match(/ref: \$\{\{ github\.sha \}\}/g)).toHaveLength(6);
expect(fullStack.match(/persist-credentials: false/g)).toHaveLength(12);
expect(fullStack).not.toContain("ref: ${{ inputs.target_branch }}");
expect(fullStack).toContain(
"PAPERCLIP_RUNNER_SOURCE_REVISION=${TARGET_SHA}",
@@ -609,6 +612,11 @@ describe("public repository paid workflow security", () => {
"runner-full-stack-e2e.yml",
"runner-live-evals.yml",
"runner-protocol-live-evals.yml",
// PR #18516 admitted guarded, pinned-source chat TestOps cells. The full
// catalog (#18542, #18647) replaced the pilot workflows that #18625 removed.
// private-trust-workflow-security.test.ts checks how these two hold the keys.
"chat-catalog-testops.yml",
"chat-catalog-testops-case.yml",
]);
const names = (await readdir(workflowDirectory)).filter((name) =>
/\.ya?ml$/.test(name),
@@ -877,35 +885,6 @@ describe("public repository paid workflow security", () => {
expect(marker).not.toMatch(/BUCKET|ROLE_ARN|private-report-reference/);
});
it("isolates private archive trust from workflows that execute target-branch code", async () => {
const workflows = path.join(repositoryRoot, ".github/workflows");
const source = await readFile(path.join(workflows, "runner-full-stack-e2e.yml"), "utf8");
const trust = await readFile(path.join(repositoryRoot, ".github/infra/runner-e2e-private-history.yml"), "utf8");
const environments = ["runner-e2e-private-paid", "runner-e2e-private-history"];
const subjects = [...trust.matchAll(/!Sub 'repo:\$\{GitHubRepository\}:environment:([^']+)'/gu)].map(match => match[1]);
expect(subjects).toEqual(environments);
const recurring = source.split("\n test_recurring:")[1]!.split("\n report:")[0]!;
const report = source.split("\n report:")[1]!.split("\n publish_history:")[0]!;
for (const [index, job] of [recurring, report].entries()) {
expect(job).toContain(`name: ${environments[index]}`);
expect(job).toContain("github.ref == format('refs/heads/{0}', github.event.repository.default_branch)");
}
expect(recurring).toContain("needs.authorize.outputs.target_sha == github.sha");
for (const filename of await readdir(workflows)) {
if (!/\.ya?ml$/u.test(filename) || ["runner-full-stack-e2e.yml", "runner-protocol-live-evals.yml"].includes(filename)) continue;
const other = await readFile(path.join(workflows, filename), "utf8");
for (const environment of environments) expect(other, filename).not.toContain(environment);
}
const protocol = await readFile(path.join(workflows, "runner-protocol-live-evals.yml"), "utf8");
expect(protocol).toContain("'runner-e2e-private-paid' || 'runner-e2e-paid'");
const admission = protocol.split(" - name: Validate accounting mode and retry envelope")[1]!.split(" - name:")[0]!;
expect(admission).toContain('test "$TARGET_SHA" = "$GITHUB_SHA"');
const worker = protocol.split(" steps: &direct_eval_steps")[1]!.split(" - uses:")[0]!;
expect(worker).toContain("test '${{ needs.authorize.outputs.target_sha }}' = \"$GITHUB_SHA\"");
expect(worker).toContain('test "$GITHUB_RUN_ATTEMPT" = 1');
});
it("uses environment-scoped OIDC for a no-delete history publisher", async () => {
const workflow = await readFile(
path.join(repositoryRoot, ".github/workflows/runner-full-stack-e2e.yml"),