From 45af351d592efacb2e7c72616ac101f78c8dd69f Mon Sep 17 00:00:00 2001 From: Mux Date: Sat, 5 Sep 2026 19:40:25 -0500 Subject: [PATCH 1/5] =?UTF-8?q?[desktop]=20=F0=9F=A4=96=20feat:=20add=20re?= =?UTF-8?q?mote=20server=20connections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the local backend running while a separate window connects to a remote server. --- _Generated with `xum` • Model: `openai:gpt-6-astra` • Thinking: `high` • Cost: `$26.87`_ Co-authored-by: Mux --- .../Sections/RemoteConnectionSection.tsx | 182 +++++ .../features/Settings/SettingsPage.tsx | 21 +- .../stories/App.remoteConnection.stories.tsx | 216 ++++++ src/common/constants/remoteConnection.ts | 12 + src/common/types/global.d.ts | 3 + src/common/types/remoteConnection.test.ts | 55 ++ src/common/types/remoteConnection.ts | 31 + src/desktop/main.ts | 153 +++- src/desktop/preload.ts | 16 +- src/desktop/remoteConnectionManager.test.ts | 718 ++++++++++++++++++ src/desktop/remoteConnectionManager.ts | 265 +++++++ src/desktop/terminalWindowManager.ts | 11 +- tests/e2e/scenarios/remoteConnection.spec.ts | 214 ++++++ 13 files changed, 1877 insertions(+), 20 deletions(-) create mode 100644 src/browser/features/Settings/Sections/RemoteConnectionSection.tsx create mode 100644 src/browser/stories/App.remoteConnection.stories.tsx create mode 100644 src/common/constants/remoteConnection.ts create mode 100644 src/common/types/remoteConnection.test.ts create mode 100644 src/common/types/remoteConnection.ts create mode 100644 src/desktop/remoteConnectionManager.test.ts create mode 100644 src/desktop/remoteConnectionManager.ts create mode 100644 tests/e2e/scenarios/remoteConnection.spec.ts diff --git a/src/browser/features/Settings/Sections/RemoteConnectionSection.tsx b/src/browser/features/Settings/Sections/RemoteConnectionSection.tsx new file mode 100644 index 00000000000..1cf5ad9ec44 --- /dev/null +++ b/src/browser/features/Settings/Sections/RemoteConnectionSection.tsx @@ -0,0 +1,182 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { Button } from "@/browser/components/Button/Button"; +import { Input } from "@/browser/components/Input/Input"; +import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import { isMac } from "@/browser/utils/ui/keybinds"; +import { REMOTE_CONNECTION_RETURN_ACCELERATOR } from "@/common/constants/remoteConnection"; +import { + parseRemoteConnectionUrl, + type RemoteConnectionState, +} from "@/common/types/remoteConnection"; +import { getErrorMessage } from "@/common/utils/errors"; + +export const REMOTE_CONNECTION_ORIGIN_KEY = "remoteConnectionOrigin"; + +const STATUS_LABELS: Record = { + disconnected: "Disconnected", + connecting: "Connecting…", + connected: "Connected", +}; + +export function RemoteConnectionSection() { + const bridge = window.api?.remoteConnection; + const [savedOrigin, setSavedOrigin] = usePersistedState(REMOTE_CONNECTION_ORIGIN_KEY, ""); + // Keep pasted tokens transient. Only the validated origin can enter local storage. + const [url, setUrl] = useState(() => { + try { + return parseRemoteConnectionUrl(savedOrigin).origin; + } catch { + return ""; + } + }); + const [connection, setConnection] = useState(null); + const [error, setError] = useState(null); + const [connecting, setConnecting] = useState(false); + const [disconnecting, setDisconnecting] = useState(false); + + useEffect(() => { + if (!bridge) return; + let disposed = false; + let receivedUpdate = false; + // Subscribe first. A later snapshot must not overwrite a newer bridge event. + const unsubscribe = bridge.onStateChanged((state) => { + if (disposed) return; + receivedUpdate = true; + setConnection(state); + setError(state.error ?? null); + }); + bridge.getState().then( + (state) => { + if (disposed || receivedUpdate) return; + setConnection(state); + setError(state.error ?? null); + }, + (cause: unknown) => { + if (disposed || receivedUpdate) return; + setError(`Cannot read the remote connection state: ${getErrorMessage(cause)}`); + } + ); + return () => { + disposed = true; + unsubscribe(); + }; + }, [bridge]); + + if (!bridge) return null; + + const isConnecting = connecting || connection?.status === "connecting"; + const canConnect = !isConnecting && !disconnecting && connection?.status !== "connected"; + const canDisconnect = + connection != null && connection.status !== "disconnected" && !disconnecting; + const returnShortcut = REMOTE_CONNECTION_RETURN_ACCELERATOR.replace( + "CommandOrControl", + isMac() ? "Cmd" : "Ctrl" + ); + + async function handleConnect(event: FormEvent) { + event.preventDefault(); + if (!bridge || !canConnect) return; + setError(null); + try { + const origin = parseRemoteConnectionUrl(url).origin; + setSavedOrigin(origin); + const enteredUrl = url; + setUrl(origin); + setConnecting(true); + await bridge.connect(enteredUrl); + } finally { + setConnecting(false); + } + } + + async function handleDisconnect() { + if (!bridge || !canDisconnect) return; + setError(null); + setDisconnecting(true); + try { + await bridge.disconnect(); + } finally { + setDisconnecting(false); + } + } + + return ( +
+
+

Connect to a remote server

+

+ Open a remote Xum server in a separate window. Your local workspaces and tasks keep + running. +

+
+ +
{ + handleConnect(event).catch((cause: unknown) => setError(getErrorMessage(cause))); + }} + className="space-y-3" + > +
+ + { + setUrl(event.target.value); + setError(null); + }} + placeholder="https://xum.example.com" + autoComplete="off" + autoCapitalize="none" + spellCheck={false} + inputMode="url" + aria-describedby="remote-connection-help" + disabled={isConnecting || disconnecting || connection?.status === "connected"} + /> +

+ Enter an HTTP or HTTPS URL. You can include a token link. Sign in through the remote web + UI. Only the server origin is saved. Xum does not connect automatically. +

+
+
+ + +
+
+ +
+ {connection + ? STATUS_LABELS[connection.status] + : error + ? "Connection state unavailable" + : "Reading connection state…"} + {connection?.origin && · {connection.origin}} +
+ {error && ( +

+ {error} +

+ )} +

+ Close the remote window to return here. + You can also disconnect with {returnShortcut}. +

