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()) {