diff --git a/src/browser/features/Settings/Sections/RemoteConnectionSection.tsx b/src/browser/features/Settings/Sections/RemoteConnectionSection.tsx new file mode 100644 index 00000000000..8f26b8952dd --- /dev/null +++ b/src/browser/features/Settings/Sections/RemoteConnectionSection.tsx @@ -0,0 +1,213 @@ +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 { + getRemoteConnectionServerUrl, + parseRemoteConnectionUrl, + type RemoteConnectionState, +} from "@/common/types/remoteConnection"; +import { getErrorMessage } from "@/common/utils/errors"; + +export const REMOTE_CONNECTION_URL_KEY = "remoteConnectionUrl"; + +const STATUS_LABELS: Record = { + disconnected: "Disconnected", + connecting: "Connecting…", + connected: "Connected", +}; + +export function RemoteConnectionSection() { + const bridge = window.api?.remoteConnection; + 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 getRemoteConnectionServerUrl(savedUrl); + } 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" + ); + + // 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; + setError(null); + try { + const serverUrl = getRemoteConnectionServerUrl(url); + setSavedUrl(serverUrl); + const enteredUrl = url; + setUrl(serverUrl); + 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={ + showHttpWarning + ? "remote-connection-help remote-connection-http-warning" + : "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. The server address and path are saved without tokens. Xum does not connect + 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. +

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

+ {error} +

+ )} +

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

