mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Its eval journals retain model costs and operating reservations under one cap. > - The operating policy records the controller's execution origin. > - The schema currently accepts only GitHub-hosted and AWS Fleet origins. > - A local controller must therefore use an inaccurate label or fail admission. > - This PR adds the local origin and keeps the hosted-only schedule gate. ## Linked Issues or Issue Description Refs #17986. No duplicate open PR or issue was found in the operating-overhead/local search. **What existing behavior does this improve?** The Product E2E operating allowance and its retained journal policy. **Current behavior** At master `40c1ab36cd`, an unscheduled local controller cannot set `runner: "local"`. The generic allowance validator rejects it. Local qualification can use the same accounting controls but cannot record its origin correctly. **Proposed behavior** Accept the local origin in the generic allowance. Keep all component, price, cap, expiry, and receipt rules. The scheduled validator still requires `github-hosted` and rejects a local allowance. **Reason and benefit** Local qualification can retain an accurate execution origin without claiming that its reservations are billed GitHub charges. ## What Changed - Add `local` to the operating allowance's runner type and validator. - Exercise local journal creation, model admission, retained overhead, restart, and reconciliation in the existing boundary test. - Check that a local allowance fails Product scheduled admission. - Pin new hosted Runner controller admission to `github-hosted`. Preserve original labels when reading historical manifests; those reads cannot authorize a new worker. - Exercise the hosted-origin rejection and historical/local reads in both source and copied portable runtimes. - Document the local label and its accounting limits. ## Verification - Before the fix: the new local persistence case and schedule-boundary assertion failed; eight tests passed. - Focused ledger, overhead, operations, and scheduled finalization tests: 71 passed across five files. - Controlled Runner source and portable tests: 86 passed. The new hosted-origin regression failed before the fix. - Full Product support suite at the first commit: 4,524 passed, three skipped, and one retained embedded PostgreSQL bootstrap failure. The same database failure was previously reproduced on a clean baseline. - Separate Node test phase on the host: 137 passed. - Actual Linux controller: database lifecycle tests pass. After installing its missing `zip` utility, the full Product suite has 4,527 passes and one Copilot parent-version coverage failure. That same failure reproduces on clean master `40c1ab36cd` with the same Linux filesystem and dependencies (14 passes, one failure). Copilot files are unchanged. The initial seven ZIP-related failures and the watcher failure remain retained. - The final fresh Linux semantic check passes: `GOMEMLIMIT=2GiB GOGC=50 GOMAXPROCS=1 node node_modules/typescript/bin/tsc --singleThreaded -p tests/runner-e2e/tsconfig.json`. The parallel compiler OOM is retained. No semantic checks were omitted. - Both fresh reviews are 5/5, with the addressed thread resolved. Hosted Build first failed during unchanged Pi setup prefetch with an operation timeout. One job rerun recovered; the original failure remains retained. All 58 latest-head checks are now acceptable. - No paid model requests, sandbox creation, schedule changes, or historical journal edits. ## Risks - The caller supplies this origin. It must describe the actual controller. - `local` does not prove zero compute cost, provide a default allowance, or release a hold. - Both maintained hosted admission paths require the GitHub-hosted controller origin. A previously accepted hosted policy with an AWS/local label now fails new admission. Historical reads preserve the original evidence. ## Model Used - OpenAI GPT-6 through Codex, with tool use and code execution. The exact serving model ID and context window are not exposed in 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>
131 lines
11 KiB
JavaScript
131 lines
11 KiB
JavaScript
import { readFile, mkdir, writeFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { isEntrypoint } from './entrypoint.mjs';
|
|
import { assertHostedAdmission, canonical, costRuntime, fields, observabilityBoundConfiguration, requestBindingSha256, sha256, validateManifest } from './manifest.mjs';
|
|
import { readRegularFile } from './evidence-files.mjs';
|
|
|
|
export function hostedPaths(runId) {
|
|
if (typeof runId !== 'string' || !/^[1-9][0-9]{0,19}$/.test(runId)) throw new Error('Invalid hosted run ID');
|
|
const root = `/tmp/paperclip-runner-controlled-${runId}`;
|
|
return { root, runner: join(root, 'runner'), controller: join(root, 'runtime'),
|
|
manifest: join(root, 'controller-manifest.json'), evidence: join(root, 'evidence') };
|
|
}
|
|
|
|
export function hostedPolicyConfiguration(policy) {
|
|
const { capacityBound, ...configuration } = policy;
|
|
return configuration;
|
|
}
|
|
|
|
export function prepareHostedManifest({ catalog, catalogSha256, prepared, policy, artifacts, environment, now = Date.now() }) {
|
|
fields(policy, ['schema', 'maxUsd', 'durationMs', 'pricingVerifiedAt', 'pricingExpiresAt', 'route', 'projectId', 'models', 'overhead', 'observability', 'allocations', 'maxCells', 'capacityBound', 'controllerLockSha256']);
|
|
if (policy.schema !== 'paperclip.runner-eval.hosted-policy/v1' || !policy.observability
|
|
|| !Number.isSafeInteger(policy.durationMs) || policy.durationMs < 1 || policy.durationMs > 4 * 3_600_000
|
|
|| !Number.isSafeInteger(policy.maxCells) || policy.maxCells < 1 || policy.maxCells > 512
|
|
|| !Array.isArray(policy.allocations) || !policy.allocations.length) throw new Error('A bounded hosted policy with tracing is required');
|
|
if (!/^[a-f0-9]{64}$/.test(policy.controllerLockSha256 ?? '')) throw new Error('Invalid hosted controller lock binding');
|
|
if (policy.overhead?.accountingReadiness !== 'model_requests' || policy.capacityBound !== undefined) {
|
|
costRuntime.reservationBound('operating_allowance', policy.capacityBound, hostedPolicyConfiguration(policy));
|
|
}
|
|
fields(policy.observability, ['projectId', 'capture', 'nativeLimits']);
|
|
const runId = environment.GITHUB_RUN_ID, paths = hostedPaths(runId);
|
|
if (prepared.schema !== 'paperclip.runner-eval.prepared-requests/v1' || prepared.githubRunId !== runId || prepared.githubRunAttempt !== 1
|
|
|| prepared.campaignId !== `gha-${runId}-1` || prepared.catalogSha256 !== catalogSha256
|
|
|| catalog.schema !== 'paperclip.runner-protocol-eval.catalog/v1' || catalog.campaignId !== prepared.campaignId
|
|
|| prepared.sourceRevision !== environment.GITHUB_SHA || prepared.sourceRevision !== environment.PAPERCLIP_PROTOCOL_EVAL_SOURCE_SHA || prepared.sourceRevision !== catalog.source?.paperclipSha
|
|
|| prepared.definitionsRevision !== environment.PAPERCLIP_PROTOCOL_EVALS_SHA || prepared.definitionsRevision !== catalog.source?.evalsSha
|
|
|| !Array.isArray(prepared.cells) || !prepared.cells.length || prepared.cells.length > policy.maxCells
|
|
|| !Array.isArray(catalog.cells) || catalog.cells.length !== prepared.cells.length
|
|
|| !Array.isArray(catalog.matrices) || catalog.matrices.length !== 2) throw new Error('Prepared selection does not match the authorized catalog');
|
|
const indexed = new Map(catalog.cells.map(cell => [cell.cellId, cell]));
|
|
const scheduled = catalog.matrices.flatMap(matrix => Array.isArray(matrix.include) ? matrix.include : []);
|
|
if (indexed.size !== catalog.cells.length || scheduled.length !== catalog.cells.length
|
|
|| new Set(scheduled.map(cell => cell.cellId)).size !== indexed.size
|
|
|| scheduled.some(cell => canonical(cell) !== canonical(indexed.get(cell.cellId)))) throw new Error('Every selected cell must occur exactly once');
|
|
const key = item => canonical([item.provider, item.acpxAgent ?? null, item.model]);
|
|
const allocations = new Map();
|
|
for (const allocation of policy.allocations) {
|
|
fields(allocation, ['provider', 'acpxAgent', 'model', 'maxUsd', 'maxRequests', 'maxConcurrent', 'timeoutMs']);
|
|
if (allocations.has(key(allocation))) throw new Error('Duplicate hosted allocation policy');
|
|
allocations.set(key(allocation), allocation);
|
|
}
|
|
const seen = new Set();
|
|
const attempts = prepared.cells.map(cell => {
|
|
const request = cell.request, expected = indexed.get(cell.cellId), allocation = allocations.get(key(request));
|
|
if (!expected || seen.has(cell.cellId) || !allocation || cell.runId !== `gha-${runId}-1-${cell.cellId}`
|
|
|| request.schema !== 'paperclip-runner/eval-session-request/v1'
|
|
|| !(request.provider === 'codex' && !request.acpxAgent || request.provider === 'acpx' && request.acpxAgent === 'claude')
|
|
|| request.driver !== (request.provider === 'codex' ? 'codex_app_server' : 'acpx_runtime') || request.driver !== expected.driver
|
|
|| request.provider !== expected.provider || (request.acpxAgent ?? null) !== (expected.acpxAgent ?? null) || request.model !== expected.model
|
|
|| request.runnerd?.path !== join(paths.runner, 'paperclip-runnerd') || request.runnerd.sha256?.replace(/^sha256:/, '') !== artifacts.runnerd.sha256
|
|
|| prepared.runnerd?.path !== request.runnerd.path || prepared.runnerd.sha256 !== request.runnerd.sha256) throw new Error('Unsupported or mismatched controlled cell');
|
|
seen.add(cell.cellId);
|
|
return { ...allocation, executionId: cell.cellId, attemptId: request.attemptId, requestSha256: requestBindingSha256(request) };
|
|
});
|
|
const manifest = { schema: 'paperclip.runner-eval.controller/v1', campaignId: prepared.campaignId,
|
|
maxUsd: policy.maxUsd, deadlineAt: new Date(now + policy.durationMs).toISOString(), pricingVerifiedAt: policy.pricingVerifiedAt,
|
|
pricingExpiresAt: policy.pricingExpiresAt, route: policy.route, ...(policy.projectId ? { projectId: policy.projectId } : {}),
|
|
models: policy.models, overhead: policy.overhead, observability: { ...policy.observability },
|
|
sourceRevision: prepared.sourceRevision, definitionsRevision: prepared.definitionsRevision, ...artifacts,
|
|
outputDirectory: paths.evidence, hosted: { repository: environment.GITHUB_REPOSITORY, runId, runAttempt: 1, workflowRevision: environment.GITHUB_SHA, controllerLockSha256: policy.controllerLockSha256, policySha256: sha256(canonical(policy)) }, attempts };
|
|
if (policy.capacityBound !== undefined) manifest.observability.provisionalBound = { ...policy.capacityBound, configurationSha256: sha256(canonical(observabilityBoundConfiguration(manifest))) };
|
|
const validated = validateManifest(manifest, now);
|
|
assertHostedAdmission(manifest, attempts[0], { ...environment, PAPERCLIP_RUNNER_EVAL_EXECUTION_ID: attempts[0].executionId });
|
|
return validated;
|
|
}
|
|
|
|
export async function verifyHostedCell({ manifestPath, manifestSha256, cellId, environment }) {
|
|
const bytes = await readRegularFile(manifestPath, 16 * 1_048_576);
|
|
if (sha256(bytes) !== manifestSha256) throw new Error('Hosted manifest bytes changed');
|
|
const { manifest } = validateManifest(JSON.parse(bytes.toString('utf8')));
|
|
const attempt = manifest.attempts.find(item => item.executionId === cellId);
|
|
if (!attempt) throw new Error('Cell is outside the controlled selection');
|
|
assertHostedAdmission(manifest, attempt, environment);
|
|
return { manifest, attempt };
|
|
}
|
|
|
|
const safeAdmissionReasons = new Set([
|
|
'Invalid hosted run ID', 'Invalid hosted controller lock binding', 'Invalid controller fields', 'A bounded hosted policy with tracing is required',
|
|
'Invalid reviewed cost bound evidence', 'Reviewed cost bound configuration changed',
|
|
'Prepared selection does not match the authorized catalog', 'Every selected cell must occur exactly once',
|
|
'Duplicate hosted allocation policy', 'Unsupported or mismatched controlled cell',
|
|
'Invalid controller campaign identity or validity window', 'Controller requires current full-lifecycle operating bounds',
|
|
'Invalid controlled campaign selection', 'Invalid controller executable provenance', 'Invalid hosted controller identity',
|
|
'Invalid controller model pricing', 'Invalid controlled attempt allocation', 'Controlled allocations exceed the campaign budget',
|
|
'Incomplete operating overhead allowance', 'Operating overhead leaves no model allowance',
|
|
'Invalid operating overhead component', 'Invalid operating overhead fields', 'Invalid controller observability allocation',
|
|
'Observability capacity requires its operating bound evidence', 'Hosted manifest bytes changed',
|
|
'Cell is outside the controlled selection', 'Hosted controller admission does not match the current first-attempt worker',
|
|
'Hosted controller requires GitHub-hosted operating origin',
|
|
]);
|
|
export function hostedAdmissionDiagnostic(error) {
|
|
if (error instanceof Error && safeAdmissionReasons.has(error.message)) return error.message;
|
|
if (error?.code === 'ENOENT') return 'A required hosted input file is missing';
|
|
if (error instanceof SyntaxError) return 'A hosted input is not valid JSON';
|
|
return 'Invalid or unreadable hosted input; check the catalog, policy and runtime bindings';
|
|
}
|
|
|
|
if (await isEntrypoint(import.meta.url)) {
|
|
try {
|
|
const [command, ...args] = process.argv.slice(2);
|
|
if (command === 'prepare' && args.length === 5) {
|
|
const [catalogPath, preparedPath, policyPath, runner, output] = args;
|
|
const json = async path => JSON.parse((await readRegularFile(path, 16 * 1_048_576)).toString('utf8'));
|
|
const catalogBytes = await readRegularFile(catalogPath, 16 * 1_048_576);
|
|
const prepared = await json(preparedPath), paths = hostedPaths(process.env.GITHUB_RUN_ID), artifacts = {};
|
|
for (const [name, relative] of [['runnerCli', 'portable/dist/cli/eval-session.js'], ['runnerPackage', 'paperclip-runner.tgz'], ['runnerd', 'paperclip-runnerd']]) {
|
|
artifacts[name] = { path: join(paths.runner, relative), sha256: sha256(await readFile(join(runner, relative))) };
|
|
}
|
|
const result = prepareHostedManifest({ catalog: JSON.parse(catalogBytes), catalogSha256: sha256(catalogBytes), prepared,
|
|
policy: await json(policyPath), artifacts, environment: process.env });
|
|
await mkdir(output, { mode: 0o700 });
|
|
await writeFile(join(output, 'controller-manifest.json'), canonical(result.manifest), { flag: 'wx', mode: 0o600 });
|
|
console.log(JSON.stringify({ manifestSha256: result.manifestSha256, cells: result.manifest.attempts.length,
|
|
allocatedUsd: result.allocatedUsd, providerCalls: 0 }));
|
|
} else if (command === 'verify' && args.length === 3) {
|
|
await verifyHostedCell({ manifestPath: args[0], manifestSha256: args[1], cellId: args[2], environment: process.env });
|
|
} else throw new Error('Usage: hosted.mjs <prepare catalog prepared policy runner output | verify manifest digest cell>');
|
|
} catch (error) {
|
|
console.error(`Hosted campaign admission failed: ${hostedAdmissionDiagnostic(error)}. No provider work was started by this command.`); process.exitCode = 2;
|
|
}
|
|
}
|