diff --git a/package.json b/package.json index 66b847bad4..307a31513b 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,9 @@ "pnpm": { "patchedDependencies": { "embedded-postgres@18.1.0-beta.16": "patches/embedded-postgres@18.1.0-beta.16.patch", - "acpx@0.12.0": "patches/acpx@0.12.0.patch" + "acpx@0.12.0": "patches/acpx@0.12.0.patch", + "acpx@0.13.1": "patches/acpx@0.13.1.patch", + "@agentclientprotocol/codex-acp@1.6.2": "patches/@agentclientprotocol__codex-acp@1.6.2.patch" }, "overrides": { "rollup": ">=4.59.0", diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index d732e2e42f..c7859a4871 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -32,7 +32,7 @@ "typecheck:typescript": "node --check scripts/protocol-contract.mjs && node --check scripts/generate-protocol-manifest.mjs && node --check scripts/generate-protocol-schema-module.mjs && node --check scripts/generate-acpx-sidecar-contract.mjs && node --check scripts/generate-replay-goldens.mjs && node --check scripts/generate-semantic-action-catalog.mjs && pnpm run check:protocol-types && tsc -p tsconfig.json --noEmit", "typecheck:rust": "cargo fmt --manifest-path runner/Cargo.toml --all -- --check && cargo check --manifest-path runner/Cargo.toml --locked --workspace", "test": "pnpm run test:typescript && pnpm run test:rust", - "test:typescript": "node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs && vitest run", + "test:typescript": "node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs && vitest run", "test:rust": "cargo test --release --manifest-path runner/Cargo.toml --locked --workspace", "test:codex": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --test codex_provider", "test:durable": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core durable::", @@ -52,6 +52,8 @@ "trace:conformance:rust": "cargo run --quiet --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin conformance-tracer" }, "dependencies": { + "@agentclientprotocol/codex-acp": "1.6.2", + "acpx": "0.13.1", "ajv": "^8.20.0", "json-schema-to-ts": "^3.1.1" }, diff --git a/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs b/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs new file mode 100644 index 0000000000..5f079eb8f7 --- /dev/null +++ b/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const runnerPackage = JSON.parse( + await readFile(new URL("../package.json", import.meta.url), "utf8"), +); +const rootPackage = JSON.parse( + await readFile(new URL("../../../package.json", import.meta.url), "utf8"), +); +const workspace = await readFile( + new URL("../../../pnpm-workspace.yaml", import.meta.url), + "utf8", +); +const acpxPatch = await readFile( + new URL("../../../patches/acpx@0.13.1.patch", import.meta.url), + "utf8", +); +const codexPatch = await readFile( + new URL( + "../../../patches/@agentclientprotocol__codex-acp@1.6.2.patch", + import.meta.url, + ), + "utf8", +); + +test("the runner pins only the Codex ACPX production dependencies", () => { + assert.equal(runnerPackage.dependencies.acpx, "0.13.1"); + assert.equal( + runnerPackage.dependencies["@agentclientprotocol/codex-acp"], + "1.6.2", + ); + assert.equal(runnerPackage.dependencies["pi-acp"], undefined); + assert.equal( + runnerPackage.dependencies["@agentclientprotocol/claude-agent-acp"], + undefined, + ); +}); + +test("old and new pnpm configuration both apply the exact runtime patches", () => { + assert.equal( + rootPackage.pnpm.patchedDependencies["acpx@0.13.1"], + "patches/acpx@0.13.1.patch", + ); + assert.equal( + rootPackage.pnpm.patchedDependencies[ + "@agentclientprotocol/codex-acp@1.6.2" + ], + "patches/@agentclientprotocol__codex-acp@1.6.2.patch", + ); + assert.match(workspace, /acpx@0\.13\.1: patches\/acpx@0\.13\.1\.patch/); + assert.match( + workspace, + /codex-acp@1\.6\.2': patches\/@agentclientprotocol__codex-acp@1\.6\.2\.patch/, + ); +}); + +test("the ACPX patch preserves launch-only state and verified spawning", () => { + for (const token of [ + "spawnEnvironment", + "spawnCwd", + "spawnAgent", + "SpawnOptionsWithoutStdio", + "this.options.spawnAgent", + ]) { + assert.match(acpxPatch, new RegExp(token)); + } +}); + +test("the ACPX patch fails closed on an invalid spawn environment", () => { + for (const token of [ + "isPlainStringEnvironment", + "Object.getPrototypeOf(value)", + 'Object.values(value).every((entry) => typeof entry === "string")', + "spawnEnvironment !== void 0", + "sourceEnvironment = spawnEnvironment()", + "ACPX spawn environment must be a plain record of string values", + ]) { + assert.match( + acpxPatch, + new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), + ); + } + assert.doesNotMatch(acpxPatch, /spawnEnvironment\?\.\(\)/); + assert.doesNotMatch( + acpxPatch, + /spawnEnvironment \? \{ \.\.\.spawnEnvironment \} : \{ \.\.\.process\.env \}/, + ); +}); + +test("the Codex patch enforces isolated instructions, tools, and skills", () => { + for (const token of [ + "PAPERCLIP_ACPX_ISOLATED_CONTEXT", + "baseInstructions", + "rawInput: { serverName: params.serverName }", + '"features.apps": false', + "process.env.CODEX_HOME", + ]) { + assert.match( + codexPatch, + new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), + ); + } +}); diff --git a/patches/@agentclientprotocol__codex-acp@1.6.2.patch b/patches/@agentclientprotocol__codex-acp@1.6.2.patch new file mode 100644 index 0000000000..f6a8c34bfe --- /dev/null +++ b/patches/@agentclientprotocol__codex-acp@1.6.2.patch @@ -0,0 +1,90 @@ +diff --git a/dist/index.js b/dist/index.js +--- a/dist/index.js ++++ b/dist/index.js +@@ -25563,7 +25563,7 @@ + toolCall: { + toolCallId: context.correlatedCallId, + kind: "execute", +- status: "pending" ++ status: "pending", ++ rawInput: { serverName: params.serverName } + // content: [messageContent], — omitted: already rendered via item/started +- // rawInput: { ... } — omitted: same reason + }, +@@ -26988,4 +26988,13 @@ + }; ++function paperclipBaseInstructions(request) { ++ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT !== "1") return void 0; ++ const prompt = request?._meta?.systemPrompt; ++ if (typeof prompt === "string") return prompt; ++ if (prompt && typeof prompt === "object" && typeof prompt.append === "string") { ++ return prompt.append; ++ } ++ return void 0; ++} + var CodexAcpClient = class { + codexClient; + config; +@@ -27288,6 +27297,7 @@ + const response = await this.codexClient.threadResume({ + config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []), + cwd: request.cwd, ++ baseInstructions: paperclipBaseInstructions(request), + modelProvider: await this.getResumeModelProvider(), + threadId: request.sessionId + }); +@@ -27310,6 +27320,7 @@ + const response = await this.codexClient.threadResume({ + config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []), + cwd: request.cwd, ++ baseInstructions: paperclipBaseInstructions(request), + modelProvider: await this.getResumeModelProvider(), + threadId: request.sessionId + }); +@@ -27337,5 +27348,6 @@ + const response = await this.codexClient.threadStart({ + config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers), + modelProvider: this.getModelProvider(), ++ baseInstructions: paperclipBaseInstructions(request), + cwd: request.cwd + }); +@@ -27437,5 +27449,12 @@ + const mergedConfig = { + ...mergeGatewayConfig(this.config, this.gatewayConfig), ++ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" ? { ++ "include_apps_instructions": false, ++ "features.apps": false, ++ "features.memory_tool": false, ++ "skills.include_instructions": true, ++ "mcp_servers": {} ++ } : {}), + projects: Object.fromEntries(sessionRoots.map((root) => [root, { + trust_level: "trusted" + }])) +@@ -27449,7 +27468,7 @@ + server: mcp + })); + let serversToConfigure = requestedServers; +- if (shouldDeduplicateMcpConflicts()) { ++ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT !== "1" && shouldDeduplicateMcpConflicts()) { + const existingNames = await this.getConfigMcpServerNames(projectPath); + serversToConfigure = requestedServers.filter((mcp) => !existingNames.has(mcp.name)); + } +@@ -27483,14 +27502,15 @@ + async refreshSkills(cwd, additionalRoots) { + if (!cwd) { + return; + } +- const skillExtraRoots = additionalRoots.map((root) => path6.join(root, ".agents", "skills")); ++ const isolated = process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1"; ++ const skillExtraRoots = isolated ? [] : additionalRoots.map((root) => path6.join(root, ".agents", "skills")); + if (!arraysEqual(this.skillExtraRoots, skillExtraRoots)) { + await this.codexClient.skillsExtraRootsSet({ extraRoots: skillExtraRoots }); + this.skillExtraRoots = skillExtraRoots; + } + await this.codexClient.listSkills({ +- cwds: [cwd, ...additionalRoots], ++ cwds: isolated ? [process.env.CODEX_HOME] : [cwd, ...additionalRoots], + forceReload: true + }); + } diff --git a/patches/acpx@0.13.1.patch b/patches/acpx@0.13.1.patch new file mode 100644 index 0000000000..351f10176a --- /dev/null +++ b/patches/acpx@0.13.1.patch @@ -0,0 +1,146 @@ +diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js +index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..2ed4b0ba1bd6d3abf8bbafe0a3cd5ea1f093f90b 100644 +--- a/dist/live-checkpoint-BSIrfgVo.js ++++ b/dist/live-checkpoint-BSIrfgVo.js +@@ -3135,8 +3135,18 @@ function promotePrefixedAuthEnvironment(env) { + } + return protectedKeys; + } +-function buildAgentEnvironment(authCredentials, sessionEnv) { +- const env = { ...process.env }; ++function isPlainStringEnvironment(value) { ++ if (value === null || typeof value !== "object" || Array.isArray(value)) return false; ++ const prototype = Object.getPrototypeOf(value); ++ return (prototype === Object.prototype || prototype === null) && Object.values(value).every((entry) => typeof entry === "string"); ++} ++function buildAgentEnvironment(authCredentials, sessionEnv, spawnEnvironment) { ++ let sourceEnvironment = process.env; ++ if (spawnEnvironment !== void 0) { ++ sourceEnvironment = spawnEnvironment(); ++ if (!isPlainStringEnvironment(sourceEnvironment)) throw new TypeError("ACPX spawn environment must be a plain record of string values"); ++ } ++ const env = { ...sourceEnvironment }; + const protectedAuthEnvKeys = promotePrefixedAuthEnvironment(env); + if (authCredentials) for (const [methodId, credential] of Object.entries(authCredentials)) { + addAuthCredentialEnvKeys(protectedAuthEnvKeys, methodId, credential); +@@ -3178,10 +3178,10 @@ function resolveConfiguredAuthCredential(methodId, authCredentials) { + const configCredentials = authCredentials ?? {}; + return configCredentials[methodId] ?? configCredentials[toEnvToken(methodId)]; + } +-function buildAgentSpawnOptions(cwd, authCredentials, sessionEnv) { ++function buildAgentSpawnOptions(cwd, authCredentials, sessionEnv, spawnEnvironment) { + return { + cwd, +- env: buildAgentEnvironment(authCredentials, sessionEnv), ++ env: buildAgentEnvironment(authCredentials, sessionEnv, spawnEnvironment), + stdio: [ + "pipe", + "pipe", +@@ -4253,7 +4253,12 @@ var AcpClient = class { + geminiAcp: isGeminiAcpCommand(spawnCommand, args), + copilotAcp: isCopilotAcpCommand(spawnCommand, args), + claudeAcp: isClaudeAcpCommand(spawnCommand, args), +- spawnOptions: buildAgentSpawnOptions(this.options.cwd, this.options.authCredentials, this.options.sessionOptions?.env) ++ spawnOptions: buildAgentSpawnOptions( ++ this.options.spawnCwd ?? this.options.cwd, ++ this.options.authCredentials, ++ this.options.sessionOptions?.env, ++ this.options.spawnEnvironment ++ ) + }; + } + logAgentLaunch(plan) { +@@ -4280,10 +4285,17 @@ var AcpClient = class { + } + async spawnAgentProcess(plan) { + const spawnCommand = buildAgentSpawnCommand(plan.spawnCommand, plan.args, process.platform, plan.spawnOptions.env); +- const spawnedChild = spawn(spawnCommand.command, spawnCommand.args, { ++ const options = { + ...plan.spawnOptions, + windowsVerbatimArguments: spawnCommand.windowsVerbatimArguments +- }); ++ }; ++ const spawnedChild = this.options.spawnAgent ++ ? this.options.spawnAgent({ ++ command: spawnCommand.command, ++ args: spawnCommand.args, ++ options ++ }) ++ : spawn(spawnCommand.command, spawnCommand.args, options); + try { + await waitForSpawn$1(spawnedChild); + } catch (error) { +diff --git a/dist/runtime.d.ts b/dist/runtime.d.ts +index e8102acb03c4c38830ad5ec22f356125eb0423b7..ac835a64ed36edfedf47f10757b11258fe77de51 100644 +--- a/dist/runtime.d.ts ++++ b/dist/runtime.d.ts +@@ -1,5 +1,6 @@ + import { _ as SessionRecord, a as AcpElicitationHandler, c as AcpElicitationResponse, f as McpServer$1, h as PermissionPolicy, i as AcpElicitationContext, l as AcpPermissionDecision, m as PermissionMode, n as SystemPromptOption, o as AcpElicitationMode, p as NonInteractivePermissionPolicy, s as AcpElicitationRequest, t as SessionAgentOptions, u as AcpPermissionRequest } from "./session-options-DwRDODlr.js"; + import { a as RequestedModelUnsupportedErrorCode, i as RequestedModelUnsupportedError, n as REQUESTED_MODEL_UNSUPPORTED_ERROR_CODE, o as RequestedModelUnsupportedReason, r as REQUESTED_MODEL_UNSUPPORTED_REASONS, s as isRequestedModelUnsupportedError, t as AcpClient } from "./client-CxNllqui.js"; ++import { ChildProcess, SpawnOptionsWithoutStdio } from "node:child_process"; + import fs from "node:fs"; + import { ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk"; + //#region src/agent-registry.d.ts +@@ -313,6 +314,16 @@ type AcpRuntimeOptions = { + onPermissionRequest?: (req: AcpPermissionRequest, ctx: { + signal: AbortSignal; + }) => Promise; ++ /** Ephemeral allowlisted environment evaluated immediately before child spawn. */ ++ spawnEnvironment?: () => Record; ++ /** Host-only spawn cwd; does not change the cwd advertised in session/new. */ ++ spawnCwd?: string; ++ /** Host-owned verified executable launch. */ ++ spawnAgent?: (input: { ++ command: string; ++ args: readonly string[]; ++ options: SpawnOptionsWithoutStdio; ++ }) => ChildProcess; + }; + type AcpFileSessionStoreOptions = { + stateDir: string; +diff --git a/dist/runtime.js b/dist/runtime.js +index a1f4a70a003792c6eacf68b6b038f37bfec1db53..c11bf5c877779d8489371b5dcac69b4f64bd0069 100644 +--- a/dist/runtime.js ++++ b/dist/runtime.js +@@ -812,7 +812,13 @@ var AcpRuntimeManager = class { + this.deps = deps; + } + createClient(options) { +- return this.deps.clientFactory?.(options) ?? new AcpClient(options); ++ const patchedOptions = { ++ ...options, ++ spawnCwd: this.options.spawnCwd, ++ spawnEnvironment: this.options.spawnEnvironment, ++ spawnAgent: this.options.spawnAgent ++ }; ++ return this.deps.clientFactory?.(patchedOptions) ?? new AcpClient(patchedOptions); + } + createSessionOwner(input) { + const owner = { +diff --git a/dist/session-options-DwRDODlr.d.ts b/dist/session-options-DwRDODlr.d.ts +index c3da1645235bbea22de3f8484149051cd7dca56b..77f883542e7055370026884e6ce3cf80ba8d5767 100644 +--- a/dist/session-options-DwRDODlr.d.ts ++++ b/dist/session-options-DwRDODlr.d.ts +@@ -1,4 +1,5 @@ + import { AgentCapabilities, AnyMessage, ContentBlock, CreateElicitationRequest, ElicitationContentValue, JsonRpcId, McpServer, McpServer as McpServer$1, RequestPermissionRequest, SessionConfigOption, SessionNotification, SetSessionConfigOptionResponse, ToolKind } from "@agentclientprotocol/sdk"; ++import { ChildProcess, SpawnOptionsWithoutStdio } from "node:child_process"; + //#region src/prompt-content.d.ts + type PromptInput = ContentBlock[]; + //#endregion +@@ -116,6 +117,16 @@ type AcpClientOptions = { + }; + env?: Record; + }; ++ /** Ephemeral child environment factory; its return value is never persisted. */ ++ spawnEnvironment?: () => Record; ++ /** Host-only child cwd, separate from the cwd advertised to ACP. */ ++ spawnCwd?: string; ++ /** Host-owned verified executable launch. */ ++ spawnAgent?: (input: { ++ command: string; ++ args: readonly string[]; ++ options: SpawnOptionsWithoutStdio; ++ }) => ChildProcess; + onAcpMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void; + onAcpOutputMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void; + onSessionUpdate?: (notification: SessionNotification) => void; diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5bc8038cf9..7262ab6ace 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,3 +12,11 @@ packages: - server - ui - cli + +# Keep in sync with package.json#pnpm.patchedDependencies. Newer pnpm +# versions read patch configuration only from the workspace manifest. +patchedDependencies: + embedded-postgres@18.1.0-beta.16: patches/embedded-postgres@18.1.0-beta.16.patch + acpx@0.12.0: patches/acpx@0.12.0.patch + acpx@0.13.1: patches/acpx@0.13.1.patch + '@agentclientprotocol/codex-acp@1.6.2': patches/@agentclientprotocol__codex-acp@1.6.2.patch diff --git a/scripts/acpx-patch-packaging.test.mjs b/scripts/acpx-patch-packaging.test.mjs index 227c77695b..beea369944 100644 --- a/scripts/acpx-patch-packaging.test.mjs +++ b/scripts/acpx-patch-packaging.test.mjs @@ -19,6 +19,7 @@ import { bundledCliNpmDependencies } from "./cli-bundled-npm-dependencies.mjs"; import { createBundledInstallManifest, materializePublishManifest, + selectBundledDependencyPatches, } from "./prepare-bundled-package.mjs"; const rootPackage = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")); @@ -97,6 +98,89 @@ test("bundled package staging installs only dependencies included in the tarball assert.deepEqual(installManifest.bundleDependencies, ["embedded-postgres"]); }); +test("bundled package staging selects only the installed dependency version's patch", (t) => { + const destinationDir = mkdtempSync(join(tmpdir(), "paperclip-bundled-patch-selection-")); + const installedPackageDir = join(destinationDir, "node_modules", "acpx"); + mkdirSync(installedPackageDir, { recursive: true }); + writeFileSync( + join(installedPackageDir, "package.json"), + JSON.stringify({ name: "acpx", version: "0.12.0" }), + ); + t.after(() => rmSync(destinationDir, { recursive: true, force: true })); + + assert.deepEqual( + selectBundledDependencyPatches(destinationDir, ["acpx"], { + "acpx@0.12.0": "patches/acpx@0.12.0.patch", + "acpx@0.13.1": "patches/acpx@0.13.1.patch", + }), + [ + { + packageName: "acpx", + specifier: "acpx@0.12.0", + patchPath: "patches/acpx@0.12.0.patch", + }, + ], + ); +}); + +test("bundled package patch selection handles scoped package names", (t) => { + const destinationDir = mkdtempSync(join(tmpdir(), "paperclip-scoped-patch-selection-")); + const installedPackageDir = join(destinationDir, "node_modules", "@example", "runtime"); + mkdirSync(installedPackageDir, { recursive: true }); + writeFileSync( + join(installedPackageDir, "package.json"), + JSON.stringify({ name: "@example/runtime", version: "1.2.3" }), + ); + t.after(() => rmSync(destinationDir, { recursive: true, force: true })); + + assert.deepEqual( + selectBundledDependencyPatches(destinationDir, ["@example/runtime"], { + "@example/runtime@1.2.3": "patches/runtime@1.2.3.patch", + "@example/runtime@2.0.0": "patches/runtime@2.0.0.patch", + }), + [ + { + packageName: "@example/runtime", + specifier: "@example/runtime@1.2.3", + patchPath: "patches/runtime@1.2.3.patch", + }, + ], + ); +}); + +test("bundled package patch selection reports missing installed metadata", (t) => { + const destinationDir = mkdtempSync(join(tmpdir(), "paperclip-missing-patch-metadata-")); + t.after(() => rmSync(destinationDir, { recursive: true, force: true })); + + assert.throws( + () => + selectBundledDependencyPatches(destinationDir, ["acpx"], { + "acpx@0.12.0": "patches/acpx@0.12.0.patch", + }), + /Cannot select a patch for bundled dependency acpx: failed to read/, + ); +}); + +test("bundled package patch selection rejects an unpatched installed version", (t) => { + const destinationDir = mkdtempSync(join(tmpdir(), "paperclip-unmatched-patch-version-")); + const installedPackageDir = join(destinationDir, "node_modules", "acpx"); + mkdirSync(installedPackageDir, { recursive: true }); + writeFileSync( + join(installedPackageDir, "package.json"), + JSON.stringify({ name: "acpx", version: "0.14.0" }), + ); + t.after(() => rmSync(destinationDir, { recursive: true, force: true })); + + assert.throws( + () => + selectBundledDependencyPatches(destinationDir, ["acpx"], { + "acpx@0.12.0": "patches/acpx@0.12.0.patch", + "acpx@0.13.1": "patches/acpx@0.13.1.patch", + }), + /installed acpx@0\.14\.0, but configured patches are acpx@0\.12\.0, acpx@0\.13\.1/, + ); +}); + test("bundled package staging rebuilds npm dependencies and applies the acpx patch", (t) => { const fixtureDir = mkdtempSync(join(tmpdir(), "paperclip-bundled-stage-")); const sourceDir = join(fixtureDir, "source"); @@ -133,6 +217,7 @@ printf 'npm %s\\n' "$*" >> "$FAKE_CALL_LOG" [ "$*" = "install --omit=dev --ignore-scripts --no-audit --no-fund" ] mkdir -p node_modules/acpx/dist printf 'unpatched runtime\\n' > node_modules/acpx/dist/runtime.js +printf '{"name":"acpx","version":"0.12.0"}\\n' > node_modules/acpx/package.json `, ); writeExecutable( @@ -151,6 +236,7 @@ while [ "$#" -gt 0 ]; do done patch_input="$(cat)" grep -q onAgentStderr <<< "$patch_input" +! grep -q spawnEnvironment <<< "$patch_input" printf 'patched onAgentStderr runtime\\n' > "$target/dist/runtime.js" `, ); @@ -178,6 +264,10 @@ printf 'patched onAgentStderr runtime\\n' > "$target/dist/runtime.js" readFileSync(callLog, "utf8"), /patch -p1 --forward -d .*node_modules\/acpx/, ); + assert.equal( + readFileSync(callLog, "utf8").split("\n").filter((line) => line.startsWith("patch ")).length, + 1, + ); }); 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 90aac8b68f..84cef8d001 100644 --- a/scripts/prepare-bundled-package.mjs +++ b/scripts/prepare-bundled-package.mjs @@ -48,18 +48,81 @@ export function createBundledInstallManifest(publishManifest, bundledDependencie function patchedDependencyPackageName(specifier) { const versionSeparator = specifier.lastIndexOf("@"); - return versionSeparator > 0 ? specifier.slice(0, versionSeparator) : specifier; + const packageNameEnd = specifier.startsWith("@") ? specifier.indexOf("/") : 0; + if (packageNameEnd < 0) return specifier; + return versionSeparator > packageNameEnd ? specifier.slice(0, versionSeparator) : specifier; +} + +export function selectBundledDependencyPatches( + destinationDir, + bundledDependencies, + patchedDependencies, +) { + const patchesByPackageName = new Map(); + for (const [specifier, patchPath] of Object.entries(patchedDependencies)) { + const packageName = patchedDependencyPackageName(specifier); + const packagePatches = patchesByPackageName.get(packageName) ?? new Map(); + packagePatches.set(specifier, patchPath); + patchesByPackageName.set(packageName, packagePatches); + } + + const selectedPatches = []; + for (const packageName of new Set(bundledDependencies)) { + const packagePatches = patchesByPackageName.get(packageName); + if (!packagePatches) continue; + + const installedManifestPath = resolve( + destinationDir, + "node_modules", + packageName, + "package.json", + ); + let installedManifest; + try { + installedManifest = JSON.parse(readFileSync(installedManifestPath, "utf8")); + } catch (cause) { + throw new Error( + `Cannot select a patch for bundled dependency ${packageName}: failed to read ${installedManifestPath}`, + { cause }, + ); + } + + if ( + installedManifest.name !== packageName || + typeof installedManifest.version !== "string" || + installedManifest.version.length === 0 + ) { + throw new Error( + `Cannot select a patch for bundled dependency ${packageName}: installed package manifest must declare the expected name and a version`, + ); + } + + const installedSpecifier = `${packageName}@${installedManifest.version}`; + const patchPath = packagePatches.get(installedSpecifier); + if (patchPath === undefined) { + const configuredSpecifiers = [...packagePatches.keys()].sort().join(", "); + throw new Error( + `Cannot select a patch for bundled dependency ${packageName}: installed ${installedSpecifier}, but configured patches are ${configuredSpecifiers}`, + ); + } + if (typeof patchPath !== "string" || patchPath.length === 0) { + throw new Error(`Patch path for ${installedSpecifier} must be a non-empty string`); + } + selectedPatches.push({ packageName, specifier: installedSpecifier, patchPath }); + } + + return selectedPatches; } export function applyBundledDependencyPatches(destinationDir, bundledDependencies) { const rootPackage = JSON.parse(readFileSync(resolve(repoRoot, "package.json"), "utf8")); const patchedDependencies = rootPackage.pnpm?.patchedDependencies ?? {}; - const bundledDependencyNames = new Set(bundledDependencies); - - for (const [specifier, patchPath] of Object.entries(patchedDependencies)) { - const packageName = patchedDependencyPackageName(specifier); - if (!bundledDependencyNames.has(packageName)) continue; + for (const { packageName, patchPath } of selectBundledDependencyPatches( + destinationDir, + bundledDependencies, + patchedDependencies, + )) { execFileSync( "patch", ["-p1", "--forward", "-d", resolve(destinationDir, "node_modules", packageName)],