fix(runner): restore rooted ACPX package resolution

This commit is contained in:
Dotta 2026-09-03 01:45:52 -05:00
parent 6f0614ec74
commit 3d8720c16f
10 changed files with 139 additions and 11 deletions

View File

@ -154,13 +154,18 @@
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "0.70.0",
"@agentclientprotocol/codex-acp": "1.6.2",
"@openai/codex": "0.148.0",
"acpx": "0.13.1",
"ajv": "^8.20.0",
"json-schema-to-ts": "^3.1.1",
"node": "24.11.0",
"opencode-ai": "1.18.17",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
"optionalDependencies": {
"@openai/codex-linux-x64": "npm:@openai/codex@0.148.0-linux-x64"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"

View File

@ -120,6 +120,7 @@ impl AcpxSidecarTransport {
"PAPERCLIP_NATIVE_MCP_NAME",
"PAPERCLIP_NATIVE_MCP_URL",
"PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT",
"PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST",
];
keys.extend_from_slice(credential_keys);
Self::start_with_environment_keys(config, &keys)

View File

@ -208,6 +208,8 @@ it("preserves the controller-selected ACPX provider package root", () => {
environment: {
PATH: "/bin",
PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: "/verified/provider-pack",
PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST:
"/verified/provider-pack/package.json",
NODE_PATH: "/untrusted/modules",
},
processLauncher: (spec) => {
@ -233,6 +235,9 @@ it("preserves the controller-selected ACPX provider package root", () => {
expect(launches[0]!.environment.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT).toBe(
"/verified/provider-pack",
);
expect(
launches[0]!.environment.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST,
).toBe("/verified/provider-pack/package.json");
expect(launches[0]!.environment.NODE_PATH).toBeUndefined();
});

View File

@ -1929,6 +1929,7 @@ const runnerExplicitProviderEnvironmentKeys = [
"PAPERCLIP_NATIVE_MCP_TOKEN",
"PAPERCLIP_NATIVE_RUNTIME_CONTEXT_PATH",
"PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT",
"PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST",
"PAPERCLIP_ACPX_PROVIDER_RECOVERY_POLICY",
"PAPERCLIP_PROVIDER_TRACE_PATH",
"PAPERCLIP_PROVIDER_TRACE_MAX_BYTES",

View File

@ -76,6 +76,39 @@ describe("ACPX installation integrity", () => {
"explicit normalized absolute path",
);
const runnerPackage = join(root, "packages", "paperclip-runner");
const runnerManifest = join(runnerPackage, "package.json");
const pnpmProviderDirectory = join(
root,
"node_modules",
".pnpm",
"qualified-provider@1.0.0",
"node_modules",
"pnpm-provider",
);
await Promise.all([
mkdir(join(runnerPackage, "node_modules"), { recursive: true }),
mkdir(pnpmProviderDirectory, { recursive: true }),
writeFile(runnerManifest, JSON.stringify({ private: true })),
writeFile(
join(pnpmProviderDirectory, "package.json"),
JSON.stringify({ name: "pnpm-provider", version: "1.0.0" }),
),
]);
await symlink(
pnpmProviderDirectory,
join(runnerPackage, "node_modules", "pnpm-provider"),
);
expect(
createAcpxPackageJsonResolver(root, runnerManifest)("pnpm-provider"),
).toBe(join(pnpmProviderDirectory, "package.json"));
const outsideManifest = join(parent, "outside-package.json");
await writeFile(outsideManifest, JSON.stringify({ private: true }));
expect(() =>
createAcpxPackageJsonResolver(root, outsideManifest),
).toThrow("manifest resolves outside the selected provider root");
const ancestorProviderDirectory = join(
parent,
"node_modules",

View File

@ -218,6 +218,7 @@ export type AcpxPackageJsonResolver = (packageName: string) => string;
export function createAcpxPackageJsonResolver(
providerPackageRoot: string | undefined,
providerPackageManifest?: string,
): AcpxPackageJsonResolver {
const root = providerPackageRoot?.trim();
if (
@ -230,7 +231,26 @@ export function createAcpxPackageJsonResolver(
"ACPX provider package root must be an explicit normalized absolute path",
);
}
const manifest = (
providerPackageManifest ?? resolve(root, "package.json")
).trim();
if (
!manifest ||
!isAbsolute(manifest) ||
manifest.includes("\0") ||
resolve(manifest) !== manifest
) {
throw new Error(
"ACPX provider package manifest must be an explicit normalized absolute path",
);
}
const canonicalRoot = realpathSync(root);
const canonicalManifest = realpathSync(manifest);
if (!pathIsInside(canonicalRoot, canonicalManifest)) {
throw new Error(
"ACPX provider package manifest resolves outside the selected provider root",
);
}
const canonicalNodeModules = realpathSync(
resolve(canonicalRoot, "node_modules"),
);
@ -239,7 +259,7 @@ export function createAcpxPackageJsonResolver(
"ACPX provider node_modules resolves outside the selected provider root",
);
}
const providerRequire = createRequire(resolve(root, "package.json"));
const providerRequire = createRequire(canonicalManifest);
return (packageName) => {
const packageJsonPath = realpathSync(
providerRequire.resolve(`${packageName}/package.json`),
@ -576,7 +596,10 @@ export async function verifyQualifiedAcpxInstallation(
function defaultPackageJsonResolver(packageName: string): string {
const providerPackageRoot = process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT;
if (providerPackageRoot !== undefined) {
return createAcpxPackageJsonResolver(providerPackageRoot)(packageName);
return createAcpxPackageJsonResolver(
providerPackageRoot,
process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST,
)(packageName);
}
// Source-mode and direct runtimes still have a stable module URL. The
// descriptor-backed runner sidecar always receives the explicit root above.

View File

@ -11,6 +11,7 @@ import {
import { createHash } from "node:crypto";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
@ -140,14 +141,26 @@ it.each(["acpx-runtime-sidecar.cjs", "opencode-app-server-proxy.cjs"] as const)(
},
);
it("derives the ACPX package root only from the verified dist/cli layout", () => {
it("derives the ACPX package authority only from the verified dist/cli layout", () => {
const runnerPackageRoot = fileURLToPath(new URL("../..", import.meta.url));
expect(
runnerdLaunchProfileInternals.acpxProviderPackageRoot(
runnerdLaunchProfileInternals.acpxProviderPackageAuthority(
resolve(runnerPackageRoot, "dist/cli/acpx-runtime-sidecar.cjs"),
),
).toEqual({
root: resolve(runnerPackageRoot, "../.."),
manifest: resolve(runnerPackageRoot, "package.json"),
});
expect(
runnerdLaunchProfileInternals.acpxProviderPackageAuthority(
"/provider-pack/dist/cli/acpx-runtime-sidecar.cjs",
),
).toBe("/provider-pack");
).toEqual({
root: "/provider-pack",
manifest: "/provider-pack/package.json",
});
expect(() =>
runnerdLaunchProfileInternals.acpxProviderPackageRoot(
runnerdLaunchProfileInternals.acpxProviderPackageAuthority(
"/unverified/acpx-runtime-sidecar.cjs",
),
).toThrow("ACPX sidecar must use the provider package dist/cli layout");
@ -446,6 +459,8 @@ it.each([
PATH: "/bin",
...credentialEnvironment,
PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: "/attacker/package-root",
PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST:
"/attacker/package-root/package.json",
PAPERCLIP_API_KEY: "must-not-reach-provider",
DATABASE_URL: "must-not-reach-provider",
},
@ -472,6 +487,8 @@ it.each([
PAPERCLIP_NORMALIZED_SESSION_ID: "session-1",
PAPERCLIP_NATIVE_RUNTIME_CONTEXT_PATH: "/isolated/runtime-context.json",
PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: "/verified/provider-pack",
PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST:
"/verified/provider-pack/package.json",
});
for (const key of allowed)
expect(environment[key]).toBe(credentialEnvironment[key]);

View File

@ -1111,7 +1111,10 @@ function resolveBuildOwnedCliArtifact(
);
}
function acpxProviderPackageRoot(sidecarScript: string): string {
function acpxProviderPackageAuthority(sidecarScript: string): {
root: string;
manifest: string;
} {
const cliDirectory = dirname(sidecarScript);
if (
basename(sidecarScript) !== "acpx-runtime-sidecar.cjs" ||
@ -1126,8 +1129,14 @@ function acpxProviderPackageRoot(sidecarScript: string): string {
// A local source build consumes pnpm's workspace-owned node_modules tree.
// A deployed provider pack owns a closed node_modules tree at its own root.
return sidecarPackageRoot === packageRoot
? resolve(packageRoot, "../..")
: sidecarPackageRoot;
? {
root: resolve(packageRoot, "../.."),
manifest: resolve(packageRoot, "package.json"),
}
: {
root: sidecarPackageRoot,
manifest: resolve(sidecarPackageRoot, "package.json"),
};
}
function acpxRunnerLaunchProfile(
@ -1342,6 +1351,8 @@ export function createCapabilityRunnerdProviderEnvironment(input: {
input.acpxSidecarPath ??
input.options.acpxSidecarPath ??
resolve(packageRoot, "dist", "cli", "acpx-runtime-sidecar.cjs");
const providerPackageAuthority =
acpxProviderPackageAuthority(sidecarPath);
return {
...createSanitizedAcpxSpawnInput(
input.options.environment,
@ -1352,7 +1363,9 @@ export function createCapabilityRunnerdProviderEnvironment(input: {
// executes it through /proc/self/fd. Anchor its closed provider package
// lookups at the package that owns the already-authenticated bundle.
PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT:
acpxProviderPackageRoot(sidecarPath),
providerPackageAuthority.root,
PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST:
providerPackageAuthority.manifest,
...(input.options.providerRecoveryPolicy ===
"allow_replacement_after_governed_wait"
? {
@ -3413,7 +3426,7 @@ export const createRunnerdCodexTransport =
createCapabilityRunnerdCodexTransport;
export const runnerdLaunchProfileInternals = Object.freeze({
acpxProviderPackageRoot,
acpxProviderPackageAuthority,
acpxRunnerLaunchProfile,
resolveBuildOwnedCliArtifact,
});

View File

@ -32,6 +32,12 @@ const claudePatch = await readFile(
);
test("the runner pins every qualified ACPX production dependency", () => {
assert.equal(runnerPackage.dependencies.node, "24.11.0");
assert.equal(runnerPackage.dependencies["@openai/codex"], "0.148.0");
assert.equal(
runnerPackage.optionalDependencies["@openai/codex-linux-x64"],
"npm:@openai/codex@0.148.0-linux-x64",
);
assert.equal(runnerPackage.dependencies.acpx, "0.13.1");
assert.equal(
runnerPackage.dependencies["@agentclientprotocol/codex-acp"],

View File

@ -477,6 +477,9 @@ importers:
'@agentclientprotocol/codex-acp':
specifier: 1.6.2
version: 1.6.2(patch_hash=grbydorkxatbzksnwwfuawwtim)
'@openai/codex':
specifier: 0.148.0
version: 0.148.0
acpx:
specifier: 0.13.1
version: 0.13.1(patch_hash=lzpwjtiaybzoijy455dfycwavu)
@ -486,6 +489,9 @@ importers:
json-schema-to-ts:
specifier: ^3.1.1
version: 3.1.1
node:
specifier: 24.11.0
version: 24.11.0
opencode-ai:
specifier: 1.18.17
version: 1.18.17
@ -495,6 +501,10 @@ importers:
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
optionalDependencies:
'@openai/codex-linux-x64':
specifier: npm:@openai/codex@0.148.0-linux-x64
version: '@openai/codex@0.148.0-linux-x64'
devDependencies:
'@paperclipai/paperclip-eval-kernel':
specifier: workspace:*
@ -7088,6 +7098,9 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
node-bin-setup@1.1.4:
resolution: {integrity: sha512-vWNHOne0ZUavArqPP5LJta50+S8R261Fr5SvGul37HbEDcowvLjwdvd0ZeSr0r2lTSrPxl6okq9QUw8BFGiAxA==}
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
@ -7101,6 +7114,11 @@ packages:
resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==}
engines: {node: '>=18'}
node@24.11.0:
resolution: {integrity: sha512-pGLc7hd4xh0doDpnHC5+PL09WLUDKFoBFsC28341AYhHjzTDbNSwY+2615tYY6YFO2WQUKit4yqTwzIHaaityg==}
engines: {npm: '>=5.0.0'}
hasBin: true
npm-run-path@4.0.1:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
@ -14577,6 +14595,8 @@ snapshots:
negotiator@1.0.0: {}
node-bin-setup@1.1.4: {}
node-domexception@1.0.0: {}
node-fetch@3.3.2:
@ -14587,6 +14607,10 @@ snapshots:
node-releases@2.0.53: {}
node@24.11.0:
dependencies:
node-bin-setup: 1.1.4
npm-run-path@4.0.1:
dependencies:
path-key: 3.1.1