fix: build provider packs from an immutable dependency lock

This commit is contained in:
Dotta 2026-09-09 14:40:27 -05:00
parent 8bbf888a71
commit 310d5e3627
7 changed files with 16965 additions and 748 deletions

View File

@ -256,11 +256,26 @@ RUN set -eu; \
# Keep it Cloud-only so ordinary local execution and the production target do
# not acquire remote-provider configuration.
FROM node:24-bookworm@sha256:9137a20e25879e0b557227b57e3ee4e9af4bde29eb3db66134cd1723e84f830b AS cloud-provider-pack
RUN corepack enable
WORKDIR /app
COPY --from=build /app /app
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /workspace
# Install the same immutable dependency graph as the qualified sandbox, in a
# fresh stage. Never inherit the app build's independently resolved node_modules
# or merely replace the manifest lock after installing a different graph.
COPY package.json pnpm-workspace.yaml .npmrc tsconfig.base.json ./
COPY docker/daytona-runner/provider-dependencies.lock.yaml ./pnpm-lock.yaml
COPY patches ./patches
COPY scripts/link-plugin-dev-sdk.mjs ./scripts/link-plugin-dev-sdk.mjs
COPY packages ./packages
COPY server/package.json ./server/package.json
COPY ui/package.json ./ui/package.json
COPY cli/package.json ./cli/package.json
ARG PAPERCLIP_RUNNER_LOCK_SHA256=84409576c7cbd2bec50b535c6df6acf3691bdec7c7697e6c50b2fb834b56f203
RUN printf '%s pnpm-lock.yaml\n' "${PAPERCLIP_RUNNER_LOCK_SHA256}" > /tmp/provider-lock.sha256 \
&& sha256sum -c /tmp/provider-lock.sha256 \
&& pnpm install --frozen-lockfile --filter '@paperclipai/paperclip-runner...'
ARG PAPERCLIP_BUILD_COMMIT
RUN test -n "${PAPERCLIP_BUILD_COMMIT}" \
&& pnpm --filter @paperclipai/paperclip-runner build:typescript \
&& PAPERCLIP_RUNNER_SOURCE_REVISION="${PAPERCLIP_BUILD_COMMIT}" \
node packages/paperclip-runner/scripts/build-provider-pack.mjs /provider-pack \
&& node packages/paperclip-runner/scripts/verify-pi-provider-launch.mjs /provider-pack \

View File

@ -367,13 +367,15 @@ verification; a fallback within `runner.artifact.prepare` can transfer gigabytes
independently of task files. Acceptance must prove that a matching image uses
its installed pack instead of silently relying on that fallback.
Commit an up-to-date workspace lockfile whenever runner dependencies change.
The app and qualified sandbox must use the same resolved lockfile bytes; matching
source files alone does not prove matching dependencies. A stale lockfile can
force independent builds to resolve newer transitive packages and invalidate
preinstalled-pack reuse. Check the committed lockfile with pnpm 9.15.4 using
`pnpm install --lockfile-only --ignore-scripts --ignore-pnpmfile --frozen-lockfile`
and compare the actual app and sandbox production-lock hashes before acceptance.
Both provider-pack build stages install from the immutable
`docker/daytona-runner/provider-dependencies.lock.yaml` using pnpm 9.15.4 and a
frozen install. This deployment input is separate from the CI-owned app
lockfile. Each stage checks its SHA-256 before installing, builds the provider
entrypoints under that graph, and records the installed lock in the pack.
Refresh this lock from a reviewed CI-resolved artifact when provider manifests
or patches change, update both expected hashes, and qualify a new sandbox image.
Matching source files alone does not prove matching dependencies: acceptance
also compares the actual app and sandbox production-lock hashes.
Native and legacy Git credential callbacks honor the same experimental duplex
setting and provider capability gates. When streaming is disabled or unavailable,

View File

