From 3a2d768ca6abd309105085b0695828f9ccc92ada Mon Sep 17 00:00:00 2001 From: "Brian J. Fox" Date: Mon, 2 Nov 2026 03:19:12 +0000 Subject: [PATCH] fix(hub): reach the dev app across the sandbox's network namespaces on Linux On Linux every sandboxed command runs in a network namespace of its own, so the dev app started inside the sandbox listened on a loopback the hub could not reach. The live-app host polled its URL forever and reported "Starting the app", and the coder's browser and page watcher, each in a wrap of their own, could not reach it either. Relay the app through a Unix socket in the sockets folder the wraps and the hub share: a socat inside the app's wrap serves its client port on the socket, a socat in this process serves the socket on a port picked once, and every other wrap gets a socat serving the socket on that same port, so one URL holds for the hub, the browser, the page watcher and the hub's own commands. The port survives restarts of the app, which the browser outlives. On macOS, where the wraps share the host's loopback, nothing changes. Closes #71 --- apps/hub/src/builds/posix-sandbox.ts | 103 ++++++++++++++++++++++++--- apps/hub/src/builds/sidecar-tools.ts | 1 + apps/hub/src/live-app/host.ts | 2 +- 3 files changed, 96 insertions(+), 10 deletions(-) diff --git a/apps/hub/src/builds/posix-sandbox.ts b/apps/hub/src/builds/posix-sandbox.ts index ceeb5984..a83493ba 100644 --- a/apps/hub/src/builds/posix-sandbox.ts +++ b/apps/hub/src/builds/posix-sandbox.ts @@ -70,9 +70,83 @@ const PageError = type({ detail: "string" }); // kqueue and needs neither. const MACOS_WATCH_POLLING = { WATCH_POLL_MS: "300" }; const SANDBOX_RUNTIME_VERSION = "0.0.81"; +// Where the sandbox allows Unix sockets: for the browser, and on Linux for the app's relays. +const needsSockets = (options: { browser?: boolean; app?: boolean }) => + options.browser === true || + (options.app === true && process.platform === "linux"); // srt's manager is process-global. A second job must use another sidecar // process; it cannot silently replace the policy protecting a running job. let leased = false; + +/** + * How the dev app is reached from outside its wrap. On macOS every wrap shares the host's loopback, + * so the app's own port serves. On Linux every wrap has a loopback of its own, so the app is reached + * through a Unix socket in the sockets folder: a relay inside the app's wrap serves the socket, a + * relay in this process serves the socket on a port of its own, and a relay inside every other wrap + * serves it on that same port, so one URL holds for the hub, the browser and the page watcher + * alike. The port is picked once and kept across restarts of the app, which the browser outlives. + */ +type AppRelay = { + /** Picks the relayed port, once. */ + reserve(): Promise; + /** The app's URL as every party reaches it, given the port its client listens on. */ + url(clientPort: number): string; + /** The prefix for the app's own wrap: serves its client port on the socket. */ + serve(clientPort: number): string; + /** A command for any other wrap, prefixed to serve the socket on the relayed port there. */ + into(command: string): string; + /** Serves the socket on the relayed port in this process, for the hub. */ + start(): Promise; + stop(): Promise; +}; + +function createAppRelay(sockets: string | undefined): AppRelay { + const socat = process.platform === "linux" ? Bun.which("socat") : null; + if (sockets === undefined || socat === null) + return { + reserve: async () => undefined, + url: (clientPort) => `http://127.0.0.1:${String(clientPort)}`, + serve: () => "", + into: (command) => command, + start: async () => undefined, + stop: async () => undefined, + }; + const socket = join(sockets, "app.sock"); + const quiet = ">/dev/null 2>&1 & "; + let port: number | undefined; + let child: Bun.Subprocess | undefined; + const listen = () => + `TCP-LISTEN:${String(port)},bind=127.0.0.1,fork,reuseaddr`; + const stop = async () => { + if (!child) return; + const current = child; + child = undefined; + current.kill("SIGKILL"); + await current.exited; + }; + return { + reserve: async () => { + port ??= await freePort(); + }, + url: (clientPort) => `http://127.0.0.1:${String(port ?? clientPort)}`, + serve: (clientPort) => + `${quote(socat)} UNIX-LISTEN:${quote(socket)},fork,reuseaddr,unlink-early TCP:127.0.0.1:${String(clientPort)} ${quiet}`, + into: (command) => + port === undefined + ? command + : `${quote(socat)} ${listen()} UNIX-CONNECT:${quote(socket)} ${quiet}${command}`, + start: async () => { + await stop(); + if (port === undefined) return; + child = Bun.spawn([socat, listen(), `UNIX-CONNECT:${socket}`], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + }, + stop, + }; +} // What bun, sh, curl, unzip and Chrome's headless shell load to run; nothing here holds user data. const SYSTEM_READ_PATHS = process.platform === "darwin" @@ -166,6 +240,8 @@ export async function createPosixBuildExecutor( pageWatcher?: string; // Whether the agent drives its app in Playwright's browser, which browser() starts. browser?: boolean; + // Whether the workspace's dev app runs here, which app() starts. + app?: boolean; // The shared Chrome install, which the sandbox reads through $HOME/browsers but never writes. browsers?: string | undefined; } = {}, @@ -269,8 +345,9 @@ export async function createPosixBuildExecutor( if (options.browsers) await symlink(options.browsers, join(home, "browsers")); // Playwright's MCP server always serves its browser on a Unix socket, which the sandbox allows - // only here. Under /tmp, as a socket's path fits in 104 bytes on macOS. - if (options.browser) + // only here. Under /tmp, as a socket's path fits in 104 bytes on macOS. On Linux the dev app is + // reached through a socket here too, since every wrap there has a loopback of its own. + if (needsSockets(options)) sockets = await realpath(await mkdtemp("/tmp/sb-browser-")); stage = "policy"; const policy = SandboxRuntimeConfigSchema.parse({ @@ -397,6 +474,7 @@ export async function createPosixBuildExecutor( TMPDIR: home, NO_COLOR: "1", }; + const appRelay = createAppRelay(sockets); let browser: { process: Bun.Subprocess; url: string } | undefined; let browserStarting: Promise | undefined; // A turn's tool calls run in parallel; two app_status calls must not each start a server. @@ -520,7 +598,7 @@ export async function createPosixBuildExecutor( devApp = undefined; kill(child); if (page) kill(page); - await Promise.all([child.exited, page?.exited]); + await Promise.all([child.exited, page?.exited, appRelay.stop()]); }; const startApp = (): Promise => { if (disposed) @@ -538,8 +616,9 @@ export async function createPosixBuildExecutor( freePort(), freePort(), ]); + await appRelay.reserve(); const command = await SandboxManager.wrapWithSandbox( - `exec ${quote(executable)} run dev`, + `${appRelay.serve(clientPort)}exec ${quote(executable)} run dev`, "/bin/sh", ); const child = Bun.spawn(["/bin/sh", "-c", command], { @@ -612,8 +691,10 @@ export async function createPosixBuildExecutor( } }; void Promise.all([collect(child.stdout), collect(child.stderr)]); - // Vite serves the client on CLIENT_PORT and proxies /api to the server on PORT. - const url = `http://127.0.0.1:${clientPort}`; + await appRelay.start(); + // Vite serves the client on CLIENT_PORT and proxies /api to the server on PORT; where the + // wraps have loopbacks of their own, the relays serve it on one port everywhere. + const url = appRelay.url(clientPort); const view = (): DevApp => ({ url, running: child.exitCode === null, @@ -627,7 +708,9 @@ export async function createPosixBuildExecutor( "/bin/sh", "-c", await SandboxManager.wrapWithSandbox( - `exec ${[executable, "--no-env-file", pageWatcher, url].map(quote).join(" ")}`, + appRelay.into( + `exec ${[executable, "--no-env-file", pageWatcher, url].map(quote).join(" ")}`, + ), "/bin/sh", ), ], @@ -721,7 +804,7 @@ export async function createPosixBuildExecutor( "/bin/sh", "-c", await SandboxManager.wrapWithSandbox( - `exec ${argv.map(quote).join(" ")}`, + appRelay.into(`exec ${argv.map(quote).join(" ")}`), "/bin/sh", ), ], @@ -797,7 +880,9 @@ export async function createPosixBuildExecutor( "/bin/sh", "-c", await SandboxManager.wrapWithSandbox( - `exec ${[join(shimDirectory!, name), ...args].map(quote).join(" ")}`, + appRelay.into( + `exec ${[join(shimDirectory!, name), ...args].map(quote).join(" ")}`, + ), "/bin/sh", ), ], diff --git a/apps/hub/src/builds/sidecar-tools.ts b/apps/hub/src/builds/sidecar-tools.ts index fef7123c..21f923f4 100644 --- a/apps/hub/src/builds/sidecar-tools.ts +++ b/apps/hub/src/builds/sidecar-tools.ts @@ -257,6 +257,7 @@ export function createSidecarCodingTools(policy: { openNetwork: scope.openNetwork, commands: { "check-app": CHECK_APP }, browser: policy.browser !== undefined, + app: policy.workspace === "app", browsers: await sharedBrowser(), ...(policy.workspace === "app" ? { diff --git a/apps/hub/src/live-app/host.ts b/apps/hub/src/live-app/host.ts index 3626a00f..71510237 100644 --- a/apps/hub/src/live-app/host.ts +++ b/apps/hub/src/live-app/host.ts @@ -63,7 +63,7 @@ async function createExecutor() { }, layout, // The person's running app installs its packages and reaches the services it was built against. - { openNetwork: true }, + { openNetwork: true, app: true }, ); } catch (error) { report({ state: "failed", error: message(error), log: [] });