From edc5e19fbbf579369bbf03ed9a1957b37ce2067f Mon Sep 17 00:00:00 2001 From: Kaizer303 <15319636+Kaizer303@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:17:47 +0700 Subject: [PATCH 1/9] fix(preview): stream a WSL-only primary's browser tabs instead of a dead webview A WSL primary is bootstrapped over stdin, which cannot carry the desktop browser channel fds, so its server runs every tab on its own headless page. The desktop still mounted a local server-driven webview for those tabs, so typed URLs never reached the server and the Browser panel stayed empty. The server now advertises an optional desktopBrowser capability only when both channel fds are present, and the desktop draws the primary's server tabs natively only with it. Without it they stream like any other environment's server tabs. Fixes #17009 --- .../src/environment/ServerEnvironment.test.ts | 30 +++++++++++++ .../src/environment/ServerEnvironment.ts | 5 +++ .../ElectronBrowserHost.browser.test.tsx | 44 ++++++++++++++++++- apps/web/src/browser/ElectronBrowserHost.tsx | 13 +++++- apps/web/src/browser/previewRuntime.ts | 18 ++++++-- .../preview/ThreadPreviewMiniPlayer.tsx | 9 ++-- apps/web/src/state/entities.ts | 12 +++++ docs/user/remote-access.md | 4 +- packages/contracts/src/environment.ts | 5 +++ 9 files changed, 126 insertions(+), 14 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 7cebccb2a1f7c..c66f4e7a75f27 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -122,6 +122,36 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { } }), ); + it.effect("advertises desktopBrowser only when the desktop browser channel fds are present", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped(); + const config = yield* makeServerConfig(baseDir); + yield* fs.makeDirectory(config.stateDir, { recursive: true }); + const descriptorFor = (overrides: Partial) => + Effect.gen(function* () { + const environment = yield* ServerEnvironment.ServerEnvironment; + return yield* environment.getDescriptor; + }).pipe( + Effect.provide( + ServerEnvironment.layer.pipe( + Layer.provide(layerEmptySecretStore), + Layer.provide(ServerConfig.layer({ ...config, mode: "desktop", ...overrides })), + ), + ), + ); + + const withChannel = yield* descriptorFor({ + desktopBrowserFd: 6, + desktopBrowserControlFd: 7, + }); + expect(withChannel.capabilities.desktopBrowser).toBe(true); + // A WSL backend is bootstrapped over stdin, which carries no browser fds. + const wslBackend = yield* descriptorFor({}); + expect(wslBackend.capabilities.serverBrowser).toBe(true); + expect(wslBackend.capabilities.desktopBrowser).toBeUndefined(); + }), + ); it.effect.each([ { name: "missing", content: undefined }, { name: "empty", content: "" }, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index d542b4ee55bb4..9c8a5edeec4e6 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -279,6 +279,11 @@ export const make = Effect.gen(function* () { peerLinks: true, mcpModeLimitHeader: true, linkFence: true, + // Same condition as DesktopBrowserChannel.available, which is fixed at bootstrap. + ...(serverConfig.desktopBrowserFd !== undefined && + serverConfig.desktopBrowserControlFd !== undefined + ? { desktopBrowser: true } + : {}), }, }; diff --git a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx index 29c217e064adc..42c2820929966 100644 --- a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx @@ -18,10 +18,27 @@ const mocks = vi.hoisted(() => ({ closeTab: vi.fn(), registerWebview: vi.fn(), getPreviewConfig: vi.fn(), + primaryHasDesktopBrowser: true, })); vi.mock(import("~/env"), async (original) => ({ ...(await original()), isElectron: true })); +vi.mock(import("~/state/primaryEnvironment"), async () => { + const { Atom } = await import("effect/reactivity"); + return { primaryEnvironmentIdAtom: Atom.make(EnvironmentId.make("host-webview-order")) }; +}); + +vi.mock(import("~/state/entities"), async (original) => ({ + ...(await original()), + useEnvironmentHasDesktopBrowser: () => mocks.primaryHasDesktopBrowser, +})); + +// Background-tab discovery subscribes to the primary over RPC; these tests seed sessions directly. +vi.mock(import("~/components/preview/usePreviewSession"), async (original) => ({ + ...(await original()), + useDesktopHostedPreviewSessions: () => {}, +})); + vi.mock(import("~/state/session"), async (original) => ({ ...(await original()), useEnvironmentScope: () => true, @@ -73,7 +90,8 @@ const snapshot = (tabId: string, updatedAt: string): PreviewSessionSnapshot => ( updatedAt, }); -function setup(): Disposable { +function setup({ primaryHasDesktopBrowser = true } = {}): Disposable { + mocks.primaryHasDesktopBrowser = primaryHasDesktopBrowser; __resetClientSettingsPersistenceForTests(); mocks.getClientSettings.mockReset().mockResolvedValue(DEFAULT_CLIENT_SETTINGS); mocks.setClientSettings.mockReset().mockResolvedValue(undefined); @@ -160,4 +178,28 @@ describe("ElectronBrowserHost", () => { expect(after).toHaveLength(2); expect(after.every((node, index) => node.isSameNode(before[index]!))).toBe(true); }); + + // A WSL primary has no desktop browser channel, so a local webview would never reach its server. + it.each([ + { primaryHasDesktopBrowser: true, mounted: ["tab_1"] }, + { primaryHasDesktopBrowser: false, mounted: [] }, + ])( + "mounts a webview for the primary's server tab only with the desktop browser channel ($primaryHasDesktopBrowser)", + async ({ primaryHasDesktopBrowser, mounted }) => { + using _state = setup({ primaryHasDesktopBrowser }); + reconcilePreviewServerSessions(threadRef, { + sessions: [{ ...snapshot("tab_1", "2026-01-01T00:00:01.000Z"), runtime: "server" }], + serverEpoch: "server-a", + revision: 1, + }); + await using screen = await renderHost(); + + const host = screen.container.querySelector("[data-electron-browser-host]")!; + expect( + [...host.querySelectorAll("webview")].map((node) => + node.getAttribute("data-preview-server-tab"), + ), + ).toEqual(mounted); + }, + ); }); diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index d71b7a2b6b2ba..ecc7a897ecf1e 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -21,6 +21,7 @@ import { useActivePreviewSessions, } from "~/previewStateStore"; import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { readEnvironmentHasDesktopBrowser, useEnvironmentHasDesktopBrowser } from "~/state/entities"; import { previewEnvironment } from "~/state/preview"; import { useEnvironmentScope } from "~/state/session"; @@ -36,6 +37,7 @@ import { previewRuntimeTabId } from "./previewRuntimeTabId"; function hostedBrowserSessions( previewByThreadKey: Record, primaryEnvironmentId: EnvironmentId | null, + primaryHasDesktopBrowser: boolean, ) { const activeSessions = Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { const threadRef = parseScopedThreadKey(threadKey); @@ -44,7 +46,12 @@ function hostedBrowserSessions( .filter( (snapshot) => snapshot.runtime !== "server" || - rendersServerTabNatively(threadRef.environmentId, primaryEnvironmentId, snapshot), + rendersServerTabNatively( + threadRef.environmentId, + primaryEnvironmentId, + primaryHasDesktopBrowser, + snapshot, + ), ) .map((snapshot) => ({ threadRef, @@ -98,6 +105,7 @@ function openLinkInPreview({ tabId, url, background }: DesktopPreviewOpenLinkEve const source = hostedBrowserSessions( readActivePreviewSessions(), appAtomRegistry.get(primaryEnvironmentIdAtom), + readEnvironmentHasDesktopBrowser(appAtomRegistry.get(primaryEnvironmentIdAtom)), ).find((session) => session.runtimeTabId === tabId); if (!source) return; void openUrlInPreview({ @@ -129,7 +137,8 @@ export function ElectronBrowserHost() { const previewByThreadKey = useActivePreviewSessions(); const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); useDesktopHostedPreviewSessions(primaryEnvironmentId); - const sessions = hostedBrowserSessions(previewByThreadKey, primaryEnvironmentId); + const primaryHasDesktopBrowser = useEnvironmentHasDesktopBrowser(primaryEnvironmentId); + const sessions = hostedBrowserSessions(previewByThreadKey, primaryEnvironmentId, primaryHasDesktopBrowser); if (!isElectron) return null; return ( diff --git a/apps/web/src/browser/previewRuntime.ts b/apps/web/src/browser/previewRuntime.ts index 7ca4a8a8f6995..d3b2d51713da1 100644 --- a/apps/web/src/browser/previewRuntime.ts +++ b/apps/web/src/browser/previewRuntime.ts @@ -7,6 +7,7 @@ import { appAtomRegistry } from "~/rpc/atomRegistry"; import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; import { readEnvironmentSupportsServerBrowser, + useEnvironmentHasDesktopBrowser, useEnvironmentSupportsServerBrowser, } from "~/state/entities"; @@ -41,9 +42,12 @@ export function usePreviewAvailable(environmentId: EnvironmentId | null): boolea return isPreviewSupportedInRuntime() || serverBrowser; } +// A primary without the desktop browser channel (a WSL backend) never hears about +// navigation in a local , so its tabs stream like any other environment's. export function rendersServerTabNatively( environmentId: EnvironmentId, primaryEnvironmentId: EnvironmentId | null, + primaryHasDesktopBrowser: boolean, snapshot: Pick | null | undefined, ): boolean { return ( @@ -51,13 +55,21 @@ export function rendersServerTabNatively( snapshot?.runtime === "server" && !snapshot.headless && primaryEnvironmentId !== null && - environmentId === primaryEnvironmentId + environmentId === primaryEnvironmentId && + primaryHasDesktopBrowser ); } export function useRendersServerTabNatively( - environmentId: EnvironmentId, + environmentId: EnvironmentId | null, snapshot: Pick | null | undefined, ): boolean { - return rendersServerTabNatively(environmentId, useAtomValue(primaryEnvironmentIdAtom), snapshot); + const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); + const primaryHasDesktopBrowser = useEnvironmentHasDesktopBrowser(primaryEnvironmentId); + return environmentId !== null && rendersServerTabNatively( + environmentId, + primaryEnvironmentId, + primaryHasDesktopBrowser, + snapshot, + ); } diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 50b5ae47c449b..04b55947a492f 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -23,7 +23,7 @@ import { import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; -import { rendersServerTabNatively } from "~/browser/previewRuntime"; +import { useRendersServerTabNatively } from "~/browser/previewRuntime"; import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; import { type ServerBrowserHandle, ServerBrowserSurface } from "~/browser/ServerBrowserSurface"; import { @@ -137,11 +137,8 @@ function useBrowserMiniPlayerSource(threadRef: ScopedThreadRef | null, tabId: st const fittedSourceContent = useBrowserSurfaceStore((state) => runtimeTabId === null ? null : (state.byTabId[runtimeTabId]?.fittedSourceContent ?? null), ); - const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); - const serverTab = - threadRef !== null && - snapshot?.runtime === "server" && - !rendersServerTabNatively(threadRef.environmentId, primaryEnvironmentId, snapshot); + const nativeServerTab = useRendersServerTabNatively(threadRef?.environmentId ?? null, snapshot); + const serverTab = threadRef !== null && snapshot?.runtime === "server" && !nativeServerTab; const streamViewport = useStreamedSourceSize((state) => threadRef && tabId !== null && diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 0f94280768d4b..3ed6cb4a26064 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -329,6 +329,18 @@ export function useEnvironmentSupportsServerBrowser(environmentId: EnvironmentId ); } +export function useEnvironmentHasDesktopBrowser(environmentId: EnvironmentId | null): boolean { + const configs = useServerConfigs(); + return ( + environmentId !== null && + configs.get(environmentId)?.environment.capabilities.desktopBrowser === true + ); +} + +export function readEnvironmentHasDesktopBrowser(environmentId: EnvironmentId | null): boolean { + return environmentId !== null && appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities.desktopBrowser === true; +} + export function readEnvironmentSupportsServerBrowser(environmentId: EnvironmentId): boolean { return ( appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index c7e69989e0493..d3a906291228d 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -240,8 +240,8 @@ For Antigravity's Google callback on a remote host, see Browser tabs belong to the environment, so you and your agents see the same tabs from any device. The desktop app shows its own environment's tabs -directly. Every other device, and the desktop app for other environments, -streams them from the host. Agents keep using them while no device is +directly unless **WSL only** is on in **Settings → Connections**. Every other +device, and the desktop app for other environments, streams them from the host. Agents keep using them while no device is connected, and `localhost` addresses reach servers on the host. The first tab downloads a headless Chrome, about 120 MB, into the T3 home. It diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index f9cd98311d19d..a1921c9c5ad80 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -163,6 +163,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ triage: Schema.optionalKey(Schema.Boolean), /** Server accepts `{ type: "once" }` scheduled tasks; older servers reject them on save. */ oneTimeSchedules: Schema.optionalKey(Schema.Boolean), + /** The desktop app that launched this server holds its browser channel, so + it can draw this server's tabs in its own ``. Absent on a WSL + backend, whose bootstrap cannot carry the channel fds, and on servers no + desktop app launched; the desktop app streams their tabs instead. */ + desktopBrowser: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; From 7558fef29c6aeb7af9aa52858b023a2ec5d59755 Mon Sep 17 00:00:00 2001 From: Kaizer303 <15319636+Kaizer303@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:18:50 +0700 Subject: [PATCH 2/9] fix(preview): carry the desktop browser channel to a WSL primary over a WebSocket wsl.exe drops the fds that carry the desktop browser channel, so a WSL-only primary ran every tab headless and the desktop could not draw them natively. The desktop now gives that primary a bootstrap secret and, once it is ready, dials /api/desktop-browser with the secret in a header. The same events and commands then travel as WebSocket frames, so the WSL server drives the native webview: the element picker and agent browser tools work on the tab the user sees. The relay maps the server's download directory to its \wsl.localhost path so agent downloads land where the WSL server reads them. Fixes #17009 --- .../DesktopBackendConfiguration.test.ts | 42 ++++ .../backend/DesktopBackendConfiguration.ts | 7 + .../src/backend/DesktopBackendManager.ts | 96 +++++++- apps/desktop/src/preview/CdpRelay.test.ts | 33 +++ apps/desktop/src/preview/CdpRelay.ts | 22 +- .../desktop/src/preview/DesktopBrowserHost.ts | 14 +- apps/desktop/src/wsl/wslPathParsing.ts | 7 +- apps/server/src/cli/config.ts | 2 + apps/server/src/config.ts | 11 + .../src/environment/ServerEnvironment.test.ts | 12 +- .../src/environment/ServerEnvironment.ts | 6 +- .../src/preview/DesktopBrowserChannel.test.ts | 122 ++++++++++ .../src/preview/DesktopBrowserChannel.ts | 213 ++++++++++++++---- apps/server/src/preview/ServerBrowser.test.ts | 3 + apps/server/src/server.ts | 6 +- .../ElectronBrowserHost.browser.test.tsx | 2 +- apps/web/src/browser/previewRuntime.ts | 2 +- .../preview/ThreadPreviewMiniPlayer.tsx | 1 - docs/user/remote-access.md | 4 +- packages/contracts/src/desktopBootstrap.ts | 4 + packages/contracts/src/desktopBrowser.ts | 8 + packages/contracts/src/environment.ts | 5 +- 22 files changed, 542 insertions(+), 80 deletions(-) create mode 100644 apps/server/src/preview/DesktopBrowserChannel.test.ts diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 7464d8fbe9e2f..e89d16d3fcadc 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -835,6 +835,48 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("gives only a WSL-only primary the browser channel socket secret", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const primary = yield* configuration.resolvePrimary; + const besideWindows = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); + + assert.equal(primary.executablePath, "wsl.exe"); + assert.match(primary.bootstrap.desktopBrowserSocketSecret ?? "", /^[0-9a-f]{64}$/); + // Only the primary's tabs render natively. + assert.isUndefined(besideWindows.bootstrap.desktopBrowserSocketSecret); + }).pipe( + Effect.provide( + layerBackendConfiguration().pipe( + Layer.provideMerge(layerServerExposure), + Layer.provideMerge( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: true, + wslOnly: true, + wslDistro: "Ubuntu", + }), + ), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], + }), + ), + Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect( "resolvePrimary marks a removed persisted WSL distro as a fatal preflight failure", () => diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 492903139547b..ffee82e0afef1 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -548,6 +548,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl input: SharedBootstrapInput & { readonly port: number; readonly distro: string | null; + readonly desktopBrowserSocketSecret?: string; }, ): Effect.fn.Return< DesktopBackendManager.DesktopBackendStartConfig, @@ -575,6 +576,9 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl // PortSchema rejects 0 even when Tailscale Serve is disabled. tailscaleServeEnabled: false, tailscaleServePort: 443, + ...(input.desktopBrowserSocketSecret === undefined + ? {} + : { desktopBrowserSocketSecret: input.desktopBrowserSocketSecret }), ...buildObservabilityFragment(input.observabilitySettings), }; @@ -760,10 +764,13 @@ export const make = Effect.gen(function* () { const persistedSettings = yield* settings.get; const shared = yield* sharedInputs; yield* wslEnvironment.preWarm(persistedSettings.wslDistro); + // Only the primary's tabs render natively, so a WSL backend beside Windows gets no channel. + const desktopBrowserSocketSecret = Hex.encode(yield* crypto.randomBytes(32)); return yield* resolveWslStartConfig({ ...shared, port: backendExposure.port, distro: persistedSettings.wslDistro, + desktopBrowserSocketSecret, }).pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment), diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 1930be355513f..74cd8e57b542c 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -15,9 +15,13 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { HttpClient } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import * as Socket from "effect/socket/Socket"; +import * as NodeSocket from "@effect/platform-node/NodeSocket"; import { SERVER_EXIT_CODE_STATE_DIR_OWNED, + DESKTOP_BROWSER_SOCKET_PATH, + DESKTOP_BROWSER_SOCKET_SECRET_HEADER, DesktopBackendBootstrap, type DesktopBackendBootstrap as DesktopBackendBootstrapValue, PRIMARY_LOCAL_ENVIRONMENT_ID, @@ -30,6 +34,7 @@ import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; +import { linuxPathToWslUncPath } from "../wsl/wslPathParsing.ts"; const INITIAL_RESTART_DELAY = Duration.millis(500); const MAX_RESTART_DELAY = Duration.seconds(10); @@ -45,6 +50,8 @@ const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; // Three restarts without readiness is about 2 s of backoff: past a blip, early enough to help. const STARTUP_FAILURE_RESTART_ATTEMPTS = 3; const STARTUP_FAILURE_OUTPUT_CHARS = 4_000; +const DESKTOP_BROWSER_SOCKET_RETRY = Duration.seconds(1); +const DESKTOP_BROWSER_SOCKET_MAX_RETRY = Duration.seconds(30); const { logWarning: logBackendProcessWarning } = DesktopObservability.makeComponentLogger("desktop-backend-process"); @@ -189,7 +196,10 @@ export type BackendProcessError = typeof BackendProcessError.Type; interface RunBackendProcessOptions extends DesktopBackendStartConfig { readonly desktopTelemetryStream: Stream.Stream; readonly desktopBrowserStream?: Stream.Stream; - readonly onDesktopBrowserCommand?: (line: string) => Effect.Effect; + readonly onDesktopBrowserCommand?: ( + line: string, + serverPath?: (path: string) => string, + ) => Effect.Effect; readonly onDesktopTelemetryControl?: ( message: DesktopTelemetryControlMessageValue, ) => Effect.Effect; @@ -379,6 +389,64 @@ function drainBackendOutput( ); } +const socketTextDecoder = new TextDecoder(); + +/** + * Carries the browser channel to a backend that could not inherit its fds, as + * inside WSL. Reconnects, backing off, until the backend process's scope + * interrupts it. + */ +const bridgeDesktopBrowserSocket = Effect.fn("desktop.backendProcess.bridgeDesktopBrowserSocket")( + function* (input: { + readonly httpBaseUrl: URL; + readonly secret: string; + readonly events: Stream.Stream; + readonly onCommand: (line: string) => Effect.Effect; + }) { + const url = new URL(DESKTOP_BROWSER_SOCKET_PATH, input.httpBaseUrl); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + const connect = Effect.gen(function* () { + const socket = yield* Socket.makeWebSocket(url.href, { + headers: { [DESKTOP_BROWSER_SOCKET_SECRET_HEADER]: input.secret }, + }); + const reader = yield* socket.reader; + const writer = yield* socket.writer; + yield* input.events.pipe( + Stream.runForEach((line) => writer.write(line)), + Effect.forkScoped, + ); + return yield* reader.pull.pipe( + Effect.flatMap((frames) => + Effect.forEach( + frames.flatMap((frame) => + (typeof frame === "string" ? frame : socketTextDecoder.decode(frame)).split("\n"), + ), + (line) => (line.length === 0 ? Effect.void : input.onCommand(line)), + { discard: true }, + ), + ), + Effect.forever, + ); + }).pipe(Effect.scoped); + return yield* connect.pipe( + Effect.catchCause((cause) => + logBackendProcessWarning("desktop browser socket stopped", { + url: url.href, + cause: Cause.pretty(cause), + }), + ), + Effect.repeat( + Schedule.exponential(DESKTOP_BROWSER_SOCKET_RETRY).pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, DESKTOP_BROWSER_SOCKET_MAX_RETRY)), + ), + ), + ), + ); + }, + Effect.provide(NodeSocket.layerWebSocketConstructor), +); + const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); const decodeDesktopTelemetryControlLine = Schema.decodeUnknownEffect( Schema.fromJsonString(DesktopTelemetryControlMessage), @@ -561,7 +629,31 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( ), ); - yield* probeReadiness().pipe(Effect.repeat({ while: (ready) => !ready }), Effect.forkScoped); + const desktopBrowserSocketSecret = options.bootstrap.desktopBrowserSocketSecret; + const onDesktopBrowserCommand = options.onDesktopBrowserCommand; + const runningDistro = options.runningDistro; + // The backend asks for downloads on its own disk, which this app reaches over \\wsl.localhost. + const serverPath = + runningDistro === undefined + ? undefined + : (path: string) => linuxPathToWslUncPath(runningDistro, path); + const bridgeDesktopBrowser = + desktopBrowserSocketSecret !== undefined && + options.desktopBrowserStream !== undefined && + onDesktopBrowserCommand !== undefined + ? bridgeDesktopBrowserSocket({ + httpBaseUrl: options.httpBaseUrl, + secret: desktopBrowserSocketSecret, + events: options.desktopBrowserStream, + onCommand: (line) => onDesktopBrowserCommand(line, serverPath), + }) + : Effect.void; + + yield* probeReadiness().pipe( + Effect.repeat({ while: (ready) => !ready }), + Effect.andThen(bridgeDesktopBrowser), + Effect.forkScoped, + ); const exit = yield* handle.exitCode.pipe( Effect.map((exitCode) => ({ diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index a2589edf8f7b2..00b170c36bcb4 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -5,12 +5,14 @@ import { createCdpRelayConnection, type CdpRelayTarget } from "./CdpRelay.ts"; const makeTarget = ( send: CdpRelayTarget["send"], setDownloadDirectory: CdpRelayTarget["setDownloadDirectory"] = () => {}, + serverPath: CdpRelayTarget["serverPath"] = (path) => path, ): CdpRelayTarget => ({ send, targetId: async () => "GUEST-TARGET", url: () => "http://localhost:4719/", title: () => "Fixture", userAgent: () => "Electron", + serverPath, setDownloadDirectory, }); @@ -205,6 +207,37 @@ describe("CDP relay downloads", () => { { method: "Browser.downloadWillBegin", params: { guid: "g1", suggestedFilename: "r.csv" } }, ]); }); + + it("saves downloads where this app reaches a WSL server's directory", async () => { + const send = vi.fn(async () => ({})); + const directories: Array = []; + const relay = createCdpRelayConnection( + makeTarget( + send, + (directory) => directories.push(directory), + (path) => `\\\\wsl.localhost\\Ubuntu${path.replaceAll("/", "\\")}`, + ), + () => {}, + ); + relay.receive( + JSON.stringify({ + id: 1, + method: "Browser.setDownloadBehavior", + params: { behavior: "allowAndName", downloadPath: "/tmp/artifacts", eventsEnabled: true }, + }), + ); + await settle(); + expect(directories).toEqual(["\\\\wsl.localhost\\Ubuntu\\tmp\\artifacts"]); + expect(send).toHaveBeenCalledWith( + "Browser.setDownloadBehavior", + { + behavior: "allowAndName", + downloadPath: "\\\\wsl.localhost\\Ubuntu\\tmp\\artifacts", + eventsEnabled: true, + }, + undefined, + ); + }); }); describe("CDP relay payload budget", () => { diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index d52b0a3b2ab55..a04ab1335e1cc 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -14,7 +14,12 @@ export interface CdpRelayTarget { readonly url: () => string; readonly title: () => string; readonly userAgent: () => string; - /** The desktop and its server share a disk; download paths need no translation. */ + /** + * Where this app reaches a path on the server's disk: the path itself when it + * launched the server on its own disk, a `\\wsl.localhost` path for WSL. + */ + readonly serverPath: (path: string) => string; + /** Downloads land there, so the server's Playwright reads them where it asked. */ readonly setDownloadDirectory: (directory: string | null) => void; } @@ -120,11 +125,18 @@ export function createCdpRelayConnection( if (ACKNOWLEDGED.has(command.method)) return {}; switch (command.method) { case "Browser.setDownloadBehavior": { - const directory = command.params?.["downloadPath"]; - const allowed = command.params?.["behavior"] !== "deny" && typeof directory === "string"; - target.setDownloadDirectory(allowed ? directory : null); + const requested = command.params?.["downloadPath"]; + const directory = + command.params?.["behavior"] !== "deny" && typeof requested === "string" + ? target.serverPath(requested) + : null; + target.setDownloadDirectory(directory); const { browserContextId: _ignored, ...params } = command.params ?? {}; - return target.send("Browser.setDownloadBehavior", params, undefined); + return target.send( + "Browser.setDownloadBehavior", + directory === null ? params : { ...params, downloadPath: directory }, + undefined, + ); } case "Browser.getVersion": return { diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 7bf1246f9a497..8ae436d81f613 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -8,6 +8,7 @@ import { import * as Context from "effect/Context"; import * as NodePath from "node:path"; import * as Effect from "effect/Effect"; +import { identity } from "effect/Function"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; @@ -84,7 +85,11 @@ export class DesktopBrowserHost extends Context.Service< DesktopBrowserHost, { readonly events: Stream.Stream; - readonly handleCommandLine: (line: string) => Effect.Effect; + /** `serverPath` maps a path on the server's disk to one this app can write, as for WSL. */ + readonly handleCommandLine: ( + line: string, + serverPath?: (path: string) => string, + ) => Effect.Effect; readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void; readonly detach: (key: DesktopBrowserTabKey) => void; readonly humanStartedDownload: (source: Electron.WebContents) => boolean; @@ -298,7 +303,7 @@ export const make = Effect.gen(function* () { } }; - const relayFor = (tab: AttachedTab) => { + const relayFor = (tab: AttachedTab, serverPath: (path: string) => string) => { if (tab.relay) return tab.relay; const { webContents, debugger: debuggee } = tab.debuggee; const send = (method: string, params: Record, sessionId?: string) => @@ -361,6 +366,7 @@ export const make = Effect.gen(function* () { url: () => webContents.getURL(), title: () => webContents.getTitle(), userAgent: () => webContents.getUserAgent(), + serverPath, setDownloadDirectory: (directory) => { tab.downloadDirectory = directory; }, @@ -458,7 +464,7 @@ export const make = Effect.gen(function* () { emit({ type: "attached", ...key }); }; - const handleCommandLine = (line: string) => + const handleCommandLine = (line: string, serverPath: (path: string) => string = identity) => Effect.sync(() => { const command = decodeCommand(line); if (Option.isNone(command)) return; @@ -479,7 +485,7 @@ export const make = Effect.gen(function* () { return; } if (AGENT_INPUT_COMMAND.test(command.value.message)) tab.agentInputAt = performance.now(); - relayFor(tab).receive(command.value.message); + relayFor(tab, serverPath).receive(command.value.message); }); const humanStartedDownload = (source: Electron.WebContents) => { diff --git a/apps/desktop/src/wsl/wslPathParsing.ts b/apps/desktop/src/wsl/wslPathParsing.ts index a1ce99c129d13..ff049043ec540 100644 --- a/apps/desktop/src/wsl/wslPathParsing.ts +++ b/apps/desktop/src/wsl/wslPathParsing.ts @@ -45,6 +45,10 @@ export function extractDistroFromUncPath(windowsPath: string): string | null { return DISTRO_NAME_PATTERN.test(candidate) ? candidate : null; } +export function linuxPathToWslUncPath(distro: string, linuxPath: string): string { + return `\\\\wsl.localhost\\${distro}${linuxPath.replaceAll("/", "\\")}`; +} + export function wslUncPathToLinuxPath(windowsPath: string): string | null { const match = /^\\\\(?:wsl\.localhost|wsl\$)\\([^\\]+)(?:\\(.*))?$/i.exec(windowsPath.trim()); if (!match) return null; @@ -92,8 +96,7 @@ export function resolveWslPickFolderDefaultPath( return homePath; } - const toUncPath = (linuxPath: string) => - `\\\\wsl.localhost\\${distroName}${linuxPath.replaceAll("/", "\\")}`; + const toUncPath = (linuxPath: string) => linuxPathToWslUncPath(distroName, linuxPath); const normalizedUserHome = userHome && userHome.startsWith("/") ? userHome : null; diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index f86fde0f59750..96113eb99d6b4 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -381,6 +381,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd; const desktopBrowserFd = bootstrap?.desktopBrowserFd; const desktopBrowserControlFd = bootstrap?.desktopBrowserControlFd; + const desktopBrowserSocketSecret = bootstrap?.desktopBrowserSocketSecret; const resourceMonitorPath = bootstrap?.resourceMonitorPath; const autoBootstrapProjectFromCwd = Option.getOrElse( resolveOptionPrecedence( @@ -514,6 +515,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct desktopTelemetryControlFd, desktopBrowserFd, desktopBrowserControlFd, + ...(desktopBrowserSocketSecret === undefined ? {} : { desktopBrowserSocketSecret }), resourceMonitorPath, autoBootstrapProjectFromCwd, logWebSocketEvents, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 468cc89ad01e6..7c44a6e3cd3e3 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -101,6 +101,7 @@ export class ServerConfig extends Context.Service< readonly desktopTelemetryControlFd?: number | undefined; readonly desktopBrowserFd?: number | undefined; readonly desktopBrowserControlFd?: number | undefined; + readonly desktopBrowserSocketSecret?: string | undefined; readonly resourceMonitorPath?: string | undefined; readonly autoBootstrapProjectFromCwd: boolean; readonly logWebSocketEvents: boolean; @@ -116,6 +117,16 @@ export class ServerConfig extends Context.Service< export const make = (config: ServerConfig["Service"]) => ServerConfig.of(config); +/** Whether the desktop app that launched this server can reach its browser channel. */ +export const hasDesktopBrowserChannel = ( + config: Pick< + ServerConfig["Service"], + "desktopBrowserFd" | "desktopBrowserControlFd" | "desktopBrowserSocketSecret" + >, +) => + (config.desktopBrowserFd !== undefined && config.desktopBrowserControlFd !== undefined) || + config.desktopBrowserSocketSecret !== undefined; + export const otlpResource = ( config: Pick, hostName: string, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index c66f4e7a75f27..746ae5f11d1ab 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -122,7 +122,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { } }), ); - it.effect("advertises desktopBrowser only when the desktop browser channel fds are present", () => + it.effect("advertises desktopBrowser only when the desktop can reach the browser channel", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const baseDir = yield* fs.makeTempDirectoryScoped(); @@ -146,10 +146,12 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { desktopBrowserControlFd: 7, }); expect(withChannel.capabilities.desktopBrowser).toBe(true); - // A WSL backend is bootstrapped over stdin, which carries no browser fds. - const wslBackend = yield* descriptorFor({}); - expect(wslBackend.capabilities.serverBrowser).toBe(true); - expect(wslBackend.capabilities.desktopBrowser).toBeUndefined(); + // A WSL primary gets no fds through wsl.exe; the desktop dials it with this secret instead. + const wslPrimary = yield* descriptorFor({ desktopBrowserSocketSecret: "secret" }); + expect(wslPrimary.capabilities.desktopBrowser).toBe(true); + const withoutChannel = yield* descriptorFor({}); + expect(withoutChannel.capabilities.serverBrowser).toBe(true); + expect(withoutChannel.capabilities.desktopBrowser).toBeUndefined(); }), ); it.effect.each([ diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 9c8a5edeec4e6..4b2eac66866df 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -279,11 +279,7 @@ export const make = Effect.gen(function* () { peerLinks: true, mcpModeLimitHeader: true, linkFence: true, - // Same condition as DesktopBrowserChannel.available, which is fixed at bootstrap. - ...(serverConfig.desktopBrowserFd !== undefined && - serverConfig.desktopBrowserControlFd !== undefined - ? { desktopBrowser: true } - : {}), + ...(ServerConfig.hasDesktopBrowserChannel(serverConfig) ? { desktopBrowser: true } : {}), }, }; diff --git a/apps/server/src/preview/DesktopBrowserChannel.test.ts b/apps/server/src/preview/DesktopBrowserChannel.test.ts new file mode 100644 index 0000000000000..64f2838026820 --- /dev/null +++ b/apps/server/src/preview/DesktopBrowserChannel.test.ts @@ -0,0 +1,122 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Socket from "effect/socket/Socket"; + +import * as ServerConfig from "../config.ts"; +import * as DesktopBrowserChannel from "./DesktopBrowserChannel.ts"; + +const SECRET = "desktop-browser-socket-secret"; +const key = { threadId: "thread-1", tabId: "tab-1" }; +const attached = JSON.stringify({ type: "attached", ...key }); + +const layer = DesktopBrowserChannel.layer.pipe( + Layer.provide( + Layer.effect( + ServerConfig.ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + return ServerConfig.make({ ...config, desktopBrowserSocketSecret: SECRET }); + }), + ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-desktop-browser-" }))), + ), + Layer.provide(NodeServices.layer), +); + +const fakeDesktop = Effect.gen(function* () { + const frames = yield* Queue.unbounded(); + // One entry per read; the server reads again only once it has handled the previous frame. + const pulls = yield* Queue.unbounded(); + const written: Array = []; + const socket = Socket.make({ + reader: Effect.succeed({ + pull: Queue.offer(pulls, undefined).pipe( + Effect.andThen(Queue.take(frames)), + Effect.orDie, + Effect.map((frame) => [frame] as const), + ), + upgrade: () => Effect.die("unused"), + }), + writer: Effect.succeed({ + write: (chunk) => Effect.sync(() => void written.push(chunk)), + writeAll: (chunks) => Effect.sync(() => void written.push(...chunks)), + }), + }); + const commands = () => + written.filter((chunk) => typeof chunk === "string").map((line) => JSON.parse(line)); + const awaitPulls = (count: number) => + Effect.forEach(Array.from({ length: count }), () => Queue.take(pulls), { discard: true }); + return { frames, written, socket, commands, awaitPulls }; +}); + +it.layer(layer)("DesktopBrowserChannel over a desktop socket", (it) => { + it.effect("authorizes only an upgrade that presents the bootstrap secret", () => + Effect.gen(function* () { + const channel = yield* DesktopBrowserChannel.DesktopBrowserChannel; + + expect(channel.acceptsSocket).toBe(true); + expect(channel.authorizesSocket(SECRET)).toBe(true); + expect(channel.authorizesSocket(`${SECRET}x`)).toBe(false); + expect(channel.authorizesSocket(undefined)).toBe(false); + }), + ); + + it.effect("carries tabs and commands until the desktop disconnects", () => + Effect.gen(function* () { + const channel = yield* DesktopBrowserChannel.DesktopBrowserChannel; + const desktop = yield* fakeDesktop; + yield* Queue.offer(desktop.frames, attached); + const session = yield* Effect.scoped(channel.serveSocket(desktop.socket)).pipe( + Effect.forkChild, + ); + + expect(yield* channel.awaitAttached(key, "1 second")).toBe(true); + yield* channel.pointer(key, { phase: "click", x: 1, y: 2 }); + expect(desktop.commands()).toContainEqual({ + type: "pointer", + ...key, + phase: "click", + x: 1, + y: 2, + }); + + // A dropped socket reports no detaches; the desktop announces its tabs again on reconnect. + yield* Fiber.interrupt(session); + expect(yield* channel.isAttached(key)).toBe(false); + }), + ); + + it.effect("closes the socket a reconnecting desktop replaces and ignores its frames", () => + Effect.gen(function* () { + const channel = yield* DesktopBrowserChannel.DesktopBrowserChannel; + const stale = yield* fakeDesktop; + const fresh = yield* fakeDesktop; + yield* Queue.offer(stale.frames, attached); + const staleSession = yield* Effect.scoped(channel.serveSocket(stale.socket)).pipe( + Effect.forkChild, + ); + yield* stale.awaitPulls(2); + expect(yield* channel.isAttached(key)).toBe(true); + + const freshSession = yield* Effect.scoped(channel.serveSocket(fresh.socket)).pipe( + Effect.forkChild, + ); + yield* fresh.awaitPulls(1); + expect(stale.written).toContainEqual(expect.objectContaining({ code: 1000 })); + expect(yield* channel.isAttached(key)).toBe(false); + + yield* Queue.offer(stale.frames, attached); + yield* stale.awaitPulls(1); + expect(yield* channel.isAttached(key)).toBe(false); + yield* channel.pointer(key, { phase: "move", x: 3, y: 4 }); + expect(fresh.commands()).toContainEqual(expect.objectContaining({ type: "pointer" })); + expect(stale.commands()).toEqual([]); + + yield* Fiber.interrupt(staleSession); + yield* Fiber.interrupt(freshSession); + }), + ); +}); diff --git a/apps/server/src/preview/DesktopBrowserChannel.ts b/apps/server/src/preview/DesktopBrowserChannel.ts index d407fc78faf57..e9dd28a7cc841 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.ts @@ -1,11 +1,21 @@ -// @effect-diagnostics nodeBuiltinImport:off - Bridges Playwright's WebSocket transport to the desktop's fds. -/** Playwright needs a CDP WebSocket; the secret loopback endpoint relays desktop debugger frames over bootstrap fds. */ +// @effect-diagnostics nodeBuiltinImport:off - Bridges Playwright's WebSocket transport to the desktop's channel. +/** + * Playwright connects over CDP only through a WebSocket URL, so each attached + * desktop tab gets a loopback endpoint with an unguessable path. Its frames + * cross to the desktop's relay, which owns the tab's `webContents.debugger`: + * over the bootstrap file descriptors, or over a WebSocket the desktop dials + * when it could not hand those down, as for a WSL backend. + */ +import * as NodeHttpServerRequest from "@effect/platform-node/NodeHttpServerRequest"; import * as NodeStream from "@effect/platform-node/NodeStream"; import * as NodeSocketServer from "@effect/platform-node/NodeSocketServer"; import { + DESKTOP_BROWSER_SOCKET_PATH, + DESKTOP_BROWSER_SOCKET_SECRET_HEADER, DesktopBrowserCommand, DesktopBrowserEvent, type DesktopBrowserCommand as DesktopBrowserCommandType, + type DesktopBrowserEvent as DesktopBrowserEventType, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; @@ -19,6 +29,8 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as Ndjson from "effect/encoding/Ndjson"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; +import * as Socket from "effect/socket/Socket"; import * as NodeCrypto from "node:crypto"; import * as NodeNet from "node:net"; @@ -26,8 +38,11 @@ import * as ServerConfig from "../config.ts"; import { writeAllToFileDescriptor } from "../resourceTelemetry/DesktopTelemetryReceiver.ts"; const decodeEvent = Schema.decodeUnknownEffect(DesktopBrowserEvent); +const decodeEventLine = Schema.decodeUnknownEffect(Schema.fromJsonString(DesktopBrowserEvent)); const encodeCommand = Schema.encodeEffect(Schema.fromJsonString(DesktopBrowserCommand)); +const textDecoder = new TextDecoder(); + export interface DesktopTabKey { readonly threadId: string; readonly tabId: string; @@ -35,6 +50,19 @@ export interface DesktopTabKey { const keyOf = ({ threadId, tabId }: DesktopTabKey) => `${threadId}\u0000${tabId}`; +const frameText = (frame: string | Uint8Array) => + typeof frame === "string" ? frame : textDecoder.decode(frame); + +const secretMatches = (expected: string, presented: string | undefined) => { + if (presented === undefined) return false; + const presentedBytes = Buffer.from(presented); + const expectedBytes = Buffer.from(expected); + return ( + presentedBytes.length === expectedBytes.length && + NodeCrypto.timingSafeEqual(presentedBytes, expectedBytes) + ); +}; + export class DesktopBrowserChannel extends Context.Service< DesktopBrowserChannel, { @@ -51,6 +79,9 @@ export class DesktopBrowserChannel extends Context.Service< ) => Effect.Effect; /** Never fails. A failed write means a broken channel; the next backend starts with no turns. */ readonly turn: (threadId: string, active: boolean) => Effect.Effect; + readonly acceptsSocket: boolean; + readonly authorizesSocket: (secret: string | undefined) => boolean; + readonly serveSocket: (socket: Socket.Socket) => Effect.Effect; } >()("t3/preview/DesktopBrowserChannel") {} @@ -58,12 +89,13 @@ const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const inputFd = config.desktopBrowserFd; const controlFd = config.desktopBrowserControlFd; + const socketSecret = config.desktopBrowserSocketSecret; const changes = yield* PubSub.unbounded<{ key: DesktopTabKey; attached: boolean }>(); - const attachedTabs = new Set(); + const attachedTabs = new Map(); const inbound = new Map>(); const writeLock = yield* Semaphore.make(1); - if (inputFd === undefined || controlFd === undefined) { + if (!ServerConfig.hasDesktopBrowserChannel(config)) { return DesktopBrowserChannel.of({ available: false, awaitAttached: Effect.fn("DesktopBrowserChannel.awaitAttached")(function () { @@ -83,55 +115,120 @@ const make = Effect.gen(function* () { pointer: Effect.fn("DesktopBrowserChannel.pointer")(function () { return Effect.void; }), + acceptsSocket: false, + authorizesSocket: () => false, + serveSocket: () => Effect.void, }); } + // Commands sent while no desktop socket is connected are dropped: the desktop + // announces its tabs again when it reconnects, and the server then reattaches. + let writeLine: ((line: string) => Effect.Effect) | null = + inputFd !== undefined && controlFd !== undefined + ? (line) => writeAllToFileDescriptor(controlFd, Buffer.from(`${line}\n`)) + : null; + const command = (message: DesktopBrowserCommandType) => writeLock.withPermits(1)( encodeCommand(message).pipe( - Effect.flatMap((line) => writeAllToFileDescriptor(controlFd, Buffer.from(`${line}\n`))), + Effect.flatMap((line) => (writeLine === null ? Effect.void : writeLine(line))), Effect.catchCause((cause) => Effect.logWarning("desktop browser command failed", { cause }), ), ), ); - // Use cancellable socket reads; filesystem pipe reads keep Node alive after scope shutdown. - const readable = yield* Effect.acquireRelease( - Effect.sync(() => new NodeNet.Socket({ fd: inputFd, readable: true, writable: false })), - (stream) => Effect.sync(() => stream.destroy()), - ); - yield* NodeStream.fromReadable({ - evaluate: () => readable, - closeOnDone: true, - onError: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }).pipe( - Stream.pipeThroughChannel(Ndjson.decode({ ignoreEmptyLines: true })), - Stream.mapEffect((value) => decodeEvent(value).pipe(Effect.option)), - Stream.runForEach((decoded) => { - if (Option.isNone(decoded)) return Effect.void; - const event = decoded.value; - const key = { threadId: event.threadId, tabId: event.tabId }; - const id = keyOf(key); - switch (event.type) { - case "cdp": { - const queue = inbound.get(id); - return queue ? Queue.offer(queue, event.message).pipe(Effect.asVoid) : Effect.void; - } - case "attached": - attachedTabs.add(id); - return PubSub.publish(changes, { key, attached: true }); - case "detached": { - attachedTabs.delete(id); - const queue = inbound.get(id); - return (queue ? Queue.shutdown(queue) : Effect.void).pipe( - Effect.andThen(PubSub.publish(changes, { key, attached: false })), - ); - } + const receive = (event: DesktopBrowserEventType) => { + const key = { threadId: event.threadId, tabId: event.tabId }; + const id = keyOf(key); + switch (event.type) { + case "cdp": { + const queue = inbound.get(id); + return queue ? Queue.offer(queue, event.message).pipe(Effect.asVoid) : Effect.void; } - }), - Effect.catchCause((cause) => Effect.logWarning("desktop browser channel stopped", { cause })), - Effect.forkScoped, + case "attached": + attachedTabs.set(id, key); + return PubSub.publish(changes, { key, attached: true }); + case "detached": + return detach(id, key); + } + }; + + const detach = (id: string, key: DesktopTabKey) => { + attachedTabs.delete(id); + const queue = inbound.get(id); + return (queue ? Queue.shutdown(queue) : Effect.void).pipe( + Effect.andThen(PubSub.publish(changes, { key, attached: false })), + Effect.asVoid, + ); + }; + + const detachAll = Effect.suspend(() => + Effect.forEach([...attachedTabs], ([id, key]) => detach(id, key), { discard: true }), + ); + + if (inputFd !== undefined && controlFd !== undefined) { + // Socket reads can be cancelled while the desktop keeps its write end open. + // Filesystem reads on this pipe keep Node alive after scope shutdown. + const readable = yield* Effect.acquireRelease( + Effect.sync(() => new NodeNet.Socket({ fd: inputFd, readable: true, writable: false })), + (stream) => Effect.sync(() => stream.destroy()), + ); + yield* NodeStream.fromReadable({ + evaluate: () => readable, + closeOnDone: true, + onError: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }).pipe( + Stream.pipeThroughChannel(Ndjson.decode({ ignoreEmptyLines: true })), + Stream.mapEffect((value) => decodeEvent(value).pipe(Effect.option)), + Stream.runForEach((decoded) => + Option.isSome(decoded) ? receive(decoded.value) : Effect.void, + ), + Effect.catchCause((cause) => Effect.logWarning("desktop browser channel stopped", { cause })), + Effect.forkScoped, + ); + } + + let currentSocket: { readonly close: Effect.Effect } | null = null; + const serveSocket = Effect.fn("DesktopBrowserChannel.serveSocket")( + function* (socket: Socket.Socket) { + if (socketSecret === undefined) return; + const reader = yield* socket.reader; + const writer = yield* socket.writer; + // A desktop only reconnects after losing the old socket, which may not have + // noticed yet. It reports no detaches, and its frames must not reach the new one. + const replaced = currentSocket; + const connection = { + close: writer.write(new Socket.CloseEvent(1000, "replaced")).pipe(Effect.ignore), + }; + currentSocket = connection; + writeLine = (line) => writer.write(line); + if (replaced !== null) yield* replaced.close; + yield* detachAll; + yield* Effect.addFinalizer(() => + currentSocket === connection + ? Effect.suspend(() => { + currentSocket = null; + writeLine = null; + return detachAll; + }) + : Effect.void, + ); + const receiveFrames = (frames: ReadonlyArray) => + Effect.forEach( + frames.flatMap((frame) => frameText(frame).split("\n")), + (line) => + line.trim().length === 0 || currentSocket !== connection + ? Effect.void + : decodeEventLine(line).pipe( + Effect.flatMap(receive), + Effect.catch(() => Effect.void), + ), + { discard: true }, + ); + return yield* reader.pull.pipe(Effect.flatMap(receiveFrames), Effect.forever); + }, + Effect.catchCause((cause) => Effect.logDebug("desktop browser socket closed", { cause })), ); const endpoint = Effect.fn("DesktopBrowserChannel.endpoint")(function* (key: DesktopTabKey) { @@ -169,17 +266,11 @@ const make = Effect.gen(function* () { Stream.runForEach((message) => writer.write(message)), Effect.forkScoped, ); - const decoder = new TextDecoder(); return yield* reader.pull.pipe( Effect.flatMap((frames) => Effect.forEach( frames, - (frame) => - command({ - type: "cdp", - ...key, - message: typeof frame === "string" ? frame : decoder.decode(frame), - }), + (frame) => command({ type: "cdp", ...key, message: frameText(frame) }), { discard: true }, ), ), @@ -228,7 +319,35 @@ const make = Effect.gen(function* () { pointer: Effect.fn("DesktopBrowserChannel.pointer")(function (key, pointer) { return command({ type: "pointer", ...key, ...pointer }); }), + acceptsSocket: socketSecret !== undefined, + authorizesSocket: (secret) => socketSecret !== undefined && secretMatches(socketSecret, secret), + serveSocket, }); }); export const layer = Layer.effect(DesktopBrowserChannel, make); + +// The upgrade presents the bootstrap secret, so this route skips session auth. +export const socketRouteLayer = HttpRouter.use((router) => + Effect.gen(function* () { + const channel = yield* DesktopBrowserChannel; + yield* router.add( + "GET", + DESKTOP_BROWSER_SOCKET_PATH, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + if (!channel.acceptsSocket || request.headers.upgrade?.toLowerCase() !== "websocket") { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + if (!channel.authorizesSocket(request.headers[DESKTOP_BROWSER_SOCKET_SECRET_HEADER])) { + return HttpServerResponse.text("Unauthorized", { status: 401 }); + } + // The desktop sits on this machine, so deflate would only cost CPU. + delete NodeHttpServerRequest.toIncomingMessage(request).headers["sec-websocket-extensions"]; + const socket = yield* request.upgrade; + yield* Effect.scoped(channel.serveSocket(socket)); + return HttpServerResponse.empty(); + }), + ); + }), +); diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 4b21b6f3421da..b372543bcd77f 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -380,6 +380,9 @@ const dependencies = Layer.mergeAll( ), pointer: () => Effect.void, turn: (threadId, active) => Effect.sync(() => desktopTurns.emit("turn", threadId, active)), + acceptsSocket: false, + authorizesSocket: () => false, + serveSocket: () => Effect.void, }), PreviewAgentActivity.layer, ).pipe( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 452addfb4a667..8a226c144819e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -736,6 +736,7 @@ const layerMakeRoutes = Layer.mergeAll( GuestHttp.layer, DeviceHubProxy.layer, ServerBrowserStream.routeLayer, + DesktopBrowserChannel.socketRouteLayer, ServerHttp.layerStaticAndDevRoute, Ws.layer, ), @@ -757,10 +758,11 @@ const layerMakeRoutes = Layer.mergeAll( Layer.provide(PeerLinkRequests.layer), // Share the service so HTTP/WS clients single-flight reads and invalidate each other's cached patches. Layer.provide(layerPullRequestService), - // The same activity instance RunExecutionService reports to, by layer memoization. + // The route and browser share the channel; the browser shares agent activity with run execution. Layer.provide( ServerBrowser.layer.pipe( - Layer.provide(Layer.merge(DesktopBrowserChannel.layer, PreviewAgentActivity.layer)), + Layer.provide(PreviewAgentActivity.layer), + Layer.provideMerge(DesktopBrowserChannel.layer), ), ), Layer.provide(PreviewBrowser.layer), diff --git a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx index 42c2820929966..6a986483751b9 100644 --- a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx @@ -179,7 +179,7 @@ describe("ElectronBrowserHost", () => { expect(after.every((node, index) => node.isSameNode(before[index]!))).toBe(true); }); - // A WSL primary has no desktop browser channel, so a local webview would never reach its server. + // Without the desktop browser channel, a local webview would never reach the primary's server. it.each([ { primaryHasDesktopBrowser: true, mounted: ["tab_1"] }, { primaryHasDesktopBrowser: false, mounted: [] }, diff --git a/apps/web/src/browser/previewRuntime.ts b/apps/web/src/browser/previewRuntime.ts index d3b2d51713da1..8316d19cf784f 100644 --- a/apps/web/src/browser/previewRuntime.ts +++ b/apps/web/src/browser/previewRuntime.ts @@ -42,7 +42,7 @@ export function usePreviewAvailable(environmentId: EnvironmentId | null): boolea return isPreviewSupportedInRuntime() || serverBrowser; } -// A primary without the desktop browser channel (a WSL backend) never hears about +// A primary the desktop cannot reach over the browser channel never hears about // navigation in a local , so its tabs stream like any other environment's. export function rendersServerTabNatively( environmentId: EnvironmentId, diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 04b55947a492f..157cd7a77e27a 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -24,7 +24,6 @@ import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { useRendersServerTabNatively } from "~/browser/previewRuntime"; -import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; import { type ServerBrowserHandle, ServerBrowserSurface } from "~/browser/ServerBrowserSurface"; import { closeServerPictureInPicture, diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index d3a906291228d..c7e69989e0493 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -240,8 +240,8 @@ For Antigravity's Google callback on a remote host, see Browser tabs belong to the environment, so you and your agents see the same tabs from any device. The desktop app shows its own environment's tabs -directly unless **WSL only** is on in **Settings → Connections**. Every other -device, and the desktop app for other environments, streams them from the host. Agents keep using them while no device is +directly. Every other device, and the desktop app for other environments, +streams them from the host. Agents keep using them while no device is connected, and `localhost` addresses reach servers on the host. The first tab downloads a headless Chrome, about 120 MB, into the T3 home. It diff --git a/packages/contracts/src/desktopBootstrap.ts b/packages/contracts/src/desktopBootstrap.ts index a77e14b555420..1f333da7828e8 100644 --- a/packages/contracts/src/desktopBootstrap.ts +++ b/packages/contracts/src/desktopBootstrap.ts @@ -26,6 +26,10 @@ export const DesktopBackendBootstrap = Schema.Struct({ desktopTelemetryControlFd: Schema.optionalKey(PositiveInt), desktopBrowserFd: Schema.optionalKey(PositiveInt), desktopBrowserControlFd: Schema.optionalKey(PositiveInt), + // wsl.exe drops the browser channel fds, so a WSL primary gets this instead + // and the desktop dials the channel over a WebSocket that presents it. + // See `DESKTOP_BROWSER_SOCKET_PATH`. + desktopBrowserSocketSecret: Schema.optionalKey(TrimmedNonEmptyString), resourceMonitorPath: Schema.optionalKey(TrimmedNonEmptyString), }); diff --git a/packages/contracts/src/desktopBrowser.ts b/packages/contracts/src/desktopBrowser.ts index 17d256f0d8d3e..9922c108e1a96 100644 --- a/packages/contracts/src/desktopBrowser.ts +++ b/packages/contracts/src/desktopBrowser.ts @@ -33,3 +33,11 @@ export const DesktopBrowserCommand = Schema.Union([ }), ]); export type DesktopBrowserCommand = typeof DesktopBrowserCommand.Type; + +/** + * Carries the channel, one event or command per frame, when the backend could + * not inherit its fds, as inside WSL. The upgrade request presents the + * bootstrap's `desktopBrowserSocketSecret` in this header. + */ +export const DESKTOP_BROWSER_SOCKET_PATH = "/api/desktop-browser"; +export const DESKTOP_BROWSER_SOCKET_SECRET_HEADER = "x-t3-desktop-browser-secret"; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index a1921c9c5ad80..93b868297dfba 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -164,9 +164,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server accepts `{ type: "once" }` scheduled tasks; older servers reject them on save. */ oneTimeSchedules: Schema.optionalKey(Schema.Boolean), /** The desktop app that launched this server holds its browser channel, so - it can draw this server's tabs in its own ``. Absent on a WSL - backend, whose bootstrap cannot carry the channel fds, and on servers no - desktop app launched; the desktop app streams their tabs instead. */ + it can draw this server's tabs in its own ``. Absent on servers + launched without the channel; the desktop app streams their tabs instead. */ desktopBrowser: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; From 0649c953e0ae699b61715cce218bb4d0b91a74b5 Mon Sep 17 00:00:00 2001 From: narongchai Date: Sun, 1 Nov 2026 16:27:12 +0700 Subject: [PATCH 3/9] fix(preview): preserve older desktop browser capabilities --- .../src/environment/ServerEnvironment.test.ts | 2 +- apps/server/src/environment/ServerEnvironment.ts | 2 +- apps/server/src/preview/DesktopBrowserChannel.ts | 7 ++----- apps/web/src/browser/ElectronBrowserHost.tsx | 11 +++++++++-- apps/web/src/browser/previewRuntime.ts | 13 ++++++++----- apps/web/src/state/entities.ts | 13 ++++++++----- packages/contracts/src/environment.ts | 4 +--- 7 files changed, 30 insertions(+), 22 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 746ae5f11d1ab..730b631c52c9e 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -151,7 +151,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(wslPrimary.capabilities.desktopBrowser).toBe(true); const withoutChannel = yield* descriptorFor({}); expect(withoutChannel.capabilities.serverBrowser).toBe(true); - expect(withoutChannel.capabilities.desktopBrowser).toBeUndefined(); + expect(withoutChannel.capabilities.desktopBrowser).toBe(false); }), ); it.effect.each([ diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 4b2eac66866df..8ff68148413ec 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -279,7 +279,7 @@ export const make = Effect.gen(function* () { peerLinks: true, mcpModeLimitHeader: true, linkFence: true, - ...(ServerConfig.hasDesktopBrowserChannel(serverConfig) ? { desktopBrowser: true } : {}), + desktopBrowser: ServerConfig.hasDesktopBrowserChannel(serverConfig), }, }; diff --git a/apps/server/src/preview/DesktopBrowserChannel.ts b/apps/server/src/preview/DesktopBrowserChannel.ts index e9dd28a7cc841..736e86786071a 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.ts @@ -1,10 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off - Bridges Playwright's WebSocket transport to the desktop's channel. /** - * Playwright connects over CDP only through a WebSocket URL, so each attached - * desktop tab gets a loopback endpoint with an unguessable path. Its frames - * cross to the desktop's relay, which owns the tab's `webContents.debugger`: - * over the bootstrap file descriptors, or over a WebSocket the desktop dials - * when it could not hand those down, as for a WSL backend. + * Playwright requires a WebSocket CDP endpoint for each desktop tab. + * The loopback endpoint relays frames over inherited pipes or a WSL socket. */ import * as NodeHttpServerRequest from "@effect/platform-node/NodeHttpServerRequest"; import * as NodeStream from "@effect/platform-node/NodeStream"; diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index ecc7a897ecf1e..f0ea0335706dc 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -21,7 +21,10 @@ import { useActivePreviewSessions, } from "~/previewStateStore"; import { appAtomRegistry } from "~/rpc/atomRegistry"; -import { readEnvironmentHasDesktopBrowser, useEnvironmentHasDesktopBrowser } from "~/state/entities"; +import { + readEnvironmentHasDesktopBrowser, + useEnvironmentHasDesktopBrowser, +} from "~/state/entities"; import { previewEnvironment } from "~/state/preview"; import { useEnvironmentScope } from "~/state/session"; @@ -138,7 +141,11 @@ export function ElectronBrowserHost() { const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); useDesktopHostedPreviewSessions(primaryEnvironmentId); const primaryHasDesktopBrowser = useEnvironmentHasDesktopBrowser(primaryEnvironmentId); - const sessions = hostedBrowserSessions(previewByThreadKey, primaryEnvironmentId, primaryHasDesktopBrowser); + const sessions = hostedBrowserSessions( + previewByThreadKey, + primaryEnvironmentId, + primaryHasDesktopBrowser, + ); if (!isElectron) return null; return ( diff --git a/apps/web/src/browser/previewRuntime.ts b/apps/web/src/browser/previewRuntime.ts index 8316d19cf784f..b38b7509c2037 100644 --- a/apps/web/src/browser/previewRuntime.ts +++ b/apps/web/src/browser/previewRuntime.ts @@ -66,10 +66,13 @@ export function useRendersServerTabNatively( ): boolean { const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); const primaryHasDesktopBrowser = useEnvironmentHasDesktopBrowser(primaryEnvironmentId); - return environmentId !== null && rendersServerTabNatively( - environmentId, - primaryEnvironmentId, - primaryHasDesktopBrowser, - snapshot, + return ( + environmentId !== null && + rendersServerTabNatively( + environmentId, + primaryEnvironmentId, + primaryHasDesktopBrowser, + snapshot, + ) ); } diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 3ed6cb4a26064..2308c5b46ad20 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -329,16 +329,19 @@ export function useEnvironmentSupportsServerBrowser(environmentId: EnvironmentId ); } +// Older primary servers do not advertise this capability. Preserve their native rendering. export function useEnvironmentHasDesktopBrowser(environmentId: EnvironmentId | null): boolean { const configs = useServerConfigs(); - return ( - environmentId !== null && - configs.get(environmentId)?.environment.capabilities.desktopBrowser === true - ); + const config = environmentId === null ? undefined : configs.get(environmentId); + return config !== undefined && config.environment.capabilities.desktopBrowser !== false; } export function readEnvironmentHasDesktopBrowser(environmentId: EnvironmentId | null): boolean { - return environmentId !== null && appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities.desktopBrowser === true; + const config = + environmentId === null + ? undefined + : appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId); + return config !== undefined && config.environment.capabilities.desktopBrowser !== false; } export function readEnvironmentSupportsServerBrowser(environmentId: EnvironmentId): boolean { diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 93b868297dfba..bfeed11a547e0 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -163,9 +163,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ triage: Schema.optionalKey(Schema.Boolean), /** Server accepts `{ type: "once" }` scheduled tasks; older servers reject them on save. */ oneTimeSchedules: Schema.optionalKey(Schema.Boolean), - /** The desktop app that launched this server holds its browser channel, so - it can draw this server's tabs in its own ``. Absent on servers - launched without the channel; the desktop app streams their tabs instead. */ + /** Whether the launching desktop can reach the browser channel. Absent on older servers. */ desktopBrowser: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; From abdb453d97a8ba17cf7b80f98db3cee85cab00a5 Mon Sep 17 00:00:00 2001 From: narongchai Date: Sun, 1 Nov 2026 16:34:50 +0700 Subject: [PATCH 4/9] fix(preview): update channel checks for current tooling --- apps/desktop/src/preview/CdpRelay.test.ts | 2 +- apps/server/src/preview/DesktopBrowserChannel.ts | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index 00b170c36bcb4..be89313a1c284 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -211,7 +211,7 @@ describe("CDP relay downloads", () => { it("saves downloads where this app reaches a WSL server's directory", async () => { const send = vi.fn(async () => ({})); const directories: Array = []; - const relay = createCdpRelayConnection( + const relay = makeRelay( makeTarget( send, (directory) => directories.push(directory), diff --git a/apps/server/src/preview/DesktopBrowserChannel.ts b/apps/server/src/preview/DesktopBrowserChannel.ts index 736e86786071a..1501bb6a1b60d 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.ts @@ -114,7 +114,9 @@ const make = Effect.gen(function* () { }), acceptsSocket: false, authorizesSocket: () => false, - serveSocket: () => Effect.void, + serveSocket: Effect.fn("DesktopBrowserChannel.serveSocket")(function () { + return Effect.void; + }), }); } @@ -217,10 +219,7 @@ const make = Effect.gen(function* () { (line) => line.trim().length === 0 || currentSocket !== connection ? Effect.void - : decodeEventLine(line).pipe( - Effect.flatMap(receive), - Effect.catch(() => Effect.void), - ), + : decodeEventLine(line).pipe(Effect.flatMap(receive), Effect.ignore), { discard: true }, ); return yield* reader.pull.pipe(Effect.flatMap(receiveFrames), Effect.forever); From c1d848a1065d8c75785ac8329fcc5353b8dbb15b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 11:13:57 +0000 Subject: [PATCH 5/9] fix(preview): isolate commands from replaced desktop sockets --- .../server/src/preview/DesktopBrowserChannel.test.ts | 12 +++++++++++- apps/server/src/preview/DesktopBrowserChannel.ts | 12 ++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/server/src/preview/DesktopBrowserChannel.test.ts b/apps/server/src/preview/DesktopBrowserChannel.test.ts index 64f2838026820..ea603753fb556 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.test.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.test.ts @@ -1,9 +1,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; +import * as Scope from "effect/Scope"; import * as Socket from "effect/socket/Socket"; import * as ServerConfig from "../config.ts"; @@ -101,6 +103,9 @@ it.layer(layer)("DesktopBrowserChannel over a desktop socket", (it) => { yield* stale.awaitPulls(2); expect(yield* channel.isAttached(key)).toBe(true); + const endpointScope = yield* Scope.make(); + yield* channel.endpoint(key).pipe(Scope.provide(endpointScope)); + const freshSession = yield* Effect.scoped(channel.serveSocket(fresh.socket)).pipe( Effect.forkChild, ); @@ -108,9 +113,14 @@ it.layer(layer)("DesktopBrowserChannel over a desktop socket", (it) => { expect(stale.written).toContainEqual(expect.objectContaining({ code: 1000 })); expect(yield* channel.isAttached(key)).toBe(false); + yield* Queue.offer(fresh.frames, attached); + yield* fresh.awaitPulls(1); + yield* Scope.close(endpointScope, Exit.void); + expect(fresh.commands()).not.toContainEqual({ type: "release", ...key }); + yield* Queue.offer(stale.frames, attached); yield* stale.awaitPulls(1); - expect(yield* channel.isAttached(key)).toBe(false); + expect(yield* channel.isAttached(key)).toBe(true); yield* channel.pointer(key, { phase: "move", x: 3, y: 4 }); expect(fresh.commands()).toContainEqual(expect.objectContaining({ type: "pointer" })); expect(stale.commands()).toEqual([]); diff --git a/apps/server/src/preview/DesktopBrowserChannel.ts b/apps/server/src/preview/DesktopBrowserChannel.ts index 1501bb6a1b60d..1b855794db050 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.ts @@ -127,10 +127,12 @@ const make = Effect.gen(function* () { ? (line) => writeAllToFileDescriptor(controlFd, Buffer.from(`${line}\n`)) : null; - const command = (message: DesktopBrowserCommandType) => + const command = (message: DesktopBrowserCommandType, writer = writeLine) => writeLock.withPermits(1)( encodeCommand(message).pipe( - Effect.flatMap((line) => (writeLine === null ? Effect.void : writeLine(line))), + Effect.flatMap((line) => + writer === null || writer !== writeLine ? Effect.void : writer(line), + ), Effect.catchCause((cause) => Effect.logWarning("desktop browser command failed", { cause }), ), @@ -228,6 +230,7 @@ const make = Effect.gen(function* () { ); const endpoint = Effect.fn("DesktopBrowserChannel.endpoint")(function* (key: DesktopTabKey) { + const channelWriter = writeLine; const id = keyOf(key); const secret = NodeCrypto.randomBytes(24).toString("base64url"); const server = yield* NodeSocketServer.makeWebSocket({ @@ -246,7 +249,7 @@ const make = Effect.gen(function* () { Effect.gen(function* () { if (inbound.get(id) === queue) inbound.delete(id); yield* Queue.shutdown(queue); - yield* command({ type: "release", ...key }); + yield* command({ type: "release", ...key }, channelWriter); }), ); // The relay serves one Playwright connection; a second would see the first's sessions. @@ -266,7 +269,8 @@ const make = Effect.gen(function* () { Effect.flatMap((frames) => Effect.forEach( frames, - (frame) => command({ type: "cdp", ...key, message: frameText(frame) }), + (frame) => + command({ type: "cdp", ...key, message: frameText(frame) }, channelWriter), { discard: true }, ), ), From e45a6463402075311218f208f0325f16f4044955 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 11:22:13 +0000 Subject: [PATCH 6/9] fix(preview): preserve WSL uploads and activity on reconnect --- apps/desktop/src/preview/CdpRelay.test.ts | 17 ++++++++++++++++- apps/desktop/src/preview/CdpRelay.ts | 11 ++++++++++- .../src/preview/DesktopBrowserChannel.test.ts | 16 +++++++++++++++- .../server/src/preview/DesktopBrowserChannel.ts | 15 ++++++++++++--- 4 files changed, 53 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index be89313a1c284..ab3ef4e38994c 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -208,7 +208,7 @@ describe("CDP relay downloads", () => { ]); }); - it("saves downloads where this app reaches a WSL server's directory", async () => { + it("maps downloads and directory uploads to the WSL server's disk", async () => { const send = vi.fn(async () => ({})); const directories: Array = []; const relay = makeRelay( @@ -237,6 +237,21 @@ describe("CDP relay downloads", () => { }, undefined, ); + relay.receive( + JSON.stringify({ + id: 2, + method: "DOM.setFileInputFiles", + params: { objectId: "input", files: ["/home/user/assets"] }, + sessionId: "t3-preview-page", + }), + ); + await settle(); + expect(send).toHaveBeenLastCalledWith( + "DOM.setFileInputFiles", + { objectId: "input", files: ["\\\\wsl.localhost\\Ubuntu\\home\\user\\assets"] }, + undefined, + undefined, + ); }); }); diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index a04ab1335e1cc..303c24a365f73 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -203,7 +203,16 @@ export function createCdpRelayConnection( } // Playwright asks for this on every page; the host emulates focus only while it is in use. if (command.method === "Emulation.setFocusEmulationEnabled") return Promise.resolve({}); - return target.send(command.method, command.params ?? {}, child, owner); + let params = command.params ?? {}; + if (command.method === "DOM.setFileInputFiles" && Array.isArray(params["files"])) { + params = { + ...params, + files: params["files"].map((path: unknown) => + typeof path === "string" ? target.serverPath(path) : path, + ), + }; + } + return target.send(command.method, params, child, owner); }; return { diff --git a/apps/server/src/preview/DesktopBrowserChannel.test.ts b/apps/server/src/preview/DesktopBrowserChannel.test.ts index ea603753fb556..6707aeb2e8f46 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.test.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.test.ts @@ -96,6 +96,7 @@ it.layer(layer)("DesktopBrowserChannel over a desktop socket", (it) => { const channel = yield* DesktopBrowserChannel.DesktopBrowserChannel; const stale = yield* fakeDesktop; const fresh = yield* fakeDesktop; + yield* channel.turn(key.threadId, true); yield* Queue.offer(stale.frames, attached); const staleSession = yield* Effect.scoped(channel.serveSocket(stale.socket)).pipe( Effect.forkChild, @@ -115,6 +116,11 @@ it.layer(layer)("DesktopBrowserChannel over a desktop socket", (it) => { yield* Queue.offer(fresh.frames, attached); yield* fresh.awaitPulls(1); + expect(fresh.commands()).toContainEqual({ + type: "turn", + threadId: key.threadId, + active: true, + }); yield* Scope.close(endpointScope, Exit.void); expect(fresh.commands()).not.toContainEqual({ type: "release", ...key }); @@ -123,7 +129,15 @@ it.layer(layer)("DesktopBrowserChannel over a desktop socket", (it) => { expect(yield* channel.isAttached(key)).toBe(true); yield* channel.pointer(key, { phase: "move", x: 3, y: 4 }); expect(fresh.commands()).toContainEqual(expect.objectContaining({ type: "pointer" })); - expect(stale.commands()).toEqual([]); + expect(stale.commands()).toEqual([{ type: "turn", threadId: key.threadId, active: true }]); + + yield* channel.turn(key.threadId, false); + yield* Queue.offer(fresh.frames, attached); + yield* fresh.awaitPulls(1); + expect(fresh.commands().filter((command) => command.type === "turn")).toEqual([ + { type: "turn", threadId: key.threadId, active: true }, + { type: "turn", threadId: key.threadId, active: false }, + ]); yield* Fiber.interrupt(staleSession); yield* Fiber.interrupt(freshSession); diff --git a/apps/server/src/preview/DesktopBrowserChannel.ts b/apps/server/src/preview/DesktopBrowserChannel.ts index 1b855794db050..82b151279850a 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.ts @@ -89,6 +89,7 @@ const make = Effect.gen(function* () { const socketSecret = config.desktopBrowserSocketSecret; const changes = yield* PubSub.unbounded<{ key: DesktopTabKey; attached: boolean }>(); const attachedTabs = new Map(); + const activeThreads = new Set(); const inbound = new Map>(); const writeLock = yield* Semaphore.make(1); @@ -149,7 +150,13 @@ const make = Effect.gen(function* () { } case "attached": attachedTabs.set(id, key); - return PubSub.publish(changes, { key, attached: true }); + return PubSub.publish(changes, { key, attached: true }).pipe( + Effect.andThen(() => + activeThreads.has(key.threadId) + ? command({ type: "turn", threadId: key.threadId, active: true }) + : Effect.void, + ), + ); case "detached": return detach(id, key); } @@ -313,8 +320,10 @@ const make = Effect.gen(function* () { return Effect.sync(() => attachedTabs.has(keyOf(key))); }), endpoint, - turn: Effect.fn("DesktopBrowserChannel.turn")(function (threadId, active) { - return command({ type: "turn", threadId, active }); + turn: Effect.fn("DesktopBrowserChannel.turn")(function* (threadId, active) { + if (active) activeThreads.add(threadId); + else activeThreads.delete(threadId); + yield* command({ type: "turn", threadId, active }); }), pointer: Effect.fn("DesktopBrowserChannel.pointer")(function (key, pointer) { return command({ type: "pointer", ...key, ...pointer }); From 91df6f22d0812c623cb88b1c430a108eddce0138 Mon Sep 17 00:00:00 2001 From: narongchai Date: Mon, 2 Nov 2026 10:49:09 +0700 Subject: [PATCH 7/9] fix(preview): map WSL browser paths through distro mounts --- .../src/backend/DesktopBackendManager.ts | 16 ++- apps/desktop/src/preview/CdpRelay.test.ts | 136 ++++++++++++------ apps/desktop/src/preview/CdpRelay.ts | 23 ++- .../desktop/src/preview/DesktopBrowserHost.ts | 9 +- .../src/wsl/DesktopWslEnvironment.test.ts | 134 +++++++++++++++++ apps/desktop/src/wsl/DesktopWslEnvironment.ts | 70 +++++++++ apps/desktop/src/wsl/wslPathParsing.ts | 7 +- 7 files changed, 329 insertions(+), 66 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 74cd8e57b542c..98469c9e474de 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -34,7 +34,6 @@ import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; -import { linuxPathToWslUncPath } from "../wsl/wslPathParsing.ts"; const INITIAL_RESTART_DELAY = Duration.millis(500); const MAX_RESTART_DELAY = Duration.seconds(10); @@ -198,7 +197,7 @@ interface RunBackendProcessOptions extends DesktopBackendStartConfig { readonly desktopBrowserStream?: Stream.Stream; readonly onDesktopBrowserCommand?: ( line: string, - serverPath?: (path: string) => string, + serverPath?: (path: string) => string | Promise, ) => Effect.Effect; readonly onDesktopTelemetryControl?: ( message: DesktopTelemetryControlMessageValue, @@ -629,14 +628,23 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( ), ); + const processScope = yield* Scope.Scope; + const runPromise = Effect.runPromiseWith(yield* Effect.context()); const desktopBrowserSocketSecret = options.bootstrap.desktopBrowserSocketSecret; const onDesktopBrowserCommand = options.onDesktopBrowserCommand; const runningDistro = options.runningDistro; - // The backend asks for downloads on its own disk, which this app reaches over \\wsl.localhost. + // DrvFS mounts cannot be re-exported through WSL UNC paths; let the distro resolve its mounts. const serverPath = runningDistro === undefined ? undefined - : (path: string) => linuxPathToWslUncPath(runningDistro, path); + : (path: string) => + runPromise( + DesktopWslEnvironment.wslToWindowsPath(runningDistro, path).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.forkIn(processScope), + Effect.flatMap(Fiber.join), + ), + ); const bridgeDesktopBrowser = desktopBrowserSocketSecret !== undefined && options.desktopBrowserStream !== undefined && diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index 084c9be877ca3..3ba504d74e793 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -208,47 +208,101 @@ describe("CDP relay downloads", () => { ]); }); - it("maps downloads and directory uploads to the WSL server's disk", async () => { - const send = vi.fn(async () => ({})); - const directories: Array = []; - const relay = makeRelay( - makeTarget( - send, - (directory) => directories.push(directory), - (path) => `\\\\wsl.localhost\\Ubuntu${path.replaceAll("/", "\\")}`, - ), - () => {}, - ); - relay.receive( - JSON.stringify({ - id: 1, - method: "Browser.setDownloadBehavior", - params: { behavior: "allowAndName", downloadPath: "/tmp/artifacts", eventsEnabled: true }, - }), - ); - await settle(); - expect(directories).toEqual(["\\\\wsl.localhost\\Ubuntu\\tmp\\artifacts"]); - expect(send).toHaveBeenCalledWith( - "Page.setDownloadBehavior", - { behavior: "default" }, - undefined, - ); - relay.receive( - JSON.stringify({ - id: 2, - method: "DOM.setFileInputFiles", - params: { objectId: "input", files: ["/home/user/assets"] }, - sessionId: "t3-preview-page", - }), - ); - await settle(); - expect(send).toHaveBeenLastCalledWith( - "DOM.setFileInputFiles", - { objectId: "input", files: ["\\\\wsl.localhost\\Ubuntu\\home\\user\\assets"] }, - undefined, - undefined, - ); - }); + it.each([ + { + downloadPath: "/tmp/artifacts", + directoryPath: "/home/user/assets", + windowsDownloads: "\\\\wsl.localhost\\Ubuntu\\tmp\\artifacts", + windowsDirectory: "\\\\wsl.localhost\\Ubuntu\\home\\user\\assets", + }, + { + downloadPath: "/mnt/c/Users/user/My Downloads", + directoryPath: "/windows/Users/user/My Assets", + windowsDownloads: "C:\\Users\\user\\My Downloads", + windowsDirectory: "C:\\Users\\user\\My Assets", + }, + ])( + "awaits WSL path conversion for downloads and directory uploads ($directoryPath)", + async ({ downloadPath, directoryPath, windowsDownloads, windowsDirectory }) => { + const send = vi.fn(async () => ({})); + const directories: Array = []; + const relay = makeRelay( + makeTarget( + send, + (directory) => directories.push(directory), + async (path) => (path === downloadPath ? windowsDownloads : windowsDirectory), + ), + () => {}, + ); + relay.receive( + JSON.stringify({ + id: 1, + method: "Browser.setDownloadBehavior", + params: { behavior: "allowAndName", downloadPath, eventsEnabled: true }, + }), + ); + await settle(); + expect(directories).toEqual([windowsDownloads]); + expect(send).toHaveBeenCalledWith( + "Page.setDownloadBehavior", + { behavior: "default" }, + undefined, + ); + relay.receive( + JSON.stringify({ + id: 2, + method: "DOM.setFileInputFiles", + params: { objectId: "input", files: [directoryPath] }, + sessionId: "t3-preview-page", + }), + ); + await settle(); + expect(send).toHaveBeenLastCalledWith( + "DOM.setFileInputFiles", + { objectId: "input", files: [windowsDirectory] }, + undefined, + undefined, + ); + }, + ); + + it.each(["DOM.setFileInputFiles", "Browser.setDownloadBehavior"])( + "reports a failed path conversion without forwarding %s", + async (method) => { + const send = vi.fn(async () => ({})); + const setDirectory = vi.fn(); + const written: Array> = []; + const relay = makeRelay( + makeTarget(send, setDirectory, async () => { + throw new Error("WSL path conversion failed"); + }), + (raw) => written.push(JSON.parse(raw)), + ); + const session = method === "DOM.setFileInputFiles" ? { sessionId: "t3-preview-page" } : {}; + relay.receive( + JSON.stringify({ + id: 1, + method, + params: { + files: ["/mnt/c/assets"], + behavior: "allowAndName", + downloadPath: "/mnt/c/artifacts", + }, + ...session, + }), + ); + await settle(); + expect(written).toEqual([ + { + id: 1, + error: { code: -32000, message: "WSL path conversion failed" }, + ...session, + }, + ]); + expect(send).not.toHaveBeenCalled(); + expect(setDirectory).not.toHaveBeenCalled(); + }, + ); it("keeps denial scoped to the preview and restores Electron's save handling when allowed", async () => { const send = vi.fn(async () => ({})); diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index ab203b998007d..7da034a8a7d5b 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -14,11 +14,8 @@ export interface CdpRelayTarget { readonly url: () => string; readonly title: () => string; readonly userAgent: () => string; - /** - * Where this app reaches a path on the server's disk: the path itself when it - * launched the server on its own disk, a `\\wsl.localhost` path for WSL. - */ - readonly serverPath: (path: string) => string; + /** Where this app reaches a server path, resolving WSL mounts to their Windows paths. */ + readonly serverPath: (path: string) => string | Promise; /** Downloads land there, so the server's Playwright reads them where it asked. */ readonly setDownloadDirectory: (directory: string | null) => void; } @@ -128,13 +125,14 @@ export function createCdpRelayConnection( case "Browser.setDownloadBehavior": { const directory = command.params?.["downloadPath"]; const allowed = command.params?.["behavior"] !== "deny" && typeof directory === "string"; + const downloadDirectory = allowed ? await target.serverPath(directory) : null; // Browser-scoped behavior targets Electron's app session; preview paths belong to will-download. const result = await target.send( "Page.setDownloadBehavior", { behavior: command.params?.["behavior"] === "deny" ? "deny" : "default" }, undefined, ); - target.setDownloadDirectory(allowed ? target.serverPath(directory) : null); + target.setDownloadDirectory(downloadDirectory); downloadEventsEnabled = command.params?.["eventsEnabled"] === true; return result; } @@ -193,7 +191,7 @@ export function createCdpRelayConnection( } }; - const pageCommand = (command: CdpCommand): Promise => { + const pageCommand = async (command: CdpCommand): Promise => { const child = sessions.get(command.sessionId!) === "page" ? undefined : command.sessionId; const owner = child === undefined && command.sessionId !== PAGE_SESSION_ID ? command.sessionId : undefined; @@ -205,12 +203,11 @@ export function createCdpRelayConnection( if (command.method === "Emulation.setFocusEmulationEnabled") return Promise.resolve({}); let params = command.params ?? {}; if (command.method === "DOM.setFileInputFiles" && Array.isArray(params["files"])) { - params = { - ...params, - files: params["files"].map((path: unknown) => - typeof path === "string" ? target.serverPath(path) : path, - ), - }; + const files: Array = []; + for (const path of params["files"]) { + files.push(typeof path === "string" ? await target.serverPath(path) : path); + } + params = { ...params, files }; } return target.send(command.method, params, child, owner); }; diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 72280682587af..23583a54eb38b 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -88,7 +88,7 @@ export class DesktopBrowserHost extends Context.Service< /** `serverPath` maps a path on the server's disk to one this app can write, as for WSL. */ readonly handleCommandLine: ( line: string, - serverPath?: (path: string) => string, + serverPath?: (path: string) => string | Promise, ) => Effect.Effect; readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void; readonly detach: (key: DesktopBrowserTabKey) => void; @@ -303,7 +303,7 @@ export const make = Effect.gen(function* () { } }; - const relayFor = (tab: AttachedTab, serverPath: (path: string) => string) => { + const relayFor = (tab: AttachedTab, serverPath: (path: string) => string | Promise) => { if (tab.relay) return tab.relay; const { webContents, debugger: debuggee } = tab.debuggee; const send = (method: string, params: Record, sessionId?: string) => @@ -464,7 +464,10 @@ export const make = Effect.gen(function* () { emit({ type: "attached", ...key }); }; - const handleCommandLine = (line: string, serverPath: (path: string) => string = identity) => + const handleCommandLine = ( + line: string, + serverPath: (path: string) => string | Promise = identity, + ) => Effect.sync(() => { const command = decodeCommand(line); if (Option.isNone(command)) return; diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 69962f52e4f18..0dd46271a9740 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -1,9 +1,17 @@ // @effect-diagnostics nodeBuiltinImport:off - the executed suite runs the generated install script through a real POSIX shell. import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { afterAll, expect } from "vite-plus/test"; import * as NodeChildProcess from "node:child_process"; @@ -18,6 +26,7 @@ import { buildWslRuntimeProbeScript, parseResolvedPath, parseWslRuntimeRoot, + wslToWindowsPath, } from "./DesktopWslEnvironment.ts"; // The install script only fails the way this file cares about when a real shell @@ -459,3 +468,128 @@ describe("makeIsAvailable", () => { }), ); }); + +describe("wslToWindowsPath", () => { + const makeProcess = ( + stdout: Stream.Stream = Stream.empty, + exitCode: Effect.Effect = Effect.succeed( + ChildProcessSpawner.ExitCode(0), + ), + ) => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout, + stderr: Stream.empty, + all: stdout, + exitCode, + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); + + it.effect.each([ + ["/mnt/c/Users/test/assets", "C:\\Users\\test\\assets\n", "C:\\Users\\test\\assets"], + [ + "/windows/c/Users/test user's assets ", + "C:\\Users\\test user's assets \r\n", + "C:\\Users\\test user's assets ", + ], + [ + "/home/test/assets", + "\\\\wsl.localhost\\Test Distro\\home\\test\\assets\n", + "\\\\wsl.localhost\\Test Distro\\home\\test\\assets", + ], + ] as const)( + "uses the selected distro's mount resolution for %s", + ([linuxPath, output, expected]) => + Effect.gen(function* () { + let spawned: ChildProcess.Command | undefined; + const spawner = ChildProcessSpawner.make((command) => { + spawned = command; + return Effect.succeed(makeProcess(Stream.encodeText(Stream.make(output)))); + }); + const converted = yield* wslToWindowsPath("Test Distro", linuxPath).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + assert.equal(converted, expected); + assert.isDefined(spawned); + assert.equal(spawned._tag, "StandardCommand"); + if (spawned._tag !== "StandardCommand") return; + assert.equal(spawned.command, "wsl.exe"); + assert.deepEqual(spawned.args, ["-d", "Test Distro", "--", "wslpath", "-w", linuxPath]); + }), + ); + + it.effect.each([ + { output: "", exitCode: 1 }, + { output: "\n", exitCode: 0 }, + ])("refuses an unsuccessful or empty conversion (%s)", ({ output, exitCode }) => + Effect.gen(function* () { + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess( + Stream.encodeText(Stream.make(output)), + Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + ), + ), + ); + const error = yield* wslToWindowsPath("Test Distro", "/mnt/c/assets").pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.flip, + ); + assert.equal(error._tag, "DesktopWslPathConversionError"); + assert.equal(error.stage, "conversion"); + assert.equal(error.distro, "Test Distro"); + assert.equal(error.linuxPath, "/mnt/c/assets"); + }), + ); + + it.effect("reports failure to start the path converter", () => + Effect.gen(function* () { + const cause = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + cause: new Error("wsl.exe missing"), + }); + const error = yield* wslToWindowsPath("Test Distro", "/mnt/c/assets").pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.fail(cause)), + ), + Effect.flip, + ); + assert.equal(error.stage, "spawn"); + assert.equal(error.cause, cause); + }), + ); + + it.effect("closes the converter's process scope when it times out", () => + Effect.scoped( + Effect.gen(function* () { + const started = yield* Deferred.make(); + const released = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make(() => + Effect.acquireRelease( + Deferred.succeed(started, undefined).pipe( + Effect.as(makeProcess(Stream.never, Effect.never)), + ), + () => Deferred.succeed(released, undefined), + ), + ); + const fiber = yield* wslToWindowsPath("Test Distro", "/mnt/c/assets").pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.forkScoped, + ); + yield* Deferred.await(started); + yield* TestClock.adjust(Duration.seconds(10)); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + yield* Deferred.await(released); + assert.equal(error.stage, "timeout"); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); +}); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index f65fc11f59a75..98674af82f76d 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -84,6 +84,21 @@ export class DesktopWslDistroListError extends Schema.TaggedError()( + "DesktopWslPathConversionError", + { + distro: Schema.String, + linuxPath: Schema.String, + stage: Schema.Literals(["spawn", "read", "exit", "conversion", "timeout"]), + exitCode: Schema.optionalKey(Schema.Number), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + return `Could not convert the WSL path ${this.linuxPath} in ${this.distro} (${this.stage}).`; + } +} + const isDesktopWslDistroListError = Schema.is(DesktopWslDistroListError); export class DesktopWslEnvironment extends Context.Service< @@ -958,6 +973,61 @@ const windowsToWslPathImpl = ( ); }; +export const wslToWindowsPath = Effect.fn("desktop.wsl.wslToWindowsPath")(function* ( + distro: string, + linuxPath: string, +): Effect.fn.Return< + string, + DesktopWslPathConversionError, + ChildProcessSpawner.ChildProcessSpawner +> { + const converted = yield* Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const command = ChildProcess.make( + "wsl.exe", + [...buildDistroArgs(distro), "--", "wslpath", "-w", linuxPath], + { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + killSignal: "SIGTERM", + forceKillAfter: PROCESS_TERMINATE_GRACE, + }, + ); + const failure = (stage: "spawn" | "read" | "exit", cause: unknown) => + new DesktopWslPathConversionError({ distro, linuxPath, stage, cause }); + const handle = yield* spawner + .spawn(command) + .pipe(Effect.mapError((cause) => failure("spawn", cause))); + const [stdout, exitCode] = yield* Effect.all( + [ + Stream.runCollect(handle.stdout).pipe(Effect.mapError((cause) => failure("read", cause))), + handle.exitCode.pipe(Effect.mapError((cause) => failure("exit", cause))), + ], + { concurrency: 2 }, + ); + if (exitCode !== ChildProcessSpawner.ExitCode(0)) { + return yield* new DesktopWslPathConversionError({ + distro, + linuxPath, + stage: "conversion", + exitCode, + }); + } + const path = decodeUtf8(concatChunks(stdout)).replace(/\r?\n$/, ""); + if (path.length === 0) { + return yield* new DesktopWslPathConversionError({ distro, linuxPath, stage: "conversion" }); + } + return path; + }), + ).pipe(Effect.timeoutOption(WSLPATH_TIMEOUT)); + return yield* Option.match(converted, { + onNone: () => new DesktopWslPathConversionError({ distro, linuxPath, stage: "timeout" }), + onSome: Effect.succeed, + }); +}); + const IPV4_PATTERN = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; const getDistroIpImpl = ( diff --git a/apps/desktop/src/wsl/wslPathParsing.ts b/apps/desktop/src/wsl/wslPathParsing.ts index ff049043ec540..a1ce99c129d13 100644 --- a/apps/desktop/src/wsl/wslPathParsing.ts +++ b/apps/desktop/src/wsl/wslPathParsing.ts @@ -45,10 +45,6 @@ export function extractDistroFromUncPath(windowsPath: string): string | null { return DISTRO_NAME_PATTERN.test(candidate) ? candidate : null; } -export function linuxPathToWslUncPath(distro: string, linuxPath: string): string { - return `\\\\wsl.localhost\\${distro}${linuxPath.replaceAll("/", "\\")}`; -} - export function wslUncPathToLinuxPath(windowsPath: string): string | null { const match = /^\\\\(?:wsl\.localhost|wsl\$)\\([^\\]+)(?:\\(.*))?$/i.exec(windowsPath.trim()); if (!match) return null; @@ -96,7 +92,8 @@ export function resolveWslPickFolderDefaultPath( return homePath; } - const toUncPath = (linuxPath: string) => linuxPathToWslUncPath(distroName, linuxPath); + const toUncPath = (linuxPath: string) => + `\\\\wsl.localhost\\${distroName}${linuxPath.replaceAll("/", "\\")}`; const normalizedUserHome = userHome && userHome.startsWith("/") ? userHome : null; From 1840dc5faaf3b58f569a99e3225831ed8241ccfd Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 5 Nov 2026 04:31:51 +0000 Subject: [PATCH 8/9] perf(desktop): convert WSL upload paths in parallel --- apps/desktop/src/preview/CdpRelay.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 7da034a8a7d5b..df77a9e133bd5 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -203,10 +203,11 @@ export function createCdpRelayConnection( if (command.method === "Emulation.setFocusEmulationEnabled") return Promise.resolve({}); let params = command.params ?? {}; if (command.method === "DOM.setFileInputFiles" && Array.isArray(params["files"])) { - const files: Array = []; - for (const path of params["files"]) { - files.push(typeof path === "string" ? await target.serverPath(path) : path); - } + const files = await Promise.all( + params["files"].map((path: unknown) => + typeof path === "string" ? target.serverPath(path) : path, + ), + ); params = { ...params, files }; } return target.send(command.method, params, child, owner); From fba13deece8ae46707e9b4432778061226d2be0b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 5 Nov 2026 04:37:40 +0000 Subject: [PATCH 9/9] fix(desktop): use SecureRandom for the WSL browser socket secret after syncing main --- apps/desktop/src/backend/DesktopBackendConfiguration.test.ts | 2 +- apps/desktop/src/backend/DesktopBackendConfiguration.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index ba903a77dbb50..3268a8db46a97 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -848,7 +848,7 @@ describe("DesktopBackendConfiguration", () => { ), ), ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect( diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 18daed184b194..3b193bb89fa91 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -770,7 +770,7 @@ export const make = Effect.gen(function* () { const shared = yield* sharedInputs; yield* wslEnvironment.preWarm(persistedSettings.wslDistro); // Only the primary's tabs render natively, so a WSL backend beside Windows gets no channel. - const desktopBrowserSocketSecret = Hex.encode(yield* crypto.randomBytes(32)); + const desktopBrowserSocketSecret = Hex.encode(yield* random.randomBytes(32)); return yield* resolveWslStartConfig({ ...shared, port: backendExposure.port,