Skip to content

Commit c26c11d

Browse files
committed
add-manual-login-mode-for-headless-auth-flows
1 parent 11073f3 commit c26c11d

8 files changed

Lines changed: 245 additions & 24 deletions

File tree

docs/reference/commands.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Compatibility aliases are supported:
4545

4646
| Flag | Applies to | Meaning |
4747
| --- | --- | --- |
48+
| `--manual`, `--no-browser` | login | Skip browser launch and use manual callback flow |
4849
| `--json` | verify-flagged, forecast, report, fix, doctor | Print machine-readable output |
4950
| `--live` | forecast, report, fix | Use live probe before decisions/output |
5051
| `--dry-run` | verify-flagged, fix, doctor | Preview without writing storage |
@@ -60,6 +61,7 @@ Compatibility aliases are supported:
6061
- `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.
6162
- 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.
6263
- 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.
64+
- `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.
6365

6466
---
6567

index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import {
3434
REDIRECT_URI,
3535
} from "./lib/auth/auth.js";
3636
import { queuedRefresh } from "./lib/refresh-queue.js";
37-
import { openBrowserUrl } from "./lib/auth/browser.js";
37+
import { isBrowserLaunchSuppressed, openBrowserUrl } from "./lib/auth/browser.js";
3838
import { startLocalOAuthServer } from "./lib/auth/server.js";
3939
import { promptAddAnotherAccount, promptLoginMode } from "./lib/cli.js";
4040
import {
@@ -2386,9 +2386,10 @@ while (attempted.size < Math.max(1, accountCount)) {
23862386

23872387
const accounts: TokenSuccessWithAccount[] = [];
23882388
const noBrowser =
2389+
inputs?.manual === "true" ||
23892390
inputs?.noBrowser === "true" ||
23902391
inputs?.["no-browser"] === "true";
2391-
const useManualMode = noBrowser;
2392+
const useManualMode = noBrowser || isBrowserLaunchSuppressed();
23922393
const explicitLoginMode =
23932394
inputs?.loginMode === "fresh" || inputs?.loginMode === "add"
23942395
? inputs.loginMode

lib/auth/browser.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import fs from "node:fs";
88
import path from "node:path";
99
import { PLATFORM_OPENERS } from "../constants.js";
1010

11+
const BROWSER_DISABLED_VALUES = new Set(["0", "false", "no", "off", "none"]);
12+
1113
/**
1214
* Gets the platform-specific command to open a URL in the default browser
1315
* @returns Browser opener command for the current platform
@@ -19,6 +21,16 @@ export function getBrowserOpener(): string {
1921
return PLATFORM_OPENERS.linux;
2022
}
2123

24+
export function isBrowserLaunchSuppressed(): boolean {
25+
const explicitNoBrowser = (process.env.CODEX_AUTH_NO_BROWSER ?? "").trim().toLowerCase();
26+
if (explicitNoBrowser === "1" || BROWSER_DISABLED_VALUES.has(explicitNoBrowser)) {
27+
return true;
28+
}
29+
30+
const browserSetting = (process.env.BROWSER ?? "").trim().toLowerCase();
31+
return BROWSER_DISABLED_VALUES.has(browserSetting);
32+
}
33+
2234
/**
2335
* Determines whether a given command name exists on the system PATH.
2436
*
@@ -92,6 +104,10 @@ function commandExists(command: string): boolean {
92104
*/
93105
export function openBrowserUrl(url: string): boolean {
94106
try {
107+
if (isBrowserLaunchSuppressed()) {
108+
return false;
109+
}
110+
95111
// Windows: use PowerShell Start-Process to avoid cmd/start quirks with URLs containing '&' or ':'
96112
if (process.platform === "win32") {
97113
if (!commandExists("powershell.exe")) {

lib/codex-manager.ts

Lines changed: 80 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
REDIRECT_URI,
1010
} from "./auth/auth.js";
1111
import { startLocalOAuthServer } from "./auth/server.js";
12-
import { copyTextToClipboard, openBrowserUrl } from "./auth/browser.js";
12+
import { copyTextToClipboard, isBrowserLaunchSuppressed, openBrowserUrl } from "./auth/browser.js";
1313
import { promptAddAnotherAccount, promptLoginMode, type ExistingAccountInfo } from "./cli.js";
1414
import {
1515
extractAccountEmail,
@@ -291,7 +291,7 @@ function printUsage(): void {
291291
"Codex Multi-Auth CLI",
292292
"",
293293
"Usage:",
294-
" codex auth login",
294+
" codex auth login [--manual|--no-browser]",
295295
" codex auth list",
296296
" codex auth status",
297297
" codex auth switch <index>",
@@ -311,6 +311,37 @@ function printUsage(): void {
311311
);
312312
}
313313

314+
type AuthLoginOptions = {
315+
manual: boolean;
316+
};
317+
318+
type ParsedAuthLoginArgs =
319+
| { ok: true; options: AuthLoginOptions }
320+
| { ok: false; message: string };
321+
322+
function parseAuthLoginArgs(args: string[]): ParsedAuthLoginArgs {
323+
const options: AuthLoginOptions = {
324+
manual: false,
325+
};
326+
327+
for (const arg of args) {
328+
if (arg === "--manual" || arg === "--no-browser") {
329+
options.manual = true;
330+
continue;
331+
}
332+
if (arg === "--help" || arg === "-h") {
333+
printUsage();
334+
return { ok: false, message: "" };
335+
}
336+
return {
337+
ok: false,
338+
message: `Unknown login option: ${arg}`,
339+
};
340+
}
341+
342+
return { ok: true, options };
343+
}
344+
314345
interface ImplementedFeature {
315346
id: number;
316347
name: string;
@@ -1095,16 +1126,22 @@ function applyTokenAccountIdentity(
10951126
return true;
10961127
}
10971128

1098-
async function promptManualCallback(state: string): Promise<string | null> {
1099-
if (!input.isTTY || !output.isTTY) {
1129+
async function promptManualCallback(
1130+
state: string,
1131+
options: { allowNonTty?: boolean } = {},
1132+
): Promise<string | null> {
1133+
const useInteractivePrompt = input.isTTY && output.isTTY;
1134+
if (!useInteractivePrompt && !options.allowNonTty) {
11001135
return null;
11011136
}
11021137

11031138
const rl = createInterface({ input, output });
11041139
try {
1105-
console.log("");
1106-
console.log(stylePromptText(UI_COPY.oauth.pastePrompt, "accent"));
1107-
const answer = await rl.question("◆ ");
1140+
if (useInteractivePrompt) {
1141+
console.log("");
1142+
console.log(stylePromptText(UI_COPY.oauth.pastePrompt, "accent"));
1143+
}
1144+
const answer = await rl.question(useInteractivePrompt ? "◆ " : "");
11081145
if (answer.includes("\u001b")) {
11091146
return null;
11101147
}
@@ -1425,12 +1462,21 @@ async function runActionPanel(
14251462
}
14261463
}
14271464

1428-
async function runOAuthFlow(forceNewLogin: boolean): Promise<TokenResult> {
1465+
async function runOAuthFlow(
1466+
forceNewLogin: boolean,
1467+
options: AuthLoginOptions = { manual: false },
1468+
): Promise<TokenResult> {
14291469
const { pkce, state, url } = await createAuthorizationFlow({ forceNewLogin });
1430-
const oauthServer = await startLocalOAuthServer({ state });
1470+
const preferManualMode = options.manual || isBrowserLaunchSuppressed();
1471+
let oauthServer: Awaited<ReturnType<typeof startLocalOAuthServer>> | null = null;
1472+
try {
1473+
oauthServer = await startLocalOAuthServer({ state });
1474+
} catch {
1475+
oauthServer = null;
1476+
}
14311477
let code: string | null = null;
14321478
try {
1433-
const signInMode = await promptOAuthSignInMode();
1479+
const signInMode = preferManualMode ? "manual" : await promptOAuthSignInMode();
14341480
if (signInMode === "cancel") {
14351481
return {
14361482
type: "failed",
@@ -1465,18 +1511,23 @@ async function runOAuthFlow(forceNewLogin: boolean): Promise<TokenResult> {
14651511
);
14661512
}
14671513

1468-
if (oauthServer.ready) {
1514+
if (oauthServer?.ready) {
14691515
console.log(stylePromptText(UI_COPY.oauth.waitingCallback, "muted"));
14701516
const callbackResult = await oauthServer.waitForCode(state);
14711517
code = callbackResult?.code ?? null;
14721518
}
14731519

14741520
if (!code) {
1475-
console.log(stylePromptText(UI_COPY.oauth.callbackMissed, "warning"));
1476-
code = await promptManualCallback(state);
1521+
console.log(
1522+
stylePromptText(
1523+
oauthServer?.ready ? UI_COPY.oauth.callbackMissed : UI_COPY.oauth.callbackUnavailable,
1524+
"warning",
1525+
),
1526+
);
1527+
code = await promptManualCallback(state, { allowNonTty: preferManualMode });
14771528
}
14781529
} finally {
1479-
oauthServer.close();
1530+
oauthServer?.close();
14801531
}
14811532

14821533
if (!code) {
@@ -4099,7 +4150,7 @@ async function handleManageAction(
40994150
const existing = storage.accounts[idx];
41004151
if (!existing) return;
41014152

4102-
const tokenResult = await runOAuthFlow(true);
4153+
const tokenResult = await runOAuthFlow(true, { manual: false });
41034154
if (tokenResult.type !== "success") {
41044155
console.error(`Refresh failed: ${tokenResult.message ?? tokenResult.reason ?? "unknown error"}`);
41054156
return;
@@ -4112,7 +4163,18 @@ async function handleManageAction(
41124163
}
41134164
}
41144165

4115-
async function runAuthLogin(): Promise<number> {
4166+
async function runAuthLogin(args: string[]): Promise<number> {
4167+
const parsedArgs = parseAuthLoginArgs(args);
4168+
if (!parsedArgs.ok) {
4169+
if (parsedArgs.message) {
4170+
console.error(parsedArgs.message);
4171+
printUsage();
4172+
return 1;
4173+
}
4174+
return 0;
4175+
}
4176+
4177+
const loginOptions = parsedArgs.options;
41164178
setStoragePath(null);
41174179
let pendingMenuQuotaRefresh: Promise<void> | null = null;
41184180
let menuQuotaRefreshStatus: string | undefined;
@@ -4231,7 +4293,7 @@ async function runAuthLogin(): Promise<number> {
42314293
const existingCount = refreshedStorage?.accounts.length ?? 0;
42324294
let forceNewLogin = existingCount > 0;
42334295
while (true) {
4234-
const tokenResult = await runOAuthFlow(forceNewLogin);
4296+
const tokenResult = await runOAuthFlow(forceNewLogin, loginOptions);
42354297
if (tokenResult.type !== "success") {
42364298
if (isUserCancelledOAuth(tokenResult)) {
42374299
if (existingCount > 0) {
@@ -4719,7 +4781,7 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise<number> {
47194781
return 0;
47204782
}
47214783
if (command === "login") {
4722-
return runAuthLogin();
4784+
return runAuthLogin(rest);
47234785
}
47244786
if (command === "list" || command === "status") {
47254787
await showAccountStatus();

lib/ui/copy.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const UI_COPY = {
4242
browserOpened: "Browser opened.",
4343
browserOpenFail: "Could not open browser. Use this link:",
4444
waitingCallback: "Waiting for login callback on localhost:1455...",
45+
callbackUnavailable: "Callback listener unavailable. Paste the callback URL manually.",
4546
callbackMissed: "No callback received. Paste manually.",
4647
cancelled: "Sign-in cancelled.",
4748
cancelledBackToMenu: "Sign-in cancelled. Going back to menu.",

test/browser.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import { spawn } from "node:child_process";
33
import fs from "node:fs";
4-
import { getBrowserOpener, openBrowserUrl, copyTextToClipboard } from "../lib/auth/browser.js";
4+
import {
5+
getBrowserOpener,
6+
isBrowserLaunchSuppressed,
7+
openBrowserUrl,
8+
copyTextToClipboard,
9+
} from "../lib/auth/browser.js";
510
import { PLATFORM_OPENERS } from "../lib/constants.js";
611

712
vi.mock("node:child_process", () => ({
@@ -37,6 +42,7 @@ describe("auth browser utilities", () => {
3742
const originalPlatform = process.platform;
3843
const originalPath = process.env.PATH;
3944
const originalPathExt = process.env.PATHEXT;
45+
const originalNoBrowser = process.env.CODEX_AUTH_NO_BROWSER;
4046

4147
beforeEach(() => {
4248
vi.clearAllMocks();
@@ -53,6 +59,8 @@ describe("auth browser utilities", () => {
5359
else process.env.PATH = originalPath;
5460
if (originalPathExt === undefined) delete process.env.PATHEXT;
5561
else process.env.PATHEXT = originalPathExt;
62+
if (originalNoBrowser === undefined) delete process.env.CODEX_AUTH_NO_BROWSER;
63+
else process.env.CODEX_AUTH_NO_BROWSER = originalNoBrowser;
5664
});
5765

5866
it("returns platform opener command", () => {
@@ -65,6 +73,14 @@ describe("auth browser utilities", () => {
6573
});
6674

6775
describe("openBrowserUrl", () => {
76+
it("returns false when browser launch is suppressed by environment", () => {
77+
process.env.CODEX_AUTH_NO_BROWSER = "1";
78+
79+
expect(isBrowserLaunchSuppressed()).toBe(true);
80+
expect(openBrowserUrl("https://example.com")).toBe(false);
81+
expect(mockedSpawn).not.toHaveBeenCalled();
82+
});
83+
6884
it("returns false on win32 when powershell.exe is unavailable", () => {
6985
Object.defineProperty(process, "platform", { value: "win32" });
7086
process.env.PATH = "C:\\missing";

0 commit comments

Comments
 (0)