mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 21:01:54 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Runner protocol qualification runs real providers against 41 cases and reports them in TestOps. > - Controlled mode (#17889) adds a spend cap, private evidence storage, and cost and trace accounting. > - Operators must hand-maintain one large JSON variable for each campaign size, price review, and lockfile change. > - A one-cell policy blocked the first 41-case run, and its prices expired after 24 hours. > - This pull request derives that policy from committed defaults and the selected cases. > - The benefit is that a full run needs no policy edit, and all three safety properties stay. ## Linked Issues or Issue Description Refs #17889, #19011, #19070. **What existing behavior does this improve?** Controlled Runner protocol campaigns read `RUNNER_PROTOCOL_EVAL_CONTROLLER_POLICY_JSON`. That variable holds the cell limit, a price table with a review expiry, a pinned lockfile digest, and self-hashed bound proofs. **Current behavior** A campaign with more cells than `maxCells` fails before provider work. Expired prices also fail before provider work. Any change to the lockfile needs a new pin. A one-cell policy made all 41 TestOps cases fail as infrastructure, so an operator had to edit the variable by hand before the full run. **Proposed behavior** The workflow derives the policy at preparation time. The cell count comes from the selected catalog. Prices come from a committed table with a version and a source URL. The controller installs the committed frozen lockfile. One optional variable, `RUNNER_PROTOCOL_EVAL_MAX_USD`, can override the committed $400 spend cap. **Reason and benefit** Each piece that was hand-maintained duplicated a check that already exists. The manifest digest binds every allocation. The frozen install binds dependencies to the reviewed commit. Under model-request readiness, the self-hashed bounds never changed admission or accounting. **Breaking changes** The old variable is no longer read. It can stay set or be deleted. Retained manifests that include the old proofs still replay. ## What Changed - Add `packages/paperclip-runner/scripts/controlled-evals/hosted-defaults.json`. It holds the spend cap, duration, route, Claude Sonnet 5 prices, allocation, overhead reservation and trace limits. - Add `hostedPolicy()` in `hosted.mjs`. It derives `maxCells` from the selection. It gives each campaign a validity window from preparation to one hour after its deadline. It applies the optional spend cap override. - Remove `capacityBound` and the policy self-hash from hosted preparation. `manifest.mjs` needs model `provisionalBound` only when readiness is not `model_requests`. - Workflow: stop reading the policy variable. The controller build installs the committed `pnpm-lock.yaml` with `--frozen-lockfile` and fails on any lockfile drift. Preparation checks that the runtime receipt lockfile digest equals the committed lockfile. The catalog limit is now 1 to 512 selected cells. - Keep everything else the same: the spend-cap check before provider work, private S3 retention of the manifest and derived `hosted-policy.json`, receipts, tracing, first-attempt and zero-retry gates, and paid-environment approvals. - Update `hosted.test.mjs`, `portable.test.mjs`, `runner-protocol-eval-workflow-security.test.mjs`, and `docs/runner-protocol-live-evals.md`. ## Verification - `node --test scripts/controlled-evals/hosted.test.mjs`: 8 pass. The new tests show that `maxCells` equals the selection and that the validity window covers the deadline with no committed expiry. They also show that the spend cap override applies. With the committed defaults, 41 cells validate with $339 reserved, and 49 cells fail on the $400 budget. - Negative controls still fail: drift in selection, source or attempt; a bad lock digest; missing tracing; a duration over 4 hours; a malformed or too-low spend cap; and zero cells. - `node --test scripts/runner-protocol-eval-workflow-security.test.mjs`: 20 pass. Controlled admission now passes whether the old variable is absent, still set, or malformed. A real git checkout with a modified `pnpm-lock.yaml` fails the controller guard before install. - `node --test scripts/controlled-evals/controller.test.mjs scripts/controlled-evals/portable.test.mjs`: all pass. - `actionlint .github/workflows/runner-protocol-live-evals.yml` and `git diff --check` pass. - `tests/runner-e2e/workflow-security.test.ts` has 5 failures that this change does not cause. They concern `pr-trusted.yml`, `chat-catalog-testops-case.yml` and `runner-full-stack-e2e.yml`. This PR does not touch those files. - I have not run a live controlled campaign on this branch. Controlled mode runs only from the default branch. The current 41-case run uses master and the existing variable. ## Risks - The committed price table has no expiry. A provider price change needs a reviewed edit to the table. The spend cap still limits the maximum spend. - If the committed lockfile does not match the package manifests, the controller build fails before provider work. CI already requires a frozen lockfile. - The spend cap is now $400 by default. It was $339 in the last manual policy. The cap is a reservation limit, not expected spend. The expected spend for 41 cases is about $5–20. - Only ACPX Claude Sonnet 5 has a committed allocation, as in the old variable. A new controlled model needs a committed price entry and allocation. ## Model Used Anthropic Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use and code execution. ## 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 - [ ] All Paperclip CI gates are green - [ ] 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>
204 lines
15 KiB
JavaScript
204 lines
15 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { test } from 'node:test';
|
|
import { execFile } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { join, resolve } from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import { fixture, success } from './controller-fixture.mjs';
|
|
import { canonical, sha256, units, validateManifest } from './manifest.mjs';
|
|
const runtime = process.env.PAPERCLIP_CONTROLLER_TEST_RUNTIME ? pathToFileURL(resolve(process.env.PAPERCLIP_CONTROLLER_TEST_RUNTIME) + '/') : import.meta.url;
|
|
const { hostedPaths, hostedPolicy, prepareHostedManifest, verifyHostedCell, hostedAdmissionDiagnostic } = await import(new URL('./hosted.mjs', runtime));
|
|
const { packControlledAttempt, summarizeControlledManifest } = await import(new URL('./archive.mjs', runtime));
|
|
|
|
async function campaign(t) {
|
|
const f = await fixture(t, { count: 2, observability: true });
|
|
const runId = String(Date.now() * 1000 + Math.floor(Math.random() * 1000)), paths = hostedPaths(runId);
|
|
t.after(() => rm(paths.root, { recursive: true, force: true }));
|
|
await mkdir(join(paths.runner, 'portable/dist/cli'), { recursive: true });
|
|
const artifacts = {};
|
|
for (const [name, relative] of [['runnerCli', 'portable/dist/cli/eval-session.js'], ['runnerPackage', 'paperclip-runner.tgz'], ['runnerd', 'paperclip-runnerd']]) {
|
|
artifacts[name] = { ...f.manifest[name], path: join(paths.runner, relative) };
|
|
await cp(f.manifest[name].path, artifacts[name].path);
|
|
}
|
|
const cells = f.requests.map(({ request }, index) => ({ cellId: `execution-${index}`, provider: request.provider, driver: 'codex_app_server', model: request.model }));
|
|
const catalog = { schema: 'paperclip.runner-protocol-eval.catalog/v1', campaignId: `gha-${runId}-1`,
|
|
source: { paperclipSha: f.manifest.sourceRevision, evalsSha: f.manifest.definitionsRevision },
|
|
cells, matrices: cells.map(cell => ({ include: [cell] })) };
|
|
const catalogSha256 = sha256(canonical(catalog));
|
|
for (const item of f.requests) { item.request.runnerd = { ...artifacts.runnerd, sha256: `sha256:${artifacts.runnerd.sha256}` }; item.request.acpxAgent = null; item.request.driver = 'codex_app_server'; await writeFile(item.requestPath, JSON.stringify(item.request)); }
|
|
const prepared = { schema: 'paperclip.runner-eval.prepared-requests/v1', campaignId: catalog.campaignId, githubRunId: runId, githubRunAttempt: 1,
|
|
sourceRevision: f.manifest.sourceRevision, definitionsRevision: f.manifest.definitionsRevision, catalogSha256, runnerd: f.requests[0].request.runnerd,
|
|
cells: cells.map((cell, index) => ({ cellId: cell.cellId, runId: `${catalog.campaignId}-${cell.cellId}`, request: f.requests[index].request })) };
|
|
const { projectId, capture, nativeLimits } = f.manifest.observability;
|
|
const { executionId, attemptId, requestSha256, ...allocation } = f.manifest.attempts[0];
|
|
const { verifiedAt, expiresAt, components, ...overhead } = f.manifest.overhead;
|
|
// Committed defaults carry no cell count, review window, lock pin or self-hashed bounds.
|
|
const defaults = { schema: 'paperclip.runner-eval.hosted-defaults/v1', maxUsd: f.manifest.maxUsd, durationMs: 60000, route: 'direct',
|
|
models: f.manifest.models.map(({ provisionalBound, ...model }) => model), allocations: [allocation],
|
|
overhead: { ...overhead, accountingReadiness: 'model_requests', components: components.map(({ provisionalBound, ...component }) => component) },
|
|
observability: { capture, nativeLimits } };
|
|
const environment = { ...f.environment, GITHUB_ACTIONS: 'true', GITHUB_REPOSITORY: 'paperclipai/paperclip', GITHUB_RUN_ID: runId,
|
|
GITHUB_RUN_ATTEMPT: '1', GITHUB_SHA: f.manifest.sourceRevision, PAPERCLIP_PROTOCOL_EVAL_SOURCE_SHA: prepared.sourceRevision, PAPERCLIP_PROTOCOL_EVALS_SHA: prepared.definitionsRevision };
|
|
const input = { catalog, catalogSha256, prepared, defaults, projectId, controllerLockSha256: 'e'.repeat(64), artifacts, environment };
|
|
const build = () => prepareHostedManifest(input);
|
|
return { f, input, paths, environment, build };
|
|
}
|
|
|
|
test('one prepared manifest binds disjoint workers and retains partial or entirely missing campaigns', async t => {
|
|
const c = await campaign(t), result = c.build();
|
|
Object.assign(c.f.manifest, result.manifest); await c.f.save();
|
|
const empty = await summarizeControlledManifest(result.manifest, []);
|
|
assert.equal(empty.incompleteAttempts, 2); assert.equal(empty.heldUsd, '0.024000000');
|
|
assert.equal(empty.costStatus, 'incomplete');
|
|
Object.assign(c.f.environment, c.environment, { PAPERCLIP_RUNNER_EVAL_EXECUTION_ID: 'execution-0' });
|
|
assert.equal((await c.f.run(0, () => success())).code, 0);
|
|
const bundle = await packControlledAttempt({ manifest: result.manifest, attemptId: 'attempt-0', environment: c.f.environment });
|
|
const partial = await summarizeControlledManifest(result.manifest, [bundle, bundle]);
|
|
assert.equal(partial.incompleteAttempts, 1); assert.equal(partial.knownUsd, '0.000136000');
|
|
assert.equal(partial.heldUsd, '0.014000000'); assert.equal(partial.operatingHeldUsd, '0.004000000');
|
|
Object.assign(c.f.environment, { PAPERCLIP_RUNNER_EVAL_EXECUTION_ID: 'execution-1' });
|
|
assert.equal((await c.f.run(1, () => success())).code, 0);
|
|
const second = await packControlledAttempt({ manifest: result.manifest, attemptId: 'attempt-1', environment: c.f.environment });
|
|
const complete = await summarizeControlledManifest(result.manifest, [bundle, second]);
|
|
assert.equal(complete.incompleteAttempts, 0); assert.equal(complete.knownUsd, '0.000272000');
|
|
assert.equal(complete.heldUsd, '0.004000000'); assert.equal(complete.costStatus, 'provisional');
|
|
await assert.rejects(summarizeControlledManifest({ ...result.manifest, maxUsd: '2' }, [bundle]), /retained campaign manifest/);
|
|
});
|
|
|
|
test('hosted preparation rejects selection drift, unsupported cells and unreviewed or insufficient budgets', async t => {
|
|
const c = await campaign(t);
|
|
const changes = [
|
|
input => { input.prepared.definitionsRevision = 'd'.repeat(40); },
|
|
input => { input.prepared.catalogSha256 = '0'.repeat(64); },
|
|
input => { input.catalog.matrices[1].include = input.catalog.matrices[0].include; },
|
|
input => { input.prepared.cells[1] = input.prepared.cells[0]; },
|
|
input => { input.prepared.cells[0].request.provider = 'opencode'; },
|
|
input => { input.prepared.cells[0].request.driver = 'unsupported'; },
|
|
input => { input.catalog.cells[0].driver = 'unsupported'; input.catalog.matrices[0].include[0].driver = 'unsupported'; },
|
|
input => { input.prepared.cells[0].request.runnerd.sha256 = 'sha256:' + '0'.repeat(64); },
|
|
input => { input.maxUsd = '0.01'; },
|
|
input => { input.maxUsd = 'unbounded'; },
|
|
input => { input.prepared.cells = []; },
|
|
input => { input.environment.GITHUB_RUN_ATTEMPT = '2'; },
|
|
input => { input.environment.GITHUB_SHA = 'wrong'; },
|
|
input => { input.environment.GITHUB_SHA = 'c'.repeat(40); },
|
|
input => { delete input.defaults.observability; },
|
|
input => { delete input.controllerLockSha256; },
|
|
input => { input.controllerLockSha256 = 'not-a-digest'; },
|
|
input => { input.defaults.durationMs = 5 * 3_600_000; },
|
|
input => { input.defaults.schema = 'paperclip.runner-eval.hosted-policy/v1'; },
|
|
];
|
|
for (const change of changes) {
|
|
const input = structuredClone(c.input); change(input);
|
|
assert.throws(() => prepareHostedManifest(input));
|
|
}
|
|
const input = structuredClone(c.input); input.maxUsd = '0.01';
|
|
assert.throws(() => prepareHostedManifest(input), /budget/);
|
|
});
|
|
|
|
test('hosted admission rejects local and fleet origins while preserving retained manifests', async t => {
|
|
const c = await campaign(t);
|
|
const original = c.build().manifest;
|
|
for (const runner of ['local', 'aws-fleet']) {
|
|
const input = structuredClone(c.input); input.defaults.overhead.runner = runner;
|
|
assert.throws(() => prepareHostedManifest(input), /GitHub-hosted operating origin/);
|
|
const retained = structuredClone(original); retained.overhead.runner = runner;
|
|
assert.equal(validateManifest(retained, Date.now(), { admission: false }).manifest.overhead.runner, runner);
|
|
await writeFile(c.paths.manifest, canonical(retained));
|
|
await assert.rejects(verifyHostedCell({ manifestPath: c.paths.manifest, manifestSha256: sha256(canonical(retained)),
|
|
cellId: 'execution-0', environment: { ...c.environment, PAPERCLIP_RUNNER_EVAL_EXECUTION_ID: 'execution-0' } }), /GitHub-hosted operating origin/);
|
|
}
|
|
const local = structuredClone(original); delete local.hosted; local.overhead.runner = 'local';
|
|
assert.equal(validateManifest(local).manifest.overhead.runner, 'local');
|
|
});
|
|
|
|
test('worker preflight verifies identical manifest bytes and current cell/run before credentials', async t => {
|
|
const c = await campaign(t), { manifest, manifestSha256 } = c.build();
|
|
await writeFile(c.paths.manifest, canonical(manifest));
|
|
const input = { manifestPath: c.paths.manifest, manifestSha256, cellId: 'execution-0',
|
|
environment: { ...c.environment, PAPERCLIP_RUNNER_EVAL_EXECUTION_ID: 'execution-0' } };
|
|
assert.equal((await verifyHostedCell(input)).attempt.attemptId, 'attempt-0');
|
|
await assert.rejects(verifyHostedCell({ ...input, cellId: 'another-cell' }), /outside/);
|
|
await assert.rejects(verifyHostedCell({ ...input, environment: { ...input.environment, GITHUB_RUN_ATTEMPT: '2' } }), /first-attempt/);
|
|
await writeFile(c.paths.manifest, canonical(manifest) + '\n');
|
|
await assert.rejects(verifyHostedCell(input), /bytes changed/);
|
|
});
|
|
|
|
test('hosted CLI reports safe admission reasons without echoing private paths or malformed input', async () => {
|
|
assert.equal(hostedAdmissionDiagnostic(new Error('Reviewed cost bound configuration changed')), 'Reviewed cost bound configuration changed');
|
|
assert.equal(hostedAdmissionDiagnostic(new SyntaxError('SENSITIVE_SENTINEL')), 'A hosted input is not valid JSON');
|
|
assert.doesNotMatch(hostedAdmissionDiagnostic(new Error('private SENSITIVE_SENTINEL')), /SENSITIVE_SENTINEL/);
|
|
const result = await promisify(execFile)(process.execPath, [fileURLToPath(new URL('./hosted.mjs', runtime)), 'verify', '/nonexistent/SENSITIVE_SENTINEL', 'a'.repeat(64), 'cell'], {
|
|
env: { PATH: process.env.PATH, OPENAI_API_KEY: 'SENSITIVE_SENTINEL' }, timeout: 10000,
|
|
}).then(value => value, error => error);
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /A required hosted input file is missing/);
|
|
assert.doesNotMatch(result.stderr + result.stdout, /SENSITIVE_SENTINEL/);
|
|
});
|
|
|
|
|
|
test('model-readiness policy retains operating holds without requiring vendor cost proofs', async t => {
|
|
const c = await campaign(t);
|
|
const result = c.build();
|
|
assert.equal(result.manifest.overhead.accountingReadiness, 'model_requests');
|
|
assert.equal(result.manifest.observability.provisionalBound, undefined);
|
|
Object.assign(c.f.manifest, result.manifest); await c.f.save();
|
|
const missing = await summarizeControlledManifest(result.manifest, []);
|
|
assert.equal(missing.incompleteAttempts, 2);
|
|
assert.equal(missing.operatingHeldUsd, '0.004000000');
|
|
Object.assign(c.f.environment, c.environment, { PAPERCLIP_RUNNER_EVAL_EXECUTION_ID: 'execution-0' });
|
|
assert.equal((await c.f.run(0, () => success())).code, 0);
|
|
const first = await packControlledAttempt({ manifest: result.manifest, attemptId: 'attempt-0', environment: c.f.environment });
|
|
Object.assign(c.f.environment, { PAPERCLIP_RUNNER_EVAL_EXECUTION_ID: 'execution-1' });
|
|
assert.equal((await c.f.run(1, () => success())).code, 0);
|
|
const second = await packControlledAttempt({ manifest: result.manifest, attemptId: 'attempt-1', environment: c.f.environment });
|
|
const complete = await summarizeControlledManifest(result.manifest, [first, second]);
|
|
assert.equal(complete.incompleteAttempts, 0);
|
|
assert.equal(complete.operatingHeldUsd, '0.004000000');
|
|
assert.equal(complete.accountingReadiness, 'model_requests');
|
|
assert.equal(complete.operatingReconciliation, 'pending');
|
|
delete c.input.defaults.overhead.accountingReadiness;
|
|
assert.throws(() => c.build(), /operating bound/);
|
|
});
|
|
|
|
|
|
test('the campaign policy is derived from the selection and committed defaults', async t => {
|
|
const c = await campaign(t), now = Date.now(), result = c.build();
|
|
assert.equal(result.policy.maxCells, 2);
|
|
assert.equal(result.policy.schema, 'paperclip.runner-eval.hosted-policy/v2');
|
|
assert.equal(result.manifest.hosted.controllerLockSha256, 'e'.repeat(64));
|
|
assert.equal(result.manifest.models[0].provisionalBound, undefined);
|
|
// The validity window covers this campaign only; the committed table itself never expires.
|
|
assert.ok(Date.parse(result.manifest.pricingVerifiedAt) <= Date.now() && Date.parse(result.manifest.pricingVerifiedAt) >= now - 1000);
|
|
assert.ok(Date.parse(result.manifest.pricingExpiresAt) > Date.parse(result.manifest.deadlineAt));
|
|
assert.equal(result.manifest.overhead.expiresAt, result.manifest.pricingExpiresAt);
|
|
const later = structuredClone(c.input); later.now = now + 400 * 86_400_000;
|
|
later.environment = { ...later.environment };
|
|
assert.equal(hostedPolicy(later.defaults, { cellCount: 2, now: later.now, controllerLockSha256: 'e'.repeat(64) }).pricingVerifiedAt,
|
|
new Date(later.now).toISOString());
|
|
const capped = structuredClone(c.input); capped.maxUsd = '5';
|
|
assert.equal(prepareHostedManifest(capped).manifest.maxUsd, '5');
|
|
});
|
|
|
|
test('the committed defaults admit the full 41-case Claude plan within their spend cap', async () => {
|
|
// Under the portable runtime this reads the packaged copy that hosted.mjs prepare uses.
|
|
const defaults = JSON.parse(await readFile(new URL('./hosted-defaults.json', runtime), 'utf8'));
|
|
const now = Date.now(), projectId = '4dcaad06-b1ce-4416-ac28-482f1443d5ea';
|
|
const manifestFor = cells => {
|
|
const policy = hostedPolicy(defaults, { cellCount: cells, now, projectId, controllerLockSha256: 'e'.repeat(64) });
|
|
const attempts = Array.from({ length: cells }, (_, index) => ({ ...policy.allocations[0],
|
|
executionId: `protocol-live-testops-claude--case-${index}`, attemptId: `attempt-${index}`, requestSha256: 'a'.repeat(64) }));
|
|
return { schema: 'paperclip.runner-eval.controller/v1', campaignId: 'gha-1-1', maxUsd: policy.maxUsd,
|
|
deadlineAt: new Date(now + policy.durationMs).toISOString(), pricingVerifiedAt: policy.pricingVerifiedAt, pricingExpiresAt: policy.pricingExpiresAt,
|
|
route: policy.route, projectId: policy.projectId, models: policy.models, overhead: policy.overhead, observability: policy.observability,
|
|
sourceRevision: 'b'.repeat(40), definitionsRevision: 'c'.repeat(40),
|
|
runnerCli: { path: '/tmp/r/cli.js', sha256: '1'.repeat(64) }, runnerPackage: { path: '/tmp/r/p.tgz', sha256: '2'.repeat(64) },
|
|
runnerd: { path: '/tmp/r/runnerd', sha256: '3'.repeat(64) }, outputDirectory: '/tmp/r/evidence', attempts };
|
|
};
|
|
const full = validateManifest(manifestFor(41), now);
|
|
assert.equal(full.manifest.attempts.length, 41);
|
|
assert.equal(full.allocatedUsd, "339.000000000"); assert.ok(units(full.allocatedUsd) <= units(defaults.maxUsd));
|
|
assert.throws(() => validateManifest(manifestFor(49), now), /exceed the campaign budget/);
|
|
});
|