fix: build provider packs from an immutable dependency lock
This commit is contained in:
parent
8bbf888a71
commit
310d5e3627
21
Dockerfile
21
Dockerfile
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
773
pnpm-lock.yaml
773
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue