mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
Materialize independent files in new provider packs
Bind the new layout policy into provider-pack identity and verify it on controller and guest while preserving legacy immutable packs and approved image root aliases. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
f5a393dd60
commit
b64d630969
10 files changed
+235
No files matched your search
@@ -168,6 +168,15 @@ are unavailable. Accepting a native plan ends the planning run successfully whil
|
||||
the task waits for the next instruction. See the
|
||||
[Cursor release report](../../doc/plans/2026-10-03-cursor-production-readiness.md).
|
||||
|
||||
New provider packs declare `payload.fileLayout: "independent-regular-files-v1"`.
|
||||
The builder separates hardlinked regular files in its unpublished temporary tree,
|
||||
preserving file bytes, modes, and symlinks. The policy participates in the manifest
|
||||
digest, so a new layout cannot reuse a legacy cache identity. Controller and guest
|
||||
verification reject unknown policies and hardlinked files under this policy.
|
||||
Existing manifests without the field retain their original immutable contract;
|
||||
they are not rewritten or repaired during session reuse. Runtime module and
|
||||
executable integrity guards remain unchanged.
|
||||
|
||||
A release includes all three platform daemons and the Linux provider-pack identity
|
||||
from its matching Daytona image. Run `stage:release-binaries` with a manifest that
|
||||
binds each daemon path and SHA-256, plus `remoteProviderPack: {path, sha256}` for
|
||||
|
||||
@@ -20,6 +20,7 @@ import { dirname, join, relative, resolve } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseProviderPackArguments, materializeCandidateProviderPack, providerPackProviders, providerPackManifestFields } from "./candidate-provider-pack.mjs";
|
||||
import { materializeIndependentProviderPackFiles, PROVIDER_PACK_FILE_LAYOUT } from "./provider-pack-file-layout.mjs";
|
||||
import { buildNodeStartupTimeout } from "./build-node-startup-timeout.mjs";
|
||||
import { writePortableExecutableShim, writePortableCopilotShims } from "./provider-pack-executable-shims.mjs";
|
||||
|
||||
@@ -277,6 +278,8 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
materializeIndependentProviderPackFiles(temporaryRoot);
|
||||
|
||||
const opencodeProxySha = sha256File(join(temporaryRoot, opencodeProxyPath));
|
||||
const acpxSidecarSha = sha256File(join(temporaryRoot, acpxSidecarPath));
|
||||
const distDigest = sha256Tree(join(temporaryRoot, "dist"));
|
||||
@@ -297,6 +300,7 @@ try {
|
||||
cwd: workspaceRoot,
|
||||
}).status !== 0;
|
||||
const payload = {
|
||||
fileLayout: PROVIDER_PACK_FILE_LAYOUT,
|
||||
pins: {
|
||||
nodeMinimum: minimumNodeVersion.join("."),
|
||||
codex: profiles.profiles.codex.agentRuntimeVersion,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { chmodSync, constants, copyFileSync, lstatSync, mkdtempSync, readdirSync, renameSync, rmSync, utimesSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export const PROVIDER_PACK_FILE_LAYOUT = "independent-regular-files-v1";
|
||||
|
||||
/** Only call on the unpublished build tree, never on a retained provider cache. */
|
||||
export function materializeIndependentProviderPackFiles(root) {
|
||||
const visit = directory => {
|
||||
for (const name of readdirSync(directory)) {
|
||||
const file = join(directory, name);
|
||||
const stat = lstatSync(file);
|
||||
if (stat.isDirectory()) visit(file);
|
||||
else if (stat.isSymbolicLink()) continue;
|
||||
else if (!stat.isFile()) throw new Error("Unsupported provider pack file layout entry");
|
||||
else if (stat.nlink > 1) {
|
||||
// Copy to a new inode before changing metadata: the source may also be
|
||||
// linked into pnpm's store outside this unpublished tree.
|
||||
const temporary = mkdtempSync(join(directory, ".paperclip-independent-"));
|
||||
try {
|
||||
const replacement = join(temporary, "file");
|
||||
copyFileSync(file, replacement, constants.COPYFILE_EXCL);
|
||||
chmodSync(replacement, stat.mode & 0o7777);
|
||||
utimesSync(replacement, stat.atimeMs / 1000, stat.mtimeMs / 1000);
|
||||
renameSync(replacement, file);
|
||||
} finally {
|
||||
rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if (!lstatSync(root).isDirectory()) throw new Error("Invalid provider pack build root");
|
||||
visit(root);
|
||||
const verify = directory => {
|
||||
for (const name of readdirSync(directory)) {
|
||||
const file = join(directory, name);
|
||||
const stat = lstatSync(file);
|
||||
if (stat.isDirectory()) verify(file);
|
||||
else if (stat.isFile() && stat.nlink !== 1) throw new Error("Provider pack files are not independent");
|
||||
else if (!stat.isFile() && !stat.isSymbolicLink()) throw new Error("Unsupported provider pack file layout entry");
|
||||
}
|
||||
};
|
||||
verify(root);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { chmodSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { materializeIndependentProviderPackFiles } from "./provider-pack-file-layout.mjs";
|
||||
|
||||
test("new pack files remain independent after a real tar round trip without changing source bytes, modes or symlinks", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "provider-pack-layout-"));
|
||||
try {
|
||||
const pack = join(root, "pack"); mkdirSync(pack);
|
||||
const outside = join(root, "store"); mkdirSync(outside);
|
||||
const source = join(outside, "source"); writeFileSync(source, "identical module bytes\n"); chmodSync(source, 0o755);
|
||||
linkSync(source, join(pack, "a")); linkSync(source, join(pack, "b"));
|
||||
writeFileSync(join(pack, "c"), "identical module bytes\n");
|
||||
symlinkSync("a", join(pack, "relative-link"));
|
||||
symlinkSync(outside, join(pack, "external-directory-link"));
|
||||
writeFileSync(join(outside, "untouched-a"), "outside");
|
||||
linkSync(join(outside, "untouched-a"), join(outside, "untouched-b"));
|
||||
const outsideInode = lstatSync(join(outside, "untouched-a")).ino;
|
||||
const original = lstatSync(source);
|
||||
// Prove plain tar preserves the old hardlink topology.
|
||||
execFileSync("tar", ["-czf", join(root, "legacy.tgz"), "-C", pack, "."]);
|
||||
const legacy = join(root, "legacy"); mkdirSync(legacy);
|
||||
execFileSync("tar", ["-xzf", join(root, "legacy.tgz"), "-C", legacy]);
|
||||
assert.equal(lstatSync(join(legacy, "a")).ino, lstatSync(join(legacy, "b")).ino);
|
||||
assert.equal(lstatSync(join(legacy, "a")).nlink, 2);
|
||||
materializeIndependentProviderPackFiles(pack);
|
||||
const once = ["a", "b", "c"].map(name => lstatSync(join(pack, name)).ino);
|
||||
materializeIndependentProviderPackFiles(pack);
|
||||
assert.deepEqual(["a", "b", "c"].map(name => lstatSync(join(pack, name)).ino), once);
|
||||
execFileSync("tar", ["-czf", join(root, "independent.tgz"), "-C", pack, "."]);
|
||||
const unpacked = join(root, "unpacked"); mkdirSync(unpacked);
|
||||
execFileSync("tar", ["-xzf", join(root, "independent.tgz"), "-C", unpacked]);
|
||||
const stats = ["a", "b", "c"].map(name => lstatSync(join(unpacked, name)));
|
||||
assert.equal(new Set(stats.map(stat => stat.ino)).size, 3);
|
||||
assert.ok(stats.every(stat => stat.nlink === 1));
|
||||
for (const name of ["a", "b", "c"]) assert.equal(readFileSync(join(unpacked, name), "utf8"), "identical module bytes\n");
|
||||
assert.equal(stats[0].mode & 0o7777, 0o755);
|
||||
assert.equal(stats[1].mode & 0o7777, 0o755);
|
||||
assert.equal(readlinkSync(join(unpacked, "relative-link")), "a");
|
||||
assert.equal(readlinkSync(join(unpacked, "external-directory-link")), outside);
|
||||
const after = lstatSync(source);
|
||||
assert.equal(after.ino, original.ino); assert.equal(after.mode, original.mode);
|
||||
assert.equal(after.mtimeMs, original.mtimeMs); assert.equal(after.nlink, 1);
|
||||
assert.equal(readFileSync(source, "utf8"), "identical module bytes\n");
|
||||
for (const name of ["untouched-a", "untouched-b"]) {
|
||||
assert.equal(lstatSync(join(outside, name)).ino, outsideInode);
|
||||
assert.equal(lstatSync(join(outside, name)).nlink, 2);
|
||||
}
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("does not accept a symlink as the unpublished root", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "provider-pack-layout-root-"));
|
||||
try {
|
||||
mkdirSync(join(root, "tree")); symlinkSync(join(root, "tree"), join(root, "link"));
|
||||
assert.throws(() => materializeIndependentProviderPackFiles(join(root, "link")), /build root/);
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -12,6 +12,9 @@ export function verifyReleaseProviderPack(identity, sourceRevision) {
|
||||
if (identity?.schema !== "paperclip-runner/remote-provider-pack/v1" || !payload
|
||||
|| payload.target?.platform !== "linux" || payload.target?.architecture !== "x64"
|
||||
|| payload.runnerSourceRevision !== sourceRevision) throw new Error("Remote provider pack must match the release source and Linux x64 target");
|
||||
if (payload.fileLayout !== undefined && payload.fileLayout !== "independent-regular-files-v1") {
|
||||
throw new Error("Remote provider-pack file layout is unsupported");
|
||||
}
|
||||
if (identity.digest !== `sha256:${createHash("sha256").update(canonical(payload)).digest("hex")}`) {
|
||||
throw new Error("Remote provider-pack payload digest mismatch");
|
||||
}
|
||||
|
||||
@@ -77,3 +77,14 @@ test("release assembly rejects rehashed stale or missing packs before staging an
|
||||
}
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("file layout is a closed, digest-bound policy with legacy compatibility", () => {
|
||||
const legacy = seal(fixture());
|
||||
const independent = seal({ ...fixture(), fileLayout: "independent-regular-files-v1" });
|
||||
assert.notEqual(independent.digest, legacy.digest);
|
||||
assert.equal(verifyReleaseProviderPack(independent, revision), independent);
|
||||
assert.equal(verifyReleaseProviderPack(legacy, revision), legacy);
|
||||
for (const fileLayout of [null, {}, "unknown-policy"]) {
|
||||
assert.throws(() => verifyReleaseProviderPack(seal({ ...fixture(), fileLayout }), revision), /file layout/);
|
||||
}
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
chmod,
|
||||
cp,
|
||||
lstat,
|
||||
link,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
open,
|
||||
@@ -1289,6 +1290,28 @@ describe("remote provider pack manifest", () => {
|
||||
expect(readRemoteProviderPackManifest(root).payload.pins.opencode).toBe(
|
||||
"1.18.34",
|
||||
);
|
||||
// Content digests cannot distinguish hardlinks from independent files.
|
||||
const legacyDigest = readRemoteProviderPackManifest(root).digest;
|
||||
const alias = join(root, "node_modules", "node-alias");
|
||||
await link(join(root, "node_modules", "node", "bin", "node"), alias);
|
||||
expect(readRemoteProviderPackManifest(root).digest).toBe(legacyDigest);
|
||||
Object.assign(payload, { fileLayout: "independent-regular-files-v1" });
|
||||
await writeManifest();
|
||||
expect(() => readRemoteProviderPackManifest(root)).toThrow("provider pack file layout");
|
||||
expect((await lstat(alias)).nlink).toBe(2); // Reject; never repair immutable caches.
|
||||
await rm(alias);
|
||||
const independent = readRemoteProviderPackManifest(root);
|
||||
expect(independent.digest).not.toBe(legacyDigest);
|
||||
expect(readBundledRemoteProviderPackManifest(join(root, "provider-pack.json")).digest).toBe(independent.digest);
|
||||
expect(independent.payload.distDigest).toBe(payload.distDigest);
|
||||
expect(readRemoteProviderPackManifest(root).digest).toBe(independent.digest);
|
||||
Object.assign(payload, { fileLayout: "unknown-policy" });
|
||||
await writeManifest();
|
||||
expect(() => readRemoteProviderPackManifest(root)).toThrow("provider pack file layout");
|
||||
expect(() => readBundledRemoteProviderPackManifest(join(root, "provider-pack.json"))).toThrow("provider pack file layout");
|
||||
delete (payload as typeof payload & { fileLayout?: string }).fileLayout;
|
||||
await writeManifest();
|
||||
expect(readRemoteProviderPackManifest(root).digest).toBe(legacyDigest);
|
||||
const cursorPath = "provider-assets/cursor/linux-x64";
|
||||
await mkdir(join(root, cursorPath), { recursive: true });
|
||||
await writeFile(join(root, cursorPath, "runtime"), "pinned Cursor runtime");
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import { nativeCompletionFeedback } from "./native-completion-feedback.js";
|
||||
import { hasAcknowledgedNativeReassignmentStopIntent, hasAcknowledgedNativeStopIntent } from "../acknowledged-native-stop.js";
|
||||
import { stoppedCodexTurnIsTextOnly } from "./stopped-codex-turn.js";
|
||||
import { verifyProviderPackFileLayout } from "./remote-provider-pack-file-layout.js";
|
||||
import { prepareVerifiedRemoteProviderPack } from "./remote-provider-pack.js";
|
||||
import { selectRemotePiCompanion } from "./remote-pi-companion.js";
|
||||
import { readNativeLocalProcessStop, PROCESS_START_REQUESTED } from "../native-local-process-stop.js";
|
||||
@@ -67,6 +68,7 @@ import {
|
||||
readSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -9906,6 +9908,7 @@ type RemoteProviderPackManifest = {
|
||||
schema: typeof REMOTE_PROVIDER_PACK_SCHEMA;
|
||||
digest: string;
|
||||
payload: {
|
||||
fileLayout?: "independent-regular-files-v1";
|
||||
pins: typeof REMOTE_PROVIDER_PACK_PINS;
|
||||
target: { platform: string; architecture: string };
|
||||
runnerSourceRevision: string;
|
||||
@@ -10029,6 +10032,7 @@ function readRemoteProviderPackIdentity(packRoot: string, verifyControllerFiles:
|
||||
"runner_remote_provider_artifact_incompatible: provider pack pins or source revision do not match",
|
||||
);
|
||||
}
|
||||
verifyProviderPackFileLayout(null, payload.fileLayout, { lstatSync, statSync, readdirSync }, { join });
|
||||
const digest = `sha256:${createHash("sha256")
|
||||
.update(canonicalJson(payload))
|
||||
.digest("hex")}`;
|
||||
@@ -10139,6 +10143,9 @@ function readRemoteProviderPackIdentity(packRoot: string, verifyControllerFiles:
|
||||
"runner_remote_provider_artifact_incompatible: provider bridge digest mismatch",
|
||||
);
|
||||
}
|
||||
if (verifyControllerFiles) {
|
||||
verifyProviderPackFileLayout(packRoot, payload.fileLayout, { lstatSync, statSync, readdirSync }, { join });
|
||||
}
|
||||
return structuredClone(manifest);
|
||||
}
|
||||
|
||||
@@ -11745,6 +11752,7 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
"const packageVersion=(pkg)=>JSON.parse(fs.readFileSync(path.join(root,'node_modules',...pkg.split('/'),'package.json'),'utf8')).version",
|
||||
"const expectedPackages={acpx:manifest.payload.pins.acpx,'@agentclientprotocol/claude-agent-acp':manifest.payload.pins.claudeAcp,'@agentclientprotocol/codex-acp':manifest.payload.pins.codexAcp,'opencode-ai':manifest.payload.pins.opencode}",
|
||||
"for(const [pkg,version] of Object.entries(expectedPackages))if(packageVersion(pkg)!==version)throw new Error(pkg+' version mismatch')",
|
||||
`(${verifyProviderPackFileLayout.toString()})(root,manifest.payload.fileLayout,fs,path)`,
|
||||
].join(";");
|
||||
const verified = await remoteCommandRunner.execute({
|
||||
command: providerNodeCommand,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { verifyProviderPackFileLayout } from "./remote-provider-pack-file-layout.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); });
|
||||
function fixture() {
|
||||
const root = fs.mkdtempSync(path.join(tmpdir(), "remote-file-layout-")); roots.push(root);
|
||||
fs.mkdirSync(path.join(root, "nested")); fs.writeFileSync(path.join(root, "nested", "one"), "same bytes");
|
||||
fs.symlinkSync("nested/one", path.join(root, "link"));
|
||||
return root;
|
||||
}
|
||||
function check(root: string, policy: unknown, remote: boolean) {
|
||||
if (!remote) return verifyProviderPackFileLayout(root, policy, fs, path);
|
||||
// Exercise precisely the self-contained function serialized by the guest verifier.
|
||||
const program = `const fs=require('node:fs'),path=require('node:path');(${verifyProviderPackFileLayout.toString()})(process.argv[1],JSON.parse(process.argv[2]).policy,fs,path)`;
|
||||
execFileSync(process.execPath, ["-e", program, root, JSON.stringify({ policy })], { stdio: "pipe" });
|
||||
}
|
||||
describe.each([false, true])("provider file layout validation remote=%s", remote => {
|
||||
it("accepts independent files and symlinks; rejects later hardlinks without repair", () => {
|
||||
const root = fixture(); const policy = "independent-regular-files-v1";
|
||||
check(root, policy, remote);
|
||||
fs.linkSync(path.join(root, "nested", "one"), path.join(root, "two"));
|
||||
expect(() => check(root, policy, remote)).toThrow("runner_remote_provider_artifact_incompatible: provider pack file layout");
|
||||
expect(fs.lstatSync(path.join(root, "two")).nlink).toBe(2);
|
||||
check(root, undefined, remote); // Legacy caches remain readable and unchanged.
|
||||
expect(fs.lstatSync(path.join(root, "two")).nlink).toBe(2);
|
||||
});
|
||||
it("accepts an authority-selected preinstalled root alias without following interior directory aliases", () => {
|
||||
const root = fixture();
|
||||
const aliasParent = fs.mkdtempSync(path.join(tmpdir(), "preinstalled-file-layout-")); roots.push(aliasParent);
|
||||
const alias = path.join(aliasParent, "approved-image-pack"); fs.symlinkSync(root, alias);
|
||||
const outside = path.join(aliasParent, "outside"); fs.mkdirSync(outside);
|
||||
fs.writeFileSync(path.join(outside, "one"), "unrelated");
|
||||
fs.linkSync(path.join(outside, "one"), path.join(outside, "two"));
|
||||
fs.symlinkSync(outside, path.join(root, "interior-directory-alias"));
|
||||
check(alias, "independent-regular-files-v1", remote);
|
||||
expect(fs.lstatSync(path.join(outside, "one")).nlink).toBe(2);
|
||||
fs.linkSync(path.join(root, "nested", "one"), path.join(root, "two"));
|
||||
expect(() => check(alias, "independent-regular-files-v1", remote)).toThrow("runner_remote_provider_artifact_incompatible: provider pack file layout");
|
||||
});
|
||||
it.each([null, "unknown-policy", {}, 1])("rejects unknown policy %j", policy => {
|
||||
expect(() => check(fixture(), policy, remote)).toThrow("runner_remote_provider_artifact_incompatible: provider pack file layout");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Also serialized into the remote verifier; keep this body self-contained. */
|
||||
export function verifyProviderPackFileLayout(
|
||||
root: string | null,
|
||||
policy: unknown,
|
||||
fs: Pick<typeof import("node:fs"), "lstatSync" | "statSync" | "readdirSync">,
|
||||
path: Pick<typeof import("node:path"), "join">,
|
||||
): void {
|
||||
if (policy === undefined) return; // Existing immutable packs retain their original contract.
|
||||
if (policy !== "independent-regular-files-v1") throw new Error("runner_remote_provider_artifact_incompatible: provider pack file layout is unsupported");
|
||||
if (root === null) return; // Bundled manifest identity has no controller-side asset tree.
|
||||
// The caller may select an approved preinstalled-image root alias. Only this
|
||||
// root follows that alias; interior directory symlinks remain untouched.
|
||||
if (!fs.statSync(root).isDirectory()) throw new Error("runner_remote_provider_artifact_incompatible: provider pack file layout root is invalid");
|
||||
const visit = (directory: string): void => {
|
||||
for (const name of fs.readdirSync(directory)) {
|
||||
const file = path.join(directory, name);
|
||||
const stat = fs.lstatSync(file);
|
||||
if (stat.isDirectory()) visit(file);
|
||||
else if (stat.isFile()) {
|
||||
if (stat.nlink !== 1) throw new Error("runner_remote_provider_artifact_incompatible: provider pack file layout requires independent regular files");
|
||||
} else if (!stat.isSymbolicLink()) throw new Error("runner_remote_provider_artifact_incompatible: provider pack file layout entry is unsupported");
|
||||
}
|
||||
};
|
||||
visit(root);
|
||||
}
|
||||
Reference in new issue
Block a user