Skip to content
Source
Sign inSign up

PreviewBrowserHost.ts

138 lines5.7 KB

1import * as HostProcess from "@t3tools/shared/HostProcess";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Schema from "effect/Schema";
5import * as ChildProcess from "effect/process/ChildProcess";
6import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
7​
8export const SETUP_SUBCOMMAND = "browser setup";
9​
10export const NO_SANDBOX_SIGNATURE = "No usable sandbox";
11​
12export const sandboxDisabled = (env: Readonly<Record<string, string | undefined>>) =>
13 env.T3CODE_SERVER_BROWSER_SANDBOX === "0";
14​
15/** Set on Ubuntu 23.10+: unprivileged user namespaces need an AppArmor profile. */
16const USERNS_RESTRICTION = "/proc/sys/kernel/apparmor_restrict_unprivileged_userns";
17export const APPARMOR_PROFILE_PATH = "/etc/apparmor.d/t3-chrome-headless-shell";
18​
19/** Match Ubuntu's Chrome profile; unconfined adds nothing beyond userns. */
20export const APPARMOR_PROFILE = `# Written by \`t3 browser setup\`: lets T3 Code's headless browser use Chrome's sandbox.
21abi <abi/4.0>,
22include <tunables/global>
23​
24profile t3-chrome-headless-shell /**/tools/chrome-headless-shell/*/*/chrome-headless-shell flags=(unconfined) {
25 userns,
26​
27 include if exists <local/t3-chrome-headless-shell>
29`;
30​
31/** Ubuntu 24.04 and Debian 13 use some t64 library names; choose names available through apt. */
32export const DEBIAN_PACKAGES: ReadonlyArray<ReadonlyArray<string>> = [
33 ["libnss3"],
34 ["libglib2.0-0t64", "libglib2.0-0"],
35 ["libatk1.0-0t64", "libatk1.0-0"],
36 ["libatk-bridge2.0-0t64", "libatk-bridge2.0-0"],
37 ["libatspi2.0-0t64", "libatspi2.0-0"],
38 ["libdbus-1-3"],
39 ["libx11-6"],
40 ["libxcb1"],
41 ["libxcomposite1"],
42 ["libxdamage1"],
43 ["libxext6"],
44 ["libxfixes3"],
45 ["libxrandr2"],
46 ["libxkbcommon0"],
47 ["libgbm1"],
48 ["libasound2t64", "libasound2"],
49 ["libexpat1"],
50];
51​
52export class PreviewBrowserSandboxError extends Schema.TaggedError<PreviewBrowserSandboxError>()(
53 "PreviewBrowserSandboxError",
54 { setupCommand: Schema.String },
55) {
56 override get message(): string {
57 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.`;
58 }
60​
61export class PreviewBrowserLibrariesError extends Schema.TaggedError<PreviewBrowserLibrariesError>()(
62 "PreviewBrowserLibrariesError",
63 { setupCommand: Schema.String, libraries: Schema.Array(Schema.String) },
64) {
65 override get message(): string {
66 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.`;
67 }
69​
70/** Chrome will not start its sandbox as root (crbug.com/638180), which no host setup fixes. */
71export class PreviewBrowserRootError extends Schema.TaggedError<PreviewBrowserRootError>()(
72 "PreviewBrowserRootError",
73 {},
74) {
75 override get message(): string {
76 return "T3 server is running as root; Chrome's sandbox cannot start. Run T3 as a nonroot user. Otherwise set `T3CODE_SERVER_BROWSER_SANDBOX=0` in the T3 server's environment and restart the server; this disables Chrome's sandbox. Manual: `T3CODE_SERVER_BROWSER_SANDBOX=0 t3 serve`. Systemd: add `Environment=T3CODE_SERVER_BROWSER_SANDBOX=0` under `[Service]`, reload systemd, then restart the service. `t3 browser setup` cannot fix root sandboxing.";
77 }
79​
80export type PreviewBrowserHostError =
81 | PreviewBrowserSandboxError
82 | PreviewBrowserLibrariesError
83 | PreviewBrowserRootError;
84​
85const MISSING_LIBRARY = /^\s*(\S+) => not found$/gm;
86const LOADER_ERROR = /error while loading shared libraries: ([^:\s]+)/;
87​
88export const diagnoseLaunchFailure = Effect.fn("PreviewBrowserHost.diagnoseLaunchFailure")(
89 function* (input: {
90 readonly executable: string;
91 readonly output: string;
92 readonly setupCommand: string;
93 }) {
94 const { setupCommand } = input;
95 if (input.output.includes(NO_SANDBOX_SIGNATURE)) {
96 // Playwright reports Chrome's refusal to sandbox as root as a sandbox failure too.
97 return (yield* HostProcess.UserId) === 0
98 ? new PreviewBrowserRootError()
99 : new PreviewBrowserSandboxError({ setupCommand });
100 }
101 if ((yield* HostProcess.Platform) !== "linux") return undefined;
102 const libraries = yield* missingLibraries(input.executable);
103 return libraries.length === 0
104 ? undefined
105 : new PreviewBrowserLibrariesError({ setupCommand, libraries });
106 },
107);
108​
109/** Detect missing libraries with --version; ldd can report misses that NixOS nix-ld provides. */
110export const missingLibraries = Effect.fn("PreviewBrowserHost.missingLibraries")(function* (
111 executable: string,
112) {
113 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
114 const run = (command: string, args: ReadonlyArray<string>) =>
115 spawner
116 .string(ChildProcess.make(command, args, { stdin: "ignore" }), { includeStderr: true })
117 .pipe(
118 Effect.timeout("5 seconds"),
119 Effect.orElseSucceed(() => ""),
120 );
121 const loaderError = LOADER_ERROR.exec(yield* run(executable, ["--version"]))?.[1];
122 if (loaderError === undefined) return [];
123 const listed = [...(yield* run("ldd", [executable])).matchAll(MISSING_LIBRARY)].map(
124 (match) => match[1]!,
125 );
126 return listed.length > 0 ? listed : [loaderError];
127});
128​
129export const sandboxBlocked = Effect.gen(function* () {
130 if ((yield* HostProcess.Platform) !== "linux") return false;
131 const fs = yield* FileSystem.FileSystem;
132 const restricted = yield* fs.readFileString(USERNS_RESTRICTION).pipe(
133 Effect.map((value) => value.trim() === "1"),
134 Effect.orElseSucceed(() => false),
135 );
136 if (!restricted) return false;
137 return !(yield* fs.exists(APPARMOR_PROFILE_PATH).pipe(Effect.orElseSucceed(() => false)));
138}).pipe(Effect.withSpan("PreviewBrowserHost.sandboxBlocked"));