From e7d7e053a0f8a42e37929d833c7adfacc12eb4ea Mon Sep 17 00:00:00 2001 From: SchwarzschildX Date: Fri, 6 Nov 2026 01:31:58 +0100 Subject: [PATCH 1/3] fix(server): allow a managed browser executable path --- .../server/src/preview/PreviewBrowser.test.ts | 110 ++++++++++++++++-- apps/server/src/preview/PreviewBrowser.ts | 31 ++++- docs/user/html-renders.md | 2 +- docs/user/remote-access.md | 10 +- 4 files changed, 141 insertions(+), 12 deletions(-) diff --git a/apps/server/src/preview/PreviewBrowser.test.ts b/apps/server/src/preview/PreviewBrowser.test.ts index db714e86d1ec0..8487bf43a3ea1 100644 --- a/apps/server/src/preview/PreviewBrowser.test.ts +++ b/apps/server/src/preview/PreviewBrowser.test.ts @@ -81,6 +81,7 @@ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* ( readonly body?: Stream.Stream; readonly wait?: Duration.Input; readonly unsupported?: boolean; + readonly environment?: NodeJS.ProcessEnv; } = {}, ) { const fs = yield* FileSystem.FileSystem; @@ -93,19 +94,22 @@ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* ( const requests: Array = []; const browser = yield* PreviewBrowser.makePreviewBrowser({ baseDir, - release: options.unsupported - ? null + ...(options.unsupported + ? {} : { - version: options.version ?? "1.2.3", - platform: "fixture", - url: "https://storage.googleapis.com/chrome-headless-shell-fixture.zip", - bytes: archive.byteLength, - sha256, - }, + release: { + version: options.version ?? "1.2.3", + platform: "fixture", + url: "https://storage.googleapis.com/chrome-headless-shell-fixture.zip", + bytes: archive.byteLength, + sha256, + }, + }), ...(options.wait === undefined ? {} : { wait: options.wait }), }).pipe( - Effect.provideService(HostProcess.Platform, hostPlatform), + Effect.provideService(HostProcess.Platform, options.unsupported ? "freebsd" : hostPlatform), Effect.provideService(HostProcess.Architecture, "x64"), + Effect.provideService(HostProcess.Environment, options.environment ?? {}), Effect.provideService( HttpClient.HttpClient, HttpClient.make((request) => @@ -124,6 +128,94 @@ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* ( }); it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("PreviewBrowser", (it) => { + it.effect.each([false, true])( + "uses the configured executable without installing (unsupported platform: %s)", + (unsupported) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + const executable = path.join(directory, "custom chromium"); + const target = path.join(directory, "chromium"); + yield* fs.writeFileString(target, "#!/bin/sh\n", { mode: 0o755 }); + yield* hostPlatform === "win32" + ? fs.copyFile(target, executable) + : fs.symlink(target, executable); + const { browser, baseDir, requests } = yield* makeHarness({ + unsupported, + environment: { T3CODE_BROWSER_EXECUTABLE_PATH: executable }, + }); + + expect(yield* browser.installed).toEqual(Option.some(executable)); + expect(yield* browser.executable).toBe(executable); + expect(requests).toEqual([]); + expect(yield* fs.exists(path.join(baseDir, "tools"))).toBe(false); + }), + ); + + it.effect.each([ + { name: "an empty path", value: "", absolute: false }, + { name: "a whitespace path", value: " ", absolute: false }, + { name: "a relative path", value: "./chromium", absolute: false }, + { name: "a missing file", value: "missing", absolute: true }, + { name: "a directory", value: "directory", absolute: true }, + ...(hostPlatform === "win32" + ? [] + : [{ name: "a non-executable file", value: "not-executable", absolute: true }]), + ])("rejects $name without falling back to a download", (testCase) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + yield* fs.makeDirectory(path.join(directory, "directory")); + yield* fs.writeFileString(path.join(directory, "not-executable"), "", { mode: 0o644 }); + const { browser, baseDir, requests } = yield* makeHarness({ + environment: { + T3CODE_BROWSER_EXECUTABLE_PATH: testCase.absolute + ? path.join(directory, testCase.value) + : testCase.value, + }, + }); + + expect(yield* browser.installed).toEqual(Option.none()); + const error = yield* browser.executable.pipe(Effect.flip); + expect(error._tag).toBe("PreviewBrowserInstallError"); + expect(error.message).toContain("T3CODE_BROWSER_EXECUTABLE_PATH"); + expect(yield* browser.installed).toEqual(Option.none()); + expect(requests).toEqual([]); + expect(yield* fs.exists(path.join(baseDir, "tools"))).toBe(false); + }), + ); + + it.effect("keeps the unsupported-platform error when the override is unset", () => + Effect.gen(function* () { + const { browser, fs, path, baseDir, requests } = yield* makeHarness({ unsupported: true }); + + expect(yield* browser.installed).toEqual(Option.none()); + expect(yield* browser.executable.pipe(Effect.flip)).toMatchObject({ + _tag: "PreviewBrowserUnsupportedError", + platform: "freebsd", + arch: "x64", + }); + expect(requests).toEqual([]); + expect(yield* fs.exists(path.join(baseDir, "tools"))).toBe(false); + }), + ); + + it.effect("reuses a managed executable when the override is unset", () => + Effect.gen(function* () { + const { browser, fs, path, installRoot, requests } = yield* makeHarness(); + const directory = path.join(installRoot, "1.2.3"); + const executable = path.join(directory, executableName); + yield* fs.makeDirectory(directory, { recursive: true }); + yield* fs.writeFileString(executable, "#!/bin/sh\n", { mode: 0o755 }); + + expect(yield* browser.installed).toEqual(Option.some(executable)); + expect(yield* browser.executable).toBe(executable); + expect(requests).toEqual([]); + }), + ); + it.effect.each(["0.8.0", "1.2.3"])("retains another version when installing %s", (current) => Effect.gen(function* () { const { browser, fs, path, installRoot, requests } = yield* makeHarness({ version: current }); diff --git a/apps/server/src/preview/PreviewBrowser.ts b/apps/server/src/preview/PreviewBrowser.ts index a93c06e8003c5..d64c230af7697 100644 --- a/apps/server/src/preview/PreviewBrowser.ts +++ b/apps/server/src/preview/PreviewBrowser.ts @@ -126,7 +126,7 @@ export class PreviewBrowserUnsupportedError extends Schema.TaggedError Option.none()), + ), + }); + } const release = options.release === undefined ? previewBrowserRelease(platform, arch) : options.release; const wait = options.wait ?? "45 seconds"; diff --git a/docs/user/html-renders.md b/docs/user/html-renders.md index ee0b1111a653f..aa130f6ca0717 100644 --- a/docs/user/html-renders.md +++ b/docs/user/html-renders.md @@ -6,7 +6,7 @@ Pages use your current theme, including custom themes, and follow light and dark Agents can place local images in a page by file path. T3 Code embeds them when the page is published, so the page keeps working after the original files move or are deleted. Deleting the thread deletes its pages. -Before publishing, agents check their work with screenshots from a small headless browser that T3 Code keeps for itself; it never uses a browser you installed. The first preview on a machine downloads it once (about 120 MB) into T3 Code's data folder, so that preview can take a minute. Browser tabs on a remote environment use the same browser, so whichever comes first downloads it. Some Linux hosts need [setup](remote-access.md#browser-host-setup) before it can start. Pages publish without it. +Before publishing, agents check their work with screenshots from a small headless browser that T3 Code manages by default. The first preview on a machine downloads it once (about 120 MB) into T3 Code's data folder, so that preview can take a minute. Browser tabs on a remote environment use the same browser, so whichever comes first downloads it. Some Linux hosts need [setup](remote-access.md#browser-host-setup) before it can start; you can also [configure a system-managed Chromium](remote-access.md#browser-host-setup). Pages publish without it. ## MCP apps diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 649dfbca63780..2d7a7ab0f61bd 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -244,7 +244,7 @@ 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 +By default, the first tab downloads a headless Chrome, about 120 MB, into the T3 home. It is the same browser [HTML renders](html-renders.md) use, so a host downloads it only once. Some Linux hosts need [setup](#browser-host-setup) before it can start. @@ -278,6 +278,14 @@ the background service, and restart it. Script preview URLs still appear. ### Browser host setup +To use a system-managed Chromium instead, such as on NixOS, set +`T3CODE_BROWSER_EXECUTABLE_PATH` to its absolute executable path in the environment +that starts the T3 Code server, then restart it. For the background service, set it in +[`service.env`](./background-service.md#configure-the-service-environment). +This also applies to HTML previews. T3 Code does not download or update that browser; +you maintain its Playwright compatibility, libraries, and security updates. An empty, +relative, missing, or non-executable path fails without falling back to a download. + macOS, Windows, and Linux desktops run the browser as is. Some Linux hosts need one-time setup: Ubuntu 23.10 and later block the sandbox the browser runs in, and minimal images and containers lack libraries it loads. When that happens, From f5466dc5a417150d937d32069d518fed1d889b69 Mon Sep 17 00:00:00 2001 From: SchwarzschildX Date: Fri, 6 Nov 2026 01:39:03 +0100 Subject: [PATCH 2/3] fix(server): validate browser access for the running user --- apps/server/src/preview/PreviewBrowser.test.ts | 4 ++++ apps/server/src/preview/PreviewBrowser.ts | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/server/src/preview/PreviewBrowser.test.ts b/apps/server/src/preview/PreviewBrowser.test.ts index 8487bf43a3ea1..3305ad06bd318 100644 --- a/apps/server/src/preview/PreviewBrowser.test.ts +++ b/apps/server/src/preview/PreviewBrowser.test.ts @@ -162,6 +162,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("PreviewBrowser", ...(hostPlatform === "win32" ? [] : [{ name: "a non-executable file", value: "not-executable", absolute: true }]), + ...(hostPlatform === "win32" || HostProcess.UserId.defaultValue() === 0 + ? [] + : [{ name: "execution denied to its owner", value: "owner-denied", absolute: true }]), ])("rejects $name without falling back to a download", (testCase) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -169,6 +172,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("PreviewBrowser", const directory = yield* fs.makeTempDirectoryScoped(); yield* fs.makeDirectory(path.join(directory, "directory")); yield* fs.writeFileString(path.join(directory, "not-executable"), "", { mode: 0o644 }); + yield* fs.writeFileString(path.join(directory, "owner-denied"), "", { mode: 0o655 }); const { browser, baseDir, requests } = yield* makeHarness({ environment: { T3CODE_BROWSER_EXECUTABLE_PATH: testCase.absolute diff --git a/apps/server/src/preview/PreviewBrowser.ts b/apps/server/src/preview/PreviewBrowser.ts index d64c230af7697..f541eb8ccb463 100644 --- a/apps/server/src/preview/PreviewBrowser.ts +++ b/apps/server/src/preview/PreviewBrowser.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off - Effect has no incremental digest. +// @effect-diagnostics nodeBuiltinImport:off - Effect has no incremental digest or execute-access check. import * as EffectNodeStream from "@effect/platform-node/NodeStream"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Cause from "effect/Cause"; @@ -17,6 +17,7 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; import * as ServerConfig from "../config.ts"; import { openZipArchive } from "../zipArchive.ts"; @@ -186,6 +187,10 @@ export const makePreviewBrowser = Effect.fn("PreviewBrowser.make")(function* ( if (info.type !== "File" || (platform !== "win32" && (info.mode & 0o111) === 0)) { return yield* invalidPath; } + yield* Effect.tryPromise({ + try: () => NodeFSP.access(configuredPath, NodeFSP.constants.X_OK), + catch: () => invalidPath, + }); return configuredPath; }).pipe(Effect.withSpan("PreviewBrowser.executable")); return PreviewBrowser.of({ From 596404f45923498f721532668bd0ea367f5bc1a0 Mon Sep 17 00:00:00 2001 From: SchwarzschildX Date: Fri, 6 Nov 2026 01:48:34 +0100 Subject: [PATCH 3/3] docs(browser): clarify sandbox setup for configured executables --- docs/user/remote-access.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 2d7a7ab0f61bd..aade28ac92582 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -286,7 +286,15 @@ This also applies to HTML previews. T3 Code does not download or update that bro you maintain its Playwright compatibility, libraries, and security updates. An empty, relative, missing, or non-executable path fails without falling back to a download. -macOS, Windows, and Linux desktops run the browser as is. Some Linux hosts need +The configured browser also needs its own working sandbox. On Linux hosts that +restrict user namespaces, its package must supply a usable sandbox helper or an +AppArmor profile permitting user namespaces for that executable's actual path. +See [Chromium's sandbox guidance](https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md). +`t3 browser setup` configures only T3's managed browser path; its success message +and the managed-profile startup check do not establish that a configured browser +can launch. Follow the configured browser package's sandbox setup instructions. + +For T3's default managed browser, macOS, Windows, and Linux desktops run it as is. Some Linux hosts need one-time setup: Ubuntu 23.10 and later block the sandbox the browser runs in, and minimal images and containers lack libraries it loads. When that happens, the server says so at startup, and browser tabs and HTML previews show the