Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 105 additions & 9 deletions apps/server/src/preview/PreviewBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* (
readonly body?: Stream.Stream<Uint8Array>;
readonly wait?: Duration.Input;
readonly unsupported?: boolean;
readonly environment?: NodeJS.ProcessEnv;
} = {},
) {
const fs = yield* FileSystem.FileSystem;
Expand All @@ -93,19 +94,22 @@ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* (
const requests: Array<string> = [];
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) =>
Expand All @@ -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 });
Expand Down
38 changes: 36 additions & 2 deletions apps/server/src/preview/PreviewBrowser.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -126,7 +127,7 @@ export class PreviewBrowserUnsupportedError extends Schema.TaggedError<PreviewBr
export class PreviewBrowser extends Context.Service<
PreviewBrowser,
{
/** Starts installation on first call; returns progress after a bounded wait while installation continues. */
/** Uses the configured executable, or starts a managed install with a bounded wait for progress. */
readonly executable: Effect.Effect<
string,
PreviewBrowserInstallError | PreviewBrowserInstallingError | PreviewBrowserUnsupportedError
Expand Down Expand Up @@ -167,6 +168,39 @@ export const makePreviewBrowser = Effect.fn("PreviewBrowser.make")(function* (
const serviceScope = yield* Effect.scope;
const platform = yield* HostProcess.Platform;
const arch = yield* HostProcess.Architecture;
const configuredPath = (yield* HostProcess.Environment).T3CODE_BROWSER_EXECUTABLE_PATH;
if (configuredPath !== undefined) {
const invalidPath = new PreviewBrowserInstallError({
detail: "T3CODE_BROWSER_EXECUTABLE_PATH must be an absolute path to an executable file.",
});
const executable = Effect.gen(function* () {
if (!path.isAbsolute(configuredPath)) return yield* invalidPath;
const info = yield* fs
.stat(configuredPath)
.pipe(
Effect.mapError(
wrapFailure(
"Could not read T3CODE_BROWSER_EXECUTABLE_PATH. Check the file and permissions.",
),
),
);
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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}).pipe(Effect.withSpan("PreviewBrowser.executable"));
return PreviewBrowser.of({
executable,
installed: executable.pipe(
Effect.asSome,
Effect.orElseSucceed(() => Option.none<string>()),
),
});
}
const release =
options.release === undefined ? previewBrowserRelease(platform, arch) : options.release;
const wait = options.wait ?? "45 seconds";
Expand Down
2 changes: 1 addition & 1 deletion docs/user/html-renders.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 18 additions & 2 deletions docs/user/remote-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading