diff --git a/packages/paperclip-runner/src/cli/eval-provider-runtime.ts b/packages/paperclip-runner/src/cli/eval-provider-runtime.ts index ff2c2f96d13..b55ba3f6d33 100644 --- a/packages/paperclip-runner/src/cli/eval-provider-runtime.ts +++ b/packages/paperclip-runner/src/cli/eval-provider-runtime.ts @@ -1,6 +1,4 @@ -import { readFileSync } from "node:fs"; -import { createRequire } from "node:module"; -import { dirname, resolve } from "node:path"; +import { resolvePinnedCodexCommand } from "../drivers/codex/codex-command.js"; import type { CapabilityRunnerdCodexTransportOptions } from "../live/runnerd-codex-transport.js"; @@ -22,15 +20,5 @@ export function evalProviderTransportOptions( return { acpxPermissionMode: "approve-all", acpxPermissionModePinned: true }; } if (provider !== "codex") return {}; - const runnerRequire = createRequire(import.meta.url); - const codexRequire = createRequire( - runnerRequire.resolve("@agentclientprotocol/codex-acp/package.json"), - ); - const manifestPath = codexRequire.resolve("@openai/codex/package.json"); - const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { - bin?: string | Record; - }; - const executable = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.codex; - if (!executable) throw new Error("Pinned Codex dependency does not expose its executable"); - return { codexCommand: resolve(dirname(manifestPath), executable) }; + return { codexCommand: resolvePinnedCodexCommand() }; } diff --git a/packages/paperclip-runner/src/drivers/codex/codex-command.ts b/packages/paperclip-runner/src/drivers/codex/codex-command.ts new file mode 100644 index 00000000000..de1df67db81 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/codex/codex-command.ts @@ -0,0 +1,46 @@ +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"; + +/** Resolve the already-installed qualified CLI, without borrowing an ambient command. */ +export function resolvePinnedCodexCommand(issuer: string | URL = import.meta.url): string { + try { + const runnerRequire = createRequire(issuer); + 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; + // 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"); + } + const manifestPath = realpathSync(createRequire(bridgeManifest).resolve("@openai/codex/package.json")); + 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)}`); + } + 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"); + } + 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"); + 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"); + 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 }); + } +} 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 acecaf2c838..34a5165cdf3 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 @@ -1,5 +1,7 @@ import { createRequire } from "node:module"; +import { execFileSync } from "node:child_process"; import { dirname, resolve } from "node:path"; +import { resolvePinnedCodexCommand } from "./codex-command.js"; import { evalProviderTransportOptions } from "../../cli/eval-provider-runtime.js"; import { describe, expect, it } from "vitest"; @@ -12,6 +14,70 @@ import { } from "./codex-security-config.js"; describe("Codex security configuration", () => { + it("uses the same actual pinned executable for native defaults and direct evals without a global Codex PATH", () => { + const command = resolvePinnedCodexCommand(); + expect(evalProviderTransportOptions("codex").codexCommand).toBe(command); + expect(execFileSync(command, ["--version"], { env: { PATH: dirname(process.execPath) }, + 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", () => { + // 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 { 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)}; + const root = realpathSync(mkdtempSync(join(tmpdir(), "paperclip-pinned-codex-test-"))); + 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"); + const codex = join(bridge, "node_modules/@openai/codex"); + const executable = join(codex, "bin/codex.js"); + const metadata = { name: "@openai/codex", version: "0.160.0", bin: { codex: "bin/codex.js" } }; + try { + 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" } })); + writeFileSync(join(adapter, "server.js"), ""); + writeFileSync(join(bridge, "package.json"), JSON.stringify({ name: "@agentclientprotocol/codex-acp", version: "1.6.2" })); + writeFileSync(join(codex, "package.json"), JSON.stringify(metadata)); + 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(execFileSync(resolvePinnedCodexCommand(issuer), ["--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/); + } + writeFileSync(join(codex, "package.json"), JSON.stringify({ ...metadata, version: "0.159.0" })); + assert.throws(() => resolvePinnedCodexCommand(issuer), /version mismatch.*0\\.160\\.0/); + writeFileSync(join(codex, "package.json"), JSON.stringify(metadata)); + chmodSync(executable, 0o600); + assert.throws(() => resolvePinnedCodexCommand(issuer), /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/); + rmSync(executable); + assert.throws(() => resolvePinnedCodexCommand(issuer), /runtime unavailable/); + rmSync(codex, { recursive: true }); + assert.throws(() => resolvePinnedCodexCommand(issuer), /runtime unavailable.*Reinstall/); + } finally { rmSync(root, { recursive: true, force: true }); } + process.stdout.write("PINNED_CODEX_ISOLATION_VERIFIED"); + `; + expect(execFileSync(process.execPath, [ + "--no-global-search-paths", "--import", createRequire(import.meta.url).resolve("tsx"), + "--input-type=module", "--eval", source, + ], { env: { ...process.env, NODE_PATH: "", NODE_OPTIONS: "" }, encoding: "utf8", timeout: 10_000, + maxBuffer: 32 * 1024 })).toBe("PINNED_CODEX_ISOLATION_VERIFIED"); + }); + it("allows only the registered private instruction directory while keeping shared context read-only", () => { const args = createIsolatedCodexAppServerArgs({ HOME: "/host/home" }, ["/runtime/immutable-context"], "/runtime/instruction-edits/run-1").join("\n"); expect(args).toContain('"/runtime/instruction-edits/run-1"="write"'); diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index 2641cf334c6..9e95ce1df84 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -45,6 +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 * 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 612991674ac..16d97d96a01 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -42,6 +42,7 @@ import { parsePaperclipQuestionSet } from "../contracts/question-set.js"; import { ACPX_CREDENTIAL_BINDING_ENV, createAcpxCredentialBinding, createAcpxSidecarHostEnvironment } from "../drivers/acpx/environment.js"; import { DurablePrpControlPlane } from "../control-plane/durable-prp-control-plane.js"; import * as durableControlPlane from "../control-plane/durable-prp-control-plane.js"; +import * as codexCommandRuntime from "../drivers/codex/codex-command.js"; import { NATIVE_RUNTIME_ASSET_SCHEMA, @@ -1940,6 +1941,108 @@ 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) => { + 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); + let template: Record | null = null; + const launch = vi.fn(() => { throw new Error("A provider process must not start in this fixture"); }); + const { transport } = createCapabilityRunnerdCodexTransport({ + provider: "codex", runnerBinary, stateDirectory: root, sourceCodexHome: "", + environment: { PATH: "/missing-ambient-codex" }, + ...(selection === "explicit" ? { codexCommand: command } : {}), + controlPlaneRegistration: async (authority) => { + template = structuredClone(authority.store.state.runAttachTemplate!); + throw new Error("fixture_stop_before_runner_launch"); + }, + runnerProcessLauncher: launch, + }); + try { + await expect(transport.request("thread/start", { cwd: root, model: "gpt-6.1-sol", dynamicTools: [] })) + .rejects.toThrow("fixture_stop_before_runner_launch"); + 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(); + expect(launch).not.toHaveBeenCalled(); + } finally { + resolver.mockRestore(); + await transport.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +it("rejects remote Codex without a guest executable before resolving controller dependencies", async () => { + 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(() => { + throw new Error("Controller package resolution must not authorize a guest executable"); + }); + const { transport } = createCapabilityRunnerdCodexTransport({ + provider: "codex", runnerBinary, stateDirectory: root, sourceCodexHome: "", + runnerFilesystemRoot: "/workspaces/task/.paperclip-runtime/session", + }); + try { + await expect(transport.request("thread/start", { cwd: "/workspaces/task", model: "gpt-6.1-sol", dynamicTools: [] })) + .rejects.toThrow("runner_remote_provider_artifact_incompatible: remote Codex omitted its qualified guest executable"); + expect(resolver).not.toHaveBeenCalled(); + } finally { + resolver.mockRestore(); + await transport.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +it.each(["missing", "changed"] as const)("reuses the recorded Codex command when dependency discovery is %s during resume", async (discovery) => { + const root = await mkdtemp(join(tmpdir(), "paperclip-codex-recorded-command-")); + const command = join(root, "recorded-codex"); + const replacement = join(root, "replacement-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 priorIdentity = { runnerInstanceId: "runner-command", environmentLeaseId: "lease-command", normalizedSessionId: "session-command", + runId: "run-prior", turnId: "turn-prior", itemId: "item-prior" }; + const nextIdentity = { ...priorIdentity, runId: "run-next", turnId: "turn-next", itemId: "item-next" }; + const core = new DurablePrpControlPlane({ stateDirectory: join(root, "control-plane"), identity: priorIdentity, + expectedRunnerVersion: "fixture", expectedRunnerDigest: "sha256:" + createHash("sha256").update(await readFile(runnerBinary)).digest("hex") }); + core.persistRunAttachTemplate({ provider: { kind: "codex", command, args: [], model: "gpt-6.1-sol" }, workspace: { cwd: root } }); + 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(() => { + if (discovery === "missing") throw new Error("The current dependency is missing"); + return replacement; + }); + let template: Record | null = null; + const launch = vi.fn(() => { throw new Error("A provider process must not start in this fixture"); }); + const { transport } = createCapabilityRunnerdCodexTransport({ + provider: "codex", runnerBinary, stateDirectory: root, sourceCodexHome: "", prpIdentity: nextIdentity, + environment: { PATH: "/missing-ambient-codex" }, + controlPlaneRegistration: async (authority) => { + template = structuredClone(authority.store.state.commands.find(command => command.type === "run.attach")?.payload ?? null); + throw new Error("fixture_stop_before_resumed_runner_launch"); + }, + runnerProcessLauncher: launch, + }); + try { + await expect(transport.request("thread/read", { threadId: "provider-prior" })) + .rejects.toThrow("fixture_stop_before_resumed_runner_launch"); + expect(template).toMatchObject({ provider: { command, model: "gpt-6.1-sol" } }); + expect(JSON.stringify(template)).toContain(`${JSON.stringify(command).replaceAll('"', '\\"')}=\\"read\\"`); + expect(JSON.stringify(template)).not.toContain(replacement); + expect(resolver).not.toHaveBeenCalled(); + expect(launch).not.toHaveBeenCalled(); + } finally { + resolver.mockRestore(); + await transport.close(); + await rm(root, { recursive: true, force: true }); + } +}); + it("rejects remote OpenCode before spawn when provider-pack paths are absent", async () => { const root = await mkdtemp(join(tmpdir(), "paperclip-runner-remote-pack-")); const { transport } = createCapabilityRunnerdCodexTransport({ diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index ba6f3f32487..1def2638f94 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -4,6 +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 { isCanonicalProviderEventType } from "../provider-events.js"; import { execFileSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; @@ -4558,6 +4559,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { } const provider = this.options.provider ?? "codex"; const sourceRuntimeContext = this.options.runtimeContext ?? null; + if (provider === "codex" && this.options.runnerFilesystemRoot && !this.options.codexCommand) { + throw new Error("runner_remote_provider_artifact_incompatible: remote Codex omitted its qualified guest executable; verify the selected environment's Codex artifacts"); + } const runtimeContext = this.options.runnerRuntimeContext ?? sourceRuntimeContext; const localRuntimeContextPath = resolve(this.#root, "runtime-context.json"); @@ -4607,6 +4611,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { )); const providerNodeCommand = this.options.providerNodeCommand ?? process.execPath; + const effectiveCodexCommand = provider === "codex" + ? this.options.codexCommand ?? resolvePinnedCodexCommand() + : undefined; const opencodeExecutable = provider === "opencode" ? (this.options.opencodeCommand ?? @@ -4811,7 +4818,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { command: provider === "opencode" ? providerNodeCommand - : (this.options.codexCommand ?? "codex"), + : effectiveCodexCommand!, args: provider === "opencode" ? [opencodeProxyPath] @@ -4819,7 +4826,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { createRunnerdCodexAppServerArgs({ environment: this.options.environment, codexHome, - codexCommand: this.options.codexCommand, + codexCommand: effectiveCodexCommand, instructionWorkingCopyRoot: runtimeContext?.instructions.workingCopy?.rootPath, readOnlyRoots: [ ...trustedRuntimeReadOnlyRoots( @@ -5297,12 +5304,13 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { if (provider === "codex") { // These controller-owned, token-free paths belong to the new run. // Keep the durable provider profile and thread identity unchanged. + const recordedCommand = record(runAttachTemplate.provider).command; runAttachTemplate.runtimeLaunchArgs = this.options.codexArgs ?? createRunnerdCodexAppServerArgs({ environment: this.options.environment, codexHome, - codexCommand: this.options.codexCommand, + codexCommand: typeof recordedCommand === "string" ? recordedCommand : this.options.codexCommand, instructionWorkingCopyRoot: runtimeContext?.instructions.workingCopy?.rootPath, readOnlyRoots: [ ...trustedRuntimeReadOnlyRoots(this.options.environment), diff --git a/server/src/services/local-ai-browser-login.test.ts b/server/src/services/local-ai-browser-login.test.ts index 410cc02d0ee..aaf021e3298 100644 --- a/server/src/services/local-ai-browser-login.test.ts +++ b/server/src/services/local-ai-browser-login.test.ts @@ -1,13 +1,24 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; 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"; + +vi.mock("../vendor/paperclip-runner/index.js", () => ({ + resolvePinnedCodexCommand: vi.fn(), +})); const initialPath = process.env.PATH; +const initialPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; +const initialArchitecture = Object.getOwnPropertyDescriptor(process, "arch")!; let root: string | undefined; afterEach(async () => { + Object.defineProperty(process, "platform", initialPlatform); + Object.defineProperty(process, "arch", initialArchitecture); process.env.PATH = initialPath; + vi.unstubAllEnvs(); + vi.clearAllMocks(); if (root) await rm(root, { recursive: true, force: true }); root = undefined; }); @@ -17,13 +28,83 @@ 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)); process.env.PATH = `${bin}${path.delimiter}${initialPath}`; const home = path.join(root, "credential-home"); await mkdir(home, { mode: 0o700 }); - return home; + return realpath(home); +} + +function targetPlatform(platform: string, architecture: string) { + Object.defineProperty(process, "platform", { ...initialPlatform, value: platform }); + Object.defineProperty(process, "arch", { ...initialArchitecture, value: architecture }); } describe.skipIf(process.platform === "win32")("local browser subscription login", () => { + it.each([false, true])("uses the selected Codex command with isolated credential homes (legacy Linux ARM64: %s)", async (legacy) => { + if (legacy) targetPlatform("linux", "arm64"); + const home = await fakeCli("codex", [ + '[ "$1 $2" = "login --device-auth" ] || exit 11', + '[ "$HOME" = "$CODEX_HOME" ] && [ "$HOME" = "$CLAUDE_CONFIG_DIR" ] || exit 12', + '[ "$PWD" = "$HOME" ] || exit 13', + '[ -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"); }); + else process.env.PATH = "/usr/bin:/bin"; + const ambientHome = path.join(root!, "unrelated-host-home"); + await mkdir(ambientHome, { mode: 0o700 }); + for (const key of ["HOME", "CODEX_HOME", "CLAUDE_CONFIG_DIR"]) vi.stubEnv(key, ambientHome); + for (const key of ["OPENAI_API_KEY", "CODEX_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"]) + 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(); + 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) => { + 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; }); + 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(JSON.stringify(login)).not.toContain(failure.message); + await expect(readFile(path.join(home, "unreviewed-cli"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(login.authorizationUrl).toBeUndefined(); + }); + + it("reports a missing Linux ARM64 Codex CLI without revealing the host PATH", async () => { + targetPlatform("linux", "arm64"); + const home = await fakeCli("codex", "exit 0"); + process.env.PATH = "/usr/bin:/bin"; + const login = startLocalBrowserLogin("openai", home); + 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(); + }); + + it("reports a missing terminal prerequisite without exposing spawn details", async () => { + const home = await fakeCli("codex", "exit 0"); + process.env.PATH = path.join(root!, "no-python"); + const login = startLocalBrowserLogin("openai", home); + await vi.waitFor(() => expect(login.outcome).toBe("failure"), { timeout: 5000 }); + expect(login.error).toMatch(/requires Python 3.*Install Python 3/); + expect(JSON.stringify(login)).not.toContain(process.env.PATH); + }); + + it("cancels before launching the resolved Codex command", async () => { + const home = await fakeCli("codex", 'echo invoked > "$CODEX_HOME/cancelled-cli"'); + const login = startLocalBrowserLogin("openai", home); + login.abort(); + await vi.waitFor(() => expect(login.outcome).toBe("failure"), { timeout: 5000 }); + await expect(readFile(path.join(home, "cancelled-cli"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("surfaces a Codex device link and code from a local PTY without a user shell command", async () => { const home = await fakeCli("codex", [ 'printf "1. Open this link in your browser and sign in to your account\\n"', diff --git a/server/src/services/local-ai-browser-login.ts b/server/src/services/local-ai-browser-login.ts index ad3ee6dfe7a..10505588dae 100644 --- a/server/src/services/local-ai-browser-login.ts +++ b/server/src/services/local-ai-browser-login.ts @@ -4,11 +4,14 @@ import { constants } from "node:fs"; 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"; export type LocalBrowserLoginState = { authorizationUrl?: string; code?: string; outcome?: "success" | "failure"; + error?: string; submitCode?: (code: string) => void; abort: () => void; }; @@ -47,24 +50,46 @@ sys.exit(os.waitstatus_to_exitcode(status)) export function startLocalBrowserLogin(provider: "anthropic" | "openai", home: string): LocalBrowserLoginState { const controller = new AbortController(); const state: LocalBrowserLoginState = { abort: () => controller.abort() }; - const executable = provider === "anthropic" ? "claude" : "codex"; const args = provider === "anthropic" ? ["setup-token"] : ["login", "--device-auth"]; let child: ChildProcessWithoutNullStreams | null = null; const driver = { - start(_command: string, onData: (chunk: string) => void): Promise<{ exitCode: number | null }> { + async start(_command: string, onData: (chunk: string) => void): Promise<{ exitCode: number | null }> { + 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. + 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(); + } + } 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."; + throw new Error("Browser login runtime unavailable"); + } + } + if (controller.signal.aborted) throw new Error("Local sign-in cancelled before launch"); const env = { ...process.env, ANTHROPIC_API_KEY: "", ANTHROPIC_AUTH_TOKEN: "", CLAUDE_CODE_OAUTH_TOKEN: "", OPENAI_API_KEY: "", CODEX_API_KEY: "", - CLAUDE_CONFIG_DIR: provider === "anthropic" ? home : process.env.CLAUDE_CONFIG_DIR, - CODEX_HOME: provider === "openai" ? home : process.env.CODEX_HOME, + HOME: home, CLAUDE_CONFIG_DIR: home, CODEX_HOME: home, BROWSER: "true", }; return new Promise((resolve, reject) => { - child = spawn("python3", ["-u", "-c", PYTHON_PTY_BRIDGE, executable, ...args], { env, stdio: ["pipe", "pipe", "pipe"] }); + child = spawn("python3", ["-u", "-c", PYTHON_PTY_BRIDGE, executable, ...args], { env, cwd: home, stdio: ["pipe", "pipe", "pipe"] }); child.stdout.on("data", (bytes: Buffer) => onData(bytes.toString("utf8"))); // Provider output may contain secrets. Never log or retain stderr. child.stderr.resume(); - child.once("error", reject); + child.once("error", () => { + state.error = "Browser sign-in requires Python 3 and a working local terminal. Install Python 3 on this execution host, then start sign-in again."; + reject(new Error("Browser login terminal unavailable")); + }); child.once("close", (exitCode) => resolve({ exitCode })); }); }, diff --git a/server/src/services/local-ai-login.ts b/server/src/services/local-ai-login.ts index 3492d47896a..71482abc4cb 100644 --- a/server/src/services/local-ai-login.ts +++ b/server/src/services/local-ai-login.ts @@ -171,7 +171,7 @@ export function localAiLoginService(db: Db) { ...(!login && intent.provider !== "xai" ? { error: "The server restarted during sign-in. Start sign-in again." } : login?.outcome === "failure" - ? { error: "Sign-in ended before the account was connected. Start sign-in again." } + ? { error: login.error ?? "Sign-in ended before the account was connected. Start sign-in again." } : {}) }; } } diff --git a/server/src/vendor/paperclip-runner/index.ts b/server/src/vendor/paperclip-runner/index.ts index bfab10f1e21..588ba19893e 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 resolvePinnedCodexCommand = runner.resolvePinnedCodexCommand; export const validatePrpEvent = runner.validatePrpEvent; export const validatePrpStructuredRunResult = runner.validatePrpStructuredRunResult;