fix: resolve installed Codex for startup and isolated login

Share qualified dependency resolution while preserving explicit and recorded session commands. Isolate login homes and report runtime or terminal prerequisites.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-08 07:02:49 -05:00
1 parent f8e2bee66e
commit 7bc9b64944
10 files changed
+345 -26

No files matched your search

@@ -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<string, string>;
};
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() };
}
@@ -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<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)}`);
}
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 });
}
}
@@ -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"');
+1
View File
@@ -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";
@@ -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<string, unknown> | 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<string, unknown> | 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({
@@ -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),
@@ -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"',
+31 -6
View File
@@ -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 }));
});
},
+1 -1
View File
@@ -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." }
: {}) };
}
}
+1
View File
@@ -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;