+
+ ); +} 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 689c32e43de..89369d160d7 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"); @@ -180,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 }; @@ -198,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; } @@ -212,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; @@ -231,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 @@ -250,7 +276,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, + 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 new file mode 100644 index 00000000000..12ef1bbd02b --- /dev/null +++ b/src/browser/stories/App.remoteConnection.stories.tsx @@ -0,0 +1,273 @@ +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_URL_KEY } from "@/browser/features/Settings/Sections/RemoteConnectionSection"; +import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { + getRemoteConnectionServerUrl, + type RemoteConnectionApi, + type RemoteConnectionState, +} from "@/common/types/remoteConnection"; + +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 = { serverUrl: 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({ serverUrl: getRemoteConnectionServerUrl(url), status: "connecting" }); + return Promise.resolve(); + }), + disconnect: fn(() => { + publish({ serverUrl: 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 previousUrl = readPersistedState(REMOTE_CONNECTION_URL_KEY, undefined); + remote = createRemoteBridge(); + window.api = { + platform: "linux", + versions: {}, + ...previousApi, + remoteConnection: remote.bridge, + }; + updatePersistedState(REMOTE_CONNECTION_URL_KEY, SAVED_SERVER_URL); + return () => { + window.api = previousApi; + updatePersistedState(REMOTE_CONNECTION_URL_KEY, previousUrl); + }; + }, +}; + +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_SERVER_URL); + 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_URL_KEY, "")).toBe(SAVED_SERVER_URL); + } + + 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_URL_KEY, "")).toBe( + "https://offline.example.com" + ); + + await userEvent.clear(input); + await userEvent.type(input, TOKEN_URL); + 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_URL_KEY, "")).toBe(SERVER_URL); + await expect(input).toHaveValue(SERVER_URL); + await expect(section.queryByRole("alert")).toBeNull(); + + remote.publish({ serverUrl: SERVER_URL, 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_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" }); + 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); + }, +}; + +async function exerciseHttpWarning(canvasElement: HTMLElement) { + const section = await openRemoteSettings(canvasElement); + const input = section.getByRole("textbox", { name: "Server URL" }); + await expect(section.queryByRole("note")).toBeNull(); + + // The warning follows the entered protocol, not saved preferences or a connection attempt. + for (const serverUrl of ["http://100.64.0.10:3000", "http://localhost:3000"]) { + await userEvent.clear(input); + await userEvent.type(input, serverUrl); + await expect(section.getByRole("note")).toBeVisible(); + await expect(section.getByRole("button", { name: "Connect" })).toBeEnabled(); + await expect(remote.bridge.connect).not.toHaveBeenCalled(); + } + for (const serverUrl of [SERVER_URL, "not a URL"]) { + await userEvent.clear(input); + await userEvent.type(input, serverUrl); + await expect(section.queryByRole("note")).toBeNull(); + } + + const httpUrl = "http://100.64.0.10:3000/?token=transient-http-secret"; + await userEvent.clear(input); + await userEvent.type(input, httpUrl); + await expect(section.getByRole("note")).not.toHaveTextContent("transient-http-secret"); + await userEvent.keyboard("{Enter}"); + await waitFor(() => expect(remote.bridge.connect).toHaveBeenCalledWith(httpUrl)); + await expect(readPersistedState(REMOTE_CONNECTION_URL_KEY, "")).toBe("http://100.64.0.10:3000"); + await userEvent.click(section.getByRole("button", { name: "Disconnect" })); + await expect(section.getByRole("note")).toBeVisible(); + + if (window.innerWidth < 768) { + const region = within(canvasElement).getByRole("region", { name: "Remote connection" }); + await expect(region.scrollWidth).toBeLessThanOrEqual(region.clientWidth); + await expect(section.getByRole("note").getBoundingClientRect().right).toBeLessThanOrEqual( + window.innerWidth + ); + } +} + +export const HttpWarning: AppStory = { + ...Desktop, + play: async ({ canvasElement }) => exerciseHttpWarning(canvasElement), +}; + +export const HttpWarningPhone: AppStory = { + ...Phone, + play: async ({ canvasElement, parameters }) => { + await expect(parameters.pixel).toMatchObject({ matrix: { viewports: ["phone"] } }); + await exerciseHttpWarning(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({ 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(); + await expect(remote.bridge.connect).not.toHaveBeenCalled(); + }, +}; + +export const InvalidSavedUrl: AppStory = { + beforeEach: () => { + updatePersistedState(REMOTE_CONNECTION_URL_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/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 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..6994552b1f9 --- /dev/null +++ b/src/common/types/remoteConnection.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from "bun:test"; +import { getRemoteConnectionServerUrl, 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"); + }); +}); + +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 new file mode 100644 index 00000000000..0be3fbc6231 --- /dev/null +++ b/src/common/types/remoteConnection.ts @@ -0,0 +1,40 @@ +import { getAppProxyBasePathFromPathname } from "@/common/appProxyBasePath"; + +/** 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 base URL retains its app-proxy path but excludes credentials and URL tokens. */ + serverUrl: 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; +} + +/** 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/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..0c5a1376836 --- /dev/null +++ b/src/desktop/remoteConnectionManager.test.ts @@ -0,0 +1,961 @@ +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"; +import { REMOTE_CONNECTION_EDITOR_FRAME_NAME_PREFIX } from "@/common/constants/remoteConnection"; + +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; frameName: 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/?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", serverUrl: "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().serverUrl).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", serverUrl: 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", + serverUrl: "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("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/path/?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", serverUrl: 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.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({ frameName: "", 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({ frameName: "", url: "about:blank" })).toEqual({ action: "deny" }); + const popup = new TestWindow(); + 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({ 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]({ frameName: "", url: "about:blank" }); + const popup = new TestWindow(); + contents.emit("did-create-window", popup, { url: "about:blank" }); + 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({ frameName: "", 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]({ frameName: "", url: "about:blank" }); + const popup = new TestWindow(); + contents.emit("did-create-window", popup, { url: "about:blank" }); + 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]({ frameName: "", url: "about:blank" }); + manager.disconnect(); + await manager.connect("https://new.example.com/"); + const popup = new TestWindow(); + oldContents.emit("did-create-window", popup, { url: "about:blank" }); + expect(popup.destroyed).toBe(true); + expect(windows[1].destroyed).toBe(false); + expect(manager.getState()).toEqual({ + status: "connected", + serverUrl: "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]({ + 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" + ? { 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", serverUrl: null }); + } + ); + + 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; + 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://external.example.com/help", "http://external.example.com/help"]) { + expect(openWindow({ frameName: "", 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({ frameName: "", 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..89149bd5dfb --- /dev/null +++ b/src/desktop/remoteConnectionManager.ts @@ -0,0 +1,361 @@ +import type { BrowserWindow, BrowserWindowConstructorOptions, Event } from "electron"; +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, +} from "@/common/constants/remoteConnection"; +import { + parseRemoteConnectionUrl, + getRemoteConnectionServerUrl, + type RemoteConnectionState, +} 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; + authPopup: BrowserWindow | "opening" | null; + popups: Map; +} + +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, +}; + +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; +} + +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; + private state: RemoteConnectionState = { status: "disconnected", serverUrl: 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 serverUrl = getRemoteConnectionServerUrl(input); + const existing = this.entry; + if (existing) { + if (existing.serverUrl !== serverUrl) { + 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. + // 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, + title: "Xum — " + url.host, + show: false, + webPreferences: { + ...REMOTE_WEB_PREFERENCES, + partition, + }, + }); + const entry: RemoteWindowEntry = { + window, + serverUrl, + abort: new AbortController(), + loaded: Promise.resolve(), + authPopup: null, + popups: new Map(), + }; + this.entry = entry; + 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); + await entry.loaded; + } + + private guardWindow(entry: RemoteWindowEntry): void { + const contents = entry.window.webContents; + contents.session.setPermissionCheckHandler(() => false); + contents.session.setPermissionRequestHandler((requester, permission, callback, details) => { + 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, 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."); + } + }); + 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 app windows. + } + event.preventDefault(); + }; + contents.on("will-navigate", guardNavigation); + contents.on("will-redirect", guardNavigation); + 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: { + webPreferences: { ...REMOTE_WEB_PREFERENCES, session: contents.session }, + }, + }; + } + try { + 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, details) => { + this.registerPopup(entry, window, popup, details.url); + }); + } + + 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 { + 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, + window: BrowserWindow, + requestingUrl: string + ): Promise { + const isActiveRequest = (): boolean => + this.entry === entry && + (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 window.webContents.executeJavaScriptInIsolatedWorld( + REMOTE_CONNECTION_GESTURE_WORLD_ID, + [{ code: "navigator.userActivation.isActive" }] + ); + return activated === true && isActiveRequest(); + } + + 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.popups.set(popup, kind); + if (kind === "auth") entry.authPopup = popup; + popup.on("closed", () => { + entry.popups.delete(popup); + if (entry.authPopup === popup) entry.authPopup = null; + }); + if (kind === "app") { + this.guardAppWindow(entry, popup); + return; + } + this.guardChildWindow(entry, popup); + const guardNavigation = (event: Event, target: string): void => { + try { + 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 { + // 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.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", serverUrl: entry.serverUrl }); + } 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(); + 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(); + } + + 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..6140f2735ca --- /dev/null +++ b/tests/e2e/scenarios/remoteConnection.spec.ts @@ -0,0 +1,349 @@ +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"; +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) => { + 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", + serverUrl: 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", + serverUrl: 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(); +}); + +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); + } +}); +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(); +});