mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 05:16:50 +01:00
## Thinking Path
> - Paperclip coordinates agent work through Runner.
> - Controlled evals route model calls through a budget gateway.
> - The definitions pin each ACPX runtime identity.
> - A stale qualified profile currently reaches a paid turn before
scoring rejects it.
> - The Runner CLI already checks an exact expected profile before it
creates a provider.
> - This change forwards that existing admission argument through the
controller.
> - Profile drift can then stop before model spending.
## Linked Issues or Issue Description
Refs #17943 and #17929.
**What happened?**
The worker in run 40618022700 passed all four behavior checks and
retained three final request receipts. Its grader then rejected the old
Claude SDK pin. The controlled wrapper could not pass the existing
`--expected-acpx-profile` argument to the built CLI.
**What did you expect to happen?**
The built CLI must reject a stale expected profile before provider
creation.
**Steps to reproduce**
Run a controlled ACPX cell with a definition that pins a different SDK
from the built Runner. The old wrapper accepts only request and output
arguments. It cannot enforce the profile admission supplied by updated
definitions.
**Paperclip version**
The observed run used e4b30163bb.
**Deployment mode**
The controlled GitHub Actions Runner protocol workflow.
## What Changed
- Forward bounded expected-profile JSON through the controlled wrapper
to the same built CLI.
- Reject invalid objects, oversized arguments, and profiles on non-ACPX
requests before opening evidence.
- Exercise a real child boundary and the actual qualified Claude CLI
admission gate.
- Document the definition/controller compatibility requirement.
## Verification
- Controller suite: 77 tests passed. The new regressions fail against
the original wrapper.
- Hosted and portable controller suites: 8 tests passed.
- Actual eval-session CLI contract suite: 39 tests passed. A stale
qualified Claude SDK never creates a provider service.
- Runner package typecheck passed.
- The non-ACPX rejection guard also passed its focused regression after
review.
- No live provider request was made for this change.
- Full repository checks and current-head reviews run on this PR.
## Risks
- Updated definitions require this wrapper before they can pass the new
argument. Merge the controller first.
- Profile identity remains owned by the built Runner CLI. The controller
only forwards its existing argument.
- Existing callers that omit the argument retain their current behavior.
- Optional review suggestions to duplicate the expected-profile hash
into controller evidence and add a full built-CLI/controller integration
case are deferred. The pinned definition revision retains the expected
profile, and both controller entrypoints plus the actual CLI admission
boundary are covered. A compatibility error fails closed without a paid
fallback.
## Model Used
OpenAI Codex, GPT-6 family, with tool execution and extended reasoning.
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>
242 lines
18 KiB
JavaScript
242 lines
18 KiB
JavaScript
import { modelReadinessDefect } from './manifest.mjs';
|
|
import { isEntrypoint } from './entrypoint.mjs';
|
|
import { spawn } from 'node:child_process';
|
|
import { once } from 'node:events';
|
|
import { lstat, mkdir, open, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
|
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { parseArgs } from 'node:util';
|
|
import { startControlledObservability } from './observability.mjs';
|
|
import { limitFor, readRegularFile, replaceEvidenceFile } from './evidence-files.mjs';
|
|
import { assertHostedAdmission, bindRequest, canonical, costRuntime, readJson, sha256, validateManifest } from './manifest.mjs';
|
|
|
|
async function atomicJson(file, value) {
|
|
const temporary = `${file}.tmp`;
|
|
const handle = await open(temporary, 'wx', 0o600);
|
|
try { await handle.writeFile(JSON.stringify(value, null, 2) + '\n'); await handle.sync(); }
|
|
finally { await handle.close(); }
|
|
await rename(temporary, file);
|
|
}
|
|
const absent = pid => { try { process.kill(pid, 0); return false; } catch (error) { return error.code === 'ESRCH'; } };
|
|
export const attemptDirectory = (manifest, attemptId) => join(manifest.outputDirectory, sha256(attemptId));
|
|
|
|
async function runChild(input) {
|
|
const child = spawn(process.execPath, ['--import', input.loader, input.cli, '--request', input.requestPath, '--output', input.artifactPath, '--gateway-config', input.descriptorPath,
|
|
...(input.expectedAcpxProfile ? ['--expected-acpx-profile', input.expectedAcpxProfile] : []),
|
|
...(input.observabilityPath ? ['--observability-config', input.observabilityPath] : [])], {
|
|
cwd: input.cwd, env: input.environment, detached: true, stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
let stopped = null, force, escalation;
|
|
const output = { stdout: [], stderr: [] }, sizes = { stdout: 0, stderr: 0 };
|
|
const stop = reason => {
|
|
if (stopped) return;
|
|
stopped = reason;
|
|
void input.closeGateway().catch(() => {});
|
|
try { process.kill(-child.pid, 'SIGTERM'); } catch {}
|
|
escalation = new Promise(resolve => {
|
|
force = setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} finally { resolve(); } }, 5000);
|
|
});
|
|
};
|
|
for (const stream of ['stdout', 'stderr']) child[stream].on('data', bytes => {
|
|
const available = Math.max(0, 1_048_576 - sizes[stream]);
|
|
output[stream].push(bytes.subarray(0, available)); sizes[stream] += bytes.length;
|
|
if (sizes[stream] > 1_048_576) stop('output_limit');
|
|
});
|
|
const onAbort = () => stop('controller_cancelled');
|
|
input.signal?.addEventListener('abort', onAbort, { once: true });
|
|
const timeout = setTimeout(() => stop('attempt_deadline'), Math.max(1, input.deadlineAt - Date.now()));
|
|
if (input.signal?.aborted) onAbort();
|
|
try {
|
|
const [code, signal] = await once(child, 'exit');
|
|
const groupPresent = !absent(-child.pid);
|
|
if (groupPresent && !stopped) stop('provider_group_survived_cli_exit');
|
|
if (groupPresent) await escalation;
|
|
return { code, signal, pid: child.pid, stopped, stdout: Buffer.concat(output.stdout).toString(), stderr: Buffer.concat(output.stderr).toString() };
|
|
} finally {
|
|
clearTimeout(timeout); clearTimeout(force); input.signal?.removeEventListener('abort', onAbort);
|
|
child.stdout.destroy(); child.stderr.destroy();
|
|
}
|
|
}
|
|
|
|
export function controlledResultArtifact(artifact, report, attempt) {
|
|
return { ...(artifact ?? { schema: 'paperclip-runner/eval-session-artifact/v1', attemptId: attempt.attemptId,
|
|
requestedModel: attempt.model, infrastructureError: 'Controlled provider did not retain an artifact' }),
|
|
...(report.failure ? { accountingError: 'Controlled request accounting or cleanup is incomplete',
|
|
accountingFailure: { class: 'provider_budget_coverage_unknown', category: 'provider_budget', retryable: false } } : {}),
|
|
controllerAccounting: report };
|
|
}
|
|
|
|
export async function runControlledEval({ manifestPath, requestPath, outputPath, expectedAcpxProfile, environment = process.env, signal, transport, otlpTransport }) {
|
|
if (!['linux', 'darwin'].includes(process.platform)) throw new Error('Controlled eval process ownership requires Linux or macOS');
|
|
signal?.throwIfAborted();
|
|
const { manifest, manifestSha256 } = validateManifest(await readJson(manifestPath));
|
|
const request = await readJson(requestPath, 8_388_608), attempt = bindRequest(manifest, request);
|
|
let profileArgument;
|
|
if (expectedAcpxProfile !== undefined) {
|
|
if (request.provider !== 'acpx' || expectedAcpxProfile === null || typeof expectedAcpxProfile !== 'object' || Array.isArray(expectedAcpxProfile)) throw new Error('Expected ACPX profile requires an ACPX request and JSON object');
|
|
profileArgument = canonical(expectedAcpxProfile);
|
|
if (Buffer.byteLength(profileArgument) > 4096) throw new Error('Expected ACPX profile exceeds its 4096-byte bound');
|
|
}
|
|
assertHostedAdmission(manifest, attempt, environment);
|
|
try { await lstat(outputPath); throw new Error('Controlled output already exists'); }
|
|
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
if (request.session.workingDirectory) {
|
|
const suffix = relative(resolve(request.session.workingDirectory), resolve(manifest.outputDirectory));
|
|
if (!suffix || suffix !== '..' && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix)) throw new Error('Controller evidence must be outside the disposable workspace');
|
|
}
|
|
const originalCliUrl = pathToFileURL(await realpath(manifest.runnerCli.path)).href;
|
|
const verified = {};
|
|
for (const name of ['runnerCli', 'runnerPackage', 'runnerd']) {
|
|
const artifact = manifest[name], bytes = await readFile(artifact.path);
|
|
if (sha256(bytes) !== artifact.sha256) throw new Error('Controlled eval executable provenance changed');
|
|
if (name !== 'runnerPackage') verified[name] = bytes;
|
|
}
|
|
const provider = attempt.provider === 'codex' ? 'openai' : 'anthropic';
|
|
const keyName = manifest.route === 'braintrust' ? 'BRAINTRUST_API_KEY' : provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY';
|
|
const key = environment[keyName];
|
|
if (!key || /[\r\n]/.test(key)) throw new Error('Missing controlled eval upstream credential');
|
|
const observabilityKey = manifest.observability ? environment.BRAINTRUST_API_KEY : undefined;
|
|
if (manifest.observability && (!observabilityKey || /[\r\n]/.test(observabilityKey))) throw new Error('Missing controlled observability credential');
|
|
const model = manifest.models.find(item => item.provider === provider && item.price.model === attempt.model);
|
|
const root = attemptDirectory(manifest, attempt.attemptId);
|
|
await mkdir(manifest.outputDirectory, { recursive: true, mode: 0o700 });
|
|
if (request.session.workingDirectory) {
|
|
const suffix = relative(await realpath(request.session.workingDirectory), await realpath(manifest.outputDirectory));
|
|
if (!suffix || suffix !== '..' && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix)) throw new Error('Controller evidence resolves inside the disposable workspace');
|
|
}
|
|
await mkdir(root, { mode: 0o700 });
|
|
const started = Date.now(), startedAt = new Date(started).toISOString();
|
|
const deadlineAt = Math.min(Date.parse(manifest.deadlineAt), started + attempt.timeoutMs);
|
|
const limits = { campaignId: manifest.campaignId, maxUsd: attempt.maxUsd, deadlineAt: new Date(deadlineAt).toISOString(),
|
|
maxRequests: attempt.maxRequests, maxConcurrent: attempt.maxConcurrent, maxAttemptsPerExecution: 1 };
|
|
await atomicJson(join(root, 'admission.json'), { schema: 'paperclip.runner-eval.admission/v1', campaignId: manifest.campaignId,
|
|
manifestSha256, attempt, limits, startedAt });
|
|
const executables = join(root, 'executables');
|
|
await mkdir(executables, { mode: 0o700 });
|
|
const stagedCli = join(executables, 'entry.mjs'), stagedRunnerd = join(executables, 'runnerd'), loader = join(executables, 'imports.mjs');
|
|
await writeFile(stagedCli, verified.runnerCli, { flag: 'wx', mode: 0o400 });
|
|
await writeFile(stagedRunnerd, verified.runnerd, { flag: 'wx', mode: 0o500 });
|
|
const stagedUrl = JSON.stringify(pathToFileURL(await realpath(stagedCli)).href), originalUrl = JSON.stringify(originalCliUrl);
|
|
await writeFile(loader, `import { registerHooks } from 'node:module';
|
|
registerHooks({ resolve(specifier, context, nextResolve) {
|
|
return nextResolve(specifier, context.parentURL === ${stagedUrl} ? { ...context, parentURL: ${originalUrl} } : context);
|
|
} });\n`, { flag: 'wx', mode: 0o400 });
|
|
const retainedRequestPath = join(root, 'request.json');
|
|
const childRequest = structuredClone(request); childRequest.runnerd.path = stagedRunnerd;
|
|
await writeFile(retainedRequestPath, JSON.stringify(childRequest) + '\n', { flag: 'wx', mode: 0o600 });
|
|
await mkdir(join(root, 'receipts'), { mode: 0o700 });
|
|
await mkdir(join(root, 'home'), { mode: 0o700 });
|
|
await mkdir(join(root, 'tmp'), { mode: 0o700 });
|
|
const ledgerPath = join(root, 'ledger.jsonl');
|
|
const ledger = await costRuntime.openEvalCostLedger(ledgerPath, limits);
|
|
let gateway, observability, observabilityEvidence, child, artifact, failure = null, cleanupVerified = false, secretExcluded = true;
|
|
let providerArtifactSha256, failureStage = 'controlled_attempt_incomplete';
|
|
const secrets = [...new Set([key, observabilityKey].filter(Boolean))];
|
|
const redact = raw => secrets.reduce((text, secret) => text.split(secret).join('[redacted]'), raw);
|
|
try {
|
|
failureStage = 'request_admission_failed';
|
|
await ledger.admitAttempt(attempt.executionId, attempt.attemptId);
|
|
failureStage = 'gateway_start_failed';
|
|
gateway = await costRuntime.startEvalGateway({ ledger, identity: { executionId: attempt.executionId, attemptId: attempt.attemptId, phase: 'agent' },
|
|
route: { ...model, mode: manifest.route, projectId: manifest.projectId, endpointName: provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY', apiKey: key },
|
|
deadlineAt, transport, persist: receipt => atomicJson(join(root, 'receipts', `${receipt.requestId}.json`), receipt),
|
|
});
|
|
secrets.push(gateway.token);
|
|
const descriptor = { schema: 'paperclip.runner-eval.gateway/v1', provider, model: attempt.model, baseUrl: gateway.baseUrl,
|
|
campaignId: manifest.campaignId, executionId: attempt.executionId, attemptId: attempt.attemptId, upstreamRoute: manifest.route };
|
|
const descriptorPath = join(root, 'gateway.json'), descriptorRaw = JSON.stringify(descriptor) + '\n';
|
|
failureStage = 'gateway_descriptor_failed';
|
|
await writeFile(descriptorPath, descriptorRaw, { flag: 'wx', mode: 0o600 });
|
|
const childEnvironment = Object.fromEntries(['PATH', 'LANG', 'LANGUAGE', 'LC_ALL', 'TZ'].flatMap(name => environment[name] === undefined ? [] : [[name, environment[name]]]));
|
|
Object.assign(childEnvironment, { HOME: join(root, 'home'), TMPDIR: join(root, 'tmp'), TMP: join(root, 'tmp'), TEMP: join(root, 'tmp'), PAPERCLIP_RUNNER_EVAL_GATEWAY_TOKEN: gateway.token });
|
|
if (manifest.observability) {
|
|
failureStage = 'observability_start_failed';
|
|
observability = await startControlledObservability({ manifest, attempt, request, root, apiKey: observabilityKey, deadlineAt, startedAt, transport: otlpTransport, signal });
|
|
secrets.push(...observability.secrets);
|
|
childEnvironment.PAPERCLIP_RUNNER_EVAL_OTLP_TOKEN = observability.token;
|
|
}
|
|
const artifactPath = join(root, 'provider-artifact.json');
|
|
failureStage = 'child_failed';
|
|
signal?.throwIfAborted();
|
|
if (Date.now() >= deadlineAt) throw new Error('Controlled attempt deadline reached before spawn');
|
|
child = await runChild({ cli: stagedCli, loader, requestPath: retainedRequestPath, artifactPath, descriptorPath, expectedAcpxProfile: profileArgument, observabilityPath: observability?.descriptorPath,
|
|
cwd: request.session.workingDirectory ?? root, environment: childEnvironment, deadlineAt, signal, closeGateway: () => Promise.all([gateway.close(), observability?.close()]) });
|
|
failureStage = 'output_retention_failed';
|
|
for (const stream of ['stdout', 'stderr']) await writeFile(join(root, `${stream}.log`), redact(child[stream]), { flag: 'wx', mode: 0o600 });
|
|
failureStage = child.code === 0 ? 'artifact_invalid' : 'child_failed';
|
|
const raw = (await readRegularFile(artifactPath, limitFor('provider-artifact.json'))).toString('utf8');
|
|
secretExcluded = secrets.every(secret => !raw.includes(secret));
|
|
if (!secretExcluded) await replaceEvidenceFile(artifactPath, redact(raw));
|
|
providerArtifactSha256 = sha256(redact(raw));
|
|
artifact = JSON.parse(redact(raw));
|
|
const expected = { mode: 'controller_gateway', provider, declaredUpstreamRoute: manifest.route, campaignId: manifest.campaignId,
|
|
executionId: attempt.executionId, attemptId: attempt.attemptId, descriptorSha256: sha256(descriptorRaw) };
|
|
if (canonical(artifact.requestRouting) !== canonical(expected) || artifact.attemptId !== attempt.attemptId || artifact.requestedModel !== attempt.model) throw new Error('Provider did not retain the assigned route');
|
|
if (observability && canonical(artifact.nativeObservability) !== canonical(observability.expectedEvidence)) throw new Error('Provider did not retain the assigned observability route');
|
|
failureStage = 'provider_cleanup_or_credential_unverified';
|
|
const processInfo = artifact.snapshot?.process;
|
|
const pids = [...new Set([child.pid, processInfo?.runnerPid, processInfo?.providerPid, processInfo?.sidecarPid, processInfo?.agentPid].filter(pid => Number.isSafeInteger(pid) && pid > 0))];
|
|
cleanupVerified = !child.stopped && artifact.snapshot?.status === 'closed' && processInfo?.runnerExited === true
|
|
&& Number.isSafeInteger(processInfo.runnerPid) && processInfo.runnerPid > 0
|
|
&& Number.isSafeInteger(processInfo.providerPid) && processInfo.providerPid > 0 && pids.every(absent);
|
|
if (!cleanupVerified || !secretExcluded) throw new Error('Controlled provider cleanup or credential exclusion is unverified');
|
|
} catch {
|
|
failure = failureStage;
|
|
} finally {
|
|
try { await gateway?.close(); } catch { failure = 'gateway_cleanup_unverified'; }
|
|
try { await ledger.close(); } catch { failure = 'ledger_close_failed'; }
|
|
try { await observability?.closeNative(); } catch { failure ??= 'observability_cleanup_unverified'; }
|
|
}
|
|
try {
|
|
if (cleanupVerified) {
|
|
try { await rm(executables, { recursive: true }); }
|
|
catch { cleanupVerified = false; failure ??= 'executable_cleanup_unverified'; }
|
|
}
|
|
const snapshot = ledger.snapshot();
|
|
if (snapshot.overBudget || snapshot.conflictingRequests || snapshot.inflight || snapshot.unknownRequests !== snapshot.boundedUnresolvedRequests
|
|
|| modelReadinessDefect(manifest, snapshot)) failure ??= 'request_accounting_unbounded';
|
|
const receiptHashes = {};
|
|
try {
|
|
for (const request of snapshot.requests) receiptHashes[request.requestId] = sha256(await readRegularFile(join(root, 'receipts', `${request.requestId}.json`), limitFor(`receipts/${request.requestId}.json`)));
|
|
} catch { failure ??= 'request_receipt_missing'; }
|
|
let ledgerSha256;
|
|
try { ledgerSha256 = sha256(await readRegularFile(ledgerPath, limitFor('ledger.jsonl'))); }
|
|
catch { failure ??= 'ledger_evidence_invalid'; }
|
|
if (observability) {
|
|
try {
|
|
observabilityEvidence = await observability.finish({ artifact, cleanupVerified, failure });
|
|
if (observabilityEvidence.status !== 'accepted') failure ??= 'observability_export_incomplete';
|
|
} catch { failure ??= 'observability_evidence_incomplete'; }
|
|
}
|
|
if (signal?.aborted) failure = 'controller_cancelled';
|
|
const report = { schema: 'paperclip.runner-eval.control-result/v1', campaignId: manifest.campaignId, manifestSha256,
|
|
executionId: attempt.executionId, attemptId: attempt.attemptId, requestSha256: attempt.requestSha256,
|
|
allocationUsd: attempt.maxUsd, limits, startedAt, finishedAt: new Date().toISOString(), status: failure ? 'incomplete' : 'closed', failure,
|
|
child: child ? { code: child.code, signal: child.signal, stopped: child.stopped } : null,
|
|
...(manifest.observability ? { observability: observabilityEvidence ?? { status: 'incomplete', verification: 'unverified' } } : {}),
|
|
cleanupVerified, secretExcluded, providerArtifactSha256, ledgerSha256, receiptHashes, accounting: snapshot };
|
|
await atomicJson(join(root, 'result.json'), report);
|
|
const resultArtifact = controlledResultArtifact(artifact, report, attempt);
|
|
await mkdir(dirname(resolve(outputPath)), { recursive: true, mode: 0o700 });
|
|
await writeFile(outputPath, JSON.stringify(resultArtifact, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
return { code: failure ? 2 : child?.code ?? 2, report };
|
|
} finally { try { await observability?.close(); } catch {} }
|
|
|
|
}
|
|
|
|
if (await isEntrypoint(import.meta.url)) {
|
|
const controller = new AbortController();
|
|
const cancel = () => controller.abort();
|
|
process.once('SIGINT', cancel); process.once('SIGTERM', cancel);
|
|
try {
|
|
const { values } = parseArgs({ options: { request: { type: 'string' }, output: { type: 'string' }, 'expected-acpx-profile': { type: 'string' } } });
|
|
if (!values.request || !values.output || !process.env.PAPERCLIP_RUNNER_EVAL_CONTROL_MANIFEST) throw new Error('A controlled manifest, request and output are required');
|
|
const rawProfile = values['expected-acpx-profile'];
|
|
if (rawProfile !== undefined && Buffer.byteLength(rawProfile) > 4096) throw new Error('Expected ACPX profile exceeds its 4096-byte bound');
|
|
const result = await runControlledEval({ manifestPath: process.env.PAPERCLIP_RUNNER_EVAL_CONTROL_MANIFEST,
|
|
requestPath: values.request, outputPath: values.output, expectedAcpxProfile: rawProfile === undefined ? undefined : JSON.parse(rawProfile), signal: controller.signal });
|
|
process.exitCode = result.code;
|
|
} catch { process.stderr.write('Controlled Runner eval admission or retention failed; inspect retained controller evidence.\n'); process.exitCode = 2; }
|
|
finally { process.removeListener('SIGINT', cancel); process.removeListener('SIGTERM', cancel); }
|
|
}
|