diff --git a/.changeset/fn-7473-desktop-anthropic-oauth-browser.md b/.changeset/fn-7473-desktop-anthropic-oauth-browser.md new file mode 100644 index 0000000000..0ba9284e63 --- /dev/null +++ b/.changeset/fn-7473-desktop-anthropic-oauth-browser.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Open desktop Anthropic Subscription OAuth logins in the system browser. +category: fix +dev: Adds Electron window-open policy coverage and preserves Settings auth polling completion paths. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index c993aee024..a4c3880e82 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -638,7 +638,7 @@ The global OAuth re-login banner clears a provider row immediately after that pr For Claude/Anthropic OAuth credentials, the same `/auth/status` poll also attempts an automatic refresh when the stored OAuth credential has a refresh token and the access token is expired or within the refresh buffer. Anthropic banner state is keyed to `anthropic-subscription` (including legacy Anthropic OAuth rows), not Claude CLI state. When that refresh succeeds, the banner clears for the subscription provider without manual re-login and without waiting for a separate model request. -If the OAuth credential has no refresh token, the refresh request fails, or the provider is not Anthropic, the provider stays expired and the banner remains visible. Re-authenticate with manual re-login from **Settings → Authentication** or Model Onboarding. +If the OAuth credential has no refresh token, the refresh request fails, or the provider is not Anthropic, the provider stays expired and the banner remains visible. Re-authenticate with manual re-login from **Settings → Authentication** or Model Onboarding. On Fusion desktop, OAuth login URLs open in the operating system browser rather than an in-app Electron child window; the Settings/Onboarding UI keeps polling until the provider authenticates or the login truly stops. Anthropic also supports a raw `ANTHROPIC_API_KEY` from a separate **Anthropic API Key** card in **Settings → Authentication** and Model Onboarding. Claude subscription OAuth remains on the **Anthropic Subscription** card for auth status, usage/subscription checks, and banner clearing; it also drives direct agent execution on the `anthropic` provider — a subscription/OAuth token runs `anthropic/*` selections against `https://api.anthropic.com/v1` with Claude Code identity headers, no API key required. CLI-backed execution remains the distinct, explicit **Claude CLI** provider (`pi-claude-cli`); subscription OAuth does not require it. When Anthropic Subscription is expired but Anthropic API Key or Anthropic — via Claude CLI is already authenticated, the global banner suppresses only the urgent subscription re-login entry so it does not imply agents are blocked; Settings still shows the subscription OAuth card as expired/not connected and re-login remains available. A configured API key takes precedence over OAuth on the direct provider. Saving or clearing an API key does not affect the OAuth sign-in path or turn OAuth tokens into raw API-key material. The dashboard only displays masked key hints after a key is saved. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 33ef7436ad..44ed8b961e 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -755,7 +755,7 @@ Anthropic has three independent authentication/routing paths: - **Claude CLI** (`pi-claude-cli`) is the CLI-backed execution provider. Use it when you want sessions to run through the local `claude` CLI; CLI availability does not prove the subscription OAuth status is valid. It is a separate, explicit choice — subscription OAuth does not require or reroute to it. When Claude CLI is enabled, model selectors show the registered `pi-claude-cli/*` rows (for example `pi-claude-cli/claude-sonnet-5`) in addition to the direct `anthropic/*` rows. - **Anthropic API Key** (`anthropic` direct `/v1`) is raw API-key auth. It accepts `ANTHROPIC_API_KEY`, a `models.json` `apiKey`, or an `api_key` auth credential, uses `x-api-key`, and takes precedence over subscription OAuth when both are configured for the direct `https://api.anthropic.com/v1` provider. -Anthropic can be connected with a raw API key from both Model Onboarding and **Settings → Authentication**. Anthropic API-key auth appears as a separate **Anthropic API Key** card, while Claude subscription OAuth appears as **Anthropic Subscription** with Login/Logout controls. `/api/auth/status` returns only masked key hints for the API-key card. +Anthropic can be connected with a raw API key from both Model Onboarding and **Settings → Authentication**. Anthropic API-key auth appears as a separate **Anthropic API Key** card, while Claude subscription OAuth appears as **Anthropic Subscription** with Login/Logout controls. On Fusion desktop, Anthropic Subscription OAuth login URLs are delegated to the operating system browser instead of an Electron child window so the existing polling/callback flow can complete. `/api/auth/status` returns only masked key hints for the API-key card. ### Authentication troubleshooting (mobile OAuth fallback) diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx index 9f6d345c74..d450f9c3da 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx @@ -1133,6 +1133,82 @@ describe("SettingsModal", () => { }); }); + it("keeps polling Anthropic Subscription OAuth until authenticated without false incomplete toast", async () => { + const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); + const addToast = vi.fn(); + mockFetchAuthStatus + .mockResolvedValueOnce({ + providers: [{ id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth" }], + }) + .mockResolvedValueOnce({ + providers: [{ id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth", loginInProgress: true }], + }) + .mockResolvedValueOnce({ + providers: [{ id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: true, type: "oauth", loginInProgress: false }], + }); + mockLoginProvider.mockResolvedValueOnce({ url: "https://claude.ai/oauth/authorize" }); + + render(); + await waitForSettingsModalReady(); + await settingsModalUser.click(screen.getByRole("button", { name: "Authentication" })); + vi.useFakeTimers(); + + try { + const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic-subscription").closest(".auth-provider-card") as HTMLElement; + fireEvent.click(within(anthropicCard).getByRole("button", { name: "Login" })); + + await act(async () => { + await Promise.resolve(); + }); + expect(openSpy).toHaveBeenCalledWith("https://claude.ai/oauth/authorize", "_blank"); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + await vi.advanceTimersByTimeAsync(2000); + }); + + expect(addToast).toHaveBeenCalledWith("Login successful", "success"); + expect(addToast).not.toHaveBeenCalledWith("Login did not complete. Please try again.", "error"); + } finally { + vi.useRealTimers(); + } + }); + + it("shows incomplete toast when Anthropic Subscription OAuth stops without authentication", async () => { + const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); + const addToast = vi.fn(); + mockFetchAuthStatus + .mockResolvedValueOnce({ + providers: [{ id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth" }], + }) + .mockResolvedValueOnce({ + providers: [{ id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth", loginInProgress: false }], + }); + mockLoginProvider.mockResolvedValueOnce({ url: "https://claude.ai/oauth/authorize" }); + + render(); + await waitForSettingsModalReady(); + await settingsModalUser.click(screen.getByRole("button", { name: "Authentication" })); + vi.useFakeTimers(); + + try { + const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic-subscription").closest(".auth-provider-card") as HTMLElement; + fireEvent.click(within(anthropicCard).getByRole("button", { name: "Login" })); + + await act(async () => { + await Promise.resolve(); + }); + expect(openSpy).toHaveBeenCalledWith("https://claude.ai/oauth/authorize", "_blank"); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + + expect(addToast).toHaveBeenCalledWith("Login did not complete. Please try again.", "error"); + expect(addToast).not.toHaveBeenCalledWith("Login successful", "success"); + } finally { + vi.useRealTimers(); + } + }); + it("renders Anthropic pasted-code form when login response includes manualCode", async () => { const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); mockFetchAuthStatus.mockResolvedValueOnce({ diff --git a/packages/desktop/src/__tests__/main-integration.test.ts b/packages/desktop/src/__tests__/main-integration.test.ts index 85cbe0501b..800b149717 100644 --- a/packages/desktop/src/__tests__/main-integration.test.ts +++ b/packages/desktop/src/__tests__/main-integration.test.ts @@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => { show: vi.fn(), focus: vi.fn(), maximize: vi.fn(), + webContents: { setWindowOpenHandler: vi.fn() }, isDestroyed: vi.fn(() => false), getBounds: vi.fn(() => ({ x: 50, y: 80, width: 1280, height: 900 })), isMaximized: vi.fn(() => false), @@ -169,6 +170,7 @@ vi.mock("electron", () => ({ Tray: mocks.Tray, nativeImage: mocks.nativeImage, screen: mocks.screen, + shell: { openExternal: vi.fn(() => Promise.resolve()) }, })); vi.mock("../menu.js", () => ({ diff --git a/packages/desktop/src/__tests__/main-local-mode.test.ts b/packages/desktop/src/__tests__/main-local-mode.test.ts index 61334a98df..288d7c599a 100644 --- a/packages/desktop/src/__tests__/main-local-mode.test.ts +++ b/packages/desktop/src/__tests__/main-local-mode.test.ts @@ -24,7 +24,7 @@ const mocks = vi.hoisted(() => { show: vi.fn(), focus: vi.fn(), maximize: vi.fn(), - webContents: { send: vi.fn() }, + webContents: { send: vi.fn(), setWindowOpenHandler: vi.fn() }, }; const BrowserWindow = vi.fn(function () { @@ -70,6 +70,7 @@ vi.mock("electron", () => ({ Tray: mocks.Tray, nativeImage: { createEmpty: vi.fn(() => ({})) }, screen: mocks.screen, + shell: { openExternal: vi.fn(() => Promise.resolve()) }, })); vi.mock("../renderer.js", () => ({ isUrlRenderer: vi.fn(() => true), getRendererUrl: vi.fn(() => "http://localhost"), getRendererFilePath: vi.fn(() => "index.html") })); diff --git a/packages/desktop/src/__tests__/main.integration.test.ts b/packages/desktop/src/__tests__/main.integration.test.ts index ad1335b75e..8758b8b1c9 100644 --- a/packages/desktop/src/__tests__/main.integration.test.ts +++ b/packages/desktop/src/__tests__/main.integration.test.ts @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => { show: vi.fn(), focus: vi.fn(), maximize: vi.fn(), + webContents: { setWindowOpenHandler: vi.fn() }, }; return { @@ -59,6 +60,7 @@ vi.mock("electron", () => ({ Tray: mocks.Tray, nativeImage: mocks.nativeImage, screen: mocks.screen, + shell: { openExternal: vi.fn(() => Promise.resolve()) }, })); vi.mock("../menu.js", () => ({ diff --git a/packages/desktop/src/__tests__/main.test.ts b/packages/desktop/src/__tests__/main.test.ts index 9bd54c3b6f..b10108edf3 100644 --- a/packages/desktop/src/__tests__/main.test.ts +++ b/packages/desktop/src/__tests__/main.test.ts @@ -42,7 +42,12 @@ const mocks = vi.hoisted(() => { hide: vi.fn(), close: vi.fn(), maximize: vi.fn(), - webContents: { reload: vi.fn() }, + webContents: { + reload: vi.fn(), + setWindowOpenHandler: vi.fn((handler: (details: { url: string }) => unknown) => { + browserWindowHandlers.set("window-open", handler as (...args: unknown[]) => void); + }), + }, }; const BrowserWindow = vi.fn(function () { @@ -326,6 +331,53 @@ describe("main process", () => { expect(mocks.browserWindowInstance.loadURL).not.toHaveBeenCalled(); }); + it("macOS Anthropic Subscription OAuth denies Claude app-like popup and opens system browser", async () => { + const authUrl = "https://claude.ai/oauth/authorize?client_id=fusion&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fcallback"; + const { createMainWindow } = await importMainModule(); + + createMainWindow(); + const handler = mocks.browserWindowHandlers.get("window-open") as + | ((details: { url: string }) => { action: "allow" | "deny" }) + | undefined; + + expect(handler).toBeTypeOf("function"); + const result = handler?.({ url: authUrl }); + + expect(result).toEqual({ action: "deny" }); + expect(mocks.shell.openExternal).toHaveBeenCalledTimes(1); + expect(mocks.shell.openExternal).toHaveBeenCalledWith(authUrl); + }); + + it("keeps same-origin Fusion renderer popups inside the desktop app", async () => { + rendererMocks.isUrlRenderer.mockReturnValue(true); + rendererMocks.getRendererUrl.mockReturnValue("http://localhost:5173"); + rendererMocks.getRendererFilePath.mockReturnValue(""); + const { createMainWindow } = await importMainModule(); + + createMainWindow(); + const handler = mocks.browserWindowHandlers.get("window-open") as + | ((details: { url: string }) => { action: "allow" | "deny" }) + | undefined; + + const result = handler?.({ url: "http://localhost:5173/settings?section=auth" }); + + expect(result).toEqual({ action: "allow" }); + expect(mocks.shell.openExternal).not.toHaveBeenCalled(); + }); + + it("does not externalize Fusion deep links or unsafe custom window-open schemes", async () => { + const { createMainWindow } = await importMainModule(); + + createMainWindow(); + const handler = mocks.browserWindowHandlers.get("window-open") as + | ((details: { url: string }) => { action: "allow" | "deny" }) + | undefined; + + expect(handler?.({ url: "fusion://task/FN-7473" })).toEqual({ action: "deny" }); + expect(handler?.({ url: "claude://oauth/callback?code=abc" })).toEqual({ action: "deny" }); + expect(mocks.shell.openExternal).not.toHaveBeenCalled(); + }); + it("exports initializeApp for lifecycle orchestration", async () => { const mainModule = await importMainModule(); diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 26b31067d8..1b5e1d57a0 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, nativeImage, screen, Tray } from "electron"; +import { app, BrowserWindow, nativeImage, screen, shell, Tray } from "electron"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import os from "node:os"; @@ -99,6 +99,37 @@ export function getCurrentDesktopLaunchMode(): DesktopLaunchMode { return currentDesktopLaunchMode; } +function isLoopbackHost(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]"; +} + +function getDesktopRendererOrigin(launchTargetUrl?: string): string | null { + const candidateUrl = launchTargetUrl ?? (isUrlRenderer() ? getRendererUrl() : `file://${getRendererFilePath()}`); + try { + return new URL(candidateUrl).origin; + } catch { + return null; + } +} + +function isInternalDesktopNavigation(url: URL, rendererOrigin: string | null): boolean { + if (url.protocol === "file:") { + return true; + } + + if ((url.protocol === "http:" || url.protocol === "https:") && rendererOrigin && url.origin === rendererOrigin) { + return true; + } + + // Local runtime/dev dashboard URLs represent Fusion app navigation, not external OAuth destinations. + if ((url.protocol === "http:" || url.protocol === "https:") && isLoopbackHost(url.hostname)) { + return true; + } + + return false; +} + async function resetLaunchModeAndReload(window: BrowserWindow): Promise { try { const settings = await readShellSettings(); @@ -148,6 +179,33 @@ export function createMainWindow(state?: WindowState, launchTargetUrl?: string): }, }); + const rendererOrigin = getDesktopRendererOrigin(launchTargetUrl); + window.webContents.setWindowOpenHandler(({ url }) => { + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + return { action: "deny" }; + } + + if (isInternalDesktopNavigation(parsedUrl, rendererOrigin)) { + return { action: "allow" }; + } + + if (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:") { + /* + FNXC:DesktopOAuth 2026-07-03-15:55: + macOS Anthropic Subscription login must leave the Electron popup path and open in the user's system browser instead of a child BrowserWindow or Claude Code app handoff. Keep Fusion polling in the renderer while denying the popup so the OAuth callback can complete the existing login state. + */ + void shell.openExternal(url).catch((error: unknown) => { + console.error(`[desktop/main] Failed to open external URL: ${url}`, error); + }); + return { action: "deny" }; + } + + return { action: "deny" }; + }); + if (launchTargetUrl) { void window.loadURL(launchTargetUrl); } else if (isUrlRenderer()) {