diff --git a/lib/codex-native/oauth-auth-methods.ts b/lib/codex-native/oauth-auth-methods.ts index ed7837d..dfdefc7 100644 --- a/lib/codex-native/oauth-auth-methods.ts +++ b/lib/codex-native/oauth-auth-methods.ts @@ -1,7 +1,6 @@ import type { CodexSpoofMode } from "../config.js" import type { OpenAIAuthMode } from "../types.js" import { resolveRequestUserAgent } from "./client-identity.js" -import { resolveCodexOriginator } from "./originator.js" import { buildAuthorizeUrl, CLIENT_ID, @@ -13,10 +12,11 @@ import { OAUTH_DEVICE_AUTH_TIMEOUT_MS, OAUTH_HTTP_TIMEOUT_MS, OAUTH_POLLING_SAFETY_MARGIN_MS, - sleep, type PkceCodes, + sleep, type TokenResponse } from "./oauth-utils.js" +import { resolveCodexOriginator } from "./originator.js" type OAuthSuccess = { type: "success" @@ -101,6 +101,9 @@ function toOAuthSuccess(tokens: TokenResponse): OAuthSuccess { export function createBrowserOAuthAuthorize(deps: BrowserAuthorizeDeps) { return async (inputs?: Record): Promise => { + const shouldUseInteractiveMenu = + process.env.OPENCODE_NO_BROWSER !== "1" && process.stdin.isTTY && process.stdout.isTTY + const runSingleBrowserOAuthInline = async (): Promise => { let redirectUri: string try { @@ -184,7 +187,7 @@ export function createBrowserOAuthAuthorize(deps: BrowserAuthorizeDeps) { } } - if (inputs && process.env.OPENCODE_NO_BROWSER !== "1" && process.stdin.isTTY && process.stdout.isTTY) { + if (shouldUseInteractiveMenu) { return runInteractiveBrowserAuthLoop() } diff --git a/test/codex-native-auth-menu-wiring.test.ts b/test/codex-native-auth-menu-wiring.test.ts index 93c8ccf..1ce85fd 100644 --- a/test/codex-native-auth-menu-wiring.test.ts +++ b/test/codex-native-auth-menu-wiring.test.ts @@ -282,6 +282,36 @@ describe("codex-native auth menu wiring", () => { } }) + it("shows interactive menu in tty login flow when authorize inputs are omitted", async () => { + const { hooks, runAuthMenuOnce } = await loadPluginWithMenu({ + offerLegacyTransfer: true, + menuResult: "exit" + }) + const browserMethod = hooks.auth?.methods.find((method) => method.label === "ChatGPT Pro/Plus (browser)") + expect(browserMethod).toBeDefined() + if (!browserMethod || browserMethod.type !== "oauth") throw new Error("Missing browser oauth method") + + const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean } + const stdout = process.stdout as NodeJS.WriteStream & { isTTY?: boolean } + const prevIn = stdin.isTTY + const prevOut = stdout.isTTY + stdin.isTTY = true + stdout.isTTY = true + + try { + const flow = await browserMethod.authorize() + expect(runAuthMenuOnce).toHaveBeenCalledTimes(1) + expect(flow.instructions).toBe("Login cancelled.") + expect(flow.method).toBe("auto") + expect(flow.url).toBe("") + const result = await flow.callback("") + expect(result.type).toBe("failed") + } finally { + stdin.isTTY = prevIn + stdout.isTTY = prevOut + } + }) + it("passes transfer availability flag into auth menu runner", async () => { const { hooks, runAuthMenuOnce } = await loadPluginWithMenu({ offerLegacyTransfer: true, diff --git a/test/codex-native-oauth-auth-methods.test.ts b/test/codex-native-oauth-auth-methods.test.ts index 76a0a6c..5b311cf 100644 --- a/test/codex-native-oauth-auth-methods.test.ts +++ b/test/codex-native-oauth-auth-methods.test.ts @@ -130,6 +130,47 @@ describe("createBrowserOAuthAuthorize", () => { } }) + it("defaults missing authorize inputs to the interactive auth menu in tty mode", async () => { + const scheduleOAuthServerStop = vi.fn() + const persistOAuthTokens = vi.fn() + const openAuthUrl = vi.fn() + const runInteractiveAuthMenu = vi.fn<(options: { allowExit: boolean }) => Promise<"add" | "exit">>( + async () => "exit" + ) + + const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean } + const stdout = process.stdout as NodeJS.WriteStream & { isTTY?: boolean } + const previousIn = stdin.isTTY + const previousOut = stdout.isTTY + stdin.isTTY = true + stdout.isTTY = true + + try { + const authorize = createBrowserOAuthAuthorize({ + authMode: "native", + spoofMode: "native", + runInteractiveAuthMenu, + startOAuthServer: vi.fn(async () => ({ redirectUri: "http://localhost:1455/auth/callback" })), + waitForOAuthCallback: vi.fn(async () => { + throw new Error("callback failed") + }), + scheduleOAuthServerStop, + persistOAuthTokens, + openAuthUrl, + shutdownGraceMs: 1_000, + shutdownErrorGraceMs: 5_000 + }) + + const payload = await authorize() + expect(payload.url).toBe("") + expect(payload.instructions).toBe("Login cancelled.") + expect(runInteractiveAuthMenu).toHaveBeenCalledTimes(1) + } finally { + stdin.isTTY = previousIn + stdout.isTTY = previousOut + } + }) + it("returns a failed payload when browser oauth server startup fails", async () => { const scheduleOAuthServerStop = vi.fn() const persistOAuthTokens = vi.fn() @@ -160,6 +201,62 @@ describe("createBrowserOAuthAuthorize", () => { expect(persistOAuthTokens).not.toHaveBeenCalled() expect(scheduleOAuthServerStop).not.toHaveBeenCalled() }) + + it("skips the interactive auth menu when browser launch is disabled by env", async () => { + const scheduleOAuthServerStop = vi.fn() + const persistOAuthTokens = vi.fn(async () => {}) + const openAuthUrl = vi.fn() + const runInteractiveAuthMenu = vi.fn<(options: { allowExit: boolean }) => Promise<"add" | "exit">>( + async () => "exit" + ) + + const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean } + const stdout = process.stdout as NodeJS.WriteStream & { isTTY?: boolean } + const previousIn = stdin.isTTY + const previousOut = stdout.isTTY + const previousNoBrowser = process.env.OPENCODE_NO_BROWSER + stdin.isTTY = true + stdout.isTTY = true + process.env.OPENCODE_NO_BROWSER = "1" + + try { + const authorize = createBrowserOAuthAuthorize({ + authMode: "native", + spoofMode: "native", + runInteractiveAuthMenu, + startOAuthServer: vi.fn(async () => ({ redirectUri: "http://localhost:1455/auth/callback" })), + waitForOAuthCallback: vi.fn(async () => ({ + refresh_token: "rt_no_browser", + access_token: "at_no_browser", + expires_in: 1200 + })), + scheduleOAuthServerStop, + persistOAuthTokens, + openAuthUrl, + shutdownGraceMs: 1_000, + shutdownErrorGraceMs: 5_000 + }) + + const payload = await authorize() + expect(runInteractiveAuthMenu).not.toHaveBeenCalled() + expect(payload.url).toContain("https://auth.openai.com/oauth/authorize") + await expect(payload.callback()).resolves.toMatchObject({ + type: "success", + refresh: "rt_no_browser", + access: "at_no_browser" + }) + expect(openAuthUrl).toHaveBeenCalledTimes(1) + expect(persistOAuthTokens).toHaveBeenCalledTimes(1) + } finally { + stdin.isTTY = previousIn + stdout.isTTY = previousOut + if (previousNoBrowser === undefined) { + delete process.env.OPENCODE_NO_BROWSER + } else { + process.env.OPENCODE_NO_BROWSER = previousNoBrowser + } + } + }) }) describe("createHeadlessOAuthAuthorize", () => {