Files
PaperClipAI/tests/runner-e2e/daytona-image-content.ts
T
DottaandPaperclip bf3ef12f3a fix(evals): qualify model spend separately from operating costs (#17928)
## Thinking Path

> - Paperclip manages agent work and its budgets.
> - Product E2E campaigns must explain each model request and attempt.
> - The current completion gate also requires complete operating-cost
evidence.
> - Long-lived observability and storage charges can block a campaign
after its model receipts are complete.
> - An operator can now choose model-request readiness in a retained
allowance.
> - The report keeps full accounting, operating holds and model
readiness separate.

## Linked Issues or Issue Description

Refs #17889, #17523, #17452.

**What happened?**
A hosted campaign could pass both product cases and retain complete
model receipts, yet fail readiness because operating charges were
unresolved.

**Expected behavior**
An explicit model-readiness policy permits complete model receipts to
qualify a run. Unknown operating charges stay visible and reserved.
Missing model receipts and budget violations still block qualification.

**Steps to reproduce**
Use a campaign with four unresolved operating reservations and one
completed model request. The regression checks readiness before and
after the final model receipt, then checks an operating overrun. The
default policy retains the original full-accounting gate.

## What Changed

- Add the explicit `accountingReadiness: "model_requests"` allowance
policy. Bind it into operating-bound configuration when present.
- Derive a model subtotal and receipt-coverage projection from the
retained journal. Show it beside full accounting and retained holds.
- Use the selected readiness policy in scheduled finalization. Keep
product, cleanup, feedback, trace and budget checks.
- Refresh two existing eval prerequisites found by the full unit suite:
the active Copilot image identity and the model-account catalog mapping.
- Document the policy and its limit: it does not certify a
full-lifecycle operating-cost ceiling.

## Verification

- Regression failed on the old implementation with `Invalid operating
overhead fields` and passed after the change.
- Focused accounting/operations/finalization checks: 54 tests passed
before the final policy assertions.
- Existing image/catalog failures reproduced, then their 12 checks
passed after the metadata repairs.
- Product E2E unit suite: 4,360 passed and 2 skipped, plus 137 Node
checks. Product E2E and workspace typechecks and workspace build passed.
- Review regressions: 35 focused accounting/finalization checks passed
on 4aeb8bd65c, including the optional-policy hash and the scheduled
consumer.
- Full local server tests encountered embedded PostgreSQL bootstrap
failures; retained output is not a passing full-suite claim. Hosted CI
remains required.
- Final head 4aeb8bd65c: all 57 check runs pass or are skipped, the
legacy security status passes, both reviews are 5/5 and no review
threads remain open.
- No paid campaign or schedule activation is claimed by this PR.

## Risks

The opt-in policy permits readiness while operating costs remain
unreconciled. The report retains full-accounting status and all holds.
The gateway still reserves requests inside the same total allowance.
This is an admission limit, not a vendor invoice guarantee. The omitted
policy keeps existing behavior. Old journals remain unchanged.

## 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>
2026-10-27 15:10:26 -05:00

348 lines
13 KiB
TypeScript

import { createHash } from "node:crypto";
import { lstat, readdir, readFile, readlink } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const repositoryRoot = path.resolve(import.meta.dirname, "../..");
export const DAYTONA_IMAGE_CONTENT_SCHEMA =
"paperclip-daytona-runner-image-content/v5";
export const DAYTONA_IMAGE_PLATFORM = "linux/amd64";
export const DAYTONA_IMAGE_DOCKERFILE_PATH = "docker/daytona-runner/Dockerfile";
// This mirrors the explicit repository-local build inputs copied by
// docker/daytona-runner/Dockerfile. Broad package-tree COPYs are forbidden by
// the contract test so development-only files cannot silently enter the image
// without first changing this content-identity contract.
export const DAYTONA_IMAGE_INPUT_PATHS = [
".dockerignore",
".npmrc",
"docker/daytona-runner/Dockerfile",
"package.json",
"patches",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
"scripts/link-plugin-dev-sdk.mjs",
"tsconfig.base.json",
"packages/paperclip-eval-kernel/package.json",
"packages/paperclip-eval-kernel/src",
"packages/paperclip-eval-kernel/tsconfig.json",
"packages/paperclip-runner/scripts/provision-grok.mjs",
"packages/paperclip-runner/package.json",
"packages/paperclip-runner/cursor-distributions.json",
"packages/paperclip-runner/cursor-contract.json",
"packages/paperclip-runner/protocol",
"packages/paperclip-runner/runner/Cargo.lock",
"packages/paperclip-runner/runner/Cargo.toml",
"packages/paperclip-runner/runner/crates",
"packages/paperclip-runner/scripts/acpx-sidecar-contract.mjs",
"packages/paperclip-runner/scripts/build-provider-pack.mjs",
"packages/paperclip-runner/scripts/provider-pack-executable-shims.mjs",
"packages/paperclip-runner/scripts/build-node-startup-timeout.mjs",
"packages/paperclip-runner/scripts/candidate-provider-pack.mjs",
"packages/paperclip-runner/scripts/build-copilot-distribution.mjs",
"packages/paperclip-runner/scripts/copilot-inner-distribution.mjs",
"packages/paperclip-runner/scripts/copilot-profile-identity.mjs",
"packages/paperclip-runner/scripts/materialize-copilot-binary.mjs",
"packages/paperclip-runner/scripts/install-copilot-assets.mjs",
"packages/paperclip-runner/test/fixtures/copilot-profile-v12-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v14-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v21-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v24-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v25-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v26-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v35-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v40-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v42-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v46-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v47-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v48-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v49-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v50-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v51-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v52-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v54-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v55-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v57-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v58-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v61-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v65-identity.json",
"packages/paperclip-runner/scripts/materialize-cursor-distribution.mjs",
"packages/paperclip-runner/scripts/cursor-runtime-patch.mjs",
"packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs",
"packages/paperclip-runner/scripts/provider-pack-runtime-tree.mjs",
"packages/paperclip-runner/scripts/generate-acpx-sidecar-contract.mjs",
"packages/paperclip-runner/scripts/generate-protocol-schema-module.mjs",
"packages/paperclip-runner/scripts/materialize-pi-distribution.mjs",
"packages/paperclip-runner/scripts/pi-distribution",
"packages/paperclip-runner/scripts/cursor-native-usage.mjs",
"packages/paperclip-runner/scripts/cursor-native-usage-source.json",
"packages/paperclip-runner/scripts/provision-cursor.mjs",
"packages/paperclip-runner/cursor-distributions.json",
"packages/paperclip-runner/cursor-contract.json",
// Pi runtime/extension/Node/closure pins are included by the src tree below.
"packages/paperclip-runner/src",
"packages/paperclip-runner/styles.css",
"packages/paperclip-runner/tsconfig.json",
"packages/paperclip-runner/tsconfig.surfaces.json",
] as const;
const ignoredRunnerDevelopmentDirectoryPaths = new Set([
"packages/paperclip-runner/devtools",
"packages/paperclip-runner/docs",
"packages/paperclip-runner/examples",
"packages/paperclip-runner/test",
"packages/paperclip-runner/test-fixtures",
"packages/paperclip-runner/test-support",
]);
const runnerDocumentationFilePattern = /\.md$/;
const runnerTestFilePattern = /\.(?:spec|test)\.(?:[cm]?[jt]sx?)$/;
const runnerRustIntegrationTestPathPattern =
/^packages\/paperclip-runner\/runner\/crates\/[^/]+\/tests(?:\/|$)/;
const runnerSmokeScriptPattern =
/^packages\/paperclip-runner\/scripts\/[^/]+-smoke\.mjs$/;
export interface DaytonaImageContentOptions {
repositoryRoot?: string;
inputPaths?: readonly string[];
platform?: string;
baseImages?: readonly string[];
frontendDigest?: string;
/** Must match the Docker PAPERCLIP_RUNNER_CANDIDATE_PROVIDERS build argument. */
candidateProviders?: readonly string[];
}
export function normalizedDaytonaCandidateProviders(values: readonly string[]): string[] {
const selected = [...values].sort();
if (selected.some(value => !["cursor", "copilot", "pi"].includes(value))
|| new Set(selected).size !== selected.length) {
throw new Error("Daytona candidate providers must be distinct known ACP profiles");
}
return selected;
}
function compareNames(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function normalizedRelativePath(value: string): string {
return value.split(path.sep).join("/");
}
function shouldIgnore(relativePath: string): boolean {
return relativePath.split("/").includes("node_modules");
}
function shouldIgnoreRunnerDevelopmentInput(relativePath: string): boolean {
if (!relativePath.startsWith("packages/paperclip-runner/")) return false;
if (ignoredRunnerDevelopmentDirectoryPaths.has(relativePath)) return true;
return (
runnerDocumentationFilePattern.test(relativePath) ||
runnerTestFilePattern.test(relativePath) ||
runnerRustIntegrationTestPathPattern.test(relativePath) ||
runnerSmokeScriptPattern.test(relativePath)
);
}
function updateRecord(
hash: ReturnType<typeof createHash>,
kind: string,
relativePath: string,
payload = "",
): void {
hash.update(kind);
hash.update("\0");
hash.update(relativePath);
hash.update("\0");
hash.update(payload);
hash.update("\0");
}
function assertPinnedBaseImage(reference: string): void {
if (!/@sha256:[0-9a-f]{64}$/.test(reference)) {
throw new Error(
`Daytona image base must use an immutable sha256 digest: ${reference}`,
);
}
}
function assertPinnedFrontendDigest(digest: string): void {
if (!/^sha256:[0-9a-f]{64}$/.test(digest)) {
throw new Error(
`Daytona Dockerfile frontend must use an immutable sha256 digest: ${digest}`,
);
}
}
export function extractDaytonaDockerfileFrontendDigest(
dockerfile: string,
): string {
const firstLine = dockerfile.split(/\r?\n/, 1)[0] ?? "";
const match = /^#\s*syntax=\S+@(sha256:[0-9a-f]{64})\s*$/.exec(firstLine);
if (!match) {
throw new Error(
"Daytona Dockerfile first line must pin its syntax frontend to an immutable sha256 digest",
);
}
const digest = match[1]!;
assertPinnedFrontendDigest(digest);
return digest;
}
export function extractDaytonaBaseImages(dockerfile: string): string[] {
const baseImages: string[] = [];
const stageAliases = new Set<string>();
for (const line of dockerfile.split(/\r?\n/)) {
if (!/^\s*FROM\s/i.test(line)) continue;
const match = /^\s*FROM(?:\s+--\S+)*\s+(\S+)(?:\s+AS\s+(\S+))?\s*$/i.exec(
line,
);
if (!match) {
throw new Error(`Cannot parse Daytona Dockerfile FROM line: ${line}`);
}
const reference = match[1]!;
if (!stageAliases.has(reference)) {
assertPinnedBaseImage(reference);
baseImages.push(reference);
}
if (match[2]) stageAliases.add(match[2]);
}
if (baseImages.length === 0) {
throw new Error("Daytona Dockerfile does not declare a base image");
}
return baseImages;
}
async function resolveBaseImages(
root: string,
explicitBaseImages?: readonly string[],
): Promise<string[]> {
const baseImages = explicitBaseImages
? [...explicitBaseImages]
: extractDaytonaBaseImages(
await readFile(path.join(root, DAYTONA_IMAGE_DOCKERFILE_PATH), "utf8"),
);
if (baseImages.length === 0) {
throw new Error("Daytona image content identity requires a base image");
}
for (const reference of baseImages) assertPinnedBaseImage(reference);
return baseImages.sort(compareNames);
}
async function resolveFrontendDigest(
root: string,
explicitFrontendDigest?: string,
): Promise<string> {
const digest =
explicitFrontendDigest ??
extractDaytonaDockerfileFrontendDigest(
await readFile(path.join(root, DAYTONA_IMAGE_DOCKERFILE_PATH), "utf8"),
);
assertPinnedFrontendDigest(digest);
return digest;
}
async function hashEntry(
hash: ReturnType<typeof createHash>,
root: string,
relativePath: string,
): Promise<void> {
const normalizedPath = normalizedRelativePath(relativePath);
if (
shouldIgnore(normalizedPath) ||
shouldIgnoreRunnerDevelopmentInput(normalizedPath)
) {
return;
}
const absolutePath = path.resolve(root, relativePath);
const relativeFromRoot = path.relative(root, absolutePath);
if (
relativeFromRoot.startsWith(`..${path.sep}`) ||
relativeFromRoot === ".." ||
path.isAbsolute(relativeFromRoot)
) {
throw new Error(
`Daytona image input escapes repository root: ${relativePath}`,
);
}
const stats = await lstat(absolutePath);
if (stats.isDirectory()) {
const entries = await readdir(absolutePath, { withFileTypes: true });
entries.sort((left, right) => compareNames(left.name, right.name));
for (const entry of entries) {
await hashEntry(hash, root, path.join(relativePath, entry.name));
}
return;
}
if (stats.isSymbolicLink()) {
updateRecord(hash, "symlink", normalizedPath, await readlink(absolutePath));
return;
}
if (stats.isFile()) {
const executable =
(stats.mode & 0o111) === 0 ? "non-executable" : "executable";
updateRecord(hash, "file", normalizedPath, executable);
hash.update(await readFile(absolutePath));
hash.update("\0");
return;
}
throw new Error(`Unsupported Daytona image input type: ${relativePath}`);
}
export async function computeDaytonaImageContentId(
options: DaytonaImageContentOptions = {},
): Promise<string> {
const root = path.resolve(options.repositoryRoot ?? repositoryRoot);
const inputPaths = [
...(options.inputPaths ?? DAYTONA_IMAGE_INPUT_PATHS),
].sort(compareNames);
const hash = createHash("sha256");
updateRecord(
hash,
"contract",
DAYTONA_IMAGE_CONTENT_SCHEMA,
options.platform ?? DAYTONA_IMAGE_PLATFORM,
);
updateRecord(
hash,
"dockerfile-frontend",
await resolveFrontendDigest(root, options.frontendDigest),
);
for (const baseImage of await resolveBaseImages(root, options.baseImages)) {
updateRecord(hash, "base-image", baseImage);
}
for (const candidate of normalizedDaytonaCandidateProviders(options.candidateProviders ?? [])) {
updateRecord(hash, "candidate-provider", candidate);
}
for (const inputPath of inputPaths) {
await hashEntry(hash, root, inputPath);
}
return hash.digest("hex");
}
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : null;
if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) {
const arguments_ = process.argv.slice(2);
const candidateFlag = arguments_[0];
if (arguments_.length > 1 || (candidateFlag !== undefined && !candidateFlag.startsWith("--candidate-providers="))) {
throw new Error("Expected only --candidate-providers=cursor,copilot,pi");
}
const candidateProviders = candidateFlag?.slice("--candidate-providers=".length).split(",").filter(Boolean) ?? [];
computeDaytonaImageContentId({ candidateProviders })
.then((contentId) => process.stdout.write(`${contentId}\n`))
.catch((error: unknown) => {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 1;
});
}
export const DAYTONA_IMAGE_CONTENT_SCRIPT = fileURLToPath(import.meta.url);