Skip to content

Commit 5dc7613

Browse files
committed
fix(auth): log callback fallback causes and cover manual bind failures
1 parent 6ee4d3b commit 5dc7613

4 files changed

Lines changed: 71 additions & 1 deletion

File tree

docs/reference/commands.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ Compatibility aliases are supported:
6262
- 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.
6363
- 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.
6464
- `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.
65+
- 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`.
6566

6667
---
6768

lib/codex-manager.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
summarizeForecast,
3838
type ForecastAccountResult,
3939
} from "./forecast.js";
40+
import { createLogger } from "./logger.js";
4041
import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js";
4142
import {
4243
fetchCodexQuotaSnapshot,
@@ -87,6 +88,8 @@ type TokenSuccessWithAccount = TokenSuccess & {
8788
};
8889
type PromptTone = "accent" | "success" | "warning" | "danger" | "muted";
8990

91+
const log = createLogger("codex-manager");
92+
9093
function stylePromptText(text: string, tone: PromptTone): string {
9194
if (!output.isTTY) return text;
9295
const ui = getUiRuntimeOptions();
@@ -1471,7 +1474,22 @@ async function runOAuthFlow(
14711474
let oauthServer: Awaited<ReturnType<typeof startLocalOAuthServer>> | null = null;
14721475
try {
14731476
oauthServer = await startLocalOAuthServer({ state });
1474-
} catch {
1477+
} catch (serverError) {
1478+
log.warn(
1479+
"Local OAuth callback server unavailable; falling back to manual callback entry.",
1480+
serverError instanceof Error
1481+
? {
1482+
message: serverError.message,
1483+
stack: serverError.stack,
1484+
code:
1485+
typeof serverError === "object" &&
1486+
serverError !== null &&
1487+
"code" in serverError
1488+
? String(serverError.code)
1489+
: undefined,
1490+
}
1491+
: { error: String(serverError) },
1492+
);
14751493
oauthServer = null;
14761494
}
14771495
let code: string | null = null;

test/codex-manager-cli.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3110,6 +3110,55 @@ describe("codex manager cli commands", () => {
31103110
expect(storageState.accounts).toHaveLength(1);
31113111
});
31123112

3113+
it("falls back to pasted manual input when Windows-style callback bind fails", async () => {
3114+
setInteractiveTTY(false);
3115+
const now = Date.now();
3116+
let storageState = {
3117+
version: 3 as const,
3118+
activeIndex: 0,
3119+
activeIndexByFamily: { codex: 0 },
3120+
accounts: [] as Array<Record<string, unknown>>,
3121+
};
3122+
loadAccountsMock.mockImplementation(async () => structuredClone(storageState));
3123+
saveAccountsMock.mockImplementation(async (nextStorage) => {
3124+
storageState = structuredClone(nextStorage);
3125+
});
3126+
promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" });
3127+
promptQuestionMock.mockResolvedValueOnce(
3128+
"http://127.0.0.1:1455/auth/callback?code=oauth-code&state=oauth-state",
3129+
);
3130+
3131+
const authModule = await import("../lib/auth/auth.js");
3132+
vi.mocked(authModule.createAuthorizationFlow).mockResolvedValueOnce({
3133+
pkce: { challenge: "pkce-challenge", verifier: "pkce-verifier" },
3134+
state: "oauth-state",
3135+
url: "https://auth.openai.com/mock",
3136+
});
3137+
vi.mocked(authModule.exchangeAuthorizationCode).mockResolvedValueOnce({
3138+
type: "success",
3139+
access: "access-eacces",
3140+
refresh: "refresh-eacces",
3141+
expires: now + 7_200_000,
3142+
idToken: "id-token-eacces",
3143+
multiAccount: true,
3144+
});
3145+
3146+
const browserModule = await import("../lib/auth/browser.js");
3147+
const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl);
3148+
const serverModule = await import("../lib/auth/server.js");
3149+
vi.mocked(serverModule.startLocalOAuthServer).mockRejectedValueOnce(
3150+
Object.assign(new Error("permission denied"), { code: "EACCES" }),
3151+
);
3152+
3153+
const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js");
3154+
const exitCode = await runCodexMultiAuthCli(["auth", "login", "--manual"]);
3155+
3156+
expect(exitCode).toBe(0);
3157+
expect(promptQuestionMock).toHaveBeenCalledWith("");
3158+
expect(openBrowserUrlMock).not.toHaveBeenCalled();
3159+
expect(storageState.accounts).toHaveLength(1);
3160+
});
3161+
31133162
it("preserves distinct same-email workspaces when oauth login reuses a refresh token", async () => {
31143163
const now = Date.now();
31153164
let storageState = {

test/index.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,7 @@ describe("OpenAIOAuthPlugin", () => {
564564
it("uses manual auth flow when browser launch is globally suppressed", async () => {
565565
const browserModule = await import("../lib/auth/browser.js");
566566
vi.mocked(browserModule.isBrowserLaunchSuppressed).mockReturnValueOnce(true);
567+
const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl);
567568
const autoMethod = plugin.auth.methods[0] as unknown as {
568569
authorize: (inputs?: Record<string, string>) => Promise<{
569570
method: string;
@@ -577,6 +578,7 @@ describe("OpenAIOAuthPlugin", () => {
577578
expect(flow.method).toBe("code");
578579
expect(flow.instructions).toContain("copy the full redirect URL");
579580
expect(flow.validate("invalid-callback-value")).toContain("No authorization code found");
581+
expect(openBrowserUrlMock).not.toHaveBeenCalled();
580582
});
581583

582584
it("rejects manual OAuth callbacks with mismatched state", async () => {

0 commit comments

Comments
 (0)