From d085fba50c503e81de81d475b720f976585c2ee3 Mon Sep 17 00:00:00 2001 From: milind-soni Date: Mon, 17 Aug 2026 15:59:22 +0530 Subject: [PATCH] Detect signed-out Codex installations --- server/drivers/codex.test.ts | 44 +++++++++++++++++++++++ server/drivers/codex.ts | 47 +++++++++++++++++-------- server/testing/fake-codex-app-server.ts | 27 +++++++++++++- 3 files changed, 102 insertions(+), 16 deletions(-) diff --git a/server/drivers/codex.test.ts b/server/drivers/codex.test.ts index 2a7ee3b77..e34db05ae 100644 --- a/server/drivers/codex.test.ts +++ b/server/drivers/codex.test.ts @@ -107,6 +107,18 @@ describe("CodexDriver turns (fake app-server)", () => { expect(threadStart.params).toMatchObject({ model: "gpt-5.6-sol", modelProvider: "openai" }); }); + it("uses the instance environment for the Codex process", async () => { + const codexHome = join(scratch, "custom-codex-home"); + await create({ environment: { CODEX_HOME: codexHome } }); + const dump = join(scratch, "environment.json"); + process.env.FAKE_CODEX_DUMP = dump; + + await instance.adapter.sendTurn({ threadId: "t-environment", text: "hi" }); + await recorder.until((event) => event.type === "turn.completed"); + + expect(JSON.parse(readFileSync(dump, "utf8")).env.CODEX_HOME).toBe(codexHome); + }); + it("sends the local provider when the picker id is custom-encoded", async () => { await create({ environment: { UNSLOTH_STUDIO_AUTH_TOKEN: "unsloth-secret" } }); const dump = join(scratch, "dump.json"); @@ -224,6 +236,38 @@ describe("CodexDriver turns (fake app-server)", () => { expect(await instance.snapshot()).toMatchObject({ state: "unavailable" }); }); + it("reports whether the installed Codex CLI is signed in", async () => { + await create(); + await expect(instance.snapshot()).resolves.toMatchObject({ + state: "available", + authenticated: true, + }); + + await instance.dispose(); + recorder.stop(); + await create({ mode: "logged-out" }); + await expect(instance.snapshot()).resolves.toMatchObject({ + state: "available", + authenticated: false, + }); + }); + + it("marks a Codex 401 as setup so the UI offers sign-in instead of Retry", async () => { + await create({ mode: "unauthorized" }); + await instance.adapter.sendTurn({ threadId: "t-unauthorized", text: "hi" }); + + const error = await recorder.until((event) => event.type === "runtime.error"); + expect(error).toMatchObject({ setup: true }); + await expect(recorder.until((event) => event.type === "turn.completed")).resolves.toMatchObject({ + ok: false, + stopReason: "auth_required", + }); + }); + + it("uses the explicit login command from the official Codex flow", () => { + expect(CodexDriver.install?.signInCommand).toBe("codex login"); + }); + it("declares the effort levels the app-server accepts", async () => { await create(); expect(instance.adapter.capabilities.effortLevels).toEqual([ diff --git a/server/drivers/codex.ts b/server/drivers/codex.ts index 5135640c9..8c72b0f8b 100644 --- a/server/drivers/codex.ts +++ b/server/drivers/codex.ts @@ -60,7 +60,7 @@ export const CodexDriver: ProviderDriver = { }, needsNode: true, docsUrl: "https://github.com/openai/codex", - signInCommand: "codex", + signInCommand: "codex login", }, models: STATIC_CODEX_MODELS, decodeConfig, @@ -68,7 +68,19 @@ export const CodexDriver: ProviderDriver = { async create(input: DriverCreateInput): Promise { const { instanceId, config } = input; - const catalogEnv: Record = { ...process.env, ...input.environment }; + const childEnv = (): Record => { + const env: Record = { + ...process.env, + ...input.environment, + PATH: augmentedPath(), + NPM_CONFIG_LOGLEVEL: "error", + }; + // The CLI owns its own ChatGPT login; a leaked API key silently flips + // billing to pay-as-you-go (agentcal). + delete env.OPENAI_API_KEY; + return env; + }; + const catalogEnv = childEnv(); let models = STATIC_CODEX_MODELS; const refreshModels = async () => { try { @@ -103,15 +115,7 @@ export const CodexDriver: ProviderDriver = { if (active.has(threadId)) throw new Error("a turn is already running on this thread"); const turnId = newId(); - const env: Record = { - ...process.env, - ...input.environment, - PATH: augmentedPath(), - NPM_CONFIG_LOGLEVEL: "error", - }; - // the CLI owns its own ChatGPT login; a leaked API key silently flips - // billing to pay-as-you-go (agentcal) - delete env.OPENAI_API_KEY; + const env = childEnv(); const child = spawnCli(config.cli, ["app-server", ...codexLocalProviderArgs(env, turn.model)], { cwd: turn.cwd ?? homedir(), @@ -414,8 +418,15 @@ export const CodexDriver: ProviderDriver = { }); } catch (e) { if (!state.settled) { - emit({ ...base(threadId, turnId), type: "runtime.error", message: (e as Error).message }); - settle(false, "rpc_error"); + const message = e instanceof Error ? e.message : String(e); + const needsAuth = /(?:\b401\b|unauthorized|missing bearer|authentication required)/i.test(message); + emit({ + ...base(threadId, turnId), + type: "runtime.error", + message, + ...(needsAuth ? { setup: true } : {}), + }); + settle(false, needsAuth ? "auth_required" : "rpc_error"); } } })(); @@ -424,13 +435,19 @@ export const CodexDriver: ProviderDriver = { }; const snapshot = async (): Promise => { + const env = childEnv(); const version = await new Promise((resolve) => { - execCli(config.cli, ["--version"], { timeout: 8000, env: { ...process.env, PATH: augmentedPath() } }, (err, stdout) => + execCli(config.cli, ["--version"], { timeout: 8000, env }, (err, stdout) => resolve(err ? null : stdout.trim()), ); }); if (!version) return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` }; - return { state: "available", version }; + const authenticated = await new Promise((resolve) => { + execCli(config.cli, ["login", "status"], { timeout: 8000, env }, (err, stdout) => + resolve(!err && /logged in/i.test(stdout)), + ); + }); + return { state: "available", version, authenticated }; }; return { diff --git a/server/testing/fake-codex-app-server.ts b/server/testing/fake-codex-app-server.ts index 678069197..2af12bad3 100755 --- a/server/testing/fake-codex-app-server.ts +++ b/server/testing/fake-codex-app-server.ts @@ -4,13 +4,27 @@ // initialize/thread/turn handshake, then plays a scripted turn. Like the // real app-server, it never exits on its own — the driver kills it. // -// FAKE_CODEX_MODE happy (default) | approval | resume | stream +// FAKE_CODEX_MODE happy (default) | approval | resume | stream | +// logged-out | unauthorized // FAKE_CODEX_DUMP path to write {argv, env, calls, decision} as JSON // // Keep this file dependency-free — it runs as a bare `node` subprocess. import { writeFileSync } from "node:fs"; const mode = process.env.FAKE_CODEX_MODE ?? "happy"; + +if (process.argv[2] === "--version") { + process.stdout.write("codex-cli 0.146.0\n"); + process.exit(0); +} +if (process.argv[2] === "login" && process.argv[3] === "status") { + if (mode === "logged-out") { + process.stderr.write("Not logged in\n"); + process.exit(1); + } + process.stdout.write("Logged in using ChatGPT\n"); + process.exit(0); +} const calls: Array<{ method: string; params: unknown }> = []; let decision: unknown = null; @@ -78,6 +92,17 @@ process.stdin.on("data", (chunk) => { out({ jsonrpc: "2.0", id: msg.id, result: { thread: { id: "codex-thread-1" }, model: "fake-codex-model" } }); break; case "turn/start": + if (mode === "unauthorized") { + out({ + jsonrpc: "2.0", + id: msg.id, + error: { + code: -32603, + message: "unexpected status 401 Unauthorized: Missing bearer or basic authentication in header", + }, + }); + break; + } out({ jsonrpc: "2.0", id: msg.id, result: { ok: true } }); notify("item/started", { item: { id: "i1", type: "commandExecution", command: "ls -la" } }); if (mode === "approval") {