diff --git a/apps/desktop/src/main/lib/browser-mcp-bridge/cdp-filter-proxy.ts b/apps/desktop/src/main/lib/browser-mcp-bridge/cdp-filter-proxy.ts index 487d357edab..bdc30db2259 100644 --- a/apps/desktop/src/main/lib/browser-mcp-bridge/cdp-filter-proxy.ts +++ b/apps/desktop/src/main/lib/browser-mcp-bridge/cdp-filter-proxy.ts @@ -1,8 +1,11 @@ import { randomBytes } from "node:crypto"; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import type { IncomingMessage, ServerResponse } from "node:http"; +import { dirname, join } from "node:path"; import type { Duplex } from "node:stream"; import { WebSocket, type WebSocketServer } from "ws"; import { bindingStore } from "../../../lib/trpc/routers/browser-automation/index"; +import { SUPERSET_HOME_DIR } from "../app-environment"; import { browserManager } from "../browser/browser-manager"; import { resolveCdpPort } from "./cdp-port"; @@ -26,6 +29,7 @@ import { resolveCdpPort } from "./cdp-port"; */ const TOKEN_BYTES = 24; +const TOKEN_STORE_PATH = join(SUPERSET_HOME_DIR, "browser-mcp-tokens.json"); interface TokenEntry { sessionId: string; @@ -35,8 +39,73 @@ interface TokenEntry { const tokensBySession = new Map(); const entriesByToken = new Map(); +let hydrated = false; + +interface PersistedTokenFile { + version: 1; + entries: Array; +} + +/** + * Load previously-minted tokens from disk. Tokens survive app + * restarts so the URL a user registered once into their external + * browser MCP (chrome-devtools-mcp / browser-use) stays valid. + */ +function hydrate(): void { + if (hydrated) return; + hydrated = true; + try { + const raw = readFileSync(TOKEN_STORE_PATH, "utf8"); + const parsed = JSON.parse(raw) as Partial; + if (parsed?.version !== 1 || !Array.isArray(parsed.entries)) return; + for (const e of parsed.entries) { + if ( + typeof e.token === "string" && + e.token.length >= TOKEN_BYTES * 2 && + typeof e.sessionId === "string" + ) { + tokensBySession.set(e.sessionId, e.token); + entriesByToken.set(e.token, { + sessionId: e.sessionId, + createdAt: e.createdAt ?? Date.now(), + lastUsedAt: e.lastUsedAt ?? Date.now(), + }); + } + } + } catch { + /* no prior state, start fresh */ + } +} + +function persist(): void { + try { + const payload: PersistedTokenFile = { + version: 1, + entries: Array.from(entriesByToken.entries()).map(([token, entry]) => ({ + token, + ...entry, + })), + }; + mkdirSync(dirname(TOKEN_STORE_PATH), { recursive: true }); + writeFileSync(TOKEN_STORE_PATH, JSON.stringify(payload, null, 2), { + mode: 0o600, + }); + // writeFileSync's `mode` only applies to new files. If the file + // already existed with broader permissions (backup/restore, etc.) + // we still need to tighten it so long-lived /cdp/ + // credentials never leak to other local users. + try { + chmodSync(TOKEN_STORE_PATH, 0o600); + } catch { + /* best-effort */ + } + } catch (error) { + console.warn("[cdp-filter-proxy] failed to persist tokens:", error); + } +} export function mintCdpToken(sessionId: string): string { + hydrate(); const existing = tokensBySession.get(sessionId); if (existing) { const entry = entriesByToken.get(existing); @@ -50,12 +119,14 @@ export function mintCdpToken(sessionId: string): string { createdAt: Date.now(), lastUsedAt: Date.now(), }); + persist(); return token; } function resolveTokenToPane( token: string, ): { sessionId: string; paneId: string; targetId: string } | null { + hydrate(); const entry = entriesByToken.get(token); if (!entry) return null; entry.lastUsedAt = Date.now(); diff --git a/apps/desktop/src/main/lib/browser-mcp-bridge/server.ts b/apps/desktop/src/main/lib/browser-mcp-bridge/server.ts index f16eab3cbd7..623fecf31fe 100644 --- a/apps/desktop/src/main/lib/browser-mcp-bridge/server.ts +++ b/apps/desktop/src/main/lib/browser-mcp-bridge/server.ts @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { createServer, type IncomingMessage, @@ -37,6 +37,73 @@ import { getBoundPaneForSession, resolvePpidToSession } from "./pane-resolver"; const RUNTIME_INFO_PATH = join(SUPERSET_HOME_DIR, "browser-mcp.json"); +/** + * Preferred loopback port for the bridge. Chosen in the IANA + * dynamic-port range where browser dev tools are unlikely to collide + * (9000-series is taken by Chrome remote debugging, 3000/5173 by dev + * servers, 8080 by everything, etc.). Persisted to browser-mcp.json so + * the same port is reused on restart — which lets the CDP URL that an + * external MCP was registered with stay valid across Superset launches. + */ +const PREFERRED_BRIDGE_PORT = 47834; + +async function tryListen(server: Server, port: number): Promise { + return new Promise((resolve) => { + const onError = (err: NodeJS.ErrnoException): void => { + server.off("error", onError); + if (err.code === "EADDRINUSE") resolve(null); + else resolve(null); + }; + server.once("error", onError); + server.listen(port, "127.0.0.1", () => { + server.off("error", onError); + const address = server.address(); + if (!address || typeof address === "string") { + resolve(null); + return; + } + resolve(address.port); + }); + }); +} + +function readPersistedPort(): number | null { + try { + const raw = readFileSync(RUNTIME_INFO_PATH, "utf8"); + const parsed = JSON.parse(raw) as { port?: number }; + if ( + typeof parsed.port === "number" && + Number.isInteger(parsed.port) && + parsed.port > 0 && + parsed.port < 65_536 + ) { + return parsed.port; + } + } catch { + /* no prior state */ + } + return null; +} + +async function listenPreferringStablePort(server: Server): Promise { + // 1. Try the port used last run (so external MCP registrations stay + // valid across restarts). + const previous = readPersistedPort(); + const candidates = [previous, PREFERRED_BRIDGE_PORT].filter( + (p): p is number => typeof p === "number", + ); + for (const candidate of candidates) { + const bound = await tryListen(server, candidate); + if (bound) return bound; + } + // 2. Fall back to a kernel-assigned port. The user will have to + // re-register external MCPs with the new URL, but the app still + // comes up cleanly instead of hanging on a conflict. + const bound = await tryListen(server, 0); + if (bound) return bound; + throw new Error("browser-mcp-bridge: could not bind any loopback port"); +} + async function resolvePaneFromRequest( req: IncomingMessage, ): Promise< @@ -209,15 +276,7 @@ export async function startBrowserMcpBridge(): Promise { }); }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); - const address = server.address(); - if (!address || typeof address === "string") { - throw new Error("browser-mcp-bridge: failed to bind port"); - } - const port = address.port; + const port = await listenPreferringStablePort(server); mkdirSync(dirname(RUNTIME_INFO_PATH), { recursive: true }); writeFileSync(RUNTIME_INFO_PATH, JSON.stringify({ port, secret }, null, 2), { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx index f16d338ac62..3efa151a775 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx @@ -162,7 +162,7 @@ export function SessionConnectModal({ return ( - + Connect browser automation @@ -172,7 +172,7 @@ export function SessionConnectModal({ -
+
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/components/CdpEndpointCard/CdpEndpointCard.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/components/CdpEndpointCard/CdpEndpointCard.tsx index a2bf7f82656..80f739abb0b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/components/CdpEndpointCard/CdpEndpointCard.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/components/CdpEndpointCard/CdpEndpointCard.tsx @@ -58,7 +58,12 @@ export function CdpEndpointCard({ sessionId }: CdpEndpointCardProps) { } const chromeDevtoolsCmd = `claude mcp add chrome-devtools-mcp -s user -- npx -y chrome-devtools-mcp --browser-url ${data.httpBase}`; - const browserUseCmd = `browser-use --cdp-url ${data.wsEndpoint}`; + // browser-use ships its own MCP mode via `uvx --from "browser-use[cli]"`. + // CDP endpoint is passed via the same `--cdp-url` flag that the CLI + // accepts. Port + token are stable across Superset restarts (see + // server.ts / cdp-filter-proxy.ts), so this registration only has to + // be done once per install. + const browserUseCmd = `claude mcp add browser-use -s user -- uvx --from "browser-use[cli]" browser-use --mcp --cdp-url ${data.wsEndpoint}`; return (
@@ -149,7 +154,7 @@ function CommandBlock({
{title}
-
+				
 					{cmd}