Files
PaperClipAI/tests/runner-e2e/private-trust-workflow-security.test.ts
T
Nicky LeachandClaude Opus 5.5 4c1f629c31 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>
2026-11-06 11:26:44 -08:00

98 lines
6.7 KiB
TypeScript

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);
}
});
});