From 3c971255fa90a4cd2384a90849e33fe8c61282e8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 00:03:55 -0700 Subject: [PATCH] fix(auth): quiet codex oauth manual fallback --- CHANGELOG.md | 1 + .../provider-openai-codex-oauth.test.ts | 100 +++++++++++++++++- src/plugins/provider-openai-codex-oauth.ts | 44 ++++++-- 3 files changed, 131 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f6ce2fb540..afd8ba329b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ Docs: https://docs.openclaw.ai - Discord: clear stale startup probe bot/application status when the async bot probe throws, not just when it returns a degraded probe result. Thanks @vincentkoc. - Web search: scope explicit bundled `web_search` provider runtime loading through manifest ownership, so selecting DuckDuckGo/Gemini/etc. does not import unrelated bundled providers or log their optional dependency failures. Thanks @vincentkoc. - Plugins/discovery: demote the source-only TypeScript runtime check on already-installed `origin: "global"` plugin packages from a config-blocking error to a warning and let the runtime fall through to the TypeScript source via jiti, so a single broken installed package no longer blocks `plugins install` for unrelated plugins; install-time rejection of newly-installed source-only packages is unchanged. Thanks @romneyda. +- Providers/OpenAI Codex: stop the OAuth progress spinner before showing the manual redirect paste prompt, so callback timeouts do not spam `Browser callback did not finish` across terminals. - Release/beta smoke: resolve the dispatched Telegram beta E2E run from `gh run list` when `gh workflow run` returns no run URL, so the maintainer helper does not fail immediately after dispatch. Thanks @vincentkoc. - Media/images: keep HEIC/HEIF attachments fail-closed when optional Sharp conversion is unavailable instead of sending originals that still need conversion. Thanks @vincentkoc. - Google Meet: fork the caller's current agent transcript into agent-mode meeting consultant sessions, so Meet replies inherit the context from the tool call that joined the meeting. diff --git a/src/plugins/provider-openai-codex-oauth.test.ts b/src/plugins/provider-openai-codex-oauth.test.ts index 427e0a21624..09103bae9d6 100644 --- a/src/plugins/provider-openai-codex-oauth.test.ts +++ b/src/plugins/provider-openai-codex-oauth.test.ts @@ -34,12 +34,13 @@ type CodexLoginOptions = { function createPrompter() { const spin = { update: vi.fn(), stop: vi.fn() }; + const text = vi.fn(async () => "http://localhost:1455/auth/callback?code=test"); const prompter: Pick = { note: vi.fn(async () => {}), progress: vi.fn(() => spin), - text: vi.fn(async () => "http://localhost:1455/auth/callback?code=test"), + text, }; - return { prompter: prompter as unknown as WizardPrompter, spin }; + return { prompter: prompter as unknown as WizardPrompter, spin, text }; } function createRuntime(): RuntimeEnv { @@ -222,7 +223,7 @@ describe("loginOpenAICodexOAuth", () => { it("waits briefly before prompting for manual input after the local browser flow starts", async () => { vi.useFakeTimers(); - const { prompter } = createPrompter(); + const { prompter, spin, text } = createPrompter(); const runtime = createRuntime(); mocks.loginOpenAICodex.mockImplementation(async (opts: CodexLoginOptions) => { await startCodexAuth(opts); @@ -252,6 +253,54 @@ describe("loginOpenAICodexOAuth", () => { message: "Paste the authorization code (or full redirect URL):", validate: expect.any(Function), }); + expect(spin.stop).toHaveBeenCalledWith("Manual OAuth entry required"); + expect(spin.stop.mock.invocationCallOrder[0]).toBeLessThan( + text.mock.invocationCallOrder[0] ?? 0, + ); + expect(runtime.log).toHaveBeenCalledWith( + "OpenAI Codex OAuth callback did not arrive within 15000ms; switching to manual entry (callback_timeout).", + ); + vi.useRealTimers(); + }); + + it("reuses one local manual prompt when the oauth helper repeats fallback calls", async () => { + vi.useFakeTimers(); + const { prompter, spin, text } = createPrompter(); + const runtime = createRuntime(); + mocks.loginOpenAICodex.mockImplementation(async (opts: CodexLoginOptions) => { + await startCodexAuth(opts); + const firstManualPromise = opts.onManualCodeInput?.(); + const secondManualPromise = opts.onManualCodeInput?.(); + await vi.advanceTimersByTimeAsync(16_000); + const [firstManualCode, secondManualCode] = await Promise.all([ + firstManualPromise, + secondManualPromise, + ]); + expect(secondManualCode).toBe(firstManualCode); + return createCodexCredentials({ manualCode: firstManualCode }); + }); + + await expect( + loginOpenAICodexOAuth({ + prompter, + runtime, + isRemote: false, + openUrl: async () => {}, + }), + ).resolves.toMatchObject({ + access: "access-token", + refresh: "refresh-token", + }); + + expect(text).toHaveBeenCalledOnce(); + expect(spin.stop).toHaveBeenCalledWith("Manual OAuth entry required"); + expect( + spin.update.mock.calls.filter( + ([message]) => + message === "Browser callback did not finish. Paste the redirect URL to continue…", + ), + ).toHaveLength(1); + expect(runtime.log).toHaveBeenCalledTimes(2); expect(runtime.log).toHaveBeenCalledWith( "OpenAI Codex OAuth callback did not arrive within 15000ms; switching to manual entry (callback_timeout).", ); @@ -326,7 +375,7 @@ describe("loginOpenAICodexOAuth", () => { it("prompts for manual input immediately when the local callback flow never starts", async () => { vi.useFakeTimers(); - const { prompter } = createPrompter(); + const { prompter, spin, text } = createPrompter(); const runtime = createRuntime(); mocks.loginOpenAICodex.mockImplementation( async (opts: { onManualCodeInput?: () => Promise }) => { @@ -352,6 +401,49 @@ describe("loginOpenAICodexOAuth", () => { message: "Paste the authorization code (or full redirect URL):", validate: expect.any(Function), }); + expect(spin.stop).toHaveBeenCalledWith("Manual OAuth entry required"); + expect(spin.stop.mock.invocationCallOrder[0]).toBeLessThan( + text.mock.invocationCallOrder[0] ?? 0, + ); + expect(vi.getTimerCount()).toBe(0); + vi.useRealTimers(); + }); + + it("reuses one immediate manual prompt when the local callback flow never starts", async () => { + vi.useFakeTimers(); + const { prompter, spin, text } = createPrompter(); + const runtime = createRuntime(); + mocks.loginOpenAICodex.mockImplementation(async (opts: CodexLoginOptions) => { + expect(opts.onManualCodeInput).toBeTypeOf("function"); + const [firstManualCode, secondManualCode] = await Promise.all([ + opts.onManualCodeInput?.(), + opts.onManualCodeInput?.(), + ]); + expect(secondManualCode).toBe(firstManualCode); + return createCodexCredentials({ manualCode: firstManualCode }); + }); + + await expect( + loginOpenAICodexOAuth({ + prompter, + runtime, + isRemote: false, + openUrl: async () => {}, + }), + ).resolves.toMatchObject({ + access: "access-token", + refresh: "refresh-token", + }); + + expect(text).toHaveBeenCalledOnce(); + expect(spin.stop).toHaveBeenCalledWith("Manual OAuth entry required"); + expect( + spin.update.mock.calls.filter( + ([message]) => + message === "Local OAuth callback was unavailable. Paste the redirect URL to continue…", + ), + ).toHaveLength(1); + expect(runtime.log).toHaveBeenCalledTimes(1); expect(vi.getTimerCount()).toBe(0); vi.useRealTimers(); }); diff --git a/src/plugins/provider-openai-codex-oauth.ts b/src/plugins/provider-openai-codex-oauth.ts index fc41bf37d22..dd286dbbbda 100644 --- a/src/plugins/provider-openai-codex-oauth.ts +++ b/src/plugins/provider-openai-codex-oauth.ts @@ -77,25 +77,30 @@ function createManualCodeInputHandler(params: { isRemote: boolean; onPrompt: (prompt: OAuthPrompt) => Promise; runtime: RuntimeEnv; - spin: ReturnType; + updateProgress: (message: string) => void; + stopProgress: (message?: string) => void; waitForLoginToSettle: Promise; hasBrowserAuthStarted: () => boolean; }): (() => Promise) | undefined { + let manualFallbackPromise: Promise | undefined; if (params.isRemote) { - return async () => - await params.onPrompt({ + return async () => { + manualFallbackPromise ??= params.onPrompt({ message: manualInputPromptMessage, }); + return await manualFallbackPromise; + }; } - return async () => { + const runLocalManualFallback = async () => { if (!params.hasBrowserAuthStarted()) { - params.spin.update( + params.updateProgress( "Local OAuth callback was unavailable. Paste the redirect URL to continue…", ); params.runtime.log( "OpenAI Codex OAuth local callback did not start; switching to manual entry immediately.", ); + params.stopProgress("Manual OAuth entry required"); return await params.onPrompt({ message: manualInputPromptMessage, }); @@ -121,14 +126,20 @@ function createManualCodeInputHandler(params: { return await createNeverSettlingPromptResult(); } - params.spin.update("Browser callback did not finish. Paste the redirect URL to continue…"); + params.updateProgress("Browser callback did not finish. Paste the redirect URL to continue…"); params.runtime.log( `OpenAI Codex OAuth callback did not arrive within ${localManualFallbackDelayMs}ms; switching to manual entry (callback_timeout).`, ); + params.stopProgress("Manual OAuth entry required"); return await params.onPrompt({ message: manualInputPromptMessage, }); }; + + return async () => { + manualFallbackPromise ??= runLocalManualFallback(); + return await manualFallbackPromise; + }; } export async function loginOpenAICodexOAuth(params: { @@ -166,6 +177,18 @@ export async function loginOpenAICodexOAuth(params: { ); const spin = prompter.progress("Starting OAuth flow…"); + let progressActive = true; + const updateProgress = (message: string) => { + if (progressActive) { + spin.update(message); + } + }; + const stopProgress = (message?: string) => { + if (progressActive) { + progressActive = false; + spin.stop(message); + } + }; let browserAuthStarted = false; let markLoginSettled!: () => void; const waitForLoginToSettle = new Promise((resolve) => { @@ -194,16 +217,17 @@ export async function loginOpenAICodexOAuth(params: { isRemote, onPrompt, runtime, - spin, + updateProgress, + stopProgress, waitForLoginToSettle, hasBrowserAuthStarted: () => browserAuthStarted, }), - onProgress: (msg: string) => spin.update(msg), + onProgress: (msg: string) => updateProgress(msg), }); - spin.stop("OpenAI OAuth complete"); + stopProgress("OpenAI OAuth complete"); return creds ?? null; } catch (err) { - spin.stop("OpenAI OAuth failed"); + stopProgress("OpenAI OAuth failed"); const rewrittenError = rewriteOpenAICodexOAuthError(err); runtime.error(String(rewrittenError)); await prompter.note("Trouble with OAuth? See https://docs.openclaw.ai/start/faq", "OAuth help");