diff --git a/Dockerfile b/Dockerfile index 1db10902f6..986282521b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,7 +63,7 @@ WORKDIR /app # dependency, and rustup does not — without it every build script dies on # "linker `cc` not found". RUN apt-get update \ - && apt-get install -y --no-install-recommends gcc libc6-dev pkg-config \ + && apt-get install -y --no-install-recommends gcc libc6-dev pkg-config patch \ && rm -rf /var/lib/apt/lists/* ENV RUSTUP_HOME=/usr/local/rustup \ CARGO_HOME=/usr/local/cargo \ @@ -143,6 +143,14 @@ ENV NODE_OPTIONS=--max-old-space-size=4096 RUN pnpm --filter @paperclipai/server build RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1) RUN rm -rf packages/paperclip-runner/runner/target +# Vendored Codex ACPX execution binds package authority to server/node_modules. +# pnpm links into the workspace store cannot satisfy that boundary. Reuse the +# public-package producer to materialize only this image's existing qualified +# native target, with dependency scripts disabled and runtime authority intact. +# ARM64 remains usable through legacy adapters while native qualification is +# limited to the source-owned supported targets. +ARG TARGETARCH +RUN node scripts/prepare-bundled-package.mjs --docker-provider-graph /app/server "${TARGETARCH}" # Remote OpenCode and ACPX runs require a controller-owned provider pack to # verify the sandbox installation or stage matching assets. Ship it in the diff --git a/cli/src/__tests__/install-command.test.ts b/cli/src/__tests__/install-command.test.ts index 620504e348..a298cbcdf3 100644 --- a/cli/src/__tests__/install-command.test.ts +++ b/cli/src/__tests__/install-command.test.ts @@ -28,6 +28,7 @@ import { resolveCliVersion } from "../version.js"; import { systemdServiceName } from "../services/service-manager.js"; const ORIGINAL_ENV = { ...process.env }; +const SERVER_PACKAGE_FILES = (JSON.parse(fs.readFileSync(new URL("../../../server/package.json", import.meta.url), "utf8")) as { files: string[] }).files; describe("managed install commands", () => { let root: string; @@ -105,7 +106,7 @@ describe("managed install commands", () => { expect(runCommand.mock.calls[0]?.[0]).toBe(process.execPath); }); - const createGitCheckoutRunCommand = (sha: string) => + const createGitCheckoutRunCommand = (sha: string, { bundledServer = false }: { bundledServer?: boolean } = {}) => vi.fn(async (file: string, args: string[], _options?: Parameters[2]) => { if (file === "curl" && !args.includes("--output")) return { stdout: JSON.stringify({ sha }), stderr: "" }; if (file === "curl") { fs.writeFileSync(args[args.indexOf("--output") + 1], "archive"); return { stdout: "", stderr: "" }; } @@ -114,19 +115,45 @@ describe("managed install commands", () => { const packages = [ { dir: "packages/shared", name: "@paperclipai/shared", packageJson: { name: "@paperclipai/shared", version: "0.3.1" } }, { dir: "packages/db", name: "@paperclipai/db", packageJson: { name: "@paperclipai/db", version: "0.3.1", dependencies: { "@paperclipai/shared": "workspace:*" }, bundleDependencies: ["embedded-postgres"] } }, - { dir: "server", name: "@paperclipai/server", packageJson: { name: "@paperclipai/server", version: "0.3.1", dependencies: { "@paperclipai/db": "workspace:*" } } }, + { dir: "server", name: "@paperclipai/server", packageJson: { name: "@paperclipai/server", version: "0.3.1", dependencies: { "@paperclipai/db": "workspace:*" }, ...(bundledServer ? { bundleDependencies: ["acpx"], files: SERVER_PACKAGE_FILES } : {}) } }, ]; fs.mkdirSync(path.join(checkout, "cli"), { recursive: true }); fs.writeFileSync(path.join(checkout, "cli", "package.json"), JSON.stringify({ version: "0.3.1" })); + fs.writeFileSync(path.join(checkout, "pnpm-lock.yaml"), "source lockfile awaiting refresh\n"); fs.mkdirSync(path.join(checkout, "scripts"), { recursive: true }); fs.writeFileSync(path.join(checkout, "scripts", "release-package-manifest.json"), JSON.stringify(packages.map(({ dir, name }) => ({ dir, name })))); for (const workspacePackage of packages) { fs.mkdirSync(path.join(checkout, workspacePackage.dir), { recursive: true }); fs.writeFileSync(path.join(checkout, workspacePackage.dir, "package.json"), JSON.stringify(workspacePackage.packageJson)); } + const skill = path.join(checkout, "skills", "paperclip"); + fs.mkdirSync(path.join(skill, "scripts"), { recursive: true }); + fs.writeFileSync(path.join(skill, "SKILL.md"), "Skill from this Git checkout\n"); + fs.writeFileSync(path.join(skill, "scripts", "helper.mjs"), "export const sameCheckout = true;\n"); + for (const packageDir of ["server", "packages/adapters/claude-local", "packages/adapters/codex-local"]) { + const stale = path.join(checkout, packageDir, "skills"); + fs.mkdirSync(stale, { recursive: true }); + fs.writeFileSync(path.join(stale, "stale.md"), "stale package-local skills\n"); + } return { stdout: "", stderr: "" }; } if (file === "corepack") { + if (args.includes("build")) { + const dist = path.join(_options!.cwd as string, "server", "dist"); + fs.mkdirSync(dist, { recursive: true }); + fs.writeFileSync(path.join(dist, "index.js"), "export const builtServer = true;\n"); + } + if (args.includes("prepare:ui-dist")) { + const ui = path.join(_options!.cwd as string, "server", "ui-dist"); + fs.mkdirSync(ui, { recursive: true }); + fs.writeFileSync(path.join(ui, "index.html"), "Built from this Git checkout"); + } + if (args.includes("--resolution-only")) { + fs.writeFileSync(path.join(_options!.cwd as string, "pnpm-lock.yaml"), "resolved checkout lockfile\n"); + } + if (args.includes("--frozen-lockfile")) { + expect(fs.readFileSync(path.join(_options!.cwd as string, "pnpm-lock.yaml"), "utf8")).toBe("resolved checkout lockfile\n"); + } if (args.includes("pack")) { const destination = args[args.indexOf("--pack-destination") + 1]; const packageDir = args[args.indexOf("--dir") + 1]; @@ -137,14 +164,32 @@ describe("managed install commands", () => { } if (file === "bash") return { stdout: "", stderr: "" }; if (file === "npm" && args[0] === "pack") { - const packageName = args[1]?.includes("workspace-package-") ? "paperclipai-db" : "paperclipai"; + const packageName = args[1]?.includes("workspace-package-") + ? (JSON.parse(fs.readFileSync(path.join(args[1], "package.json"), "utf8")) as { name: string }).name.replace("@", "").replace("/", "-") + : "paperclipai"; + if (packageName === "paperclipai-server") { + expect(fs.readFileSync(path.join(args[1], "ui-dist", "index.html"), "utf8")) + .toBe("Built from this Git checkout"); + expect(fs.readFileSync(path.join(args[1], "skills", "paperclip", "SKILL.md"), "utf8")) + .toBe("Skill from this Git checkout\n"); + expect(fs.readFileSync(path.join(args[1], "dist", "index.js"), "utf8")) + .toBe("export const builtServer = true;\n"); + } fs.writeFileSync(path.join(args[args.indexOf("--pack-destination") + 1], `${packageName}-0.3.1.tgz`), "package"); return { stdout: "", stderr: "" }; } if (file === "npm" && args[0] === "install") { const prefix = args[args.indexOf("--prefix") + 1]; const packageRoot = path.join(prefix, "node_modules", "paperclipai"); fs.mkdirSync(path.join(packageRoot, "dist"), { recursive: true }); fs.writeFileSync(path.join(packageRoot, "package.json"), JSON.stringify({ version: "0.3.1" })); fs.writeFileSync(path.join(packageRoot, "dist", "index.js"), "#!/usr/bin/env node\n"); return { stdout: "", stderr: "" }; } if (file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs")) { + const packageJson = JSON.parse(fs.readFileSync(path.join(args[1], "package.json"), "utf8")) as { name: string; version: string; files?: string[] }; fs.mkdirSync(args[2], { recursive: true }); - fs.writeFileSync(path.join(args[2], "package.json"), JSON.stringify({ name: "@paperclipai/db", version: "0.3.1" })); + if (packageJson.name === "@paperclipai/server") { + // Match the real bundler's complete declared-file copy. Missing + // runtime assets must fail before packing an incomplete payload. + for (const entry of packageJson.files ?? []) { + fs.cpSync(path.join(args[1], entry), path.join(args[2], entry), { recursive: true }); + } + } + fs.writeFileSync(path.join(args[2], "package.json"), JSON.stringify(packageJson)); return { stdout: "", stderr: "" }; } if (file === process.execPath) return { stdout: "0.3.1\n", stderr: "" }; @@ -160,7 +205,17 @@ describe("managed install commands", () => { expect(manifest).toMatchObject({ source: "git", repo: "HenkDz/paperclip", ref: "master", sha }); expect(manifest?.payloadPath).toContain(path.join("git", sha.slice(0, 12))); expect(runCommand.mock.calls.filter(([command, args]) => command === "curl" && args.includes("--output"))).toHaveLength(1); - expect(runCommand.mock.calls.filter(([command, args]) => command === "corepack" && args[1] === "install")).toHaveLength(1); + const pnpmInstallCalls = runCommand.mock.calls.filter(([command, args]) => command === "corepack" && args[1] === "install"); + expect(pnpmInstallCalls.map(([, args]) => args)).toEqual([ + ["pnpm", "install", "--resolution-only", "--ignore-scripts", "--no-frozen-lockfile"], + ["pnpm", "install", "--frozen-lockfile"], + ]); + const checkoutPath = runCommand.mock.calls.find(([command]) => command === "tar")?.[1].at(-1); + expect(checkoutPath).toContain(path.join("installs", "git", `.${sha.slice(0, 12)}.tmp-`)); + for (const [, , options] of pnpmInstallCalls) expect(options?.cwd).toBe(checkoutPath); + expect(runCommand.mock.calls.indexOf(pnpmInstallCalls[1])).toBeLessThan( + runCommand.mock.calls.findIndex(([command]) => command === "bash"), + ); expect(runCommand.mock.calls.filter(([command, args]) => command === "corepack" && args.includes("pack"))).toHaveLength(2); expect(runCommand.mock.calls.filter(([command, args]) => command === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs"))).toHaveLength(1); expect(runCommand.mock.calls.filter(([command, args]) => command === "npm" && args[0] === "pack")).toHaveLength(2); @@ -168,6 +223,367 @@ describe("managed install commands", () => { expect(installCall?.[1].filter((arg) => arg.endsWith(".tgz"))).toHaveLength(4); }); + it("cleans a failed Git checkout lockfile resolution and preserves the active install", async () => { + const previousSha = "1".repeat(40); + await installCommand({ ref: previousSha, yes: true }, { runCommand: createGitCheckoutRunCommand(previousSha) }); + const paths = resolveInstallStorePaths(); + const previousManifest = fs.readFileSync(paths.manifestPath, "utf8"); + const previousTarget = fs.readlinkSync(paths.currentPath); + const previousShim = fs.readFileSync(paths.shimPath, "utf8"); + const sha = "2".repeat(40); + const checkoutCommands = createGitCheckoutRunCommand(sha); + let failedCheckout: string | undefined; + const runCommand = vi.fn(async (file: string, args: string[], options?: Parameters[2]) => { + if (file === "corepack" && args.includes("--resolution-only")) { + failedCheckout = options?.cwd as string; + expect(fs.existsSync(paths.lockPath)).toBe(true); + fs.writeFileSync(path.join(failedCheckout, "pnpm-lock.yaml"), "partial failed resolution\n"); + throw new Error("dependency lockfile resolution failed"); + } + return checkoutCommands(file, args, options); + }); + + await expect(installCommand({ ref: sha, yes: true }, { runCommand })) + .rejects.toThrow("dependency lockfile resolution failed"); + + expect(failedCheckout).toBeDefined(); + expect(fs.existsSync(path.dirname(failedCheckout!))).toBe(false); + expect(fs.readdirSync(path.join(paths.installsRoot, "git"))).toEqual([previousSha.slice(0, 12)]); + expect(runCommand.mock.calls.some(([, args]) => args.includes("--frozen-lockfile"))).toBe(false); + expect(runCommand.mock.calls.some(([file]) => file === "bash" || file === "npm")).toBe(false); + expect(fs.existsSync(paths.lockPath)).toBe(false); + expect(fs.readFileSync(paths.manifestPath, "utf8")).toBe(previousManifest); + expect(fs.readlinkSync(paths.currentPath)).toBe(previousTarget); + expect(fs.readFileSync(paths.shimPath, "utf8")).toBe(previousShim); + expect(fs.existsSync(readInstallManifest(paths)!.payloadPath)).toBe(true); + }); + + it("builds bundled Git server UI from its checkout before staging the declared files", async () => { + process.env.NODE_ENV = "production"; + const sha = "3".repeat(40); + const runCommand = createGitCheckoutRunCommand(sha, { bundledServer: true }); + await expect(installGitPayload("paperclipai/paperclip", sha, runCommand, resolveInstallStorePaths())) + .resolves.toMatchObject({ version: "0.3.1", reused: false }); + const uiCalls = runCommand.mock.calls.filter(([file, args]) => file === "corepack" && args.includes("prepare:ui-dist")); + expect(uiCalls).toHaveLength(1); + expect(uiCalls[0][1]).toEqual(["pnpm", "--dir", "server", "prepare:ui-dist"]); + expect(uiCalls[0][2]?.env).not.toHaveProperty("NODE_ENV"); + const serverStage = runCommand.mock.calls.findIndex(([file, args]) => + file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs") && args[1]?.endsWith(path.sep + "server")); + expect(serverStage).toBeGreaterThan(runCommand.mock.calls.indexOf(uiCalls[0])); + expect(runCommand.mock.calls[serverStage][2]?.cwd).toBe(uiCalls[0][2]?.cwd); + }); + + it("packs the built Git stage without source-only hooks and preserves consumer install hooks", async () => { + const sha = "9".repeat(40); + const checkoutCommands = createGitCheckoutRunCommand(sha, { bundledServer: true }); + let packedFiles: string[] = []; + let negativeControlPassed = false; + const npmEnv = { + ...process.env, + PATH: ORIGINAL_ENV.PATH, + npm_config_cache: path.join(root, "npm-cache"), + npm_config_userconfig: path.join(root, ".npmrc"), + npm_config_offline: "true", + npm_config_ignore_scripts: "false", + }; + fs.writeFileSync(npmEnv.npm_config_userconfig, ""); + const runCommand = vi.fn(async (file: string, args: string[], options?: Parameters[2]) => { + if (file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs") && args[1]?.endsWith(path.sep + "server")) { + const result = await checkoutCommands(file, args, options); + const stagedManifest = path.join(args[2], "package.json"); + const packageJson = JSON.parse(fs.readFileSync(stagedManifest, "utf8")); + // The real bundler preserves scripts but stages only declared runtime files. + packageJson.scripts = { + prepack: "node ../scripts/source-prepack.cjs", + postpack: "node ../scripts/source-postpack.cjs", + postinstall: "node dist/consumer-install.cjs", + }; + packageJson.dependencies["@paperclipai/db"] = "0.3.1"; + fs.writeFileSync(stagedManifest, JSON.stringify(packageJson)); + fs.writeFileSync(path.join(args[2], "dist", "consumer-install.cjs"), "process.stdout.write('consumer hook retained');\n"); + for (const hook of ["source-prepack", "source-postpack"]) { + fs.writeFileSync(path.join(options!.cwd as string, "scripts", `${hook}.cjs`), "process.stdout.write('source workspace hook');\n"); + } + return result; + } + if (file === "npm" && args[0] === "pack" && args[1]?.includes("workspace-package-")) { + const manifest = JSON.parse(fs.readFileSync(path.join(args[1], "package.json"), "utf8")) as { name: string }; + if (manifest.name === "@paperclipai/server") { + const realOptions = { ...options, env: npmEnv, timeout: 20_000 }; + // Removing the lifecycle boundary must reproduce the actual staged + // packaging failure, without any provider, build, or network access. + await expect(runCommandWithDiagnostics("npm", [...args.filter((arg) => arg !== "--ignore-scripts"), "--json"], realOptions)) + .rejects.toThrow("source-prepack.cjs"); + negativeControlPassed = true; + const result = await runCommandWithDiagnostics("npm", [...args, "--json"], realOptions); + const packed = JSON.parse(result.stdout) as Array<{ files: Array<{ path: string }> }>; + packedFiles = packed[0].files.map((entry) => entry.path); + expect(fs.existsSync(path.join(args[1], "ui-dist", "index.html"))).toBe(true); + expect(JSON.parse(fs.readFileSync(path.join(args[1], "package.json"), "utf8")).scripts.postinstall) + .toBe("node dist/consumer-install.cjs"); + return result; + } + } + return checkoutCommands(file, args, options); + }); + + await expect(installGitPayload("paperclipai/paperclip", sha, runCommand, resolveInstallStorePaths())) + .resolves.toMatchObject({ version: "0.3.1", reused: false }); + expect(negativeControlPassed).toBe(true); + expect(packedFiles).toEqual(expect.arrayContaining([ + "dist/index.js", "dist/consumer-install.cjs", "ui-dist/index.html", + "skills/paperclip/SKILL.md", "skills/paperclip/scripts/helper.mjs", + ])); + const consumerInstall = runCommand.mock.calls.find(([file, args]) => file === "npm" && args[0] === "install"); + expect(consumerInstall?.[1]).not.toContain("--ignore-scripts"); + expect(consumerInstall?.[2]?.env?.npm_config_ignore_scripts).toBeUndefined(); + const sourcePack = runCommand.mock.calls.find(([file, args]) => file === "npm" && args[0] === "pack" && args[1] === "--pack-destination"); + expect(sourcePack?.[1]).not.toContain("--ignore-scripts"); + }); + + it("installs mixed-version Git packages offline with the dependency's own SDK version and consumer hooks", async () => { + const sha = "f".repeat(40); + const checkoutCommands = createGitCheckoutRunCommand(sha, { bundledServer: true }); + const { materializePublishManifest } = await import(new URL("../../../scripts/prepare-bundled-package.mjs", import.meta.url).href); + const npmEnv = { + ...process.env, + PATH: ORIGINAL_ENV.PATH, + npm_config_cache: path.join(root, "npm-cache"), + npm_config_userconfig: path.join(root, ".npmrc"), + npm_config_offline: "true", + npm_config_ignore_scripts: "false", + }; + fs.writeFileSync(npmEnv.npm_config_userconfig, ""); + let checkout: string; + let consumerInstalled = false; + let packedSdkReferences: Record = {}; + const runCommand = vi.fn(async (file: string, args: string[], options?: Parameters[2]) => { + if (file === "tar") { + const result = await checkoutCommands(file, args, options); + checkout = args[args.indexOf("-C") + 1]; + const sdk = path.join(checkout, "packages/plugins/sdk"); + fs.mkdirSync(path.join(sdk, "dist"), { recursive: true }); + fs.writeFileSync(path.join(sdk, "dist/index.cjs"), "module.exports.ownVersion = '1.0.0';\n"); + fs.writeFileSync(path.join(sdk, "prepare.cjs"), "require('node:fs').writeFileSync('source-pack-hook.txt', 'source hook ran');\n"); + fs.writeFileSync(path.join(sdk, "package.json"), JSON.stringify({ + name: "@paperclipai/plugin-sdk", version: "1.0.0", main: "dist/index.cjs", files: ["dist"], + dependencies: { "@paperclipai/shared": "0.3.1" }, scripts: { prepack: "node prepare.cjs" }, + })); + const manifestPath = path.join(checkout, "scripts/release-package-manifest.json"); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + manifest.push({ dir: "packages/plugins/sdk", name: "@paperclipai/plugin-sdk" }); + fs.writeFileSync(manifestPath, JSON.stringify(manifest)); + const serverManifestPath = path.join(checkout, "server/package.json"); + const serverManifest = JSON.parse(fs.readFileSync(serverManifestPath, "utf8")); + serverManifest.dependencies["@paperclipai/plugin-sdk"] = "workspace:*"; + serverManifest.dependencies["@paperclipai/shared"] = "~0.3.0"; + serverManifest.optionalDependencies = { "@paperclipai/db": "workspace:~" }; + serverManifest.peerDependencies = { "@paperclipai/plugin-sdk": "workspace:^" }; + fs.writeFileSync(serverManifestPath, JSON.stringify(serverManifest)); + const dbManifestPath = path.join(checkout, "packages/db/package.json"); + const dbManifest = JSON.parse(fs.readFileSync(dbManifestPath, "utf8")); + dbManifest.version = "0.2.0"; + fs.writeFileSync(dbManifestPath, JSON.stringify(dbManifest)); + fs.mkdirSync(path.join(checkout, "cli/dist"), { recursive: true }); + fs.writeFileSync(path.join(checkout, "cli/dist/index.js"), "console.log('0.3.1');\n"); + fs.writeFileSync(path.join(checkout, "cli/package.json"), JSON.stringify({ + name: "paperclipai", version: "0.3.1", files: ["dist"], dependencies: { "@paperclipai/server": "0.3.1" }, + })); + return result; + } + if (file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs")) { + const result = await checkoutCommands(file, args, options); + const stagedManifestPath = path.join(args[2], "package.json"); + // Use the real release manifest translator, including its owning-package + // workspace normalization. The installer must correct the Git-only case. + const manifest = materializePublishManifest(JSON.parse(fs.readFileSync(stagedManifestPath, "utf8"))); + if (manifest.name === "@paperclipai/server") { + manifest.scripts = { + prepack: "node ../scripts/source-prepack-does-not-exist.cjs", + postpack: "node ../scripts/source-postpack-does-not-exist.cjs", + postinstall: "node dist/consumer-install.cjs", + }; + fs.writeFileSync(path.join(args[2], "dist/consumer-install.cjs"), + "require('node:fs').writeFileSync('consumer-hook.txt', require('@paperclipai/plugin-sdk').ownVersion);\n"); + } + fs.writeFileSync(stagedManifestPath, JSON.stringify(manifest)); + return result; + } + if (file === "corepack" && args.includes("pack")) { + const source = path.join(checkout, args[args.indexOf("--dir") + 1]); + return runCommandWithDiagnostics("npm", ["pack", "--pack-destination", args[args.indexOf("--pack-destination") + 1], "--json"], + { cwd: source, env: npmEnv, timeout: 20_000 }); + } + if (file === "npm" && args[0] === "pack") { + if (args[1]?.includes("workspace-package-")) { + const manifest = JSON.parse(fs.readFileSync(path.join(args[1], "package.json"), "utf8")); + if (manifest.name === "@paperclipai/server") { + packedSdkReferences = Object.fromEntries(["dependencies", "optionalDependencies", "peerDependencies"] + .map((section) => [section, manifest[section]["@paperclipai/plugin-sdk"]])); + } + } + return runCommandWithDiagnostics(file, [...args, "--json"], { ...options, env: npmEnv, timeout: 20_000 }); + } + if (file === "npm" && args[0] === "install") { + expect(args).not.toContain("--ignore-scripts"); + expect(args.filter((arg) => arg.endsWith(".tgz"))).toHaveLength(5); + expect(args.some((arg) => arg.endsWith("paperclipai-plugin-sdk-1.0.0.tgz"))).toBe(true); + expect(fs.readFileSync(path.join(checkout, "packages/plugins/sdk/source-pack-hook.txt"), "utf8")).toBe("source hook ran"); + const sourceManifest = JSON.parse(fs.readFileSync(path.join(checkout, "server/package.json"), "utf8")); + expect(sourceManifest.dependencies["@paperclipai/plugin-sdk"]).toBe("workspace:*"); + expect(sourceManifest.optionalDependencies["@paperclipai/db"]).toBe("workspace:~"); + expect(sourceManifest.peerDependencies["@paperclipai/plugin-sdk"]).toBe("workspace:^"); + let result; + try { + result = await runCommandWithDiagnostics(file, args, { ...options, env: npmEnv, timeout: 20_000 }); + } catch (error) { + throw new Error(`Offline consumer failed with SDK 1.0.0 archive and packed SDK references ${JSON.stringify(packedSdkReferences)}: ${String(error)}`, { cause: error }); + } + const installedServer = path.join(args[args.indexOf("--prefix") + 1], "node_modules/@paperclipai/server"); + const installed = JSON.parse(fs.readFileSync(path.join(installedServer, "package.json"), "utf8")); + expect(installed.dependencies["@paperclipai/plugin-sdk"]).toBe("1.0.0"); + expect(installed.dependencies["@paperclipai/shared"]).toBe("~0.3.0"); + expect(installed.dependencies["@paperclipai/db"]).toBe("0.2.0"); + expect(installed.optionalDependencies["@paperclipai/db"]).toBe("~0.2.0"); + expect(installed.peerDependencies["@paperclipai/plugin-sdk"]).toBe("^1.0.0"); + expect(fs.readFileSync(path.join(installedServer, "consumer-hook.txt"), "utf8")).toBe("1.0.0"); + consumerInstalled = true; + return result; + } + if (file === process.execPath && args.at(-1) === "--version") { + return runCommandWithDiagnostics(file, args, { ...options, env: npmEnv, timeout: 20_000 }); + } + return checkoutCommands(file, args, options); + }); + + await expect(installGitPayload("paperclipai/paperclip", sha, runCommand, resolveInstallStorePaths())) + .resolves.toMatchObject({ version: "0.3.1", reused: false }); + expect(consumerInstalled).toBe(true); + }, 30_000); + + it.each(["source build", "staged pack", "consumer install"])("preserves the active Git install after a failed %s", async (phase) => { + const previousSha = "a".repeat(40); + await installCommand({ ref: previousSha, yes: true }, { runCommand: createGitCheckoutRunCommand(previousSha) }); + const paths = resolveInstallStorePaths(); + const previousManifest = fs.readFileSync(paths.manifestPath, "utf8"); + const previousTarget = fs.readlinkSync(paths.currentPath); + const previousShim = fs.readFileSync(paths.shimPath, "utf8"); + const sha = "e".repeat(40); + const checkoutCommands = createGitCheckoutRunCommand(sha, { bundledServer: true }); + let failedCheckout: string | undefined; + const runCommand = vi.fn(async (file: string, args: string[], options?: Parameters[2]) => { + if (file === "tar") failedCheckout = args[args.indexOf("-C") + 1]; + const failedPhase = phase === "source build" + ? file === "corepack" && args.includes("-r") && args.includes("build") + : phase === "staged pack" + ? file === "npm" && args[0] === "pack" && args[1]?.includes("workspace-package-") + : file === "npm" && args[0] === "install"; + if (failedPhase) throw new Error(`${phase} failed`); + return checkoutCommands(file, args, options); + }); + + await expect(installCommand({ ref: sha, yes: true }, { runCommand })).rejects.toThrow(`${phase} failed`); + expect(failedCheckout).toBeDefined(); + expect(fs.existsSync(path.dirname(failedCheckout!))).toBe(false); + expect(fs.readdirSync(path.join(paths.installsRoot, "git"))).toEqual([previousSha.slice(0, 12)]); + if (phase === "source build") expect(runCommand.mock.calls.some(([file]) => file === "npm")).toBe(false); + if (phase === "staged pack") expect(runCommand.mock.calls.some(([file, args]) => file === "npm" && args[0] === "install")).toBe(false); + expect(fs.existsSync(paths.lockPath)).toBe(false); + expect(fs.readFileSync(paths.manifestPath, "utf8")).toBe(previousManifest); + expect(fs.readlinkSync(paths.currentPath)).toBe(previousTarget); + expect(fs.readFileSync(paths.shimPath, "utf8")).toBe(previousShim); + expect(fs.existsSync(readInstallManifest(paths)!.payloadPath)).toBe(true); + }); + + it("stages same-checkout runtime skills in the release packages before bundled Git packaging", async () => { + const sha = "6".repeat(40); + const checkoutCommands = createGitCheckoutRunCommand(sha, { bundledServer: true }); + let checkedSkills = false; + const runCommand = vi.fn(async (file: string, args: string[], options?: Parameters[2]) => { + if (file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs")) { + const checkout = options!.cwd as string; + for (const packageDir of ["server", "packages/adapters/claude-local", "packages/adapters/codex-local"]) { + const skills = path.join(checkout, packageDir, "skills"); + expect(fs.readdirSync(skills)).toEqual(["paperclip"]); + for (const file of ["SKILL.md", "scripts/helper.mjs"]) { + expect(fs.readFileSync(path.join(skills, "paperclip", file), "utf8")) + .toBe(fs.readFileSync(path.join(checkout, "skills", "paperclip", file), "utf8")); + } + } + // Other adapters' optional skills keep their established pack behavior. + expect(fs.existsSync(path.join(checkout, "packages/adapters/cursor-local/skills"))).toBe(false); + checkedSkills = true; + } + return checkoutCommands(file, args, options); + }); + + await expect(installGitPayload("paperclipai/paperclip", sha, runCommand, resolveInstallStorePaths())) + .resolves.toMatchObject({ version: "0.3.1", reused: false }); + expect(checkedSkills).toBe(true); + }); + + it("cleans missing Git runtime skills and preserves the active installed payload", async () => { + const previousSha = "7".repeat(40); + await installCommand({ ref: previousSha, yes: true }, { runCommand: createGitCheckoutRunCommand(previousSha) }); + const paths = resolveInstallStorePaths(); + const previousManifest = fs.readFileSync(paths.manifestPath, "utf8"); + const previousTarget = fs.readlinkSync(paths.currentPath); + const previousShim = fs.readFileSync(paths.shimPath, "utf8"); + const sha = "8".repeat(40); + const checkoutCommands = createGitCheckoutRunCommand(sha, { bundledServer: true }); + let failedCheckout: string | undefined; + const runCommand = vi.fn(async (file: string, args: string[], options?: Parameters[2]) => { + const result = await checkoutCommands(file, args, options); + if (file === "tar") { + failedCheckout = args[args.indexOf("-C") + 1]; + fs.rmSync(path.join(failedCheckout, "skills"), { recursive: true }); + } + return result; + }); + + await expect(installCommand({ ref: sha, yes: true }, { runCommand })) + .rejects.toThrow(/ENOENT.*skills/); + expect(failedCheckout).toBeDefined(); + expect(fs.existsSync(path.dirname(failedCheckout!))).toBe(false); + expect(fs.readdirSync(path.join(paths.installsRoot, "git"))).toEqual([previousSha.slice(0, 12)]); + expect(runCommand.mock.calls.some(([file, args]) => file === "npm" || (file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs")))).toBe(false); + expect(fs.existsSync(paths.lockPath)).toBe(false); + expect(fs.readFileSync(paths.manifestPath, "utf8")).toBe(previousManifest); + expect(fs.readlinkSync(paths.currentPath)).toBe(previousTarget); + expect(fs.readFileSync(paths.shimPath, "utf8")).toBe(previousShim); + expect(fs.existsSync(readInstallManifest(paths)!.payloadPath)).toBe(true); + }); + + it("cleans a failed Git UI build and preserves the active installed payload", async () => { + const previousSha = "4".repeat(40); + await installCommand({ ref: previousSha, yes: true }, { runCommand: createGitCheckoutRunCommand(previousSha) }); + const paths = resolveInstallStorePaths(); + const previousManifest = fs.readFileSync(paths.manifestPath, "utf8"); + const previousTarget = fs.readlinkSync(paths.currentPath); + const previousShim = fs.readFileSync(paths.shimPath, "utf8"); + const sha = "5".repeat(40); + const checkoutCommands = createGitCheckoutRunCommand(sha, { bundledServer: true }); + let failedCheckout: string | undefined; + const runCommand = vi.fn(async (file: string, args: string[], options?: Parameters[2]) => { + if (file === "corepack" && args.includes("prepare:ui-dist")) { + failedCheckout = options?.cwd as string; + throw new Error("UI build failed"); + } + return checkoutCommands(file, args, options); + }); + await expect(installCommand({ ref: sha, yes: true }, { runCommand })).rejects.toThrow("UI build failed"); + expect(failedCheckout).toBeDefined(); + expect(fs.existsSync(path.dirname(failedCheckout!))).toBe(false); + expect(fs.readdirSync(path.join(paths.installsRoot, "git"))).toEqual([previousSha.slice(0, 12)]); + expect(runCommand.mock.calls.some(([file]) => file === "npm")).toBe(false); + expect(fs.existsSync(paths.lockPath)).toBe(false); + expect(fs.readFileSync(paths.manifestPath, "utf8")).toBe(previousManifest); + expect(fs.readlinkSync(paths.currentPath)).toBe(previousTarget); + expect(fs.readFileSync(paths.shimPath, "utf8")).toBe(previousShim); + expect(fs.existsSync(readInstallManifest(paths)!.payloadPath)).toBe(true); + }); + it("builds git checkouts with NODE_ENV cleared so ambient production mode keeps devDependencies", async () => { process.env.NODE_ENV = "production"; const sha = "d".repeat(40); @@ -178,7 +594,7 @@ describe("managed install commands", () => { file === "corepack" || (file === "npm" && args[0] === "pack") || (file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs"))); - expect(buildCalls).toHaveLength(9); + expect(buildCalls).toHaveLength(11); for (const call of buildCalls) { const env = call[2]?.env; expect(env, `${call[0]} ${call[1].join(" ")} must run with an explicit env`).toBeDefined(); diff --git a/cli/src/commands/install.ts b/cli/src/commands/install.ts index 3fd4a5b198..f81ff10c55 100644 --- a/cli/src/commands/install.ts +++ b/cli/src/commands/install.ts @@ -275,19 +275,61 @@ export async function installGitPayload(repo: string, sha: string, runCommand: C await runGitHubCurl(["--fail", "--silent", "--show-error", "--location", "--output", archivePath, `https://codeload.github.com/${repo}/tar.gz/${sha}`], runCommand, { maxBuffer: 4 * 1024 * 1024 }); await runCommand("tar", ["-xzf", archivePath, "--strip-components=1", "-C", checkoutPath], { maxBuffer: 4 * 1024 * 1024 }); await runCommand("corepack", ["enable", "pnpm", "--install-directory", pnpmShimDir], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 4 * 1024 * 1024 }); + // Exact source commits can precede the lockfile bot. Resolve only inside this + // downloaded checkout, without lifecycle scripts, before its frozen install. + await runCommand("corepack", ["pnpm", "install", "--resolution-only", "--ignore-scripts", "--no-frozen-lockfile"], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 32 * 1024 * 1024 }); await runCommand("corepack", ["pnpm", "install", "--frozen-lockfile"], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 32 * 1024 * 1024 }); await runCommand("bash", ["scripts/build-npm.sh", "--skip-checks", "--skip-typecheck"], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 32 * 1024 * 1024 }); await runCommand("corepack", ["pnpm", "-r", "--filter", "@paperclipai/server...", "--if-present", "run", "build"], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 32 * 1024 * 1024 }); + // Bundled package staging bypasses the server's prepack hook. Produce its + // declared UI files from this same checkout before staging that package. + await runCommand("corepack", ["pnpm", "--dir", "server", "prepare:ui-dist"], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 32 * 1024 * 1024 }); + // Match release.sh's runtime-skill preparation. These packages declare + // skills separately from their build output; bundled staging bypasses it. + for (const packageDir of ["server", "packages/adapters/claude-local", "packages/adapters/codex-local"]) { + const destination = path.join(checkoutPath, packageDir, "skills"); + fs.rmSync(destination, { recursive: true, force: true }); + fs.cpSync(path.join(checkoutPath, "skills"), destination, { recursive: true }); + } const metadata = JSON.parse(fs.readFileSync(path.join(checkoutPath, "cli", "package.json"), "utf8")) as { version: string }; const workspacePackages = resolveGitInstallWorkspacePackages(checkoutPath); + const workspaceVersions = new Map(workspacePackages.map((entry) => { + const packageJson = JSON.parse(fs.readFileSync(path.join(checkoutPath, entry.dir, "package.json"), "utf8")) as { version?: unknown }; + if (typeof packageJson.version !== "string" || !EXACT_VERSION_PATTERN.test(packageJson.version)) { + throw new Error(`Git install cannot stage ${entry.name}: its package version must be an exact version.`); + } + return [entry.name, packageJson.version] as const; + })); for (const [index, workspacePackage] of workspacePackages.entries()) { const packageDir = path.join(checkoutPath, workspacePackage.dir); - const packageJson = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8")) as { bundleDependencies?: string[]; bundledDependencies?: string[] }; + const packageJson = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8")) as { + bundleDependencies?: string[]; bundledDependencies?: string[]; + dependencies?: Record; optionalDependencies?: Record; peerDependencies?: Record; + }; const bundledDependencies = packageJson.bundleDependencies ?? packageJson.bundledDependencies ?? []; if (bundledDependencies.length > 0) { const stagedPackage = path.join(stagingRoot, `workspace-package-${index}`); await runCommand(process.execPath, [path.join(checkoutPath, "scripts", "prepare-bundled-package.mjs"), packageDir, stagedPackage], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 32 * 1024 * 1024 }); - await runCommand("npm", ["pack", stagedPackage, "--pack-destination", stagingRoot], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 16 * 1024 * 1024 }); + // Release packaging gives workspace packages one version. A Git checkout + // can retain independent versions, so bind each staged workspace reference + // to that dependency's actual package rather than the owning package. + const stagedManifestPath = path.join(stagedPackage, "package.json"); + const stagedManifest = JSON.parse(fs.readFileSync(stagedManifestPath, "utf8")) as typeof packageJson; + for (const section of ["dependencies", "optionalDependencies", "peerDependencies"] as const) { + for (const [name, specifier] of Object.entries(packageJson[section] ?? {})) { + if (!specifier.startsWith("workspace:")) continue; + const version = workspaceVersions.get(name); + if (!version) throw new Error(`Git install cannot stage workspace dependency ${name}: its package version is missing.`); + const range = specifier.slice("workspace:".length); + const prefix = range === "^" || range === "~" ? range : ""; + stagedManifest[section] = { ...stagedManifest[section], [name]: `${prefix}${version}` }; + } + } + fs.writeFileSync(stagedManifestPath, `${JSON.stringify(stagedManifest, null, 2)}\n`); + // The stage contains compiled runtime files, not the source workspace. + // Match release packaging: source-only prepack/postpack hooks must not + // rebuild it or remove its prepared assets. Consumer install hooks stay enabled. + await runCommand("npm", ["pack", stagedPackage, "--pack-destination", stagingRoot, "--ignore-scripts"], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 16 * 1024 * 1024 }); } else { await runCommand("corepack", ["pnpm", "--dir", workspacePackage.dir, "pack", "--pack-destination", stagingRoot], { cwd: checkoutPath, env: buildEnv({ PAPERCLIP_RELEASE_REUSE_UI_DIST: "1" }), maxBuffer: 32 * 1024 * 1024 }); } diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 135dff03f2..076c9995dd 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -26,6 +26,12 @@ GitHub Actions owns `pnpm-lock.yaml`. - Pull request CI validates dependency resolution when manifests change. - Pushes to `master` regenerate `pnpm-lock.yaml` with `pnpm install --lockfile-only --no-frozen-lockfile`, commit it back if needed, and then run verification with `--frozen-lockfile`. +`paperclipai install --ref` resolves dependencies inside its disposable downloaded +checkout before the frozen install. It builds the UI and stages runtime skills +from that checkout, then packs the prepared files with source packaging hooks +disabled. Consumer installation hooks remain enabled. A preparation failure +leaves the current installed version active. + ## Trusted PR Workflow The PR caller uses `paperclipai/paperclip/.github/workflows/pr-trusted.yml@master`. diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md new file mode 100644 index 0000000000..8d67a352f3 --- /dev/null +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -0,0 +1,64 @@ +# Runner defaults: staged delivery + +Updated 2026-10-08. + +## Outcome and ownership + +Users choose a harness. New agents use Paperclip Runner where the harness and +execution target are qualified. Existing agents keep their recorded runner. +The task owns implementation, verification, review fixes, and reviewable PRs. +Merging and deployment require separate authorization. + +The original implementation is preserved on `codex/default-agent-runner` at +`171d841295808ca185a258aaeb10b3dee68bb2b9`. Its public review is +[PR #15422](https://github.com/paperclipai/paperclip/pull/15422). +The user approved splitting that work into four useful steps: + +1. Packaged runtime reliability and Codex prerequisites, without changing defaults. +2. Codex defaults across creation, onboarding, hiring, imports, and the UI. +3. Claude defaults and authentication. +4. The remaining supported harness defaults and provider-specific fixes. + +## Current slice + +Canonical branch: `codex/runner-packaging-prerequisites`. +Base: `71cd0a2621615182274a977d6cc969b9ab920b92`. +PR, tested commit, and CI links: pending the first slice's commit and publication. + +This slice fixes the installed dependency graph, Codex executable resolution, +isolated browser login, Git install staging, and the release asset transfer needed +for Linux and macOS packages. It reuses the existing install sandbox, release +verification workflow, tests, and runner binary assembler. + +It does not change agent defaults, the harness picker, provider qualification, +database schema, experimental feature policy, or existing agent configuration. +The large execution and setup changes stay in the later slices. + +## Evidence and gates + +| Gate | Status | Evidence | +| --- | --- | --- | +| Preserve original implementation | Passed | Original branch and commit above | +| Extract only first-slice behavior | Passed | 30 files; separate branch; independent packaging/release review | +| Installer and packaging tests | Passed | Existing installer suite: 26 tests; existing packaging suite: 24 tests | +| Login and transport controls | Passed | Login: 13/13; focused Codex/transport selection: 21 passed, 185 outside the focused filter | +| Release-transfer controls | Passed | Release workflow, transfer, and sandbox suites: 38/38; actionlint and Node/shell syntax checks passed | +| Token gates | Passed | All three commands in `check:token-gates` passed | +| Package and module contracts | Passed | Release package manifest and feature module boundary checks passed | +| Clean installed Codex version probe | Pending | Existing Linux npm consumer sandbox in CI | +| Full checks and fresh review | Pending | Require green checks on the final PR head | +| All-harness live onboarding and cloud qualification | Deferred | Owned by later slices; prior evidence does not prove this revision | + +Heavy builds run in hosted CI. No local Docker or Rust build is planned. +No provider-backed runs or new disposable environments are needed for this +slice. The original $250 cost ceiling and cleanup obligations still apply. +Existing login credentials and running user previews must remain untouched. + +Focused test commands use Node 24 and a single worker: `node --test +--test-concurrency=1 scripts/acpx-patch-packaging.test.mjs` and, from `cli/`, +`node ../node_modules/vitest/vitest.mjs run src/__tests__/install-command.test.ts +--maxWorkers=1 --no-file-parallelism`. These tests include real offline npm pack +and installation controls; they do not claim a published release or provider task. + +Next action: publish the first PR and resolve its review and check failures on +the final candidate. diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 5d758d956b..b6c9fc3b49 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -58,6 +58,20 @@ checks installed dependency pins and agreement with Cursor's distribution manife and immutable release attestation (`cursor-contract.json`). Cursor's per-platform closure pins are generated from `cursor-distributions.json`. +Packaged Codex startup and browser login resolve the Codex CLI from the installed +ACP bridge dependency graph. They verify the qualified version and the executable +path before launch. An explicit execution command still takes precedence. A +missing or mismatched bundled runtime returns an installation error; it does not +borrow a different Codex installation from `PATH`. Linux ARM64 retains its +existing legacy login path because native execution is not qualified there. + +Release packages stage the qualified Codex dependency graph and native platform +artifacts. The Linux artifact digest is recorded in +`src/drivers/acpx/qualified-runtime-artifacts.json`. macOS payloads must match the +pinned package version and host architecture. Release assembly also checks the +source revision and runner binary identities before publication. This packaging +does not change agent defaults or the selected runner of an existing agent. + Every ACPX harness accepts an explicit caller-selected model without a Paperclip model allowlist. The adapter sends that ID unchanged and verifies the provider's effective model before prompting. An incomplete discovery catalog does not block diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-runtime-artifacts.json b/packages/paperclip-runner/src/drivers/acpx/qualified-runtime-artifacts.json new file mode 100644 index 0000000000..98e926efc1 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-runtime-artifacts.json @@ -0,0 +1,17 @@ +{ + "schema": "paperclip.acpx-runtime-artifacts.v1", + "codex": { + "platforms": { + "linux-x64": { + "runtimePackageName": "@openai/codex", + "runtimePackageVersion": "0.160.0", + "packageName": "@openai/codex-linux-x64", + "packageVersion": "0.160.0-linux-x64", + "dependencyDeclaration": "npm:@openai/codex@0.160.0-linux-x64", + "relativeExecutable": "vendor/x86_64-unknown-linux-musl/bin/codex", + "executableDigest": "sha256:12eb3e81114588aca3b7998f4f19e8997b056aca08e57a7ca7c8a3ec8c652aad", + "environmentVariable": "CODEX_PATH" + } + } + } +} diff --git a/scripts/acpx-patch-packaging.test.mjs b/scripts/acpx-patch-packaging.test.mjs index 7502350e16..a474cc0af4 100644 --- a/scripts/acpx-patch-packaging.test.mjs +++ b/scripts/acpx-patch-packaging.test.mjs @@ -1,12 +1,15 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, + realpathSync, rmSync, + symlinkSync, writeFileSync, } from "node:fs"; import { readFile } from "node:fs/promises"; @@ -20,11 +23,58 @@ import cliEsbuildConfig from "../cli/esbuild.config.mjs"; import { bundledCliNpmDependencies } from "./cli-bundled-npm-dependencies.mjs"; import { createBundledInstallManifest, + configureBundledProviderOverrides, + dockerBundledProviderTarget, + inspectBundledNativeArtifact, materializePublishManifest, + mergeBundledProviderGraph, selectBundledDependencyPatches, + stageBundledEsbuildOptionalDependencies, + stageBundledProviderOptionalDependencies, } from "./prepare-bundled-package.mjs"; const rootPackage = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")); +const profileData = JSON.parse(await readFile(new URL("../packages/paperclip-runner/acpx-profiles.json", import.meta.url), "utf8")); +const runtimeData = JSON.parse(await readFile(new URL("../packages/paperclip-runner/src/drivers/acpx/qualified-runtime-artifacts.json", import.meta.url), "utf8")); +const nativeTargets = ["linux-x64", "darwin-arm64", "darwin-x64"]; +function esbuildFixture(t) { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "paperclip-bundled-esbuild-"))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const graph = join(directory, "node_modules"); + const writePackage = (location, metadata) => { + mkdirSync(location, { recursive: true }); + writeFileSync(join(location, "package.json"), JSON.stringify(metadata)); + return location; + }; + const acpx = writePackage(join(graph, "acpx"), { name: "acpx", version: "0.13.1" }); + const esbuild = writePackage(join(acpx, "node_modules/esbuild"), { + name: "esbuild", version: "0.28.2", scripts: { postinstall: "node install.js" }, + optionalDependencies: Object.fromEntries([...nativeTargets, "win32-x64"].map(target => [`@esbuild/${target}`, "0.28.2"])), + }); + const producerBinary = writePackage(join(graph, "@esbuild/linux-x64"), { + name: "@esbuild/linux-x64", version: "0.28.2", os: ["linux"], cpu: ["x64"], + }); + writePackage(join(graph, "unrelated-linux-x64"), { name: "unrelated-linux-x64", version: "1.0.0" }); + const publish = { name: "@paperclipai/adapter-utils", version: "0.3.1", bundleDependencies: ["acpx"], + dependencies: { acpx: "0.13.1" }, optionalDependencies: { unrelated: "1.0.0" } }; + return { directory, graph, esbuild, producerBinary, publish, writePackage }; +} +function nativeFixture(directory, profile, target, data) { + const [os, cpu] = target.split("-"); + const name = `${profile.agentRuntimePackage}-${target}`; + const packageRoot = join(directory, name); + mkdirSync(packageRoot, { recursive: true }); + writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ name, version: `${profile.agentRuntimeVersion}-${target}`, os: [os], cpu: [cpu] })); + const qualification = data[profile.agent].platforms[target]; + const executable = qualification?.relativeExecutable ?? `vendor/${cpu === "arm64" ? "aarch64" : "x86_64"}-apple-darwin/bin/codex`; + const bytes = Buffer.alloc(32); + if (os === "linux") { bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(62, 18); } + else { bytes.writeUInt32LE(0xfeedfacf, 0); bytes.writeUInt32LE(cpu === "arm64" ? 0x0100000c : 0x01000007, 4); } + mkdirSync(join(packageRoot, executable, ".."), { recursive: true }); + writeFileSync(join(packageRoot, executable), bytes, { mode: 0o755 }); + if (qualification) qualification.executableDigest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`; + return packageRoot; +} const adapterUtilsPackage = JSON.parse( await readFile(new URL("../packages/adapter-utils/package.json", import.meta.url), "utf8"), ); @@ -40,6 +90,7 @@ const dbPackage = JSON.parse( const releaseScript = await readFile(new URL("./release.sh", import.meta.url), "utf8"); const releaseLib = await readFile(new URL("./release-lib.sh", import.meta.url), "utf8"); const buildNpmScript = await readFile(new URL("./build-npm.sh", import.meta.url), "utf8"); + const acpxRuntimePatch = await readFile( new URL("../patches/acpx@0.13.1.patch", import.meta.url), "utf8", @@ -124,6 +175,186 @@ test("published packages preserve the patched ACPX runtime", () => { assert.equal(cliEsbuildConfig.external.includes("acpx"), false); }); +test("published Codex bridge uses its qualified producer closure without consumer overrides", () => { + const published = materializePublishManifest(serverPackage); + const { installManifest, profiles } = configureBundledProviderOverrides( + createBundledInstallManifest(published, serverPackage.bundleDependencies), + serverPackage.bundleDependencies, rootPackage, profileData, + ); + assert.equal(profiles.length, 1); + for (const profile of profiles) { + assert.equal(published.dependencies[profile.agentServerPackage], profile.agentServerVersion); + assert.ok(published.bundleDependencies.includes(profile.agentServerPackage)); + assert.equal(installManifest.overrides[`${profile.agentServerPackage}@${profile.agentServerVersion}`][profile.agentRuntimePackage], profile.agentRuntimeVersion); + } + assert.equal(published.overrides, undefined); + assert.equal(installManifest.overrides.rollup, undefined); + const bad = structuredClone(rootPackage); + bad.pnpm.overrides["@agentclientprotocol/codex-acp@1.6.2>@openai/codex"] = "0.156.1"; + assert.throws(() => configureBundledProviderOverrides(installManifest, serverPackage.bundleDependencies, bad, profileData), /runtime override must match/); + const unpinned = createBundledInstallManifest(published, serverPackage.bundleDependencies); + unpinned.dependencies["@agentclientprotocol/codex-acp"] = "^1.6.2"; + assert.throws(() => configureBundledProviderOverrides(unpinned, serverPackage.bundleDependencies, rootPackage, profileData), /exact qualified profile/); + const conflict = createBundledInstallManifest(published, serverPackage.bundleDependencies); + conflict.overrides = { "@agentclientprotocol/codex-acp@1.6.2": { "@openai/codex": "0.156.1" } }; + assert.throws(() => configureBundledProviderOverrides(conflict, serverPackage.bundleDependencies, rootPackage, profileData), /conflicting producer override/); +}); + +test("Linux-produced Codex bridge tarballs retain all three contained artifacts and preserve runtime declarations", (t) => { + const destination = mkdtempSync(join(tmpdir(), "paperclip-bundled-provider-")); + t.after(() => rmSync(destination, { recursive: true, force: true })); + const graph = join(destination, "node_modules"); + const artifacts = join(destination, "artifacts"), data = structuredClone(runtimeData); + mkdirSync(artifacts); + const { profiles } = configureBundledProviderOverrides( + createBundledInstallManifest(serverPackage, serverPackage.bundleDependencies), serverPackage.bundleDependencies, rootPackage, profileData, + ); + const writePackage = (name, metadata) => { + const directory = join(graph, name); + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, "package.json"), JSON.stringify({ name, ...metadata })); + return directory; + }; + for (const profile of profiles) { + writePackage(profile.agentServerPackage, { version: profile.agentServerVersion, + dependencies: { [profile.agentRuntimePackage]: profile.agentRuntimeVersion } }); + const optional = Object.fromEntries(nativeTargets.map(target => [`${profile.agentRuntimePackage}-${target}`, + `npm:@openai/codex@${profile.agentRuntimeVersion}-${target}`])); + writePackage(profile.agentRuntimePackage, { version: profile.agentRuntimeVersion, optionalDependencies: optional }); + writePackage(`${profile.agentRuntimePackage}-linux-x64`, { version: "0.160.0-linux-x64" }); + for (const target of nativeTargets) nativeFixture(artifacts, profile, target, data); + } + writePackage("unrelated-linux-x64", { version: "1.0.0" }); + const before = readFileSync(join(graph, "@agentclientprotocol/codex-acp/package.json"), "utf8"); + const stage = manifest => stageBundledProviderOptionalDependencies(destination, manifest, profiles, data, { nativeArtifactsDirectory: artifacts }); + const staged = stage(serverPackage); + assert.equal(staged.overrides, undefined); + assert.equal(staged.optionalDependencies["@openai/codex-darwin-arm64"], "npm:@openai/codex@0.160.0-darwin-arm64"); + for (const profile of profiles) { + for (const target of nativeTargets) { + assert.equal(existsSync(join(graph, `${profile.agentRuntimePackage}-${target}`)), true); + assert.ok(staged.bundleDependencies.includes(`${profile.agentRuntimePackage}-${target}`)); + } + assert.equal(existsSync(join(graph, profile.agentRuntimePackage, "package.json")), true); + } + assert.equal(existsSync(join(graph, "unrelated-linux-x64")), true); + assert.equal(readFileSync(join(graph, "@agentclientprotocol/codex-acp/package.json"), "utf8"), before); + const sdkPath = join(graph, "@openai/codex/package.json"); + const sdk = JSON.parse(readFileSync(sdkPath, "utf8")); + writeFileSync(sdkPath, JSON.stringify({ ...sdk, version: "0.156.1" })); + assert.throws(() => stage(serverPackage), /runtime version mismatch/); + writeFileSync(sdkPath, JSON.stringify(sdk)); + assert.throws(() => stage({ ...serverPackage, + optionalDependencies: { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.156.1-darwin-arm64" } }), /conflicts with published/); + writeFileSync(sdkPath, JSON.stringify({ ...sdk, optionalDependencies: { "@openai/codex-untrusted": "0.160.0" } })); + assert.throws(() => stage(serverPackage), /not qualified/); + writeFileSync(sdkPath, JSON.stringify(sdk)); + const codex = profiles.find(value => value.agent === "codex"); + const artifact = join(artifacts, "@openai/codex-linux-x64"); + assert.equal(inspectBundledNativeArtifact(artifact, codex, "linux-x64", data).target, "linux-x64"); + writeFileSync(join(artifact, "package.json"), JSON.stringify({ name: "@openai/codex-linux-x64", version: "0.160.0-linux-x64", os: ["darwin"], cpu: ["x64"] })); + assert.throws(() => inspectBundledNativeArtifact(artifact, codex, "linux-x64", data), /identity mismatch/); +}); + +test("Linux-produced esbuild bundles publish exact consumer platform dependencies and retain their hooks", (t) => { + const { directory, graph, esbuild, producerBinary, publish, writePackage } = esbuildFixture(t); + const original = readFileSync(join(esbuild, "package.json"), "utf8"); + const issuer = createRequire(join(esbuild, "package.json")); + // The original producer-only topology cannot resolve a Mac binary. The + // public optional declaration lets npm obtain it before offline hooks run. + assert.throws(() => issuer.resolve("@esbuild/darwin-arm64/bin/esbuild"), /Cannot find module/); + const staged = stageBundledEsbuildOptionalDependencies(directory, publish); + for (const target of [...nativeTargets, "win32-x64"]) assert.equal(staged.optionalDependencies[`@esbuild/${target}`], "0.28.2"); + assert.equal(staged.optionalDependencies.unrelated, "1.0.0"); + assert.deepEqual(staged.bundleDependencies, ["acpx"]); + assert.deepEqual(staged.dependencies, publish.dependencies); + assert.equal(publish.optionalDependencies["@esbuild/darwin-arm64"], undefined); + assert.equal(existsSync(producerBinary), false); + assert.equal(existsSync(join(graph, "unrelated-linux-x64")), true); + assert.equal(readFileSync(join(esbuild, "package.json"), "utf8"), original); + assert.deepEqual(stageBundledEsbuildOptionalDependencies(directory, staged), staged); + for (const target of ["darwin-arm64", "darwin-x64"]) { + const artifact = writePackage(join(graph, `@esbuild/${target}`), { + name: `@esbuild/${target}`, version: staged.optionalDependencies[`@esbuild/${target}`], os: ["darwin"], cpu: [target.slice(7)], + }); + mkdirSync(join(artifact, "bin")); writeFileSync(join(artifact, "bin/esbuild"), "consumer-selected fixture; never executed"); + assert.equal(issuer.resolve(`@esbuild/${target}/bin/esbuild`), join(artifact, "bin/esbuild")); + } +}); + +test("bundled esbuild rejects conflicting published or nested platform versions without removing the producer", (t) => { + const { directory, graph, producerBinary, publish, writePackage } = esbuildFixture(t); + assert.throws(() => stageBundledEsbuildOptionalDependencies(directory, { + ...publish, optionalDependencies: { "@esbuild/darwin-arm64": "0.27.0" }, + }), /platform version conflicts/); + assert.equal(existsSync(producerBinary), true); + writePackage(join(graph, "esbuild"), { name: "esbuild", version: "0.27.0", + optionalDependencies: Object.fromEntries(nativeTargets.map(target => [`@esbuild/${target}`, "0.27.0"])) }); + assert.throws(() => stageBundledEsbuildOptionalDependencies(directory, publish), /platform version conflicts|installed platform identity mismatch/); + assert.equal(existsSync(producerBinary), true); +}); + +test("bundled esbuild rejects missing targets, altered declarations and incorrect platform identity", (t) => { + const { directory, esbuild, producerBinary, publish } = esbuildFixture(t); + const path = join(esbuild, "package.json"), metadata = JSON.parse(readFileSync(path, "utf8")); + writeFileSync(path, JSON.stringify({ ...metadata, optionalDependencies: { "@esbuild/linux-x64": "0.28.2" } })); + assert.throws(() => stageBundledEsbuildOptionalDependencies(directory, publish), /omitted a supported platform/); + writeFileSync(path, JSON.stringify({ ...metadata, optionalDependencies: { ...metadata.optionalDependencies, "@esbuild/win32-x64": "^0.28.2" } })); + assert.throws(() => stageBundledEsbuildOptionalDependencies(directory, publish), /exact package version/); + writeFileSync(path, JSON.stringify(metadata)); + writeFileSync(join(producerBinary, "package.json"), JSON.stringify({ name: "@esbuild/linux-x64", version: "0.27.0" })); + assert.throws(() => stageBundledEsbuildOptionalDependencies(directory, publish), /installed platform identity mismatch/); + assert.equal(existsSync(producerBinary), true); +}); + +test("bundled esbuild cannot follow a platform payload or package namespace outside its producer graph", (t) => { + const { directory, graph, producerBinary, publish, writePackage } = esbuildFixture(t); + const external = writePackage(join(directory, "outside"), { name: "@esbuild/linux-x64", version: "0.28.2" }); + rmSync(producerBinary, { recursive: true }); symlinkSync(external, producerBinary, "dir"); + assert.throws(() => stageBundledEsbuildOptionalDependencies(directory, publish), /escapes its producer graph/); + assert.equal(readFileSync(join(external, "package.json"), "utf8").includes("0.28.2"), true); + rmSync(join(graph, "@esbuild"), { recursive: true }); symlinkSync(external, join(graph, "@esbuild"), "dir"); + assert.throws(() => stageBundledEsbuildOptionalDependencies(directory, publish), /escapes its producer graph/); +}); + +test("Codex runtime artifact data preserves the existing Linux qualification without adding targets", () => { + assert.deepEqual(Object.keys(runtimeData).sort(), ["codex", "schema"]); + assert.deepEqual(Object.keys(runtimeData.codex.platforms), ["linux-x64"]); + assert.equal(runtimeData.codex.platforms["linux-x64"].executableDigest, "sha256:12eb3e81114588aca3b7998f4f19e8997b056aca08e57a7ca7c8a3ec8c652aad"); + assert.equal(runtimeData.codex.platforms["linux-x64"].environmentVariable, "CODEX_PATH"); +}); + +test("Docker provider graphs use qualified targets and contain only native entry bindings without changing server dependencies", (t) => { + assert.equal(dockerBundledProviderTarget("amd64"), "linux-x64"); + assert.equal(dockerBundledProviderTarget("arm64"), null, "Unqualified native ARM64 must not break legacy images"); + assert.throws(() => dockerBundledProviderTarget("x64"), /declared supported target/); + const missing = structuredClone(runtimeData); delete missing.codex.platforms["linux-x64"]; + assert.equal(dockerBundledProviderTarget("amd64", missing), null, "The existing Codex qualification contract is required"); + const fixture = realpathSync(mkdtempSync(join(tmpdir(), "paperclip-docker-provider-graph-"))); + t.after(() => rmSync(fixture, { recursive: true, force: true })); + const server = join(fixture, "server"), staged = join(fixture, "staged"), store = join(fixture, "workspace-store"); + for (const directory of [server, staged, store]) mkdirSync(directory); + mkdirSync(join(server, "node_modules/@agentclientprotocol"), { recursive: true }); + writeFileSync(join(server, "package.json"), JSON.stringify(serverPackage)); + for (const name of ["@agentclientprotocol/codex-acp", "zod"]) { + const directory = join(staged, "node_modules", name); mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, "package.json"), JSON.stringify({ name, version: serverPackage.dependencies[name] ?? "4.4.3" })); + const previous = join(store, name); mkdirSync(previous, { recursive: true }); + writeFileSync(join(previous, "package.json"), JSON.stringify({ name, version: "previous-version" })); + symlinkSync(previous, join(server, "node_modules", name)); + } + writeFileSync(join(staged, "package.json"), JSON.stringify({ name: serverPackage.name })); + const before = readFileSync(join(server, "node_modules/zod/package.json"), "utf8"); + mergeBundledProviderGraph(staged, server); + assert.equal(readFileSync(join(server, "node_modules/zod/package.json"), "utf8"), before, "Application dependencies retain the existing workspace binding"); + for (const name of ["@agentclientprotocol/codex-acp"]) { + assert.equal(lstatSync(join(server, "node_modules", name)).isSymbolicLink(), true); + assert.equal(realpathSync(join(server, "node_modules", name)), join(server, "node_modules/.paperclip-native-providers/node_modules", name)); + assert.equal(JSON.parse(readFileSync(join(store, name, "package.json"))).version, "previous-version", "Workspace store must remain untouched"); + } + assert.throws(() => mergeBundledProviderGraph(staged, server), /destination already exists/); +}); + test("Paperclip Runner pins the qualified ACPX host callbacks", () => { assert.equal(rootPackage.pnpm.patchedDependencies["acpx@0.13.1"], "patches/acpx@0.13.1.patch"); assert.equal( @@ -285,11 +516,14 @@ test("bundled package patch selection rejects an unpatched installed version", ( }); test("server package staging applies every bundled runtime patch and preserves the vendored runner", (t) => { - const fixtureDir = mkdtempSync(join(tmpdir(), "paperclip-bundled-stage-")); + const fixtureDir = realpathSync(mkdtempSync(join(tmpdir(), "paperclip-bundled-stage-"))); const sourceDir = join(fixtureDir, "source"); const destinationDir = join(fixtureDir, "destination"); const binDir = join(fixtureDir, "bin"); const callLog = join(fixtureDir, "calls.log"); + const fixtureSourceRoot = join(fixtureDir, "repo"); + const fixtureArtifacts = join(fixtureDir, "native-artifacts"); + const fixtureRuntimeData = structuredClone(runtimeData); mkdirSync(sourceDir); mkdirSync(join(sourceDir, "dist")); writeFileSync(join(sourceDir, "dist", "index.js"), "export {};\n"); @@ -300,6 +534,18 @@ test("server package staging applies every bundled runtime patch and preserves t JSON.stringify({ ...serverPackage, files: ["dist"] }), ); writeFileSync(callLog, ""); + mkdirSync(fixtureArtifacts); + for (const [agent, profile] of Object.entries(profileData.profiles).filter(([agent]) => agent === "codex")) { + for (const target of nativeTargets) nativeFixture(fixtureArtifacts, { agent, ...profile }, target, fixtureRuntimeData); + } + mkdirSync(join(fixtureSourceRoot, "packages/paperclip-runner/src/drivers/acpx"), { recursive: true }); + writeFileSync(join(fixtureSourceRoot, "package.json"), JSON.stringify(rootPackage)); + writeFileSync(join(fixtureSourceRoot, "packages/paperclip-runner/acpx-profiles.json"), JSON.stringify(profileData)); + writeFileSync(join(fixtureSourceRoot, "packages/paperclip-runner/src/drivers/acpx/qualified-runtime-artifacts.json"), JSON.stringify(fixtureRuntimeData)); + mkdirSync(join(fixtureSourceRoot, "patches")); + for (const patchPath of Object.values(rootPackage.pnpm.patchedDependencies)) { + writeFileSync(join(fixtureSourceRoot, patchPath), readFileSync(new URL(`../${patchPath}`, import.meta.url))); + } t.after(() => rmSync(fixtureDir, { recursive: true, force: true })); const writeExecutable = (name, body) => { @@ -320,9 +566,11 @@ mkdir -p "$destination/node_modules/.pnpm" `#!/usr/bin/env bash set -euo pipefail printf 'npm %s\\n' "$*" >> "$FAKE_CALL_LOG" -[ "$*" = "install --omit=dev --ignore-scripts --no-audit --no-fund" ] -node -e 'const fs = require("node:fs"); const pkg = require("./package.json"); if ("devDependencies" in pkg) process.exit(1); for (const [name, version] of Object.entries(pkg.dependencies)) { const dir = "node_modules/" + name; fs.mkdirSync(dir + "/dist", { recursive: true }); fs.writeFileSync(dir + "/package.json", JSON.stringify({ name, version })); }' +[ "$*" = "install --omit=dev --ignore-scripts --no-audit --no-fund" ] || [ "$*" = "install --force --omit=dev --ignore-scripts --no-audit --no-fund" ] +node -e 'const fs = require("node:fs"); const path=require("node:path"); const pkg = require("./package.json"); if ("devDependencies" in pkg) process.exit(1); if(pkg.name === "paperclip-native-artifact-staging"){for(const name of Object.keys(pkg.dependencies)){fs.cpSync(path.join(process.env.FAKE_NATIVE_ARTIFACTS,name),path.join("node_modules",name),{recursive:true});}process.exit(0);} for (const [name, version] of Object.entries(pkg.dependencies)) { const dir = "node_modules/" + name; fs.mkdirSync(dir + "/dist", { recursive: true }); fs.writeFileSync(dir + "/package.json", JSON.stringify({ name, version })); } for (const [bridge, runtimes] of Object.entries(pkg.overrides ?? {})) { for (const [name, version] of Object.entries(runtimes)) { const dir = "node_modules/" + name; fs.mkdirSync(dir, { recursive: true }); const native = name === "@openai/codex"; const optionalDependencies = native ? Object.fromEntries(["linux-x64","darwin-arm64","darwin-x64"].map(target => [name+"-"+target,"npm:"+name+"@"+version+"-"+target])) : undefined; fs.writeFileSync(dir + "/package.json", JSON.stringify({ name, version, optionalDependencies })); } }' +if [ -f package.json ] && node -e 'process.exit(require("./package.json").name === "paperclip-native-artifact-staging" ? 0 : 1)'; then exit 0; fi mkdir -p node_modules/acpx/dist +node -e 'const fs=require("node:fs");fs.mkdirSync("node_modules/esbuild",{recursive:true});fs.writeFileSync("node_modules/esbuild/package.json",JSON.stringify({name:"esbuild",version:"0.28.2",scripts:{postinstall:"node install.js"},optionalDependencies:Object.fromEntries(["linux-x64","darwin-arm64","darwin-x64"].map(target=>["@esbuild/"+target,"0.28.2"]))}));fs.mkdirSync("node_modules/@esbuild/linux-x64",{recursive:true});fs.writeFileSync("node_modules/@esbuild/linux-x64/package.json",JSON.stringify({name:"@esbuild/linux-x64",version:"0.28.2",os:["linux"],cpu:["x64"]}));' printf 'unpatched runtime\\n' > node_modules/acpx/dist/runtime.js printf '{"name":"acpx","version":"0.13.1"}\\n' > node_modules/acpx/package.json `, @@ -356,9 +604,11 @@ printf 'patched spawnEnvironment runtime\\n' > "$target/dist/runtime.js" execFileSync( process.execPath, [ - new URL("./prepare-bundled-package.mjs", import.meta.url).pathname, + "--input-type=module", "-e", + `import { prepareBundledPackage } from ${JSON.stringify(new URL("./prepare-bundled-package.mjs", import.meta.url).href)}; prepareBundledPackage(process.argv[1], process.argv[2], { sourceRoot: process.argv[3] });`, sourceDir, destinationDir, + fixtureSourceRoot, ], { env: { @@ -366,6 +616,7 @@ printf 'patched spawnEnvironment runtime\\n' > "$target/dist/runtime.js" PATH: `${binDir}:${process.env.PATH}`, FAKE_CALL_LOG: callLog, FAKE_SOURCE_PACKAGE: join(sourceDir, "package.json"), + FAKE_NATIVE_ARTIFACTS: fixtureArtifacts, }, stdio: "pipe", }, @@ -375,6 +626,11 @@ printf 'patched spawnEnvironment runtime\\n' > "$target/dist/runtime.js" assert.equal(lstatSync(stagedAcpxDir).isDirectory(), true); assert.equal(lstatSync(stagedAcpxDir).isSymbolicLink(), false); assert.equal(existsSync(join(destinationDir, "node_modules/.pnpm")), false); + const stagedManifest = JSON.parse(readFileSync(join(destinationDir, "package.json"), "utf8")); + assert.equal(stagedManifest.optionalDependencies["@esbuild/darwin-arm64"], "0.28.2"); + assert.equal(stagedManifest.optionalDependencies["@esbuild/darwin-x64"], "0.28.2"); + assert.equal(existsSync(join(destinationDir, "node_modules/@esbuild/linux-x64")), false); + assert.equal(JSON.parse(readFileSync(join(destinationDir, "node_modules/esbuild/package.json"))).scripts.postinstall, "node install.js"); assert.match( readFileSync(join(stagedAcpxDir, "dist/runtime.js"), "utf8"), /spawnEnvironment/, @@ -383,13 +639,14 @@ printf 'patched spawnEnvironment runtime\\n' > "$target/dist/runtime.js" readFileSync(callLog, "utf8"), /patch -p1 --forward -d .*node_modules\/acpx/, ); + const patchedBundledDependencies = serverPackage.bundleDependencies.filter(name => rootPackage.pnpm.patchedDependencies[`${name}@${serverPackage.dependencies[name]}`]); assert.equal( readFileSync(callLog, "utf8") .split("\n") .filter((line) => line.startsWith("patch ")).length, - serverPackage.bundleDependencies.length, + patchedBundledDependencies.length, ); - for (const name of serverPackage.bundleDependencies) { + for (const name of patchedBundledDependencies) { const specifier = `${name}@${serverPackage.dependencies[name]}`; const patchPath = rootPackage.pnpm.patchedDependencies[specifier]; assert.equal( @@ -401,6 +658,23 @@ printf 'patched spawnEnvironment runtime\\n' > "$target/dist/runtime.js" `${specifier} receives its own full configured patch`, ); } + + const imageServer = join(fixtureDir, "image-server"); + mkdirSync(join(imageServer, "node_modules"), { recursive: true }); + writeFileSync(join(imageServer, "package.json"), JSON.stringify(serverPackage)); + writeFileSync(join(imageServer, "node_modules/unrelated-marker"), "keep existing application graph"); + const imageReceipt = JSON.parse(execFileSync(process.execPath, ["--input-type=module", "-e", + `import { materializeDockerProviderGraph } from ${JSON.stringify(new URL("./prepare-bundled-package.mjs", import.meta.url).href)}; + console.log(JSON.stringify(materializeDockerProviderGraph(process.argv[1], 'amd64', { sourceRoot: process.argv[2] })));`, + imageServer, fixtureSourceRoot], { env: { ...process.env, PATH: `${binDir}:${process.env.PATH}`, + FAKE_CALL_LOG: callLog, FAKE_NATIVE_ARTIFACTS: fixtureArtifacts }, encoding: "utf8" }).trim().split("\n").at(-1)); + assert.equal(imageReceipt.materialized, true); + assert.equal(imageReceipt.artifacts.artifacts.length, 1, "Image must stage only its existing qualified Linux target"); + assert.equal(imageReceipt.artifacts.artifacts.every(artifact => artifact.target === "linux-x64"), true); + assert.equal(readFileSync(join(imageServer, "node_modules/unrelated-marker"), "utf8"), "keep existing application graph"); + for (const name of ["@agentclientprotocol/codex-acp"]) { + assert.match(realpathSync(join(imageServer, "node_modules", name)), /image-server\/node_modules\/\.paperclip-native-providers\/node_modules/); + } }); test("bundled package dry runs preview without querying published versions", () => { diff --git a/scripts/prepare-bundled-package.mjs b/scripts/prepare-bundled-package.mjs index aeb2bef0c5..463f1bd5bf 100644 --- a/scripts/prepare-bundled-package.mjs +++ b/scripts/prepare-bundled-package.mjs @@ -1,11 +1,17 @@ #!/usr/bin/env node import { execFileSync } from "node:child_process"; -import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { createHash } from "node:crypto"; +import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; const repoRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); +const runtimeDataPath = "packages/paperclip-runner/src/drivers/acpx/qualified-runtime-artifacts.json"; +const nativeTargets = ["linux-x64", "darwin-arm64", "darwin-x64"]; +const digest = bytes => `sha256:${createHash("sha256").update(bytes).digest("hex")}`; export function materializePublishManifest(pkg) { const publishConfig = pkg.publishConfig ?? {}; @@ -48,6 +54,332 @@ export function createBundledInstallManifest(publishManifest, bundledDependencie return installManifest; } +// npm consumers cannot inherit the workspace's pnpm overrides or patches. +// Materialize the already-qualified Codex bridge closure in the tarball, +// with overrides confined to the temporary, scripts-disabled producer graph. +export function configureBundledProviderOverrides(installManifest, bundledDependencies, rootPackage, profileData, + runtimeData = JSON.parse(readFileSync(resolve(repoRoot, runtimeDataPath), "utf8"))) { + const result = structuredClone(installManifest); + const selected = []; + for (const [agent, serverPackage, runtimePackage] of [ + ["codex", "@agentclientprotocol/codex-acp", "@openai/codex"], + ]) { + if (!bundledDependencies.includes(serverPackage)) continue; + const profile = profileData?.profiles?.[agent]; + if (profileData?.schema !== "paperclip.acpx-profiles.v1" || profile?.agentServerPackage !== serverPackage + || profile.agentRuntimePackage !== runtimePackage || !/^\d+\.\d+\.\d+$/.test(profile.agentServerVersion ?? "") + || !/^\d+\.\d+\.\d+$/.test(profile.agentRuntimeVersion ?? "") + || result.dependencies?.[serverPackage] !== profile.agentServerVersion) { + throw new Error(`Bundled ${agent} bridge must match its exact qualified profile`); + } + const selector = `${serverPackage}@${profile.agentServerVersion}>${runtimePackage}`; + if (rootPackage.pnpm?.overrides?.[selector] !== profile.agentRuntimeVersion) { + throw new Error(`Bundled ${agent} runtime override must match its qualified profile`); + } + const npmSelector = `${serverPackage}@${profile.agentServerVersion}`; + if (result.overrides?.[npmSelector] !== undefined) { + throw new Error(`Bundled ${agent} runtime has a conflicting producer override`); + } + const overrides = { [runtimePackage]: profile.agentRuntimeVersion }; + result.overrides = { ...result.overrides, [npmSelector]: overrides }; + selected.push({ agent, ...profile }); + } + return { installManifest: result, profiles: selected }; +} + +const inside = (root, candidate) => { + const value = relative(root, candidate); + return value !== "" && value !== ".." && !value.startsWith(`..${sep}`) && !isAbsolute(value); +}; + +export function inspectBundledNativeArtifact(directory, profile, target, runtimeData) { + if (profile.agent !== "codex" || !nativeTargets.includes(target) || runtimeData?.schema !== "paperclip.acpx-runtime-artifacts.v1") throw new Error("Bundled native artifact has no supported target contract"); + const rootStat = lstatSync(directory), manifestStat = lstatSync(resolve(directory, "package.json")); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || !manifestStat.isFile() || manifestStat.nlink !== 1 || manifestStat.size > 256 * 1024) { + throw new Error("Bundled native artifact manifest is not a bounded regular file"); + } + const [platform, architecture] = target.split("-"); + const metadata = JSON.parse(readFileSync(resolve(directory, "package.json"), "utf8")); + const name = `${profile.agentRuntimePackage}-${target}`; + const version = `${profile.agentRuntimeVersion}-${target}`; + if (![name, profile.agentRuntimePackage].includes(metadata.name) || metadata.version !== version + || !Array.isArray(metadata.os) || !metadata.os.includes(platform) || !Array.isArray(metadata.cpu) || !metadata.cpu.includes(architecture) + || Object.keys(metadata.dependencies ?? {}).length || Object.keys(metadata.optionalDependencies ?? {}).length || Object.keys(metadata.scripts ?? {}).length) { + throw new Error(`Bundled ${profile.agent} native artifact identity mismatch: ${target}`); + } + const qualification = runtimeData?.[profile.agent]?.platforms?.[target]; + if (!qualification && !(profile.agent === "codex" && platform === "darwin")) throw new Error("Bundled native artifact omitted its existing executable qualification"); + const relativeExecutable = qualification?.relativeExecutable ?? (profile.agent === "codex" && platform === "darwin" + ? `vendor/${architecture === "arm64" ? "aarch64" : "x86_64"}-apple-darwin/bin/codex` : undefined); + if (!relativeExecutable || relativeExecutable.split("/").some(value => !value || value === "..") || isAbsolute(relativeExecutable)) { + throw new Error("Bundled native artifact has no recognized executable"); + } + const entries = []; + let bytes = 0; + const walk = (root, prefix = "") => { + if (!lstatSync(root).isDirectory() || lstatSync(root).isSymbolicLink()) throw new Error("Bundled native artifact contains a linked directory"); + for (const entry of readdirSync(root).sort()) { + const path = prefix ? `${prefix}/${entry}` : entry; + if (/[\u0000-\u001f\u007f\\]/.test(path)) throw new Error("Bundled native artifact contains an unsafe path"); + const file = resolve(root, entry), stat = lstatSync(file); + if (stat.isDirectory()) walk(file, path); + else { + if (!stat.isFile() || stat.nlink !== 1 || stat.mode & 0o7000) throw new Error("Bundled native artifact contains links or special files"); + bytes += stat.size; + if (bytes > 1024 * 1024 * 1024 || entries.length >= 4096) throw new Error("Bundled native artifact exceeds size limits"); + entries.push({ path, size: stat.size, sha256: digest(readFileSync(file)), executable: Boolean(stat.mode & 0o111) }); + } + } + }; + walk(directory); + const executable = entries.find(entry => entry.path === relativeExecutable); + if (!executable?.executable) throw new Error("Bundled native artifact has no executable payload"); + const header = readFileSync(resolve(directory, relativeExecutable)); + const correctArchitecture = platform === "linux" + ? header.length >= 20 && header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) && header[4] === 2 && header[5] === 1 && header.readUInt16LE(18) === 62 + : header.length >= 8 && header.readUInt32LE(0) === 0xfeedfacf && header.readUInt32LE(4) === (architecture === "arm64" ? 0x0100000c : 0x01000007); + if (!correctArchitecture) throw new Error(`Bundled native artifact architecture mismatch: ${target}`); + if (qualification && (qualification.packageName !== name || qualification.packageVersion !== version + || qualification.runtimePackageName !== profile.agentRuntimePackage || qualification.runtimePackageVersion !== profile.agentRuntimeVersion + || qualification.executableDigest !== executable.sha256)) throw new Error(`Bundled native artifact qualified digest mismatch: ${name}`); + return { agent: profile.agent, target, packageName: name, packageVersion: version, size: bytes, + executable: relativeExecutable, executableDigest: executable.sha256, qualification: qualification ? "existing executable hash" : "native Codex package/version; ACPX qualification unchanged", + closureDigest: digest(JSON.stringify(entries)), entries }; +} + +export function stageBundledProviderOptionalDependencies(destinationDir, publishManifest, profiles, + runtimeData = JSON.parse(readFileSync(resolve(repoRoot, runtimeDataPath), "utf8")), { nativeArtifactsDirectory, targets = nativeTargets } = {}) { + if (!Array.isArray(targets) || targets.length === 0 || new Set(targets).size !== targets.length + || targets.some(target => !nativeTargets.includes(target))) throw new Error("Invalid bundled native target selection"); + const result = structuredClone(publishManifest); + const graphRoot = realpathSync(resolve(destinationDir, "node_modules")); + const artifacts = [], nativeDependencies = {}; + for (const profile of profiles) { + const bridgeManifestPath = resolve(graphRoot, profile.agentServerPackage, "package.json"); + const bridge = JSON.parse(readFileSync(bridgeManifestPath, "utf8")); + if (bridge.name !== profile.agentServerPackage || bridge.version !== profile.agentServerVersion) { + throw new Error(`Bundled ${profile.agent} bridge version mismatch`); + } + const bridgeRequire = createRequire(bridgeManifestPath); + const runtimeManifestPath = realpathSync(bridgeRequire.resolve(`${profile.agentRuntimePackage}/package.json`)); + if (!inside(graphRoot, runtimeManifestPath)) throw new Error("Bundled Codex runtime escapes its producer graph"); + const runtime = JSON.parse(readFileSync(runtimeManifestPath, "utf8")); + if (runtime.name !== profile.agentRuntimePackage || runtime.version !== profile.agentRuntimeVersion) { + throw new Error(`Bundled ${profile.agent} runtime version mismatch`); + } + const optional = Object.entries(runtime.optionalDependencies ?? {}); + if (optional.length === 0) throw new Error(`Bundled ${profile.agent} runtime omitted platform artifacts`); + const runtimeRequire = createRequire(runtimeManifestPath); + for (const [name, specifier] of optional) { + const suffix = name.slice(profile.agentRuntimePackage.length + 1); + const recognized = profile.agent === "codex" && name.startsWith(`${profile.agentRuntimePackage}-`) && /^(linux|darwin|win32)-(x64|arm64)$/.test(suffix); + const version = `${runtime.version}-${suffix}`; + const expected = `npm:${runtime.name}@${version}`; + if (!recognized || specifier !== expected) throw new Error(`Bundled ${profile.agent} platform artifact is not qualified: ${name}`); + if (result.optionalDependencies?.[name] !== undefined && result.optionalDependencies[name] !== expected) { + throw new Error(`Bundled ${profile.agent} platform artifact conflicts with published dependency: ${name}`); + } + if (targets.includes(suffix)) { + result.optionalDependencies = { ...result.optionalDependencies, [name]: expected }; + nativeDependencies[name] = expected; + } + // Remove only recognized producer-platform payloads before staging the + // bounded supported target set. Preserve the original runtime manifest. + for (const lookup of runtimeRequire.resolve.paths(name) ?? []) { + const candidate = resolve(lookup, name); + if (!inside(graphRoot, candidate) || !existsSync(candidate)) continue; + if (lstatSync(candidate).isSymbolicLink() || !inside(graphRoot, realpathSync(candidate))) { + throw new Error(`Bundled ${profile.agent} platform artifact escapes its producer graph`); + } + const artifact = JSON.parse(readFileSync(resolve(candidate, "package.json"), "utf8")); + if (![name, runtime.name].includes(artifact.name) || artifact.version !== version) { + throw new Error(`Bundled ${profile.agent} installed platform artifact version mismatch: ${name}`); + } + rmSync(candidate, { recursive: true, force: true }); + } + } + + } + if (Object.keys(nativeDependencies).length !== profiles.length * targets.length) throw new Error("Bundled provider omitted a supported native target"); + const temporary = mkdtempSync(resolve(destinationDir, ".paperclip-native-artifacts-")); + try { + writeFileSync(resolve(temporary, "package.json"), JSON.stringify({ private: true, name: "paperclip-native-artifact-staging", version: "1.0.0", dependencies: nativeDependencies })); + if (!nativeArtifactsDirectory) execFileSync("npm", ["install", "--force", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"], + { cwd: temporary, stdio: "inherit", timeout: 180_000 }); + for (const profile of profiles) for (const target of targets) { + const name = `${profile.agentRuntimePackage}-${target}`; + const input = resolve(nativeArtifactsDirectory ?? resolve(temporary, "node_modules"), name); + const artifact = inspectBundledNativeArtifact(input, profile, target, runtimeData); + const output = resolve(graphRoot, name); + if (existsSync(output)) throw new Error("Bundled native artifact destination already exists"); + cpSync(input, output, { recursive: true, dereference: false }); + for (const entry of artifact.entries) chmodSync(resolve(output, entry.path), entry.executable ? 0o755 : 0o644); + const copied = inspectBundledNativeArtifact(output, profile, target, runtimeData); + if (copied.closureDigest !== artifact.closureDigest) throw new Error("Bundled native artifact changed during staging"); + artifacts.push(copied); + result.bundleDependencies = [...new Set([...(result.bundleDependencies ?? result.bundledDependencies ?? []), name])]; + } + const total = artifacts.reduce((size, artifact) => size + artifact.size, 0); + if (total > 2 * 1024 * 1024 * 1024) throw new Error("Bundled native provider payload exceeds release size limit"); + result.paperclipProviderArtifacts = { schema: "paperclip.bundled-provider-artifacts.v1", providerCalls: 0, lifecycleScriptsRun: false, + totalBytes: total, artifacts: artifacts.map(({ entries, ...artifact }) => artifact) }; + } finally { rmSync(temporary, { recursive: true, force: true }); } + return result; +} + +export function dockerBundledProviderTarget(architecture, + runtimeData = JSON.parse(readFileSync(resolve(repoRoot, runtimeDataPath), "utf8"))) { + const architectures = { amd64: "x64", arm64: "arm64" }; + if (!Object.hasOwn(architectures, architecture) || runtimeData?.schema !== "paperclip.acpx-runtime-artifacts.v1") { + throw new Error("Docker provider materialization requires a declared supported target architecture and qualification contract"); + } + const target = `linux-${architectures[architecture]}`; + // A Docker target selects an already-qualified artifact; it cannot qualify + // a new platform. Unqualified native targets retain their normal legacy CLI. + return runtimeData.codex?.platforms?.[target] ? target : null; +} + +export function mergeBundledProviderGraph(stagedDirectory, serverDirectory) { + for (const directory of [stagedDirectory, serverDirectory]) { + if (!isAbsolute(directory) || resolve(directory) !== directory || directory === "/" || realpathSync(directory) !== directory) { + throw new Error("Docker provider graph requires canonical owned package directories"); + } + } + const input = resolve(stagedDirectory, "node_modules"), output = resolve(serverDirectory, "node_modules"); + if (!existsSync(output)) mkdirSync(output); + if (realpathSync(input) !== input || realpathSync(output) !== output) throw new Error("Docker provider node_modules cannot escape its package"); + const names = ["@agentclientprotocol/codex-acp"]; + const serverMetadata = JSON.parse(readFileSync(resolve(serverDirectory, "package.json"), "utf8")); + for (const name of names) { + const source = resolve(input, name), stat = lstatSync(source); + const metadata = JSON.parse(readFileSync(resolve(source, "package.json"), "utf8")); + if (!stat.isDirectory() || stat.isSymbolicLink() || realpathSync(source) !== source + || metadata.name !== name || metadata.version !== serverMetadata.dependencies?.[name]) { + throw new Error("Docker provider bridge identity mismatch"); + } + } + // Keep the provider closure nested and physically inside the server's own + // authority. Changing unrelated direct server bindings (for example zod) + // would change application behavior even when no native agent is running. + const owned = resolve(output, ".paperclip-native-providers"); + if (lstatExists(owned)) throw new Error("Docker provider graph destination already exists"); + cpSync(stagedDirectory, owned, { recursive: true, dereference: false, verbatimSymlinks: true }); + chmodSync(owned, 0o755); + for (const name of names) { + const destination = resolve(output, name), namespace = dirname(destination); + if (!existsSync(namespace)) mkdirSync(namespace); + if (realpathSync(namespace) !== namespace) throw new Error("Docker provider namespace cannot resolve outside the server"); + if (lstatExists(destination)) { + const stat = lstatSync(destination); + // pnpm's store is untouched; remove only its server-local package link. + if (!stat.isSymbolicLink()) throw new Error("Docker provider entry binding must be a workspace package link"); + rmSync(destination); + } + symlinkSync(relative(namespace, resolve(owned, "node_modules", name)), destination, "dir"); + if (!inside(output, realpathSync(destination))) throw new Error("Docker provider bridge escaped its server authority"); + } +} + +function lstatExists(path) { + try { lstatSync(path); return true; } + catch (error) { if (error.code === "ENOENT") return false; throw error; } +} + +// A Linux-produced bundle contains esbuild's JavaScript but only the producer's +// optional executable. Expose its exact platform declarations to the consumer, +// just as embedded-postgres does below, before retaining the original hooks. +export function stageBundledEsbuildOptionalDependencies(destinationDir, publishManifest) { + const graphRoot = resolve(destinationDir, "node_modules"); + if (realpathSync(graphRoot) !== graphRoot) throw new Error("Bundled esbuild graph must be a canonical owned directory"); + const result = structuredClone(publishManifest), pending = [{ directory: graphRoot, depth: 0 }]; + const optional = {}, remove = new Set(); + let packageCount = 0; + const ownedDirectory = directory => { + const stat = lstatSync(directory); + if (!stat.isDirectory() || stat.isSymbolicLink() || !inside(graphRoot, realpathSync(directory))) { + throw new Error("Bundled esbuild dependency escapes its producer graph"); + } + }; + const manifestAt = directory => { + const file = resolve(directory, "package.json"), stat = lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 256 * 1024) { + throw new Error("Bundled esbuild dependency manifest must be a bounded regular file"); + } + return JSON.parse(readFileSync(file, "utf8")); + }; + while (pending.length) { + const { directory, depth } = pending.pop(); + if (depth > 64) throw new Error("Bundled esbuild dependency graph exceeds its depth limit"); + const packages = []; + for (const entry of readdirSync(directory)) { + if (entry.startsWith(".")) continue; + const candidate = resolve(directory, entry); + ownedDirectory(candidate); + if (entry.startsWith("@")) { + for (const child of readdirSync(candidate)) { + const scoped = resolve(candidate, child); ownedDirectory(scoped); packages.push(scoped); + } + } else packages.push(candidate); + } + for (const packageDirectory of packages) { + if (++packageCount > 20_000) throw new Error("Bundled esbuild dependency graph exceeds its package limit"); + const metadata = manifestAt(packageDirectory); + const nested = resolve(packageDirectory, "node_modules"); + if (lstatExists(nested)) { ownedDirectory(nested); pending.push({ directory: nested, depth: depth + 1 }); } + if (basename(packageDirectory) !== "esbuild") continue; + if (metadata.name !== "esbuild" || !/^\d+\.\d+\.\d+$/.test(metadata.version ?? "")) { + throw new Error("Bundled esbuild package identity mismatch"); + } + const declarations = metadata.optionalDependencies ?? {}; + if (nativeTargets.some(target => declarations[`@esbuild/${target}`] !== metadata.version)) { + throw new Error("Bundled esbuild omitted a supported platform declaration"); + } + const issuer = createRequire(resolve(packageDirectory, "package.json")); + for (const [name, version] of Object.entries(declarations)) { + if (!/^@esbuild\/[a-z0-9]+-[a-z0-9]+$/.test(name) || version !== metadata.version) { + throw new Error("Bundled esbuild platform declaration must match its exact package version"); + } + if ((optional[name] !== undefined && optional[name] !== version) + || (result.optionalDependencies?.[name] !== undefined && result.optionalDependencies[name] !== version)) { + throw new Error(`Bundled esbuild platform version conflicts with published dependency: ${name}`); + } + optional[name] = version; + for (const lookup of issuer.resolve.paths(name) ?? []) { + const candidate = resolve(lookup, name); + if (!inside(graphRoot, candidate) || !lstatExists(candidate)) continue; + ownedDirectory(candidate); + const installed = manifestAt(candidate); + if (installed.name !== name || installed.version !== version) { + throw new Error(`Bundled esbuild installed platform identity mismatch: ${name}`); + } + remove.add(candidate); + } + } + } + } + for (const directory of remove) rmSync(directory, { recursive: true, force: true }); + if (Object.keys(optional).length) result.optionalDependencies = { ...result.optionalDependencies, ...optional }; + return result; +} + +export function materializeDockerProviderGraph(serverDirectory, architecture, { sourceRoot = repoRoot } = {}) { + const runtimeData = JSON.parse(readFileSync(resolve(sourceRoot, runtimeDataPath), "utf8")); + const target = dockerBundledProviderTarget(architecture, runtimeData); + if (!target) return { target: `linux-${architecture === "arm64" ? "arm64" : "x64"}`, materialized: false, reason: "Native providers are not qualified for this target; legacy installation preserved" }; + const metadata = JSON.parse(readFileSync(resolve(serverDirectory, "package.json"), "utf8")); + const names = ["@agentclientprotocol/codex-acp"]; + const temporary = realpathSync(mkdtempSync(resolve(tmpdir(), "paperclip-docker-provider-graph-"))); + try { + const source = resolve(temporary, "source"), staged = resolve(temporary, "staged"); mkdirSync(source); + writeFileSync(resolve(source, "package.json"), JSON.stringify({ name: metadata.name, version: metadata.version, type: "module", files: [], + dependencies: Object.fromEntries(names.map(name => [name, metadata.dependencies?.[name]])), bundleDependencies: names })); + prepareBundledPackage(source, staged, { sourceRoot, targets: [target] }); + mergeBundledProviderGraph(staged, serverDirectory); + const stagedMetadata = JSON.parse(readFileSync(resolve(staged, "package.json"), "utf8")); + return { target, materialized: true, artifacts: stagedMetadata.paperclipProviderArtifacts, providerCalls: 0, lifecycleScriptsRun: false }; + } finally { rmSync(temporary, { recursive: true, force: true }); } +} + function patchedDependencyPackageName(specifier) { const versionSeparator = specifier.lastIndexOf("@"); const packageNameEnd = specifier.startsWith("@") ? specifier.indexOf("/") : 0; @@ -136,7 +468,7 @@ export function applyBundledDependencyPatches(destinationDir, bundledDependencie } } -export function prepareBundledPackage(sourceDir, destinationDir, { sourceRoot = repoRoot } = {}) { +export function prepareBundledPackage(sourceDir, destinationDir, { sourceRoot = repoRoot, targets = nativeTargets } = {}) { const sourcePackagePath = resolve(sourceDir, "package.json"); const sourcePackage = JSON.parse(readFileSync(sourcePackagePath, "utf8")); const bundledDependencies = sourcePackage.bundleDependencies ?? sourcePackage.bundledDependencies ?? []; @@ -157,7 +489,13 @@ export function prepareBundledPackage(sourceDir, destinationDir, { sourceRoot = const deployedPackagePath = resolve(destinationDir, "package.json"); const publishManifest = materializePublishManifest(sourcePackage); - const installManifest = createBundledInstallManifest(publishManifest, bundledDependencies); + const rootPackage = JSON.parse(readFileSync(resolve(sourceRoot, "package.json"), "utf8")); + const profileData = bundledDependencies.includes("@agentclientprotocol/codex-acp") + ? JSON.parse(readFileSync(resolve(sourceRoot, "packages/paperclip-runner/acpx-profiles.json"), "utf8")) : undefined; + const runtimeData = profileData ? JSON.parse(readFileSync(resolve(sourceRoot, runtimeDataPath), "utf8")) : undefined; + const { installManifest, profiles } = configureBundledProviderOverrides( + createBundledInstallManifest(publishManifest, bundledDependencies), bundledDependencies, rootPackage, profileData, runtimeData, + ); writeFileSync(deployedPackagePath, `${JSON.stringify(installManifest, null, 2)}\n`); execFileSync( @@ -167,6 +505,7 @@ export function prepareBundledPackage(sourceDir, destinationDir, { sourceRoot = ); writeFileSync(deployedPackagePath, `${JSON.stringify(publishManifest, null, 2)}\n`); applyBundledDependencyPatches(destinationDir, bundledDependencies, sourceRoot); + if (profiles.length) writeFileSync(deployedPackagePath, `${JSON.stringify(stageBundledProviderOptionalDependencies(destinationDir, publishManifest, profiles, runtimeData, { targets }), null, 2)}\n`); if (bundledDependencies.includes("acpx")) { const acpxPackage = JSON.parse( @@ -210,9 +549,17 @@ export function prepareBundledPackage(sourceDir, destinationDir, { sourceRoot = writeFileSync(deployedPackagePath, `${JSON.stringify(stagedPackage, null, 2)}\n`); rmSync(resolve(destinationDir, "node_modules/@embedded-postgres"), { recursive: true, force: true }); } + writeFileSync(deployedPackagePath, `${JSON.stringify(stageBundledEsbuildOptionalDependencies(destinationDir, + JSON.parse(readFileSync(deployedPackagePath, "utf8"))), null, 2)}\n`); } if (process.argv[1] === fileURLToPath(import.meta.url)) { + if (process.argv[2] === "--docker-provider-graph") { + const [, serverDirectory, architecture, ...extra] = process.argv.slice(2); + if (!serverDirectory || !architecture || extra.length) throw new Error("Usage: prepare-bundled-package.mjs --docker-provider-graph "); + console.log(JSON.stringify(materializeDockerProviderGraph(resolve(serverDirectory), architecture))); + process.exit(0); + } const [sourceDir, destinationDir] = process.argv.slice(2); if (!sourceDir || !destinationDir) { console.error("Usage: prepare-bundled-package.mjs "); diff --git a/server/package.json b/server/package.json index 42d53f18c7..a88fa571c7 100644 --- a/server/package.json +++ b/server/package.json @@ -44,6 +44,7 @@ "typecheck": "pnpm run prepare:runner-vendor && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit" }, "dependencies": { + "@agentclientprotocol/codex-acp": "1.6.2", "ai": "7.0.130", "@ai-sdk/openai": "4.0.86", "@openrouter/ai-sdk-provider": "3.1.0", @@ -107,6 +108,7 @@ "zod": "^4.4.3" }, "bundleDependencies": [ + "@agentclientprotocol/codex-acp", "@chat-adapter/discord", "@chat-adapter/github", "@chat-adapter/slack",