FN-5917: fix Codex OAuth option selection

Prevent multi-option OAuth prompts from cancelling Codex browser login.

- add OAuth option selection logic that prefers the browser flow for openai-codex prompts
- fall back to default-labelled or first options for other multi-option OAuth prompts
- add API route coverage for Codex multi-option, single-option, and generic default-labelled prompt handling

Files changed:
 .changeset/fn-5917-codex-oauth-login.md            |  5 ++
 packages/dashboard/src/__tests__/routes-auth.test.ts    | 72 ++++++++++++++++++++++
 packages/dashboard/src/routes/register-auth-routes.ts   | 26 ++++++--
 3 files changed, 97 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-5917

Fusion-Task-Lineage: 3b069280-9dbf-47ee-ae04-b7c54ff14d63
This commit is contained in:
gsxdsm
2026-06-02 18:56:36 -07:00
parent cf23c6f571
commit 3d18872f98
3 changed files with 97 additions and 6 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix the dashboard OAuth login flow for ChatGPT Plus/Pro (Codex Subscription) so multi-option provider selection prompts no longer cancel the login before browser auth starts.

View File

@@ -1499,6 +1499,78 @@ describe("POST /auth/login", () => {
expect(observedPromptInput).toBe("manual-code"); expect(observedPromptInput).toBe("manual-code");
}); });
it("prefers browser login for openai-codex multi-option prompts", async () => {
let selectedOption: string | undefined;
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockReturnValue([{ id: "openai-codex", name: "OpenAI Codex" }]);
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation(async (_provider: string, callbacks: any) => {
selectedOption = await callbacks.onSelect({
message: "Select OpenAI Codex login method:",
options: [
{ id: "browser", label: "Browser login (default)" },
{ id: "device_code", label: "Device code login (headless)" },
],
});
if (!selectedOption) {
throw new Error("Login cancelled");
}
callbacks.onAuth({
url: "https://auth.openai.com/oauth/authorize?state=test-state&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback",
});
});
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({ provider: "openai-codex" }), {
"Content-Type": "application/json",
});
expect(selectedOption).toBe("browser");
expect(res.status).toBe(200);
expect(res.body.url).toBe(
"https://auth.openai.com/oauth/authorize?state=test-state&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback",
);
});
it("keeps returning the only option id for single-option prompts", async () => {
let selectedOption: string | undefined;
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockReturnValue([{ id: "openai-codex", name: "OpenAI Codex" }]);
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation(async (_provider: string, callbacks: any) => {
selectedOption = await callbacks.onSelect({
message: "Only one choice",
options: [{ id: "browser", label: "Browser login" }],
});
callbacks.onAuth({ url: "https://auth.example.com/login" });
});
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({ provider: "openai-codex" }), {
"Content-Type": "application/json",
});
expect(selectedOption).toBe("browser");
expect(res.status).toBe(200);
});
it("prefers the default-labelled option for generic multi-option prompts", async () => {
let selectedOption: string | undefined;
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockReturnValue([{ id: "anthropic", name: "Anthropic" }]);
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation(async (_provider: string, callbacks: any) => {
selectedOption = await callbacks.onSelect({
message: "Select login method:",
options: [
{ id: "device_code", label: "Device code login" },
{ id: "browser", label: "Browser login (DEFAULT)" },
{ id: "manual", label: "Manual login" },
],
});
callbacks.onAuth({ url: "https://claude.ai/oauth/authorize?state=test-state&redirect_uri=http%3A%2F%2Flocalhost%3A3210%2Fauth%2Fcallback" });
});
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({ provider: "anthropic" }), {
"Content-Type": "application/json",
});
expect(selectedOption).toBe("browser");
expect(res.status).toBe(200);
});
it("returns 400 when provider is missing", async () => { it("returns 400 when provider is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({}), { const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({}), {
"Content-Type": "application/json", "Content-Type": "application/json",

View File

@@ -189,6 +189,25 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
return providerId === "github-copilot"; return providerId === "github-copilot";
} }
function selectOauthOption(
providerId: string,
prompt: { options: Array<{ id: string; label?: string }> },
): string | undefined {
if (prompt.options.length === 1) {
return prompt.options[0]?.id;
}
const defaultLabeledOption = prompt.options.find((option) => /\(default\)/i.test(option.label ?? ""));
// FN-5917: returning undefined here caused pi-ai's openai-codex login
// flow to throw "Login cancelled" before it could open browser auth.
if (providerId === "openai-codex") {
return prompt.options.find((option) => option.id === "browser")?.id ?? defaultLabeledOption?.id ?? prompt.options[0]?.id;
}
return defaultLabeledOption?.id ?? prompt.options[0]?.id;
}
async function probeDroidCliWithEffectiveBinary(req?: Request) { async function probeDroidCliWithEffectiveBinary(req?: Request) {
let pluginSettings: Record<string, unknown> | undefined; let pluginSettings: Record<string, unknown> | undefined;
if (req) { if (req) {
@@ -866,12 +885,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
// to race pasted codes against the localhost callback server. // to race pasted codes against the localhost callback server.
onManualCodeInput: async () => await pendingLogin.inputPromise, onManualCodeInput: async () => await pendingLogin.inputPromise,
onProgress: () => {}, // no-op for web UI onProgress: () => {}, // no-op for web UI
onSelect: async (prompt) => { onSelect: async (prompt) => selectOauthOption(provider, prompt),
if (prompt.options.length === 1) {
return prompt.options[0]?.id;
}
return undefined;
},
signal: abortController.signal, signal: abortController.signal,
}); });