FN-7473: open desktop Anthropic OAuth in browser

Route desktop OAuth popups to the system browser while preserving Anthropic subscription login polling.\n\n- Add Electron window-open handling that keeps internal Fusion navigation in-app and external http(s) OAuth URLs in the system browser.\n- Cover Anthropic Subscription polling so login-in-progress states do not show a false incomplete-login toast.\n- Document desktop OAuth browser handoff behavior and add a patch changeset.\n\nFiles changed:\n .../fn-7473-desktop-anthropic-oauth-browser.md     |  7 ++\n docs/dashboard-guide.md                            |  2 +-\n docs/settings-reference.md                         |  2 +-\n .../__tests__/SettingsModal.models-auth.test.tsx   | 76 ++++++++++++++++++++++\n .../desktop/src/__tests__/main-integration.test.ts |  2 +\n .../desktop/src/__tests__/main-local-mode.test.ts  |  3 +-\n .../desktop/src/__tests__/main.integration.test.ts |  2 +\n packages/desktop/src/__tests__/main.test.ts        | 54 ++++++++++++++-\n packages/desktop/src/main.ts                       | 60 +++++++++++++++++-\n 9 files changed, 203 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-7473

Fusion-Task-Lineage: 3d6b0acd-536c-4f7d-8c6b-35eb519b46bb

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-03 16:23:42 -07:00
parent 315f3bc32c
commit ced783ea79
9 changed files with 203 additions and 5 deletions

View File

@@ -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.

View File

@@ -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. 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. 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.

View File

@@ -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. - **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 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) ### Authentication troubleshooting (mobile OAuth fallback)

View File

@@ -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(<SettingsModal onClose={noop} addToast={addToast} />);
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(<SettingsModal onClose={noop} addToast={addToast} />);
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 () => { it("renders Anthropic pasted-code form when login response includes manualCode", async () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
mockFetchAuthStatus.mockResolvedValueOnce({ mockFetchAuthStatus.mockResolvedValueOnce({

View File

@@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => {
show: vi.fn(), show: vi.fn(),
focus: vi.fn(), focus: vi.fn(),
maximize: vi.fn(), maximize: vi.fn(),
webContents: { setWindowOpenHandler: vi.fn() },
isDestroyed: vi.fn(() => false), isDestroyed: vi.fn(() => false),
getBounds: vi.fn(() => ({ x: 50, y: 80, width: 1280, height: 900 })), getBounds: vi.fn(() => ({ x: 50, y: 80, width: 1280, height: 900 })),
isMaximized: vi.fn(() => false), isMaximized: vi.fn(() => false),
@@ -169,6 +170,7 @@ vi.mock("electron", () => ({
Tray: mocks.Tray, Tray: mocks.Tray,
nativeImage: mocks.nativeImage, nativeImage: mocks.nativeImage,
screen: mocks.screen, screen: mocks.screen,
shell: { openExternal: vi.fn(() => Promise.resolve()) },
})); }));
vi.mock("../menu.js", () => ({ vi.mock("../menu.js", () => ({

View File

@@ -24,7 +24,7 @@ const mocks = vi.hoisted(() => {
show: vi.fn(), show: vi.fn(),
focus: vi.fn(), focus: vi.fn(),
maximize: vi.fn(), maximize: vi.fn(),
webContents: { send: vi.fn() }, webContents: { send: vi.fn(), setWindowOpenHandler: vi.fn() },
}; };
const BrowserWindow = vi.fn(function () { const BrowserWindow = vi.fn(function () {
@@ -70,6 +70,7 @@ vi.mock("electron", () => ({
Tray: mocks.Tray, Tray: mocks.Tray,
nativeImage: { createEmpty: vi.fn(() => ({})) }, nativeImage: { createEmpty: vi.fn(() => ({})) },
screen: mocks.screen, 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") })); vi.mock("../renderer.js", () => ({ isUrlRenderer: vi.fn(() => true), getRendererUrl: vi.fn(() => "http://localhost"), getRendererFilePath: vi.fn(() => "index.html") }));

View File

@@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => {
show: vi.fn(), show: vi.fn(),
focus: vi.fn(), focus: vi.fn(),
maximize: vi.fn(), maximize: vi.fn(),
webContents: { setWindowOpenHandler: vi.fn() },
}; };
return { return {
@@ -59,6 +60,7 @@ vi.mock("electron", () => ({
Tray: mocks.Tray, Tray: mocks.Tray,
nativeImage: mocks.nativeImage, nativeImage: mocks.nativeImage,
screen: mocks.screen, screen: mocks.screen,
shell: { openExternal: vi.fn(() => Promise.resolve()) },
})); }));
vi.mock("../menu.js", () => ({ vi.mock("../menu.js", () => ({

View File

@@ -42,7 +42,12 @@ const mocks = vi.hoisted(() => {
hide: vi.fn(), hide: vi.fn(),
close: vi.fn(), close: vi.fn(),
maximize: 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 () { const BrowserWindow = vi.fn(function () {
@@ -326,6 +331,53 @@ describe("main process", () => {
expect(mocks.browserWindowInstance.loadURL).not.toHaveBeenCalled(); 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 () => { it("exports initializeApp for lifecycle orchestration", async () => {
const mainModule = await importMainModule(); const mainModule = await importMainModule();

View File

@@ -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 { join, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import os from "node:os"; import os from "node:os";
@@ -99,6 +99,37 @@ export function getCurrentDesktopLaunchMode(): DesktopLaunchMode {
return currentDesktopLaunchMode; 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<void> { async function resetLaunchModeAndReload(window: BrowserWindow): Promise<void> {
try { try {
const settings = await readShellSettings(); 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) { if (launchTargetUrl) {
void window.loadURL(launchTargetUrl); void window.loadURL(launchTargetUrl);
} else if (isUrlRenderer()) { } else if (isUrlRenderer()) {