From f82a748be8ea70cd8861bd57cb9b453aa4261613 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 20 Mar 2026 04:35:03 +0800 Subject: [PATCH 01/10] add-manual-login-mode-for-headless-auth-flows --- docs/reference/commands.md | 2 + index.ts | 5 +- lib/auth/browser.ts | 16 +++++ lib/codex-manager.ts | 98 +++++++++++++++++++++++------ lib/ui/copy.ts | 1 + test/browser.test.ts | 18 +++++- test/codex-manager-cli.test.ts | 110 ++++++++++++++++++++++++++++++++- test/index.test.ts | 19 ++++++ 8 files changed, 245 insertions(+), 24 deletions(-) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 36c735f9..7f37b4c7 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -45,6 +45,7 @@ Compatibility aliases are supported: | Flag | Applies to | Meaning | | --- | --- | --- | +| `--manual`, `--no-browser` | login | Skip browser launch and use manual callback flow | | `--json` | verify-flagged, forecast, report, fix, doctor | Print machine-readable output | | `--live` | forecast, report, fix | Use live probe before decisions/output | | `--dry-run` | verify-flagged, fix, doctor | Preview without writing storage | @@ -60,6 +61,7 @@ Compatibility aliases are supported: - `codex` remains the primary wrapper entrypoint. It routes `codex auth ...` and the compatibility aliases to the multi-auth runtime, and forwards every other command to the official `@openai/codex` CLI. - In non-TTY or host-managed sessions, including `CODEX_TUI=1`, `CODEX_DESKTOP=1`, `TERM_PROGRAM=codex`, or `ELECTRON_RUN_AS_NODE=1`, auth flows degrade to deterministic text behavior. - The non-TTY fallback keeps `codex auth login` predictable: it defaults to add-account mode, skips the extra "add another account" prompt, and auto-picks the default workspace selection when a follow-up choice is needed. +- `codex auth login --manual` keeps the login flow usable in browser-restricted shells by printing the OAuth URL and accepting manual callback input instead of trying to open a browser. --- diff --git a/index.ts b/index.ts index 147959c4..553420c6 100644 --- a/index.ts +++ b/index.ts @@ -34,7 +34,7 @@ import { REDIRECT_URI, } from "./lib/auth/auth.js"; import { queuedRefresh } from "./lib/refresh-queue.js"; -import { openBrowserUrl } from "./lib/auth/browser.js"; +import { isBrowserLaunchSuppressed, openBrowserUrl } from "./lib/auth/browser.js"; import { startLocalOAuthServer } from "./lib/auth/server.js"; import { promptAddAnotherAccount, promptLoginMode } from "./lib/cli.js"; import { @@ -2386,9 +2386,10 @@ while (attempted.size < Math.max(1, accountCount)) { const accounts: TokenSuccessWithAccount[] = []; const noBrowser = + inputs?.manual === "true" || inputs?.noBrowser === "true" || inputs?.["no-browser"] === "true"; - const useManualMode = noBrowser; + const useManualMode = noBrowser || isBrowserLaunchSuppressed(); const explicitLoginMode = inputs?.loginMode === "fresh" || inputs?.loginMode === "add" ? inputs.loginMode diff --git a/lib/auth/browser.ts b/lib/auth/browser.ts index 65fde943..ec4488b7 100644 --- a/lib/auth/browser.ts +++ b/lib/auth/browser.ts @@ -8,6 +8,8 @@ import fs from "node:fs"; import path from "node:path"; import { PLATFORM_OPENERS } from "../constants.js"; +const BROWSER_DISABLED_VALUES = new Set(["0", "false", "no", "off", "none"]); + /** * Gets the platform-specific command to open a URL in the default browser * @returns Browser opener command for the current platform @@ -19,6 +21,16 @@ export function getBrowserOpener(): string { return PLATFORM_OPENERS.linux; } +export function isBrowserLaunchSuppressed(): boolean { + const explicitNoBrowser = (process.env.CODEX_AUTH_NO_BROWSER ?? "").trim().toLowerCase(); + if (explicitNoBrowser === "1" || BROWSER_DISABLED_VALUES.has(explicitNoBrowser)) { + return true; + } + + const browserSetting = (process.env.BROWSER ?? "").trim().toLowerCase(); + return BROWSER_DISABLED_VALUES.has(browserSetting); +} + /** * Determines whether a given command name exists on the system PATH. * @@ -92,6 +104,10 @@ function commandExists(command: string): boolean { */ export function openBrowserUrl(url: string): boolean { try { + if (isBrowserLaunchSuppressed()) { + return false; + } + // Windows: use PowerShell Start-Process to avoid cmd/start quirks with URLs containing '&' or ':' if (process.platform === "win32") { if (!commandExists("powershell.exe")) { diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 3df5b7f7..844a1317 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -9,7 +9,7 @@ import { REDIRECT_URI, } from "./auth/auth.js"; import { startLocalOAuthServer } from "./auth/server.js"; -import { copyTextToClipboard, openBrowserUrl } from "./auth/browser.js"; +import { copyTextToClipboard, isBrowserLaunchSuppressed, openBrowserUrl } from "./auth/browser.js"; import { promptAddAnotherAccount, promptLoginMode, type ExistingAccountInfo } from "./cli.js"; import { extractAccountEmail, @@ -291,7 +291,7 @@ function printUsage(): void { "Codex Multi-Auth CLI", "", "Usage:", - " codex auth login", + " codex auth login [--manual|--no-browser]", " codex auth list", " codex auth status", " codex auth switch ", @@ -311,6 +311,37 @@ function printUsage(): void { ); } +type AuthLoginOptions = { + manual: boolean; +}; + +type ParsedAuthLoginArgs = + | { ok: true; options: AuthLoginOptions } + | { ok: false; message: string }; + +function parseAuthLoginArgs(args: string[]): ParsedAuthLoginArgs { + const options: AuthLoginOptions = { + manual: false, + }; + + for (const arg of args) { + if (arg === "--manual" || arg === "--no-browser") { + options.manual = true; + continue; + } + if (arg === "--help" || arg === "-h") { + printUsage(); + return { ok: false, message: "" }; + } + return { + ok: false, + message: `Unknown login option: ${arg}`, + }; + } + + return { ok: true, options }; +} + interface ImplementedFeature { id: number; name: string; @@ -1095,16 +1126,22 @@ function applyTokenAccountIdentity( return true; } -async function promptManualCallback(state: string): Promise { - if (!input.isTTY || !output.isTTY) { +async function promptManualCallback( + state: string, + options: { allowNonTty?: boolean } = {}, +): Promise { + const useInteractivePrompt = input.isTTY && output.isTTY; + if (!useInteractivePrompt && !options.allowNonTty) { return null; } const rl = createInterface({ input, output }); try { - console.log(""); - console.log(stylePromptText(UI_COPY.oauth.pastePrompt, "accent")); - const answer = await rl.question("◆ "); + if (useInteractivePrompt) { + console.log(""); + console.log(stylePromptText(UI_COPY.oauth.pastePrompt, "accent")); + } + const answer = await rl.question(useInteractivePrompt ? "◆ " : ""); if (answer.includes("\u001b")) { return null; } @@ -1425,12 +1462,21 @@ async function runActionPanel( } } -async function runOAuthFlow(forceNewLogin: boolean): Promise { +async function runOAuthFlow( + forceNewLogin: boolean, + options: AuthLoginOptions = { manual: false }, +): Promise { const { pkce, state, url } = await createAuthorizationFlow({ forceNewLogin }); - const oauthServer = await startLocalOAuthServer({ state }); + const preferManualMode = options.manual || isBrowserLaunchSuppressed(); + let oauthServer: Awaited> | null = null; + try { + oauthServer = await startLocalOAuthServer({ state }); + } catch { + oauthServer = null; + } let code: string | null = null; try { - const signInMode = await promptOAuthSignInMode(); + const signInMode = preferManualMode ? "manual" : await promptOAuthSignInMode(); if (signInMode === "cancel") { return { type: "failed", @@ -1465,18 +1511,23 @@ async function runOAuthFlow(forceNewLogin: boolean): Promise { ); } - if (oauthServer.ready) { + if (oauthServer?.ready) { console.log(stylePromptText(UI_COPY.oauth.waitingCallback, "muted")); const callbackResult = await oauthServer.waitForCode(state); code = callbackResult?.code ?? null; } if (!code) { - console.log(stylePromptText(UI_COPY.oauth.callbackMissed, "warning")); - code = await promptManualCallback(state); + console.log( + stylePromptText( + oauthServer?.ready ? UI_COPY.oauth.callbackMissed : UI_COPY.oauth.callbackUnavailable, + "warning", + ), + ); + code = await promptManualCallback(state, { allowNonTty: preferManualMode }); } } finally { - oauthServer.close(); + oauthServer?.close(); } if (!code) { @@ -4099,7 +4150,7 @@ async function handleManageAction( const existing = storage.accounts[idx]; if (!existing) return; - const tokenResult = await runOAuthFlow(true); + const tokenResult = await runOAuthFlow(true, { manual: false }); if (tokenResult.type !== "success") { console.error(`Refresh failed: ${tokenResult.message ?? tokenResult.reason ?? "unknown error"}`); return; @@ -4112,7 +4163,18 @@ async function handleManageAction( } } -async function runAuthLogin(): Promise { +async function runAuthLogin(args: string[]): Promise { + const parsedArgs = parseAuthLoginArgs(args); + if (!parsedArgs.ok) { + if (parsedArgs.message) { + console.error(parsedArgs.message); + printUsage(); + return 1; + } + return 0; + } + + const loginOptions = parsedArgs.options; setStoragePath(null); let pendingMenuQuotaRefresh: Promise | null = null; let menuQuotaRefreshStatus: string | undefined; @@ -4231,7 +4293,7 @@ async function runAuthLogin(): Promise { const existingCount = refreshedStorage?.accounts.length ?? 0; let forceNewLogin = existingCount > 0; while (true) { - const tokenResult = await runOAuthFlow(forceNewLogin); + const tokenResult = await runOAuthFlow(forceNewLogin, loginOptions); if (tokenResult.type !== "success") { if (isUserCancelledOAuth(tokenResult)) { if (existingCount > 0) { @@ -4719,7 +4781,7 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { return 0; } if (command === "login") { - return runAuthLogin(); + return runAuthLogin(rest); } if (command === "list" || command === "status") { await showAccountStatus(); diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index 406e6a8a..4e95e2d1 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -42,6 +42,7 @@ export const UI_COPY = { browserOpened: "Browser opened.", browserOpenFail: "Could not open browser. Use this link:", waitingCallback: "Waiting for login callback on localhost:1455...", + callbackUnavailable: "Callback listener unavailable. Paste the callback URL manually.", callbackMissed: "No callback received. Paste manually.", cancelled: "Sign-in cancelled.", cancelledBackToMenu: "Sign-in cancelled. Going back to menu.", diff --git a/test/browser.test.ts b/test/browser.test.ts index a2e08377..96f76fed 100644 --- a/test/browser.test.ts +++ b/test/browser.test.ts @@ -1,7 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { spawn } from "node:child_process"; import fs from "node:fs"; -import { getBrowserOpener, openBrowserUrl, copyTextToClipboard } from "../lib/auth/browser.js"; +import { + getBrowserOpener, + isBrowserLaunchSuppressed, + openBrowserUrl, + copyTextToClipboard, +} from "../lib/auth/browser.js"; import { PLATFORM_OPENERS } from "../lib/constants.js"; vi.mock("node:child_process", () => ({ @@ -37,6 +42,7 @@ describe("auth browser utilities", () => { const originalPlatform = process.platform; const originalPath = process.env.PATH; const originalPathExt = process.env.PATHEXT; + const originalNoBrowser = process.env.CODEX_AUTH_NO_BROWSER; beforeEach(() => { vi.clearAllMocks(); @@ -53,6 +59,8 @@ describe("auth browser utilities", () => { else process.env.PATH = originalPath; if (originalPathExt === undefined) delete process.env.PATHEXT; else process.env.PATHEXT = originalPathExt; + if (originalNoBrowser === undefined) delete process.env.CODEX_AUTH_NO_BROWSER; + else process.env.CODEX_AUTH_NO_BROWSER = originalNoBrowser; }); it("returns platform opener command", () => { @@ -65,6 +73,14 @@ describe("auth browser utilities", () => { }); describe("openBrowserUrl", () => { + it("returns false when browser launch is suppressed by environment", () => { + process.env.CODEX_AUTH_NO_BROWSER = "1"; + + expect(isBrowserLaunchSuppressed()).toBe(true); + expect(openBrowserUrl("https://example.com")).toBe(false); + expect(mockedSpawn).not.toHaveBeenCalled(); + }); + it("returns false on win32 when powershell.exe is unavailable", () => { Object.defineProperty(process, "platform", { value: "win32" }); process.env.PATH = "C:\\missing"; diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index be2f116a..7864db24 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -41,11 +41,19 @@ vi.mock("../lib/logger.js", () => ({ vi.mock("../lib/auth/auth.js", () => ({ createAuthorizationFlow: vi.fn(), exchangeAuthorizationCode: vi.fn(), - parseAuthorizationInput: vi.fn(), + parseAuthorizationInput: vi.fn((input: string) => { + const codeMatch = input.match(/code=([^&]+)/); + const stateMatch = input.match(/state=([^&#]+)/); + return { + code: codeMatch?.[1], + state: stateMatch?.[1], + }; + }), REDIRECT_URI: "http://localhost:1455/auth/callback", })); vi.mock("../lib/auth/browser.js", () => ({ + isBrowserLaunchSuppressed: vi.fn(() => false), openBrowserUrl: vi.fn(), copyTextToClipboard: vi.fn(() => true), })); @@ -2945,8 +2953,104 @@ describe("codex manager cli commands", () => { expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); expect(storageState.accounts).toHaveLength(2); expect(storageState.activeIndex).toBe(1); - expect(storageState.activeIndexByFamily.codex).toBe(1); - expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(1); + expect(storageState.activeIndexByFamily.codex).toBe(1); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(1); +}); + + it("supports --manual login without launching a browser", async () => { + const now = Date.now(); + let storageState = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [] as Array>, + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + promptAddAnotherAccountMock.mockResolvedValue(false); + + const authModule = await import("../lib/auth/auth.js"); + vi.mocked(authModule.createAuthorizationFlow).mockResolvedValueOnce({ + pkce: { challenge: "pkce-challenge", verifier: "pkce-verifier" }, + state: "oauth-state", + url: "https://auth.openai.com/mock", + }); + vi.mocked(authModule.exchangeAuthorizationCode).mockResolvedValueOnce({ + type: "success", + access: "access-manual", + refresh: "refresh-manual", + expires: now + 7_200_000, + idToken: "id-token-manual", + multiAccount: true, + }); + + const browserModule = await import("../lib/auth/browser.js"); + const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); + const serverModule = await import("../lib/auth/server.js"); + vi.mocked(serverModule.startLocalOAuthServer).mockResolvedValueOnce({ + ready: true, + waitForCode: vi.fn(async () => ({ code: "oauth-code" })), + close: vi.fn(), + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login", "--manual"]); + + expect(exitCode).toBe(0); + expect(openBrowserUrlMock).not.toHaveBeenCalled(); + expect(storageState.accounts).toHaveLength(1); + }); + + it("accepts manual callback input in non-tty mode when --manual is set", async () => { + setInteractiveTTY(false); + const now = Date.now(); + let storageState = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [] as Array>, + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + promptQuestionMock.mockResolvedValueOnce( + "http://127.0.0.1:1455/auth/callback?code=oauth-code&state=oauth-state", + ); + + const authModule = await import("../lib/auth/auth.js"); + vi.mocked(authModule.createAuthorizationFlow).mockResolvedValueOnce({ + pkce: { challenge: "pkce-challenge", verifier: "pkce-verifier" }, + state: "oauth-state", + url: "https://auth.openai.com/mock", + }); + vi.mocked(authModule.exchangeAuthorizationCode).mockResolvedValueOnce({ + type: "success", + access: "access-stdin", + refresh: "refresh-stdin", + expires: now + 7_200_000, + idToken: "id-token-stdin", + multiAccount: true, + }); + + const browserModule = await import("../lib/auth/browser.js"); + const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); + const serverModule = await import("../lib/auth/server.js"); + vi.mocked(serverModule.startLocalOAuthServer).mockRejectedValueOnce( + new Error("port in use"), + ); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login", "--manual"]); + + expect(exitCode).toBe(0); + expect(promptQuestionMock).toHaveBeenCalledWith(""); + expect(openBrowserUrlMock).not.toHaveBeenCalled(); + expect(storageState.accounts).toHaveLength(1); }); it("preserves distinct same-email workspaces when oauth login reuses a refresh token", async () => { diff --git a/test/index.test.ts b/test/index.test.ts index 7810c942..d88c4696 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -53,6 +53,7 @@ vi.mock("../lib/refresh-queue.js", () => ({ })); vi.mock("../lib/auth/browser.js", () => ({ + isBrowserLaunchSuppressed: vi.fn(() => false), openBrowserUrl: vi.fn(), })); @@ -560,6 +561,24 @@ describe("OpenAIOAuthPlugin", () => { expect(plugin.auth.methods[1].label).toBe("ChatGPT Plus/Pro MULTI (Manual URL Paste)"); }); + it("uses manual auth flow when browser launch is globally suppressed", async () => { + const browserModule = await import("../lib/auth/browser.js"); + vi.mocked(browserModule.isBrowserLaunchSuppressed).mockReturnValueOnce(true); + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ + method: string; + instructions: string; + validate: (input: string) => string | undefined; + }>; + }; + + const flow = await autoMethod.authorize(); + + expect(flow.method).toBe("code"); + expect(flow.instructions).toContain("copy the full redirect URL"); + expect(flow.validate("invalid-callback-value")).toContain("No authorization code found"); + }); + it("rejects manual OAuth callbacks with mismatched state", async () => { const authModule = await import("../lib/auth/auth.js"); const manualMethod = plugin.auth.methods[1] as unknown as { From 3953d5d3f9fcb60e73c03c0f1fd164138f65be75 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 20 Mar 2026 05:14:32 +0800 Subject: [PATCH 02/10] fix(auth): skip callback wait in manual login mode --- lib/codex-manager.ts | 2 +- test/codex-manager-cli.test.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 844a1317..98387ae2 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -1511,7 +1511,7 @@ async function runOAuthFlow( ); } - if (oauthServer?.ready) { + if (!preferManualMode && oauthServer?.ready) { console.log(stylePromptText(UI_COPY.oauth.waitingCallback, "muted")); const callbackResult = await oauthServer.waitForCode(state); code = callbackResult?.code ?? null; diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 7864db24..d40714b7 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2990,17 +2990,22 @@ describe("codex manager cli commands", () => { const browserModule = await import("../lib/auth/browser.js"); const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); const serverModule = await import("../lib/auth/server.js"); + const waitForCodeMock = vi.fn(async () => ({ code: "oauth-code" })); vi.mocked(serverModule.startLocalOAuthServer).mockResolvedValueOnce({ ready: true, - waitForCode: vi.fn(async () => ({ code: "oauth-code" })), + waitForCode: waitForCodeMock, close: vi.fn(), }); + promptQuestionMock.mockResolvedValueOnce( + "http://127.0.0.1:1455/auth/callback?code=oauth-code&state=oauth-state", + ); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login", "--manual"]); expect(exitCode).toBe(0); expect(openBrowserUrlMock).not.toHaveBeenCalled(); + expect(waitForCodeMock).not.toHaveBeenCalled(); expect(storageState.accounts).toHaveLength(1); }); From 5972554d6ebec058cb583b570b583d8d9c166aa2 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 20 Mar 2026 05:22:56 +0800 Subject: [PATCH 03/10] fix(auth): honor explicit no-browser env toggles --- lib/auth/browser.ts | 5 ++-- test/browser.test.ts | 42 +++++++++++++++++++++++++++ test/codex-manager-cli.test.ts | 52 ++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/lib/auth/browser.ts b/lib/auth/browser.ts index ec4488b7..28402fc8 100644 --- a/lib/auth/browser.ts +++ b/lib/auth/browser.ts @@ -9,6 +9,7 @@ import path from "node:path"; import { PLATFORM_OPENERS } from "../constants.js"; const BROWSER_DISABLED_VALUES = new Set(["0", "false", "no", "off", "none"]); +const NO_BROWSER_TRUTHY_VALUES = new Set(["1", "true", "yes", "on"]); /** * Gets the platform-specific command to open a URL in the default browser @@ -23,8 +24,8 @@ export function getBrowserOpener(): string { export function isBrowserLaunchSuppressed(): boolean { const explicitNoBrowser = (process.env.CODEX_AUTH_NO_BROWSER ?? "").trim().toLowerCase(); - if (explicitNoBrowser === "1" || BROWSER_DISABLED_VALUES.has(explicitNoBrowser)) { - return true; + if (explicitNoBrowser.length > 0) { + return NO_BROWSER_TRUTHY_VALUES.has(explicitNoBrowser); } const browserSetting = (process.env.BROWSER ?? "").trim().toLowerCase(); diff --git a/test/browser.test.ts b/test/browser.test.ts index 96f76fed..886bf100 100644 --- a/test/browser.test.ts +++ b/test/browser.test.ts @@ -43,6 +43,7 @@ describe("auth browser utilities", () => { const originalPath = process.env.PATH; const originalPathExt = process.env.PATHEXT; const originalNoBrowser = process.env.CODEX_AUTH_NO_BROWSER; + const originalBrowser = process.env.BROWSER; beforeEach(() => { vi.clearAllMocks(); @@ -61,6 +62,8 @@ describe("auth browser utilities", () => { else process.env.PATHEXT = originalPathExt; if (originalNoBrowser === undefined) delete process.env.CODEX_AUTH_NO_BROWSER; else process.env.CODEX_AUTH_NO_BROWSER = originalNoBrowser; + if (originalBrowser === undefined) delete process.env.BROWSER; + else process.env.BROWSER = originalBrowser; }); it("returns platform opener command", () => { @@ -81,6 +84,45 @@ describe("auth browser utilities", () => { expect(mockedSpawn).not.toHaveBeenCalled(); }); + it("treats false-like CODEX_AUTH_NO_BROWSER values as opt-in browser launch", () => { + Object.defineProperty(process, "platform", { value: "darwin" }); + process.env.PATH = "/usr/bin"; + process.env.CODEX_AUTH_NO_BROWSER = "false"; + mockedExistsSync.mockImplementation( + (candidate) => typeof candidate === "string" && candidate.endsWith("open"), + ); + mockedStatSync.mockReturnValue({ + isFile: () => true, + mode: 0o755, + } as unknown as ReturnType); + + expect(isBrowserLaunchSuppressed()).toBe(false); + expect(openBrowserUrl("https://example.com")).toBe(true); + expect(mockedSpawn).toHaveBeenCalledWith( + "open", + ["https://example.com"], + { stdio: "ignore", shell: false }, + ); + }); + + it("lets explicit false-like CODEX_AUTH_NO_BROWSER override a disabling BROWSER value", () => { + process.env.CODEX_AUTH_NO_BROWSER = "0"; + process.env.BROWSER = "none"; + + expect(isBrowserLaunchSuppressed()).toBe(false); + expect(openBrowserUrl("https://example.com")).toBe(false); + expect(mockedSpawn).not.toHaveBeenCalled(); + }); + + it("keeps suppression enabled when CODEX_AUTH_NO_BROWSER is truthy even if BROWSER is also disabled", () => { + process.env.CODEX_AUTH_NO_BROWSER = "true"; + process.env.BROWSER = "none"; + + expect(isBrowserLaunchSuppressed()).toBe(true); + expect(openBrowserUrl("https://example.com")).toBe(false); + expect(mockedSpawn).not.toHaveBeenCalled(); + }); + it("returns false on win32 when powershell.exe is unavailable", () => { Object.defineProperty(process, "platform", { value: "win32" }); process.env.PATH = "C:\\missing"; diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index d40714b7..edfe3ec6 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2958,6 +2958,7 @@ describe("codex manager cli commands", () => { }); it("supports --manual login without launching a browser", async () => { + setInteractiveTTY(true); const now = Date.now(); let storageState = { version: 3 as const, @@ -3009,6 +3010,57 @@ describe("codex manager cli commands", () => { expect(storageState.accounts).toHaveLength(1); }); + it("falls back to pasted callback input when browser launch is suppressed", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [] as Array>, + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + promptAddAnotherAccountMock.mockResolvedValue(false); + promptQuestionMock.mockResolvedValueOnce( + "http://127.0.0.1:1455/auth/callback?code=oauth-code&state=oauth-state", + ); + + const authModule = await import("../lib/auth/auth.js"); + vi.mocked(authModule.createAuthorizationFlow).mockResolvedValueOnce({ + pkce: { challenge: "pkce-challenge", verifier: "pkce-verifier" }, + state: "oauth-state", + url: "https://auth.openai.com/mock", + }); + vi.mocked(authModule.exchangeAuthorizationCode).mockResolvedValueOnce({ + type: "success", + access: "access-suppressed", + refresh: "refresh-suppressed", + expires: now + 7_200_000, + idToken: "id-token-suppressed", + multiAccount: true, + }); + + const browserModule = await import("../lib/auth/browser.js"); + const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); + vi.mocked(browserModule.isBrowserLaunchSuppressed).mockReturnValueOnce(true); + const serverModule = await import("../lib/auth/server.js"); + const startLocalOAuthServerMock = vi.mocked(serverModule.startLocalOAuthServer); + startLocalOAuthServerMock.mockRejectedValueOnce(new Error("suppressed browser mode")); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptQuestionMock).toHaveBeenCalled(); + expect(openBrowserUrlMock).not.toHaveBeenCalled(); + expect(startLocalOAuthServerMock).toHaveBeenCalledTimes(1); + expect(storageState.accounts).toHaveLength(1); + }); + it("accepts manual callback input in non-tty mode when --manual is set", async () => { setInteractiveTTY(false); const now = Date.now(); From fb6d5fb8956927b7e7a0464cd7413900df0d5564 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 20 Mar 2026 05:47:08 +0800 Subject: [PATCH 04/10] fix(auth): log callback fallback causes and cover manual bind failures --- docs/reference/commands.md | 1 + lib/codex-manager.ts | 20 +++++++++++++- test/codex-manager-cli.test.ts | 49 ++++++++++++++++++++++++++++++++++ test/index.test.ts | 2 ++ 4 files changed, 71 insertions(+), 1 deletion(-) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 7f37b4c7..5d801770 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -62,6 +62,7 @@ Compatibility aliases are supported: - In non-TTY or host-managed sessions, including `CODEX_TUI=1`, `CODEX_DESKTOP=1`, `TERM_PROGRAM=codex`, or `ELECTRON_RUN_AS_NODE=1`, auth flows degrade to deterministic text behavior. - The non-TTY fallback keeps `codex auth login` predictable: it defaults to add-account mode, skips the extra "add another account" prompt, and auto-picks the default workspace selection when a follow-up choice is needed. - `codex auth login --manual` keeps the login flow usable in browser-restricted shells by printing the OAuth URL and accepting manual callback input instead of trying to open a browser. +- In non-TTY/manual shells, provide the full redirect URL on stdin, for example: `echo "http://127.0.0.1:1455/auth/callback?code=..." | codex auth login --manual`. --- diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 98387ae2..fb9745d6 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -37,6 +37,7 @@ import { summarizeForecast, type ForecastAccountResult, } from "./forecast.js"; +import { createLogger } from "./logger.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; import { fetchCodexQuotaSnapshot, @@ -87,6 +88,8 @@ type TokenSuccessWithAccount = TokenSuccess & { }; type PromptTone = "accent" | "success" | "warning" | "danger" | "muted"; +const log = createLogger("codex-manager"); + function stylePromptText(text: string, tone: PromptTone): string { if (!output.isTTY) return text; const ui = getUiRuntimeOptions(); @@ -1471,7 +1474,22 @@ async function runOAuthFlow( let oauthServer: Awaited> | null = null; try { oauthServer = await startLocalOAuthServer({ state }); - } catch { + } catch (serverError) { + log.warn( + "Local OAuth callback server unavailable; falling back to manual callback entry.", + serverError instanceof Error + ? { + message: serverError.message, + stack: serverError.stack, + code: + typeof serverError === "object" && + serverError !== null && + "code" in serverError + ? String(serverError.code) + : undefined, + } + : { error: String(serverError) }, + ); oauthServer = null; } let code: string | null = null; diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index edfe3ec6..3abb353a 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -3110,6 +3110,55 @@ describe("codex manager cli commands", () => { expect(storageState.accounts).toHaveLength(1); }); + it("falls back to pasted manual input when Windows-style callback bind fails", async () => { + setInteractiveTTY(false); + const now = Date.now(); + let storageState = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [] as Array>, + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + promptQuestionMock.mockResolvedValueOnce( + "http://127.0.0.1:1455/auth/callback?code=oauth-code&state=oauth-state", + ); + + const authModule = await import("../lib/auth/auth.js"); + vi.mocked(authModule.createAuthorizationFlow).mockResolvedValueOnce({ + pkce: { challenge: "pkce-challenge", verifier: "pkce-verifier" }, + state: "oauth-state", + url: "https://auth.openai.com/mock", + }); + vi.mocked(authModule.exchangeAuthorizationCode).mockResolvedValueOnce({ + type: "success", + access: "access-eacces", + refresh: "refresh-eacces", + expires: now + 7_200_000, + idToken: "id-token-eacces", + multiAccount: true, + }); + + const browserModule = await import("../lib/auth/browser.js"); + const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); + const serverModule = await import("../lib/auth/server.js"); + vi.mocked(serverModule.startLocalOAuthServer).mockRejectedValueOnce( + Object.assign(new Error("permission denied"), { code: "EACCES" }), + ); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login", "--manual"]); + + expect(exitCode).toBe(0); + expect(promptQuestionMock).toHaveBeenCalledWith(""); + expect(openBrowserUrlMock).not.toHaveBeenCalled(); + expect(storageState.accounts).toHaveLength(1); + }); + it("preserves distinct same-email workspaces when oauth login reuses a refresh token", async () => { const now = Date.now(); let storageState = { diff --git a/test/index.test.ts b/test/index.test.ts index d88c4696..94b289b0 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -564,6 +564,7 @@ describe("OpenAIOAuthPlugin", () => { it("uses manual auth flow when browser launch is globally suppressed", async () => { const browserModule = await import("../lib/auth/browser.js"); vi.mocked(browserModule.isBrowserLaunchSuppressed).mockReturnValueOnce(true); + const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); const autoMethod = plugin.auth.methods[0] as unknown as { authorize: (inputs?: Record) => Promise<{ method: string; @@ -577,6 +578,7 @@ describe("OpenAIOAuthPlugin", () => { expect(flow.method).toBe("code"); expect(flow.instructions).toContain("copy the full redirect URL"); expect(flow.validate("invalid-callback-value")).toContain("No authorization code found"); + expect(openBrowserUrlMock).not.toHaveBeenCalled(); }); it("rejects manual OAuth callbacks with mismatched state", async () => { From e3339726a746de87445c72b5c695a9326235d25c Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 20 Mar 2026 05:49:44 +0800 Subject: [PATCH 05/10] test(auth): cover browser suppression precedence --- test/browser.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/browser.test.ts b/test/browser.test.ts index 886bf100..836798f7 100644 --- a/test/browser.test.ts +++ b/test/browser.test.ts @@ -114,6 +114,23 @@ describe("auth browser utilities", () => { expect(mockedSpawn).not.toHaveBeenCalled(); }); + it("does not treat CODEX_AUTH_NO_BROWSER=false as suppression when BROWSER is disabled", () => { + process.env.CODEX_AUTH_NO_BROWSER = "false"; + process.env.BROWSER = "none"; + + expect(isBrowserLaunchSuppressed()).toBe(false); + expect(openBrowserUrl("https://example.com")).toBe(false); + expect(mockedSpawn).not.toHaveBeenCalled(); + }); + + it("suppresses browser launch when BROWSER is set to none", () => { + process.env.BROWSER = "none"; + + expect(isBrowserLaunchSuppressed()).toBe(true); + expect(openBrowserUrl("https://example.com")).toBe(false); + expect(mockedSpawn).not.toHaveBeenCalled(); + }); + it("keeps suppression enabled when CODEX_AUTH_NO_BROWSER is truthy even if BROWSER is also disabled", () => { process.env.CODEX_AUTH_NO_BROWSER = "true"; process.env.BROWSER = "none"; From c122ed08da3f0adda6891ade943ca220e5d276ca Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 20 Mar 2026 13:38:28 +0800 Subject: [PATCH 06/10] fix: clarify manual callback fallback messaging --- lib/codex-manager.ts | 7 +++-- test/codex-manager-cli.test.ts | 54 ++++++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index fb9745d6..8dcbd86f 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -1529,7 +1529,8 @@ async function runOAuthFlow( ); } - if (!preferManualMode && oauthServer?.ready) { + const waitingForCallback = !preferManualMode && oauthServer?.ready === true; + if (waitingForCallback && oauthServer) { console.log(stylePromptText(UI_COPY.oauth.waitingCallback, "muted")); const callbackResult = await oauthServer.waitForCode(state); code = callbackResult?.code ?? null; @@ -1538,7 +1539,9 @@ async function runOAuthFlow( if (!code) { console.log( stylePromptText( - oauthServer?.ready ? UI_COPY.oauth.callbackMissed : UI_COPY.oauth.callbackUnavailable, + waitingForCallback + ? UI_COPY.oauth.callbackMissed + : UI_COPY.oauth.callbackUnavailable, "warning", ), ); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 3abb353a..52090078 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2953,9 +2953,9 @@ describe("codex manager cli commands", () => { expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); expect(storageState.accounts).toHaveLength(2); expect(storageState.activeIndex).toBe(1); - expect(storageState.activeIndexByFamily.codex).toBe(1); - expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(1); -}); + expect(storageState.activeIndexByFamily.codex).toBe(1); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(1); + }); it("supports --manual login without launching a browser", async () => { setInteractiveTTY(true); @@ -3000,13 +3000,19 @@ describe("codex manager cli commands", () => { promptQuestionMock.mockResolvedValueOnce( "http://127.0.0.1:1455/auth/callback?code=oauth-code&state=oauth-state", ); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login", "--manual"]); + const renderedLogs = logSpy.mock.calls.flat().map((entry) => String(entry)); expect(exitCode).toBe(0); expect(openBrowserUrlMock).not.toHaveBeenCalled(); expect(waitForCodeMock).not.toHaveBeenCalled(); + expect(renderedLogs.some((entry) => entry.includes("Callback listener unavailable"))).toBe( + true, + ); + expect(renderedLogs.some((entry) => entry.includes("No callback received"))).toBe(false); expect(storageState.accounts).toHaveLength(1); }); @@ -3110,6 +3116,48 @@ describe("codex manager cli commands", () => { expect(storageState.accounts).toHaveLength(1); }); + it("rejects mismatched manual callback state in non-tty mode without persisting login", async () => { + setInteractiveTTY(false); + let storageState = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [] as Array>, + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + promptQuestionMock.mockResolvedValueOnce( + "http://127.0.0.1:1455/auth/callback?code=oauth-code&state=wrong-state", + ); + + const authModule = await import("../lib/auth/auth.js"); + vi.mocked(authModule.createAuthorizationFlow).mockResolvedValueOnce({ + pkce: { challenge: "pkce-challenge", verifier: "pkce-verifier" }, + state: "oauth-state", + url: "https://auth.openai.com/mock", + }); + const exchangeAuthorizationCodeMock = vi.mocked(authModule.exchangeAuthorizationCode); + + const browserModule = await import("../lib/auth/browser.js"); + const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); + const serverModule = await import("../lib/auth/server.js"); + vi.mocked(serverModule.startLocalOAuthServer).mockRejectedValueOnce( + new Error("port in use"), + ); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login", "--manual"]); + + expect(exitCode).toBe(0); + expect(promptQuestionMock).toHaveBeenCalledWith(""); + expect(openBrowserUrlMock).not.toHaveBeenCalled(); + expect(exchangeAuthorizationCodeMock).not.toHaveBeenCalled(); + expect(storageState.accounts).toHaveLength(0); + }); + it("falls back to pasted manual input when Windows-style callback bind fails", async () => { setInteractiveTTY(false); const now = Date.now(); From 8b5e6bc257fb7a5089481732f65239951ed7b2ea Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 20 Mar 2026 13:48:51 +0800 Subject: [PATCH 07/10] fix: respect interactive manual auth selection --- lib/codex-manager.ts | 2 +- test/codex-manager-cli.test.ts | 61 ++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 8dcbd86f..8c406db0 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -1529,7 +1529,7 @@ async function runOAuthFlow( ); } - const waitingForCallback = !preferManualMode && oauthServer?.ready === true; + const waitingForCallback = signInMode === "browser" && oauthServer?.ready === true; if (waitingForCallback && oauthServer) { console.log(stylePromptText(UI_COPY.oauth.waitingCallback, "muted")); const callbackResult = await oauthServer.waitForCode(state); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 52090078..96f3efcc 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -3016,6 +3016,67 @@ describe("codex manager cli commands", () => { expect(storageState.accounts).toHaveLength(1); }); + it("supports interactive manual login selection without waiting for a callback", async () => { + setInteractiveTTY(true); + const now = Date.now(); + let storageState = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [] as Array>, + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + promptAddAnotherAccountMock.mockResolvedValue(false); + selectMock.mockResolvedValueOnce("manual"); + + const authModule = await import("../lib/auth/auth.js"); + vi.mocked(authModule.createAuthorizationFlow).mockResolvedValueOnce({ + pkce: { challenge: "pkce-challenge", verifier: "pkce-verifier" }, + state: "oauth-state", + url: "https://auth.openai.com/mock", + }); + vi.mocked(authModule.exchangeAuthorizationCode).mockResolvedValueOnce({ + type: "success", + access: "access-manual-choice", + refresh: "refresh-manual-choice", + expires: now + 7_200_000, + idToken: "id-token-manual-choice", + multiAccount: true, + }); + + const browserModule = await import("../lib/auth/browser.js"); + const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); + const serverModule = await import("../lib/auth/server.js"); + const waitForCodeMock = vi.fn(async () => ({ code: "oauth-code" })); + vi.mocked(serverModule.startLocalOAuthServer).mockResolvedValueOnce({ + ready: true, + waitForCode: waitForCodeMock, + close: vi.fn(), + }); + promptQuestionMock.mockResolvedValueOnce( + "http://127.0.0.1:1455/auth/callback?code=oauth-code&state=oauth-state", + ); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + const renderedLogs = logSpy.mock.calls.flat().map((entry) => String(entry)); + + expect(exitCode).toBe(0); + expect(selectMock).toHaveBeenCalled(); + expect(openBrowserUrlMock).not.toHaveBeenCalled(); + expect(waitForCodeMock).not.toHaveBeenCalled(); + expect(renderedLogs.some((entry) => entry.includes("Callback listener unavailable"))).toBe( + true, + ); + expect(renderedLogs.some((entry) => entry.includes("No callback received"))).toBe(false); + expect(storageState.accounts).toHaveLength(1); + }); + it("falls back to pasted callback input when browser launch is suppressed", async () => { setInteractiveTTY(true); const now = Date.now(); From 3ef32f1cbadb660b0b2281d7ba782e12c2d6846c Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 20 Mar 2026 14:11:56 +0800 Subject: [PATCH 08/10] fix(auth): clarify manual callback flow --- lib/codex-manager.ts | 109 +++++++++++++++++++++++++-------- lib/ui/copy.ts | 1 + test/browser.test.ts | 20 ++++-- test/codex-manager-cli.test.ts | 49 ++++++++++++++- 4 files changed, 147 insertions(+), 32 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 8c406db0..b033680b 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -1144,7 +1144,50 @@ async function promptManualCallback( console.log(""); console.log(stylePromptText(UI_COPY.oauth.pastePrompt, "accent")); } - const answer = await rl.question(useInteractivePrompt ? "◆ " : ""); + const answer = useInteractivePrompt + ? await rl.question("◆ ") + : await new Promise((resolve, reject) => { + if (input.readableEnded || input.destroyed) { + resolve(null); + return; + } + let settled = false; + const handleInputClosed = () => { + if (settled) return; + settled = true; + input.off("end", handleInputClosed); + input.off("close", handleInputClosed); + resolve(null); + }; + const finish = (value: string) => { + if (settled) return; + settled = true; + input.off("end", handleInputClosed); + input.off("close", handleInputClosed); + resolve(value); + }; + const fail = (error: unknown) => { + if (settled) return; + settled = true; + input.off("end", handleInputClosed); + input.off("close", handleInputClosed); + reject(error); + }; + rl.question("") + .then((value) => finish(value)) + .catch((error) => { + if (isAbortError(error) || isReadlineClosedError(error)) { + handleInputClosed(); + return; + } + fail(error); + }); + input.once("end", handleInputClosed); + input.once("close", handleInputClosed); + }); + if (answer === null) { + return null; + } if (answer.includes("\u001b")) { return null; } @@ -1163,7 +1206,7 @@ async function promptManualCallback( if (parsed.state && parsed.state !== state) return null; return parsed.code; } catch (error) { - if (isAbortError(error)) { + if (isAbortError(error) || isReadlineClosedError(error)) { return null; } throw error; @@ -1172,6 +1215,17 @@ async function promptManualCallback( } } +function isReadlineClosedError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const errorCode = + typeof error === "object" && error !== null && "code" in error + ? String((error as { code?: unknown }).code) + : ""; + return errorCode === "ERR_USE_AFTER_CLOSE" || /readline was closed/i.test(error.message); +} + type OAuthSignInMode = "browser" | "manual" | "cancel"; async function promptOAuthSignInMode(): Promise { @@ -1471,28 +1525,8 @@ async function runOAuthFlow( ): Promise { const { pkce, state, url } = await createAuthorizationFlow({ forceNewLogin }); const preferManualMode = options.manual || isBrowserLaunchSuppressed(); - let oauthServer: Awaited> | null = null; - try { - oauthServer = await startLocalOAuthServer({ state }); - } catch (serverError) { - log.warn( - "Local OAuth callback server unavailable; falling back to manual callback entry.", - serverError instanceof Error - ? { - message: serverError.message, - stack: serverError.stack, - code: - typeof serverError === "object" && - serverError !== null && - "code" in serverError - ? String(serverError.code) - : undefined, - } - : { error: String(serverError) }, - ); - oauthServer = null; - } let code: string | null = null; + let oauthServer: Awaited> | null = null; try { const signInMode = preferManualMode ? "manual" : await promptOAuthSignInMode(); if (signInMode === "cancel") { @@ -1503,6 +1537,29 @@ async function runOAuthFlow( }; } + if (signInMode === "browser") { + try { + oauthServer = await startLocalOAuthServer({ state }); + } catch (serverError) { + log.warn( + "Local OAuth callback server unavailable; falling back to manual callback entry.", + serverError instanceof Error + ? { + message: serverError.message, + stack: serverError.stack, + code: + typeof serverError === "object" && + serverError !== null && + "code" in serverError + ? String(serverError.code) + : undefined, + } + : { error: String(serverError) }, + ); + oauthServer = null; + } + } + if (signInMode === "browser") { const opened = openBrowserUrl(url); if (opened) { @@ -1529,7 +1586,7 @@ async function runOAuthFlow( ); } - const waitingForCallback = signInMode === "browser" && oauthServer?.ready === true; + const waitingForCallback = oauthServer?.ready === true; if (waitingForCallback && oauthServer) { console.log(stylePromptText(UI_COPY.oauth.waitingCallback, "muted")); const callbackResult = await oauthServer.waitForCode(state); @@ -1541,7 +1598,9 @@ async function runOAuthFlow( stylePromptText( waitingForCallback ? UI_COPY.oauth.callbackMissed - : UI_COPY.oauth.callbackUnavailable, + : signInMode === "manual" + ? UI_COPY.oauth.callbackBypassed + : UI_COPY.oauth.callbackUnavailable, "warning", ), ); diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index 4e95e2d1..95113a12 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -42,6 +42,7 @@ export const UI_COPY = { browserOpened: "Browser opened.", browserOpenFail: "Could not open browser. Use this link:", waitingCallback: "Waiting for login callback on localhost:1455...", + callbackBypassed: "Manual mode active. Paste the callback URL manually.", callbackUnavailable: "Callback listener unavailable. Paste the callback URL manually.", callbackMissed: "No callback received. Paste manually.", cancelled: "Sign-in cancelled.", diff --git a/test/browser.test.ts b/test/browser.test.ts index 836798f7..0e7fb0a2 100644 --- a/test/browser.test.ts +++ b/test/browser.test.ts @@ -106,21 +106,33 @@ describe("auth browser utilities", () => { }); it("lets explicit false-like CODEX_AUTH_NO_BROWSER override a disabling BROWSER value", () => { + Object.defineProperty(process, "platform", { value: "darwin" }); + process.env.PATH = "/usr/bin"; process.env.CODEX_AUTH_NO_BROWSER = "0"; process.env.BROWSER = "none"; expect(isBrowserLaunchSuppressed()).toBe(false); - expect(openBrowserUrl("https://example.com")).toBe(false); - expect(mockedSpawn).not.toHaveBeenCalled(); + expect(openBrowserUrl("https://example.com")).toBe(true); + expect(mockedSpawn).toHaveBeenCalledWith( + "open", + ["https://example.com"], + { stdio: "ignore", shell: false }, + ); }); it("does not treat CODEX_AUTH_NO_BROWSER=false as suppression when BROWSER is disabled", () => { + Object.defineProperty(process, "platform", { value: "darwin" }); + process.env.PATH = "/usr/bin"; process.env.CODEX_AUTH_NO_BROWSER = "false"; process.env.BROWSER = "none"; expect(isBrowserLaunchSuppressed()).toBe(false); - expect(openBrowserUrl("https://example.com")).toBe(false); - expect(mockedSpawn).not.toHaveBeenCalled(); + expect(openBrowserUrl("https://example.com")).toBe(true); + expect(mockedSpawn).toHaveBeenCalledWith( + "open", + ["https://example.com"], + { stdio: "ignore", shell: false }, + ); }); it("suppresses browser launch when BROWSER is set to none", () => { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 96f3efcc..0224d4b9 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -3008,8 +3008,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(openBrowserUrlMock).not.toHaveBeenCalled(); + expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled(); expect(waitForCodeMock).not.toHaveBeenCalled(); - expect(renderedLogs.some((entry) => entry.includes("Callback listener unavailable"))).toBe( + expect(renderedLogs.some((entry) => entry.includes("Manual mode active"))).toBe( true, ); expect(renderedLogs.some((entry) => entry.includes("No callback received"))).toBe(false); @@ -3069,8 +3070,9 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(selectMock).toHaveBeenCalled(); expect(openBrowserUrlMock).not.toHaveBeenCalled(); + expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled(); expect(waitForCodeMock).not.toHaveBeenCalled(); - expect(renderedLogs.some((entry) => entry.includes("Callback listener unavailable"))).toBe( + expect(renderedLogs.some((entry) => entry.includes("Manual mode active"))).toBe( true, ); expect(renderedLogs.some((entry) => entry.includes("No callback received"))).toBe(false); @@ -3124,7 +3126,7 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(promptQuestionMock).toHaveBeenCalled(); expect(openBrowserUrlMock).not.toHaveBeenCalled(); - expect(startLocalOAuthServerMock).toHaveBeenCalledTimes(1); + expect(startLocalOAuthServerMock).not.toHaveBeenCalled(); expect(storageState.accounts).toHaveLength(1); }); @@ -3174,6 +3176,7 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(promptQuestionMock).toHaveBeenCalledWith(""); expect(openBrowserUrlMock).not.toHaveBeenCalled(); + expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled(); expect(storageState.accounts).toHaveLength(1); }); @@ -3215,6 +3218,7 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(promptQuestionMock).toHaveBeenCalledWith(""); expect(openBrowserUrlMock).not.toHaveBeenCalled(); + expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled(); expect(exchangeAuthorizationCodeMock).not.toHaveBeenCalled(); expect(storageState.accounts).toHaveLength(0); }); @@ -3265,9 +3269,48 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(promptQuestionMock).toHaveBeenCalledWith(""); expect(openBrowserUrlMock).not.toHaveBeenCalled(); + expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled(); expect(storageState.accounts).toHaveLength(1); }); + it("returns to the menu when stdin closes without input in non-tty manual mode", async () => { + setInteractiveTTY(false); + let storageState = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [] as Array>, + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + promptQuestionMock.mockRejectedValueOnce(new Error("readline was closed")); + + const authModule = await import("../lib/auth/auth.js"); + vi.mocked(authModule.createAuthorizationFlow).mockResolvedValueOnce({ + pkce: { challenge: "pkce-challenge", verifier: "pkce-verifier" }, + state: "oauth-state", + url: "https://auth.openai.com/mock", + }); + const exchangeAuthorizationCodeMock = vi.mocked(authModule.exchangeAuthorizationCode); + + const browserModule = await import("../lib/auth/browser.js"); + const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); + const serverModule = await import("../lib/auth/server.js"); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login", "--manual"]); + + expect(exitCode).toBe(0); + expect(promptQuestionMock).toHaveBeenCalledWith(""); + expect(openBrowserUrlMock).not.toHaveBeenCalled(); + expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled(); + expect(exchangeAuthorizationCodeMock).not.toHaveBeenCalled(); + expect(storageState.accounts).toHaveLength(0); + }); + it("preserves distinct same-email workspaces when oauth login reuses a refresh token", async () => { const now = Date.now(); let storageState = { From bccd04348cfb3d142b985572fa75840ee0dde072 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 20 Mar 2026 14:33:09 +0800 Subject: [PATCH 09/10] fix(auth): finish docs parity and isolate storage path state --- README.md | 16 +++++ docs/features.md | 4 +- docs/getting-started.md | 16 +++++ docs/reference/commands.md | 10 ++++ docs/upgrade.md | 12 ++++ lib/storage.ts | 118 +++++++++++++++++++++++++------------ 6 files changed, 138 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 8bcfe401..d7cd064b 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,21 @@ codex auth fix --dry-run codex auth doctor --fix ``` +If the shell should not launch a browser, use the manual callback flow: + +```bash +codex auth login --manual +CODEX_AUTH_NO_BROWSER=1 codex auth login +``` + +In non-TTY/manual shells, provide the full redirect URL on stdin instead of waiting for a browser callback: + +```bash +echo "http://127.0.0.1:1455/auth/callback?code=..." | codex auth login --manual +``` + +No new npm scripts or storage migration steps are required for this login-flow update. + --- ## Command Toolkit @@ -234,6 +249,7 @@ codex auth login - `codex auth` unrecognized: run `where codex`, then follow `docs/troubleshooting.md` for routing fallback commands - Switch succeeds but wrong account appears active: run `codex auth switch `, then restart session - OAuth callback on port `1455` fails: free the port and re-run `codex auth login` +- Browser launch is blocked or you are in a headless shell: re-run `codex auth login --manual` or set `CODEX_AUTH_NO_BROWSER=1` - `missing field id_token` / `token_expired` / `refresh_token_reused`: re-login affected account diff --git a/docs/features.md b/docs/features.md index e88c6342..9ab8b73d 100644 --- a/docs/features.md +++ b/docs/features.md @@ -54,7 +54,9 @@ User-facing capability map for `codex-multi-auth`. | Quick switch and search hotkeys | Faster navigation in the dashboard | | Account action hotkeys | Per-account set, refresh, toggle, and delete shortcuts | | In-dashboard settings hub | Runtime and display tuning without editing files directly | -| Browser-first OAuth with manual fallback | Works in normal and constrained terminal environments | +| Browser-first OAuth with manual fallback | `codex auth login` stays browser-first, while `--manual`, `--no-browser`, and `CODEX_AUTH_NO_BROWSER=1` keep login usable in browser-restricted shells | + +Manual/non-TTY login accepts the full callback URL on stdin, so automation and host-managed shells can complete auth without relying on a local browser handoff. --- diff --git a/docs/getting-started.md b/docs/getting-started.md index 2de9337b..97b90b4d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -56,6 +56,21 @@ codex auth list codex auth check ``` +If browser launch is blocked or you want to handle the callback manually: + +```bash +codex auth login --manual +CODEX_AUTH_NO_BROWSER=1 codex auth login +``` + +In non-TTY/manual shells, provide the full redirect URL on stdin: + +```bash +echo "http://127.0.0.1:1455/auth/callback?code=..." | codex auth login --manual +``` + +`codex auth login` remains browser-first by default. No new npm scripts or storage migration steps are required for this auth-flow update. + --- ## Add More Accounts @@ -111,6 +126,7 @@ If the OAuth callback on port `1455` fails: - stop the process using port `1455` - rerun `codex auth login` +- if browser launch is unavailable, rerun `codex auth login --manual` If account state looks stale: diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 5d801770..c69b3065 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -56,6 +56,16 @@ Compatibility aliases are supported: --- +## Upgrade Notes + +- `codex auth login` remains browser-first by default. +- `codex auth login --manual` and `codex auth login --no-browser` force the manual callback flow instead of launching a browser. +- `CODEX_AUTH_NO_BROWSER=1` suppresses browser launch for automation/headless sessions. False-like values such as `0` and `false` do not disable browser launch by themselves. +- In non-TTY/manual shells, pass the full redirect URL on stdin, for example: `echo "http://127.0.0.1:1455/auth/callback?code=..." | codex auth login --manual`. +- No new npm scripts or storage migration steps were introduced for this auth-flow update. + +--- + ## Compatibility and Non-TTY Behavior - `codex` remains the primary wrapper entrypoint. It routes `codex auth ...` and the compatibility aliases to the multi-auth runtime, and forwards every other command to the official `@openai/codex` CLI. diff --git a/docs/upgrade.md b/docs/upgrade.md index e34ecb2d..1b6bf0d4 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -49,6 +49,18 @@ codex auth forecast --live --model gpt-5-codex --- +## Login Flow Upgrade Notes + +- `codex auth login` remains the default browser-first path. +- `codex auth login --manual` and `codex auth login --no-browser` force manual callback handling for browser-restricted shells. +- `CODEX_AUTH_NO_BROWSER=1` suppresses browser launch for automation/headless sessions. False-like values such as `0` and `false` no longer force manual mode. +- In non-TTY/manual shells, provide the full redirect URL on stdin, for example: `echo "http://127.0.0.1:1455/auth/callback?code=..." | codex auth login --manual`. +- No new npm scripts, storage migrations, or extra upgrade steps were introduced for this auth-flow change. + +For the full command/behavior reference, see [reference/commands.md](reference/commands.md). + +--- + ## Configuration Upgrade Notes During upgrades, runtime config source precedence is: diff --git a/lib/storage.ts b/lib/storage.ts index 1ea0fc0b..71266436 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -167,6 +167,7 @@ export function formatStorageErrorHint(error: unknown, path: string): string { let storageMutex: Promise = Promise.resolve(); const transactionSnapshotContext = new AsyncLocalStorage<{ snapshot: AccountStorageV3 | null; + storagePath: string; active: boolean; }>(); @@ -225,11 +226,12 @@ function looksLikeSyntheticFixtureStorage( } async function ensureGitignore(storagePath: string): Promise { - if (!currentStoragePath) return; + const state = getStoragePathState(); + if (!state.currentStoragePath) return; const configDir = dirname(storagePath); const inferredProjectRoot = dirname(configDir); - const candidateRoots = [currentProjectRoot, inferredProjectRoot].filter( + const candidateRoots = [state.currentProjectRoot, inferredProjectRoot].filter( (root): root is string => typeof root === "string" && root.length > 0, ); const projectRoot = candidateRoots.find((root) => @@ -262,10 +264,30 @@ async function ensureGitignore(storagePath: string): Promise { } } -let currentStoragePath: string | null = null; -let currentLegacyProjectStoragePath: string | null = null; -let currentLegacyWorktreeStoragePath: string | null = null; -let currentProjectRoot: string | null = null; +type StoragePathState = { + currentStoragePath: string | null; + currentLegacyProjectStoragePath: string | null; + currentLegacyWorktreeStoragePath: string | null; + currentProjectRoot: string | null; +}; + +let currentStorageState: StoragePathState = { + currentStoragePath: null, + currentLegacyProjectStoragePath: null, + currentLegacyWorktreeStoragePath: null, + currentProjectRoot: null, +}; + +const storagePathStateContext = new AsyncLocalStorage(); + +function getStoragePathState(): StoragePathState { + return storagePathStateContext.getStore() ?? currentStorageState; +} + +function setStoragePathState(state: StoragePathState): void { + currentStorageState = state; + storagePathStateContext.enterWith(state); +} export function setStorageBackupEnabled(enabled: boolean): void { storageBackupEnabled = enabled; @@ -754,22 +776,23 @@ export function getLastAccountsSaveTimestamp(): number { export function setStoragePath(projectPath: string | null): void { if (!projectPath) { - currentStoragePath = null; - currentLegacyProjectStoragePath = null; - currentLegacyWorktreeStoragePath = null; - currentProjectRoot = null; + setStoragePathState({ + currentStoragePath: null, + currentLegacyProjectStoragePath: null, + currentLegacyWorktreeStoragePath: null, + currentProjectRoot: null, + }); return; } const projectRoot = findProjectRoot(projectPath); if (projectRoot) { - currentProjectRoot = projectRoot; const identityRoot = resolveProjectStorageIdentityRoot(projectRoot); - currentStoragePath = join( + const currentStoragePath = join( getProjectGlobalConfigDir(identityRoot), ACCOUNTS_FILE_NAME, ); - currentLegacyProjectStoragePath = join( + const currentLegacyProjectStoragePath = join( getProjectConfigDir(projectRoot), ACCOUNTS_FILE_NAME, ); @@ -777,23 +800,33 @@ export function setStoragePath(projectPath: string | null): void { getProjectGlobalConfigDir(projectRoot), ACCOUNTS_FILE_NAME, ); - currentLegacyWorktreeStoragePath = + const currentLegacyWorktreeStoragePath = previousWorktreeScopedPath !== currentStoragePath ? previousWorktreeScopedPath : null; + setStoragePathState({ + currentStoragePath, + currentLegacyProjectStoragePath, + currentLegacyWorktreeStoragePath, + currentProjectRoot: projectRoot, + }); } else { - currentStoragePath = null; - currentLegacyProjectStoragePath = null; - currentLegacyWorktreeStoragePath = null; - currentProjectRoot = null; + setStoragePathState({ + currentStoragePath: null, + currentLegacyProjectStoragePath: null, + currentLegacyWorktreeStoragePath: null, + currentProjectRoot: null, + }); } } export function setStoragePathDirect(path: string | null): void { - currentStoragePath = path; - currentLegacyProjectStoragePath = null; - currentLegacyWorktreeStoragePath = null; - currentProjectRoot = null; + setStoragePathState({ + currentStoragePath: path, + currentLegacyProjectStoragePath: null, + currentLegacyWorktreeStoragePath: null, + currentProjectRoot: null, + }); } /** @@ -801,8 +834,9 @@ export function setStoragePathDirect(path: string | null): void { * @returns Absolute path to the accounts.json file */ export function getStoragePath(): string { - if (currentStoragePath) { - return currentStoragePath; + const state = getStoragePathState(); + if (state.currentStoragePath) { + return state.currentStoragePath; } return join(getConfigDir(), ACCOUNTS_FILE_NAME); } @@ -836,19 +870,20 @@ function getLegacyFlaggedAccountsPath(): string { async function migrateLegacyProjectStorageIfNeeded( persist: (storage: AccountStorageV3) => Promise = saveAccounts, ): Promise { - if (!currentStoragePath) { + const state = getStoragePathState(); + if (!state.currentStoragePath) { return null; } const candidatePaths = [ - currentLegacyWorktreeStoragePath, - currentLegacyProjectStoragePath, + state.currentLegacyWorktreeStoragePath, + state.currentLegacyProjectStoragePath, ] .filter( (path): path is string => typeof path === "string" && path.length > 0 && - path !== currentStoragePath, + path !== state.currentStoragePath, ) .filter((path, index, all) => all.indexOf(path) === index); @@ -864,7 +899,7 @@ async function migrateLegacyProjectStorageIfNeeded( } let targetStorage = await loadNormalizedStorageFromPath( - currentStoragePath, + state.currentStoragePath, "current account storage", ); let migrated = false; @@ -892,7 +927,7 @@ async function migrateLegacyProjectStorageIfNeeded( targetStorage = fallbackStorage; log.warn("Failed to persist migrated account storage", { from: legacyPath, - to: currentStoragePath, + to: state.currentStoragePath, error: String(error), }); continue; @@ -918,7 +953,7 @@ async function migrateLegacyProjectStorageIfNeeded( log.info("Migrated legacy project account storage", { from: legacyPath, - to: currentStoragePath, + to: state.currentStoragePath, accounts: mergedStorage.accounts.length, }); } @@ -926,7 +961,7 @@ async function migrateLegacyProjectStorageIfNeeded( if (migrated) { return targetStorage; } - if (targetStorage && !existsSync(currentStoragePath)) { + if (targetStorage && !existsSync(state.currentStoragePath)) { return targetStorage; } return null; @@ -1912,8 +1947,10 @@ export async function withAccountStorageTransaction( ) => Promise, ): Promise { return withStorageLock(async () => { + const storagePath = getStoragePath(); const state = { snapshot: await loadAccountsInternal(saveAccountsUnlocked), + storagePath, active: true, }; const current = state.snapshot; @@ -1937,8 +1974,10 @@ export async function withAccountAndFlaggedStorageTransaction( ) => Promise, ): Promise { return withStorageLock(async () => { + const storagePath = getStoragePath(); const state = { snapshot: await loadAccountsInternal(saveAccountsUnlocked), + storagePath, active: true, }; const current = state.snapshot; @@ -2308,17 +2347,22 @@ export async function exportAccounts( beforeCommit?: (resolvedPath: string) => Promise | void, ): Promise { const resolvedPath = resolvePath(filePath); + const currentStoragePath = getStoragePath(); if (!force && existsSync(resolvedPath)) { throw new Error(`File already exists: ${resolvedPath}`); } const transactionState = transactionSnapshotContext.getStore(); - const storage = transactionState?.active - ? transactionState.snapshot - : await withAccountStorageTransaction((current) => - Promise.resolve(current), - ); + const storage = + transactionState?.active && + transactionState.storagePath === currentStoragePath + ? transactionState.snapshot + : transactionState?.active + ? await loadAccountsInternal(saveAccountsUnlocked) + : await withAccountStorageTransaction((current) => + Promise.resolve(current), + ); if (!storage || storage.accounts.length === 0) { throw new Error("No accounts to export"); } From 56ea657a82382355a53185a44aaef87478c16a49 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 20 Mar 2026 19:35:33 +0800 Subject: [PATCH 10/10] test(auth): consume manual login mock paths --- test/codex-manager-cli.test.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 0224d4b9..e353a3da 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -3094,6 +3094,7 @@ describe("codex manager cli commands", () => { }); promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); promptAddAnotherAccountMock.mockResolvedValue(false); + selectMock.mockResolvedValueOnce("browser"); promptQuestionMock.mockResolvedValueOnce( "http://127.0.0.1:1455/auth/callback?code=oauth-code&state=oauth-state", ); @@ -3118,7 +3119,6 @@ describe("codex manager cli commands", () => { vi.mocked(browserModule.isBrowserLaunchSuppressed).mockReturnValueOnce(true); const serverModule = await import("../lib/auth/server.js"); const startLocalOAuthServerMock = vi.mocked(serverModule.startLocalOAuthServer); - startLocalOAuthServerMock.mockRejectedValueOnce(new Error("suppressed browser mode")); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); @@ -3166,9 +3166,6 @@ describe("codex manager cli commands", () => { const browserModule = await import("../lib/auth/browser.js"); const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); const serverModule = await import("../lib/auth/server.js"); - vi.mocked(serverModule.startLocalOAuthServer).mockRejectedValueOnce( - new Error("port in use"), - ); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login", "--manual"]); @@ -3208,9 +3205,6 @@ describe("codex manager cli commands", () => { const browserModule = await import("../lib/auth/browser.js"); const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); const serverModule = await import("../lib/auth/server.js"); - vi.mocked(serverModule.startLocalOAuthServer).mockRejectedValueOnce( - new Error("port in use"), - ); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login", "--manual"]); @@ -3259,9 +3253,6 @@ describe("codex manager cli commands", () => { const browserModule = await import("../lib/auth/browser.js"); const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); const serverModule = await import("../lib/auth/server.js"); - vi.mocked(serverModule.startLocalOAuthServer).mockRejectedValueOnce( - Object.assign(new Error("permission denied"), { code: "EACCES" }), - ); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login", "--manual"]);