mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
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:
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"');
|
||||
|
||||
@@ -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"',
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user