@ -15,12 +15,13 @@ RUN cargo build --locked --release -p paperclip-runner-core --bin paperclip-runn
FROM node:24-bookworm@sha256:9137a20e25879e0b557227b57e3ee4e9af4bde29eb3db66134cd1723e84f830b AS provider-pack-build
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /workspace
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc tsconfig.base.json ./
COPY package.json pnpm-workspace.yaml .npmrc tsconfig.base.json ./
COPY docker/daytona-runner/provider-dependencies.lock.yaml ./pnpm-lock.yaml
COPY patches ./patches
COPY scripts/link-plugin-dev-sdk.mjs ./scripts/link-plugin-dev-sdk.mjs
# CI owns pnpm-lock.yaml and regenerates it for manifest-only PRs. Include the
# complete workspace manifest graph so all source-owned patches apply, then
# resolve before the frozen install. This happens only during image creation.
# Provider dependencies use a separate immutable lock, shared with the Cloud
# provider-pack stage. The CI-owned app lock may be regenerated independently.
# Include the complete manifest graph so frozen installation checks every input.
COPY packages ./packages
COPY server/package.json ./server/package.json
COPY ui/package.json ./ui/package.json
@ -29,8 +30,7 @@ COPY cli/package.json ./cli/package.json
# Reject registry-time drift BEFORE installing packages or running lifecycle code.
# Refresh this digest together with source/provider dependency changes.
ARG PAPERCLIP_RUNNER_LOCK_SHA256=84409576c7cbd2bec50b535c6df6acf3691bdec7c7697e6c50b2fb834b56f203
RUN pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile \
&& printf '%s pnpm-lock.yaml\n' "${PAPERCLIP_RUNNER_LOCK_SHA256}" > /tmp/provider-lock.sha256 \
RUN printf '%s pnpm-lock.yaml\n' "${PAPERCLIP_RUNNER_LOCK_SHA256}" > /tmp/provider-lock.sha256 \
&& sha256sum -c /tmp/provider-lock.sha256 \
&& pnpm install --frozen-lockfile --filter '@paperclipai/paperclip-runner...'
ARG PAPERCLIP_RUNNER_SOURCE_REVISION

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -6,28 +6,70 @@ 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";
"paperclip-daytona-runner-image-content/v6";
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.
// Hash the provider build dependency closure: the complete copied manifest
// graph and immutable provider lock, plus the runner/eval runtime build inputs.
// The contract test checks copied package manifests against this explicit list;
// unrelated application source is not part of the provider output.
export const DAYTONA_IMAGE_INPUT_PATHS = [
".dockerignore",
".npmrc",
"docker/daytona-runner/Dockerfile",
"package.json",
"patches",
"pnpm-lock.yaml",
"docker/daytona-runner/provider-dependencies.lock.yaml",
"pnpm-workspace.yaml",
"scripts/link-plugin-dev-sdk.mjs",
"tsconfig.base.json",
"server/package.json",
"ui/package.json",
"cli/package.json",
"packages/adapter-utils/package.json",
"packages/adapters/claude-local/package.json",
"packages/adapters/codex-local/package.json",
"packages/adapters/cursor-cloud/package.json",
"packages/adapters/cursor-local/package.json",
"packages/adapters/gemini-local/package.json",
"packages/adapters/grok-local/package.json",
"packages/adapters/hermes-gateway/package.json",
"packages/adapters/hermes/package.json",
"packages/adapters/kimi-local/package.json",
"packages/adapters/openclaw-gateway/package.json",
"packages/adapters/opencode-local/package.json",
"packages/adapters/pi-local/package.json",
"packages/db/package.json",
"packages/google-sheets-mcp-server/package.json",
"packages/kv-demo-mcp-server/package.json",
"packages/mcp-server/package.json",
"packages/paperclip-eval-kernel/package.json",
"packages/paperclip-runner/package.json",
"packages/plugins/create-paperclip-plugin/package.json",
"packages/plugins/examples/plugin-authoring-smoke-example/package.json",
"packages/plugins/examples/plugin-file-browser-example/package.json",
"packages/plugins/examples/plugin-hello-world-example/package.json",
"packages/plugins/examples/plugin-kitchen-sink-example/package.json",
"packages/plugins/examples/plugin-orchestration-smoke-example/package.json",
"packages/plugins/paperclip-plugin-fake-sandbox/package.json",
"packages/plugins/plugin-llm-wiki/package.json",
"packages/plugins/plugin-workspace-diff/package.json",
"packages/plugins/sandbox-providers/cloudflare/bridge-template/package.json",
"packages/plugins/sandbox-providers/cloudflare/package.json",
"packages/plugins/sandbox-providers/daytona/package.json",
"packages/plugins/sandbox-providers/e2b/package.json",
"packages/plugins/sandbox-providers/exe-dev/package.json",
"packages/plugins/sandbox-providers/kubernetes/package.json",
"packages/plugins/sandbox-providers/modal/package.json",
"packages/plugins/sandbox-providers/novita/package.json",
"packages/plugins/sdk/package.json",
"packages/shared/package.json",
"packages/skills-catalog/package.json",
"packages/tailscale-https-broker/package.json",
"packages/teams-catalog/package.json",
"packages/paperclip-eval-kernel/src",
"packages/paperclip-eval-kernel/tsconfig.json",
"packages/paperclip-runner/package.json",
"packages/paperclip-runner/protocol",
"packages/paperclip-runner/runner/Cargo.lock",
"packages/paperclip-runner/runner/Cargo.toml",

View File

@ -1,9 +1,12 @@
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
computeDaytonaImageContentId,
DAYTONA_IMAGE_CONTENT_SCHEMA,
DAYTONA_IMAGE_DOCKERFILE_PATH,
DAYTONA_IMAGE_INPUT_PATHS,
extractDaytonaBaseImages,
@ -37,12 +40,14 @@ describe("runner E2E Daytona image contract", () => {
expect(normalizedDockerfile).not.toContain(
"COPY packages/paperclip-runner ./packages/paperclip-runner",
);
// Branch images need the full manifest graph for workspace patches. The
// resolved lock is verified before the frozen provider dependency install.
// Both provider builds consume the dedicated immutable resolution. Image
// creation must not refresh transitive packages from the registry.
expect(dockerfile).toContain("COPY packages ./packages");
expect(dockerfile).toContain(
"pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile",
"COPY docker/daytona-runner/provider-dependencies.lock.yaml ./pnpm-lock.yaml",
);
expect(dockerfile).not.toContain("--resolution-only");
expect(dockerfile).not.toContain("--no-frozen-lockfile");
expect(dockerfile).toContain("sha256sum -c /tmp/provider-lock.sha256");
expect(dockerfile).toContain(
"/opt/paperclip-runner/provider-pack/provider-pack.json",
@ -161,11 +166,114 @@ describe("runner E2E Daytona image contract", () => {
expect(cliInstall).toBeLessThan(finalMetadataArgs);
});
it("builds both provider packs from the same verified dedicated lock before compiling", async () => {
const lockPath = "docker/daytona-runner/provider-dependencies.lock.yaml";
const lock = await readFile(path.join(repositoryRoot, lockPath));
const lockDigest = createHash("sha256").update(lock).digest("hex");
for (const [file, stageName] of [
["docker/daytona-runner/Dockerfile", "provider-pack-build"],
["Dockerfile", "cloud-provider-pack"],
] as const) {
const dockerfile = await readFile(path.join(repositoryRoot, file), "utf8");
const stage = dockerfile
.split(/^FROM /m)
.find((part) => part.split("\n", 1)[0]!.endsWith(` AS ${stageName}`));
expect(stage, file).toBeDefined();
const normalized = stage!.replace(/\\\r?\n\s*/g, " ");
const copy = normalized.indexOf(`COPY ${lockPath} ./pnpm-lock.yaml`);
const verify = normalized.indexOf("sha256sum -c /tmp/provider-lock.sha256");
const install = normalized.indexOf(
"pnpm install --frozen-lockfile --filter '@paperclipai/paperclip-runner...'",
);
const build = normalized.indexOf(
"pnpm --filter @paperclipai/paperclip-runner build:typescript",
);
const pack = normalized.indexOf(
"node packages/paperclip-runner/scripts/build-provider-pack.mjs /provider-pack",
);
expect(normalized).toContain(`ARG PAPERCLIP_RUNNER_LOCK_SHA256=${lockDigest}`);
expect(copy).toBeGreaterThan(0);
expect(verify).toBeGreaterThan(copy);
expect(install).toBeGreaterThan(verify);
expect(build).toBeGreaterThan(install);
expect(pack).toBeGreaterThan(build);
expect(normalized).not.toContain("--resolution-only");
expect(normalized).not.toContain("--no-frozen-lockfile");
expect(normalized).not.toMatch(/COPY --from=.*(?:node_modules|packages)/);
for (const input of [
"COPY packages ./packages",
"COPY server/package.json",
"COPY ui/package.json",
"COPY cli/package.json",
]) {
expect(normalized).toContain(input);
}
}
});
it("tracks dedicated transitive resolution and copied manifests but ignores CI root-lock churn", async () => {
const root = await mkdtemp(
path.join(tmpdir(), "paperclip-provider-lock-inputs-"),
);
const lockPath = "docker/daytona-runner/provider-dependencies.lock.yaml";
const manifestPath = "packages/adapters/codex-local/package.json";
// Select actual contract paths, so silently removing one breaks this test.
const inputPaths = DAYTONA_IMAGE_INPUT_PATHS.filter((entry) =>
[lockPath, manifestPath, "ui/package.json", "pnpm-lock.yaml"].includes(entry),
);
const options = {
repositoryRoot: root,
inputPaths,
baseImages: [`example.test/base:1@sha256:${"a".repeat(64)}`],
frontendDigest: `sha256:${"c".repeat(64)}`,
};
try {
for (const file of [lockPath, manifestPath, "ui/package.json"]) {
await mkdir(path.dirname(path.join(root, file)), { recursive: true });
}
const resolution =
"lockfileVersion: '9.0'\npackages:\n '@aws-sdk/client-s3@3.999.0':\n resolution: {integrity: sha512-reviewed}\n";
await writeFile(path.join(root, lockPath), resolution);
await writeFile(
path.join(root, manifestPath),
'{"dependencies":{"codex-acp":"1.6.2"}}',
);
await writeFile(
path.join(root, "ui/package.json"),
'{"dependencies":{"react":"19.2.8"}}',
);
await writeFile(path.join(root, "pnpm-lock.yaml"), "CI lock before refresh");
const original = await computeDaytonaImageContentId(options);
await writeFile(
path.join(root, "pnpm-lock.yaml"),
"CI lock after unrelated refresh",
);
expect(await computeDaytonaImageContentId(options)).toBe(original);
await writeFile(
path.join(root, lockPath),
resolution.replace("sha512-reviewed", "sha512-different"),
);
expect(await computeDaytonaImageContentId(options)).not.toBe(original);
await writeFile(path.join(root, lockPath), resolution);
for (const file of [manifestPath, "ui/package.json"]) {
const originalManifest = await readFile(path.join(root, file), "utf8");
await writeFile(
path.join(root, file),
originalManifest.replace(/1\.6\.2|19\.2\.8/, "99.0.0"),
);
expect(await computeDaytonaImageContentId(options)).not.toBe(original);
await writeFile(path.join(root, file), originalManifest);
}
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("hashes the audited image dependency closure rather than the repository revision", async () => {
for (const requiredPath of [
".dockerignore",
"docker/daytona-runner/Dockerfile",
"pnpm-lock.yaml",
"docker/daytona-runner/provider-dependencies.lock.yaml",
"patches",
"packages/paperclip-eval-kernel/src",
"packages/paperclip-runner/package.json",
@ -174,6 +282,28 @@ describe("runner E2E Daytona image contract", () => {
]) {
expect(DAYTONA_IMAGE_INPUT_PATHS).toContain(requiredPath);
}
expect(DAYTONA_IMAGE_CONTENT_SCHEMA).toBe(
"paperclip-daytona-runner-image-content/v6",
);
expect(DAYTONA_IMAGE_INPUT_PATHS).not.toContain("pnpm-lock.yaml");
// COPY packages ./packages includes every committed manifest, including
// nested adapter/plugin packages. A new package must update this contract.
const packageManifests = execFileSync(
"git",
["ls-files", "--", "packages/**/package.json"],
{ cwd: repositoryRoot, encoding: "utf8" },
)
.trim()
.split("\n");
expect(packageManifests.length).toBeGreaterThan(0);
for (const manifest of [
...packageManifests,
"server/package.json",
"ui/package.json",
"cli/package.json",
]) {
expect(DAYTONA_IMAGE_INPUT_PATHS).toContain(manifest);
}
expect(DAYTONA_IMAGE_INPUT_PATHS).not.toContain(
"packages/paperclip-eval-kernel",
);
@ -195,7 +325,7 @@ describe("runner E2E Daytona image contract", () => {
const inputPaths = [
"docker/daytona-runner/Dockerfile",
"package.json",
"pnpm-lock.yaml",
"docker/daytona-runner/provider-dependencies.lock.yaml",
"packages/paperclip-runner/package.json",
"packages/paperclip-runner/src",
"packages/paperclip-runner/runner/crates",
@ -226,7 +356,7 @@ describe("runner E2E Daytona image contract", () => {
);
await writeFile(path.join(root, "package.json"), '{"private":true}\n');
await writeFile(
path.join(root, "pnpm-lock.yaml"),
path.join(root, "docker/daytona-runner/provider-dependencies.lock.yaml"),
"lockfileVersion: 9\n",
);
await writeFile(
@ -265,10 +395,16 @@ describe("runner E2E Daytona image contract", () => {
);
expect(await computeDaytonaImageContentId(options)).toBe(baseline);
await writeFile(
path.join(root, "pnpm-lock.yaml"),
"unrelated root resolution\n",
);
expect(await computeDaytonaImageContentId(options)).toBe(baseline);
for (const relativePath of [
"docker/daytona-runner/Dockerfile",
"package.json",
"pnpm-lock.yaml",
"docker/daytona-runner/provider-dependencies.lock.yaml",
"packages/paperclip-runner/package.json",
"packages/paperclip-runner/src/runner.ts",
"packages/paperclip-runner/runner/crates/runner-core/src/lib.rs",