feat(runner): pin the Codex ACPX runtime (#12400)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The package-local host boundary is ready for a concrete ACP
implementation, but the first production profile is Codex only.
> - ACPX must not inherit the server process environment or choose an
executable by pathname after admission.
> - Codex must not re-enable ambient apps, memory, skills, MCP
configuration, or instructions inside its isolated home.
> - This pull request pins only the two required production packages and
applies narrowly tested host patches.
> - The benefit is a minimal dependency boundary that follows the
repository's CI-owned lockfile process.

## Linked Issues or Issue Description

**Agent or provider**

Codex through `acpx@0.13.1` and `@agentclientprotocol/codex-acp@1.6.2`.

**Why this adapter is useful**

The injected runtime host needs a concrete ACP session manager and the
exact reviewed Codex ACP server. Upstream ACPX does not yet expose a
host-owned spawn callback, and upstream Codex ACP does not yet apply
Paperclip's isolated instruction, MCP, app, memory, and skill boundary.
Both behaviors are required before the dependency can execute inside the
runner.

**How the agent is invoked**

The next pull request will adapt these pinned packages to the private
runtime host. ACPX receives a host-owned callback that consumes the
already verified executable lease. Codex receives only the isolated
environment, explicit base instructions, explicit MCP servers, and the
skills rooted in its private `CODEX_HOME`. This pull request alone does
not spawn either package or register an adapter.

**Additional context**

This pull request is stacked on #12399. It adds no Pi, Claude, AWS, SDK,
lab, browser, or UI dependency. It intentionally does not commit
`pnpm-lock.yaml`: the repository policy job regenerates a manifest-only
PR lockfile artifact for downstream frozen installs, and the lockfile
bot updates master separately.

## What Changed

- Pin `acpx` to `0.13.1` and the Codex ACP server to `1.6.2` in the
runner package.
- Register both patches in the pnpm 9 root configuration and newer-pnpm
workspace configuration.
- Preserve the existing embedded-Postgres and ACPX 0.12 patch entries
used by other packages.
- Patch ACPX to evaluate an allowlisted environment at child-spawn time
and keep spawn cwd out of provider-visible session identity.
- Patch ACPX to accept a host-owned spawn callback with the resolved
arguments and options, allowing the verified command lease to own
execution.
- Patch Codex ACP to retain runner-owned MCP server identity in
permission requests.
- Patch Codex ACP to pass explicit Paperclip base instructions on both
start and resume.
- In isolated mode, disable ambient apps, memory, and existing MCP
configuration; load skills only from `CODEX_HOME`; and configure only
requested servers.
- Add a package contract test that enforces exact versions, Codex-only
dependency scope, both pnpm patch registries, and every required patch
hook.

## Verification

- Both patch files dry-apply successfully to fresh published tarballs
for `acpx@0.13.1` and `@agentclientprotocol/codex-acp@1.6.2`.
- A local no-lockfile install applied both patches; their runtime
markers and exact installed versions were inspected.
- Runner TypeScript typecheck — passed against the patched packages.
- Runner package tests — passed: 16 Node protocol/package tests and 426
Vitest tests.
- `pnpm -r typecheck` — passed for all applicable workspaces.
- `pnpm build` — passed, including runner binary, server, UI, and
workspace packages.
- `git diff --check` — passed.
- The diff contains 6 files and does not change `pnpm-lock.yaml`, a
GitHub workflow, server selection, or UI behavior.

## Risks

The primary risk is drift between published package contents and
checked-in compiled patches. Exact versions are pinned, both patches are
exercised by package-contract gates, and CI performs the authoritative
regenerated-lockfile frozen install. The spawn callback does not grant a
new executable path: the following adapter must consume the opaque
verified command lease. Codex isolation changes activate only when
`PAPERCLIP_ACPX_ISOLATED_CONTEXT=1`, so existing direct Codex adapters
are unaffected.

## Model Used

OpenAI Codex with GPT-5 and repository tool use.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either linked an existing public item or described the
issue in this PR
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal task
identifier
- [x] I have run the affected tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have documented the dependency, patch, isolation, and lockfile
boundaries
- [ ] All applicable GitHub Actions are green
- [ ] Greptile is 5/5 with every actionable comment resolved
- [x] I will address all review findings before requesting merge
This commit is contained in:
Dotta 2026-08-30 18:11:25 -05:00 committed by GitHub
parent d0718c226c
commit 9ca24bba3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 513 additions and 8 deletions

View File

@ -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",

View File

@ -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"
},

View File

@ -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, "\\$&")),
);
}
});

View File

@ -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
});
}

146
patches/acpx@0.13.1.patch Normal file
View File

@ -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<AcpPermissionDecision | undefined>;
+ /** Ephemeral allowlisted environment evaluated immediately before child spawn. */
+ spawnEnvironment?: () => Record<string, string>;
+ /** 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<string, string>;
};
+ /** Ephemeral child environment factory; its return value is never persisted. */
+ spawnEnvironment?: () => Record<string, string>;
+ /** 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;

View File

@ -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

View File

@ -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", () => {

View File

@ -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)],