mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
## Thinking Path > - Paperclip manages agent work through Runner. > - Product E2E uses a content hash to identify its Daytona image. > - The hash must include the active provider identity declaration. > - The active Copilot fixture advanced past the manually listed image inputs. > - The runner routing test also captured later YAML steps as shell commands. > - This change binds the active fixture and stops shell extraction at the next step. > - Operators can verify image identity and routing against the current source. ## Linked Issues or Issue Description Refs #17937 and #17947. The related Copilot work includes #15563. This change repairs the shared image contract; it does not add a Copilot qualification claim. **What happened?** Two image tests fail because the active Copilot identity fixture is absent from the image hash inputs. Four routing tests execute later workflow YAML as shell commands after checkout and dependency steps were added to the authorization job. **Expected behavior** The image hash must change when the active identity fixture changes. The routing tests must execute only the selected routing step. **Steps to reproduce** Run the Daytona image and paid runner routing unit files on current master. Six assertions fail before this patch. The same failures occur on the clean6b9e5f3abaseline. **Paperclip version or commit** Rebased onto master at9b7d5b35eb. **Deployment mode** Source checkout. These tests make no provider requests. ## What Changed - Read the active Copilot fixture version from the source-owned profile manifest. - Include that manifest and its active fixture in the image inputs. Keep the historical entries and remove duplicate paths. - Stop routing shell extraction at the next workflow step or job. - Remove an obsolete comment that contradicted the current Docker build context. ## Verification - Focused image and routing tests: 18 passed after rebase. - Existing image tests independently read the identity generator and verify that changed fixture bytes change the image hash. - Before this patch, six of those 18 tests fail on the unchanged source. - Full Product support suite before rebase: 4,514 passed, one failed, three skipped. The remaining macOS embedded Postgres bootstrap failure also occurs on the clean baseline. - The shared installed Runner workspace exports are stale in this local checkout. Fresh CI remains the typecheck and build gate. - No workflow, provider credentials, paid campaign, spending limit, or schedule is changed. ## Risks - The input set changes the image content ID. A fresh immutable image is required for that content. - The active fixture version must remain synchronized with the profile manifest. The existing independent generator check fails if they drift. - The extraction still depends on the workflow's current indentation. The tests fail if the routing step cannot be found. ## Model Used OpenAI Codex, GPT-6 based, 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>
352 lines
14 KiB
TypeScript
352 lines
14 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";
|
|
import acpxProfiles from "../../packages/paperclip-runner/acpx-profiles.json" with { type: "json" };
|
|
|
|
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";
|
|
|
|
export const DAYTONA_IMAGE_INPUT_PATHS = [...new Set([
|
|
".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/acpx-profiles.json",
|
|
`packages/paperclip-runner/test/fixtures/copilot-profile-v${acpxProfiles.profiles.copilot.agentProfileVersion}-identity.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-v56-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-v59-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/test/fixtures/copilot-profile-v66-identity.json",
|
|
"packages/paperclip-runner/test/fixtures/copilot-profile-v67-identity.json",
|
|
"packages/paperclip-runner/test/fixtures/copilot-profile-v68-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);
|