mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 13:38:01 +02:00
fix: allow usable installed Codex versions
Prefer installed Codex without a numeric version gate and use a selected host CLI when the dependency is absent. Preserve executable validation, narrow sandbox resource grants, and explicit or recorded execution choices. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
6baec5181f
commit
591b656a33
13 files changed
+195
-82
No files matched your search
@@ -51,7 +51,7 @@ Actual npm install testing exposed a collision with the unchanged legacy Codex
|
||||
adapter. Move platform dependency declarations from the bundled wrapper metadata
|
||||
to the published server manifest, preserving the wrapper code and patched bridge.
|
||||
The existing installer checks must prove both native and legacy version selection,
|
||||
and the runtime must still verify the selected executable's version and digest.
|
||||
and artifact qualification must still verify the selected executable's version and digest.
|
||||
Direct/source runtimes retain their own dependency declarations. A CI credential
|
||||
test fixture also reserves its ports before making its unchanged denial assertion.
|
||||
Installed-consumer verification also checks the native vendor sandbox read roots
|
||||
@@ -66,6 +66,23 @@ payload in Paperclip tarballs, host-only npm resolution, pinned execution, and
|
||||
unchanged integrity checks. Record the final revision and hosted results in the
|
||||
canonical PR description before handoff.
|
||||
|
||||
### Codex CLI compatibility, 2026-10-08
|
||||
|
||||
The user requested tolerance for usable older Codex installations. Ordinary native
|
||||
Codex startup, direct evals, and browser login now prefer the installed dependency
|
||||
without requiring its version to equal the release pin. When the dependency is
|
||||
absent, resolve an executable from the selected host's PATH. Explicit commands and
|
||||
recorded sessions retain precedence; remote runs never borrow a controller CLI.
|
||||
Actual protocol/login failures remain setup/run errors. Release pins and the
|
||||
separate ACPX artifact-integrity qualification remain unchanged.
|
||||
|
||||
The normalized npm resource lookup accepts version differences while retaining
|
||||
package identities, actual resolver bindings, host compatibility, canonical paths,
|
||||
and narrow vendor read grants. Existing tests cover older/newer metadata, PATH
|
||||
fallback, installed preference, credential isolation, unsafe manifests and paths,
|
||||
and session continuity. Reopen current-head CI and fresh review for this refinement;
|
||||
record its tested revision and exact evidence in the PR before handoff.
|
||||
|
||||
## Evidence and gates
|
||||
|
||||
| Gate | Status | Evidence |
|
||||
|
||||
@@ -58,12 +58,19 @@ checks installed dependency pins and agreement with Cursor's distribution manife
|
||||
and immutable release attestation (`cursor-contract.json`). Cursor's per-platform
|
||||
closure pins are generated from `cursor-distributions.json`.
|
||||
|
||||
Packaged Codex startup and browser login resolve the Codex CLI from the installed
|
||||
ACP bridge dependency graph. They verify the qualified version and the executable
|
||||
path before launch. An explicit execution command still takes precedence. A
|
||||
missing or mismatched installed runtime returns an installation error; it does not
|
||||
borrow a different Codex installation from `PATH`. Linux ARM64 retains its
|
||||
existing legacy login path because native execution is not qualified there.
|
||||
Codex startup and browser login prefer the CLI from the installed dependency
|
||||
graph. If that dependency is absent, they use an executable `codex` from the
|
||||
selected execution host's `PATH`. An explicit execution command takes precedence,
|
||||
and resumed sessions retain their recorded command. Older or newer CLI versions
|
||||
are allowed; compatibility is established by the actual protocol or login attempt.
|
||||
Missing executables and real protocol failures still return actionable errors.
|
||||
Package identity, executable containment, and isolated credential homes remain
|
||||
checked. Linux ARM64 retains its existing legacy login path because native
|
||||
execution is not qualified there.
|
||||
|
||||
Exact dependency pins remain release and ACPX artifact-qualification checks.
|
||||
They make published builds reproducible and verify sandbox artifacts; they do not
|
||||
add a version-number gate to ordinary native Codex startup or browser login.
|
||||
|
||||
Release packages retain the pinned Codex JavaScript dependency graph, but do not
|
||||
bundle Codex native binaries. The published manifest declares the official,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { evalProviderTransportOptions } from "./eval-provider-runtime.js";
|
||||
|
||||
describe("direct eval provider runtime", () => {
|
||||
it("uses the installed pinned Codex dependency without requiring a global CLI", () => {
|
||||
it("uses the installed Codex dependency without requiring a global CLI", () => {
|
||||
const options = evalProviderTransportOptions("codex");
|
||||
expect(isAbsolute(options.codexCommand!)).toBe(true);
|
||||
expect(existsSync(options.codexCommand!)).toBe(true);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { resolvePinnedCodexCommand } from "../drivers/codex/codex-command.js";
|
||||
import { resolveCodexCommand } from "../drivers/codex/codex-command.js";
|
||||
|
||||
import type { CapabilityRunnerdCodexTransportOptions } from "../live/runnerd-codex-transport.js";
|
||||
|
||||
@@ -20,5 +20,5 @@ export function evalProviderTransportOptions(
|
||||
return { acpxPermissionMode: "approve-all", acpxPermissionModePinned: true };
|
||||
}
|
||||
if (provider !== "codex") return {};
|
||||
return { codexCommand: resolvePinnedCodexCommand() };
|
||||
return { codexCommand: resolveCodexCommand() };
|
||||
}
|
||||
@@ -1,46 +1,73 @@
|
||||
import { accessSync, constants, readFileSync, realpathSync, statSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
||||
import { QUALIFIED_ACPX_PROFILES } from "../acpx/qualified-profiles.js";
|
||||
import { delimiter, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
||||
|
||||
/** Resolve the already-installed qualified CLI, without borrowing an ambient command. */
|
||||
export function resolvePinnedCodexCommand(issuer: string | URL = import.meta.url): string {
|
||||
/** Prefer the installed dependency; compatibility is established by the CLI protocol. */
|
||||
export function resolveCodexCommand(issuer: string | URL = import.meta.url, environment: NodeJS.ProcessEnv = process.env): string {
|
||||
try {
|
||||
const runnerRequire = createRequire(issuer);
|
||||
let bridgeManifest: string;
|
||||
let manifestPath: string;
|
||||
try {
|
||||
bridgeManifest = runnerRequire.resolve("@agentclientprotocol/codex-acp/package.json");
|
||||
let bridgeManifest: string;
|
||||
try {
|
||||
bridgeManifest = runnerRequire.resolve("@agentclientprotocol/codex-acp/package.json");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "MODULE_NOT_FOUND") throw error;
|
||||
// pnpm can retain the bridge inside its declared adapter dependency.
|
||||
const adapter = runnerRequire.resolve("@paperclipai/adapter-codex-local/server");
|
||||
bridgeManifest = createRequire(adapter).resolve("@agentclientprotocol/codex-acp/package.json");
|
||||
}
|
||||
manifestPath = createRequire(bridgeManifest).resolve("@openai/codex/package.json");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "MODULE_NOT_FOUND") throw error;
|
||||
// A public server vendors runner code, while pnpm may keep the bridge
|
||||
// inside its declared adapter dependency rather than hoisting it.
|
||||
const adapter = runnerRequire.resolve("@paperclipai/adapter-codex-local/server");
|
||||
bridgeManifest = createRequire(adapter).resolve("@agentclientprotocol/codex-acp/package.json");
|
||||
try {
|
||||
manifestPath = runnerRequire.resolve("@openai/codex/package.json");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "MODULE_NOT_FOUND") throw error;
|
||||
return resolveCodexOnPath(environment);
|
||||
}
|
||||
}
|
||||
const manifestPath = realpathSync(createRequire(bridgeManifest).resolve("@openai/codex/package.json"));
|
||||
manifestPath = realpathSync(manifestPath);
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as {
|
||||
name?: unknown; version?: unknown; bin?: string | Record<string, unknown>;
|
||||
};
|
||||
const expected = QUALIFIED_ACPX_PROFILES.codex.agentRuntimeVersion;
|
||||
if (manifest.name !== "@openai/codex" || manifest.version !== expected) {
|
||||
throw new Error(`Codex package version mismatch: expected @openai/codex@${expected}, received ${String(manifest.name)}@${String(manifest.version)}`);
|
||||
}
|
||||
if (manifest.name !== "@openai/codex") throw new Error("Installed Codex dependency has an unexpected package identity");
|
||||
const bin = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.codex;
|
||||
if (typeof bin !== "string" || !bin || bin.includes("\0") || isAbsolute(bin)) {
|
||||
throw new Error("Pinned Codex dependency does not expose a contained executable");
|
||||
throw new Error("Installed Codex dependency does not expose a contained executable");
|
||||
}
|
||||
const root = dirname(manifestPath), candidate = resolve(root, bin);
|
||||
const inside = (path: string) => {
|
||||
const value = relative(root, path);
|
||||
return value !== "" && value !== ".." && !value.startsWith(`..${sep}`) && !isAbsolute(value);
|
||||
};
|
||||
if (!inside(candidate)) throw new Error("Pinned Codex executable escapes its package");
|
||||
if (!inside(candidate)) throw new Error("Installed Codex executable escapes its package");
|
||||
const executable = realpathSync(candidate);
|
||||
if (!inside(executable)) throw new Error("Pinned Codex executable escapes its package");
|
||||
if (!statSync(executable).isFile()) throw new Error("Pinned Codex executable is not a regular file");
|
||||
if (!inside(executable)) throw new Error("Installed Codex executable escapes its package");
|
||||
if (!statSync(executable).isFile()) throw new Error("Installed Codex executable is not a regular file");
|
||||
accessSync(executable, constants.X_OK);
|
||||
return executable;
|
||||
} catch (error) {
|
||||
throw new Error(`Pinned Codex runtime unavailable: ${(error as Error).message}. Reinstall Paperclip's qualified runtime dependencies.`, { cause: error });
|
||||
throw new Error(`Codex runtime unavailable: ${(error as Error).message}. Install Codex or restore Paperclip's runtime dependencies.`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCodexOnPath(environment: NodeJS.ProcessEnv): string {
|
||||
const names = process.platform === "win32"
|
||||
? ["codex", ...(environment.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean).map(extension => `codex${extension.toLowerCase()}`)]
|
||||
: ["codex"];
|
||||
for (const directory of (environment.PATH ?? "").split(delimiter).filter(isAbsolute)) {
|
||||
for (const name of names) {
|
||||
try {
|
||||
const executable = realpathSync(resolve(directory, name));
|
||||
if (!statSync(executable).isFile()) continue;
|
||||
accessSync(executable, constants.X_OK);
|
||||
return executable;
|
||||
} catch { /* Continue looking for an executable in the selected host PATH. */ }
|
||||
}
|
||||
}
|
||||
throw new Error("No installed Codex dependency or executable on PATH");
|
||||
}
|
||||
|
||||
// Compatibility export: the release pin is not a runtime version gate.
|
||||
export const resolvePinnedCodexCommand = resolveCodexCommand;
|
||||
@@ -21,19 +21,24 @@ describe("Codex security configuration", () => {
|
||||
encoding: "utf8", timeout: 30_000, maxBuffer: 16 * 1024 }).trim()).toBe("codex-cli 0.160.0");
|
||||
});
|
||||
|
||||
it("resolves an isolated public server dependency graph and rejects missing, mismatched or escaped commands", () => {
|
||||
it("resolves usable installed Codex commands and an absent-package PATH fallback while rejecting malformed or escaped dependencies", () => {
|
||||
// Vitest adds its dependency directories to global module lookup paths.
|
||||
// Use the existing Node/tsx boundary so an absent fixture dependency cannot
|
||||
// be supplied by the test runner's installed bridge instead.
|
||||
const source = `
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { resolvePinnedCodexCommand } from ${JSON.stringify(new URL("./codex-command.ts", import.meta.url).href)};
|
||||
import { resolveCodexCommand, resolvePinnedCodexCommand } from ${JSON.stringify(new URL("./codex-command.ts", import.meta.url).href)};
|
||||
import { codexExecutableReadOnlyRoots, createIsolatedCodexAppServerArgs } from ${JSON.stringify(new URL("./codex-security-config.ts", import.meta.url).href)};
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), "paperclip-pinned-codex-test-")));
|
||||
const pathOnlyRoot = realpathSync(mkdtempSync(join(tmpdir(), "paperclip-path-codex-test-")));
|
||||
const pathDirectory = join(pathOnlyRoot, "bin");
|
||||
const pathCommand = join(pathDirectory, "codex");
|
||||
const commandEnvironment = { PATH: pathDirectory };
|
||||
const issuer = join(root, "node_modules/@paperclipai/server/dist/vendor/paperclip-runner/drivers/codex/codex-command.js");
|
||||
const adapter = join(root, "node_modules/@paperclipai/adapter-codex-local");
|
||||
const bridge = join(adapter, "node_modules/@agentclientprotocol/codex-acp");
|
||||
@@ -44,6 +49,22 @@ describe("Codex security configuration", () => {
|
||||
const metadata = { name: "@openai/codex", version: "0.160.0", bin: { codex: "bin/codex.js" },
|
||||
optionalDependencies: { [platformName]: "npm:@openai/codex@0.160.0-" + process.platform + "-" + process.arch } };
|
||||
try {
|
||||
mkdirSync(pathDirectory);
|
||||
writeFileSync(pathCommand, "#!" + process.execPath + "\\nif (process.argv.slice(2).join(' ') !== '--version') process.exit(9); console.log('codex-cli 0.141.0');\\n", { mode: 0o755 });
|
||||
const pathOnlyIssuer = join(pathOnlyRoot, "standalone-command.js");
|
||||
assert.equal(resolveCodexCommand(pathOnlyIssuer, commandEnvironment), pathCommand, "Only absent dependency graphs use the selected PATH command");
|
||||
assert.equal(execFileSync(resolveCodexCommand(pathOnlyIssuer, commandEnvironment), ["--version"], {
|
||||
env: commandEnvironment, encoding: "utf8", timeout: 5_000 }).trim(), "codex-cli 0.141.0");
|
||||
const directCodex = join(pathOnlyRoot, "node_modules/@openai/codex");
|
||||
const directCommand = join(directCodex, "bin/codex.js");
|
||||
mkdirSync(dirname(directCommand), { recursive: true });
|
||||
writeFileSync(join(directCodex, "package.json"), JSON.stringify({ ...metadata, version: "0.155.0" }));
|
||||
writeFileSync(directCommand, "#!" + process.execPath + "\\n", { mode: 0o755 });
|
||||
assert.equal(resolveCodexCommand(pathOnlyIssuer, commandEnvironment), directCommand, "A directly installed older wrapper takes precedence over PATH");
|
||||
writeFileSync(join(directCodex, "package.json"), JSON.stringify({ ...metadata, name: "unowned-wrapper" }));
|
||||
assert.throws(() => resolveCodexCommand(pathOnlyIssuer, commandEnvironment), /unexpected package identity/);
|
||||
writeFileSync(join(directCodex, "package.json"), "{");
|
||||
assert.throws(() => resolveCodexCommand(pathOnlyIssuer, commandEnvironment), /runtime unavailable/);
|
||||
mkdirSync(dirname(issuer), { recursive: true });
|
||||
mkdirSync(join(codex, "bin"), { recursive: true });
|
||||
writeFileSync(join(adapter, "package.json"), JSON.stringify({ name: "@paperclipai/adapter-codex-local", exports: { "./server": "./server.js" } }));
|
||||
@@ -55,30 +76,47 @@ describe("Codex security configuration", () => {
|
||||
os: [process.platform], cpu: [process.arch] };
|
||||
writeFileSync(join(platform, "package.json"), JSON.stringify(platformMetadata));
|
||||
writeFileSync(executable, "#!" + process.execPath + "\\nif (process.argv.slice(2).join(' ') !== '--version') process.exit(9); console.log('codex-cli 0.160.0');\\n", { mode: 0o755 });
|
||||
assert.equal(resolvePinnedCodexCommand(issuer), executable);
|
||||
assert.equal(resolveCodexCommand(issuer, commandEnvironment), executable, "The selected installed bridge closure takes precedence over PATH");
|
||||
assert.equal(resolvePinnedCodexCommand(issuer, commandEnvironment), executable, "The published compatibility alias keeps ordinary command semantics");
|
||||
const roots = codexExecutableReadOnlyRoots({ PATH: "/missing-codex-command" }, executable);
|
||||
assert.ok(roots.includes(vendor), "The npm-hoisted native vendor directory remains readable");
|
||||
assert.ok(!roots.includes(platform) && !roots.includes(root), "Hoisting must not expose npm ancestry");
|
||||
assert.equal(execFileSync(resolvePinnedCodexCommand(issuer), ["--version"], { env: { PATH: "/missing-codex-command" },
|
||||
assert.equal(execFileSync(resolveCodexCommand(issuer, commandEnvironment), ["--version"], { env: { PATH: "/missing-codex-command" },
|
||||
encoding: "utf8", timeout: 5_000 }).trim(), "codex-cli 0.160.0");
|
||||
|
||||
for (const bin of ["../../escaped-codex", "/usr/bin/codex", {}, ""]) {
|
||||
writeFileSync(join(codex, "package.json"), JSON.stringify({ ...metadata, bin }));
|
||||
assert.throws(() => resolvePinnedCodexCommand(issuer), /contained executable|escapes its package/);
|
||||
assert.throws(() => resolveCodexCommand(issuer, commandEnvironment), /contained executable|escapes its package/);
|
||||
}
|
||||
writeFileSync(join(codex, "package.json"), JSON.stringify({ ...metadata, version: "0.159.0" }));
|
||||
assert.throws(() => resolvePinnedCodexCommand(issuer), /version mismatch.*0\\.160\\.0/);
|
||||
for (const version of ["0.159.0", "0.161.0"]) {
|
||||
writeFileSync(join(codex, "package.json"), JSON.stringify({ ...metadata, version }));
|
||||
assert.equal(resolveCodexCommand(issuer, commandEnvironment), executable, "Independently usable older and newer installed wrappers remain selectable");
|
||||
}
|
||||
writeFileSync(join(codex, "package.json"), JSON.stringify({ ...metadata, name: "unowned-wrapper" }));
|
||||
assert.throws(() => resolveCodexCommand(issuer, commandEnvironment), /unexpected package identity/);
|
||||
writeFileSync(join(codex, "package.json"), "{");
|
||||
assert.throws(() => resolveCodexCommand(issuer, commandEnvironment), /runtime unavailable/);
|
||||
writeFileSync(join(codex, "package.json"), JSON.stringify(metadata));
|
||||
chmodSync(executable, 0o600);
|
||||
assert.throws(() => resolvePinnedCodexCommand(issuer), /runtime unavailable/);
|
||||
assert.throws(() => resolveCodexCommand(issuer, commandEnvironment), /runtime unavailable/);
|
||||
rmSync(executable);
|
||||
writeFileSync(join(root, "external-codex"), "external executable", { mode: 0o755 });
|
||||
symlinkSync(join(root, "external-codex"), executable);
|
||||
assert.throws(() => resolvePinnedCodexCommand(issuer), /escapes its package/);
|
||||
assert.throws(() => resolveCodexCommand(issuer, commandEnvironment), /escapes its package/);
|
||||
rmSync(executable);
|
||||
assert.throws(() => resolvePinnedCodexCommand(issuer), /runtime unavailable/);
|
||||
assert.throws(() => resolveCodexCommand(issuer, commandEnvironment), /runtime unavailable/);
|
||||
rmSync(codex, { recursive: true });
|
||||
assert.throws(() => resolvePinnedCodexCommand(issuer), /runtime unavailable.*Reinstall/);
|
||||
// Use the existing fresh-process boundary: Node caches earlier module
|
||||
// paths, whereas a genuinely absent installed graph must use PATH.
|
||||
const resolverModule = ${JSON.stringify(new URL("./codex-command.ts", import.meta.url).href)};
|
||||
const freshAbsence = [
|
||||
'import assert from "node:assert/strict";',
|
||||
'import { resolveCodexCommand } from ' + JSON.stringify(resolverModule) + ';',
|
||||
'assert.equal(resolveCodexCommand(' + JSON.stringify(issuer) + ',' + JSON.stringify(commandEnvironment) + '),' + JSON.stringify(pathCommand) + ');',
|
||||
'assert.throws(() => resolveCodexCommand(' + JSON.stringify(issuer) + ', { PATH: "/missing-codex-command" }), /runtime unavailable.*No installed Codex dependency or executable on PATH/);',
|
||||
].join("\\n");
|
||||
execFileSync(process.execPath, ['--no-global-search-paths', '--import', createRequire(resolverModule).resolve('tsx'),
|
||||
'--input-type=module', '--eval', freshAbsence], { env: commandEnvironment, encoding: 'utf8', timeout: 5_000 });
|
||||
|
||||
// npm installs the host alias declared by the published server while
|
||||
// its bundled JS wrapper deliberately omits optionalDependencies.
|
||||
@@ -106,25 +144,46 @@ describe("Codex security configuration", () => {
|
||||
for (const ancestor of [platform, dirname(platform), join(root, "node_modules"), server, root]) {
|
||||
assert.ok(!normalizedRoots().includes(ancestor), "Platform discovery must not grant enclosing npm or server roots");
|
||||
}
|
||||
const restoreMetadata = () => {
|
||||
writeFileSync(serverManifest, JSON.stringify(serverMetadata));
|
||||
writeFileSync(publishedBridgeManifest, JSON.stringify(publishedBridgeMetadata));
|
||||
writeFileSync(normalizedManifest, JSON.stringify(normalizedMetadata));
|
||||
writeFileSync(join(platform, "package.json"), JSON.stringify(platformMetadata));
|
||||
};
|
||||
// Resource lookup protects package ownership and paths. Version
|
||||
// qualification belongs to ACPX, not ordinary Codex shell access.
|
||||
for (const version of ["0.159.0", "0.161.0"]) {
|
||||
for (const [path, changed] of [
|
||||
[serverManifest, { ...serverMetadata, optionalDependencies: { [platformName]: "npm:@openai/codex@" + version + "-" + process.platform + "-" + process.arch } }],
|
||||
[serverManifest, { ...serverMetadata, dependencies: { "@agentclientprotocol/codex-acp": version } }],
|
||||
[publishedBridgeManifest, { ...publishedBridgeMetadata, version }],
|
||||
[normalizedManifest, { ...normalizedMetadata, version }],
|
||||
[join(platform, "package.json"), { ...platformMetadata, version: version + "-" + process.platform + "-" + process.arch }],
|
||||
]) {
|
||||
writeFileSync(path, JSON.stringify(changed));
|
||||
assert.ok(normalizedRoots().includes(vendor), "Older or newer usable metadata must retain native resources independently of other version numbers");
|
||||
for (const ancestor of [platform, dirname(platform), server, root]) assert.ok(!normalizedRoots().includes(ancestor));
|
||||
restoreMetadata();
|
||||
}
|
||||
}
|
||||
for (const [path, changed] of [
|
||||
[serverManifest, { ...serverMetadata, optionalDependencies: {} }],
|
||||
[serverManifest, { ...serverMetadata, optionalDependencies: { [platformName]: "npm:@openai/codex@0.159.0-" + process.platform + "-" + process.arch } }],
|
||||
[serverManifest, { ...serverMetadata, dependencies: { "@agentclientprotocol/codex-acp": "1.5.0" } }],
|
||||
[publishedBridgeManifest, { ...publishedBridgeMetadata, version: "1.5.0" }],
|
||||
[serverManifest, { ...serverMetadata, name: "unowned-server" }],
|
||||
[serverManifest, { ...serverMetadata, optionalDependencies: { [platformName]: "npm:unowned/codex@0.160.0-" + process.platform + "-" + process.arch } }],
|
||||
[serverManifest, { ...serverMetadata, optionalDependencies: { [platformName]: "file:/unowned/native" } }],
|
||||
[serverManifest, { ...serverMetadata, optionalDependencies: { [platformName]: "npm:@openai/codex@0.160.0-unsupported-os-" + process.arch } }],
|
||||
[serverManifest, { ...serverMetadata, dependencies: {} }],
|
||||
[serverManifest, { ...serverMetadata, dependencies: { "@agentclientprotocol/codex-acp": "" } }],
|
||||
[publishedBridgeManifest, { ...publishedBridgeMetadata, name: "unqualified-bridge" }],
|
||||
[normalizedManifest, { ...normalizedMetadata, version: "0.159.0" }],
|
||||
[normalizedManifest, { ...normalizedMetadata, name: "unowned-wrapper" }],
|
||||
[normalizedManifest, { ...normalizedMetadata, optionalDependencies: {} }],
|
||||
[join(platform, "package.json"), { ...platformMetadata, name: "unqualified-native" }],
|
||||
[join(platform, "package.json"), { ...platformMetadata, version: "0.159.0-" + process.platform + "-" + process.arch }],
|
||||
[join(platform, "package.json"), { ...platformMetadata, os: ["unsupported-os"] }],
|
||||
[join(platform, "package.json"), { ...platformMetadata, cpu: ["unsupported-cpu"] }],
|
||||
]) {
|
||||
writeFileSync(path, JSON.stringify(changed));
|
||||
assert.ok(!normalizedRoots().includes(vendor), "Unqualified metadata must not grant native resources");
|
||||
writeFileSync(serverManifest, JSON.stringify(serverMetadata));
|
||||
writeFileSync(publishedBridgeManifest, JSON.stringify(publishedBridgeMetadata));
|
||||
writeFileSync(normalizedManifest, JSON.stringify(normalizedMetadata));
|
||||
writeFileSync(join(platform, "package.json"), JSON.stringify(platformMetadata));
|
||||
restoreMetadata();
|
||||
}
|
||||
const outsideVendor = join(root, "outside-vendor");
|
||||
mkdirSync(outsideVendor);
|
||||
@@ -140,7 +199,7 @@ describe("Codex security configuration", () => {
|
||||
rmSync(platform); mkdirSync(vendor, { recursive: true });
|
||||
writeFileSync(join(platform, "package.json"), JSON.stringify(platformMetadata));
|
||||
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
} finally { rmSync(root, { recursive: true, force: true }); rmSync(pathOnlyRoot, { recursive: true, force: true }); }
|
||||
process.stdout.write("PINNED_CODEX_ISOLATION_VERIFIED");
|
||||
`;
|
||||
expect(execFileSync(process.execPath, [
|
||||
|
||||
@@ -77,23 +77,26 @@ export function codexExecutableReadOnlyRoots(source: NodeJS.ProcessEnv, command
|
||||
function normalizedCodexPlatformManifest(runtimeManifest: string, platformPackage: string): string | undefined {
|
||||
const profile = QUALIFIED_ACPX_PROFILES.codex;
|
||||
const runtime = readCodexPackageManifest(runtimeManifest);
|
||||
if (runtime.name !== profile.agentRuntimePackage || runtime.version !== profile.agentRuntimeVersion
|
||||
|| runtime.optionalDependencies !== undefined) return undefined;
|
||||
const expectedVersion = `${profile.agentRuntimeVersion}-${process.platform}-${process.arch}`;
|
||||
const expectedDeclaration = `npm:${profile.agentRuntimePackage}@${expectedVersion}`;
|
||||
if (runtime.name !== profile.agentRuntimePackage || runtime.optionalDependencies !== undefined) return undefined;
|
||||
for (let root = dirname(dirname(runtimeManifest)), depth = 0; depth < 24; depth += 1) {
|
||||
const serverManifest = resolve(root, "package.json");
|
||||
if (existsSync(serverManifest)) {
|
||||
const server = readCodexPackageManifest(serverManifest);
|
||||
if (server.name === "@paperclipai/server") {
|
||||
if (server.dependencies?.[profile.agentServerPackage] !== profile.agentServerVersion
|
||||
|| server.optionalDependencies?.[platformPackage] !== expectedDeclaration
|
||||
const bridgeDeclaration = server.dependencies?.[profile.agentServerPackage];
|
||||
const platformDeclaration = server.optionalDependencies?.[platformPackage];
|
||||
const declarationPrefix = `npm:${profile.agentRuntimePackage}@`;
|
||||
const declarationSuffix = `-${process.platform}-${process.arch}`;
|
||||
if (typeof bridgeDeclaration !== "string" || !bridgeDeclaration.trim()
|
||||
|| typeof platformDeclaration !== "string" || !platformDeclaration.startsWith(declarationPrefix)
|
||||
|| !platformDeclaration.endsWith(declarationSuffix)
|
||||
|| !/^[0-9A-Za-z][0-9A-Za-z.+-]*$/.test(platformDeclaration.slice(declarationPrefix.length, -declarationSuffix.length))
|
||||
|| !codexPathInside(resolve(root, "node_modules"), runtimeManifest)) return undefined;
|
||||
const bridgeManifest = createRequire(serverManifest).resolve(`${profile.agentServerPackage}/package.json`);
|
||||
if (!codexPathInside(resolve(root, "node_modules"), bridgeManifest)
|
||||
|| realpathSync(bridgeManifest) !== bridgeManifest) return undefined;
|
||||
const bridge = readCodexPackageManifest(bridgeManifest);
|
||||
if (bridge.name !== profile.agentServerPackage || bridge.version !== profile.agentServerVersion
|
||||
if (bridge.name !== profile.agentServerPackage
|
||||
|| createRequire(bridgeManifest).resolve(`${profile.agentRuntimePackage}/package.json`) !== runtimeManifest) return undefined;
|
||||
const runtimeSelection = createRequire(runtimeManifest).resolve(`${platformPackage}/package.json`);
|
||||
const selected = createRequire(serverManifest).resolve(`${platformPackage}/package.json`);
|
||||
@@ -109,7 +112,6 @@ function normalizedCodexPlatformManifest(runtimeManifest: string, platformPackag
|
||||
if (!slot) return undefined;
|
||||
const native = readCodexPackageManifest(selected);
|
||||
if ((native.name !== platformPackage && native.name !== profile.agentRuntimePackage)
|
||||
|| native.version !== expectedVersion
|
||||
|| !Array.isArray(native.os) || !native.os.includes(process.platform)
|
||||
|| !Array.isArray(native.cpu) || !native.cpu.includes(process.arch)) return undefined;
|
||||
return selected;
|
||||
|
||||
@@ -45,7 +45,7 @@ export {
|
||||
export type { DurableRecoveryIdentity } from "./control-plane/prp-transport-types.js";
|
||||
export * from "./drivers/codex/app-server-transport.js";
|
||||
export * from "./drivers/codex/codex-app-server-driver.js";
|
||||
export { resolvePinnedCodexCommand } from "./drivers/codex/codex-command.js";
|
||||
export { resolveCodexCommand, resolvePinnedCodexCommand } from "./drivers/codex/codex-command.js";
|
||||
export * from "./drivers/opencode/opencode-server-driver.js";
|
||||
export * from "./drivers/opencode/mcp-bridge.js";
|
||||
export * from "./drivers/acpx/qualified-profiles.js";
|
||||
|
||||
@@ -1941,13 +1941,13 @@ it("denies the isolated Codex home without denying a remote execution workspace"
|
||||
expect(serialized).toContain('\":workspace_roots\"={\".\"=\"write\"}');
|
||||
});
|
||||
|
||||
it.each(["bundled", "explicit"] as const)("records the %s Codex command before launch without resolving an ambient CLI", async (selection) => {
|
||||
it.each(["installed", "explicit"] as const)("records the selected %s Codex command before launch", async (selection) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-codex-command-selection-"));
|
||||
const command = join(root, "codex");
|
||||
const runnerBinary = join(root, "runnerd");
|
||||
await writeFile(command, "#!/bin/sh\nexit 99\n", { mode: 0o700 });
|
||||
await writeFile(runnerBinary, "unexecuted fixture artifact", { mode: 0o700 });
|
||||
const resolver = vi.spyOn(codexCommandRuntime, "resolvePinnedCodexCommand").mockReturnValue(command);
|
||||
const resolver = vi.spyOn(codexCommandRuntime, "resolveCodexCommand").mockReturnValue(command);
|
||||
let template: Record<string, unknown> | null = null;
|
||||
const launch = vi.fn(() => { throw new Error("A provider process must not start in this fixture"); });
|
||||
const { transport } = createCapabilityRunnerdCodexTransport({
|
||||
@@ -1966,7 +1966,7 @@ it.each(["bundled", "explicit"] as const)("records the %s Codex command before l
|
||||
expect(template).toMatchObject({ provider: { command, model: "gpt-6.1-sol" } });
|
||||
expect(JSON.stringify(template)).toContain(`${JSON.stringify(command).replaceAll('"', '\\"')}=\\"read\\"`);
|
||||
if (selection === "explicit") expect(resolver).not.toHaveBeenCalled();
|
||||
else expect(resolver).toHaveBeenCalledOnce();
|
||||
else expect(resolver).toHaveBeenCalledExactlyOnceWith(undefined, { PATH: "/missing-ambient-codex" });
|
||||
expect(launch).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
resolver.mockRestore();
|
||||
@@ -1979,7 +1979,7 @@ it("rejects remote Codex without a guest executable before resolving controller
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-runner-remote-codex-command-"));
|
||||
const runnerBinary = join(root, "runnerd");
|
||||
await writeFile(runnerBinary, "unexecuted fixture artifact", { mode: 0o700 });
|
||||
const resolver = vi.spyOn(codexCommandRuntime, "resolvePinnedCodexCommand").mockImplementation(() => {
|
||||
const resolver = vi.spyOn(codexCommandRuntime, "resolveCodexCommand").mockImplementation(() => {
|
||||
throw new Error("Controller package resolution must not authorize a guest executable");
|
||||
});
|
||||
const { transport } = createCapabilityRunnerdCodexTransport({
|
||||
@@ -2013,7 +2013,7 @@ it.each(["missing", "changed"] as const)("reuses the recorded Codex command when
|
||||
await core.stop();
|
||||
await mkdir(join(root, "runner"), { mode: 0o700 });
|
||||
await writeFile(join(root, "runner", "runner-state.json"), JSON.stringify({ schema: "paperclip.runner.durable.state.v1", ...priorIdentity, lifecycle: "suspended" }), { mode: 0o600 });
|
||||
const resolver = vi.spyOn(codexCommandRuntime, "resolvePinnedCodexCommand").mockImplementation(() => {
|
||||
const resolver = vi.spyOn(codexCommandRuntime, "resolveCodexCommand").mockImplementation(() => {
|
||||
if (discovery === "missing") throw new Error("The current dependency is missing");
|
||||
return replacement;
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { isAcpxCanonicalInputMethod } from "../drivers/acpx/profile-extensions.j
|
||||
import { RunnerdTraceFrameIndex } from "./runnerd-trace-frame-index.js";
|
||||
import { waitForWarmAttachmentReadiness } from "./warm-attachment-readiness.js";
|
||||
import { codexExecutableReadOnlyRoots } from "../drivers/codex/codex-security-config.js";
|
||||
import { resolvePinnedCodexCommand } from "../drivers/codex/codex-command.js";
|
||||
import { resolveCodexCommand } from "../drivers/codex/codex-command.js";
|
||||
import { isCanonicalProviderEventType } from "../provider-events.js";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
@@ -4612,7 +4612,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
const providerNodeCommand =
|
||||
this.options.providerNodeCommand ?? process.execPath;
|
||||
const effectiveCodexCommand = provider === "codex"
|
||||
? this.options.codexCommand ?? resolvePinnedCodexCommand()
|
||||
? this.options.codexCommand ?? resolveCodexCommand(undefined, this.options.environment)
|
||||
: undefined;
|
||||
const opencodeExecutable =
|
||||
provider === "opencode"
|
||||
|
||||
@@ -3,10 +3,10 @@ import { mkdtemp, mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { startLocalBrowserLogin } from "./local-ai-browser-login.js";
|
||||
import { resolvePinnedCodexCommand } from "../vendor/paperclip-runner/index.js";
|
||||
import { resolveCodexCommand } from "../vendor/paperclip-runner/index.js";
|
||||
|
||||
vi.mock("../vendor/paperclip-runner/index.js", () => ({
|
||||
resolvePinnedCodexCommand: vi.fn(),
|
||||
resolveCodexCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
const initialPath = process.env.PATH;
|
||||
@@ -28,7 +28,7 @@ async function fakeCli(name: string, source: string) {
|
||||
const bin = path.join(root, "bin");
|
||||
await mkdir(bin, { recursive: true });
|
||||
await writeFile(path.join(bin, name), `#!/bin/sh\n${source}\n`, { mode: 0o700 });
|
||||
if (name === "codex") vi.mocked(resolvePinnedCodexCommand).mockReturnValue(path.join(bin, name));
|
||||
if (name === "codex") vi.mocked(resolveCodexCommand).mockReturnValue(path.join(bin, name));
|
||||
process.env.PATH = `${bin}${path.delimiter}${initialPath}`;
|
||||
const home = path.join(root, "credential-home");
|
||||
await mkdir(home, { mode: 0o700 });
|
||||
@@ -50,7 +50,7 @@ describe.skipIf(process.platform === "win32")("local browser subscription login"
|
||||
'[ -z "$OPENAI_API_KEY$CODEX_API_KEY$ANTHROPIC_API_KEY$ANTHROPIC_AUTH_TOKEN$CLAUDE_CODE_OAUTH_TOKEN" ] || exit 14',
|
||||
'printf "1. Open this link in your browser and sign in to your account\\nhttps://auth.openai.com/codex/device\\n2. Enter this one-time code (expires in 15 minutes)\\nABCD-EFGHJ\\n"',
|
||||
].join("\n"));
|
||||
if (legacy) vi.mocked(resolvePinnedCodexCommand).mockImplementation(() => { throw new Error("native artifact is unavailable"); });
|
||||
if (legacy) vi.mocked(resolveCodexCommand).mockImplementation(() => { throw new Error("native artifact is unavailable"); });
|
||||
else process.env.PATH = "/usr/bin:/bin";
|
||||
const ambientHome = path.join(root!, "unrelated-host-home");
|
||||
await mkdir(ambientHome, { mode: 0o700 });
|
||||
@@ -59,19 +59,19 @@ describe.skipIf(process.platform === "win32")("local browser subscription login"
|
||||
vi.stubEnv(key, "unrelated-host-value");
|
||||
const login = startLocalBrowserLogin("openai", home);
|
||||
await vi.waitFor(() => expect(login.outcome).toBe("success"), { timeout: 5000 });
|
||||
if (legacy) expect(resolvePinnedCodexCommand).not.toHaveBeenCalled();
|
||||
else expect(resolvePinnedCodexCommand).toHaveBeenCalledOnce();
|
||||
if (legacy) expect(resolveCodexCommand).not.toHaveBeenCalled();
|
||||
else expect(resolveCodexCommand).toHaveBeenCalledOnce();
|
||||
expect(login.code).toBe("ABCD-EFGHJ");
|
||||
});
|
||||
|
||||
it.each([["linux", "x64"], ["darwin", "arm64"], ["darwin", "x64"]] as const)("fails closed on missing qualified Codex artifacts on %s/%s", async (platform, architecture) => {
|
||||
it.each([["linux", "x64"], ["darwin", "arm64"], ["darwin", "x64"]] as const)("reports an unavailable Codex CLI on %s/%s without revealing private details", async (platform, architecture) => {
|
||||
targetPlatform(platform, architecture);
|
||||
const home = await fakeCli("codex", 'echo invoked > "$CODEX_HOME/unreviewed-cli"');
|
||||
const failure = new Error("private-installation-path-or-provider-output");
|
||||
vi.mocked(resolvePinnedCodexCommand).mockImplementation(() => { throw failure; });
|
||||
vi.mocked(resolveCodexCommand).mockImplementation(() => { throw failure; });
|
||||
const login = startLocalBrowserLogin("openai", home);
|
||||
await vi.waitFor(() => expect(login.outcome).toBe("failure"), { timeout: 5000 });
|
||||
expect(login.error).toMatch(/requires the qualified runtime.*Reinstall Paperclip/);
|
||||
expect(login.error).toMatch(/requires an installed Codex CLI.*Install Codex/);
|
||||
expect(JSON.stringify(login)).not.toContain(failure.message);
|
||||
await expect(readFile(path.join(home, "unreviewed-cli"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(login.authorizationUrl).toBeUndefined();
|
||||
@@ -85,7 +85,7 @@ describe.skipIf(process.platform === "win32")("local browser subscription login"
|
||||
await vi.waitFor(() => expect(login.outcome).toBe("failure"), { timeout: 5000 });
|
||||
expect(login.error).toMatch(/requires the Codex CLI.*Install Codex.*Paperclip's PATH/);
|
||||
expect(JSON.stringify(login)).not.toContain(process.env.PATH);
|
||||
expect(resolvePinnedCodexCommand).not.toHaveBeenCalled();
|
||||
expect(resolveCodexCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a missing terminal prerequisite without exposing spawn details", async () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import path from "node:path";
|
||||
import { runSetupTokenLogin } from "@paperclipai/adapter-claude-local/server";
|
||||
import { runDeviceLogin } from "@paperclipai/adapter-codex-local/server";
|
||||
import { ensureCommandResolvable, resolveCommandForLogs } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { resolvePinnedCodexCommand } from "../vendor/paperclip-runner/index.js";
|
||||
import { resolveCodexCommand } from "../vendor/paperclip-runner/index.js";
|
||||
|
||||
export type LocalBrowserLoginState = {
|
||||
authorizationUrl?: string;
|
||||
@@ -57,20 +57,20 @@ export function startLocalBrowserLogin(provider: "anthropic" | "openai", home: s
|
||||
let executable = "claude";
|
||||
if (provider === "openai") {
|
||||
// Linux ARM64 retains its supported legacy CLI path; it is not a
|
||||
// qualified native runner target. Missing qualified artifacts elsewhere
|
||||
// never authorize a fallback to a different installation.
|
||||
// qualified native runner target. Other hosts prefer the installed
|
||||
// dependency, then an existing CLI when the dependency is absent.
|
||||
const legacyPlatform = process.platform === "linux" && process.arch === "arm64";
|
||||
try {
|
||||
if (legacyPlatform) {
|
||||
await ensureCommandResolvable("codex", home, process.env);
|
||||
executable = path.resolve(home, await resolveCommandForLogs("codex", home, process.env));
|
||||
} else {
|
||||
executable = resolvePinnedCodexCommand();
|
||||
executable = resolveCodexCommand();
|
||||
}
|
||||
} catch {
|
||||
state.error = legacyPlatform
|
||||
? "Codex browser sign-in requires the Codex CLI on this platform. Install Codex on this execution host and ensure codex is on Paperclip's PATH, then start sign-in again."
|
||||
: "Codex browser sign-in requires the qualified runtime for this platform. Reinstall Paperclip's runtime dependencies, then start sign-in again.";
|
||||
: "Codex browser sign-in requires an installed Codex CLI. Install Codex on this execution host or restore Paperclip's runtime dependencies, then start sign-in again.";
|
||||
throw new Error("Browser login runtime unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -133,6 +133,7 @@ export const QUALIFIED_ACPX_PROFILES = runner.QUALIFIED_ACPX_PROFILES;
|
||||
export const QUALIFIED_ACPX_VERSION = runner.QUALIFIED_ACPX_VERSION;
|
||||
export const CURSOR_DISTRIBUTION_PINS = runner.CURSOR_DISTRIBUTION_PINS;
|
||||
export const resolveSourceCodexHome = runner.resolveSourceCodexHome;
|
||||
export const resolveCodexCommand = runner.resolveCodexCommand;
|
||||
export const resolvePinnedCodexCommand = runner.resolvePinnedCodexCommand;
|
||||
export const validatePrpEvent = runner.validatePrpEvent;
|
||||
export const validatePrpStructuredRunResult =
|
||||
|
||||
Reference in new issue
Block a user