mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +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>
147 lines
11 KiB
JavaScript
147 lines
11 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { createHash } from 'node:crypto';
|
|
import { execFile } from 'node:child_process';
|
|
import { mkdtemp, mkdir, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import { promisify } from 'node:util';
|
|
import { test } from 'node:test';
|
|
import { buildControllerRuntime } from './build-runtime.mjs';
|
|
import { encodeRuntimeReceipt, RUNTIME_MAX_RECEIPT_BYTES, verifyControllerRuntime } from './verify-runtime.mjs';
|
|
|
|
const exec = promisify(execFile);
|
|
const source = dirname(fileURLToPath(import.meta.url));
|
|
const isolatedProbe = `
|
|
import assert from 'node:assert/strict';
|
|
import { createHash } from 'node:crypto';
|
|
import { registerHooks, isBuiltin, createRequire } from 'node:module';
|
|
import { realpath } from 'node:fs/promises';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { join } from 'node:path';
|
|
const root = await realpath(process.argv[2]), prefix = pathToFileURL(root + '/').href;
|
|
registerHooks({ resolve(specifier, context, nextResolve) {
|
|
const result = nextResolve(specifier, context);
|
|
if (context.parentURL?.startsWith(prefix) && !isBuiltin(result.url) && !result.url.startsWith(prefix)) throw new Error('Runtime depends on a file outside its artifact');
|
|
return result;
|
|
} });
|
|
const { costRuntime } = await import(prefix + 'controlled-evals/manifest.mjs');
|
|
const protobuf = createRequire(join(root, 'server/package.json'))('protobufjs');
|
|
const type = protobuf.parse('syntax = "proto3"; message Value { int64 int_value = 3; } message Log { fixed64 time_unix_nano = 1; Value body = 5; } message Scope { repeated Log log_records = 2; } message Resource { repeated Scope scope_logs = 2; } message Data { repeated Resource resource_logs = 1; }').root.lookupType('Data');
|
|
const received = [];
|
|
const receiptPath = join(process.cwd(), 'protobuf-receipt.jsonl');
|
|
const relay = await costRuntime.startEvalOtlpRelay({ projectId: '11111111-1111-4111-8111-111111111111', apiKey: 'synthetic-only',
|
|
campaignId: 'portable', attemptId: 'portable-protobuf', receiptPath, deadlineAt: Date.now() + 10000,
|
|
transport: async (_url, init) => { received.push(type.decode(Buffer.from(init.body)).toJSON()); return Response.json({}); } });
|
|
const varint = number => { let value = BigInt(number), bytes = []; do { const byte = Number(value & 127n); value >>= 7n; bytes.push(byte | (value ? 128 : 0)); } while (value); return Buffer.from(bytes); };
|
|
const message = (field, ...parts) => { const bytes = Buffer.concat(parts); return Buffer.concat([varint(field * 8 + 2), varint(bytes.length), bytes]); };
|
|
const nanos = '1792879903123456789', integer = '9223372036854775806';
|
|
const timestamp = Buffer.alloc(9); timestamp[0] = 9; timestamp.writeBigUInt64LE(BigInt(nanos), 1);
|
|
const body = message(1, message(2, message(2, timestamp, message(5, varint(24), varint(integer)))));
|
|
try {
|
|
const result = await fetch(relay.baseUrl + '/v1/logs', { method: 'POST', headers: { authorization: 'Bearer ' + relay.token, 'content-type': 'application/x-protobuf' }, body });
|
|
assert.equal(result.status, 200); await result.arrayBuffer();
|
|
assert.equal(received.length, 1);
|
|
const record = received[0].resourceLogs[0].scopeLogs[0].logRecords[0];
|
|
assert.equal(record.timeUnixNano, nanos); assert.equal(record.body.intValue, integer);
|
|
} finally { await relay.close(); }
|
|
console.log('portable protobuf preserves 64-bit values');
|
|
`;
|
|
|
|
test('copied controller runs without source dependencies, retains native evidence, and detects artifact changes', { timeout: 120_000 }, async t => {
|
|
const root = await mkdtemp(join(tmpdir(), 'runner-portable-controller-'));
|
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
const built = join(root, 'build'), directory = join(root, 'relocated');
|
|
const sourceRevision = 'a'.repeat(40);
|
|
const receipt = await buildControllerRuntime({ outputDirectory: built, sourceRevision });
|
|
await assert.rejects(buildControllerRuntime({ outputDirectory: built, sourceRevision }), { code: 'EEXIST' });
|
|
await rename(built, directory);
|
|
const input = { directory, sourceRevision, receiptSha256: receipt.receiptSha256 };
|
|
assert.equal((await verifyControllerRuntime(input)).totalBytes, receipt.totalBytes);
|
|
const rawReceipt = JSON.parse(await readFile(join(directory, 'runtime.json'), 'utf8'));
|
|
assert(encodeRuntimeReceipt(rawReceipt).length < RUNTIME_MAX_RECEIPT_BYTES);
|
|
const manyInputs = Object.fromEntries(Array.from({ length: 25_000 }, (_, index) => [`src/part-${index}.ts`, 'a'.repeat(64)]));
|
|
assert.throws(() => encodeRuntimeReceipt({ ...rawReceipt, inputs: manyInputs }), /receipt exceeds its byte allowance/);
|
|
const environment = { PATH: process.env.PATH, ...(process.env.LANG ? { LANG: process.env.LANG } : {}) };
|
|
const probe = join(root, 'probe.mjs'); await writeFile(probe, isolatedProbe);
|
|
const isolated = await exec(process.execPath, [probe, directory], { cwd: root, env: environment, timeout: 20_000 });
|
|
assert.match(isolated.stdout, /preserves 64-bit values/);
|
|
const privateArchive = exec(process.execPath, ['--input-type=module', '-'], { cwd: root, env: environment, timeout: 10000 });
|
|
privateArchive.child.stdin.end(`
|
|
const { archivePrivateEvidence } = await import(${JSON.stringify(pathToFileURL(join(directory, 'private-history.mjs')).href)});
|
|
await archivePrivateEvidence('pack', ${JSON.stringify(join(directory, 'controlled-evals'))}, ${JSON.stringify(join(root, 'private.zip'))});
|
|
await archivePrivateEvidence('unpack', ${JSON.stringify(join(root, 'private.zip'))}, ${JSON.stringify(join(root, 'restored'))});
|
|
console.log('portable private archive round trip');
|
|
`);
|
|
assert.match((await privateArchive).stdout, /portable private archive round trip/);
|
|
assert.equal(await readFile(join(root, 'restored/hosted.mjs'), 'utf8'), await readFile(join(directory, 'controlled-evals/hosted.mjs'), 'utf8'));
|
|
const stdin = exec(process.execPath, ['--input-type=module', '-'], { cwd: root, env: environment, timeout: 10_000 });
|
|
stdin.child.stdin.end(`
|
|
for (const name of ['run.mjs', 'archive.mjs', 'report.mjs', 'hosted.mjs', 'verify-runtime.mjs']) await import(${JSON.stringify(pathToFileURL(join(directory, 'controlled-evals') + '/').href)} + name);
|
|
await import(${JSON.stringify(pathToFileURL(join(source, 'build-runtime.mjs')).href)});
|
|
const { isEntrypoint } = await import(${JSON.stringify(pathToFileURL(join(directory, 'controlled-evals/entrypoint.mjs')).href)});
|
|
if (await isEntrypoint(import.meta.url, '/missing/entrypoint.mjs')) throw new Error('Missing entrypoint was accepted');
|
|
console.log('stdin imports are side-effect free');
|
|
`);
|
|
assert.match((await stdin).stdout, /stdin imports are side-effect free/);
|
|
const cases = 'real child requests|disjoint concurrent|controller owns native|cancellation during root export|campaign collection retains and audits private archives|native post-child otlp-native.jsonl rejects fifo|credential redaction replaces|missing provider artifacts|hosted workers reject reruns|hosted context requires a manifest|failed final OTLP receipt';
|
|
const behavioral = await exec(process.execPath, ['--test', '--test-reporter=tap', '--test-name-pattern', cases, join(source, 'controller.test.mjs')], {
|
|
cwd: root, env: { ...environment, PAPERCLIP_CONTROLLER_TEST_RUNTIME: join(directory, 'controlled-evals') }, timeout: 80_000, maxBuffer: 1_048_576,
|
|
});
|
|
assert.match(behavioral.stdout, /# pass 12\b/);
|
|
const hosted = await exec(process.execPath, ['--test', '--test-reporter=tap', join(source, 'hosted.test.mjs')], {
|
|
cwd: root, env: { ...environment, PAPERCLIP_CONTROLLER_TEST_RUNTIME: join(directory, 'controlled-evals') }, timeout: 30000, maxBuffer: 1_048_576,
|
|
});
|
|
assert.match(hosted.stdout, /# pass 7\b/);
|
|
|
|
|
|
const cli = await exec(process.execPath, [join(directory, 'controlled-evals/archive.mjs'), 'report-attempts', join(root, 'empty-attempts')], {
|
|
cwd: root, env: environment, timeout: 10_000,
|
|
}).then(value => value, error => error);
|
|
assert.equal(cli.code, 1); assert.match(cli.stderr, /ENOENT|no such file/);
|
|
const entryAlias = join(root, 'run-alias.mjs'); await symlink(join(directory, 'controlled-evals/run.mjs'), entryAlias);
|
|
const runCli = await exec(process.execPath, [entryAlias], { cwd: root, env: environment, timeout: 10_000 })
|
|
.then(value => value, error => error);
|
|
assert.equal(runCli.code, 2); assert.match(runCli.stderr, /admission or retention failed/);
|
|
await assert.rejects(verifyControllerRuntime({ ...input, sourceRevision: 'b'.repeat(40) }), /Invalid runtime receipt/);
|
|
await assert.rejects(verifyControllerRuntime({ ...input, receiptSha256: '0'.repeat(64) }), /digest mismatch/);
|
|
const file = join(directory, 'controlled-evals/run.mjs'), bytes = await readFile(file);
|
|
await writeFile(file, Buffer.concat([bytes, Buffer.from('\n')]));
|
|
await assert.rejects(verifyControllerRuntime(input), /oversized|content mismatch/);
|
|
await rm(file); await assert.rejects(verifyControllerRuntime(input), /inventory mismatch/);
|
|
await symlink(probe, file); await assert.rejects(verifyControllerRuntime(input), /nonregular entry/);
|
|
await rm(file); await writeFile(file, bytes);
|
|
const extra = join(directory, 'unexpected.mjs'); await writeFile(extra, 'throw new Error()');
|
|
await assert.rejects(verifyControllerRuntime(input), /Unexpected runtime artifact/);
|
|
await rm(extra);
|
|
assert.equal((await verifyControllerRuntime(input)).receiptSha256, receipt.receiptSha256);
|
|
});
|
|
|
|
|
|
test('only the root runtime receipt is excluded from the verified file inventory', async t => {
|
|
const root = await mkdtemp(join(tmpdir(), 'runner-runtime-nested-receipt-'));
|
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
const sourceRevision = 'a'.repeat(40), files = {};
|
|
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
let payloadBytes = 0;
|
|
for (const name of ['controlled-evals/run.mjs', 'controlled-evals/archive.mjs', 'controlled-evals/runtime.mjs', 'dependency/runtime.json']) {
|
|
const bytes = Buffer.from(name.endsWith('.json') ? '{"dependency":true}' : 'export {};');
|
|
await mkdir(dirname(join(root, name)), { recursive: true });
|
|
await writeFile(join(root, name), bytes);
|
|
files[name] = { sizeBytes: bytes.length, sha256: digest(bytes) }; payloadBytes += bytes.length;
|
|
}
|
|
const receipt = encodeRuntimeReceipt({ schema: 'paperclip.runner-eval.controller-runtime/v1', sourceRevision, nodeMajor: 24, payloadBytes, files });
|
|
await writeFile(join(root, 'runtime.json'), receipt);
|
|
const input = { directory: root, sourceRevision, receiptSha256: digest(receipt) };
|
|
assert.equal((await verifyControllerRuntime(input)).files, 5);
|
|
const nested = join(root, 'dependency/runtime.json'), original = await readFile(nested);
|
|
await writeFile(nested, Buffer.from('{"dependency":fake}'));
|
|
await assert.rejects(verifyControllerRuntime(input), /content mismatch|oversized/);
|
|
await writeFile(nested, original);
|
|
await mkdir(join(root, 'dependency/deeper'));
|
|
await writeFile(join(root, 'dependency/deeper/runtime.json'), '{}');
|
|
await assert.rejects(verifyControllerRuntime(input), /Unexpected runtime artifact file/);
|
|
await rm(join(root, 'dependency/deeper'), { recursive: true });
|
|
assert.equal((await verifyControllerRuntime(input)).files, 5);
|
|
});
|