diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index a7f0647a39f..eec229a332c 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -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 diff --git a/packages/paperclip-runner/scripts/build-provider-pack.mjs b/packages/paperclip-runner/scripts/build-provider-pack.mjs index a58e8cb3156..76f66fdc4a6 100644 --- a/packages/paperclip-runner/scripts/build-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/build-provider-pack.mjs @@ -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, diff --git a/packages/paperclip-runner/scripts/provider-pack-file-layout.mjs b/packages/paperclip-runner/scripts/provider-pack-file-layout.mjs new file mode 100644 index 00000000000..0d9a8e99ece --- /dev/null +++ b/packages/paperclip-runner/scripts/provider-pack-file-layout.mjs @@ -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); +} diff --git a/packages/paperclip-runner/scripts/provider-pack-file-layout.test.mjs b/packages/paperclip-runner/scripts/provider-pack-file-layout.test.mjs new file mode 100644 index 00000000000..f871cf1db25 --- /dev/null +++ b/packages/paperclip-runner/scripts/provider-pack-file-layout.test.mjs @@ -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 }); } +}); diff --git a/packages/paperclip-runner/scripts/release-provider-pack.mjs b/packages/paperclip-runner/scripts/release-provider-pack.mjs index cedbde6bbf2..b15940eee7f 100644 --- a/packages/paperclip-runner/scripts/release-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/release-provider-pack.mjs @@ -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"); } diff --git a/packages/paperclip-runner/scripts/release-provider-pack.test.mjs b/packages/paperclip-runner/scripts/release-provider-pack.test.mjs index 33d6de5874f..2f96872f502 100644 --- a/packages/paperclip-runner/scripts/release-provider-pack.test.mjs +++ b/packages/paperclip-runner/scripts/release-provider-pack.test.mjs @@ -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/); + } +}); diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 212c729d4fb..03e33e7bd23 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -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"); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 8947dbb6bd0..77dea07b37e 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -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, diff --git a/server/src/services/native-runtime/remote-provider-pack-file-layout.test.ts b/server/src/services/native-runtime/remote-provider-pack-file-layout.test.ts new file mode 100644 index 00000000000..d7b6b17292d --- /dev/null +++ b/server/src/services/native-runtime/remote-provider-pack-file-layout.test.ts @@ -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"); + }); +}); diff --git a/server/src/services/native-runtime/remote-provider-pack-file-layout.ts b/server/src/services/native-runtime/remote-provider-pack-file-layout.ts new file mode 100644 index 00000000000..efea8d62745 --- /dev/null +++ b/server/src/services/native-runtime/remote-provider-pack-file-layout.ts @@ -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, + path: Pick, +): 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); +}