diff --git a/src/plugins/provider-openai-codex-oauth.test.ts b/src/plugins/provider-openai-codex-oauth.test.ts new file mode 100644 index 00000000000..779204725dd --- /dev/null +++ b/src/plugins/provider-openai-codex-oauth.test.ts @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../runtime.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; + +const mocks = vi.hoisted(() => ({ + loginOpenAICodex: vi.fn(), + createVpsAwareOAuthHandlers: vi.fn(), + runOpenAIOAuthTlsPreflight: vi.fn(), + formatOpenAIOAuthTlsPreflightFix: vi.fn(), + tryListenOnPort: vi.fn(), + describePortOwner: vi.fn(), +})); + +vi.mock("@mariozechner/pi-ai/oauth", () => ({ + loginOpenAICodex: mocks.loginOpenAICodex, +})); + +vi.mock("./provider-oauth-flow.js", () => ({ + createVpsAwareOAuthHandlers: mocks.createVpsAwareOAuthHandlers, +})); + +vi.mock("./provider-openai-codex-oauth-tls.js", () => ({ + runOpenAIOAuthTlsPreflight: mocks.runOpenAIOAuthTlsPreflight, + formatOpenAIOAuthTlsPreflightFix: mocks.formatOpenAIOAuthTlsPreflightFix, +})); + +vi.mock("../infra/ports-probe.js", () => ({ + tryListenOnPort: mocks.tryListenOnPort, +})); + +vi.mock("../infra/ports.js", () => ({ + describePortOwner: mocks.describePortOwner, +})); + +import { loginOpenAICodexOAuth } from "./provider-openai-codex-oauth.js"; + +function createPrompter() { + const spin = { update: vi.fn(), stop: vi.fn() }; + const prompter: Pick = { + note: vi.fn(async () => {}), + progress: vi.fn(() => spin), + }; + return { prompter: prompter as unknown as WizardPrompter, spin }; +} + +function createRuntime(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; +} + +async function runCodexOAuth(params: { isRemote: boolean }) { + const { prompter, spin } = createPrompter(); + const runtime = createRuntime(); + const result = await loginOpenAICodexOAuth({ + prompter, + runtime, + isRemote: params.isRemote, + openUrl: async () => {}, + }); + return { result, prompter, spin, runtime }; +} + +describe("loginOpenAICodexOAuth – port conflict", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.runOpenAIOAuthTlsPreflight.mockResolvedValue({ ok: true }); + mocks.tryListenOnPort.mockResolvedValue(undefined); + mocks.describePortOwner.mockResolvedValue(undefined); + }); + + it("does not activate manual fallback when the callback port is free", async () => { + const creds = { + provider: "openai-codex" as const, + access: "a", + refresh: "r", + expires: Date.now() + 60_000, + email: "u@example.com", + }; + mocks.createVpsAwareOAuthHandlers.mockReturnValue({ onAuth: vi.fn(), onPrompt: vi.fn() }); + mocks.loginOpenAICodex.mockResolvedValue(creds); + + await runCodexOAuth({ isRemote: false }); + + expect(mocks.tryListenOnPort).toHaveBeenCalledWith({ + port: 1455, + host: "127.0.0.1", + exclusive: true, + }); + expect(mocks.loginOpenAICodex.mock.calls[0]?.[0]?.onManualCodeInput).toBeUndefined(); + }); + + it("enables immediate manual fallback when the callback port is occupied with known owner", async () => { + const creds = { + provider: "openai-codex" as const, + access: "a", + refresh: "r", + expires: Date.now() + 60_000, + email: "u@example.com", + }; + mocks.tryListenOnPort.mockRejectedValue( + Object.assign(new Error("address in use"), { code: "EADDRINUSE" }), + ); + mocks.describePortOwner.mockResolvedValue("Code Helper (Plugin)"); + mocks.createVpsAwareOAuthHandlers.mockReturnValue({ onAuth: vi.fn(), onPrompt: vi.fn() }); + mocks.loginOpenAICodex.mockResolvedValue(creds); + + const { prompter } = await runCodexOAuth({ isRemote: false }); + + expect(mocks.describePortOwner).toHaveBeenCalledWith(1455); + expect(mocks.loginOpenAICodex.mock.calls[0]?.[0]?.onManualCodeInput).toEqual( + expect.any(Function), + ); + expect(prompter.note).toHaveBeenCalledWith( + expect.stringContaining("Code Helper (Plugin)"), + "OpenAI Codex OAuth", + ); + }); + + it("enables manual fallback without owner details when describePortOwner returns undefined", async () => { + const creds = { + provider: "openai-codex" as const, + access: "a", + refresh: "r", + expires: Date.now() + 60_000, + email: "u@example.com", + }; + mocks.tryListenOnPort.mockRejectedValue( + Object.assign(new Error("address in use"), { code: "EADDRINUSE" }), + ); + mocks.describePortOwner.mockResolvedValue(undefined); + mocks.createVpsAwareOAuthHandlers.mockReturnValue({ onAuth: vi.fn(), onPrompt: vi.fn() }); + mocks.loginOpenAICodex.mockResolvedValue(creds); + + const { prompter } = await runCodexOAuth({ isRemote: false }); + + expect(mocks.loginOpenAICodex.mock.calls[0]?.[0]?.onManualCodeInput).toEqual( + expect.any(Function), + ); + expect(prompter.note).toHaveBeenCalledWith( + expect.stringContaining("localhost:1455"), + "OpenAI Codex OAuth", + ); + expect(prompter.note).not.toHaveBeenCalledWith( + expect.stringContaining("Port listener details:"), + "OpenAI Codex OAuth", + ); + }); + + it("ignores non-EADDRINUSE probe failures and proceeds without manual fallback", async () => { + const creds = { + provider: "openai-codex" as const, + access: "a", + refresh: "r", + expires: Date.now() + 60_000, + email: "u@example.com", + }; + mocks.tryListenOnPort.mockRejectedValue( + Object.assign(new Error("address not available"), { code: "EADDRNOTAVAIL" }), + ); + mocks.createVpsAwareOAuthHandlers.mockReturnValue({ onAuth: vi.fn(), onPrompt: vi.fn() }); + mocks.loginOpenAICodex.mockResolvedValue(creds); + + await runCodexOAuth({ isRemote: false }); + + expect(mocks.describePortOwner).not.toHaveBeenCalled(); + expect(mocks.loginOpenAICodex.mock.calls[0]?.[0]?.onManualCodeInput).toBeUndefined(); + }); + + it("skips port preflight in remote mode", async () => { + const creds = { + provider: "openai-codex" as const, + access: "a", + refresh: "r", + expires: Date.now() + 60_000, + email: "u@example.com", + }; + mocks.createVpsAwareOAuthHandlers.mockReturnValue({ onAuth: vi.fn(), onPrompt: vi.fn() }); + mocks.loginOpenAICodex.mockResolvedValue(creds); + + await runCodexOAuth({ isRemote: true }); + + expect(mocks.tryListenOnPort).not.toHaveBeenCalled(); + expect(mocks.describePortOwner).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/provider-openai-codex-oauth.ts b/src/plugins/provider-openai-codex-oauth.ts index 6e16cf863f0..d18181cb6fc 100644 --- a/src/plugins/provider-openai-codex-oauth.ts +++ b/src/plugins/provider-openai-codex-oauth.ts @@ -1,4 +1,7 @@ import { loginOpenAICodex, type OAuthCredentials } from "@mariozechner/pi-ai/oauth"; +import { isErrno } from "../infra/errors.js"; +import { tryListenOnPort } from "../infra/ports-probe.js"; +import { describePortOwner } from "../infra/ports.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js"; @@ -7,6 +10,35 @@ import { runOpenAIOAuthTlsPreflight, } from "./provider-openai-codex-oauth-tls.js"; +const OPENAI_CODEX_CALLBACK_PORT = 1455; + +/** Non-null return means the port is occupied; `owner` is the process name when available. */ +async function detectOpenAICodexCallbackPortConflict(): Promise<{ owner?: string } | null> { + try { + await tryListenOnPort({ + port: OPENAI_CODEX_CALLBACK_PORT, + host: "127.0.0.1", + exclusive: true, + }); + return null; + } catch (err) { + if (!isErrno(err) || err.code !== "EADDRINUSE") { + return null; + } + const owner = await describePortOwner(OPENAI_CODEX_CALLBACK_PORT); + return { owner: owner ?? undefined }; + } +} + +function buildOpenAICodexCallbackPortConflictNote(conflict: { owner?: string }): string { + return [ + `Detected another local process already listening on localhost:${OPENAI_CODEX_CALLBACK_PORT}.`, + "OpenAI Codex browser callback will not complete automatically in this state.", + "Finish sign-in in the browser, then paste the full redirect URL back here.", + ...(conflict.owner ? ["", "Port listener details:", conflict.owner] : []), + ].join("\n"); +} + export async function loginOpenAICodexOAuth(params: { prompter: WizardPrompter; runtime: RuntimeEnv; @@ -23,6 +55,8 @@ export async function loginOpenAICodexOAuth(params: { throw new Error(preflight.message); } + const callbackPortConflict = isRemote ? null : await detectOpenAICodexCallbackPortConflict(); + await prompter.note( isRemote ? [ @@ -30,13 +64,21 @@ export async function loginOpenAICodexOAuth(params: { "A URL will be shown for you to open in your LOCAL browser.", "After signing in, paste the redirect URL back here.", ].join("\n") - : [ - "Browser will open for OpenAI authentication.", - "If the callback doesn't auto-complete, paste the redirect URL.", - "OpenAI OAuth uses localhost:1455 for the callback.", - ].join("\n"), + : callbackPortConflict !== null + ? "Browser will open for OpenAI authentication." + : [ + "Browser will open for OpenAI authentication.", + "If the callback doesn't auto-complete, paste the redirect URL.", + "OpenAI OAuth uses localhost:1455 for the callback.", + ].join("\n"), "OpenAI Codex OAuth", ); + if (callbackPortConflict !== null) { + await prompter.note( + buildOpenAICodexCallbackPortConflictNote(callbackPortConflict), + "OpenAI Codex OAuth", + ); + } const spin = prompter.progress("Starting OAuth flow…"); try { @@ -53,6 +95,13 @@ export async function loginOpenAICodexOAuth(params: { onAuth: baseOnAuth, onPrompt, onProgress: (msg: string) => spin.update(msg), + onManualCodeInput: + callbackPortConflict !== null + ? () => + onPrompt({ + message: "Paste the authorization code (or full redirect URL):", + }) + : undefined, }); spin.stop("OpenAI OAuth complete"); return creds ?? null;