diff --git a/.changeset/desktop-oauth-open-external.md b/.changeset/desktop-oauth-open-external.md
new file mode 100644
index 0000000000..c5a1b10d9c
--- /dev/null
+++ b/.changeset/desktop-oauth-open-external.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: OAuth sign-ins (OpenAI Codex and others) now reliably open the system browser from the desktop app.
+category: fix
+dev: "window.open after the /auth/login await can outlive Chromium's transient user activation (~5s) and get silently popup-blocked on desktop — Codex's slower flow (method select + localhost callback server) hit this while Anthropic's usually didn't. New shell:openExternal IPC (http/https-validated) + preload openExternal + dashboard openExternalUrl helper used by all auth-URL opens, with window.open fallback on web."
diff --git a/packages/dashboard/app/components/ModelOnboardingModal.tsx b/packages/dashboard/app/components/ModelOnboardingModal.tsx
index 9a60e92caf..d7ed7e06bd 100644
--- a/packages/dashboard/app/components/ModelOnboardingModal.tsx
+++ b/packages/dashboard/app/components/ModelOnboardingModal.tsx
@@ -35,6 +35,7 @@ import { OnboardingDisclosure } from "./OnboardingDisclosure";
import { CustomProviderForm } from "./CustomProviderForm";
import { PluginSlot } from "./PluginSlot";
import { appendTokenQuery, OAUTH_RELOGIN_SUCCESS_EVENT } from "../auth";
+import { openExternalUrl } from "../utils/open-external";
import { copyTextToClipboard } from "../utils/copyToClipboard";
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
import { useShellConnection } from "../hooks/useShellConnection";
@@ -1441,7 +1442,7 @@ export function ModelOnboardingModal({
setDeviceCodes((prev) => ({ ...prev, [providerId]: deviceCode }));
}
if (providerId !== "github-copilot" || !deviceCode) {
- window.open(appendTokenQuery(deviceCode?.verificationUri ?? url), "_blank");
+ openExternalUrl(appendTokenQuery(deviceCode?.verificationUri ?? url));
}
// Poll for auth completion
@@ -2395,7 +2396,7 @@ export function ModelOnboardingModal({
>
{t("setup.copyCode", "Copy code")}
-
- window.open(appendTokenQuery(deviceCodes[provider.id].verificationUri), "_blank")}>
+ openExternalUrl(appendTokenQuery(deviceCodes[provider.id].verificationUri))}>
{t("settings.auth.openGitHub", "Open GitHub")}
diff --git a/packages/dashboard/app/utils/__tests__/open-external.test.ts b/packages/dashboard/app/utils/__tests__/open-external.test.ts
new file mode 100644
index 0000000000..f7e1a614be
--- /dev/null
+++ b/packages/dashboard/app/utils/__tests__/open-external.test.ts
@@ -0,0 +1,51 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { openExternalUrl } from "../open-external";
+
+/*
+FNXC:DesktopOAuth 2026-07-18-04:00:
+OAuth window.open after the /auth/login await can outlive Chromium's transient
+user activation and get silently popup-blocked on desktop (observed with the
+OpenAI Codex flow). The helper must prefer the activation-free desktop IPC
+bridge and only fall back to window.open on the web (or when the bridge
+declines the URL).
+*/
+describe("openExternalUrl", () => {
+ const w = window as unknown as { fusionAPI?: { openExternal?: (url: string) => Promise } };
+
+ afterEach(() => {
+ delete w.fusionAPI;
+ vi.restoreAllMocks();
+ });
+
+ it("prefers the desktop openExternal bridge over window.open", async () => {
+ const openExternal = vi.fn().mockResolvedValue(true);
+ w.fusionAPI = { openExternal };
+ const windowOpen = vi.spyOn(window, "open").mockReturnValue(null);
+
+ openExternalUrl("https://auth.openai.com/oauth/authorize?x=1");
+ await Promise.resolve();
+
+ expect(openExternal).toHaveBeenCalledWith("https://auth.openai.com/oauth/authorize?x=1");
+ expect(windowOpen).not.toHaveBeenCalled();
+ });
+
+ it("falls back to window.open when the bridge declines the URL", async () => {
+ const openExternal = vi.fn().mockResolvedValue(false);
+ w.fusionAPI = { openExternal };
+ const windowOpen = vi.spyOn(window, "open").mockReturnValue(null);
+
+ openExternalUrl("https://example.com/auth");
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(windowOpen).toHaveBeenCalledWith("https://example.com/auth", "_blank");
+ });
+
+ it("uses window.open when no desktop bridge exists", () => {
+ const windowOpen = vi.spyOn(window, "open").mockReturnValue(null);
+
+ openExternalUrl("https://example.com/auth");
+
+ expect(windowOpen).toHaveBeenCalledWith("https://example.com/auth", "_blank");
+ });
+});
diff --git a/packages/dashboard/app/utils/open-external.ts b/packages/dashboard/app/utils/open-external.ts
new file mode 100644
index 0000000000..af1c999d77
--- /dev/null
+++ b/packages/dashboard/app/utils/open-external.ts
@@ -0,0 +1,32 @@
+/*
+FNXC:DesktopOAuth 2026-07-18-04:00:
+OAuth login handlers call window.open AFTER awaiting POST /auth/login. When
+the round trip outlives Chromium's transient user activation (~5s — observed
+with the OpenAI Codex flow while the Anthropic flow, being faster, worked),
+the popup is silently blocked in the desktop app and the system browser never
+opens. On desktop, prefer the activation-free shell:openExternal IPC bridge;
+in the web app fall back to window.open.
+*/
+
+interface DesktopShellApi {
+ openExternal?: (url: string) => Promise;
+}
+
+function desktopShellApi(): DesktopShellApi | undefined {
+ const w = window as unknown as { fusionAPI?: DesktopShellApi; electronAPI?: DesktopShellApi };
+ return w.fusionAPI ?? w.electronAPI;
+}
+
+/** Open a URL in the user's browser: desktop IPC when available, window.open otherwise. */
+export function openExternalUrl(url: string): void {
+ const api = desktopShellApi();
+ if (typeof api?.openExternal === "function") {
+ void api.openExternal(url).then((opened) => {
+ if (!opened) window.open(url, "_blank");
+ }).catch(() => {
+ window.open(url, "_blank");
+ });
+ return;
+ }
+ window.open(url, "_blank");
+}
diff --git a/packages/desktop/src/ipc.ts b/packages/desktop/src/ipc.ts
index 64f823a2a6..cc2a370de6 100644
--- a/packages/desktop/src/ipc.ts
+++ b/packages/desktop/src/ipc.ts
@@ -1,4 +1,4 @@
-import { app, type BrowserWindow, ipcMain, type Tray } from "electron";
+import { app, type BrowserWindow, ipcMain, shell, type Tray } from "electron";
import {
showExportSettingsDialog,
showImportSettingsDialog,
@@ -109,6 +109,28 @@ export function registerIpcHandlers(mainWindow: BrowserWindow, tray: Tray, optio
});
ipcMain.handle("tray:updateStatus", (_event, status: EngineStatus) => updateTrayStatus(tray, status));
+ /*
+ FNXC:DesktopOAuth 2026-07-18-04:00:
+ OAuth flows call window.open AFTER awaiting POST /auth/login; when the round
+ trip outlives Chromium's transient user activation (~5s — observed with the
+ OpenAI Codex flow: method-select round-trip + localhost callback server +
+ auth-lock retries), the popup is silently blocked and setWindowOpenHandler
+ never fires, so the system browser never opens. This explicit IPC needs no
+ user activation. http/https only — reject everything else.
+ */
+ ipcMain.handle("shell:openExternal", async (_event, url: unknown) => {
+ if (typeof url !== "string") return false;
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return false;
+ }
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
+ await shell.openExternal(url);
+ return true;
+ });
+
ipcMain.handle("native:showExportDialog", () => showExportSettingsDialog(mainWindow));
ipcMain.handle("native:showImportDialog", () => showImportSettingsDialog(mainWindow));
ipcMain.handle("app:getServerPort", () => options.getServerPort?.());
diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts
index a2d7362560..54fa146220 100644
--- a/packages/desktop/src/preload.ts
+++ b/packages/desktop/src/preload.ts
@@ -64,6 +64,10 @@ const electronApi = {
// Tray status
updateTrayStatus: (status: string): Promise => ipcRenderer.invoke("tray:updateStatus", status),
+ // FNXC:DesktopOAuth 2026-07-18-04:00:
+ // Activation-free system-browser open for OAuth URLs (see shell:openExternal in ipc.ts).
+ openExternal: (url: string): Promise => ipcRenderer.invoke("shell:openExternal", url),
+
// Native dialogs
showExportDialog: (): Promise => ipcRenderer.invoke("native:showExportDialog"),
showImportDialog: (): Promise => ipcRenderer.invoke("native:showImportDialog"),