diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index 1ff260b7ef..068e60cf11 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -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 | diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index a32730eb75..3cab35c7ec 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -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, diff --git a/packages/paperclip-runner/src/cli/eval-provider-runtime.test.ts b/packages/paperclip-runner/src/cli/eval-provider-runtime.test.ts index 5116264a92..f6cf321bc6 100644 --- a/packages/paperclip-runner/src/cli/eval-provider-runtime.test.ts +++ b/packages/paperclip-runner/src/cli/eval-provider-runtime.test.ts @@ -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); diff --git a/packages/paperclip-runner/src/cli/eval-provider-runtime.ts b/packages/paperclip-runner/src/cli/eval-provider-runtime.ts index b55ba3f6d3..f4eb6d9a8a 100644 --- a/packages/paperclip-runner/src/cli/eval-provider-runtime.ts +++ b/packages/paperclip-runner/src/cli/eval-provider-runtime.ts @@ -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() }; } diff --git a/packages/paperclip-runner/src/drivers/codex/codex-command.ts b/packages/paperclip-runner/src/drivers/codex/codex-command.ts index de1df67db8..21a81cdf7b 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-command.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-command.ts @@ -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; }; - 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; diff --git a/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts index ec04203ecd..0de741bfbf 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts @@ -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, [ diff --git a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts index 6a3a787432..d02e0cfb4b 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts @@ -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; diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index 9e95ce1df8..8b498a1093 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -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"; diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index 16d97d96a0..0174c0505c 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -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 | 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; }); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 1def2638f9..78ad971aeb 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -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" diff --git a/server/src/services/local-ai-browser-login.test.ts b/server/src/services/local-ai-browser-login.test.ts index aaf021e329..192411cbd2 100644 --- a/server/src/services/local-ai-browser-login.test.ts +++ b/server/src/services/local-ai-browser-login.test.ts @@ -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 () => { diff --git a/server/src/services/local-ai-browser-login.ts b/server/src/services/local-ai-browser-login.ts index 10505588da..df7d5fe2b0 100644 --- a/server/src/services/local-ai-browser-login.ts +++ b/server/src/services/local-ai-browser-login.ts @@ -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"); } } diff --git a/server/src/vendor/paperclip-runner/index.ts b/server/src/vendor/paperclip-runner/index.ts index 588ba19893..735f73a0a4 100644 --- a/server/src/vendor/paperclip-runner/index.ts +++ b/server/src/vendor/paperclip-runner/index.ts @@ -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 =