mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
## Thinking Path > - Paperclip must account for paid eval work. > - The maintained Runner workflow distributes one immutable controller manifest. > - Its cost gate currently requires vendor proofs for every operating charge. > - Model-spend qualification must also work under an explicit policy that keeps operating charges separate. > - This PR applies the shared model-readiness policy to the hosted controller. > - It retains every operating hold and still rejects missing model receipts and incomplete workers. ## Linked Issues or Issue Description Refs #17928, #17889. **What happened?** Runner qualification requires full-lifecycle vendor operating proofs even when the operator selects model-request readiness. That blocks the model-token qualification path. **Expected behavior** An explicit `overhead.accountingReadiness: "model_requests"` policy permits absent operating cost proofs. Supplied proofs still validate. Complete final model receipts are required. **Steps to reproduce** Prepare a two-worker hosted manifest with the explicit policy and no vendor cost proofs. Preserve missing-worker allocations, then complete both workers and inspect the private archive summary. A second test returns a provider refusal without terminal usage and requires failure with the full hold. ## What Changed - Apply the shared explicit policy to hosted capacity and observability cost-proof admission. - Preserve pricing windows, model bounds, immutable source/request/runtime identities, trace quotas and every allocation. - Reject zero or unresolved model requests in this policy, even if a request has a provisional bound. - Retain operating amounts and pending reconciliation through campaign and private archive summaries. - Document the separate model-readiness contract. Preserve strict behavior when the field is absent. ## Verification - Regression first failed at the original vendor-bound gate. - All 80 controlled-eval Node checks pass, including source and copied portable runtime checks, archived partial/missing workers, credential exclusion and model receipt failure. - Product readiness prerequisite #17928 has its own eval unit/typecheck and full CI checks. - No paid workflow, credential change or recurring activation is part of this source PR. ## Risks This opt-in policy does not prove a full-lifecycle operating-cost ceiling. The report retains operating holds and pending reconciliation. Default policies retain the existing vendor-proof gate. Source and worker identity checks remain required. A model refusal without final usage now fails readiness even if its reservation has a reviewed bound. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, repository inspection and code execution. The exact serving model ID and context window are not exposed to this session. ## 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
171 lines
13 KiB
JavaScript
171 lines
13 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 } from './manifest.mjs';
|
|
const runtime = process.env.PAPERCLIP_CONTROLLER_TEST_RUNTIME ? pathToFileURL(resolve(process.env.PAPERCLIP_CONTROLLER_TEST_RUNTIME) + '/') : import.meta.url;
|
|
const { hostedPaths, hostedPolicyConfiguration, 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 })) };
|
|
for (const component of f.manifest.overhead.components) component.provisionalBound.evidenceSha256 = sha256(component.component);
|
|
const { provisionalBound, ...observability } = f.manifest.observability;
|
|
const { executionId, attemptId, requestSha256, ...allocation } = f.manifest.attempts[0];
|
|
const policy = { schema: 'paperclip.runner-eval.hosted-policy/v1', controllerLockSha256: 'e'.repeat(64), maxUsd: f.manifest.maxUsd, maxCells: 2, durationMs: 60000,
|
|
pricingVerifiedAt: f.manifest.pricingVerifiedAt, pricingExpiresAt: f.manifest.pricingExpiresAt,
|
|
route: 'direct', models: f.manifest.models, overhead: f.manifest.overhead, observability, allocations: [allocation] };
|
|
policy.capacityBound = { version: 1, scope: 'full_lifecycle', evidenceSha256: f.manifest.overhead.components.find(component => component.component === 'observability').provisionalBound.evidenceSha256, configurationSha256: sha256(canonical(hostedPolicyConfiguration(policy))) };
|
|
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, policy, 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.policy.maxUsd = '0.01'; },
|
|
input => { input.environment.GITHUB_RUN_ATTEMPT = '2'; },
|
|
input => { input.environment.GITHUB_SHA = 'wrong'; },
|
|
input => { input.environment.GITHUB_SHA = 'c'.repeat(40); },
|
|
input => { delete input.policy.observability; },
|
|
input => { delete input.policy.controllerLockSha256; },
|
|
input => { input.policy.controllerLockSha256 = 'f'.repeat(64); },
|
|
];
|
|
for (const change of changes) {
|
|
const input = structuredClone(c.input); change(input);
|
|
assert.throws(() => prepareHostedManifest(input));
|
|
}
|
|
const mixed = structuredClone(c.input);
|
|
mixed.policy.capacityBound.evidenceSha256 = mixed.policy.overhead.components.find(component => component.component !== 'observability').provisionalBound.evidenceSha256;
|
|
assert.throws(() => prepareHostedManifest(mixed), /Observability capacity requires its operating bound evidence/);
|
|
const input = structuredClone(c.input); input.policy.maxUsd = '0.01';
|
|
input.policy.capacityBound.configurationSha256 = sha256(canonical(hostedPolicyConfiguration(input.policy)));
|
|
assert.throws(() => prepareHostedManifest(input), /budget/);
|
|
});
|
|
|
|
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);
|
|
c.input.policy.overhead.accountingReadiness = 'model_requests';
|
|
for (const component of c.input.policy.overhead.components) delete component.provisionalBound;
|
|
delete c.input.policy.capacityBound;
|
|
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.policy.overhead.accountingReadiness;
|
|
assert.throws(() => c.build(), /cost bound|operating bound/);
|
|
});
|
|
|
|
|
|
test('model readiness still validates supplied operating and capacity proofs', async t => {
|
|
const c = await campaign(t), policy = c.input.policy;
|
|
policy.overhead.accountingReadiness = 'model_requests';
|
|
for (const component of policy.overhead.components) {
|
|
component.provisionalBound.configurationSha256 = sha256(canonical({ accountingReadiness: 'model_requests',
|
|
reference: policy.overhead.reference, runner: policy.overhead.runner, verifiedAt: policy.overhead.verifiedAt,
|
|
expiresAt: policy.overhead.expiresAt, component: { component: component.component, maxUsd: component.maxUsd, reference: component.reference } }));
|
|
}
|
|
policy.capacityBound.configurationSha256 = sha256(canonical(hostedPolicyConfiguration(policy)));
|
|
assert.equal(c.build().manifest.overhead.accountingReadiness, 'model_requests');
|
|
const mismatch = structuredClone(c.input); mismatch.policy.capacityBound.evidenceSha256 = '0'.repeat(64);
|
|
assert.throws(() => prepareHostedManifest(mismatch), /Observability capacity requires its operating bound evidence/);
|
|
const stale = structuredClone(c.input); stale.policy.capacityBound.configurationSha256 = '0'.repeat(64);
|
|
assert.throws(() => prepareHostedManifest(stale), /Reviewed cost bound configuration changed/);
|
|
const absent = structuredClone(c.input);
|
|
for (const component of absent.policy.overhead.components) delete component.provisionalBound;
|
|
absent.policy.capacityBound.configurationSha256 = sha256(canonical(hostedPolicyConfiguration(absent.policy)));
|
|
assert.throws(() => prepareHostedManifest(absent), /Observability capacity requires its operating bound evidence/);
|
|
});
|