diff --git a/apps/server/src/preview/PreviewBrowser.test.ts b/apps/server/src/preview/PreviewBrowser.test.ts index db714e86d1ec0..3305ad06bd318 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,98 @@ 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 }]), + ...(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; + 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 }); + yield* fs.writeFileString(path.join(directory, "owner-denied"), "", { mode: 0o655 }); + 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..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"; @@ -126,7 +127,7 @@ export class PreviewBrowserUnsupportedError extends Schema.TaggedError NodeFSP.access(configuredPath, NodeFSP.constants.X_OK), + catch: () => invalidPath, + }); + return configuredPath; + }).pipe(Effect.withSpan("PreviewBrowser.executable")); + return PreviewBrowser.of({ + executable, + installed: executable.pipe( + Effect.asSome, + Effect.orElseSucceed(() => 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..aade28ac92582 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,7 +278,23 @@ the background service, and restart it. Script preview URLs still appear. ### Browser host setup -macOS, Windows, and Linux desktops run the browser as is. Some Linux hosts need +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. + +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