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:
parent
d0718c226c
commit
9ca24bba3c
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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, "\\$&")),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
|
|
|
|||
Loading…
Reference in New Issue