1/**
2 * Host setup the shared headless browser needs, and the one command that does
3 * it. Every failure here names that command, so the server log, an agent's tool
4 * error, and the viewer all offer the same fix: `sudo t3 browser setup`.
5 *
6 * T3 never turns Chrome's sandbox off by itself. A host that cannot give it one
7 * gets the command instead, and only the operator's explicit
8 * `T3CODE_SERVER_BROWSER_SANDBOX=0` launches without it.
9 */
10import * as HostProcess from "@t3tools/shared/HostProcess";
11import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";
13import * as Schema from "effect/Schema";
14import * as ChildProcess from "effect/process/ChildProcess";
15import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
16
17/** The subcommand that sets the host up; callers render it, with `sudo`, for how T3 was launched. */
18export const SETUP_SUBCOMMAND = "browser setup";
19
20/** What Chrome prints before aborting when it cannot sandbox itself. */
21export const NO_SANDBOX_SIGNATURE = "No usable sandbox";
22
23/** Whether an operator explicitly allowed Chrome to run without its sandbox. */
24export const sandboxDisabled = (env: Readonly<Record<string, string | undefined>>) =>
25 env.T3CODE_SERVER_BROWSER_SANDBOX === "0";
26
27/** Set on Ubuntu 23.10+: unprivileged user namespaces need an AppArmor profile. */
28const USERNS_RESTRICTION = "/proc/sys/kernel/apparmor_restrict_unprivileged_userns";
29export const APPARMOR_PROFILE_PATH = "/etc/apparmor.d/t3-chrome-headless-shell";
30
31/**
32 * Lets T3's headless browser, in any T3 home and at any pinned version, create
33 * the user namespace Chrome's sandbox runs in. Modelled on the profile Ubuntu
34 * ships for Google Chrome; `unconfined` adds nothing beyond `userns`.
35 */
36export const APPARMOR_PROFILE = `# Written by \`t3 browser setup\`: lets T3 Code's headless browser use Chrome's sandbox.
37abi <abi/4.0>,
38include <tunables/global>
39
40profile t3-chrome-headless-shell /**/tools/chrome-headless-shell/*/*/chrome-headless-shell flags=(unconfined) {
41 userns,
42
43 include if exists <local/t3-chrome-headless-shell>
44}
45`;
46
47/**
48 * Chrome's Debian dependencies that minimal images leave out, from the headless
49 * shell's deb.deps. Ubuntu 24.04 and Debian 13 renamed some with a `t64`
50 * suffix; setup installs whichever name the host's apt offers.
51 */
52export const DEBIAN_PACKAGES: ReadonlyArray<ReadonlyArray<string>> = [
53 ["libnss3"],
54 ["libglib2.0-0t64", "libglib2.0-0"],
55 ["libatk1.0-0t64", "libatk1.0-0"],
56 ["libatk-bridge2.0-0t64", "libatk-bridge2.0-0"],
57 ["libatspi2.0-0t64", "libatspi2.0-0"],
58 ["libdbus-1-3"],
59 ["libx11-6"],
60 ["libxcb1"],
61 ["libxcomposite1"],
62 ["libxdamage1"],
63 ["libxext6"],
64 ["libxfixes3"],
65 ["libxrandr2"],
66 ["libxkbcommon0"],
67 ["libgbm1"],
68 ["libasound2t64", "libasound2"],
69 ["libexpat1"],
70];
71
72export class PreviewBrowserSandboxError extends Schema.TaggedError<PreviewBrowserSandboxError>()(
73 "PreviewBrowserSandboxError",
74 { setupCommand: Schema.String },
75) {
76 override get message(): string {
77 return `This host blocks the sandbox T3's browser runs in (AppArmor on Ubuntu 23.10+). Run \`${this.setupCommand}\` on the host once to allow it, then try again.`;
78 }
79}
80
81export class PreviewBrowserLibrariesError extends Schema.TaggedError<PreviewBrowserLibrariesError>()(
82 "PreviewBrowserLibrariesError",
83 { setupCommand: Schema.String, libraries: Schema.Array(Schema.String) },
84) {
85 override get message(): string {
86 return `This host is missing libraries T3's browser needs (${this.libraries.join(", ")}). Run \`${this.setupCommand}\` on the host to install them, then try again.`;
87 }
88}
89
90export type PreviewBrowserHostError = PreviewBrowserSandboxError | PreviewBrowserLibrariesError;
91
92const MISSING_LIBRARY = /^\s*(\S+) => not found$/gm;
93/** glibc's loader abort, naming the first library it could not load. */
94const LOADER_ERROR = /error while loading shared libraries: ([^:\s]+)/;
95
96/**
97 * After a launch fails, names the host setup it is missing: the sandbox, from
98 * Chrome's own abort message, or shared libraries the loader refuses. Undefined
99 * when neither explains it. Linux only; other hosts never need either.
100 */
101export const diagnoseLaunchFailure = Effect.fn("PreviewBrowserHost.diagnoseLaunchFailure")(
102 function* (input: {
103 readonly executable: string;
104 readonly output: string;
105 readonly setupCommand: string;
106 }) {
107 const { setupCommand } = input;
108 if (input.output.includes(NO_SANDBOX_SIGNATURE)) {
109 return new PreviewBrowserSandboxError({ setupCommand });
110 }
111 if ((yield* HostProcess.Platform) !== "linux") return undefined;
112 const libraries = yield* missingLibraries(input.executable);
113 return libraries.length === 0
114 ? undefined
115 : new PreviewBrowserLibrariesError({ setupCommand, libraries });
116 },
117);
118
119/**
120 * Shared libraries the loader cannot find for `executable`. The browser's own
121 * `--version` decides whether any are missing; `ldd` only lists them after the
122 * real loader fails, because it can report libraries a host provides another
123 * way (NixOS's nix-ld).
124 */
125export const missingLibraries = Effect.fn("PreviewBrowserHost.missingLibraries")(function* (
126 executable: string,
127) {
128 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
129 const run = (command: string, args: ReadonlyArray<string>) =>
130 spawner
131 .string(ChildProcess.make(command, args, { stdin: "ignore" }), { includeStderr: true })
132 .pipe(
133 Effect.timeout("5 seconds"),
134 Effect.orElseSucceed(() => ""),
135 );
136 const loaderError = LOADER_ERROR.exec(yield* run(executable, ["--version"]))?.[1];
137 if (loaderError === undefined) return [];
138 const listed = [...(yield* run("ldd", [executable])).matchAll(MISSING_LIBRARY)].map(
139 (match) => match[1]!,
140 );
141 return listed.length > 0 ? listed : [loaderError];
142});
143
144/**
145 * Whether Chrome's sandbox will be blocked here: the host restricts user
146 * namespaces and T3's AppArmor profile is not installed. Readable without
147 * root, so the server checks it at startup.
148 */
149export const sandboxBlocked = Effect.gen(function* () {
150 if ((yield* HostProcess.Platform) !== "linux") return false;
151 const fs = yield* FileSystem.FileSystem;
152 const restricted = yield* fs.readFileString(USERNS_RESTRICTION).pipe(
153 Effect.map((value) => value.trim() === "1"),
154 Effect.orElseSucceed(() => false),
155 );
156 if (!restricted) return false;
157 return !(yield* fs.exists(APPARMOR_PROFILE_PATH).pipe(Effect.orElseSucceed(() => false)));
158}).pipe(Effect.withSpan("PreviewBrowserHost.sandboxBlocked"));