From f19cc242e4674e4e28f9e385d7c41d870f836a40 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/2] 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 | 9 +++- apps/web/src/browser/previewRuntime.ts | 16 ++++++- apps/web/src/state/entities.ts | 8 ++++ docs/user/remote-access.md | 4 +- packages/contracts/src/environment.ts | 5 +++ 8 files changed, 115 insertions(+), 6 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 fbb5970560bc6..d5826e2613ade 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -266,6 +266,11 @@ export const make = Effect.gen(function* () { : {}), ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}), serverBrowser: 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 8818b377f5e3b..7681f78e559d4 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -13,6 +13,7 @@ import { isElectron } from "~/env"; import { useClientSettingsHydrated } from "~/hooks/useSettings"; import { useDesktopHostedPreviewSessions } from "~/components/preview/usePreviewSession"; import { useActivePreviewSessions } from "~/previewStateStore"; +import { useEnvironmentHasDesktopBrowser } from "~/state/entities"; import { previewEnvironment } from "~/state/preview"; import { useEnvironmentScope } from "~/state/session"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -29,6 +30,7 @@ export function ElectronBrowserHost() { const previewByThreadKey = useActivePreviewSessions(); const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); useDesktopHostedPreviewSessions(primaryEnvironmentId); + const primaryHasDesktopBrowser = useEnvironmentHasDesktopBrowser(primaryEnvironmentId); const activeSessions = Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { const threadRef = parseScopedThreadKey(threadKey); return threadRef @@ -36,7 +38,12 @@ export function ElectronBrowserHost() { .filter( (snapshot) => snapshot.runtime !== "server" || - rendersServerTabNatively(threadRef.environmentId, primaryEnvironmentId, snapshot), + rendersServerTabNatively( + threadRef.environmentId, + primaryEnvironmentId, + primaryHasDesktopBrowser, + snapshot, + ), ) .map((snapshot) => ({ threadRef, diff --git a/apps/web/src/browser/previewRuntime.ts b/apps/web/src/browser/previewRuntime.ts index 3d8e1d0a1f43d..87c48c6d2b9b8 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,16 +42,20 @@ 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 ( isElectron && snapshot?.runtime === "server" && primaryEnvironmentId !== null && - environmentId === primaryEnvironmentId + environmentId === primaryEnvironmentId && + primaryHasDesktopBrowser ); } @@ -58,5 +63,12 @@ export function useRendersServerTabNatively( environmentId: EnvironmentId, snapshot: Pick | null | undefined, ): boolean { - return rendersServerTabNatively(environmentId, useAtomValue(primaryEnvironmentIdAtom), snapshot); + const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); + const primaryHasDesktopBrowser = useEnvironmentHasDesktopBrowser(primaryEnvironmentId); + return rendersServerTabNatively( + environmentId, + primaryEnvironmentId, + primaryHasDesktopBrowser, + snapshot, + ); } diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index e88422aa54e1a..749147950d4e8 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -308,6 +308,14 @@ 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 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 493a580d14c5a..5dd40e29cae0d 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -194,8 +194,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 18d09c304a83e..35c1b534e7577 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -152,6 +152,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ "server"`) and streams them over `/api/preview-stream`. Clients without a local browser runtime open server tabs here. */ serverBrowser: 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 462eb40dd19082a86d6243600717a3b31cef34c7 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/2] 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 | 21 +- .../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 | 214 ++++++++++++++---- apps/server/src/preview/ServerBrowser.test.ts | 3 + apps/server/src/server.ts | 3 +- .../ElectronBrowserHost.browser.test.tsx | 2 +- apps/web/src/browser/previewRuntime.ts | 2 +- docs/user/remote-access.md | 4 +- packages/contracts/src/desktopBootstrap.ts | 4 + packages/contracts/src/desktopBrowser.ts | 8 + packages/contracts/src/environment.ts | 5 +- 21 files changed, 537 insertions(+), 81 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 8d70ab4c4791f..0cf9965b1cc29 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -830,6 +830,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 dadbe8acc743b..6594569dce8e7 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -568,6 +568,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, @@ -602,6 +603,9 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl // valid number in this slot. tailscaleServeEnabled: false, tailscaleServePort: 443, + ...(input.desktopBrowserSocketSecret === undefined + ? {} + : { desktopBrowserSocketSecret: input.desktopBrowserSocketSecret }), ...buildObservabilityFragment(input.observabilitySettings), }; @@ -799,10 +803,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 b4057f4d0194b..83c0ccd2816b7 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); @@ -42,6 +47,8 @@ const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(5); const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5); const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; +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"); @@ -188,7 +195,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; @@ -374,6 +384,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), @@ -565,7 +633,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 2d17a28744545..2f022c05f28d5 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, }); @@ -190,4 +192,35 @@ 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, + ); + }); }); diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 581b692882b30..325420b1450d1 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -15,9 +15,11 @@ export interface CdpRelayTarget { readonly title: () => string; readonly userAgent: () => string; /** - * The desktop app and the server it launched share a disk, so the server's - * Playwright reads the file where it asked for it. + * 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; } @@ -82,11 +84,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 7028d16b4be2d..c2b40cf3dbeef 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -11,6 +11,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"; @@ -61,7 +62,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; @@ -122,7 +127,7 @@ export const make = Effect.gen(function* () { else tab.waitingScreenshots.push(start); }); - 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) => @@ -142,6 +147,7 @@ export const make = Effect.gen(function* () { url: () => webContents.getURL(), title: () => webContents.getTitle(), userAgent: () => webContents.getUserAgent(), + serverPath, setDownloadDirectory: (directory) => { tab.downloadDirectory = directory; }, @@ -213,7 +219,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; @@ -229,7 +235,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 e4f7aab394bf8..78b3dc7744202 100644 --- a/apps/desktop/src/wsl/wslPathParsing.ts +++ b/apps/desktop/src/wsl/wslPathParsing.ts @@ -47,6 +47,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; @@ -94,8 +98,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 0ac80602221b7..352288ecb4094 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -369,6 +369,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( @@ -488,6 +489,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 c5178547b43d1..9444289f483a8 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -87,6 +87,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; @@ -102,6 +103,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 d5826e2613ade..6ab23214aaba1 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -266,11 +266,7 @@ export const make = Effect.gen(function* () { : {}), ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}), serverBrowser: 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 0d1db856c1e6b..c0b7dcc145337 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.ts @@ -1,16 +1,21 @@ -// @effect-diagnostics nodeBuiltinImport:off - Bridges Playwright's WebSocket transport to the desktop's 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 the bootstrap file descriptors to the desktop's relay, which owns the - * tab's `webContents.debugger`. + * 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"; @@ -24,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"; @@ -31,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; @@ -40,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, { @@ -57,6 +80,12 @@ export class DesktopBrowserChannel extends Context.Service< key: DesktopTabKey, pointer: { readonly phase: "move" | "click"; readonly x: number; readonly y: number }, ) => Effect.Effect; + /** Whether the desktop reaches this channel over `DESKTOP_BROWSER_SOCKET_PATH`. */ + readonly acceptsSocket: boolean; + /** Whether an upgrade request presenting `secret` may carry the channel. */ + readonly authorizesSocket: (secret: string | undefined) => boolean; + /** Serves one desktop connection until it closes; a newer one replaces it. */ + readonly serveSocket: (socket: Socket.Socket) => Effect.Effect; } >()("t3/preview/DesktopBrowserChannel") {} @@ -64,12 +93,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.succeed(false), @@ -78,56 +108,120 @@ const make = Effect.gen(function* () { isAttached: () => Effect.succeed(false), endpoint: () => Effect.die("No desktop app is attached to this server."), pointer: () => 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 }), ), ), ); - // 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) => { - 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) { @@ -165,17 +259,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 }, ), ), @@ -216,7 +304,35 @@ const make = Effect.gen(function* () { isAttached: (key) => Effect.sync(() => attachedTabs.has(keyOf(key))), endpoint, pointer: (key, pointer) => 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 ac4a785f207f3..eb2b3f3dba618 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -291,6 +291,9 @@ const dependencies = Layer.mergeAll( Effect.sync(() => releasedDesktopTabs.push(key.tabId)), ), pointer: () => Effect.void, + acceptsSocket: false, + authorizesSocket: () => false, + serveSocket: () => Effect.void, }), ).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-server-browser-" })), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 403b30af1c719..05643db415789 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -708,6 +708,7 @@ const layerMakeRoutes = Layer.mergeAll( ServerHttp.layerAttachmentUploadRoute, DeviceHubProxy.layer, ServerBrowserStream.routeLayer, + DesktopBrowserChannel.socketRouteLayer, ServerHttp.layerStaticAndDevRoute, Ws.layer, ), @@ -722,7 +723,7 @@ const layerMakeRoutes = Layer.mergeAll( // Both transports consume the same service instance, so caches single-flight across clients // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. Layer.provide(layerPullRequestService), - Layer.provide(ServerBrowser.layer.pipe(Layer.provide(DesktopBrowserChannel.layer))), + Layer.provide(ServerBrowser.layer.pipe(Layer.provideMerge(DesktopBrowserChannel.layer))), Layer.provide(PreviewBrowser.layer), Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ServerSelfUpdate.layer.pipe(Layer.provide(layerDesktopAppUpdate))), 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 87c48c6d2b9b8..25353ecf16741 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/docs/user/remote-access.md b/docs/user/remote-access.md index 5dd40e29cae0d..493a580d14c5a 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -194,8 +194,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 bc0a723b57b02..e577b4c0d2888 100644 --- a/packages/contracts/src/desktopBootstrap.ts +++ b/packages/contracts/src/desktopBootstrap.ts @@ -33,6 +33,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 c70b374910079..918fa8ce905e3 100644 --- a/packages/contracts/src/desktopBrowser.ts +++ b/packages/contracts/src/desktopBrowser.ts @@ -27,3 +27,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 35c1b534e7577..b199ed795a6a4 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -153,9 +153,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ without a local browser runtime open server tabs here. */ serverBrowser: 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;