+
+ ); +} diff --git a/src/browser/features/Settings/SettingsPage.tsx b/src/browser/features/Settings/SettingsPage.tsx index 689c32e43de..6d4e13edc1f 100644 --- a/src/browser/features/Settings/SettingsPage.tsx +++ b/src/browser/features/Settings/SettingsPage.tsx @@ -16,6 +16,7 @@ import { Shield, ShieldCheck, Server, + Monitor, Lock, ArchiveRestore, ScrollText, @@ -40,6 +41,7 @@ import { LayoutsSection } from "./Sections/LayoutsSection"; import { RuntimesSection } from "./Sections/RuntimesSection"; import { ExperimentsSection } from "./Sections/ExperimentsSection"; import { ServerAccessSection } from "./Sections/ServerAccessSection"; +import { RemoteConnectionSection } from "./Sections/RemoteConnectionSection"; import { KeybindsSection } from "./Sections/KeybindsSection"; import { SecuritySection } from "./Sections/SecuritySection"; import { BackupSection } from "./Sections/BackupSection"; @@ -136,9 +138,19 @@ interface SettingsSectionRedirect { export function getSettingsSections( governorEnabled: boolean, memoryEnabled: boolean, - agentPluginsEnabled: boolean + agentPluginsEnabled: boolean, + remoteConnectionAvailable = false ): SettingsSection[] { const sections = [...BASE_SECTIONS]; + if (remoteConnectionAvailable) { + const serverAccessIndex = sections.findIndex((section) => section.id === "server-access"); + sections.splice(serverAccessIndex + 1, 0, { + id: "remote-connection", + label: "Remote Connection", + icon: , + component: RemoteConnectionSection, + }); + } if (agentPluginsEnabled) { // Next to MCP: plugins contribute skills + MCP servers. const mcpIndex = sections.findIndex((section) => section.id === "mcp"); @@ -250,7 +262,12 @@ export function SettingsPage(props: SettingsPageProps) { window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [close]); - const sections = getSettingsSections(governorEnabled, memoryEnabled, agentPluginsEnabled); + const sections = getSettingsSections( + governorEnabled, + memoryEnabled, + agentPluginsEnabled, + window.api?.remoteConnection != null + ); const currentSection = sections.find((section) => section.id === activeSection) ?? sections[0]; const SectionComponent = currentSection.component; diff --git a/src/browser/stories/App.remoteConnection.stories.tsx b/src/browser/stories/App.remoteConnection.stories.tsx new file mode 100644 index 00000000000..52c216137c4 --- /dev/null +++ b/src/browser/stories/App.remoteConnection.stories.tsx @@ -0,0 +1,216 @@ +import { expect, fn, userEvent, waitFor, within } from "@storybook/test"; +import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; +import { expandLeftSidebar } from "./helpers/uiState"; +import { setupSettingsStory } from "@/browser/features/Settings/Sections/settingsStoryUtils"; +import { REMOTE_CONNECTION_ORIGIN_KEY } from "@/browser/features/Settings/Sections/RemoteConnectionSection"; +import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { + parseRemoteConnectionUrl, + type RemoteConnectionApi, + type RemoteConnectionState, +} from "@/common/types/remoteConnection"; + +const SAVED_ORIGIN = "https://saved.example.com"; +const SERVER_ORIGIN = "https://remote.example.com"; +const TOKEN_URL = SERVER_ORIGIN + "/?token=transient-secret#private-fragment"; + +function createRemoteBridge() { + let state: RemoteConnectionState = { origin: null, status: "disconnected" }; + const listeners = new Set<(next: RemoteConnectionState) => void>(); + const publish = (next: RemoteConnectionState) => { + state = next; + for (const listener of listeners) listener(next); + }; + const bridge = { + getState: fn(() => Promise.resolve(state)), + connect: fn((url: string) => { + publish({ origin: parseRemoteConnectionUrl(url).origin, status: "connecting" }); + return Promise.resolve(); + }), + disconnect: fn(() => { + publish({ origin: null, status: "disconnected" }); + return Promise.resolve(); + }), + onStateChanged: fn((listener: (next: RemoteConnectionState) => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }), + } satisfies RemoteConnectionApi; + return { bridge, publish, listeners }; +} + +let remote = createRemoteBridge(); + +export default { + ...appMeta, + title: "App/RemoteConnection", + beforeEach: () => { + const previousApi = window.api; + const previousOrigin = readPersistedState(REMOTE_CONNECTION_ORIGIN_KEY, undefined); + remote = createRemoteBridge(); + window.api = { + platform: "linux", + versions: {}, + ...previousApi, + remoteConnection: remote.bridge, + }; + updatePersistedState(REMOTE_CONNECTION_ORIGIN_KEY, SAVED_ORIGIN); + return () => { + window.api = previousApi; + updatePersistedState(REMOTE_CONNECTION_ORIGIN_KEY, previousOrigin); + }; + }, +}; + +function setupRemoteSettings() { + expandLeftSidebar(); + return setupSettingsStory({}); +} + +async function openSettings(canvasElement: HTMLElement) { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByTestId("settings-button", {}, { timeout: 10000 })); + return canvas; +} + +async function openRemoteSettings(canvasElement: HTMLElement) { + const canvas = await openSettings(canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: "Remote Connection" })); + return within(await canvas.findByRole("region", { name: "Remote connection" })); +} + +async function exerciseConnection(canvasElement: HTMLElement) { + const canvas = within(canvasElement); + const section = await openRemoteSettings(canvasElement); + await waitFor(() => expect(section.getByRole("status")).toHaveTextContent("Disconnected")); + const input = section.getByRole("textbox", { name: "Server URL" }); + await expect(input).toHaveValue(SAVED_ORIGIN); + await expect(remote.bridge.connect).not.toHaveBeenCalled(); + + // Invalid schemes and embedded passwords never reach the bridge or saved preferences. + for (const invalidUrl of [ + "ftp://remote.example.com", + "https://user:password@remote.example.com", + ]) { + await userEvent.clear(input); + await userEvent.type(input, invalidUrl + "{Enter}"); + await expect(await section.findByRole("alert")).toBeVisible(); + await expect(remote.bridge.connect).not.toHaveBeenCalled(); + await expect(readPersistedState(REMOTE_CONNECTION_ORIGIN_KEY, "")).toBe(SAVED_ORIGIN); + } + + remote.bridge.connect.mockRejectedValueOnce(new Error("The remote server is unavailable.")); + await userEvent.clear(input); + await userEvent.type(input, "https://offline.example.com/?token=failed-secret{Enter}"); + await expect(await section.findByRole("alert")).toHaveTextContent( + "The remote server is unavailable." + ); + await expect(section.getByRole("button", { name: "Connect" })).toBeEnabled(); + await expect(readPersistedState(REMOTE_CONNECTION_ORIGIN_KEY, "")).toBe( + "https://offline.example.com" + ); + + await userEvent.clear(input); + await userEvent.type(input, TOKEN_URL); + await expect(readPersistedState(REMOTE_CONNECTION_ORIGIN_KEY, "")).toBe( + "https://offline.example.com" + ); + await userEvent.keyboard("{Enter}"); + await waitFor(() => expect(remote.bridge.connect).toHaveBeenLastCalledWith(TOKEN_URL)); + await expect(section.getByRole("button", { name: "Connecting…" })).toBeDisabled(); + await expect(readPersistedState(REMOTE_CONNECTION_ORIGIN_KEY, "")).toBe(SERVER_ORIGIN); + await expect(input).toHaveValue(SERVER_ORIGIN); + await expect(section.queryByRole("alert")).toBeNull(); + + remote.publish({ origin: SERVER_ORIGIN, status: "connected" }); + await waitFor(() => expect(section.getByRole("status")).toHaveTextContent("Connected")); + const disconnect = section.getByRole("button", { name: "Disconnect" }); + disconnect.focus(); + await userEvent.keyboard("{Enter}"); + await waitFor(() => expect(remote.bridge.disconnect).toHaveBeenCalledTimes(1)); + await expect(disconnect).toBeDisabled(); + + await userEvent.click(canvas.getByRole("button", { name: "General" })); + await expect(remote.listeners.size).toBe(0); + await userEvent.click(canvas.getByRole("button", { name: "Remote Connection" })); + const restored = within(await canvas.findByRole("region", { name: "Remote connection" })); + await expect(restored.getByRole("textbox", { name: "Server URL" })).toHaveValue(SERVER_ORIGIN); + await expect(remote.bridge.connect).toHaveBeenCalledTimes(2); + await expect(remote.listeners.size).toBe(1); + + // The test-runner ignores viewport globals. Pixel runs this contract at the pinned phone width. + if (window.innerWidth < 768) { + const region = canvas.getByRole("region", { name: "Remote connection" }); + await expect(region.scrollWidth).toBeLessThanOrEqual(region.clientWidth); + for (const control of restored.getAllByRole("button")) { + await expect(control.getBoundingClientRect().right).toBeLessThanOrEqual(window.innerWidth); + } + } +} + +export const Desktop: AppStory = { + globals: { viewport: { value: "desktop", isRotated: false } }, + parameters: { pixel: { matrix: { themes: ["dark", "light"], viewports: ["desktop"] } } }, + render: () => , + play: async ({ canvasElement }) => exerciseConnection(canvasElement), +}; + +export const Phone: AppStory = { + ...Desktop, + globals: { viewport: { value: "mobile1", isRotated: false } }, + parameters: { pixel: { matrix: { themes: ["dark", "light"], viewports: ["phone"] } } }, + play: async ({ canvasElement, parameters }) => { + // Keep the narrow capture when the story matrix changes. + await expect(parameters.pixel).toMatchObject({ matrix: { viewports: ["phone"] } }); + await exerciseConnection(canvasElement); + }, +}; + +export const NewerStateWins: AppStory = { + render: () => , + play: async ({ canvasElement }) => { + // Deliver an event while the initial snapshot is pending. + let resolveSnapshot!: (state: RemoteConnectionState) => void; + remote.bridge.getState.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSnapshot = resolve; + }) + ); + const section = await openRemoteSettings(canvasElement); + await waitFor(() => expect(remote.bridge.getState).toHaveBeenCalled()); + remote.publish({ origin: SERVER_ORIGIN, status: "connected" }); + resolveSnapshot({ origin: null, status: "disconnected" }); + await waitFor(() => expect(section.getByRole("status")).toHaveTextContent("Connected")); + await expect(section.getByRole("button", { name: "Disconnect" })).toBeEnabled(); + await expect(section.getByRole("button", { name: "Connect" })).toBeDisabled(); + await expect(remote.bridge.connect).not.toHaveBeenCalled(); + }, +}; + +export const InvalidSavedOrigin: AppStory = { + beforeEach: () => { + updatePersistedState(REMOTE_CONNECTION_ORIGIN_KEY, { invalid: true }); + }, + render: () => , + play: async ({ canvasElement }) => { + const section = await openRemoteSettings(canvasElement); + await expect(section.getByRole("textbox", { name: "Server URL" })).toHaveValue(""); + await expect(section.getByRole("button", { name: "Connect" })).toBeDisabled(); + await expect(remote.bridge.connect).not.toHaveBeenCalled(); + }, +}; + +export const BrowserWithoutBridge: AppStory = { + beforeEach: () => { + delete window.api; + }, + render: () => , + play: async ({ canvasElement }) => { + const canvas = await openSettings(canvasElement); + await expect(await canvas.findByRole("button", { name: "Server Access" })).toBeVisible(); + await expect(canvas.queryByRole("button", { name: "Remote Connection" })).toBeNull(); + }, +}; diff --git a/src/common/constants/remoteConnection.ts b/src/common/constants/remoteConnection.ts new file mode 100644 index 00000000000..9e12aafaf21 --- /dev/null +++ b/src/common/constants/remoteConnection.ts @@ -0,0 +1,12 @@ +export const REMOTE_CONNECTION_LOAD_TIMEOUT_MS = 30_000; +// Keep gesture checks outside the page world and Electron's reserved preload world. +export const REMOTE_CONNECTION_GESTURE_WORLD_ID = 1001; +export const REMOTE_CONNECTION_RETURN_KEY = "L"; +export const REMOTE_CONNECTION_RETURN_ACCELERATOR = `CommandOrControl+Shift+${REMOTE_CONNECTION_RETURN_KEY}`; + +export const REMOTE_CONNECTION_CHANNELS = { + getState: "xum:remote-connection:get-state", + connect: "xum:remote-connection:connect", + disconnect: "xum:remote-connection:disconnect", + stateChanged: "xum:remote-connection:state-changed", +} as const; diff --git a/src/common/types/global.d.ts b/src/common/types/global.d.ts index a8ebb95bbee..d5adf97d177 100644 --- a/src/common/types/global.d.ts +++ b/src/common/types/global.d.ts @@ -1,6 +1,7 @@ import type { RouterClient } from "@orpc/server"; import type { AppRouter } from "@/node/orpc/router"; import type { DeepLinkPayload } from "@/common/types/deepLink"; +import type { RemoteConnectionApi } from "@/common/types/remoteConnection"; declare global { interface WindowApi { @@ -35,6 +36,8 @@ declare global { consumePendingDeepLinks?: () => DeepLinkPayload[]; // Subscribe to mux:// deep links as they arrive. Returns an unsubscribe function. onDeepLink?: (callback: (payload: DeepLinkPayload) => void) => () => void; + // Only the local desktop renderer can control remote windows. + remoteConnection?: RemoteConnectionApi; // Optional ORPC-backed API surfaces populated in tests/storybook mocks tokenizer?: unknown; providers?: unknown; diff --git a/src/common/types/remoteConnection.test.ts b/src/common/types/remoteConnection.test.ts new file mode 100644 index 00000000000..1b5eb0906f2 --- /dev/null +++ b/src/common/types/remoteConnection.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { parseRemoteConnectionUrl } from "./remoteConnection"; + +describe("parseRemoteConnectionUrl", () => { + test.each([ + [ + " HTTPS://Example.COM:443/path?token=secret#session ", + "https://example.com/path?token=secret#session", + ], + ["http://localhost:3000/", "http://localhost:3000/"], + ["http://[::1]:8080/path", "http://[::1]:8080/path"], + ])("normalizes a server URL without removing its path or token: %s", (input, expected) => { + const url = parseRemoteConnectionUrl(input); + expect(url.href).toBe(expected); + expect(url.origin).not.toContain("secret"); + expect(url.origin).not.toContain("session"); + }); + + test.each([ + "", + " ", + "not a URL", + "/relative/path", + "//example.com", + "http://", + "https://example.com:99999", + "https://[invalid]", + "file:///etc/passwd", + "javascript:alert(1)", + "data:text/html,hello", + "about:blank", + "ftp://example.com", + "xum://open", + ])("rejects malformed URLs and non-HTTP schemes: %s", (input) => { + expect(() => parseRemoteConnectionUrl(input)).toThrow(); + }); + + test.each([ + "https://username@example.com/", + "https://:password@example.com/", + "https://username:password@example.com/", + "https://%75ser:%70assword@example.com/", + ])("rejects URL credentials: %s", (input) => { + expect(() => parseRemoteConnectionUrl(input)).toThrow(); + }); + + test("accepts token links without credentials in the origin", () => { + const url = parseRemoteConnectionUrl( + "https://example.com/base?token=private-token#private-session" + ); + expect(url.searchParams.get("token")).toBe("private-token"); + expect(url.hash).toBe("#private-session"); + expect(url.origin).toBe("https://example.com"); + }); +}); diff --git a/src/common/types/remoteConnection.ts b/src/common/types/remoteConnection.ts new file mode 100644 index 00000000000..d575e1a1dc7 --- /dev/null +++ b/src/common/types/remoteConnection.ts @@ -0,0 +1,31 @@ +/** Local desktop controls. Remote pages never receive this bridge. */ +export interface RemoteConnectionApi { + getState(): Promise; + connect(url: string): Promise; + disconnect(): Promise; + onStateChanged(callback: (state: RemoteConnectionState) => void): () => void; +} + +export interface RemoteConnectionState { + /** The server origin excludes credentials and URL tokens. */ + origin: string | null; + status: "disconnected" | "connecting" | "connected"; + error?: string; +} + +/** Validate a server address before opening remote content. */ +export function parseRemoteConnectionUrl(input: string): URL { + let url: URL; + try { + url = new URL(input.trim()); + } catch { + throw new Error("Enter a valid HTTP or HTTPS server URL."); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Use an HTTP or HTTPS server URL."); + } + if (url.username || url.password) { + throw new Error("Remove the username and password from the server URL."); + } + return url; +} diff --git a/src/desktop/main.ts b/src/desktop/main.ts index 408a460416c..c5e44fe8336 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -16,6 +16,7 @@ import "source-map-support/register"; import { promises as fsPromises } from "node:fs"; import * as path from "node:path"; import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; import { cleanupObsoleteXumBinArtifacts, getXumHome } from "@/common/constants/paths"; import { getElectronAppIdentity } from "@/common/compat/electronAppIdentity"; import { @@ -42,6 +43,11 @@ if (process.platform === "darwin") { } import { DesktopWindowManager } from "./desktopWindowManager"; +import { RemoteConnectionManager } from "./remoteConnectionManager"; +import { + REMOTE_CONNECTION_CHANNELS, + REMOTE_CONNECTION_RETURN_ACCELERATOR, +} from "@/common/constants/remoteConnection"; import { randomBytes } from "crypto"; import { RPCHandler } from "@orpc/server/message-port"; import { onError } from "@orpc/server"; @@ -50,7 +56,15 @@ import { formatOrpcError } from "../node/orpc/formatOrpcError"; import { ServerLockfile } from "../node/services/serverLockfile"; import "disposablestack/auto"; -import type { MenuItemConstructorOptions, MessageBoxOptions } from "electron"; +import type { + BrowserWindowConstructorOptions, + Event as ElectronEvent, + IpcMainEvent, + IpcMainInvokeEvent, + MenuItemConstructorOptions, + MessageBoxOptions, + WebContents, +} from "electron"; import { app, crashReporter, @@ -201,6 +215,54 @@ import { log } from "@/node/services/log"; // These will be loaded on-demand when createWindow() is called let config: Config | null = null; let services: ServiceContainer | null = null; +let remoteConnectionManager: RemoteConnectionManager | null = null; +const localIpcWindows = new Map(); + +function isTrustedLocalUrl(target: string, expected: URL): boolean { + try { + const url = new URL(target); + return expected.protocol === "file:" + ? url.protocol === "file:" && url.host === expected.host && url.pathname === expected.pathname + : url.origin === expected.origin; + } catch { + return false; + } +} + +function createLocalWindow( + options: BrowserWindowConstructorOptions, + page: "index.html" | "terminal.html" | "desktop.html" = "index.html" +): BrowserWindow { + const window = new BrowserWindow(options); + const contents = window.webContents; + const expected = + !app.isPackaged && !forceDistLoad + ? new URL( + page === "terminal.html" + ? "http://localhost:5173" + : "http://" + (getXumEnv("DEVSERVER_HOST") ?? "127.0.0.1") + ":" + devServerPort + ) + : pathToFileURL(path.join(__dirname, "..", page)); + localIpcWindows.set(contents, expected); + contents.once("destroyed", () => localIpcWindows.delete(contents)); + // Redirects and other packaged files must not retain this window's privileged preload. + const guardNavigation = (event: ElectronEvent, target: string): void => { + if (!isTrustedLocalUrl(target, expected)) event.preventDefault(); + }; + contents.on("will-navigate", guardNavigation); + contents.on("will-redirect", guardNavigation); + return window; +} + +function isLocalIpcSender(event: IpcMainEvent | IpcMainInvokeEvent): boolean { + const expected = localIpcWindows.get(event.sender); + return ( + expected != null && + event.senderFrame === event.sender.mainFrame && + isTrustedLocalUrl(event.senderFrame.url, expected) + ); +} + const requireDesktopModule = createRequire(__filename); // XUM_PROXY_URI is canonical; the transition layer mirrors legacy MUX_PROXY_URI. @@ -369,6 +431,49 @@ function timestamp(): string { return `${hours}:${minutes}:${seconds}.${ms}`; } +function initializeRemoteConnections(): void { + const manager = new RemoteConnectionManager({ + createWindow: (options) => new BrowserWindow(options), + onConnected: () => mainWindow?.hide(), + onDisconnected: () => { + if (!isQuitting) openXumFromTray(); + }, + onStateChanged: (state) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(REMOTE_CONNECTION_CHANNELS.stateChanged, state); + } + const returnItem = Menu.getApplicationMenu()?.getMenuItemById("return-to-local"); + if (returnItem) returnItem.enabled = state.status !== "disconnected"; + }, + openExternal: (url) => { + shell.openExternal(url).catch(() => { + log.warn("Cannot open the remote server link in the browser."); + }); + }, + }); + remoteConnectionManager = manager; + + // Keep desktop connection controls off the network API and out of remote pages. + const assertLocalController = (event: IpcMainInvokeEvent): void => { + if (!isLocalIpcSender(event) || event.sender !== mainWindow?.webContents) { + throw new Error("Remote connection controls require the local desktop window."); + } + }; + electronIpcMain.handle(REMOTE_CONNECTION_CHANNELS.getState, (event) => { + assertLocalController(event); + return manager.getState(); + }); + electronIpcMain.handle(REMOTE_CONNECTION_CHANNELS.connect, (event, url: unknown) => { + assertLocalController(event); + if (typeof url !== "string") throw new Error("Enter a valid server URL."); + return manager.connect(url); + }); + electronIpcMain.handle(REMOTE_CONNECTION_CHANNELS.disconnect, (event) => { + assertLocalController(event); + manager.disconnect(); + }); +} + function createMenu() { const template: MenuItemConstructorOptions[] = [ { @@ -411,7 +516,20 @@ function createMenu() { }, { label: "Window", - submenu: [{ role: "minimize" }, { role: "close" }], + submenu: [ + { role: "minimize" }, + { role: "close" }, + { type: "separator" }, + { + id: "return-to-local", + label: "Return to Local", + accelerator: REMOTE_CONNECTION_RETURN_ACCELERATOR, + enabled: + remoteConnectionManager?.getState().status !== "disconnected" && + remoteConnectionManager != null, + click: () => remoteConnectionManager?.disconnect(), + }, + ], }, ]; @@ -425,6 +543,7 @@ function createMenu() { label: "Settings...", accelerator: "Cmd+,", click: () => { + openXumFromTray(); services?.menuEventService.emitOpenSettings(); }, }, @@ -500,15 +619,8 @@ function openXumFromTray() { return; } - // On macOS the app stays open after all windows are closed; recreate the window. - if (process.platform === "darwin") { - if (!services) { - console.warn(`[${timestamp()}] [tray] Cannot open xum (services not loaded yet)`); - return; - } - - createWindow(); - } + // A remote window can keep the app open after the local window closes. + if (services) createWindow(); } function updateTrayIcon() { @@ -755,7 +867,13 @@ async function loadServices(): Promise { }); electronIpcMain.on("start-orpc-server", (event) => { + // SECURITY AUDIT: only registered local main frames can receive the local bearer credential. + if (!isLocalIpcSender(event)) { + for (const port of event.ports) port.close(); + return; + } const [serverPort] = event.ports; + if (!serverPort) return; // Use Object.defineProperties to copy all property descriptors from // orpcContext as own-properties (required by oRPC's internal property // enumeration) while preserving any getters that must resolve lazily @@ -823,7 +941,9 @@ async function loadServices(): Promise { } // Set TerminalWindowManager for desktop mode (pop-out terminal windows) - const terminalWindowManager = new TerminalWindowManagerClass(config); + const terminalWindowManager = new TerminalWindowManagerClass(config, (options) => + createLocalWindow(options, "terminal.html") + ); services.setProjectDirectoryPicker(async (initialPath) => { const win = BrowserWindow.getFocusedWindow(); if (!win) return null; @@ -854,7 +974,10 @@ async function loadServices(): Promise { }); services.setDesktopWindowManager( - new DesktopWindowManager((options) => new BrowserWindow(options), app.isPackaged) + new DesktopWindowManager( + (options) => createLocalWindow(options, "desktop.html"), + app.isPackaged + ) ); services.setTerminalWindowManager(terminalWindowManager); @@ -931,7 +1054,7 @@ function createWindow() { console.log(`[${timestamp()}] [window] Creating BrowserWindow...`); - mainWindow = new BrowserWindow({ + mainWindow = createLocalWindow({ x: windowState.x, y: windowState.y, width: windowState.width, @@ -1245,6 +1368,7 @@ async function startDesktopAfterStorage(): Promise { await showSplashScreen(); // Wait for splash to actually load } await loadServices(); + initializeRemoteConnections(); createWindow(); createTray(); // Note: splash closes in ready-to-show event handler @@ -1276,6 +1400,7 @@ async function startDesktopAfterStorage(): Promise { // Ensure window close handlers don't block an explicit quit. // IMPORTANT: must be set before any early returns. isQuitting = true; + remoteConnectionManager?.dispose(); if (isUpdateInstallInProgress()) { // Don't block updater-driven quitAndInstall() — let Electron quit immediately // so the platform installer can take over. Best-effort cleanup only. diff --git a/src/desktop/preload.ts b/src/desktop/preload.ts index d680e2bfee0..2a0adb617c3 100644 --- a/src/desktop/preload.ts +++ b/src/desktop/preload.ts @@ -18,6 +18,8 @@ import { resolveXumEnvironmentValue } from "@/common/compat/legacyMux"; import { contextBridge, ipcRenderer } from "electron"; import type { DeepLinkPayload } from "@/common/types/deepLink"; +import type { RemoteConnectionApi, RemoteConnectionState } from "@/common/types/remoteConnection"; +import { REMOTE_CONNECTION_CHANNELS } from "@/common/constants/remoteConnection"; const getXumEnv = (suffix: string): string | undefined => resolveXumEnvironmentValue(suffix, process.env); @@ -53,12 +55,24 @@ function getEnableTutorialsInSandbox(): boolean | undefined { // Forward ORPC MessagePort from renderer to main process window.addEventListener("message", (event) => { - if (event.data === "start-orpc-client" && event.ports?.[0]) { + if (event.source === window && event.data === "start-orpc-client" && event.ports?.[0]) { ipcRenderer.postMessage("start-orpc-server", null, [...event.ports]); } }); +const remoteConnection: RemoteConnectionApi = { + getState: () => ipcRenderer.invoke(REMOTE_CONNECTION_CHANNELS.getState), + connect: (url) => ipcRenderer.invoke(REMOTE_CONNECTION_CHANNELS.connect, url), + disconnect: () => ipcRenderer.invoke(REMOTE_CONNECTION_CHANNELS.disconnect), + onStateChanged: (callback) => { + const listener = (_event: unknown, state: RemoteConnectionState) => callback(state); + ipcRenderer.on(REMOTE_CONNECTION_CHANNELS.stateChanged, listener); + return () => ipcRenderer.off(REMOTE_CONNECTION_CHANNELS.stateChanged, listener); + }, +}; + contextBridge.exposeInMainWorld("api", { + remoteConnection, platform: process.platform, versions: { node: process.versions.node, diff --git a/src/desktop/remoteConnectionManager.test.ts b/src/desktop/remoteConnectionManager.test.ts new file mode 100644 index 00000000000..6348ce1875f --- /dev/null +++ b/src/desktop/remoteConnectionManager.test.ts @@ -0,0 +1,718 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import type { BrowserWindow, BrowserWindowConstructorOptions } from "electron"; +import { EventEmitter } from "node:events"; +import type { RemoteConnectionState } from "@/common/types/remoteConnection"; +import { RemoteConnectionManager } from "./remoteConnectionManager"; + +class TestWindow extends EventEmitter { + destroyed = false; + minimized = false; + focused = true; + url = ""; + loading = Promise.resolve(); + webContents = Object.assign(new EventEmitter(), { + getURL: () => this.url, + paste: mock(() => undefined), + executeJavaScriptInIsolatedWorld: mock< + (worldId: number, scripts: Array<{ code: string }>) => Promise + >(() => Promise.resolve(true)), + session: { + setPermissionRequestHandler: + mock< + ( + handler: ( + contents: unknown, + permission: string, + callback: (allow: boolean) => void, + details: { isMainFrame: boolean; requestingUrl: string } + ) => void + ) => void + >(), + setPermissionCheckHandler: mock<(handler: () => boolean) => void>(), + }, + setWindowOpenHandler: mock< + ( + handler: (details: { url: string }) => { + action: string; + overrideBrowserWindowOptions?: BrowserWindowConstructorOptions; + } + ) => void + >(), + }); + isDestroyed = () => this.destroyed; + isMinimized = () => this.minimized; + isFocused = () => this.focused; + restore = mock(() => { + this.minimized = false; + }); + show = mock(() => undefined); + focus = mock(() => undefined); + loadURL = mock((url: string) => { + this.url = url; + return this.loading; + }); + destroy = mock(() => { + this.destroyed = true; + this.emit("closed"); + }); + close = () => { + this.destroyed = true; + this.emit("closed"); + }; +} + +const managers: RemoteConnectionManager[] = []; +afterEach(() => { + for (const manager of managers.splice(0)) manager.dispose(); +}); + +function setup(loading = Promise.resolve()) { + const windows: TestWindow[] = []; + const options: BrowserWindowConstructorOptions[] = []; + const onConnected = mock(() => undefined); + const onDisconnected = mock(() => undefined); + const onStateChanged = mock<(state: RemoteConnectionState) => void>(); + const openExternal = mock<(url: string) => void>(); + const manager = new RemoteConnectionManager({ + createWindow: (windowOptions) => { + const window = new TestWindow(); + window.loading = loading; + windows.push(window); + options.push(windowOptions); + // Electron is the host boundary. Keep this fake local to avoid global module mocks. + return window as unknown as BrowserWindow; + }, + onConnected, + onDisconnected, + onStateChanged, + openExternal, + }); + managers.push(manager); + return { + manager, + windows, + options, + onConnected, + onDisconnected, + onStateChanged, + openExternal, + setLoading: (promise: Promise) => { + loading = promise; + }, + }; +} + +function deferred() { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe("RemoteConnectionManager", () => { + test("keeps the local window until load completes and shares duplicate connections", async () => { + const load = deferred(); + const { manager, windows, onConnected, onStateChanged } = setup(load.promise); + const first = manager.connect("https://example.com/path?token=first"); + const duplicate = manager.connect("https://example.com/other?token=second"); + expect(windows).toHaveLength(1); + expect(windows[0].loadURL).toHaveBeenCalledTimes(1); + expect(windows[0].show).not.toHaveBeenCalled(); + expect(onConnected).not.toHaveBeenCalled(); + expect(manager.getState()).toEqual({ status: "connecting", origin: "https://example.com" }); + load.resolve(); + await Promise.all([first, duplicate]); + expect(onConnected).toHaveBeenCalledTimes(1); + expect(windows[0].show).toHaveBeenCalled(); + expect(windows[0].focus).toHaveBeenCalled(); + expect(onStateChanged.mock.calls.map(([state]) => state.status)).toEqual([ + "connecting", + "connected", + ]); + windows[0].minimized = true; + await manager.connect("https://example.com/"); + expect(windows[0].restore).toHaveBeenCalledTimes(1); + expect(windows[0].minimized).toBe(false); + expect(windows).toHaveLength(1); + expect(onConnected).toHaveBeenCalledTimes(1); + }); + + test.each([false, true])( + "rejects another origin without replacing the current window (loaded=%s)", + async (loaded) => { + const load = deferred(); + const { manager, windows } = setup(load.promise); + const pending = manager.connect("https://example.com/"); + if (loaded) { + load.resolve(); + await pending; + } + expect(manager.connect("https://other.example.com/")).rejects.toThrow(); + expect(windows).toHaveLength(1); + expect(windows[0].destroyed).toBe(false); + expect(manager.getState().origin).toBe("https://example.com"); + load.resolve(); + await pending; + } + ); + + test.each(["resolve", "reject"] as const)( + "disconnects pending duplicates before load settles: %s", + async (completion) => { + const load = deferred(); + const { manager, windows, onConnected, onDisconnected } = setup(load.promise); + const first = manager.connect("https://example.com/"); + const duplicate = manager.connect("https://example.com/"); + manager.disconnect(); + await Promise.all([first, duplicate]); + expect(manager.getState()).toEqual({ status: "disconnected", origin: null }); + expect(onDisconnected).toHaveBeenCalledTimes(1); + expect(windows[0].destroyed).toBe(true); + if (completion === "resolve") load.resolve(); + else load.reject(new Error("late failure")); + await load.promise.catch(() => undefined); + expect(onConnected).not.toHaveBeenCalled(); + expect(windows[0].show).not.toHaveBeenCalled(); + manager.disconnect(); + expect(onDisconnected).toHaveBeenCalledTimes(1); + } + ); + + test.each(["resolve", "reject"] as const)( + "ignores old load completion and events after a new connection: %s", + async (completion) => { + const oldLoad = deferred(); + const newLoad = deferred(); + const { manager, windows, onConnected, onDisconnected, setLoading } = setup(oldLoad.promise); + const oldConnection = manager.connect("https://old.example.com/"); + manager.disconnect(); + await oldConnection; + setLoading(newLoad.promise); + const newConnection = manager.connect("https://new.example.com/"); + newLoad.resolve(); + await newConnection; + if (completion === "resolve") oldLoad.resolve(); + else oldLoad.reject(new Error("old failure")); + await oldLoad.promise.catch(() => undefined); + windows[0].emit("closed"); + windows[0].webContents.emit("render-process-gone"); + windows[0].webContents.emit( + "did-fail-load", + {}, + -105, + "failure", + "https://old.example.com/", + true + ); + expect(manager.getState()).toEqual({ + status: "connected", + origin: "https://new.example.com", + }); + expect(onConnected).toHaveBeenCalledTimes(1); + expect(onDisconnected).toHaveBeenCalledTimes(1); + expect(windows[1].destroyed).toBe(false); + expect(windows[0].show).not.toHaveBeenCalled(); + } + ); + + test.each(["closed", "crash", "failure"])( + "restores the local window once after %s", + async (event) => { + const { manager, windows, onDisconnected } = setup(); + await manager.connect("https://example.com/"); + const window = windows[0]; + if (event === "closed") window.close(); + else if (event === "crash") window.webContents.emit("render-process-gone"); + else + window.webContents.emit("did-fail-load", {}, -105, "failure", "https://example.com/", true); + expect(manager.getState().status).toBe("disconnected"); + expect(window.destroyed).toBe(true); + expect(onDisconnected).toHaveBeenCalledTimes(1); + window.webContents.emit("render-process-gone"); + window.emit("closed"); + manager.disconnect(); + expect(onDisconnected).toHaveBeenCalledTimes(1); + } + ); + + test.each(["closed", "crash", "failure"])( + "restores local state when a pending window stops: %s", + async (event) => { + const load = deferred(); + const { manager, windows, onConnected, onDisconnected } = setup(load.promise); + const pending = manager.connect("https://example.com/?token=private-token"); + const window = windows[0]; + if (event === "closed") window.close(); + else if (event === "crash") window.webContents.emit("render-process-gone"); + else + window.webContents.emit( + "did-fail-load", + {}, + -105, + "private-token", + "https://example.com/?token=private-token", + true + ); + await pending; + expect(manager.getState().status).toBe("disconnected"); + expect(JSON.stringify(manager.getState())).not.toContain("private-token"); + expect(onDisconnected).toHaveBeenCalledTimes(1); + expect(window.destroyed).toBe(true); + load.resolve(); + await load.promise; + expect(onConnected).not.toHaveBeenCalled(); + expect(window.show).not.toHaveBeenCalled(); + } + ); + + test("ignores cancelled navigation and subframe failures", async () => { + const { manager, windows, onDisconnected } = setup(); + await manager.connect("https://example.com/"); + windows[0].webContents.emit("did-fail-load", {}, -3, "aborted", "https://example.com/", true); + windows[0].webContents.emit( + "did-fail-load", + {}, + -105, + "subframe", + "https://example.com/", + false + ); + expect(manager.getState().status).toBe("connected"); + expect(windows[0].destroyed).toBe(false); + expect(onDisconnected).not.toHaveBeenCalled(); + }); + + test("sanitizes rejected load errors and restores the local window", async () => { + const load = deferred(); + const token = "secret-load-token"; + const url = "https://example.com/?token=" + token; + const { manager, windows, options, onConnected, onDisconnected, onStateChanged } = setup( + load.promise + ); + const pending = manager.connect(url); + load.reject(new Error("Cannot load " + url)); + expect(pending).rejects.toThrow(); + expect(windows[0].loadURL).toHaveBeenCalledWith(url); + expect(windows[0].destroyed).toBe(true); + expect(onConnected).not.toHaveBeenCalled(); + expect(onDisconnected).toHaveBeenCalledTimes(1); + expect(manager.getState().error).toBeTruthy(); + expect(JSON.stringify(onStateChanged.mock.calls)).not.toContain(token); + expect(JSON.stringify(options)).not.toContain(token); + await pending.catch((error: unknown) => { + expect(String(error)).not.toContain(token); + }); + }); + + test.each([false, true])("dispose suppresses local restoration (loaded=%s)", async (loaded) => { + const load = deferred(); + const { manager, windows, onConnected, onDisconnected } = setup(load.promise); + const pending = manager.connect("https://example.com/"); + if (loaded) { + load.resolve(); + await pending; + } + manager.dispose(); + await pending; + load.resolve(); + await load.promise; + expect(windows[0].destroyed).toBe(true); + expect(onDisconnected).not.toHaveBeenCalled(); + expect(onConnected).toHaveBeenCalledTimes(loaded ? 1 : 0); + expect(manager.connect("https://example.com/")).rejects.toThrow(); + expect(windows).toHaveLength(1); + }); + + test("isolates origin sessions without a preload or URL tokens", async () => { + const { manager, options, onStateChanged, windows } = setup(); + const urls = [ + "https://example.com/path?token=private-token#private-session", + "https://EXAMPLE.com:443/other?token=other-token", + "http://example.com/", + "https://example.com:8443/", + "https://other.example.com/", + ]; + for (const url of urls) { + await manager.connect(url); + manager.disconnect(); + } + const partitions = options.map((option) => option.webPreferences?.partition); + expect(partitions[0]).toBe(partitions[1]); + expect(new Set(partitions).size).toBe(4); + for (const option of options) { + expect(option.show).toBe(false); + expect(option.webPreferences).toMatchObject({ + sandbox: true, + nodeIntegration: false, + contextIsolation: true, + webviewTag: false, + }); + expect(option.webPreferences?.preload).toBeUndefined(); + expect(option.webPreferences?.partition).toBeTruthy(); + } + expect(windows[0].loadURL).toHaveBeenCalledWith(urls[0]); + for (const secret of ["private-token", "private-session", "other-token"]) { + expect(JSON.stringify(options)).not.toContain(secret); + expect(JSON.stringify(onStateChanged.mock.calls)).not.toContain(secret); + } + }); + + test.each(["file:///etc/passwd", "https://user:password@example.com/", "not a URL"])( + "rejects invalid input before creating a window: %s", + (url) => { + const { manager, windows, onConnected, onStateChanged } = setup(); + expect(manager.connect(url)).rejects.toThrow(); + expect(windows).toHaveLength(0); + expect(manager.getState()).toEqual({ status: "disconnected", origin: null }); + expect(onConnected).not.toHaveBeenCalled(); + expect(onStateChanged).not.toHaveBeenCalled(); + } + ); + + test.each(["will-navigate", "will-redirect"])( + "restricts %s to credential-free URLs on the server origin", + async (event) => { + const { manager, windows } = setup(); + await manager.connect("https://example.com/"); + for (const url of [ + "https://example.com/path?token=next", + "https://EXAMPLE.com:443/other#hash", + ]) { + const preventDefault = mock(() => undefined); + windows[0].webContents.emit(event, { preventDefault }, url); + expect(preventDefault).not.toHaveBeenCalled(); + } + for (const url of [ + "https://other.example.com/", + "https://example.com:8443/", + "http://example.com/", + "file:///etc/passwd", + "javascript:alert(1)", + "data:text/html,hello", + "about:blank", + "xum://open", + "https://user:password@example.com/", + "invalid", + ]) { + const preventDefault = mock(() => undefined); + windows[0].webContents.emit(event, { preventDefault }, url); + expect(preventDefault).toHaveBeenCalledTimes(1); + } + } + ); + + test("blocks webviews and lets the host close pages with unload handlers", async () => { + const { manager, windows } = setup(); + await manager.connect("https://example.com/"); + for (const event of ["will-attach-webview", "will-prevent-unload"]) { + const preventDefault = mock(() => undefined); + windows[0].webContents.emit(event, { preventDefault }); + expect(preventDefault).toHaveBeenCalledTimes(1); + } + }); + + test("allows one blank auth popup with the remote session and no preload", async () => { + const { manager, windows, onDisconnected, openExternal } = setup(); + await manager.connect("https://example.com/"); + const contents = windows[0].webContents; + const openWindow = contents.setWindowOpenHandler.mock.calls[0][0]; + const first = openWindow({ url: "about:blank" }); + expect(first.action).toBe("allow"); + expect(first.overrideBrowserWindowOptions?.webPreferences).toMatchObject({ + sandbox: true, + nodeIntegration: false, + contextIsolation: true, + webviewTag: false, + }); + expect(first.overrideBrowserWindowOptions?.webPreferences?.preload).toBeUndefined(); + expect( + Object.is(first.overrideBrowserWindowOptions?.webPreferences?.session, contents.session) + ).toBe(true); + expect(openWindow({ url: "about:blank" })).toEqual({ action: "deny" }); + const popup = new TestWindow(); + contents.emit("did-create-window", popup); + expect(openWindow({ url: "about:blank" })).toEqual({ action: "deny" }); + popup.close(); + expect(manager.getState().status).toBe("connected"); + expect(onDisconnected).not.toHaveBeenCalled(); + expect(openExternal).not.toHaveBeenCalled(); + expect(openWindow({ url: "about:blank" }).action).toBe("allow"); + }); + + test("allows HTTP auth redirects but blocks privileged navigation and nested popups", async () => { + const { manager, windows, openExternal } = setup(); + await manager.connect("https://example.com/"); + const contents = windows[0].webContents; + contents.setWindowOpenHandler.mock.calls[0][0]({ url: "about:blank" }); + const popup = new TestWindow(); + contents.emit("did-create-window", popup); + for (const event of ["will-navigate", "will-redirect"]) { + for (const url of [ + "about:blank", + "https://auth.example.com/login", + "http://localhost:8080/callback", + "https://example.com/callback", + ]) { + const preventDefault = mock(() => undefined); + popup.webContents.emit(event, { preventDefault }, url); + expect(preventDefault).not.toHaveBeenCalled(); + } + for (const url of [ + "file:///etc/passwd", + "javascript:alert(1)", + "data:text/html,hello", + "xum://open", + "https://user:password@example.com/", + "invalid", + ]) { + const preventDefault = mock(() => undefined); + popup.webContents.emit(event, { preventDefault }, url); + expect(preventDefault).toHaveBeenCalledTimes(1); + } + } + for (const event of ["will-attach-webview", "will-prevent-unload"]) { + const preventDefault = mock(() => undefined); + popup.webContents.emit(event, { preventDefault }); + expect(preventDefault).toHaveBeenCalledTimes(1); + } + const openNested = popup.webContents.setWindowOpenHandler.mock.calls[0][0]; + for (const url of ["about:blank", "https://example.com/", "xum://open"]) { + expect(openNested({ url })).toEqual({ action: "deny" }); + } + expect(openExternal).not.toHaveBeenCalled(); + }); + + test.each(["disconnect", "dispose"] as const)( + "destroys an auth popup during %s", + async (action) => { + const { manager, windows, onDisconnected } = setup(); + await manager.connect("https://example.com/"); + const contents = windows[0].webContents; + contents.setWindowOpenHandler.mock.calls[0][0]({ url: "about:blank" }); + const popup = new TestWindow(); + contents.emit("did-create-window", popup); + manager[action](); + expect(popup.destroyed).toBe(true); + expect(windows[0].destroyed).toBe(true); + expect(onDisconnected).toHaveBeenCalledTimes(action === "disconnect" ? 1 : 0); + } + ); + + test("destroys a late auth popup without changing a new connection", async () => { + const { manager, windows, onDisconnected } = setup(); + await manager.connect("https://old.example.com/"); + const oldContents = windows[0].webContents; + oldContents.setWindowOpenHandler.mock.calls[0][0]({ url: "about:blank" }); + manager.disconnect(); + await manager.connect("https://new.example.com/"); + const popup = new TestWindow(); + oldContents.emit("did-create-window", popup); + expect(popup.destroyed).toBe(true); + expect(windows[1].destroyed).toBe(false); + expect(manager.getState()).toEqual({ status: "connected", origin: "https://new.example.com" }); + expect(onDisconnected).toHaveBeenCalledTimes(1); + }); + + test.each([ + "allowed", + "no gesture", + "truthy gesture", + "unfocused", + "wrong requester", + "subframe", + "wrong origin", + "wrong URL", + "failed check", + ])("gates clipboard writes on an active main-frame request: %s", async (scenario) => { + const { manager, windows } = setup(); + await manager.connect("https://example.com/"); + const window = windows[0]; + const contents = window.webContents; + const details = { isMainFrame: true, requestingUrl: window.url }; + let requester = contents; + if (scenario === "no gesture") + contents.executeJavaScriptInIsolatedWorld.mockResolvedValueOnce(false); + if (scenario === "truthy gesture") + contents.executeJavaScriptInIsolatedWorld.mockResolvedValueOnce("true"); + if (scenario === "unfocused") window.focused = false; + if (scenario === "wrong requester") requester = new TestWindow().webContents; + if (scenario === "subframe") details.isMainFrame = false; + if (scenario === "wrong URL") details.requestingUrl += "other"; + if (scenario === "wrong origin") { + window.url = "https://other.example.com/"; + details.requestingUrl = window.url; + } + if (scenario === "failed check") + contents.executeJavaScriptInIsolatedWorld.mockRejectedValueOnce( + new Error("renderer stopped") + ); + const request = contents.session.setPermissionRequestHandler.mock.calls[0][0]; + const allowed = await new Promise((resolve) => { + request(requester, "clipboard-sanitized-write", resolve, details); + }); + expect(allowed).toBe(scenario === "allowed"); + if (scenario === "allowed") { + // An isolated world prevents page scripts from replacing the activation getter. + expect(contents.executeJavaScriptInIsolatedWorld.mock.calls[0][0]).toBeGreaterThan(0); + } + }); + + test.each(["disconnect", "unfocus", "navigate", "destroy"])( + "denies clipboard writes after pending activation changes: %s", + async (action) => { + const { manager, windows } = setup(); + await manager.connect("https://example.com/"); + const window = windows[0]; + const contents = window.webContents; + const activation = deferred(); + contents.executeJavaScriptInIsolatedWorld.mockImplementation(async () => { + await activation.promise; + return true; + }); + const request = contents.session.setPermissionRequestHandler.mock.calls[0][0]; + const allowed = new Promise((resolve) => { + request(contents, "clipboard-sanitized-write", resolve, { + isMainFrame: true, + requestingUrl: window.url, + }); + }); + if (action === "disconnect") manager.disconnect(); + if (action === "unfocus") window.focused = false; + if (action === "navigate") window.url += "new"; + if (action === "destroy") window.destroy(); + activation.resolve(); + expect(await allowed).toBe(false); + } + ); + + test("uses native paste for the platform shortcut without granting clipboard reads", async () => { + const { manager, windows } = setup(); + await manager.connect("https://example.com/"); + const contents = windows[0].webContents; + const modifier = + process.platform === "darwin" + ? { meta: true, control: false } + : { control: true, meta: false }; + for (const key of ["v", "V"]) { + const preventDefault = mock(() => undefined); + contents.emit( + "before-input-event", + { preventDefault }, + { type: "keyDown", key, alt: false, ...modifier } + ); + expect(preventDefault).toHaveBeenCalledTimes(1); + } + expect(contents.paste).toHaveBeenCalledTimes(2); + for (const change of [ + { type: "keyUp" }, + { key: "c" }, + { alt: true }, + { meta: true, control: true }, + { meta: false, control: false }, + ]) { + const preventDefault = mock(() => undefined); + contents.emit( + "before-input-event", + { preventDefault }, + { type: "keyDown", key: "v", alt: false, ...modifier, ...change } + ); + expect(preventDefault).not.toHaveBeenCalled(); + } + expect(contents.paste).toHaveBeenCalledTimes(2); + const request = contents.session.setPermissionRequestHandler.mock.calls[0][0]; + const allowed = await new Promise((resolve) => { + request(contents, "clipboard-read", resolve, { + isMainFrame: true, + requestingUrl: windows[0].url, + }); + }); + expect(allowed).toBe(false); + }); + + test.each(["remote", "auth popup"])( + "returns to local with the platform shortcut from %s", + async (target) => { + const { manager, windows, onDisconnected } = setup(); + await manager.connect("https://example.com/"); + const remote = windows[0]; + const popup = new TestWindow(); + remote.webContents.setWindowOpenHandler.mock.calls[0][0]({ url: "about:blank" }); + remote.webContents.emit("did-create-window", popup); + const contents = target === "remote" ? remote.webContents : popup.webContents; + const modifier = + process.platform === "darwin" + ? { meta: true, control: false } + : { control: true, meta: false }; + for (const change of [ + { type: "keyUp" }, + { shift: false }, + { alt: true }, + { meta: true, control: true }, + { meta: false, control: false }, + ]) { + const preventDefault = mock(() => undefined); + contents.emit( + "before-input-event", + { preventDefault }, + { type: "keyDown", key: "l", shift: true, alt: false, ...modifier, ...change } + ); + expect(preventDefault).not.toHaveBeenCalled(); + expect(manager.getState().status).toBe("connected"); + } + const preventDefault = mock(() => undefined); + contents.emit( + "before-input-event", + { preventDefault }, + { type: "keyDown", key: "l", shift: true, alt: false, ...modifier } + ); + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(remote.destroyed).toBe(true); + expect(popup.destroyed).toBe(true); + expect(onDisconnected).toHaveBeenCalledTimes(1); + expect(manager.getState()).toEqual({ status: "disconnected", origin: null }); + } + ); + + test("denies permissions and popups, opening only HTTP links externally", async () => { + const { manager, windows, openExternal } = setup(); + await manager.connect("https://example.com/"); + const contents = windows[0].webContents; + const permissionRequest = contents.session.setPermissionRequestHandler.mock.calls[0][0]; + const permissionCheck = contents.session.setPermissionCheckHandler.mock.calls[0][0]; + for (const permission of [ + "media", + "geolocation", + "notifications", + "clipboard-read", + "unknown", + ]) { + const callback = mock<(allow: boolean) => void>(); + permissionRequest(contents, permission, callback, { + isMainFrame: true, + requestingUrl: "https://example.com/", + }); + expect(callback).toHaveBeenCalledWith(false); + } + expect(permissionCheck()).toBe(false); + const openWindow = contents.setWindowOpenHandler.mock.calls[0][0]; + for (const url of ["https://example.com/help", "http://external.example.com/help"]) { + expect(openWindow({ url })).toEqual({ action: "deny" }); + expect(openExternal).toHaveBeenLastCalledWith(url); + } + for (const url of [ + "file:///etc/passwd", + "javascript:alert(1)", + "data:text/html,hello", + "xum://open", + "mailto:user@example.com", + "https://user:password@example.com/", + "invalid", + ]) { + expect(openWindow({ url })).toEqual({ action: "deny" }); + } + expect(openExternal).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/desktop/remoteConnectionManager.ts b/src/desktop/remoteConnectionManager.ts new file mode 100644 index 00000000000..48f18a39afa --- /dev/null +++ b/src/desktop/remoteConnectionManager.ts @@ -0,0 +1,265 @@ +import type { BrowserWindow, BrowserWindowConstructorOptions, Event } from "electron"; +import { createHash } from "node:crypto"; +import { + REMOTE_CONNECTION_GESTURE_WORLD_ID, + REMOTE_CONNECTION_LOAD_TIMEOUT_MS, + REMOTE_CONNECTION_RETURN_KEY, +} from "@/common/constants/remoteConnection"; +import { + parseRemoteConnectionUrl, + type RemoteConnectionState, +} from "@/common/types/remoteConnection"; +import { raceWithAbortAndTimeout } from "@/node/utils/concurrency/withTimeout"; + +interface RemoteWindowEntry { + window: BrowserWindow; + origin: string; + abort: AbortController; + loaded: Promise; + popup: BrowserWindow | "opening" | null; +} + +interface RemoteWindowOptions { + createWindow(options: BrowserWindowConstructorOptions): BrowserWindow; + onConnected(): void; + onDisconnected(): void; + onStateChanged(state: RemoteConnectionState): void; + openExternal(url: string): void; +} + +const REMOTE_WEB_PREFERENCES = { + sandbox: true, + nodeIntegration: false, + contextIsolation: true, + webviewTag: false, + spellcheck: false, +}; + +/** Owns remote windows, never the local backend or its running tasks. */ +export class RemoteConnectionManager { + private entry: RemoteWindowEntry | null = null; + private state: RemoteConnectionState = { status: "disconnected", origin: null }; + private disposed = false; + + constructor(private readonly options: RemoteWindowOptions) {} + + getState(): RemoteConnectionState { + return this.state; + } + + async connect(input: string): Promise { + if (this.disposed) throw new Error("Remote connections are shutting down."); + const url = parseRemoteConnectionUrl(input); + const existing = this.entry; + if (existing) { + if (existing.origin !== url.origin) { + throw new Error("Disconnect the current remote server first."); + } + await existing.loaded; + if (this.entry === existing) { + if (existing.window.isMinimized()) existing.window.restore(); + existing.window.show(); + existing.window.focus(); + } + return; + } + + // SECURITY AUDIT: remote HTML must never receive the local preload or local session credentials. + // A partition per origin also isolates servers that share a hostname but use different ports. + const partition = "persist:xum-remote-" + createHash("sha256").update(url.origin).digest("hex"); + const window = this.options.createWindow({ + width: 1200, + height: 800, + title: "Xum — " + url.host, + show: false, + webPreferences: { + ...REMOTE_WEB_PREFERENCES, + partition, + }, + }); + const entry: RemoteWindowEntry = { + window, + origin: url.origin, + abort: new AbortController(), + loaded: Promise.resolve(), + popup: null, + }; + this.entry = entry; + this.setState({ status: "connecting", origin: url.origin }); + this.guardWindow(entry); + // Reserve the window before loading. Duplicate requests share its completion. + entry.loaded = this.loadWindow(entry, url.href); + await entry.loaded; + } + + private guardWindow(entry: RemoteWindowEntry): void { + const contents = entry.window.webContents; + contents.session.setPermissionCheckHandler(() => false); + contents.session.setPermissionRequestHandler((requester, permission, callback, details) => { + if ( + permission !== "clipboard-sanitized-write" || + requester !== contents || + !details.isMainFrame + ) { + callback(false); + return; + } + this.allowClipboardWrite(entry, details.requestingUrl).then(callback, () => callback(false)); + }); + this.installInputHandler(entry, entry.window); + contents.on("will-attach-webview", (event) => event.preventDefault()); + contents.on("will-prevent-unload", (event) => event.preventDefault()); + const guardNavigation = (event: Event, target: string): void => { + try { + if (parseRemoteConnectionUrl(target).origin === entry.origin) return; + } catch { + // Malformed URLs and non-HTTP schemes cannot navigate remote windows. + } + event.preventDefault(); + }; + contents.on("will-navigate", guardNavigation); + contents.on("will-redirect", guardNavigation); + contents.setWindowOpenHandler(({ url }) => { + // Browser OAuth flows retain a blank popup handle before fetching the authorization URL. + if (url === "about:blank" && this.entry === entry && entry.popup == null) { + entry.popup = "opening"; + return { + action: "allow", + overrideBrowserWindowOptions: { + webPreferences: { ...REMOTE_WEB_PREFERENCES, session: contents.session }, + }, + }; + } + try { + const target = parseRemoteConnectionUrl(url); + this.options.openExternal(target.href); + } catch { + // Remote content cannot launch local programs through custom URL schemes. + } + return { action: "deny" }; + }); + contents.on("did-create-window", (popup) => this.guardAuthPopup(entry, popup)); + contents.on("render-process-gone", () => { + this.finish(entry, "The remote window stopped. Connect again to retry."); + }); + contents.on("did-fail-load", (_event, errorCode, _description, _url, isMainFrame) => { + // Ignore cancelled navigation and subresource errors. + if (isMainFrame && errorCode !== -3) { + this.finish(entry, "Cannot load the remote server. Check its URL and network connection."); + } + }); + entry.window.on("closed", () => this.finish(entry)); + } + + private installInputHandler(entry: RemoteWindowEntry, window: BrowserWindow): void { + window.webContents.on("before-input-event", (event, input) => { + const modifier = + process.platform === "darwin" ? input.meta && !input.control : input.control && !input.meta; + if (this.entry !== entry || input.type !== "keyDown" || !modifier || input.alt) return; + if (input.shift && input.key.toUpperCase() === REMOTE_CONNECTION_RETURN_KEY) { + // Return remains available even when remote content handles its own shortcuts. + event.preventDefault(); + this.disconnect(); + } else if (input.key.toLowerCase() === "v") { + // Native paste sends clipboardData to inputs and terminals without granting background reads. + event.preventDefault(); + window.webContents.paste(); + } + }); + } + + private async allowClipboardWrite( + entry: RemoteWindowEntry, + requestingUrl: string + ): Promise { + const isActiveRequest = (): boolean => + this.entry === entry && + !entry.window.isDestroyed() && + entry.window.isFocused() && + entry.window.webContents.getURL() === requestingUrl; + if (!isActiveRequest() || parseRemoteConnectionUrl(requestingUrl).origin !== entry.origin) + return false; + // SECURITY AUDIT: the page can replace its own navigator properties, but not this isolated world's properties. + const activated: unknown = await entry.window.webContents.executeJavaScriptInIsolatedWorld( + REMOTE_CONNECTION_GESTURE_WORLD_ID, + [{ code: "navigator.userActivation.isActive" }] + ); + return activated === true && isActiveRequest(); + } + + private guardAuthPopup(entry: RemoteWindowEntry, popup: BrowserWindow): void { + if (this.entry !== entry) { + popup.destroy(); + return; + } + entry.popup = popup; + this.installInputHandler(entry, popup); + popup.on("closed", () => { + if (entry.popup === popup) entry.popup = null; + }); + // OAuth redirects cross origins. They retain only the remote session, never local IPC access. + const guardNavigation = (event: Event, target: string): void => { + if (target === "about:blank") return; + try { + parseRemoteConnectionUrl(target); + } catch { + event.preventDefault(); + } + }; + popup.webContents.on("will-navigate", guardNavigation); + popup.webContents.on("will-redirect", guardNavigation); + popup.webContents.on("will-attach-webview", (event) => event.preventDefault()); + popup.webContents.on("will-prevent-unload", (event) => event.preventDefault()); + popup.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + } + + private async loadWindow(entry: RemoteWindowEntry, url: string): Promise { + let error = "Cannot load the remote server. Check its URL and network connection."; + try { + const result = await raceWithAbortAndTimeout(entry.window.loadURL(url), { + signal: entry.abort.signal, + timeoutMs: REMOTE_CONNECTION_LOAD_TIMEOUT_MS, + }); + if (result.kind === "aborted" || this.entry !== entry) return; + if (result.kind === "timeout") { + error = "The remote server did not respond in time. Connect again to retry."; + throw new Error(error); + } + entry.window.show(); + entry.window.focus(); + // Hide only the local window. Local agents and their renderer state remain alive. + this.options.onConnected(); + this.setState({ status: "connected", origin: entry.origin }); + } catch { + // Electron errors can include URL tokens. Report only a credential-free error. + if (this.entry !== entry) return; + this.finish(entry, error); + throw new Error(error); + } + } + + private setState(state: RemoteConnectionState): void { + this.state = state; + this.options.onStateChanged(state); + } + + private finish(entry: RemoteWindowEntry, error?: string): void { + if (this.entry !== entry) return; + this.entry = null; + entry.abort.abort(); + const popup = entry.popup; + if (popup && popup !== "opening" && !popup.isDestroyed()) popup.destroy(); + if (!entry.window.isDestroyed()) entry.window.destroy(); + this.setState({ status: "disconnected", origin: null, ...(error ? { error } : {}) }); + if (!this.disposed) this.options.onDisconnected(); + } + + disconnect(): void { + if (this.entry) this.finish(this.entry); + } + + dispose(): void { + this.disposed = true; + this.disconnect(); + } +} diff --git a/src/desktop/terminalWindowManager.ts b/src/desktop/terminalWindowManager.ts index 864cfff6661..60de2b48261 100644 --- a/src/desktop/terminalWindowManager.ts +++ b/src/desktop/terminalWindowManager.ts @@ -5,7 +5,7 @@ * Each workspace can have multiple terminal windows open simultaneously. */ -import { app, BrowserWindow, shell } from "electron"; +import { app, BrowserWindow, shell, type BrowserWindowConstructorOptions } from "electron"; import * as path from "path"; import { resolveXumEnvironmentValue } from "@/common/compat/legacyMux"; import { normalizeAndValidateExternalUrl } from "@/desktop/utils/normalizeAndValidateExternalUrl"; @@ -23,7 +23,12 @@ export class TerminalWindowManager { private windowCount = 0; // Counter for unique window IDs private readonly config: Config; - constructor(config: Config) { + constructor( + config: Config, + private readonly createWindow: (options: BrowserWindowConstructorOptions) => BrowserWindow = ( + options + ) => new BrowserWindow(options) + ) { this.config = config; } @@ -52,7 +57,7 @@ export class TerminalWindowManager { title = `Terminal ${windowId} — ${workspaceId}`; } - const terminalWindow = new BrowserWindow({ + const terminalWindow = this.createWindow({ width: 1000, height: 600, title, diff --git a/tests/e2e/scenarios/remoteConnection.spec.ts b/tests/e2e/scenarios/remoteConnection.spec.ts new file mode 100644 index 00000000000..1ca318250b8 --- /dev/null +++ b/tests/e2e/scenarios/remoteConnection.spec.ts @@ -0,0 +1,214 @@ +import assert from "node:assert/strict"; +import type { BrowserWindow, Clipboard } from "electron"; +import { once } from "node:events"; +import { createServer } from "node:http"; +import { electronTest, electronExpect as expect } from "../electronTest"; + +const test = electronTest.extend<{ remoteServer: { url: string; requests: string[] } }>({ + remoteServer: async ({ workspace }, use) => { + assert(workspace.configRoot); + const requests: string[] = []; + let authUrl = ""; + const server = createServer((request, response) => { + requests.push(request.url ?? ""); + if (request.url === "/auth-url") { + response.writeHead(200, { "Content-Type": "text/plain" }); + response.end(authUrl); + return; + } + response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + if (request.url === "/callback") { + response.end( + '

Auth callback

' + + '" + ); + return; + } + response.end( + '

Remote server

' + + '

' + + '

' + + '" + ); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + assert(address && typeof address !== "string"); + const url = "http://127.0.0.1:" + address.port; + // A separate port proves that auth redirects can cross origins. + const authServer = createServer((_request, response) => { + response.writeHead(302, { Location: url + "/callback" }); + response.end(); + }); + try { + authServer.listen(0, "127.0.0.1"); + await once(authServer, "listening"); + const authAddress = authServer.address(); + assert(authAddress && typeof authAddress !== "string"); + authUrl = "http://127.0.0.1:" + authAddress.port + "/authorize"; + await use({ url, requests }); + } finally { + await Promise.all( + [server, authServer].map(async (activeServer) => { + const closed = new Promise((resolve, reject) => { + activeServer.close((error) => (error ? reject(error) : resolve())); + }); + activeServer.closeAllConnections(); + await closed; + }) + ); + } + }, +}); + +test("remote connection isolates the page and returns to the same local renderer", async ({ + app, + page, + ui, + workspace, + remoteServer, +}) => { + // The Electron fixture creates a separate root and checks the child process environment. + expect(workspace.configRoot).not.toBe(""); + await page.waitForFunction(() => Boolean(window.__ORPC_CLIENT__)); + const localProjects = await page.evaluate(async () => { + const api = window.__ORPC_CLIENT__; + if (!api) throw new Error("Local API is unavailable"); + return api.projects.list(); + }); + expect(localProjects.length).toBeGreaterThan(0); + await ui.settings.open(); + await page.getByRole("button", { name: "Remote Connection", exact: true }).click(); + const localWindow = await app.browserWindow(page); + const localRenderer = await page.evaluateHandle(() => document.documentElement); + const url = remoteServer.url + "/?token=e2e-private-token"; + await page.getByLabel("Server URL", { exact: true }).fill(url); + const remoteOpened = app.waitForEvent("window"); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + const remote = await remoteOpened; + await expect(remote.getByRole("heading", { name: "Remote server" })).toBeVisible(); + expect(remoteServer.requests).toContain("/?token=e2e-private-token"); + expect( + await remote.evaluate(() => ({ + api: typeof window.api, + require: typeof Reflect.get(window, "require"), + process: typeof Reflect.get(window, "process"), + })) + ).toEqual({ api: "undefined", require: "undefined", process: "undefined" }); + await expect + .poll(() => localWindow.evaluate((window: BrowserWindow) => window.isVisible())) + .toBe(false); + await expect + .poll(() => page.evaluate(() => window.api?.remoteConnection?.getState())) + .toEqual({ + status: "connected", + origin: remoteServer.url, + }); + const previousClipboard = await app.evaluate(({ clipboard }: { clipboard: Clipboard }) => + clipboard.readText() + ); + try { + await remote.getByRole("button", { name: "Copy text", exact: true }).click(); + await expect(remote.locator("#copied")).toHaveText("Copied"); + expect( + await app.evaluate(({ clipboard }: { clipboard: Clipboard }) => clipboard.readText()) + ).toBe("remote-copy-value"); + await remote.getByRole("textbox", { name: "Paste target" }).click(); + await remote.keyboard.press("ControlOrMeta+v"); + await expect(remote.getByRole("textbox", { name: "Paste target" })).toHaveValue( + "remote-copy-value" + ); + await expect(remote.locator("#pasted")).toHaveText("remote-copy-value"); + expect( + await remote.evaluate(() => + navigator.clipboard.readText().then( + () => false, + () => true + ) + ) + ).toBe(true); + } finally { + await app.evaluate( + ({ clipboard }: { clipboard: Clipboard }, text) => clipboard.writeText(text), + previousClipboard + ); + } + const popupOpened = app.waitForEvent("window"); + await remote.getByRole("button", { name: "Sign in", exact: true }).click(); + const popup = await popupOpened; + await expect(popup.getByRole("heading", { name: "Auth callback" })).toBeVisible(); + expect( + await popup.evaluate(() => ({ + api: typeof window.api, + require: typeof Reflect.get(window, "require"), + })) + ).toEqual({ api: "undefined", require: "undefined" }); + await Promise.all([ + popup.waitForEvent("close"), + popup.getByRole("button", { name: "Finish sign in", exact: true }).click(), + ]); + await expect(remote.getByText("Signed in", { exact: true })).toBeVisible(); + // A remote connection must not stop the local backend or replace its renderer. + expect( + await page.evaluate(async () => { + const api = window.__ORPC_CLIENT__; + if (!api) throw new Error("Local API is unavailable while hidden"); + return api.projects.list(); + }) + ).toEqual(localProjects); + await remote.close(); + await expect + .poll(() => localWindow.evaluate((window: BrowserWindow) => window.isVisible())) + .toBe(true); + await expect + .poll(() => page.evaluate(() => window.api?.remoteConnection?.getState())) + .toEqual({ + status: "disconnected", + origin: null, + }); + expect(await localRenderer.evaluate((element) => element === document.documentElement)).toBe( + true + ); + expect(app.windows()).toEqual([page]); + await expect(page.getByLabel("Server URL", { exact: true })).toHaveValue(remoteServer.url); + const reconnectOpened = app.waitForEvent("window"); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + const reconnected = await reconnectOpened; + await expect(reconnected.getByRole("heading", { name: "Remote server" })).toBeVisible(); + const reconnectedWindow = await app.browserWindow(reconnected); + await Promise.all([ + reconnected.waitForEvent("close"), + // Use Electron input events to exercise before-input-event, not the CDP keyboard path. + reconnectedWindow.evaluate((window: BrowserWindow) => { + window.focus(); + window.webContents.sendInputEvent({ + type: "keyDown", + keyCode: "L", + modifiers: process.platform === "darwin" ? ["meta", "shift"] : ["control", "shift"], + }); + }), + ]); + await reconnectedWindow.dispose(); + await expect + .poll(() => localWindow.evaluate((window: BrowserWindow) => window.isVisible())) + .toBe(true); + expect(await localRenderer.evaluate((element) => element === document.documentElement)).toBe( + true + ); + await localRenderer.dispose(); + await localWindow.dispose(); +}); From 63dc72efcd2bb0dc526e782d10a774b9d101f613 Mon Sep 17 00:00:00 2001 From: Mux Date: Sat, 5 Sep 2026 19:58:44 -0500 Subject: [PATCH 2/5] =?UTF-8?q?[desktop]=20=F0=9F=A4=96=20fix:=20isolate?= =?UTF-8?q?=20path-mounted=20remote=20connections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve app-proxy paths for connection identity and reconnects. Redirect unavailable remote settings routes. --- _Generated with `xum` • Model: `openai:gpt-6-astra` • Thinking: `high` • Cost: `$49.68`_ Co-authored-by: Mux --- .../Sections/RemoteConnectionSection.tsx | 21 ++-- .../features/Settings/SettingsPage.test.tsx | 16 +++ .../features/Settings/SettingsPage.tsx | 24 +++- .../stories/App.remoteConnection.stories.tsx | 52 ++++---- src/common/types/remoteConnection.test.ts | 37 +++++- src/common/types/remoteConnection.ts | 13 +- src/desktop/remoteConnectionManager.test.ts | 115 ++++++++++++++++-- src/desktop/remoteConnectionManager.ts | 35 ++++-- tests/e2e/scenarios/remoteConnection.spec.ts | 59 ++++++++- 9 files changed, 308 insertions(+), 64 deletions(-) diff --git a/src/browser/features/Settings/Sections/RemoteConnectionSection.tsx b/src/browser/features/Settings/Sections/RemoteConnectionSection.tsx index 1cf5ad9ec44..1ac1a19435b 100644 --- a/src/browser/features/Settings/Sections/RemoteConnectionSection.tsx +++ b/src/browser/features/Settings/Sections/RemoteConnectionSection.tsx @@ -5,12 +5,12 @@ import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { isMac } from "@/browser/utils/ui/keybinds"; import { REMOTE_CONNECTION_RETURN_ACCELERATOR } from "@/common/constants/remoteConnection"; import { - parseRemoteConnectionUrl, + getRemoteConnectionServerUrl, type RemoteConnectionState, } from "@/common/types/remoteConnection"; import { getErrorMessage } from "@/common/utils/errors"; -export const REMOTE_CONNECTION_ORIGIN_KEY = "remoteConnectionOrigin"; +export const REMOTE_CONNECTION_URL_KEY = "remoteConnectionUrl"; const STATUS_LABELS: Record = { disconnected: "Disconnected", @@ -20,11 +20,11 @@ const STATUS_LABELS: Record = { export function RemoteConnectionSection() { const bridge = window.api?.remoteConnection; - const [savedOrigin, setSavedOrigin] = usePersistedState(REMOTE_CONNECTION_ORIGIN_KEY, ""); - // Keep pasted tokens transient. Only the validated origin can enter local storage. + const [savedUrl, setSavedUrl] = usePersistedState(REMOTE_CONNECTION_URL_KEY, ""); + // Keep pasted tokens transient. Save the server pathname for app-proxy connections. const [url, setUrl] = useState(() => { try { - return parseRemoteConnectionUrl(savedOrigin).origin; + return getRemoteConnectionServerUrl(savedUrl); } catch { return ""; } @@ -78,10 +78,10 @@ export function RemoteConnectionSection() { if (!bridge || !canConnect) return; setError(null); try { - const origin = parseRemoteConnectionUrl(url).origin; - setSavedOrigin(origin); + const serverUrl = getRemoteConnectionServerUrl(url); + setSavedUrl(serverUrl); const enteredUrl = url; - setUrl(origin); + setUrl(serverUrl); setConnecting(true); await bridge.connect(enteredUrl); } finally { @@ -137,7 +137,8 @@ export function RemoteConnectionSection() { />

Enter an HTTP or HTTPS URL. You can include a token link. Sign in through the remote web - UI. Only the server origin is saved. Xum does not connect automatically. + UI. The server address and path are saved without tokens. Xum does not connect + automatically.

@@ -166,7 +167,7 @@ export function RemoteConnectionSection() { : error ? "Connection state unavailable" : "Reading connection state…"} - {connection?.origin && · {connection.origin}} + {connection?.serverUrl && · {connection.serverUrl}}
{error && (

diff --git a/src/browser/features/Settings/SettingsPage.test.tsx b/src/browser/features/Settings/SettingsPage.test.tsx index b731a34a198..1944706701b 100644 --- a/src/browser/features/Settings/SettingsPage.test.tsx +++ b/src/browser/features/Settings/SettingsPage.test.tsx @@ -53,6 +53,22 @@ describe("SettingsPage", () => { expect(getSettingsSectionRedirect("plugins", false, false, true)).toBeNull(); }); + test("shows Remote Connection only when the desktop bridge is available", () => { + expect(getSettingsSections(false, false, false, true).map((section) => section.id)).toContain( + "remote-connection" + ); + expect(getSettingsSections(true, true, true, false).map((section) => section.id)).not.toContain( + "remote-connection" + ); + }); + + test("redirects an unavailable Remote Connection deep link to General", () => { + expect(getSettingsSectionRedirect("remote-connection", true, true, true, false)).toEqual({ + section: "general", + }); + expect(getSettingsSectionRedirect("remote-connection", false, false, false, true)).toBeNull(); + }); + test("always shows the Backup section", () => { expect(getSettingsSections(false, false, false).map((section) => section.id)).toContain( "backup" diff --git a/src/browser/features/Settings/SettingsPage.tsx b/src/browser/features/Settings/SettingsPage.tsx index 6d4e13edc1f..89369d160d7 100644 --- a/src/browser/features/Settings/SettingsPage.tsx +++ b/src/browser/features/Settings/SettingsPage.tsx @@ -192,7 +192,8 @@ export function getSettingsSectionRedirect( activeSection: string, governorEnabled: boolean, memoryEnabled: boolean, - agentPluginsEnabled: boolean + agentPluginsEnabled: boolean, + remoteConnectionAvailable = false ): SettingsSectionRedirect | null { if (LEGACY_EXPERIMENT_SETTINGS_SECTION_IDS.has(activeSection)) { return { section: "experiments", replace: true }; @@ -210,6 +211,10 @@ export function getSettingsSectionRedirect( return { section: BASE_SECTIONS[0]?.id ?? "general" }; } + if (!remoteConnectionAvailable && activeSection === "remote-connection") { + return { section: BASE_SECTIONS[0]?.id ?? "general" }; + } + return null; } @@ -224,14 +229,16 @@ export function SettingsPage(props: SettingsPageProps) { const governorEnabled = useExperimentValue(EXPERIMENT_IDS.MUX_GOVERNOR); const memoryEnabled = useExperimentValue(EXPERIMENT_IDS.MEMORY); const agentPluginsEnabled = useExperimentValue(EXPERIMENT_IDS.AGENT_PLUGINS); + const remoteConnectionAvailable = window.api?.remoteConnection != null; - // Keep routing on a valid section when experiment-owned settings move or disappear. + // Redirect restored links when an experiment or desktop bridge is unavailable. useEffect(() => { const redirect = getSettingsSectionRedirect( activeSection, governorEnabled, memoryEnabled, - agentPluginsEnabled + agentPluginsEnabled, + remoteConnectionAvailable ); if (!redirect) { return; @@ -243,7 +250,14 @@ export function SettingsPage(props: SettingsPageProps) { } setActiveSection(redirect.section); - }, [activeSection, setActiveSection, governorEnabled, memoryEnabled, agentPluginsEnabled]); + }, [ + activeSection, + setActiveSection, + governorEnabled, + memoryEnabled, + agentPluginsEnabled, + remoteConnectionAvailable, + ]); // Close settings on Escape. Uses bubble phase so inner surfaces (Select dropdowns, // Popover, Dialog) that call stopPropagation/preventDefault on Escape get first @@ -266,7 +280,7 @@ export function SettingsPage(props: SettingsPageProps) { governorEnabled, memoryEnabled, agentPluginsEnabled, - window.api?.remoteConnection != null + remoteConnectionAvailable ); const currentSection = sections.find((section) => section.id === activeSection) ?? sections[0]; const SectionComponent = currentSection.component; diff --git a/src/browser/stories/App.remoteConnection.stories.tsx b/src/browser/stories/App.remoteConnection.stories.tsx index 52c216137c4..2687e98b87a 100644 --- a/src/browser/stories/App.remoteConnection.stories.tsx +++ b/src/browser/stories/App.remoteConnection.stories.tsx @@ -2,20 +2,20 @@ import { expect, fn, userEvent, waitFor, within } from "@storybook/test"; import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; import { expandLeftSidebar } from "./helpers/uiState"; import { setupSettingsStory } from "@/browser/features/Settings/Sections/settingsStoryUtils"; -import { REMOTE_CONNECTION_ORIGIN_KEY } from "@/browser/features/Settings/Sections/RemoteConnectionSection"; +import { REMOTE_CONNECTION_URL_KEY } from "@/browser/features/Settings/Sections/RemoteConnectionSection"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { - parseRemoteConnectionUrl, + getRemoteConnectionServerUrl, type RemoteConnectionApi, type RemoteConnectionState, } from "@/common/types/remoteConnection"; -const SAVED_ORIGIN = "https://saved.example.com"; -const SERVER_ORIGIN = "https://remote.example.com"; -const TOKEN_URL = SERVER_ORIGIN + "/?token=transient-secret#private-fragment"; +const SAVED_SERVER_URL = "https://saved.example.com/@user/existing/apps/xum"; +const SERVER_URL = "https://remote.example.com/@user/workspace/apps/xum"; +const TOKEN_URL = SERVER_URL + "///?token=transient-secret#private-fragment"; function createRemoteBridge() { - let state: RemoteConnectionState = { origin: null, status: "disconnected" }; + let state: RemoteConnectionState = { serverUrl: null, status: "disconnected" }; const listeners = new Set<(next: RemoteConnectionState) => void>(); const publish = (next: RemoteConnectionState) => { state = next; @@ -24,11 +24,11 @@ function createRemoteBridge() { const bridge = { getState: fn(() => Promise.resolve(state)), connect: fn((url: string) => { - publish({ origin: parseRemoteConnectionUrl(url).origin, status: "connecting" }); + publish({ serverUrl: getRemoteConnectionServerUrl(url), status: "connecting" }); return Promise.resolve(); }), disconnect: fn(() => { - publish({ origin: null, status: "disconnected" }); + publish({ serverUrl: null, status: "disconnected" }); return Promise.resolve(); }), onStateChanged: fn((listener: (next: RemoteConnectionState) => void) => { @@ -48,7 +48,7 @@ export default { title: "App/RemoteConnection", beforeEach: () => { const previousApi = window.api; - const previousOrigin = readPersistedState(REMOTE_CONNECTION_ORIGIN_KEY, undefined); + const previousUrl = readPersistedState(REMOTE_CONNECTION_URL_KEY, undefined); remote = createRemoteBridge(); window.api = { platform: "linux", @@ -56,10 +56,10 @@ export default { ...previousApi, remoteConnection: remote.bridge, }; - updatePersistedState(REMOTE_CONNECTION_ORIGIN_KEY, SAVED_ORIGIN); + updatePersistedState(REMOTE_CONNECTION_URL_KEY, SAVED_SERVER_URL); return () => { window.api = previousApi; - updatePersistedState(REMOTE_CONNECTION_ORIGIN_KEY, previousOrigin); + updatePersistedState(REMOTE_CONNECTION_URL_KEY, previousUrl); }; }, }; @@ -86,7 +86,7 @@ async function exerciseConnection(canvasElement: HTMLElement) { const section = await openRemoteSettings(canvasElement); await waitFor(() => expect(section.getByRole("status")).toHaveTextContent("Disconnected")); const input = section.getByRole("textbox", { name: "Server URL" }); - await expect(input).toHaveValue(SAVED_ORIGIN); + await expect(input).toHaveValue(SAVED_SERVER_URL); await expect(remote.bridge.connect).not.toHaveBeenCalled(); // Invalid schemes and embedded passwords never reach the bridge or saved preferences. @@ -98,7 +98,7 @@ async function exerciseConnection(canvasElement: HTMLElement) { await userEvent.type(input, invalidUrl + "{Enter}"); await expect(await section.findByRole("alert")).toBeVisible(); await expect(remote.bridge.connect).not.toHaveBeenCalled(); - await expect(readPersistedState(REMOTE_CONNECTION_ORIGIN_KEY, "")).toBe(SAVED_ORIGIN); + await expect(readPersistedState(REMOTE_CONNECTION_URL_KEY, "")).toBe(SAVED_SERVER_URL); } remote.bridge.connect.mockRejectedValueOnce(new Error("The remote server is unavailable.")); @@ -108,23 +108,23 @@ async function exerciseConnection(canvasElement: HTMLElement) { "The remote server is unavailable." ); await expect(section.getByRole("button", { name: "Connect" })).toBeEnabled(); - await expect(readPersistedState(REMOTE_CONNECTION_ORIGIN_KEY, "")).toBe( + await expect(readPersistedState(REMOTE_CONNECTION_URL_KEY, "")).toBe( "https://offline.example.com" ); await userEvent.clear(input); await userEvent.type(input, TOKEN_URL); - await expect(readPersistedState(REMOTE_CONNECTION_ORIGIN_KEY, "")).toBe( + await expect(readPersistedState(REMOTE_CONNECTION_URL_KEY, "")).toBe( "https://offline.example.com" ); await userEvent.keyboard("{Enter}"); await waitFor(() => expect(remote.bridge.connect).toHaveBeenLastCalledWith(TOKEN_URL)); await expect(section.getByRole("button", { name: "Connecting…" })).toBeDisabled(); - await expect(readPersistedState(REMOTE_CONNECTION_ORIGIN_KEY, "")).toBe(SERVER_ORIGIN); - await expect(input).toHaveValue(SERVER_ORIGIN); + await expect(readPersistedState(REMOTE_CONNECTION_URL_KEY, "")).toBe(SERVER_URL); + await expect(input).toHaveValue(SERVER_URL); await expect(section.queryByRole("alert")).toBeNull(); - remote.publish({ origin: SERVER_ORIGIN, status: "connected" }); + remote.publish({ serverUrl: SERVER_URL, status: "connected" }); await waitFor(() => expect(section.getByRole("status")).toHaveTextContent("Connected")); const disconnect = section.getByRole("button", { name: "Disconnect" }); disconnect.focus(); @@ -136,10 +136,16 @@ async function exerciseConnection(canvasElement: HTMLElement) { await expect(remote.listeners.size).toBe(0); await userEvent.click(canvas.getByRole("button", { name: "Remote Connection" })); const restored = within(await canvas.findByRole("region", { name: "Remote connection" })); - await expect(restored.getByRole("textbox", { name: "Server URL" })).toHaveValue(SERVER_ORIGIN); + await expect(restored.getByRole("textbox", { name: "Server URL" })).toHaveValue(SERVER_URL); await expect(remote.bridge.connect).toHaveBeenCalledTimes(2); await expect(remote.listeners.size).toBe(1); + // Reconnect to the saved app-proxy path without the original token. + await userEvent.click(restored.getByRole("button", { name: "Connect" })); + await waitFor(() => expect(remote.bridge.connect).toHaveBeenLastCalledWith(SERVER_URL)); + await userEvent.click(restored.getByRole("button", { name: "Disconnect" })); + await expect(remote.bridge.connect).toHaveBeenCalledTimes(3); + // The test-runner ignores viewport globals. Pixel runs this contract at the pinned phone width. if (window.innerWidth < 768) { const region = canvas.getByRole("region", { name: "Remote connection" }); @@ -181,8 +187,8 @@ export const NewerStateWins: AppStory = { ); const section = await openRemoteSettings(canvasElement); await waitFor(() => expect(remote.bridge.getState).toHaveBeenCalled()); - remote.publish({ origin: SERVER_ORIGIN, status: "connected" }); - resolveSnapshot({ origin: null, status: "disconnected" }); + remote.publish({ serverUrl: SERVER_URL, status: "connected" }); + resolveSnapshot({ serverUrl: null, status: "disconnected" }); await waitFor(() => expect(section.getByRole("status")).toHaveTextContent("Connected")); await expect(section.getByRole("button", { name: "Disconnect" })).toBeEnabled(); await expect(section.getByRole("button", { name: "Connect" })).toBeDisabled(); @@ -190,9 +196,9 @@ export const NewerStateWins: AppStory = { }, }; -export const InvalidSavedOrigin: AppStory = { +export const InvalidSavedUrl: AppStory = { beforeEach: () => { - updatePersistedState(REMOTE_CONNECTION_ORIGIN_KEY, { invalid: true }); + updatePersistedState(REMOTE_CONNECTION_URL_KEY, { invalid: true }); }, render: () => , play: async ({ canvasElement }) => { diff --git a/src/common/types/remoteConnection.test.ts b/src/common/types/remoteConnection.test.ts index 1b5eb0906f2..6994552b1f9 100644 --- a/src/common/types/remoteConnection.test.ts +++ b/src/common/types/remoteConnection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { parseRemoteConnectionUrl } from "./remoteConnection"; +import { getRemoteConnectionServerUrl, parseRemoteConnectionUrl } from "./remoteConnection"; describe("parseRemoteConnectionUrl", () => { test.each([ @@ -53,3 +53,38 @@ describe("parseRemoteConnectionUrl", () => { expect(url.origin).toBe("https://example.com"); }); }); + +describe("getRemoteConnectionServerUrl", () => { + test.each([ + ["HTTPS://Example.COM:443/?token=secret#session", "https://example.com"], + ["http://localhost:3000/", "http://localhost:3000"], + ["https://example.com/mounted/xum/?token=secret#session", "https://example.com/mounted/xum"], + ["https://example.com/mounted/xum?token=other", "https://example.com/mounted/xum"], + ["https://example.com/other/xum/", "https://example.com/other/xum"], + [ + "https://example.com/@alice/workspace/apps/xum/workspaces/one?token=secret#chat", + "https://example.com/@alice/workspace/apps/xum", + ], + [ + "https://example.com/@alice/workspace/agent/apps/xum/settings/providers", + "https://example.com/@alice/workspace/agent/apps/xum", + ], + ["https://example.com/team%20one/xum/", "https://example.com/team%20one/xum"], + ])("preserves the server path without credentials or tokens: %s", (input, serverUrl) => { + expect(getRemoteConnectionServerUrl(input)).toBe(serverUrl); + }); + + test("keeps different path-mounted servers separate", () => { + const first = getRemoteConnectionServerUrl("https://example.com/first/?token=one"); + const second = getRemoteConnectionServerUrl("https://example.com/second/?token=two"); + expect(first).not.toBe(second); + expect(first).toBe(getRemoteConnectionServerUrl("https://example.com/first?token=new#session")); + }); + + test.each(["https://user:password@example.com/path", "file:///path", "invalid"])( + "rejects invalid server identities: %s", + (input) => { + expect(() => getRemoteConnectionServerUrl(input)).toThrow(); + } + ); +}); diff --git a/src/common/types/remoteConnection.ts b/src/common/types/remoteConnection.ts index d575e1a1dc7..0be3fbc6231 100644 --- a/src/common/types/remoteConnection.ts +++ b/src/common/types/remoteConnection.ts @@ -1,3 +1,5 @@ +import { getAppProxyBasePathFromPathname } from "@/common/appProxyBasePath"; + /** Local desktop controls. Remote pages never receive this bridge. */ export interface RemoteConnectionApi { getState(): Promise; @@ -7,8 +9,8 @@ export interface RemoteConnectionApi { } export interface RemoteConnectionState { - /** The server origin excludes credentials and URL tokens. */ - origin: string | null; + /** The server base URL retains its app-proxy path but excludes credentials and URL tokens. */ + serverUrl: string | null; status: "disconnected" | "connecting" | "connected"; error?: string; } @@ -29,3 +31,10 @@ export function parseRemoteConnectionUrl(input: string): URL { } return url; } + +/** Keep path-mounted servers distinct without retaining token links or page fragments. */ +export function getRemoteConnectionServerUrl(input: string): string { + const url = parseRemoteConnectionUrl(input); + const pathname = getAppProxyBasePathFromPathname(url.pathname) ?? url.pathname; + return url.origin + pathname.replace(/\/+$/, ""); +} diff --git a/src/desktop/remoteConnectionManager.test.ts b/src/desktop/remoteConnectionManager.test.ts index 6348ce1875f..10d7e51e9a1 100644 --- a/src/desktop/remoteConnectionManager.test.ts +++ b/src/desktop/remoteConnectionManager.test.ts @@ -116,13 +116,13 @@ describe("RemoteConnectionManager", () => { test("keeps the local window until load completes and shares duplicate connections", async () => { const load = deferred(); const { manager, windows, onConnected, onStateChanged } = setup(load.promise); - const first = manager.connect("https://example.com/path?token=first"); - const duplicate = manager.connect("https://example.com/other?token=second"); + const first = manager.connect("https://example.com/?token=first"); + const duplicate = manager.connect("https://example.com/?token=second"); expect(windows).toHaveLength(1); expect(windows[0].loadURL).toHaveBeenCalledTimes(1); expect(windows[0].show).not.toHaveBeenCalled(); expect(onConnected).not.toHaveBeenCalled(); - expect(manager.getState()).toEqual({ status: "connecting", origin: "https://example.com" }); + expect(manager.getState()).toEqual({ status: "connecting", serverUrl: "https://example.com" }); load.resolve(); await Promise.all([first, duplicate]); expect(onConnected).toHaveBeenCalledTimes(1); @@ -153,7 +153,7 @@ describe("RemoteConnectionManager", () => { expect(manager.connect("https://other.example.com/")).rejects.toThrow(); expect(windows).toHaveLength(1); expect(windows[0].destroyed).toBe(false); - expect(manager.getState().origin).toBe("https://example.com"); + expect(manager.getState().serverUrl).toBe("https://example.com"); load.resolve(); await pending; } @@ -168,7 +168,7 @@ describe("RemoteConnectionManager", () => { const duplicate = manager.connect("https://example.com/"); manager.disconnect(); await Promise.all([first, duplicate]); - expect(manager.getState()).toEqual({ status: "disconnected", origin: null }); + expect(manager.getState()).toEqual({ status: "disconnected", serverUrl: null }); expect(onDisconnected).toHaveBeenCalledTimes(1); expect(windows[0].destroyed).toBe(true); if (completion === "resolve") load.resolve(); @@ -209,7 +209,7 @@ describe("RemoteConnectionManager", () => { ); expect(manager.getState()).toEqual({ status: "connected", - origin: "https://new.example.com", + serverUrl: "https://new.example.com", }); expect(onConnected).toHaveBeenCalledTimes(1); expect(onDisconnected).toHaveBeenCalledTimes(1); @@ -326,11 +326,103 @@ describe("RemoteConnectionManager", () => { expect(windows).toHaveLength(1); }); + test("keeps path-mounted server identities and sessions separate on one origin", async () => { + const load = deferred(); + const { manager, windows, options, onStateChanged } = setup(load.promise); + const address = "https://example.com/mounted/first/?token=private-token#private-session"; + const first = manager.connect(address); + const duplicate = manager.connect("https://EXAMPLE.com:443/mounted/first?token=other-token"); + expect(windows).toHaveLength(1); + expect(windows[0].loadURL).toHaveBeenCalledWith(address); + expect(manager.getState()).toEqual({ + status: "connecting", + serverUrl: "https://example.com/mounted/first", + }); + const otherServer = manager.connect("https://example.com/mounted/second").then( + () => undefined, + (error: unknown) => error + ); + expect(windows).toHaveLength(1); + load.resolve(); + await Promise.all([first, duplicate]); + expect(await otherServer).toBeInstanceOf(Error); + expect(manager.getState()).toEqual({ + status: "connected", + serverUrl: "https://example.com/mounted/first", + }); + manager.disconnect(); + await manager.connect("https://example.com/mounted/first"); + expect(options[1].webPreferences?.partition).toBe(options[0].webPreferences?.partition); + manager.disconnect(); + await manager.connect("https://example.com/mounted/second"); + expect(options[2].webPreferences?.partition).not.toBe(options[0].webPreferences?.partition); + expect(manager.getState().serverUrl).toBe("https://example.com/mounted/second"); + for (const secret of ["private-token", "private-session", "other-token"]) { + expect(JSON.stringify(options)).not.toContain(secret); + expect(JSON.stringify(onStateChanged.mock.calls)).not.toContain(secret); + } + }); + + test.each(["will-navigate", "will-redirect"])( + "allows Coder login and return but blocks sibling app %s", + async (event) => { + const { manager, windows } = setup(); + await manager.connect( + "https://example.com/@alice/workspace/apps/xum/workspaces/one?token=private-token" + ); + const contents = windows[0].webContents; + for (const url of [ + "https://example.com/login?redirect=%2F%40alice%2Fworkspace%2Fapps%2Fxum", + "https://example.com/", + "https://example.com/@alice/workspace/apps/xum", + "https://example.com/@alice/workspace/apps/xum/workspaces/two?token=next#message", + ]) { + const preventDefault = mock(() => undefined); + contents.emit(event, { preventDefault }, url); + expect(preventDefault).not.toHaveBeenCalled(); + } + for (const url of [ + "https://example.com/@alice/workspace/apps/other", + "https://example.com/@alice/workspace/apps/xum-sibling", + "https://example.com/@alice/other/apps/xum/", + "https://example.com/@bob/workspace/apps/xum/", + "https://other.example.com/@alice/workspace/apps/xum/", + "https://user:password@example.com/@alice/workspace/apps/xum/", + ]) { + const preventDefault = mock(() => undefined); + contents.emit(event, { preventDefault }, url); + expect(preventDefault).toHaveBeenCalledTimes(1); + } + } + ); + + test.each(["https://example.com/", "https://example.com/mounted/xum"])( + "retains same-origin navigation outside Coder mounts: %s", + async (serverUrl) => { + const { manager, windows } = setup(); + await manager.connect(serverUrl); + for (const event of ["will-navigate", "will-redirect"]) { + for (const url of [ + "https://example.com/login", + "https://example.com/mounted/other", + "https://example.com/@alice/workspace/apps/other", + ]) { + const preventDefault = mock(() => undefined); + windows[0].webContents.emit(event, { preventDefault }, url); + expect(preventDefault).not.toHaveBeenCalled(); + } + const preventDefault = mock(() => undefined); + windows[0].webContents.emit(event, { preventDefault }, "https://other.example.com/login"); + expect(preventDefault).toHaveBeenCalledTimes(1); + } + } + ); + test("isolates origin sessions without a preload or URL tokens", async () => { const { manager, options, onStateChanged, windows } = setup(); const urls = [ "https://example.com/path?token=private-token#private-session", - "https://EXAMPLE.com:443/other?token=other-token", + "https://EXAMPLE.com:443/path/?token=other-token", "http://example.com/", "https://example.com:8443/", "https://other.example.com/", @@ -366,7 +458,7 @@ describe("RemoteConnectionManager", () => { const { manager, windows, onConnected, onStateChanged } = setup(); expect(manager.connect(url)).rejects.toThrow(); expect(windows).toHaveLength(0); - expect(manager.getState()).toEqual({ status: "disconnected", origin: null }); + expect(manager.getState()).toEqual({ status: "disconnected", serverUrl: null }); expect(onConnected).not.toHaveBeenCalled(); expect(onStateChanged).not.toHaveBeenCalled(); } @@ -512,7 +604,10 @@ describe("RemoteConnectionManager", () => { oldContents.emit("did-create-window", popup); expect(popup.destroyed).toBe(true); expect(windows[1].destroyed).toBe(false); - expect(manager.getState()).toEqual({ status: "connected", origin: "https://new.example.com" }); + expect(manager.getState()).toEqual({ + status: "connected", + serverUrl: "https://new.example.com", + }); expect(onDisconnected).toHaveBeenCalledTimes(1); }); @@ -672,7 +767,7 @@ describe("RemoteConnectionManager", () => { expect(remote.destroyed).toBe(true); expect(popup.destroyed).toBe(true); expect(onDisconnected).toHaveBeenCalledTimes(1); - expect(manager.getState()).toEqual({ status: "disconnected", origin: null }); + expect(manager.getState()).toEqual({ status: "disconnected", serverUrl: null }); } ); diff --git a/src/desktop/remoteConnectionManager.ts b/src/desktop/remoteConnectionManager.ts index 48f18a39afa..a073aa4a842 100644 --- a/src/desktop/remoteConnectionManager.ts +++ b/src/desktop/remoteConnectionManager.ts @@ -1,5 +1,6 @@ import type { BrowserWindow, BrowserWindowConstructorOptions, Event } from "electron"; import { createHash } from "node:crypto"; +import { getAppProxyBasePathFromPathname } from "@/common/appProxyBasePath"; import { REMOTE_CONNECTION_GESTURE_WORLD_ID, REMOTE_CONNECTION_LOAD_TIMEOUT_MS, @@ -7,13 +8,14 @@ import { } from "@/common/constants/remoteConnection"; import { parseRemoteConnectionUrl, + getRemoteConnectionServerUrl, type RemoteConnectionState, } from "@/common/types/remoteConnection"; import { raceWithAbortAndTimeout } from "@/node/utils/concurrency/withTimeout"; interface RemoteWindowEntry { window: BrowserWindow; - origin: string; + serverUrl: string; abort: AbortController; loaded: Promise; popup: BrowserWindow | "opening" | null; @@ -35,10 +37,20 @@ const REMOTE_WEB_PREFERENCES = { spellcheck: false, }; +function isRemoteNavigationAllowed(serverUrl: string, target: string): boolean { + const server = parseRemoteConnectionUrl(serverUrl); + const destination = parseRemoteConnectionUrl(target); + if (server.origin !== destination.origin) return false; + const serverAppPath = getAppProxyBasePathFromPathname(server.pathname); + const targetAppPath = getAppProxyBasePathFromPathname(destination.pathname); + // Coder login redirects can leave the app path, but must not enter another proxied app. + return serverAppPath == null || targetAppPath == null || serverAppPath === targetAppPath; +} + /** Owns remote windows, never the local backend or its running tasks. */ export class RemoteConnectionManager { private entry: RemoteWindowEntry | null = null; - private state: RemoteConnectionState = { status: "disconnected", origin: null }; + private state: RemoteConnectionState = { status: "disconnected", serverUrl: null }; private disposed = false; constructor(private readonly options: RemoteWindowOptions) {} @@ -50,9 +62,10 @@ export class RemoteConnectionManager { async connect(input: string): Promise { if (this.disposed) throw new Error("Remote connections are shutting down."); const url = parseRemoteConnectionUrl(input); + const serverUrl = getRemoteConnectionServerUrl(input); const existing = this.entry; if (existing) { - if (existing.origin !== url.origin) { + if (existing.serverUrl !== serverUrl) { throw new Error("Disconnect the current remote server first."); } await existing.loaded; @@ -65,8 +78,8 @@ export class RemoteConnectionManager { } // SECURITY AUDIT: remote HTML must never receive the local preload or local session credentials. - // A partition per origin also isolates servers that share a hostname but use different ports. - const partition = "persist:xum-remote-" + createHash("sha256").update(url.origin).digest("hex"); + // App-proxy paths need separate storage because browser localStorage only isolates by origin. + const partition = "persist:xum-remote-" + createHash("sha256").update(serverUrl).digest("hex"); const window = this.options.createWindow({ width: 1200, height: 800, @@ -79,13 +92,13 @@ export class RemoteConnectionManager { }); const entry: RemoteWindowEntry = { window, - origin: url.origin, + serverUrl, abort: new AbortController(), loaded: Promise.resolve(), popup: null, }; this.entry = entry; - this.setState({ status: "connecting", origin: url.origin }); + this.setState({ status: "connecting", serverUrl }); this.guardWindow(entry); // Reserve the window before loading. Duplicate requests share its completion. entry.loaded = this.loadWindow(entry, url.href); @@ -111,7 +124,7 @@ export class RemoteConnectionManager { contents.on("will-prevent-unload", (event) => event.preventDefault()); const guardNavigation = (event: Event, target: string): void => { try { - if (parseRemoteConnectionUrl(target).origin === entry.origin) return; + if (isRemoteNavigationAllowed(entry.serverUrl, target)) return; } catch { // Malformed URLs and non-HTTP schemes cannot navigate remote windows. } @@ -177,7 +190,7 @@ export class RemoteConnectionManager { !entry.window.isDestroyed() && entry.window.isFocused() && entry.window.webContents.getURL() === requestingUrl; - if (!isActiveRequest() || parseRemoteConnectionUrl(requestingUrl).origin !== entry.origin) + if (!isActiveRequest() || !isRemoteNavigationAllowed(entry.serverUrl, requestingUrl)) return false; // SECURITY AUDIT: the page can replace its own navigator properties, but not this isolated world's properties. const activated: unknown = await entry.window.webContents.executeJavaScriptInIsolatedWorld( @@ -229,7 +242,7 @@ export class RemoteConnectionManager { entry.window.focus(); // Hide only the local window. Local agents and their renderer state remain alive. this.options.onConnected(); - this.setState({ status: "connected", origin: entry.origin }); + this.setState({ status: "connected", serverUrl: entry.serverUrl }); } catch { // Electron errors can include URL tokens. Report only a credential-free error. if (this.entry !== entry) return; @@ -250,7 +263,7 @@ export class RemoteConnectionManager { const popup = entry.popup; if (popup && popup !== "opening" && !popup.isDestroyed()) popup.destroy(); if (!entry.window.isDestroyed()) entry.window.destroy(); - this.setState({ status: "disconnected", origin: null, ...(error ? { error } : {}) }); + this.setState({ status: "disconnected", serverUrl: null, ...(error ? { error } : {}) }); if (!this.disposed) this.options.onDisconnected(); } diff --git a/tests/e2e/scenarios/remoteConnection.spec.ts b/tests/e2e/scenarios/remoteConnection.spec.ts index 1ca318250b8..c30744cdbed 100644 --- a/tests/e2e/scenarios/remoteConnection.spec.ts +++ b/tests/e2e/scenarios/remoteConnection.spec.ts @@ -116,7 +116,7 @@ test("remote connection isolates the page and returns to the same local renderer .poll(() => page.evaluate(() => window.api?.remoteConnection?.getState())) .toEqual({ status: "connected", - origin: remoteServer.url, + serverUrl: remoteServer.url, }); const previousClipboard = await app.evaluate(({ clipboard }: { clipboard: Clipboard }) => clipboard.readText() @@ -178,7 +178,7 @@ test("remote connection isolates the page and returns to the same local renderer .poll(() => page.evaluate(() => window.api?.remoteConnection?.getState())) .toEqual({ status: "disconnected", - origin: null, + serverUrl: null, }); expect(await localRenderer.evaluate((element) => element === document.documentElement)).toBe( true @@ -212,3 +212,58 @@ test("remote connection isolates the page and returns to the same local renderer await localRenderer.dispose(); await localWindow.dispose(); }); + +test("Coder path-mounted servers retain their URL and isolate sibling sessions", async ({ + app, + page, + ui, + remoteServer, +}) => { + await ui.settings.open(); + await page.getByRole("button", { name: "Remote Connection", exact: true }).click(); + const firstServerUrl = remoteServer.url + "/@alice/first/apps/xum"; + const secondServerUrl = remoteServer.url + "/@alice/second/apps/xum"; + const connections = [ + { + url: firstServerUrl + "/workspaces/one?token=path-token", + serverUrl: firstServerUrl, + cookie: "", + }, + { url: firstServerUrl, serverUrl: firstServerUrl, cookie: "remote-session=kept" }, + { url: secondServerUrl, serverUrl: secondServerUrl, cookie: "" }, + ]; + for (const [index, connection] of connections.entries()) { + const input = page.getByLabel("Server URL", { exact: true }); + if (index !== 1) await input.fill(connection.url); + else await expect(input).toHaveValue(firstServerUrl); + const opened = app.waitForEvent("window"); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + const remote = await opened; + await expect(remote.getByRole("heading", { name: "Remote server" })).toBeVisible(); + expect(remote.url()).toBe(connection.url); + await expect + .poll(() => page.evaluate(() => window.api?.remoteConnection?.getState())) + .toEqual({ + status: "connected", + serverUrl: connection.serverUrl, + }); + expect(await remote.evaluate(() => document.cookie)).toBe(connection.cookie); + if (index === 0) { + expect(remoteServer.requests).toContain( + "/@alice/first/apps/xum/workspaces/one?token=path-token" + ); + // A root-path cookie detects session sharing between servers on the same origin. + await remote.evaluate(() => { + document.cookie = "remote-session=kept; Path=/; SameSite=Lax"; + }); + } + await remote.close(); + await expect + .poll(() => page.evaluate(() => window.api?.remoteConnection?.getState())) + .toEqual({ + status: "disconnected", + serverUrl: null, + }); + await expect(input).toHaveValue(connection.serverUrl); + } +}); From 8345aac1b425664ab4f9e2f33667a0231a879063 Mon Sep 17 00:00:00 2001 From: Mux Date: Sun, 6 Sep 2026 11:17:48 -0500 Subject: [PATCH 3/5] =?UTF-8?q?[browser]=20=F0=9F=A4=96=20fix:=20identify?= =?UTF-8?q?=20editor=20placeholders=20before=20admission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use unique editor frame names so remote wrappers reject unsupported launches before recording. Keep browser launch and rollback behavior unchanged. --- _Generated with `xum` • Model: `openai:gpt-6-astra` • Thinking: `high` • Cost: `$29.57`_ --- src/browser/utils/openInEditor.test.ts | 63 ++++++++++++++++++++++-- src/browser/utils/openInEditor.ts | 8 ++- src/common/constants/remoteConnection.ts | 3 ++ 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/browser/utils/openInEditor.test.ts b/src/browser/utils/openInEditor.test.ts index 63dbcd10e36..37d8ab63d88 100644 --- a/src/browser/utils/openInEditor.test.ts +++ b/src/browser/utils/openInEditor.test.ts @@ -2,6 +2,7 @@ import { describe, expect, mock, test } from "bun:test"; import type { APIClient } from "@/browser/contexts/API"; import { openInEditor } from "./openInEditor"; import type { RuntimeConfig } from "@/common/types/runtime"; +import { REMOTE_CONNECTION_EDITOR_FRAME_NAME_PREFIX } from "@/common/constants/remoteConnection"; interface GlobalWithOptionalWindow { window?: unknown; @@ -46,7 +47,10 @@ describe("openInEditor", () => { // Browser-mode window (no `api`): window.open returns a placeholder that records // navigations and close() calls, mirroring a real popup. - function createBrowserModeWindow(calls: OpenCall[], opts?: { popupBlocked?: boolean }) { + function createBrowserModeWindow( + calls: OpenCall[], + opts?: { popupBlocked?: boolean; denyEditorPlaceholders?: boolean } + ) { const placeholder = { closed: false, navigations: [] as string[], @@ -65,7 +69,10 @@ describe("openInEditor", () => { location: { hostname: "localhost" }, open: (url: string, target?: string) => { calls.push([url, target]); - return opts?.popupBlocked ? null : placeholder; + const wrapperBlocked = + opts?.denyEditorPlaceholders && + target?.startsWith(REMOTE_CONNECTION_EDITOR_FRAME_NAME_PREFIX); + return opts?.popupBlocked || wrapperBlocked ? null : placeholder; }, }; return { windowValue, placeholder }; @@ -277,12 +284,35 @@ describe("openInEditor", () => { expect(result.success).toBe(true); // The only window.open call is the synchronous placeholder; the deep link reaches the // already-open window via navigation, immune to popup blocking. - expect(calls).toEqual([["about:blank", "_blank"]]); + expect(calls).toHaveLength(1); + expect(calls[0][0]).toBe("about:blank"); + expect(calls[0][1]?.startsWith(REMOTE_CONNECTION_EDITOR_FRAME_NAME_PREFIX)).toBe(true); expect(placeholder.navigations.length).toBe(1); expect(placeholder.navigations[0]).toContain("ssh-remote+devbox"); expect(placeholder.closed).toBe(false); }); + test("browser mode: each editor launch uses a different placeholder name", async () => { + const calls: OpenCall[] = []; + const { windowValue } = createBrowserModeWindow(calls); + + await withWindow(windowValue, async () => { + for (let index = 0; index < 2; index++) { + const result = await openInEditor({ + api: createApiStub(), + workspaceId, + targetPath: filePath, + runtimeConfig: { type: "ssh", host: "devbox", srcBaseDir: "~/xum" }, + isFile: true, + }); + expect(result.success).toBe(true); + } + }); + + expect(calls).toHaveLength(2); + expect(calls[0][1]).not.toBe(calls[1][1]); + }); + test("browser mode: closes the placeholder when the open is refused", async () => { const calls: OpenCall[] = []; const { windowValue, placeholder } = createBrowserModeWindow(calls); @@ -379,6 +409,29 @@ describe("openInEditor", () => { expect(placeholder.navigations.length).toBe(0); }); + test("remote wrapper: refuses editor placeholders before recording an open", async () => { + const calls: OpenCall[] = []; + const { windowValue, placeholder } = createBrowserModeWindow(calls, { + denyEditorPlaceholders: true, + }); + const recordEditorOpen = mock(() => Promise.resolve({ success: true })); + const api = { general: { recordEditorOpen } } as unknown as APIClient; + + const result = await withWindow(windowValue, () => + openInEditor({ + api, + workspaceId, + targetPath: filePath, + runtimeConfig: { type: "ssh", host: "devbox", srcBaseDir: "~/xum" }, + isFile: true, + }) + ); + + expect(result.success).toBe(false); + expect(recordEditorOpen).not.toHaveBeenCalled(); + expect(placeholder.navigations).toEqual([]); + }); + test("browser mode: refuses before recording when the placeholder is popup-blocked", async () => { const calls: OpenCall[] = []; const { windowValue, placeholder } = createBrowserModeWindow(calls, { popupBlocked: true }); @@ -401,7 +454,9 @@ describe("openInEditor", () => { expect(result.success).toBe(false); expect(result.error).toContain("popup"); expect(recordEditorOpen).not.toHaveBeenCalled(); - expect(calls).toEqual([["about:blank", "_blank"]]); + expect(calls).toHaveLength(1); + expect(calls[0][0]).toBe("about:blank"); + expect(calls[0][1]?.startsWith(REMOTE_CONNECTION_EDITOR_FRAME_NAME_PREFIX)).toBe(true); expect(placeholder.navigations.length).toBe(0); }); }); diff --git a/src/browser/utils/openInEditor.ts b/src/browser/utils/openInEditor.ts index ce35ff0bad0..bf2af58eacb 100644 --- a/src/browser/utils/openInEditor.ts +++ b/src/browser/utils/openInEditor.ts @@ -1,4 +1,5 @@ import { readPersistedState } from "@/browser/hooks/usePersistedState"; +import { REMOTE_CONNECTION_EDITOR_FRAME_NAME_PREFIX } from "@/common/constants/remoteConnection"; import { getEditorDeepLink, getDockerDeepLink, @@ -42,7 +43,7 @@ function trimTrailingSlash(path: string): string { // Guarded token generator (mirrors createLayoutPresetId/createHeaderRowId): Crypto.randomUUID // exists only in secure contexts, and Xum's browser UI can be served from a plain-HTTP remote // origin. Throwing here would reject every built-in editor open before the recording RPC's -// try/catch; the fallback only needs to be unique enough to key one launch's rollback. +// try/catch; the fallback distinguishes placeholder frames and each launch's rollback. function createEditorLaunchToken(): string { const maybeCrypto = globalThis.crypto; if (maybeCrypto && typeof maybeCrypto.randomUUID === "function") { @@ -133,8 +134,11 @@ export async function openInEditor(args: OpenInEditorArgs): Promise Date: Sun, 6 Sep 2026 11:22:07 -0500 Subject: [PATCH 4/5] =?UTF-8?q?[desktop]=20=F0=9F=A4=96=20fix:=20preserve?= =?UTF-8?q?=20isolated=20remote=20popups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep app pop-outs and attachments inside the remote session. Reject editor placeholders before the renderer records an editor open. Destroy every remote child window when the connection ends. --- _Generated with `xum` • Model: `openai:gpt-6-astra` • Thinking: `high` • Cost: `$69.72`_ --- src/desktop/remoteConnectionManager.test.ts | 186 +++++++++++++++++-- src/desktop/remoteConnectionManager.ts | 177 +++++++++++++----- tests/e2e/scenarios/remoteConnection.spec.ts | 80 ++++++++ 3 files changed, 377 insertions(+), 66 deletions(-) diff --git a/src/desktop/remoteConnectionManager.test.ts b/src/desktop/remoteConnectionManager.test.ts index 10d7e51e9a1..0c5a1376836 100644 --- a/src/desktop/remoteConnectionManager.test.ts +++ b/src/desktop/remoteConnectionManager.test.ts @@ -3,6 +3,7 @@ import type { BrowserWindow, BrowserWindowConstructorOptions } from "electron"; import { EventEmitter } from "node:events"; import type { RemoteConnectionState } from "@/common/types/remoteConnection"; import { RemoteConnectionManager } from "./remoteConnectionManager"; +import { REMOTE_CONNECTION_EDITOR_FRAME_NAME_PREFIX } from "@/common/constants/remoteConnection"; class TestWindow extends EventEmitter { destroyed = false; @@ -32,7 +33,7 @@ class TestWindow extends EventEmitter { }, setWindowOpenHandler: mock< ( - handler: (details: { url: string }) => { + handler: (details: { url: string; frameName: string }) => { action: string; overrideBrowserWindowOptions?: BrowserWindowConstructorOptions; } @@ -506,12 +507,156 @@ describe("RemoteConnectionManager", () => { } }); + test.each(["terminal.html?terminalId=one", "desktop.html?workspaceId=two"])( + "keeps the app popup %s in the remote session", + async (page) => { + const { manager, windows, openExternal } = setup(); + const base = "https://example.com/@user/workspace/apps/xum/"; + await manager.connect(base); + const contents = windows[0].webContents; + const url = base + page; + const opened = contents.setWindowOpenHandler.mock.calls[0][0]({ frameName: "", url }); + expect(opened.action).toBe("allow"); + expect( + Object.is(opened.overrideBrowserWindowOptions?.webPreferences?.session, contents.session) + ).toBe(true); + expect(opened.overrideBrowserWindowOptions?.webPreferences?.preload).toBeUndefined(); + expect(opened.overrideBrowserWindowOptions?.webPreferences?.sandbox).toBe(true); + expect(openExternal).not.toHaveBeenCalled(); + const popup = new TestWindow(); + popup.url = url; + contents.emit("did-create-window", popup, { url }); + const request = contents.session.setPermissionRequestHandler.mock.calls[0][0]; + expect( + await new Promise((resolve) => { + request(popup.webContents, "clipboard-sanitized-write", resolve, { + isMainFrame: true, + requestingUrl: url, + }); + }) + ).toBe(true); + for (const event of ["will-navigate", "will-redirect"]) { + const preventDefault = mock(() => undefined); + popup.webContents.emit( + event, + { preventDefault }, + "https://example.com/@user/other/apps/xum/" + ); + expect(preventDefault).toHaveBeenCalled(); + } + const nestedUrl = base + "terminal.html?terminalId=nested"; + expect( + popup.webContents.setWindowOpenHandler.mock.calls[0][0]({ frameName: "", url: nestedUrl }) + .action + ).toBe("allow"); + const nested = new TestWindow(); + popup.webContents.emit("did-create-window", nested, { url: nestedUrl }); + popup.close(); + expect(manager.getState().status).toBe("connected"); + manager.disconnect(); + expect(nested.destroyed).toBe(true); + } + ); + + test("isolates blob attachments and closes them on disconnect", async () => { + const { manager, windows, openExternal } = setup(); + await manager.connect("https://example.com/"); + const contents = windows[0].webContents; + const openWindow = contents.setWindowOpenHandler.mock.calls[0][0]; + const url = "blob:https://example.com/attachment-id"; + expect(openWindow({ frameName: "", url }).action).toBe("allow"); + for (const blocked of [ + "blob:null/id", + "blob:https://other.example.com/id", + "data:text/html,hello", + ]) { + expect(openWindow({ frameName: "", url: blocked }).action).toBe("deny"); + } + expect(openExternal).not.toHaveBeenCalled(); + const popup = new TestWindow(); + contents.emit("did-create-window", popup, { url }); + for (const event of ["will-navigate", "will-redirect"]) { + for (const blocked of [ + "https://example.com/", + "file:///etc/passwd", + "blob:https://other.example.com/id", + ]) { + const preventDefault = mock(() => undefined); + popup.webContents.emit(event, { preventDefault }, blocked); + expect(preventDefault).toHaveBeenCalled(); + } + } + expect( + popup.webContents.setWindowOpenHandler.mock.calls[0][0]({ + frameName: "", + url: "https://example.com/", + }).action + ).toBe("deny"); + manager.disconnect(); + expect(popup.destroyed).toBe(true); + }); + + test("does not open sibling app mounts or login pages as app popups", async () => { + const { manager, windows, openExternal } = setup(); + await manager.connect("https://example.com/@user/workspace/apps/xum/"); + const openWindow = windows[0].webContents.setWindowOpenHandler.mock.calls[0][0]; + for (const url of [ + "https://example.com/@user/other/apps/xum/terminal.html", + "https://example.com/login", + ]) { + expect(openWindow({ frameName: "", url }).action).toBe("deny"); + expect(openExternal).toHaveBeenCalledWith(url); + } + }); + + test("rejects editor placeholders before reserving an authentication popup", async () => { + const { manager, windows, openExternal } = setup(); + await manager.connect("https://example.com/"); + const openWindow = windows[0].webContents.setWindowOpenHandler.mock.calls[0][0]; + expect( + openWindow({ + url: "about:blank", + frameName: REMOTE_CONNECTION_EDITOR_FRAME_NAME_PREFIX + "unique-launch", + }) + ).toEqual({ action: "deny" }); + expect(openWindow({ url: "about:blank", frameName: "auth" }).action).toBe("allow"); + expect(openExternal).not.toHaveBeenCalled(); + }); + + test("keeps auth redirects outside sibling Coder app mounts", async () => { + const { manager, windows } = setup(); + const base = "https://example.com/@user/workspace/apps/xum"; + await manager.connect(base); + const contents = windows[0].webContents; + contents.setWindowOpenHandler.mock.calls[0][0]({ url: "about:blank", frameName: "auth" }); + const popup = new TestWindow(); + contents.emit("did-create-window", popup, { url: "about:blank" }); + for (const event of ["will-navigate", "will-redirect"]) { + for (const target of [ + base + "/callback", + "https://example.com/login", + "https://auth.example.com/", + ]) { + const preventDefault = mock(() => undefined); + popup.webContents.emit(event, { preventDefault }, target); + expect(preventDefault).not.toHaveBeenCalled(); + } + const preventDefault = mock(() => undefined); + popup.webContents.emit( + event, + { preventDefault }, + "https://example.com/@user/other/apps/xum/" + ); + expect(preventDefault).toHaveBeenCalled(); + } + }); + test("allows one blank auth popup with the remote session and no preload", async () => { const { manager, windows, onDisconnected, openExternal } = setup(); await manager.connect("https://example.com/"); const contents = windows[0].webContents; const openWindow = contents.setWindowOpenHandler.mock.calls[0][0]; - const first = openWindow({ url: "about:blank" }); + const first = openWindow({ frameName: "", url: "about:blank" }); expect(first.action).toBe("allow"); expect(first.overrideBrowserWindowOptions?.webPreferences).toMatchObject({ sandbox: true, @@ -523,24 +668,24 @@ describe("RemoteConnectionManager", () => { expect( Object.is(first.overrideBrowserWindowOptions?.webPreferences?.session, contents.session) ).toBe(true); - expect(openWindow({ url: "about:blank" })).toEqual({ action: "deny" }); + expect(openWindow({ frameName: "", url: "about:blank" })).toEqual({ action: "deny" }); const popup = new TestWindow(); - contents.emit("did-create-window", popup); - expect(openWindow({ url: "about:blank" })).toEqual({ action: "deny" }); + contents.emit("did-create-window", popup, { url: "about:blank" }); + expect(openWindow({ frameName: "", url: "about:blank" })).toEqual({ action: "deny" }); popup.close(); expect(manager.getState().status).toBe("connected"); expect(onDisconnected).not.toHaveBeenCalled(); expect(openExternal).not.toHaveBeenCalled(); - expect(openWindow({ url: "about:blank" }).action).toBe("allow"); + expect(openWindow({ frameName: "", url: "about:blank" }).action).toBe("allow"); }); test("allows HTTP auth redirects but blocks privileged navigation and nested popups", async () => { const { manager, windows, openExternal } = setup(); await manager.connect("https://example.com/"); const contents = windows[0].webContents; - contents.setWindowOpenHandler.mock.calls[0][0]({ url: "about:blank" }); + contents.setWindowOpenHandler.mock.calls[0][0]({ frameName: "", url: "about:blank" }); const popup = new TestWindow(); - contents.emit("did-create-window", popup); + contents.emit("did-create-window", popup, { url: "about:blank" }); for (const event of ["will-navigate", "will-redirect"]) { for (const url of [ "about:blank", @@ -572,7 +717,7 @@ describe("RemoteConnectionManager", () => { } const openNested = popup.webContents.setWindowOpenHandler.mock.calls[0][0]; for (const url of ["about:blank", "https://example.com/", "xum://open"]) { - expect(openNested({ url })).toEqual({ action: "deny" }); + expect(openNested({ frameName: "", url })).toEqual({ action: "deny" }); } expect(openExternal).not.toHaveBeenCalled(); }); @@ -583,9 +728,9 @@ describe("RemoteConnectionManager", () => { const { manager, windows, onDisconnected } = setup(); await manager.connect("https://example.com/"); const contents = windows[0].webContents; - contents.setWindowOpenHandler.mock.calls[0][0]({ url: "about:blank" }); + contents.setWindowOpenHandler.mock.calls[0][0]({ frameName: "", url: "about:blank" }); const popup = new TestWindow(); - contents.emit("did-create-window", popup); + contents.emit("did-create-window", popup, { url: "about:blank" }); manager[action](); expect(popup.destroyed).toBe(true); expect(windows[0].destroyed).toBe(true); @@ -597,11 +742,11 @@ describe("RemoteConnectionManager", () => { const { manager, windows, onDisconnected } = setup(); await manager.connect("https://old.example.com/"); const oldContents = windows[0].webContents; - oldContents.setWindowOpenHandler.mock.calls[0][0]({ url: "about:blank" }); + oldContents.setWindowOpenHandler.mock.calls[0][0]({ frameName: "", url: "about:blank" }); manager.disconnect(); await manager.connect("https://new.example.com/"); const popup = new TestWindow(); - oldContents.emit("did-create-window", popup); + oldContents.emit("did-create-window", popup, { url: "about:blank" }); expect(popup.destroyed).toBe(true); expect(windows[1].destroyed).toBe(false); expect(manager.getState()).toEqual({ @@ -734,8 +879,11 @@ describe("RemoteConnectionManager", () => { await manager.connect("https://example.com/"); const remote = windows[0]; const popup = new TestWindow(); - remote.webContents.setWindowOpenHandler.mock.calls[0][0]({ url: "about:blank" }); - remote.webContents.emit("did-create-window", popup); + remote.webContents.setWindowOpenHandler.mock.calls[0][0]({ + frameName: "", + url: "about:blank", + }); + remote.webContents.emit("did-create-window", popup, { url: "about:blank" }); const contents = target === "remote" ? remote.webContents : popup.webContents; const modifier = process.platform === "darwin" @@ -771,7 +919,7 @@ describe("RemoteConnectionManager", () => { } ); - test("denies permissions and popups, opening only HTTP links externally", async () => { + test("denies permissions and custom schemes, opening external HTTP links in the browser", async () => { const { manager, windows, openExternal } = setup(); await manager.connect("https://example.com/"); const contents = windows[0].webContents; @@ -793,8 +941,8 @@ describe("RemoteConnectionManager", () => { } expect(permissionCheck()).toBe(false); const openWindow = contents.setWindowOpenHandler.mock.calls[0][0]; - for (const url of ["https://example.com/help", "http://external.example.com/help"]) { - expect(openWindow({ url })).toEqual({ action: "deny" }); + for (const url of ["https://external.example.com/help", "http://external.example.com/help"]) { + expect(openWindow({ frameName: "", url })).toEqual({ action: "deny" }); expect(openExternal).toHaveBeenLastCalledWith(url); } for (const url of [ @@ -806,7 +954,7 @@ describe("RemoteConnectionManager", () => { "https://user:password@example.com/", "invalid", ]) { - expect(openWindow({ url })).toEqual({ action: "deny" }); + expect(openWindow({ frameName: "", url })).toEqual({ action: "deny" }); } expect(openExternal).toHaveBeenCalledTimes(2); }); diff --git a/src/desktop/remoteConnectionManager.ts b/src/desktop/remoteConnectionManager.ts index a073aa4a842..89149bd5dfb 100644 --- a/src/desktop/remoteConnectionManager.ts +++ b/src/desktop/remoteConnectionManager.ts @@ -2,6 +2,7 @@ import type { BrowserWindow, BrowserWindowConstructorOptions, Event } from "elec import { createHash } from "node:crypto"; import { getAppProxyBasePathFromPathname } from "@/common/appProxyBasePath"; import { + REMOTE_CONNECTION_EDITOR_FRAME_NAME_PREFIX, REMOTE_CONNECTION_GESTURE_WORLD_ID, REMOTE_CONNECTION_LOAD_TIMEOUT_MS, REMOTE_CONNECTION_RETURN_KEY, @@ -13,12 +14,15 @@ import { } from "@/common/types/remoteConnection"; import { raceWithAbortAndTimeout } from "@/node/utils/concurrency/withTimeout"; +type RemotePopupKind = "app" | "attachment" | "auth"; + interface RemoteWindowEntry { window: BrowserWindow; serverUrl: string; abort: AbortController; loaded: Promise; - popup: BrowserWindow | "opening" | null; + authPopup: BrowserWindow | "opening" | null; + popups: Map; } interface RemoteWindowOptions { @@ -47,6 +51,24 @@ function isRemoteNavigationAllowed(serverUrl: string, target: string): boolean { return serverAppPath == null || targetAppPath == null || serverAppPath === targetAppPath; } +function isRemoteAppUrl(serverUrl: string, target: string): boolean { + const server = parseRemoteConnectionUrl(serverUrl); + const destination = parseRemoteConnectionUrl(target); + if (server.origin !== destination.origin) return false; + if ( + getAppProxyBasePathFromPathname(server.pathname) !== + getAppProxyBasePathFromPathname(destination.pathname) + ) + return false; + const basePath = server.pathname.replace(/[/]$/, ""); + return destination.pathname === basePath || destination.pathname.startsWith(basePath + "/"); +} + +function isRemoteBlobUrl(serverUrl: string, target: string): boolean { + const destination = new URL(target); + return destination.protocol === "blob:" && destination.origin === new URL(serverUrl).origin; +} + /** Owns remote windows, never the local backend or its running tasks. */ export class RemoteConnectionManager { private entry: RemoteWindowEntry | null = null; @@ -95,7 +117,8 @@ export class RemoteConnectionManager { serverUrl, abort: new AbortController(), loaded: Promise.resolve(), - popup: null, + authPopup: null, + popups: new Map(), }; this.entry = entry; this.setState({ status: "connecting", serverUrl }); @@ -109,33 +132,58 @@ export class RemoteConnectionManager { const contents = entry.window.webContents; contents.session.setPermissionCheckHandler(() => false); contents.session.setPermissionRequestHandler((requester, permission, callback, details) => { - if ( - permission !== "clipboard-sanitized-write" || - requester !== contents || - !details.isMainFrame - ) { + const window = + requester === contents + ? entry.window + : [...entry.popups].find( + ([popup, kind]) => kind === "app" && popup.webContents === requester + )?.[0]; + if (permission !== "clipboard-sanitized-write" || !window || !details.isMainFrame) { callback(false); return; } - this.allowClipboardWrite(entry, details.requestingUrl).then(callback, () => callback(false)); + this.allowClipboardWrite(entry, window, details.requestingUrl).then(callback, () => + callback(false) + ); + }); + this.guardAppWindow(entry, entry.window); + contents.on("render-process-gone", () => { + this.finish(entry, "The remote window stopped. Connect again to retry."); + }); + contents.on("did-fail-load", (_event, errorCode, _description, _url, isMainFrame) => { + // Ignore cancelled navigation and subresource errors. + if (isMainFrame && errorCode !== -3) { + this.finish(entry, "Cannot load the remote server. Check its URL and network connection."); + } }); - this.installInputHandler(entry, entry.window); - contents.on("will-attach-webview", (event) => event.preventDefault()); - contents.on("will-prevent-unload", (event) => event.preventDefault()); + entry.window.on("closed", () => this.finish(entry)); + } + + private guardAppWindow(entry: RemoteWindowEntry, window: BrowserWindow): void { + this.guardChildWindow(entry, window); + const contents = window.webContents; const guardNavigation = (event: Event, target: string): void => { try { if (isRemoteNavigationAllowed(entry.serverUrl, target)) return; } catch { - // Malformed URLs and non-HTTP schemes cannot navigate remote windows. + // Malformed URLs and non-HTTP schemes cannot navigate app windows. } event.preventDefault(); }; contents.on("will-navigate", guardNavigation); contents.on("will-redirect", guardNavigation); - contents.setWindowOpenHandler(({ url }) => { - // Browser OAuth flows retain a blank popup handle before fetching the authorization URL. - if (url === "about:blank" && this.entry === entry && entry.popup == null) { - entry.popup = "opening"; + contents.setWindowOpenHandler(({ url, frameName }) => { + // Reject editor launches before the browser renderer records a durable editor-open marker. + if ( + this.entry !== entry || + frameName.startsWith(REMOTE_CONNECTION_EDITOR_FRAME_NAME_PREFIX) + ) { + return { action: "deny" }; + } + const kind = this.getPopupKind(entry, window, url); + if (kind && (kind !== "auth" || entry.authPopup == null)) { + // Browser OAuth flows reserve a blank popup before fetching the authorization URL. + if (kind === "auth") entry.authPopup = "opening"; return { action: "allow", overrideBrowserWindowOptions: { @@ -144,24 +192,38 @@ export class RemoteConnectionManager { }; } try { - const target = parseRemoteConnectionUrl(url); - this.options.openExternal(target.href); + this.options.openExternal(parseRemoteConnectionUrl(url).href); } catch { // Remote content cannot launch local programs through custom URL schemes. } return { action: "deny" }; }); - contents.on("did-create-window", (popup) => this.guardAuthPopup(entry, popup)); - contents.on("render-process-gone", () => { - this.finish(entry, "The remote window stopped. Connect again to retry."); - }); - contents.on("did-fail-load", (_event, errorCode, _description, _url, isMainFrame) => { - // Ignore cancelled navigation and subresource errors. - if (isMainFrame && errorCode !== -3) { - this.finish(entry, "Cannot load the remote server. Check its URL and network connection."); - } + contents.on("did-create-window", (popup, details) => { + this.registerPopup(entry, window, popup, details.url); }); - entry.window.on("closed", () => this.finish(entry)); + } + + private getPopupKind( + entry: RemoteWindowEntry, + source: BrowserWindow, + url: string + ): RemotePopupKind | null { + try { + // Login pages share the origin, but cannot create app or attachment windows. + if (!isRemoteAppUrl(entry.serverUrl, source.webContents.getURL())) return null; + if (url === "about:blank") return "auth"; + if (isRemoteBlobUrl(entry.serverUrl, url)) return "attachment"; + if (isRemoteAppUrl(entry.serverUrl, url)) return "app"; + } catch { + // Reject malformed URLs and unsupported schemes. + } + return null; + } + + private guardChildWindow(entry: RemoteWindowEntry, window: BrowserWindow): void { + this.installInputHandler(entry, window); + window.webContents.on("will-attach-webview", (event) => event.preventDefault()); + window.webContents.on("will-prevent-unload", (event) => event.preventDefault()); } private installInputHandler(entry: RemoteWindowEntry, window: BrowserWindow): void { @@ -183,46 +245,66 @@ export class RemoteConnectionManager { private async allowClipboardWrite( entry: RemoteWindowEntry, + window: BrowserWindow, requestingUrl: string ): Promise { const isActiveRequest = (): boolean => this.entry === entry && - !entry.window.isDestroyed() && - entry.window.isFocused() && - entry.window.webContents.getURL() === requestingUrl; - if (!isActiveRequest() || !isRemoteNavigationAllowed(entry.serverUrl, requestingUrl)) - return false; + (window === entry.window || entry.popups.get(window) === "app") && + !window.isDestroyed() && + window.isFocused() && + window.webContents.getURL() === requestingUrl; + if (!isActiveRequest() || !isRemoteAppUrl(entry.serverUrl, requestingUrl)) return false; // SECURITY AUDIT: the page can replace its own navigator properties, but not this isolated world's properties. - const activated: unknown = await entry.window.webContents.executeJavaScriptInIsolatedWorld( + const activated: unknown = await window.webContents.executeJavaScriptInIsolatedWorld( REMOTE_CONNECTION_GESTURE_WORLD_ID, [{ code: "navigator.userActivation.isActive" }] ); return activated === true && isActiveRequest(); } - private guardAuthPopup(entry: RemoteWindowEntry, popup: BrowserWindow): void { - if (this.entry !== entry) { + private registerPopup( + entry: RemoteWindowEntry, + source: BrowserWindow, + popup: BrowserWindow, + url: string + ): void { + const kind = this.getPopupKind(entry, source, url); + if (this.entry !== entry || !kind) { popup.destroy(); return; } - entry.popup = popup; - this.installInputHandler(entry, popup); + entry.popups.set(popup, kind); + if (kind === "auth") entry.authPopup = popup; popup.on("closed", () => { - if (entry.popup === popup) entry.popup = null; + entry.popups.delete(popup); + if (entry.authPopup === popup) entry.authPopup = null; }); - // OAuth redirects cross origins. They retain only the remote session, never local IPC access. + if (kind === "app") { + this.guardAppWindow(entry, popup); + return; + } + this.guardChildWindow(entry, popup); const guardNavigation = (event: Event, target: string): void => { - if (target === "about:blank") return; try { - parseRemoteConnectionUrl(target); + if (kind === "auth") { + // OAuth redirects cross origins but retain only the isolated remote session. + if (target === "about:blank") return; + const destination = parseRemoteConnectionUrl(target); + if ( + destination.origin !== new URL(entry.serverUrl).origin || + isRemoteNavigationAllowed(entry.serverUrl, target) + ) + return; + } + if (isRemoteBlobUrl(entry.serverUrl, target)) return; } catch { - event.preventDefault(); + // Attachments cannot navigate to websites or launch local programs. } + event.preventDefault(); }; popup.webContents.on("will-navigate", guardNavigation); popup.webContents.on("will-redirect", guardNavigation); - popup.webContents.on("will-attach-webview", (event) => event.preventDefault()); - popup.webContents.on("will-prevent-unload", (event) => event.preventDefault()); popup.webContents.setWindowOpenHandler(() => ({ action: "deny" })); } @@ -260,8 +342,9 @@ export class RemoteConnectionManager { if (this.entry !== entry) return; this.entry = null; entry.abort.abort(); - const popup = entry.popup; - if (popup && popup !== "opening" && !popup.isDestroyed()) popup.destroy(); + for (const popup of entry.popups.keys()) { + if (!popup.isDestroyed()) popup.destroy(); + } if (!entry.window.isDestroyed()) entry.window.destroy(); this.setState({ status: "disconnected", serverUrl: null, ...(error ? { error } : {}) }); if (!this.disposed) this.options.onDisconnected(); diff --git a/tests/e2e/scenarios/remoteConnection.spec.ts b/tests/e2e/scenarios/remoteConnection.spec.ts index c30744cdbed..6140f2735ca 100644 --- a/tests/e2e/scenarios/remoteConnection.spec.ts +++ b/tests/e2e/scenarios/remoteConnection.spec.ts @@ -3,6 +3,7 @@ import type { BrowserWindow, Clipboard } from "electron"; import { once } from "node:events"; import { createServer } from "node:http"; import { electronTest, electronExpect as expect } from "../electronTest"; +import { REMOTE_CONNECTION_EDITOR_FRAME_NAME_PREFIX } from "../../../src/common/constants/remoteConnection"; const test = electronTest.extend<{ remoteServer: { url: string; requests: string[] } }>({ remoteServer: async ({ workspace }, use) => { @@ -267,3 +268,82 @@ test("Coder path-mounted servers retain their URL and isolate sibling sessions", await expect(input).toHaveValue(connection.serverUrl); } }); +test("remote app popups and blob attachments retain isolation and close on disconnect", async ({ + app, + page, + remoteServer, +}) => { + await page.waitForFunction(() => Boolean(window.api?.remoteConnection)); + const base = remoteServer.url + "/@user/workspace/apps/xum/"; + const opened = app.waitForEvent("window"); + await page.evaluate((url) => window.api!.remoteConnection!.connect(url), base); + const remote = await opened; + await expect(remote.getByRole("heading", { name: "Remote server" })).toBeVisible(); + await remote.evaluate(() => { + // A browser pop-out needs the remote session's token and cookies, not the local session. + window.localStorage.setItem("popout-auth", "remote-session-token"); + document.cookie = "popout-cookie=remote-session-cookie; path=/"; + }); + expect( + await remote.evaluate( + (prefix) => window.open("about:blank", prefix + "e2e") === null, + REMOTE_CONNECTION_EDITOR_FRAME_NAME_PREFIX + ) + ).toBe(true); + const popups = []; + for (const path of ["terminal.html?terminalId=one", "desktop.html?workspaceId=two"]) { + const popupOpened = app.waitForEvent("window"); + expect(await remote.evaluate((url) => Boolean(window.open(url, "_blank")), base + path)).toBe( + true + ); + const popup = await popupOpened; + await popup.waitForURL(base + path); + expect( + await popup.evaluate(() => ({ + api: typeof window.api, + require: typeof Reflect.get(window, "require"), + token: window.localStorage.getItem("popout-auth"), + cookie: document.cookie, + })) + ).toEqual({ + api: "undefined", + require: "undefined", + token: "remote-session-token", + cookie: "popout-cookie=remote-session-cookie", + }); + const popupWindow = await app.browserWindow(popup); + expect( + await popupWindow.evaluate((window) => { + const preferences = window.webContents.getLastWebPreferences(); + return { + sandbox: preferences.sandbox, + nodeIntegration: preferences.nodeIntegration, + preload: preferences.preload, + }; + }) + ).toMatchObject({ sandbox: true, nodeIntegration: false, preload: undefined }); + await popupWindow.evaluate((window) => window.focus()); + await popup.getByRole("button", { name: "Copy text" }).click(); + await expect(popup.locator("#copied")).toHaveText("Copied"); + popups.push(popup); + } + const blobOpened = app.waitForEvent("window"); + expect( + await remote.evaluate(() => { + const url = URL.createObjectURL(new Blob(["Attachment content"], { type: "text/plain" })); + return Boolean(window.open(url, "_blank")); + }) + ).toBe(true); + const attachment = await blobOpened; + await expect(attachment.locator("body")).toContainText("Attachment content"); + expect(await attachment.evaluate(() => typeof window.api)).toBe("undefined"); + expect( + await attachment.evaluate(() => window.open("https://example.com/", "_blank") === null) + ).toBe(true); + const returned = Promise.all( + [remote, ...popups, attachment].map((window) => window.waitForEvent("close")) + ); + await page.evaluate(() => window.api!.remoteConnection!.disconnect()); + await returned; + expect(await page.evaluate(() => window.localStorage.getItem("popout-auth"))).toBeNull(); +}); From ada83422f6eb4fe4307695b689c97247d1f2dcb8 Mon Sep 17 00:00:00 2001 From: Mux Date: Sun, 6 Sep 2026 11:51:48 -0500 Subject: [PATCH 5/5] =?UTF-8?q?[desktop]=20=F0=9F=A4=96=20fix:=20warn=20ab?= =?UTF-8?q?out=20remote=20HTTP=20connections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep HTTP available for trusted encrypted tunnels. Explain token exposure and browser secure-context limits before connecting. Leave microphone permission changes for separate work. --- _Generated with `xum` • Model: `openai:gpt-6-astra` • Thinking: `high` • Cost: `$83.84`_ --- .../Sections/RemoteConnectionSection.tsx | 32 +++++++++++- .../stories/App.remoteConnection.stories.tsx | 51 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/browser/features/Settings/Sections/RemoteConnectionSection.tsx b/src/browser/features/Settings/Sections/RemoteConnectionSection.tsx index 1ac1a19435b..8f26b8952dd 100644 --- a/src/browser/features/Settings/Sections/RemoteConnectionSection.tsx +++ b/src/browser/features/Settings/Sections/RemoteConnectionSection.tsx @@ -6,6 +6,7 @@ import { isMac } from "@/browser/utils/ui/keybinds"; import { REMOTE_CONNECTION_RETURN_ACCELERATOR } from "@/common/constants/remoteConnection"; import { getRemoteConnectionServerUrl, + parseRemoteConnectionUrl, type RemoteConnectionState, } from "@/common/types/remoteConnection"; import { getErrorMessage } from "@/common/utils/errors"; @@ -73,6 +74,14 @@ export function RemoteConnectionSection() { isMac() ? "Cmd" : "Ctrl" ); + // Keep HTTP available for encrypted tunnels without assuming the tunnel makes a secure browser context. + let showHttpWarning = false; + try { + showHttpWarning = parseRemoteConnectionUrl(url).protocol === "http:"; + } catch { + // Incomplete addresses use the existing validation when the user connects. + } + async function handleConnect(event: FormEvent) { event.preventDefault(); if (!bridge || !canConnect) return; @@ -132,7 +141,11 @@ export function RemoteConnectionSection() { autoCapitalize="none" spellCheck={false} inputMode="url" - aria-describedby="remote-connection-help" + aria-describedby={ + showHttpWarning + ? "remote-connection-help remote-connection-http-warning" + : "remote-connection-help" + } disabled={isConnecting || disconnecting || connection?.status === "connected"} />

@@ -141,6 +154,23 @@ export function RemoteConnectionSection() { automatically.

+ {showHttpWarning && ( +
+

+ HTTP does not encrypt your authentication token or data. Use HTTPS or a trusted + encrypted tunnel, such as Tailscale. +

+

+ Voice input and other secure-context features require HTTPS for remote addresses, even + over Tailscale. Browsers treat localhost and loopback addresses as exceptions. +

+
+ )}